From ad0c0fd41ff84024007e82d92d20aa212d7b62bf Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Thu, 15 Oct 2026 12:21:31 +0100 Subject: [PATCH 01/92] feat(config): add the workspace target key (CLI-2695) --- apps/docs/public/cli/config.schema.json | 6 ++ packages/config/src/base.ts | 12 ++++ packages/config/src/io.ts | 27 ++++++++ packages/config/src/io.unit.test.ts | 92 +++++++++++++++++++++++++ 4 files changed, 137 insertions(+) diff --git a/apps/docs/public/cli/config.schema.json b/apps/docs/public/cli/config.schema.json index b0132becc6..46ec72a46a 100644 --- a/apps/docs/public/cli/config.schema.json +++ b/apps/docs/public/cli/config.schema.json @@ -2515,6 +2515,12 @@ }, "additionalProperties": false }, + "target": { + "type": "string", + "enum": ["local", "remote"], + "description": "Default target for commands that can run locally or against the linked project (`db reset`, `db query`, `inspect`, `migration list`, `gen types`, `storage`, ...). `remote` means the linked project. The `--local`, `--linked`, `--db-url` and `--project-ref` flags always win.", + "default": "local" + }, "remotes": { "anyOf": [ { diff --git a/packages/config/src/base.ts b/packages/config/src/base.ts index a13a079fd8..82e51c1d29 100644 --- a/packages/config/src/base.ts +++ b/packages/config/src/base.ts @@ -8,6 +8,7 @@ import { experimental } from "./experimental.ts"; import { functions } from "./functions.ts"; import { inbucket } from "./inbucket.ts"; import { toPublishedJsonSchemaDocument } from "./json-schema-document.ts"; +import { stringEnum } from "./lib/schema.ts"; import { realtime } from "./realtime.ts"; import { storage } from "./storage.ts"; import { studio } from "./studio.ts"; @@ -75,8 +76,19 @@ export const RemotesSchema = Schema.Record(Schema.String, remoteCliConfigBlock). tags: ["general"], }); +const defaultTarget = "local"; + +// Workspace-local: a [remotes.*] block must not carry it, so it lives outside remoteCliConfigBlock. +const target = stringEnum(["local", "remote"], { + default: defaultTarget, + description: + "Default target for commands that can run locally or against the linked project (`db reset`, `db query`, `inspect`, `migration list`, `gen types`, `storage`, ...). `remote` means the linked project. The `--local`, `--linked`, `--db-url` and `--project-ref` flags always win.", + tags: ["general"], +}).pipe(Schema.withDecodingDefaultKey(Effect.succeed(defaultTarget))); + export const CliConfigSchema = Schema.Struct({ ...baseCliConfigFields, + target, remotes: RemotesSchema.pipe(Schema.withDecodingDefault(Effect.succeed({}))), }); diff --git a/packages/config/src/io.ts b/packages/config/src/io.ts index a601a34410..65c6ac2748 100644 --- a/packages/config/src/io.ts +++ b/packages/config/src/io.ts @@ -143,6 +143,31 @@ export function remoteNameForProjectRef( return remoteProjectIdEntries(remotes).find((entry) => entry.projectId === projectRef)?.name; } +/** + * Names the first `[remotes.*]` block that sets a workspace-wide key. `target` decides how + * commands behave before any project is chosen, so a remote block cannot override it. + */ +const rejectRemoteWorkspaceKeys = Effect.fnUntraced(function* ( + document: unknown, + path: string, + format: ConfigFormat, +) { + if (!isObject(document) || !isObject(document["remotes"])) { + return; + } + for (const [remoteName, remote] of Object.entries(document["remotes"])) { + if (isObject(remote) && Object.hasOwn(remote, "target")) { + return yield* new CliConfigParseError({ + path, + format, + cause: new Error( + `[remotes.${remoteName}] cannot set "target"; it is a workspace-wide setting`, + ), + }); + } + } +}); + /** Valid project ref format: exactly 20 lowercase ASCII letters. */ const REMOTE_PROJECT_ID_PATTERN = /^[a-z]{20}$/; @@ -346,6 +371,7 @@ export const decodeCliConfigDocumentForValidationEffect = Effect.fn( const cliCompat = options.cliCompat ?? false; const withoutTolerated = yield* rejectRemovedConfigKeys(document, options.path, options.format); + yield* rejectRemoteWorkspaceKeys(withoutTolerated, options.path, options.format); const { document: documentForDecode, appliedRemote } = mergeSelectedRemoteForValidation( withoutTolerated, options.remoteName, @@ -388,6 +414,7 @@ const readAndNormalizeCliConfigFile = Effect.fnUntraced(function* (filePath: str const normalized = isObject(document) ? yield* rejectRemovedConfigKeys(document, filePath, format) : document; + yield* rejectRemoteWorkspaceKeys(normalized, filePath, format); return { format, content, document, normalized } as const; }); diff --git a/packages/config/src/io.unit.test.ts b/packages/config/src/io.unit.test.ts index 010d90fa69..4950c55d69 100644 --- a/packages/config/src/io.unit.test.ts +++ b/packages/config/src/io.unit.test.ts @@ -238,6 +238,18 @@ describe("config io", () => { expect(config.remotes).toEqual({}); }); + test("defaults the workspace target to local", () => { + expect(decodeCliConfig({}).target).toBe("local"); + }); + + test("decodes target = remote", () => { + expect(decodeCliConfig({ target: "remote" }).target).toBe("remote"); + }); + + test("rejects an unknown workspace target", () => { + expect(() => decodeCliConfig({ target: "staging" })).toThrow(); + }); + test("requires enabled twilio fields during decode", () => { expect(() => decodeCliConfig({ @@ -1626,6 +1638,86 @@ enabled = false }; } + describe("workspace target", () => { + const targetInRemote = `project_id = "baseref" + +[remotes.preview] +project_id = "${PREVIEW_REF}" +target = "remote" +`; + + async function loadFailureCause( + toml: string, + options?: { readonly projectRef: string }, + ): Promise { + const cwd = await writeTomlProject(toml); + try { + const exit = await Effect.runPromiseExit( + loadCliConfig(cwd, options).pipe(Effect.provide(BunServices.layer)), + ); + expect(Exit.isFailure(exit)).toBe(true); + const error = Exit.isFailure(exit) ? Cause.findErrorOption(exit.cause) : Option.none(); + expect(Option.isSome(error) ? (error.value as { _tag: string })._tag : undefined).toBe( + "CliConfigParseError", + ); + return Option.isSome(error) ? (error.value as { cause: unknown }).cause : undefined; + } finally { + await rm(cwd, { recursive: true, force: true }); + } + } + + test("loads the top-level target", async () => { + const cwd = await writeTomlProject(`project_id = "baseref"\ntarget = "remote"\n`); + try { + const loaded = await runConfigEffect(loadCliConfig(cwd)); + expect(loaded?.config.target).toBe("remote"); + } finally { + await rm(cwd, { recursive: true, force: true }); + } + }); + + test("defaults to local when the file omits target", async () => { + const cwd = await writeTomlProject(`project_id = "baseref"\n`); + try { + const loaded = await runConfigEffect(loadCliConfig(cwd)); + expect(loaded?.config.target).toBe("local"); + } finally { + await rm(cwd, { recursive: true, force: true }); + } + }); + + test("fails to load an invalid top-level target", async () => { + await loadFailureCause(`project_id = "baseref"\ntarget = "staging"\n`); + }); + + test("rejects target in an unselected [remotes.*] block", async () => { + const cause = await loadFailureCause(targetInRemote); + expect(String(cause)).toContain('[remotes.preview] cannot set "target"'); + }); + + test("rejects target in the selected [remotes.*] block instead of overriding the workspace value", async () => { + const cause = await loadFailureCause(targetInRemote, { projectRef: PREVIEW_REF }); + expect(String(cause)).toContain('[remotes.preview] cannot set "target"'); + }); + + test("rejects target in a [remotes.*] block of a pending document", async () => { + const exit = await Effect.runPromiseExit( + decodeCliConfigDocumentForValidationEffect( + { + project_id: "abc123", + remotes: { preview: { project_id: PREVIEW_REF, target: "remote" } }, + }, + { cliCompat: true, path: "supabase/config.toml", format: "toml", remoteName: "preview" }, + ).pipe(Effect.provide(BunServices.layer)), + ); + expect(Exit.isFailure(exit)).toBe(true); + const error = Exit.isFailure(exit) ? Cause.findErrorOption(exit.cause) : Option.none(); + expect(Option.isSome(error) ? (error.value as { _tag: string })._tag : undefined).toBe( + "CliConfigParseError", + ); + }); + }); + test("tracks the source of effective local, remote, and environment values", async () => { const localCwd = await writeTomlProject(`project_id = "baseref" From 3ada876f96d9d934ef81c4f12285628101346c6e Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Thu, 15 Oct 2026 12:29:06 +0100 Subject: [PATCH 02/92] feat(cli): add the shared target selector module (CLI-2695) --- .../src/command-internal/db-target-flags.ts | 125 +------------- .../db-target-flags.unit.test.ts | 147 +---------------- apps/cli/src/command-internal/global-flags.ts | 5 +- .../command-internal/target/target-flags.ts | 91 ++++++++++ .../target/target-flags.unit.test.ts | 115 +++++++++++++ .../command-internal/target/target-policy.ts | 47 ++++++ .../target/target-selector.ts | 79 +++++++++ .../target/target-selector.unit.test.ts | 156 ++++++++++++++++++ .../command-internal/target/target.errors.ts | 54 ++++++ .../target/target.errors.unit.test.ts | 23 +++ .../src/shared/cli/value-consuming-flags.ts | 118 +++++++++++++ .../cli/value-consuming-flags.unit.test.ts | 148 +++++++++++++++++ .../telemetry/__fixtures__/error-tags.txt | 4 + apps/cli/src/telemetry/command-telemetry.ts | 2 +- 14 files changed, 848 insertions(+), 266 deletions(-) create mode 100644 apps/cli/src/command-internal/target/target-flags.ts create mode 100644 apps/cli/src/command-internal/target/target-flags.unit.test.ts create mode 100644 apps/cli/src/command-internal/target/target-policy.ts create mode 100644 apps/cli/src/command-internal/target/target-selector.ts create mode 100644 apps/cli/src/command-internal/target/target-selector.unit.test.ts create mode 100644 apps/cli/src/command-internal/target/target.errors.ts create mode 100644 apps/cli/src/command-internal/target/target.errors.unit.test.ts create mode 100644 apps/cli/src/shared/cli/value-consuming-flags.ts create mode 100644 apps/cli/src/shared/cli/value-consuming-flags.unit.test.ts diff --git a/apps/cli/src/command-internal/db-target-flags.ts b/apps/cli/src/command-internal/db-target-flags.ts index 007426b9a4..f76b13da65 100644 --- a/apps/cli/src/command-internal/db-target-flags.ts +++ b/apps/cli/src/command-internal/db-target-flags.ts @@ -8,9 +8,15 @@ import { Effect, Option } from "effect"; -import { GEN_TYPES_LANGUAGE_VALUE_FLAG_NAMES } from "../commands/gen/types/types.languages.ts"; +import { + VALUE_CONSUMING_LONG_FLAGS, + VALUE_CONSUMING_SHORT_FLAGS, +} from "../shared/cli/value-consuming-flags.ts"; import { DbPasswordFlagsError } from "./db-config.errors.ts"; +/** @public */ +export { VALUE_CONSUMING_LONG_FLAGS, VALUE_CONSUMING_SHORT_FLAGS }; + export type DbConnType = "db-url" | "linked" | "local"; export interface DbTargetSelection { @@ -20,123 +26,6 @@ export interface DbTargetSelection { readonly connType: DbConnType | undefined; } -/** - * 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-url", - "password", // db push/pull/dump/remote (short -p) - "sql-paths", - "schema", - "level", - "fail-on", - "type", - "output-dir", - "cache-control", - "content-type", - "jobs", - "output", - "output-format", - "profile", - "workdir", - "network-id", - "dns-resolver", - "agent", - // `--log-level` and `--completions` are unlisted: the real parse or an early exit precedes - // every scan here. - "add-domains", - "algorithm", - "attribute-mapping-file", - "auth", - "config", - "custom-hostname", - "db-allow-cidr", - "db-password", - "db-unban-ip", - "desired-subdomain", - "diff-engine", - "domains", - "env-file", - "exclude", - "exp", - "exposure", - "file", - "from", - "from-backup", - "git-branch", - "import-map", - "inspect-mode", - "instances", - "kind", - "lang", - "last", - "metadata-file", - "metadata-url", - "name", - "name-id-format", - "notify-url", - "org-id", - "override-name", - "payload", - "plan", - "postgres-engine", - "project-id", - "project-ref", - "query-timeout", - "region", - "release-channel", - "remote-label", - "remove-domains", - "role", - "runtime", - "size", - "source", - "status", - "sub", - ...GEN_TYPES_LANGUAGE_VALUE_FLAG_NAMES, - "tail", - "template", - "timestamp", - "to", - "token", - "valid-for", - "version", - // Built by `issueOptionalTextFlag`, invisible to the static scan. - "additional-context", - "area", - "command", - "actual-output", - "expected-behavior", - "reproduce", - "crash-report-id", - "docker-services", - "problem", - "proposed-solution", - "alternatives", - "link", - "issue-type", - "improvement", - "capability", - "service", - "since", - "stack", - "stack-id", - "preparation", -]); - -/** 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 - "p", // --password / -p (migration list, db push/pull/dump/remote) - "j", // --jobs / -j (storage cp) - "f", // --file / -f (db dump/diff/query, db schema declarative sync) - "t", // --template / -t (test new); --type / -t (sso add); --timestamp / -t (backups restore) - "x", // --exclude / -x (start, db dump) -]); - /** * 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`. 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 aebc2584e2..25f29fe58d 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,15 +1,9 @@ -import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; -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 { Effect, Exit, Option } from "effect"; import { changedLinkedLocalFlags, rejectPasswordWithDirectTarget, resolveDbTargetFlags, - VALUE_CONSUMING_LONG_FLAGS, - VALUE_CONSUMING_SHORT_FLAGS, } from "./db-target-flags.ts"; describe("resolveDbTargetFlags", () => { @@ -175,145 +169,6 @@ describe("resolveDbTargetFlags", () => { }); }); -describe("VALUE_CONSUMING_LONG_FLAGS / VALUE_CONSUMING_SHORT_FLAGS completeness (CLI-1896 review)", () => { - // `extractChangedFlagNames` relies on these two sets across every command, not just the - // db-target subset this file's other describe block covers — see the doc comment on - // `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", "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; - const path = yield* Path.Path; - const entries = yield* fs.readDirectory(dir, { recursive: true }); - return entries - .filter((entry) => entry.endsWith(".command.ts")) - .map((entry) => path.join(dir, entry)); - }); - - interface DeclaredFlag { - readonly file: string; - readonly name: string; - 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 = /\bFlag\.([A-Z]\w*)\(|\.flag\(\{/g; - const calls = Array.from(source.matchAll(callRegex), (match) => ({ - index: match.index, - kind: match[1], - })); - - const declared: Array = []; - for (let i = 0; i < calls.length; i++) { - const current = calls[i]!; - - // 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+\(|\.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 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*\)|\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", - () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const missing: Array = []; - - for (const filePath of yield* walk(commandsDir)) { - if (INDIRECT_NAME_FILES.has(path.basename(filePath))) continue; - - for (const flag of extractDeclaredFlags(filePath, yield* fs.readFileString(filePath))) { - if (!VALUE_CONSUMING_LONG_FLAGS.has(flag.name)) { - missing.push(`${flag.name} (${path.relative(commandsDir, flag.file)})`); - } - } - } - - expect(missing).toEqual([]); - }).pipe(Effect.provide(BunServices.layer)), - ); - - it.effect( - "registers every directly-declared value-consuming flag's shorthand in VALUE_CONSUMING_SHORT_FLAGS", - () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const missing: Array = []; - - for (const filePath of yield* walk(commandsDir)) { - if (INDIRECT_NAME_FILES.has(path.basename(filePath))) continue; - - for (const flag of extractDeclaredFlags(filePath, yield* fs.readFileString(filePath))) { - if (flag.alias !== undefined && !VALUE_CONSUMING_SHORT_FLAGS.has(flag.alias)) { - missing.push( - `-${flag.alias} (--${flag.name}, ${path.relative(commandsDir, flag.file)})`, - ); - } - } - } - - expect(missing).toEqual([]); - }).pipe(Effect.provide(BunServices.layer)), - ); -}); - describe("changedLinkedLocalFlags", () => { it("returns nothing when neither selector is present", () => { expect(changedLinkedLocalFlags(["seed", "buckets"])).toEqual([]); diff --git a/apps/cli/src/command-internal/global-flags.ts b/apps/cli/src/command-internal/global-flags.ts index d737d077d1..3751d1ae37 100644 --- a/apps/cli/src/command-internal/global-flags.ts +++ b/apps/cli/src/command-internal/global-flags.ts @@ -2,7 +2,10 @@ import { Effect, Option } from "effect"; import { Flag, GlobalFlag } from "effect/cli"; import { CliArgs } from "../shared/cli/cli-args.service.ts"; -import { VALUE_CONSUMING_LONG_FLAGS, VALUE_CONSUMING_SHORT_FLAGS } from "./db-target-flags.ts"; +import { + VALUE_CONSUMING_LONG_FLAGS, + VALUE_CONSUMING_SHORT_FLAGS, +} from "../shared/cli/value-consuming-flags.ts"; import { supabaseEnvBool, supabaseEnvBoolWithProjectFallback } from "./supabase-env.ts"; // The CLI's global-flag registry is tree-wide, so `-o/--output` can't be redeclared per command to diff --git a/apps/cli/src/command-internal/target/target-flags.ts b/apps/cli/src/command-internal/target/target-flags.ts new file mode 100644 index 0000000000..fc3e20abe5 --- /dev/null +++ b/apps/cli/src/command-internal/target/target-flags.ts @@ -0,0 +1,91 @@ +import type { Option } from "effect"; +import { Flag, GlobalFlag } from "effect/cli"; + +export type TargetFlagGroup = "sync" | "selectable"; +export type TargetSubject = "database" | "Storage API"; + +interface TargetFlagOptions { + readonly group: TargetFlagGroup; + readonly subject: TargetSubject; +} + +const localFlag = ({ group, subject }: TargetFlagOptions) => + Flag.Boolean("local").pipe( + Flag.withDescription( + group === "sync" + ? `Target the local ${subject}.` + : `Target the local ${subject}. Default unless the workspace target is "remote".`, + ), + Flag.withDefault(false), + ); + +const linkedFlag = ({ group }: TargetFlagOptions) => + Flag.Boolean("linked").pipe( + Flag.withDescription( + group === "sync" + ? "Target the linked project (default)." + : 'Target the linked project. Default when the workspace target is "remote".', + ), + Flag.withDefault(false), + ); + +const dbUrlFlag = () => + Flag.String("db-url").pipe( + Flag.withDescription( + "Target the database specified by the connection string (must be percent-encoded).", + ), + Flag.optional, + ); + +const projectRefFlag = () => + Flag.String("project-ref").pipe( + Flag.withDescription("Project ref of the Supabase project. Implies --linked."), + Flag.optional, + ); + +export type TargetFlagsWithDbUrl = { + readonly local: Flag.Flag; + readonly linked: Flag.Flag; + readonly dbUrl: Flag.Flag>; + readonly projectRef: Flag.Flag>; +}; + +export type TargetFlagsWithoutDbUrl = Omit; + +/** The only declaration of `--local`, `--linked`, `--db-url` and `--project-ref` for sync and selectable commands. */ +export function targetFlags( + options: TargetFlagOptions & { readonly dbUrl: true }, +): TargetFlagsWithDbUrl; +export function targetFlags( + options: TargetFlagOptions & { readonly dbUrl: false }, +): TargetFlagsWithoutDbUrl; +export function targetFlags( + options: TargetFlagOptions & { readonly dbUrl: boolean }, +): TargetFlagsWithDbUrl | TargetFlagsWithoutDbUrl { + const base = { + local: localFlag(options), + linked: linkedFlag(options), + projectRef: projectRefFlag(), + }; + return options.dbUrl ? { ...base, dbUrl: dbUrlFlag() } : base; +} + +/** + * `seed` accepts the selectors before or after its subcommand, which needs scoped globals; they + * share their definitions with {@link targetFlags}. + */ +export const seedTargetGlobalFlags = { + linked: GlobalFlag.Setting("linked")({ + flag: linkedFlag({ group: "selectable", subject: "Storage API" }), + }), + local: GlobalFlag.Setting("local")({ + flag: localFlag({ group: "selectable", subject: "Storage API" }), + }), +}; + +/** Hidden alias of `--project-ref`, accepted only by `gen types`. */ +export const deprecatedProjectIdFlag = Flag.String("project-id").pipe( + Flag.withDescription("Deprecated: use --project-ref."), + Flag.optional, + Flag.withHidden, +); diff --git a/apps/cli/src/command-internal/target/target-flags.unit.test.ts b/apps/cli/src/command-internal/target/target-flags.unit.test.ts new file mode 100644 index 0000000000..02aa9f6300 --- /dev/null +++ b/apps/cli/src/command-internal/target/target-flags.unit.test.ts @@ -0,0 +1,115 @@ +import { BunServices } from "@effect/platform-bun"; +import { describe, expect, it } from "@effect/vitest"; +import { Effect, Layer, Option } from "effect"; +import { CliOutput, Command, type Flag } from "effect/cli"; +import type * as CliCommand from "effect/cli/Command"; + +import { unwrapParam } from "../param-introspection.ts"; +import { deprecatedProjectIdFlag, seedTargetGlobalFlags, targetFlags } from "./target-flags.ts"; + +const silentFormatter: CliOutput.Formatter = { + formatCliError: () => "", + formatError: () => "", + formatErrors: () => "", + formatHelpDoc: () => "", + formatVersion: () => "", +}; + +const parseWith = ( + config: Config, + args: ReadonlyArray, +) => { + const parsed: Array = []; + const command = Command.make("target-test", config).pipe( + Command.withHandler((flags) => Effect.sync(() => parsed.push(flags))), + ); + return Command.runWith(command, { version: "0.0.0-test" })(args).pipe( + Effect.provide(Layer.mergeAll(BunServices.layer, CliOutput.layer(silentFormatter))), + Effect.map(() => parsed[0]), + ); +}; + +const describeFlag = (flag: Flag.Flag) => { + const single = unwrapParam(flag)?.single; + return { + name: single?.name, + description: Option.getOrUndefined(single?.description ?? Option.none()), + }; +}; + +describe("targetFlags", () => { + const withDbUrl = targetFlags({ group: "selectable", subject: "database", dbUrl: true }); + + it.effect("defaults every selector to absent", () => + Effect.gen(function* () { + expect(yield* parseWith(withDbUrl, [])).toEqual({ + local: false, + linked: false, + dbUrl: Option.none(), + projectRef: Option.none(), + }); + }), + ); + + it.effect("parses each selector", () => + Effect.gen(function* () { + expect(yield* parseWith(withDbUrl, ["--local"])).toMatchObject({ local: true }); + expect(yield* parseWith(withDbUrl, ["--linked"])).toMatchObject({ linked: true }); + expect( + yield* parseWith(withDbUrl, ["--db-url", "postgresql://h/db", "--project-ref", "abc"]), + ).toMatchObject({ + dbUrl: Option.some("postgresql://h/db"), + projectRef: Option.some("abc"), + }); + }), + ); + + it.effect("accepts an explicit false switch", () => + Effect.gen(function* () { + expect(yield* parseWith(withDbUrl, ["--linked=false"])).toMatchObject({ linked: false }); + }), + ); + + it("omits --db-url for commands without a database connection", () => { + const storage = targetFlags({ group: "selectable", subject: "Storage API", dbUrl: false }); + expect(Object.keys(storage).sort()).toEqual(["linked", "local", "projectRef"]); + expect(Object.keys(withDbUrl).sort()).toEqual(["dbUrl", "linked", "local", "projectRef"]); + }); + + it("describes the linked default by group", () => { + const sync = targetFlags({ group: "sync", subject: "database", dbUrl: true }); + expect(describeFlag(sync.linked).description).toBe("Target the linked project (default)."); + expect(describeFlag(withDbUrl.linked).description).toBe( + 'Target the linked project. Default when the workspace target is "remote".', + ); + }); + + it("names the subject in the local help", () => { + const storage = targetFlags({ group: "sync", subject: "Storage API", dbUrl: false }); + expect(describeFlag(storage.local).description).toBe("Target the local Storage API."); + }); +}); + +describe("deprecatedProjectIdFlag", () => { + it("is hidden from help", () => { + expect(unwrapParam(deprecatedProjectIdFlag)?.single.hidden).toBe(true); + }); + + it.effect("parses an optional value", () => + Effect.gen(function* () { + expect(yield* parseWith({ projectId: deprecatedProjectIdFlag }, [])).toEqual({ + projectId: Option.none(), + }); + expect( + yield* parseWith({ projectId: deprecatedProjectIdFlag }, ["--project-id", "abc"]), + ).toEqual({ projectId: Option.some("abc") }); + }), + ); +}); + +describe("seedTargetGlobalFlags", () => { + it("shares the selector flag names with targetFlags", () => { + expect(describeFlag(seedTargetGlobalFlags.linked.flag).name).toBe("linked"); + expect(describeFlag(seedTargetGlobalFlags.local.flag).name).toBe("local"); + }); +}); diff --git a/apps/cli/src/command-internal/target/target-policy.ts b/apps/cli/src/command-internal/target/target-policy.ts new file mode 100644 index 0000000000..744a22d524 --- /dev/null +++ b/apps/cli/src/command-internal/target/target-policy.ts @@ -0,0 +1,47 @@ +import { Context, Layer, Option } from "effect"; +import { Command } from "effect/cli"; + +export type TargetGroup = "local-only" | "account" | "hosted" | "sync" | "selectable" | "removed"; + +interface TargetPolicyBase { + readonly group: TargetGroup; +} + +/** `consentExemptReason` is only meaningful on a leaf that never asks for hosted-mutation consent. */ +export type TargetPolicyShape = + | (TargetPolicyBase & { + readonly consent: "none"; + readonly consentExemptReason?: string; + }) + | (TargetPolicyBase & { + readonly consent: "no" | "yes"; + readonly consentExemptReason?: never; + }); + +/** The command's target group and hosted-mutation consent default, provided to its handler. */ +export class TargetPolicy extends Context.Service()( + "supabase/TargetPolicy", +) {} + +/** Context key under which a command's policy is stored in `Command.annotations`. */ +export class TargetPolicyAnnotation extends Context.Service< + TargetPolicyAnnotation, + TargetPolicyShape +>()("supabase/TargetPolicyAnnotation") {} + +/** + * Declares a leaf's target group and consent default; apply it after the leaf's handler and its + * other `Command.provide` layers so every one of them can read `TargetPolicy`. + */ +export const withTargetPolicy = + (policy: TargetPolicyShape) => + ( + self: Command.Command, + ) => + self.pipe( + Command.annotate(TargetPolicyAnnotation, policy), + Command.provide(Layer.succeed(TargetPolicy, policy)), + ); + +export const readTargetPolicy = (command: Command.Command.Any): Option.Option => + Context.getOption(command.annotations, TargetPolicyAnnotation); diff --git a/apps/cli/src/command-internal/target/target-selector.ts b/apps/cli/src/command-internal/target/target-selector.ts new file mode 100644 index 0000000000..466032d666 --- /dev/null +++ b/apps/cli/src/command-internal/target/target-selector.ts @@ -0,0 +1,79 @@ +import { Option, Result } from "effect"; + +import { + TargetEmptyValueError, + TargetProjectRefConflictError, + TargetSelectorConflictError, + type TargetSelectorError, +} from "./target.errors.ts"; + +export interface TargetSelectorInput { + readonly local: boolean; + readonly linked: boolean; + readonly dbUrl: Option.Option; + readonly projectRef: Option.Option; +} + +export type TargetSelection = + | { readonly explicit: "local" } + | { readonly explicit: "db-url"; readonly url: string } + | { readonly explicit: "linked"; readonly projectRef: Option.Option } + | { readonly explicit: undefined }; + +const joinFlags = (flags: ReadonlyArray): string => + flags.length <= 2 + ? flags.join(" and ") + : `${flags.slice(0, -1).join(", ")} and ${flags[flags.length - 1]}`; + +/** A `false` switch counts as absent, so `--linked=false` and `--no-linked` select nothing. */ +export const parseTargetSelector = ( + input: TargetSelectorInput, +): Result.Result => { + const dbUrl = Option.getOrUndefined(input.dbUrl); + const projectRef = Option.getOrUndefined(input.projectRef); + + if (dbUrl === "") { + return Result.fail( + new TargetEmptyValueError({ flag: "--db-url", message: "--db-url cannot be empty." }), + ); + } + if (projectRef === "") { + return Result.fail( + new TargetEmptyValueError({ + flag: "--project-ref", + message: "--project-ref cannot be empty.", + }), + ); + } + + const selected = [ + ...(input.local ? ["--local"] : []), + ...(input.linked ? ["--linked"] : []), + ...(dbUrl === undefined ? [] : ["--db-url"]), + ]; + if (selected.length > 1) { + return Result.fail( + new TargetSelectorConflictError({ + flags: selected, + message: `Only one of --local, --linked and --db-url can be set; got ${joinFlags(selected)}.`, + }), + ); + } + + if (projectRef !== undefined && (input.local || dbUrl !== undefined)) { + const conflictsWith = input.local ? "--local" : "--db-url"; + return Result.fail( + new TargetProjectRefConflictError({ + conflictsWith, + message: `--project-ref selects a linked project and cannot be combined with ${conflictsWith}.`, + }), + ); + } + + if (input.local) return Result.succeed({ explicit: "local" }); + if (dbUrl !== undefined) return Result.succeed({ explicit: "db-url", url: dbUrl }); + if (input.linked || projectRef !== undefined) { + return Result.succeed({ explicit: "linked", projectRef: input.projectRef }); + } + return Result.succeed({ explicit: undefined }); +}; diff --git a/apps/cli/src/command-internal/target/target-selector.unit.test.ts b/apps/cli/src/command-internal/target/target-selector.unit.test.ts new file mode 100644 index 0000000000..9c391daecd --- /dev/null +++ b/apps/cli/src/command-internal/target/target-selector.unit.test.ts @@ -0,0 +1,156 @@ +import { Option, Result } from "effect"; +import { describe, expect, it } from "vitest"; + +import { parseTargetSelector, type TargetSelectorInput } from "./target-selector.ts"; + +const none: TargetSelectorInput = { + local: false, + linked: false, + dbUrl: Option.none(), + projectRef: Option.none(), +}; + +const parse = (overrides: Partial) => + parseTargetSelector({ ...none, ...overrides }); + +const failure = (overrides: Partial) => { + const result = parse(overrides); + if (Result.isSuccess(result)) throw new Error("expected the selector to be rejected"); + return result.failure; +}; + +describe("parseTargetSelector", () => { + it("reports no explicit target when nothing is set", () => { + expect(parse({})).toEqual(Result.succeed({ explicit: undefined })); + }); + + it.each([ + ["--local", { local: true }, { explicit: "local" }], + ["--linked", { linked: true }, { explicit: "linked", projectRef: Option.none() }], + [ + "--db-url", + { dbUrl: Option.some("postgresql://u:p@db.example.com:5432/postgres") }, + { explicit: "db-url", url: "postgresql://u:p@db.example.com:5432/postgres" }, + ], + ] as const)("selects the target named by %s", (_flag, overrides, expected) => { + expect(parse(overrides)).toEqual(Result.succeed(expected)); + }); + + it("treats false switches as absent, so --local=false and --linked=false select nothing", () => { + expect(parse({ local: false, linked: false })).toEqual(Result.succeed({ explicit: undefined })); + }); + + it("lets a false switch coexist with another selector", () => { + expect(parse({ linked: false, dbUrl: Option.some("postgresql://h/db") })).toEqual( + Result.succeed({ explicit: "db-url", url: "postgresql://h/db" }), + ); + }); + + describe("--project-ref", () => { + it("implies the linked target and carries the ref", () => { + expect(parse({ projectRef: Option.some("abcdefghijklmnopqrst") })).toEqual( + Result.succeed({ explicit: "linked", projectRef: Option.some("abcdefghijklmnopqrst") }), + ); + }); + + it("agrees with an explicit --linked", () => { + expect(parse({ linked: true, projectRef: Option.some("abcdefghijklmnopqrst") })).toEqual( + Result.succeed({ explicit: "linked", projectRef: Option.some("abcdefghijklmnopqrst") }), + ); + }); + + it("is rejected with --local", () => { + expect( + failure({ local: true, projectRef: Option.some("abcdefghijklmnopqrst") }), + ).toMatchObject({ + _tag: "TargetProjectRefConflictError", + conflictsWith: "--local", + message: "--project-ref selects a linked project and cannot be combined with --local.", + }); + }); + + it("is rejected with --db-url", () => { + expect( + failure({ + dbUrl: Option.some("postgresql://h/db"), + projectRef: Option.some("abcdefghijklmnopqrst"), + }), + ).toMatchObject({ + _tag: "TargetProjectRefConflictError", + conflictsWith: "--db-url", + }); + }); + + it("is not rejected with --local=false", () => { + expect(parse({ local: false, projectRef: Option.some("abcdefghijklmnopqrst") })).toEqual( + Result.succeed({ explicit: "linked", projectRef: Option.some("abcdefghijklmnopqrst") }), + ); + }); + }); + + describe("mutual exclusion", () => { + it("rejects --local with --linked and names both flags", () => { + expect(failure({ local: true, linked: true })).toMatchObject({ + _tag: "TargetSelectorConflictError", + flags: ["--local", "--linked"], + message: "Only one of --local, --linked and --db-url can be set; got --local and --linked.", + }); + }); + + it("rejects --local with --db-url", () => { + expect(failure({ local: true, dbUrl: Option.some("postgresql://h/db") })).toMatchObject({ + _tag: "TargetSelectorConflictError", + flags: ["--local", "--db-url"], + }); + }); + + it("rejects --linked with --db-url", () => { + expect(failure({ linked: true, dbUrl: Option.some("postgresql://h/db") })).toMatchObject({ + _tag: "TargetSelectorConflictError", + flags: ["--linked", "--db-url"], + }); + }); + + it("lists all three flags when every selector is set", () => { + expect( + failure({ local: true, linked: true, dbUrl: Option.some("postgresql://h/db") }), + ).toMatchObject({ + _tag: "TargetSelectorConflictError", + flags: ["--local", "--linked", "--db-url"], + message: + "Only one of --local, --linked and --db-url can be set; got --local, --linked and --db-url.", + }); + }); + + it("reports the selector conflict before the --project-ref conflict", () => { + expect( + failure({ local: true, linked: true, projectRef: Option.some("abcdefghijklmnopqrst") }), + ).toMatchObject({ _tag: "TargetSelectorConflictError" }); + }); + }); + + describe("empty values", () => { + it("rejects an empty --db-url", () => { + expect(failure({ dbUrl: Option.some("") })).toMatchObject({ + _tag: "TargetEmptyValueError", + flag: "--db-url", + message: "--db-url cannot be empty.", + }); + }); + + it("rejects an empty --project-ref instead of treating it as a branch name", () => { + expect(failure({ projectRef: Option.some("") })).toMatchObject({ + _tag: "TargetEmptyValueError", + flag: "--project-ref", + message: "--project-ref cannot be empty.", + }); + }); + + it("reports an empty value before any conflict", () => { + expect(failure({ local: true, dbUrl: Option.some("") })).toMatchObject({ + _tag: "TargetEmptyValueError", + flag: "--db-url", + }); + }); + }); +}); diff --git a/apps/cli/src/command-internal/target/target.errors.ts b/apps/cli/src/command-internal/target/target.errors.ts new file mode 100644 index 0000000000..360e4f3d0b --- /dev/null +++ b/apps/cli/src/command-internal/target/target.errors.ts @@ -0,0 +1,54 @@ +import { Data } from "effect"; + +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../../shared/telemetry/error-actionability.ts"; + +/** More than one of `--local`, `--linked` and `--db-url` was set. */ +export class TargetSelectorConflictError extends Data.TaggedError("TargetSelectorConflictError")<{ + readonly flags: ReadonlyArray; + readonly message: string; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} + +/** `--project-ref` selects a linked project, so it cannot be combined with `--local` or `--db-url`. */ +export class TargetProjectRefConflictError extends Data.TaggedError( + "TargetProjectRefConflictError", +)<{ + readonly conflictsWith: "--local" | "--db-url"; + readonly message: string; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} + +/** `--db-url` or `--project-ref` was passed an empty value. */ +export class TargetEmptyValueError extends Data.TaggedError("TargetEmptyValueError")<{ + readonly flag: "--db-url" | "--project-ref"; + readonly message: string; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} + +/** `--password` was given while the resolved target is not a linked project. */ +export class TargetPasswordConflictError extends Data.TaggedError("TargetPasswordConflictError")<{ + readonly target: "local" | "db-url"; + readonly message: string; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} + +export type TargetSelectorError = + | TargetSelectorConflictError + | TargetProjectRefConflictError + | TargetEmptyValueError; diff --git a/apps/cli/src/command-internal/target/target.errors.unit.test.ts b/apps/cli/src/command-internal/target/target.errors.unit.test.ts new file mode 100644 index 0000000000..29260506c2 --- /dev/null +++ b/apps/cli/src/command-internal/target/target.errors.unit.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from "vitest"; + +import { classifyCliErrorActionability } from "../../shared/telemetry/error-actionability.ts"; +import { + TargetEmptyValueError, + TargetPasswordConflictError, + TargetProjectRefConflictError, + TargetSelectorConflictError, +} from "./target.errors.ts"; + +describe("target errors", () => { + it.each([ + new TargetSelectorConflictError({ flags: ["--local", "--linked"], message: "m" }), + new TargetProjectRefConflictError({ conflictsWith: "--local", message: "m" }), + new TargetEmptyValueError({ flag: "--db-url", message: "m" }), + new TargetPasswordConflictError({ target: "local", message: "m" }), + ])("classifies $_tag as invalid input the user can fix", (error) => { + expect(classifyCliErrorActionability(error)).toMatchObject({ + error_kind: "user_actionable", + error_category: "invalid_input", + }); + }); +}); diff --git a/apps/cli/src/shared/cli/value-consuming-flags.ts b/apps/cli/src/shared/cli/value-consuming-flags.ts new file mode 100644 index 0000000000..d081c837fc --- /dev/null +++ b/apps/cli/src/shared/cli/value-consuming-flags.ts @@ -0,0 +1,118 @@ +import { GEN_TYPES_LANGUAGE_VALUE_FLAG_NAMES } from "../../commands/gen/types/types.languages.ts"; + +/** + * 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-url", + "password", // db push/pull/dump/remote (short -p) + "sql-paths", + "schema", + "level", + "fail-on", + "type", + "output-dir", + "cache-control", + "content-type", + "jobs", + "output", + "output-format", + "profile", + "workdir", + "network-id", + "dns-resolver", + "agent", + // `--log-level` and `--completions` are unlisted: the real parse or an early exit precedes + // every scan here. + "add-domains", + "algorithm", + "attribute-mapping-file", + "auth", + "config", + "custom-hostname", + "db-allow-cidr", + "db-password", + "db-unban-ip", + "desired-subdomain", + "diff-engine", + "domains", + "env-file", + "exclude", + "exp", + "exposure", + "file", + "from", + "from-backup", + "git-branch", + "import-map", + "inspect-mode", + "instances", + "kind", + "lang", + "last", + "metadata-file", + "metadata-url", + "name", + "name-id-format", + "notify-url", + "org-id", + "override-name", + "payload", + "plan", + "postgres-engine", + "project-id", + "project-ref", + "query-timeout", + "region", + "release-channel", + "remote-label", + "remove-domains", + "role", + "runtime", + "size", + "source", + "status", + "sub", + ...GEN_TYPES_LANGUAGE_VALUE_FLAG_NAMES, + "tail", + "template", + "timestamp", + "to", + "token", + "valid-for", + "version", + // Built by `issueOptionalTextFlag`, invisible to the static scan. + "additional-context", + "area", + "command", + "actual-output", + "expected-behavior", + "reproduce", + "crash-report-id", + "docker-services", + "problem", + "proposed-solution", + "alternatives", + "link", + "issue-type", + "improvement", + "capability", + "service", + "since", + "stack", + "stack-id", + "preparation", +]); + +/** 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 + "p", // --password / -p (migration list, db push/pull/dump/remote) + "j", // --jobs / -j (storage cp) + "f", // --file / -f (db dump/diff/query, db schema declarative sync) + "t", // --template / -t (test new); --type / -t (sso add); --timestamp / -t (backups restore) + "x", // --exclude / -x (start, db dump) +]); diff --git a/apps/cli/src/shared/cli/value-consuming-flags.unit.test.ts b/apps/cli/src/shared/cli/value-consuming-flags.unit.test.ts new file mode 100644 index 0000000000..8f13f4e0fa --- /dev/null +++ b/apps/cli/src/shared/cli/value-consuming-flags.unit.test.ts @@ -0,0 +1,148 @@ +import { BunServices } from "@effect/platform-bun"; +import { describe, expect, it } from "@effect/vitest"; +import { Effect, FileSystem, 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 { + VALUE_CONSUMING_LONG_FLAGS, + VALUE_CONSUMING_SHORT_FLAGS, +} from "./value-consuming-flags.ts"; + +describe("VALUE_CONSUMING_LONG_FLAGS / VALUE_CONSUMING_SHORT_FLAGS completeness", () => { + // `extractChangedFlagNames` relies on these two sets across every command — see the doc comment + // on `VALUE_CONSUMING_LONG_FLAGS` in `value-consuming-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", "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; + const path = yield* Path.Path; + const entries = yield* fs.readDirectory(dir, { recursive: true }); + return entries + .filter((entry) => entry.endsWith(".command.ts")) + .map((entry) => path.join(dir, entry)); + }); + + interface DeclaredFlag { + readonly file: string; + readonly name: string; + 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 = /\bFlag\.([A-Z]\w*)\(|\.flag\(\{/g; + const calls = Array.from(source.matchAll(callRegex), (match) => ({ + index: match.index, + kind: match[1], + })); + + const declared: Array = []; + for (let i = 0; i < calls.length; i++) { + const current = calls[i]!; + + // 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+\(|\.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 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*\)|\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", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const missing: Array = []; + + for (const filePath of yield* walk(commandsDir)) { + if (INDIRECT_NAME_FILES.has(path.basename(filePath))) continue; + + for (const flag of extractDeclaredFlags(filePath, yield* fs.readFileString(filePath))) { + if (!VALUE_CONSUMING_LONG_FLAGS.has(flag.name)) { + missing.push(`${flag.name} (${path.relative(commandsDir, flag.file)})`); + } + } + } + + expect(missing).toEqual([]); + }).pipe(Effect.provide(BunServices.layer)), + ); + + it.effect( + "registers every directly-declared value-consuming flag's shorthand in VALUE_CONSUMING_SHORT_FLAGS", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const missing: Array = []; + + for (const filePath of yield* walk(commandsDir)) { + if (INDIRECT_NAME_FILES.has(path.basename(filePath))) continue; + + for (const flag of extractDeclaredFlags(filePath, yield* fs.readFileString(filePath))) { + if (flag.alias !== undefined && !VALUE_CONSUMING_SHORT_FLAGS.has(flag.alias)) { + missing.push( + `-${flag.alias} (--${flag.name}, ${path.relative(commandsDir, flag.file)})`, + ); + } + } + } + + expect(missing).toEqual([]); + }).pipe(Effect.provide(BunServices.layer)), + ); +}); diff --git a/apps/cli/src/shared/telemetry/__fixtures__/error-tags.txt b/apps/cli/src/shared/telemetry/__fixtures__/error-tags.txt index 3fc1503969..4bfe70d5e1 100644 --- a/apps/cli/src/shared/telemetry/__fixtures__/error-tags.txt +++ b/apps/cli/src/shared/telemetry/__fixtures__/error-tags.txt @@ -579,6 +579,10 @@ StorageUrlParseError StorageWorkdirError TarFieldOutOfRangeError TarPathTooLongError +TargetEmptyValueError +TargetPasswordConflictError +TargetProjectRefConflictError +TargetSelectorConflictError TestDbEnablePgtapError TestDbMutuallyExclusiveFlagsError TestDbNoTestsError diff --git a/apps/cli/src/telemetry/command-telemetry.ts b/apps/cli/src/telemetry/command-telemetry.ts index 0b42dd748a..1db7d382d2 100644 --- a/apps/cli/src/telemetry/command-telemetry.ts +++ b/apps/cli/src/telemetry/command-telemetry.ts @@ -45,7 +45,7 @@ import { IdentityStitch } from "../command-internal/identity-stitch.ts"; import { VALUE_CONSUMING_LONG_FLAGS, VALUE_CONSUMING_SHORT_FLAGS, -} from "../command-internal/db-target-flags.ts"; +} from "../shared/cli/value-consuming-flags.ts"; import { unwrapToSingleParam } from "../command-internal/param-introspection.ts"; import { StackBackendContext } from "../command-internal/stack-backend.ts"; import { From ecbbd47e046641f242ca238bed1b74ca515bb8e7 Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Thu, 15 Oct 2026 12:47:03 +0100 Subject: [PATCH 03/92] test(cli): refresh config precedence goldens for the workspace target key (CLI-2695) --- .../matched-remote-with-project-id-env.json | 3 ++- .../local-container-inputs/matched-remote-with-shell-env.json | 3 ++- .../local-container-inputs/matched-remote.json | 3 ++- .../config-precedence/local-container-inputs/no-overrides.json | 3 ++- .../local-container-inputs/project-dotenv.json | 3 ++- .../config-precedence/local-container-inputs/shell-env.json | 3 ++- .../testdata/config-precedence/stack-config/no-overrides.json | 3 ++- .../config-precedence/stack-config/project-dotenv.json | 3 ++- .../config-precedence/stack-config/remote-block-ignored.json | 3 ++- .../remote-block-merged-via-context-with-shell-env.json | 3 ++- .../stack-config/remote-block-merged-via-context.json | 3 ++- .../config-precedence/stack-config/shell-beats-dotenv.json | 3 ++- .../testdata/config-precedence/stack-config/shell-env.json | 3 ++- 13 files changed, 26 insertions(+), 13 deletions(-) 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 index a49452a620..40f0945c4a 100644 --- 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 @@ -525,7 +525,8 @@ "api_url": "http://127.0.0.1", "enabled": true, "port": 54323 - } + }, + "target": "local" }, "dbUrl": "postgresql://postgres:postgres@127.0.0.1:58322/postgres", "debug": false, 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 index 9c45da7b15..653407f9f2 100644 --- 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 @@ -525,7 +525,8 @@ "api_url": "http://127.0.0.1", "enabled": true, "port": 54323 - } + }, + "target": "local" }, "dbUrl": "postgresql://postgres:postgres@127.0.0.1:56322/postgres", "debug": false, 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 index a0698bac71..9c44bf7c2f 100644 --- 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 @@ -525,7 +525,8 @@ "api_url": "http://127.0.0.1", "enabled": true, "port": 54323 - } + }, + "target": "local" }, "dbUrl": "postgresql://postgres:postgres@127.0.0.1:58322/postgres", "debug": false, 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 index c35d4d806b..696752da26 100644 --- 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 @@ -525,7 +525,8 @@ "api_url": "http://127.0.0.1", "enabled": true, "port": 54323 - } + }, + "target": "local" }, "dbUrl": "postgresql://postgres:postgres@127.0.0.1:55322/postgres", "debug": false, 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 index 713f623162..274198ecb9 100644 --- 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 @@ -532,7 +532,8 @@ "api_url": "http://127.0.0.1", "enabled": true, "port": 54323 - } + }, + "target": "local" }, "dbUrl": "postgresql://postgres:postgres@127.0.0.1:57322/postgres", "debug": false, 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 index a6ab8caf6f..50eccadf1d 100644 --- 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 @@ -525,7 +525,8 @@ "api_url": "http://127.0.0.1", "enabled": true, "port": 54323 - } + }, + "target": "local" }, "dbUrl": "postgresql://postgres:postgres@127.0.0.1:56322/postgres", "debug": 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 index 14e5c5dc26..4da32d2256 100644 --- 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 @@ -707,6 +707,7 @@ "api_url": "http://127.0.0.1", "enabled": true, "port": 55323 - } + }, + "target": "local" } } 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 index 034299bb78..15ec88281a 100644 --- 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 @@ -676,6 +676,7 @@ "api_url": "http://127.0.0.1", "enabled": false, "port": 55323 - } + }, + "target": "local" } } 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 index ca7de557e0..4354055636 100644 --- 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 @@ -1121,6 +1121,7 @@ "api_url": "http://127.0.0.1", "enabled": true, "port": 55323 - } + }, + "target": "local" } } 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 index 405b3322ba..91b6e9789b 100644 --- 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 @@ -668,6 +668,7 @@ "api_url": "http://127.0.0.1", "enabled": false, "port": 55323 - } + }, + "target": "local" } } 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 index 788f1a6061..7c5f677b02 100644 --- 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 @@ -692,6 +692,7 @@ "api_url": "http://127.0.0.1", "enabled": true, "port": 55323 - } + }, + "target": "local" } } 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 index b2fdf27b39..1c691d0814 100644 --- 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 @@ -674,6 +674,7 @@ "api_url": "http://127.0.0.1", "enabled": false, "port": 55323 - } + }, + "target": "local" } } 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 index 7e20a8e533..60435ac8ca 100644 --- 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 @@ -668,6 +668,7 @@ "api_url": "http://127.0.0.1", "enabled": false, "port": 55323 - } + }, + "target": "local" } } From 74486453f43d3c0984f71a1be1c26a16c7854579 Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Thu, 15 Oct 2026 12:48:42 +0100 Subject: [PATCH 04/92] refactor(cli): keep the target flag option types module-private (CLI-2695) --- apps/cli/src/command-internal/target/target-flags.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/cli/src/command-internal/target/target-flags.ts b/apps/cli/src/command-internal/target/target-flags.ts index fc3e20abe5..e4a2a02913 100644 --- a/apps/cli/src/command-internal/target/target-flags.ts +++ b/apps/cli/src/command-internal/target/target-flags.ts @@ -1,8 +1,8 @@ import type { Option } from "effect"; import { Flag, GlobalFlag } from "effect/cli"; -export type TargetFlagGroup = "sync" | "selectable"; -export type TargetSubject = "database" | "Storage API"; +type TargetFlagGroup = "sync" | "selectable"; +type TargetSubject = "database" | "Storage API"; interface TargetFlagOptions { readonly group: TargetFlagGroup; From 493b7544b0786cacdb19c9caf8ac435bd499571a Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Thu, 15 Oct 2026 12:48:44 +0100 Subject: [PATCH 05/92] feat(cli): declare a target policy on every command (CLI-2695) Every leaf command carries a withTargetPolicy annotation with its target group and hosted-mutation consent default. A tree guard checks the group against each leaf's flags, database-target default and consent. --- .../target/target-policy.unit.test.ts | 257 ++++++++++++++++++ .../src/commands/backups/list/list.command.ts | 2 + .../backups/restore/restore.command.ts | 2 + .../commands/bootstrap/bootstrap.command.ts | 2 + .../branches/create/create.command.ts | 2 + .../branches/delete/delete.command.ts | 2 + .../branches/disable/disable.command.ts | 2 + .../src/commands/branches/get/get.command.ts | 2 + .../commands/branches/list/list.command.ts | 2 + .../commands/branches/pause/pause.command.ts | 2 + .../branches/unpause/unpause.command.ts | 2 + .../branches/update/update.command.ts | 2 + .../commands/completion/bash/bash.command.ts | 2 + .../commands/completion/fish/fish.command.ts | 2 + .../powershell/powershell.command.ts | 2 + .../commands/completion/zsh/zsh.command.ts | 2 + .../src/commands/config/diff/diff.command.ts | 2 + .../src/commands/config/pull/pull.command.ts | 2 + .../src/commands/config/push/push.command.ts | 2 + .../commands/db/advisors/advisors.command.ts | 2 + .../src/commands/db/branch/branch.command.ts | 9 + apps/cli/src/commands/db/diff/diff.command.ts | 2 + apps/cli/src/commands/db/dump/dump.command.ts | 2 + apps/cli/src/commands/db/lint/lint.command.ts | 2 + apps/cli/src/commands/db/pull/pull.command.ts | 2 + apps/cli/src/commands/db/push/push.command.ts | 2 + .../src/commands/db/query/query.command.ts | 2 + .../db/remote/changes/changes.command.ts | 2 + .../db/remote/commit/commit.command.ts | 2 + .../src/commands/db/reset/reset.command.ts | 2 + .../declarative/generate/generate.command.ts | 2 + .../schema/declarative/sync/sync.command.ts | 2 + .../src/commands/db/start/start.command.ts | 2 + apps/cli/src/commands/db/test/test.command.ts | 2 + .../domains/activate/activate.command.ts | 2 + .../commands/domains/create/create.command.ts | 2 + .../commands/domains/delete/delete.command.ts | 2 + .../src/commands/domains/get/get.command.ts | 2 + .../domains/reverify/reverify.command.ts | 6 + .../get-root-key/get-root-key.command.ts | 2 + .../update-root-key.command.ts | 2 + .../compute/delete/delete.command.ts | 2 + .../experimental/compute/list/list.command.ts | 2 + .../experimental/compute/logs/logs.command.ts | 2 + .../experimental/compute/new/new.command.ts | 2 + .../experimental/compute/push/push.command.ts | 2 + .../compute/status/status.command.ts | 2 + .../stack/destroy/destroy.command.ts | 2 + .../experimental/stack/list/list.command.ts | 2 + .../experimental/stack/logs/logs.command.ts | 2 + .../stack/prepare/prepare.command.ts | 2 + .../stack/restart/restart.command.ts | 2 + .../experimental/stack/start/start.command.ts | 2 + .../stack/status/status.command.ts | 2 + .../experimental/stack/stop/stop.command.ts | 2 + .../src/commands/feedback/add/add.command.ts | 6 + .../feedback/delete/delete.command.ts | 2 + .../functions/delete/delete.command.ts | 2 + .../functions/deploy/deploy.command.ts | 2 + .../functions/download/download.command.ts | 2 + .../commands/functions/list/list.command.ts | 2 + .../src/commands/functions/new/new.command.ts | 2 + .../commands/functions/serve/serve.command.ts | 2 + .../gen/bearer-jwt/bearer-jwt.command.ts | 2 + .../cli/src/commands/gen/keys/keys.command.ts | 2 + .../gen/signing-key/signing-key.command.ts | 2 + .../src/commands/gen/types/types.command.ts | 2 + apps/cli/src/commands/init/init.command.ts | 2 + .../inspect/db/bloat/bloat.command.ts | 2 + .../inspect/db/blocking/blocking.command.ts | 2 + .../inspect/db/cache-hit/cache-hit.command.ts | 2 + .../inspect/db/calls/calls.command.ts | 2 + .../inspect/db/db-stats/db-stats.command.ts | 2 + .../db/index-sizes/index-sizes.command.ts | 2 + .../db/index-stats/index-stats.command.ts | 2 + .../db/index-usage/index-usage.command.ts | 2 + .../commands/inspect/db/inspect-db-command.ts | 3 + .../inspect/db/locks/locks.command.ts | 2 + .../long-running-queries.command.ts | 2 + .../inspect/db/outliers/outliers.command.ts | 2 + .../replication-slots.command.ts | 2 + .../db/role-configs/role-configs.command.ts | 2 + .../role-connections.command.ts | 2 + .../db/role-stats/role-stats.command.ts | 2 + .../inspect/db/seq-scans/seq-scans.command.ts | 2 + .../table-index-sizes.command.ts | 3 +- .../table-record-counts.command.ts | 2 + .../db/table-sizes/table-sizes.command.ts | 2 + .../db/table-stats/table-stats.command.ts | 2 + .../total-index-size.command.ts | 3 +- .../total-table-sizes.command.ts | 3 +- .../traffic-profile.command.ts | 2 + .../unused-indexes/unused-indexes.command.ts | 2 + .../db/vacuum-stats/vacuum-stats.command.ts | 2 + .../commands/inspect/report/report.command.ts | 2 + apps/cli/src/commands/issue/issue.command.ts | 4 + apps/cli/src/commands/link/link.command.ts | 2 + apps/cli/src/commands/login/login.command.ts | 6 + .../cli/src/commands/logout/logout.command.ts | 2 + .../commands/migration/down/down.command.ts | 2 + .../commands/migration/fetch/fetch.command.ts | 2 + .../commands/migration/list/list.command.ts | 2 + .../src/commands/migration/new/new.command.ts | 2 + .../migration/repair/repair.command.ts | 2 + .../migration/squash/squash.command.ts | 2 + .../src/commands/migration/up/up.command.ts | 2 + .../commands/network-bans/get/get.command.ts | 2 + .../network-bans/remove/remove.command.ts | 2 + .../network-restrictions/get/get.command.ts | 2 + .../update/update.command.ts | 2 + .../commands/notebooks/pull/pull.command.ts | 2 + .../commands/notebooks/push/push.command.ts | 2 + .../commands/orgs/create/create.command.ts | 2 + .../src/commands/orgs/list/list.command.ts | 2 + .../postgres-config/delete/delete.command.ts | 2 + .../postgres-config/get/get.command.ts | 2 + .../postgres-config/update/update.command.ts | 2 + .../projects/api-keys/api-keys.command.ts | 2 + .../projects/create/create.command.ts | 2 + .../projects/delete/delete.command.ts | 2 + .../commands/projects/list/list.command.ts | 2 + apps/cli/src/commands/pull/pull.command.ts | 2 + .../src/commands/secrets/list/list.command.ts | 2 + .../src/commands/secrets/set/set.command.ts | 2 + .../commands/secrets/unset/unset.command.ts | 2 + .../commands/seed/buckets/buckets.command.ts | 2 + .../src/commands/services/services.command.ts | 2 + .../snippets/download/download.command.ts | 2 + .../commands/snippets/list/list.command.ts | 2 + .../ssl-enforcement/get/get.command.ts | 2 + .../ssl-enforcement/update/update.command.ts | 2 + apps/cli/src/commands/sso/add/add.command.ts | 2 + .../cli/src/commands/sso/info/info.command.ts | 2 + .../cli/src/commands/sso/list/list.command.ts | 2 + .../src/commands/sso/remove/remove.command.ts | 2 + .../cli/src/commands/sso/show/show.command.ts | 2 + .../src/commands/sso/update/update.command.ts | 2 + apps/cli/src/commands/start/start.command.ts | 2 + .../cli/src/commands/status/status.command.ts | 2 + apps/cli/src/commands/stop/stop.command.ts | 2 + .../cli/src/commands/storage/cp/cp.command.ts | 2 + .../cli/src/commands/storage/ls/ls.command.ts | 2 + .../cli/src/commands/storage/mv/mv.command.ts | 2 + .../cli/src/commands/storage/rm/rm.command.ts | 2 + .../telemetry/disable/disable.command.ts | 6 + .../telemetry/enable/enable.command.ts | 2 + .../telemetry/status/status.command.ts | 2 + apps/cli/src/commands/test/db/db.command.ts | 2 + apps/cli/src/commands/test/new/new.command.ts | 2 + .../cli/src/commands/unlink/unlink.command.ts | 2 + .../activate/activate.command.ts | 2 + .../check-availability.command.ts | 2 + .../delete/delete.command.ts | 2 + .../vanity-subdomains/get/get.command.ts | 2 + .../cli/src/commands/whoami/whoami.command.ts | 2 + 155 files changed, 591 insertions(+), 3 deletions(-) create mode 100644 apps/cli/src/command-internal/target/target-policy.unit.test.ts diff --git a/apps/cli/src/command-internal/target/target-policy.unit.test.ts b/apps/cli/src/command-internal/target/target-policy.unit.test.ts new file mode 100644 index 0000000000..6edd720884 --- /dev/null +++ b/apps/cli/src/command-internal/target/target-policy.unit.test.ts @@ -0,0 +1,257 @@ +import { BunServices } from "@effect/platform-bun"; +import { describe, expect, it } from "@effect/vitest"; +import { Context, Effect, Layer, Option } from "effect"; +import { CliOutput, Command } from "effect/cli"; + +import { commandTreeFor } from "../../../tests/helpers/command-flags.ts"; +import type { TreeNode } from "../../../tests/helpers/command-flags.ts"; +import { OPERATIONS } from "../../../tests/helpers/operation-table.ts"; +import type { PermissionVariant } from "../../../tests/helpers/permission-registry.ts"; +import { readPermissions } from "../command-permissions/command-permissions.annotation.ts"; +import { TargetPolicy, readTargetPolicy, withTargetPolicy } from "./target-policy.ts"; +import type { TargetGroup, TargetPolicyShape } from "./target-policy.ts"; + +const VARIANTS: ReadonlyArray<{ readonly name: string; readonly variant: PermissionVariant }> = [ + { name: "default backend", variant: undefined }, + { + name: "stack backend", + variant: { stackBackend: "stack", computeEnabled: true } satisfies Required< + NonNullable + >, + }, +]; + +/** Leaves whose flags have not yet moved to the shared target flags. */ +const FLAG_MIGRATION_PENDING: ReadonlySet = new Set([ + "gen types", + "db schema declarative generate", +]); + +/** Selectable leaves whose declared database-target default is still `linked`. */ +const DB_TARGET_DEFAULT_PENDING: ReadonlySet = new Set(["db dump"]); + +/** Non-GET operations a leaf may call without hosted-mutation consent, with the reason. */ +const CONSENT_FREE_OPERATIONS: ReadonlyMap = new Map([ + [ + "v1-create-login-role", + "mints a short-lived login role for the connection; revisit when connection setup asks for consent", + ], + ["v1-delete-network-bans", "removes the caller's own ban only when the token permits it"], + ["v1-list-all-network-bans", "a POST that only reads the ban list"], +]); + +const MUTATING_VERBS: ReadonlySet = new Set([ + "create", + "delete", + "remove", + "update", + "set", + "unset", + "deploy", + "push", + "restore", + "activate", + "disable", + "pause", + "unpause", + "add", + "up", + "down", + "squash", + "repair", + "reset", + "query", + "cp", + "mv", + "rm", + "buckets", +]); + +const TARGET_SELECTOR_FLAGS = ["local", "linked", "db-url"] as const; + +interface Leaf { + readonly path: string; + readonly node: TreeNode; + readonly policy: TargetPolicyShape; +} + +const leavesByVariant = VARIANTS.map(({ name, variant }) => { + const tree = commandTreeFor(variant); + const leaves = [...tree].filter(([, node]) => node.isLeaf); + return { + name, + leafPaths: new Set(leaves.map(([path]) => path)), + withPolicy: leaves.flatMap(([path, node]): ReadonlyArray => { + const policy = readTargetPolicy(node.command); + return Option.isSome(policy) ? [{ path, node, policy: policy.value }] : []; + }), + missingPolicy: leaves + .filter(([, node]) => Option.isNone(readTargetPolicy(node.command))) + .map(([path]) => path), + }; +}); + +const everyLeafPath = new Set(leavesByVariant.flatMap(({ leafPaths }) => [...leafPaths])); + +const lastWord = (path: string) => path.split(" ").at(-1) ?? path; + +describe.each(leavesByVariant)( + "target policy tree guards ($name)", + ({ withPolicy, missingPolicy }) => { + it("declares a target policy on every leaf", () => { + expect(missingPolicy).toEqual([]); + }); + + it("gives local-only, account and hosted leaves no local, linked or db-url flag", () => { + const offenders = withPolicy + .filter(({ policy }) => ["local-only", "account", "hosted"].includes(policy.group)) + .flatMap(({ path, node }) => + TARGET_SELECTOR_FLAGS.filter((flag) => node.flags.has(flag)).map( + (flag) => `${path} --${flag}`, + ), + ); + expect(offenders).toEqual([]); + }); + + it("gives sync and selectable leaves local, linked and project-ref flags", () => { + const offenders = withPolicy + .filter(({ policy }) => policy.group === "sync" || policy.group === "selectable") + .filter(({ path }) => !FLAG_MIGRATION_PENDING.has(path)) + .flatMap(({ path, node }) => + (["local", "linked", "project-ref"] as const) + .filter((flag) => !node.flags.has(flag)) + .map((flag) => `${path} lacks --${flag}`), + ); + expect(offenders).toEqual([]); + }); + + it("matches a declared database-target default to the group", () => { + const expected: Partial> = { + sync: "linked", + selectable: "local", + }; + const offenders = withPolicy + .filter(({ path }) => !DB_TARGET_DEFAULT_PENDING.has(path)) + .flatMap(({ path, node, policy }) => { + const permissions = Option.getOrUndefined(readPermissions(node.command)); + if (permissions?.status !== "mapped" || permissions.dbTargetDefault === undefined) + return []; + const want = expected[policy.group]; + return want === undefined || permissions.dbTargetDefault === want + ? [] + : [`${path}: ${policy.group} defaults to ${permissions.dbTargetDefault}`]; + }); + expect(offenders).toEqual([]); + }); + + it("requires consent for every declared non-GET operation outside the consent-free map", () => { + const offenders = withPolicy + .filter(({ policy }) => policy.consent === "none") + .flatMap(({ path, node }) => { + const permissions = Option.getOrUndefined(readPermissions(node.command)); + if (permissions?.status !== "mapped") return []; + return permissions.operations + .filter( + ({ operationId }) => + OPERATIONS.get(operationId)?.method !== "GET" && + !CONSENT_FREE_OPERATIONS.has(operationId), + ) + .map(({ operationId }) => `${path}: ${operationId}`); + }); + expect([...new Set(offenders)]).toEqual([]); + }); + + it("requires consent or an exemption reason on a leaf named for a mutating verb", () => { + const offenders = withPolicy + .filter(({ path }) => MUTATING_VERBS.has(lastWord(path))) + .filter( + ({ policy }) => + policy.consent === "none" && (policy.consentExemptReason ?? "").trim() === "", + ) + .map(({ path }) => path); + expect(offenders).toEqual([]); + }); + }, +); + +describe("target policy allowlists", () => { + it("name only real leaves", () => { + const stale = [...FLAG_MIGRATION_PENDING, ...DB_TARGET_DEFAULT_PENDING].filter( + (path) => !everyLeafPath.has(path), + ); + expect(stale).toEqual([]); + }); + + it("name only operations in the API spec", () => { + const unknown = [...CONSENT_FREE_OPERATIONS.keys()].filter( + (operationId) => !OPERATIONS.has(operationId), + ); + expect(unknown).toEqual([]); + }); +}); + +const silentFormatter: CliOutput.Formatter = { + formatCliError: () => "", + formatError: () => "", + formatErrors: () => "", + formatHelpDoc: () => "", + formatVersion: () => "", +}; + +const runtimeLayer = Layer.mergeAll(BunServices.layer, CliOutput.layer(silentFormatter)); + +class GroupProbe extends Context.Service()("test/GroupProbe") {} + +describe("withTargetPolicy", () => { + it("annotates the command with the declared policy", () => { + const leaf = Command.make("leaf").pipe(withTargetPolicy({ group: "hosted", consent: "no" })); + expect(readTargetPolicy(leaf)).toEqual(Option.some({ group: "hosted", consent: "no" })); + }); + + it("reads no policy from an unannotated command", () => { + expect(readTargetPolicy(Command.make("leaf"))).toEqual(Option.none()); + }); + + it.effect("provides the policy to the handler", () => + Effect.gen(function* () { + const seen: Array = []; + const leaf = Command.make("leaf").pipe( + Command.withHandler(() => + Effect.gen(function* () { + seen.push(yield* TargetPolicy); + }), + ), + withTargetPolicy({ group: "selectable", consent: "yes" }), + ); + yield* Command.runWith(leaf, { version: "0.0.0-test" })([]).pipe( + Effect.provide(runtimeLayer), + ); + expect(seen).toEqual([{ group: "selectable", consent: "yes" }]); + }), + ); + + it.effect("provides the policy to layers applied before it", () => + Effect.gen(function* () { + const probeLayer = Layer.effect( + GroupProbe, + Effect.gen(function* () { + return (yield* TargetPolicy).group; + }), + ); + const seen: Array = []; + const leaf = Command.make("leaf").pipe( + Command.withHandler(() => + Effect.gen(function* () { + seen.push(yield* GroupProbe); + }), + ), + Command.provide(probeLayer), + withTargetPolicy({ group: "account", consent: "none" }), + ); + yield* Command.runWith(leaf, { version: "0.0.0-test" })([]).pipe( + Effect.provide(runtimeLayer), + ); + expect(seen).toEqual(["account"]); + }), + ); +}); diff --git a/apps/cli/src/commands/backups/list/list.command.ts b/apps/cli/src/commands/backups/list/list.command.ts index 069efa6a50..a6dd493fcf 100644 --- a/apps/cli/src/commands/backups/list/list.command.ts +++ b/apps/cli/src/commands/backups/list/list.command.ts @@ -5,6 +5,7 @@ import { withJsonErrorHandling } from "../../../shared/output/json-error-handlin import { managementApiRuntimeLayer } from "../../../command-internal/management-api-runtime.layer.ts"; import { withCommandTelemetry } from "../../../telemetry/command-telemetry.ts"; import { backupsList } from "./list.handler.ts"; +import { withTargetPolicy } from "../../../command-internal/target/target-policy.ts"; const config = { projectRef: Flag.String("project-ref").pipe( @@ -32,4 +33,5 @@ export const backupsListCommand = Command.make("list", config).pipe( backupsList(flags).pipe(withCommandTelemetry({ flags }), withJsonErrorHandling), ), Command.provide(managementApiRuntimeLayer(["backups", "list"])), + withTargetPolicy({ group: "hosted", consent: "none" }), ); diff --git a/apps/cli/src/commands/backups/restore/restore.command.ts b/apps/cli/src/commands/backups/restore/restore.command.ts index d15aed8db9..a6775d6c72 100644 --- a/apps/cli/src/commands/backups/restore/restore.command.ts +++ b/apps/cli/src/commands/backups/restore/restore.command.ts @@ -5,6 +5,7 @@ import { withJsonErrorHandling } from "../../../shared/output/json-error-handlin import { managementApiRuntimeLayer } from "../../../command-internal/management-api-runtime.layer.ts"; import { withCommandTelemetry } from "../../../telemetry/command-telemetry.ts"; import { backupsRestore } from "./restore.handler.ts"; +import { withTargetPolicy } from "../../../command-internal/target/target-policy.ts"; const config = { projectRef: Flag.String("project-ref").pipe( @@ -33,4 +34,5 @@ export const backupsRestoreCommand = Command.make("restore", config).pipe( backupsRestore(flags).pipe(withCommandTelemetry({ flags }), withJsonErrorHandling), ), Command.provide(managementApiRuntimeLayer(["backups", "restore"])), + withTargetPolicy({ group: "hosted", consent: "no" }), ); diff --git a/apps/cli/src/commands/bootstrap/bootstrap.command.ts b/apps/cli/src/commands/bootstrap/bootstrap.command.ts index 69bd8236e3..66d7fc532d 100644 --- a/apps/cli/src/commands/bootstrap/bootstrap.command.ts +++ b/apps/cli/src/commands/bootstrap/bootstrap.command.ts @@ -10,6 +10,7 @@ import { cliConfigProviderLayer } from "../../shared/config/cli-config-provider. import { withCommandTelemetry } from "../../telemetry/command-telemetry.ts"; import { bootstrapRuntimeLayer } from "./bootstrap.layers.ts"; import { bootstrap } from "./bootstrap.handler.ts"; +import { withTargetPolicy } from "../../command-internal/target/target-policy.ts"; const config = { template: Argument.String("template").pipe( @@ -35,4 +36,5 @@ export const bootstrapCommand = Command.make("bootstrap", config).pipe( Command.provide(bootstrapRuntimeLayer.pipe(Layer.provideMerge(cliConfigProviderLayer))), Command.provide(cliConfigValuesLayer), withCliConfigFlags(config), + withTargetPolicy({ group: "account", consent: "yes" }), ); diff --git a/apps/cli/src/commands/branches/create/create.command.ts b/apps/cli/src/commands/branches/create/create.command.ts index 20cd15af79..83c15583ba 100644 --- a/apps/cli/src/commands/branches/create/create.command.ts +++ b/apps/cli/src/commands/branches/create/create.command.ts @@ -7,6 +7,7 @@ import { stdinLayer } from "../../../shared/runtime/stdin.layer.ts"; import { managementApiRuntimeLayer } from "../../../command-internal/management-api-runtime.layer.ts"; import { withCommandTelemetry } from "../../../telemetry/command-telemetry.ts"; import { branchesCreate } from "./create.handler.ts"; +import { withTargetPolicy } from "../../../command-internal/target/target-policy.ts"; const BRANCH_REGIONS = [ "ap-east-1", @@ -100,4 +101,5 @@ export const branchesCreateCommand = Command.make("create", config).pipe( ), // `stdinLayer` lets the confirmation prompt read piped stdin via `promptYesNo` on a non-TTY. Command.provide(Layer.mergeAll(managementApiRuntimeLayer(["branches", "create"]), stdinLayer)), + withTargetPolicy({ group: "hosted", consent: "yes" }), ); diff --git a/apps/cli/src/commands/branches/delete/delete.command.ts b/apps/cli/src/commands/branches/delete/delete.command.ts index d2a8d7a42c..a47bb7ab6f 100644 --- a/apps/cli/src/commands/branches/delete/delete.command.ts +++ b/apps/cli/src/commands/branches/delete/delete.command.ts @@ -5,6 +5,7 @@ import { withJsonErrorHandling } from "../../../shared/output/json-error-handlin import { managementApiRuntimeLayer } from "../../../command-internal/management-api-runtime.layer.ts"; import { withCommandTelemetry } from "../../../telemetry/command-telemetry.ts"; import { branchesDelete } from "./delete.handler.ts"; +import { withTargetPolicy } from "../../../command-internal/target/target-policy.ts"; const config = { name: Argument.String("name").pipe( @@ -28,4 +29,5 @@ export const branchesDeleteCommand = Command.make("delete", config).pipe( ), ), Command.provide(managementApiRuntimeLayer(["branches", "delete"])), + withTargetPolicy({ group: "hosted", consent: "no" }), ); diff --git a/apps/cli/src/commands/branches/disable/disable.command.ts b/apps/cli/src/commands/branches/disable/disable.command.ts index ed13186d2f..eb5ea93263 100644 --- a/apps/cli/src/commands/branches/disable/disable.command.ts +++ b/apps/cli/src/commands/branches/disable/disable.command.ts @@ -5,6 +5,7 @@ import { withJsonErrorHandling } from "../../../shared/output/json-error-handlin import { managementApiRuntimeLayer } from "../../../command-internal/management-api-runtime.layer.ts"; import { withCommandTelemetry } from "../../../telemetry/command-telemetry.ts"; import { branchesDisable } from "./disable.handler.ts"; +import { withTargetPolicy } from "../../../command-internal/target/target-policy.ts"; const config = { projectRef: Flag.String("project-ref").pipe( @@ -25,4 +26,5 @@ export const branchesDisableCommand = Command.make("disable", config).pipe( ), ), Command.provide(managementApiRuntimeLayer(["branches", "disable"])), + withTargetPolicy({ group: "hosted", consent: "no" }), ); diff --git a/apps/cli/src/commands/branches/get/get.command.ts b/apps/cli/src/commands/branches/get/get.command.ts index 5d6b0a34e7..d054e5fa3a 100644 --- a/apps/cli/src/commands/branches/get/get.command.ts +++ b/apps/cli/src/commands/branches/get/get.command.ts @@ -5,6 +5,7 @@ import { withJsonErrorHandling } from "../../../shared/output/json-error-handlin import { managementApiRuntimeLayer } from "../../../command-internal/management-api-runtime.layer.ts"; import { withCommandTelemetry } from "../../../telemetry/command-telemetry.ts"; import { branchesGet } from "./get.handler.ts"; +import { withTargetPolicy } from "../../../command-internal/target/target-policy.ts"; const config = { name: Argument.String("name").pipe( @@ -31,4 +32,5 @@ export const branchesGetCommand = Command.make("get", config).pipe( ), ), Command.provide(managementApiRuntimeLayer(["branches", "get"])), + withTargetPolicy({ group: "hosted", consent: "none" }), ); diff --git a/apps/cli/src/commands/branches/list/list.command.ts b/apps/cli/src/commands/branches/list/list.command.ts index 2b15000ace..f3a5bc79e9 100644 --- a/apps/cli/src/commands/branches/list/list.command.ts +++ b/apps/cli/src/commands/branches/list/list.command.ts @@ -5,6 +5,7 @@ import { withJsonErrorHandling } from "../../../shared/output/json-error-handlin import { managementApiRuntimeLayer } from "../../../command-internal/management-api-runtime.layer.ts"; import { withCommandTelemetry } from "../../../telemetry/command-telemetry.ts"; import { branchesList } from "./list.handler.ts"; +import { withTargetPolicy } from "../../../command-internal/target/target-policy.ts"; const config = { projectRef: Flag.String("project-ref").pipe( @@ -25,4 +26,5 @@ export const branchesListCommand = Command.make("list", config).pipe( ), ), Command.provide(managementApiRuntimeLayer(["branches", "list"])), + withTargetPolicy({ group: "hosted", consent: "none" }), ); diff --git a/apps/cli/src/commands/branches/pause/pause.command.ts b/apps/cli/src/commands/branches/pause/pause.command.ts index 5361025bbe..ffa7136812 100644 --- a/apps/cli/src/commands/branches/pause/pause.command.ts +++ b/apps/cli/src/commands/branches/pause/pause.command.ts @@ -5,6 +5,7 @@ import { withJsonErrorHandling } from "../../../shared/output/json-error-handlin import { managementApiRuntimeLayer } from "../../../command-internal/management-api-runtime.layer.ts"; import { withCommandTelemetry } from "../../../telemetry/command-telemetry.ts"; import { branchesPause } from "./pause.handler.ts"; +import { withTargetPolicy } from "../../../command-internal/target/target-policy.ts"; const config = { name: Argument.String("name").pipe( @@ -29,4 +30,5 @@ export const branchesPauseCommand = Command.make("pause", config).pipe( ), ), Command.provide(managementApiRuntimeLayer(["branches", "pause"])), + withTargetPolicy({ group: "hosted", consent: "no" }), ); diff --git a/apps/cli/src/commands/branches/unpause/unpause.command.ts b/apps/cli/src/commands/branches/unpause/unpause.command.ts index 66ede0fb27..6751a213cc 100644 --- a/apps/cli/src/commands/branches/unpause/unpause.command.ts +++ b/apps/cli/src/commands/branches/unpause/unpause.command.ts @@ -5,6 +5,7 @@ import { withJsonErrorHandling } from "../../../shared/output/json-error-handlin import { managementApiRuntimeLayer } from "../../../command-internal/management-api-runtime.layer.ts"; import { withCommandTelemetry } from "../../../telemetry/command-telemetry.ts"; import { branchesUnpause } from "./unpause.handler.ts"; +import { withTargetPolicy } from "../../../command-internal/target/target-policy.ts"; const config = { name: Argument.String("name").pipe( @@ -29,4 +30,5 @@ export const branchesUnpauseCommand = Command.make("unpause", config).pipe( ), ), Command.provide(managementApiRuntimeLayer(["branches", "unpause"])), + withTargetPolicy({ group: "hosted", consent: "no" }), ); diff --git a/apps/cli/src/commands/branches/update/update.command.ts b/apps/cli/src/commands/branches/update/update.command.ts index 29c2a265c9..5feaccf752 100644 --- a/apps/cli/src/commands/branches/update/update.command.ts +++ b/apps/cli/src/commands/branches/update/update.command.ts @@ -5,6 +5,7 @@ import { withJsonErrorHandling } from "../../../shared/output/json-error-handlin import { managementApiRuntimeLayer } from "../../../command-internal/management-api-runtime.layer.ts"; import { withCommandTelemetry } from "../../../telemetry/command-telemetry.ts"; import { branchesUpdate } from "./update.handler.ts"; +import { withTargetPolicy } from "../../../command-internal/target/target-policy.ts"; const BRANCH_STATUSES = [ "RUNNING_MIGRATIONS", @@ -56,4 +57,5 @@ export const branchesUpdateCommand = Command.make("update", config).pipe( ), ), Command.provide(managementApiRuntimeLayer(["branches", "update"])), + withTargetPolicy({ group: "hosted", consent: "no" }), ); diff --git a/apps/cli/src/commands/completion/bash/bash.command.ts b/apps/cli/src/commands/completion/bash/bash.command.ts index b1e3db687e..8edfe7fecc 100644 --- a/apps/cli/src/commands/completion/bash/bash.command.ts +++ b/apps/cli/src/commands/completion/bash/bash.command.ts @@ -5,6 +5,7 @@ import { commandRuntimeLayer } from "../../../shared/runtime/command-runtime.lay import { withCommandTelemetry } from "../../../telemetry/command-telemetry.ts"; import { CompletionNoDescriptionsFlagDef } from "../completion.flags.ts"; import { completionBash } from "./bash.handler.ts"; +import { withTargetPolicy } from "../../../command-internal/target/target-policy.ts"; const config = { noDescriptions: CompletionNoDescriptionsFlagDef, @@ -30,4 +31,5 @@ export const completionBashCommand = Command.make("bash", config).pipe( completionBash(flags).pipe(withCommandTelemetry({ flags }), withJsonErrorHandling), ), Command.provide(commandRuntimeLayer(["completion", "bash"])), + withTargetPolicy({ group: "local-only", consent: "none" }), ); diff --git a/apps/cli/src/commands/completion/fish/fish.command.ts b/apps/cli/src/commands/completion/fish/fish.command.ts index 594a89899f..394c5a4620 100644 --- a/apps/cli/src/commands/completion/fish/fish.command.ts +++ b/apps/cli/src/commands/completion/fish/fish.command.ts @@ -5,6 +5,7 @@ import { commandRuntimeLayer } from "../../../shared/runtime/command-runtime.lay import { withCommandTelemetry } from "../../../telemetry/command-telemetry.ts"; import { CompletionNoDescriptionsFlagDef } from "../completion.flags.ts"; import { completionFish } from "./fish.handler.ts"; +import { withTargetPolicy } from "../../../command-internal/target/target-policy.ts"; const config = { noDescriptions: CompletionNoDescriptionsFlagDef, @@ -25,4 +26,5 @@ export const completionFishCommand = Command.make("fish", config).pipe( completionFish(flags).pipe(withCommandTelemetry({ flags }), withJsonErrorHandling), ), Command.provide(commandRuntimeLayer(["completion", "fish"])), + withTargetPolicy({ group: "local-only", consent: "none" }), ); diff --git a/apps/cli/src/commands/completion/powershell/powershell.command.ts b/apps/cli/src/commands/completion/powershell/powershell.command.ts index 7afb1a75e5..c3aaaf4d33 100644 --- a/apps/cli/src/commands/completion/powershell/powershell.command.ts +++ b/apps/cli/src/commands/completion/powershell/powershell.command.ts @@ -5,6 +5,7 @@ import { commandRuntimeLayer } from "../../../shared/runtime/command-runtime.lay import { withCommandTelemetry } from "../../../telemetry/command-telemetry.ts"; import { CompletionNoDescriptionsFlagDef } from "../completion.flags.ts"; import { completionPowershell } from "./powershell.handler.ts"; +import { withTargetPolicy } from "../../../command-internal/target/target-policy.ts"; const config = { noDescriptions: CompletionNoDescriptionsFlagDef, @@ -24,4 +25,5 @@ export const completionPowershellCommand = Command.make("powershell", config).pi completionPowershell(flags).pipe(withCommandTelemetry({ flags }), withJsonErrorHandling), ), Command.provide(commandRuntimeLayer(["completion", "powershell"])), + withTargetPolicy({ group: "local-only", consent: "none" }), ); diff --git a/apps/cli/src/commands/completion/zsh/zsh.command.ts b/apps/cli/src/commands/completion/zsh/zsh.command.ts index b80c3e3871..e570eb57ba 100644 --- a/apps/cli/src/commands/completion/zsh/zsh.command.ts +++ b/apps/cli/src/commands/completion/zsh/zsh.command.ts @@ -5,6 +5,7 @@ import { commandRuntimeLayer } from "../../../shared/runtime/command-runtime.lay import { withCommandTelemetry } from "../../../telemetry/command-telemetry.ts"; import { CompletionNoDescriptionsFlagDef } from "../completion.flags.ts"; import { completionZsh } from "./zsh.handler.ts"; +import { withTargetPolicy } from "../../../command-internal/target/target-policy.ts"; const config = { noDescriptions: CompletionNoDescriptionsFlagDef, @@ -31,4 +32,5 @@ export const completionZshCommand = Command.make("zsh", config).pipe( completionZsh(flags).pipe(withCommandTelemetry({ flags }), withJsonErrorHandling), ), Command.provide(commandRuntimeLayer(["completion", "zsh"])), + withTargetPolicy({ group: "local-only", consent: "none" }), ); diff --git a/apps/cli/src/commands/config/diff/diff.command.ts b/apps/cli/src/commands/config/diff/diff.command.ts index 68910bd7c6..604f107788 100644 --- a/apps/cli/src/commands/config/diff/diff.command.ts +++ b/apps/cli/src/commands/config/diff/diff.command.ts @@ -10,6 +10,7 @@ import { GLOBAL_OUTPUT_FORMATS } from "../../../command-internal/global-flags.ts import { managementApiRuntimeLayer } from "../../../command-internal/management-api-runtime.layer.ts"; import { withCommandTelemetry } from "../../../telemetry/command-telemetry.ts"; import { configDiff } from "./diff.handler.ts"; +import { withTargetPolicy } from "../../../command-internal/target/target-policy.ts"; const config = { // Accepts either a project ref or a branch of the linked project; no separate `--target` flag. @@ -68,4 +69,5 @@ export const configDiffCommand = Command.make("diff", config).pipe( Command.provide(managementApiRuntimeLayer(["config", "diff"])), Command.provide(cliConfigValuesLayer), withCliConfigFlags(config), + withTargetPolicy({ group: "hosted", consent: "none" }), ); diff --git a/apps/cli/src/commands/config/pull/pull.command.ts b/apps/cli/src/commands/config/pull/pull.command.ts index afe66499c1..9b5494e900 100644 --- a/apps/cli/src/commands/config/pull/pull.command.ts +++ b/apps/cli/src/commands/config/pull/pull.command.ts @@ -11,6 +11,7 @@ import { GLOBAL_OUTPUT_FORMATS } from "../../../command-internal/global-flags.ts import { managementApiRuntimeLayer } from "../../../command-internal/management-api-runtime.layer.ts"; import { withCommandTelemetry } from "../../../telemetry/command-telemetry.ts"; import { configPull } from "./pull.handler.ts"; +import { withTargetPolicy } from "../../../command-internal/target/target-policy.ts"; const config = { // Accepts either a project ref or a branch name/UUID of the linked project; there's no @@ -83,4 +84,5 @@ export const configPullCommand = Command.make("pull", config).pipe( Command.provide(Layer.mergeAll(managementApiRuntimeLayer(["config", "pull"]), stdinLayer)), Command.provide(cliConfigValuesLayer), withCliConfigFlags(config), + withTargetPolicy({ group: "hosted", consent: "none" }), ); diff --git a/apps/cli/src/commands/config/push/push.command.ts b/apps/cli/src/commands/config/push/push.command.ts index 9ef706092b..75e6340009 100644 --- a/apps/cli/src/commands/config/push/push.command.ts +++ b/apps/cli/src/commands/config/push/push.command.ts @@ -11,6 +11,7 @@ import { stdinLayer } from "../../../shared/runtime/stdin.layer.ts"; import { managementApiRuntimeLayer } from "../../../command-internal/management-api-runtime.layer.ts"; import { withCommandTelemetry } from "../../../telemetry/command-telemetry.ts"; import { configPush } from "./push.handler.ts"; +import { withTargetPolicy } from "../../../command-internal/target/target-policy.ts"; const config = { // Accepts either a project ref or a branch name/UUID of the linked project; there's no @@ -64,4 +65,5 @@ export const configPushCommand = Command.make("push", config).pipe( Command.provide(Layer.mergeAll(managementApiRuntimeLayer(["config", "push"]), stdinLayer)), Command.provide(cliConfigValuesLayer), withCliConfigFlags(config), + withTargetPolicy({ group: "hosted", consent: "no" }), ); diff --git a/apps/cli/src/commands/db/advisors/advisors.command.ts b/apps/cli/src/commands/db/advisors/advisors.command.ts index e789d0c667..500c31aaf7 100644 --- a/apps/cli/src/commands/db/advisors/advisors.command.ts +++ b/apps/cli/src/commands/db/advisors/advisors.command.ts @@ -14,6 +14,7 @@ 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"; +import { withTargetPolicy } from "../../../command-internal/target/target-policy.ts"; const config = { dbUrl: Flag.String("db-url").pipe( @@ -111,4 +112,5 @@ export const dbAdvisorsCommand = Command.make("advisors", config).pipe( Command.provide(dbAdvisorsRuntimeLayer), Command.provide(cliConfigValuesLayer), withCliConfigFlags(config), + withTargetPolicy({ group: "selectable", consent: "none" }), ); diff --git a/apps/cli/src/commands/db/branch/branch.command.ts b/apps/cli/src/commands/db/branch/branch.command.ts index 8d133ba354..ebbdd9cb37 100644 --- a/apps/cli/src/commands/db/branch/branch.command.ts +++ b/apps/cli/src/commands/db/branch/branch.command.ts @@ -1,5 +1,6 @@ import { Argument, Command } from "effect/cli"; import { removedCommand } from "../../../command-internal/removed-command.ts"; +import { withTargetPolicy } from "../../../command-internal/target/target-policy.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"; @@ -8,6 +9,12 @@ import { telemetryStateLayer } from "../../../telemetry/telemetry-state.layer.ts const REMOVED_SUGGESTION = "Local database branches are no longer supported. For hosted preview branches, see `supabase branches --help`."; +const withRemovedBranchTargetPolicy = withTargetPolicy({ + group: "removed", + consent: "none", + consentExemptReason: "removed command", +}); + /** A tombstoned `db branch` leaf taking an optional `` positional. */ function removedBranchLeaf(name: string, argDescription: string) { return Command.make(name, { @@ -23,6 +30,7 @@ function removedBranchLeaf(name: string, argDescription: string) { ), Command.provide(commandRuntimeLayer(["db", "branch", name])), Command.provide(telemetryStateLayer), + withRemovedBranchTargetPolicy, ); } @@ -38,6 +46,7 @@ const dbBranchListCommand = Command.make("list", {}).pipe( ), Command.provide(commandRuntimeLayer(["db", "branch", "list"])), Command.provide(telemetryStateLayer), + withRemovedBranchTargetPolicy, ); export const dbBranchCommand = Command.make("branch").pipe( diff --git a/apps/cli/src/commands/db/diff/diff.command.ts b/apps/cli/src/commands/db/diff/diff.command.ts index 7e4592048c..4c6320df7f 100644 --- a/apps/cli/src/commands/db/diff/diff.command.ts +++ b/apps/cli/src/commands/db/diff/diff.command.ts @@ -17,6 +17,7 @@ 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"; +import { withTargetPolicy } from "../../../command-internal/target/target-policy.ts"; const config = { // The engine flags are a mutually-exclusive group, modelled as `Option` so the mutex check keys @@ -166,4 +167,5 @@ export const dbDiffCommand = Command.make("diff", config).pipe( Command.provide(dbDiffRuntimeLayer), Command.provide(cliConfigValuesLayer), withCliConfigFlags(config), + withTargetPolicy({ group: "selectable", consent: "none" }), ); diff --git a/apps/cli/src/commands/db/dump/dump.command.ts b/apps/cli/src/commands/db/dump/dump.command.ts index bf09694752..ae6da9228b 100644 --- a/apps/cli/src/commands/db/dump/dump.command.ts +++ b/apps/cli/src/commands/db/dump/dump.command.ts @@ -21,6 +21,7 @@ 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"; +import { withTargetPolicy } from "../../../command-internal/target/target-policy.ts"; /** * `db dump` has no `--output-format` machine envelope; it streams pg_dump SQL to @@ -170,4 +171,5 @@ export const dbDumpCommand = Command.make("dump", config).pipe( Command.provide(dbDumpRuntimeLayer), Command.provide(cliConfigValuesLayer), withCliConfigFlags(config), + withTargetPolicy({ group: "selectable", consent: "none" }), ); diff --git a/apps/cli/src/commands/db/lint/lint.command.ts b/apps/cli/src/commands/db/lint/lint.command.ts index 880ece2f40..3358171391 100644 --- a/apps/cli/src/commands/db/lint/lint.command.ts +++ b/apps/cli/src/commands/db/lint/lint.command.ts @@ -15,6 +15,7 @@ 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"; +import { withTargetPolicy } from "../../../command-internal/target/target-policy.ts"; const config = { dbUrl: Flag.String("db-url").pipe( @@ -93,4 +94,5 @@ export const dbLintCommand = Command.make("lint", config).pipe( Command.provide(dbLintRuntimeLayer), Command.provide(cliConfigValuesLayer), withCliConfigFlags(config), + withTargetPolicy({ group: "selectable", consent: "none" }), ); diff --git a/apps/cli/src/commands/db/pull/pull.command.ts b/apps/cli/src/commands/db/pull/pull.command.ts index e1899f77d7..bf18b3492d 100644 --- a/apps/cli/src/commands/db/pull/pull.command.ts +++ b/apps/cli/src/commands/db/pull/pull.command.ts @@ -21,6 +21,7 @@ import { withCommandTelemetry } from "../../../telemetry/command-telemetry.ts"; import { dbPull, resolveRemovedExperimental } from "./pull.handler.ts"; import { dbPullRuntimeLayer } from "./pull.layers.ts"; import { cliConfigValuesLayer } from "../../../config/cli-config-values.layer.ts"; +import { withTargetPolicy } from "../../../command-internal/target/target-policy.ts"; const config = { name: Argument.String("migration name").pipe( @@ -176,4 +177,5 @@ export const dbPullCommand = Command.make("pull", config).pipe( }), Command.provide(cliConfigValuesLayer), withCliConfigFlags(config), + withTargetPolicy({ group: "sync", consent: "no" }), ); diff --git a/apps/cli/src/commands/db/push/push.command.ts b/apps/cli/src/commands/db/push/push.command.ts index 216d66078e..7e011b85ce 100644 --- a/apps/cli/src/commands/db/push/push.command.ts +++ b/apps/cli/src/commands/db/push/push.command.ts @@ -16,6 +16,7 @@ import { withJsonErrorHandling } from "../../../shared/output/json-error-handlin import { withCommandTelemetry } from "../../../telemetry/command-telemetry.ts"; import { dbPush } from "./push.handler.ts"; import { dbPushRuntimeLayer } from "./push.layers.ts"; +import { withTargetPolicy } from "../../../command-internal/target/target-policy.ts"; const config = { includeAll: Flag.Boolean("include-all").pipe( @@ -122,4 +123,5 @@ export const dbPushCommand = Command.make("push", config).pipe( Command.provide(dbPushRuntimeLayer), Command.provide(cliConfigValuesLayer), withCliConfigFlags(config), + withTargetPolicy({ group: "sync", consent: "no" }), ); diff --git a/apps/cli/src/commands/db/query/query.command.ts b/apps/cli/src/commands/db/query/query.command.ts index 98df15ac8f..4f75ddc3e3 100644 --- a/apps/cli/src/commands/db/query/query.command.ts +++ b/apps/cli/src/commands/db/query/query.command.ts @@ -16,6 +16,7 @@ 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"; +import { withTargetPolicy } from "../../../command-internal/target/target-policy.ts"; /** * `db query` needs its own `--output`/`-o` (`json|table|csv`), but Effect CLI keeps one global @@ -97,4 +98,5 @@ export const dbQueryCommand = Command.make("query", config).pipe( Command.provide(dbQueryRuntimeLayer), Command.provide(cliConfigValuesLayer), withCliConfigFlags(config), + withTargetPolicy({ group: "selectable", consent: "no" }), ); 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 82de4cb8b6..0fca291eb1 100644 --- a/apps/cli/src/commands/db/remote/changes/changes.command.ts +++ b/apps/cli/src/commands/db/remote/changes/changes.command.ts @@ -6,6 +6,7 @@ import { withJsonErrorHandling } from "../../../../shared/output/json-error-hand import { commandRuntimeLayer } from "../../../../shared/runtime/command-runtime.layer.ts"; import { withCommandTelemetry } from "../../../../telemetry/command-telemetry.ts"; import { telemetryStateLayer } from "../../../../telemetry/telemetry-state.layer.ts"; +import { withTargetPolicy } from "../../../../command-internal/target/target-policy.ts"; const config = { schema: Flag.String("schema").pipe( @@ -41,4 +42,5 @@ export const dbRemoteChangesCommand = Command.make("changes", config).pipe( Command.provide(commandRuntimeLayer(["db", "remote", "changes"])), Command.provide(telemetryStateLayer), withCliConfigFlags(config), + withTargetPolicy({ group: "removed", consent: "none" }), ); 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 3befc308ed..c62e394a85 100644 --- a/apps/cli/src/commands/db/remote/commit/commit.command.ts +++ b/apps/cli/src/commands/db/remote/commit/commit.command.ts @@ -6,6 +6,7 @@ import { withJsonErrorHandling } from "../../../../shared/output/json-error-hand import { commandRuntimeLayer } from "../../../../shared/runtime/command-runtime.layer.ts"; import { withCommandTelemetry } from "../../../../telemetry/command-telemetry.ts"; import { telemetryStateLayer } from "../../../../telemetry/telemetry-state.layer.ts"; +import { withTargetPolicy } from "../../../../command-internal/target/target-policy.ts"; const config = { schema: Flag.String("schema").pipe( @@ -41,4 +42,5 @@ export const dbRemoteCommitCommand = Command.make("commit", config).pipe( Command.provide(commandRuntimeLayer(["db", "remote", "commit"])), Command.provide(telemetryStateLayer), withCliConfigFlags(config), + withTargetPolicy({ group: "removed", consent: "none" }), ); diff --git a/apps/cli/src/commands/db/reset/reset.command.ts b/apps/cli/src/commands/db/reset/reset.command.ts index 21ff473bcb..2196377a4a 100644 --- a/apps/cli/src/commands/db/reset/reset.command.ts +++ b/apps/cli/src/commands/db/reset/reset.command.ts @@ -16,6 +16,7 @@ import { withCommandTelemetry } from "../../../telemetry/command-telemetry.ts"; import { dbReset } from "./reset.handler.ts"; import { dbResetRuntimeLayer } from "./reset.layers.ts"; import { cliConfigValuesLayer } from "../../../config/cli-config-values.layer.ts"; +import { withTargetPolicy } from "../../../command-internal/target/target-policy.ts"; const config = { dbUrl: Flag.String("db-url").pipe( @@ -94,4 +95,5 @@ export const dbResetCommand = Command.make("reset", config).pipe( Command.provide(dbResetRuntimeLayer), Command.provide(cliConfigValuesLayer), withCliConfigFlags(config), + withTargetPolicy({ group: "selectable", consent: "no" }), ); 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 9dfed8e9aa..373132eb44 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 @@ -22,6 +22,7 @@ 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"; +import { withTargetPolicy } from "../../../../../command-internal/target/target-policy.ts"; const config = { overwrite: Flag.Boolean("overwrite").pipe( @@ -171,4 +172,5 @@ export const dbSchemaDeclarativeGenerateCommand = Command.make("generate", confi Command.provide(dbSchemaDeclarativeGenerateRuntimeLayer), Command.provide(cliConfigValuesLayer), withCliConfigFlags(config), + withTargetPolicy({ group: "selectable", consent: "none" }), ); 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 3742498371..da5dd7d111 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 @@ -14,6 +14,7 @@ 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"; +import { withTargetPolicy } from "../../../../../command-internal/target/target-policy.ts"; const config = { schema: Flag.String("schema").pipe( @@ -117,4 +118,5 @@ export const dbSchemaDeclarativeSyncCommand = Command.make("sync", config).pipe( Command.provide(dbSchemaDeclarativeSyncRuntimeLayer), Command.provide(cliConfigValuesLayer), withCliConfigFlags(config), + withTargetPolicy({ group: "local-only", consent: "none" }), ); diff --git a/apps/cli/src/commands/db/start/start.command.ts b/apps/cli/src/commands/db/start/start.command.ts index 0c5e2593c5..d1ceaf1f3c 100644 --- a/apps/cli/src/commands/db/start/start.command.ts +++ b/apps/cli/src/commands/db/start/start.command.ts @@ -8,6 +8,7 @@ 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"; +import { withTargetPolicy } from "../../../command-internal/target/target-policy.ts"; const config = { fromBackup: Flag.String("from-backup").pipe( @@ -35,4 +36,5 @@ export const dbStartCommand = Command.make("start", config).pipe( Command.provide(dbStartRuntimeLayer), Command.provide(cliConfigValuesLayer), withCliConfigFlags(config), + withTargetPolicy({ group: "local-only", consent: "none" }), ); diff --git a/apps/cli/src/commands/db/test/test.command.ts b/apps/cli/src/commands/db/test/test.command.ts index 2944ec8082..6941e68528 100644 --- a/apps/cli/src/commands/db/test/test.command.ts +++ b/apps/cli/src/commands/db/test/test.command.ts @@ -12,6 +12,7 @@ import { 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"; +import { withTargetPolicy } from "../../../command-internal/target/target-policy.ts"; /** * `db test` is a hidden alias for `test db`, registered hidden by the parent @@ -31,4 +32,5 @@ export const dbTestCommand = Command.make("test", testDbConfig).pipe( Command.provide(testDbRuntimeLayer(["db", "test"])), Command.provide(cliConfigValuesLayer), withCliConfigFlags(testDbConfig), + withTargetPolicy({ group: "selectable", consent: "no" }), ); diff --git a/apps/cli/src/commands/domains/activate/activate.command.ts b/apps/cli/src/commands/domains/activate/activate.command.ts index f517d6b288..fd6acdaece 100644 --- a/apps/cli/src/commands/domains/activate/activate.command.ts +++ b/apps/cli/src/commands/domains/activate/activate.command.ts @@ -7,6 +7,7 @@ import { managementApiRuntimeLayer } from "../../../command-internal/management- import { rejectIncludeRawOutput } from "../domains.removed-flag.ts"; import { withCommandTelemetry } from "../../../telemetry/command-telemetry.ts"; import { domainsActivate } from "./activate.handler.ts"; +import { withTargetPolicy } from "../../../command-internal/target/target-policy.ts"; const config = { projectRef: Flag.String("project-ref").pipe( @@ -47,4 +48,5 @@ export const domainsActivateCommand = Command.make("activate", config).pipe( Effect.provide(managementApiLayer), ), ), + withTargetPolicy({ group: "hosted", consent: "no" }), ); diff --git a/apps/cli/src/commands/domains/create/create.command.ts b/apps/cli/src/commands/domains/create/create.command.ts index 7b4923a276..157592ef1a 100644 --- a/apps/cli/src/commands/domains/create/create.command.ts +++ b/apps/cli/src/commands/domains/create/create.command.ts @@ -7,6 +7,7 @@ import { managementApiRuntimeLayer } from "../../../command-internal/management- import { rejectIncludeRawOutput } from "../domains.removed-flag.ts"; import { withCommandTelemetry } from "../../../telemetry/command-telemetry.ts"; import { domainsCreate } from "./create.handler.ts"; +import { withTargetPolicy } from "../../../command-internal/target/target-policy.ts"; const config = { projectRef: Flag.String("project-ref").pipe( @@ -51,4 +52,5 @@ export const domainsCreateCommand = Command.make("create", config).pipe( Effect.provide(managementApiLayer), ), ), + withTargetPolicy({ group: "hosted", consent: "yes" }), ); diff --git a/apps/cli/src/commands/domains/delete/delete.command.ts b/apps/cli/src/commands/domains/delete/delete.command.ts index f1d3a551a0..237b51e409 100644 --- a/apps/cli/src/commands/domains/delete/delete.command.ts +++ b/apps/cli/src/commands/domains/delete/delete.command.ts @@ -7,6 +7,7 @@ import { managementApiRuntimeLayer } from "../../../command-internal/management- import { rejectIncludeRawOutput } from "../domains.removed-flag.ts"; import { withCommandTelemetry } from "../../../telemetry/command-telemetry.ts"; import { domainsDelete } from "./delete.handler.ts"; +import { withTargetPolicy } from "../../../command-internal/target/target-policy.ts"; const config = { projectRef: Flag.String("project-ref").pipe( @@ -45,4 +46,5 @@ export const domainsDeleteCommand = Command.make("delete", config).pipe( Effect.provide(managementApiLayer), ), ), + withTargetPolicy({ group: "hosted", consent: "no" }), ); diff --git a/apps/cli/src/commands/domains/get/get.command.ts b/apps/cli/src/commands/domains/get/get.command.ts index bbbc1d7ba3..9583473788 100644 --- a/apps/cli/src/commands/domains/get/get.command.ts +++ b/apps/cli/src/commands/domains/get/get.command.ts @@ -7,6 +7,7 @@ import { managementApiRuntimeLayer } from "../../../command-internal/management- import { rejectIncludeRawOutput } from "../domains.removed-flag.ts"; import { withCommandTelemetry } from "../../../telemetry/command-telemetry.ts"; import { domainsGet } from "./get.handler.ts"; +import { withTargetPolicy } from "../../../command-internal/target/target-policy.ts"; const config = { projectRef: Flag.String("project-ref").pipe( @@ -47,4 +48,5 @@ export const domainsGetCommand = Command.make("get", config).pipe( Effect.provide(managementApiLayer), ), ), + withTargetPolicy({ group: "hosted", consent: "none" }), ); diff --git a/apps/cli/src/commands/domains/reverify/reverify.command.ts b/apps/cli/src/commands/domains/reverify/reverify.command.ts index 0d26352068..474532e975 100644 --- a/apps/cli/src/commands/domains/reverify/reverify.command.ts +++ b/apps/cli/src/commands/domains/reverify/reverify.command.ts @@ -7,6 +7,7 @@ import { managementApiRuntimeLayer } from "../../../command-internal/management- import { rejectIncludeRawOutput } from "../domains.removed-flag.ts"; import { withCommandTelemetry } from "../../../telemetry/command-telemetry.ts"; import { domainsReverify } from "./reverify.handler.ts"; +import { withTargetPolicy } from "../../../command-internal/target/target-policy.ts"; const config = { projectRef: Flag.String("project-ref").pipe( @@ -45,4 +46,9 @@ export const domainsReverifyCommand = Command.make("reverify", config).pipe( Effect.provide(managementApiLayer), ), ), + withTargetPolicy({ + group: "hosted", + consent: "none", + consentExemptReason: "re-runs verification and changes no configuration", + }), ); diff --git a/apps/cli/src/commands/encryption/get-root-key/get-root-key.command.ts b/apps/cli/src/commands/encryption/get-root-key/get-root-key.command.ts index 43c739540f..ce907bb695 100644 --- a/apps/cli/src/commands/encryption/get-root-key/get-root-key.command.ts +++ b/apps/cli/src/commands/encryption/get-root-key/get-root-key.command.ts @@ -5,6 +5,7 @@ import { withJsonErrorHandling } from "../../../shared/output/json-error-handlin import { managementApiRuntimeLayer } from "../../../command-internal/management-api-runtime.layer.ts"; import { withCommandTelemetry } from "../../../telemetry/command-telemetry.ts"; import { encryptionGetRootKey } from "./get-root-key.handler.ts"; +import { withTargetPolicy } from "../../../command-internal/target/target-policy.ts"; const config = { projectRef: Flag.String("project-ref").pipe( @@ -26,4 +27,5 @@ export const encryptionGetRootKeyCommand = Command.make("get-root-key", config). ), ), Command.provide(managementApiRuntimeLayer(["encryption", "get-root-key"])), + withTargetPolicy({ group: "hosted", consent: "none" }), ); diff --git a/apps/cli/src/commands/encryption/update-root-key/update-root-key.command.ts b/apps/cli/src/commands/encryption/update-root-key/update-root-key.command.ts index 38e38f8556..bd5921b02a 100644 --- a/apps/cli/src/commands/encryption/update-root-key/update-root-key.command.ts +++ b/apps/cli/src/commands/encryption/update-root-key/update-root-key.command.ts @@ -8,6 +8,7 @@ import { ttyLayer } from "../../../shared/runtime/tty.layer.ts"; import { managementApiRuntimeLayer } from "../../../command-internal/management-api-runtime.layer.ts"; import { withCommandTelemetry } from "../../../telemetry/command-telemetry.ts"; import { encryptionUpdateRootKey } from "./update-root-key.handler.ts"; +import { withTargetPolicy } from "../../../command-internal/target/target-policy.ts"; const config = { projectRef: Flag.String("project-ref").pipe( @@ -37,4 +38,5 @@ export const encryptionUpdateRootKeyCommand = Command.make("update-root-key", co ), ), Command.provide(updateRuntime), + withTargetPolicy({ group: "hosted", consent: "no" }), ); 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 ad01d8b174..07ce262ae5 100644 --- a/apps/cli/src/commands/experimental/compute/delete/delete.command.ts +++ b/apps/cli/src/commands/experimental/compute/delete/delete.command.ts @@ -6,6 +6,7 @@ import { withJsonErrorHandling } from "../../../../shared/output/json-error-hand import { managementApiRuntimeLayer } from "../../../../command-internal/management-api-runtime.layer.ts"; import { withCommandTelemetry } from "../../../../telemetry/command-telemetry.ts"; import { computeDelete } from "./delete.handler.ts"; +import { withTargetPolicy } from "../../../../command-internal/target/target-policy.ts"; // No local `--yes`: it is a root persistent flag every other confirming command // reads through `resolveYes`, so redeclaring it here would shadow the @@ -41,4 +42,5 @@ export const computeDeleteCommand = Command.make("delete", config).pipe( Command.provide(managementApiRuntimeLayer(["compute", "delete"])), Command.provide(cliConfigValuesLayer), withCliConfigFlags(config), + withTargetPolicy({ group: "hosted", consent: "no" }), ); 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 0917496af3..f748d5d2dc 100644 --- a/apps/cli/src/commands/experimental/compute/list/list.command.ts +++ b/apps/cli/src/commands/experimental/compute/list/list.command.ts @@ -6,6 +6,7 @@ import { withJsonErrorHandling } from "../../../../shared/output/json-error-hand import { managementApiRuntimeLayer } from "../../../../command-internal/management-api-runtime.layer.ts"; import { withCommandTelemetry } from "../../../../telemetry/command-telemetry.ts"; import { computeList } from "./list.handler.ts"; +import { withTargetPolicy } from "../../../../command-internal/target/target-policy.ts"; const config = { projectRef: Flag.String("project-ref").pipe( @@ -33,4 +34,5 @@ export const computeListCommand = Command.make("list", config).pipe( Command.provide(managementApiRuntimeLayer(["compute", "list"])), Command.provide(cliConfigValuesLayer), withCliConfigFlags(config), + withTargetPolicy({ group: "hosted", consent: "none" }), ); diff --git a/apps/cli/src/commands/experimental/compute/logs/logs.command.ts b/apps/cli/src/commands/experimental/compute/logs/logs.command.ts index a4d2633215..a310f9a64b 100644 --- a/apps/cli/src/commands/experimental/compute/logs/logs.command.ts +++ b/apps/cli/src/commands/experimental/compute/logs/logs.command.ts @@ -8,6 +8,7 @@ import { } from "../../../../shared/compute/compute-logs.sql.ts"; import { withCommandTelemetry } from "../../../../telemetry/command-telemetry.ts"; import { computeLogs } from "./logs.handler.ts"; +import { withTargetPolicy } from "../../../../command-internal/target/target-policy.ts"; /** * The endpoint's own ceiling is the SQL `LIMIT`, so this bound is the CLI's @@ -94,4 +95,5 @@ export const computeLogsCommand = Command.make("logs", config).pipe( ), ), Command.provide(managementApiRuntimeLayer(["compute", "logs"])), + withTargetPolicy({ group: "hosted", consent: "none" }), ); 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 c4861e2c71..34227c4294 100644 --- a/apps/cli/src/commands/experimental/compute/new/new.command.ts +++ b/apps/cli/src/commands/experimental/compute/new/new.command.ts @@ -15,6 +15,7 @@ import { debugLoggerLayer } from "../../../../command-internal/debug-logger.laye import { telemetryStateLayer } from "../../../../telemetry/telemetry-state.layer.ts"; import { withCommandTelemetry } from "../../../../telemetry/command-telemetry.ts"; import { computeNew } from "./new.handler.ts"; +import { withTargetPolicy } from "../../../../command-internal/target/target-policy.ts"; const config = { name: Argument.String("name").pipe( @@ -109,4 +110,5 @@ export const computeNewCommand = Command.make("new", config).pipe( Command.provide(computeNewRuntimeLayer), Command.provide(cliConfigValuesLayer), withCliConfigFlags(config), + withTargetPolicy({ group: "local-only", consent: "none" }), ); 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 0ca6246a48..93c9cf8ff7 100644 --- a/apps/cli/src/commands/experimental/compute/push/push.command.ts +++ b/apps/cli/src/commands/experimental/compute/push/push.command.ts @@ -7,6 +7,7 @@ import { COMPUTE_EXPOSURES } from "../../../../shared/compute/compute-runtimes.t import { managementApiRuntimeLayer } from "../../../../command-internal/management-api-runtime.layer.ts"; import { withCommandTelemetry } from "../../../../telemetry/command-telemetry.ts"; import { computePush } from "./push.handler.ts"; +import { withTargetPolicy } from "../../../../command-internal/target/target-policy.ts"; const config = { names: Argument.String("name").pipe( @@ -88,4 +89,5 @@ export const computePushCommand = Command.make("push", config).pipe( Command.provide(managementApiRuntimeLayer(["compute", "push"])), Command.provide(cliConfigValuesLayer), withCliConfigFlags(config), + withTargetPolicy({ group: "hosted", consent: "no" }), ); 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 4af2c4b9cd..1970a6767d 100644 --- a/apps/cli/src/commands/experimental/compute/status/status.command.ts +++ b/apps/cli/src/commands/experimental/compute/status/status.command.ts @@ -6,6 +6,7 @@ import { withJsonErrorHandling } from "../../../../shared/output/json-error-hand import { managementApiRuntimeLayer } from "../../../../command-internal/management-api-runtime.layer.ts"; import { withCommandTelemetry } from "../../../../telemetry/command-telemetry.ts"; import { computeStatus } from "./status.handler.ts"; +import { withTargetPolicy } from "../../../../command-internal/target/target-policy.ts"; const config = { name: Argument.String("name").pipe(Argument.withDescription("Compute to inspect.")), @@ -34,4 +35,5 @@ export const computeStatusCommand = Command.make("status", config).pipe( Command.provide(managementApiRuntimeLayer(["compute", "status"])), Command.provide(cliConfigValuesLayer), withCliConfigFlags(config), + withTargetPolicy({ group: "hosted", consent: "none" }), ); diff --git a/apps/cli/src/commands/experimental/stack/destroy/destroy.command.ts b/apps/cli/src/commands/experimental/stack/destroy/destroy.command.ts index 2fe3d0619e..f66cb98d41 100644 --- a/apps/cli/src/commands/experimental/stack/destroy/destroy.command.ts +++ b/apps/cli/src/commands/experimental/stack/destroy/destroy.command.ts @@ -6,6 +6,7 @@ import { machineErrorContextLayer } from "../../../../shared/output/machine-erro import { stdinLayer } from "../../../../shared/runtime/stdin.layer.ts"; import { withCommandTelemetry } from "../../../../telemetry/command-telemetry.ts"; import { stackDestroy } from "./destroy.handler.ts"; +import { withTargetPolicy } from "../../../../command-internal/target/target-policy.ts"; const config = { stack: Flag.String("stack").pipe( @@ -47,4 +48,5 @@ export const stackDestroyCommand = Command.make("destroy", config).pipe( // the effect's type requirements only. `machineErrorContextLayer` lists the stacks a // partly failed batch did destroy on the JSON/stream-json error envelope. Command.provide(Layer.mergeAll(stdinLayer, machineErrorContextLayer)), + withTargetPolicy({ group: "local-only", consent: "none" }), ); diff --git a/apps/cli/src/commands/experimental/stack/list/list.command.ts b/apps/cli/src/commands/experimental/stack/list/list.command.ts index 826485911a..3ed746ced0 100644 --- a/apps/cli/src/commands/experimental/stack/list/list.command.ts +++ b/apps/cli/src/commands/experimental/stack/list/list.command.ts @@ -2,6 +2,7 @@ import { Command } from "effect/cli"; import { withJsonErrorHandling } from "../../../../shared/output/json-error-handling.ts"; import { withCommandTelemetry } from "../../../../telemetry/command-telemetry.ts"; import { stackList } from "./list.handler.ts"; +import { withTargetPolicy } from "../../../../command-internal/target/target-policy.ts"; export const stackListCommand = Command.make("list").pipe( Command.withDescription("List persisted managed local Supabase stacks."), @@ -9,4 +10,5 @@ export const stackListCommand = Command.make("list").pipe( Command.withHandler(() => stackList().pipe(withCommandTelemetry({ flags: {} }), withJsonErrorHandling), ), + withTargetPolicy({ group: "local-only", consent: "none" }), ); diff --git a/apps/cli/src/commands/experimental/stack/logs/logs.command.ts b/apps/cli/src/commands/experimental/stack/logs/logs.command.ts index 36a267b6db..407d5f15a1 100644 --- a/apps/cli/src/commands/experimental/stack/logs/logs.command.ts +++ b/apps/cli/src/commands/experimental/stack/logs/logs.command.ts @@ -3,6 +3,7 @@ import type * as CliCommand from "effect/cli/Command"; import { withJsonErrorHandling } from "../../../../shared/output/json-error-handling.ts"; import { withCommandTelemetry } from "../../../../telemetry/command-telemetry.ts"; import { stackLogs } from "./logs.handler.ts"; +import { withTargetPolicy } from "../../../../command-internal/target/target-policy.ts"; const config = { stack: Flag.String("stack").pipe( @@ -73,4 +74,5 @@ export const stackLogsCommand = Command.make("logs", config).pipe( withJsonErrorHandling, ), ), + withTargetPolicy({ group: "local-only", consent: "none" }), ); 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 9e3fc54c12..6887c1c229 100644 --- a/apps/cli/src/commands/experimental/stack/prepare/prepare.command.ts +++ b/apps/cli/src/commands/experimental/stack/prepare/prepare.command.ts @@ -6,6 +6,7 @@ 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"; +import { withTargetPolicy } from "../../../../command-internal/target/target-policy.ts"; const config = { stack: Flag.String("stack").pipe(Flag.withDescription("Name this stack."), Flag.optional), @@ -44,4 +45,5 @@ export const stackPrepareCommand = Command.make("prepare", config).pipe( ), Command.provide(cliConfigValuesLayer), withCliConfigFlags(config), + withTargetPolicy({ group: "local-only", consent: "none" }), ); diff --git a/apps/cli/src/commands/experimental/stack/restart/restart.command.ts b/apps/cli/src/commands/experimental/stack/restart/restart.command.ts index 1ca449e6de..989ef26f14 100644 --- a/apps/cli/src/commands/experimental/stack/restart/restart.command.ts +++ b/apps/cli/src/commands/experimental/stack/restart/restart.command.ts @@ -3,6 +3,7 @@ import type * as CliCommand from "effect/cli/Command"; import { withJsonErrorHandling } from "../../../../shared/output/json-error-handling.ts"; import { withCommandTelemetry } from "../../../../telemetry/command-telemetry.ts"; import { stackRestart } from "./restart.handler.ts"; +import { withTargetPolicy } from "../../../../command-internal/target/target-policy.ts"; const config = { stack: Flag.String("stack").pipe( @@ -31,4 +32,5 @@ export const stackRestartCommand = Command.make("restart", config).pipe( Command.withHandler((flags) => stackRestart(flags).pipe(withCommandTelemetry({ flags, config }), withJsonErrorHandling), ), + withTargetPolicy({ group: "local-only", consent: "none" }), ); 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 9b06c82fd1..578f8ee6b3 100644 --- a/apps/cli/src/commands/experimental/stack/start/start.command.ts +++ b/apps/cli/src/commands/experimental/stack/start/start.command.ts @@ -10,6 +10,7 @@ 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"; +import { withTargetPolicy } from "../../../../command-internal/target/target-policy.ts"; const excludeFlag = stringSliceFlag( "exclude", @@ -75,4 +76,5 @@ export const stackStartCommand = Command.make("start", config).pipe( Command.provide(Layer.mergeAll(stdinLayer, machineErrorContextLayer)), Command.provide(cliConfigValuesLayer), withCliConfigFlags(config), + withTargetPolicy({ group: "local-only", consent: "none" }), ); 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 8dafade58e..76a59e8413 100644 --- a/apps/cli/src/commands/experimental/stack/status/status.command.ts +++ b/apps/cli/src/commands/experimental/stack/status/status.command.ts @@ -6,6 +6,7 @@ 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"; +import { withTargetPolicy } from "../../../../command-internal/target/target-policy.ts"; const config = { stack: Flag.String("stack").pipe(Flag.withDescription("Inspect a named stack."), Flag.optional), @@ -47,4 +48,5 @@ export const stackStatusCommand = Command.make("status", config).pipe( ), Command.provide(cliConfigValuesLayer), withCliConfigFlags(config), + withTargetPolicy({ group: "local-only", consent: "none" }), ); diff --git a/apps/cli/src/commands/experimental/stack/stop/stop.command.ts b/apps/cli/src/commands/experimental/stack/stop/stop.command.ts index 38b665cdb0..14610b65be 100644 --- a/apps/cli/src/commands/experimental/stack/stop/stop.command.ts +++ b/apps/cli/src/commands/experimental/stack/stop/stop.command.ts @@ -3,6 +3,7 @@ import type * as CliCommand from "effect/cli/Command"; import { withJsonErrorHandling } from "../../../../shared/output/json-error-handling.ts"; import { withCommandTelemetry } from "../../../../telemetry/command-telemetry.ts"; import { stackStop } from "./stop.handler.ts"; +import { withTargetPolicy } from "../../../../command-internal/target/target-policy.ts"; const config = { all: Flag.Boolean("all").pipe( @@ -33,4 +34,5 @@ export const stackStopCommand = Command.make("stop", config).pipe( Command.withHandler((flags) => stackStop(flags).pipe(withCommandTelemetry({ flags, config }), withJsonErrorHandling), ), + withTargetPolicy({ group: "local-only", consent: "none" }), ); diff --git a/apps/cli/src/commands/feedback/add/add.command.ts b/apps/cli/src/commands/feedback/add/add.command.ts index 3aa6b98f18..96e00159be 100644 --- a/apps/cli/src/commands/feedback/add/add.command.ts +++ b/apps/cli/src/commands/feedback/add/add.command.ts @@ -9,6 +9,7 @@ import { telemetryStateLayer } from "../../../telemetry/telemetry-state.layer.ts import { commandFeedbackClientLayer, feedbackCliConfigLayer } from "../feedback.layers.ts"; import { FEEDBACK_OUTPUT_FORMATS } from "../feedback-output.ts"; import { feedbackAdd } from "./add.handler.ts"; +import { withTargetPolicy } from "../../../command-internal/target/target-policy.ts"; const config = { message: Argument.String("message").pipe( @@ -66,4 +67,9 @@ export const feedbackAddCommand = Command.make("add", config).pipe( // client each get their own cli-config provision (CLI Agent Guide invariant 5). Command.provide(commandFeedbackClientLayer), Command.provide(feedbackCliConfigLayer), + withTargetPolicy({ + group: "account", + consent: "none", + consentExemptReason: "sending the message is the command's whole purpose", + }), ); diff --git a/apps/cli/src/commands/feedback/delete/delete.command.ts b/apps/cli/src/commands/feedback/delete/delete.command.ts index bdf3f48e17..6b62a091aa 100644 --- a/apps/cli/src/commands/feedback/delete/delete.command.ts +++ b/apps/cli/src/commands/feedback/delete/delete.command.ts @@ -8,6 +8,7 @@ import { telemetryStateLayer } from "../../../telemetry/telemetry-state.layer.ts import { commandFeedbackClientLayer, feedbackCliConfigLayer } from "../feedback.layers.ts"; import { FEEDBACK_OUTPUT_FORMATS } from "../feedback-output.ts"; import { feedbackDelete } from "./delete.handler.ts"; +import { withTargetPolicy } from "../../../command-internal/target/target-policy.ts"; const config = { token: Argument.String("token").pipe( @@ -52,4 +53,5 @@ export const feedbackDeleteCommand = Command.make("delete", config).pipe( // client each get their own cli-config provision (CLI Agent Guide invariant 5). Command.provide(commandFeedbackClientLayer), Command.provide(feedbackCliConfigLayer), + withTargetPolicy({ group: "account", consent: "no" }), ); diff --git a/apps/cli/src/commands/functions/delete/delete.command.ts b/apps/cli/src/commands/functions/delete/delete.command.ts index b37b234ae6..4b7e91124f 100644 --- a/apps/cli/src/commands/functions/delete/delete.command.ts +++ b/apps/cli/src/commands/functions/delete/delete.command.ts @@ -5,6 +5,7 @@ import { withJsonErrorHandling } from "../../../shared/output/json-error-handlin import { managementApiRuntimeLayer } from "../../../command-internal/management-api-runtime.layer.ts"; import { withCommandTelemetry } from "../../../telemetry/command-telemetry.ts"; import { functionsDelete } from "./delete.handler.ts"; +import { withTargetPolicy } from "../../../command-internal/target/target-policy.ts"; const config = { functionName: Argument.String("Function name").pipe( @@ -43,4 +44,5 @@ export const functionsDeleteCommand = Command.make("delete", config).pipe( ]), Command.withHandler(functionsDeleteHandler), Command.provide(managementApiRuntimeLayer(["functions", "delete"])), + withTargetPolicy({ group: "hosted", consent: "no" }), ); diff --git a/apps/cli/src/commands/functions/deploy/deploy.command.ts b/apps/cli/src/commands/functions/deploy/deploy.command.ts index 67c6ffd1fb..f31a6177ff 100644 --- a/apps/cli/src/commands/functions/deploy/deploy.command.ts +++ b/apps/cli/src/commands/functions/deploy/deploy.command.ts @@ -9,6 +9,7 @@ import { stdinLayer } from "../../../shared/runtime/stdin.layer.ts"; import { managementApiRuntimeLayer } from "../../../command-internal/management-api-runtime.layer.ts"; import { withCommandTelemetry } from "../../../telemetry/command-telemetry.ts"; import { functionsDeploy } from "./deploy.handler.ts"; +import { withTargetPolicy } from "../../../command-internal/target/target-policy.ts"; const config = { functionNames: Argument.String("Function name").pipe( @@ -82,4 +83,5 @@ export const functionsDeployCommand = Command.make("deploy", config).pipe( Command.provide(Layer.mergeAll(managementApiRuntimeLayer(["functions", "deploy"]), stdinLayer)), Command.provide(cliConfigValuesLayer), withCliConfigFlags(config), + withTargetPolicy({ group: "hosted", consent: "no" }), ); diff --git a/apps/cli/src/commands/functions/download/download.command.ts b/apps/cli/src/commands/functions/download/download.command.ts index 3f5c043f21..b5ea074650 100644 --- a/apps/cli/src/commands/functions/download/download.command.ts +++ b/apps/cli/src/commands/functions/download/download.command.ts @@ -11,6 +11,7 @@ import { removedFlag } from "../../../command-internal/removed-command.ts"; import { withCommandTelemetry } from "../../../telemetry/command-telemetry.ts"; import { telemetryStateLayer } from "../../../telemetry/telemetry-state.layer.ts"; import { functionsDownload } from "./download.handler.ts"; +import { withTargetPolicy } from "../../../command-internal/target/target-policy.ts"; const config = { functionName: Argument.String("Function name").pipe( @@ -89,4 +90,5 @@ export const functionsDownloadCommand = Command.make("download", config).pipe( ), Command.provide(cliConfigValuesLayer), withCliConfigFlags(config), + withTargetPolicy({ group: "hosted", consent: "none" }), ); diff --git a/apps/cli/src/commands/functions/list/list.command.ts b/apps/cli/src/commands/functions/list/list.command.ts index d83c55f542..ea8463ccdd 100644 --- a/apps/cli/src/commands/functions/list/list.command.ts +++ b/apps/cli/src/commands/functions/list/list.command.ts @@ -5,6 +5,7 @@ import { withJsonErrorHandling } from "../../../shared/output/json-error-handlin import { managementApiRuntimeLayer } from "../../../command-internal/management-api-runtime.layer.ts"; import { withCommandTelemetry } from "../../../telemetry/command-telemetry.ts"; import { functionsList } from "./list.handler.ts"; +import { withTargetPolicy } from "../../../command-internal/target/target-policy.ts"; const config = { projectRef: Flag.String("project-ref").pipe( @@ -35,4 +36,5 @@ export const functionsListCommand = Command.make("list", config).pipe( ), ), Command.provide(managementApiRuntimeLayer(["functions", "list"])), + withTargetPolicy({ group: "hosted", consent: "none" }), ); diff --git a/apps/cli/src/commands/functions/new/new.command.ts b/apps/cli/src/commands/functions/new/new.command.ts index 090e68266d..f332e845d3 100644 --- a/apps/cli/src/commands/functions/new/new.command.ts +++ b/apps/cli/src/commands/functions/new/new.command.ts @@ -11,6 +11,7 @@ import { debugLoggerLayer } from "../../../command-internal/debug-logger.layer.t import { telemetryStateLayer } from "../../../telemetry/telemetry-state.layer.ts"; import { withCommandTelemetry } from "../../../telemetry/command-telemetry.ts"; import { functionsNew } from "./new.handler.ts"; +import { withTargetPolicy } from "../../../command-internal/target/target-policy.ts"; const AUTH_MODE_VALUES = ["none", "apikey", "user"] as const; @@ -46,4 +47,5 @@ export const functionsNewCommand = Command.make("new", config).pipe( Command.provide(functionsNewRuntimeLayer), Command.provide(cliConfigValuesLayer), withCliConfigFlags(config), + withTargetPolicy({ group: "local-only", consent: "none" }), ); diff --git a/apps/cli/src/commands/functions/serve/serve.command.ts b/apps/cli/src/commands/functions/serve/serve.command.ts index 2198a9ac94..a6f3b3e240 100644 --- a/apps/cli/src/commands/functions/serve/serve.command.ts +++ b/apps/cli/src/commands/functions/serve/serve.command.ts @@ -13,6 +13,7 @@ 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"; +import { withTargetPolicy } from "../../../command-internal/target/target-policy.ts"; const cliSettings = commandSettingsLayer.pipe(Layer.provide(debugLoggerLayer)); const functionsServeRuntimeLayer = Layer.mergeAll( @@ -76,4 +77,5 @@ export const functionsServeCommand = Command.make("serve", commandConfig).pipe( Command.provide(functionsServeRuntimeLayer), Command.provide(cliConfigValuesLayer), withCliConfigFlags(commandConfig), + withTargetPolicy({ group: "local-only", consent: "none" }), ); 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 34001edba3..a1cc71fde1 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 @@ -12,6 +12,7 @@ import { withCommandTelemetry } from "../../../telemetry/command-telemetry.ts"; import { telemetryStateLayer } from "../../../telemetry/telemetry-state.layer.ts"; import { genBearerJwt } from "./bearer-jwt.handler.ts"; import { parseBearerJwtExp, parseBearerJwtValidFor } from "./bearer-jwt.flags.ts"; +import { withTargetPolicy } from "../../../command-internal/target/target-policy.ts"; const config = { // `--role` stays optional at parse time so a missing value is enforced in @@ -65,4 +66,5 @@ export const genBearerJwtCommand = Command.make("bearer-jwt", config).pipe( Command.provide(genBearerJwtRuntimeLayer), Command.provide(cliConfigValuesLayer), withCliConfigFlags(config), + withTargetPolicy({ group: "local-only", consent: "none" }), ); diff --git a/apps/cli/src/commands/gen/keys/keys.command.ts b/apps/cli/src/commands/gen/keys/keys.command.ts index 5447a5a73f..805864d544 100644 --- a/apps/cli/src/commands/gen/keys/keys.command.ts +++ b/apps/cli/src/commands/gen/keys/keys.command.ts @@ -4,6 +4,7 @@ import { withJsonErrorHandling } from "../../../shared/output/json-error-handlin import { commandRuntimeLayer } from "../../../shared/runtime/command-runtime.layer.ts"; import { withCommandTelemetry } from "../../../telemetry/command-telemetry.ts"; import { telemetryStateLayer } from "../../../telemetry/telemetry-state.layer.ts"; +import { withTargetPolicy } from "../../../command-internal/target/target-policy.ts"; const config = { projectRef: Flag.String("project-ref").pipe( @@ -26,4 +27,5 @@ export const genKeysCommand = Command.make("keys", config).pipe( ), Command.provide(commandRuntimeLayer(["gen", "keys"])), Command.provide(telemetryStateLayer), + withTargetPolicy({ group: "hosted", consent: "none" }), ); 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 4edbf0e377..19912efc94 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 @@ -11,6 +11,7 @@ import { debugLoggerLayer } from "../../../command-internal/debug-logger.layer.t import { withCommandTelemetry } from "../../../telemetry/command-telemetry.ts"; import { telemetryStateLayer } from "../../../telemetry/telemetry-state.layer.ts"; import { genSigningKey } from "./signing-key.handler.ts"; +import { withTargetPolicy } from "../../../command-internal/target/target-policy.ts"; const ALGORITHM_VALUES = ["ES256", "RS256"] as const; @@ -67,4 +68,5 @@ export const genSigningKeyCommand = Command.make("signing-key", config).pipe( Command.provide(genSigningKeyRuntimeLayer), Command.provide(cliConfigValuesLayer), withCliConfigFlags(config), + withTargetPolicy({ group: "local-only", consent: "none" }), ); diff --git a/apps/cli/src/commands/gen/types/types.command.ts b/apps/cli/src/commands/gen/types/types.command.ts index ad4f0b2eb8..b4b66c6ed6 100644 --- a/apps/cli/src/commands/gen/types/types.command.ts +++ b/apps/cli/src/commands/gen/types/types.command.ts @@ -13,6 +13,7 @@ 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"; +import { withTargetPolicy } from "../../../command-internal/target/target-policy.ts"; const config = { local: Flag.Boolean("local").pipe( @@ -120,4 +121,5 @@ export const genTypesCommand = Command.make("types", commandConfig).pipe( Command.provide(genTypesRuntimeLayer), Command.provide(cliConfigValuesLayer), withCliConfigFlags(commandConfig), + withTargetPolicy({ group: "selectable", consent: "none" }), ); diff --git a/apps/cli/src/commands/init/init.command.ts b/apps/cli/src/commands/init/init.command.ts index 0d85adb6ec..0befd49c9b 100644 --- a/apps/cli/src/commands/init/init.command.ts +++ b/apps/cli/src/commands/init/init.command.ts @@ -7,6 +7,7 @@ import { commandRuntimeLayer } from "../../shared/runtime/command-runtime.layer. import { stdinLayer } from "../../shared/runtime/stdin.layer.ts"; import { withCommandTelemetry } from "../../telemetry/command-telemetry.ts"; import { init } from "./init.handler.ts"; +import { withTargetPolicy } from "../../command-internal/target/target-policy.ts"; const config = { interactive: Flag.Boolean("interactive").pipe( @@ -55,4 +56,5 @@ export const initCommand = Command.make("init", config).pipe( Command.provide( Layer.mergeAll(commandRuntimeLayer(["init"]), stdinLayer, cliConfigProviderLayer), ), + withTargetPolicy({ group: "local-only", consent: "none" }), ); 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 d4654ef10a..f866792975 100644 --- a/apps/cli/src/commands/inspect/db/bloat/bloat.command.ts +++ b/apps/cli/src/commands/inspect/db/bloat/bloat.command.ts @@ -5,6 +5,7 @@ import { INSPECT_DB_FLAGS, INSPECT_DB_PERMISSIONS, inspectDbCommandHandler, + withInspectDbTargetPolicy, } from "../inspect-db-command.ts"; import { inspectDbRuntimeLayer } from "../db.layers.ts"; import { cliConfigValuesLayer } from "../../../../config/cli-config-values.layer.ts"; @@ -18,4 +19,5 @@ export const inspectDbBloatCommand = Command.make("bloat", INSPECT_DB_FLAGS).pip Command.provide(inspectDbRuntimeLayer("bloat")), Command.provide(cliConfigValuesLayer), withCliConfigFlags(INSPECT_DB_FLAGS), + withInspectDbTargetPolicy, ); 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 88ab264c32..1baf62e901 100644 --- a/apps/cli/src/commands/inspect/db/blocking/blocking.command.ts +++ b/apps/cli/src/commands/inspect/db/blocking/blocking.command.ts @@ -5,6 +5,7 @@ import { INSPECT_DB_FLAGS, INSPECT_DB_PERMISSIONS, inspectDbCommandHandler, + withInspectDbTargetPolicy, } from "../inspect-db-command.ts"; import { inspectDbRuntimeLayer } from "../db.layers.ts"; import { cliConfigValuesLayer } from "../../../../config/cli-config-values.layer.ts"; @@ -20,4 +21,5 @@ export const inspectDbBlockingCommand = Command.make("blocking", INSPECT_DB_FLAG Command.provide(inspectDbRuntimeLayer("blocking")), Command.provide(cliConfigValuesLayer), withCliConfigFlags(INSPECT_DB_FLAGS), + withInspectDbTargetPolicy, ); 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 3acae05f79..03404a48dd 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 @@ -5,6 +5,7 @@ import { INSPECT_DB_FLAGS, INSPECT_DB_PERMISSIONS, inspectDbCommandHandler, + withInspectDbTargetPolicy, } from "../inspect-db-command.ts"; import { inspectDbRuntimeLayer } from "../db.layers.ts"; import { cliConfigValuesLayer } from "../../../../config/cli-config-values.layer.ts"; @@ -20,4 +21,5 @@ export const inspectDbCacheHitCommand = Command.make("cache-hit", INSPECT_DB_FLA Command.provide(inspectDbRuntimeLayer("cache-hit")), Command.provide(cliConfigValuesLayer), withCliConfigFlags(INSPECT_DB_FLAGS), + withInspectDbTargetPolicy, ); 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 4de81e2e93..546fd85673 100644 --- a/apps/cli/src/commands/inspect/db/calls/calls.command.ts +++ b/apps/cli/src/commands/inspect/db/calls/calls.command.ts @@ -5,6 +5,7 @@ import { INSPECT_DB_FLAGS, INSPECT_DB_PERMISSIONS, inspectDbCommandHandler, + withInspectDbTargetPolicy, } from "../inspect-db-command.ts"; import { inspectDbRuntimeLayer } from "../db.layers.ts"; import { cliConfigValuesLayer } from "../../../../config/cli-config-values.layer.ts"; @@ -18,4 +19,5 @@ export const inspectDbCallsCommand = Command.make("calls", INSPECT_DB_FLAGS).pip Command.provide(inspectDbRuntimeLayer("calls")), Command.provide(cliConfigValuesLayer), withCliConfigFlags(INSPECT_DB_FLAGS), + withInspectDbTargetPolicy, ); 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 f5d6451edc..fc87ab2614 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 @@ -5,6 +5,7 @@ import { INSPECT_DB_FLAGS, INSPECT_DB_PERMISSIONS, inspectDbCommandHandler, + withInspectDbTargetPolicy, } from "../inspect-db-command.ts"; import { inspectDbRuntimeLayer } from "../db.layers.ts"; import { cliConfigValuesLayer } from "../../../../config/cli-config-values.layer.ts"; @@ -18,4 +19,5 @@ export const inspectDbDbStatsCommand = Command.make("db-stats", INSPECT_DB_FLAGS Command.provide(inspectDbRuntimeLayer("db-stats")), Command.provide(cliConfigValuesLayer), withCliConfigFlags(INSPECT_DB_FLAGS), + withInspectDbTargetPolicy, ); 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 4c212eae17..b1cf72223b 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 @@ -5,6 +5,7 @@ import { INSPECT_DB_FLAGS, INSPECT_DB_PERMISSIONS, inspectDbCommandHandler, + withInspectDbTargetPolicy, } from "../inspect-db-command.ts"; import { inspectDbRuntimeLayer } from "../db.layers.ts"; import { cliConfigValuesLayer } from "../../../../config/cli-config-values.layer.ts"; @@ -20,4 +21,5 @@ export const inspectDbIndexSizesCommand = Command.make("index-sizes", INSPECT_DB Command.provide(inspectDbRuntimeLayer("index-sizes")), Command.provide(cliConfigValuesLayer), withCliConfigFlags(INSPECT_DB_FLAGS), + withInspectDbTargetPolicy, ); 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 952cea8bac..c81d0235e5 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 @@ -5,6 +5,7 @@ import { INSPECT_DB_FLAGS, INSPECT_DB_PERMISSIONS, inspectDbCommandHandler, + withInspectDbTargetPolicy, } from "../inspect-db-command.ts"; import { inspectDbRuntimeLayer } from "../db.layers.ts"; import { cliConfigValuesLayer } from "../../../../config/cli-config-values.layer.ts"; @@ -20,4 +21,5 @@ export const inspectDbIndexStatsCommand = Command.make("index-stats", INSPECT_DB Command.provide(inspectDbRuntimeLayer("index-stats")), Command.provide(cliConfigValuesLayer), withCliConfigFlags(INSPECT_DB_FLAGS), + withInspectDbTargetPolicy, ); 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 e0c683be81..b584230c0a 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 @@ -5,6 +5,7 @@ import { INSPECT_DB_FLAGS, INSPECT_DB_PERMISSIONS, inspectDbCommandHandler, + withInspectDbTargetPolicy, } from "../inspect-db-command.ts"; import { inspectDbRuntimeLayer } from "../db.layers.ts"; import { cliConfigValuesLayer } from "../../../../config/cli-config-values.layer.ts"; @@ -20,4 +21,5 @@ export const inspectDbIndexUsageCommand = Command.make("index-usage", INSPECT_DB Command.provide(inspectDbRuntimeLayer("index-usage")), Command.provide(cliConfigValuesLayer), withCliConfigFlags(INSPECT_DB_FLAGS), + withInspectDbTargetPolicy, ); diff --git a/apps/cli/src/commands/inspect/db/inspect-db-command.ts b/apps/cli/src/commands/inspect/db/inspect-db-command.ts index d3f50334ff..0f85b240e7 100644 --- a/apps/cli/src/commands/inspect/db/inspect-db-command.ts +++ b/apps/cli/src/commands/inspect/db/inspect-db-command.ts @@ -3,6 +3,7 @@ import { Flag } from "effect/cli"; import { withDbTarget } from "../../../command-internal/command-permissions/model.ts"; import { dbConfig } from "../../../command-internal/db-config.layer.ts"; +import { withTargetPolicy } from "../../../command-internal/target/target-policy.ts"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; import { withCommandTelemetry } from "../../../telemetry/command-telemetry.ts"; import type { InspectConnectionFlags } from "./inspect-query.ts"; @@ -35,6 +36,8 @@ export const INSPECT_DB_FLAGS = { /** The Management API calls every `inspect db` subcommand makes, all through the database-config resolver. */ export const INSPECT_DB_PERMISSIONS = withDbTarget(dbConfig, { default: "linked" }); +/** Target policy shared by every `inspect db` command. */ +export const withInspectDbTargetPolicy = withTargetPolicy({ group: "selectable", consent: "none" }); /** * Wraps an `inspect db` handler with the standard command-level pipeline: telemetry 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 264c403403..8237010bfa 100644 --- a/apps/cli/src/commands/inspect/db/locks/locks.command.ts +++ b/apps/cli/src/commands/inspect/db/locks/locks.command.ts @@ -5,6 +5,7 @@ import { INSPECT_DB_FLAGS, INSPECT_DB_PERMISSIONS, inspectDbCommandHandler, + withInspectDbTargetPolicy, } from "../inspect-db-command.ts"; import { inspectDbRuntimeLayer } from "../db.layers.ts"; import { cliConfigValuesLayer } from "../../../../config/cli-config-values.layer.ts"; @@ -18,4 +19,5 @@ export const inspectDbLocksCommand = Command.make("locks", INSPECT_DB_FLAGS).pip Command.provide(inspectDbRuntimeLayer("locks")), Command.provide(cliConfigValuesLayer), withCliConfigFlags(INSPECT_DB_FLAGS), + withInspectDbTargetPolicy, ); 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 03a740f719..52a6a69682 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 @@ -5,6 +5,7 @@ import { INSPECT_DB_FLAGS, INSPECT_DB_PERMISSIONS, inspectDbCommandHandler, + withInspectDbTargetPolicy, } from "../inspect-db-command.ts"; import { inspectDbRuntimeLayer } from "../db.layers.ts"; import { cliConfigValuesLayer } from "../../../../config/cli-config-values.layer.ts"; @@ -21,4 +22,5 @@ export const inspectDbLongRunningQueriesCommand = Command.make( Command.provide(inspectDbRuntimeLayer("long-running-queries")), Command.provide(cliConfigValuesLayer), withCliConfigFlags(INSPECT_DB_FLAGS), + withInspectDbTargetPolicy, ); 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 be422ba44f..e7ab222640 100644 --- a/apps/cli/src/commands/inspect/db/outliers/outliers.command.ts +++ b/apps/cli/src/commands/inspect/db/outliers/outliers.command.ts @@ -5,6 +5,7 @@ import { INSPECT_DB_FLAGS, INSPECT_DB_PERMISSIONS, inspectDbCommandHandler, + withInspectDbTargetPolicy, } from "../inspect-db-command.ts"; import { inspectDbRuntimeLayer } from "../db.layers.ts"; import { cliConfigValuesLayer } from "../../../../config/cli-config-values.layer.ts"; @@ -18,4 +19,5 @@ export const inspectDbOutliersCommand = Command.make("outliers", INSPECT_DB_FLAG Command.provide(inspectDbRuntimeLayer("outliers")), Command.provide(cliConfigValuesLayer), withCliConfigFlags(INSPECT_DB_FLAGS), + withInspectDbTargetPolicy, ); 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 bd25bcbe04..5479f8cdcf 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 @@ -5,6 +5,7 @@ import { INSPECT_DB_FLAGS, INSPECT_DB_PERMISSIONS, inspectDbCommandHandler, + withInspectDbTargetPolicy, } from "../inspect-db-command.ts"; import { inspectDbRuntimeLayer } from "../db.layers.ts"; import { cliConfigValuesLayer } from "../../../../config/cli-config-values.layer.ts"; @@ -21,4 +22,5 @@ export const inspectDbReplicationSlotsCommand = Command.make( Command.provide(inspectDbRuntimeLayer("replication-slots")), Command.provide(cliConfigValuesLayer), withCliConfigFlags(INSPECT_DB_FLAGS), + withInspectDbTargetPolicy, ); 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 4d52c41f32..057274f77e 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 @@ -5,6 +5,7 @@ import { INSPECT_DB_FLAGS, INSPECT_DB_PERMISSIONS, inspectDbCommandHandler, + withInspectDbTargetPolicy, } from "../inspect-db-command.ts"; import { inspectDbRuntimeLayer } from "../db.layers.ts"; import { cliConfigValuesLayer } from "../../../../config/cli-config-values.layer.ts"; @@ -20,4 +21,5 @@ export const inspectDbRoleConfigsCommand = Command.make("role-configs", INSPECT_ Command.provide(inspectDbRuntimeLayer("role-configs")), Command.provide(cliConfigValuesLayer), withCliConfigFlags(INSPECT_DB_FLAGS), + withInspectDbTargetPolicy, ); 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 a41cde73de..686089f083 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 @@ -5,6 +5,7 @@ import { INSPECT_DB_FLAGS, INSPECT_DB_PERMISSIONS, inspectDbCommandHandler, + withInspectDbTargetPolicy, } from "../inspect-db-command.ts"; import { inspectDbRuntimeLayer } from "../db.layers.ts"; import { cliConfigValuesLayer } from "../../../../config/cli-config-values.layer.ts"; @@ -23,4 +24,5 @@ export const inspectDbRoleConnectionsCommand = Command.make( Command.provide(inspectDbRuntimeLayer("role-connections")), Command.provide(cliConfigValuesLayer), withCliConfigFlags(INSPECT_DB_FLAGS), + withInspectDbTargetPolicy, ); 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 1b1fb3307c..c3b5e3dd7e 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 @@ -5,6 +5,7 @@ import { INSPECT_DB_FLAGS, INSPECT_DB_PERMISSIONS, inspectDbCommandHandler, + withInspectDbTargetPolicy, } from "../inspect-db-command.ts"; import { inspectDbRuntimeLayer } from "../db.layers.ts"; import { cliConfigValuesLayer } from "../../../../config/cli-config-values.layer.ts"; @@ -18,4 +19,5 @@ export const inspectDbRoleStatsCommand = Command.make("role-stats", INSPECT_DB_F Command.provide(inspectDbRuntimeLayer("role-stats")), Command.provide(cliConfigValuesLayer), withCliConfigFlags(INSPECT_DB_FLAGS), + withInspectDbTargetPolicy, ); 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 f8a8d667da..599ad3b392 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 @@ -5,6 +5,7 @@ import { INSPECT_DB_FLAGS, INSPECT_DB_PERMISSIONS, inspectDbCommandHandler, + withInspectDbTargetPolicy, } from "../inspect-db-command.ts"; import { inspectDbRuntimeLayer } from "../db.layers.ts"; import { cliConfigValuesLayer } from "../../../../config/cli-config-values.layer.ts"; @@ -20,4 +21,5 @@ export const inspectDbSeqScansCommand = Command.make("seq-scans", INSPECT_DB_FLA Command.provide(inspectDbRuntimeLayer("seq-scans")), Command.provide(cliConfigValuesLayer), withCliConfigFlags(INSPECT_DB_FLAGS), + withInspectDbTargetPolicy, ); 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 dc13590ec2..22152b47d7 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 @@ -5,7 +5,7 @@ import { withJsonErrorHandling } from "../../../../shared/output/json-error-hand import { commandRuntimeLayer } from "../../../../shared/runtime/command-runtime.layer.ts"; import { withCommandTelemetry } from "../../../../telemetry/command-telemetry.ts"; import { telemetryStateLayer } from "../../../../telemetry/telemetry-state.layer.ts"; -import { INSPECT_DB_FLAGS } from "../inspect-db-command.ts"; +import { INSPECT_DB_FLAGS, withInspectDbTargetPolicy } from "../inspect-db-command.ts"; import { inspectDbCommandPath } from "../db.layers.ts"; export const inspectDbTableIndexSizesCommand = Command.make( @@ -27,4 +27,5 @@ export const inspectDbTableIndexSizesCommand = Command.make( ), Command.provide(commandRuntimeLayer(inspectDbCommandPath("table-index-sizes"))), Command.provide(telemetryStateLayer), + withInspectDbTargetPolicy, ); 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 0c5fbd70b1..bc9cfd673c 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 @@ -5,6 +5,7 @@ import { INSPECT_DB_FLAGS, INSPECT_DB_PERMISSIONS, inspectDbCommandHandler, + withInspectDbTargetPolicy, } from "../inspect-db-command.ts"; import { inspectDbRuntimeLayer } from "../db.layers.ts"; import { cliConfigValuesLayer } from "../../../../config/cli-config-values.layer.ts"; @@ -23,4 +24,5 @@ export const inspectDbTableRecordCountsCommand = Command.make( Command.provide(inspectDbRuntimeLayer("table-record-counts")), Command.provide(cliConfigValuesLayer), withCliConfigFlags(INSPECT_DB_FLAGS), + withInspectDbTargetPolicy, ); 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 90986cfab1..7ea8782416 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 @@ -5,6 +5,7 @@ import { INSPECT_DB_FLAGS, INSPECT_DB_PERMISSIONS, inspectDbCommandHandler, + withInspectDbTargetPolicy, } from "../inspect-db-command.ts"; import { inspectDbRuntimeLayer } from "../db.layers.ts"; import { cliConfigValuesLayer } from "../../../../config/cli-config-values.layer.ts"; @@ -20,4 +21,5 @@ export const inspectDbTableSizesCommand = Command.make("table-sizes", INSPECT_DB Command.provide(inspectDbRuntimeLayer("table-sizes")), Command.provide(cliConfigValuesLayer), withCliConfigFlags(INSPECT_DB_FLAGS), + withInspectDbTargetPolicy, ); 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 f5f6d7bbc6..33639f2f68 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 @@ -5,6 +5,7 @@ import { INSPECT_DB_FLAGS, INSPECT_DB_PERMISSIONS, inspectDbCommandHandler, + withInspectDbTargetPolicy, } from "../inspect-db-command.ts"; import { inspectDbRuntimeLayer } from "../db.layers.ts"; import { cliConfigValuesLayer } from "../../../../config/cli-config-values.layer.ts"; @@ -18,4 +19,5 @@ export const inspectDbTableStatsCommand = Command.make("table-stats", INSPECT_DB Command.provide(inspectDbRuntimeLayer("table-stats")), Command.provide(cliConfigValuesLayer), withCliConfigFlags(INSPECT_DB_FLAGS), + withInspectDbTargetPolicy, ); 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 48a0822074..bccc10f235 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 @@ -5,7 +5,7 @@ import { withJsonErrorHandling } from "../../../../shared/output/json-error-hand import { commandRuntimeLayer } from "../../../../shared/runtime/command-runtime.layer.ts"; import { withCommandTelemetry } from "../../../../telemetry/command-telemetry.ts"; import { telemetryStateLayer } from "../../../../telemetry/telemetry-state.layer.ts"; -import { INSPECT_DB_FLAGS } from "../inspect-db-command.ts"; +import { INSPECT_DB_FLAGS, withInspectDbTargetPolicy } from "../inspect-db-command.ts"; import { inspectDbCommandPath } from "../db.layers.ts"; export const inspectDbTotalIndexSizeCommand = Command.make( @@ -27,4 +27,5 @@ export const inspectDbTotalIndexSizeCommand = Command.make( ), Command.provide(commandRuntimeLayer(inspectDbCommandPath("total-index-size"))), Command.provide(telemetryStateLayer), + withInspectDbTargetPolicy, ); 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 9c985e19fd..f8b3d16e7f 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 @@ -5,7 +5,7 @@ import { withJsonErrorHandling } from "../../../../shared/output/json-error-hand import { commandRuntimeLayer } from "../../../../shared/runtime/command-runtime.layer.ts"; import { withCommandTelemetry } from "../../../../telemetry/command-telemetry.ts"; import { telemetryStateLayer } from "../../../../telemetry/telemetry-state.layer.ts"; -import { INSPECT_DB_FLAGS } from "../inspect-db-command.ts"; +import { INSPECT_DB_FLAGS, withInspectDbTargetPolicy } from "../inspect-db-command.ts"; import { inspectDbCommandPath } from "../db.layers.ts"; export const inspectDbTotalTableSizesCommand = Command.make( @@ -27,4 +27,5 @@ export const inspectDbTotalTableSizesCommand = Command.make( ), Command.provide(commandRuntimeLayer(inspectDbCommandPath("total-table-sizes"))), Command.provide(telemetryStateLayer), + withInspectDbTargetPolicy, ); 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 69b83cf562..7684ae71c5 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 @@ -5,6 +5,7 @@ import { INSPECT_DB_FLAGS, INSPECT_DB_PERMISSIONS, inspectDbCommandHandler, + withInspectDbTargetPolicy, } from "../inspect-db-command.ts"; import { inspectDbRuntimeLayer } from "../db.layers.ts"; import { cliConfigValuesLayer } from "../../../../config/cli-config-values.layer.ts"; @@ -23,4 +24,5 @@ export const inspectDbTrafficProfileCommand = Command.make( Command.provide(inspectDbRuntimeLayer("traffic-profile")), Command.provide(cliConfigValuesLayer), withCliConfigFlags(INSPECT_DB_FLAGS), + withInspectDbTargetPolicy, ); 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 5f1532d157..4557296b46 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 @@ -5,6 +5,7 @@ import { INSPECT_DB_FLAGS, INSPECT_DB_PERMISSIONS, inspectDbCommandHandler, + withInspectDbTargetPolicy, } from "../inspect-db-command.ts"; import { inspectDbRuntimeLayer } from "../db.layers.ts"; import { cliConfigValuesLayer } from "../../../../config/cli-config-values.layer.ts"; @@ -18,4 +19,5 @@ export const inspectDbUnusedIndexesCommand = Command.make("unused-indexes", INSP Command.provide(inspectDbRuntimeLayer("unused-indexes")), Command.provide(cliConfigValuesLayer), withCliConfigFlags(INSPECT_DB_FLAGS), + withInspectDbTargetPolicy, ); 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 a4709dcad0..3966ea33bc 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 @@ -5,6 +5,7 @@ import { INSPECT_DB_FLAGS, INSPECT_DB_PERMISSIONS, inspectDbCommandHandler, + withInspectDbTargetPolicy, } from "../inspect-db-command.ts"; import { inspectDbRuntimeLayer } from "../db.layers.ts"; import { cliConfigValuesLayer } from "../../../../config/cli-config-values.layer.ts"; @@ -18,4 +19,5 @@ export const inspectDbVacuumStatsCommand = Command.make("vacuum-stats", INSPECT_ Command.provide(inspectDbRuntimeLayer("vacuum-stats")), Command.provide(cliConfigValuesLayer), withCliConfigFlags(INSPECT_DB_FLAGS), + withInspectDbTargetPolicy, ); diff --git a/apps/cli/src/commands/inspect/report/report.command.ts b/apps/cli/src/commands/inspect/report/report.command.ts index ead991a6d1..709f50b25a 100644 --- a/apps/cli/src/commands/inspect/report/report.command.ts +++ b/apps/cli/src/commands/inspect/report/report.command.ts @@ -12,6 +12,7 @@ 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"; +import { withTargetPolicy } from "../../../command-internal/target/target-policy.ts"; const config = { dbUrl: Flag.String("db-url").pipe( @@ -67,4 +68,5 @@ export const inspectReportCommand = Command.make("report", config).pipe( Command.provide(Layer.merge(inspectBaseLayer, commandRuntimeLayer(["inspect", "report"]))), Command.provide(cliConfigValuesLayer), withCliConfigFlags(config), + withTargetPolicy({ group: "selectable", consent: "none" }), ); diff --git a/apps/cli/src/commands/issue/issue.command.ts b/apps/cli/src/commands/issue/issue.command.ts index a54308a324..7d5337804e 100644 --- a/apps/cli/src/commands/issue/issue.command.ts +++ b/apps/cli/src/commands/issue/issue.command.ts @@ -1,5 +1,6 @@ import { Command, Flag } from "effect/cli"; import type * as CliCommand from "effect/cli/Command"; +import { withTargetPolicy } from "../../command-internal/target/target-policy.ts"; import { browserLayer } from "../../shared/runtime/browser.layer.ts"; import { commandRuntimeLayer } from "../../shared/runtime/command-runtime.layer.ts"; import { withJsonErrorHandling } from "../../shared/output/json-error-handling.ts"; @@ -82,6 +83,7 @@ const issueBugCommand = Command.make("bug", issueBugConfig).pipe( ), Command.provide(commandRuntimeLayer(["issue", "bug"])), Command.provide(browserLayer), + withTargetPolicy({ group: "local-only", consent: "none" }), ); const issueFeatureCommand = Command.make("feature", issueFeatureConfig).pipe( @@ -99,6 +101,7 @@ const issueFeatureCommand = Command.make("feature", issueFeatureConfig).pipe( ), Command.provide(commandRuntimeLayer(["issue", "feature"])), Command.provide(browserLayer), + withTargetPolicy({ group: "local-only", consent: "none" }), ); const issueDocsCommand = Command.make("docs", issueDocsConfig).pipe( @@ -116,6 +119,7 @@ const issueDocsCommand = Command.make("docs", issueDocsConfig).pipe( ), Command.provide(commandRuntimeLayer(["issue", "docs"])), Command.provide(browserLayer), + withTargetPolicy({ group: "local-only", consent: "none" }), ); export const issueCommand = Command.make("issue").pipe( diff --git a/apps/cli/src/commands/link/link.command.ts b/apps/cli/src/commands/link/link.command.ts index 48b07aecd9..0058e210e2 100644 --- a/apps/cli/src/commands/link/link.command.ts +++ b/apps/cli/src/commands/link/link.command.ts @@ -11,6 +11,7 @@ import { withJsonErrorHandling } from "../../shared/output/json-error-handling.t import { managementApiRuntimeLayer } from "../../command-internal/management-api-runtime.layer.ts"; import { withCommandTelemetry } from "../../telemetry/command-telemetry.ts"; import { link } from "./link.handler.ts"; +import { withTargetPolicy } from "../../command-internal/target/target-policy.ts"; const config = { refOrBranch: Argument.String("ref-or-branch").pipe( @@ -78,4 +79,5 @@ export const linkCommand = Command.make("link", config).pipe( // Outermost, so it runs before the management API runtime resolves the access token. Command.provideEffectDiscard((flags) => warnPasswordIgnored(flags)), withCliConfigFlags(config), + withTargetPolicy({ group: "hosted", consent: "none" }), ); diff --git a/apps/cli/src/commands/login/login.command.ts b/apps/cli/src/commands/login/login.command.ts index 40d1403f8b..f350b34e09 100644 --- a/apps/cli/src/commands/login/login.command.ts +++ b/apps/cli/src/commands/login/login.command.ts @@ -5,6 +5,7 @@ import { withCommandTelemetry } from "../../telemetry/command-telemetry.ts"; import { withJsonErrorHandling } from "../../shared/output/json-error-handling.ts"; import { loginRuntimeLayer } from "./login.layers.ts"; import { login } from "./login.handler.ts"; +import { withTargetPolicy } from "../../command-internal/target/target-policy.ts"; const config = { token: Flag.String("token").pipe( @@ -30,4 +31,9 @@ export const loginCommand = Command.make("login", config).pipe( login(flags).pipe(withCommandTelemetry({ flags }), withJsonErrorHandling), ), Command.provide(loginRuntimeLayer), + withTargetPolicy({ + group: "account", + consent: "none", + consentExemptReason: "mints the caller's own access token", + }), ); diff --git a/apps/cli/src/commands/logout/logout.command.ts b/apps/cli/src/commands/logout/logout.command.ts index 970cdfd28a..7b4b197ba7 100644 --- a/apps/cli/src/commands/logout/logout.command.ts +++ b/apps/cli/src/commands/logout/logout.command.ts @@ -4,10 +4,12 @@ import { withJsonErrorHandling } from "../../shared/output/json-error-handling.t import { withCommandTelemetry } from "../../telemetry/command-telemetry.ts"; import { logoutRuntimeLayer } from "./logout.layers.ts"; import { logout } from "./logout.handler.ts"; +import { withTargetPolicy } from "../../command-internal/target/target-policy.ts"; export const logoutCommand = Command.make("logout").pipe( Command.withDescription("Log out and delete access tokens locally."), Command.withShortDescription("Log out and delete access tokens locally"), Command.withHandler(() => logout().pipe(withCommandTelemetry(), withJsonErrorHandling)), Command.provide(logoutRuntimeLayer), + withTargetPolicy({ group: "account", consent: "none" }), ); diff --git a/apps/cli/src/commands/migration/down/down.command.ts b/apps/cli/src/commands/migration/down/down.command.ts index cbd258ee9c..98493f716c 100644 --- a/apps/cli/src/commands/migration/down/down.command.ts +++ b/apps/cli/src/commands/migration/down/down.command.ts @@ -15,6 +15,7 @@ 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"; +import { withTargetPolicy } from "../../../command-internal/target/target-policy.ts"; const config = { // `--last` is conceptually a uint (default 1); Effect has no uint type, so negatives are @@ -85,4 +86,5 @@ export const migrationDownCommand = Command.make("down", config).pipe( Command.provide(migrationDbRuntimeLayer(["migration", "down"])), Command.provide(cliConfigValuesLayer), withCliConfigFlags(config), + withTargetPolicy({ group: "selectable", consent: "no" }), ); diff --git a/apps/cli/src/commands/migration/fetch/fetch.command.ts b/apps/cli/src/commands/migration/fetch/fetch.command.ts index 68e50a7dd3..a664130b7a 100644 --- a/apps/cli/src/commands/migration/fetch/fetch.command.ts +++ b/apps/cli/src/commands/migration/fetch/fetch.command.ts @@ -15,6 +15,7 @@ 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"; +import { withTargetPolicy } from "../../../command-internal/target/target-policy.ts"; const config = { dbUrl: Flag.String("db-url").pipe( @@ -70,4 +71,5 @@ export const migrationFetchCommand = Command.make("fetch", config).pipe( Command.provide(migrationDbRuntimeLayer(["migration", "fetch"])), Command.provide(cliConfigValuesLayer), withCliConfigFlags(config), + withTargetPolicy({ group: "sync", consent: "none" }), ); diff --git a/apps/cli/src/commands/migration/list/list.command.ts b/apps/cli/src/commands/migration/list/list.command.ts index db60757b30..a40567616f 100644 --- a/apps/cli/src/commands/migration/list/list.command.ts +++ b/apps/cli/src/commands/migration/list/list.command.ts @@ -16,6 +16,7 @@ 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"; +import { withTargetPolicy } from "../../../command-internal/target/target-policy.ts"; const config = { dbUrl: Flag.String("db-url").pipe( @@ -80,4 +81,5 @@ export const migrationListCommand = Command.make("list", config).pipe( Command.provide(migrationDbRuntimeLayer(["migration", "list"])), Command.provide(cliConfigValuesLayer), withCliConfigFlags(config), + withTargetPolicy({ group: "selectable", consent: "none" }), ); diff --git a/apps/cli/src/commands/migration/new/new.command.ts b/apps/cli/src/commands/migration/new/new.command.ts index fca7e49391..394ea3ff11 100644 --- a/apps/cli/src/commands/migration/new/new.command.ts +++ b/apps/cli/src/commands/migration/new/new.command.ts @@ -6,6 +6,7 @@ import { withJsonErrorHandling } from "../../../shared/output/json-error-handlin import { withCommandTelemetry } from "../../../telemetry/command-telemetry.ts"; import { migrationNewRuntimeLayer } from "../migration.layers.ts"; import { migrationNew } from "./new.handler.ts"; +import { withTargetPolicy } from "../../../command-internal/target/target-policy.ts"; const config = { migrationName: Argument.String("migration name").pipe( @@ -23,4 +24,5 @@ export const migrationNewCommand = Command.make("new", config).pipe( migrationNew(flags).pipe(withCommandTelemetry(), withJsonErrorHandling), ), Command.provide(migrationNewRuntimeLayer), + withTargetPolicy({ group: "local-only", consent: "none" }), ); diff --git a/apps/cli/src/commands/migration/repair/repair.command.ts b/apps/cli/src/commands/migration/repair/repair.command.ts index 3d46a5cb40..ce8126e576 100644 --- a/apps/cli/src/commands/migration/repair/repair.command.ts +++ b/apps/cli/src/commands/migration/repair/repair.command.ts @@ -15,6 +15,7 @@ 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"; +import { withTargetPolicy } from "../../../command-internal/target/target-policy.ts"; const config = { versions: Argument.String("version").pipe( @@ -95,4 +96,5 @@ export const migrationRepairCommand = Command.make("repair", config).pipe( Command.provide(migrationDbRuntimeLayer(["migration", "repair"])), Command.provide(cliConfigValuesLayer), withCliConfigFlags(config), + withTargetPolicy({ group: "selectable", consent: "no" }), ); diff --git a/apps/cli/src/commands/migration/squash/squash.command.ts b/apps/cli/src/commands/migration/squash/squash.command.ts index 9491a7cea2..898642e0c0 100644 --- a/apps/cli/src/commands/migration/squash/squash.command.ts +++ b/apps/cli/src/commands/migration/squash/squash.command.ts @@ -16,6 +16,7 @@ import { withJsonErrorHandling } from "../../../shared/output/json-error-handlin import { withCommandTelemetry } from "../../../telemetry/command-telemetry.ts"; import { migrationSquashRuntimeLayer } from "../migration.layers.ts"; import { migrationSquash } from "./squash.handler.ts"; +import { withTargetPolicy } from "../../../command-internal/target/target-policy.ts"; const config = { version: Flag.String("version").pipe( @@ -87,4 +88,5 @@ export const migrationSquashCommand = Command.make("squash", config).pipe( Command.provide(migrationSquashRuntimeLayer), Command.provide(cliConfigValuesLayer), withCliConfigFlags(config), + withTargetPolicy({ group: "selectable", consent: "no" }), ); diff --git a/apps/cli/src/commands/migration/up/up.command.ts b/apps/cli/src/commands/migration/up/up.command.ts index 9a2750740c..711bad31b7 100644 --- a/apps/cli/src/commands/migration/up/up.command.ts +++ b/apps/cli/src/commands/migration/up/up.command.ts @@ -15,6 +15,7 @@ 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"; +import { withTargetPolicy } from "../../../command-internal/target/target-policy.ts"; const config = { includeAll: Flag.Boolean("include-all").pipe( @@ -75,4 +76,5 @@ export const migrationUpCommand = Command.make("up", config).pipe( Command.provide(migrationDbRuntimeLayer(["migration", "up"])), Command.provide(cliConfigValuesLayer), withCliConfigFlags(config), + withTargetPolicy({ group: "selectable", consent: "no" }), ); diff --git a/apps/cli/src/commands/network-bans/get/get.command.ts b/apps/cli/src/commands/network-bans/get/get.command.ts index f24dbb593d..ef57b0933c 100644 --- a/apps/cli/src/commands/network-bans/get/get.command.ts +++ b/apps/cli/src/commands/network-bans/get/get.command.ts @@ -11,6 +11,7 @@ import { withCommandTelemetry, } from "../../../telemetry/command-telemetry.ts"; import { networkBansGet } from "./get.handler.ts"; +import { withTargetPolicy } from "../../../command-internal/target/target-policy.ts"; const config = { projectRef: Flag.String("project-ref").pipe( @@ -39,4 +40,5 @@ export const networkBansGetCommand = Command.make("get", config).pipe( ); }).pipe(withJsonErrorHandling), ), + withTargetPolicy({ group: "hosted", consent: "none" }), ); diff --git a/apps/cli/src/commands/network-bans/remove/remove.command.ts b/apps/cli/src/commands/network-bans/remove/remove.command.ts index 306774e405..e8b0d59fca 100644 --- a/apps/cli/src/commands/network-bans/remove/remove.command.ts +++ b/apps/cli/src/commands/network-bans/remove/remove.command.ts @@ -12,6 +12,7 @@ import { withCommandTelemetry, } from "../../../telemetry/command-telemetry.ts"; import { networkBansRemove } from "./remove.handler.ts"; +import { withTargetPolicy } from "../../../command-internal/target/target-policy.ts"; /** * CSV-splits each occurrence (`--db-unban-ip=1.2.3.4,5.6.7.8` → two IPs) and appends across @@ -51,4 +52,5 @@ export const networkBansRemoveCommand = Command.make("remove", config).pipe( ); }).pipe(withJsonErrorHandling), ), + withTargetPolicy({ group: "hosted", consent: "no" }), ); diff --git a/apps/cli/src/commands/network-restrictions/get/get.command.ts b/apps/cli/src/commands/network-restrictions/get/get.command.ts index 630909cd1a..46594ed5db 100644 --- a/apps/cli/src/commands/network-restrictions/get/get.command.ts +++ b/apps/cli/src/commands/network-restrictions/get/get.command.ts @@ -11,6 +11,7 @@ import { withCommandTelemetry, } from "../../../telemetry/command-telemetry.ts"; import { networkRestrictionsGet } from "./get.handler.ts"; +import { withTargetPolicy } from "../../../command-internal/target/target-policy.ts"; const config = { projectRef: Flag.String("project-ref").pipe( @@ -39,4 +40,5 @@ export const networkRestrictionsGetCommand = Command.make("get", config).pipe( ); }).pipe(withJsonErrorHandling), ), + withTargetPolicy({ group: "hosted", consent: "none" }), ); diff --git a/apps/cli/src/commands/network-restrictions/update/update.command.ts b/apps/cli/src/commands/network-restrictions/update/update.command.ts index 2b99a2a6da..64a691e643 100644 --- a/apps/cli/src/commands/network-restrictions/update/update.command.ts +++ b/apps/cli/src/commands/network-restrictions/update/update.command.ts @@ -12,6 +12,7 @@ import { withCommandTelemetry, } from "../../../telemetry/command-telemetry.ts"; import { networkRestrictionsUpdate } from "./update.handler.ts"; +import { withTargetPolicy } from "../../../command-internal/target/target-policy.ts"; /** * CSV-splits each occurrence (`--db-allow-cidr=1.2.3.0/24,5.6.7.0/24` → two CIDRs) and @@ -60,4 +61,5 @@ export const networkRestrictionsUpdateCommand = Command.make("update", config).p ); }).pipe(withJsonErrorHandling), ), + withTargetPolicy({ group: "hosted", consent: "no" }), ); diff --git a/apps/cli/src/commands/notebooks/pull/pull.command.ts b/apps/cli/src/commands/notebooks/pull/pull.command.ts index 5cac62d7de..ab4b4e4980 100644 --- a/apps/cli/src/commands/notebooks/pull/pull.command.ts +++ b/apps/cli/src/commands/notebooks/pull/pull.command.ts @@ -5,6 +5,7 @@ import { managementApiRuntimeLayer } from "../../../command-internal/management- import { withCommandTelemetry } from "../../../telemetry/command-telemetry.ts"; import { notebooksProjectRefSafeFlags } from "../notebooks.shared.ts"; import { notebooksPull } from "./pull.handler.ts"; +import { withTargetPolicy } from "../../../command-internal/target/target-policy.ts"; const config = { notebookId: Argument.String("Notebook id").pipe( @@ -46,4 +47,5 @@ export const notebooksPullCommand = Command.make("pull", config).pipe( ]), Command.withHandler(notebooksPullHandler), Command.provide(managementApiRuntimeLayer(["notebooks", "pull"])), + withTargetPolicy({ group: "hosted", consent: "none" }), ); diff --git a/apps/cli/src/commands/notebooks/push/push.command.ts b/apps/cli/src/commands/notebooks/push/push.command.ts index 983d3f48cd..85921a41fe 100644 --- a/apps/cli/src/commands/notebooks/push/push.command.ts +++ b/apps/cli/src/commands/notebooks/push/push.command.ts @@ -5,6 +5,7 @@ import { managementApiRuntimeLayer } from "../../../command-internal/management- import { withCommandTelemetry } from "../../../telemetry/command-telemetry.ts"; import { notebooksProjectRefSafeFlags } from "../notebooks.shared.ts"; import { notebooksPush } from "./push.handler.ts"; +import { withTargetPolicy } from "../../../command-internal/target/target-policy.ts"; const config = { notebookName: Argument.String("Notebook name").pipe( @@ -44,4 +45,5 @@ export const notebooksPushCommand = Command.make("push", config).pipe( ]), Command.withHandler(notebooksPushHandler), Command.provide(managementApiRuntimeLayer(["notebooks", "push"])), + withTargetPolicy({ group: "hosted", consent: "no" }), ); diff --git a/apps/cli/src/commands/orgs/create/create.command.ts b/apps/cli/src/commands/orgs/create/create.command.ts index 683f8f0e75..a1a805cb39 100644 --- a/apps/cli/src/commands/orgs/create/create.command.ts +++ b/apps/cli/src/commands/orgs/create/create.command.ts @@ -5,6 +5,7 @@ import { withJsonErrorHandling } from "../../../shared/output/json-error-handlin import { managementApiRuntimeLayer } from "../../../command-internal/management-api-runtime.layer.ts"; import { withCommandTelemetry } from "../../../telemetry/command-telemetry.ts"; import { orgsCreate } from "./create.handler.ts"; +import { withTargetPolicy } from "../../../command-internal/target/target-policy.ts"; const config = { name: Argument.String("name").pipe( @@ -20,4 +21,5 @@ export const orgsCreateCommand = Command.make("create", config).pipe( orgsCreate(flags).pipe(withCommandTelemetry({ flags }), withJsonErrorHandling), ), Command.provide(managementApiRuntimeLayer(["orgs", "create"])), + withTargetPolicy({ group: "account", consent: "yes" }), ); diff --git a/apps/cli/src/commands/orgs/list/list.command.ts b/apps/cli/src/commands/orgs/list/list.command.ts index 8c8cf207f8..11da7f904c 100644 --- a/apps/cli/src/commands/orgs/list/list.command.ts +++ b/apps/cli/src/commands/orgs/list/list.command.ts @@ -5,6 +5,7 @@ import { withJsonErrorHandling } from "../../../shared/output/json-error-handlin import { managementApiRuntimeLayer } from "../../../command-internal/management-api-runtime.layer.ts"; import { withCommandTelemetry } from "../../../telemetry/command-telemetry.ts"; import { orgsList } from "./list.handler.ts"; +import { withTargetPolicy } from "../../../command-internal/target/target-policy.ts"; const config = {}; export type OrgsListFlags = CliCommand.Command.Config.Infer; @@ -16,4 +17,5 @@ export const orgsListCommand = Command.make("list", config).pipe( orgsList(flags).pipe(withCommandTelemetry({ flags }), withJsonErrorHandling), ), Command.provide(managementApiRuntimeLayer(["orgs", "list"])), + withTargetPolicy({ group: "account", consent: "none" }), ); diff --git a/apps/cli/src/commands/postgres-config/delete/delete.command.ts b/apps/cli/src/commands/postgres-config/delete/delete.command.ts index 54ed9cafe6..eb641cc054 100644 --- a/apps/cli/src/commands/postgres-config/delete/delete.command.ts +++ b/apps/cli/src/commands/postgres-config/delete/delete.command.ts @@ -12,6 +12,7 @@ import { withCommandTelemetry, } from "../../../telemetry/command-telemetry.ts"; import { postgresConfigDelete } from "./delete.handler.ts"; +import { withTargetPolicy } from "../../../command-internal/target/target-policy.ts"; /** * CSV-splits each occurrence into config keys, failing at parse time with a diagnostic @@ -54,4 +55,5 @@ export const postgresConfigDeleteCommand = Command.make("delete", config).pipe( ); }).pipe(withJsonErrorHandling), ), + withTargetPolicy({ group: "hosted", consent: "no" }), ); diff --git a/apps/cli/src/commands/postgres-config/get/get.command.ts b/apps/cli/src/commands/postgres-config/get/get.command.ts index 8209076dd9..c927f87d5a 100644 --- a/apps/cli/src/commands/postgres-config/get/get.command.ts +++ b/apps/cli/src/commands/postgres-config/get/get.command.ts @@ -11,6 +11,7 @@ import { withCommandTelemetry, } from "../../../telemetry/command-telemetry.ts"; import { postgresConfigGet } from "./get.handler.ts"; +import { withTargetPolicy } from "../../../command-internal/target/target-policy.ts"; const config = { projectRef: Flag.String("project-ref").pipe( @@ -39,4 +40,5 @@ export const postgresConfigGetCommand = Command.make("get", config).pipe( ); }).pipe(withJsonErrorHandling), ), + withTargetPolicy({ group: "hosted", consent: "none" }), ); diff --git a/apps/cli/src/commands/postgres-config/update/update.command.ts b/apps/cli/src/commands/postgres-config/update/update.command.ts index 9d3b3979eb..fd8bacca8e 100644 --- a/apps/cli/src/commands/postgres-config/update/update.command.ts +++ b/apps/cli/src/commands/postgres-config/update/update.command.ts @@ -12,6 +12,7 @@ import { withCommandTelemetry, } from "../../../telemetry/command-telemetry.ts"; import { postgresConfigUpdate } from "./update.handler.ts"; +import { withTargetPolicy } from "../../../command-internal/target/target-policy.ts"; /** * CSV-splits each occurrence into config overrides, failing at parse time with a @@ -60,4 +61,5 @@ export const postgresConfigUpdateCommand = Command.make("update", config).pipe( ); }).pipe(withJsonErrorHandling), ), + withTargetPolicy({ group: "hosted", consent: "no" }), ); diff --git a/apps/cli/src/commands/projects/api-keys/api-keys.command.ts b/apps/cli/src/commands/projects/api-keys/api-keys.command.ts index 30d1244354..98fd8e952f 100644 --- a/apps/cli/src/commands/projects/api-keys/api-keys.command.ts +++ b/apps/cli/src/commands/projects/api-keys/api-keys.command.ts @@ -4,6 +4,7 @@ import { withJsonErrorHandling } from "../../../shared/output/json-error-handlin import { managementApiRuntimeLayer } from "../../../command-internal/management-api-runtime.layer.ts"; import { withCommandTelemetry } from "../../../telemetry/command-telemetry.ts"; import { projectsApiKeys } from "./api-keys.handler.ts"; +import { withTargetPolicy } from "../../../command-internal/target/target-policy.ts"; const config = { projectRef: Flag.String("project-ref").pipe( @@ -40,4 +41,5 @@ export const projectsApiKeysCommand = Command.make("api-keys", config).pipe( ), ), Command.provide(managementApiRuntimeLayer(["projects", "api-keys"])), + withTargetPolicy({ group: "hosted", consent: "none" }), ); diff --git a/apps/cli/src/commands/projects/create/create.command.ts b/apps/cli/src/commands/projects/create/create.command.ts index 7ca2da77c0..35e428a7e3 100644 --- a/apps/cli/src/commands/projects/create/create.command.ts +++ b/apps/cli/src/commands/projects/create/create.command.ts @@ -5,6 +5,7 @@ import { withJsonErrorHandling } from "../../../shared/output/json-error-handlin import { managementApiRuntimeLayer } from "../../../command-internal/management-api-runtime.layer.ts"; import { withCommandTelemetry } from "../../../telemetry/command-telemetry.ts"; import { projectsCreate } from "./create.handler.ts"; +import { withTargetPolicy } from "../../../command-internal/target/target-policy.ts"; const AWS_REGIONS = [ "ap-east-1", @@ -124,4 +125,5 @@ export const projectsCreateCommand = Command.make("create", config).pipe( ), ), Command.provide(managementApiRuntimeLayer(["projects", "create"])), + withTargetPolicy({ group: "account", consent: "yes" }), ); diff --git a/apps/cli/src/commands/projects/delete/delete.command.ts b/apps/cli/src/commands/projects/delete/delete.command.ts index 0159ce28b2..ce01606c01 100644 --- a/apps/cli/src/commands/projects/delete/delete.command.ts +++ b/apps/cli/src/commands/projects/delete/delete.command.ts @@ -6,6 +6,7 @@ import { stdinLayer } from "../../../shared/runtime/stdin.layer.ts"; import { managementApiRuntimeLayer } from "../../../command-internal/management-api-runtime.layer.ts"; import { withCommandTelemetry } from "../../../telemetry/command-telemetry.ts"; import { projectsDelete } from "./delete.handler.ts"; +import { withTargetPolicy } from "../../../command-internal/target/target-policy.ts"; const config = { ref: Argument.String("ref").pipe( @@ -33,4 +34,5 @@ export const projectsDeleteCommand = Command.make("delete", config).pipe( // `stdinLayer`: the delete confirmation reads piped stdin via `promptYesNo` // on a non-TTY stdin. Command.provide(Layer.mergeAll(managementApiRuntimeLayer(["projects", "delete"]), stdinLayer)), + withTargetPolicy({ group: "account", consent: "no" }), ); diff --git a/apps/cli/src/commands/projects/list/list.command.ts b/apps/cli/src/commands/projects/list/list.command.ts index c8395b618c..a04b0f6e0d 100644 --- a/apps/cli/src/commands/projects/list/list.command.ts +++ b/apps/cli/src/commands/projects/list/list.command.ts @@ -5,6 +5,7 @@ import { withJsonErrorHandling } from "../../../shared/output/json-error-handlin import { managementApiRuntimeLayer } from "../../../command-internal/management-api-runtime.layer.ts"; import { withCommandTelemetry } from "../../../telemetry/command-telemetry.ts"; import { projectsList } from "./list.handler.ts"; +import { withTargetPolicy } from "../../../command-internal/target/target-policy.ts"; const config = {}; export type ProjectsListFlags = CliCommand.Command.Config.Infer; @@ -26,4 +27,5 @@ export const projectsListCommand = Command.make("list", config).pipe( projectsList(flags).pipe(withCommandTelemetry({ flags, safeFlags: [] }), withJsonErrorHandling), ), Command.provide(managementApiRuntimeLayer(["projects", "list"])), + withTargetPolicy({ group: "account", consent: "none" }), ); diff --git a/apps/cli/src/commands/pull/pull.command.ts b/apps/cli/src/commands/pull/pull.command.ts index 04d7703b5f..272d0955d3 100644 --- a/apps/cli/src/commands/pull/pull.command.ts +++ b/apps/cli/src/commands/pull/pull.command.ts @@ -10,6 +10,7 @@ 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"; +import { withTargetPolicy } from "../../command-internal/target/target-policy.ts"; const config = { // Accepts either a project ref or a branch name of the linked project — no separate `--target`. @@ -88,4 +89,5 @@ export const pullCommand = Command.make("pull", config).pipe( Command.provide(pullRuntimeLayer), Command.provide(cliConfigValuesLayer), withCliConfigFlags(config), + withTargetPolicy({ group: "hosted", consent: "no" }), ); diff --git a/apps/cli/src/commands/secrets/list/list.command.ts b/apps/cli/src/commands/secrets/list/list.command.ts index 26224a044c..0b33e261ec 100644 --- a/apps/cli/src/commands/secrets/list/list.command.ts +++ b/apps/cli/src/commands/secrets/list/list.command.ts @@ -9,6 +9,7 @@ import { withJsonErrorHandling } from "../../../shared/output/json-error-handlin import { managementApiRuntimeLayer } from "../../../command-internal/management-api-runtime.layer.ts"; import { withCommandTelemetry } from "../../../telemetry/command-telemetry.ts"; import { secretsList } from "./list.handler.ts"; +import { withTargetPolicy } from "../../../command-internal/target/target-policy.ts"; const config = { projectRef: Flag.String("project-ref").pipe( @@ -43,4 +44,5 @@ export const secretsListCommand = Command.make("list", config).pipe( secretsList(flags).pipe(withCommandTelemetry({ flags }), withJsonErrorHandling), ), Command.provide(managementApiRuntimeLayer(["secrets", "list"])), + withTargetPolicy({ group: "hosted", consent: "none" }), ); diff --git a/apps/cli/src/commands/secrets/set/set.command.ts b/apps/cli/src/commands/secrets/set/set.command.ts index fca3bd1391..fb1043a709 100644 --- a/apps/cli/src/commands/secrets/set/set.command.ts +++ b/apps/cli/src/commands/secrets/set/set.command.ts @@ -7,6 +7,7 @@ import { withJsonErrorHandling } from "../../../shared/output/json-error-handlin import { managementApiRuntimeLayer } from "../../../command-internal/management-api-runtime.layer.ts"; import { withCommandTelemetry } from "../../../telemetry/command-telemetry.ts"; import { secretsSet } from "./set.handler.ts"; +import { withTargetPolicy } from "../../../command-internal/target/target-policy.ts"; const config = { projectRef: Flag.String("project-ref").pipe( @@ -44,4 +45,5 @@ export const secretsSetCommand = Command.make("set", config).pipe( Command.provide(managementApiRuntimeLayer(["secrets", "set"])), Command.provide(cliConfigValuesLayer), withCliConfigFlags(config), + withTargetPolicy({ group: "hosted", consent: "no" }), ); diff --git a/apps/cli/src/commands/secrets/unset/unset.command.ts b/apps/cli/src/commands/secrets/unset/unset.command.ts index 0c7bc8513a..6f527ffa9a 100644 --- a/apps/cli/src/commands/secrets/unset/unset.command.ts +++ b/apps/cli/src/commands/secrets/unset/unset.command.ts @@ -7,6 +7,7 @@ import { stdinLayer } from "../../../shared/runtime/stdin.layer.ts"; import { managementApiRuntimeLayer } from "../../../command-internal/management-api-runtime.layer.ts"; import { withCommandTelemetry } from "../../../telemetry/command-telemetry.ts"; import { secretsUnset } from "./unset.handler.ts"; +import { withTargetPolicy } from "../../../command-internal/target/target-policy.ts"; const config = { projectRef: Flag.String("project-ref").pipe( @@ -57,4 +58,5 @@ export const secretsUnsetCommand = Command.make("unset", config).pipe( ), // `promptYesNo` requires `Stdin` even though the handler fails before its non-TTY branch. Command.provide(Layer.mergeAll(managementApiRuntimeLayer(["secrets", "unset"]), stdinLayer)), + withTargetPolicy({ group: "hosted", consent: "no" }), ); diff --git a/apps/cli/src/commands/seed/buckets/buckets.command.ts b/apps/cli/src/commands/seed/buckets/buckets.command.ts index 7ca68bc592..27c63eaa9e 100644 --- a/apps/cli/src/commands/seed/buckets/buckets.command.ts +++ b/apps/cli/src/commands/seed/buckets/buckets.command.ts @@ -19,6 +19,7 @@ import { assertSeedTargetsExclusive } from "./buckets.flags.ts"; import { stdinLayer } from "../../../shared/runtime/stdin.layer.ts"; import { storageGatewayRuntimeLayer } from "../../../command-internal/storage-runtime.layer.ts"; import { seedBuckets } from "./buckets.handler.ts"; +import { withTargetPolicy } from "../../../command-internal/target/target-policy.ts"; const config = { // TS-only flag; see push.command.ts (db push) for the same pattern. @@ -76,4 +77,5 @@ export const bucketsCommand = Command.make("buckets", config).pipe( Command.provide(Layer.mergeAll(storageGatewayRuntimeLayer(["seed", "buckets"]), stdinLayer)), Command.provide(cliConfigValuesLayer), withCliConfigFlags(config), + withTargetPolicy({ group: "selectable", consent: "no" }), ); diff --git a/apps/cli/src/commands/services/services.command.ts b/apps/cli/src/commands/services/services.command.ts index a66e3bdd3e..f98156cc9b 100644 --- a/apps/cli/src/commands/services/services.command.ts +++ b/apps/cli/src/commands/services/services.command.ts @@ -6,6 +6,7 @@ import { withCommandTelemetry } from "../../telemetry/command-telemetry.ts"; import type * as CliCommand from "effect/cli/Command"; import { services } from "./services.handler.ts"; import { servicesRuntimeLayer } from "./services.layers.ts"; +import { withTargetPolicy } from "../../command-internal/target/target-policy.ts"; const config = {}; export type ServicesFlags = CliCommand.Command.Config.Infer; @@ -19,4 +20,5 @@ export const servicesCommand = Command.make("services", config).pipe( Command.provide(servicesRuntimeLayer), Command.provide(cliConfigValuesLayer), withCliConfigFlags(config), + withTargetPolicy({ group: "local-only", consent: "none" }), ); diff --git a/apps/cli/src/commands/snippets/download/download.command.ts b/apps/cli/src/commands/snippets/download/download.command.ts index 16872c7f74..0b8ae83996 100644 --- a/apps/cli/src/commands/snippets/download/download.command.ts +++ b/apps/cli/src/commands/snippets/download/download.command.ts @@ -5,6 +5,7 @@ import { withJsonErrorHandling } from "../../../shared/output/json-error-handlin import { managementApiRuntimeLayer } from "../../../command-internal/management-api-runtime.layer.ts"; import { withCommandTelemetry } from "../../../telemetry/command-telemetry.ts"; import { snippetsDownload } from "./download.handler.ts"; +import { withTargetPolicy } from "../../../command-internal/target/target-policy.ts"; const config = { snippetId: Argument.String("snippet-id").pipe( @@ -35,4 +36,5 @@ export const snippetsDownloadCommand = Command.make("download", config).pipe( ), ), Command.provide(managementApiRuntimeLayer(["snippets", "download"])), + withTargetPolicy({ group: "hosted", consent: "none" }), ); diff --git a/apps/cli/src/commands/snippets/list/list.command.ts b/apps/cli/src/commands/snippets/list/list.command.ts index f19f376d28..9de66e6973 100644 --- a/apps/cli/src/commands/snippets/list/list.command.ts +++ b/apps/cli/src/commands/snippets/list/list.command.ts @@ -9,6 +9,7 @@ import { withJsonErrorHandling } from "../../../shared/output/json-error-handlin import { managementApiRuntimeLayer } from "../../../command-internal/management-api-runtime.layer.ts"; import { withCommandTelemetry } from "../../../telemetry/command-telemetry.ts"; import { snippetsList } from "./list.handler.ts"; +import { withTargetPolicy } from "../../../command-internal/target/target-policy.ts"; const config = { projectRef: Flag.String("project-ref").pipe( @@ -48,4 +49,5 @@ export const snippetsListCommand = Command.make("list", config).pipe( ), ), Command.provide(managementApiRuntimeLayer(["snippets", "list"])), + withTargetPolicy({ group: "hosted", consent: "none" }), ); diff --git a/apps/cli/src/commands/ssl-enforcement/get/get.command.ts b/apps/cli/src/commands/ssl-enforcement/get/get.command.ts index a7784e1416..7508a99b2f 100644 --- a/apps/cli/src/commands/ssl-enforcement/get/get.command.ts +++ b/apps/cli/src/commands/ssl-enforcement/get/get.command.ts @@ -11,6 +11,7 @@ import { withCommandTelemetry, } from "../../../telemetry/command-telemetry.ts"; import { sslEnforcementGet } from "./get.handler.ts"; +import { withTargetPolicy } from "../../../command-internal/target/target-policy.ts"; const config = { projectRef: Flag.String("project-ref").pipe( @@ -39,4 +40,5 @@ export const sslEnforcementGetCommand = Command.make("get", config).pipe( ); }).pipe(withJsonErrorHandling), ), + withTargetPolicy({ group: "hosted", consent: "none" }), ); diff --git a/apps/cli/src/commands/ssl-enforcement/update/update.command.ts b/apps/cli/src/commands/ssl-enforcement/update/update.command.ts index 9894ae976f..57f74b18e2 100644 --- a/apps/cli/src/commands/ssl-enforcement/update/update.command.ts +++ b/apps/cli/src/commands/ssl-enforcement/update/update.command.ts @@ -11,6 +11,7 @@ import { withCommandTelemetry, } from "../../../telemetry/command-telemetry.ts"; import { sslEnforcementUpdate } from "./update.handler.ts"; +import { withTargetPolicy } from "../../../command-internal/target/target-policy.ts"; const config = { projectRef: Flag.String("project-ref").pipe( @@ -51,4 +52,5 @@ export const sslEnforcementUpdateCommand = Command.make("update", config).pipe( ); }).pipe(withJsonErrorHandling), ), + withTargetPolicy({ group: "hosted", consent: "no" }), ); diff --git a/apps/cli/src/commands/sso/add/add.command.ts b/apps/cli/src/commands/sso/add/add.command.ts index 8d63451471..449a25da76 100644 --- a/apps/cli/src/commands/sso/add/add.command.ts +++ b/apps/cli/src/commands/sso/add/add.command.ts @@ -7,6 +7,7 @@ import { stringSliceFlag } from "../../../command-internal/string-slice-flag.ts" import { withCommandTelemetry } from "../../../telemetry/command-telemetry.ts"; import { SSO_NAME_ID_FORMATS } from "../sso.saml.ts"; import { ssoAdd } from "./add.handler.ts"; +import { withTargetPolicy } from "../../../command-internal/target/target-policy.ts"; // `--domains` is a CSV string-slice flag; malformed CSV reports a diagnostic (see `stringSliceFlag`). export const ssoAddDomainsFlag = stringSliceFlag( @@ -83,4 +84,5 @@ export const ssoAddCommand = Command.make("add", config).pipe( ), ), Command.provide(managementApiRuntimeLayer(["sso", "add"])), + withTargetPolicy({ group: "hosted", consent: "no" }), ); diff --git a/apps/cli/src/commands/sso/info/info.command.ts b/apps/cli/src/commands/sso/info/info.command.ts index f7ef7b1d3c..dbdfc4357a 100644 --- a/apps/cli/src/commands/sso/info/info.command.ts +++ b/apps/cli/src/commands/sso/info/info.command.ts @@ -5,6 +5,7 @@ import { withJsonErrorHandling } from "../../../shared/output/json-error-handlin import { managementApiRuntimeLayer } from "../../../command-internal/management-api-runtime.layer.ts"; import { withCommandTelemetry } from "../../../telemetry/command-telemetry.ts"; import { ssoInfo } from "./info.handler.ts"; +import { withTargetPolicy } from "../../../command-internal/target/target-policy.ts"; const config = { projectRef: Flag.String("project-ref").pipe( @@ -32,4 +33,5 @@ export const ssoInfoCommand = Command.make("info", config).pipe( ), ), Command.provide(managementApiRuntimeLayer(["sso", "info"])), + withTargetPolicy({ group: "hosted", consent: "none" }), ); diff --git a/apps/cli/src/commands/sso/list/list.command.ts b/apps/cli/src/commands/sso/list/list.command.ts index a3cd242bae..f5afa8f868 100644 --- a/apps/cli/src/commands/sso/list/list.command.ts +++ b/apps/cli/src/commands/sso/list/list.command.ts @@ -5,6 +5,7 @@ import { withJsonErrorHandling } from "../../../shared/output/json-error-handlin import { managementApiRuntimeLayer } from "../../../command-internal/management-api-runtime.layer.ts"; import { withCommandTelemetry } from "../../../telemetry/command-telemetry.ts"; import { ssoList } from "./list.handler.ts"; +import { withTargetPolicy } from "../../../command-internal/target/target-policy.ts"; const config = { projectRef: Flag.String("project-ref").pipe( @@ -30,4 +31,5 @@ export const ssoListCommand = Command.make("list", config).pipe( ), ), Command.provide(managementApiRuntimeLayer(["sso", "list"])), + withTargetPolicy({ group: "hosted", consent: "none" }), ); diff --git a/apps/cli/src/commands/sso/remove/remove.command.ts b/apps/cli/src/commands/sso/remove/remove.command.ts index 49d70dfb3e..d26910cc7b 100644 --- a/apps/cli/src/commands/sso/remove/remove.command.ts +++ b/apps/cli/src/commands/sso/remove/remove.command.ts @@ -5,6 +5,7 @@ import { withJsonErrorHandling } from "../../../shared/output/json-error-handlin import { managementApiRuntimeLayer } from "../../../command-internal/management-api-runtime.layer.ts"; import { withCommandTelemetry } from "../../../telemetry/command-telemetry.ts"; import { ssoRemove } from "./remove.handler.ts"; +import { withTargetPolicy } from "../../../command-internal/target/target-policy.ts"; const config = { projectRef: Flag.String("project-ref").pipe( @@ -36,4 +37,5 @@ export const ssoRemoveCommand = Command.make("remove", config).pipe( ), ), Command.provide(managementApiRuntimeLayer(["sso", "remove"])), + withTargetPolicy({ group: "hosted", consent: "no" }), ); diff --git a/apps/cli/src/commands/sso/show/show.command.ts b/apps/cli/src/commands/sso/show/show.command.ts index 678d1a5ecb..8980df336c 100644 --- a/apps/cli/src/commands/sso/show/show.command.ts +++ b/apps/cli/src/commands/sso/show/show.command.ts @@ -5,6 +5,7 @@ import { withJsonErrorHandling } from "../../../shared/output/json-error-handlin import { managementApiRuntimeLayer } from "../../../command-internal/management-api-runtime.layer.ts"; import { withCommandTelemetry } from "../../../telemetry/command-telemetry.ts"; import { ssoShow } from "./show.handler.ts"; +import { withTargetPolicy } from "../../../command-internal/target/target-policy.ts"; const config = { projectRef: Flag.String("project-ref").pipe( @@ -40,4 +41,5 @@ export const ssoShowCommand = Command.make("show", config).pipe( ), ), Command.provide(managementApiRuntimeLayer(["sso", "show"])), + withTargetPolicy({ group: "hosted", consent: "none" }), ); diff --git a/apps/cli/src/commands/sso/update/update.command.ts b/apps/cli/src/commands/sso/update/update.command.ts index f8c7be1b2d..471a6f88be 100644 --- a/apps/cli/src/commands/sso/update/update.command.ts +++ b/apps/cli/src/commands/sso/update/update.command.ts @@ -7,6 +7,7 @@ import { stringSliceFlag } from "../../../command-internal/string-slice-flag.ts" import { withCommandTelemetry } from "../../../telemetry/command-telemetry.ts"; import { SSO_NAME_ID_FORMATS } from "../sso.saml.ts"; import { ssoUpdate } from "./update.handler.ts"; +import { withTargetPolicy } from "../../../command-internal/target/target-policy.ts"; // All three domain flags are CSV string-slice flags; malformed CSV reports // a diagnostic (see `stringSliceFlag`). @@ -90,4 +91,5 @@ export const ssoUpdateCommand = Command.make("update", config).pipe( ), ), Command.provide(managementApiRuntimeLayer(["sso", "update"])), + withTargetPolicy({ group: "hosted", consent: "no" }), ); diff --git a/apps/cli/src/commands/start/start.command.ts b/apps/cli/src/commands/start/start.command.ts index a190df5fcc..af35c64652 100644 --- a/apps/cli/src/commands/start/start.command.ts +++ b/apps/cli/src/commands/start/start.command.ts @@ -21,6 +21,7 @@ import { telemetryStateLayer } from "../../telemetry/telemetry-state.layer.ts"; import { withCommandTelemetry } from "../../telemetry/command-telemetry.ts"; import { START_EXCLUDABLE_KEYS } from "./start.exclude.ts"; import { start } from "./start.handler.ts"; +import { withTargetPolicy } from "../../command-internal/target/target-policy.ts"; /** * A string-slice flag: CSV-splits each occurrence (`--exclude gotrue,realtime` -> two @@ -94,4 +95,5 @@ export const startCommand = Command.make("start", config).pipe( Command.provide(startRuntimeLayer), Command.provide(cliConfigValuesLayer), withCliConfigFlags(config), + withTargetPolicy({ group: "local-only", consent: "none" }), ); diff --git a/apps/cli/src/commands/status/status.command.ts b/apps/cli/src/commands/status/status.command.ts index 47297f575c..e564adfd65 100644 --- a/apps/cli/src/commands/status/status.command.ts +++ b/apps/cli/src/commands/status/status.command.ts @@ -17,6 +17,7 @@ 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"; +import { withTargetPolicy } from "../../command-internal/target/target-policy.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. @@ -95,4 +96,5 @@ export const statusCommand = Command.make("status", config).pipe( Command.provide(statusRuntimeLayer), Command.provide(cliConfigValuesLayer), withCliConfigFlags(config), + withTargetPolicy({ group: "local-only", consent: "none" }), ); diff --git a/apps/cli/src/commands/stop/stop.command.ts b/apps/cli/src/commands/stop/stop.command.ts index b596f4728d..ee3754a17c 100644 --- a/apps/cli/src/commands/stop/stop.command.ts +++ b/apps/cli/src/commands/stop/stop.command.ts @@ -11,6 +11,7 @@ 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"; +import { withTargetPolicy } from "../../command-internal/target/target-policy.ts"; const config = { projectId: Flag.String("project-id").pipe( @@ -58,4 +59,5 @@ export const stopCommand = Command.make("stop", config).pipe( Command.provide(stopRuntimeLayer), Command.provide(cliConfigValuesLayer), withCliConfigFlags(config), + withTargetPolicy({ group: "local-only", consent: "none" }), ); diff --git a/apps/cli/src/commands/storage/cp/cp.command.ts b/apps/cli/src/commands/storage/cp/cp.command.ts index f06cc84eec..80cbdded96 100644 --- a/apps/cli/src/commands/storage/cp/cp.command.ts +++ b/apps/cli/src/commands/storage/cp/cp.command.ts @@ -18,6 +18,7 @@ import { assertStorageTargetsExclusive, } from "../storage.flags.ts"; import { storageCp } from "./cp.handler.ts"; +import { withTargetPolicy } from "../../../command-internal/target/target-policy.ts"; // Effect CLI renders no flag defaults, so the established `(default …)` tokens are written into // the descriptions below (`--content-type`'s actual runtime default is `""` for auto-detect). @@ -112,4 +113,5 @@ export const storageCpCommand = Command.make("cp", config).pipe( Command.provide(storageGatewayRuntimeLayer(["storage", "cp"])), Command.provide(cliConfigValuesLayer), withCliConfigFlags(config), + withTargetPolicy({ group: "selectable", consent: "no" }), ); diff --git a/apps/cli/src/commands/storage/ls/ls.command.ts b/apps/cli/src/commands/storage/ls/ls.command.ts index 3a55a41c4f..4036b677a5 100644 --- a/apps/cli/src/commands/storage/ls/ls.command.ts +++ b/apps/cli/src/commands/storage/ls/ls.command.ts @@ -16,6 +16,7 @@ import { assertStorageTargetsExclusive, } from "../storage.flags.ts"; import { storageLs } from "./ls.handler.ts"; +import { withTargetPolicy } from "../../../command-internal/target/target-policy.ts"; const config = { path: Argument.String("path").pipe( @@ -64,4 +65,5 @@ export const storageLsCommand = Command.make("ls", config).pipe( Command.provide(storageGatewayRuntimeLayer(["storage", "ls"])), Command.provide(cliConfigValuesLayer), withCliConfigFlags(config), + withTargetPolicy({ group: "selectable", consent: "none" }), ); diff --git a/apps/cli/src/commands/storage/mv/mv.command.ts b/apps/cli/src/commands/storage/mv/mv.command.ts index c957574954..20a074edce 100644 --- a/apps/cli/src/commands/storage/mv/mv.command.ts +++ b/apps/cli/src/commands/storage/mv/mv.command.ts @@ -16,6 +16,7 @@ import { assertStorageTargetsExclusive, } from "../storage.flags.ts"; import { storageMv } from "./mv.handler.ts"; +import { withTargetPolicy } from "../../../command-internal/target/target-policy.ts"; const config = { src: Argument.String("src").pipe(Argument.withDescription("Source path to move from.")), @@ -62,4 +63,5 @@ export const storageMvCommand = Command.make("mv", config).pipe( Command.provide(storageGatewayRuntimeLayer(["storage", "mv"])), Command.provide(cliConfigValuesLayer), withCliConfigFlags(config), + withTargetPolicy({ group: "selectable", consent: "no" }), ); diff --git a/apps/cli/src/commands/storage/rm/rm.command.ts b/apps/cli/src/commands/storage/rm/rm.command.ts index c8605a3175..d6b16637a5 100644 --- a/apps/cli/src/commands/storage/rm/rm.command.ts +++ b/apps/cli/src/commands/storage/rm/rm.command.ts @@ -16,6 +16,7 @@ import { assertStorageTargetsExclusive, } from "../storage.flags.ts"; import { storageRm } from "./rm.handler.ts"; +import { withTargetPolicy } from "../../../command-internal/target/target-policy.ts"; const config = { files: Argument.String("file").pipe( @@ -72,4 +73,5 @@ export const storageRmCommand = Command.make("rm", config).pipe( Command.provide(Layer.mergeAll(storageGatewayRuntimeLayer(["storage", "rm"]), stdinLayer)), Command.provide(cliConfigValuesLayer), withCliConfigFlags(config), + withTargetPolicy({ group: "selectable", consent: "no" }), ); diff --git a/apps/cli/src/commands/telemetry/disable/disable.command.ts b/apps/cli/src/commands/telemetry/disable/disable.command.ts index d8795b07f7..fdd0822d18 100644 --- a/apps/cli/src/commands/telemetry/disable/disable.command.ts +++ b/apps/cli/src/commands/telemetry/disable/disable.command.ts @@ -4,6 +4,7 @@ import { withJsonErrorHandling } from "../../../shared/output/json-error-handlin import { commandRuntimeLayer } from "../../../shared/runtime/command-runtime.layer.ts"; import { withCommandTelemetry } from "../../../telemetry/command-telemetry.ts"; import { telemetryDisable } from "./disable.handler.ts"; +import { withTargetPolicy } from "../../../command-internal/target/target-policy.ts"; const config = {}; export type TelemetryDisableFlags = CliCommand.Command.Config.Infer; @@ -18,4 +19,9 @@ export const telemetryDisableCommand = Command.make("disable", config).pipe( telemetryDisable(flags).pipe(withCommandTelemetry({ flags }), withJsonErrorHandling), ), Command.provide(commandRuntimeLayer(["telemetry", "disable"])), + withTargetPolicy({ + group: "local-only", + consent: "none", + consentExemptReason: "changes only the local telemetry preference", + }), ); diff --git a/apps/cli/src/commands/telemetry/enable/enable.command.ts b/apps/cli/src/commands/telemetry/enable/enable.command.ts index dd1eba8da3..af379bf273 100644 --- a/apps/cli/src/commands/telemetry/enable/enable.command.ts +++ b/apps/cli/src/commands/telemetry/enable/enable.command.ts @@ -4,6 +4,7 @@ import { withJsonErrorHandling } from "../../../shared/output/json-error-handlin import { commandRuntimeLayer } from "../../../shared/runtime/command-runtime.layer.ts"; import { withCommandTelemetry } from "../../../telemetry/command-telemetry.ts"; import { telemetryEnable } from "./enable.handler.ts"; +import { withTargetPolicy } from "../../../command-internal/target/target-policy.ts"; const config = {}; export type TelemetryEnableFlags = CliCommand.Command.Config.Infer; @@ -17,4 +18,5 @@ export const telemetryEnableCommand = Command.make("enable", config).pipe( telemetryEnable(flags).pipe(withCommandTelemetry({ flags }), withJsonErrorHandling), ), Command.provide(commandRuntimeLayer(["telemetry", "enable"])), + withTargetPolicy({ group: "local-only", consent: "none" }), ); diff --git a/apps/cli/src/commands/telemetry/status/status.command.ts b/apps/cli/src/commands/telemetry/status/status.command.ts index 20ca21cf00..ff61defd5c 100644 --- a/apps/cli/src/commands/telemetry/status/status.command.ts +++ b/apps/cli/src/commands/telemetry/status/status.command.ts @@ -4,6 +4,7 @@ import { withJsonErrorHandling } from "../../../shared/output/json-error-handlin import { commandRuntimeLayer } from "../../../shared/runtime/command-runtime.layer.ts"; import { withCommandTelemetry } from "../../../telemetry/command-telemetry.ts"; import { telemetryStatus } from "./status.handler.ts"; +import { withTargetPolicy } from "../../../command-internal/target/target-policy.ts"; const config = {}; export type TelemetryStatusFlags = CliCommand.Command.Config.Infer; @@ -15,4 +16,5 @@ export const telemetryStatusCommand = Command.make("status", config).pipe( telemetryStatus(flags).pipe(withCommandTelemetry({ flags }), withJsonErrorHandling), ), Command.provide(commandRuntimeLayer(["telemetry", "status"])), + withTargetPolicy({ group: "local-only", consent: "none" }), ); diff --git a/apps/cli/src/commands/test/db/db.command.ts b/apps/cli/src/commands/test/db/db.command.ts index 4152d96d12..55f358b2b0 100644 --- a/apps/cli/src/commands/test/db/db.command.ts +++ b/apps/cli/src/commands/test/db/db.command.ts @@ -12,6 +12,7 @@ import { 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"; +import { withTargetPolicy } from "../../../command-internal/target/target-policy.ts"; /** * `test db` — the visible entry point. Its hidden alias `db test` reuses the same @@ -25,4 +26,5 @@ export const testDbCommand = Command.make("db", testDbConfig).pipe( Command.provide(testDbRuntimeLayer(["test", "db"])), Command.provide(cliConfigValuesLayer), withCliConfigFlags(testDbConfig), + withTargetPolicy({ group: "selectable", consent: "no" }), ); diff --git a/apps/cli/src/commands/test/new/new.command.ts b/apps/cli/src/commands/test/new/new.command.ts index bf27416af7..922c989c36 100644 --- a/apps/cli/src/commands/test/new/new.command.ts +++ b/apps/cli/src/commands/test/new/new.command.ts @@ -10,6 +10,7 @@ import { commandRuntimeLayer } from "../../../shared/runtime/command-runtime.lay import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; import { withCommandTelemetry } from "../../../telemetry/command-telemetry.ts"; import { testNew } from "./new.handler.ts"; +import { withTargetPolicy } from "../../../command-internal/target/target-policy.ts"; const TEMPLATE_VALUES = ["pgtap"] as const; @@ -52,4 +53,5 @@ export const testNewCommand = Command.make("new", config).pipe( ), ), Command.provide(testNewRuntimeLayer), + withTargetPolicy({ group: "local-only", consent: "none" }), ); diff --git a/apps/cli/src/commands/unlink/unlink.command.ts b/apps/cli/src/commands/unlink/unlink.command.ts index 649ea8371e..03f8cbe00c 100644 --- a/apps/cli/src/commands/unlink/unlink.command.ts +++ b/apps/cli/src/commands/unlink/unlink.command.ts @@ -9,6 +9,7 @@ import { commandRuntimeLayer } from "../../shared/runtime/command-runtime.layer. import { withJsonErrorHandling } from "../../shared/output/json-error-handling.ts"; import { withCommandTelemetry } from "../../telemetry/command-telemetry.ts"; import { unlink } from "./unlink.handler.ts"; +import { withTargetPolicy } from "../../command-internal/target/target-policy.ts"; // `unlink` makes no Management API calls, so it avoids `managementApiRuntimeLayer`, // which eagerly resolves an access token and would fail for a token-less `unlink`. @@ -32,4 +33,5 @@ export const unlinkCommand = Command.make("unlink").pipe( Command.withShortDescription("Unlink a Supabase project"), Command.withHandler(() => unlink().pipe(withCommandTelemetry(), withJsonErrorHandling)), Command.provide(unlinkRuntimeLayer), + withTargetPolicy({ group: "local-only", consent: "none" }), ); diff --git a/apps/cli/src/commands/vanity-subdomains/activate/activate.command.ts b/apps/cli/src/commands/vanity-subdomains/activate/activate.command.ts index fbec9d3ba1..14cc3a23b1 100644 --- a/apps/cli/src/commands/vanity-subdomains/activate/activate.command.ts +++ b/apps/cli/src/commands/vanity-subdomains/activate/activate.command.ts @@ -11,6 +11,7 @@ import { withCommandTelemetry, } from "../../../telemetry/command-telemetry.ts"; import { vanitySubdomainsActivate } from "./activate.handler.ts"; +import { withTargetPolicy } from "../../../command-internal/target/target-policy.ts"; const config = { projectRef: Flag.String("project-ref").pipe( @@ -49,4 +50,5 @@ export const vanitySubdomainsActivateCommand = Command.make("activate", config). ); }).pipe(withJsonErrorHandling), ), + withTargetPolicy({ group: "hosted", consent: "no" }), ); diff --git a/apps/cli/src/commands/vanity-subdomains/check-availability/check-availability.command.ts b/apps/cli/src/commands/vanity-subdomains/check-availability/check-availability.command.ts index c107a041e0..922828e7db 100644 --- a/apps/cli/src/commands/vanity-subdomains/check-availability/check-availability.command.ts +++ b/apps/cli/src/commands/vanity-subdomains/check-availability/check-availability.command.ts @@ -11,6 +11,7 @@ import { withCommandTelemetry, } from "../../../telemetry/command-telemetry.ts"; import { vanitySubdomainsCheckAvailability } from "./check-availability.handler.ts"; +import { withTargetPolicy } from "../../../command-internal/target/target-policy.ts"; const config = { projectRef: Flag.String("project-ref").pipe( @@ -50,4 +51,5 @@ export const vanitySubdomainsCheckAvailabilityCommand = Command.make( ); }).pipe(withJsonErrorHandling), ), + withTargetPolicy({ group: "hosted", consent: "none" }), ); diff --git a/apps/cli/src/commands/vanity-subdomains/delete/delete.command.ts b/apps/cli/src/commands/vanity-subdomains/delete/delete.command.ts index ba1b99fa5b..495449d632 100644 --- a/apps/cli/src/commands/vanity-subdomains/delete/delete.command.ts +++ b/apps/cli/src/commands/vanity-subdomains/delete/delete.command.ts @@ -11,6 +11,7 @@ import { withCommandTelemetry, } from "../../../telemetry/command-telemetry.ts"; import { vanitySubdomainsDelete } from "./delete.handler.ts"; +import { withTargetPolicy } from "../../../command-internal/target/target-policy.ts"; const config = { projectRef: Flag.String("project-ref").pipe( @@ -42,4 +43,5 @@ export const vanitySubdomainsDeleteCommand = Command.make("delete", config).pipe ); }).pipe(withJsonErrorHandling), ), + withTargetPolicy({ group: "hosted", consent: "no" }), ); diff --git a/apps/cli/src/commands/vanity-subdomains/get/get.command.ts b/apps/cli/src/commands/vanity-subdomains/get/get.command.ts index f6bb914a91..5dfbd64980 100644 --- a/apps/cli/src/commands/vanity-subdomains/get/get.command.ts +++ b/apps/cli/src/commands/vanity-subdomains/get/get.command.ts @@ -11,6 +11,7 @@ import { withCommandTelemetry, } from "../../../telemetry/command-telemetry.ts"; import { vanitySubdomainsGet } from "./get.handler.ts"; +import { withTargetPolicy } from "../../../command-internal/target/target-policy.ts"; const config = { projectRef: Flag.String("project-ref").pipe( @@ -40,4 +41,5 @@ export const vanitySubdomainsGetCommand = Command.make("get", config).pipe( ); }).pipe(withJsonErrorHandling), ), + withTargetPolicy({ group: "hosted", consent: "none" }), ); diff --git a/apps/cli/src/commands/whoami/whoami.command.ts b/apps/cli/src/commands/whoami/whoami.command.ts index 3bb8c56210..3f7c2df968 100644 --- a/apps/cli/src/commands/whoami/whoami.command.ts +++ b/apps/cli/src/commands/whoami/whoami.command.ts @@ -6,6 +6,7 @@ import { managementApiRuntimeLayer } from "../../command-internal/management-api import { withJsonErrorHandling } from "../../shared/output/json-error-handling.ts"; import { withCommandTelemetry } from "../../telemetry/command-telemetry.ts"; import { whoami } from "./whoami.handler.ts"; +import { withTargetPolicy } from "../../command-internal/target/target-policy.ts"; const config = {}; export type WhoamiFlags = CliCommand.Command.Config.Infer; @@ -34,4 +35,5 @@ export const whoamiCommand = Command.make("whoami", config).pipe( ]), Command.withHandler(whoamiHandler), Command.provide(managementApiRuntimeLayer(["whoami"])), + withTargetPolicy({ group: "account", consent: "none" }), ); From f1b8543992479760a6c06e0288ebb00717268803 Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Thu, 15 Oct 2026 12:51:59 +0100 Subject: [PATCH 06/92] refactor(cli): declare local database-target defaults for selectable commands (CLI-2695) --- .../src/command-internal/target/target-policy.unit.test.ts | 6 +----- apps/cli/src/commands/db/dump/dump.command.ts | 2 +- apps/cli/src/commands/inspect/db/inspect-db-command.ts | 2 +- apps/cli/src/commands/inspect/report/report.command.ts | 2 +- apps/cli/src/commands/migration/list/list.command.ts | 2 +- apps/cli/src/commands/migration/repair/repair.command.ts | 2 +- 6 files changed, 6 insertions(+), 10 deletions(-) diff --git a/apps/cli/src/command-internal/target/target-policy.unit.test.ts b/apps/cli/src/command-internal/target/target-policy.unit.test.ts index 6edd720884..1a7d2b8817 100644 --- a/apps/cli/src/command-internal/target/target-policy.unit.test.ts +++ b/apps/cli/src/command-internal/target/target-policy.unit.test.ts @@ -27,9 +27,6 @@ const FLAG_MIGRATION_PENDING: ReadonlySet = new Set([ "db schema declarative generate", ]); -/** Selectable leaves whose declared database-target default is still `linked`. */ -const DB_TARGET_DEFAULT_PENDING: ReadonlySet = new Set(["db dump"]); - /** Non-GET operations a leaf may call without hosted-mutation consent, with the reason. */ const CONSENT_FREE_OPERATIONS: ReadonlyMap = new Map([ [ @@ -131,7 +128,6 @@ describe.each(leavesByVariant)( selectable: "local", }; const offenders = withPolicy - .filter(({ path }) => !DB_TARGET_DEFAULT_PENDING.has(path)) .flatMap(({ path, node, policy }) => { const permissions = Option.getOrUndefined(readPermissions(node.command)); if (permissions?.status !== "mapped" || permissions.dbTargetDefault === undefined) @@ -176,7 +172,7 @@ describe.each(leavesByVariant)( describe("target policy allowlists", () => { it("name only real leaves", () => { - const stale = [...FLAG_MIGRATION_PENDING, ...DB_TARGET_DEFAULT_PENDING].filter( + const stale = [...FLAG_MIGRATION_PENDING].filter( (path) => !everyLeafPath.has(path), ); expect(stale).toEqual([]); diff --git a/apps/cli/src/commands/db/dump/dump.command.ts b/apps/cli/src/commands/db/dump/dump.command.ts index ae6da9228b..e0ba591cc8 100644 --- a/apps/cli/src/commands/db/dump/dump.command.ts +++ b/apps/cli/src/commands/db/dump/dump.command.ts @@ -139,7 +139,7 @@ export const dbDumpCommand = Command.make("dump", config).pipe( dbConfigPoolerFallback, withCondition(linkedProjectCache, { dbTarget: ["linked"] }), ), - { default: "linked" }, + { default: "local" }, ), ), Command.withHandler((flags) => diff --git a/apps/cli/src/commands/inspect/db/inspect-db-command.ts b/apps/cli/src/commands/inspect/db/inspect-db-command.ts index 0f85b240e7..eff736089b 100644 --- a/apps/cli/src/commands/inspect/db/inspect-db-command.ts +++ b/apps/cli/src/commands/inspect/db/inspect-db-command.ts @@ -35,7 +35,7 @@ export const INSPECT_DB_FLAGS = { } as const; /** The Management API calls every `inspect db` subcommand makes, all through the database-config resolver. */ -export const INSPECT_DB_PERMISSIONS = withDbTarget(dbConfig, { default: "linked" }); +export const INSPECT_DB_PERMISSIONS = withDbTarget(dbConfig, { default: "local" }); /** Target policy shared by every `inspect db` command. */ export const withInspectDbTargetPolicy = withTargetPolicy({ group: "selectable", consent: "none" }); diff --git a/apps/cli/src/commands/inspect/report/report.command.ts b/apps/cli/src/commands/inspect/report/report.command.ts index 709f50b25a..27446535c3 100644 --- a/apps/cli/src/commands/inspect/report/report.command.ts +++ b/apps/cli/src/commands/inspect/report/report.command.ts @@ -47,7 +47,7 @@ export const inspectReportCommand = Command.make("report", config).pipe( Command.withShortDescription("Generate a CSV output for all inspect commands"), withPermissions( withDbTarget(compose({ operations: [], noApiEffectFlags: ["output-dir"] }, dbConfig), { - default: "linked", + default: "local", }), ), Command.withHandler((flags) => diff --git a/apps/cli/src/commands/migration/list/list.command.ts b/apps/cli/src/commands/migration/list/list.command.ts index a40567616f..1d0633f9ad 100644 --- a/apps/cli/src/commands/migration/list/list.command.ts +++ b/apps/cli/src/commands/migration/list/list.command.ts @@ -58,7 +58,7 @@ export const migrationListCommand = Command.make("list", config).pipe( dbConfig, withCondition(linkedProjectCache, { dbTarget: ["linked"] }), ), - { default: "linked" }, + { default: "local" }, ), ), Command.withHandler((flags) => diff --git a/apps/cli/src/commands/migration/repair/repair.command.ts b/apps/cli/src/commands/migration/repair/repair.command.ts index ce8126e576..a7f0a4cb47 100644 --- a/apps/cli/src/commands/migration/repair/repair.command.ts +++ b/apps/cli/src/commands/migration/repair/repair.command.ts @@ -62,7 +62,7 @@ export const migrationRepairCommand = Command.make("repair", config).pipe( dbConfig, withCondition(linkedProjectCache, { dbTarget: ["linked"] }), ), - { default: "linked" }, + { default: "local" }, ), ), Command.withHandler((flags) => From 913bc2846526343c0d5662b908c470a9b5d71bae Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Thu, 15 Oct 2026 12:59:21 +0100 Subject: [PATCH 07/92] feat(cli): announce the resolved target on stderr and in JSON results (CLI-2695) --- apps/cli/src/cli/root.ts | 16 +- .../src/shared/output/command-target.layer.ts | 52 +++++ .../shared/output/command-target.service.ts | 29 +++ apps/cli/src/shared/output/output.layer.ts | 27 ++- .../shared/output/output.layer.unit.test.ts | 181 ++++++++++++++++++ apps/cli/src/shared/output/resource-format.ts | 7 + 6 files changed, 302 insertions(+), 10 deletions(-) create mode 100644 apps/cli/src/shared/output/command-target.layer.ts create mode 100644 apps/cli/src/shared/output/command-target.service.ts create mode 100644 apps/cli/src/shared/output/resource-format.ts diff --git a/apps/cli/src/cli/root.ts b/apps/cli/src/cli/root.ts index a87d951430..434b6ff3cd 100644 --- a/apps/cli/src/cli/root.ts +++ b/apps/cli/src/cli/root.ts @@ -1,4 +1,4 @@ -import { Effect, Layer, Option } from "effect"; +import { Effect, Layer } from "effect"; import { Command } from "effect/cli"; import { backupsCommand } from "../commands/backups/backups.command.ts"; import { bootstrapCommand } from "../commands/bootstrap/bootstrap.command.ts"; @@ -52,7 +52,10 @@ import { unlinkCommand } from "../commands/unlink/unlink.command.ts"; import { vanitySubdomainsCommand } from "../commands/vanity-subdomains/vanity-subdomains.command.ts"; import { whoamiCommand } from "../commands/whoami/whoami.command.ts"; import { CLI_VERSION, cliBuildChannel } from "../shared/cli/version.ts"; +import { commandTargetLayer } from "../shared/output/command-target.layer.ts"; +import { machineErrorContextLayer } from "../shared/output/machine-error-context.layer.ts"; import { outputLayerFor } from "../shared/output/output.layer.ts"; +import { isMachineResourceFormat } from "../shared/output/resource-format.ts"; import { quietProgressTextOutputLayer } from "../output/quiet-progress-text-output.layer.ts"; import { AiTool } from "../shared/telemetry/ai-tool.service.ts"; import { aiToolLayer } from "../shared/telemetry/ai-tool.layer.ts"; @@ -218,20 +221,17 @@ export const rootCommandForFeatures = (options: RootCommandFeatures = {}) => // Machine formats keep the text layer's error rendering but suppress the progress // spinner, which would otherwise corrupt the stdout payload. `-o pretty`/`-o table` // (db query's human default) and no `-o` keep the normal text/json layers. - const resourceFormat = Option.getOrUndefined(resourceOutput); - const isMachineResourceFormat = - resourceFormat !== undefined && - resourceFormat !== "pretty" && - resourceFormat !== "table"; - const outputLayer = isMachineResourceFormat + const outputLayer = isMachineResourceFormat(resourceOutput) ? quietProgressTextOutputLayer : outputLayerFor(outputFormat); + // A command that provides its own `machineErrorContextLayer` shadows the default cell. return Layer.mergeAll( options.stackBackend === undefined ? Layer.empty : stackBackendLayer(options.stackBackend), - outputLayer, + commandTargetLayer.pipe(Layer.provideMerge(outputLayer)), + machineErrorContextLayer, ); }), ), diff --git a/apps/cli/src/shared/output/command-target.layer.ts b/apps/cli/src/shared/output/command-target.layer.ts new file mode 100644 index 0000000000..84ad430f1f --- /dev/null +++ b/apps/cli/src/shared/output/command-target.layer.ts @@ -0,0 +1,52 @@ +import { Effect, Layer, Option, Ref } from "effect"; + +import { CommandTarget, type RenderedTarget, type TargetJson } from "./command-target.service.ts"; +import { MachineErrorContext } from "./machine-error-context.service.ts"; +import { Output } from "./output.service.ts"; + +interface AnnouncedTargets { + readonly texts: ReadonlyArray; + readonly first: Option.Option; +} + +export const commandTargetLayer = Layer.effect( + CommandTarget, + Effect.gen(function* () { + const output = yield* Output; + const state = yield* Ref.make({ texts: [], first: Option.none() }); + + return CommandTarget.of({ + announce: (rendered: RenderedTarget) => + Effect.gen(function* () { + const outcome = yield* Ref.modify( + state, + (current): readonly ["repeat" | "first" | "additional", AnnouncedTargets] => + current.texts.includes(rendered.text) + ? ["repeat", current] + : [ + Option.isNone(current.first) ? "first" : "additional", + { + texts: [...current.texts, rendered.text], + first: Option.isNone(current.first) + ? Option.some(rendered.json) + : current.first, + }, + ], + ); + if (outcome === "repeat") return; + if (outcome === "first") { + // Read at call time so a command that shadows the root cell receives the target. + const context = yield* Effect.serviceOption(MachineErrorContext); + if (Option.isSome(context)) { + const recorded = yield* context.value.get; + if (recorded.target === undefined) { + yield* context.value.set({ target: rendered.json }); + } + } + } + yield* output.raw(`Target: ${rendered.text}\n`, "stderr"); + }), + json: Ref.get(state).pipe(Effect.map((current) => current.first)), + }); + }), +); diff --git a/apps/cli/src/shared/output/command-target.service.ts b/apps/cli/src/shared/output/command-target.service.ts new file mode 100644 index 0000000000..6782f55b76 --- /dev/null +++ b/apps/cli/src/shared/output/command-target.service.ts @@ -0,0 +1,29 @@ +import type { Effect, Option } from "effect"; +import { Context } from "effect"; + +/** The machine-readable form of a target, merged into JSON results and error envelopes. */ +export type TargetJson = Readonly>; + +/** A target as announced to the user (`text`) and to machine consumers (`json`). */ +export interface RenderedTarget { + readonly text: string; + readonly json: TargetJson; +} + +interface CommandTargetShape { + /** + * Prints `Target: ` to stderr in every output format, once per distinct `text`. The first + * announced target also becomes the run's {@link TargetJson}. + */ + readonly announce: (rendered: RenderedTarget) => Effect.Effect; + /** The first announced target's JSON form, or `None` before any announcement. */ + readonly json: Effect.Effect>; +} + +/** One cell per command invocation, provided at the root. */ +export class CommandTarget extends Context.Service()( + "supabase/output/CommandTarget", +) {} + +export const announceTarget = (rendered: RenderedTarget) => + CommandTarget.use((cell) => cell.announce(rendered)); diff --git a/apps/cli/src/shared/output/output.layer.ts b/apps/cli/src/shared/output/output.layer.ts index 5440a67521..42353a0054 100644 --- a/apps/cli/src/shared/output/output.layer.ts +++ b/apps/cli/src/shared/output/output.layer.ts @@ -17,6 +17,7 @@ import { styleText } from "node:util"; import { DateTime, Effect, Fiber, Layer, Option, Schema, Stdio, Stream } from "effect"; import { Tty } from "../runtime/tty.service.ts"; +import { CommandTarget } from "./command-target.service.ts"; import { CONTEXT_CANCELED_MESSAGE, NonInteractiveError } from "./errors.ts"; import { MachineErrorContext } from "./machine-error-context.service.ts"; import { Output } from "./output.service.ts"; @@ -38,6 +39,21 @@ const readMachineErrorContext = Effect.fnUntraced(function* () { return Option.isSome(context) ? yield* context.value.get : {}; }); +const isPlainObject = (value: unknown): value is Record => { + if (typeof value !== "object" || value === null || Array.isArray(value)) return false; + const prototype = Object.getPrototypeOf(value); + return prototype === Object.prototype || prototype === null; +}; + +/** Adds the announced target to a plain-object result; arrays, scalars and a payload's own `target` are left alone. */ +const withCommandTarget = Effect.fnUntraced(function* (data: unknown) { + if (!isPlainObject(data) || data.target !== undefined) return data; + const cell = yield* Effect.serviceOption(CommandTarget); + if (Option.isNone(cell)) return data; + const target = yield* cell.value.json; + return Option.isSome(target) ? { ...data, target: target.value } : data; +}); + function formatTaskMessage(message: string | undefined): string | undefined { if (message === undefined || !message.includes("\n")) { return message; @@ -494,7 +510,10 @@ export const jsonOutputLayer = Layer.effect( suggestion: "Provide all required values via flags", }), ); - const result = (data: unknown) => writeStdout(`${JSON.stringify(data)}\n`); + const result = (data: unknown) => + withCommandTarget(data).pipe( + Effect.flatMap((merged) => writeStdout(`${JSON.stringify(merged)}\n`)), + ); return Output.of({ format: "json" as const, @@ -575,7 +594,11 @@ export const streamJsonOutputLayer = Layer.effect( }), ); const result = (data: unknown) => - emitEvent((timestamp) => ({ type: "result", data, timestamp })); + withCommandTarget(data).pipe( + Effect.flatMap((merged) => + emitEvent((timestamp) => ({ type: "result", data: merged, timestamp })), + ), + ); return Output.of({ format: "stream-json" as const, diff --git a/apps/cli/src/shared/output/output.layer.unit.test.ts b/apps/cli/src/shared/output/output.layer.unit.test.ts index 6495f8b787..495190808a 100644 --- a/apps/cli/src/shared/output/output.layer.unit.test.ts +++ b/apps/cli/src/shared/output/output.layer.unit.test.ts @@ -4,6 +4,8 @@ import { Cause, Deferred, Effect, Exit, Fiber, Layer, Schema, Sink, Stdio, Strea import { TestClock } from "effect/testing"; import { CONTEXT_CANCELED_MESSAGE, NonInteractiveError } from "./errors.ts"; import { mockTty } from "../../../tests/helpers/mocks.ts"; +import { announceTarget } from "./command-target.service.ts"; +import { commandTargetLayer } from "./command-target.layer.ts"; import { machineErrorContextLayer } from "./machine-error-context.layer.ts"; import { MachineErrorContext } from "./machine-error-context.service.ts"; import { Output } from "./output.service.ts"; @@ -1327,6 +1329,185 @@ describe("Output", () => { ); }); + describe("command target", () => { + const localTarget = { + text: "local stack (127.0.0.1:54322)", + json: { kind: "local", host: "127.0.0.1", port: 54322 }, + }; + const linkedTarget = { + text: "project abcdefghijklmnopqrst · identity unverified", + json: { + kind: "linked", + project_ref: "abcdefghijklmnopqrst", + environment: "unverified", + }, + }; + + const layerWith = ( + outputLayer: ReturnType, + mock: ReturnType, + ) => + Layer.mergeAll( + commandTargetLayer.pipe( + Layer.provideMerge( + outputLayer.pipe( + Layer.provide(Layer.mergeAll(mock.layer, mockTty({ stdoutIsTty: false }))), + ), + ), + ), + machineErrorContextLayer, + ); + + it.effect("a json object result gains the announced target", () => { + const mock = mockStdio(); + return Effect.gen(function* () { + const out = yield* Output; + yield* announceTarget(localTarget); + yield* out.result({ rows: 2 }); + const parsed = yield* decodeJson(mock.stdout[0]!); + expect(parsed).toEqual({ rows: 2, target: localTarget.json }); + }).pipe(Effect.provide(layerWith(jsonOutputLayer, mock))); + }); + + it.effect("a json success payload gains the announced target next to its message", () => { + const mock = mockStdio(); + return Effect.gen(function* () { + const out = yield* Output; + yield* announceTarget(linkedTarget); + yield* out.success("Done.", { applied: 1 }); + const parsed = yield* decodeJson(mock.stdout[0]!); + expect(parsed).toEqual({ applied: 1, message: "Done.", target: linkedTarget.json }); + }).pipe(Effect.provide(layerWith(jsonOutputLayer, mock))); + }); + + it.effect("a json array result is written unchanged", () => { + const mock = mockStdio(); + return Effect.gen(function* () { + const out = yield* Output; + yield* announceTarget(localTarget); + yield* out.result([{ id: 1 }]); + expect(mock.stdout).toEqual(['[{"id":1}]\n']); + }).pipe(Effect.provide(layerWith(jsonOutputLayer, mock))); + }); + + it.effect("a handler-owned target in the payload is preserved", () => { + const mock = mockStdio(); + return Effect.gen(function* () { + const out = yield* Output; + yield* announceTarget(localTarget); + yield* out.result({ target: "remote", rows: 1 }); + const parsed = yield* decodeJson(mock.stdout[0]!); + expect(parsed).toEqual({ target: "remote", rows: 1 }); + }).pipe(Effect.provide(layerWith(jsonOutputLayer, mock))); + }); + + it.effect("a json result is unchanged when no target was announced", () => { + const mock = mockStdio(); + return Effect.gen(function* () { + const out = yield* Output; + yield* out.result({ rows: 2 }); + expect(mock.stdout).toEqual(['{"rows":2}\n']); + }).pipe(Effect.provide(layerWith(jsonOutputLayer, mock))); + }); + + it.effect("a json result is unchanged when the cell is not provided", () => { + const mock = mockStdio(); + return Effect.gen(function* () { + const out = yield* Output; + yield* out.result({ rows: 2 }); + expect(mock.stdout).toEqual(['{"rows":2}\n']); + }).pipe(Effect.provide(jsonOutputLayer.pipe(Layer.provide(mock.layer)))); + }); + + it.effect("the json error envelope carries the announced target", () => { + const mock = mockStdio(); + return Effect.gen(function* () { + const out = yield* Output; + yield* announceTarget(linkedTarget); + yield* out.fail({ code: "E_TEST", message: "failed" }); + const parsed = yield* decodeJson(mock.stdout[0]!); + expect(parsed).toEqual({ + _tag: "Error", + error: { code: "E_TEST", message: "failed" }, + target: linkedTarget.json, + }); + }).pipe(Effect.provide(layerWith(jsonOutputLayer, mock))); + }); + + it.effect("the stream-json result event gains the announced target", () => { + const mock = mockStdio(); + return Effect.gen(function* () { + const out = yield* Output; + yield* announceTarget(localTarget); + yield* out.result({ rows: 2 }); + const parsed = yield* decodeJson(mock.stdout[0]!); + expect(parsed.type).toBe("result"); + expect(parsed.data).toEqual({ rows: 2, target: localTarget.json }); + }).pipe(Effect.provide(layerWith(streamJsonOutputLayer, mock))); + }); + + it.effect("the stream-json error event carries the announced target", () => { + const mock = mockStdio(); + return Effect.gen(function* () { + const out = yield* Output; + yield* announceTarget(linkedTarget); + yield* out.fail({ code: "E_FAIL", message: "boom" }); + const parsed = yield* decodeJson(mock.stdout[0]!); + expect(parsed.type).toBe("error"); + expect(parsed.target).toEqual(linkedTarget.json); + }).pipe(Effect.provide(layerWith(streamJsonOutputLayer, mock))); + }); + + it.effect("announcing writes the banner to stderr in every output format", () => + Effect.gen(function* () { + for (const outputLayer of [jsonOutputLayer, streamJsonOutputLayer, textOutputLayer]) { + const mock = mockStdio(); + yield* announceTarget(localTarget).pipe(Effect.provide(layerWith(outputLayer, mock))); + expect(mock.stderr).toEqual(["Target: local stack (127.0.0.1:54322)\n"]); + expect(mock.stdout).toEqual([]); + } + }), + ); + + it.effect("announcing the same target twice prints one banner", () => { + const mock = mockStdio(); + return Effect.gen(function* () { + yield* announceTarget(localTarget); + yield* announceTarget(localTarget); + expect(mock.stderr).toEqual(["Target: local stack (127.0.0.1:54322)\n"]); + }).pipe(Effect.provide(layerWith(jsonOutputLayer, mock))); + }); + + it.effect("two distinct targets print both banners and the json keeps the first", () => { + const mock = mockStdio(); + return Effect.gen(function* () { + const out = yield* Output; + yield* announceTarget(linkedTarget); + yield* announceTarget(localTarget); + yield* out.result({ diff: "" }); + expect(mock.stderr).toEqual([ + "Target: project abcdefghijklmnopqrst · identity unverified\n", + "Target: local stack (127.0.0.1:54322)\n", + ]); + const parsed = yield* decodeJson(mock.stdout[0]!); + expect(parsed.target).toEqual(linkedTarget.json); + }).pipe(Effect.provide(layerWith(jsonOutputLayer, mock))); + }); + + it.effect("an error context target set before the announcement is kept", () => { + const mock = mockStdio(); + return Effect.gen(function* () { + const out = yield* Output; + const context = yield* MachineErrorContext; + yield* context.set({ target: { project_ref: "own" } }); + yield* announceTarget(localTarget); + yield* out.fail({ code: "E_TEST", message: "failed" }); + const parsed = yield* decodeJson(mock.stdout[0]!); + expect(parsed.target).toEqual({ project_ref: "own" }); + }).pipe(Effect.provide(layerWith(jsonOutputLayer, mock))); + }); + }); + describe("layerFor", () => { it.effect("returns text layer for 'text'", () => { const mock = mockStdio(); diff --git a/apps/cli/src/shared/output/resource-format.ts b/apps/cli/src/shared/output/resource-format.ts new file mode 100644 index 0000000000..27486d4876 --- /dev/null +++ b/apps/cli/src/shared/output/resource-format.ts @@ -0,0 +1,7 @@ +import { Option } from "effect"; + +/** `-o json|yaml|toml|env|csv`: the formats whose stdout is a payload rather than a human table. */ +export const isMachineResourceFormat = (resourceOutput: Option.Option): boolean => { + const format = Option.getOrUndefined(resourceOutput); + return format !== undefined && format !== "pretty" && format !== "table"; +}; From 5dd735657b5cd6f00e9c981f69d150323fccc01a Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Thu, 15 Oct 2026 13:21:24 +0100 Subject: [PATCH 08/92] feat(cli): resolve the project ref through one chain and announce the target (CLI-2695) --- .../db-config.integration.test.ts | 5 +- .../src/command-internal/db-config.layer.ts | 2 + .../command-internal/parent-project-ref.ts | 23 +++ .../src/command-internal/project-target.ts | 11 ++ .../target/target-identity.ts | 89 ++++++++++++ .../target/target-identity.unit.test.ts | 136 ++++++++++++++++++ .../test-db.layers.unit.test.ts | 2 + .../restore/restore.integration.test.ts | 9 +- .../bootstrap/bootstrap.layers.unit.test.ts | 3 + .../branches/list/list.integration.test.ts | 15 ++ .../branches/pause/pause.integration.test.ts | 3 +- .../unpause/unpause.integration.test.ts | 3 +- .../config/push/push.integration.test.ts | 14 ++ .../db/advisors/advisors.integration.test.ts | 1 + .../commands/db/diff/diff.integration.test.ts | 2 + .../commands/db/dump/dump.integration.test.ts | 3 + .../commands/db/lint/lint.integration.test.ts | 1 + .../commands/db/lint/lint.layers.unit.test.ts | 3 + .../commands/db/pull/pull.integration.test.ts | 6 + .../pull.removed-flag.integration.test.ts | 2 + .../commands/db/push/push.integration.test.ts | 2 + .../db/query/query.integration.test.ts | 1 + .../db/reset/reset.integration.test.ts | 2 + .../commands/db/test/test.integration.test.ts | 6 +- .../domains/create/create.integration.test.ts | 5 +- .../domains/delete/delete.integration.test.ts | 9 +- .../domains/get/get.integration.test.ts | 15 +- .../reverify/reverify.integration.test.ts | 3 +- .../get-root-key.integration.test.ts | 3 +- .../update-root-key.integration.test.ts | 3 +- .../functions/list/list.integration.test.ts | 1 + .../gen/types/types.layers.unit.test.ts | 3 + .../inspect/inspect.layers.unit.test.ts | 2 + .../commands/link/link.integration.test.ts | 4 +- .../migration/down/down.integration.test.ts | 1 + .../migration/fetch/fetch.integration.test.ts | 1 + .../migration/list/list.integration.test.ts | 1 + .../repair/repair.integration.test.ts | 1 + .../squash/squash.integration.test.ts | 2 + .../squash/squash.native.integration.test.ts | 2 + .../migration/up/up.integration.test.ts | 1 + .../network-bans/get/get.integration.test.ts | 17 +-- .../postgres-config.integration.test.ts | 21 +-- .../secrets/unset/unset.integration.test.ts | 21 +-- .../seed/buckets/buckets.integration.test.ts | 8 ++ .../storage/cp/cp.command.integration.test.ts | 2 + ...rage.experimental-gate.integration.test.ts | 2 + .../vanity-subdomains.integration.test.ts | 9 +- .../project-ref.layer.integration.test.ts | 2 + apps/cli/src/config/project-ref.layer.ts | 89 +++++++----- .../src/config/project-ref.layer.unit.test.ts | 123 ++++++++++++++++ apps/cli/src/config/project-ref.service.ts | 30 +++- .../src/shared/cli/hidden-flag.unit.test.ts | 10 +- .../src/shared/cli/run.integration.test.ts | 12 +- apps/cli/tests/helpers/command-mocks.ts | 9 ++ apps/cli/tests/helpers/compute.ts | 32 +++-- apps/cli/tests/helpers/notebooks.ts | 6 +- .../tests/helpers/permission-real-layers.ts | 5 +- apps/cli/tests/helpers/permission-registry.ts | 3 +- apps/cli/tests/helpers/storage.ts | 4 + 60 files changed, 704 insertions(+), 102 deletions(-) create mode 100644 apps/cli/src/command-internal/target/target-identity.ts create mode 100644 apps/cli/src/command-internal/target/target-identity.unit.test.ts 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 28913f9c0b..f72c42ec97 100644 --- a/apps/cli/src/command-internal/db-config.integration.test.ts +++ b/apps/cli/src/command-internal/db-config.integration.test.ts @@ -57,6 +57,7 @@ import { WorkdirFlag, } from "./global-flags.ts"; import { CliConfigFlagInputs, makeCliConfigFlagInputs } from "../config/cli-config-flags.ts"; +import { commandTargetLayer } from "../shared/output/command-target.layer.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"; @@ -102,6 +103,7 @@ function buildResolver( readonly flagPassword?: string; } = {}, ) { + const out = mockOutput(); const deps = Layer.mergeAll( Layer.succeed( CliConfigFlagInputs, @@ -119,7 +121,8 @@ function buildResolver( }), opts.dbConnection ?? mockDbConnection, opts.debugLogger ?? mockDebugLogger, - mockOutput().layer, + out.layer, + commandTargetLayer.pipe(Layer.provide(out.layer)), mockAnalytics().layer, mockTelemetryRuntime(), mockTty(), diff --git a/apps/cli/src/command-internal/db-config.layer.ts b/apps/cli/src/command-internal/db-config.layer.ts index 862cfb694a..0a44481f0c 100644 --- a/apps/cli/src/command-internal/db-config.layer.ts +++ b/apps/cli/src/command-internal/db-config.layer.ts @@ -16,6 +16,7 @@ import { ProfileFlag, WorkdirFlag, } from "./global-flags.ts"; +import { CommandTarget } from "../shared/output/command-target.service.ts"; import { Output } from "../shared/output/output.service.ts"; import { RuntimeInfo } from "../shared/runtime/runtime-info.service.ts"; import { Tty } from "../shared/runtime/tty.service.ts"; @@ -597,6 +598,7 @@ export const dbConfigResolverLayer = Layer.effect( Layer.succeed(TelemetryRuntime, yield* TelemetryRuntime), Layer.succeed(Tty, yield* Tty), Layer.succeed(Output, yield* Output), + Layer.succeed(CommandTarget, yield* CommandTarget), // The per-command identity stitcher, shared with the linked stack's lazy platform-API // factory + linked-project cache. Provided to this layer by each command runtime. Layer.succeed(IdentityStitch, yield* IdentityStitch), diff --git a/apps/cli/src/command-internal/parent-project-ref.ts b/apps/cli/src/command-internal/parent-project-ref.ts index e87050ea85..893744234a 100644 --- a/apps/cli/src/command-internal/parent-project-ref.ts +++ b/apps/cli/src/command-internal/parent-project-ref.ts @@ -3,6 +3,8 @@ 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 "../shared/config/temp-paths.ts"; +import { announceTarget } from "../shared/output/command-target.service.ts"; +import { describeLinkedTarget } from "./target/target-identity.ts"; export type ParentRefResolution = | { readonly kind: "resolved"; readonly ref: string } @@ -58,6 +60,17 @@ export function parseCachedLinkedProject(content: string): Option.Option/supabase/.temp/linked-project.json`; any failure is `None`. */ +export const readCachedLinkedProject = ( + fs: FileSystem.FileSystem, + path: Path.Path, + workdir: string, +): Effect.Effect> => + fs.readFileString(tempPaths(path, workdir).linkedProjectCache).pipe( + Effect.map(parseCachedLinkedProject), + Effect.orElseSucceed(() => Option.none()), + ); + function parseCachedParentRef(content: string): Option.Option { return Option.map(parseCachedLinkedProject(content), (project) => project.ref); } @@ -140,6 +153,16 @@ export const resolveParentScopedProjectRef = Effect.fn("ParentProjectRef.resolve yield* Effect.annotateCurrentSpan("parent_project_ref.linked_parent", parent.kind); if (parent.kind === "resolved") { yield* Effect.annotateCurrentSpan("parent_project_ref.source", "linked_parent"); + const cliSettings = yield* CommandSettings; + const cache = yield* readCachedLinkedProject( + yield* FileSystem.FileSystem, + yield* Path.Path, + cliSettings.workdir, + ); + const cachedParent = Option.exists(cache, (project) => project.ref === parent.ref); + yield* announceTarget( + describeLinkedTarget({ ref: parent.ref, source: cachedParent ? "file" : "env", cache }), + ); return parent.ref; } diff --git a/apps/cli/src/command-internal/project-target.ts b/apps/cli/src/command-internal/project-target.ts index 1dac052d46..22bc54a6c9 100644 --- a/apps/cli/src/command-internal/project-target.ts +++ b/apps/cli/src/command-internal/project-target.ts @@ -9,10 +9,12 @@ import { resolveLinkedParentRef, resolveParentScopedProjectRef, } from "./parent-project-ref.ts"; +import { announceTarget } from "../shared/output/command-target.service.ts"; import { Output } from "../shared/output/output.service.ts"; import { resolveBranchProjectRef } from "./branch-ref.resolver.ts"; import { sanitizeInlineName } from "./http-errors.ts"; import { BRANCH_PROJECT_REF_PATTERN, BRANCH_UUID_PATTERN } from "./ref-patterns.ts"; +import { renderBranchTarget } from "./target/target-identity.ts"; import { actionability, type CliErrorActionabilityDeclaration, @@ -176,6 +178,7 @@ export const resolveConfigTarget = Effect.fn("ProjectTarget.resolve")(function* yield* Effect.annotateCurrentSpan("project_target.kind", byId ? "branch_id" : "branch_name"); let parentRef: ReturnType; + let parentProjectRef = Option.none(); if (byId) { parentRef = resolveParentScopedProjectRef(Option.none()); } else { @@ -187,6 +190,7 @@ export const resolveConfigTarget = Effect.fn("ProjectTarget.resolve")(function* return yield* Effect.fail(errors.parentRefInvalid(target)); } parentRef = Effect.succeed(parent.ref); + parentProjectRef = Option.some(parent.ref); } const resolving = @@ -205,6 +209,13 @@ export const resolveConfigTarget = Effect.fn("ProjectTarget.resolve")(function* if (!BRANCH_PROJECT_REF_PATTERN.test(ref)) { return yield* Effect.fail(errors.branchNotReady(target)); } + yield* announceTarget( + renderBranchTarget({ + branch: byId ? Option.none() : Option.some(target), + ref, + parentRef: parentProjectRef, + }), + ); } else { yield* Effect.annotateCurrentSpan("project_target.kind", "project"); ref = yield* resolver.resolve(requested); diff --git a/apps/cli/src/command-internal/target/target-identity.ts b/apps/cli/src/command-internal/target/target-identity.ts new file mode 100644 index 0000000000..2c3171569e --- /dev/null +++ b/apps/cli/src/command-internal/target/target-identity.ts @@ -0,0 +1,89 @@ +import { Option } from "effect"; + +import type { ProjectRefSource } from "../../config/project-ref.service.ts"; +import type { RenderedTarget } from "../../shared/output/command-target.service.ts"; +import { sanitizeInlineName } from "../http-errors.ts"; +import type { CachedLinkedProject } from "../parent-project-ref.ts"; + +const hostWithPort = (host: string, port: number): string => + `${host.includes(":") ? `[${host}]` : host}:${port}`; + +export const renderLocalTarget = (host: string, port: number): RenderedTarget => ({ + text: `local stack (${hostWithPort(sanitizeInlineName(host), port)})`, + json: { kind: "local", host, port }, +}); + +/** Names the host only; the connection string's credentials never reach the banner. */ +export const renderDbUrlTarget = (host: string, port: number): RenderedTarget => ({ + text: `custom Postgres (${sanitizeInlineName(host)})`, + json: { kind: "db-url", host, port }, +}); + +const named = (name: string | undefined, ref: string): string => { + const safeName = name === undefined ? "" : sanitizeInlineName(name); + return safeName.length === 0 ? ref : `${safeName} (${ref})`; +}; + +/** + * A linked project's environment from the `linked-project.json` cache alone, with no API call. + * A cache naming the ref itself is a production project. A cache naming another project while the + * ref came from `supabase/.temp/project-ref` is that project's branch, because `link ` + * stores the parent in the cache. Any other ref is unverified. + */ +export const describeLinkedTarget = (input: { + readonly ref: string; + readonly source: ProjectRefSource; + readonly cache: Option.Option; +}): RenderedTarget => { + const { ref, source } = input; + const cached = Option.getOrUndefined(input.cache); + if (cached?.ref === ref) { + return { + text: `project ${named(cached.name, ref)} · production`, + json: { + kind: "linked", + project_ref: ref, + ...(cached.name === undefined ? {} : { project_name: cached.name }), + environment: "production", + }, + }; + } + if (cached !== undefined && source === "file") { + return { + text: `project ${named(cached.name, cached.ref)} · branch (${ref})`, + json: { + kind: "linked", + project_ref: ref, + environment: "branch", + parent_project_ref: cached.ref, + }, + }; + } + return { + text: `project ${ref} · identity unverified`, + json: { kind: "linked", project_ref: ref, environment: "unverified" }, + }; +}; + +/** A branch resolved to its own project ref; `branch` is absent for a UUID lookup and `parentRef` for anything but a name lookup. */ +export const renderBranchTarget = (input: { + readonly branch: Option.Option; + readonly ref: string; + readonly parentRef: Option.Option; +}): RenderedTarget => { + const branch = Option.map(input.branch, sanitizeInlineName); + const parentRef = Option.getOrUndefined(input.parentRef); + const label = Option.match(branch, { + onNone: () => `branch ${input.ref}`, + onSome: (name) => `branch ${name} (${input.ref})`, + }); + return { + text: parentRef === undefined ? label : `project ${parentRef} · ${label}`, + json: { + kind: "linked", + project_ref: input.ref, + environment: "branch", + ...(parentRef === undefined ? {} : { parent_project_ref: parentRef }), + }, + }; +}; diff --git a/apps/cli/src/command-internal/target/target-identity.unit.test.ts b/apps/cli/src/command-internal/target/target-identity.unit.test.ts new file mode 100644 index 0000000000..de13ecae27 --- /dev/null +++ b/apps/cli/src/command-internal/target/target-identity.unit.test.ts @@ -0,0 +1,136 @@ +import { describe, expect, it } from "@effect/vitest"; +import { Option } from "effect"; + +import { + describeLinkedTarget, + renderBranchTarget, + renderDbUrlTarget, + renderLocalTarget, +} from "./target-identity.ts"; + +const REF = "abcdefghijklmnopqrst"; +const OTHER_REF = "qrstuvwxyzabcdefghij"; + +describe("renderLocalTarget", () => { + it("renders the host and port", () => { + expect(renderLocalTarget("127.0.0.1", 54322)).toEqual({ + text: "local stack (127.0.0.1:54322)", + json: { kind: "local", host: "127.0.0.1", port: 54322 }, + }); + }); + + it("brackets an IPv6 host", () => { + expect(renderLocalTarget("::1", 54322).text).toBe("local stack ([::1]:54322)"); + }); +}); + +describe("renderDbUrlTarget", () => { + it("names the host without credentials", () => { + expect(renderDbUrlTarget("db.example.com", 5432)).toEqual({ + text: "custom Postgres (db.example.com)", + json: { kind: "db-url", host: "db.example.com", port: 5432 }, + }); + }); +}); + +describe("describeLinkedTarget", () => { + it("is production when the cache names the resolved ref", () => { + const target = describeLinkedTarget({ + ref: REF, + source: "flag", + cache: Option.some({ ref: REF, name: "alpha" }), + }); + expect(target).toEqual({ + text: `project alpha (${REF}) · production`, + json: { kind: "linked", project_ref: REF, project_name: "alpha", environment: "production" }, + }); + }); + + it("omits the name when the cache has none", () => { + const target = describeLinkedTarget({ + ref: REF, + source: "file", + cache: Option.some({ ref: REF }), + }); + expect(target.text).toBe(`project ${REF} · production`); + expect(target.json).toEqual({ kind: "linked", project_ref: REF, environment: "production" }); + }); + + it("is a branch when the ref file names a different project than the cached parent", () => { + const target = describeLinkedTarget({ + ref: OTHER_REF, + source: "file", + cache: Option.some({ ref: REF, name: "alpha" }), + }); + expect(target).toEqual({ + text: `project alpha (${REF}) · branch (${OTHER_REF})`, + json: { + kind: "linked", + project_ref: OTHER_REF, + environment: "branch", + parent_project_ref: REF, + }, + }); + }); + + it.each(["flag", "env", "picker"] as const)( + "is unverified for a %s ref the cache does not name", + (source) => { + const target = describeLinkedTarget({ + ref: OTHER_REF, + source, + cache: Option.some({ ref: REF, name: "alpha" }), + }); + expect(target).toEqual({ + text: `project ${OTHER_REF} · identity unverified`, + json: { kind: "linked", project_ref: OTHER_REF, environment: "unverified" }, + }); + }, + ); + + it("is unverified without a cache", () => { + const target = describeLinkedTarget({ ref: REF, source: "file", cache: Option.none() }); + expect(target.text).toBe(`project ${REF} · identity unverified`); + }); + + it("strips control characters and newlines from a cached project name", () => { + const target = describeLinkedTarget({ + ref: REF, + source: "file", + cache: Option.some({ ref: REF, name: "alpha\nTarget: forged\u001b[31m" }), + }); + expect(target.text).not.toContain("\n"); + expect(target.text).not.toContain("\u001b"); + }); +}); + +describe("renderBranchTarget", () => { + it("names the parent, branch and resolved ref for a name lookup", () => { + const target = renderBranchTarget({ + branch: Option.some("feature-x"), + ref: OTHER_REF, + parentRef: Option.some(REF), + }); + expect(target).toEqual({ + text: `project ${REF} · branch feature-x (${OTHER_REF})`, + json: { + kind: "linked", + project_ref: OTHER_REF, + environment: "branch", + parent_project_ref: REF, + }, + }); + }); + + it("shows only the resolved ref for a UUID lookup", () => { + const target = renderBranchTarget({ + branch: Option.none(), + ref: OTHER_REF, + parentRef: Option.none(), + }); + expect(target).toEqual({ + text: `branch ${OTHER_REF}`, + json: { kind: "linked", project_ref: OTHER_REF, environment: "branch" }, + }); + }); +}); 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 db2062ca89..d29f428faf 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 @@ -39,6 +39,7 @@ import { DbConnection } from "./db-connection.service.ts"; import { IdentityStitch } from "./identity-stitch.ts"; import { testDbRuntimeLayer } from "./test-db.layers.ts"; +import { commandTargetLayer } from "../shared/output/command-target.layer.ts"; const tempRoot = useTempWorkdir("supabase-test-db-layers-"); @@ -82,6 +83,7 @@ function ambientStubs() { analytics.layer, mockTelemetryRuntime(), out.layer, + commandTargetLayer.pipe(Layer.provide(out.layer)), flagLayers, mockTelemetryStateLayer, cliConfigValuesTestLayer, diff --git a/apps/cli/src/commands/backups/restore/restore.integration.test.ts b/apps/cli/src/commands/backups/restore/restore.integration.test.ts index d3d6f44629..a323cd56fc 100644 --- a/apps/cli/src/commands/backups/restore/restore.integration.test.ts +++ b/apps/cli/src/commands/backups/restore/restore.integration.test.ts @@ -10,6 +10,7 @@ import { mockCommandSettings, mockCommandPlatformApi, useTempWorkdir, + unverifiedTargetBanner, } from "../../../../tests/helpers/command-mocks.ts"; import { backupsRestore } from "./restore.handler.ts"; @@ -70,7 +71,9 @@ describe("backups restore integration", () => { projectRef: Option.none(), timestamp: Option.none(), }); - expect(out.stderrText).toBe(`Started PITR restore: ${VALID_REF}\n`); + expect(out.stderrText).toBe( + `${unverifiedTargetBanner()}Started PITR restore: ${VALID_REF}\n`, + ); expect(out.stdoutText).toBe(""); }).pipe(Effect.provide(layer)); }); @@ -122,7 +125,9 @@ describe("backups restore integration", () => { projectRef: Option.none(), timestamp: Option.none(), }); - expect(out.stderrText).toBe(`Started PITR restore: ${VALID_REF}\n`); + expect(out.stderrText).toBe( + `${unverifiedTargetBanner()}Started PITR restore: ${VALID_REF}\n`, + ); expect(out.stdoutText).toBe(""); }).pipe(Effect.provide(layer)); }, 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 714c0e5b1c..0b0b47f06c 100644 --- a/apps/cli/src/commands/bootstrap/bootstrap.layers.unit.test.ts +++ b/apps/cli/src/commands/bootstrap/bootstrap.layers.unit.test.ts @@ -43,6 +43,7 @@ import { IdentityStitch } from "../../command-internal/identity-stitch.ts"; import { TemplateService } from "./bootstrap.templates.ts"; import { bootstrapRuntimeLayer } from "./bootstrap.layers.ts"; +import { commandTargetLayer } from "../../shared/output/command-target.layer.ts"; const tempRoot = useTempWorkdir("supabase-bootstrap-layers-"); @@ -74,6 +75,7 @@ function ambientStubs() { Layer.succeed(ProjectRefResolver, { resolve: () => Effect.die("project-ref-resolver not needed for layer-exposure test"), resolveForLink: () => Effect.die("project-ref-resolver not needed for layer-exposure test"), + resolveRef: () => Effect.die("project-ref-resolver not needed for layer-exposure test"), resolveOptional: () => Effect.die("project-ref-resolver not needed for layer-exposure test"), loadProjectRef: () => Effect.die("project-ref-resolver not needed for layer-exposure test"), promptProjectRef: () => Effect.die("project-ref-resolver not needed for layer-exposure test"), @@ -99,6 +101,7 @@ function ambientStubs() { analytics.layer, mockTelemetryRuntime(), out.layer, + commandTargetLayer.pipe(Layer.provide(out.layer)), flagLayers, mockCommandSettings({ workdir: "/tmp/bootstrap-layers-test" }), mockCommandCredentialsLayer, diff --git a/apps/cli/src/commands/branches/list/list.integration.test.ts b/apps/cli/src/commands/branches/list/list.integration.test.ts index 0db1d70931..af5fe400e5 100644 --- a/apps/cli/src/commands/branches/list/list.integration.test.ts +++ b/apps/cli/src/commands/branches/list/list.integration.test.ts @@ -327,6 +327,21 @@ describe("branches list integration", () => { }, ); + it.effect("announces the cached parent project as the production target", () => { + const { layer, out, workdir } = setup({ + projectId: Option.none(), + response: [SAMPLE_BRANCH], + }); + return Effect.gen(function* () { + yield* writeProjectRefFile(workdir, BRANCH_OWN_REF); + yield* writeLinkedProjectCacheFile(workdir, PARENT_REF); + yield* branchesList({ projectRef: Option.none() }); + expect(out.stderrText).toBe( + `Target: project Parent Project (${PARENT_REF}) · production\n`, + ); + }).pipe(Effect.provide(layer)); + }); + it.effect( "an explicit --project-ref still wins over both the cache and the project-ref file", () => { diff --git a/apps/cli/src/commands/branches/pause/pause.integration.test.ts b/apps/cli/src/commands/branches/pause/pause.integration.test.ts index c3ac7a5473..9f567cd67d 100644 --- a/apps/cli/src/commands/branches/pause/pause.integration.test.ts +++ b/apps/cli/src/commands/branches/pause/pause.integration.test.ts @@ -11,6 +11,7 @@ import { mockCommandPlatformApi, mockTelemetryStateTracked, useTempWorkdir, + unverifiedTargetBanner, } from "../../../../tests/helpers/command-mocks.ts"; import type { BranchesPauseFlags } from "./pause.command.ts"; import { branchesPause } from "./pause.handler.ts"; @@ -141,7 +142,7 @@ describe("branches pause integration", () => { return Effect.gen(function* () { yield* branchesPause({ ...baseFlags, name: Option.some(BRANCH_REF) }); expect(out.stdoutText).toBe(""); - expect(out.stderrText).toBe(""); + expect(out.stderrText).toBe(unverifiedTargetBanner()); }).pipe(Effect.provide(layer)); }); diff --git a/apps/cli/src/commands/branches/unpause/unpause.integration.test.ts b/apps/cli/src/commands/branches/unpause/unpause.integration.test.ts index 79b69717a2..a341ea98df 100644 --- a/apps/cli/src/commands/branches/unpause/unpause.integration.test.ts +++ b/apps/cli/src/commands/branches/unpause/unpause.integration.test.ts @@ -8,6 +8,7 @@ import { mockCommandSettings, mockCommandPlatformApi, useTempWorkdir, + unverifiedTargetBanner, } from "../../../../tests/helpers/command-mocks.ts"; import type { BranchesUnpauseFlags } from "./unpause.command.ts"; import { branchesUnpause } from "./unpause.handler.ts"; @@ -59,7 +60,7 @@ describe("branches unpause integration", () => { return Effect.gen(function* () { yield* branchesUnpause({ ...baseFlags, name: Option.some(BRANCH_REF) }); expect(out.stdoutText).toBe(""); - expect(out.stderrText).toBe(""); + expect(out.stderrText).toBe(unverifiedTargetBanner()); }).pipe(Effect.provide(layer)); }); 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 29b65beb1e..565a5e2882 100644 --- a/apps/cli/src/commands/config/push/push.integration.test.ts +++ b/apps/cli/src/commands/config/push/push.integration.test.ts @@ -3631,6 +3631,19 @@ describe("config push --project-ref branch name/UUID resolution (CLI-2289)", () }, ); + it.effect("--project-ref announces the parent project and resolved branch", () => { + const { layer, out } = setup({ + toml: BRANCH_PUSH_TOML, + yes: true, + branchByName: { status: 200, body: BRANCH_BY_NAME }, + v2: { status: 200, body: v2Response({ ref: BRANCH_REF }) }, + }); + return Effect.gen(function* () { + yield* configPush({ projectRef: Option.some("staging") }); + expect(out.stderrText).toContain(`Target: project ${REF} · branch staging (${BRANCH_REF})\n`); + }).pipe(Effect.provide(layer)); + }); + it.effect( "--project-ref enriches the parent name from a matching linked-project cache", () => { @@ -3702,6 +3715,7 @@ describe("config push --project-ref branch name/UUID resolution (CLI-2289)", () const exit = yield* configPush({ projectRef: Option.some(BRANCH_UUID) }).pipe(Effect.exit); expect(Exit.isSuccess(exit)).toBe(true); expect(out.stderrText).toContain(`Pushing config to branch: ${UUID_TARGET_REF}`); + expect(out.stderrText).toContain(`Target: branch ${UUID_TARGET_REF}\n`); expect(out.stderrText).not.toContain(BRANCH_UUID); const branchRequests = api.requests.filter((r) => r.url.includes("/branches")); expect(branchRequests).toHaveLength(1); diff --git a/apps/cli/src/commands/db/advisors/advisors.integration.test.ts b/apps/cli/src/commands/db/advisors/advisors.integration.test.ts index 83181c6cd9..6f3bf2a20d 100644 --- a/apps/cli/src/commands/db/advisors/advisors.integration.test.ts +++ b/apps/cli/src/commands/db/advisors/advisors.integration.test.ts @@ -173,6 +173,7 @@ function mockProjectRef() { return VALID_REF; }), resolveForLink: () => Effect.succeed(VALID_REF), + resolveRef: () => Effect.succeed({ ref: VALID_REF, source: "file", matchesLinkedRef: true }), resolveOptional: () => Effect.succeed(Option.some(VALID_REF)), // Gives an explicit `--project-ref` flag top precedence, mirroring the real resolver so a // test can prove the flag (not just the hardcoded fallback) drives the linked ref. 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 7c1a9ed199..d93eed3176 100644 --- a/apps/cli/src/commands/db/diff/diff.integration.test.ts +++ b/apps/cli/src/commands/db/diff/diff.integration.test.ts @@ -427,6 +427,8 @@ function setup(workdir: string, opts: SetupOpts = {}) { const projectRefResolver = Layer.succeed(ProjectRefResolver, { resolve: () => Effect.succeed(opts.linkedRef ?? VALID_REF), resolveForLink: () => Effect.succeed(opts.linkedRef ?? VALID_REF), + resolveRef: () => + Effect.succeed({ ref: opts.linkedRef ?? VALID_REF, source: "file", matchesLinkedRef: true }), resolveOptional: () => Effect.succeed(Option.some(opts.linkedRef ?? VALID_REF)), loadProjectRef: (flagValue: Option.Option) => Option.isSome(flagValue) && flagValue.value.length > 0 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 2b051ae9e5..079b916611 100644 --- a/apps/cli/src/commands/db/dump/dump.integration.test.ts +++ b/apps/cli/src/commands/db/dump/dump.integration.test.ts @@ -295,6 +295,8 @@ function mockProjectRefResolver(opts: { const layer = Layer.succeed(ProjectRefResolver, { resolve: () => Effect.succeed(opts.ref ?? VALID_REF), resolveForLink: () => Effect.succeed(opts.ref ?? VALID_REF), + resolveRef: () => + Effect.succeed({ ref: opts.ref ?? VALID_REF, source: "file", matchesLinkedRef: true }), resolveOptional: () => Effect.succeed(Option.some(opts.ref ?? VALID_REF)), loadProjectRef: (flagValue: Option.Option) => { if (Option.isSome(flagValue) && flagValue.value.length > 0) { @@ -1492,6 +1494,7 @@ describe("db dump permissions through the real resolver and cache", () => { expect(asLines(requests)).toEqual([loginRoleRequest, poolerRequest, loginRoleRequest]); assertPermissionDrift({ command: "db dump", + activeFlags: { linked: true }, requests: [...asRecordedRequests(requests), ...projectHttp.calls], exact: true, }); diff --git a/apps/cli/src/commands/db/lint/lint.integration.test.ts b/apps/cli/src/commands/db/lint/lint.integration.test.ts index 86f99868a0..c2b08bb854 100644 --- a/apps/cli/src/commands/db/lint/lint.integration.test.ts +++ b/apps/cli/src/commands/db/lint/lint.integration.test.ts @@ -168,6 +168,7 @@ function mockProjectRef() { const layer = Layer.succeed(ProjectRefResolver, { resolve: () => Effect.succeed(VALID_REF), resolveForLink: () => Effect.succeed(VALID_REF), + resolveRef: () => Effect.succeed({ ref: VALID_REF, source: "file", matchesLinkedRef: true }), resolveOptional: () => Effect.succeed(Option.some(VALID_REF)), // An explicit `--project-ref` flag takes top precedence, mirroring the real // resolver. 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 0b612eb9b7..332162cd37 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 @@ -49,6 +49,7 @@ import { IdentityStitch } from "../../../command-internal/identity-stitch.ts"; import { dbAdvisorsRuntimeLayer } from "../advisors/advisors.layers.ts"; import { dbLintRuntimeLayer } from "./lint.layers.ts"; +import { commandTargetLayer } from "../../../shared/output/command-target.layer.ts"; const tempRoot = useTempWorkdir("supabase-lint-layers-"); @@ -86,6 +87,7 @@ function ambientStubs() { Layer.succeed(ProjectRefResolver, { resolve: () => Effect.die("project-ref-resolver not needed for layer-exposure test"), resolveForLink: () => Effect.die("project-ref-resolver not needed for layer-exposure test"), + resolveRef: () => Effect.die("project-ref-resolver not needed for layer-exposure test"), resolveOptional: () => Effect.die("project-ref-resolver not needed for layer-exposure test"), loadProjectRef: () => Effect.die("project-ref-resolver not needed for layer-exposure test"), promptProjectRef: () => Effect.die("project-ref-resolver not needed for layer-exposure test"), @@ -105,6 +107,7 @@ function ambientStubs() { analytics.layer, mockTelemetryRuntime(), out.layer, + commandTargetLayer.pipe(Layer.provide(out.layer)), flagLayers, mockCommandSettings({ workdir: "/tmp/lint-layers-test" }), mockCommandCredentialsLayer, 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 6a3b7652e3..fe24fa1234 100644 --- a/apps/cli/src/commands/db/pull/pull.integration.test.ts +++ b/apps/cli/src/commands/db/pull/pull.integration.test.ts @@ -441,6 +441,12 @@ function setup(workdir: string, opts: SetupOpts = {}) { const projectRefResolver = Layer.succeed(ProjectRefResolver, { resolve: () => Effect.succeed(opts.resolvedRef ?? VALID_REF), resolveForLink: () => Effect.succeed(opts.resolvedRef ?? VALID_REF), + resolveRef: () => + Effect.succeed({ + ref: opts.resolvedRef ?? VALID_REF, + source: "file", + matchesLinkedRef: true, + }), resolveOptional: () => Effect.succeed(Option.some(opts.resolvedRef ?? VALID_REF)), loadProjectRef: (flagValue: Option.Option) => Option.isSome(flagValue) && flagValue.value.length > 0 diff --git a/apps/cli/src/commands/db/pull/pull.removed-flag.integration.test.ts b/apps/cli/src/commands/db/pull/pull.removed-flag.integration.test.ts index 9f641222c2..00262b17b7 100644 --- a/apps/cli/src/commands/db/pull/pull.removed-flag.integration.test.ts +++ b/apps/cli/src/commands/db/pull/pull.removed-flag.integration.test.ts @@ -17,6 +17,7 @@ import { } from "../../../../tests/helpers/mocks.ts"; import { isolatedHomeLayer } from "../../../../tests/helpers/command-mocks.ts"; import { dbCommand } from "../db.command.ts"; +import { commandTargetLayer } from "../../../shared/output/command-target.layer.ts"; const testRoot = Command.make("supabase").pipe( Command.withSubcommands([dbCommand]), @@ -53,6 +54,7 @@ const runDbPull = ( Layer.succeed(CliArgs, { args: argv }), Stdio.layerTest({ args: Effect.succeed(argv) }), out.layer, + commandTargetLayer.pipe(Layer.provide(out.layer)), analytics.layer, mockTelemetryRuntime({ configDir: path.join(workdir, ".supabase") }), mockTty(), 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 5028bdffdf..402cc067f6 100644 --- a/apps/cli/src/commands/db/push/push.integration.test.ts +++ b/apps/cli/src/commands/db/push/push.integration.test.ts @@ -225,6 +225,8 @@ function setup( const projectRefLayer = Layer.succeed(ProjectRefResolver, { resolve: () => Effect.succeed(opts.projectRef ?? VALID_REF), resolveForLink: () => Effect.succeed(opts.projectRef ?? VALID_REF), + resolveRef: () => + Effect.succeed({ ref: opts.projectRef ?? VALID_REF, source: "file", matchesLinkedRef: true }), resolveOptional: () => Effect.succeed(Option.some(opts.projectRef ?? VALID_REF)), // An explicit `--project-ref` flag takes top precedence, short-circuiting before // the "not linked" failure. 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 86d0acc0f1..006f166d89 100644 --- a/apps/cli/src/commands/db/query/query.integration.test.ts +++ b/apps/cli/src/commands/db/query/query.integration.test.ts @@ -160,6 +160,7 @@ function mockProjectRef(unlinked = false, refReadFails = false) { return Layer.succeed(ProjectRefResolver, { resolve: () => Effect.succeed(REF), resolveForLink: () => Effect.succeed(REF), + resolveRef: () => Effect.succeed({ ref: REF, source: "file", matchesLinkedRef: true }), resolveOptional: () => Effect.succeed(unlinked ? Option.none() : Option.some(REF)), loadProjectRef, promptProjectRef: () => Effect.succeed(REF), 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 d9c768a32e..aa9d30103b 100644 --- a/apps/cli/src/commands/db/reset/reset.integration.test.ts +++ b/apps/cli/src/commands/db/reset/reset.integration.test.ts @@ -1059,6 +1059,8 @@ function setup( Layer.succeed(ProjectRefResolver, { resolve: () => Effect.succeed(opts.ref ?? VALID_REF), resolveForLink: () => Effect.succeed(opts.ref ?? VALID_REF), + resolveRef: () => + Effect.succeed({ ref: opts.ref ?? VALID_REF, source: "file", matchesLinkedRef: true }), resolveOptional: () => Effect.succeed(Option.some(opts.ref ?? VALID_REF)), loadProjectRef: (flagValue: Option.Option) => Option.isSome(flagValue) && flagValue.value.length > 0 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 d6f1030b76..6ede790438 100644 --- a/apps/cli/src/commands/db/test/test.integration.test.ts +++ b/apps/cli/src/commands/db/test/test.integration.test.ts @@ -43,6 +43,7 @@ import { } from "../../../../tests/helpers/permission-real-layers.ts"; import { cliConfigValuesTestLayer } from "../../../../tests/helpers/config-values-layer.ts"; import { CliArgs } from "../../../shared/cli/cli-args.service.ts"; +import { commandTargetLayer } from "../../../shared/output/command-target.layer.ts"; import { commandRuntimeLayer } from "../../../shared/runtime/command-runtime.layer.ts"; import { textCliOutputFormatter } from "../../../shared/output/text-formatter.ts"; import { @@ -199,6 +200,7 @@ function setup(opts: SetupOpts = {}) { cliConfigValuesTestLayer, unusedStackServices, out.layer, + commandTargetLayer.pipe(Layer.provide(out.layer)), processControl.layer, analytics.layer, telemetry.layer, @@ -331,6 +333,7 @@ describe("db test (alias) integration", () => { // so this can dispatch through the real command tree without a real Postgres or Docker. const args = ["db", "test", "--local", "--linked"]; const analytics = mockContextualAnalytics(); + const out = mockOutput({ format: "text" }); const layer = Layer.mergeAll( cliConfigValuesTestLayer, unusedStackServices, @@ -338,7 +341,8 @@ describe("db test (alias) integration", () => { mockRuntimeInfo(), mockTty(), mockProcessControl().layer, - mockOutput({ format: "text" }).layer, + out.layer, + commandTargetLayer.pipe(Layer.provide(out.layer)), mockTelemetryRuntime(), analytics.layer, CliOutput.layer(textCliOutputFormatter()), diff --git a/apps/cli/src/commands/domains/create/create.integration.test.ts b/apps/cli/src/commands/domains/create/create.integration.test.ts index 7bf83052d5..5ce24aedfb 100644 --- a/apps/cli/src/commands/domains/create/create.integration.test.ts +++ b/apps/cli/src/commands/domains/create/create.integration.test.ts @@ -14,6 +14,7 @@ import { mockCommandPlatformApi, mockTelemetryStateTracked, useTempWorkdir, + unverifiedTargetBanner, } from "../../../../tests/helpers/command-mocks.ts"; import { DomainsCnameError } from "../domains.errors.ts"; import { domainsCreate } from "./create.handler.ts"; @@ -248,7 +249,7 @@ describe("domains create integration", () => { return Effect.gen(function* () { yield* domainsCreate(flags()); expect(out.stdoutText.startsWith("{")).toBe(true); - expect(out.stderrText).toBe(""); + expect(out.stderrText).toBe(unverifiedTargetBanner()); }).pipe(Effect.provide(layer)); }); @@ -258,7 +259,7 @@ describe("domains create integration", () => { yield* domainsCreate(flags()); // YAML output lowercases the whole field name. expect(out.stdoutText).toContain(`customhostname: ${CUSTOM_HOSTNAME}`); - expect(out.stderrText).toBe(""); + expect(out.stderrText).toBe(unverifiedTargetBanner()); }).pipe(Effect.provide(layer)); }); diff --git a/apps/cli/src/commands/domains/delete/delete.integration.test.ts b/apps/cli/src/commands/domains/delete/delete.integration.test.ts index 5fb28fe213..852b3867ac 100644 --- a/apps/cli/src/commands/domains/delete/delete.integration.test.ts +++ b/apps/cli/src/commands/domains/delete/delete.integration.test.ts @@ -9,6 +9,7 @@ import { mockCommandPlatformApi, mockTelemetryStateTracked, useTempWorkdir, + unverifiedTargetBanner, } from "../../../../tests/helpers/command-mocks.ts"; import { domainsDelete } from "./delete.handler.ts"; @@ -50,7 +51,9 @@ describe("domains delete integration", () => { const { layer, out, api, telemetry, linkedProjectCache } = setup(); return Effect.gen(function* () { yield* domainsDelete(baseFlags); - expect(out.stderrText).toBe("Deleted custom hostname config successfully.\n"); + expect(out.stderrText).toBe( + unverifiedTargetBanner() + "Deleted custom hostname config successfully.\n", + ); expect(out.stdoutText).toBe(""); expect(api.requests[0]?.method).toBe("DELETE"); expect(telemetry.flushed).toBe(true); @@ -80,7 +83,9 @@ describe("domains delete integration", () => { return Effect.gen(function* () { yield* domainsDelete(baseFlags); expect(out.stdoutText).toBe(""); - expect(out.stderrText).toBe("Deleted custom hostname config successfully.\n"); + expect(out.stderrText).toBe( + unverifiedTargetBanner() + "Deleted custom hostname config successfully.\n", + ); }).pipe(Effect.provide(layer)); }); diff --git a/apps/cli/src/commands/domains/get/get.integration.test.ts b/apps/cli/src/commands/domains/get/get.integration.test.ts index 4f725b79c9..7680a1e0f5 100644 --- a/apps/cli/src/commands/domains/get/get.integration.test.ts +++ b/apps/cli/src/commands/domains/get/get.integration.test.ts @@ -10,6 +10,7 @@ import { mockCommandPlatformApi, mockTelemetryStateTracked, useTempWorkdir, + unverifiedTargetBanner, } from "../../../../tests/helpers/command-mocks.ts"; import { domainsGet } from "./get.handler.ts"; @@ -116,7 +117,7 @@ describe("domains get integration", () => { expect(out.stdoutText.startsWith("{")).toBe(true); expect(out.stdoutText).toContain('"custom_hostname": "shop.acme.dev"'); // Structured -o output: human status is suppressed entirely — see emit. - expect(out.stderrText).toBe(""); + expect(out.stderrText).toBe(unverifiedTargetBanner()); }).pipe(Effect.provide(layer)); }); @@ -163,7 +164,8 @@ describe("domains get integration", () => { return Effect.gen(function* () { yield* domainsGet(baseFlags); expect(out.stderrText).toBe( - "Custom hostname setup is being initialized; please request re-verification in a few seconds.\n", + unverifiedTargetBanner() + + "Custom hostname setup is being initialized; please request re-verification in a few seconds.\n", ); expect(out.stdoutText).toBe(""); }).pipe(Effect.provide(layer)); @@ -229,7 +231,8 @@ describe("domains get integration", () => { return Effect.gen(function* () { yield* domainsGet(baseFlags); expect(out.stderrText).toBe( - "Custom hostname setup is being initialized; please request re-verification in a few seconds.\n", + unverifiedTargetBanner() + + "Custom hostname setup is being initialized; please request re-verification in a few seconds.\n", ); expect(out.stdoutText).toBe(""); }).pipe(Effect.provide(layer)); @@ -265,7 +268,8 @@ describe("domains get integration", () => { return Effect.gen(function* () { yield* domainsGet(baseFlags); expect(out.stderrText).toBe( - "Custom hostname verification in-progress; please configure the appropriate DNS entries and request re-verification.\n" + + unverifiedTargetBanner() + + "Custom hostname verification in-progress; please configure the appropriate DNS entries and request re-verification.\n" + "Required outstanding validation records:\n" + "\t_acme-challenge.sbstg4.thewheatfield.org TXT -> i6XyXv3kU4SRX9YcCE8h4LExoHE6y_poV1-5R1cjpk4\n", ); @@ -292,7 +296,8 @@ describe("domains get integration", () => { return Effect.gen(function* () { yield* domainsGet(baseFlags); expect(out.stderrText).toBe( - "Custom hostname verification in-progress; please configure the appropriate DNS entries and request re-verification.\n" + + unverifiedTargetBanner() + + "Custom hostname verification in-progress; please configure the appropriate DNS entries and request re-verification.\n" + "Required outstanding validation records:\n", ); expect(out.stderrText).not.toContain("undefined"); diff --git a/apps/cli/src/commands/domains/reverify/reverify.integration.test.ts b/apps/cli/src/commands/domains/reverify/reverify.integration.test.ts index 349cbed09b..0b461af71f 100644 --- a/apps/cli/src/commands/domains/reverify/reverify.integration.test.ts +++ b/apps/cli/src/commands/domains/reverify/reverify.integration.test.ts @@ -10,6 +10,7 @@ import { mockCommandPlatformApi, mockTelemetryStateTracked, useTempWorkdir, + unverifiedTargetBanner, } from "../../../../tests/helpers/command-mocks.ts"; import { domainsReverify } from "./reverify.handler.ts"; @@ -122,7 +123,7 @@ describe("domains reverify integration", () => { return Effect.gen(function* () { yield* domainsReverify(baseFlags); expect(out.stdoutText.startsWith("{")).toBe(true); - expect(out.stderrText).toBe(""); + expect(out.stderrText).toBe(unverifiedTargetBanner()); }).pipe(Effect.provide(layer)); }); diff --git a/apps/cli/src/commands/encryption/get-root-key/get-root-key.integration.test.ts b/apps/cli/src/commands/encryption/get-root-key/get-root-key.integration.test.ts index 41b44545d5..8a8e9b1571 100644 --- a/apps/cli/src/commands/encryption/get-root-key/get-root-key.integration.test.ts +++ b/apps/cli/src/commands/encryption/get-root-key/get-root-key.integration.test.ts @@ -10,6 +10,7 @@ import { mockCommandPlatformApi, mockTelemetryStateTracked, useTempWorkdir, + unverifiedTargetBanner, } from "../../../../tests/helpers/command-mocks.ts"; import { encryptionGetRootKey } from "./get-root-key.handler.ts"; @@ -54,7 +55,7 @@ describe("encryption get-root-key integration", () => { return Effect.gen(function* () { yield* encryptionGetRootKey(baseFlags); expect(out.stdoutText).toBe("abc123rootkey\n"); - expect(out.stderrText).toBe(""); + expect(out.stderrText).toBe(unverifiedTargetBanner()); }).pipe(Effect.provide(layer)); }); diff --git a/apps/cli/src/commands/encryption/update-root-key/update-root-key.integration.test.ts b/apps/cli/src/commands/encryption/update-root-key/update-root-key.integration.test.ts index 5d3dbeb3ca..34c0d9cc4a 100644 --- a/apps/cli/src/commands/encryption/update-root-key/update-root-key.integration.test.ts +++ b/apps/cli/src/commands/encryption/update-root-key/update-root-key.integration.test.ts @@ -10,6 +10,7 @@ import { mockCommandPlatformApi, mockTelemetryStateTracked, useTempWorkdir, + unverifiedTargetBanner, } from "../../../../tests/helpers/command-mocks.ts"; import { encryptionUpdateRootKey } from "./update-root-key.handler.ts"; @@ -103,7 +104,7 @@ describe("encryption update-root-key integration", () => { expect(success?.message).toBe(""); expect(success?.data).toEqual({ root_key: "new-key" }); expect(out.stdoutText).toBe(""); - expect(out.stderrText).toBe(""); + expect(out.stderrText).toBe(unverifiedTargetBanner()); }).pipe(Effect.provide(layer)); }); diff --git a/apps/cli/src/commands/functions/list/list.integration.test.ts b/apps/cli/src/commands/functions/list/list.integration.test.ts index 89b7bce507..111027d281 100644 --- a/apps/cli/src/commands/functions/list/list.integration.test.ts +++ b/apps/cli/src/commands/functions/list/list.integration.test.ts @@ -411,6 +411,7 @@ describe("functions list integration", () => { }), ), resolveForLink: () => Effect.die("not used in functions list test"), + resolveRef: () => Effect.die("not used in functions list test"), resolveOptional: () => Effect.die("not used in functions list test"), loadProjectRef: () => Effect.die("not used in functions list test"), promptProjectRef: () => Effect.die("not used in functions list test"), 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 0e9b2dab98..ed21f4c5d2 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 @@ -38,6 +38,7 @@ import { ProjectRefResolver } from "../../../config/project-ref.service.ts"; import { IdentityStitch } from "../../../command-internal/identity-stitch.ts"; import { genTypesRuntimeLayer } from "./types.layers.ts"; +import { commandTargetLayer } from "../../../shared/output/command-target.layer.ts"; const tempRoot = useTempWorkdir("supabase-gen-types-layers-"); @@ -63,6 +64,7 @@ function ambientStubs() { Layer.succeed(ProjectRefResolver, { resolve: () => Effect.die("project-ref-resolver not needed for layer-exposure test"), resolveForLink: () => Effect.die("project-ref-resolver not needed for layer-exposure test"), + resolveRef: () => Effect.die("project-ref-resolver not needed for layer-exposure test"), resolveOptional: () => Effect.die("project-ref-resolver not needed for layer-exposure test"), loadProjectRef: () => Effect.die("project-ref-resolver not needed for layer-exposure test"), promptProjectRef: () => Effect.die("project-ref-resolver not needed for layer-exposure test"), @@ -82,6 +84,7 @@ function ambientStubs() { analytics.layer, mockTelemetryRuntime(), out.layer, + commandTargetLayer.pipe(Layer.provide(out.layer)), flagLayers, mockCommandSettings({ workdir: "/tmp/gen-types-layers-test" }), mockCommandCredentialsLayer, 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 bcc98ae421..17a7ebf2de 100644 --- a/apps/cli/src/commands/inspect/inspect.layers.unit.test.ts +++ b/apps/cli/src/commands/inspect/inspect.layers.unit.test.ts @@ -33,6 +33,7 @@ import { DbConnection } from "../../command-internal/db-connection.service.ts"; import { IdentityStitch } from "../../command-internal/identity-stitch.ts"; import { inspectBaseLayer } from "./inspect.layers.ts"; +import { commandTargetLayer } from "../../shared/output/command-target.layer.ts"; const tempRoot = useTempWorkdir("supabase-inspect-layers-"); @@ -70,6 +71,7 @@ function ambientStubs() { analytics.layer, mockTelemetryRuntime(), out.layer, + commandTargetLayer.pipe(Layer.provide(out.layer)), flagLayers, mockCommandSettings({ workdir: "/tmp/inspect-layers-test" }), mockCommandCredentialsLayer, diff --git a/apps/cli/src/commands/link/link.integration.test.ts b/apps/cli/src/commands/link/link.integration.test.ts index 83b2e285b3..36501c476a 100644 --- a/apps/cli/src/commands/link/link.integration.test.ts +++ b/apps/cli/src/commands/link/link.integration.test.ts @@ -35,6 +35,7 @@ import { mockCommandPlatformApiService, mockTelemetryStateTracked, useTempWorkdir, + unverifiedTargetBanner, } from "../../../tests/helpers/command-mocks.ts"; import { link } from "./link.handler.ts"; import { linkHandler } from "./link.command.ts"; @@ -1317,7 +1318,8 @@ describe("link integration", () => { message: "Resolving branch...", }); expect(out.stderrText).toBe( - `Resolved branch "feature-branch" of project ${PARENT_REF} to project ref ${BRANCH_PROJECT_REF}.\n`, + `Resolved branch "feature-branch" of project ${PARENT_REF} to project ref ${BRANCH_PROJECT_REF}.\n` + + unverifiedTargetBanner(BRANCH_PROJECT_REF), ); expect(out.stdoutText).toBe("Finished supabase link.\n"); }).pipe(Effect.provide(layer)); 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 ebe4a8d100..2c67eb3312 100644 --- a/apps/cli/src/commands/migration/down/down.integration.test.ts +++ b/apps/cli/src/commands/migration/down/down.integration.test.ts @@ -139,6 +139,7 @@ function setup(workdir: string, opts: SetupOpts = {}) { const projectRef = Layer.succeed(ProjectRefResolver, { resolve: () => Effect.succeed(VALID_REF), resolveForLink: () => Effect.succeed(VALID_REF), + resolveRef: () => Effect.succeed({ ref: VALID_REF, source: "file", matchesLinkedRef: true }), resolveOptional: () => Effect.succeed(Option.some(VALID_REF)), loadProjectRef: (flagValue: Option.Option) => Effect.succeed( diff --git a/apps/cli/src/commands/migration/fetch/fetch.integration.test.ts b/apps/cli/src/commands/migration/fetch/fetch.integration.test.ts index 42054fddeb..13f13a468e 100644 --- a/apps/cli/src/commands/migration/fetch/fetch.integration.test.ts +++ b/apps/cli/src/commands/migration/fetch/fetch.integration.test.ts @@ -118,6 +118,7 @@ function setup(workdir: string, opts: SetupOpts = {}) { const projectRef = Layer.succeed(ProjectRefResolver, { resolve: () => Effect.succeed(VALID_REF), resolveForLink: () => Effect.succeed(VALID_REF), + resolveRef: () => Effect.succeed({ ref: VALID_REF, source: "file", matchesLinkedRef: true }), resolveOptional: () => Effect.succeed(Option.some(VALID_REF)), loadProjectRef: (flagValue: Option.Option) => Effect.succeed( 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 0952e93453..aaf2146312 100644 --- a/apps/cli/src/commands/migration/list/list.integration.test.ts +++ b/apps/cli/src/commands/migration/list/list.integration.test.ts @@ -105,6 +105,7 @@ function setup(workdir: string, opts: SetupOpts = {}) { const projectRef = Layer.succeed(ProjectRefResolver, { resolve: () => Effect.succeed(VALID_REF), resolveForLink: () => Effect.succeed(VALID_REF), + resolveRef: () => Effect.succeed({ ref: VALID_REF, source: "file", matchesLinkedRef: true }), resolveOptional: () => Effect.succeed(Option.some(VALID_REF)), loadProjectRef: (flagValue: Option.Option) => Effect.succeed( 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 b09b7924c3..3b8a7cc593 100644 --- a/apps/cli/src/commands/migration/repair/repair.integration.test.ts +++ b/apps/cli/src/commands/migration/repair/repair.integration.test.ts @@ -130,6 +130,7 @@ function setup(workdir: string, opts: SetupOpts = {}) { const projectRef = Layer.succeed(ProjectRefResolver, { resolve: () => Effect.succeed(VALID_REF), resolveForLink: () => Effect.succeed(VALID_REF), + resolveRef: () => Effect.succeed({ ref: VALID_REF, source: "file", matchesLinkedRef: true }), resolveOptional: () => Effect.succeed(Option.some(VALID_REF)), loadProjectRef: (flagValue: Option.Option) => Effect.succeed( 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 c12ace13bb..4c1bf298c0 100644 --- a/apps/cli/src/commands/migration/squash/squash.integration.test.ts +++ b/apps/cli/src/commands/migration/squash/squash.integration.test.ts @@ -349,6 +349,8 @@ function setup(workdir: string, opts: SetupOpts = {}) { const projectRef = Layer.succeed(ProjectRefResolver, { resolve: () => Effect.succeed(opts.linkedRef ?? VALID_REF), resolveForLink: () => Effect.succeed(opts.linkedRef ?? VALID_REF), + resolveRef: () => + Effect.succeed({ ref: opts.linkedRef ?? VALID_REF, source: "file", matchesLinkedRef: true }), resolveOptional: () => Effect.succeed(Option.some(opts.linkedRef ?? VALID_REF)), loadProjectRef: (flagValue: Option.Option) => Effect.succeed( 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 a92ade3e41..86b4a55f7b 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 @@ -84,6 +84,8 @@ const testLayer = (root: string) => Layer.succeed(ProjectRefResolver, { resolve: () => Effect.succeed("abcdefghijklmnopqrst"), resolveForLink: () => Effect.succeed("abcdefghijklmnopqrst"), + resolveRef: () => + Effect.succeed({ ref: "abcdefghijklmnopqrst", source: "file", matchesLinkedRef: true }), resolveOptional: () => Effect.succeed(Option.some("abcdefghijklmnopqrst")), loadProjectRef: () => Effect.succeed("abcdefghijklmnopqrst"), promptProjectRef: () => Effect.succeed("abcdefghijklmnopqrst"), 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 2b917f4e79..8ae7aeaa37 100644 --- a/apps/cli/src/commands/migration/up/up.integration.test.ts +++ b/apps/cli/src/commands/migration/up/up.integration.test.ts @@ -123,6 +123,7 @@ function setup(workdir: string, opts: SetupOpts = {}) { const projectRef = Layer.succeed(ProjectRefResolver, { resolve: () => Effect.succeed(VALID_REF), resolveForLink: () => Effect.succeed(VALID_REF), + resolveRef: () => Effect.succeed({ ref: VALID_REF, source: "file", matchesLinkedRef: true }), resolveOptional: () => Effect.succeed(Option.some(VALID_REF)), loadProjectRef: (flagValue: Option.Option) => Effect.succeed( diff --git a/apps/cli/src/commands/network-bans/get/get.integration.test.ts b/apps/cli/src/commands/network-bans/get/get.integration.test.ts index b1642c4a2e..8ce483f5ca 100644 --- a/apps/cli/src/commands/network-bans/get/get.integration.test.ts +++ b/apps/cli/src/commands/network-bans/get/get.integration.test.ts @@ -12,6 +12,7 @@ import { mockCommandPlatformApi, mockTelemetryStateTracked, useTempWorkdir, + unverifiedTargetBanner, } from "../../../../tests/helpers/command-mocks.ts"; import { networkBansGet } from "./get.handler.ts"; @@ -69,7 +70,7 @@ describe("network-bans get integration", () => { const { layer, out } = setup(); return Effect.gen(function* () { yield* networkBansGet({ projectRef: Option.none() }); - expect(out.stderrText).toBe("DB banned IPs:\n"); + expect(out.stderrText).toBe(unverifiedTargetBanner() + "DB banned IPs:\n"); expect(out.stdoutText).toBe( `[ "192.168.0.1", @@ -84,7 +85,7 @@ describe("network-bans get integration", () => { const { layer, out } = setup({ outputFlag: "toml" }); return Effect.gen(function* () { yield* networkBansGet({ projectRef: Option.none() }); - expect(out.stderrText).toBe("DB banned IPs:\n"); + expect(out.stderrText).toBe(unverifiedTargetBanner() + "DB banned IPs:\n"); expect(out.stdoutText).toBe('banned_ips = ["192.168.0.1", "192.168.0.2"]\n'); }).pipe(Effect.provide(layer)); }); @@ -93,7 +94,7 @@ describe("network-bans get integration", () => { const { layer, out } = setup({ outputFlag: "yaml" }); return Effect.gen(function* () { yield* networkBansGet({ projectRef: Option.none() }); - expect(out.stderrText).toBe("DB banned IPs:\n"); + expect(out.stderrText).toBe(unverifiedTargetBanner() + "DB banned IPs:\n"); expect(out.stdoutText).toContain("192.168.0.1"); expect(out.stdoutText).toContain("192.168.0.2"); }).pipe(Effect.provide(layer)); @@ -103,7 +104,7 @@ describe("network-bans get integration", () => { const { layer, out } = setup({ outputFlag: "json" }); return Effect.gen(function* () { yield* networkBansGet({ projectRef: Option.none() }); - expect(out.stderrText).toBe("DB banned IPs:\n"); + expect(out.stderrText).toBe(unverifiedTargetBanner() + "DB banned IPs:\n"); expect(out.stdoutText).toBe( `[ "192.168.0.1", @@ -118,7 +119,7 @@ describe("network-bans get integration", () => { const { layer, out } = setup({ outputFlag: "pretty" }); return Effect.gen(function* () { yield* networkBansGet({ projectRef: Option.none() }); - expect(out.stderrText).toBe("DB banned IPs:\n"); + expect(out.stderrText).toBe(unverifiedTargetBanner() + "DB banned IPs:\n"); expect(out.stdoutText).toBe( `[ "192.168.0.1", @@ -134,7 +135,7 @@ describe("network-bans get integration", () => { return Effect.gen(function* () { const exit = yield* Effect.exit(networkBansGet({ projectRef: Option.none() })); expect(Exit.isFailure(exit)).toBe(true); - expect(out.stderrText).toBe("DB banned IPs:\n"); + expect(out.stderrText).toBe(unverifiedTargetBanner() + "DB banned IPs:\n"); if (Exit.isFailure(exit)) { const causeText = Cause.pretty(exit.cause); expect(causeText).toContain("NetworkBansEnvNotSupportedError"); @@ -153,7 +154,7 @@ describe("network-bans get integration", () => { banned_ipv4_addresses: ["192.168.0.1", "192.168.0.2"], }); // TS-native machine-readable modes skip the stderr heading. - expect(out.stderrText).toBe(""); + expect(out.stderrText).toBe(unverifiedTargetBanner()); }).pipe(Effect.provide(layer)); }); @@ -173,7 +174,7 @@ describe("network-bans get integration", () => { const { layer, out } = setup({ format: "json", outputFlag: "toml" }); return Effect.gen(function* () { yield* networkBansGet({ projectRef: Option.none() }); - expect(out.stderrText).toBe("DB banned IPs:\n"); + expect(out.stderrText).toBe(unverifiedTargetBanner() + "DB banned IPs:\n"); expect(out.stdoutText).toBe('banned_ips = ["192.168.0.1", "192.168.0.2"]\n'); }).pipe(Effect.provide(layer)); }); diff --git a/apps/cli/src/commands/postgres-config/postgres-config.integration.test.ts b/apps/cli/src/commands/postgres-config/postgres-config.integration.test.ts index 65ee2d3026..fcdda400ce 100644 --- a/apps/cli/src/commands/postgres-config/postgres-config.integration.test.ts +++ b/apps/cli/src/commands/postgres-config/postgres-config.integration.test.ts @@ -12,6 +12,7 @@ import { mockCommandPlatformApi, mockTelemetryStateTracked, useTempWorkdir, + unverifiedTargetBanner, } from "../../../tests/helpers/command-mocks.ts"; import { postgresConfigDelete } from "./delete/delete.handler.ts"; import { postgresConfigGet } from "./get/get.handler.ts"; @@ -50,7 +51,8 @@ describe("postgres-config get", () => { yield* postgresConfigGet({ projectRef: Option.none() }); expect(out.progressEvents[0]?.message).toBe("Fetching Postgres config..."); expect(out.stderrText).toBe( - "- Custom Postgres Config -\n- End of Custom Postgres Config -\n", + unverifiedTargetBanner() + + "- Custom Postgres Config -\n- End of Custom Postgres Config -\n", ); expect(out.stdoutText).toContain("Parameter"); expect(out.stdoutText).toContain("max_connections"); @@ -89,7 +91,7 @@ describe("postgres-config get", () => { return Effect.gen(function* () { yield* postgresConfigGet({ projectRef: Option.none() }); expect(out.stdoutText).toBe("max_connections = 100.0\n"); - expect(out.stderrText).toBe(""); + expect(out.stderrText).toBe(unverifiedTargetBanner()); }).pipe(Effect.provide(layer)); }); @@ -156,7 +158,7 @@ describe("postgres-config get", () => { const layer = runtimeWith({ out, api, outputFlag }); yield* postgresConfigGet({ projectRef: Option.none() }).pipe(Effect.provide(layer)); expect(out.stdoutText).toContain(expected); - expect(out.stderrText).toBe(""); + expect(out.stderrText).toBe(unverifiedTargetBanner()); } }), ); @@ -199,7 +201,8 @@ describe("postgres-config get", () => { return Effect.gen(function* () { yield* postgresConfigGet({ projectRef: Option.none() }); expect(out.stderrText).toBe( - "- Custom Postgres Config -\n- End of Custom Postgres Config -\n", + unverifiedTargetBanner() + + "- Custom Postgres Config -\n- End of Custom Postgres Config -\n", ); expect(out.stdoutText).toContain("Parameter"); expect(out.stdoutText).toContain("max_connections"); @@ -286,7 +289,8 @@ describe("postgres-config update", () => { restart_database: false, }); expect(out.stderrText).toBe( - "- Custom Postgres Config -\n- End of Custom Postgres Config -\n", + unverifiedTargetBanner() + + "- Custom Postgres Config -\n- End of Custom Postgres Config -\n", ); }).pipe(Effect.provide(layer)); }, @@ -352,7 +356,7 @@ describe("postgres-config update", () => { noRestart: false, }).pipe(Effect.provide(layer)); expect(out.stdoutText).toContain(testCase.expected); - expect(out.stderrText).toBe(""); + expect(out.stderrText).toBe(unverifiedTargetBanner()); } }), ); @@ -500,7 +504,8 @@ describe("postgres-config delete", () => { }); expect(out.stdoutText).toContain("shared_buffers"); expect(out.stderrText).toBe( - "- Custom Postgres Config -\n- End of Custom Postgres Config -\n", + unverifiedTargetBanner() + + "- Custom Postgres Config -\n- End of Custom Postgres Config -\n", ); }).pipe(Effect.provide(layer)); }); @@ -562,7 +567,7 @@ describe("postgres-config delete", () => { noRestart: false, }).pipe(Effect.provide(layer)); expect(out.stdoutText).toContain(testCase.expected); - expect(out.stderrText).toBe(""); + expect(out.stderrText).toBe(unverifiedTargetBanner()); } }), ); diff --git a/apps/cli/src/commands/secrets/unset/unset.integration.test.ts b/apps/cli/src/commands/secrets/unset/unset.integration.test.ts index 2aa4d56274..719b7c44b9 100644 --- a/apps/cli/src/commands/secrets/unset/unset.integration.test.ts +++ b/apps/cli/src/commands/secrets/unset/unset.integration.test.ts @@ -14,6 +14,7 @@ import { mockCommandPlatformApi, useTempWorkdir, withConfigEnv, + unverifiedTargetBanner, } from "../../../../tests/helpers/command-mocks.ts"; import { SecretsNoArgumentsError, @@ -137,7 +138,8 @@ describe("secrets unset integration", () => { expect(methods(api)).toEqual(["DELETE"]); expect(api.requests[0]?.body).toEqual(["FOO", "BAR"]); expect(out.stderrText).toBe( - `Do you want to unset these function secrets from project ${VALID_REF}?\n • FOO\n • BAR\n\n [y/N] y\n`, + unverifiedTargetBanner() + + `Do you want to unset these function secrets from project ${VALID_REF}?\n • FOO\n • BAR\n\n [y/N] y\n`, ); expect(out.stdoutText).toBe("Finished supabase secrets unset.\n"); }).pipe(Effect.provide(layer)); @@ -150,7 +152,7 @@ describe("secrets unset integration", () => { { SUPABASE_YES: "1" }, Effect.gen(function* () { yield* unset({ names: ["FOO"] }); - expect(out.stderrText).toBe(`${FOO_PROMPT} [y/N] y\n`); + expect(out.stderrText).toBe(`${unverifiedTargetBanner()}${FOO_PROMPT} [y/N] y\n`); expect(methods(api)).toEqual(["DELETE"]); }).pipe(Effect.provide(layer)), ); @@ -190,7 +192,8 @@ describe("secrets unset integration", () => { expect(methods(api)).toEqual(["GET", "DELETE"]); expect(api.requests[1]?.body).toEqual(["FOO", "BAR"]); expect(out.stderrText).toBe( - `Do you want to unset these function secrets from project ${VALID_REF}?\n • FOO\n • BAR\n\n [y/N] y\n`, + unverifiedTargetBanner() + + `Do you want to unset these function secrets from project ${VALID_REF}?\n • FOO\n • BAR\n\n [y/N] y\n`, ); }).pipe(Effect.provide(layer)); }); @@ -202,7 +205,9 @@ describe("secrets unset integration", () => { const { layer, out, api } = setup({ yes: true, list }); return Effect.gen(function* () { yield* unset({ all: true }); - expect(out.stderrText).toBe("You have not set any function secrets, nothing to do.\n"); + expect(out.stderrText).toBe( + unverifiedTargetBanner() + "You have not set any function secrets, nothing to do.\n", + ); expect(out.stdoutText).toBe(""); expect(methods(api)).toEqual(["GET"]); }).pipe(Effect.provide(layer)); @@ -223,7 +228,7 @@ describe("secrets unset integration", () => { suggestion: expect.stringMatching(/--yes.*--dry-run/u), }); expect(api.requests).toEqual([]); - expect(out.stderrText).toBe(""); + expect(out.stderrText).toBe(unverifiedTargetBanner()); }).pipe(Effect.provide(layer)); }); @@ -233,7 +238,7 @@ describe("secrets unset integration", () => { const error = yield* unset({ names: ["FOO"] }).pipe(Effect.flip); expect(error).toBeInstanceOf(SecretsUnsetConfirmationRequiredError); expect(api.requests).toEqual([]); - expect(out.stderrText).toBe(""); + expect(out.stderrText).toBe(unverifiedTargetBanner()); }).pipe(Effect.provide(layer)); }); @@ -269,7 +274,7 @@ describe("secrets unset integration", () => { expect(out.stdoutText).toBe( `Would unset these function secrets from project ${VALID_REF}:\n • FOO\n • BAR\n`, ); - expect(out.stderrText).toBe(""); + expect(out.stderrText).toBe(unverifiedTargetBanner()); expect(api.requests).toEqual([]); }).pipe(Effect.provide(layer)); }); @@ -354,7 +359,7 @@ describe("secrets unset integration", () => { data: { project_ref: VALID_REF, count: 0, names: [], dry_run: dryRun }, }, ]); - expect(out.stderrText).toBe(""); + expect(out.stderrText).toBe(unverifiedTargetBanner()); expect(methods(api)).toEqual(["GET"]); }).pipe(Effect.provide(layer)); }, 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 5bcf23923a..5e12021d32 100644 --- a/apps/cli/src/commands/seed/buckets/buckets.integration.test.ts +++ b/apps/cli/src/commands/seed/buckets/buckets.integration.test.ts @@ -191,6 +191,14 @@ const setupSeedBuckets = Effect.fnUntraced(function* ( }), ) : Effect.succeed(projectRefRef), + resolveRef: () => + opts.linkedFails === true + ? Effect.fail( + new ProjectRefNotLinkedError({ + message: "Cannot find project ref. Have you run supabase link?", + }), + ) + : Effect.succeed({ ref: projectRefRef, source: "file" as const, matchesLinkedRef: true }), resolveOptional: () => Effect.succeed(Option.some(projectRefRef)), // An explicit `--project-ref` flag takes precedence and short-circuits // before `linkedFails`, so a test can prove the flag resolves a ref even diff --git a/apps/cli/src/commands/storage/cp/cp.command.integration.test.ts b/apps/cli/src/commands/storage/cp/cp.command.integration.test.ts index 21a2fdbf83..48404d37d0 100644 --- a/apps/cli/src/commands/storage/cp/cp.command.integration.test.ts +++ b/apps/cli/src/commands/storage/cp/cp.command.integration.test.ts @@ -18,6 +18,7 @@ import { import { makeTelemetryIdentity } from "../../../shared/telemetry/identity.ts"; import { TelemetryRuntime } from "../../../shared/telemetry/runtime.service.ts"; import { storageCommand } from "../storage.command.ts"; +import { commandTargetLayer } from "../../../shared/output/command-target.layer.ts"; // Proves `--jobs`'s negative-value rejection is wired into the real command tree, ahead of the // `--experimental` gate and the `--linked`/`--local` mutex check in the handler — not just @@ -34,6 +35,7 @@ function setup(args: ReadonlyArray) { BunServices.layer, CliOutput.layer(textCliOutputFormatter()), out.layer, + commandTargetLayer.pipe(Layer.provide(out.layer)), Layer.succeed(CliArgs, { args }), // The jobs check never reaches `storageGatewayRuntimeLayer`'s lazy cliSettings/credentials // factory, but isolate ambient env defensively anyway. diff --git a/apps/cli/src/commands/storage/storage.experimental-gate.integration.test.ts b/apps/cli/src/commands/storage/storage.experimental-gate.integration.test.ts index 6c3f1e161e..ae5409114b 100644 --- a/apps/cli/src/commands/storage/storage.experimental-gate.integration.test.ts +++ b/apps/cli/src/commands/storage/storage.experimental-gate.integration.test.ts @@ -17,6 +17,7 @@ import { isolatedHomeLayer, useTempWorkdir } from "../../../tests/helpers/comman import { ExperimentalRequiredError } from "../../command-internal/experimental-gate.ts"; import { storageCommand } from "./storage.command.ts"; import { StorageMutuallyExclusiveFlagsError } from "./storage.errors.ts"; +import { commandTargetLayer } from "../../shared/output/command-target.layer.ts"; const tempRoot = useTempWorkdir("supabase-storage-experimental-int-"); @@ -31,6 +32,7 @@ function setup(args: ReadonlyArray) { BunServices.layer, CliOutput.layer(textCliOutputFormatter()), out.layer, + commandTargetLayer.pipe(Layer.provide(out.layer)), Layer.succeed(CliArgs, { args }), // Isolates ambient env/homeDir defensively even though neither check here reaches // storageGatewayRuntimeLayer's lazy cliSettings/credentials factory. diff --git a/apps/cli/src/commands/vanity-subdomains/vanity-subdomains.integration.test.ts b/apps/cli/src/commands/vanity-subdomains/vanity-subdomains.integration.test.ts index 5b83db0d6c..c133979b57 100644 --- a/apps/cli/src/commands/vanity-subdomains/vanity-subdomains.integration.test.ts +++ b/apps/cli/src/commands/vanity-subdomains/vanity-subdomains.integration.test.ts @@ -16,6 +16,7 @@ import { mockCommandPlatformApi, mockTelemetryStateTracked, useTempWorkdir, + unverifiedTargetBanner, } from "../../../tests/helpers/command-mocks.ts"; import { vanitySubdomainsActivate } from "./activate/activate.handler.ts"; import { vanitySubdomainsCheckAvailability } from "./check-availability/check-availability.handler.ts"; @@ -634,7 +635,9 @@ describe("vanity-subdomains delete", () => { return Effect.gen(function* () { yield* vanitySubdomainsDelete({ projectRef: Option.none() }); - expect(out.stderrText).toBe("Deleted vanity subdomain successfully.\n"); + expect(out.stderrText).toBe( + unverifiedTargetBanner() + "Deleted vanity subdomain successfully.\n", + ); expect(api.requests[0]?.method).toBe("DELETE"); expect(api.requests[0]?.url).toContain(`/v1/projects/${VALID_REF}/vanity-subdomain`); }).pipe(Effect.provide(layer)); @@ -659,7 +662,9 @@ describe("vanity-subdomains delete", () => { return Effect.gen(function* () { yield* vanitySubdomainsDelete({ projectRef: Option.none() }); - expect(out.stderrText).toBe("Deleted vanity subdomain successfully.\n"); + expect(out.stderrText).toBe( + unverifiedTargetBanner() + "Deleted vanity subdomain successfully.\n", + ); expect(out.messages.find((m) => m.type === "success")).toBeUndefined(); }).pipe(Effect.provide(layer)); }); diff --git a/apps/cli/src/config/project-ref.layer.integration.test.ts b/apps/cli/src/config/project-ref.layer.integration.test.ts index 5fcb71d55c..c01c69e21f 100644 --- a/apps/cli/src/config/project-ref.layer.integration.test.ts +++ b/apps/cli/src/config/project-ref.layer.integration.test.ts @@ -14,6 +14,7 @@ import { } from "../../tests/helpers/command-mocks.ts"; import { mockOutput, mockTty } from "../../tests/helpers/mocks.ts"; import { assertBlockDrift } from "../../tests/helpers/permission-drift.ts"; +import { commandTargetLayer } from "../shared/output/command-target.layer.ts"; import { projectRefLayer, projectRefPicker } from "./project-ref.layer.ts"; import { ProjectRefResolver } from "./project-ref.service.ts"; @@ -51,6 +52,7 @@ describe("projectRefLayer Management API requests", () => { ), Layer.provide(mockTty({ stdinIsTty: true })), Layer.provide(output.layer), + Layer.provide(commandTargetLayer.pipe(Layer.provide(output.layer))), Layer.provide(api.factoryLayer), Layer.provide(BunServices.layer), ); diff --git a/apps/cli/src/config/project-ref.layer.ts b/apps/cli/src/config/project-ref.layer.ts index 0db05959ee..0c1fe2db99 100644 --- a/apps/cli/src/config/project-ref.layer.ts +++ b/apps/cli/src/config/project-ref.layer.ts @@ -1,6 +1,7 @@ import { Effect, FileSystem, Layer, Option, Path } from "effect"; import { CommandPlatformApiFactory } from "../auth/command-platform-api-factory.service.ts"; +import { CommandTarget } from "../shared/output/command-target.service.ts"; import { Output } from "../shared/output/output.service.ts"; import { Tty } from "../shared/runtime/tty.service.ts"; import { readProjectRefFile } from "../shared/config/temp-paths.ts"; @@ -15,8 +16,12 @@ import { ProjectRefResolver, PROJECT_NOT_LINKED_MESSAGE, PROJECT_REF_PATTERN, + type ProjectRefSource, + type ResolvedProjectRef, } from "./project-ref.service.ts"; import { block } from "../command-internal/command-permissions/model.ts"; +import { readCachedLinkedProject } from "../command-internal/parent-project-ref.ts"; +import { describeLinkedTarget } from "../command-internal/target/target-identity.ts"; /** `ProjectRefResolver`'s interactive picker, reached only when no ref comes from a flag, env or the linked workdir. */ export const projectRefPicker = block("project-ref", [ @@ -45,6 +50,7 @@ export const projectRefLayer = Layer.effect( const tty = yield* Tty; const output = yield* Output; const platformApi = yield* CommandPlatformApiFactory; + const commandTarget = yield* CommandTarget; const readRefFile = readProjectRefFile(fs, path, cliSettings.workdir); @@ -87,45 +93,62 @@ export const projectRefLayer = Layer.effect( return chosen; }); + const announceLinked = Effect.fnUntraced(function* (ref: string, source: ProjectRefSource) { + const cache = yield* readCachedLinkedProject(fs, path, cliSettings.workdir); + yield* commandTarget.announce(describeLinkedTarget({ ref, source, cache })); + }); + + const resolveRef = Effect.fnUntraced(function* (flagValue: Option.Option) { + let resolved: { readonly ref: string; readonly source: ProjectRefSource }; + let linkedRef = Option.none(); + if (Option.isSome(flagValue) && flagValue.value.length > 0) { + resolved = { ref: yield* assertValid(flagValue.value), source: "flag" }; + } else if (Option.isSome(cliSettings.projectId)) { + resolved = { ref: yield* assertValid(cliSettings.projectId.value), source: "env" }; + } else { + linkedRef = yield* readRefFile; + if (Option.isSome(linkedRef)) { + resolved = { ref: yield* assertValid(linkedRef.value), source: "file" }; + } else if (tty.stdinIsTty && output.interactive) { + const chosen = yield* promptForProjectRef("Select a project:"); + resolved = { ref: yield* assertValid(chosen), source: "picker" }; + } else { + return yield* new ProjectRefNotLinkedError({ message: PROJECT_NOT_LINKED_MESSAGE }); + } + } + if (resolved.source === "flag" || resolved.source === "env") { + linkedRef = yield* readRefFile.pipe(Effect.orElseSucceed(() => Option.none())); + } + yield* announceLinked(resolved.ref, resolved.source); + return { + ...resolved, + matchesLinkedRef: Option.isSome(linkedRef) && linkedRef.value === resolved.ref, + } satisfies ResolvedProjectRef; + }); + return ProjectRefResolver.of({ - resolve: (flagValue) => - Effect.gen(function* () { - if (Option.isSome(flagValue) && flagValue.value.length > 0) { - return yield* assertValid(flagValue.value); - } - if (Option.isSome(cliSettings.projectId)) { - return yield* assertValid(cliSettings.projectId.value); - } - const fileValue = yield* readRefFile; - if (Option.isSome(fileValue)) { - return yield* assertValid(fileValue.value); - } - if (tty.stdinIsTty && output.interactive) { - const chosen = yield* promptForProjectRef("Select a project:"); - return yield* assertValid(chosen); - } - return yield* Effect.fail( - new ProjectRefNotLinkedError({ message: PROJECT_NOT_LINKED_MESSAGE }), - ); - }), + resolveRef, + resolve: (flagValue) => resolveRef(flagValue).pipe(Effect.map((resolved) => resolved.ref)), resolveForLink: (flagValue) => Effect.gen(function* () { + let resolved: { readonly ref: string; readonly source: ProjectRefSource }; if (Option.isSome(flagValue) && flagValue.value.length > 0) { - return yield* assertValid(flagValue.value); - } - if (Option.isSome(cliSettings.projectId)) { - return yield* assertValid(cliSettings.projectId.value); - } - // `resolveForLink` skips the ref-file fallback that `resolve` uses. - if (tty.stdinIsTty && output.interactive) { + resolved = { ref: yield* assertValid(flagValue.value), source: "flag" }; + } else if (Option.isSome(cliSettings.projectId)) { + resolved = { ref: yield* assertValid(cliSettings.projectId.value), source: "env" }; + } else if (tty.stdinIsTty && output.interactive) { + // `resolveForLink` skips the ref-file fallback that `resolveRef` uses. const chosen = yield* promptForProjectRef("Select a project:"); - return yield* assertValid(chosen); + resolved = { ref: yield* assertValid(chosen), source: "picker" }; + } else { + return yield* Effect.fail( + new ProjectRefRequiredError({ + message: `required flag(s) "project-ref" not set`, + }), + ); } - return yield* Effect.fail( - new ProjectRefRequiredError({ - message: `required flag(s) "project-ref" not set`, - }), - ); + yield* announceLinked(resolved.ref, resolved.source); + return resolved.ref; }), resolveOptional: (flagValue) => Effect.gen(function* () { 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 e9535d3c48..426f0170fa 100644 --- a/apps/cli/src/config/project-ref.layer.unit.test.ts +++ b/apps/cli/src/config/project-ref.layer.unit.test.ts @@ -11,6 +11,7 @@ import { afterEach, beforeEach } from "vitest"; import { CommandPlatformApiFactory } from "../auth/command-platform-api-factory.service.ts"; import { CommandPlatformApi } from "../auth/command-platform-api.service.ts"; import { mockOutput, mockTty } from "../../tests/helpers/mocks.ts"; +import { commandTargetLayer } from "../shared/output/command-target.layer.ts"; import { CommandSettings } from "./command-settings.service.ts"; import { ProjectRefResolver } from "./project-ref.service.ts"; import { projectRefLayer } from "./project-ref.layer.ts"; @@ -74,6 +75,7 @@ function makeLayer(opts: { Layer.provide(mockCliSettings(opts)), Layer.provide(mockTty({ stdinIsTty: opts.stdinIsTty ?? false, stdoutIsTty: false })), Layer.provide(out.layer), + Layer.provide(commandTargetLayer.pipe(Layer.provide(out.layer))), Layer.provide( Layer.succeed(CommandPlatformApiFactory, { make: CommandPlatformApi.pipe(Effect.provide(mockPlatformApi(opts.projects ?? []))), @@ -393,6 +395,127 @@ describe("projectRefLayer", () => { }); }); + describe("resolveRef", () => { + const writeLinkedCache = (workdir: string, cache: { ref: string; name?: string }) => { + const tempDir = join(workdir, "supabase", ".temp"); + mkdirSync(tempDir, { recursive: true }); + writeFileSync(join(tempDir, "linked-project.json"), JSON.stringify(cache)); + }; + const banner = (text: string) => `Target: ${text}\n`; + + it.effect("reports the flag as its source and announces an unverified project", () => { + const { layer, out } = makeLayer({ workdir: tempRoot }); + return Effect.gen(function* () { + const { resolveRef } = yield* ProjectRefResolver; + const resolved = yield* resolveRef(Option.some(VALID_REF)); + expect(resolved).toEqual({ ref: VALID_REF, source: "flag", matchesLinkedRef: false }); + expect(out.stderrText).toBe(banner(`project ${VALID_REF} · identity unverified`)); + }).pipe(Effect.provide(layer)); + }); + + it.effect("reports SUPABASE_PROJECT_ID as the env source", () => { + const { layer } = makeLayer({ workdir: tempRoot, projectId: VALID_REF }); + return Effect.gen(function* () { + const { resolveRef } = yield* ProjectRefResolver; + const resolved = yield* resolveRef(Option.none()); + expect(resolved).toEqual({ ref: VALID_REF, source: "env", matchesLinkedRef: false }); + }).pipe(Effect.provide(layer)); + }); + + it.effect("names the production project from the linked-project cache", () => { + writeRefFile(tempRoot, VALID_REF); + writeLinkedCache(tempRoot, { ref: VALID_REF, name: "alpha" }); + const { layer, out } = makeLayer({ workdir: tempRoot }); + return Effect.gen(function* () { + const { resolveRef } = yield* ProjectRefResolver; + const resolved = yield* resolveRef(Option.none()); + expect(resolved).toEqual({ ref: VALID_REF, source: "file", matchesLinkedRef: true }); + expect(out.stderrText).toBe(banner(`project alpha (${VALID_REF}) · production`)); + }).pipe(Effect.provide(layer)); + }); + + it.effect("treats a saved ref that differs from the cached project as its branch", () => { + writeRefFile(tempRoot, ANOTHER_REF); + writeLinkedCache(tempRoot, { ref: VALID_REF, name: "alpha" }); + const { layer, out } = makeLayer({ workdir: tempRoot }); + return Effect.gen(function* () { + const { resolveRef } = yield* ProjectRefResolver; + yield* resolveRef(Option.none()); + expect(out.stderrText).toBe( + banner(`project alpha (${VALID_REF}) · branch (${ANOTHER_REF})`), + ); + }).pipe(Effect.provide(layer)); + }); + + it.effect("leaves a flag ref outside the linked project unverified", () => { + writeRefFile(tempRoot, VALID_REF); + writeLinkedCache(tempRoot, { ref: VALID_REF, name: "alpha" }); + const { layer, out } = makeLayer({ workdir: tempRoot }); + return Effect.gen(function* () { + const { resolveRef } = yield* ProjectRefResolver; + const resolved = yield* resolveRef(Option.some(ANOTHER_REF)); + expect(resolved.matchesLinkedRef).toBe(false); + expect(out.stderrText).toBe(banner(`project ${ANOTHER_REF} · identity unverified`)); + }).pipe(Effect.provide(layer)); + }); + + it.effect("marks a flag ref equal to the saved ref as matching the linked project", () => { + writeRefFile(tempRoot, VALID_REF); + const { layer } = makeLayer({ workdir: tempRoot }); + return Effect.gen(function* () { + const { resolveRef } = yield* ProjectRefResolver; + const resolved = yield* resolveRef(Option.some(VALID_REF)); + expect(resolved).toEqual({ ref: VALID_REF, source: "flag", matchesLinkedRef: true }); + }).pipe(Effect.provide(layer)); + }); + + it.effect("reports the picker as its source on a TTY", () => { + const projects = [ + { id: ANOTHER_REF, name: "beta", organization_slug: "acme", region: "eu-west-1" }, + ]; + const { layer } = makeLayer({ + workdir: tempRoot, + stdinIsTty: true, + projects, + promptSelectResponses: [ANOTHER_REF], + }); + return Effect.gen(function* () { + const { resolveRef } = yield* ProjectRefResolver; + const resolved = yield* resolveRef(Option.none()); + expect(resolved).toEqual({ ref: ANOTHER_REF, source: "picker", matchesLinkedRef: false }); + }).pipe(Effect.provide(layer)); + }); + + it.effect("announces once when the same command resolves twice", () => { + const { layer, out } = makeLayer({ workdir: tempRoot }); + return Effect.gen(function* () { + const { resolve } = yield* ProjectRefResolver; + yield* resolve(Option.some(VALID_REF)); + yield* resolve(Option.some(VALID_REF)); + expect(out.stderrText).toBe(banner(`project ${VALID_REF} · identity unverified`)); + }).pipe(Effect.provide(layer)); + }); + + it.effect("announces nothing when no ref resolves", () => { + const { layer, out } = makeLayer({ workdir: tempRoot }); + return Effect.gen(function* () { + const { resolveRef } = yield* ProjectRefResolver; + const exit = yield* Effect.exit(resolveRef(Option.none())); + expect(Exit.isFailure(exit)).toBe(true); + expect(out.stderrText).toBe(""); + }).pipe(Effect.provide(layer)); + }); + + it.effect("announces the target for resolveForLink", () => { + const { layer, out } = makeLayer({ workdir: tempRoot }); + return Effect.gen(function* () { + const { resolveForLink } = yield* ProjectRefResolver; + yield* resolveForLink(Option.some(VALID_REF)); + expect(out.stderrText).toBe(banner(`project ${VALID_REF} · identity unverified`)); + }).pipe(Effect.provide(layer)); + }); + }); + describe("promptProjectRef", () => { it.effect("prompts with the given title, returns the choice, and echoes it", () => { const projects = [ diff --git a/apps/cli/src/config/project-ref.service.ts b/apps/cli/src/config/project-ref.service.ts index 5e35d145d5..63bfe97d84 100644 --- a/apps/cli/src/config/project-ref.service.ts +++ b/apps/cli/src/config/project-ref.service.ts @@ -9,7 +9,30 @@ import type { ProjectRefRequiredError, } from "./project-ref.errors.ts"; +/** Where a resolved project ref came from, in chain order. */ +export type ProjectRefSource = "flag" | "env" | "file" | "picker"; + +export interface ResolvedProjectRef { + readonly ref: string; + readonly source: ProjectRefSource; + /** Whether `ref` is the project linked in `supabase/.temp/project-ref`. */ + readonly matchesLinkedRef: boolean; +} + interface ProjectRefResolverShape { + /** + * The single resolution chain: `--project-ref` → `SUPABASE_PROJECT_ID` → `.temp/project-ref` + * → project picker (stdin TTY and interactive output only), otherwise `ProjectRefNotLinkedError`. + * Announces the resolved target. + */ + readonly resolveRef: ( + flagValue: Option.Option, + ) => Effect.Effect< + ResolvedProjectRef, + ProjectRefNotLinkedError | InvalidProjectRefError | ProjectRefReadError, + never + >; + /** `resolveRef`, narrowed to the ref. */ readonly resolve: ( flagValue: Option.Option, ) => Effect.Effect< @@ -21,7 +44,7 @@ interface ProjectRefResolverShape { * Resolution chain used by `supabase link`, skipping the on-disk `project-ref` file: * flag → `cliSettings.projectId` (env `SUPABASE_PROJECT_ID`) → (TTY) prompt. Fails with * `ProjectRefRequiredError` on a non-TTY when neither is set, with the - * `required flag(s) "project-ref" not set` wording. + * `required flag(s) "project-ref" not set` wording. Announces the resolved target. */ readonly resolveForLink: ( flagValue: Option.Option, @@ -42,8 +65,9 @@ interface ProjectRefResolverShape { /** * Non-prompting resolution chain (flag → `cliSettings.projectId` → ref file) that fails * hard with `ProjectRefNotLinkedError` when nothing resolves, with ref-format validation. - * Used by the `--linked` PreRun of the `db` command family, so a run with a token but no - * linked-project file fails fast instead of opening a project picker. + * Announces no target. + * + * @deprecated Use `resolveRef`, or `resolveTarget` for database and storage commands. */ readonly loadProjectRef: ( flagValue: Option.Option, diff --git a/apps/cli/src/shared/cli/hidden-flag.unit.test.ts b/apps/cli/src/shared/cli/hidden-flag.unit.test.ts index c3a4ee01af..57c08ee1c0 100644 --- a/apps/cli/src/shared/cli/hidden-flag.unit.test.ts +++ b/apps/cli/src/shared/cli/hidden-flag.unit.test.ts @@ -30,6 +30,7 @@ import { mockTty, processEnvLayer, } from "../../../tests/helpers/mocks.ts"; +import { commandTargetLayer } from "../output/command-target.layer.ts"; import { textCliOutputFormatter } from "../output/text-formatter.ts"; import { CliArgs } from "./cli-args.service.ts"; @@ -41,11 +42,13 @@ const buildHelpDoc = ( cmd: Command.Command, ): HelpDoc.HelpDoc => (cmd as unknown as CommandImpl).buildHelpDoc([]); -const testRootLayer = (formatter: CliOutput.Formatter, args: ReadonlyArray) => - Layer.mergeAll( +const testRootLayer = (formatter: CliOutput.Formatter, args: ReadonlyArray) => { + const out = mockOutput({ format: "text" }); + return Layer.mergeAll( CliOutput.layer(formatter), Layer.succeed(CliArgs, { args }), - mockOutput({ format: "text" }).layer, + out.layer, + commandTargetLayer.pipe(Layer.provide(out.layer)), BunServices.layer, mockRuntimeInfo(), mockAnalytics().layer, @@ -53,6 +56,7 @@ const testRootLayer = (formatter: CliOutput.Formatter, args: ReadonlyArray { + const out = mockOutput({ format: "text" }); + return Layer.mergeAll(out.layer, commandTargetLayer.pipe(Layer.provide(out.layer))); +}; + const testBranchesCommand = branchesCommand.pipe( Command.withGlobalFlags([OutputFormatFlag, ...GLOBAL_FLAGS]), ); @@ -23,7 +29,7 @@ describe("group command exit codes (CLI-1906)", () => { Layer.mergeAll( CliOutput.layer(textCliOutputFormatter()), Layer.succeed(CliArgs, { args }), - mockOutput({ format: "text" }).layer, + textOutputLayer(), emptyEnv(), ); @@ -82,7 +88,7 @@ describe("withoutParseErrorHelpDump (CLI-1901)", () => { CliOutput.layer(textCliOutputFormatter()), Layer.succeed(CliArgs, { args }), Layer.succeed(Console.Console, console), - mockOutput({ format: "text" }).layer, + textOutputLayer(), emptyEnv(), ); @@ -249,7 +255,7 @@ describe("nested command parsing", () => { Layer.mergeAll( CliOutput.layer(textCliOutputFormatter()), Layer.succeed(CliArgs, { args }), - mockOutput({ format: "text" }).layer, + textOutputLayer(), emptyEnv(), ), ), diff --git a/apps/cli/tests/helpers/command-mocks.ts b/apps/cli/tests/helpers/command-mocks.ts index 14af1df36f..c76cc99091 100644 --- a/apps/cli/tests/helpers/command-mocks.ts +++ b/apps/cli/tests/helpers/command-mocks.ts @@ -57,6 +57,7 @@ import { PGDATA_PATH, } from "../../src/command-internal/db-bootstrap/pgdata-snapshot.ts"; import { projectRefLayer } from "../../src/config/project-ref.layer.ts"; +import { commandTargetLayer } from "../../src/shared/output/command-target.layer.ts"; import { LocalDockerEngine } from "../../src/command-internal/db-bootstrap/local-db-running.ts"; import { LinkedProjectCache } from "../../src/telemetry/linked-project-cache.service.ts"; import { TelemetryState } from "../../src/telemetry/telemetry-state.service.ts"; @@ -81,6 +82,11 @@ import { // Fixtures shared across every native-port integration test. Centralized so a change to the // project-ref schema (e.g. updated length) only needs to update one constant. export const VALID_REF = "abcdefghijklmnopqrst"; + +/** The stderr banner a command prints for a linked project with no `linked-project.json` cache. */ +export const unverifiedTargetBanner = (ref: string = VALID_REF): string => + `Target: project ${ref} · identity unverified\n`; + export const VALID_TOKEN = "sbp_" + "a".repeat(40); export const DEFAULT_API_URL = "https://api.supabase.com"; const DEFAULT_USER_AGENT = "SupabaseCLI/0.0.0-dev"; @@ -1471,9 +1477,11 @@ export function buildTestRuntime(opts: BuildTestRuntimeOpts) { const topLevelFactory = Layer.succeed(CommandPlatformApiFactory, { make: CommandPlatformApi.pipe(Effect.provide(opts.api.layer)), }); + const commandTarget = commandTargetLayer.pipe(Layer.provide(opts.out.layer)); return Layer.mergeAll( opts.out.layer, + commandTarget, opts.api.layer, topLevelFactory, opts.cliSettings, @@ -1490,6 +1498,7 @@ export function buildTestRuntime(opts: BuildTestRuntimeOpts) { Layer.provide(opts.cliSettings), Layer.provide(tty), Layer.provide(opts.out.layer), + Layer.provide(commandTarget), Layer.provide(BunServices.layer), ), BunServices.layer, diff --git a/apps/cli/tests/helpers/compute.ts b/apps/cli/tests/helpers/compute.ts index 4ddb16ba48..f2f075315d 100644 --- a/apps/cli/tests/helpers/compute.ts +++ b/apps/cli/tests/helpers/compute.ts @@ -373,18 +373,25 @@ const testCliConfigLayer = (workdir: string, explicitWorkdir: boolean) => ).pipe(Layer.provide(BunServices.layer)); /** The resolver, stubbed: `--project-ref` wins, else the linked project. */ -const testProjectRefLayer = (linked: boolean) => - Layer.succeed(ProjectRefResolver, { - resolve: (flagValue: Option.Option) => - Option.isSome(flagValue) - ? Effect.succeed(flagValue.value) - : linked - ? Effect.succeed(COMPUTE_PROJECT_REF) - : Effect.fail( - new ProjectRefNotLinkedError({ - message: "Cannot find project ref. Have you run supabase link?", - }), - ), +const testProjectRefLayer = (linked: boolean) => { + const resolve = ( + flagValue: Option.Option, + ): Effect.Effect => + Option.isSome(flagValue) + ? Effect.succeed(flagValue.value) + : linked + ? Effect.succeed(COMPUTE_PROJECT_REF) + : Effect.fail( + new ProjectRefNotLinkedError({ + message: "Cannot find project ref. Have you run supabase link?", + }), + ); + return Layer.succeed(ProjectRefResolver, { + resolve, + resolveRef: (flagValue: Option.Option) => + resolve(flagValue).pipe( + Effect.map((ref) => ({ ref, source: "file" as const, matchesLinkedRef: true })), + ), resolveForLink: () => Effect.fail(new ProjectRefNotLinkedError({ message: "Not available in compute tests" })), resolveOptional: () => @@ -394,6 +401,7 @@ const testProjectRefLayer = (linked: boolean) => promptProjectRef: () => Effect.fail(new ProjectRefNotLinkedError({ message: "Not available in compute tests" })), }); +}; export interface ComputeSetupOptions { readonly workdir: string; diff --git a/apps/cli/tests/helpers/notebooks.ts b/apps/cli/tests/helpers/notebooks.ts index 0e6d275cbf..bd938add82 100644 --- a/apps/cli/tests/helpers/notebooks.ts +++ b/apps/cli/tests/helpers/notebooks.ts @@ -222,7 +222,7 @@ export function notebooksProject(dir: string) { function testProjectRefLayer(linked: boolean) { const optional = (flag: Option.Option) => Option.orElse(flag, () => (linked ? Option.some(NOTEBOOKS_PROJECT_REF) : Option.none())); - const resolve = (flag: Option.Option) => + const resolve = (flag: Option.Option): Effect.Effect => Option.match(optional(flag), { onSome: Effect.succeed, onNone: () => @@ -234,6 +234,10 @@ function testProjectRefLayer(linked: boolean) { }); return Layer.succeed(ProjectRefResolver, { resolve, + resolveRef: (flag) => + resolve(flag).pipe( + Effect.map((ref) => ({ ref, source: "file" as const, matchesLinkedRef: true })), + ), resolveForLink: resolve, resolveOptional: (flag) => Effect.succeed(optional(flag)), loadProjectRef: resolve, diff --git a/apps/cli/tests/helpers/permission-real-layers.ts b/apps/cli/tests/helpers/permission-real-layers.ts index ad6abc4ef8..784f81112b 100644 --- a/apps/cli/tests/helpers/permission-real-layers.ts +++ b/apps/cli/tests/helpers/permission-real-layers.ts @@ -15,6 +15,7 @@ import { } from "../../src/command-internal/global-flags.ts"; import { identityStitchLayer } from "../../src/command-internal/identity-stitch.ts"; import type { CommandSettings } from "../../src/config/command-settings.service.ts"; +import { commandTargetLayer } from "../../src/shared/output/command-target.layer.ts"; import { DebugLogger } from "../../src/shared/output/debug-logger.service.ts"; import { linkedProjectCacheLayer } from "../../src/telemetry/linked-project-cache.layer.ts"; import type { LinkedProjectCache } from "../../src/telemetry/linked-project-cache.service.ts"; @@ -64,6 +65,7 @@ export function realDbConfigResolver(opts: { readonly dbConnection: Layer.Layer; readonly env: Record; }): Layer.Layer { + const out = mockOutput(); const deps = Layer.mergeAll( mockCommandSettings({ workdir: opts.workdir, @@ -72,7 +74,8 @@ export function realDbConfigResolver(opts: { }), opts.dbConnection, Layer.succeed(DebugLogger, { debug: () => Effect.void, http: () => Effect.void }), - mockOutput().layer, + out.layer, + commandTargetLayer.pipe(Layer.provide(out.layer)), mockAnalytics().layer, mockTelemetryRuntime(), mockTty(), diff --git a/apps/cli/tests/helpers/permission-registry.ts b/apps/cli/tests/helpers/permission-registry.ts index 47002f010c..1dd07198cc 100644 --- a/apps/cli/tests/helpers/permission-registry.ts +++ b/apps/cli/tests/helpers/permission-registry.ts @@ -28,7 +28,7 @@ export const DB_TARGET_FLAGS = [ "linked", ] as const satisfies ReadonlyArray; -/** The target `activeFlags` selects (`db-url`, then `local`, then `linked`), else `fallback`. */ +/** The target `activeFlags` selects (`db-url`, `local`, `linked`, or `project-ref`, which implies linked), else `fallback`. */ function dbTargetOf( activeFlags: ActiveFlags, fallback: DbTarget | undefined, @@ -36,6 +36,7 @@ function dbTargetOf( for (const flag of DB_TARGET_FLAGS) { if (Object.hasOwn(activeFlags, flag)) return flag; } + if (Object.hasOwn(activeFlags, "project-ref")) return "linked"; return fallback; } diff --git a/apps/cli/tests/helpers/storage.ts b/apps/cli/tests/helpers/storage.ts index 716f30b25d..8ca1562730 100644 --- a/apps/cli/tests/helpers/storage.ts +++ b/apps/cli/tests/helpers/storage.ts @@ -390,6 +390,10 @@ export function setupStorage(workdir: string, opts: SetupStorageOptions) { const projectRefLayer = Layer.succeed(ProjectRefResolver, { resolve: () => opts.linkedFails === true ? Effect.fail(notLinked()) : Effect.succeed(projectRefRef), + resolveRef: () => + opts.linkedFails === true + ? Effect.fail(notLinked()) + : Effect.succeed({ ref: projectRefRef, source: "file" as const, matchesLinkedRef: true }), resolveForLink: () => opts.linkedFails === true ? Effect.fail(notLinked()) : Effect.succeed(projectRefRef), resolveOptional: () => Effect.succeed(Option.some(projectRefRef)), From 617fa76d2c9cc381d8b8863d427e914471c0c176 Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Thu, 15 Oct 2026 13:41:49 +0100 Subject: [PATCH 09/92] chore(cli): format the target policy test (CLI-2695) --- .../target/target-policy.unit.test.ts | 23 ++++++++----------- 1 file changed, 10 insertions(+), 13 deletions(-) diff --git a/apps/cli/src/command-internal/target/target-policy.unit.test.ts b/apps/cli/src/command-internal/target/target-policy.unit.test.ts index 1a7d2b8817..70ed587733 100644 --- a/apps/cli/src/command-internal/target/target-policy.unit.test.ts +++ b/apps/cli/src/command-internal/target/target-policy.unit.test.ts @@ -127,16 +127,15 @@ describe.each(leavesByVariant)( sync: "linked", selectable: "local", }; - const offenders = withPolicy - .flatMap(({ path, node, policy }) => { - const permissions = Option.getOrUndefined(readPermissions(node.command)); - if (permissions?.status !== "mapped" || permissions.dbTargetDefault === undefined) - return []; - const want = expected[policy.group]; - return want === undefined || permissions.dbTargetDefault === want - ? [] - : [`${path}: ${policy.group} defaults to ${permissions.dbTargetDefault}`]; - }); + const offenders = withPolicy.flatMap(({ path, node, policy }) => { + const permissions = Option.getOrUndefined(readPermissions(node.command)); + if (permissions?.status !== "mapped" || permissions.dbTargetDefault === undefined) + return []; + const want = expected[policy.group]; + return want === undefined || permissions.dbTargetDefault === want + ? [] + : [`${path}: ${policy.group} defaults to ${permissions.dbTargetDefault}`]; + }); expect(offenders).toEqual([]); }); @@ -172,9 +171,7 @@ describe.each(leavesByVariant)( describe("target policy allowlists", () => { it("name only real leaves", () => { - const stale = [...FLAG_MIGRATION_PENDING].filter( - (path) => !everyLeafPath.has(path), - ); + const stale = [...FLAG_MIGRATION_PENDING].filter((path) => !everyLeafPath.has(path)); expect(stale).toEqual([]); }); From 192c26a7ee67c3e28ee40de595ec6d4bf727c50f Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Thu, 15 Oct 2026 13:41:52 +0100 Subject: [PATCH 10/92] feat(cli): resolve database targets and connections without defaults (CLI-2695) Add resolveTarget for sync and selectable commands and DbConfigResolver.resolveConnection. It takes an already-resolved target, rejects --password unless the target is linked, announces the local and --db-url targets, and only removes network bans when the caller holds an approval. The existing resolve method keeps its behaviour. --- .../src/command-internal/db-config.errors.ts | 14 + .../db-config.integration.test.ts | 322 +++++++++++++- .../src/command-internal/db-config.layer.ts | 399 +++++++++++------- .../src/command-internal/db-config.service.ts | 18 +- .../src/command-internal/db-config.types.ts | 19 + .../hosted-consent/hosted-consent.token.ts | 8 + .../target/resolve-target.integration.test.ts | 279 ++++++++++++ .../command-internal/target/resolve-target.ts | 86 ++++ .../test-db.integration.test.ts | 1 + .../test-db.layers.unit.test.ts | 1 + .../test-db.native.integration.test.ts | 1 + .../db/advisors/advisors.integration.test.ts | 1 + .../commands/db/diff/diff.integration.test.ts | 1 + .../commands/db/dump/dump.integration.test.ts | 1 + .../commands/db/lint/lint.integration.test.ts | 1 + .../commands/db/lint/lint.layers.unit.test.ts | 1 + .../commands/db/pull/pull.integration.test.ts | 1 + .../commands/db/push/push.integration.test.ts | 1 + .../db/query/query.integration.test.ts | 1 + .../db/reset/reset.integration.test.ts | 1 + .../generate/generate.integration.test.ts | 1 + .../declarative/sync/sync.integration.test.ts | 1 + .../commands/db/test/test.integration.test.ts | 1 + .../gen/types/types.integration.test.ts | 1 + .../db/inspect-deprecated.integration.test.ts | 1 + .../db/inspect-query.integration.test.ts | 1 + .../db/inspect-specs.integration.test.ts | 1 + .../inspect/inspect.layers.unit.test.ts | 1 + .../inspect/report/report.integration.test.ts | 1 + .../migration/down/down.integration.test.ts | 1 + .../migration/fetch/fetch.integration.test.ts | 1 + .../migration/list/list.integration.test.ts | 1 + .../repair/repair.integration.test.ts | 1 + .../squash/squash.integration.test.ts | 1 + .../squash/squash.native.integration.test.ts | 1 + .../migration/up/up.integration.test.ts | 1 + .../commands/pull/pull.integration.test.ts | 1 + .../telemetry/__fixtures__/error-tags.txt | 1 + .../shared/telemetry/error-actionability.ts | 2 + 39 files changed, 1027 insertions(+), 150 deletions(-) create mode 100644 apps/cli/src/command-internal/hosted-consent/hosted-consent.token.ts create mode 100644 apps/cli/src/command-internal/target/resolve-target.integration.test.ts create mode 100644 apps/cli/src/command-internal/target/resolve-target.ts diff --git a/apps/cli/src/command-internal/db-config.errors.ts b/apps/cli/src/command-internal/db-config.errors.ts index c0c450e4ca..8a401f8c80 100644 --- a/apps/cli/src/command-internal/db-config.errors.ts +++ b/apps/cli/src/command-internal/db-config.errors.ts @@ -153,6 +153,20 @@ export class DbConfigConnectTempRoleError extends Data.TaggedError("DbConfigConn } } +/** The temporary login role cannot connect because the requester is network-banned and removing the ban was not approved. */ +export class LinkedDbNetworkBannedError extends Data.TaggedError("LinkedDbNetworkBannedError")<{ + readonly message: string; + readonly suggestion?: string; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return { + ...actionability.dbConnection, + suggestion_type: CliSuggestionType.RunCommand, + suggested_command: "supabase network-bans remove", + }; + } +} + /** * The configured pooler connection string does not match the linked project ref, or its domain * falls outside the active profile (MITM guard). 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 f72c42ec97..e5d9118ee7 100644 --- a/apps/cli/src/command-internal/db-config.integration.test.ts +++ b/apps/cli/src/command-internal/db-config.integration.test.ts @@ -63,7 +63,9 @@ 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 { DbConfigResolver } from "./db-config.service.ts"; -import type { DbConfigFlags } from "./db-config.types.ts"; +import type { DbConfigFlags, DbConnectionInput } from "./db-config.types.ts"; +import type { HostedMutationConsent } from "./hosted-consent/hosted-consent.token.ts"; +import type { ResolvedTarget } from "./target/resolve-target.ts"; import { DbConnectError } from "./db-connection.errors.ts"; import { DbConnection, type DbSession, type PgConnInput } from "./db-connection.service.ts"; import { StackApi } from "./stack-api.ts"; @@ -101,9 +103,10 @@ function buildResolver( readonly configEnv?: Record; readonly stackApi?: Layer.Layer; readonly flagPassword?: string; + readonly output?: ReturnType; } = {}, ) { - const out = mockOutput(); + const out = opts.output ?? mockOutput(); const deps = Layer.mergeAll( Layer.succeed( CliConfigFlagInputs, @@ -1981,3 +1984,318 @@ describe("dbConfigResolver (container pooler fallback)", () => { }).pipe(Effect.scoped, Effect.provide(BunServices.layer)); }); }); + +describe("dbConfigResolver.resolveConnection", () => { + const approval = Option.some({ + action: "remove network bans", + via: "yes", + } as unknown as HostedMutationConsent); + const inputFor = ( + target: ResolvedTarget, + overrides: Partial = {}, + ): DbConnectionInput => ({ + target, + dnsResolver: "native", + password: Option.none(), + networkBanRemoval: Option.none(), + ...overrides, + }); + const resolveConnection = ( + workdir: string, + input: DbConnectionInput, + opts?: Parameters[1], + ) => + Effect.gen(function* () { + const resolver = yield* DbConfigResolver; + return yield* resolver.resolveConnection(input); + }).pipe( + Effect.provide(buildResolver(workdir, opts)), + Effect.provideServiceEffect( + ConfigProvider.ConfigProvider, + pinnedConfigProvider(explicitEnv(opts?.configEnv)), + ), + ); + const linkedTarget = ( + ref: string, + overrides: Partial> = {}, + ): ResolvedTarget => ({ + kind: "linked", + ref, + source: "file", + matchesLinkedRef: true, + ...overrides, + }); + const failureText = (exit: Exit.Exit) => + Exit.isFailure(exit) ? Cause.pretty(exit.cause) : ""; + const session: DbSession = { + exec: () => Effect.void, + execBatch: () => Effect.void, + query: () => Effect.succeed([]), + extensionExists: () => Effect.succeed(false), + copyToCsv: () => Effect.succeed(new Uint8Array()), + queryRaw: () => Effect.succeed({ fields: [], rows: [], commandTag: "SELECT 1" }), + }; + + it.effect("local target connects to the local stack and announces it", () => + Effect.gen(function* () { + const output = mockOutput(); + const dir = yield* withWorkdir( + ["[db]", "port = 55555", 'password = "hunter2"', ""].join("\n"), + ); + const r = yield* resolveConnection(dir, inputFor({ kind: "local" }), { output }); + expect(r.conn).toMatchObject({ host: "127.0.0.1", port: 55555, password: "hunter2" }); + expect(r.isLocal).toBe(true); + expect(r.target).toEqual({ kind: "local" }); + expect(output.stderrText).toBe("Target: local stack (127.0.0.1:55555)\n"); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), + ); + + it.effect("db-url target announces the host and never the credentials", () => + Effect.gen(function* () { + const output = mockOutput(); + const dir = yield* withWorkdir(); + const url = Redacted.make("postgres://alice:secret@db.example.com:6543/appdb"); + const r = yield* resolveConnection(dir, inputFor({ kind: "db-url", url }), { output }); + expect(r.conn).toMatchObject({ host: "db.example.com", port: 6543, user: "alice" }); + expect(r.isLocal).toBe(false); + expect(output.stderrText).toBe("Target: custom Postgres (db.example.com)\n"); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), + ); + + it.effect("a db-url that fails to parse echoes the url with the password redacted", () => + Effect.gen(function* () { + const dir = yield* withWorkdir(); + const url = Redacted.make("postgres://alice:secret@:notaport/appdb"); + const exit = yield* Effect.exit(resolveConnection(dir, inputFor({ kind: "db-url", url }))); + expect(failureText(exit)).toContain("DbConfigParseUrlError"); + expect(failureText(exit)).not.toContain("secret"); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), + ); + + const directTargets: ReadonlyArray = [ + ["local", { kind: "local" }], + [ + "db-url", + { kind: "db-url", url: Redacted.make("postgres://u:p@db.example.com:5432/postgres") }, + ], + ]; + for (const [label, target] of directTargets) { + it.effect(`rejects --password for a ${label} target before announcing it`, () => + Effect.gen(function* () { + const output = mockOutput(); + const dir = yield* withWorkdir(); + const exit = yield* Effect.exit( + resolveConnection(dir, inputFor(target, { password: Option.some("pw") }), { output }), + ); + expect(failureText(exit)).toContain("TargetPasswordConflictError"); + expect(output.stderrText).toBe(""); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), + ); + + it.effect(`accepts an empty --password for a ${label} target`, () => + Effect.gen(function* () { + const dir = yield* withWorkdir(); + yield* resolveConnection(dir, inputFor(target, { password: Option.some("") })); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), + ); + } + + it.effect("a linked target that matches the linked workdir reuses the saved pooler URL", () => { + const ref = "savedpoolerrefabcdef"; + const requests: Array<{ readonly method: string; readonly path: string }> = []; + return Effect.gen(function* () { + yield* stubManagementApi(requests, () => undefined); + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const dir = yield* withWorkdir(); + yield* fs.makeDirectory(path.join(dir, "supabase", ".temp"), { recursive: true }); + yield* fs.writeFileString( + path.join(dir, "supabase", ".temp", "pooler-url"), + `postgres://postgres.${ref}:[YOUR-PASSWORD]@aws-0-us-east-1.pooler.supabase.com:6543/postgres`, + ); + const output = mockOutput(); + const target = linkedTarget(ref); + const r = yield* resolveConnection(dir, inputFor(target, { password: Option.some("pw") }), { + projectHost: "invalid", + output, + }); + expect(r.conn).toMatchObject({ + host: "aws-0-us-east-1.pooler.supabase.com", + user: `postgres.${ref}`, + password: "pw", + }); + expect(r.ref).toEqual(Option.some(ref)); + expect(r.target).toEqual(target); + expect(requests).toEqual([]); + expect(output.stderrText).toBe(""); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)); + }); + + it.effect("a linked target that is not the linked workdir ignores the saved pooler URL", () => { + const linkedRef = "abcdefghijklmnopqrst"; + const ref = "otherpoolerrefabcdef"; + const requests: Array<{ readonly method: string; readonly path: string }> = []; + return Effect.gen(function* () { + yield* stubManagementApi(requests, (method, path) => + method === "GET" && path === `/v1/projects/${ref}/config/database/pooler` + ? Response.json(primaryPoolerConfigs(ref)) + : undefined, + ); + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const dir = yield* withWorkdir(); + yield* fs.makeDirectory(path.join(dir, "supabase", ".temp"), { recursive: true }); + yield* fs.writeFileString( + path.join(dir, "supabase", ".temp", "pooler-url"), + `postgres://postgres.${linkedRef}:[YOUR-PASSWORD]@stale.pooler.supabase.com:6543/postgres`, + ); + const r = yield* resolveConnection( + dir, + inputFor(linkedTarget(ref, { source: "flag", matchesLinkedRef: false }), { + password: Option.some("pw"), + }), + { projectHost: "invalid", configEnv: { SUPABASE_ACCESS_TOKEN: VALID_TOKEN } }, + ); + expect(r.conn).toMatchObject({ + host: "aws-0-us-east-1.pooler.supabase.com", + user: `postgres.${ref}`, + }); + expect(requests).toEqual([ + { method: "GET", path: `/v1/projects/${ref}/config/database/pooler` }, + ]); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)); + }); + + describe("network bans on the temporary login role", () => { + const ref = "banrecoveryrefabcdef"; + const bannedRequests = ( + requests: Array<{ readonly method: string; readonly path: string }>, + bans: ReadonlyArray, + ) => + stubManagementApi(requests, (method, path) => { + if (method === "GET" && path === `/v1/projects/${ref}/config/database/pooler`) { + return Response.json(primaryPoolerConfigs(ref)); + } + if (method === "POST" && path === `/v1/projects/${ref}/cli/login-role`) { + return Response.json( + { role: "cli_login_role", password: "temporary-role-password", ttl_seconds: 3600 }, + { status: 201 }, + ); + } + if (method === "POST" && path === `/v1/projects/${ref}/network-bans/retrieve`) { + return Response.json({ banned_ipv4_addresses: bans }); + } + if (method === "DELETE" && path === `/v1/projects/${ref}/network-bans`) { + return Response.json({}); + } + return undefined; + }); + const failingConnect = (failures: number) => { + const attempts = { count: 0 }; + const layer = Layer.succeed(DbConnection, { + connect: () => + Effect.suspend(() => { + attempts.count += 1; + return attempts.count <= failures + ? Effect.fail(new DbConnectError({ message: "password authentication failed" })) + : Effect.succeed(session); + }), + }); + return { attempts, layer }; + }; + const forkResolve = (input: DbConnectionInput, connection: Layer.Layer) => + Effect.gen(function* () { + const retries = yield* Queue.unbounded(); + const debugLogger = Layer.succeed(DebugLogger, { + debug: (message) => + message.startsWith("Retry (") ? Queue.offer(retries, message) : Effect.void, + http: () => Effect.void, + }); + const dir = yield* withWorkdir(); + const fiber = yield* resolveConnection(dir, input, { + projectHost: "invalid", + dbConnection: connection, + debugLogger, + configEnv: { SUPABASE_ACCESS_TOKEN: VALID_TOKEN }, + }).pipe(Effect.forkChild({ startImmediately: true })); + const advance = (retryMessages: number) => + Effect.gen(function* () { + for (let seen = 1; seen <= retryMessages; seen++) { + yield* Queue.take(retries).pipe(Effect.raceFirst(Fiber.join(fiber))); + yield* TestClock.adjust("10 seconds"); + } + }); + return { fiber, advance }; + }); + + it.effect("fails with LinkedDbNetworkBannedError and removes nothing without approval", () => { + const requests: Array<{ readonly method: string; readonly path: string }> = []; + const connection = failingConnect(3); + return Effect.gen(function* () { + yield* bannedRequests(requests, ["203.0.113.7"]); + const { fiber, advance } = yield* forkResolve( + inputFor(linkedTarget(ref, { source: "flag", matchesLinkedRef: false })), + connection.layer, + ); + yield* advance(2); + const exit = yield* Fiber.await(fiber); + expect(failureText(exit)).toContain("LinkedDbNetworkBannedError"); + const error = Exit.isFailure(exit) ? Cause.findErrorOption(exit.cause) : Option.none(); + expect(error).toEqual( + Option.some( + expect.objectContaining({ + message: expect.stringContaining("203.0.113.7"), + suggestion: expect.stringContaining("supabase network-bans remove"), + }), + ), + ); + expect(connection.attempts.count).toBe(3); + expect(requests).toContainEqual({ + method: "POST", + path: `/v1/projects/${ref}/network-bans/retrieve`, + }); + expect(requests.some((request) => request.method === "DELETE")).toBe(false); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)); + }); + + it.effect("keeps retrying without removing anything when no ban is listed", () => { + const requests: Array<{ readonly method: string; readonly path: string }> = []; + const connection = failingConnect(3); + return Effect.gen(function* () { + yield* bannedRequests(requests, []); + const { fiber, advance } = yield* forkResolve( + inputFor(linkedTarget(ref, { source: "flag", matchesLinkedRef: false })), + connection.layer, + ); + yield* advance(3); + const r = yield* Fiber.join(fiber); + expect(r.conn).toMatchObject({ user: `cli_login_role.${ref}` }); + expect(connection.attempts.count).toBe(4); + expect(requests.some((request) => request.method === "DELETE")).toBe(false); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)); + }); + + it.effect("removes the bans once the caller holds an approval", () => { + const requests: Array<{ readonly method: string; readonly path: string }> = []; + const connection = failingConnect(3); + return Effect.gen(function* () { + yield* bannedRequests(requests, ["203.0.113.7"]); + const { fiber, advance } = yield* forkResolve( + inputFor(linkedTarget(ref, { source: "flag", matchesLinkedRef: false }), { + networkBanRemoval: approval, + }), + connection.layer, + ); + yield* advance(3); + const r = yield* Fiber.join(fiber); + expect(r.conn).toMatchObject({ user: `cli_login_role.${ref}` }); + expect(connection.attempts.count).toBe(4); + expect(requests).toContainEqual({ + method: "DELETE", + path: `/v1/projects/${ref}/network-bans`, + }); + assertDbConfigDrift(requests, { linked: true, "project-ref": ref }); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)); + }); + }); +}); diff --git a/apps/cli/src/command-internal/db-config.layer.ts b/apps/cli/src/command-internal/db-config.layer.ts index 0a44481f0c..03c63a2530 100644 --- a/apps/cli/src/command-internal/db-config.layer.ts +++ b/apps/cli/src/command-internal/db-config.layer.ts @@ -1,6 +1,6 @@ import * as net from "node:net"; import { BunServices } from "@effect/platform-bun"; -import { Crypto, Duration, Effect, FileSystem, Layer, Option, Path } from "effect"; +import { Crypto, Duration, Effect, FileSystem, Layer, Option, Path, Redacted } from "effect"; import { CommandPlatformApiFactory } from "../auth/command-platform-api-factory.service.ts"; import { CliArgs } from "../shared/cli/cli-args.service.ts"; @@ -8,6 +8,7 @@ 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 { projectRefPicker } from "../config/project-ref.layer.ts"; import { ProjectRefResolver, PROJECT_REF_PATTERN } from "../config/project-ref.service.ts"; import { DebugFlag, @@ -38,7 +39,7 @@ import { } from "./db-config.parse.ts"; import { DbConfigResolver, type DbConfigError } from "./db-config.service.ts"; import { loadProjectEnvValues, readDbToml } from "./db-config.toml-read.ts"; -import type { DbConfigFlags } from "./db-config.types.ts"; +import type { DbConfigFlags, DbConnectionInput } from "./db-config.types.ts"; import { rejectPasswordWithDirectTarget } from "./db-target-flags.ts"; import { DebugLogger } from "../shared/output/debug-logger.service.ts"; import { getHostname } from "./hostname.ts"; @@ -47,6 +48,8 @@ import { currentStackBackend } from "./stack-backend.ts"; import { StackApi, stackApiLayer } from "./stack-api.ts"; import { stackLocalDatabaseConn } from "./stack-local-database.ts"; import { block, type OperationEntry } from "./command-permissions/model.ts"; +import { renderDbUrlTarget, renderLocalTarget } from "./target/target-identity.ts"; +import { TargetPasswordConflictError } from "./target/target.errors.ts"; const linkedEntries: ReadonlyArray> = [ { @@ -84,6 +87,7 @@ const linkedEntries: ReadonlyArray> = [ context: "after three failed attempts to connect through the pooler as the temporary login role", }, + ...projectRefPicker.operations, ]; /** @@ -224,11 +228,18 @@ const initLoginRole = Effect.fn("DbConfig.initLoginRole")(function* ( return { ...conn, user: role.role, password: role.password }; }); -const listAndUnban = Effect.fnUntraced(function* (ref: string) { +const listAndUnban = Effect.fnUntraced(function* (ref: string, removeBans: boolean) { const api = yield* (yield* CommandPlatformApiFactory).make; const bans = yield* api.v1.listAllNetworkBans({ ref }).pipe(Effect.catch(listBansErrorMapper)); const addrs = bans.banned_ipv4_addresses; if (addrs.length === 0) return; + if (!removeBans) { + return yield* new Errors.LinkedDbNetworkBannedError({ + message: `Network bans (${addrs.join(", ")}) may be blocking the connection, and removing them needs approval.`, + suggestion: + "Re-run with --yes to remove the bans, or run supabase network-bans remove --db-unban-ip first.", + }); + } yield* api.v1 .deleteNetworkBans({ ref, ipv4_addresses: [...addrs], requester_ip: false }) .pipe(Effect.catch(unbanErrorMapper)); @@ -240,6 +251,7 @@ const waitForTempRole = Effect.fn("DbConfig.waitForTempRole")(function* ( ref: string, conn: PgConnInput, dnsResolver: "native" | "https", + removeBans: boolean, ) { const dbConn = yield* DbConnection; const debug = yield* DebugLogger; @@ -262,18 +274,22 @@ const waitForTempRole = Effect.fn("DbConfig.waitForTempRole")(function* ( // From the 3rd failure onward, clear any network ban on the requester. Uses a // deterministic backoff curve rather than jittered, since jitter only matters under // concurrent pooler refreshes. - const unban = n >= 3 ? listAndUnban(ref) : Effect.void; const delayMs = Math.min( Duration.toMillis(BACKOFF_INITIAL) * 1.5 ** (n - 1), Duration.toMillis(BACKOFF_MAX), ); return Effect.gen(function* () { // A transient ban-list/unban failure must not propagate out of the retry loop; log - // it to --debug, then discard. - yield* unban.pipe( - Effect.tapError((banError) => debug.debug(banError.message)), - Effect.ignore, - ); + // it to --debug, then discard. An unapproved ban removal is the one failure that ends it. + if (n >= 3) { + yield* listAndUnban(ref, removeBans).pipe( + Effect.catch((banError) => + banError._tag === "LinkedDbNetworkBannedError" + ? Effect.fail(banError) + : debug.debug(banError.message), + ), + ); + } yield* debug.debug(`Retry (${n}/${MAX_RETRIES}): ${cause.message}`); yield* Effect.sleep(Duration.millis(delayMs)); return yield* attempt(n + 1); @@ -324,6 +340,22 @@ const describePasswordOrigin = (origin: CliConfigKeyOrigin): string => { const passwordFlag = (password: Option.Option | undefined): Option.Option => password ?? Option.none(); +const rejectTargetPassword = ( + target: "local" | "db-url", + password: Option.Option, +): Effect.Effect => + Option.isNone(Option.filter(password, (value) => value.length > 0)) + ? Effect.void + : Effect.fail( + new TargetPasswordConflictError({ + target, + 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" + : "--password only applies to a linked project. The local database uses [db].password from supabase/config.toml.", + }), + ); + const loadFailureToDbConfigError = ( error: E, ) => @@ -402,6 +434,7 @@ const resolvePoolerConn = Effect.fn("DbConfig.resolvePoolerConn")(function* ( // `ref` from the Management API instead. ignoreSavedUrl = false, resolveVaultSecrets = true, + removeNetworkBans = true, ) { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -448,7 +481,7 @@ const resolvePoolerConn = Effect.fn("DbConfig.resolvePoolerConn")(function* ( const withRole = yield* initLoginRole(ref, poolerConn); const finalUser = originalUser.endsWith(`.${ref}`) ? `${withRole.user}.${ref}` : withRole.user; const tempConn = { ...withRole, user: finalUser }; - yield* waitForTempRole(ref, tempConn, dnsResolver); + yield* waitForTempRole(ref, tempConn, dnsResolver, removeNetworkBans); return Option.some(tempConn); }); @@ -487,6 +520,8 @@ export const resolveLinkedConn = Effect.fn("DbConfig.resolveLinkedConn")(functio readonly fetchPoolerFromApi?: boolean; /** Whether the calling command binds `--password`, so the withheld-password notice can name it. */ readonly hasPasswordFlag?: boolean; + /** When false, a network ban that blocks the temp role fails with `LinkedDbNetworkBannedError` instead of being removed. */ + readonly removeNetworkBans?: boolean; } = {}, ) { const { @@ -494,6 +529,7 @@ export const resolveLinkedConn = Effect.fn("DbConfig.resolveLinkedConn")(functio resolveVaultSecrets = true, fetchPoolerFromApi = false, hasPasswordFlag = false, + removeNetworkBans = true, } = options; const dbPassword = yield* resolveLinkedPassword(ref, workdir, explicitPassword, hasPasswordFlag); const host = `db.${ref}.${projectHost}`; @@ -529,6 +565,7 @@ export const resolveLinkedConn = Effect.fn("DbConfig.resolveLinkedConn")(functio adHocProjectRef || fetchPoolerFromApi, adHocProjectRef, resolveVaultSecrets, + removeNetworkBans, ); if (Option.isNone(poolerConn)) { return yield* new Errors.DbConfigIpv6Error({ @@ -553,6 +590,7 @@ export const dbConfigResolverLayer = Layer.effect( const output = yield* Output; const dbConn = yield* DbConnection; const configValues = yield* CliConfigValues; + const commandTarget = yield* CommandTarget; // `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 @@ -598,7 +636,7 @@ export const dbConfigResolverLayer = Layer.effect( Layer.succeed(TelemetryRuntime, yield* TelemetryRuntime), Layer.succeed(Tty, yield* Tty), Layer.succeed(Output, yield* Output), - Layer.succeed(CommandTarget, yield* CommandTarget), + Layer.succeed(CommandTarget, commandTarget), // The per-command identity stitcher, shared with the linked stack's lazy platform-API // factory + linked-project cache. Provided to this layer by each command runtime. Layer.succeed(IdentityStitch, yield* IdentityStitch), @@ -628,168 +666,226 @@ export const dbConfigResolverLayer = Layer.effect( Effect.provideService(Path.Path, path), ); + // Honors `SUPABASE_SERVICES_HOSTNAME` / a tcp `DOCKER_HOST` in dev-container or remote-Docker + // setups, defaulting to 127.0.0.1. Only env feeds the hostname, so the config file stays unread. + const resolveLocalHost = Effect.gen(function* () { + const hostConfig = yield* configValues + .load({ + workdir: cliSettings.workdir, + projectRef: Option.none(), + ignoreConfigFile: true, + tolerateInvalid: true, + }) + .pipe(Effect.mapError(loadFailureToDbConfigError)); + return 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 })), + ), + ); + }); + + const resolveDbUrl = (dbUrl: string, localHost: string, resolveVaultSecrets: boolean) => + Effect.gen(function* () { + const tomlValues = yield* readDbToml(fs, path, cliSettings.workdir, undefined, { + resolveVaultSecrets, + }); + // The project `.env*` files populate the environment that the libpq `PG*` fallbacks + // 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* loadProjectEnvValues(fs, path, cliSettings.workdir); + const conn = yield* parseConnectionString(dbUrl, yield* layeredParseEnv(projectEnv)).pipe( + Effect.provideService(FileSystem.FileSystem, fs), + Effect.provideService(Path.Path, path), + ); + if (conn === undefined) { + return yield* new Errors.DbConfigParseUrlError({ + // Redact the password component before echoing the URL back + // (CWE-209): a malformed `--db-url` often still carries a secret. + message: `failed to parse connection string: ${redactConnectionString(dbUrl)}`, + }); + } + // A multi-host URL stays remote: local disables TLS for every fallback host as well. + const singleHost = conn.fallbacks === undefined; + const legacyLocal = + singleHost && + isLocalDatabase(conn.host, localHost, conn.port, tomlValues.port, tomlValues.shadowPort); + // The stack backend publishes its database on a runtime-assigned port rather than + // `[db].port`, so a URL naming the running stack's SQL endpoint is local too. + const stackConn = + singleHost && + (yield* currentStackBackend).kind === "stack" && + mayBeStackDatabaseHost(conn.host, localHost) + ? Option.getOrUndefined(yield* Effect.option(stackDatabaseConn)) + : undefined; + const onStack = stackConn?.host === conn.host && stackConn.port === conn.port; + const isLocal = legacyLocal || onStack; + // A local direct URL fills an empty password from the local database's own + // credentials, so a passwordless DSN like `postgresql://postgres@127.0.0.1:54322/postgres` + // still authenticates. + const localPassword = onStack ? stackConn.password : tomlValues.password; + return { + conn: isLocal && conn.password.length === 0 ? { ...conn, password: localPassword } : conn, + isLocal, + }; + }); + + const resolveLocalStack = (localHost: string, resolveVaultSecrets: boolean) => + Effect.gen(function* () { + const tomlValues = yield* readDbToml(fs, path, cliSettings.workdir, undefined, { + resolveVaultSecrets, + }); + const backend = yield* currentStackBackend; + if (backend.kind === "stack") { + return { conn: yield* stackDatabaseConn, isLocal: true }; + } + return { + conn: { + host: localHost, + port: tomlValues.port, + user: "postgres", + password: tomlValues.password, + database: "postgres", + }, + isLocal: true, + }; + }); + + // Validates the `[remotes.]`-merged config before any network work, so a config error is + // never masked by the TCP probe, pooler or login-role calls. The linked-project cache GET is + // left to each command's post-run hook so it stays after the command's own requests. + const resolveLinkedConnection = ( + ref: string, + options: { + readonly dnsResolver: "native" | "https"; + readonly password: Option.Option; + readonly resolveVaultSecrets: boolean; + readonly adHocProjectRef: boolean; + readonly fetchPoolerFromApi: boolean; + readonly hasPasswordFlag: boolean; + readonly removeNetworkBans: boolean; + }, + ) => + Effect.gen(function* () { + yield* readDbToml(fs, path, cliSettings.workdir, ref, { + resolveVaultSecrets: options.resolveVaultSecrets, + }); + return yield* resolveLinkedConn( + ref, + cliSettings.workdir, + cliSettings.projectHost, + cliSettings.poolerHost, + options.dnsResolver, + options.password, + { + adHocProjectRef: options.adHocProjectRef, + resolveVaultSecrets: options.resolveVaultSecrets, + fetchPoolerFromApi: options.fetchPoolerFromApi, + hasPasswordFlag: options.hasPasswordFlag, + removeNetworkBans: options.removeNetworkBans, + }, + ); + }); + + // Built per linked call so `--local`/`--db-url` never touch it; the platform API factory + // resolves the access token only when a temp role is minted, so `--password` stays auth-free. + const withLinkedRuntime = (effect: Effect.Effect) => + effect.pipe( + Effect.provide( + Layer.mergeAll( + linkedDbResolverRuntimeLayer(["test", "db"]).pipe(Layer.provide(ambientLayer)), + localAmbientServices, + ), + ), + ); + 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 - // would validate base config (db.major_version, deno_version, …) before the ref is - // known, failing a linked run that should succeed once the ref is known. Only + // the ref first and reads the `[remotes.]`-merged config. A base read here would + // validate base config (db.major_version, deno_version, …) before the ref is known, + // failing a linked run that should succeed once the ref is known. Only // `--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. - // 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 })), - ), - ); + const localHost = yield* resolveLocalHost; // --db-url (direct) takes precedence. if (flags.connType === "db-url" && Option.isSome(flags.dbUrl)) { - const tomlValues = yield* readDbToml(fs, path, cliSettings.workdir, undefined, { - resolveVaultSecrets, - }); - // The project `.env*` files populate the environment that the libpq `PG*` fallbacks - // 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* loadProjectEnvValues(fs, path, cliSettings.workdir); - const conn = yield* parseConnectionString( - flags.dbUrl.value, - yield* layeredParseEnv(projectEnv), - ).pipe( - Effect.provideService(FileSystem.FileSystem, fs), - Effect.provideService(Path.Path, path), - ); - if (conn === undefined) { - return yield* new Errors.DbConfigParseUrlError({ - // Redact the password component before echoing the URL back - // (CWE-209): a malformed `--db-url` often still carries a secret. - message: `failed to parse connection string: ${redactConnectionString(flags.dbUrl.value)}`, - }); - } - // A multi-host URL stays remote: local disables TLS for every fallback host as well. - const singleHost = conn.fallbacks === undefined; - const legacyLocal = - singleHost && - isLocalDatabase( - conn.host, - localHost, - conn.port, - tomlValues.port, - tomlValues.shadowPort, - ); - // The stack backend publishes its database on a runtime-assigned port rather than - // `[db].port`, so a URL naming the running stack's SQL endpoint is local too. - const stackConn = - singleHost && - (yield* currentStackBackend).kind === "stack" && - mayBeStackDatabaseHost(conn.host, localHost) - ? Option.getOrUndefined(yield* Effect.option(stackDatabaseConn)) - : undefined; - const onStack = stackConn?.host === conn.host && stackConn.port === conn.port; - const isLocal = legacyLocal || onStack; - // A local direct URL fills an empty password from the local database's own - // credentials, so a passwordless DSN like `postgresql://postgres@127.0.0.1:54322/postgres` - // still authenticates. - const localPassword = onStack ? stackConn.password : tomlValues.password; - return { - conn: - isLocal && conn.password.length === 0 ? { ...conn, password: localPassword } : conn, - isLocal, - }; + return yield* resolveDbUrl(flags.dbUrl.value, localHost, resolveVaultSecrets); } - // --linked. The lazy Management API runtime (project-ref resolver + lazy platform API - // factory) is provided here at runtime so it is only built on this branch — - // `--local`/`--db-url` never touch it. The factory resolves the access token only on - // first use (minting a temp role), so a `--linked --password` invocation stays - // auth-free. if (flags.connType === "linked") { - const linked = yield* Effect.gen(function* () { - const projectRef = yield* ProjectRefResolver; - // Load-or-fail with no prompt: use `loadProjectRef` (not the soft - // `resolveOptional`, which swallows a read error to `None`) — an unlinked workdir - // fails with a not-linked error, a bad ref with the invalid-ref error, and an - // unreadable ref file surfaces the filesystem problem, for every caller of this - // resolver (`test db --linked`, dump, declarative). - const ref = yield* projectRef.loadProjectRef(flags.linkedProjectRef ?? Option.none()); - // The `[remotes.]`-merged config (e.g. an unsupported remote - // `db.major_version`/`edge_runtime.deno_version`) is validated as a pure config - // error before any network work. The base read in `resolve` above only validates - // remote `project_id`s, not the ref-merged block — so validate the merged config - // here, before the TCP probe / pooler / temp-role Management API calls, rather than - // letting those mask (or run side effects ahead of) the real config error. - yield* readDbToml(fs, path, cliSettings.workdir, ref, { - resolveVaultSecrets, - }); - const resolved = yield* resolveLinkedConn( - ref, - cliSettings.workdir, - cliSettings.projectHost, - cliSettings.poolerHost, - flags.dnsResolver, - passwordFlag(flags.password), - { - adHocProjectRef: flags.adHocProjectRef ?? false, + const linked = yield* withLinkedRuntime( + Effect.gen(function* () { + const projectRef = yield* ProjectRefResolver; + // Load-or-fail with no prompt: use `loadProjectRef` (not the soft + // `resolveOptional`, which swallows a read error to `None`) — an unlinked workdir + // fails with a not-linked error, a bad ref with the invalid-ref error, and an + // unreadable ref file surfaces the filesystem problem, for every caller of this + // resolver (`test db --linked`, dump, declarative). + const ref = yield* projectRef.loadProjectRef(flags.linkedProjectRef ?? Option.none()); + const conn = yield* resolveLinkedConnection(ref, { + dnsResolver: flags.dnsResolver, + password: passwordFlag(flags.password), resolveVaultSecrets, + adHocProjectRef: flags.adHocProjectRef ?? false, // 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 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: - // it's cached in a post-run hook, after the command's own API calls, so each linked - // command owns that GET in its post-run finalizer. Issuing it mid-resolve would - // reorder the request log ahead of the command's GETs. - return { conn: resolved, ref }; - }).pipe( - Effect.provide( - Layer.mergeAll( - linkedDbResolverRuntimeLayer(["test", "db"]).pipe(Layer.provide(ambientLayer)), - localAmbientServices, - ), - ), + removeNetworkBans: true, + }); + return { conn, ref }; + }), ); // Surface the resolved ref so the caller can re-read config with a matching // `[remotes.]` override applied (merged into the linked config). return { conn: linked.conn, isLocal: false, ref: Option.some(linked.ref) }; } - // --local (default). - const tomlValues = yield* readDbToml(fs, path, cliSettings.workdir, undefined, { - resolveVaultSecrets, - }); - const backend = yield* currentStackBackend; - if (backend.kind === "stack") { - return { conn: yield* stackDatabaseConn, isLocal: true }; + return yield* resolveLocalStack(localHost, resolveVaultSecrets); + }); + + const resolveConnection = (input: DbConnectionInput) => + Effect.gen(function* () { + const { target } = input; + if (target.kind !== "linked") { + yield* rejectTargetPassword(target.kind, input.password); } - return { - conn: { - host: localHost, - port: tomlValues.port, - user: "postgres", - password: tomlValues.password, - database: "postgres", - }, - isLocal: true, - }; + const resolveVaultSecrets = input.resolveVaultSecrets ?? true; + if (target.kind === "linked") { + const conn = yield* withLinkedRuntime( + resolveLinkedConnection(target.ref, { + dnsResolver: input.dnsResolver, + password: input.password, + resolveVaultSecrets, + adHocProjectRef: !target.matchesLinkedRef, + fetchPoolerFromApi: target.source === "flag", + hasPasswordFlag: input.hasPasswordFlag ?? false, + removeNetworkBans: Option.isSome(input.networkBanRemoval), + }), + ); + return { conn, isLocal: false, ref: Option.some(target.ref), target }; + } + const localHost = yield* resolveLocalHost; + if (target.kind === "db-url") { + const resolved = yield* resolveDbUrl( + Redacted.value(target.url), + localHost, + resolveVaultSecrets, + ); + yield* commandTarget.announce(renderDbUrlTarget(resolved.conn.host, resolved.conn.port)); + return { ...resolved, target }; + } + const resolved = yield* resolveLocalStack(localHost, resolveVaultSecrets); + yield* commandTarget.announce(renderLocalTarget(resolved.conn.host, resolved.conn.port)); + return { ...resolved, target }; }); // When a linked dump's pg_dump container fails with an IPv6 connectivity error (the direct @@ -852,6 +948,15 @@ export const dbConfigResolverLayer = Layer.effect( attributes: { "db.conn_type": flags.connType ?? "local" }, }), ), + resolveConnection: (input) => + resolveConnection(input).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.resolveConnection", { + attributes: { "db.conn_type": input.target.kind }, + }), + ), resolvePoolerFallback: (flags) => resolvePoolerFallback(flags).pipe( Effect.provideService(CliConfigValues, configValues), diff --git a/apps/cli/src/command-internal/db-config.service.ts b/apps/cli/src/command-internal/db-config.service.ts index be1f96ffeb..4e13cda94e 100644 --- a/apps/cli/src/command-internal/db-config.service.ts +++ b/apps/cli/src/command-internal/db-config.service.ts @@ -24,12 +24,19 @@ import type { DbConfigUnbanNetworkError, DbConfigUnbanStatusError, DbPasswordFlagsError, + LinkedDbNetworkBannedError, } from "./db-config.errors.ts"; -import type { DbConfigFlags, ResolvedDbConfig } from "./db-config.types.ts"; +import type { + DbConfigFlags, + DbConnectionInput, + ResolvedDbConfig, + ResolvedDbConnection, +} from "./db-config.types.ts"; import type { CliConfigFlagConflictError, CliConfigValueError, } from "../config/cli-config.errors.ts"; +import type { TargetPasswordConflictError } from "./target/target.errors.ts"; /** Every error the resolver can raise across the direct / local / linked paths. */ export type DbConfigError = @@ -55,6 +62,8 @@ export type DbConfigError = | HttpBody.HttpBodyError | DbConfigIpv6Error | DbConfigConnectTempRoleError + | LinkedDbNetworkBannedError + | TargetPasswordConflictError | DbConfigPoolerLoginError | DbConnectError // The `--linked` path resolves the access token lazily via @@ -73,6 +82,13 @@ export type DbConfigError = // `DbConfigError`) rather than a layer-build error channel. interface DbConfigResolverShape { readonly resolve: (flags: DbConfigFlags) => Effect.Effect; + /** + * Connects to an already-resolved target and announces it: the local stack, a `--db-url` + * database, or the linked project's temp-role connection. + */ + readonly resolveConnection: ( + input: DbConnectionInput, + ) => Effect.Effect; /** * Resolves the IPv4 transaction pooler connection for a linked dump's container-level * fallback. Returns `None` when the path is not pooler-eligible (`--linked` only) or no diff --git a/apps/cli/src/command-internal/db-config.types.ts b/apps/cli/src/command-internal/db-config.types.ts index 9f7914dc0c..e6ed6a92d7 100644 --- a/apps/cli/src/command-internal/db-config.types.ts +++ b/apps/cli/src/command-internal/db-config.types.ts @@ -1,6 +1,8 @@ import type { Option } from "effect"; import type { PgConnInput } from "./db-connection.service.ts"; import type { DbConnType } from "./db-target-flags.ts"; +import type { HostedMutationConsent } from "./hosted-consent/hosted-consent.token.ts"; +import type { ResolvedTarget } from "./target/resolve-target.ts"; /** * The connection-resolution flags shared by `db lint`, `db advisors`, and `test db`. @@ -62,3 +64,20 @@ export interface ResolvedDbConfig { */ readonly ref?: Option.Option; } + +/** What {@link DbConfigResolver.resolveConnection} needs: the resolved target plus how to authenticate. */ +export interface DbConnectionInput { + readonly target: ResolvedTarget; + readonly dnsResolver: "native" | "https"; + /** Rejected unless the target is the linked project. */ + readonly password: Option.Option; + readonly resolveVaultSecrets?: boolean; + /** Set when the command binds `--password`, so the withheld-password notice can name it. */ + readonly hasPasswordFlag?: boolean; + /** `Some` lets the temp-role retry clear a network ban; `None` fails with `LinkedDbNetworkBannedError` instead. */ + readonly networkBanRemoval: Option.Option; +} + +export interface ResolvedDbConnection extends ResolvedDbConfig { + readonly target: ResolvedTarget; +} diff --git a/apps/cli/src/command-internal/hosted-consent/hosted-consent.token.ts b/apps/cli/src/command-internal/hosted-consent/hosted-consent.token.ts new file mode 100644 index 0000000000..3d4577c6b7 --- /dev/null +++ b/apps/cli/src/command-internal/hosted-consent/hosted-consent.token.ts @@ -0,0 +1,8 @@ +declare const Brand: unique symbol; + +/** Proof that the user approved a hosted mutation; only the consent module constructs one. */ +export interface HostedMutationConsent { + readonly [Brand]: true; + readonly action: string; + readonly via: "prompt" | "yes"; +} diff --git a/apps/cli/src/command-internal/target/resolve-target.integration.test.ts b/apps/cli/src/command-internal/target/resolve-target.integration.test.ts new file mode 100644 index 0000000000..984fcd0970 --- /dev/null +++ b/apps/cli/src/command-internal/target/resolve-target.integration.test.ts @@ -0,0 +1,279 @@ +import { BunServices } from "@effect/platform-bun"; +import { describe, expect, it } from "@effect/vitest"; +import { Cause, Effect, Exit, FileSystem, Layer, Option, Path, Redacted, Tracer } from "effect"; + +import { configValuesLayer } from "../../../tests/helpers/config-values-layer.ts"; +import { VALID_REF, mockCommandSettings } from "../../../tests/helpers/command-mocks.ts"; +import { ProjectRefNotLinkedError } from "../../config/project-ref.errors.ts"; +import { ProjectRefResolver } from "../../config/project-ref.service.ts"; +import type { ProjectRefSource } from "../../config/project-ref.service.ts"; +import { resolveTarget } from "./resolve-target.ts"; +import { TargetPolicy } from "./target-policy.ts"; +import type { TargetGroup } from "./target-policy.ts"; +import type { TargetSelectorInput } from "./target-selector.ts"; + +const none: TargetSelectorInput = { + local: false, + linked: false, + dbUrl: Option.none(), + projectRef: Option.none(), +}; + +const FILE_REF = "filefilefilefilefile"; +const FLAG_REF = "flagflagflagflagflag"; + +const projectRefResolver = (options: { readonly linked: boolean }) => { + const flags: Array> = []; + const layer = Layer.succeed(ProjectRefResolver, { + resolveRef: (flagValue) => + options.linked + ? Effect.sync(() => { + flags.push(flagValue); + const source: ProjectRefSource = Option.isSome(flagValue) ? "flag" : "file"; + return { + ref: Option.getOrElse(flagValue, () => FILE_REF), + source, + matchesLinkedRef: !Option.isSome(flagValue), + }; + }) + : Effect.fail(new ProjectRefNotLinkedError({ message: "Cannot find project ref." })), + resolve: () => Effect.die("resolve is not used by resolveTarget"), + resolveForLink: () => Effect.die("resolveForLink is not used by resolveTarget"), + resolveOptional: () => Effect.die("resolveOptional is not used by resolveTarget"), + loadProjectRef: () => Effect.die("loadProjectRef is not used by resolveTarget"), + promptProjectRef: () => Effect.die("promptProjectRef is not used by resolveTarget"), + }); + return { layer, flags }; +}; + +const run = ( + group: TargetGroup, + input: TargetSelectorInput, + options: { + readonly toml?: string; + readonly dotenv?: string; + readonly env?: Readonly>; + readonly linked?: boolean; + readonly projectId?: Option.Option; + } = {}, +) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const dir = yield* fs.makeTempDirectoryScoped({ prefix: "resolve-target-" }); + yield* fs.makeDirectory(path.join(dir, "supabase"), { recursive: true }); + if (options.toml !== undefined) { + yield* fs.writeFileString(path.join(dir, "supabase", "config.toml"), options.toml); + } + if (options.dotenv !== undefined) { + yield* fs.writeFileString(path.join(dir, "supabase", ".env"), options.dotenv); + } + const resolver = projectRefResolver({ linked: options.linked ?? true }); + const spans: Array = []; + const tracer = Tracer.make({ + span(spanOptions) { + const span = new Tracer.NativeSpan(spanOptions); + spans.push(span); + return span; + }, + }); + const exit = yield* resolveTarget(input).pipe( + Effect.provide( + Layer.mergeAll( + Layer.succeed(TargetPolicy, { group, consent: "none" }), + resolver.layer, + mockCommandSettings({ + workdir: dir, + projectId: options.projectId ?? Option.none(), + }), + configValuesLayer({ env: options.env }), + ), + ), + Effect.withTracer(tracer), + Effect.withTracerEnabled(true), + Effect.exit, + ); + const attributes = spans.find((span) => span.name === "Target.resolve")?.attributes; + return { + exit, + refFlags: resolver.flags, + kind: attributes?.get("target.kind"), + selection: attributes?.get("target.selection"), + }; + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)); + +const succeeded = (exit: Exit.Exit): A => { + if (!Exit.isSuccess(exit)) throw new Error(`expected success, got ${JSON.stringify(exit)}`); + return exit.value; +}; + +describe("resolveTarget (selectable)", () => { + it.effect("is local when nothing selects a target", () => + Effect.gen(function* () { + const result = yield* run("selectable", none); + expect(succeeded(result.exit)).toEqual({ kind: "local" }); + expect(result.refFlags).toEqual([]); + expect([result.kind, result.selection]).toEqual(["local", "default"]); + }), + ); + + it.effect("follows a workspace target of remote to the linked project", () => + Effect.gen(function* () { + const result = yield* run("selectable", none, { toml: 'target = "remote"\n' }); + expect(succeeded(result.exit)).toEqual({ + kind: "linked", + ref: FILE_REF, + source: "file", + matchesLinkedRef: true, + }); + expect(result.refFlags).toEqual([Option.none()]); + expect([result.kind, result.selection]).toEqual(["linked", "config"]); + }), + ); + + it.effect("lets SUPABASE_TARGET override the config file in both directions", () => + Effect.gen(function* () { + const toRemote = yield* run("selectable", none, { + toml: 'target = "local"\n', + env: { SUPABASE_TARGET: "remote" }, + }); + expect(succeeded(toRemote.exit)).toMatchObject({ kind: "linked" }); + expect(toRemote.selection).toBe("env"); + + const toLocal = yield* run("selectable", none, { + toml: 'target = "remote"\n', + env: { SUPABASE_TARGET: "local" }, + }); + expect(succeeded(toLocal.exit)).toEqual({ kind: "local" }); + expect(toLocal.selection).toBe("env"); + }), + ); + + it.effect("reads the workspace target from the project .env", () => + Effect.gen(function* () { + const result = yield* run("selectable", none, { dotenv: "SUPABASE_TARGET=remote\n" }); + expect(succeeded(result.exit)).toMatchObject({ kind: "linked" }); + expect(result.selection).toBe("env"); + }), + ); + + it.effect("stays local when only SUPABASE_PROJECT_ID is set", () => + Effect.gen(function* () { + const result = yield* run("selectable", none, { projectId: Option.some(VALID_REF) }); + expect(succeeded(result.exit)).toEqual({ kind: "local" }); + expect(result.refFlags).toEqual([]); + }), + ); + + it.effect( + "fails with not-linked instead of falling back to local when remote cannot resolve", + () => + Effect.gen(function* () { + const result = yield* run("selectable", none, { + toml: 'target = "remote"\n', + linked: false, + }); + expect(Exit.isFailure(result.exit)).toBe(true); + expect(JSON.stringify(result.exit)).toContain("ProjectRefNotLinkedError"); + }), + ); + + it.effect("lets --linked beat a local workspace target", () => + Effect.gen(function* () { + const result = yield* run( + "selectable", + { ...none, linked: true }, + { toml: 'target = "local"\n' }, + ); + expect(succeeded(result.exit)).toMatchObject({ kind: "linked", ref: FILE_REF }); + expect(result.selection).toBe("flag"); + }), + ); + + it.effect("lets --local beat a remote workspace target without resolving a ref", () => + Effect.gen(function* () { + const result = yield* run( + "selectable", + { ...none, local: true }, + { toml: 'target = "remote"\n' }, + ); + expect(succeeded(result.exit)).toEqual({ kind: "local" }); + expect(result.refFlags).toEqual([]); + expect(result.selection).toBe("flag"); + }), + ); + + it.effect("passes --project-ref to the ref chain and marks it a flag source", () => + Effect.gen(function* () { + const result = yield* run("selectable", { ...none, projectRef: Option.some(FLAG_REF) }); + expect(succeeded(result.exit)).toEqual({ + kind: "linked", + ref: FLAG_REF, + source: "flag", + matchesLinkedRef: false, + }); + expect(result.refFlags).toEqual([Option.some(FLAG_REF)]); + }), + ); + + it.effect("keeps the --db-url connection string redacted", () => + Effect.gen(function* () { + const url = "postgres://user:secret@db.example.com:5432/postgres"; + const result = yield* run("selectable", { ...none, dbUrl: Option.some(url) }); + const target = succeeded(result.exit); + expect(target.kind).toBe("db-url"); + if (target.kind === "db-url") expect(Redacted.value(target.url)).toBe(url); + expect(JSON.stringify(target)).not.toContain("secret"); + expect(result.refFlags).toEqual([]); + }), + ); + + it.effect("rejects conflicting selectors", () => + Effect.gen(function* () { + const result = yield* run("selectable", { ...none, local: true, linked: true }); + expect(JSON.stringify(result.exit)).toContain("TargetSelectorConflictError"); + }), + ); +}); + +describe("resolveTarget (sync)", () => { + it.effect("defaults to the linked project and ignores a local workspace target", () => + Effect.gen(function* () { + const result = yield* run("sync", none, { toml: 'target = "local"\n' }); + expect(succeeded(result.exit)).toMatchObject({ kind: "linked", ref: FILE_REF }); + expect(result.refFlags).toEqual([Option.none()]); + expect([result.kind, result.selection]).toEqual(["linked", "default"]); + }), + ); + + it.effect("targets local only with --local", () => + Effect.gen(function* () { + const result = yield* run("sync", { ...none, local: true }); + expect(succeeded(result.exit)).toEqual({ kind: "local" }); + expect(result.selection).toBe("flag"); + }), + ); + + it.effect("fails with not-linked when no project is linked", () => + Effect.gen(function* () { + const result = yield* run("sync", none, { linked: false }); + expect(JSON.stringify(result.exit)).toContain("ProjectRefNotLinkedError"); + }), + ); +}); + +describe("resolveTarget (other groups)", () => { + it.effect.each(["local-only", "account", "hosted", "removed"] satisfies Array)( + "dies for a %s command", + (group) => + Effect.gen(function* () { + const result = yield* run(group, none); + expect(Exit.isFailure(result.exit)).toBe(true); + if (Exit.isFailure(result.exit)) { + expect(Cause.pretty(result.exit.cause)).toContain( + "resolveTarget needs a sync or selectable", + ); + } + }), + ); +}); diff --git a/apps/cli/src/command-internal/target/resolve-target.ts b/apps/cli/src/command-internal/target/resolve-target.ts new file mode 100644 index 0000000000..42d23abd61 --- /dev/null +++ b/apps/cli/src/command-internal/target/resolve-target.ts @@ -0,0 +1,86 @@ +import { Effect, Option, Redacted, Result } from "effect"; + +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 { ProjectRefResolver, type ProjectRefSource } from "../../config/project-ref.service.ts"; +import { parseTargetSelector, type TargetSelectorInput } from "./target-selector.ts"; +import { TargetPolicy } from "./target-policy.ts"; + +export type ResolvedTarget = + | { readonly kind: "local" } + | { + readonly kind: "linked"; + readonly ref: string; + readonly source: ProjectRefSource; + readonly matchesLinkedRef: boolean; + } + | { readonly kind: "db-url"; readonly url: Redacted.Redacted }; + +type TargetSelection = "flag" | "env" | "config" | "default"; + +/** + * Resolves the target a `sync` or `selectable` command acts on: a selector flag wins, then + * `sync` means the linked project and `selectable` follows the workspace `target`. Linked goes + * through {@link ProjectRefResolver.resolveRef}, which announces it. + */ +export const resolveTarget = Effect.fn("Target.resolve")(function* (input: TargetSelectorInput) { + const policy = yield* TargetPolicy; + if (policy.group !== "sync" && policy.group !== "selectable") { + return yield* Effect.die( + new Error(`resolveTarget needs a sync or selectable command, not "${policy.group}"`), + ); + } + const selection = yield* Result.match(parseTargetSelector(input), { + onFailure: Effect.fail, + onSuccess: Effect.succeed, + }); + const projectRef = yield* ProjectRefResolver; + + const finish = Effect.fnUntraced(function* (target: ResolvedTarget, selectedBy: TargetSelection) { + yield* Effect.annotateCurrentSpan({ + "target.kind": target.kind, + "target.selection": selectedBy, + }); + return target; + }); + const linked = Effect.fnUntraced(function* (flag: Option.Option) { + const resolved = yield* projectRef.resolveRef(flag); + return { + kind: "linked", + ref: resolved.ref, + source: resolved.source, + matchesLinkedRef: resolved.matchesLinkedRef, + } satisfies ResolvedTarget; + }); + + switch (selection.explicit) { + case "local": + return yield* finish({ kind: "local" }, "flag"); + case "db-url": + return yield* finish({ kind: "db-url", url: Redacted.make(selection.url) }, "flag"); + case "linked": + return yield* finish(yield* linked(selection.projectRef), "flag"); + case undefined: + } + + if (policy.group === "sync") { + return yield* finish(yield* linked(Option.none()), "default"); + } + const { workdir } = yield* CommandSettings; + const resolvedConfig = yield* (yield* CliConfigValues).load({ + workdir, + projectRef: Option.none(), + }); + const workspaceTarget = yield* resolvedConfig.get(CliConfigKeys.target); + const selectedBy: TargetSelection = + workspaceTarget.origin.tier === "shell" || workspaceTarget.origin.tier === "projectEnv" + ? "env" + : workspaceTarget.origin.tier === "config" + ? "config" + : "default"; + return yield* finish( + workspaceTarget.value === "remote" ? yield* linked(Option.none()) : { kind: "local" }, + selectedBy, + ); +}); 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 9572227a20..3d37b7c71b 100644 --- a/apps/cli/src/command-internal/test-db.integration.test.ts +++ b/apps/cli/src/command-internal/test-db.integration.test.ts @@ -79,6 +79,7 @@ function mockResolver(opts: { conn?: PgConnInput; isLocal?: boolean } = {}) { }); return Effect.succeed({ conn: opts.conn ?? LOCAL_CONN, isLocal: opts.isLocal ?? true }); }, + resolveConnection: () => Effect.die("resolveConnection is not used by this test"), resolvePoolerFallback: () => Effect.succeed(Option.none()), }); return { 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 d29f428faf..1fa2254da9 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 @@ -70,6 +70,7 @@ function ambientStubs() { }), Layer.succeed(DbConfigResolver, { resolve: () => Effect.die("db-config-resolver not needed for layer-exposure test"), + resolveConnection: () => Effect.die("resolveConnection is not used by this test"), resolvePoolerFallback: () => Effect.die("db-config-resolver not needed for layer-exposure test"), }), 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 01cd33a2ed..fbc7901e31 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 @@ -103,6 +103,7 @@ describe("managed test db pgTAP", { timeout: 180_000 }, () => { mockTelemetryStateTracked().layer, Layer.succeed(DbConfigResolver, { resolve: () => Effect.succeed({ conn: connection, isLocal: true }), + resolveConnection: () => Effect.die("resolveConnection is not used by this test"), resolvePoolerFallback: () => Effect.succeed(Option.none()), }), Layer.succeed(RuntimeInfo, { diff --git a/apps/cli/src/commands/db/advisors/advisors.integration.test.ts b/apps/cli/src/commands/db/advisors/advisors.integration.test.ts index 6f3bf2a20d..3b2ae46fe3 100644 --- a/apps/cli/src/commands/db/advisors/advisors.integration.test.ts +++ b/apps/cli/src/commands/db/advisors/advisors.integration.test.ts @@ -112,6 +112,7 @@ function mockResolver(opts: { ipv6Error?: boolean } = {}) { isLocal: flags.connType !== "linked", } satisfies ResolvedDbConfig; }), + resolveConnection: () => Effect.die("resolveConnection is not used by this test"), resolvePoolerFallback: () => Effect.succeed(Option.none()), }); return { 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 d93eed3176..f708aac488 100644 --- a/apps/cli/src/commands/db/diff/diff.integration.test.ts +++ b/apps/cli/src/commands/db/diff/diff.integration.test.ts @@ -418,6 +418,7 @@ function setup(workdir: string, opts: SetupOpts = {}) { ref: ref !== undefined ? Option.some(ref) : Option.none(), }); }, + resolveConnection: () => Effect.die("resolveConnection is not used by this test"), resolvePoolerFallback: () => Effect.succeed(Option.none()), }); 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 079b916611..95a7492153 100644 --- a/apps/cli/src/commands/db/dump/dump.integration.test.ts +++ b/apps/cli/src/commands/db/dump/dump.integration.test.ts @@ -259,6 +259,7 @@ function mockResolver(opts: { ref: ref === undefined ? undefined : Option.some(ref), }); }, + resolveConnection: () => Effect.die("resolveConnection is not used by this test"), resolvePoolerFallback: (flags) => { fallbackCalls.push(flags); return opts.poolerFallbackFails === true diff --git a/apps/cli/src/commands/db/lint/lint.integration.test.ts b/apps/cli/src/commands/db/lint/lint.integration.test.ts index c2b08bb854..84f8e58a98 100644 --- a/apps/cli/src/commands/db/lint/lint.integration.test.ts +++ b/apps/cli/src/commands/db/lint/lint.integration.test.ts @@ -77,6 +77,7 @@ function mockResolver(opts: { isLocal?: boolean } = {}) { isLocal: opts.isLocal ?? true, } satisfies ResolvedDbConfig); }, + resolveConnection: () => Effect.die("resolveConnection is not used by this test"), resolvePoolerFallback: () => Effect.succeed(Option.none()), }); return { 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 332162cd37..83ec23c9c5 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 @@ -81,6 +81,7 @@ function ambientStubs() { }), Layer.succeed(DbConfigResolver, { resolve: () => Effect.die("db-config-resolver not needed for layer-exposure test"), + resolveConnection: () => Effect.die("resolveConnection is not used by this test"), resolvePoolerFallback: () => Effect.die("db-config-resolver not needed for layer-exposure test"), }), 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 fe24fa1234..022a2dacaf 100644 --- a/apps/cli/src/commands/db/pull/pull.integration.test.ts +++ b/apps/cli/src/commands/db/pull/pull.integration.test.ts @@ -419,6 +419,7 @@ function setup(workdir: string, opts: SetupOpts = {}) { ref: opts.resolvedRef !== undefined ? Option.some(opts.resolvedRef) : Option.none(), }); }, + resolveConnection: () => Effect.die("resolveConnection is not used by this test"), resolvePoolerFallback: (resolveFlags) => { poolerFallbackCalls.push(resolveFlags); return Effect.succeed( 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 402cc067f6..2441ace3e4 100644 --- a/apps/cli/src/commands/db/push/push.integration.test.ts +++ b/apps/cli/src/commands/db/push/push.integration.test.ts @@ -95,6 +95,7 @@ function mockResolver( isLocal: opts.isLocal ?? true, } satisfies ResolvedDbConfig; }), + resolveConnection: () => Effect.die("resolveConnection is not used by this test"), resolvePoolerFallback: () => Effect.succeed(Option.none()), }); return { layer, calls }; 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 006f166d89..edb6ecb952 100644 --- a/apps/cli/src/commands/db/query/query.integration.test.ts +++ b/apps/cli/src/commands/db/query/query.integration.test.ts @@ -90,6 +90,7 @@ function mockResolver(isLocal = true, resolveFails = false) { }), ) : Effect.succeed({ conn: LOCAL_CONN, isLocal }), + resolveConnection: () => Effect.die("resolveConnection is not used by this test"), resolvePoolerFallback: () => Effect.succeed(Option.none()), }); } 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 aa9d30103b..fd49181673 100644 --- a/apps/cli/src/commands/db/reset/reset.integration.test.ts +++ b/apps/cli/src/commands/db/reset/reset.integration.test.ts @@ -171,6 +171,7 @@ function mockResolver(opts: { }) satisfies ResolvedDbConfig, ); }, + resolveConnection: () => Effect.die("resolveConnection is not used by this test"), resolvePoolerFallback: () => { calls++; return Effect.succeed(Option.none()); 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 c5c3d6e89d..6ecce6f456 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 @@ -322,6 +322,7 @@ function setup(workdir: string, opts: SetupOpts = {}) { isLocal: false, }); }, + resolveConnection: () => Effect.die("resolveConnection is not used by this test"), resolvePoolerFallback: () => Effect.succeed(Option.none()), }); const runtimeInfo = mockRuntimeInfo({ platform: "linux" }); 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 3415f1ecc7..9b6f208d75 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 @@ -317,6 +317,7 @@ function setup(workdir: string, opts: SetupOpts = {}) { }, isLocal: false, }), + resolveConnection: () => Effect.die("resolveConnection is not used by this test"), resolvePoolerFallback: () => Effect.succeed(Option.none()), }); const runtimeInfo = mockRuntimeInfo({ platform: "linux" }); 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 6ede790438..416129b310 100644 --- a/apps/cli/src/commands/db/test/test.integration.test.ts +++ b/apps/cli/src/commands/db/test/test.integration.test.ts @@ -108,6 +108,7 @@ function mockContextualAnalytics() { function mockResolver() { return Layer.succeed(DbConfigResolver, { resolve: () => Effect.succeed({ conn: LOCAL_CONN, isLocal: true }), + resolveConnection: () => Effect.die("resolveConnection is not used by this test"), resolvePoolerFallback: () => Effect.succeed(Option.none()), }); } 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 a2e93ba305..1ac6271bbe 100644 --- a/apps/cli/src/commands/gen/types/types.integration.test.ts +++ b/apps/cli/src/commands/gen/types/types.integration.test.ts @@ -173,6 +173,7 @@ function mockDbConfigResolver( ) ); }), + resolveConnection: () => Effect.die("resolveConnection is not used by this test"), resolvePoolerFallback: (flags) => opts.poolerFallbackFails === true ? Effect.fail(new DbConfigLoadError({ message: "pooler fallback failed" })) diff --git a/apps/cli/src/commands/inspect/db/inspect-deprecated.integration.test.ts b/apps/cli/src/commands/inspect/db/inspect-deprecated.integration.test.ts index 370fd7b0f1..34f0763fdb 100644 --- a/apps/cli/src/commands/inspect/db/inspect-deprecated.integration.test.ts +++ b/apps/cli/src/commands/inspect/db/inspect-deprecated.integration.test.ts @@ -43,6 +43,7 @@ function setup() { Layer.succeed(DbConfigResolver, { resolve: (_flags: DbConfigFlags) => Effect.succeed({ conn: LOCAL_CONN, isLocal: true } satisfies ResolvedDbConfig), + resolveConnection: () => Effect.die("resolveConnection is not used by this test"), resolvePoolerFallback: () => Effect.succeed(Option.none()), }), Layer.succeed(DbConnection, { diff --git a/apps/cli/src/commands/inspect/db/inspect-query.integration.test.ts b/apps/cli/src/commands/inspect/db/inspect-query.integration.test.ts index 9b02020273..5389d751de 100644 --- a/apps/cli/src/commands/inspect/db/inspect-query.integration.test.ts +++ b/apps/cli/src/commands/inspect/db/inspect-query.integration.test.ts @@ -93,6 +93,7 @@ function mockResolver(opts: { conn?: PgConnInput; isLocal?: boolean; fails?: boo isLocal: opts.isLocal ?? true, } satisfies ResolvedDbConfig); }, + resolveConnection: () => Effect.die("resolveConnection is not used by this test"), resolvePoolerFallback: () => Effect.succeed(Option.none()), }); return { diff --git a/apps/cli/src/commands/inspect/db/inspect-specs.integration.test.ts b/apps/cli/src/commands/inspect/db/inspect-specs.integration.test.ts index 6081127282..af9ab79e84 100644 --- a/apps/cli/src/commands/inspect/db/inspect-specs.integration.test.ts +++ b/apps/cli/src/commands/inspect/db/inspect-specs.integration.test.ts @@ -40,6 +40,7 @@ function setup(rows: ReadonlyArray>) { Layer.succeed(DbConfigResolver, { resolve: (_flags: DbConfigFlags) => Effect.succeed({ conn: LOCAL_CONN, isLocal: true } satisfies ResolvedDbConfig), + resolveConnection: () => Effect.die("resolveConnection is not used by this test"), resolvePoolerFallback: () => Effect.succeed(Option.none()), }), Layer.succeed(DbConnection, { 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 17a7ebf2de..6af551a24e 100644 --- a/apps/cli/src/commands/inspect/inspect.layers.unit.test.ts +++ b/apps/cli/src/commands/inspect/inspect.layers.unit.test.ts @@ -57,6 +57,7 @@ function ambientStubs() { }), Layer.succeed(DbConfigResolver, { resolve: () => Effect.die("db-config-resolver not needed for layer-exposure test"), + resolveConnection: () => Effect.die("resolveConnection is not used by this test"), resolvePoolerFallback: () => Effect.die("db-config-resolver not needed for layer-exposure test"), }), 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 721f96bde2..8a3c79b375 100644 --- a/apps/cli/src/commands/inspect/report/report.integration.test.ts +++ b/apps/cli/src/commands/inspect/report/report.integration.test.ts @@ -91,6 +91,7 @@ function mockResolver(opts: { conn?: PgConnInput; isLocal?: boolean; fails?: boo isLocal: opts.isLocal ?? true, } satisfies ResolvedDbConfig); }, + resolveConnection: () => Effect.die("resolveConnection is not used by this test"), resolvePoolerFallback: () => Effect.succeed(Option.none()), }); return { 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 2c67eb3312..d180df41a4 100644 --- a/apps/cli/src/commands/migration/down/down.integration.test.ts +++ b/apps/cli/src/commands/migration/down/down.integration.test.ts @@ -99,6 +99,7 @@ function setup(workdir: string, opts: SetupOpts = {}) { isLocal: true, ref: Option.none(), } satisfies ResolvedDbConfig), + resolveConnection: () => Effect.die("resolveConnection is not used by this test"), resolvePoolerFallback: () => Effect.succeed(Option.none()), }); diff --git a/apps/cli/src/commands/migration/fetch/fetch.integration.test.ts b/apps/cli/src/commands/migration/fetch/fetch.integration.test.ts index 13f13a468e..a82d6d1eac 100644 --- a/apps/cli/src/commands/migration/fetch/fetch.integration.test.ts +++ b/apps/cli/src/commands/migration/fetch/fetch.integration.test.ts @@ -93,6 +93,7 @@ function setup(workdir: string, opts: SetupOpts = {}) { isLocal: false, ref: Option.some(VALID_REF), } satisfies ResolvedDbConfig), + resolveConnection: () => Effect.die("resolveConnection is not used by this test"), resolvePoolerFallback: () => Effect.succeed(Option.none()), }); 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 aaf2146312..8a490e38bf 100644 --- a/apps/cli/src/commands/migration/list/list.integration.test.ts +++ b/apps/cli/src/commands/migration/list/list.integration.test.ts @@ -78,6 +78,7 @@ function setup(workdir: string, opts: SetupOpts = {}) { ref: Option.some(VALID_REF), } satisfies ResolvedDbConfig); }, + resolveConnection: () => Effect.die("resolveConnection is not used by this test"), resolvePoolerFallback: () => Effect.succeed(Option.none()), }); 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 3b8a7cc593..4787c6466b 100644 --- a/apps/cli/src/commands/migration/repair/repair.integration.test.ts +++ b/apps/cli/src/commands/migration/repair/repair.integration.test.ts @@ -95,6 +95,7 @@ function setup(workdir: string, opts: SetupOpts = {}) { isLocal: false, ref: Option.some(VALID_REF), } satisfies ResolvedDbConfig), + resolveConnection: () => Effect.die("resolveConnection is not used by this test"), resolvePoolerFallback: () => Effect.succeed(Option.none()), }); 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 4c1bf298c0..97d151570c 100644 --- a/apps/cli/src/commands/migration/squash/squash.integration.test.ts +++ b/apps/cli/src/commands/migration/squash/squash.integration.test.ts @@ -341,6 +341,7 @@ function setup(workdir: string, opts: SetupOpts = {}) { : { ref: opts.linkedRef !== undefined ? Option.some(opts.linkedRef) : Option.none() }), } satisfies ResolvedDbConfig); }, + resolveConnection: () => Effect.die("resolveConnection is not used by this test"), resolvePoolerFallback: () => Effect.succeed(Option.none()), }); 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 86b4a55f7b..8aa25fd4fc 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 @@ -73,6 +73,7 @@ const testLayer = (root: string) => }, isLocal: true, }), + resolveConnection: () => Effect.die("resolveConnection is not used by this test"), resolvePoolerFallback: () => Effect.succeed(Option.none()), }), Layer.succeed(DebugFlag, false), 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 8ae7aeaa37..77e913fb50 100644 --- a/apps/cli/src/commands/migration/up/up.integration.test.ts +++ b/apps/cli/src/commands/migration/up/up.integration.test.ts @@ -82,6 +82,7 @@ function setup(workdir: string, opts: SetupOpts = {}) { isLocal: true, ref: Option.none(), } satisfies ResolvedDbConfig), + resolveConnection: () => Effect.die("resolveConnection is not used by this test"), resolvePoolerFallback: () => Effect.succeed(Option.none()), }); diff --git a/apps/cli/src/commands/pull/pull.integration.test.ts b/apps/cli/src/commands/pull/pull.integration.test.ts index 6d59f7a813..88a6125e93 100644 --- a/apps/cli/src/commands/pull/pull.integration.test.ts +++ b/apps/cli/src/commands/pull/pull.integration.test.ts @@ -392,6 +392,7 @@ function makeDbConfigLayers( ref: Option.some(VALID_REF), }); }, + resolveConnection: () => Effect.die("resolveConnection is not used by this test"), resolvePoolerFallback: () => Effect.succeed(Option.none()), }); diff --git a/apps/cli/src/shared/telemetry/__fixtures__/error-tags.txt b/apps/cli/src/shared/telemetry/__fixtures__/error-tags.txt index 4bfe70d5e1..edd36b0929 100644 --- a/apps/cli/src/shared/telemetry/__fixtures__/error-tags.txt +++ b/apps/cli/src/shared/telemetry/__fixtures__/error-tags.txt @@ -338,6 +338,7 @@ LinkParentRefInvalidError LinkProjectStatusError LinkProjectStatusNetworkError LinkRefArgConflictError +LinkedDbNetworkBannedError LocalDbRunningError LoginCryptoError LoginDecryptError diff --git a/apps/cli/src/shared/telemetry/error-actionability.ts b/apps/cli/src/shared/telemetry/error-actionability.ts index f220feaae0..ccdcabdfa5 100644 --- a/apps/cli/src/shared/telemetry/error-actionability.ts +++ b/apps/cli/src/shared/telemetry/error-actionability.ts @@ -71,6 +71,7 @@ const CLI_SUGGESTED_COMMANDS = [ "supabase branches create", "supabase link", "supabase login", + "supabase network-bans remove", "supabase seed buckets", "supabase start", "supabase stop", @@ -222,6 +223,7 @@ type CliErrorSuggestion = readonly suggestion_type: typeof CliSuggestionType.RunCommand; readonly suggested_command?: | "supabase branches create" + | "supabase network-bans remove" | "supabase seed buckets" | "supabase start" | "supabase stop"; From f6cfd1c433870ba147c87c16c8133fc4786f76f6 Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Thu, 15 Oct 2026 13:53:40 +0100 Subject: [PATCH 11/92] feat(cli): add the hosted-mutation consent service (CLI-2695) requireHostedMutationConsent grants on --yes or a shell SUPABASE_YES, asks only an interactive text-mode terminal, and otherwise fails with HostedMutationConsentRequiredError; stdin is never read. A local target needs no consent, and hostedMutationPreapproval lets reads clear a network ban only under --yes. --- .../db-config.integration.test.ts | 15 +- .../hosted-consent/hosted-consent.errors.ts | 28 ++ .../hosted-consent/hosted-consent.ts | 121 +++++ .../hosted-consent.unit.test.ts | 427 ++++++++++++++++++ .../src/shared/output/command-target.layer.ts | 1 + .../shared/output/command-target.service.ts | 2 + .../shared/output/output.layer.unit.test.ts | 27 +- .../telemetry/__fixtures__/error-tags.txt | 2 + .../shared/telemetry/error-actionability.ts | 10 + 9 files changed, 625 insertions(+), 8 deletions(-) create mode 100644 apps/cli/src/command-internal/hosted-consent/hosted-consent.errors.ts create mode 100644 apps/cli/src/command-internal/hosted-consent/hosted-consent.ts create mode 100644 apps/cli/src/command-internal/hosted-consent/hosted-consent.unit.test.ts 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 e5d9118ee7..c58cdeb32e 100644 --- a/apps/cli/src/command-internal/db-config.integration.test.ts +++ b/apps/cli/src/command-internal/db-config.integration.test.ts @@ -55,7 +55,9 @@ import { OutputFlag, ProfileFlag, WorkdirFlag, + YesFlag, } from "./global-flags.ts"; +import { CliArgs } from "../shared/cli/cli-args.service.ts"; import { CliConfigFlagInputs, makeCliConfigFlagInputs } from "../config/cli-config-flags.ts"; import { commandTargetLayer } from "../shared/output/command-target.layer.ts"; import { cliConfigValuesLayer } from "../config/cli-config-values.layer.ts"; @@ -64,7 +66,7 @@ import { DebugLogger } from "../shared/output/debug-logger.service.ts"; import { identityStitchLayer } from "./identity-stitch.ts"; import { DbConfigResolver } from "./db-config.service.ts"; import type { DbConfigFlags, DbConnectionInput } from "./db-config.types.ts"; -import type { HostedMutationConsent } from "./hosted-consent/hosted-consent.token.ts"; +import { hostedMutationPreapproval } from "./hosted-consent/hosted-consent.ts"; import type { ResolvedTarget } from "./target/resolve-target.ts"; import { DbConnectError } from "./db-connection.errors.ts"; import { DbConnection, type DbSession, type PgConnInput } from "./db-connection.service.ts"; @@ -1986,10 +1988,11 @@ describe("dbConfigResolver (container pooler fallback)", () => { }); describe("dbConfigResolver.resolveConnection", () => { - const approval = Option.some({ - action: "remove network bans", - via: "yes", - } as unknown as HostedMutationConsent); + const approval = hostedMutationPreapproval.pipe( + Effect.provide( + Layer.mergeAll(Layer.succeed(YesFlag, true), Layer.succeed(CliArgs, { args: [] })), + ), + ); const inputFor = ( target: ResolvedTarget, overrides: Partial = {}, @@ -2282,7 +2285,7 @@ describe("dbConfigResolver.resolveConnection", () => { yield* bannedRequests(requests, ["203.0.113.7"]); const { fiber, advance } = yield* forkResolve( inputFor(linkedTarget(ref, { source: "flag", matchesLinkedRef: false }), { - networkBanRemoval: approval, + networkBanRemoval: yield* approval, }), connection.layer, ); diff --git a/apps/cli/src/command-internal/hosted-consent/hosted-consent.errors.ts b/apps/cli/src/command-internal/hosted-consent/hosted-consent.errors.ts new file mode 100644 index 0000000000..9b0ad58403 --- /dev/null +++ b/apps/cli/src/command-internal/hosted-consent/hosted-consent.errors.ts @@ -0,0 +1,28 @@ +import { Data } from "effect"; + +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../../shared/telemetry/error-actionability.ts"; + +/** A hosted mutation needs confirmation and this run can neither prompt nor see `--yes`. */ +export class HostedMutationConsentRequiredError extends Data.TaggedError( + "HostedMutationConsentRequiredError", +)<{ + readonly message: string; + readonly suggestion: string; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.consentRequired; + } +} + +/** The user answered no to a hosted-mutation confirmation. */ +export class HostedMutationDeclinedError extends Data.TaggedError("HostedMutationDeclinedError")<{ + readonly message: string; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.cancelled; + } +} diff --git a/apps/cli/src/command-internal/hosted-consent/hosted-consent.ts b/apps/cli/src/command-internal/hosted-consent/hosted-consent.ts new file mode 100644 index 0000000000..df51ee90d7 --- /dev/null +++ b/apps/cli/src/command-internal/hosted-consent/hosted-consent.ts @@ -0,0 +1,121 @@ +import { Effect, Option } from "effect"; + +import { CommandTarget } from "../../shared/output/command-target.service.ts"; +import { CONTEXT_CANCELED_MESSAGE } from "../../shared/output/errors.ts"; +import { Output } from "../../shared/output/output.service.ts"; +import { isMachineResourceFormat } from "../../shared/output/resource-format.ts"; +import { Tty } from "../../shared/runtime/tty.service.ts"; +import type { ResolvedDbConfig } from "../db-config.types.ts"; +import { OutputFlag, resolveYes } from "../global-flags.ts"; +import type { ResolvedTarget } from "../target/resolve-target.ts"; +import { TargetPolicy } from "../target/target-policy.ts"; +import { + HostedMutationConsentRequiredError, + HostedMutationDeclinedError, +} from "./hosted-consent.errors.ts"; +import type { HostedMutationConsent } from "./hosted-consent.token.ts"; + +export interface HostedMutationRequest { + /** What is about to change, phrased to follow "Confirmed by --yes:". */ + readonly action: string; + /** Plan lines printed before the question. */ + readonly details?: ReadonlyArray; + /** Asks for `expected` to be typed instead of a yes/no answer. */ + readonly confirm?: { readonly kind: "typed"; readonly expected: string }; +} + +// The brand is type-only, so minting a token needs this one assertion. +const grant = (action: string, via: HostedMutationConsent["via"]): HostedMutationConsent => + ({ action, via }) as HostedMutationConsent; + +const announcedTargetSuffix = Effect.serviceOption(CommandTarget).pipe( + Effect.flatMap( + Option.match({ + onNone: () => Effect.succeed(""), + onSome: (cell) => + Effect.map(cell.text, Option.match({ onNone: () => "", onSome: (text) => ` on ${text}` })), + }), + ), +); + +const consentRequired = (request: HostedMutationRequest, onTarget: string) => + new HostedMutationConsentRequiredError({ + message: `${request.action}${onTarget} needs confirmation, and this run can't prompt. Nothing was changed.`, + suggestion: `Re-run with --yes to confirm ${request.action}, or run it in an interactive terminal.`, + }); + +const ask = Effect.fnUntraced(function* ( + request: HostedMutationRequest, + defaultValue: boolean, + onTarget: string, +) { + const output = yield* Output; + if (request.details !== undefined && request.details.length > 0) { + yield* output.raw(`${request.details.join("\n")}\n`, "stderr"); + } + if (request.confirm === undefined) { + return yield* output.promptConfirm(`${request.action}${onTarget}?`, { defaultValue }); + } + yield* output.raw(`${request.action}${onTarget}\n`, "stderr"); + const typed = yield* output.promptText(`Type ${request.confirm.expected} to confirm`); + return typed.trim() === request.confirm.expected; +}); + +/** + * Grants consent for a hosted mutation or fails. `--yes` or a shell `SUPABASE_YES` (never the + * project `.env`) grants it; otherwise only an interactive text-mode terminal is asked. Stdin is + * never read, so a piped answer does not count. + */ +export const requireHostedMutationConsent = Effect.fn("HostedConsent.require")(function* ( + request: HostedMutationRequest, +) { + const policy = yield* TargetPolicy; + if (policy.consent === "none") { + return yield* Effect.die( + new Error( + `requireHostedMutationConsent needs a target policy with consent "yes" or "no"; "${policy.group}" declares "none"`, + ), + ); + } + const output = yield* Output; + const tty = yield* Tty; + const outputFlag = yield* OutputFlag; + const onTarget = yield* announcedTargetSuffix; + + if (yield* resolveYes) { + yield* output.raw(`Confirmed by --yes: ${request.action}${onTarget}\n`, "stderr"); + yield* Effect.annotateCurrentSpan("consent.via", "yes"); + return grant(request.action, "yes"); + } + + const canPrompt = + output.format === "text" && + output.interactive && + tty.stdinIsTty && + !isMachineResourceFormat(outputFlag); + if (!canPrompt) return yield* consentRequired(request, onTarget); + + const confirmed = yield* ask(request, policy.consent === "yes", onTarget).pipe( + Effect.catchTag("NonInteractiveError", () => consentRequired(request, onTarget)), + ); + if (!confirmed) { + return yield* new HostedMutationDeclinedError({ message: CONTEXT_CANCELED_MESSAGE }); + } + yield* Effect.annotateCurrentSpan("consent.via", "prompt"); + return grant(request.action, "prompt"); +}); + +/** `None` for a local target (a local `ResolvedTarget` or a connection with `isLocal`); otherwise {@link requireHostedMutationConsent}. */ +export const requireTargetMutationConsent = Effect.fn("HostedConsent.requireForTarget")(function* ( + target: ResolvedTarget | ResolvedDbConfig, + request: HostedMutationRequest, +) { + const isLocal = "isLocal" in target ? target.isLocal : target.kind === "local"; + if (isLocal) return Option.none(); + return Option.some(yield* requireHostedMutationConsent(request)); +}); + +/** `Some` only under `--yes` or a shell `SUPABASE_YES`; never prompts, so a read can hand it to a connection that may clear a network ban. */ +export const hostedMutationPreapproval = Effect.map(resolveYes, (yes) => + yes ? Option.some(grant("remove network bans", "yes")) : Option.none(), +); diff --git a/apps/cli/src/command-internal/hosted-consent/hosted-consent.unit.test.ts b/apps/cli/src/command-internal/hosted-consent/hosted-consent.unit.test.ts new file mode 100644 index 0000000000..6582b2c8c6 --- /dev/null +++ b/apps/cli/src/command-internal/hosted-consent/hosted-consent.unit.test.ts @@ -0,0 +1,427 @@ +import { BunServices } from "@effect/platform-bun"; +import { describe, expect, it } from "@effect/vitest"; +import { Cause, Effect, Exit, FileSystem, Layer, Option, Path, Redacted } from "effect"; + +import { withConfigEnv, withEmptyConfigEnv } from "../../../tests/helpers/command-mocks.ts"; +import { mockOutput, mockTty } from "../../../tests/helpers/mocks.ts"; +import { CliArgs } from "../../shared/cli/cli-args.service.ts"; +import { commandTargetLayer } from "../../shared/output/command-target.layer.ts"; +import { announceTarget } from "../../shared/output/command-target.service.ts"; +import type { OutputFormat } from "../../shared/output/types.ts"; +import { classifyCliErrorActionability } from "../../shared/telemetry/error-actionability.ts"; +import { loadProjectEnvValues } from "../db-config.toml-read.ts"; +import type { ResolvedDbConfig } from "../db-config.types.ts"; +import { OutputFlag, YesFlag } from "../global-flags.ts"; +import type { ResolvedTarget } from "../target/resolve-target.ts"; +import { TargetPolicy } from "../target/target-policy.ts"; +import type { TargetPolicyShape } from "../target/target-policy.ts"; +import { + HostedMutationConsentRequiredError, + HostedMutationDeclinedError, +} from "./hosted-consent.errors.ts"; +import { + hostedMutationPreapproval, + requireHostedMutationConsent, + requireTargetMutationConsent, +} from "./hosted-consent.ts"; +import type { HostedMutationRequest } from "./hosted-consent.ts"; + +type OutputFlagValue = "pretty" | "json" | "yaml" | "toml" | "env" | "table" | "csv"; + +interface Scenario { + readonly policy?: TargetPolicyShape; + readonly format?: OutputFormat; + readonly interactive?: boolean; + readonly stdinIsTty?: boolean; + readonly yes?: boolean; + readonly args?: ReadonlyArray; + readonly outputFlag?: OutputFlagValue; + readonly confirmResponses?: ReadonlyArray; + readonly textResponses?: ReadonlyArray; + readonly promptFails?: boolean; + readonly shellYes?: string; + readonly announced?: string; +} + +const REQUEST: HostedMutationRequest = { + action: "apply 3 migrations", + details: [" 20240101_a.sql", " 20240102_b.sql"], +}; + +const interactiveTerminal: Scenario = { format: "text", interactive: true, stdinIsTty: true }; + +const harness = (scenario: Scenario) => { + const out = mockOutput({ + format: scenario.format ?? "text", + interactive: scenario.interactive, + promptConfirmResponses: scenario.confirmResponses, + promptTextResponses: scenario.textResponses, + promptConfirmFail: scenario.promptFails, + }); + const layer = Layer.mergeAll( + out.layer, + commandTargetLayer.pipe(Layer.provide(out.layer)), + mockTty({ + stdinIsTty: scenario.stdinIsTty ?? false, + stdoutIsTty: scenario.stdinIsTty ?? false, + }), + Layer.succeed(YesFlag, scenario.yes ?? false), + Layer.succeed(CliArgs, { args: scenario.args ?? [] }), + Layer.succeed( + OutputFlag, + scenario.outputFlag === undefined ? Option.none() : Option.some(scenario.outputFlag), + ), + Layer.succeed(TargetPolicy, scenario.policy ?? { group: "hosted", consent: "no" }), + ); + const withShellEnv = (body: Effect.Effect) => + scenario.shellYes === undefined + ? withEmptyConfigEnv(body) + : withConfigEnv({ SUPABASE_YES: scenario.shellYes }, body); + const prepare = (body: Effect.Effect) => + withShellEnv( + (scenario.announced === undefined + ? body + : Effect.flatMap(announceTarget({ text: scenario.announced, json: {} }), () => body) + ).pipe(Effect.provide(layer)), + ); + return { out, prepare }; +}; + +const consent = (scenario: Scenario, request: HostedMutationRequest = REQUEST) => { + const { out, prepare } = harness(scenario); + const exit = prepare(requireHostedMutationConsent(request)).pipe(Effect.exit); + return { out, exit }; +}; + +const successOf = (exit: Exit.Exit): A => { + if (!Exit.isSuccess(exit)) throw new Error(`expected success, got ${Cause.pretty(exit.cause)}`); + return exit.value; +}; + +const failureOf = (exit: Exit.Exit): E => { + if (!Exit.isFailure(exit)) throw new Error("expected a failure"); + const failure = Exit.findErrorOption(exit); + if (Option.isNone(failure)) + throw new Error(`expected a typed failure, got ${Cause.pretty(exit.cause)}`); + return failure.value; +}; + +describe("requireHostedMutationConsent", () => { + describe("default answer", () => { + it.effect.each([ + { consent: "no", expected: false }, + { consent: "yes", expected: true }, + ] as const)( + "offers $expected as the default when the policy says $consent", + ({ consent: policyConsent, expected }) => + Effect.gen(function* () { + const run = consent({ + ...interactiveTerminal, + policy: { group: "hosted", consent: policyConsent }, + confirmResponses: [true], + }); + yield* run.exit; + expect(run.out.promptConfirmCalls).toEqual([ + { message: "apply 3 migrations?", opts: { defaultValue: expected } }, + ]); + }), + ); + + it.effect("dies when the command declares no consent", () => + Effect.gen(function* () { + const exit = yield* consent({ + ...interactiveTerminal, + policy: { group: "hosted", consent: "none" }, + }).exit; + expect(Exit.isFailure(exit) && Cause.hasDies(exit.cause)).toBe(true); + }), + ); + }); + + describe("--yes", () => { + it.effect("grants without asking and echoes the action and target on stderr", () => + Effect.gen(function* () { + const run = consent({ + yes: true, + format: "json", + announced: "project abcdefghijklmnopqrst", + }); + const token = successOf(yield* run.exit); + expect(token).toMatchObject({ action: "apply 3 migrations", via: "yes" }); + expect(run.out.promptConfirmCalls).toEqual([]); + expect(run.out.stderrText).toContain( + "Confirmed by --yes: apply 3 migrations on project abcdefghijklmnopqrst\n", + ); + }), + ); + + it.effect("omits the target when nothing was announced", () => + Effect.gen(function* () { + const run = consent({ yes: true }); + yield* run.exit; + expect(run.out.stderrText).toBe("Confirmed by --yes: apply 3 migrations\n"); + }), + ); + + it.effect("skips a typed confirmation", () => + Effect.gen(function* () { + const run = consent( + { ...interactiveTerminal, yes: true }, + { action: "delete compute api", confirm: { kind: "typed", expected: "api" } }, + ); + const token = successOf(yield* run.exit); + expect(token.via).toBe("yes"); + expect(run.out.promptTextCalls).toEqual([]); + }), + ); + + it.effect("is overridden by an explicit --yes=false even when SUPABASE_YES is set", () => + Effect.gen(function* () { + const exit = yield* consent({ args: ["--yes=false"], shellYes: "1" }).exit; + expect(failureOf(exit)).toBeInstanceOf(HostedMutationConsentRequiredError); + }), + ); + }); + + describe("SUPABASE_YES", () => { + it.effect("grants when set in the shell environment", () => + Effect.gen(function* () { + const run = consent({ shellYes: "1", format: "json" }); + const token = successOf(yield* run.exit); + expect(token.via).toBe("yes"); + expect(run.out.stderrText).toContain("Confirmed by --yes: apply 3 migrations"); + }), + ); + + it.effect("does not grant when it is only in the project .env", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const workdir = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-consent-" }); + yield* fs.makeDirectory(path.join(workdir, "supabase"), { recursive: true }); + yield* fs.writeFileString(path.join(workdir, "supabase", ".env"), "SUPABASE_YES=1\n"); + expect(yield* loadProjectEnvValues(fs, path, workdir)).toMatchObject({ SUPABASE_YES: "1" }); + + const exit = yield* consent({}).exit; + expect(failureOf(exit)).toBeInstanceOf(HostedMutationConsentRequiredError); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), + ); + }); + + describe("when the run cannot prompt", () => { + it.effect.each([ + { name: "json output", scenario: { format: "json", stdinIsTty: true } }, + { name: "stream-json output", scenario: { format: "stream-json", stdinIsTty: true } }, + { + name: "-o yaml in a terminal", + scenario: { ...interactiveTerminal, outputFlag: "yaml" }, + }, + { name: "-o json in a terminal", scenario: { ...interactiveTerminal, outputFlag: "json" } }, + { + name: "piped stdin carrying an affirmative answer", + scenario: { + format: "text", + interactive: true, + stdinIsTty: false, + confirmResponses: [true], + }, + }, + { + name: "a non-interactive stdout", + scenario: { format: "text", interactive: false, stdinIsTty: true }, + }, + ] as const satisfies ReadonlyArray<{ name: string; scenario: Scenario }>)( + "fails with ConsentRequiredError for $name and never asks", + ({ scenario }) => + Effect.gen(function* () { + const run = consent({ ...scenario, announced: "project abcdefghijklmnopqrst" }); + const error = failureOf(yield* run.exit); + expect(error).toBeInstanceOf(HostedMutationConsentRequiredError); + expect(error).toMatchObject({ + message: + "apply 3 migrations on project abcdefghijklmnopqrst needs confirmation, and this run can't prompt. Nothing was changed.", + suggestion: + "Re-run with --yes to confirm apply 3 migrations, or run it in an interactive terminal.", + }); + expect(run.out.promptConfirmCalls).toEqual([]); + expect(run.out.promptTextCalls).toEqual([]); + }), + ); + + it.effect("fails with ConsentRequiredError when the prompt turns out to be unavailable", () => + Effect.gen(function* () { + const exit = yield* consent({ ...interactiveTerminal, promptFails: true }).exit; + expect(failureOf(exit)).toBeInstanceOf(HostedMutationConsentRequiredError); + }), + ); + }); + + describe("in an interactive terminal", () => { + it.effect("grants an accepted prompt and prints the details first", () => + Effect.gen(function* () { + const run = consent({ + ...interactiveTerminal, + confirmResponses: [true], + announced: "project abcdefghijklmnopqrst", + }); + const token = successOf(yield* run.exit); + expect(token).toMatchObject({ action: "apply 3 migrations", via: "prompt" }); + expect(run.out.stderrText).toBe( + "Target: project abcdefghijklmnopqrst\n 20240101_a.sql\n 20240102_b.sql\n", + ); + expect(run.out.promptConfirmCalls.map((call) => call.message)).toEqual([ + "apply 3 migrations on project abcdefghijklmnopqrst?", + ]); + }), + ); + + it.effect("fails with DeclinedError when the answer is no", () => + Effect.gen(function* () { + const run = consent({ ...interactiveTerminal, confirmResponses: [false] }); + const error = failureOf(yield* run.exit); + expect(error).toBeInstanceOf(HostedMutationDeclinedError); + expect(error).toMatchObject({ message: "context canceled" }); + }), + ); + + describe("typed confirmation", () => { + const typed: HostedMutationRequest = { + action: "delete compute api", + details: ["This permanently deletes the compute."], + confirm: { kind: "typed", expected: "api" }, + }; + + it.effect("grants when the typed answer matches", () => + Effect.gen(function* () { + const run = consent({ ...interactiveTerminal, textResponses: [" api "] }, typed); + const token = successOf(yield* run.exit); + expect(token.via).toBe("prompt"); + expect(run.out.promptTextCalls.map((call) => call.message)).toEqual([ + "Type api to confirm", + ]); + expect(run.out.promptConfirmCalls).toEqual([]); + expect(run.out.stderrText).toBe( + "This permanently deletes the compute.\ndelete compute api\n", + ); + }), + ); + + it.effect("fails with DeclinedError when the typed answer differs", () => + Effect.gen(function* () { + const run = consent({ ...interactiveTerminal, textResponses: ["apis"] }, typed); + expect(failureOf(yield* run.exit)).toBeInstanceOf(HostedMutationDeclinedError); + }), + ); + }); + }); + + it("classifies the errors for telemetry", () => { + expect( + classifyCliErrorActionability( + new HostedMutationConsentRequiredError({ message: "m", suggestion: "s" }), + ), + ).toMatchObject({ + error_kind: "user_actionable", + error_category: "consent_required", + has_suggestion: true, + suggestion_type: "provide_flags", + }); + expect( + classifyCliErrorActionability(new HostedMutationDeclinedError({ message: "m" })), + ).toMatchObject({ error_kind: "user_cancelled", error_category: "cancelled" }); + }); +}); + +describe("hostedMutationPreapproval", () => { + const preapproval = (scenario: Scenario) => harness(scenario).prepare(hostedMutationPreapproval); + + it.effect("is Some under --yes", () => + Effect.gen(function* () { + expect(Option.isSome(yield* preapproval({ yes: true }))).toBe(true); + }), + ); + + it.effect("is Some under a shell SUPABASE_YES", () => + Effect.gen(function* () { + expect(Option.isSome(yield* preapproval({ shellYes: "1" }))).toBe(true); + }), + ); + + it.effect("is None in a terminal that could prompt", () => + Effect.gen(function* () { + const { out, prepare } = harness({ ...interactiveTerminal, confirmResponses: [true] }); + expect(Option.isNone(yield* prepare(hostedMutationPreapproval))).toBe(true); + expect(out.promptConfirmCalls).toEqual([]); + }), + ); + + it.effect("is None under --yes=false", () => + Effect.gen(function* () { + expect(Option.isNone(yield* preapproval({ args: ["--yes=false"], shellYes: "1" }))).toBe( + true, + ); + }), + ); +}); + +describe("requireTargetMutationConsent", () => { + const dbConfig = (isLocal: boolean): ResolvedDbConfig => ({ + conn: { + host: "db.example.com", + port: 5432, + user: "postgres", + password: "", + database: "postgres", + }, + isLocal, + }); + const linked: ResolvedTarget = { + kind: "linked", + ref: "abcdefghijklmnopqrst", + source: "file", + matchesLinkedRef: true, + }; + + const forTarget = (target: ResolvedTarget | ResolvedDbConfig, scenario: Scenario = {}) => { + const { out, prepare } = harness(scenario); + return { out, exit: prepare(requireTargetMutationConsent(target, REQUEST)).pipe(Effect.exit) }; + }; + + it.effect.each([ + { name: "a local target", target: { kind: "local" } satisfies ResolvedTarget }, + { name: "a connection to the local stack", target: dbConfig(true) }, + ])("is None for $name without asking or echoing", ({ target }) => + Effect.gen(function* () { + const run = forTarget(target, { ...interactiveTerminal, yes: true }); + const result = successOf(yield* run.exit); + expect(Option.isNone(result)).toBe(true); + expect(run.out.promptConfirmCalls).toEqual([]); + expect(run.out.stderrText).toBe(""); + }), + ); + + it.effect.each([ + { name: "a linked target", target: linked }, + { + name: "an unclassified --db-url target", + target: { + kind: "db-url", + url: Redacted.make("postgresql://postgres:secret@db.example.com:5432/postgres"), + } satisfies ResolvedTarget, + }, + { name: "a connection to a hosted database", target: dbConfig(false) }, + ])("asks for $name", ({ target }) => + Effect.gen(function* () { + const run = forTarget(target, { ...interactiveTerminal, confirmResponses: [true] }); + const result = successOf(yield* run.exit); + expect(Option.map(result, (token) => token.via)).toEqual(Option.some("prompt")); + }), + ); + + it.effect("fails for a hosted target in a run that cannot prompt", () => + Effect.gen(function* () { + const error = failureOf(yield* forTarget(linked, { format: "json" }).exit); + expect(error).toBeInstanceOf(HostedMutationConsentRequiredError); + }), + ); +}); diff --git a/apps/cli/src/shared/output/command-target.layer.ts b/apps/cli/src/shared/output/command-target.layer.ts index 84ad430f1f..7805643b59 100644 --- a/apps/cli/src/shared/output/command-target.layer.ts +++ b/apps/cli/src/shared/output/command-target.layer.ts @@ -47,6 +47,7 @@ export const commandTargetLayer = Layer.effect( yield* output.raw(`Target: ${rendered.text}\n`, "stderr"); }), json: Ref.get(state).pipe(Effect.map((current) => current.first)), + text: Ref.get(state).pipe(Effect.map((current) => Option.fromNullishOr(current.texts[0]))), }); }), ); diff --git a/apps/cli/src/shared/output/command-target.service.ts b/apps/cli/src/shared/output/command-target.service.ts index 6782f55b76..d2baeb11b2 100644 --- a/apps/cli/src/shared/output/command-target.service.ts +++ b/apps/cli/src/shared/output/command-target.service.ts @@ -18,6 +18,8 @@ interface CommandTargetShape { readonly announce: (rendered: RenderedTarget) => Effect.Effect; /** The first announced target's JSON form, or `None` before any announcement. */ readonly json: Effect.Effect>; + /** The first announced target's text, or `None` before any announcement. */ + readonly text: Effect.Effect>; } /** One cell per command invocation, provided at the root. */ diff --git a/apps/cli/src/shared/output/output.layer.unit.test.ts b/apps/cli/src/shared/output/output.layer.unit.test.ts index 495190808a..b73503850e 100644 --- a/apps/cli/src/shared/output/output.layer.unit.test.ts +++ b/apps/cli/src/shared/output/output.layer.unit.test.ts @@ -1,10 +1,22 @@ import { describe, expect, it } from "@effect/vitest"; import { beforeEach, vi } from "vitest"; -import { Cause, Deferred, Effect, Exit, Fiber, Layer, Schema, Sink, Stdio, Stream } from "effect"; +import { + Cause, + Deferred, + Effect, + Exit, + Fiber, + Layer, + Option, + Schema, + Sink, + Stdio, + Stream, +} from "effect"; import { TestClock } from "effect/testing"; import { CONTEXT_CANCELED_MESSAGE, NonInteractiveError } from "./errors.ts"; import { mockTty } from "../../../tests/helpers/mocks.ts"; -import { announceTarget } from "./command-target.service.ts"; +import { announceTarget, CommandTarget } from "./command-target.service.ts"; import { commandTargetLayer } from "./command-target.layer.ts"; import { machineErrorContextLayer } from "./machine-error-context.layer.ts"; import { MachineErrorContext } from "./machine-error-context.service.ts"; @@ -1494,6 +1506,17 @@ describe("Output", () => { }).pipe(Effect.provide(layerWith(jsonOutputLayer, mock))); }); + it.effect("the cell reports the first announced text, or none before an announcement", () => { + const mock = mockStdio(); + return Effect.gen(function* () { + const cell = yield* CommandTarget; + expect(yield* cell.text).toEqual(Option.none()); + yield* announceTarget(linkedTarget); + yield* announceTarget(localTarget); + expect(yield* cell.text).toEqual(Option.some(linkedTarget.text)); + }).pipe(Effect.provide(layerWith(jsonOutputLayer, mock))); + }); + it.effect("an error context target set before the announcement is kept", () => { const mock = mockStdio(); return Effect.gen(function* () { diff --git a/apps/cli/src/shared/telemetry/__fixtures__/error-tags.txt b/apps/cli/src/shared/telemetry/__fixtures__/error-tags.txt index edd36b0929..1095c5dd57 100644 --- a/apps/cli/src/shared/telemetry/__fixtures__/error-tags.txt +++ b/apps/cli/src/shared/telemetry/__fixtures__/error-tags.txt @@ -304,6 +304,8 @@ GenTypesWorkdirError HealthCheckProbeError HealthCheckTimeoutError HostPostgresClientError +HostedMutationConsentRequiredError +HostedMutationDeclinedError ImagePrepullError InitConfigExistsError InitParseSettingsError diff --git a/apps/cli/src/shared/telemetry/error-actionability.ts b/apps/cli/src/shared/telemetry/error-actionability.ts index ccdcabdfa5..6a461bd6a2 100644 --- a/apps/cli/src/shared/telemetry/error-actionability.ts +++ b/apps/cli/src/shared/telemetry/error-actionability.ts @@ -38,6 +38,7 @@ export const CliErrorCategory = { PlanLimit: "plan_limit", ProjectPaused: "project_paused", InvalidInput: "invalid_input", + ConsentRequired: "consent_required", ResourceLimit: "resource_limit", Network: "network", ApiStatus: "api_status", @@ -175,6 +176,7 @@ type UserActionableErrorCategory = | typeof CliErrorCategory.PlanLimit | typeof CliErrorCategory.ProjectPaused | typeof CliErrorCategory.InvalidInput + | typeof CliErrorCategory.ConsentRequired | typeof CliErrorCategory.ResourceLimit; type CliErrorKindCategory = @@ -313,6 +315,13 @@ export const actionability = { has_suggestion: false, suggestion_type: CliSuggestionType.None, }, + /** A hosted mutation needs confirmation this run cannot ask for; `--yes` or a terminal supplies it. */ + consentRequired: { + error_kind: CliErrorKind.UserActionable, + error_category: CliErrorCategory.ConsentRequired, + has_suggestion: true, + suggestion_type: CliSuggestionType.ProvideFlags, + }, invalidConfig: { error_kind: CliErrorKind.UserActionable, error_category: CliErrorCategory.InvalidConfig, @@ -638,6 +647,7 @@ function isUserActionableCategory(value: unknown): value is UserActionableErrorC value === CliErrorCategory.PlanLimit || value === CliErrorCategory.ProjectPaused || value === CliErrorCategory.InvalidInput || + value === CliErrorCategory.ConsentRequired || value === CliErrorCategory.ResourceLimit ); } From 4ade011d10f9fe598d64d9a7e172ebd8e8619c65 Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Thu, 15 Oct 2026 13:53:45 +0100 Subject: [PATCH 12/92] feat(cli): plan the db seed consent line without prompting (CLI-2695) planDbSeed returns the seed input and a detail line for the single hosted consent. The existing resolveDbSeedInput and confirmSeedIntoMatchedRemote stay until the integration shard removes them. --- .../seed-remote-consent.integration.test.ts | 92 +++++++++++++++++++ .../command-internal/seed-remote-consent.ts | 27 ++++++ 2 files changed, 119 insertions(+) create mode 100644 apps/cli/src/command-internal/seed-remote-consent.integration.test.ts diff --git a/apps/cli/src/command-internal/seed-remote-consent.integration.test.ts b/apps/cli/src/command-internal/seed-remote-consent.integration.test.ts new file mode 100644 index 0000000000..21e0ccf942 --- /dev/null +++ b/apps/cli/src/command-internal/seed-remote-consent.integration.test.ts @@ -0,0 +1,92 @@ +import { BunServices } from "@effect/platform-bun"; +import { describe, expect, it } from "@effect/vitest"; +import { Effect, FileSystem, Option, Path } from "effect"; + +import { configValuesLayer } from "../../tests/helpers/config-values-layer.ts"; +import { mockCommandSettings } from "../../tests/helpers/command-mocks.ts"; +import { CliConfigValues } from "../config/cli-config-values.service.ts"; +import { planDbSeed } from "./seed-remote-consent.ts"; + +const REF = "abcdefghijklmnopqrst"; + +const plan = ( + toml: string, + options: { + readonly projectRef?: Option.Option; + readonly env?: Record; + } = {}, +) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const workdir = yield* fs.makeTempDirectoryScoped({ prefix: "seed-plan-" }); + yield* fs.makeDirectory(path.join(workdir, "supabase"), { recursive: true }); + yield* fs.writeFileString(path.join(workdir, "supabase", "config.toml"), toml); + const configValues = yield* CliConfigValues; + const resolved = yield* configValues.load({ + workdir, + projectRef: options.projectRef ?? Option.some(REF), + }); + return yield* planDbSeed(resolved, { workdir, ref: REF }); + }).pipe( + Effect.scoped, + Effect.provide(configValuesLayer({ env: options.env })), + Effect.provide(mockCommandSettings({ workdir: "/unused" })), + Effect.provide(BunServices.layer), + ); + +const FILES = ["supabase/seed.sql", "supabase/seed_users.sql"]; + +describe("planDbSeed", () => { + it.effect("describes the files without naming a remote when none matched", () => + Effect.gen(function* () { + const { input, detail } = yield* plan("", { projectRef: Option.none() }); + expect(input.consent).toBeUndefined(); + expect(detail(FILES)).toBe("seed 2 files"); + expect(detail(FILES.slice(0, 1))).toBe("seed 1 file"); + }), + ); + + it.effect("names the matched remote and what turned seeding on", () => + Effect.gen(function* () { + const { input, detail } = yield* plan(`[remotes.prod]\nproject_id = "${REF}"\n`, { + env: { SUPABASE_DB_SEED_ENABLED: "true" }, + }); + expect(input.enabled).toBe(true); + expect(input.consent).toMatchObject({ remote: "prod", ref: REF }); + expect(input.consent?.enabledBy).toContain("SUPABASE_DB_SEED_ENABLED"); + expect(detail(FILES)).toBe( + `seed 2 files into [remotes.prod] (seeding enabled by ${input.consent?.enabledBy})`, + ); + }), + ); + + it.effect("omits the origin when the remote enables seeding itself", () => + Effect.gen(function* () { + const { input, detail } = yield* plan( + `[remotes.prod]\nproject_id = "${REF}"\n[remotes.prod.db.seed]\nenabled = true\n`, + ); + expect(input.consent).toBeUndefined(); + expect(detail(FILES)).toBe("seed 2 files into [remotes.prod]"); + }), + ); + + it.effect("has no detail when a matched remote leaves seeding off", () => + Effect.gen(function* () { + const { input, detail } = yield* plan(`[remotes.prod]\nproject_id = "${REF}"\n`); + expect(input.enabled).toBe(false); + expect(detail(FILES)).toBeUndefined(); + }), + ); + + it.effect("has no detail when seeding is off or there is nothing to seed", () => + Effect.gen(function* () { + const off = yield* plan("[db.seed]\nenabled = false\n"); + expect(off.input.enabled).toBe(false); + expect(off.detail(FILES)).toBeUndefined(); + + const none = yield* plan(""); + expect(none.detail([])).toBeUndefined(); + }), + ); +}); diff --git a/apps/cli/src/command-internal/seed-remote-consent.ts b/apps/cli/src/command-internal/seed-remote-consent.ts index 5b4582b456..a22c969a26 100644 --- a/apps/cli/src/command-internal/seed-remote-consent.ts +++ b/apps/cli/src/command-internal/seed-remote-consent.ts @@ -64,6 +64,33 @@ export const resolveDbSeedInput = Effect.fn("DbSeedInput.resolve")(function* ( return { enabled: enabled.value, sqlPaths: sqlPaths.value, consent } satisfies DbSeedInput; }); +/** The seed input plus the line a hosted-mutation consent shows for the files a run would seed. */ +export interface DbSeedPlan { + readonly input: DbSeedInput; + /** `undefined` when seeding is off or `files` is empty. */ + readonly detail: (files: ReadonlyArray) => string | undefined; +} + +/** Reads `[db.seed]` like {@link resolveDbSeedInput} but never prompts: the caller's single consent carries {@link DbSeedPlan.detail}. */ +export const planDbSeed = Effect.fn("DbSeedPlan.plan")(function* ( + resolvedConfig: ResolvedCliConfig, + target: { readonly workdir: string; readonly ref: string }, +) { + const input = yield* resolveDbSeedInput(resolvedConfig, target); + const remote = Option.getOrUndefined(resolvedConfig.appliedRemote); + return { + input, + detail: (files) => { + if (!input.enabled || files.length === 0) return undefined; + const noun = files.length === 1 ? "file" : "files"; + const into = remote === undefined ? "" : ` into [remotes.${remote}]`; + const enabledBy = + input.consent === undefined ? "" : ` (seeding enabled by ${input.consent.enabledBy})`; + return `seed ${files.length} ${noun}${into}${enabledBy}`; + }, + } satisfies DbSeedPlan; +}); + /** 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; From c3de355d19ece3905e5185d3401fd37daa4d1d1c Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Thu, 15 Oct 2026 13:57:32 +0100 Subject: [PATCH 13/92] test(cli): guard the target selection and consent APIs (CLI-2695) --- .../shared/cli/code-structure.unit.test.ts | 215 ++++++++++++++++++ 1 file changed, 215 insertions(+) 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 1dafe538c0..7656cda800 100644 --- a/apps/cli/src/shared/cli/code-structure.unit.test.ts +++ b/apps/cli/src/shared/cli/code-structure.unit.test.ts @@ -212,6 +212,119 @@ function findConfigLoaderBypasses(source: string): Array { ]; } +const RAW_TARGET_FLAG = + /\bFlag\.(?:Boolean|String)\(\s*["'](?:local|linked|db-url|project-id)["']/g; + +/** Raw declarations of the target selector flags, which `command-internal/target/target-flags.ts` owns. */ +export function findRawTargetFlags(source: string): Array { + return Array.from(stripComments(source).matchAll(RAW_TARGET_FLAG), (m) => m[0]); +} + +const LEGACY_TARGET_IDENTIFIERS: ReadonlyArray = [ + /\b(?:resolveDbTargetFlags|changedLinkedLocalFlags|rejectPasswordWithDirectTarget|DbConnType|migrationConfirm|confirmSeedIntoMatchedRemote)\b/g, + /\bconnType\s*\?\?/g, + /\.loadProjectRef\(/g, +]; + +/** Identifiers of the target selection and consent APIs that the shared target module replaces. */ +export function findLegacyTargetIdentifiers(source: string): Array { + return LEGACY_TARGET_IDENTIFIERS.flatMap((pattern) => + Array.from(source.matchAll(pattern), (m) => m[0]), + ); +} + +const NETWORK_BAN_REMOVAL = /\blistAndUnban\b|delete-?network-?bans/gi; + +/** Code that clears network bans: the connection resolver and `network-bans remove` own it. */ +export function findNetworkBanRemoval(source: string): Array { + return Array.from(stripComments(source).matchAll(NETWORK_BAN_REMOVAL), (m) => m[0]); +} + +const AD_HOC_TARGET_LINE = /Connecting to (?:local|remote|\$\{[^}]*\}) database/g; + +/** The per-command connection line that the `Target:` banner replaces. */ +export function findAdHocTargetLines(source: string): Array { + return Array.from(stripComments(source).matchAll(AD_HOC_TARGET_LINE), (m) => m[0]); +} + +/** Files that may still declare target flags themselves, with the reason. */ +const TARGET_FLAG_EXEMPT: Readonly> = { + "command-internal/target/target-flags.ts": "the one declaration of the shared target flags", + "commands/db/remote/changes/changes.command.ts": + "the removed command keeps its old flags so invocations reach the removal error", + "commands/db/remote/commit/commit.command.ts": + "the removed command keeps its old flags so invocations reach the removal error", + "commands/stop/stop.command.ts": "`--project-id` names the local stack to stop, not a target", +}; + +const NETWORK_BAN_REMOVAL_OWNERS: ReadonlyArray = [ + /^command-internal\/db-config\.layer\.ts$/, + /^commands\/network-bans\/remove\//, +]; + +const TARGET_GUARD_FILE = "shared/cli/code-structure.unit.test.ts"; + +/** Files still on the previous target selection and consent APIs; the integration pass empties this list. */ +const TARGET_MIGRATION_PENDING_FILES: ReadonlySet = new Set([ + "command-internal/db-config.layer.ts", + "command-internal/db-config.types.ts", + "command-internal/db-pull-run.ts", + "command-internal/db-push-core.ts", + "command-internal/db-target-flags.ts", + "command-internal/db-target-flags.unit.test.ts", + "command-internal/migration-fetch-run.ts", + "command-internal/seed-remote-consent.ts", + "command-internal/test-db.command-handler.ts", + "command-internal/test-db.handler.ts", + "command-internal/test-db.integration.test.ts", + "commands/db/advisors/advisors.command.ts", + "commands/db/advisors/advisors.handler.ts", + "commands/db/diff/diff.command.ts", + "commands/db/diff/diff.handler.ts", + "commands/db/dump/dump.command.ts", + "commands/db/dump/dump.handler.ts", + "commands/db/lint/lint.command.ts", + "commands/db/lint/lint.handler.ts", + "commands/db/pull/pull.command.ts", + "commands/db/push/push.command.ts", + "commands/db/push/push.handler.ts", + "commands/db/query/query.command.ts", + "commands/db/query/query.handler.ts", + "commands/db/reset/reset.command.ts", + "commands/db/reset/reset.handler.ts", + "commands/db/schema/declarative/generate/generate.command.ts", + "commands/db/schema/declarative/generate/generate.handler.ts", + "commands/db/shared/pooler-fallback.ts", + "commands/gen/types/types.command.ts", + "commands/inspect/db/inspect-db-command.ts", + "commands/inspect/db/inspect-query.ts", + "commands/inspect/report/report.command.ts", + "commands/inspect/report/report.handler.ts", + "commands/migration/down/down.command.ts", + "commands/migration/down/down.handler.ts", + "commands/migration/fetch/fetch.command.ts", + "commands/migration/fetch/fetch.handler.ts", + "commands/migration/fetch/fetch.integration.test.ts", + "commands/migration/list/list.command.ts", + "commands/migration/list/list.handler.ts", + "commands/migration/migration.prompt.ts", + "commands/migration/repair/repair.command.ts", + "commands/migration/repair/repair.handler.ts", + "commands/migration/squash/squash.command.ts", + "commands/migration/squash/squash.handler.ts", + "commands/migration/up/up.command.ts", + "commands/migration/up/up.handler.ts", + "commands/seed/buckets/buckets.flags.ts", + "commands/seed/buckets/buckets.handler.ts", + "commands/seed/seed.flags.ts", + "commands/storage/cp/cp.handler.ts", + "commands/storage/ls/ls.handler.ts", + "commands/storage/mv/mv.handler.ts", + "commands/storage/rm/rm.handler.ts", + "commands/storage/storage.flags.ts", + "telemetry/command-telemetry.ts", +]); + const allSpecifiers = (source: string): Array => Array.from( source.matchAll(/(?:from\s+|import\(\s*)["']([^"']+)["']/g), @@ -391,6 +504,52 @@ layer(BunServices.layer)("code structure", (it) => { }), ); + it.effect("declares the target selector flags only in the shared target module", () => + Effect.gen(function* () { + const files = (yield* walk(srcDir)).filter(isSourceFile); + const violations = yield* scanSource(files, (relativePath, source) => + relativePath in TARGET_FLAG_EXEMPT || TARGET_MIGRATION_PENDING_FILES.has(relativePath) + ? [] + : findRawTargetFlags(source), + ); + expect(violations).toEqual([]); + }), + ); + + it.effect("keeps the legacy target selection and consent identifiers out", () => + Effect.gen(function* () { + const files = (yield* walk(srcDir)).filter(isAnyTypeScript); + const violations = yield* scanSource(files, (relativePath, source) => + relativePath === TARGET_GUARD_FILE || TARGET_MIGRATION_PENDING_FILES.has(relativePath) + ? [] + : findLegacyTargetIdentifiers(source), + ); + expect(violations).toEqual([]); + }), + ); + + it.effect("removes network bans only in the connection resolver and network-bans remove", () => + Effect.gen(function* () { + const files = (yield* walk(srcDir)).filter(isSourceFile); + const violations = yield* scanSource(files, (relativePath, source) => + NETWORK_BAN_REMOVAL_OWNERS.some((owner) => owner.test(relativePath)) + ? [] + : findNetworkBanRemoval(source), + ); + expect(violations).toEqual([]); + }), + ); + + it.effect("leaves announcing the target to the Target banner", () => + Effect.gen(function* () { + const files = (yield* walk(srcDir)).filter(isSourceFile); + const violations = yield* scanSource(files, (relativePath, source) => + TARGET_MIGRATION_PENDING_FILES.has(relativePath) ? [] : findAdHocTargetLines(source), + ); + expect(violations).toEqual([]); + }), + ); + it.effect("never imports loadCliProjectEnvironment, even in tests", () => Effect.gen(function* () { const files = (yield* walk(srcDir)).filter(isAnyTypeScript); @@ -528,3 +687,59 @@ describe("config precedence guard rules", () => { ).toEqual(["../command-internal/x.ts", "./y.ts"]); }); }); + +describe("target guard rules", () => { + it("flags raw declarations of the target selector flags", () => { + for (const name of ["local", "linked", "db-url", "project-id"]) { + expect(findRawTargetFlags(`Flag.Boolean("${name}")`)).toHaveLength(1); + expect(findRawTargetFlags(`x: Flag.String(\n '${name}',\n)`)).toHaveLength(1); + } + expect(findRawTargetFlags('Flag.String("project-ref")')).toEqual([]); + expect(findRawTargetFlags('// Flag.Boolean("local")')).toEqual([]); + }); + + it("flags each legacy target identifier", () => { + const names = [ + "resolveDbTargetFlags", + "changedLinkedLocalFlags", + "rejectPasswordWithDirectTarget", + "DbConnType", + "migrationConfirm", + "confirmSeedIntoMatchedRemote", + ]; + for (const name of names) { + expect(findLegacyTargetIdentifiers(`const x = ${name}(y);`)).toEqual([name]); + } + expect(findLegacyTargetIdentifiers("const t = flags.connType ?? 'local';")).toEqual([ + "connType ??", + ]); + expect(findLegacyTargetIdentifiers("yield* resolver.loadProjectRef(flag)")).toEqual([ + ".loadProjectRef(", + ]); + expect( + findLegacyTargetIdentifiers("resolveTarget(input); const { connTypeLabel } = x;"), + ).toEqual([]); + }); + + it("flags network ban removal outside comments", () => { + expect(findNetworkBanRemoval("yield* listAndUnban(ref, true)")).toEqual(["listAndUnban"]); + expect(findNetworkBanRemoval("api.v1.deleteNetworkBans({ ref })")).toEqual([ + "deleteNetworkBans", + ]); + expect(findNetworkBanRemoval('operationId: "v1-delete-network-bans"')).toEqual([ + "delete-network-bans", + ]); + expect(findNetworkBanRemoval("/** Transport failure (`V1DeleteNetworkBans`). */")).toEqual([]); + }); + + it("flags the per-command connection line, literal or interpolated", () => { + expect(findAdHocTargetLines('output.raw("Connecting to remote database...\\n")')).toHaveLength( + 1, + ); + expect( + findAdHocTargetLines('`Connecting to ${isLocal ? "local" : "remote"} database...`'), + ).toHaveLength(1); + expect(findAdHocTargetLines("`Connecting to ${target.host} ${target.port}`")).toEqual([]); + expect(findAdHocTargetLines('// "Connecting to local database"')).toEqual([]); + }); +}); From 880adcf064265aa5aa299b4af984d90d48e3356f Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Thu, 15 Oct 2026 14:01:37 +0100 Subject: [PATCH 14/92] test(cli): add the hosted-consent test matrix helper (CLI-2695) describeHostedConsent runs the six consent cases against a leaf: JSON and piped text without --yes, a TTY decline, --yes, a shell SUPABASE_YES, and a project .env that must not count. A synthetic leaf proves the helper. --- .../hosted-consent.integration.test.ts | 47 ++++ apps/cli/tests/helpers/hosted-consent.ts | 216 ++++++++++++++++++ 2 files changed, 263 insertions(+) create mode 100644 apps/cli/tests/helpers/hosted-consent.integration.test.ts create mode 100644 apps/cli/tests/helpers/hosted-consent.ts diff --git a/apps/cli/tests/helpers/hosted-consent.integration.test.ts b/apps/cli/tests/helpers/hosted-consent.integration.test.ts new file mode 100644 index 0000000000..373bd39700 --- /dev/null +++ b/apps/cli/tests/helpers/hosted-consent.integration.test.ts @@ -0,0 +1,47 @@ +import { Effect, Option } from "effect"; +import { Command } from "effect/cli"; + +import { CommandPlatformApi } from "../../src/auth/command-platform-api.service.ts"; +import { requireHostedMutationConsent } from "../../src/command-internal/hosted-consent/hosted-consent.ts"; +import { withTargetPolicy } from "../../src/command-internal/target/target-policy.ts"; +import { ProjectRefResolver } from "../../src/config/project-ref.service.ts"; +import { describeHostedConsent } from "./hosted-consent.ts"; + +const syntheticWrite = (options: { typed?: boolean; onWrite?: () => void } = {}) => + Effect.gen(function* () { + const ref = yield* ProjectRefResolver.use((resolver) => resolver.resolve(Option.none())); + yield* requireHostedMutationConsent({ + action: `create a secret on ${ref}`, + ...(options.typed === true ? { confirm: { kind: "typed" as const, expected: ref } } : {}), + }); + options.onWrite?.(); + const api = yield* CommandPlatformApi; + yield* api.v1.bulkCreateSecrets({ ref, body: [{ name: "FOO", value: "bar" }] }); + }); + +const syntheticLeaf = Command.make("synthetic-write").pipe( + Command.withHandler(() => syntheticWrite()), + withTargetPolicy({ group: "hosted", consent: "no" }), +); + +describeHostedConsent("synthetic-write", { + command: syntheticLeaf, + invoke: () => ({ run: syntheticWrite() }), +}); + +describeHostedConsent("synthetic-write with a typed confirmation", { + command: syntheticLeaf, + typed: true, + invoke: () => ({ run: syntheticWrite({ typed: true }) }), +}); + +describeHostedConsent("synthetic-write with a custom mutation probe", { + command: syntheticLeaf, + invoke: () => { + let writes = 0; + return { + run: syntheticWrite({ onWrite: () => writes++ }), + mutations: () => writes, + }; + }, +}); diff --git a/apps/cli/tests/helpers/hosted-consent.ts b/apps/cli/tests/helpers/hosted-consent.ts new file mode 100644 index 0000000000..0bfbbd931b --- /dev/null +++ b/apps/cli/tests/helpers/hosted-consent.ts @@ -0,0 +1,216 @@ +import { BunServices } from "@effect/platform-bun"; +import { describe, expect, it } from "@effect/vitest"; +import { Cause, Effect, Exit, FileSystem, Layer, Option, Path } from "effect"; +import type { Command } from "effect/cli"; + +import { + HostedMutationConsentRequiredError, + HostedMutationDeclinedError, +} from "../../src/command-internal/hosted-consent/hosted-consent.errors.ts"; +import { YesFlag } from "../../src/command-internal/global-flags.ts"; +import { TargetPolicy, readTargetPolicy } from "../../src/command-internal/target/target-policy.ts"; +import type { TargetPolicyShape } from "../../src/command-internal/target/target-policy.ts"; +import { commandTreeFor } from "./command-flags.ts"; +import { + buildTestRuntime, + mockCommandPlatformApi, + mockCommandSettings, + useTempWorkdir, + withConfigEnv, + withEmptyConfigEnv, +} from "./command-mocks.ts"; +import type { MockCommandPlatformApiOpts } from "./command-mocks.ts"; +import { mockOutput, mockStdin, mockTty } from "./mocks.ts"; + +type RuntimeLayer = ReturnType; + +const consentLayer = (runtime: RuntimeLayer, yes: boolean, policy: TargetPolicyShape) => + Layer.mergeAll(runtime, Layer.succeed(YesFlag, yes), Layer.succeed(TargetPolicy, policy)); + +/** Everything a leaf's handler can require while `describeHostedConsent` runs it. */ +export type HostedConsentServices = Layer.Success>; + +export interface HostedConsentEnv { + readonly workdir: string; + readonly out: ReturnType; + readonly api: ReturnType; +} + +export interface HostedConsentInvocation { + readonly run: Effect.Effect; + /** Persistent writes made so far; defaults to the non-GET requests the mocked platform API recorded. */ + readonly mutations?: () => number; +} + +export interface HostedConsentScenario { + /** Resolve the policy from this command instead of looking `path` up in the CLI tree. */ + readonly command?: Command.Command.Any; + /** The leaf asks for a typed confirmation instead of a yes/no prompt. */ + readonly typed?: boolean; + readonly api?: MockCommandPlatformApiOpts; + readonly setup?: (workdir: string) => Effect.Effect; + /** Builds the leaf's handler call; runs once per case, so per-case state can live in its closure. */ + readonly invoke: (env: HostedConsentEnv) => HostedConsentInvocation; +} + +interface CaseOptions { + readonly json: boolean; + readonly interactive?: boolean; + readonly yes?: boolean; + readonly shellYes?: boolean; + readonly projectEnvYes?: boolean; + readonly stdinInput?: string; + readonly confirmResponses?: ReadonlyArray; + readonly textResponses?: ReadonlyArray; +} + +const policyFor = (path: string, scenario: HostedConsentScenario): TargetPolicyShape => { + const command = scenario.command ?? commandTreeFor(undefined).get(path)?.command; + if (command === undefined) { + throw new Error(`describeHostedConsent("${path}"): no such command in the CLI tree`); + } + const policy = readTargetPolicy(command); + if (Option.isNone(policy)) { + throw new Error(`describeHostedConsent("${path}"): the leaf declares no withTargetPolicy`); + } + if (policy.value.consent === "none") { + throw new Error( + `describeHostedConsent("${path}"): the leaf declares consent "none", so it has no consent to test`, + ); + } + return policy.value; +}; + +const writeProjectEnv = (workdir: string) => + Effect.gen(function* () { + 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", ".env"), "SUPABASE_YES=1\n"); + }).pipe(Effect.provide(BunServices.layer)); + +const failureOf = (exit: Exit.Exit): unknown => { + if (Exit.isSuccess(exit)) return undefined; + const failure = Exit.findErrorOption(exit); + return Option.isSome(failure) ? failure.value : Cause.pretty(exit.cause); +}; + +const expectSuccess = (exit: Exit.Exit) => { + expect(failureOf(exit), "the leaf should have run to completion").toBeUndefined(); +}; + +const nonGetRequests = (api: HostedConsentEnv["api"]): number => + api.requests.filter((request) => request.method !== "GET").length; + +/** + * Runs the hosted-consent matrix against the leaf at `path`: no consent without `--yes` in + * JSON or piped text, a TTY decline, `--yes`, shell `SUPABASE_YES`, and a project `.env` + * that must not count as consent. + */ +export function describeHostedConsent(path: string, scenario: HostedConsentScenario): void { + const policy = policyFor(path, scenario); + + describe(`hosted consent: ${path}`, () => { + const tempRoot = useTempWorkdir("supabase-hosted-consent-"); + + const runCase = (options: CaseOptions) => + Effect.gen(function* () { + const workdir = tempRoot.current; + const out = mockOutput({ + format: options.json ? "json" : "text", + interactive: options.interactive ?? false, + promptConfirmResponses: options.confirmResponses, + promptTextResponses: options.textResponses, + }); + const api = mockCommandPlatformApi(scenario.api); + const interactive = options.interactive ?? false; + const stdinIsTty = interactive || options.json; + const runtime = buildTestRuntime({ + out, + api, + cliSettings: mockCommandSettings({ workdir }), + tty: mockTty({ stdinIsTty, stdoutIsTty: interactive }), + stdin: mockStdin(stdinIsTty, options.stdinInput), + outputFlag: options.json ? Option.some("json") : Option.none(), + cliArgs: options.yes === true ? ["--yes"] : [], + }); + const layer = consentLayer(runtime, options.yes ?? false, policy); + + if (scenario.setup !== undefined) { + yield* scenario.setup(workdir).pipe(Effect.provide(BunServices.layer)); + } + if (options.projectEnvYes === true) yield* writeProjectEnv(workdir); + + const invocation = scenario.invoke({ workdir, out, api }); + const shellEnv = (body: Effect.Effect) => + options.shellYes === true + ? withConfigEnv({ SUPABASE_YES: "1" }, body) + : withEmptyConfigEnv(body); + const exit = yield* shellEnv(invocation.run.pipe(Effect.provide(layer))).pipe(Effect.exit); + return { + exit, + out, + mutations: (invocation.mutations ?? (() => nonGetRequests(api)))(), + }; + }); + + it.effect("refuses JSON output without --yes and changes nothing", () => + Effect.gen(function* () { + const { exit, mutations } = yield* runCase({ json: true }); + expect(failureOf(exit)).toBeInstanceOf(HostedMutationConsentRequiredError); + expect(mutations).toBe(0); + }), + ); + + it.effect("ignores an answer piped to stdin in text mode and changes nothing", () => + Effect.gen(function* () { + const { exit, out, mutations } = yield* runCase({ json: false, stdinInput: "y\n" }); + expect(failureOf(exit)).toBeInstanceOf(HostedMutationConsentRequiredError); + expect(out.promptConfirmCalls).toEqual([]); + expect(mutations).toBe(0); + }), + ); + + it.effect("stops when the user declines at the prompt and changes nothing", () => + Effect.gen(function* () { + const { exit, out, mutations } = yield* runCase({ + json: false, + interactive: true, + confirmResponses: [false], + textResponses: ["no"], + }); + expect(failureOf(exit)).toBeInstanceOf(HostedMutationDeclinedError); + if (scenario.typed !== true) { + expect(out.promptConfirmCalls[0]?.opts?.defaultValue).toBe(policy.consent === "yes"); + } + expect(mutations).toBe(0); + }), + ); + + it.effect("runs under --yes, echoes the confirmation, and shows the target banner", () => + Effect.gen(function* () { + const { exit, out, mutations } = yield* runCase({ json: true, yes: true }); + expectSuccess(exit); + expect(mutations).toBeGreaterThan(0); + expect(out.stderrText).toContain("Confirmed by --yes: "); + expect(out.stderrText).toContain("Target: "); + }), + ); + + it.effect("runs under a shell SUPABASE_YES", () => + Effect.gen(function* () { + const { exit, mutations } = yield* runCase({ json: true, shellYes: true }); + expectSuccess(exit); + expect(mutations).toBeGreaterThan(0); + }), + ); + + it.effect("does not treat SUPABASE_YES in supabase/.env as consent", () => + Effect.gen(function* () { + const { exit, mutations } = yield* runCase({ json: true, projectEnvYes: true }); + expect(failureOf(exit)).toBeInstanceOf(HostedMutationConsentRequiredError); + expect(mutations).toBe(0); + }), + ); + }); +} From b0f07b7889b0c366ff53bb17d1f92ab787edc16d Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Thu, 15 Oct 2026 14:02:08 +0100 Subject: [PATCH 15/92] docs(cli): document target selection and hosted consent for agents (CLI-2695) --- apps/cli/AGENTS.md | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/apps/cli/AGENTS.md b/apps/cli/AGENTS.md index d791f8fdb3..3433750f01 100644 --- a/apps/cli/AGENTS.md +++ b/apps/cli/AGENTS.md @@ -225,6 +225,26 @@ text layer. Therefore stdout is payload-only for either machine flag. Do not cha layer to move spinners; that would change text output globally. Handlers check `--output` first and use established encoders before the `output.format` branch. +## Targets and hosted consent + +Every leaf declares its target group and consent default with `withTargetPolicy`; the +classification test in `src/command-internal/target/target-policy.unit.test.ts` fails a leaf +without one. Declare the target flags only through `targetFlags` in +`src/command-internal/target/target-flags.ts`. + +- Database, storage, and seed commands call `resolveTarget`, then `resolveConnection`. Never pass a + default target: the policy owns the default, so a call site cannot fall back to local or linked. +- A command that binds `--password` passes `hasPasswordFlag: true` to `resolveConnection`. +- The linked `Target:` banner comes from `resolveTarget` and `resolveRef`; never build a linked + `ResolvedTarget` by hand. +- Hosted writes call `requireHostedMutationConsent` (or `requireTargetMutationConsent` when the + target may be local) after the plan and before the first persistent effect, and pass the + returned `HostedMutationConsent` to anything that needs proof of it. +- Never read stdin for consent. JSON, piped, and agent runs need `--yes` or a shell + `SUPABASE_YES`; `supabase/.env` does not count. +- Every leaf with consent has a `describeHostedConsent("", …)` test from + `tests/helpers/hosted-consent.ts`. + ## Tests Follow the root testing philosophy and use the package scripts in `package.json`. Cover meaningful From 4f2707ac8e4502ee896f98be3ddec59c66dd7c4e Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Thu, 15 Oct 2026 14:07:33 +0100 Subject: [PATCH 16/92] chore(cli): drop unused exports and a chained provide from the consent foundation (CLI-2695) --- .../seed-remote-consent.integration.test.ts | 12 ++++++++---- apps/cli/src/command-internal/seed-remote-consent.ts | 2 +- apps/cli/tests/helpers/hosted-consent.ts | 6 +++--- 3 files changed, 12 insertions(+), 8 deletions(-) diff --git a/apps/cli/src/command-internal/seed-remote-consent.integration.test.ts b/apps/cli/src/command-internal/seed-remote-consent.integration.test.ts index 21e0ccf942..0ef8cc2bf7 100644 --- a/apps/cli/src/command-internal/seed-remote-consent.integration.test.ts +++ b/apps/cli/src/command-internal/seed-remote-consent.integration.test.ts @@ -1,6 +1,6 @@ import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; -import { Effect, FileSystem, Option, Path } from "effect"; +import { Effect, FileSystem, Layer, Option, Path } from "effect"; import { configValuesLayer } from "../../tests/helpers/config-values-layer.ts"; import { mockCommandSettings } from "../../tests/helpers/command-mocks.ts"; @@ -30,9 +30,13 @@ const plan = ( return yield* planDbSeed(resolved, { workdir, ref: REF }); }).pipe( Effect.scoped, - Effect.provide(configValuesLayer({ env: options.env })), - Effect.provide(mockCommandSettings({ workdir: "/unused" })), - Effect.provide(BunServices.layer), + Effect.provide( + Layer.mergeAll( + configValuesLayer({ env: options.env }), + mockCommandSettings({ workdir: "/unused" }), + BunServices.layer, + ), + ), ); const FILES = ["supabase/seed.sql", "supabase/seed_users.sql"]; diff --git a/apps/cli/src/command-internal/seed-remote-consent.ts b/apps/cli/src/command-internal/seed-remote-consent.ts index a22c969a26..59fad5a094 100644 --- a/apps/cli/src/command-internal/seed-remote-consent.ts +++ b/apps/cli/src/command-internal/seed-remote-consent.ts @@ -65,7 +65,7 @@ export const resolveDbSeedInput = Effect.fn("DbSeedInput.resolve")(function* ( }); /** The seed input plus the line a hosted-mutation consent shows for the files a run would seed. */ -export interface DbSeedPlan { +interface DbSeedPlan { readonly input: DbSeedInput; /** `undefined` when seeding is off or `files` is empty. */ readonly detail: (files: ReadonlyArray) => string | undefined; diff --git a/apps/cli/tests/helpers/hosted-consent.ts b/apps/cli/tests/helpers/hosted-consent.ts index 0bfbbd931b..a6dc1d417c 100644 --- a/apps/cli/tests/helpers/hosted-consent.ts +++ b/apps/cli/tests/helpers/hosted-consent.ts @@ -28,15 +28,15 @@ const consentLayer = (runtime: RuntimeLayer, yes: boolean, policy: TargetPolicyS Layer.mergeAll(runtime, Layer.succeed(YesFlag, yes), Layer.succeed(TargetPolicy, policy)); /** Everything a leaf's handler can require while `describeHostedConsent` runs it. */ -export type HostedConsentServices = Layer.Success>; +type HostedConsentServices = Layer.Success>; -export interface HostedConsentEnv { +interface HostedConsentEnv { readonly workdir: string; readonly out: ReturnType; readonly api: ReturnType; } -export interface HostedConsentInvocation { +interface HostedConsentInvocation { readonly run: Effect.Effect; /** Persistent writes made so far; defaults to the non-GET requests the mocked platform API recorded. */ readonly mutations?: () => number; From 515b3b43025fa24788495651e5f8e1f9515b61d0 Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Thu, 15 Oct 2026 13:47:01 +0100 Subject: [PATCH 17/92] docs(docs): add ADR 0033 for target selection and hosted-mutation consent (CLI-2695) --- .../0004-cli-design-goals-and-workflows.md | 8 +- docs/adr/0032-config-value-precedence.md | 15 +- ...t-selection-and-hosted-mutation-consent.md | 257 ++++++++++++++++++ docs/adr/README.md | 1 + 4 files changed, 275 insertions(+), 6 deletions(-) create mode 100644 docs/adr/0033-target-selection-and-hosted-mutation-consent.md diff --git a/docs/adr/0004-cli-design-goals-and-workflows.md b/docs/adr/0004-cli-design-goals-and-workflows.md index ad2aeadb60..7204d19d79 100644 --- a/docs/adr/0004-cli-design-goals-and-workflows.md +++ b/docs/adr/0004-cli-design-goals-and-workflows.md @@ -1,6 +1,6 @@ # 0004. CLI Design Goals & Development Workflows -**Status**: accepted +**Status**: accepted, amended by [0033](0033-target-selection-and-hosted-mutation-consent.md) **Date**: 2026-02-10 ## Problem Statement @@ -33,7 +33,7 @@ Services run locally via a unified process manager that manages both embedded bi - **For LLMs**: Same subcommands, pointed at local services. - **Goal**: Full local development environment with explicit `supabase push` / `supabase pull` to sync with the platform. -The workflow is selected via `supabase dev --target ` (or equivalent config). The subcommands underneath are identical — only the target changes. +The workflow is selected via `supabase dev --target ` (or equivalent config). The subcommands underneath are identical — only the target changes. The workspace `target` key (`local` or `remote`, [ADR 0033](0033-target-selection-and-hosted-mutation-consent.md)) is that config: database commands that can run either way follow it. ### Two Audiences @@ -81,7 +81,9 @@ Starting from `supabase dev` and working outward, these are the commands to buil - **Remote-first** never touches production. All changes target a branch. Merging a branch to production is a platform action, not a CLI action. - **Local-first** is fully isolated. Local services have no connection to production data. -- **Production access** (if ever needed) requires explicit confirmation — never implicit, never default. +- The CLI reaches a hosted database or project only through explicit intent: a linked checkout, a project ref, or a `--db-url`. +- Database operations that can run locally target the local stack unless the invocation or the workspace `target` selects remote. +- Every hosted mutation requires confirmation. When not interactive, it requires `--yes`. ## Rationale diff --git a/docs/adr/0032-config-value-precedence.md b/docs/adr/0032-config-value-precedence.md index 2b2b1af817..d3c99b031d 100644 --- a/docs/adr/0032-config-value-precedence.md +++ b/docs/adr/0032-config-value-precedence.md @@ -54,9 +54,10 @@ variables, `.env` files, `[remotes.*]` blocks and `config.toml` combine. They di 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`. + `db.seed.enabled` seeds nothing by default. `db push` still seeds only with `--include-seed`. + The prompt before seeding into the matched project is superseded by the single hosted-mutation + consent of [ADR 0033](0033-target-selection-and-hosted-mutation-consent.md): `db push` and + `db reset --linked` list the seed in their one question, and a non-interactive run needs `--yes`. - 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 @@ -71,6 +72,12 @@ variables, `.env` files, `[remotes.*]` blocks and `config.toml` combine. They di `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. + - The flag tier for `target` is the selector flags (`--local`, `--linked`, `--db-url` and + `--project-ref`), which are not `CLI_CONFIG_FLAGS` bindings. `target` applies to selectable + commands only ([ADR 0033](0033-target-selection-and-hosted-mutation-consent.md)). + - Hosted-mutation consent reads `SUPABASE_YES` from the shell only. It has no project `.env*` + tier, so a committed file cannot approve a production write. Local prompts keep the project + `.env*` tier. - 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 @@ -177,6 +184,8 @@ order stays the same for every key. - [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. +- [ADR 0033](0033-target-selection-and-hosted-mutation-consent.md): the `target` key, and the + hosted-mutation consent that supersedes the seed consent above. ## See Also diff --git a/docs/adr/0033-target-selection-and-hosted-mutation-consent.md b/docs/adr/0033-target-selection-and-hosted-mutation-consent.md new file mode 100644 index 0000000000..aab16868f8 --- /dev/null +++ b/docs/adr/0033-target-selection-and-hosted-mutation-consent.md @@ -0,0 +1,257 @@ +# 0033. Target selection and hosted-mutation consent + +**Status**: proposed +**Date**: 2026-10-15 + +## Problem Statement + +Commands that can act on either the local stack or a hosted project chose their default per handler. +`db push`, `db dump`, `migration list`, `inspect`, `storage` and `gen types` defaulted to the linked +project. `db reset`, `db query`, `migration up`, `seed buckets` and `db diff` defaulted to local. +Commands with identical flags defaulted in opposite directions, so a user could not tell whether a +command would reach production. + +Three more problems sat next to it: + +- Each family parsed the target flags its own way. `--linked=false` still selected the linked + project, and `--project-id` and `--project-ref` named the same thing on different commands. +- Project-ref resolution had several chains. Some commands opened a project picker when the checkout + was unlinked, and database and storage commands failed. +- Confirmation for a hosted mutation was inconsistent. `db push` defaulted to Yes and ran in JSON + and non-TTY mode. `migration up --linked` and `migration repair ` wrote to the remote with + no prompt. Some prompts came after the first persistent effect. A piped `y` or `SUPABASE_YES` in a + committed `supabase/.env` could approve a production write. + +Output rarely said which target a command had resolved. + +## Decision + +### Target groups + +Every leaf command declares one group with `withTargetPolicy`. The declaration is the source of +truth for the default target and for the consent default. + +- `local-only`: `start`, `stop`, `status`, `stack *`, `init`, `unlink`, `functions serve`, + `migration new`, `gen signing-key`, `db schema declarative sync`. They act on the local stack or + the checkout and have no target flags. +- `account`: `login`, `logout`, `whoami`, `orgs *`, `projects *`, `bootstrap`, `feedback`. They act on + the account or an organisation and have no default project. +- `hosted`: `functions deploy`, `secrets *`, `config *`, `branches *`, `link` and the other platform + resource commands. They act on the linked project and have no `--local`, `--linked` or `--db-url`. +- `sync`: `db push`, `db pull`, `migration fetch`. They default to the linked project; `--local` and + `--db-url` select another database. +- `selectable`: `db reset`, `db query`, `db lint`, `db advisors`, `db dump`, `db diff`, `test db`, + `inspect *`, `migration list|up|down|squash|repair`, `gen types`, `storage *`, `seed buckets`, + `db schema declarative generate`. They follow the workspace target, which defaults to local. +- `removed`: `db branch *`, `db remote changes`, `db remote commit`. They exit 1 with replacement + guidance. + +`db dump`, `migration list`, `migration repair`, `inspect`, `gen types` and `storage ls|cp|mv|rm` +move from the linked project to the workspace target. `db schema declarative generate` stops asking +which target to use. + +### The workspace target + +`target = "local" | "remote"` is a `CliConfig` key. `remote` means the linked project, whether +production or a branch. It resolves like every other config value (ADR 0032), with the selector +flags as its flag tier: + +1. `--local`, `--linked` or `--db-url`, or `--project-ref` (which implies linked) +2. `SUPABASE_TARGET` +3. `target` in `supabase/config.toml` or `config.json` +4. `local` + +It applies to `selectable` commands only. `sync` commands default to linked and ignore it. It is not +part of `ProjectConfig` and not valid in `[remotes.*]`. `SUPABASE_PROJECT_ID` alone never makes a +`selectable` command remote; it is only a ref source once linked has been chosen. An unresolvable +linked target fails. The CLI never falls back between local and remote. + +### Selector rules + +One parser (`parseTargetSelector`) backs every `sync` and `selectable` command: + +- `--local`, `--linked` and `--db-url` are mutually exclusive. They are rejected together, never + resolved by precedence. +- A `false` switch counts as absent, so `--linked=false` and `--no-linked` select nothing. +- `--project-ref` implies linked. It is rejected with `--local` or `--db-url`. +- An empty `--db-url` or `--project-ref` is rejected. +- `--password` is rejected unless the target is the linked project. + +### One project-ref chain + +`ProjectRefResolver.resolveRef` is the only chain: `--project-ref`, then `SUPABASE_PROJECT_ID`, then +`supabase/.temp/project-ref`, then the project picker (interactive terminals only), then a not-linked +error. Database, storage and seed commands use it too. These scope adapters stay because their scope +differs: `link` skips the ref file, `branches` derives the parent project, the `config` family also +accepts branch names and UUIDs, `projects delete` prompts for the project, and `projects list` and +`services` read the ref for display only. + +`gen types --project-id` is a hidden, deprecated alias of `--project-ref`. + +### The target banner and JSON `target` + +A command that resolves a target prints one line on stderr per distinct target, in every output +format. It is best effort: it adds no API call and no auth requirement, and it never prints +credentials. + +```text +Target: local stack (:) +Target: custom Postgres () +Target: project () · production +Target: project () · branch () +Target: project · identity unverified +``` + +Project identity comes from `supabase/.temp/linked-project.json` alone. A ref that is its own parent +in the cache is production. A ref from the ref file whose cache names another parent is a branch of +that parent. Any other ref, for example one from `--project-ref`, is unverified. A branch the +`config` family selects by name prints `Target: project · branch ()` +instead. + +JSON results carry the same information as `target`: + +```text +{ kind: "local", host, port } +{ kind: "db-url", host, port } +{ kind: "linked", project_ref, project_name?, environment: "production" | "branch" | "unverified", parent_project_ref? } +``` + +It is merged into plain-object results, the stream-json result event and error envelopes. Arrays, +`-o json|yaml|toml|env` encodings and generated artifacts such as `gen types` output are unchanged. +A `target` the handler already owns wins, so `config diff` and `pull` keep their richer objects. A +command that announces two targets (`db diff --from linked --to `) prints both and reports the +first in JSON. + +### Hosted-mutation consent + +Every persistent mutation of a hosted resource in the `account`, `hosted`, `sync` and `selectable` +groups needs consent. This includes writes through a non-local `--db-url`: a loopback host on the +local or shadow database ports, or the local stack's endpoint, counts as local; anything else is +hosted. + +- **Default.** It comes from the command's policy. Create-only operations default to Yes: + `projects create`, `orgs create`, `branches create`, `domains create` and `bootstrap`. Everything + that replaces or deletes live state, and all arbitrary SQL, migration, seed and storage writes, + default to No. +- **Approval.** Only `--yes` or a `SUPABASE_YES` in the shell environment counts. A `SUPABASE_YES` in + the project `supabase/.env` does not, because the checkout must not pre-approve its collaborators' + and agents' production writes. `--yes=false` wins over the environment. +- **No stdin.** The CLI never reads stdin for consent. A piped `y` does nothing. +- **Interactive only.** A prompt appears only for text output on an interactive stdout with a TTY + stdin. JSON, stream-json, `-o json|yaml|toml|env`, detected agents and non-TTY runs fail with + `HostedMutationConsentRequiredError` unless `--yes` is set. +- **Decline.** A declined prompt fails with `HostedMutationDeclinedError` ("context canceled"). It + replaces the per-command cancelled errors. `compute delete` keeps its typed-name confirmation + through the same service. +- **One question.** A command asks once, listing everything it will do. `db push` covers roles, + pending migrations, seeds and vault in one question. `functions deploy --prune` computes its + prune candidates first, so deploy and prune are confirmed together. `config push` lists every + resource that will change. +- **Ordering.** Consent comes after the target is resolved and the plan is known, and before the + first persistent effect. That includes local file writes that precede a remote history update: + declining the `db pull` or `pull` history update aborts before any file is written. A temporary + login role may be created before consent. Removing a network ban may not: it needs `--yes`, and + without it the command fails with `LinkedDbNetworkBannedError` pointing at + `supabase network-bans remove`. This applies to reads too. +- **Reads never prompt.** `db query --read-only` is a read: it sends `read_only: true` to the + Management API for a linked target, and runs the statement in a read-only transaction for a local + or `--db-url` target. It skips consent. +- **Exemptions.** These leaves declare `consent: "none"` with a reason: `login` (mints the caller's + own token), `feedback add` (sending the message is the command's purpose), `domains reverify` + (re-runs verification and changes no configuration), `telemetry disable` (a local preference) and + the removed commands. Three operations are exempt in the tree test: the temporary login role, + network-ban removal during connection setup (token-gated as above) and the network-ban list, a + POST that only reads. +- **Local prompts.** An explicitly requested local reset does not prompt: `migration down` and + `migration repair --all` lose their local prompts. Incidental resets, `stack destroy`, checkout + overwrites and `storage rm` keep theirs. Local prompts still honour a project `.env` + `SUPABASE_YES`. + +## Rationale + +The default now follows what a command can target, not who wrote the handler. An unconfigured +checkout reaches a hosted database through a `selectable` command only when the invocation says so, +and a user who wants remote-first behaviour states it once with `target = "remote"`. + +Consent is a rule about the person running the command. Taking it from flags and the shell +environment only, never from stdin or the checkout, means a script or an agent has to opt in +explicitly, and the opt-in is visible in its invocation. Defaulting replace-and-delete operations +to No makes the Enter key safe. + +## Consequences + +### Positive + +- A command's default destination follows its group. The banner and the JSON `target` say where it + went. +- One selector parser, one ref chain and one consent service replace per-handler code. +- A hosted write cannot run before its confirmation, because consent is ordered before the first + persistent effect and declining writes nothing. + +### Negative + +- Breaking changes, all listed in the pull request: the flipped defaults, `--linked=false` and + `--local=false` meaning "not set", `--project-ref` implying linked, and the new `--yes` + requirement for unattended hosted writes. +- `SUPABASE_YES` in `supabase/.env`, a piped `y` and `-o json` runs in a terminal no longer confirm + hosted writes. +- Reads that hit a network ban now fail instead of clearing it. +- A hosted `db query` needs consent unless it is `--read-only`. +- `db reset`'s JSON `target` changes from a string to the `target` object. +- Declining the `db pull` or `pull` history update loses the "write the file only" path. +- Every command that resolves a target adds a stderr line. A ref that is not in the cache shows + `identity unverified`. +- The cancelled-error tags are consolidated and new tags are added, so telemetry keyed on the old + tags needs updating. + +### Guardrails + +- `withTargetPolicy` stores the policy as a command annotation and provides a `TargetPolicy` + service. `resolveTarget` and `requireHostedMutationConsent` both require it, and it is not an + allowed runtime service, so a command that resolves a target or asks for consent without a + declared policy fails `tsc`. Handlers never pass a default. +- `HostedMutationConsent` is a branded token only the consent module constructs. Network-ban removal + requires one. +- `target-policy.unit.test.ts` walks the command tree for every backend variant and fails when: + - a leaf has no policy; + - a `local-only`, `account` or `hosted` leaf declares `--local`, `--linked` or `--db-url`; + - a `sync` or `selectable` leaf lacks `--local`, `--linked` or `--project-ref`; + - a declared database-target default disagrees with the group; + - a leaf with `consent: "none"` calls a non-GET operation outside the reviewed consent-free map; + - a leaf named for a mutating verb has `consent: "none"` and no exemption reason. +- `code-structure.unit.test.ts` fails when: + - `--local`, `--linked`, `--db-url` or `--project-id` is declared with a raw `Flag.*` outside the + shared target flags; + - the old per-command resolvers and defaults return; + - network-ban removal is called outside connection setup and `network-bans remove`; + - a handler prints its own `Connecting to local|remote database` line. +- Every leaf with `consent !== "none"` has a `describeHostedConsent` test. It runs six cases: + JSON without `--yes`, a piped `y`, a declined prompt, `--yes`, a shell `SUPABASE_YES`, and + `SUPABASE_YES` only in `supabase/.env`. A coverage test requires the set of consent leaves to + equal the set of leaves under test. + +## Alternatives Considered + +1. **Per-command defaults for the target and for consent**: keep each handler's choice and document + it. This is the current state, and users cannot predict it. The group annotation declares both + once. +2. **Default every `selectable` command to linked, or to local with no workspace key**: the first + reaches a hosted database from an unconfigured checkout. The second forces `--linked` on every + invocation of a remote-first workflow. The workspace `target` key covers both. +3. **Honour `SUPABASE_YES` from the project `.env`**: convenient for CI, but a committed file would + approve every collaborator's and agent's production writes. CI sets the variable in the shell. +4. **Prompt before removing a network ban**: reads must never prompt, and removal clears the ban for + the whole project, not only the caller. The failure points at the command that does it on purpose. +5. **Accept a piped `y` as consent**: a piped stream is not a decision by the person running the + command. A script that means to approve says so with `--yes`. + +## Related Decisions + +- [ADR 0004](0004-cli-design-goals-and-workflows.md): the safety model this decision amends. +- [ADR 0032](0032-config-value-precedence.md): the precedence the `target` key follows. This ADR + supersedes its seed consent section. + +## 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 532f634d5f..a50ae3f1bc 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -74,6 +74,7 @@ When an ADR becomes outdated, mark it as `deprecated` or reference the supersedi | 0030 | [Hotfix Releases](0030-hotfix-releases.md) | accepted | | 0031 | [Go CLI Removal](0031-go-cli-removal.md) | accepted | | 0032 | [Config Value Precedence](0032-config-value-precedence.md) | proposed | +| 0033 | [Target Selection and Hosted-Mutation Consent](0033-target-selection-and-hosted-mutation-consent.md) | proposed | ## Template From 24efd1fc0db892a5ff0f312c41669cb5ab43499d Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Thu, 15 Oct 2026 13:47:01 +0100 Subject: [PATCH 18/92] docs(config): document the workspace target precedence (CLI-2695) --- packages/config/docs/cli-config-loading.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/packages/config/docs/cli-config-loading.md b/packages/config/docs/cli-config-loading.md index 5c1159054a..568fbc3340 100644 --- a/packages/config/docs/cli-config-loading.md +++ b/packages/config/docs/cli-config-loading.md @@ -375,6 +375,22 @@ Tiers are named `flag`, `shell`, `projectEnv`, `config` and `default`. `pickCliC (`apps/cli/src/config/cli-config-key.ts`) is the single implementation; commands read through the `CliConfigValues` service rather than calling it. +### The workspace `target` + +`target` (`local` or `remote`, default `local`) picks the default instance for commands that can run +against the local stack or the linked project, such as `db reset`, `db query`, `inspect`, +`migration list`, `gen types` and `storage`. `remote` means the linked project. Its order is: + +1. `--local`, `--linked` or `--db-url`, or `--project-ref`, which implies linked +2. `SUPABASE_TARGET` +3. `target` in config +4. `local` + +The selector flags are its flag tier. It applies only to those selectable-instance commands; `db push`, +`db pull` and `migration fetch` default to the linked project, and the hosted-only and local-only +commands have no target to choose. It is a `CliConfig` key only: `ProjectConfig` and `[remotes.*]` +do not carry it. See [ADR 0033](../../../docs/adr/0033-target-selection-and-hosted-mutation-consent.md). + ### `env()` references and `SUPABASE_*` overrides These are different mechanisms and both apply: From 5f28a7242a4bf82178d49259a9a63773c37891e3 Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Thu, 15 Oct 2026 14:18:29 +0100 Subject: [PATCH 19/92] feat(cli): require hosted-mutation consent for functions deploy and delete (CLI-2695) --- .../delete/delete.integration.test.ts | 50 ++- .../functions/deploy/deploy.command.ts | 6 +- .../functions/deploy/deploy.handler.ts | 4 - .../deploy/deploy.integration.test.ts | 288 +++++++++++------- apps/cli/src/shared/functions/delete.ts | 2 + apps/cli/src/shared/functions/deploy.ts | 91 +++--- 6 files changed, 276 insertions(+), 165 deletions(-) diff --git a/apps/cli/src/commands/functions/delete/delete.integration.test.ts b/apps/cli/src/commands/functions/delete/delete.integration.test.ts index 3d3f8e1ebd..4602a05ef6 100644 --- a/apps/cli/src/commands/functions/delete/delete.integration.test.ts +++ b/apps/cli/src/commands/functions/delete/delete.integration.test.ts @@ -2,6 +2,8 @@ import { describe, expect, it } from "@effect/vitest"; import { BunCrypto } from "@effect/platform-bun"; import { Effect, Layer, Option, Stdio } from "effect"; +import { YesFlag } from "../../../command-internal/global-flags.ts"; +import { TargetPolicy } from "../../../command-internal/target/target-policy.ts"; import { commandRuntimeLayer } from "../../../shared/runtime/command-runtime.layer.ts"; import { stripControlSequences } from "../../../shared/output/strip-control-sequences.ts"; import { @@ -12,25 +14,34 @@ import { mockTelemetryStateTracked, useTempWorkdir, } from "../../../../tests/helpers/command-mocks.ts"; +import { describeHostedConsent } from "../../../../tests/helpers/hosted-consent.ts"; import { mockContextualAnalytics, mockOutput } from "../../../../tests/helpers/mocks.ts"; import { functionsDeleteHandler } from "./delete.command.ts"; import { functionsDelete } from "./delete.handler.ts"; const tempRoot = useTempWorkdir("supabase-functions-delete-"); +const approvedHostedMutation = Layer.mergeAll( + Layer.succeed(YesFlag, true), + Layer.succeed(TargetPolicy, { group: "hosted", consent: "no" }), +); + describe("functions delete", () => { it.effect("deletes a function natively through the Management API", () => { const out = mockOutput({ format: "text" }); const api = mockCommandPlatformApi({ response: { status: 200, body: null } }); const linkedProjectCache = mockLinkedProjectCacheTracked(); const telemetry = mockTelemetryStateTracked(); - const layer = buildTestRuntime({ - out, - api, - cliSettings: mockCommandSettings({ workdir: tempRoot.current }), - linkedProjectCache: linkedProjectCache.layer, - telemetry: telemetry.layer, - }); + const layer = Layer.mergeAll( + buildTestRuntime({ + out, + api, + cliSettings: mockCommandSettings({ workdir: tempRoot.current }), + linkedProjectCache: linkedProjectCache.layer, + telemetry: telemetry.layer, + }), + approvedHostedMutation, + ); return Effect.gen(function* () { yield* functionsDelete({ @@ -54,14 +65,17 @@ describe("functions delete", () => { it.effect("uses an explicit project ref", () => { const out = mockOutput({ format: "text" }); const api = mockCommandPlatformApi({ response: { status: 200, body: null } }); - const layer = buildTestRuntime({ - out, - api, - cliSettings: mockCommandSettings({ - workdir: tempRoot.current, - projectId: Option.none(), + const layer = Layer.mergeAll( + buildTestRuntime({ + out, + api, + cliSettings: mockCommandSettings({ + workdir: tempRoot.current, + projectId: Option.none(), + }), }), - }); + approvedHostedMutation, + ); return Effect.gen(function* () { yield* functionsDelete({ @@ -85,6 +99,7 @@ describe("functions delete", () => { analytics, }), commandRuntimeLayer(["functions", "delete"]).pipe(Layer.provide(BunCrypto.layer)), + approvedHostedMutation, Stdio.layerTest({ args: Effect.succeed([ "functions", @@ -107,3 +122,10 @@ describe("functions delete", () => { }).pipe(Effect.provide(layer)); }); }); + +describeHostedConsent("functions delete", { + api: { response: { status: 200, body: null } }, + invoke: () => ({ + run: functionsDelete({ functionName: "hello-world", projectRef: Option.none() }), + }), +}); diff --git a/apps/cli/src/commands/functions/deploy/deploy.command.ts b/apps/cli/src/commands/functions/deploy/deploy.command.ts index f31a6177ff..10d6cbce7b 100644 --- a/apps/cli/src/commands/functions/deploy/deploy.command.ts +++ b/apps/cli/src/commands/functions/deploy/deploy.command.ts @@ -1,11 +1,9 @@ -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"; import { managementApiRuntimeLayer } from "../../../command-internal/management-api-runtime.layer.ts"; import { withCommandTelemetry } from "../../../telemetry/command-telemetry.ts"; import { functionsDeploy } from "./deploy.handler.ts"; @@ -78,9 +76,7 @@ export const functionsDeployCommand = Command.make("deploy", config).pipe( withJsonErrorHandling, ), ), - // `stdinLayer`: the `--prune` confirmation reads piped stdin via `promptYesNo` - // on a non-TTY stdin. - Command.provide(Layer.mergeAll(managementApiRuntimeLayer(["functions", "deploy"]), stdinLayer)), + Command.provide(managementApiRuntimeLayer(["functions", "deploy"])), Command.provide(cliConfigValuesLayer), withCliConfigFlags(config), withTargetPolicy({ group: "hosted", consent: "no" }), diff --git a/apps/cli/src/commands/functions/deploy/deploy.handler.ts b/apps/cli/src/commands/functions/deploy/deploy.handler.ts index 7d1aca7d19..d79ee3833a 100644 --- a/apps/cli/src/commands/functions/deploy/deploy.handler.ts +++ b/apps/cli/src/commands/functions/deploy/deploy.handler.ts @@ -6,7 +6,6 @@ import { functionsLocalConfigLoader } from "../../../command-internal/functions- import { CommandPlatformApi } from "../../../auth/command-platform-api.service.ts"; import { CommandSettings } from "../../../config/command-settings.service.ts"; import { ProjectRefResolver } from "../../../config/project-ref.service.ts"; -import { resolveYes } from "../../../command-internal/global-flags.ts"; import { dashboardUrl } from "../../../command-internal/profile.ts"; import { LinkedProjectCache } from "../../../telemetry/linked-project-cache.service.ts"; import { TelemetryState } from "../../../telemetry/telemetry-state.service.ts"; @@ -19,8 +18,6 @@ export const functionsDeploy = Effect.fn("functions.deploy")(function* ( const api = yield* CommandPlatformApi; const cliSettings = yield* CommandSettings; const resolver = yield* ProjectRefResolver; - // Also honors `SUPABASE_YES`, not just the `--yes` flag, for the `--prune` confirm. - const yes = yield* resolveYes; const linkedProjectCache = yield* LinkedProjectCache; const telemetryState = yield* TelemetryState; const runtimeInfo = yield* RuntimeInfo; @@ -40,7 +37,6 @@ export const functionsDeploy = Effect.fn("functions.deploy")(function* ( supabaseDir: path.join(cliSettings.workdir, "supabase"), dashboardUrl: dashboardUrl(cliSettings.profile), localConfigLoader: functionsLocalConfigLoader, - yes, rawArgs, edgeRuntimeVersion, resolveProjectRef: (projectRef) => 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 7c29630bca..ff56c0861c 100644 --- a/apps/cli/src/commands/functions/deploy/deploy.integration.test.ts +++ b/apps/cli/src/commands/functions/deploy/deploy.integration.test.ts @@ -16,6 +16,8 @@ import { import * as HttpClientResponse from "effect/http/HttpClientResponse"; import { YesFlag } from "../../../command-internal/global-flags.ts"; +import { HostedMutationDeclinedError } from "../../../command-internal/hosted-consent/hosted-consent.errors.ts"; +import { TargetPolicy } from "../../../command-internal/target/target-policy.ts"; import { stripControlSequences } from "../../../shared/output/strip-control-sequences.ts"; import { type ApiHandler, @@ -30,9 +32,10 @@ import { 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 { mockOutput, mockRuntimeInfo, mockTty } from "../../../../tests/helpers/mocks.ts"; import { Output } from "../../../shared/output/output.service.ts"; import { mockChildProcessSpawner } from "../../../../tests/helpers/child-process-spawner.ts"; +import { describeHostedConsent } from "../../../../tests/helpers/hosted-consent.ts"; import { deployFunctions, shouldChmodBundleOutputDirectory, @@ -53,6 +56,11 @@ import type { FunctionsDeployFlags } from "./deploy.command.ts"; const tempRoot = useTempWorkdir("supabase-functions-deploy-legacy-"); +const approvedHostedMutation = Layer.mergeAll( + Layer.succeed(YesFlag, true), + Layer.succeed(TargetPolicy, { group: "hosted", consent: "no" }), +); + const baseFlags: FunctionsDeployFlags = { functionNames: ["hello-world"], projectRef: Option.none(), @@ -65,6 +73,20 @@ const baseFlags: FunctionsDeployFlags = { legacyBundle: false, }; +const deployedFunction = (slug: string) => ({ + id: `${slug}-id`, + slug, + name: slug, + status: "ACTIVE", + version: 2, + created_at: 1_687_423_025_152, + updated_at: 1_687_423_025_152, + verify_jwt: true, + import_map: true, + entrypoint_path: `functions/${slug}/index.ts`, + import_map_path: `functions/${slug}/deno.json`, +}); + const removeTempRoot = Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; yield* fs.remove(tempRoot.current, { recursive: true, force: true }); @@ -166,7 +188,7 @@ describe("functions deploy", () => { linkedProjectCache: linkedProjectCache.layer, telemetry: telemetry.layer, }), - Layer.succeed(YesFlag, false), + approvedHostedMutation, Stdio.layerTest({ args: Effect.succeed(["functions", "deploy", "hello-world", "--use-api"]), }), @@ -229,7 +251,7 @@ describe("functions deploy", () => { cliSettings: mockCommandSettings({ workdir: tempRoot.current }), runtimeInfo: mockRuntimeInfo({ cwd: tempRoot.current }), }), - Layer.succeed(YesFlag, false), + approvedHostedMutation, Stdio.layerTest({ args: Effect.succeed(["functions", "deploy", "hello-world", "hello-world", "--use-api"]), }), @@ -290,7 +312,7 @@ describe("functions deploy", () => { }), runtimeInfo: mockRuntimeInfo({ cwd: tempRoot.current }), }), - Layer.succeed(YesFlag, false), + approvedHostedMutation, Stdio.layerTest({ args: Effect.succeed([ "functions", @@ -352,7 +374,7 @@ describe("functions deploy", () => { cliSettings: mockCommandSettings({ workdir: tempRoot.current }), runtimeInfo: mockRuntimeInfo({ cwd: callerDir }), }), - Layer.succeed(YesFlag, false), + approvedHostedMutation, Stdio.layerTest({ args: Effect.succeed([ "functions", @@ -414,7 +436,7 @@ describe("functions deploy", () => { cliSettings: mockCommandSettings({ workdir: tempRoot.current }), runtimeInfo: mockRuntimeInfo({ cwd: callerDir }), }), - Layer.succeed(YesFlag, false), + approvedHostedMutation, Stdio.layerTest({ args: Effect.succeed(["functions", "deploy", "--use-api"]), }), @@ -489,7 +511,7 @@ describe("functions deploy", () => { cliSettings: mockCommandSettings({ workdir }), runtimeInfo: mockRuntimeInfo({ cwd: workdir }), }), - Layer.succeed(YesFlag, false), + approvedHostedMutation, Stdio.layerTest({ args: Effect.succeed(["functions", "deploy", "hello-world", "--use-api"]), }), @@ -541,7 +563,7 @@ describe("functions deploy", () => { cliSettings: mockCommandSettings({ workdir: tempRoot.current }), runtimeInfo: mockRuntimeInfo({ cwd: tempRoot.current }), }), - Layer.succeed(YesFlag, false), + approvedHostedMutation, Stdio.layerTest({ args: Effect.succeed(["functions", "deploy", "hello-world", "--use-api"]), }), @@ -633,7 +655,7 @@ describe("functions deploy", () => { cliSettings: mockCommandSettings({ workdir: tempRoot.current }), runtimeInfo: mockRuntimeInfo({ cwd: tempRoot.current }), }), - Layer.succeed(YesFlag, false), + approvedHostedMutation, Stdio.layerTest({ args: Effect.succeed(["functions", "deploy", "hello-world", "--use-api"]), }), @@ -883,7 +905,7 @@ describe("functions deploy", () => { cliSettings: mockCommandSettings({ workdir: tempRoot.current }), runtimeInfo: mockRuntimeInfo({ cwd: tempRoot.current }), }), - Layer.succeed(YesFlag, false), + approvedHostedMutation, Stdio.layerTest({ args: Effect.succeed(["functions", "deploy", "hello-world", "--use-api"]), }), @@ -977,7 +999,7 @@ describe("functions deploy", () => { cliSettings: mockCommandSettings({ workdir: tempRoot.current }), runtimeInfo: mockRuntimeInfo({ cwd: tempRoot.current }), }), - Layer.succeed(YesFlag, false), + approvedHostedMutation, child.layer, Stdio.layerTest({ args: Effect.succeed(["functions", "deploy", "hello-world", "--use-api=false"]), @@ -1034,7 +1056,7 @@ describe("functions deploy", () => { cliSettings: mockCommandSettings({ workdir: tempRoot.current }), runtimeInfo: mockRuntimeInfo({ cwd: tempRoot.current }), }), - Layer.succeed(YesFlag, false), + approvedHostedMutation, child.layer, Stdio.layerTest({ args: Effect.succeed(["functions", "deploy", "hello-world", "--use-api=false"]), @@ -1096,7 +1118,7 @@ describe("functions deploy", () => { cliSettings: mockCommandSettings({ workdir: tempRoot.current }), runtimeInfo: mockRuntimeInfo({ cwd: tempRoot.current }), }), - Layer.succeed(YesFlag, false), + approvedHostedMutation, Stdio.layerTest({ args: Effect.succeed(["functions", "deploy", "--use-api"]), }), @@ -1143,80 +1165,112 @@ describe("functions deploy", () => { }).pipe(Effect.provide(layer), Effect.ensuring(removeTempRoot)); }); - it.effect("honors global --yes when pruning remote functions", () => { - const out = mockOutput({ format: "text", promptConfirmFail: true }); - const api = mockCommandPlatformApi({ - handler: (request) => { - if (request.method === "POST") { - return Effect.succeed( - jsonResponse(request, 201, { - id: "function-id", - slug: "hello-world", - name: "hello-world", - status: "ACTIVE", - version: 2, - created_at: 1_687_423_025_152, - updated_at: 1_687_423_025_152, - verify_jwt: true, - import_map: true, - entrypoint_path: "functions/hello-world/index.ts", - import_map_path: "functions/hello-world/deno.json", - }), - ); - } - if (request.method === "GET") { - return Effect.succeed( - jsonResponse(request, 200, [ - { - id: "remote-id", - slug: "remote-only", - name: "remote-only", - status: "ACTIVE", - version: 1, - created_at: 1_687_423_025_152, - updated_at: 1_687_423_025_152, - verify_jwt: true, - import_map: false, - }, - ]), - ); - } - return Effect.succeed(jsonResponse(request, 200, {})); - }, + describe("--prune consent", () => { + const pruneArgs = ["functions", "deploy", "hello-world", "--use-api", "--prune"]; + const pruneApiHandler: ApiHandler = (request) => { + if (request.method === "POST") { + return Effect.succeed(jsonResponse(request, 201, deployedFunction("hello-world"))); + } + if (request.method === "GET") { + return Effect.succeed( + jsonResponse(request, 200, [{ ...deployedFunction("remote-only"), version: 1 }]), + ); + } + return Effect.succeed(jsonResponse(request, 200, {})); + }; + + const setupPrune = (opts: { + readonly yes: boolean; + readonly interactive?: boolean; + readonly confirmResponses?: ReadonlyArray; + }) => { + const out = mockOutput({ + format: "text", + interactive: opts.interactive, + promptConfirmResponses: opts.confirmResponses, + }); + const api = mockCommandPlatformApi({ handler: pruneApiHandler }); + const layer = Layer.mergeAll( + cliConfigValuesTestLayer, + buildTestRuntime({ + out, + api, + cliSettings: mockCommandSettings({ workdir: tempRoot.current }), + runtimeInfo: mockRuntimeInfo({ cwd: tempRoot.current }), + tty: mockTty({ + stdinIsTty: opts.interactive ?? false, + stdoutIsTty: opts.interactive ?? false, + }), + }), + Layer.succeed(YesFlag, opts.yes), + Layer.succeed(TargetPolicy, { group: "hosted", consent: "no" }), + Stdio.layerTest({ args: Effect.succeed(opts.yes ? [...pruneArgs, "--yes"] : pruneArgs) }), + ); + return { out, api, layer }; + }; + + const mutations = (api: { readonly requests: ReadonlyArray<{ readonly method: string }> }) => + api.requests.filter((request) => request.method !== "GET"); + + it.effect("deletes pruned functions under --yes after echoing one combined confirmation", () => { + const { out, api, layer } = setupPrune({ yes: true }); + + return Effect.gen(function* () { + yield* writeCliConfig(tempRoot.current); + yield* writeLocalFunction(tempRoot.current, "hello-world"); + + yield* functionsDeploy({ ...baseFlags, prune: true }); + + expect(stripControlSequences(out.stderrText)).toContain( + "Confirmed by --yes: deploy 1 Function and delete 1 Function with --prune on project", + ); + expect(out.promptConfirmCalls).toHaveLength(0); + expect(api.requests.some((request) => request.method === "DELETE")).toBe(true); + }).pipe(Effect.provide(layer), Effect.ensuring(removeTempRoot)); }); - const layer = Layer.mergeAll( - cliConfigValuesTestLayer, - buildTestRuntime({ - out, - api, - cliSettings: mockCommandSettings({ workdir: tempRoot.current }), - runtimeInfo: mockRuntimeInfo({ cwd: tempRoot.current }), - }), - Layer.succeed(YesFlag, true), - Stdio.layerTest({ - args: Effect.succeed([ - "functions", - "deploy", - "hello-world", - "--use-api", - "--prune", - "--yes", - ]), - }), - ); - return Effect.gen(function* () { - yield* writeCliConfig(tempRoot.current); - yield* writeLocalFunction(tempRoot.current, "hello-world"); + it.effect("asks one question covering the deploy and the prune before deploying", () => { + const { out, api, layer } = setupPrune({ + yes: false, + interactive: true, + confirmResponses: [true], + }); + + return Effect.gen(function* () { + yield* writeCliConfig(tempRoot.current); + yield* writeLocalFunction(tempRoot.current, "hello-world"); - yield* functionsDeploy({ ...baseFlags, prune: true }); + yield* functionsDeploy({ ...baseFlags, prune: true }); - expect(out.promptConfirmCalls).toHaveLength(0); - expect(stripControlSequences(out.stderrText)).toContain( - "Do you want to delete the following Functions from your project?\n • remote-only\n\n [y/N] y\n", - ); - expect(api.requests.some((request) => request.method === "DELETE")).toBe(true); - }).pipe(Effect.provide(layer), Effect.ensuring(removeTempRoot)); + expect(out.promptConfirmCalls).toHaveLength(1); + expect(out.promptConfirmCalls[0]?.message).toContain( + "deploy 1 Function and delete 1 Function with --prune", + ); + expect(out.promptConfirmCalls[0]?.opts?.defaultValue).toBe(false); + expect(stripControlSequences(out.stderrText)).toContain( + "Functions to deploy:\n • hello-world\nFunctions to delete (not found locally):\n • remote-only\n", + ); + expect(mutations(api).map((request) => request.method)).toEqual(["POST", "DELETE"]); + }).pipe(Effect.provide(layer), Effect.ensuring(removeTempRoot)); + }); + + it.effect("deploys and deletes nothing when the combined question is declined", () => { + const { api, layer } = setupPrune({ + yes: false, + interactive: true, + confirmResponses: [false], + }); + + return Effect.gen(function* () { + yield* writeCliConfig(tempRoot.current); + yield* writeLocalFunction(tempRoot.current, "hello-world"); + + const error = yield* functionsDeploy({ ...baseFlags, prune: true }).pipe(Effect.flip); + + expect(error).toBeInstanceOf(HostedMutationDeclinedError); + expect(mutations(api)).toEqual([]); + }).pipe(Effect.provide(layer), Effect.ensuring(removeTempRoot)); + }); }); // A `bundleOnly` upload bumps the remote version without persisting metadata, @@ -1280,7 +1334,7 @@ describe("functions deploy", () => { cliSettings: mockCommandSettings({ workdir: tempRoot.current }), runtimeInfo: mockRuntimeInfo({ cwd: tempRoot.current }), }), - Layer.succeed(YesFlag, false), + approvedHostedMutation, Stdio.layerTest({ args: Effect.succeed(["functions", "deploy", "hello-world", "bye-world", "--use-api"]), }), @@ -1380,7 +1434,7 @@ describe("functions deploy", () => { cliSettings: mockCommandSettings({ workdir: tempRoot.current }), runtimeInfo: mockRuntimeInfo({ cwd: tempRoot.current }), }), - Layer.succeed(YesFlag, false), + approvedHostedMutation, Stdio.layerTest({ args: Effect.succeed(["functions", "deploy", "hello-world", "--use-api", "--use-docker"]), }), @@ -1414,7 +1468,7 @@ describe("functions deploy", () => { cliSettings: mockCommandSettings({ workdir: tempRoot.current }), runtimeInfo: mockRuntimeInfo({ cwd: tempRoot.current }), }), - Layer.succeed(YesFlag, false), + approvedHostedMutation, Stdio.layerTest({ args: Effect.succeed(rawArgs) }), ); return { out, api, layer }; @@ -1491,7 +1545,7 @@ describe("functions deploy", () => { cliSettings: mockCommandSettings({ workdir: tempRoot.current }), runtimeInfo: mockRuntimeInfo({ cwd: tempRoot.current }), }), - Layer.succeed(YesFlag, false), + approvedHostedMutation, Stdio.layerTest({ args: Effect.succeed(["functions", "deploy", "hello-world", "--use-api", "--jobs", "2"]), }), @@ -1545,7 +1599,7 @@ describe("functions deploy", () => { cliSettings: mockCommandSettings({ workdir: tempRoot.current }), runtimeInfo: mockRuntimeInfo({ cwd: tempRoot.current }), }), - Layer.succeed(YesFlag, false), + approvedHostedMutation, Stdio.layerTest({ args: Effect.succeed(["functions", "deploy", "hello-world", "--jobs", "0"]), }), @@ -1604,7 +1658,7 @@ describe("functions deploy", () => { cliSettings: mockCommandSettings({ workdir: tempRoot.current }), runtimeInfo: mockRuntimeInfo({ cwd: tempRoot.current }), }), - Layer.succeed(YesFlag, false), + approvedHostedMutation, child.layer, Stdio.layerTest({ args: Effect.succeed(["functions", "deploy", "hello-world", "--use-api=false"]), @@ -1654,7 +1708,7 @@ describe("functions deploy", () => { cliSettings: mockCommandSettings({ workdir: tempRoot.current }), runtimeInfo: mockRuntimeInfo({ cwd: tempRoot.current }), }), - Layer.succeed(YesFlag, false), + approvedHostedMutation, Stdio.layerTest({ args: Effect.succeed(["functions", "deploy"]) }), ); const deployNoFunctions = Effect.gen(function* () { @@ -1727,7 +1781,7 @@ describe("functions deploy", () => { cliSettings: mockCommandSettings({ workdir: tempRoot.current }), runtimeInfo: mockRuntimeInfo({ cwd: tempRoot.current }), }), - Layer.succeed(YesFlag, false), + approvedHostedMutation, Stdio.layerTest({ args: Effect.succeed(["functions", "deploy", "hello-world"]), }), @@ -1759,7 +1813,7 @@ describe("functions deploy", () => { cliSettings: mockCommandSettings({ workdir: tempRoot.current }), runtimeInfo: mockRuntimeInfo({ cwd: tempRoot.current }), }), - Layer.succeed(YesFlag, false), + approvedHostedMutation, Stdio.layerTest({ args: Effect.succeed(["functions", "deploy", "hello-world"]), }), @@ -1795,7 +1849,7 @@ describe("functions deploy", () => { cliSettings: mockCommandSettings({ workdir: tempRoot.current }), runtimeInfo: mockRuntimeInfo({ cwd: tempRoot.current }), }), - Layer.succeed(YesFlag, false), + approvedHostedMutation, Stdio.layerTest({ args: Effect.succeed(["functions", "deploy", "1-invalid-slug"]), }), @@ -1826,7 +1880,7 @@ describe("functions deploy", () => { cliSettings: mockCommandSettings({ workdir: tempRoot.current }), runtimeInfo: mockRuntimeInfo({ cwd: tempRoot.current }), }), - Layer.succeed(YesFlag, false), + approvedHostedMutation, Stdio.layerTest({ args: Effect.succeed(["functions", "deploy", "1-invalid-slug"]), }), @@ -1885,7 +1939,7 @@ describe("functions deploy", () => { cliSettings: mockCommandSettings({ workdir: tempRoot.current }), runtimeInfo: mockRuntimeInfo({ cwd: tempRoot.current }), }), - Layer.succeed(YesFlag, false), + approvedHostedMutation, child.layer, Stdio.layerTest({ args: Effect.succeed(["functions", "deploy", "hello-world", "--use-api=false"]), @@ -1922,7 +1976,7 @@ describe("functions deploy", () => { cliSettings: mockCommandSettings({ workdir: tempRoot.current }), runtimeInfo: mockRuntimeInfo({ cwd: tempRoot.current }), }), - Layer.succeed(YesFlag, false), + approvedHostedMutation, child.layer, Stdio.layerTest({ args: Effect.succeed(["functions", "deploy", "hello-world", "--use-api=false"]), @@ -1955,7 +2009,7 @@ describe("functions deploy", () => { cliSettings: mockCommandSettings({ workdir: tempRoot.current }), runtimeInfo: mockRuntimeInfo({ cwd: tempRoot.current }), }), - Layer.succeed(YesFlag, false), + approvedHostedMutation, child.layer, Stdio.layerTest({ args: Effect.succeed(["functions", "deploy", "hello-world", "--use-api=false"]), @@ -1991,7 +2045,7 @@ describe("functions deploy", () => { cliSettings: mockCommandSettings({ workdir: tempRoot.current }), runtimeInfo: mockRuntimeInfo({ cwd: tempRoot.current }), }), - Layer.succeed(YesFlag, false), + approvedHostedMutation, child.layer, Stdio.layerTest({ args: Effect.succeed(["functions", "deploy", "hello-world", "--use-api=false"]), @@ -2030,7 +2084,7 @@ describe("functions deploy", () => { cliSettings: mockCommandSettings({ workdir: tempRoot.current }), runtimeInfo: mockRuntimeInfo({ cwd: tempRoot.current }), }), - Layer.succeed(YesFlag, false), + approvedHostedMutation, child.layer, Stdio.layerTest({ args: Effect.succeed([ @@ -2075,7 +2129,7 @@ describe("functions deploy", () => { cliSettings: mockCommandSettings({ workdir: tempRoot.current }), runtimeInfo: mockRuntimeInfo({ cwd: tempRoot.current }), }), - Layer.succeed(YesFlag, false), + approvedHostedMutation, child.layer, Stdio.layerTest({ args: Effect.succeed(["functions", "deploy", "hello-world", "--use-api=false"]), @@ -2130,7 +2184,7 @@ describe("functions deploy", () => { cliSettings: mockCommandSettings({ workdir: nestedWorkdir }), runtimeInfo: mockRuntimeInfo({ cwd: nestedWorkdir }), }), - Layer.succeed(YesFlag, false), + approvedHostedMutation, child.layer, Stdio.layerTest({ args: Effect.succeed(["functions", "deploy", "hello-world", "--use-api=false"]), @@ -2192,7 +2246,7 @@ describe("functions deploy", () => { cliSettings: mockCommandSettings({ workdir: nestedWorkdir }), runtimeInfo: mockRuntimeInfo({ cwd: nestedWorkdir }), }), - Layer.succeed(YesFlag, false), + approvedHostedMutation, Stdio.layerTest({ args: Effect.succeed(["functions", "deploy", "hello-world", "--use-api"]), }), @@ -2258,7 +2312,7 @@ describe("functions deploy", () => { cliSettings: mockCommandSettings({ workdir: tempRoot.current }), runtimeInfo: mockRuntimeInfo({ cwd: tempRoot.current }), }), - Layer.succeed(YesFlag, false), + approvedHostedMutation, child.layer, Stdio.layerTest({ args: Effect.succeed(["functions", "deploy", "hello-world", "--use-api=false"]), @@ -2294,3 +2348,31 @@ describe("functions deploy", () => { }); }); }); + +describeHostedConsent("functions deploy", { + api: { + handler: (request) => + Effect.succeed( + request.method === "POST" + ? jsonResponse(request, 201, deployedFunction("hello-world")) + : jsonResponse(request, 200, []), + ), + }, + setup: (workdir) => + Effect.gen(function* () { + yield* writeCliConfig(workdir); + yield* writeLocalFunction(workdir, "hello-world"); + }), + invoke: () => ({ + run: functionsDeploy(baseFlags).pipe( + Effect.provide( + Layer.mergeAll( + cliConfigValuesTestLayer, + Stdio.layerTest({ + args: Effect.succeed(["functions", "deploy", "hello-world", "--use-api"]), + }), + ), + ), + ), + }), +}); diff --git a/apps/cli/src/shared/functions/delete.ts b/apps/cli/src/shared/functions/delete.ts index 953922b198..ca076a64e4 100644 --- a/apps/cli/src/shared/functions/delete.ts +++ b/apps/cli/src/shared/functions/delete.ts @@ -6,6 +6,7 @@ import { } from "@supabase/api/effect"; import { Effect, type Option } from "effect"; import * as HttpClientError from "effect/http/HttpClientError"; +import { requireHostedMutationConsent } from "../../command-internal/hosted-consent/hosted-consent.ts"; import { Output } from "../output/output.service.ts"; import { DeleteFunctionNetworkError, @@ -52,6 +53,7 @@ export const deleteFunction = Effect.fn("functions.delete")(function* < yield* validateSlug(flags.slug); const projectRef = yield* dependencies.resolveProjectRef(flags.projectRef); + yield* requireHostedMutationConsent({ action: `delete Function ${flags.slug}` }); const response = yield* dependencies.api .executeRaw(operationDefinitions.v1DeleteAFunction, { diff --git a/apps/cli/src/shared/functions/deploy.ts b/apps/cli/src/shared/functions/deploy.ts index ffa38f4fab..4b792592cd 100644 --- a/apps/cli/src/shared/functions/deploy.ts +++ b/apps/cli/src/shared/functions/deploy.ts @@ -25,11 +25,9 @@ import { } from "effect"; import * as HttpBody from "effect/http/HttpBody"; import * as HttpClientError from "effect/http/HttpClientError"; -import { promptYesNo } from "../../command-internal/prompt-yes-no.ts"; import { bitbucketCloneDir } from "../../command-internal/bitbucket-pipeline.ts"; -import { CONTEXT_CANCELED_MESSAGE } from "../output/errors.ts"; +import { requireHostedMutationConsent } from "../../command-internal/hosted-consent/hosted-consent.ts"; import { Output } from "../output/output.service.ts"; -import { bold } from "../../command-internal/colors.ts"; import { supabaseEnvStringWithProjectFallback } from "../../command-internal/supabase-env.ts"; import { findGitRootPath } from "../git/git-root.ts"; import { @@ -47,7 +45,6 @@ import { import { slimImagesEnabled } from "../services/slim-images.ts"; import { ConflictingFunctionDeployFlagsError, - FunctionDeployCancelledError, FunctionDeployError, FunctionImportMapSyntaxError, FunctionImportNotDirectoryError, @@ -108,7 +105,6 @@ interface DeployFunctionsDependencies { * command tree directly — see {@link FunctionsLocalConfigLoader}. */ readonly localConfigLoader: FunctionsLocalConfigLoader; - readonly yes?: boolean; readonly rawArgs: ReadonlyArray; readonly edgeRuntimeVersion: string; readonly resolveProjectRef: ( @@ -2420,42 +2416,53 @@ const deployViaDocker = Effect.fn("functions.deploy.viaDocker")(function* ( } }); -const pruneFunctions = Effect.fn("functions.deploy.prune")(function* ( +const findPruneCandidates = Effect.fn("functions.deploy.pruneCandidates")(function* ( projectRef: string, configs: ReadonlyArray, api: ApiClient, - yes: boolean, ) { - const output = yield* Output; const remoteFunctions = yield* listRemoteFunctions(api, projectRef); const localSlugs = new Set(configs.map((config) => config.slug)); - const toDelete = remoteFunctions + return remoteFunctions .filter((remote) => remote.status !== "REMOVED" && !localSlugs.has(remote.slug)) .map((remote) => remote.slug); +}); +const pruneFunctions = Effect.fn("functions.deploy.prune")(function* ( + projectRef: string, + toDelete: ReadonlyArray, + api: ApiClient, +) { + const output = yield* Output; if (toDelete.length === 0) { yield* output.raw("No Functions to prune.\n", "stderr"); return; } - // Header, one ` • ` line per function, and a trailing blank line before the [y/N] - // choices. Routed through `promptYesNo` so `--yes`/`SUPABASE_YES` auto-confirms with the - // stderr echo, and a non-TTY stdin still honors a piped `y`/`n` answer. - const prompt = `${[ - "Do you want to delete the following Functions from your project?", - ...toDelete.map((slug) => ` • ${bold(slug)}`), - ].join("\n")}\n\n`; - const confirmed = yield* promptYesNo(output, yes, prompt, false); - if (!confirmed) { - return yield* new FunctionDeployCancelledError({ message: CONTEXT_CANCELED_MESSAGE }); - } - for (const slug of toDelete) { yield* output.raw(`Deleting Function: ${slug}\n`, "stderr"); yield* deleteRemoteFunction(api, projectRef, slug); } }); +const functionCount = (count: number) => `${count} ${count === 1 ? "Function" : "Functions"}`; + +const requireDeployConsent = (toDeploy: ReadonlyArray, toPrune: ReadonlyArray) => + requireHostedMutationConsent({ + action: [ + ...(toDeploy.length > 0 ? [`deploy ${functionCount(toDeploy.length)}`] : []), + ...(toPrune.length > 0 ? [`delete ${functionCount(toPrune.length)} with --prune`] : []), + ].join(" and "), + details: [ + ...(toDeploy.length > 0 + ? ["Functions to deploy:", ...toDeploy.map((slug) => ` • ${slug}`)] + : []), + ...(toPrune.length > 0 + ? ["Functions to delete (not found locally):", ...toPrune.map((slug) => ` • ${slug}`)] + : []), + ], + }); + export const deployFunctions = Effect.fn("functions.deploy")(function* < ResolveError, ResolveRequirements, @@ -2574,6 +2581,14 @@ export const deployFunctions = Effect.fn("functions.deploy")(function* < }); const dashboardUrl = `${dependencies.dashboardUrl}/project/${projectRef}/functions`; + const toDeploy = configs.filter((config) => config.enabled).map((config) => config.slug); + const toPrune = flags.prune + ? yield* findPruneCandidates(projectRef, configs, dependencies.api) + : []; + if (toDeploy.length > 0 || toPrune.length > 0) { + yield* requireDeployConsent(toDeploy, toPrune); + } + const deployWithApi = deployViaApi( projectRef, dependencies.projectRoot, @@ -2630,26 +2645,24 @@ export const deployFunctions = Effect.fn("functions.deploy")(function* < }) : yield* deployWithApi; - if (!deployed) { - return; - } - - if (output.format === "text") { - // Joins the raw `slugs` list, not the deduped set, so `functions deploy foo foo` prints - // "foo, foo". - yield* output.raw( - `Deployed Functions on project ${styleIdentifier(projectRef)}: ${slugs.join(", ")}\n`, - ); - yield* output.raw(`You can inspect your deployment in the Dashboard: ${dashboardUrl}\n`); - } else { - yield* output.success("Deployed Functions.", { - project_ref: projectRef, - functions: uniqueSlugs, - dashboard_url: dashboardUrl, - }); + if (deployed) { + if (output.format === "text") { + // Joins the raw `slugs` list, not the deduped set, so `functions deploy foo foo` prints + // "foo, foo". + yield* output.raw( + `Deployed Functions on project ${styleIdentifier(projectRef)}: ${slugs.join(", ")}\n`, + ); + yield* output.raw(`You can inspect your deployment in the Dashboard: ${dashboardUrl}\n`); + } else { + yield* output.success("Deployed Functions.", { + project_ref: projectRef, + functions: uniqueSlugs, + dashboard_url: dashboardUrl, + }); + } } if (flags.prune) { - yield* pruneFunctions(projectRef, configs, dependencies.api, dependencies.yes ?? false); + yield* pruneFunctions(projectRef, toPrune, dependencies.api); } }); From 037c58b62ba5e168136c397b216853e8d3f335d8 Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Thu, 15 Oct 2026 14:20:27 +0100 Subject: [PATCH 20/92] feat(cli): require hosted-mutation consent for secrets set and unset (CLI-2695) --- .../src/commands/secrets/set/set.handler.ts | 8 +- .../secrets/set/set.integration.test.ts | 61 +++++++++- .../commands/secrets/unset/unset.command.ts | 5 +- .../commands/secrets/unset/unset.handler.ts | 32 +----- .../secrets/unset/unset.integration.test.ts | 105 +++++++++++------- 5 files changed, 135 insertions(+), 76 deletions(-) diff --git a/apps/cli/src/commands/secrets/set/set.handler.ts b/apps/cli/src/commands/secrets/set/set.handler.ts index 387cae7508..e4fd1ec8e7 100644 --- a/apps/cli/src/commands/secrets/set/set.handler.ts +++ b/apps/cli/src/commands/secrets/set/set.handler.ts @@ -17,7 +17,8 @@ import { LinkedProjectCache } from "../../../telemetry/linked-project-cache.serv import { TelemetryState } from "../../../telemetry/telemetry-state.service.ts"; import { Output } from "../../../shared/output/output.service.ts"; import { RuntimeInfo } from "../../../shared/runtime/runtime-info.service.ts"; -import { mapHttpError } from "../../../command-internal/http-errors.ts"; +import { requireHostedMutationConsent } from "../../../command-internal/hosted-consent/hosted-consent.ts"; +import { mapHttpError, sanitizeInlineName } from "../../../command-internal/http-errors.ts"; import { InvalidSecretPairError, SecretsEnvFileOpenError, @@ -300,6 +301,11 @@ export const secretsSet = Effect.fn("secrets.set")(function* (flags: SecretsSetF ), ); + yield* requireHostedMutationConsent({ + action: `set ${body.length} ${body.length === 1 ? "secret" : "secrets"}`, + details: ["Secrets to set:", ...body.map(({ name }) => ` • ${sanitizeInlineName(name)}`)], + }); + const setting = output.format === "text" ? yield* output.task("Setting secrets...") : undefined; yield* Effect.forEach(batches, (batch) => api.v1.bulkCreateSecrets({ ref, body: batch }), { discard: true, 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 54ceedd8c8..7bd75563e9 100644 --- a/apps/cli/src/commands/secrets/set/set.integration.test.ts +++ b/apps/cli/src/commands/secrets/set/set.integration.test.ts @@ -12,8 +12,17 @@ import { Schema, } from "effect"; +import { YesFlag } from "../../../command-internal/global-flags.ts"; +import { HostedMutationDeclinedError } from "../../../command-internal/hosted-consent/hosted-consent.errors.ts"; +import { TargetPolicy } from "../../../command-internal/target/target-policy.ts"; import { cliConfigValuesTestLayer } from "../../../../tests/helpers/config-values-layer.ts"; -import { mockOutput, mockRuntimeInfo, processEnvLayer } from "../../../../tests/helpers/mocks.ts"; +import { describeHostedConsent } from "../../../../tests/helpers/hosted-consent.ts"; +import { + mockOutput, + mockRuntimeInfo, + mockTty, + processEnvLayer, +} from "../../../../tests/helpers/mocks.ts"; import { VALID_REF, buildTestRuntime, @@ -69,12 +78,13 @@ interface SetupOpts { status?: number; network?: "fail"; env?: Record; + confirm?: boolean; } const tempRoot = useTempWorkdir("supabase-secrets-set-int-"); function setup(opts: SetupOpts = {}) { - const out = mockOutput({ format: opts.format ?? "text" }); + const out = mockOutput({ format: opts.format ?? "text", confirmLogout: opts.confirm }); const api = mockCommandPlatformApi({ // POST `/v1/projects/{ref}/secrets` returns 201 with no body on success. response: { status: opts.status ?? 201, body: null }, @@ -88,11 +98,14 @@ function setup(opts: SetupOpts = {}) { api, cliSettings, outputFlag: opts.outputFlag === undefined ? Option.none() : Option.some(opts.outputFlag), + tty: mockTty({ stdinIsTty: opts.confirm !== undefined, stdoutIsTty: false }), }), mockRuntimeInfo({ cwd: tempRoot.current }), processEnvLayer(opts.env ?? {}), cliConfigValuesTestLayer, debugLogger.layer, + Layer.succeed(YesFlag, opts.confirm === undefined), + Layer.succeed(TargetPolicy, { group: "hosted", consent: "no" }), ); return { layer, out, api, debugLogger }; } @@ -123,6 +136,39 @@ const decodePostBody = Schema.decodeUnknownEffect( ); describe("secrets set integration", () => { + describe("consent", () => { + it.effect("lists the secret names but never their values in the confirmation", () => { + const { layer, out, api } = setup({ confirm: true }); + return Effect.gen(function* () { + yield* secretsSet({ + projectRef: Option.none(), + envFile: Option.none(), + secrets: ["FOO=hunter2", "BAR=swordfish"], + }); + expect(out.promptConfirmCalls[0]?.message).toBe( + `set 2 secrets on project ${VALID_REF} · identity unverified?`, + ); + expect(out.promptConfirmCalls[0]?.opts?.defaultValue).toBe(false); + expect(out.stderrText).toContain("Secrets to set:\n • FOO\n • BAR\n"); + expect(out.stderrText).not.toMatch(/hunter2|swordfish/u); + expect(api.requests).toHaveLength(1); + }).pipe(Effect.provide(layer)); + }); + + it.effect("sends nothing when the user declines", () => { + const { layer, api } = setup({ confirm: false }); + return Effect.gen(function* () { + const error = yield* secretsSet({ + projectRef: Option.none(), + envFile: Option.none(), + secrets: ["FOO=bar"], + }).pipe(Effect.flip); + expect(error).toBeInstanceOf(HostedMutationDeclinedError); + expect(api.requests).toEqual([]); + }).pipe(Effect.provide(layer)); + }); + }); + it.effect("sets a single secret via CLI arg FOO=bar", () => { const { layer, out, api } = setup(); return Effect.gen(function* () { @@ -1016,3 +1062,14 @@ FROM_CONFIG = "config-value" }, ); }); + +describeHostedConsent("secrets set", { + api: { response: { status: 201, body: null } }, + invoke: () => ({ + run: secretsSet({ + projectRef: Option.none(), + envFile: Option.none(), + secrets: ["FOO=bar"], + }).pipe(Effect.provide(Layer.mergeAll(cliConfigValuesTestLayer, mockDebugLoggerTracked().layer))), + }), +}); diff --git a/apps/cli/src/commands/secrets/unset/unset.command.ts b/apps/cli/src/commands/secrets/unset/unset.command.ts index 6f527ffa9a..9acee16856 100644 --- a/apps/cli/src/commands/secrets/unset/unset.command.ts +++ b/apps/cli/src/commands/secrets/unset/unset.command.ts @@ -1,9 +1,7 @@ import type * as CliCommand from "effect/cli/Command"; import { Argument, Command, Flag } from "effect/cli"; -import { Layer } from "effect"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; -import { stdinLayer } from "../../../shared/runtime/stdin.layer.ts"; import { managementApiRuntimeLayer } from "../../../command-internal/management-api-runtime.layer.ts"; import { withCommandTelemetry } from "../../../telemetry/command-telemetry.ts"; import { secretsUnset } from "./unset.handler.ts"; @@ -56,7 +54,6 @@ export const secretsUnsetCommand = Command.make("unset", config).pipe( Command.withHandler((flags) => secretsUnset(flags).pipe(withCommandTelemetry({ flags }), withJsonErrorHandling), ), - // `promptYesNo` requires `Stdin` even though the handler fails before its non-TTY branch. - Command.provide(Layer.mergeAll(managementApiRuntimeLayer(["secrets", "unset"]), stdinLayer)), + Command.provide(managementApiRuntimeLayer(["secrets", "unset"])), withTargetPolicy({ group: "hosted", consent: "no" }), ); diff --git a/apps/cli/src/commands/secrets/unset/unset.handler.ts b/apps/cli/src/commands/secrets/unset/unset.handler.ts index 14b0321d5b..7f0257cd1b 100644 --- a/apps/cli/src/commands/secrets/unset/unset.handler.ts +++ b/apps/cli/src/commands/secrets/unset/unset.handler.ts @@ -5,18 +5,13 @@ import { CommandPlatformApi } from "../../../auth/command-platform-api.service.t 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 { resolveYes } from "../../../command-internal/global-flags.ts"; -import { promptYesNo } from "../../../command-internal/prompt-yes-no.ts"; -import { CONTEXT_CANCELED_MESSAGE } from "../../../shared/output/errors.ts"; +import { requireHostedMutationConsent } from "../../../command-internal/hosted-consent/hosted-consent.ts"; import { Output } from "../../../shared/output/output.service.ts"; -import { Tty } from "../../../shared/runtime/tty.service.ts"; -import { mapHttpError } from "../../../command-internal/http-errors.ts"; +import { mapHttpError, sanitizeInlineName } from "../../../command-internal/http-errors.ts"; import { SecretsListNetworkError, SecretsListUnexpectedStatusError, SecretsNoArgumentsError, - SecretsUnsetCancelledError, - SecretsUnsetConfirmationRequiredError, SecretsUnsetInputError, SecretsUnsetNetworkError, SecretsUnsetUnexpectedStatusError, @@ -47,9 +42,6 @@ export const secretsUnset = Effect.fn("secrets.unset")(function* (flags: Secrets const resolver = yield* ProjectRefResolver; const linkedProjectCache = yield* LinkedProjectCache; const telemetryState = yield* TelemetryState; - const tty = yield* Tty; - // `resolveYes` also honors `SUPABASE_YES`, not just the `--yes` flag. - const yes = yield* resolveYes; if (flags.all && flags.names.length > 0) { return yield* new SecretsUnsetInputError({ @@ -67,15 +59,6 @@ export const secretsUnset = Effect.fn("secrets.unset")(function* (flags: Secrets const ref = yield* resolver.resolve(flags.projectRef); yield* Effect.gen(function* () { - const canPrompt = tty.stdinIsTty && output.interactive && output.format === "text"; - if (!flags.dryRun && !yes && !canPrompt) { - return yield* new SecretsUnsetConfirmationRequiredError({ - message: `Unsetting secrets on project ${ref} needs confirmation, and this run can't prompt. Nothing was unset.`, - suggestion: - "Pass --yes (or set SUPABASE_YES=1) to confirm without a prompt, or --dry-run to preview.", - }); - } - let names: ReadonlyArray = flags.names; if (flags.all) { @@ -121,13 +104,10 @@ export const secretsUnset = Effect.fn("secrets.unset")(function* (flags: Secrets return; } - const label = `Do you want to unset these function secrets from project ${ref}?\n • ${names.join("\n • ")}\n\n`; - - const confirmed = yield* promptYesNo(output, yes, label, false); - - if (!confirmed) { - return yield* new SecretsUnsetCancelledError({ message: CONTEXT_CANCELED_MESSAGE }); - } + yield* requireHostedMutationConsent({ + action: `unset ${names.length} ${names.length === 1 ? "secret" : "secrets"}`, + details: ["Secrets to unset:", ...names.map((name) => ` • ${sanitizeInlineName(name)}`)], + }); const unsetting = output.format === "text" ? yield* output.task("Unsetting secrets...") : undefined; diff --git a/apps/cli/src/commands/secrets/unset/unset.integration.test.ts b/apps/cli/src/commands/secrets/unset/unset.integration.test.ts index 719b7c44b9..61858c2eaa 100644 --- a/apps/cli/src/commands/secrets/unset/unset.integration.test.ts +++ b/apps/cli/src/commands/secrets/unset/unset.integration.test.ts @@ -3,7 +3,13 @@ import { describe, expect, it } from "@effect/vitest"; import { Cause, Effect, Exit, Layer, Option } from "effect"; import { YesFlag } from "../../../command-internal/global-flags.ts"; +import { + HostedMutationConsentRequiredError, + HostedMutationDeclinedError, +} from "../../../command-internal/hosted-consent/hosted-consent.errors.ts"; +import { TargetPolicy } from "../../../command-internal/target/target-policy.ts"; import { ProjectRefNotLinkedError } from "../../../config/project-ref.errors.ts"; +import { describeHostedConsent } from "../../../../tests/helpers/hosted-consent.ts"; import { mockOutput, mockStdin, mockTty } from "../../../../tests/helpers/mocks.ts"; import { VALID_REF, @@ -16,12 +22,7 @@ import { withConfigEnv, unverifiedTargetBanner, } from "../../../../tests/helpers/command-mocks.ts"; -import { - SecretsNoArgumentsError, - SecretsUnsetCancelledError, - SecretsUnsetConfirmationRequiredError, - SecretsUnsetInputError, -} from "../secrets.errors.ts"; +import { SecretsNoArgumentsError, SecretsUnsetInputError } from "../secrets.errors.ts"; import type { SecretsUnsetFlags } from "./unset.command.ts"; import { secretsUnset } from "./unset.handler.ts"; @@ -52,7 +53,7 @@ const MIXED_SECRETS: SecretsList = [ { name: "BAR", value: "d3" }, ]; -const FOO_PROMPT = `Do you want to unset these function secrets from project ${VALID_REF}?\n • FOO\n\n`; +const ON_TARGET = `project ${VALID_REF} · identity unverified`; function setup(opts: SetupOpts = {}) { const out = mockOutput({ @@ -90,6 +91,7 @@ function setup(opts: SetupOpts = {}) { outputFlag: opts.outputFlag === undefined ? Option.none() : Option.some(opts.outputFlag), }), Layer.succeed(YesFlag, opts.yes ?? false), + Layer.succeed(TargetPolicy, { group: "hosted", consent: "no" }), ); return { layer, out, api }; } @@ -131,15 +133,14 @@ describe("secrets unset integration", () => { }); describe("unsetting named secrets", () => { - it.effect("sends one DELETE with the names and echoes the prompt for --yes", () => { + it.effect("sends one DELETE with the names and echoes the confirmation for --yes", () => { const { layer, out, api } = setup({ yes: true }); return Effect.gen(function* () { yield* unset({ names: ["FOO", "BAR"] }); expect(methods(api)).toEqual(["DELETE"]); expect(api.requests[0]?.body).toEqual(["FOO", "BAR"]); expect(out.stderrText).toBe( - unverifiedTargetBanner() + - `Do you want to unset these function secrets from project ${VALID_REF}?\n • FOO\n • BAR\n\n [y/N] y\n`, + `${unverifiedTargetBanner()}Confirmed by --yes: unset 2 secrets on ${ON_TARGET}\n`, ); expect(out.stdoutText).toBe("Finished supabase secrets unset.\n"); }).pipe(Effect.provide(layer)); @@ -152,7 +153,9 @@ describe("secrets unset integration", () => { { SUPABASE_YES: "1" }, Effect.gen(function* () { yield* unset({ names: ["FOO"] }); - expect(out.stderrText).toBe(`${unverifiedTargetBanner()}${FOO_PROMPT} [y/N] y\n`); + expect(out.stderrText).toBe( + `${unverifiedTargetBanner()}Confirmed by --yes: unset 1 secret on ${ON_TARGET}\n`, + ); expect(methods(api)).toEqual(["DELETE"]); }).pipe(Effect.provide(layer)), ); @@ -163,25 +166,23 @@ describe("secrets unset integration", () => { return Effect.gen(function* () { yield* unset({ names: ["FOO"] }); expect(out.promptConfirmCalls).toEqual([ - { message: FOO_PROMPT, opts: { defaultValue: false } }, + { message: `unset 1 secret on ${ON_TARGET}?`, opts: { defaultValue: false } }, ]); + expect(out.stderrText).toContain("Secrets to unset:\n • FOO\n"); expect(methods(api)).toEqual(["DELETE"]); expect(api.requests[0]?.body).toEqual(["FOO"]); expect(out.stdoutText).toBe("Finished supabase secrets unset.\n"); }).pipe(Effect.provide(layer)); }); - it.effect( - "fails with SecretsUnsetCancelledError and sends no DELETE when the user declines", - () => { - const { layer, api } = setup({ stdinIsTty: true, confirm: false }); - return Effect.gen(function* () { - const error = yield* unset({ names: ["FOO"] }).pipe(Effect.flip); - expect(error).toBeInstanceOf(SecretsUnsetCancelledError); - expect(api.requests).toEqual([]); - }).pipe(Effect.provide(layer)); - }, - ); + it.effect("fails with HostedMutationDeclinedError and sends no DELETE when the user declines", () => { + const { layer, api } = setup({ stdinIsTty: true, confirm: false }); + return Effect.gen(function* () { + const error = yield* unset({ names: ["FOO"] }).pipe(Effect.flip); + expect(error).toBeInstanceOf(HostedMutationDeclinedError); + expect(api.requests).toEqual([]); + }).pipe(Effect.provide(layer)); + }); }); describe("--all", () => { @@ -192,8 +193,7 @@ describe("secrets unset integration", () => { expect(methods(api)).toEqual(["GET", "DELETE"]); expect(api.requests[1]?.body).toEqual(["FOO", "BAR"]); expect(out.stderrText).toBe( - unverifiedTargetBanner() + - `Do you want to unset these function secrets from project ${VALID_REF}?\n • FOO\n • BAR\n\n [y/N] y\n`, + `${unverifiedTargetBanner()}Confirmed by --yes: unset 2 secrets on ${ON_TARGET}\n`, ); }).pipe(Effect.provide(layer)); }); @@ -216,27 +216,30 @@ describe("secrets unset integration", () => { describe("consent gate", () => { it.effect.each([ - { name: "named secrets", flags: { names: ["FOO"] } }, - { name: "--all", flags: { all: true } }, - ])("refuses $name on a non-TTY run without --yes before any API call", ({ flags }) => { - const { layer, out, api } = setup({ list: MIXED_SECRETS }); - return Effect.gen(function* () { - const error = yield* unset(flags).pipe(Effect.flip); - expect(error).toBeInstanceOf(SecretsUnsetConfirmationRequiredError); - expect(error).toMatchObject({ - message: expect.stringContaining(VALID_REF), - suggestion: expect.stringMatching(/--yes.*--dry-run/u), - }); - expect(api.requests).toEqual([]); - expect(out.stderrText).toBe(unverifiedTargetBanner()); - }).pipe(Effect.provide(layer)); - }); + { name: "named secrets", flags: { names: ["FOO"] }, expectedMethods: [] }, + { name: "--all", flags: { all: true }, expectedMethods: ["GET"] }, + ])( + "refuses $name on a non-TTY run without --yes and sends no DELETE", + ({ flags, expectedMethods }) => { + const { layer, out, api } = setup({ list: MIXED_SECRETS }); + return Effect.gen(function* () { + const error = yield* unset(flags).pipe(Effect.flip); + expect(error).toBeInstanceOf(HostedMutationConsentRequiredError); + expect(error).toMatchObject({ + message: expect.stringContaining(VALID_REF), + suggestion: expect.stringContaining("--yes"), + }); + expect(methods(api)).toEqual(expectedMethods); + expect(out.stderrText).toBe(unverifiedTargetBanner()); + }).pipe(Effect.provide(layer)); + }, + ); it.effect("still refuses a piped `y` without --yes and sends no DELETE", () => { const { layer, out, api } = setup({ stdinIsTty: false, stdinInput: "y\n" }); return Effect.gen(function* () { const error = yield* unset({ names: ["FOO"] }).pipe(Effect.flip); - expect(error).toBeInstanceOf(SecretsUnsetConfirmationRequiredError); + expect(error).toBeInstanceOf(HostedMutationConsentRequiredError); expect(api.requests).toEqual([]); expect(out.stderrText).toBe(unverifiedTargetBanner()); }).pipe(Effect.provide(layer)); @@ -248,7 +251,7 @@ describe("secrets unset integration", () => { const { layer, out, api } = setup({ format, stdinIsTty: true }); return Effect.gen(function* () { const error = yield* unset({ names: ["FOO"] }).pipe(Effect.flip); - expect(error).toBeInstanceOf(SecretsUnsetConfirmationRequiredError); + expect(error).toBeInstanceOf(HostedMutationConsentRequiredError); expect(api.requests).toEqual([]); expect(out.promptConfirmCalls).toEqual([]); }).pipe(Effect.provide(layer)); @@ -259,11 +262,20 @@ describe("secrets unset integration", () => { const { layer, out, api } = setup({ stdinIsTty: true, interactive: false }); return Effect.gen(function* () { const error = yield* unset({ names: ["FOO"] }).pipe(Effect.flip); - expect(error).toBeInstanceOf(SecretsUnsetConfirmationRequiredError); + expect(error).toBeInstanceOf(HostedMutationConsentRequiredError); expect(api.requests).toEqual([]); expect(out.promptConfirmCalls).toEqual([]); }).pipe(Effect.provide(layer)); }); + + it.effect("lists every secret --all will unset before asking", () => { + const { layer, out } = setup({ stdinIsTty: true, confirm: false, list: MIXED_SECRETS }); + return Effect.gen(function* () { + yield* unset({ all: true }).pipe(Effect.flip); + expect(out.stderrText).toContain("Secrets to unset:\n • FOO\n • BAR\n"); + expect(out.stderrText).not.toContain("SUPABASE_AUTH_TOKEN"); + }).pipe(Effect.provide(layer)); + }); }); describe("--dry-run", () => { @@ -424,3 +436,10 @@ describe("secrets unset integration", () => { }); }); }); + +describeHostedConsent("secrets unset", { + api: { response: { status: 200, body: null } }, + invoke: () => ({ + run: secretsUnset({ projectRef: Option.none(), names: ["FOO"], all: false, dryRun: false }), + }), +}); From 99ee29377d6aeb00dab0d76377c7081efc5fb68f Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Thu, 15 Oct 2026 14:22:43 +0100 Subject: [PATCH 21/92] feat(cli): default storage and seed buckets to the workspace target with hosted consent --- apps/cli/src/command-internal/seed-buckets.ts | 107 +++++++++++++++++- .../src/command-internal/storage-gateway.ts | 12 ++ .../commands/seed/buckets/buckets.command.ts | 28 ++--- .../commands/seed/buckets/buckets.flags.ts | 23 ---- .../seed/buckets/buckets.flags.unit.test.ts | 45 -------- .../commands/seed/buckets/buckets.handler.ts | 69 +++++------ apps/cli/src/commands/seed/seed.command.ts | 7 +- apps/cli/src/commands/seed/seed.flags.ts | 22 ---- .../cli/src/commands/storage/cp/cp.command.ts | 17 +-- .../cli/src/commands/storage/cp/cp.handler.ts | 22 ++-- .../cli/src/commands/storage/ls/ls.command.ts | 15 +-- .../cli/src/commands/storage/ls/ls.handler.ts | 16 +-- .../cli/src/commands/storage/mv/mv.command.ts | 15 +-- .../cli/src/commands/storage/mv/mv.handler.ts | 20 ++-- .../cli/src/commands/storage/rm/rm.command.ts | 15 +-- .../cli/src/commands/storage/rm/rm.handler.ts | 62 +++++----- .../cli/src/commands/storage/storage.flags.ts | 48 +------- .../storage/storage.flags.unit.test.ts | 39 ------- .../cli/src/commands/storage/storage.frame.ts | 28 ++++- 19 files changed, 247 insertions(+), 363 deletions(-) delete mode 100644 apps/cli/src/commands/seed/buckets/buckets.flags.ts delete mode 100644 apps/cli/src/commands/seed/buckets/buckets.flags.unit.test.ts delete mode 100644 apps/cli/src/commands/seed/seed.flags.ts delete mode 100644 apps/cli/src/commands/storage/storage.flags.unit.test.ts diff --git a/apps/cli/src/command-internal/seed-buckets.ts b/apps/cli/src/command-internal/seed-buckets.ts index 957a41dad7..6120df81c2 100644 --- a/apps/cli/src/command-internal/seed-buckets.ts +++ b/apps/cli/src/command-internal/seed-buckets.ts @@ -110,7 +110,12 @@ const decodeDefaultCliConfig = Schema.decodeUnknownSync(CliConfigSchema); * `emitSummary` gates the stdout summary; `interactive` (default `true`) gates overwrite/prune * prompts. The caller owns project-ref resolution, cache writes, and telemetry. */ -export const seedBucketsRun = Effect.fnUntraced(function* (opts: { +export const seedBucketsRun = Effect.fnUntraced(function* < + EA = never, + RA = never, + EC = never, + RC = never, +>(opts: { readonly projectRef: string; readonly emitSummary: boolean; readonly interactive?: boolean; @@ -151,6 +156,13 @@ export const seedBucketsRun = Effect.fnUntraced(function* (opts: { * would swallow the next line of a `curl … | bash` script. Declined prunes say so instead. */ readonly promptless?: boolean; + /** Announces the local Storage endpoint as the run's target once its URL is known. */ + readonly announceLocalEndpoint?: (baseUrl: string) => Effect.Effect; + /** + * Asks for consent to the hosted plan's writes before the first one. One answer covers every + * bucket overwrite and prune, so no per-bucket prompt follows it. + */ + readonly confirmHostedWrites?: (details: ReadonlyArray) => Effect.Effect; }) { const output = yield* Output; const cliSettings = yield* CommandSettings; @@ -247,6 +259,9 @@ export const seedBucketsRun = Effect.fnUntraced(function* (opts: { // Build the Storage service-gateway client (local or remote). const credentials = opts.credentials ?? (yield* resolveStorageCredentials({ projectRef })); + if (projectRef === "" && opts.announceLocalEndpoint !== undefined) { + yield* opts.announceLocalEndpoint(credentials.baseUrl); + } // Gateway operations use plain `globalThis.fetch` instead of the DoH fetch, with the Kong CA in // `FetchHttpClient.RequestInit` for local + https; the api-keys lookup in @@ -260,16 +275,33 @@ export const seedBucketsRun = Effect.fnUntraced(function* (opts: { const summary = emptySummary(); - yield* upsertBuckets(output, yes, interactive, promptless, gateway, bucketPropsByName, summary); + // A hosted run never prompts per item: one consent covers every overwrite and prune. + const confirmHostedWrites = projectRef === "" ? undefined : opts.confirmHostedWrites; + if (confirmHostedWrites !== undefined) { + const details = yield* planHostedWrites(gateway, config, bucketPropsByName); + if (details.length > 0) yield* confirmHostedWrites(details); + } + const approved = confirmHostedWrites !== undefined || yes; + const skipPrompts = confirmHostedWrites !== undefined || promptless; + + yield* upsertBuckets( + output, + approved, + interactive, + skipPrompts, + gateway, + bucketPropsByName, + summary, + ); // Upsert analytics buckets (remote --linked only). if (config.storage.analytics.enabled && projectRef !== "") { yield* output.raw("Updating analytics buckets...\n", "stderr"); yield* upsertAnalyticsBuckets( output, - yes, + approved, interactive, - promptless, + skipPrompts, gateway, Object.keys(config.storage.analytics.buckets), summary, @@ -281,9 +313,9 @@ export const seedBucketsRun = Effect.fnUntraced(function* (opts: { yield* output.raw("Updating vector buckets...\n", "stderr"); yield* upsertVectorBuckets( output, - yes, + approved, interactive, - promptless, + skipPrompts, gateway, vectorBucketNames, summary, @@ -478,6 +510,69 @@ const upsertAnalyticsBuckets = Effect.fnUntraced(function* ( } }); +const joinNames = (names: ReadonlyArray): string => names.join(", "); + +/** + * The hosted writes `seedBucketsRun` is about to make, as consent detail lines; empty when it + * would write nothing. Reads the remote bucket lists but changes nothing. + */ +const planHostedWrites = Effect.fnUntraced(function* ( + gateway: StorageGateway, + config: CliConfig, + propsByName: ReadonlyMap, +) { + const details: Array = []; + const existing = new Set((yield* gateway.listBuckets).map((bucket) => bucket.name)); + const configured = [...propsByName.keys()]; + const create = configured.filter((name) => !existing.has(name)); + const overwrite = configured.filter((name) => existing.has(name)); + if (create.length > 0) details.push(`Create buckets: ${joinNames(create)}`); + if (overwrite.length > 0) + details.push(`Overwrite the properties of buckets: ${joinNames(overwrite)}`); + + const withObjects = Object.entries(config.storage.buckets ?? {}) + .filter(([, bucket]) => bucket.objects_path.length > 0) + .map(([name]) => name); + if (withObjects.length > 0) { + details.push( + `Upload objects, overwriting existing ones, to buckets: ${joinNames(withObjects)}`, + ); + } + + if (config.storage.analytics.enabled) { + const declared = Object.keys(config.storage.analytics.buckets); + const present = new Set(yield* gateway.listAnalyticsBuckets); + const created = declared.filter((name) => !present.has(name)); + const pruned = [...present].filter((name) => !declared.includes(name)); + if (created.length > 0) details.push(`Create analytics buckets: ${joinNames(created)}`); + if (pruned.length > 0) { + details.push(`Delete analytics buckets not declared in ${CONFIG_PATH}: ${joinNames(pruned)}`); + } + } + + const declaredVector = Object.keys(config.storage.vector.buckets); + if (config.storage.vector.enabled && declaredVector.length > 0) { + const listed = yield* gateway.listVectorBuckets.pipe( + Effect.catch((error) => + isVectorBucketsFeatureNotEnabled(error.message) || + isLocalVectorBucketsUnavailable(error.message) + ? Effect.succeed(undefined) + : Effect.fail(error), + ), + ); + if (listed !== undefined) { + const present = new Set(listed); + const created = declaredVector.filter((name) => !present.has(name)); + const pruned = listed.filter((name) => !declaredVector.includes(name)); + if (created.length > 0) details.push(`Create vector buckets: ${joinNames(created)}`); + if (pruned.length > 0) { + details.push(`Delete vector buckets not declared in ${CONFIG_PATH}: ${joinNames(pruned)}`); + } + } + } + return details; +}); + /** * On `FeatureNotEnabled` or local-unavailable errors, prints the matching warning and * continues (object upload still runs); any other error propagates. diff --git a/apps/cli/src/command-internal/storage-gateway.ts b/apps/cli/src/command-internal/storage-gateway.ts index 8705c4c3d4..6ba70e4b61 100644 --- a/apps/cli/src/command-internal/storage-gateway.ts +++ b/apps/cli/src/command-internal/storage-gateway.ts @@ -4,9 +4,11 @@ import * as HttpClientError from "effect/http/HttpClientError"; import * as HttpClientRequest from "effect/http/HttpClientRequest"; import * as HttpClientResponse from "effect/http/HttpClientResponse"; +import { announceTarget } from "../shared/output/command-target.service.ts"; import { kongAuthHeaders } from "./kong-auth.ts"; import { StorageGatewayNetworkError, StorageGatewayStatusError } from "./storage-gateway.errors.ts"; import { splitDirAndFile } from "./storage-url.ts"; +import { renderLocalTarget } from "./target/target-identity.ts"; /** * Client for the Supabase Storage service gateway (Kong). See @@ -149,6 +151,16 @@ function localGatewayHint(port: string): string { ); } +/** Announces the local Storage API endpoint as the run's target. */ +export const announceLocalStorageTarget = (baseUrl: string) => { + if (!URL.canParse(baseUrl)) return Effect.void; + const { hostname, port, protocol } = new URL(baseUrl); + const defaultPort = protocol === "https:" ? 443 : 80; + return announceTarget( + renderLocalTarget(hostname.replace(/^\[|\]$/g, ""), port === "" ? defaultPort : Number(port)), + ); +}; + /** * Whether a transport failure is a plain connection-refused. The * port-conflict hint is suppressed for these, since a refused connection diff --git a/apps/cli/src/commands/seed/buckets/buckets.command.ts b/apps/cli/src/commands/seed/buckets/buckets.command.ts index 27c63eaa9e..ed9a1aae49 100644 --- a/apps/cli/src/commands/seed/buckets/buckets.command.ts +++ b/apps/cli/src/commands/seed/buckets/buckets.command.ts @@ -1,9 +1,8 @@ import { Effect, Layer, type Option } from "effect"; -import { Command, Flag } from "effect/cli"; +import { Command } 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 { withPermissions } from "../../../command-internal/command-permissions/command-permissions.annotation.ts"; import { compose, @@ -14,23 +13,20 @@ import { storageCredentials } from "../../../command-internal/storage-credential import { linkedProjectCache } from "../../../telemetry/linked-project-cache.layer.ts"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; import { withCommandTelemetry } from "../../../telemetry/command-telemetry.ts"; -import { SeedLinkedFlag, SeedLocalFlag } from "../seed.flags.ts"; -import { assertSeedTargetsExclusive } from "./buckets.flags.ts"; +import { + seedTargetGlobalFlags, + targetFlags, +} from "../../../command-internal/target/target-flags.ts"; import { stdinLayer } from "../../../shared/runtime/stdin.layer.ts"; import { storageGatewayRuntimeLayer } from "../../../command-internal/storage-runtime.layer.ts"; import { seedBuckets } from "./buckets.handler.ts"; import { withTargetPolicy } from "../../../command-internal/target/target-policy.ts"; const config = { - // TS-only flag; see push.command.ts (db push) for the same pattern. - projectRef: Flag.String("project-ref").pipe( - Flag.withDescription("Project ref of the Supabase project."), - Flag.optional, - ), + projectRef: targetFlags({ group: "selectable", subject: "Storage API", dbUrl: false }).projectRef, }; -// `--linked`/`--local` are scoped globals on the `seed` group (`seed.flags.ts`); the handler -// selects the target from the changed argv set, not these parsed values. +// `--linked`/`--local` are scoped globals on the `seed` group (`seedTargetGlobalFlags`). export type BucketsFlags = { readonly linked: boolean; readonly local: boolean; @@ -51,15 +47,9 @@ export const bucketsCommand = Command.make("buckets", config).pipe( ), Command.withHandler((leafFlags) => Effect.gen(function* () { - // Enforce --local/--linked mutual exclusivity before instrumentation, so a - // flag-validation rejection doesn't emit `cli_command_executed`. - const cliArgs = yield* CliArgs; - yield* assertSeedTargetsExclusive(cliArgs.args); - // Persistent seed-group flags for the telemetry flags map; target selection - // itself uses the changed set above. const flags: BucketsFlags = { - linked: yield* SeedLinkedFlag, - local: yield* SeedLocalFlag, + linked: yield* seedTargetGlobalFlags.linked, + local: yield* seedTargetGlobalFlags.local, projectRef: leafFlags.projectRef, }; return yield* seedBuckets(flags).pipe( diff --git a/apps/cli/src/commands/seed/buckets/buckets.flags.ts b/apps/cli/src/commands/seed/buckets/buckets.flags.ts deleted file mode 100644 index f731fac0bf..0000000000 --- a/apps/cli/src/commands/seed/buckets/buckets.flags.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { Effect } from "effect"; - -import { changedLinkedLocalFlags } from "../../../command-internal/db-target-flags.ts"; -import { SeedMutuallyExclusiveFlagsError } from "./buckets.errors.ts"; - -/** - * Rejects `--local` and `--linked` set together. Must run before - * `withCommandTelemetry` so the rejection doesn't emit `cli_command_executed`. - * - * The error message's bracket lists `[local linked]` in registration order, - * not sorted — `storage`'s equivalent registers the same pair in the opposite - * order, so the two commands' messages legitimately differ. - */ -export const assertSeedTargetsExclusive = Effect.fnUntraced(function* ( - args: ReadonlyArray, -) { - const setFlags = changedLinkedLocalFlags(args); - if (setFlags.length > 1) { - return yield* new SeedMutuallyExclusiveFlagsError({ - message: `if any flags in the group [local linked] are set none of the others can be; [${setFlags.join(" ")}] were all set`, - }); - } -}); diff --git a/apps/cli/src/commands/seed/buckets/buckets.flags.unit.test.ts b/apps/cli/src/commands/seed/buckets/buckets.flags.unit.test.ts deleted file mode 100644 index 579d015306..0000000000 --- a/apps/cli/src/commands/seed/buckets/buckets.flags.unit.test.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { describe, expect, it } from "@effect/vitest"; -import { Effect, Exit } from "effect"; - -import { assertSeedTargetsExclusive } from "./buckets.flags.ts"; - -describe("assertSeedTargetsExclusive", () => { - it.effect("fails when both --local and --linked are set (mutual exclusivity)", () => - Effect.gen(function* () { - const error = yield* assertSeedTargetsExclusive([ - "seed", - "buckets", - "--local", - "--linked", - ]).pipe(Effect.flip); - expect(error.message).toBe( - "if any flags in the group [local linked] are set none of the others can be; [linked local] were all set", - ); - }), - ); - - it.effect("fails for the --no-local --linked negation combo (both changed)", () => - Effect.gen(function* () { - const error = yield* assertSeedTargetsExclusive([ - "seed", - "buckets", - "--no-local", - "--linked", - ]).pipe(Effect.flip); - expect(error.message).toContain("[linked local] were all set"); - }), - ); - - it.effect("succeeds when at most one target flag is set", () => - Effect.gen(function* () { - for (const args of [ - ["seed", "buckets", "--linked"], - ["seed", "buckets", "--local"], - ["seed", "buckets"], - ]) { - const exit = yield* assertSeedTargetsExclusive(args).pipe(Effect.exit); - expect(Exit.isSuccess(exit)).toBe(true); - } - }), - ); -}); diff --git a/apps/cli/src/commands/seed/buckets/buckets.handler.ts b/apps/cli/src/commands/seed/buckets/buckets.handler.ts index ed349cf7c3..26c755da6a 100644 --- a/apps/cli/src/commands/seed/buckets/buckets.handler.ts +++ b/apps/cli/src/commands/seed/buckets/buckets.handler.ts @@ -1,41 +1,31 @@ import { Effect, FileSystem, Option, Ref } from "effect"; -import { CliArgs } from "../../../shared/cli/cli-args.service.ts"; import { CommandSettings } from "../../../config/command-settings.service.ts"; -import { ProjectRefResolver } from "../../../config/project-ref.service.ts"; -import { changedLinkedLocalFlags } from "../../../command-internal/db-target-flags.ts"; +import { requireTargetMutationConsent } from "../../../command-internal/hosted-consent/hosted-consent.ts"; import { seedBucketsRun } from "../../../command-internal/seed-buckets.ts"; +import { announceLocalStorageTarget } from "../../../command-internal/storage-gateway.ts"; +import { resolveTarget } from "../../../command-internal/target/resolve-target.ts"; import { requireExplicitWorkdirProject } from "../../../command-internal/workdir-project.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"; import type { BucketsFlags } from "./buckets.command.ts"; -import { - SeedMissingProjectConfigError, - SeedMutuallyExclusiveFlagsError, - SeedWorkdirError, -} from "./buckets.errors.ts"; +import { SeedMissingProjectConfigError, SeedWorkdirError } from "./buckets.errors.ts"; /** * `supabase seed buckets` — seeds Storage buckets from * `[storage.buckets]` / `[storage.vector]` in `supabase/config.toml`. The * seeding work lives in the hoisted `seedBucketsRun` (shared with `db reset - * --local`); this handler owns target-flag resolution and telemetry. + * --local`); this handler owns target resolution, hosted consent and telemetry. */ -export const seedBuckets = Effect.fn("seed.buckets")(function* ( - // Target (linked vs. local) is selected from the changed-flag set, not the - // parsed `linked`/`local` values — only `projectRef` is read directly below. - flags: BucketsFlags, -) { +export const seedBuckets = Effect.fn("seed.buckets")(function* (flags: BucketsFlags) { const telemetryState = yield* TelemetryState; const linkedProjectCache = yield* LinkedProjectCache; - const cliArgs = yield* CliArgs; const cliSettings = yield* CommandSettings; const fs = yield* FileSystem.FileSystem; - // Set once --linked resolves a ref; drives the post-run linked-project cache - // write and org/project group identify. Empty on the local path, so the - // cache is never written there. + // Set once the target resolves to a linked project; drives the post-run linked-project + // cache write and org/project group identify. Empty on the local path. const linkedRef = yield* Ref.make(""); yield* Effect.gen(function* () { @@ -43,23 +33,6 @@ export const seedBuckets = Effect.fn("seed.buckets")(function* ( Effect.mapError((error) => new SeedWorkdirError({ message: error.message })), ); - // 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 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"); - - // `--project-ref` never implies `--linked` and must not be silently - // discarded on the local target — see push.handler.ts's identical guard - // (db push) for the rationale. - if (Option.isSome(flags.projectRef) && !isLinked) { - return yield* new SeedMutuallyExclusiveFlagsError({ - message: - "--project-ref only applies when targeting the linked project; use it with --linked (not --local)", - }); - } - // An explicit `--workdir`/`SUPABASE_WORKDIR` with no project config fails // here, before any Storage call, instead of authenticating and seeding // nothing while exiting 0. `start`/`db reset` skip this by calling @@ -68,15 +41,31 @@ export const seedBuckets = Effect.fn("seed.buckets")(function* ( Effect.mapError((error) => new SeedMissingProjectConfigError({ message: error.message })), ); - const projectRefResolver = yield* ProjectRefResolver; - const projectRef = isLinked ? yield* projectRefResolver.loadProjectRef(flags.projectRef) : ""; + // Resolved before the config load so the matching `[remotes.]` override + // (whose `project_id == ref`) is merged over the base config. + const target = yield* resolveTarget({ + local: flags.local, + linked: flags.linked, + dbUrl: Option.none(), + projectRef: flags.projectRef, + }); + const projectRef = target.kind === "linked" ? target.ref : ""; yield* Ref.set(linkedRef, projectRef); - yield* Effect.annotateCurrentSpan({ "db.conn_type": isLinked ? "linked" : "local" }); - yield* seedBucketsRun({ projectRef, emitSummary: true }); + yield* Effect.annotateCurrentSpan({ "db.conn_type": target.kind }); + yield* seedBucketsRun({ + projectRef, + emitSummary: true, + announceLocalEndpoint: announceLocalStorageTarget, + confirmHostedWrites: + target.kind === "linked" + ? (details) => + requireTargetMutationConsent(target, { action: "Seed storage buckets", details }) + : undefined, + }); }).pipe( // Caches the linked project and fires org/project group identify — only - // on the --linked path, when `linkedRef` is set. + // on the linked path, when `linkedRef` is set. Effect.ensuring( Effect.flatMap(Ref.get(linkedRef), (ref) => ref === "" ? Effect.void : linkedProjectCache.cache(ref), diff --git a/apps/cli/src/commands/seed/seed.command.ts b/apps/cli/src/commands/seed/seed.command.ts index b71faef63a..57f171ef56 100644 --- a/apps/cli/src/commands/seed/seed.command.ts +++ b/apps/cli/src/commands/seed/seed.command.ts @@ -1,14 +1,13 @@ import { Command } from "effect/cli"; +import { seedTargetGlobalFlags } from "../../command-internal/target/target-flags.ts"; import { bucketsCommand } from "./buckets/buckets.command.ts"; -import { SeedLinkedFlag, SeedLocalFlag } from "./seed.flags.ts"; export const seedCommand = Command.make("seed").pipe( Command.withDescription("Seed a Supabase project from supabase/config.toml."), Command.withShortDescription("Seed a Supabase project"), Command.withSubcommands([bucketsCommand]), - // Persistent `--linked`/`--local`, accepted - // before or after the subcommand. See `seed.flags.ts`. Kept after + // Persistent `--linked`/`--local`, accepted before or after the subcommand. Kept after // `withSubcommands` so it discharges the subcommands' flag requirements. - Command.withGlobalFlags([SeedLinkedFlag, SeedLocalFlag]), + Command.withGlobalFlags([seedTargetGlobalFlags.linked, seedTargetGlobalFlags.local]), ); diff --git a/apps/cli/src/commands/seed/seed.flags.ts b/apps/cli/src/commands/seed/seed.flags.ts deleted file mode 100644 index 12aeb60b49..0000000000 --- a/apps/cli/src/commands/seed/seed.flags.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { Flag, GlobalFlag } from "effect/cli"; - -/** - * `--linked` / `--local` are scoped global flags on the `seed` group, so both - * `supabase seed --linked buckets` and `supabase seed buckets --linked` work. - * `--local` defaults to `true`, but the seed target is actually selected from - * the changed-flag set (`buckets.flags.ts`), not these parsed values — the - * defaults only affect help text and the telemetry flags map. - */ -export const SeedLinkedFlag = GlobalFlag.Setting("linked")({ - flag: Flag.Boolean("linked").pipe( - Flag.withDescription("Seeds the linked project."), - Flag.withDefault(false), - ), -}); - -export const SeedLocalFlag = GlobalFlag.Setting("local")({ - flag: Flag.Boolean("local").pipe( - Flag.withDescription("Seeds the local database."), - Flag.withDefault(true), - ), -}); diff --git a/apps/cli/src/commands/storage/cp/cp.command.ts b/apps/cli/src/commands/storage/cp/cp.command.ts index 80cbdded96..bc47e270a5 100644 --- a/apps/cli/src/commands/storage/cp/cp.command.ts +++ b/apps/cli/src/commands/storage/cp/cp.command.ts @@ -4,19 +4,13 @@ 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 "../../../shared/config/parse-uint.ts"; -import { - StorageLinkedFlagDef, - StorageLocalFlagDef, - StorageProjectRefFlagDef, - assertStorageTargetsExclusive, -} from "../storage.flags.ts"; +import { storageTargetFlags } from "../storage.flags.ts"; import { storageCp } from "./cp.handler.ts"; import { withTargetPolicy } from "../../../command-internal/target/target-policy.ts"; @@ -62,9 +56,7 @@ const config = { ), Flag.optional, ), - linked: StorageLinkedFlagDef, - local: StorageLocalFlagDef, - projectRef: StorageProjectRefFlagDef, + ...storageTargetFlags, src: Argument.String("src").pipe(Argument.withDescription("Source path to copy from.")), dst: Argument.String("dst").pipe(Argument.withDescription("Destination path to copy to.")), } as const; @@ -90,11 +82,8 @@ export const storageCpCommand = Command.make("cp", config).pipe( ]), Command.withHandler((flags) => Effect.gen(function* () { - // Gate before the mutex check below; see requireExperimental's doc comment for why. A - // non-uint `--jobs` never reaches here — `Flag.mapTryCatch` rejects it at parse time. + // A non-uint `--jobs` never reaches here: `Flag.mapTryCatch` rejects it at parse time. yield* requireExperimental; - const cliArgs = yield* CliArgs; - yield* assertStorageTargetsExclusive(cliArgs.args); const telemetryFlags = { recursive: flags.recursive, cacheControl: flags.cacheControl, diff --git a/apps/cli/src/commands/storage/cp/cp.handler.ts b/apps/cli/src/commands/storage/cp/cp.handler.ts index fa567052f1..cb35e16fd2 100644 --- a/apps/cli/src/commands/storage/cp/cp.handler.ts +++ b/apps/cli/src/commands/storage/cp/cp.handler.ts @@ -4,11 +4,11 @@ import { Effect, FileSystem, Option, Path, Ref, Result, Stream } from "effect"; import type { PlatformError } from "effect/PlatformError"; import { CommandSettings } from "../../../config/command-settings.service.ts"; -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 { Output } from "../../../shared/output/output.service.ts"; import { RuntimeInfo } from "../../../shared/runtime/runtime-info.service.ts"; +import { requireTargetMutationConsent } from "../../../command-internal/hosted-consent/hosted-consent.ts"; import { contentTypeForUpload, readSniffBytes, @@ -33,12 +33,12 @@ import { assertStorageWorkdir, connectStorageGateway, loadStorageConfig, + resolveStorageTarget, } from "../storage.frame.ts"; import { StorageConfigError } from "../../../command-internal/storage-credentials.errors.ts"; import { StorageCopyBetweenBucketsError, StorageFileError, - StorageMutuallyExclusiveFlagsError, StorageObjectNotFoundError, StorageUnsupportedOperationError, StorageUrlParseError, @@ -63,7 +63,6 @@ export const storageCp = Effect.fn("storage.cp")(function* (flags: StorageCpFlag const cliSettings = yield* CommandSettings; const telemetryState = yield* TelemetryState; const linkedProjectCache = yield* LinkedProjectCache; - const resolver = yield* ProjectRefResolver; const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const posixPath = yield* Effect.provide(Path.Path, BunPath.layerPosix); @@ -83,15 +82,7 @@ export const storageCp = Effect.fn("storage.cp")(function* (flags: StorageCpFlag yield* Effect.gen(function* () { yield* assertStorageWorkdir(cliSettings.workdir); - // `--project-ref` only applies to the linked project; it never implies `--linked`. - if (Option.isSome(flags.projectRef) && flags.local) { - return yield* new StorageMutuallyExclusiveFlagsError({ - message: - "--project-ref only applies when targeting the linked project; use it with --linked (not --local)", - }); - } - - const projectRef = flags.local ? "" : yield* resolver.loadProjectRef(flags.projectRef); + const { target, projectRef } = yield* resolveStorageTarget(flags); if (projectRef !== "") { yield* Ref.set(linkedRef, Option.some(projectRef)); } @@ -107,6 +98,13 @@ export const storageCp = Effect.fn("storage.cp")(function* (flags: StorageCpFlag const srcIsStorage = srcUrl.scheme === STORAGE_SCHEME; const dstIsStorage = dstUrl.scheme === STORAGE_SCHEME; + if (srcUrl.scheme === "" && dstIsStorage) { + yield* requireTargetMutationConsent(target, { + action: `Upload ${flags.src} to ${flags.dst}`, + details: flags.recursive ? ["Existing objects under the destination are overwritten."] : [], + }); + } + const summary: CpSummary = { uploaded: [], downloaded: [] }; yield* connectStorageGateway( diff --git a/apps/cli/src/commands/storage/ls/ls.command.ts b/apps/cli/src/commands/storage/ls/ls.command.ts index 4036b677a5..0168378fb5 100644 --- a/apps/cli/src/commands/storage/ls/ls.command.ts +++ b/apps/cli/src/commands/storage/ls/ls.command.ts @@ -4,17 +4,11 @@ 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 { - StorageLinkedFlagDef, - StorageLocalFlagDef, - StorageProjectRefFlagDef, - assertStorageTargetsExclusive, -} from "../storage.flags.ts"; +import { storageTargetFlags } from "../storage.flags.ts"; import { storageLs } from "./ls.handler.ts"; import { withTargetPolicy } from "../../../command-internal/target/target-policy.ts"; @@ -28,9 +22,7 @@ const config = { Flag.withDescription("Recursively list a directory."), Flag.withDefault(false), ), - linked: StorageLinkedFlagDef, - local: StorageLocalFlagDef, - projectRef: StorageProjectRefFlagDef, + ...storageTargetFlags, } as const; export type StorageLsFlags = CliCommand.Command.Config.Infer; @@ -46,10 +38,7 @@ export const storageLsCommand = Command.make("ls", config).pipe( ]), Command.withHandler((flags) => Effect.gen(function* () { - // Gate before the mutex check below; see requireExperimental's doc comment for why. yield* requireExperimental; - const cliArgs = yield* CliArgs; - yield* assertStorageTargetsExclusive(cliArgs.args); const telemetryFlags = { recursive: flags.recursive, linked: flags.linked, diff --git a/apps/cli/src/commands/storage/ls/ls.handler.ts b/apps/cli/src/commands/storage/ls/ls.handler.ts index 76093562a4..3628e6e4ef 100644 --- a/apps/cli/src/commands/storage/ls/ls.handler.ts +++ b/apps/cli/src/commands/storage/ls/ls.handler.ts @@ -1,7 +1,6 @@ import { Effect, Option, Ref } from "effect"; import { CommandSettings } from "../../../config/command-settings.service.ts"; -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 { Output } from "../../../shared/output/output.service.ts"; @@ -11,9 +10,9 @@ import { connectStorageGateway, loadStorageConfig, parseStorageUrlEffect, + resolveStorageTarget, } from "../storage.frame.ts"; import type { StorageLsFlags } from "./ls.command.ts"; -import { StorageMutuallyExclusiveFlagsError } from "../storage.errors.ts"; /** * `supabase storage ls [path]` — list objects by path prefix. @@ -27,24 +26,13 @@ export const storageLs = Effect.fn("storage.ls")(function* (flags: StorageLsFlag const cliSettings = yield* CommandSettings; const telemetryState = yield* TelemetryState; const linkedProjectCache = yield* LinkedProjectCache; - const resolver = yield* ProjectRefResolver; const linkedRef = yield* Ref.make(Option.none()); yield* Effect.gen(function* () { yield* assertStorageWorkdir(cliSettings.workdir); - // `--project-ref` only applies to the linked project; it never implies `--linked`. - if (Option.isSome(flags.projectRef) && flags.local) { - return yield* new StorageMutuallyExclusiveFlagsError({ - message: - "--project-ref only applies when targeting the linked project; use it with --linked (not --local)", - }); - } - - // `--local` clears the ref; otherwise the linked path resolves it. No network access yet, - // safe before the URL parse below. - const projectRef = flags.local ? "" : yield* resolver.loadProjectRef(flags.projectRef); + const { projectRef } = yield* resolveStorageTarget(flags); if (projectRef !== "") { yield* Ref.set(linkedRef, Option.some(projectRef)); } diff --git a/apps/cli/src/commands/storage/mv/mv.command.ts b/apps/cli/src/commands/storage/mv/mv.command.ts index 20a074edce..71be210739 100644 --- a/apps/cli/src/commands/storage/mv/mv.command.ts +++ b/apps/cli/src/commands/storage/mv/mv.command.ts @@ -4,17 +4,11 @@ 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 { - StorageLinkedFlagDef, - StorageLocalFlagDef, - StorageProjectRefFlagDef, - assertStorageTargetsExclusive, -} from "../storage.flags.ts"; +import { storageTargetFlags } from "../storage.flags.ts"; import { storageMv } from "./mv.handler.ts"; import { withTargetPolicy } from "../../../command-internal/target/target-policy.ts"; @@ -26,9 +20,7 @@ const config = { Flag.withDescription("Recursively move a directory."), Flag.withDefault(false), ), - linked: StorageLinkedFlagDef, - local: StorageLocalFlagDef, - projectRef: StorageProjectRefFlagDef, + ...storageTargetFlags, } as const; export type StorageMvFlags = CliCommand.Command.Config.Infer; @@ -44,10 +36,7 @@ export const storageMvCommand = Command.make("mv", config).pipe( ]), Command.withHandler((flags) => Effect.gen(function* () { - // Gate before the mutex check below; see requireExperimental's doc comment for why. yield* requireExperimental; - const cliArgs = yield* CliArgs; - yield* assertStorageTargetsExclusive(cliArgs.args); const telemetryFlags = { recursive: flags.recursive, linked: flags.linked, diff --git a/apps/cli/src/commands/storage/mv/mv.handler.ts b/apps/cli/src/commands/storage/mv/mv.handler.ts index 65b5e7d982..02c66a323c 100644 --- a/apps/cli/src/commands/storage/mv/mv.handler.ts +++ b/apps/cli/src/commands/storage/mv/mv.handler.ts @@ -2,10 +2,10 @@ import { BunPath } from "@effect/platform-bun"; import { Effect, Option, Path, Ref } from "effect"; import { CommandSettings } from "../../../config/command-settings.service.ts"; -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 { Output } from "../../../shared/output/output.service.ts"; +import { requireTargetMutationConsent } from "../../../command-internal/hosted-consent/hosted-consent.ts"; import type { StorageGateway } from "../../../command-internal/storage-gateway.ts"; import { splitBucketPrefix } from "../../../command-internal/storage-url.ts"; import { @@ -13,10 +13,10 @@ import { connectStorageGateway, loadStorageConfig, parseStorageUrlEffect, + resolveStorageTarget, } from "../storage.frame.ts"; import { StorageMissingPathError, - StorageMutuallyExclusiveFlagsError, StorageObjectNotFoundError, StorageUnsupportedMoveError, } from "../storage.errors.ts"; @@ -34,7 +34,6 @@ export const storageMv = Effect.fn("storage.mv")(function* (flags: StorageMvFlag const cliSettings = yield* CommandSettings; const telemetryState = yield* TelemetryState; const linkedProjectCache = yield* LinkedProjectCache; - const resolver = yield* ProjectRefResolver; const posixPath = yield* Effect.provide(Path.Path, BunPath.layerPosix); const linkedRef = yield* Ref.make(Option.none()); @@ -42,15 +41,7 @@ export const storageMv = Effect.fn("storage.mv")(function* (flags: StorageMvFlag yield* Effect.gen(function* () { yield* assertStorageWorkdir(cliSettings.workdir); - // `--project-ref` only applies to the linked project; it never implies `--linked`. - if (Option.isSome(flags.projectRef) && flags.local) { - return yield* new StorageMutuallyExclusiveFlagsError({ - message: - "--project-ref only applies when targeting the linked project; use it with --linked (not --local)", - }); - } - - const projectRef = flags.local ? "" : yield* resolver.loadProjectRef(flags.projectRef); + const { target, projectRef } = yield* resolveStorageTarget(flags); if (projectRef !== "") { yield* Ref.set(linkedRef, Option.some(projectRef)); } @@ -72,6 +63,11 @@ export const storageMv = Effect.fn("storage.mv")(function* (flags: StorageMvFlag return yield* new StorageUnsupportedMoveError(); } + yield* requireTargetMutationConsent(target, { + action: `Move ${srcParsed} to ${dstParsed}`, + details: flags.recursive ? ["Every object under the source path is moved."] : [], + }); + yield* connectStorageGateway( { projectRef, config: loaded.config, userAgent: cliSettings.userAgent }, (gateway) => diff --git a/apps/cli/src/commands/storage/rm/rm.command.ts b/apps/cli/src/commands/storage/rm/rm.command.ts index d6b16637a5..840ab0f724 100644 --- a/apps/cli/src/commands/storage/rm/rm.command.ts +++ b/apps/cli/src/commands/storage/rm/rm.command.ts @@ -3,18 +3,12 @@ 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"; import { stdinLayer } from "../../../shared/runtime/stdin.layer.ts"; import { requireExperimental } from "../../../command-internal/experimental-gate.ts"; import { storageGatewayRuntimeLayer } from "../../../command-internal/storage-runtime.layer.ts"; -import { - StorageLinkedFlagDef, - StorageLocalFlagDef, - StorageProjectRefFlagDef, - assertStorageTargetsExclusive, -} from "../storage.flags.ts"; +import { storageTargetFlags } from "../storage.flags.ts"; import { storageRm } from "./rm.handler.ts"; import { withTargetPolicy } from "../../../command-internal/target/target-policy.ts"; @@ -28,9 +22,7 @@ const config = { Flag.withDescription("Recursively remove a directory."), Flag.withDefault(false), ), - linked: StorageLinkedFlagDef, - local: StorageLocalFlagDef, - projectRef: StorageProjectRefFlagDef, + ...storageTargetFlags, } as const; export const storageRmCommand = Command.make("rm", config).pipe( @@ -48,10 +40,7 @@ export const storageRmCommand = Command.make("rm", config).pipe( ]), Command.withHandler((flags) => Effect.gen(function* () { - // Gate before the mutex check below; see requireExperimental's doc comment for why. yield* requireExperimental; - const cliArgs = yield* CliArgs; - yield* assertStorageTargetsExclusive(cliArgs.args); const telemetryFlags = { recursive: flags.recursive, linked: flags.linked, diff --git a/apps/cli/src/commands/storage/rm/rm.handler.ts b/apps/cli/src/commands/storage/rm/rm.handler.ts index f825dea7d4..5e9435525f 100644 --- a/apps/cli/src/commands/storage/rm/rm.handler.ts +++ b/apps/cli/src/commands/storage/rm/rm.handler.ts @@ -1,13 +1,14 @@ import { Effect, FileSystem, Option, Path, Ref } from "effect"; import { CommandSettings } from "../../../config/command-settings.service.ts"; -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 { resolveYesWithProjectEnv } from "../../../command-internal/global-flags.ts"; import { Output } from "../../../shared/output/output.service.ts"; import { bold } from "../../../command-internal/colors.ts"; import { loadProjectEnvValues } from "../../../command-internal/db-config.toml-read.ts"; +import { requireTargetMutationConsent } from "../../../command-internal/hosted-consent/hosted-consent.ts"; +import type { HostedMutationConsent } from "../../../command-internal/hosted-consent/hosted-consent.token.ts"; import { promptYesNo } from "../../../command-internal/prompt-yes-no.ts"; import { DELETE_OBJECTS_LIMIT, @@ -19,11 +20,11 @@ import { connectStorageGateway, loadStorageConfig, parseStorageUrlEffect, + resolveStorageTarget, } from "../storage.frame.ts"; import { StorageMissingBucketError, StorageMissingFlagError, - StorageMutuallyExclusiveFlagsError, StorageObjectNotFoundError, StorageRmConfirmationRequiredError, } from "../storage.errors.ts"; @@ -32,10 +33,8 @@ import { listStoragePaths } from "../storage.iterate.ts"; export interface StorageRmFlags { readonly files: ReadonlyArray; readonly recursive: boolean; - // Routing reads only `local`; `linked` is unused. readonly linked: boolean; readonly local: boolean; - // Overrides the linked project's ref; only valid when targeting --linked, not --local. readonly projectRef: Option.Option; } @@ -55,7 +54,6 @@ export const storageRm = Effect.fn("storage.rm")(function* (flags: StorageRmFlag const cliSettings = yield* CommandSettings; const telemetryState = yield* TelemetryState; const linkedProjectCache = yield* LinkedProjectCache; - const resolver = yield* ProjectRefResolver; const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -64,23 +62,19 @@ export const storageRm = Effect.fn("storage.rm")(function* (flags: StorageRmFlag yield* Effect.gen(function* () { yield* assertStorageWorkdir(cliSettings.workdir); - // Resolving the project ref before loading the project `.env` ensures an unlinked - // workdir fails with the not-linked message instead of an env-parse error. - if (Option.isSome(flags.projectRef) && flags.local) { - return yield* new StorageMutuallyExclusiveFlagsError({ - message: - "--project-ref only applies when targeting the linked project; use it with --linked (not --local)", - }); - } - - const projectRef = flags.local ? "" : yield* resolver.loadProjectRef(flags.projectRef); + const { target, projectRef } = yield* resolveStorageTarget(flags); if (projectRef !== "") { yield* Ref.set(linkedRef, Option.some(projectRef)); } - // `.env` loads before the confirmation prompt, so a `SUPABASE_YES` set only in - // `supabase/.env` also auto-confirms. - const projectEnv = yield* loadProjectEnvValues(fs, path, cliSettings.workdir); - const yes = yield* resolveYesWithProjectEnv(projectEnv); + // A local delete keeps its per-bucket prompt, which honours a project `.env` `SUPABASE_YES`; + // a hosted delete asks for consent instead. Loading it after the target resolves makes an + // unlinked workdir fail with the not-linked message, not an env-parse error. + const localYes = + target.kind === "local" + ? yield* resolveYesWithProjectEnv( + yield* loadProjectEnvValues(fs, path, cliSettings.workdir), + ) + : false; const loaded = yield* loadStorageConfig(cliSettings, projectRef); if (loaded.appliedRemote !== undefined) { yield* output.raw(`Loading config override: [remotes.${loaded.appliedRemote}]\n`, "stderr"); @@ -103,7 +97,7 @@ export const storageRm = Effect.fn("storage.rm")(function* (flags: StorageRmFlag // No paths and no `-r` falls through to the missing-`-r` failure below. const missingRecursive = groups.size === 0 && !flags.recursive; - if (!yes && !missingRecursive && output.format !== "text") { + if (target.kind === "local" && !localYes && !missingRecursive && output.format !== "text") { return yield* new StorageRmConfirmationRequiredError(); } @@ -122,13 +116,26 @@ export const storageRm = Effect.fn("storage.rm")(function* (flags: StorageRmFlag for (const b of buckets) groups.set(b.name, [""]); } + const consent = + groups.size === 0 + ? Option.none() + : yield* requireTargetMutationConsent(target, { + action: `Delete objects in ${groups.size} bucket${groups.size === 1 ? "" : "s"}`, + details: [...groups].map( + ([bucket, prefixes]) => + `Delete from ${bucket}: ${prefixes.map(describePrefix).join(", ")}`, + ), + }); + for (const [bucket, prefixes] of groups) { - const shouldDelete = yield* promptYesNo( - output, - yes, - `Confirm deleting files in bucket ${bold(bucket)}?`, - false, - ); + const shouldDelete = Option.isSome(consent) + ? true + : yield* promptYesNo( + output, + localYes, + `Confirm deleting files in bucket ${bold(bucket)}?`, + false, + ); if (!shouldDelete) continue; // Always try deleting first in case the paths are extensionless files. @@ -176,6 +183,9 @@ export const storageRm = Effect.fn("storage.rm")(function* (flags: StorageRmFlag ); }); +const describePrefix = (prefix: string): string => + prefix === "" ? "every object and the bucket itself" : prefix; + /** Deletes objects in chunks of DELETE_OBJECTS_LIMIT. */ const deleteObjects = ( gateway: StorageGateway, diff --git a/apps/cli/src/commands/storage/storage.flags.ts b/apps/cli/src/commands/storage/storage.flags.ts index 785377cf77..48d2822da9 100644 --- a/apps/cli/src/commands/storage/storage.flags.ts +++ b/apps/cli/src/commands/storage/storage.flags.ts @@ -1,45 +1,7 @@ -import { Effect } from "effect"; -import { Flag } from "effect/cli"; +import { targetFlags } from "../../command-internal/target/target-flags.ts"; -import { changedLinkedLocalFlags } from "../../command-internal/db-target-flags.ts"; -import { StorageMutuallyExclusiveFlagsError } from "./storage.errors.ts"; - -/** - * `--local`'s value decides local vs linked: true clears the project ref, false - * resolves it via the linked project. Declared per-leaf rather than as a - * `storage`-scoped global because Effect CLI requires unique global-flag names - * tree-wide and `seed` already owns `linked`/`local`, so these must follow the - * subcommand token (`storage ls --local`, not `storage --local ls`). - */ -export const StorageLinkedFlagDef = Flag.Boolean("linked").pipe( - Flag.withDescription("Connects to Storage API of the linked project."), - Flag.withDefault(true), -); - -export const StorageLocalFlagDef = Flag.Boolean("local").pipe( - Flag.withDescription("Connects to Storage API of the local database."), - Flag.withDefault(false), -); - -// Overrides the project ref for the linked target; declared once since all four -// storage leaves share the identical flag. -export const StorageProjectRefFlagDef = Flag.String("project-ref").pipe( - Flag.withDescription("Project ref of the Supabase project."), - Flag.optional, -); - -/** - * Mutual-exclusion check for `--linked`/`--local`, rejected before the handler body - * so it never fires `cli_command_executed`. Each leaf must call this before - * `withCommandTelemetry`. - */ -export const assertStorageTargetsExclusive = Effect.fnUntraced(function* ( - args: ReadonlyArray, -) { - const setFlags = changedLinkedLocalFlags(args); - if (setFlags.length > 1) { - return yield* new StorageMutuallyExclusiveFlagsError({ - message: `if any flags in the group [linked local] are set none of the others can be; [${setFlags.join(" ")}] were all set`, - }); - } +export const storageTargetFlags = targetFlags({ + group: "selectable", + subject: "Storage API", + dbUrl: false, }); diff --git a/apps/cli/src/commands/storage/storage.flags.unit.test.ts b/apps/cli/src/commands/storage/storage.flags.unit.test.ts deleted file mode 100644 index 36c11b0907..0000000000 --- a/apps/cli/src/commands/storage/storage.flags.unit.test.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { describe, expect, it } from "@effect/vitest"; -import { Cause, Effect, Exit } from "effect"; - -import { assertStorageTargetsExclusive } from "./storage.flags.ts"; - -describe("assertStorageTargetsExclusive", () => { - it.effect("rejects passing both --linked and --local (byte-exact message)", () => - Effect.gen(function* () { - const exit = yield* assertStorageTargetsExclusive([ - "storage", - "--linked", - "--local", - "ls", - ]).pipe(Effect.exit); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - expect(Cause.pretty(exit.cause)).toContain( - "if any flags in the group [linked local] are set none of the others can be; [linked local] were all set", - ); - } - }), - ); - - it.effect("accepts only --local", () => - Effect.gen(function* () { - const exit = yield* assertStorageTargetsExclusive(["storage", "--local", "ls"]).pipe( - Effect.exit, - ); - expect(Exit.isSuccess(exit)).toBe(true); - }), - ); - - it.effect("accepts neither flag", () => - Effect.gen(function* () { - const exit = yield* assertStorageTargetsExclusive(["storage", "ls"]).pipe(Effect.exit); - expect(Exit.isSuccess(exit)).toBe(true); - }), - ); -}); diff --git a/apps/cli/src/commands/storage/storage.frame.ts b/apps/cli/src/commands/storage/storage.frame.ts index 6e1fb2b823..4c442fa99c 100644 --- a/apps/cli/src/commands/storage/storage.frame.ts +++ b/apps/cli/src/commands/storage/storage.frame.ts @@ -10,7 +10,12 @@ import { withStorageGatewayFetch, } from "../../command-internal/storage-credentials.ts"; import { withStackStorageGuidance } from "../../command-internal/stack-storage.ts"; -import { makeStorageGateway, type StorageGateway } from "../../command-internal/storage-gateway.ts"; +import { + announceLocalStorageTarget, + makeStorageGateway, + type StorageGateway, +} from "../../command-internal/storage-gateway.ts"; +import { resolveTarget } from "../../command-internal/target/resolve-target.ts"; import { UrlParseError, StorageUrlPatternError, @@ -26,10 +31,22 @@ import { StorageWorkdirError, } from "./storage.errors.ts"; -/** - * Shared plumbing for the four `storage` subcommands: resolving the project ref - * (`--local`'s value decides local vs linked) and building the gateway client. - */ +interface StorageTargetFlags { + readonly local: boolean; + readonly linked: boolean; + readonly projectRef: Option.Option; +} + +/** The resolved target and its project ref, empty for the local stack. */ +export const resolveStorageTarget = Effect.fnUntraced(function* (flags: StorageTargetFlags) { + const target = yield* resolveTarget({ + local: flags.local, + linked: flags.linked, + dbUrl: Option.none(), + projectRef: flags.projectRef, + }); + return { target, projectRef: target.kind === "linked" ? target.ref : "" }; +}); interface LoadedStorageConfig { readonly config: CliConfig; @@ -102,6 +119,7 @@ export const connectStorageGateway = ( ) => Effect.gen(function* () { const credentials = yield* resolveStorageCredentials({ projectRef: opts.projectRef }); + if (opts.projectRef === "") yield* announceLocalStorageTarget(credentials.baseUrl); const gatewayOps = Effect.gen(function* () { const gateway = yield* makeStorageGateway({ baseUrl: credentials.baseUrl, From c32f002a3702e12273527eea447d2022d3f5d9d0 Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Thu, 15 Oct 2026 14:22:45 +0100 Subject: [PATCH 22/92] refactor(cli): resolve db command targets through resolveTarget --- .../test-db.command-handler.ts | 23 +-- .../src/command-internal/test-db.handler.ts | 63 +++---- .../src/command-internal/test-db.layers.ts | 21 +++ .../commands/db/advisors/advisors.command.ts | 21 +-- .../commands/db/advisors/advisors.handler.ts | 81 ++++----- apps/cli/src/commands/db/diff/diff.command.ts | 24 +-- apps/cli/src/commands/db/diff/diff.handler.ts | 138 ++++++--------- apps/cli/src/commands/db/dump/dump.command.ts | 26 +-- apps/cli/src/commands/db/dump/dump.handler.ts | 72 +++----- apps/cli/src/commands/db/lint/lint.command.ts | 21 +-- apps/cli/src/commands/db/lint/lint.handler.ts | 59 ++----- .../src/commands/db/query/query.command.ts | 34 ++-- .../src/commands/db/query/query.handler.ts | 152 +++++++++-------- .../src/commands/db/reset/reset.command.ts | 20 +-- .../src/commands/db/reset/reset.handler.ts | 160 ++++++------------ .../src/commands/db/shared/pooler-fallback.ts | 8 +- 16 files changed, 329 insertions(+), 594 deletions(-) diff --git a/apps/cli/src/command-internal/test-db.command-handler.ts b/apps/cli/src/command-internal/test-db.command-handler.ts index f923bd1132..765d12b249 100644 --- a/apps/cli/src/command-internal/test-db.command-handler.ts +++ b/apps/cli/src/command-internal/test-db.command-handler.ts @@ -1,5 +1,5 @@ import { Effect, Option } from "effect"; -import { Argument, Flag } from "effect/cli"; +import { Argument } from "effect/cli"; import type * as CliCommand from "effect/cli/Command"; import { withJsonErrorHandling } from "../shared/output/json-error-handling.ts"; @@ -7,6 +7,7 @@ import { Output } from "../shared/output/output.service.ts"; import { ProcessControl } from "../shared/runtime/process-control.service.ts"; import { withCommandTelemetry } from "../telemetry/command-telemetry.ts"; import type { TestDbNoTestsError, TestDbRunError } from "./test-db.errors.ts"; +import { targetFlags } from "./target/target-flags.ts"; import { testDb } from "./test-db.handler.ts"; /** Shared verbatim by both `test db` and its `db test` alias. */ @@ -42,25 +43,7 @@ export const testDbConfig = { Argument.withDescription("Paths to test files or directories."), Argument.variadic(), ), - dbUrl: Flag.String("db-url").pipe( - Flag.withDescription( - "Tests the database specified by the connection string (must be percent-encoded).", - ), - Flag.optional, - ), - linked: Flag.Boolean("linked").pipe( - Flag.withDescription("Runs pgTAP tests on the linked project."), - Flag.withDefault(false), - ), - local: Flag.Boolean("local").pipe( - Flag.withDescription("Runs pgTAP tests on the local database."), - Flag.withDefault(false), - ), - // 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."), - Flag.optional, - ), + ...targetFlags({ group: "selectable", subject: "database", dbUrl: true }), } as const; export interface TestDbFlags { diff --git a/apps/cli/src/command-internal/test-db.handler.ts b/apps/cli/src/command-internal/test-db.handler.ts index 557eda1c63..e4506afc29 100644 --- a/apps/cli/src/command-internal/test-db.handler.ts +++ b/apps/cli/src/command-internal/test-db.handler.ts @@ -3,7 +3,6 @@ import { Effect, FileSystem, Option, Path } from "effect"; import { type DatabaseInstance, type Stack } from "@supabase/stack/effect"; import { postgres } from "@supabase/stack/commands"; -import { CliArgs } from "../shared/cli/cli-args.service.ts"; import { CommandSettings } from "../config/command-settings.service.ts"; import { TelemetryState } from "../telemetry/telemetry-state.service.ts"; import { DbConfigResolver } from "./db-config.service.ts"; @@ -11,14 +10,14 @@ import { readDbToml } from "./db-config.toml-read.ts"; import { DbConnection, type PgConnInput } from "./db-connection.service.ts"; import { LocalDbRunningError } from "./db-bootstrap/local-db-running.ts"; import { DockerRun } from "./docker-run.service.ts"; -import { resolveDbTargetFlags } from "./db-target-flags.ts"; +import { requireTargetMutationConsent, hostedMutationPreapproval } from "./hosted-consent/hosted-consent.ts"; +import { resolveTarget } from "./target/resolve-target.ts"; import { DebugFlag, DnsResolverFlag, NetworkIdFlag } from "./global-flags.ts"; import { Output } from "../shared/output/output.service.ts"; import { RuntimeInfo } from "../shared/runtime/runtime-info.service.ts"; import type { TestDbFlags } from "./test-db.command-handler.ts"; import { TestDbEnablePgtapError, - TestDbMutuallyExclusiveFlagsError, TestDbNoTestsError, TestDbRunError, } from "./test-db.errors.ts"; @@ -117,40 +116,22 @@ export const testDb = Effect.fn("test.db")(function* (flags: TestDbFlags) { const debug = yield* DebugFlag; const networkIdFlag = yield* NetworkIdFlag; const dnsResolver = yield* DnsResolverFlag; - const cliArgs = yield* CliArgs; yield* Effect.gen(function* () { - // Selection is keyed off flag presence, not its boolean value: `--linked=false` - // and `--no-linked` both count as explicitly setting `linked`. - const target = resolveDbTargetFlags(cliArgs.args); - const { setFlags } = target; - if (setFlags.length > 1) { - return yield* Effect.fail( - new TestDbMutuallyExclusiveFlagsError({ - message: `if any flags in the group [db-url linked local] are set none of the others can be; [${setFlags.join(" ")}] were all set`, - }), - ); - } - - const connType = target.connType ?? "local"; - - // `--project-ref` never implies `--linked`; see push.handler.ts's - // identical guard (db push) for the rationale. - if (Option.isSome(flags.projectRef) && connType !== "linked") { - return yield* Effect.fail( - new TestDbMutuallyExclusiveFlagsError({ - message: - "--project-ref only applies when targeting the linked project; use it with --linked (not --local or --db-url)", - }), - ); - } - - const { conn, isLocal } = yield* resolver.resolve({ + const target = yield* resolveTarget({ + local: flags.local, + linked: flags.linked, dbUrl: flags.dbUrl, - connType, + projectRef: flags.projectRef, + }); + + const resolved = yield* resolver.resolveConnection({ + target, dnsResolver, - linkedProjectRef: flags.projectRef, + password: Option.none(), + networkBanRemoval: yield* hostedMutationPreapproval, }); + const { conn, isLocal } = resolved; const args = buildPgProveArgs({ paths: flags.paths, @@ -161,7 +142,7 @@ export const testDb = Effect.fn("test.db")(function* (flags: TestDbFlags) { const backend = yield* currentStackBackend; const managedStack = - backend.kind === "stack" && connType === "local" ? yield* managedStackFor() : undefined; + backend.kind === "stack" && target.kind === "local" ? yield* managedStackFor() : undefined; const proveRuntime = backend.kind === "stack" && managedStack === undefined ? yield* resolveBundledPostgresRuntime(undefined, runtimeInfo.platform, runtimeInfo.arch) @@ -177,7 +158,7 @@ export const testDb = Effect.fn("test.db")(function* (flags: TestDbFlags) { managedStack !== undefined ? proveConnection.host : useNativeProve - ? connType === "local" + ? target.kind === "local" ? "127.0.0.1" : conn.host : stackPublishedProve @@ -217,13 +198,17 @@ export const testDb = Effect.fn("test.db")(function* (flags: TestDbFlags) { let lastVerdict = ""; let lastSummary = ""; + yield* requireTargetMutationConsent(resolved, { + action: "Run pgTAP tests", + details: [ + "enable the pgtap extension", + `run pg_prove against ${args.hostPaths.join(", ")}`, + "drop the pgtap extension afterwards unless it was already installed", + ], + }); + const { exitCode } = yield* Effect.scoped( Effect.gen(function* () { - // stdout is reserved for the pg_prove TAP stream (forwarded byte-exact - // below), so connection diagnostics go to stderr. An `Output.task` - // spinner would also corrupt the stream: it writes ANSI to stdout in - // text mode, and JSON log events to stdout in stream-json mode. - yield* output.raw(`Connecting to ${isLocal ? "local" : "remote"} database...\n`, "stderr"); const session = yield* dbConn.connect(conn, { isLocal, dnsResolver }); // Detect pre-existence before enabling so the drop is skipped when pgTAP diff --git a/apps/cli/src/command-internal/test-db.layers.ts b/apps/cli/src/command-internal/test-db.layers.ts index 2ba9df6c0f..90f826b13e 100644 --- a/apps/cli/src/command-internal/test-db.layers.ts +++ b/apps/cli/src/command-internal/test-db.layers.ts @@ -1,6 +1,9 @@ import { Layer } from "effect"; +import { commandCredentialsLayer } from "../auth/command-credentials.layer.ts"; +import { commandPlatformApiFactoryLayer } from "../auth/command-platform-api-factory.layer.ts"; import { commandSettingsLayer } from "../config/command-settings.layer.ts"; +import { projectRefLayer } from "../config/project-ref.layer.ts"; import { dbConfigLayer } from "./db-config.layer.ts"; import { dbConnectionLayer } from "./db-connection.sql-pg.layer.ts"; import { dockerRunLayer } from "./docker-run.layer.ts"; @@ -27,6 +30,23 @@ import { bundledPostgresClientLayer } from "./bundled-postgres-client.ts"; */ const cliSettings = commandSettingsLayer.pipe(Layer.provide(debugLoggerLayer)); +const credentials = commandCredentialsLayer.pipe( + Layer.provide(cliSettings), + Layer.provide(debugLoggerLayer), +); + +const platformApiFactory = commandPlatformApiFactoryLayer.pipe( + Layer.provide(credentials), + Layer.provide(cliSettings), + Layer.provide(debugLoggerLayer), + Layer.provide(identityStitchLayer), +); + +const projectRef = projectRefLayer.pipe( + Layer.provide(platformApiFactory), + Layer.provide(cliSettings), +); + const dbConfig = dbConfigLayer.pipe( Layer.provide(cliSettings), Layer.provide(dbConnectionLayer), @@ -41,6 +61,7 @@ export const testDbRuntimeLayer = (commandPath: ReadonlyArray) => dbConnectionLayer, dockerRunLayer, cliSettings, + projectRef, // Exposed at top level so `withCommandTelemetry` can read // `stitchedDistinctId()`. The same reference is provided to `dbConfig` // above, so the lazy linked stack shares a single stitch attempt. diff --git a/apps/cli/src/commands/db/advisors/advisors.command.ts b/apps/cli/src/commands/db/advisors/advisors.command.ts index 500c31aaf7..6e8a364977 100644 --- a/apps/cli/src/commands/db/advisors/advisors.command.ts +++ b/apps/cli/src/commands/db/advisors/advisors.command.ts @@ -14,28 +14,11 @@ 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"; +import { targetFlags } from "../../../command-internal/target/target-flags.ts"; import { withTargetPolicy } from "../../../command-internal/target/target-policy.ts"; const config = { - dbUrl: Flag.String("db-url").pipe( - Flag.withDescription( - "Checks the database specified by the connection string (must be percent-encoded).", - ), - Flag.optional, - ), - linked: Flag.Boolean("linked").pipe( - Flag.withDescription("Checks the linked project for issues."), - Flag.withDefault(false), - ), - local: Flag.Boolean("local").pipe( - Flag.withDescription("Checks the local database for issues."), - Flag.withDefault(false), - ), - // Overrides the linked project ref; the same flag exists on `config push`. - projectRef: Flag.String("project-ref").pipe( - Flag.withDescription("Project ref of the Supabase project."), - Flag.optional, - ), + ...targetFlags({ group: "selectable", subject: "database", dbUrl: true }), type: Flag.Literals("type", ["all", "security", "performance"] as const).pipe( Flag.withDescription("Type of advisors to check: all, security, performance."), Flag.optional, diff --git a/apps/cli/src/commands/db/advisors/advisors.handler.ts b/apps/cli/src/commands/db/advisors/advisors.handler.ts index 750938f8d5..5b9b3d70ad 100644 --- a/apps/cli/src/commands/db/advisors/advisors.handler.ts +++ b/apps/cli/src/commands/db/advisors/advisors.handler.ts @@ -1,11 +1,14 @@ import { Effect, Option } from "effect"; -import { CliArgs } from "../../../shared/cli/cli-args.service.ts"; import { DnsResolverFlag } from "../../../command-internal/global-flags.ts"; +import { hostedMutationPreapproval } from "../../../command-internal/hosted-consent/hosted-consent.ts"; +import { + resolveTarget, + type ResolvedTarget, +} from "../../../command-internal/target/resolve-target.ts"; import { Output } from "../../../shared/output/output.service.ts"; import { ProcessControl } from "../../../shared/runtime/process-control.service.ts"; import { CommandCredentials } from "../../../auth/command-credentials.service.ts"; -import { ProjectRefResolver } from "../../../config/project-ref.service.ts"; import { aqua } from "../../../command-internal/colors.ts"; import { missingAccessTokenMessage } from "../../../auth/access-token.ts"; import { failsOn } from "../../../command-internal/fail-on.ts"; @@ -15,14 +18,11 @@ import { DbConnection } from "../../../command-internal/db-connection.service.ts import type { DbSession } from "../../../command-internal/db-connection.service.ts"; import { LinkedProjectCache } from "../../../telemetry/linked-project-cache.service.ts"; import { TelemetryState } from "../../../telemetry/telemetry-state.service.ts"; -import { resolveDbTargetFlags } from "../../../command-internal/db-target-flags.ts"; -import type { DbTargetSelection } from "../../../command-internal/db-target-flags.ts"; import type { DbAdvisorsFlags } from "./advisors.command.ts"; import { DbAdvisorsBeginTxError, DbAdvisorsFailOnError, DbAdvisorsInvalidTokenError, - DbAdvisorsMutuallyExclusiveFlagsError, DbAdvisorsNotLoggedInError, DbAdvisorsQueryError, DbAdvisorsSetupError, @@ -64,28 +64,24 @@ const queryLints = Effect.fn("DbAdvisors.queryLints")(function* (session: DbSess /** Gathers lints from a local (or `--db-url`) database connection. */ const runLocal = Effect.fn("db.advisors.runLocal")(function* ( - flags: DbAdvisorsFlags, + target: Exclude, dnsResolver: "native" | "https", advisorType: string, level: string, - target: DbTargetSelection, ) { const output = yield* Output; const resolver = yield* DbConfigResolver; const dbConn = yield* DbConnection; - const cfg = yield* resolver.resolve({ - dbUrl: flags.dbUrl, - connType: target.connType === "db-url" ? "db-url" : "local", + const cfg = yield* resolver.resolveConnection({ + target, dnsResolver, + password: Option.none(), + networkBanRemoval: yield* hostedMutationPreapproval, }); const lints = yield* Effect.scoped( Effect.gen(function* () { - yield* output.raw( - `Connecting to ${cfg.isLocal ? "local" : "remote"} database...\n`, - "stderr", - ); const session = yield* dbConn.connect(cfg.conn, { isLocal: cfg.isLocal, dnsResolver }); yield* session.exec("begin").pipe( Effect.mapError( @@ -110,33 +106,31 @@ const runLocal = Effect.fn("db.advisors.runLocal")(function* ( /** Gathers lints from the Management API for the linked project. */ const runLinked = Effect.fn("db.advisors.runLinked")(function* ( - flags: DbAdvisorsFlags, + target: Extract, dnsResolver: "native" | "https", advisorType: string, level: string, ) { const resolver = yield* DbConfigResolver; const credentials = yield* CommandCredentials; - const projectRefResolver = yield* ProjectRefResolver; const linkedProjectCache = yield* LinkedProjectCache; // Every Management API response is wrapped in identity stitching, including the raw-HTTP // advisor GETs; one stitcher is shared across both calls so it fires at most once per session. const { stitch } = yield* IdentityStitch; - // The linked-project cache is written whenever the project ref was resolved, even when the - // DB-config resolve below fails. Load the ref first (non-prompting, honoring an explicit - // `--project-ref`) and wrap everything after it in the cache finalizer. - const ref = yield* projectRefResolver.loadProjectRef(flags.projectRef); + const { ref } = target; + // The linked-project cache is written whenever the project ref was resolved, even when the + // connection resolve below fails. return yield* Effect.gen(function* () { // The host probe / login-role mint / pooler / IPv6 fallback. The linked lint-gathering path // ignores the resolved config — this runs purely for the side effects and early-failure // ordering (before the token gate). - yield* resolver.resolve({ - dbUrl: Option.none(), - connType: "linked", + yield* resolver.resolveConnection({ + target, dnsResolver, - linkedProjectRef: flags.projectRef, + password: Option.none(), + networkBanRemoval: yield* hostedMutationPreapproval, }); // The access token is validated against the `sbp_` pattern before calling the API: a @@ -211,42 +205,29 @@ const outputAndCheck = Effect.fnUntraced(function* ( const runAdvisors = Effect.fnUntraced(function* ( flags: DbAdvisorsFlags, dnsResolver: "native" | "https", - target: DbTargetSelection, ) { - // Mutually-exclusive db-url/linked/local group, keyed off the - // explicitly-set flags, not the `--local` default value. - const setFlags = target.setFlags; - if (setFlags.length > 1) { - return yield* new DbAdvisorsMutuallyExclusiveFlagsError({ - message: `if any flags in the group [db-url linked local] are set none of the others can be; [${setFlags.join(" ")}] were all set`, - }); - } - - // `--project-ref` never implies `--linked` and must not be silently discarded on a non-linked - // target (see push.handler.ts's identical guard). Defaults to the local/db-url path otherwise. - if (Option.isSome(flags.projectRef) && target.connType !== "linked") { - return yield* new DbAdvisorsMutuallyExclusiveFlagsError({ - message: - "--project-ref only applies when targeting the linked project; use it with --linked (not --local or --db-url)", - }); - } + const target = yield* resolveTarget({ + local: flags.local, + linked: flags.linked, + dbUrl: flags.dbUrl, + projectRef: flags.projectRef, + }); const advisorType = Option.getOrElse(flags.type, () => "all"); const level = Option.getOrElse(flags.level, () => "warn"); const failOn = Option.getOrElse(flags.failOn, () => "none"); yield* Effect.annotateCurrentSpan({ - "db.conn_type": target.connType ?? "local", + "db.conn_type": target.kind, "advisor.type": advisorType, "advisor.level": level, "advisor.fail_on": failOn, }); - // Branches on whether `--linked` was explicitly set: linked → Management - // API; otherwise local / `--db-url`. + // Linked goes through the Management API; local and `--db-url` read the database directly. const filtered = - target.connType === "linked" - ? yield* runLinked(flags, dnsResolver, advisorType, level) - : yield* runLocal(flags, dnsResolver, advisorType, level, target); + target.kind === "linked" + ? yield* runLinked(target, dnsResolver, advisorType, level) + : yield* runLocal(target, dnsResolver, advisorType, level); yield* Effect.annotateCurrentSpan("advisor.reported_count", filtered.length); yield* outputAndCheck(filtered, failOn); @@ -255,9 +236,7 @@ const runAdvisors = Effect.fnUntraced(function* ( export const dbAdvisors = Effect.fn("db.advisors")(function* (flags: DbAdvisorsFlags) { const dnsResolver = yield* DnsResolverFlag; const telemetryState = yield* TelemetryState; - const cliArgs = yield* CliArgs; - const target = resolveDbTargetFlags(cliArgs.args); // Flush telemetry on success and failure. Command-level instrumentation / // JSON error handling are applied by `advisors.command.ts`. - yield* runAdvisors(flags, dnsResolver, target).pipe(Effect.ensuring(telemetryState.flush)); + yield* runAdvisors(flags, dnsResolver).pipe(Effect.ensuring(telemetryState.flush)); }); diff --git a/apps/cli/src/commands/db/diff/diff.command.ts b/apps/cli/src/commands/db/diff/diff.command.ts index 4c6320df7f..e6ec8face0 100644 --- a/apps/cli/src/commands/db/diff/diff.command.ts +++ b/apps/cli/src/commands/db/diff/diff.command.ts @@ -17,6 +17,7 @@ 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"; +import { targetFlags } from "../../../command-internal/target/target-flags.ts"; import { withTargetPolicy } from "../../../command-internal/target/target-policy.ts"; const config = { @@ -62,28 +63,7 @@ const config = { ), Flag.optional, ), - dbUrl: Flag.String("db-url").pipe( - Flag.withDescription( - "Diffs against the database specified by the connection string (must be percent-encoded).", - ), - Flag.optional, - ), - // The target flags form a mutually-exclusive group; modelled as `Option` so - // the mutex check tracks whether a flag was passed. `--local` defaults to true - // via the target resolver's fall-through. - linked: Flag.Boolean("linked").pipe( - Flag.withDescription("Diffs local migration files against the linked project."), - Flag.optional, - ), - local: Flag.Boolean("local").pipe( - Flag.withDescription("Diffs local migration files against the local database."), - Flag.optional, - ), - // Overrides the linked project ref; the same flag exists on `config push`. - projectRef: Flag.String("project-ref").pipe( - Flag.withDescription("Project ref of the Supabase project."), - Flag.optional, - ), + ...targetFlags({ group: "selectable", subject: "database", dbUrl: true }), file: Flag.String("file").pipe( Flag.withAlias("f"), Flag.withDescription( diff --git a/apps/cli/src/commands/db/diff/diff.handler.ts b/apps/cli/src/commands/db/diff/diff.handler.ts index 046ca7a5a5..111f2f4ede 100644 --- a/apps/cli/src/commands/db/diff/diff.handler.ts +++ b/apps/cli/src/commands/db/diff/diff.handler.ts @@ -11,7 +11,6 @@ import { branchClause, detectGitBranch } from "../../../shared/git/git-branch.ts 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"; @@ -21,7 +20,11 @@ import { type DbTomlValues, } from "../../../command-internal/db-config.toml-read.ts"; import { DbConfigResolver } from "../../../command-internal/db-config.service.ts"; -import type { DbConnType } from "../../../command-internal/db-target-flags.ts"; +import { hostedMutationPreapproval } from "../../../command-internal/hosted-consent/hosted-consent.ts"; +import { + resolveTarget, + type ResolvedTarget, +} from "../../../command-internal/target/resolve-target.ts"; import { getHostname } from "../../../command-internal/hostname.ts"; import { makeDir } from "../../../command-internal/make-dir.ts"; import type { PgConnInput } from "../../../command-internal/db-connection.service.ts"; @@ -79,7 +82,6 @@ import { DbDiffDbNotRunningError, DbDiffEngineConflictError, DbDiffExplicitFlagsError, - DbDiffTargetFlagsError, DbDiffUnknownTargetError, DbDiffWriteError, } from "./diff.errors.ts"; @@ -131,10 +133,9 @@ export const dbDiff = Effect.fn("db.diff")(function* (flags: DbDiffFlags) { const linkedRefForCache = yield* Ref.make(Option.none()); yield* Effect.gen(function* () { - // The engine flags (`use-migra use-pgadmin use-pg-delta`) and the target flags - // (`db-url linked local`) are each mutually exclusive groups; "set" means the flag was - // explicitly passed (`Option.isSome`). `use-pg-schema` is rejected unconditionally above, - // so it never reaches `engineSet`. + // The engine flags (`use-migra use-pgadmin use-pg-delta`) are a mutually exclusive group; + // "set" means the flag was explicitly passed (`Option.isSome`). `use-pg-schema` is rejected + // unconditionally above, so it never reaches `engineSet`. const engineSet: Array = []; if (Option.isSome(flags.useMigra)) engineSet.push("use-migra"); if (Option.isSome(flags.usePgAdmin)) engineSet.push("use-pgadmin"); @@ -144,15 +145,6 @@ export const dbDiff = Effect.fn("db.diff")(function* (flags: DbDiffFlags) { message: `if any flags in the group [use-migra use-pgadmin use-pg-delta] are set none of the others can be; [${[...engineSet].sort().join(" ")}] were all set`, }); } - const targetSet: Array = []; - if (Option.isSome(flags.dbUrl)) targetSet.push("db-url"); - if (Option.isSome(flags.linked)) targetSet.push("linked"); - if (Option.isSome(flags.local)) targetSet.push("local"); - if (targetSet.length > 1) { - return yield* new DbDiffTargetFlagsError({ - message: `if any flags in the group [db-url linked local] are set none of the others can be; [${[...targetSet].sort().join(" ")}] were all set`, - }); - } // `--use-migra=false` / `--use-pgadmin=false` keep the pg-delta default, which the stack // backend supports, so only a true value is rejected there. const useMigra = Option.getOrElse(flags.useMigra, () => false); @@ -178,22 +170,6 @@ export const dbDiff = Effect.fn("db.diff")(function* (flags: DbDiffFlags) { message: "must set both --from and --to when using explicit diff mode", }); } - // `--project-ref` never implies `--linked` and must not be silently discarded (see - // push.handler.ts's identical guard). Two exceptions in explicit mode: `--from`/`--to - // linked` resolves a linked ref without any `--linked`/target flag, so the guard must not - // fire when either side is the literal ref "linked"; and a changed `--linked` (even - // `--linked=false`) genuinely consumes `--project-ref` via the preflight below. - if ( - Option.isSome(flags.projectRef) && - Option.isNone(flags.linked) && - classifyExplicitRef(from) !== "linked" && - classifyExplicitRef(to) !== "linked" - ) { - return yield* new DbDiffTargetFlagsError({ - message: - "--project-ref only applies when targeting the linked project; use it with --linked, or --from/--to linked, in explicit mode", - }); - } // `mergedLinkedRef` tracks the linked ref resolved so far (preflight or cascade) so the // config read below and a later `migrations` catalog export merge the matching // `[remotes.]` override. Undefined until a linked ref resolves, so a `migrations` ref @@ -202,29 +178,26 @@ export const dbDiff = Effect.fn("db.diff")(function* (flags: DbDiffFlags) { // The first migrations endpoint resolved wins: the engine provisions a single migrations // shadow/catalog, so only refs resolved before that point should influence it. let migrationsToml: DbTomlValues | undefined; - // The preflight target resolve below validates a changed target flag and is stateful: a - // changed `--linked` resolves the project ref and merges `[remotes.]`, so the - // explicit `local`/`migrations` refs below see that override. `--local`/`--db-url` load - // base config (no merge). - if (Option.isSome(flags.dbUrl) || Option.isSome(flags.linked) || Option.isSome(flags.local)) { - const preflightConnType: DbConnType = Option.isSome(flags.dbUrl) - ? "db-url" - : Option.isSome(flags.linked) - ? "linked" - : "local"; - const preflight = yield* resolver.resolve({ + // The linked project resolves once, so `--from linked --to linked` announces it once. + let linkedTarget: ResolvedTarget | undefined; + // A target flag validates the selection and, for the linked project, merges + // `[remotes.]` so the explicit `local`/`migrations` refs below see that override. + if ( + flags.local || + flags.linked || + Option.isSome(flags.dbUrl) || + Option.isSome(flags.projectRef) + ) { + const preflight = yield* resolveTarget({ + local: flags.local, + linked: flags.linked, dbUrl: flags.dbUrl, - connType: preflightConnType, - dnsResolver, - password: Option.none(), - linkedProjectRef: flags.projectRef, + projectRef: flags.projectRef, }); - if (preflightConnType === "linked") { - const preflightRef = Option.getOrUndefined(preflight.ref ?? Option.none()); - if (preflightRef !== undefined) { - yield* Ref.set(linkedRefForCache, Option.some(preflightRef)); - mergedLinkedRef = preflightRef; - } + if (preflight.kind === "linked") { + linkedTarget = preflight; + yield* Ref.set(linkedRefForCache, Option.some(preflight.ref)); + mergedLinkedRef = preflight.ref; } } // Read config once, after the preflight: the `[remotes.]`-merged config @@ -287,12 +260,17 @@ export const dbDiff = Effect.fn("db.diff")(function* (flags: DbDiffFlags) { } satisfies PgDeltaDatabaseEndpoint; } case "linked": { - const resolved = yield* resolver.resolve({ + linkedTarget ??= yield* resolveTarget({ + local: false, + linked: true, dbUrl: Option.none(), - connType: "linked", + projectRef: flags.projectRef, + }); + const resolved = yield* resolver.resolveConnection({ + target: linkedTarget, dnsResolver, password: Option.none(), - linkedProjectRef: flags.projectRef, + networkBanRemoval: yield* hostedMutationPreapproval, }); const ref2 = Option.getOrUndefined(resolved.ref ?? Option.none()); if (ref2 !== undefined) { @@ -389,29 +367,18 @@ export const dbDiff = Effect.fn("db.diff")(function* (flags: DbDiffFlags) { const usePgAdmin = Option.getOrElse(flags.usePgAdmin, () => false); // Native path: resolve the target, provision a live shadow source, then diff. - const connType: DbConnType = Option.isSome(flags.dbUrl) - ? "db-url" - : Option.isSome(flags.linked) - ? "linked" - : "local"; - - // `--project-ref` never implies `--linked` and must not be silently discarded on a - // non-linked target (see push.handler.ts's identical guard; explicit mode has its own). - if (Option.isSome(flags.projectRef) && connType !== "linked") { - return yield* new DbDiffTargetFlagsError({ - message: - "--project-ref only applies when targeting the linked project; use it with --linked (not --local or --db-url)", - }); - } + const diffTarget = yield* resolveTarget({ + local: flags.local, + linked: flags.linked, + dbUrl: flags.dbUrl, + projectRef: flags.projectRef, + }); - // The ref is resolved and config read here, before `resolver.resolve()` below, so the - // "Loading config override" print and merged-config validation happen before the actual - // connection work (TCP probe / temp-role mint). Only the linked path pre-resolves a ref; the - // default local/db-url target never merges a remote block. - let linkedRef: string | undefined; - if (connType === "linked") { - const projectRefResolver = yield* ProjectRefResolver; - linkedRef = yield* projectRefResolver.loadProjectRef(flags.projectRef); + // The config is read before `resolver.resolveConnection()` below, so the "Loading config + // override" print and merged-config validation happen before the actual connection work + // (TCP probe / temp-role mint). Only the linked target merges a remote block. + const linkedRef = diffTarget.kind === "linked" ? diffTarget.ref : undefined; + if (linkedRef !== undefined) { // Cached the moment the ref is known, not after `cfg`/`localInputs` below (both // fallible) resolve, so the project cache is still written even if a later step fails. yield* Ref.set(linkedRefForCache, Option.some(linkedRef)); @@ -427,7 +394,7 @@ export const dbDiff = Effect.fn("db.diff")(function* (flags: DbDiffFlags) { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const networkIdFlag = yield* NetworkIdFlag; - // Built before `resolver.resolve()` below, not just before the "Creating shadow + // Built before `resolver.resolveConnection()` below, not just before the "Creating shadow // database..." banner: this performs a second config load (distinct from `cfg` above) with // its own validation, which can print a warning or fail outright. All config loading should // happen before a linked target's temp-role mint over the Management API, so a config error @@ -441,20 +408,15 @@ export const dbDiff = Effect.fn("db.diff")(function* (flags: DbDiffFlags) { // So the shadow's own container spec (image/JWT secret/root key/db.settings/service // enabled-for-setup flags) reflects the matching `[remotes.]` override too, same // as `cfg` above (`readDbToml(..., linkedRef)`). - connType === "linked" ? linkedRef : undefined, + linkedRef, ); - const resolved = yield* resolver.resolve({ - dbUrl: flags.dbUrl, - connType, + const resolved = yield* resolver.resolveConnection({ + target: diffTarget, dnsResolver, password: Option.none(), - linkedProjectRef: flags.projectRef, + networkBanRemoval: yield* hostedMutationPreapproval, }); - if (linkedRef === undefined) { - linkedRef = Option.getOrUndefined(resolved.ref ?? Option.none()); - } - if (linkedRef !== undefined) yield* Ref.set(linkedRefForCache, Option.some(linkedRef)); const targetUrl = toPostgresURL(resolved.conn); const ctx: PgDeltaContext = { projectId: (yield* resolvedConfig.get(CliConfigKeys.projectId)).value, diff --git a/apps/cli/src/commands/db/dump/dump.command.ts b/apps/cli/src/commands/db/dump/dump.command.ts index e0ba591cc8..ab4afc4dc5 100644 --- a/apps/cli/src/commands/db/dump/dump.command.ts +++ b/apps/cli/src/commands/db/dump/dump.command.ts @@ -21,6 +21,7 @@ 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"; +import { targetFlags } from "../../../command-internal/target/target-flags.ts"; import { withTargetPolicy } from "../../../command-internal/target/target-policy.ts"; /** @@ -43,9 +44,8 @@ const config = { Flag.withDescription("Prints the pg_dump script that would be executed."), Flag.withDefault(false), ), - // Mutually-exclusive-group flags (data-only/role-only/keep-comments, and the - // db-url/linked/local target group) are modelled as `Option` so presence, not - // value, drives validation — `--data-only=false` still counts as set. + // Mutually-exclusive-group flags (data-only/role-only/keep-comments) are modelled as + // `Option` so presence, not value, drives validation — `--data-only=false` still counts as set. dataOnly: Flag.Boolean("data-only").pipe( Flag.withDescription("Dumps only data records."), Flag.optional, @@ -77,25 +77,7 @@ const config = { Flag.withDescription("File path to save the dumped contents."), Flag.optional, ), - dbUrl: Flag.String("db-url").pipe( - Flag.withDescription( - "Dumps from the database specified by the connection string (must be percent-encoded).", - ), - Flag.optional, - ), - linked: Flag.Boolean("linked").pipe( - Flag.withDescription("Dumps from the linked project."), - Flag.optional, - ), - local: Flag.Boolean("local").pipe( - Flag.withDescription("Dumps from the local database."), - Flag.optional, - ), - // TS-only override of the linked project ref — see push.command.ts. - projectRef: Flag.String("project-ref").pipe( - Flag.withDescription("Project ref of the Supabase project."), - Flag.optional, - ), + ...targetFlags({ group: "selectable", subject: "database", dbUrl: true }), password: CliConfigKeys.linkedDb.password.flag({ name: "password", alias: "p", diff --git a/apps/cli/src/commands/db/dump/dump.handler.ts b/apps/cli/src/commands/db/dump/dump.handler.ts index 1af8707732..e372cda0da 100644 --- a/apps/cli/src/commands/db/dump/dump.handler.ts +++ b/apps/cli/src/commands/db/dump/dump.handler.ts @@ -2,14 +2,11 @@ import { Config, Effect, FileSystem, Option, Path, Predicate, Ref } from "effect import type { Stack } from "@supabase/stack/effect"; import { CommandSettings } from "../../../config/command-settings.service.ts"; -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 { - rejectPasswordWithDirectTarget, - type DbConnType, -} from "../../../command-internal/db-target-flags.ts"; +import { hostedMutationPreapproval } from "../../../command-internal/hosted-consent/hosted-consent.ts"; +import { resolveTarget } from "../../../command-internal/target/resolve-target.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"; @@ -67,7 +64,6 @@ import { * flags is alphabetised separately by `mutuallyExclusiveFlagsMessage`. */ const DUMP_EXCLUSIVE_GROUPS = [ - ["db-url", "linked", "local"], ["keep-comments", "data-only"], ["role-only", "data-only"], ["schema", "role-only"], @@ -118,12 +114,6 @@ export const dbDump = Effect.fn("db.dump")(function* (flags: DbDumpFlags) { // string-slice when non-empty. const isSet = (name: (typeof DUMP_EXCLUSIVE_GROUPS)[number][number]): boolean => { switch (name) { - case "db-url": - return Option.isSome(flags.dbUrl); - case "linked": - return Option.isSome(flags.linked); - case "local": - return Option.isSome(flags.local); case "data-only": return Option.isSome(flags.dataOnly); case "role-only": @@ -143,41 +133,28 @@ export const dbDump = Effect.fn("db.dump")(function* (flags: DbDumpFlags) { } } - // 3. Resolve the connection: db-url > local > linked, defaulting to linked when - // neither is set (unlike other db subcommands, which default to local). - const useLocal = Option.isNone(flags.dbUrl) && Option.isSome(flags.local); - const connType: DbConnType = Option.isSome(flags.dbUrl) - ? "db-url" - : 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({ - message: - "--project-ref only applies when targeting the linked project; use it with --linked (not --local or --db-url)", - }); - } - // The project ref is resolved before the connection is built, and the - // linked-project cache is refreshed unconditionally afterward, even on a - // connection-resolution failure. `loadProjectRef` validates the ref up front so an - // unvalidated raw `--project-ref` is never stored for the cache finalizer. - if (connType === "linked") { - const refResolver = yield* ProjectRefResolver; - const ref = yield* refResolver.loadProjectRef(flags.projectRef); - yield* Ref.set(linkedRefForCache, ref); + // 3. Resolve the target (a selector flag, else the workspace target) and connect to it. + const target = yield* resolveTarget({ + local: flags.local, + linked: flags.linked, + dbUrl: flags.dbUrl, + projectRef: flags.projectRef, + }); + // The linked-project cache is refreshed unconditionally afterward, even on a + // connection-resolution failure. + if (target.kind === "linked") { + yield* Ref.set(linkedRefForCache, target.ref); } const { conn, isLocal, ref: resolvedRef, - } = yield* resolver.resolve({ - dbUrl: flags.dbUrl, - connType, + } = yield* resolver.resolveConnection({ + target, dnsResolver, password: flags.password, - linkedProjectRef: flags.projectRef, + hasPasswordFlag: true, + networkBanRemoval: yield* hostedMutationPreapproval, }); const db = isLocal ? "local" : "remote"; // On the linked path, re-read config with the resolved ref so a matching @@ -194,7 +171,7 @@ export const dbDump = Effect.fn("db.dump")(function* (flags: DbDumpFlags) { const backend = yield* currentStackBackend; const managedProject = - backend.kind === "stack" && connType === "local" + backend.kind === "stack" && target.kind === "local" ? yield* stackOpenReadyProject.pipe( Effect.mapError((cause) => new DbDumpRunError({ message: cause.message, cause })), Effect.map(Option.getOrUndefined), @@ -202,7 +179,7 @@ export const dbDump = Effect.fn("db.dump")(function* (flags: DbDumpFlags) { : undefined; const managedStack: Stack | undefined = managedProject?.stack; const stackRuntime = - backend.kind === "stack" && connType === "local" + backend.kind === "stack" && target.kind === "local" ? yield* stackRequireProjectRuntime : undefined; const bundledRuntime = @@ -231,7 +208,7 @@ export const dbDump = Effect.fn("db.dump")(function* (flags: DbDumpFlags) { const rewriteLoopbackTarget = backend.kind === "stack" ? managedStack === undefined : !dumpUsesHostNetwork; const dumpConn = useNativeClient - ? connType === "local" + ? target.kind === "local" ? dumpConnForHostClient(conn) : conn : rewriteLoopbackTarget @@ -244,7 +221,7 @@ export const dbDump = Effect.fn("db.dump")(function* (flags: DbDumpFlags) { } : conn; const catalogVersion = - backend.kind === "stack" && connType === "local" + backend.kind === "stack" && target.kind === "local" ? ((yield* stackProjectDatabaseVersion) ?? String(tomlValues.majorVersion)) : String(tomlValues.majorVersion); const dumpMajor = tomlValues.majorVersion; @@ -423,18 +400,19 @@ export const dbDump = Effect.fn("db.dump")(function* (flags: DbDumpFlags) { Effect.flatMap((dumped) => runWithPoolerFallback({ result: dumped, - connType, + connType: target.kind, host: conn.host, isLocal, projectHost: cliSettings.projectHost, resolvePooler: () => resolver .resolvePoolerFallback({ - dbUrl: flags.dbUrl, + dbUrl: Option.none(), connType: "linked", dnsResolver, password: flags.password, - linkedProjectRef: flags.projectRef, + linkedProjectRef: target.kind === "linked" ? Option.some(target.ref) : Option.none(), + adHocProjectRef: target.kind === "linked" && !target.matchesLinkedRef, }) .pipe(Effect.orElseSucceed(() => Option.none())), runWithConn: (c) => runContainer(mode.buildEnv(c, opt)), diff --git a/apps/cli/src/commands/db/lint/lint.command.ts b/apps/cli/src/commands/db/lint/lint.command.ts index 3358171391..a90a9c97e3 100644 --- a/apps/cli/src/commands/db/lint/lint.command.ts +++ b/apps/cli/src/commands/db/lint/lint.command.ts @@ -15,28 +15,11 @@ 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"; +import { targetFlags } from "../../../command-internal/target/target-flags.ts"; import { withTargetPolicy } from "../../../command-internal/target/target-policy.ts"; const config = { - dbUrl: Flag.String("db-url").pipe( - Flag.withDescription( - "Lints the database specified by the connection string (must be percent-encoded).", - ), - Flag.optional, - ), - linked: Flag.Boolean("linked").pipe( - Flag.withDescription("Lints the linked project for schema errors."), - Flag.withDefault(false), - ), - local: Flag.Boolean("local").pipe( - Flag.withDescription("Lints the local database for schema errors."), - Flag.withDefault(false), - ), - // TS-only override of the linked project ref — see push.command.ts. - projectRef: Flag.String("project-ref").pipe( - Flag.withDescription("Project ref of the Supabase project."), - Flag.optional, - ), + ...targetFlags({ group: "selectable", subject: "database", dbUrl: true }), schema: Flag.String("schema").pipe( Flag.withAlias("s"), Flag.withDescription("Comma separated list of schema to include."), diff --git a/apps/cli/src/commands/db/lint/lint.handler.ts b/apps/cli/src/commands/db/lint/lint.handler.ts index fa4514d19a..1e77bd958e 100644 --- a/apps/cli/src/commands/db/lint/lint.handler.ts +++ b/apps/cli/src/commands/db/lint/lint.handler.ts @@ -1,18 +1,16 @@ import { Effect, Option } from "effect"; -import { CliArgs } from "../../../shared/cli/cli-args.service.ts"; import { DnsResolverFlag } from "../../../command-internal/global-flags.ts"; +import { hostedMutationPreapproval } from "../../../command-internal/hosted-consent/hosted-consent.ts"; +import { resolveTarget } from "../../../command-internal/target/resolve-target.ts"; import { Output } from "../../../shared/output/output.service.ts"; import { ProcessControl } from "../../../shared/runtime/process-control.service.ts"; -import { ProjectRefResolver } from "../../../config/project-ref.service.ts"; import { failsOn } from "../../../command-internal/fail-on.ts"; import { DbConfigResolver } from "../../../command-internal/db-config.service.ts"; import { DbConnection } from "../../../command-internal/db-connection.service.ts"; import type { DbSession } from "../../../command-internal/db-connection.service.ts"; import { LinkedProjectCache } from "../../../telemetry/linked-project-cache.service.ts"; import { TelemetryState } from "../../../telemetry/telemetry-state.service.ts"; -import { resolveDbTargetFlags } from "../../../command-internal/db-target-flags.ts"; -import type { DbTargetSelection } from "../../../command-internal/db-target-flags.ts"; import type { DbLintFlags } from "./lint.command.ts"; import { DbLintBeginTxError, @@ -20,7 +18,6 @@ import { DbLintFailOnError, DbLintListSchemasError, DbLintMalformedJsonError, - DbLintMutuallyExclusiveFlagsError, DbLintQueryError, } from "./lint.errors.ts"; import { @@ -101,29 +98,18 @@ const lintDatabase = Effect.fn("DbLint.lintDatabase")(function* ( const runLint = Effect.fnUntraced(function* ( flags: DbLintFlags, dnsResolver: "native" | "https", - target: DbTargetSelection, ) { const output = yield* Output; const resolver = yield* DbConfigResolver; const dbConn = yield* DbConnection; const processControl = yield* ProcessControl; - // Mutually-exclusive db-url/linked/local group, keyed off explicitly-set flags, - // not `--local`'s default value. - const setFlags = target.setFlags; - if (setFlags.length > 1) { - return yield* new DbLintMutuallyExclusiveFlagsError({ - message: `if any flags in the group [db-url linked local] are set none of the others can be; [${setFlags.join(" ")}] were all set`, - }); - } - - // `--project-ref` never implies `--linked`; see push.handler.ts's identical guard. - if (Option.isSome(flags.projectRef) && target.connType !== "linked") { - return yield* new DbLintMutuallyExclusiveFlagsError({ - message: - "--project-ref only applies when targeting the linked project; use it with --linked (not --local or --db-url)", - }); - } + const target = yield* resolveTarget({ + local: flags.local, + linked: flags.linked, + dbUrl: flags.dbUrl, + projectRef: flags.projectRef, + }); const level = Option.getOrElse(flags.level, () => "warning"); const failOn = Option.getOrElse(flags.failOn, () => "none"); @@ -133,13 +119,11 @@ const runLint = Effect.fnUntraced(function* ( const schemaFlags = flags.schema; const lintBody = Effect.gen(function* () { - // connType passes straight through; the resolver applies db-url > linked > - // local precedence and handles `--local`'s default. - const cfg = yield* resolver.resolve({ - dbUrl: flags.dbUrl, - connType: target.connType ?? "local", + const cfg = yield* resolver.resolveConnection({ + target, dnsResolver, - linkedProjectRef: flags.projectRef, + password: Option.none(), + networkBanRemoval: yield* hostedMutationPreapproval, }); yield* Effect.annotateCurrentSpan({ @@ -150,10 +134,6 @@ const runLint = Effect.fnUntraced(function* ( const results = yield* Effect.scoped( Effect.gen(function* () { - yield* output.raw( - `Connecting to ${cfg.isLocal ? "local" : "remote"} database...\n`, - "stderr", - ); const session = yield* dbConn.connect(cfg.conn, { isLocal: cfg.isLocal, dnsResolver }); yield* session.exec("begin").pipe( Effect.mapError( @@ -213,14 +193,11 @@ const runLint = Effect.fnUntraced(function* ( } }); - // For `--linked`, the ref is resolved up front (non-prompting) and the - // linked-project cache is refreshed on both success and failure; `--local`/`--db-url` - // never write it since caching only runs on the linked branch. - if (target.connType === "linked") { - const projectRef = yield* ProjectRefResolver; + // The linked-project cache is refreshed on success and failure; local and `--db-url` targets + // never write it. + if (target.kind === "linked") { const linkedProjectCache = yield* LinkedProjectCache; - const ref = yield* projectRef.loadProjectRef(flags.projectRef); - return yield* lintBody.pipe(Effect.ensuring(linkedProjectCache.cache(ref))); + return yield* lintBody.pipe(Effect.ensuring(linkedProjectCache.cache(target.ref))); } return yield* lintBody; }); @@ -228,8 +205,6 @@ const runLint = Effect.fnUntraced(function* ( export const dbLint = Effect.fn("db.lint")(function* (flags: DbLintFlags) { const dnsResolver = yield* DnsResolverFlag; const telemetryState = yield* TelemetryState; - const cliArgs = yield* CliArgs; - const target = resolveDbTargetFlags(cliArgs.args); // Command-level instrumentation/JSON error handling are applied by `lint.command.ts`. - yield* runLint(flags, dnsResolver, target).pipe(Effect.ensuring(telemetryState.flush)); + yield* runLint(flags, dnsResolver).pipe(Effect.ensuring(telemetryState.flush)); }); diff --git a/apps/cli/src/commands/db/query/query.command.ts b/apps/cli/src/commands/db/query/query.command.ts index 4f75ddc3e3..4c549fa579 100644 --- a/apps/cli/src/commands/db/query/query.command.ts +++ b/apps/cli/src/commands/db/query/query.command.ts @@ -16,6 +16,7 @@ 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"; +import { targetFlags } from "../../../command-internal/target/target-flags.ts"; import { withTargetPolicy } from "../../../command-internal/target/target-policy.ts"; /** @@ -29,32 +30,18 @@ const config = { Argument.withDescription("SQL query to execute."), Argument.optional, ), - dbUrl: Flag.String("db-url").pipe( - Flag.withDescription( - "Queries the database specified by the connection string (must be percent-encoded).", - ), - Flag.optional, - ), - // Selects the linked path by presence, not value, so `--linked=false` still selects it. - linked: Flag.Boolean("linked").pipe( - Flag.withDescription("Queries the linked project's database via Management API."), - Flag.optional, - ), - // In the same mutually-exclusive target group as `--db-url`/`--linked`, keyed off explicit - // presence, so `--local=false` still counts as an explicit target in the conflict check. - local: Flag.Boolean("local").pipe( - Flag.withDescription("Queries the local database."), - Flag.optional, - ), - projectRef: Flag.String("project-ref").pipe( - Flag.withDescription("Project ref of the Supabase project."), - Flag.optional, - ), + ...targetFlags({ group: "selectable", subject: "database", dbUrl: true }), file: Flag.String("file").pipe( Flag.withAlias("f"), Flag.withDescription("Path to a SQL file to execute."), Flag.optional, ), + readOnly: Flag.Boolean("read-only").pipe( + Flag.withDescription( + "Runs the statement in a read-only transaction; a hosted target needs no confirmation.", + ), + Flag.withDefault(false), + ), } as const; export type DbQueryFlags = CliCommand.Command.Config.Infer; @@ -69,10 +56,10 @@ export const dbQueryCommand = Command.make("query", config).pipe( operations: [ { operationId: "v1-run-a-query", kind: "required", when: [{ dbTarget: ["linked"] }] }, ], - noApiEffectFlags: ["file"], + noApiEffectFlags: ["file", "read-only"], }, dbConfig, - withCondition(linkedProjectCache, { flag: "linked" }), + withCondition(linkedProjectCache, { dbTarget: ["linked"] }), ), { default: "local" }, ), @@ -86,6 +73,7 @@ export const dbQueryCommand = Command.make("query", config).pipe( local: flags.local, "project-ref": flags.projectRef, file: flags.file, + "read-only": flags.readOnly, }, // --project-ref isn't in the telemetry safe-flags list, so it stays redacted. outputFormats: QUERY_OUTPUT_FORMATS, diff --git a/apps/cli/src/commands/db/query/query.handler.ts b/apps/cli/src/commands/db/query/query.handler.ts index 03737525f0..aa01b1d864 100644 --- a/apps/cli/src/commands/db/query/query.handler.ts +++ b/apps/cli/src/commands/db/query/query.handler.ts @@ -14,13 +14,21 @@ import * as HttpClientRequest from "effect/http/HttpClientRequest"; import { CommandSettings } from "../../../config/command-settings.service.ts"; import { CommandCredentials } from "../../../auth/command-credentials.service.ts"; -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 { TelemetryOutputFormat } from "../../../telemetry/telemetry-output-format.service.ts"; import { DbConfigResolver } from "../../../command-internal/db-config.service.ts"; -import { DbConnection, type PgConnInput } from "../../../command-internal/db-connection.service.ts"; +import { + DbConnection, + type DbSession, + type PgConnInput, +} from "../../../command-internal/db-connection.service.ts"; import { AgentFlag, DnsResolverFlag, OutputFlag } from "../../../command-internal/global-flags.ts"; +import { + hostedMutationPreapproval, + requireTargetMutationConsent, +} from "../../../command-internal/hosted-consent/hosted-consent.ts"; +import { resolveTarget } from "../../../command-internal/target/resolve-target.ts"; import { Output } from "../../../shared/output/output.service.ts"; import { Random } from "../../../shared/runtime/random.service.ts"; import { Stdin } from "../../../shared/runtime/stdin.service.ts"; @@ -30,7 +38,6 @@ import { RLS_CHECK_SQL, buildRlsAdvisory } from "./query.advisory.ts"; import { DbQueryExecError, DbQueryLoginRequiredError, - DbQueryMutuallyExclusiveFlagsError, DbQueryNoSqlError, DbQueryNoStdinSqlError, DbQueryReadFileError, @@ -58,11 +65,32 @@ const MISSING_TOKEN_MESSAGE = const BOUNDARY_BYTES = 16; +const SQL_PREVIEW_LENGTH = 120; + +const sqlPreview = (sql: string): string => { + const oneLine = sql.replaceAll(/\s+/gu, " ").trim(); + return oneLine.length > SQL_PREVIEW_LENGTH + ? `${oneLine.slice(0, SQL_PREVIEW_LENGTH)}...` + : oneLine; +}; + const decodeJsonBody = Schema.decodeEffect(Schema.fromJsonString(Schema.Unknown)); const isRowObject = (element: unknown): element is { readonly [key: string]: unknown } | null => element === null || Predicate.isObject(element); +const toExecError = (cause: { readonly message: string }) => + new DbQueryExecError({ message: cause.message }); + +// `queryRaw` runs every statement on one dedicated connection, so BEGIN/ROLLBACK issued through +// it bracket the user's statement. +const inReadOnlyTransaction = (session: DbSession, body: Effect.Effect) => + Effect.acquireUseRelease( + session.queryRaw("BEGIN READ ONLY").pipe(Effect.mapError(toExecError)), + () => body, + () => session.queryRaw("ROLLBACK").pipe(Effect.ignore), + ); + export const dbQuery = Effect.fn("db.query")(function* (flags: DbQueryFlags) { const output = yield* Output; const telemetryState = yield* TelemetryState; @@ -131,16 +159,15 @@ export const dbQuery = Effect.fn("db.query")(function* (flags: DbQueryFlags) { sql: string, format: ResolvedFormat, agentMode: boolean, + readOnly: boolean, ) => { const { conn, isLocal } = target; return Effect.scoped( Effect.gen(function* () { - yield* output.raw(`Connecting to ${isLocal ? "local" : "remote"} database...\n`, "stderr"); const session = yield* dbConn.connect(conn, { isLocal, dnsResolver }); - const result = yield* session - .queryRaw(sql) - .pipe(Effect.mapError((cause) => new DbQueryExecError({ message: cause.message }))); + const execute = session.queryRaw(sql).pipe(Effect.mapError(toExecError)); + const result = yield* readOnly ? inReadOnlyTransaction(session, execute) : execute; yield* Effect.annotateCurrentSpan({ "query.row_count": result.rows.length, @@ -178,6 +205,7 @@ export const dbQuery = Effect.fn("db.query")(function* (flags: DbQueryFlags) { agentMode: boolean, ref: string, token: Redacted.Redacted, + readOnly: boolean, ) => Effect.gen(function* () { const cliSettings = yield* CommandSettings; @@ -188,7 +216,7 @@ export const dbQuery = Effect.fn("db.query")(function* (flags: DbQueryFlags) { ).pipe( HttpClientRequest.setHeader("Authorization", `Bearer ${Redacted.value(token)}`), HttpClientRequest.setHeader("User-Agent", cliSettings.userAgent), - HttpClientRequest.bodyJsonUnsafe({ query: sql }), + HttpClientRequest.bodyJsonUnsafe({ query: sql, ...(readOnly ? { read_only: true } : {}) }), ); const { status, body } = yield* Effect.gen(function* () { const response = yield* httpClient.execute(request); @@ -231,52 +259,31 @@ export const dbQuery = Effect.fn("db.query")(function* (flags: DbQueryFlags) { }); yield* Effect.gen(function* () { - // 0. The db-url/linked/local group is mutually exclusive, checked before resolving SQL. - // "Set" means explicitly set (Option `Some`, or boolean literally `true`). - const exclusive: Array = []; - if (Option.isSome(flags.dbUrl)) exclusive.push("db-url"); - if (Option.isSome(flags.linked)) exclusive.push("linked"); - if (Option.isSome(flags.local)) exclusive.push("local"); - if (exclusive.length > 1) { - return yield* new DbQueryMutuallyExclusiveFlagsError({ - message: `if any flags in the group [db-url linked local] are set none of the others can be; [${exclusive.join(" ")}] were all set`, - }); - } + const target = yield* resolveTarget({ + local: flags.local, + linked: flags.linked, + dbUrl: flags.dbUrl, + projectRef: flags.projectRef, + }); + // Recorded now so the cache finalizer still fires if a later step fails. + if (target.kind === "linked") yield* Ref.set(linkedRefForCache, target.ref); - // `--project-ref` only applies to the linked target; it must not be silently ignored when - // targeting `--local`/`--db-url`. - if (Option.isSome(flags.projectRef) && Option.isNone(flags.linked)) { - return yield* new DbQueryMutuallyExclusiveFlagsError({ - message: - "--project-ref only applies when targeting the linked project; use it with --linked (not --local or --db-url)", - }); - } + // Resolved before the token check and before `--file`/stdin are read, so a bad `--db-url`, an + // invalid merged config or a connection failure surfaces ahead of a login or missing-file + // error. A linked query goes through the Management API, so that resolved connection only + // serves those early failures and is otherwise discarded. + const connection = yield* resolver.resolveConnection({ + target, + dnsResolver, + password: Option.none(), + networkBanRemoval: yield* hostedMutationPreapproval, + }); - // For `--linked`, the access token is checked and the project ref is loaded before SQL is - // resolved, so a missing `--file` or a blocking stdin pipe doesn't mask a login/not-linked - // error. let linkedAuth: { readonly token: Redacted.Redacted; readonly ref: string } | undefined; - if (Option.isSome(flags.linked)) { + if (target.kind === "linked") { const credentials = yield* CommandCredentials; - const projectRef = yield* ProjectRefResolver; - // Config is resolved before the token check, so an unlinked-project/invalid-config/pooler - // failure isn't masked behind a generic "supabase login" error. `loadProjectRef` is the - // strict loader: a real ref-file read error surfaces instead of being treated as not-linked. - const ref = yield* projectRef.loadProjectRef(flags.projectRef); - // Recorded now so the cache finalizer still fires if a later step fails. - yield* Ref.set(linkedRefForCache, ref); - // Loads and validates the remote-merged config and resolves the live DB connection (TCP - // probe, pooler fallback, temp login-role mint) purely to surface early failures — the - // linked query itself uses the Management API, so this resolved connection is discarded. - yield* resolver.resolve({ - dbUrl: Option.none(), - connType: "linked", - dnsResolver, - linkedProjectRef: flags.projectRef, - }); - // The Management API still needs a token even when config resolved without minting a login - // role. Route through `credentials.getAccessToken`, which validates it against `sbp_...`, - // rather than trusting env presence alone and surfacing a confusing status error later. + // Routed through `credentials.getAccessToken`, which validates the `sbp_...` shape, rather + // than trusting env presence alone and surfacing a confusing status error later. const tokenOpt = yield* credentials.getAccessToken; if (Option.isNone(tokenOpt)) { return yield* new DbQueryLoginRequiredError({ @@ -284,21 +291,9 @@ export const dbQuery = Effect.fn("db.query")(function* (flags: DbQueryFlags) { suggestion: "Run supabase login first.", }); } - linkedAuth = { token: tokenOpt.value, ref }; + linkedAuth = { token: tokenOpt.value, ref: target.ref }; } - // Resolves the `--db-url`/local connection target before reading `--file`/stdin, so a bad - // `--db-url` or config error surfaces ahead of a missing-file error or a blocking stdin read. - const localTarget = - linkedAuth === undefined - ? yield* resolver.resolve({ - dbUrl: flags.dbUrl, - // linkedAuth handles `--linked`, so this branch's target is `--db-url` or local. - connType: Option.isSome(flags.dbUrl) ? "db-url" : "local", - dnsResolver, - }) - : undefined; - // 1. Resolve SQL: --file > positional arg > piped stdin. const sql = yield* Effect.gen(function* () { if (Option.isSome(flags.file)) { @@ -349,23 +344,32 @@ export const dbQuery = Effect.fn("db.query")(function* (flags: DbQueryFlags) { // report the table/human default as `text`. yield* telemetryOutputFormat.set(format); yield* Effect.annotateCurrentSpan({ - "db.conn_type": - linkedAuth !== undefined ? "linked" : Option.isSome(flags.dbUrl) ? "db-url" : "local", + "db.conn_type": target.kind, "query.format": format, "query.agent_mode": agentMode, + "query.read_only": flags.readOnly, }); - // 3. Linked queries go through the Management API; local/--db-url connect directly. - if (linkedAuth !== undefined) { - return yield* runLinked(sql, format, agentMode, linkedAuth.ref, linkedAuth.token).pipe( - Effect.withSpan("db.query.runLinked"), - ); + // 3. A hosted statement may write, so it needs consent unless `--read-only` confines it. + if (!flags.readOnly) { + yield* requireTargetMutationConsent(connection, { + action: "Run a SQL statement", + details: [`sql: ${sqlPreview(sql)}`], + }); } - if (localTarget === undefined) { - // Unreachable: the non-linked branch always resolves a target above. - return yield* Effect.die(new Error("db query: connection target was not resolved")); + + // 4. Linked queries go through the Management API; local/--db-url connect directly. + if (linkedAuth !== undefined) { + return yield* runLinked( + sql, + format, + agentMode, + linkedAuth.ref, + linkedAuth.token, + flags.readOnly, + ).pipe(Effect.withSpan("db.query.runLinked")); } - return yield* runLocal(localTarget, sql, format, agentMode).pipe( + return yield* runLocal(connection, sql, format, agentMode, flags.readOnly).pipe( Effect.withSpan("db.query.runLocal"), ); }).pipe( diff --git a/apps/cli/src/commands/db/reset/reset.command.ts b/apps/cli/src/commands/db/reset/reset.command.ts index 2196377a4a..e37fb1a059 100644 --- a/apps/cli/src/commands/db/reset/reset.command.ts +++ b/apps/cli/src/commands/db/reset/reset.command.ts @@ -16,27 +16,11 @@ import { withCommandTelemetry } from "../../../telemetry/command-telemetry.ts"; import { dbReset } from "./reset.handler.ts"; import { dbResetRuntimeLayer } from "./reset.layers.ts"; import { cliConfigValuesLayer } from "../../../config/cli-config-values.layer.ts"; +import { targetFlags } from "../../../command-internal/target/target-flags.ts"; import { withTargetPolicy } from "../../../command-internal/target/target-policy.ts"; const config = { - dbUrl: Flag.String("db-url").pipe( - Flag.withDescription( - "Resets the database specified by the connection string (must be percent-encoded).", - ), - Flag.optional, - ), - linked: Flag.Boolean("linked").pipe( - Flag.withDescription("Resets the linked project with local migrations."), - Flag.withDefault(false), - ), - local: Flag.Boolean("local").pipe( - Flag.withDescription("Resets the local database with local migrations."), - Flag.withDefault(false), - ), - projectRef: Flag.String("project-ref").pipe( - Flag.withDescription("Project ref of the Supabase project."), - Flag.optional, - ), + ...targetFlags({ group: "selectable", subject: "database", dbUrl: true }), noSeed: CliConfigKeys.db.seed.enabled.flag({ name: "no-seed", description: "Skip running the seed script after reset.", diff --git a/apps/cli/src/commands/db/reset/reset.handler.ts b/apps/cli/src/commands/db/reset/reset.handler.ts index 71a804c832..32a0d679ba 100644 --- a/apps/cli/src/commands/db/reset/reset.handler.ts +++ b/apps/cli/src/commands/db/reset/reset.handler.ts @@ -1,23 +1,18 @@ import { Effect, FileSystem, Option, Path, Ref } from "effect"; -import { CliArgs } from "../../../shared/cli/cli-args.service.ts"; -import { DnsResolverFlag } from "../../../command-internal/global-flags.ts"; import { + DnsResolverFlag, resolveExperimentalWithProjectEnv, - 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"; + hostedMutationPreapproval, + requireHostedMutationConsent, +} from "../../../command-internal/hosted-consent/hosted-consent.ts"; +import { planDbSeed } 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 { resolveTarget } from "../../../command-internal/target/resolve-target.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 { resetLocalDatabase } from "../../../command-internal/db-bootstrap/reset-local-database.ts"; @@ -35,7 +30,6 @@ import { applyMigrations, applySchemaFiles } from "../../../command-internal/mig import { parseMigrationVersion } from "../../../command-internal/migration-timestamp.format.ts"; import { listLocalMigrations } from "../../../command-internal/migration-list.ts"; import { pathMatch } from "../../../command-internal/path-match.ts"; -import { resolveDbTargetFlags } from "../../../command-internal/db-target-flags.ts"; import { getPendingSeeds, seedData } from "../../../command-internal/seed-ops.ts"; import { upsertVaultSecrets } from "../../../command-internal/vault.ts"; import { LinkedProjectCache } from "../../../telemetry/linked-project-cache.service.ts"; @@ -44,12 +38,10 @@ import { dropUserSchemas } from "../shared/drop-schemas.ts"; import type { DbResetFlags } from "./reset.command.ts"; import { DbResetApplyError, - DbResetCancelledError, DbResetInvalidVersionError, DbResetLastFlagError, DbResetMigrationFileError, DbResetSeedFlagsError, - DbResetTargetFlagsError, DbResetVersionFlagsError, } from "./reset.errors.ts"; @@ -79,7 +71,6 @@ export const dbReset = Effect.fn("db.reset")(function* (flags: DbResetFlags) { const linkedProjectCache = yield* LinkedProjectCache; const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const cliArgs = yield* CliArgs; const dnsResolver = yield* DnsResolverFlag; const configValues = yield* CliConfigValues; const noSeed = Option.getOrElse(flags.noSeed, () => false); @@ -87,20 +78,11 @@ export const dbReset = Effect.fn("db.reset")(function* (flags: DbResetFlags) { 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* loadProjectEnvValues(fs, path, workdir); - const yes = yield* resolveYesWithProjectEnv(projectEnv); const experimental = yield* resolveExperimentalWithProjectEnv(projectEnv); const linkedRefForCache = yield* Ref.make(Option.none()); const body = Effect.gen(function* () { - const target = resolveDbTargetFlags(cliArgs.args); - // Mutually-exclusive db-url/linked/local group. - if (target.setFlags.length > 1) { - return yield* new DbResetTargetFlagsError({ - 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`, - }); - } // `--last` is an unsigned flag, so a negative value should be rejected; `Flag.Int` here // accepts it, so reject it explicitly rather than silently resetting the full history. if (Option.isSome(flags.last) && flags.last.value < 0) { @@ -132,16 +114,6 @@ export const dbReset = Effect.fn("db.reset")(function* (flags: DbResetFlags) { suggestion: `Pass a non-empty file path or glob pattern to ${aqua("--sql-paths")}.`, }); } - // A remote target flag + --sql-paths warns about the seed override. - if ( - sqlPaths.length > 0 && - (target.setFlags.includes("linked") || target.setFlags.includes("db-url")) - ) { - yield* output.raw( - `${yellow("WARNING:")} --sql-paths overrides [db.seed].sql_paths and seeds the remote database selected by --linked or --db-url.\n`, - "stderr", - ); - } // Version / last resolution, filesystem only. let resolvedVersion = ""; @@ -181,34 +153,28 @@ export const dbReset = Effect.fn("db.reset")(function* (flags: DbResetFlags) { resolvedVersion = versions[total - last - 1] ?? "-"; } - const connType = target.connType ?? "local"; - - // `--project-ref` only applies to the linked target; it must not be silently ignored when - // targeting `--local`/`--db-url`. - if (Option.isSome(flags.projectRef) && connType !== "linked") { - return yield* new DbResetTargetFlagsError({ - message: - "--project-ref only applies when targeting the linked project; use it with --linked (not --local or --db-url)", - }); - } + const target = yield* resolveTarget({ + local: flags.local, + linked: flags.linked, + dbUrl: flags.dbUrl, + projectRef: flags.projectRef, + }); - // Loaded before the fallible linked resolution, so the post-run cache finalizer still fires - // even when `resolver.resolve` fails mid-way (merged config, temp-role mint, connection). - if (connType === "linked") { - const refResolver = yield* ProjectRefResolver; - const ref = yield* refResolver.loadProjectRef(flags.projectRef); - yield* Ref.set(linkedRefForCache, Option.some(ref)); + // Recorded before the fallible connection resolution, so the post-run cache finalizer still + // fires when it fails mid-way (merged config, temp-role mint, connection). + if (target.kind === "linked") { + yield* Ref.set(linkedRefForCache, Option.some(target.ref)); } - const cfg = yield* resolver.resolve({ - dbUrl: flags.dbUrl, - connType, + const cfg = yield* resolver.resolveConnection({ + target, dnsResolver, - linkedProjectRef: flags.projectRef, + password: Option.none(), + networkBanRemoval: yield* hostedMutationPreapproval, }); yield* Effect.annotateCurrentSpan({ - "db.conn_type": connType, + "db.conn_type": target.kind, "db.is_local": cfg.isLocal, ...(resolvedVersion !== "" ? { "migration.version": resolvedVersion } : {}), }); @@ -219,37 +185,35 @@ export const dbReset = Effect.fn("db.reset")(function* (flags: DbResetFlags) { if (cfg.isLocal) { yield* resetLocalDatabase({ version: resolvedVersion }); if (output.format !== "text") { - yield* output.success("Reset local database.", { - target: "local", - version: resolvedVersion, - }); + yield* output.success("Reset local database.", { version: resolvedVersion }); } return; } - // Re-confirms `linkedRefForCache` from the now-resolved `cfg.ref`; a `db-url` target leaves - // it as whatever the pre-load block set (nothing), since this only fires when linked. - const linkedRef = Option.getOrUndefined(cfg.ref ?? Option.none()); - if (connType === "linked" && linkedRef !== undefined) { - yield* Ref.set(linkedRefForCache, Option.some(linkedRef)); + if (sqlPaths.length > 0) { + yield* output.raw( + `${yellow("WARNING:")} --sql-paths overrides [db.seed].sql_paths and seeds the remote database.\n`, + "stderr", + ); } + const linkedRef = target.kind === "linked" ? target.ref : undefined; + // Decodes the whole config with env-expansion and weak-typed boolean parsing, applies // `SUPABASE_*` overrides, merges a matching `[remotes.]` block, and decrypts every // `encrypted:` secret — aborting here, before the destructive prompt, on any invalid config. - const configRef = connType === "linked" && linkedRef !== undefined ? linkedRef : undefined; - const toml = yield* checkDbToml(fs, path, workdir, configRef); + const toml = yield* checkDbToml(fs, path, workdir, linkedRef); if (toml.appliedRemote !== undefined) { 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), + projectRef: Option.fromNullishOr(linkedRef), }); - const seed = yield* resolveDbSeedInput(resolvedConfig, { workdir, ref: linkedRef ?? "" }); - const seedEnabled = seed.enabled; - const seedSqlPaths = seed.sqlPaths; + const seedPlan = yield* planDbSeed(resolvedConfig, { workdir, ref: linkedRef ?? "" }); + const seedEnabled = seedPlan.input.enabled; + const seedSqlPaths = seedPlan.input.sqlPaths; if ( ignoresExperimentalSchemaPaths({ experimental, @@ -261,36 +225,27 @@ export const dbReset = Effect.fn("db.reset")(function* (flags: DbResetFlags) { yield* output.raw(experimentalSchemaPathsIgnoredWarning, "stderr"); } - // Prompt (default false) → cancel, then reset everything. - const shouldReset = yield* promptYesNo( - output, - yes, - "Do you want to reset the remote database?", - false, - ); - 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"), - }); - } - } - } + // Takes the schema-files branch when `--experimental`/`SUPABASE_EXPERIMENTAL` is set, no + // version is resolved, and pg-delta is disabled — a hard if/else-if, so an empty + // `schema_paths = []` applies nothing rather than falling back to migrations. + const useSchemaFiles = experimental && resolvedVersion === "" && !toml.pgDelta.enabled; + const seedFiles = seedEnabled + ? (yield* sqlFilesGlob(fs, path, seedSqlPaths, workdir)).files + : []; + const vaultCount = vaultSecrets.filter((secret) => secret.resolved).length; + yield* requireHostedMutationConsent({ + action: `Reset the remote database${resolvedVersion === "" ? "" : ` to version ${resolvedVersion}`}`, + details: [ + "drop every user schema", + ...(vaultCount > 0 ? [`upsert ${vaultCount} vault secret${vaultCount === 1 ? "" : "s"}`] : []), + useSchemaFiles + ? "apply the declarative schema files" + : `apply local migrations${resolvedVersion === "" ? "" : ` up to version ${resolvedVersion}`}`, + ...Option.toArray(Option.fromNullishOr(seedPlan.detail(seedFiles))), + ], + }); yield* output.raw(`Resetting remote database${toLogMessage(resolvedVersion)}\n`, "stderr"); - // Established output contract: no "Connecting to ... database..." line is printed here. yield* Effect.scoped( Effect.gen(function* () { const session = yield* dbConn.connect(cfg.conn, { isLocal: false, dnsResolver }); @@ -298,10 +253,6 @@ export const dbReset = Effect.fn("db.reset")(function* (flags: DbResetFlags) { yield* dropUserSchemas(session, applyError); yield* upsertVaultSecrets(session, vaultSecrets); - // Takes the schema-files branch when `--experimental`/`SUPABASE_EXPERIMENTAL` is set, no - // version is resolved, and pg-delta is disabled — a hard if/else-if, so an empty - // `schema_paths = []` applies nothing rather than falling back to migrations. - const useSchemaFiles = experimental && resolvedVersion === "" && !toml.pgDelta.enabled; if (useSchemaFiles) { // Threaded through so a `SUPABASE_SCANNER_BUFFER_SIZE` set only in `supabase/.env` is // honored here (see `checkScannerBufferSize` in `migration-apply.ts`). @@ -335,10 +286,7 @@ export const dbReset = Effect.fn("db.reset")(function* (flags: DbResetFlags) { ); if (output.format !== "text") { - yield* output.success("Reset remote database.", { - target: "remote", - version: resolvedVersion, - }); + yield* output.success("Reset remote database.", { version: resolvedVersion }); } }); diff --git a/apps/cli/src/commands/db/shared/pooler-fallback.ts b/apps/cli/src/commands/db/shared/pooler-fallback.ts index cd374fb157..7975454ce7 100644 --- a/apps/cli/src/commands/db/shared/pooler-fallback.ts +++ b/apps/cli/src/commands/db/shared/pooler-fallback.ts @@ -3,7 +3,7 @@ import { Effect, Option } from "effect"; import { Output } from "../../../shared/output/output.service.ts"; import { isIPv6ConnectivityError } from "../../../command-internal/connect-errors.ts"; import type { PgConnInput } from "../../../command-internal/db-connection.service.ts"; -import type { DbConnType } from "../../../command-internal/db-target-flags.ts"; +import type { ResolvedTarget } from "../../../command-internal/target/resolve-target.ts"; import { isDirectDbHost, runWithPoolerFallback as runWithSharedPoolerFallback, @@ -18,14 +18,14 @@ interface PoolerFallbackResult { } /** - * A dump/diff is only rerouted through the pooler when it's a `--linked` run against a + * A dump/diff is only rerouted through the pooler when the target is the linked project, on a * direct Supabase DB host (`db..`, never local/pooler); the `!isLocal` * check is belt-and-braces since a direct-host match already implies non-local. Shared by * the result-based dump/pull retry ({@link runWithPoolerFallback}) and the error-based diff * retry in `db pull`, each ANDing in its own IPv6 classification of the relevant error. */ export const isDirectLinkedHost = (params: { - readonly connType: DbConnType; + readonly connType: ResolvedTarget["kind"]; readonly host: string; readonly isLocal: boolean; readonly projectHost: string; @@ -44,7 +44,7 @@ export const isDirectLinkedHost = (params: { export const runWithPoolerFallback = Effect.fn("PoolerFallback.run")(function* (params: { /** The first attempt's result; returned unchanged when no fallback fires. */ readonly result: PoolerFallbackResult; - readonly connType: DbConnType; + readonly connType: ResolvedTarget["kind"]; /** The direct connection host that failed (`resolved.conn.host`). */ readonly host: string; readonly isLocal: boolean; From b4f235d579ea7c2c544aafa77072567f0f1a3ad4 Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Thu, 15 Oct 2026 14:23:00 +0100 Subject: [PATCH 23/92] feat(cli): require hosted consent in branches, backups, domains, vanity (CLI-2695) --- .../backups/restore/restore.handler.ts | 7 ++ .../restore/restore.integration.test.ts | 31 ++++- .../src/commands/branches/branches.errors.ts | 1 + .../branches/create/create.command.ts | 5 +- .../branches/create/create.handler.ts | 37 +++--- .../create/create.integration.test.ts | 110 +++++++----------- .../branches/create/create.live.test.ts | 1 + .../branches/delete/delete.handler.ts | 6 + .../delete/delete.integration.test.ts | 34 ++++-- .../branches/disable/disable.handler.ts | 3 + .../disable/disable.integration.test.ts | 21 +++- .../branches/disable/disable.live.test.ts | 8 +- .../commands/branches/pause/pause.handler.ts | 6 + .../branches/pause/pause.integration.test.ts | 36 ++++-- .../branches/unpause/unpause.handler.ts | 6 + .../unpause/unpause.integration.test.ts | 24 +++- .../branches/update/update.handler.ts | 13 +++ .../update/update.integration.test.ts | 40 +++++-- .../branches/update/update.live.test.ts | 1 + .../domains/activate/activate.handler.ts | 6 + .../activate/activate.integration.test.ts | 19 ++- .../commands/domains/create/create.handler.ts | 5 + .../domains/create/create.integration.test.ts | 38 ++++-- .../commands/domains/delete/delete.handler.ts | 3 + .../domains/delete/delete.integration.test.ts | 26 ++++- .../activate/activate.handler.ts | 5 + .../delete/delete.handler.ts | 6 + .../vanity-subdomains.integration.test.ts | 35 +++++- apps/cli/tests/helpers/branches-live.ts | 2 +- 29 files changed, 391 insertions(+), 144 deletions(-) diff --git a/apps/cli/src/commands/backups/restore/restore.handler.ts b/apps/cli/src/commands/backups/restore/restore.handler.ts index 389351aac1..9206771d74 100644 --- a/apps/cli/src/commands/backups/restore/restore.handler.ts +++ b/apps/cli/src/commands/backups/restore/restore.handler.ts @@ -5,6 +5,7 @@ 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 { OutputFlag } from "../../../command-internal/global-flags.ts"; +import { requireHostedMutationConsent } from "../../../command-internal/hosted-consent/hosted-consent.ts"; import { encodeSortedJson } from "../../../command-internal/output.encoders.ts"; import { Output } from "../../../shared/output/output.service.ts"; import { @@ -33,6 +34,12 @@ export const backupsRestore = Effect.fn("backups.restore")(function* (flags: Bac const recoveryTimeTargetUnix = Option.getOrElse(flags.timestamp, () => 0); yield* Effect.gen(function* () { + yield* requireHostedMutationConsent({ + action: Option.isSome(flags.timestamp) + ? `Restore the database to Unix timestamp ${flags.timestamp.value} (PITR)` + : "Start a PITR restore without a recovery time target", + }); + const restoring = output.format === "text" ? yield* output.task("Initiating PITR restore...") : undefined; yield* api.v1 diff --git a/apps/cli/src/commands/backups/restore/restore.integration.test.ts b/apps/cli/src/commands/backups/restore/restore.integration.test.ts index a323cd56fc..c7677a6aac 100644 --- a/apps/cli/src/commands/backups/restore/restore.integration.test.ts +++ b/apps/cli/src/commands/backups/restore/restore.integration.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "@effect/vitest"; -import { Cause, Effect, Exit, Option } from "effect"; +import { Cause, Effect, Exit, Layer, Option } from "effect"; import * as HttpClientResponse from "effect/http/HttpClientResponse"; import { mockOutput, mockTty } from "../../../../tests/helpers/mocks.ts"; @@ -12,7 +12,10 @@ import { useTempWorkdir, unverifiedTargetBanner, } from "../../../../tests/helpers/command-mocks.ts"; +import { describeHostedConsent } from "../../../../tests/helpers/hosted-consent.ts"; import { backupsRestore } from "./restore.handler.ts"; +import { YesFlag } from "../../../command-internal/global-flags.ts"; +import { TargetPolicy } from "../../../command-internal/target/target-policy.ts"; interface SetupOpts { format?: "text" | "json" | "stream-json"; @@ -23,6 +26,15 @@ interface SetupOpts { const tempRoot = useTempWorkdir("supabase-backups-restore-int-"); +const buildRuntime = (opts: Parameters[0]) => + Layer.mergeAll( + buildTestRuntime(opts), + Layer.succeed(YesFlag, true), + Layer.succeed(TargetPolicy, { group: "hosted", consent: "no" }), + ); + +const confirmed = `Confirmed by --yes: Start a PITR restore without a recovery time target on project ${VALID_REF} · identity unverified\n`; + function setup(opts: SetupOpts = {}) { const out = mockOutput({ format: opts.format ?? "text" }); const api = mockCommandPlatformApi({ @@ -31,7 +43,7 @@ function setup(opts: SetupOpts = {}) { network: opts.network, }); const cliSettings = mockCommandSettings({ workdir: tempRoot.current }); - const layer = buildTestRuntime({ + const layer = buildRuntime({ out, api, cliSettings, @@ -72,7 +84,7 @@ describe("backups restore integration", () => { timestamp: Option.none(), }); expect(out.stderrText).toBe( - `${unverifiedTargetBanner()}Started PITR restore: ${VALID_REF}\n`, + `${unverifiedTargetBanner()}${confirmed}Started PITR restore: ${VALID_REF}\n`, ); expect(out.stdoutText).toBe(""); }).pipe(Effect.provide(layer)); @@ -126,7 +138,7 @@ describe("backups restore integration", () => { timestamp: Option.none(), }); expect(out.stderrText).toBe( - `${unverifiedTargetBanner()}Started PITR restore: ${VALID_REF}\n`, + `${unverifiedTargetBanner()}${confirmed}Started PITR restore: ${VALID_REF}\n`, ); expect(out.stdoutText).toBe(""); }).pipe(Effect.provide(layer)); @@ -182,7 +194,7 @@ describe("backups restore integration", () => { workdir: tempRoot.current, projectId: Option.none(), }); - const layer = buildTestRuntime({ out, api, cliSettings }); + const layer = buildRuntime({ out, api, cliSettings }); return Effect.gen(function* () { const exit = yield* Effect.exit( @@ -240,7 +252,7 @@ describe("backups restore integration", () => { workdir: tempRoot.current, projectId: Option.none(), }); - const layer = buildTestRuntime({ + const layer = buildRuntime({ out, api, cliSettings, @@ -267,3 +279,10 @@ describe("backups restore integration", () => { }).pipe(Effect.provide(layer)); }); }); + +describeHostedConsent("backups restore", { + api: { response: { status: 201, body: null } }, + invoke: () => ({ + run: backupsRestore({ projectRef: Option.none(), timestamp: Option.some(1_707_407_047) }), + }), +}); diff --git a/apps/cli/src/commands/branches/branches.errors.ts b/apps/cli/src/commands/branches/branches.errors.ts index 293e7722c5..4c2b12e2d7 100644 --- a/apps/cli/src/commands/branches/branches.errors.ts +++ b/apps/cli/src/commands/branches/branches.errors.ts @@ -293,6 +293,7 @@ export class BranchesEnvNotSupportedError extends Data.TaggedError("BranchesEnvN } } +/** @public */ export class BranchesCreateCancelledError extends Data.TaggedError("BranchesCreateCancelledError")<{ readonly message: string; }> { diff --git a/apps/cli/src/commands/branches/create/create.command.ts b/apps/cli/src/commands/branches/create/create.command.ts index 83c15583ba..5a4e1ac330 100644 --- a/apps/cli/src/commands/branches/create/create.command.ts +++ b/apps/cli/src/commands/branches/create/create.command.ts @@ -1,9 +1,7 @@ import { Argument, Command, Flag } from "effect/cli"; import type * as CliCommand from "effect/cli/Command"; -import { Layer } from "effect"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; -import { stdinLayer } from "../../../shared/runtime/stdin.layer.ts"; import { managementApiRuntimeLayer } from "../../../command-internal/management-api-runtime.layer.ts"; import { withCommandTelemetry } from "../../../telemetry/command-telemetry.ts"; import { branchesCreate } from "./create.handler.ts"; @@ -99,7 +97,6 @@ export const branchesCreateCommand = Command.make("create", config).pipe( withJsonErrorHandling, ), ), - // `stdinLayer` lets the confirmation prompt read piped stdin via `promptYesNo` on a non-TTY. - Command.provide(Layer.mergeAll(managementApiRuntimeLayer(["branches", "create"]), stdinLayer)), + Command.provide(managementApiRuntimeLayer(["branches", "create"])), withTargetPolicy({ group: "hosted", consent: "yes" }), ); diff --git a/apps/cli/src/commands/branches/create/create.handler.ts b/apps/cli/src/commands/branches/create/create.handler.ts index 3784f35d12..d996c932c4 100644 --- a/apps/cli/src/commands/branches/create/create.handler.ts +++ b/apps/cli/src/commands/branches/create/create.handler.ts @@ -4,12 +4,10 @@ import { Effect, Option } from "effect"; import { CommandPlatformApi } from "../../../auth/command-platform-api.service.ts"; import { LinkedProjectCache } from "../../../telemetry/linked-project-cache.service.ts"; import { TelemetryState } from "../../../telemetry/telemetry-state.service.ts"; -import { OutputFlag, resolveYes } from "../../../command-internal/global-flags.ts"; -import { promptYesNo } from "../../../command-internal/prompt-yes-no.ts"; -import { CONTEXT_CANCELED_MESSAGE } from "../../../shared/output/errors.ts"; +import { OutputFlag } from "../../../command-internal/global-flags.ts"; +import { requireHostedMutationConsent } from "../../../command-internal/hosted-consent/hosted-consent.ts"; import { Output } from "../../../shared/output/output.service.ts"; import { detectGitBranch } from "../../../shared/git/git-branch.ts"; -import { aqua } from "../../../command-internal/colors.ts"; import { encodeEnv, encodeSortedJson } from "../../../command-internal/output.encoders.ts"; import { encodeStructToml, @@ -21,7 +19,6 @@ import { gateMapError } from "../../../command-internal/upgrade-suggest.ts"; import { BRANCH_RESPONSE_SHAPE } from "../branches.response-shape.ts"; import { BranchesBranchNameEmptyError, - BranchesCreateCancelledError, BranchesCreateNetworkError, BranchesCreateUnexpectedStatusError, } from "../branches.errors.ts"; @@ -44,30 +41,17 @@ export const branchesCreate = Effect.fn("branches.create")(function* (flags: Bra const linkedProjectCache = yield* LinkedProjectCache; const telemetryState = yield* TelemetryState; - // Branch-name resolution defaults to the current git branch when the arg is omitted, after a - // Y/N prompt. Declining short-circuits before resolving the project ref, so the linked-project - // cache write never fires. + // The name defaults to the current git branch when the arg is omitted. let branchName = Option.getOrElse(flags.name, () => ""); // An explicit `--git-branch` flag takes precedence over the auto-detected branch. let gitBranchForBody = Option.getOrUndefined(flags.gitBranch); + let nameFromGitBranch = false; if (branchName.length === 0) { const gitBranch = yield* detectGitBranch(); if (Option.isSome(gitBranch) && gitBranch.value.length > 0) { - // `--yes`/`SUPABASE_YES` auto-confirms with a ` [Y/n] y` stderr echo; non-TTY - // stdin scans one piped line (100ms): empty or EOF takes Yes, anything but y/yes - // declines — `echo n | supabase branches create` cancels. - const yes = yield* resolveYes; - const confirmed = yield* promptYesNo( - output, - yes, - `Do you want to create a branch named ${aqua(gitBranch.value)}?`, - true, - ); - if (!confirmed) { - return yield* new BranchesCreateCancelledError({ message: CONTEXT_CANCELED_MESSAGE }); - } branchName = gitBranch.value; + nameFromGitBranch = true; if (gitBranchForBody === undefined) { gitBranchForBody = gitBranch.value; } @@ -87,6 +71,17 @@ export const branchesCreate = Effect.fn("branches.create")(function* (flags: Bra yield* Effect.annotateCurrentSpan("project.ref", ref); yield* Effect.gen(function* () { + yield* requireHostedMutationConsent({ + action: `Create preview branch ${branchName}`, + details: [ + ...(nameFromGitBranch ? ["Branch name taken from the current git branch"] : []), + ...(Option.isSome(flags.region) ? [`Region: ${flags.region.value}`] : []), + ...(Option.isSome(flags.size) ? [`Instance size: ${flags.size.value}`] : []), + ...(Option.isSome(flags.persistent) ? [`Persistent: ${flags.persistent.value}`] : []), + ...(Option.isSome(flags.withData) ? [`With production data: ${flags.withData.value}`] : []), + ], + }); + const creating = output.format === "text" ? yield* output.task("Creating branch...") : undefined; diff --git a/apps/cli/src/commands/branches/create/create.integration.test.ts b/apps/cli/src/commands/branches/create/create.integration.test.ts index c7f7414192..00eff5ab3f 100644 --- a/apps/cli/src/commands/branches/create/create.integration.test.ts +++ b/apps/cli/src/commands/branches/create/create.integration.test.ts @@ -11,6 +11,8 @@ import { mockTty, } from "../../../../tests/helpers/mocks.ts"; import { GLOBAL_FLAGS, YesFlag } from "../../../command-internal/global-flags.ts"; +import { TargetPolicy } from "../../../command-internal/target/target-policy.ts"; +import { describeHostedConsent } from "../../../../tests/helpers/hosted-consent.ts"; import { VALID_REF, buildTestRuntime, @@ -19,6 +21,7 @@ import { mockLinkedProjectCacheTracked, mockCommandPlatformApi, mockTelemetryStateTracked, + type MockCommandPlatformApiOpts, useTempWorkdir, withConfigEnv, } from "../../../../tests/helpers/command-mocks.ts"; @@ -81,19 +84,18 @@ interface SetupOpts { readonly network?: "fail"; readonly gated?: boolean; readonly featureKey?: string; - /** Resolved `--yes`/`SUPABASE_YES` for the git-branch auto-name confirm. */ readonly yes?: boolean; readonly stdinIsTty?: boolean; - /** Piped stdin lines consumed by the non-TTY confirm read. */ - readonly stdinInput?: string; readonly promptConfirmResponses?: ReadonlyArray<boolean>; } -function buildApiLayer(opts: SetupOpts) { +const hostedCreatePolicy = Layer.succeed(TargetPolicy, { group: "hosted", consent: "yes" }); + +function apiOptions(opts: SetupOpts): MockCommandPlatformApiOpts { const status = opts.status ?? 201; const body = opts.response ?? CREATED; const featureKey = opts.featureKey ?? "branching_limit"; - return mockCommandPlatformApi({ + return { network: opts.network, handler: (request) => Effect.sync(() => { @@ -112,7 +114,11 @@ function buildApiLayer(opts: SetupOpts) { } return jsonResponse(request, 200, null); }), - }); + }; +} + +function buildApiLayer(opts: SetupOpts) { + return mockCommandPlatformApi(apiOptions(opts)); } function setup(opts: SetupOpts = {}) { @@ -130,10 +136,11 @@ function setup(opts: SetupOpts = {}) { cliSettings, analytics, tty: mockTty({ stdinIsTty: opts.stdinIsTty ?? false, stdoutIsTty: false }), - stdin: mockStdin(opts.stdinIsTty ?? false, opts.stdinInput), + stdin: mockStdin(opts.stdinIsTty ?? false), outputFlag: opts.outputFlag === undefined ? Option.none() : Option.some(opts.outputFlag), }), - Layer.succeed(YesFlag, opts.yes ?? false), + Layer.succeed(YesFlag, opts.yes ?? true), + hostedCreatePolicy, ); return { layer, out, api, analytics }; } @@ -154,7 +161,8 @@ function setupTracked(opts: SetupOpts = {}) { telemetry: telemetry.layer, linkedProjectCache: cache.layer, }), - Layer.succeed(YesFlag, opts.yes ?? false), + Layer.succeed(YesFlag, opts.yes ?? true), + hostedCreatePolicy, ); return { layer, out, api, telemetry, cache, analytics }; } @@ -247,75 +255,40 @@ describe("branches create integration", () => { const withGitBranch = <A, E, R>(effect: Effect.Effect<A, E, R>, branch = "feat-y") => withConfigEnv({ GITHUB_HEAD_REF: branch }, effect); - it.effect("--yes auto-confirms the git-branch name with the [Y/n] y echo", () => { - const { layer, out, api } = setup({ yes: true, stdinIsTty: true }); - return withGitBranch( - Effect.gen(function* () { - yield* branchesCreate(baseFlags); - expect(out.stderrText).toContain("Do you want to create a branch named "); - expect(out.stderrText).toContain("? [Y/n] y\n"); - expect(api.requests[0]?.body).toMatchObject({ - branch_name: "feat-y", - git_branch: "feat-y", - }); - }).pipe(Effect.provide(layer)), - ); - }); - - it.effect("SUPABASE_YES=1 auto-confirms the git-branch name like --yes", () => { - const { layer, out, api } = setup({ stdinIsTty: true }); - return withGitBranch( - withConfigEnv( - { SUPABASE_YES: "1" }, + it.effect( + "--yes names the branch after the current git branch and echoes the confirmation", + () => { + const { layer, out, api } = setup({ yes: true }); + return withGitBranch( Effect.gen(function* () { yield* branchesCreate(baseFlags); - expect(out.stderrText).toContain("? [Y/n] y\n"); - expect(api.requests[0]?.body).toMatchObject({ branch_name: "feat-y" }); + expect(out.stderrText).toContain("Confirmed by --yes: Create preview branch feat-y on "); + expect(api.requests[0]?.body).toMatchObject({ + branch_name: "feat-y", + git_branch: "feat-y", + }); }).pipe(Effect.provide(layer)), - ), - ); - }); - - it.effect("non-TTY with piped `n` declines the git-branch name", () => { - const { layer, out, api } = setup({ stdinIsTty: false, stdinInput: "n\n" }); - return withGitBranch( - Effect.gen(function* () { - const exit = yield* Effect.exit(branchesCreate(baseFlags)); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - expect(Cause.pretty(exit.cause)).toContain("BranchesCreateCancelledError"); - } - expect(out.stderrText).toContain("? [Y/n] n\n"); - expect(api.requests).toHaveLength(0); - }).pipe(Effect.provide(layer)), - ); - }); + ); + }, + ); - it.effect("non-TTY with empty stdin takes the Yes default and creates the branch", () => { - const { layer, out, api } = setup({ stdinIsTty: false }); + it.effect("asks once on a TTY and says the name came from the git branch", () => { + const { layer, out, api } = setup({ + yes: false, + stdinIsTty: true, + promptConfirmResponses: [true], + }); return withGitBranch( Effect.gen(function* () { yield* branchesCreate(baseFlags); - expect(out.stderrText).toContain("? [Y/n] \n"); + expect(out.promptConfirmCalls).toHaveLength(1); + expect(out.promptConfirmCalls[0]?.message).toContain("Create preview branch feat-y"); + expect(out.stderrText).toContain("Branch name taken from the current git branch"); expect(api.requests[0]?.body).toMatchObject({ branch_name: "feat-y" }); }).pipe(Effect.provide(layer)), ); }); - it.effect("TTY decline of the git-branch name cancels without creating", () => { - const { layer, api } = setup({ stdinIsTty: true, promptConfirmResponses: [false] }); - return withGitBranch( - Effect.gen(function* () { - const exit = yield* Effect.exit(branchesCreate(baseFlags)); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - expect(Cause.pretty(exit.cause)).toContain("BranchesCreateCancelledError"); - } - expect(api.requests).toHaveLength(0); - }).pipe(Effect.provide(layer)), - ); - }); - it.effect("emits a success event for --output-format=json", () => { const { layer, out } = setup({ format: "json" }); return Effect.gen(function* () { @@ -465,3 +438,8 @@ function rejectsInvalidSizeChoice(error: unknown): boolean { ) ); } + +describeHostedConsent("branches create", { + api: apiOptions({}), + invoke: () => ({ run: branchesCreate({ ...baseFlags, name: Option.some("feat-x") }) }), +}); diff --git a/apps/cli/src/commands/branches/create/create.live.test.ts b/apps/cli/src/commands/branches/create/create.live.test.ts index 5280c94bd9..57915ab5f9 100644 --- a/apps/cli/src/commands/branches/create/create.live.test.ts +++ b/apps/cli/src/commands/branches/create/create.live.test.ts @@ -28,6 +28,7 @@ test("creates a preview branch", ({ cliEffect, project }) => project.ref, "--output-format", "json", + "--yes", ]); requireLiveSuccess(result, "branches create"); const refBody = yield* Schema.decodeEffect(Schema.fromJsonString(CreatedBranchRef))( diff --git a/apps/cli/src/commands/branches/delete/delete.handler.ts b/apps/cli/src/commands/branches/delete/delete.handler.ts index d6eefd83d7..2a1dc3edb0 100644 --- a/apps/cli/src/commands/branches/delete/delete.handler.ts +++ b/apps/cli/src/commands/branches/delete/delete.handler.ts @@ -4,6 +4,7 @@ import { CommandPlatformApi } from "../../../auth/command-platform-api.service.t 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"; +import { requireHostedMutationConsent } from "../../../command-internal/hosted-consent/hosted-consent.ts"; import { mapHttpError } from "../../../command-internal/http-errors.ts"; import { resolveParentScopedProjectRef } from "../../../command-internal/parent-project-ref.ts"; import { @@ -37,6 +38,11 @@ export const branchesDelete = Effect.fn("branches.delete")(function* (flags: Bra const branchInput = yield* promptBranchId(flags.name, ref); const branchRef = yield* resolveBranchProjectRef(branchInput, ref); + yield* requireHostedMutationConsent({ + action: `Delete preview branch ${branchInput}`, + details: [`Branch project ref: ${branchRef}`], + }); + const deleting = output.format === "text" ? yield* output.task("Deleting branch...") : undefined; yield* api.v1.deleteABranch({ branch_id_or_ref: branchRef }).pipe( diff --git a/apps/cli/src/commands/branches/delete/delete.integration.test.ts b/apps/cli/src/commands/branches/delete/delete.integration.test.ts index 26b12ec97f..c59335cea7 100644 --- a/apps/cli/src/commands/branches/delete/delete.integration.test.ts +++ b/apps/cli/src/commands/branches/delete/delete.integration.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "@effect/vitest"; -import { Cause, Effect, Exit, FileSystem, Option, Path } from "effect"; +import { Cause, Effect, Exit, FileSystem, Layer, Option, Path } from "effect"; import { mockOutput } from "../../../../tests/helpers/mocks.ts"; import { @@ -8,11 +8,15 @@ import { mockCommandSettings, mockLinkedProjectCacheTracked, mockCommandPlatformApi, + type MockCommandPlatformApiOpts, mockTelemetryStateTracked, useTempWorkdir, } from "../../../../tests/helpers/command-mocks.ts"; +import { describeHostedConsent } from "../../../../tests/helpers/hosted-consent.ts"; import type { BranchesDeleteFlags } from "./delete.command.ts"; import { branchesDelete } from "./delete.handler.ts"; +import { YesFlag } from "../../../command-internal/global-flags.ts"; +import { TargetPolicy } from "../../../command-internal/target/target-policy.ts"; // `branch_id_or_ref` is a oneOf [project-ref, uuid]; a 20-lowercase ref would match both shapes // and get rejected, so tests use a v4 UUID to pick exactly one. @@ -32,13 +36,20 @@ const BRANCH_CONFIG = { const tempRoot = useTempWorkdir("supabase-branches-delete-int-"); +const buildRuntime = (opts: Parameters<typeof buildTestRuntime>[0]) => + Layer.mergeAll( + buildTestRuntime(opts), + Layer.succeed(YesFlag, true), + Layer.succeed(TargetPolicy, { group: "hosted", consent: "no" }), + ); + interface SetupOpts { readonly deleteStatus?: number; } -function buildApi(opts: SetupOpts) { +function apiOptions(opts: SetupOpts): MockCommandPlatformApiOpts { const deleteStatus = opts.deleteStatus ?? 200; - return mockCommandPlatformApi({ + return { handler: (request) => Effect.sync(() => { if (request.method === "DELETE" && request.url.includes("/v1/branches/")) { @@ -49,14 +60,18 @@ function buildApi(opts: SetupOpts) { } return jsonResponse(request, 200, null); }), - }); + }; +} + +function buildApi(opts: SetupOpts) { + return mockCommandPlatformApi(apiOptions(opts)); } function setup(opts: SetupOpts = {}) { const out = mockOutput({ format: "text" }); const api = buildApi(opts); const cliSettings = mockCommandSettings({ workdir: tempRoot.current }); - const layer = buildTestRuntime({ out, api, cliSettings }); + const layer = buildRuntime({ out, api, cliSettings }); return { layer, out, api }; } @@ -66,7 +81,7 @@ function setupTracked(opts: SetupOpts = {}) { const cliSettings = mockCommandSettings({ workdir: tempRoot.current }); const telemetry = mockTelemetryStateTracked(); const cache = mockLinkedProjectCacheTracked(); - const layer = buildTestRuntime({ + const layer = buildRuntime({ out, api, cliSettings, @@ -172,7 +187,7 @@ describe("branches delete integration", () => { workdir: tempRoot.current, projectId: Option.none(), }); - const layer = buildTestRuntime({ out, api, cliSettings }); + const layer = buildRuntime({ out, api, cliSettings }); return Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -198,3 +213,8 @@ describe("branches delete integration", () => { }, ); }); + +describeHostedConsent("branches delete", { + api: apiOptions({}), + invoke: () => ({ run: branchesDelete({ ...baseFlags, name: Option.some(BRANCH_UUID) }) }), +}); diff --git a/apps/cli/src/commands/branches/disable/disable.handler.ts b/apps/cli/src/commands/branches/disable/disable.handler.ts index 0ed631772d..3407df0834 100644 --- a/apps/cli/src/commands/branches/disable/disable.handler.ts +++ b/apps/cli/src/commands/branches/disable/disable.handler.ts @@ -4,6 +4,7 @@ import { CommandPlatformApi } from "../../../auth/command-platform-api.service.t 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"; +import { requireHostedMutationConsent } from "../../../command-internal/hosted-consent/hosted-consent.ts"; import { mapHttpError } from "../../../command-internal/http-errors.ts"; import { resolveParentScopedProjectRef } from "../../../command-internal/parent-project-ref.ts"; import { @@ -34,6 +35,8 @@ export const branchesDisable = Effect.fn("branches.disable")(function* ( yield* Effect.annotateCurrentSpan("project.ref", ref); yield* Effect.gen(function* () { + yield* requireHostedMutationConsent({ action: "Disable preview branching" }); + const disabling = output.format === "text" ? yield* output.task("Disabling preview branching...") : undefined; yield* api.v1.disablePreviewBranching({ ref }).pipe( diff --git a/apps/cli/src/commands/branches/disable/disable.integration.test.ts b/apps/cli/src/commands/branches/disable/disable.integration.test.ts index 1ca9da0e76..a42620ecb2 100644 --- a/apps/cli/src/commands/branches/disable/disable.integration.test.ts +++ b/apps/cli/src/commands/branches/disable/disable.integration.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "@effect/vitest"; -import { Cause, Effect, Exit, Option } from "effect"; +import { Cause, Effect, Exit, Layer, Option } from "effect"; import { mockOutput } from "../../../../tests/helpers/mocks.ts"; import { @@ -11,11 +11,21 @@ import { mockTelemetryStateTracked, useTempWorkdir, } from "../../../../tests/helpers/command-mocks.ts"; +import { describeHostedConsent } from "../../../../tests/helpers/hosted-consent.ts"; import type { BranchesDisableFlags } from "./disable.command.ts"; import { branchesDisable } from "./disable.handler.ts"; +import { YesFlag } from "../../../command-internal/global-flags.ts"; +import { TargetPolicy } from "../../../command-internal/target/target-policy.ts"; const tempRoot = useTempWorkdir("supabase-branches-disable-int-"); +const buildRuntime = (opts: Parameters<typeof buildTestRuntime>[0]) => + Layer.mergeAll( + buildTestRuntime(opts), + Layer.succeed(YesFlag, true), + Layer.succeed(TargetPolicy, { group: "hosted", consent: "no" }), + ); + interface SetupOpts { readonly status?: number; } @@ -26,7 +36,7 @@ function setup(opts: SetupOpts = {}) { response: { status: opts.status ?? 200, body: null }, }); const cliSettings = mockCommandSettings({ workdir: tempRoot.current }); - const layer = buildTestRuntime({ out, api, cliSettings }); + const layer = buildRuntime({ out, api, cliSettings }); return { layer, out, api }; } @@ -38,7 +48,7 @@ function setupTracked(opts: SetupOpts = {}) { const cliSettings = mockCommandSettings({ workdir: tempRoot.current }); const telemetry = mockTelemetryStateTracked(); const cache = mockLinkedProjectCacheTracked(); - const layer = buildTestRuntime({ + const layer = buildRuntime({ out, api, cliSettings, @@ -86,3 +96,8 @@ describe("branches disable integration", () => { }).pipe(Effect.provide(layer)); }); }); + +describeHostedConsent("branches disable", { + api: { response: { status: 200, body: null } }, + invoke: () => ({ run: branchesDisable(baseFlags) }), +}); diff --git a/apps/cli/src/commands/branches/disable/disable.live.test.ts b/apps/cli/src/commands/branches/disable/disable.live.test.ts index 5243068392..40aa84b81f 100644 --- a/apps/cli/src/commands/branches/disable/disable.live.test.ts +++ b/apps/cli/src/commands/branches/disable/disable.live.test.ts @@ -39,7 +39,13 @@ test("disables preview branching", ({ cliEffect, project }) => yield* awaitLiveBranchesRemovedEffect(cliEffect, project); branchRef = undefined; - const disabled = yield* cliEffect(["branches", "disable", "--project-ref", project.ref]); + const disabled = yield* cliEffect([ + "branches", + "disable", + "--project-ref", + project.ref, + "--yes", + ]); expect(disabled.exitCode, disabled.stderr).toBe(0); expect(disabled.stdout).toContain(`Disabled preview branching for project: ${project.ref}`); }); diff --git a/apps/cli/src/commands/branches/pause/pause.handler.ts b/apps/cli/src/commands/branches/pause/pause.handler.ts index 9d41d1059a..7bd6c83418 100644 --- a/apps/cli/src/commands/branches/pause/pause.handler.ts +++ b/apps/cli/src/commands/branches/pause/pause.handler.ts @@ -4,6 +4,7 @@ import { CommandPlatformApi } from "../../../auth/command-platform-api.service.t 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"; +import { requireHostedMutationConsent } from "../../../command-internal/hosted-consent/hosted-consent.ts"; import { mapHttpError } from "../../../command-internal/http-errors.ts"; import { resolveParentScopedProjectRef } from "../../../command-internal/parent-project-ref.ts"; import { @@ -36,6 +37,11 @@ export const branchesPause = Effect.fn("branches.pause")(function* (flags: Branc const branchInput = yield* promptBranchId(flags.name, ref); const branchRef = yield* resolveBranchProjectRef(branchInput, ref); + yield* requireHostedMutationConsent({ + action: `Pause preview branch ${branchInput}`, + details: [`Branch project ref: ${branchRef}`], + }); + const pausing = output.format === "text" ? yield* output.task("Pausing branch...") : undefined; yield* api.v1.pauseAProject({ ref: branchRef }).pipe( Effect.tapError(() => pausing?.fail() ?? Effect.void), diff --git a/apps/cli/src/commands/branches/pause/pause.integration.test.ts b/apps/cli/src/commands/branches/pause/pause.integration.test.ts index 9f567cd67d..e1a7060f8d 100644 --- a/apps/cli/src/commands/branches/pause/pause.integration.test.ts +++ b/apps/cli/src/commands/branches/pause/pause.integration.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "@effect/vitest"; -import { Cause, Effect, Exit, Option } from "effect"; +import { Cause, Effect, Exit, Layer, Option } from "effect"; import { mockOutput } from "../../../../tests/helpers/mocks.ts"; import { @@ -9,12 +9,16 @@ import { mockCommandSettings, mockLinkedProjectCacheTracked, mockCommandPlatformApi, + type MockCommandPlatformApiOpts, mockTelemetryStateTracked, useTempWorkdir, unverifiedTargetBanner, } from "../../../../tests/helpers/command-mocks.ts"; +import { describeHostedConsent } from "../../../../tests/helpers/hosted-consent.ts"; import type { BranchesPauseFlags } from "./pause.command.ts"; import { branchesPause } from "./pause.handler.ts"; +import { YesFlag } from "../../../command-internal/global-flags.ts"; +import { TargetPolicy } from "../../../command-internal/target/target-policy.ts"; // Pause/restore endpoints accept a plain project ref, unlike getABranchConfig's oneOf. const BRANCH_REF = "cccccccccccccccccccc"; @@ -45,13 +49,22 @@ const BRANCH_NAMED_LOOKUP = { const tempRoot = useTempWorkdir("supabase-branches-pause-int-"); +const buildRuntime = (opts: Parameters<typeof buildTestRuntime>[0]) => + Layer.mergeAll( + buildTestRuntime(opts), + Layer.succeed(YesFlag, true), + Layer.succeed(TargetPolicy, { group: "hosted", consent: "no" }), + ); + +const confirmed = `Confirmed by --yes: Pause preview branch ${BRANCH_REF} on project ${VALID_REF} · identity unverified\n`; + interface SetupOpts { readonly pauseStatus?: number; } -function buildApi(opts: SetupOpts) { +function apiOptions(opts: SetupOpts): MockCommandPlatformApiOpts { const pauseStatus = opts.pauseStatus ?? 200; - return mockCommandPlatformApi({ + return { handler: (request) => Effect.sync(() => { if (request.method === "POST" && request.url.endsWith("/pause")) { @@ -68,14 +81,18 @@ function buildApi(opts: SetupOpts) { } return jsonResponse(request, 200, null); }), - }); + }; +} + +function buildApi(opts: SetupOpts) { + return mockCommandPlatformApi(apiOptions(opts)); } function setup(opts: SetupOpts = {}) { const out = mockOutput({ format: "text" }); const api = buildApi(opts); const cliSettings = mockCommandSettings({ workdir: tempRoot.current }); - const layer = buildTestRuntime({ out, api, cliSettings }); + const layer = buildRuntime({ out, api, cliSettings }); return { layer, out, api }; } @@ -85,7 +102,7 @@ function setupTracked(opts: SetupOpts = {}) { const cliSettings = mockCommandSettings({ workdir: tempRoot.current }); const telemetry = mockTelemetryStateTracked(); const cache = mockLinkedProjectCacheTracked(); - const layer = buildTestRuntime({ + const layer = buildRuntime({ out, api, cliSettings, @@ -142,7 +159,7 @@ describe("branches pause integration", () => { return Effect.gen(function* () { yield* branchesPause({ ...baseFlags, name: Option.some(BRANCH_REF) }); expect(out.stdoutText).toBe(""); - expect(out.stderrText).toBe(unverifiedTargetBanner()); + expect(out.stderrText).toBe(unverifiedTargetBanner() + confirmed); }).pipe(Effect.provide(layer)); }); @@ -170,3 +187,8 @@ describe("branches pause integration", () => { }).pipe(Effect.provide(layer)); }); }); + +describeHostedConsent("branches pause", { + api: apiOptions({}), + invoke: () => ({ run: branchesPause({ ...baseFlags, name: Option.some(BRANCH_REF) }) }), +}); diff --git a/apps/cli/src/commands/branches/unpause/unpause.handler.ts b/apps/cli/src/commands/branches/unpause/unpause.handler.ts index 40fda69422..e1a4bdb60f 100644 --- a/apps/cli/src/commands/branches/unpause/unpause.handler.ts +++ b/apps/cli/src/commands/branches/unpause/unpause.handler.ts @@ -4,6 +4,7 @@ import { CommandPlatformApi } from "../../../auth/command-platform-api.service.t 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"; +import { requireHostedMutationConsent } from "../../../command-internal/hosted-consent/hosted-consent.ts"; import { mapHttpError } from "../../../command-internal/http-errors.ts"; import { resolveParentScopedProjectRef } from "../../../command-internal/parent-project-ref.ts"; import { @@ -38,6 +39,11 @@ export const branchesUnpause = Effect.fn("branches.unpause")(function* ( const branchInput = yield* promptBranchId(flags.name, ref); const branchRef = yield* resolveBranchProjectRef(branchInput, ref); + yield* requireHostedMutationConsent({ + action: `Unpause preview branch ${branchInput}`, + details: [`Branch project ref: ${branchRef}`], + }); + const restoring = output.format === "text" ? yield* output.task("Unpausing branch...") : undefined; yield* api.v1.restoreAProject({ ref: branchRef }).pipe( diff --git a/apps/cli/src/commands/branches/unpause/unpause.integration.test.ts b/apps/cli/src/commands/branches/unpause/unpause.integration.test.ts index a341ea98df..92ff420347 100644 --- a/apps/cli/src/commands/branches/unpause/unpause.integration.test.ts +++ b/apps/cli/src/commands/branches/unpause/unpause.integration.test.ts @@ -1,8 +1,9 @@ import { describe, expect, it } from "@effect/vitest"; -import { Cause, Effect, Exit, Option } from "effect"; +import { Cause, Effect, Exit, Layer, Option } from "effect"; import { mockOutput } from "../../../../tests/helpers/mocks.ts"; import { + VALID_REF, buildTestRuntime, jsonResponse, mockCommandSettings, @@ -10,14 +11,26 @@ import { useTempWorkdir, unverifiedTargetBanner, } from "../../../../tests/helpers/command-mocks.ts"; +import { describeHostedConsent } from "../../../../tests/helpers/hosted-consent.ts"; import type { BranchesUnpauseFlags } from "./unpause.command.ts"; import { branchesUnpause } from "./unpause.handler.ts"; +import { YesFlag } from "../../../command-internal/global-flags.ts"; +import { TargetPolicy } from "../../../command-internal/target/target-policy.ts"; // 20-lowercase ref (matches V1RestoreAProjectInput.ref plain pattern). const BRANCH_REF = "cccccccccccccccccccc"; const tempRoot = useTempWorkdir("supabase-branches-unpause-int-"); +const buildRuntime = (opts: Parameters<typeof buildTestRuntime>[0]) => + Layer.mergeAll( + buildTestRuntime(opts), + Layer.succeed(YesFlag, true), + Layer.succeed(TargetPolicy, { group: "hosted", consent: "no" }), + ); + +const confirmed = `Confirmed by --yes: Unpause preview branch ${BRANCH_REF} on project ${VALID_REF} · identity unverified\n`; + interface SetupOpts { readonly restoreStatus?: number; } @@ -35,7 +48,7 @@ function setup(opts: SetupOpts = {}) { }), }); const cliSettings = mockCommandSettings({ workdir: tempRoot.current }); - const layer = buildTestRuntime({ out, api, cliSettings }); + const layer = buildRuntime({ out, api, cliSettings }); return { layer, out, api }; } @@ -60,7 +73,7 @@ describe("branches unpause integration", () => { return Effect.gen(function* () { yield* branchesUnpause({ ...baseFlags, name: Option.some(BRANCH_REF) }); expect(out.stdoutText).toBe(""); - expect(out.stderrText).toBe(unverifiedTargetBanner()); + expect(out.stderrText).toBe(unverifiedTargetBanner() + confirmed); }).pipe(Effect.provide(layer)); }); @@ -79,3 +92,8 @@ describe("branches unpause integration", () => { }).pipe(Effect.provide(layer)); }); }); + +describeHostedConsent("branches unpause", { + api: { response: { status: 200, body: null } }, + invoke: () => ({ run: branchesUnpause({ ...baseFlags, name: Option.some(BRANCH_REF) }) }), +}); diff --git a/apps/cli/src/commands/branches/update/update.handler.ts b/apps/cli/src/commands/branches/update/update.handler.ts index 07afcf7023..253a1d438b 100644 --- a/apps/cli/src/commands/branches/update/update.handler.ts +++ b/apps/cli/src/commands/branches/update/update.handler.ts @@ -11,6 +11,7 @@ import { encodeStructToml, encodeStructYaml, } from "../../../command-internal/struct-output.encoders.ts"; +import { requireHostedMutationConsent } from "../../../command-internal/hosted-consent/hosted-consent.ts"; import { mapHttpError } from "../../../command-internal/http-errors.ts"; import { resolveParentScopedProjectRef } from "../../../command-internal/parent-project-ref.ts"; import { gateMapError } from "../../../command-internal/upgrade-suggest.ts"; @@ -49,6 +50,18 @@ export const branchesUpdate = Effect.fn("branches.update")(function* (flags: Bra const branchInput = yield* promptBranchId(flags.branchId, ref); const branchRef = yield* resolveBranchProjectRef(branchInput, ref); + yield* requireHostedMutationConsent({ + action: `Update preview branch ${branchInput}`, + details: [ + `Branch project ref: ${branchRef}`, + ...(Option.isSome(flags.name) ? [`Rename to: ${flags.name.value}`] : []), + ...(Option.isSome(flags.gitBranch) ? [`Git branch: ${flags.gitBranch.value}`] : []), + ...(Option.isSome(flags.persistent) ? [`Persistent: ${flags.persistent.value}`] : []), + ...(Option.isSome(flags.status) ? [`Status: ${flags.status.value}`] : []), + ...(Option.isSome(flags.notifyUrl) ? [`Notify URL: ${flags.notifyUrl.value}`] : []), + ], + }); + const patching = output.format === "text" ? yield* output.task("Updating branch...") : undefined; diff --git a/apps/cli/src/commands/branches/update/update.integration.test.ts b/apps/cli/src/commands/branches/update/update.integration.test.ts index e513badd0d..74b00d5877 100644 --- a/apps/cli/src/commands/branches/update/update.integration.test.ts +++ b/apps/cli/src/commands/branches/update/update.integration.test.ts @@ -1,6 +1,6 @@ import { type V1UpdateABranchConfigOutput } from "@supabase/api/effect"; import { describe, expect, it } from "@effect/vitest"; -import { Cause, Effect, Exit, Option } from "effect"; +import { Cause, Effect, Exit, Layer, Option } from "effect"; import { mockAnalytics, mockOutput } from "../../../../tests/helpers/mocks.ts"; import { @@ -9,11 +9,15 @@ import { mockCommandSettings, mockLinkedProjectCacheTracked, mockCommandPlatformApi, + type MockCommandPlatformApiOpts, mockTelemetryStateTracked, useTempWorkdir, } from "../../../../tests/helpers/command-mocks.ts"; +import { describeHostedConsent } from "../../../../tests/helpers/hosted-consent.ts"; import type { BranchesUpdateFlags } from "./update.command.ts"; import { branchesUpdate } from "./update.handler.ts"; +import { YesFlag } from "../../../command-internal/global-flags.ts"; +import { TargetPolicy } from "../../../command-internal/target/target-policy.ts"; type UpdatedBranch = typeof V1UpdateABranchConfigOutput.Type; @@ -78,15 +82,22 @@ const BRANCH_CONFIG = { const tempRoot = useTempWorkdir("supabase-branches-update-int-"); +const buildRuntime = (opts: Parameters<typeof buildTestRuntime>[0]) => + Layer.mergeAll( + buildTestRuntime(opts), + Layer.succeed(YesFlag, true), + Layer.succeed(TargetPolicy, { group: "hosted", consent: "no" }), + ); + interface SetupOpts { readonly format?: "text" | "json" | "stream-json"; readonly outputFlag?: "env" | "pretty" | "json" | "toml" | "yaml"; readonly patchStatus?: number; } -function buildApi(opts: SetupOpts) { +function apiOptions(opts: SetupOpts): MockCommandPlatformApiOpts { const patchStatus = opts.patchStatus ?? 200; - return mockCommandPlatformApi({ + return { handler: (request) => Effect.sync(() => { if (request.method === "PATCH" && request.url.includes("/v1/branches/")) { @@ -97,7 +108,11 @@ function buildApi(opts: SetupOpts) { } return jsonResponse(request, 200, null); }), - }); + }; +} + +function buildApi(opts: SetupOpts) { + return mockCommandPlatformApi(apiOptions(opts)); } function setup(opts: SetupOpts = {}) { @@ -105,7 +120,7 @@ function setup(opts: SetupOpts = {}) { const analytics = mockAnalytics(); const api = buildApi(opts); const cliSettings = mockCommandSettings({ workdir: tempRoot.current }); - const layer = buildTestRuntime({ + const layer = buildRuntime({ out, api, cliSettings, @@ -122,7 +137,7 @@ function setupTracked(opts: SetupOpts = {}) { const cliSettings = mockCommandSettings({ workdir: tempRoot.current }); const telemetry = mockTelemetryStateTracked(); const cache = mockLinkedProjectCacheTracked(); - const layer = buildTestRuntime({ + const layer = buildRuntime({ out, api, cliSettings, @@ -293,7 +308,7 @@ describe("branches update integration", () => { }), }); - const layer = buildTestRuntime({ + const layer = buildRuntime({ out, api: apiMock, cliSettings, @@ -327,3 +342,14 @@ describe("branches update integration", () => { }, ); }); + +describeHostedConsent("branches update", { + api: apiOptions({}), + invoke: () => ({ + run: branchesUpdate({ + ...baseFlags, + branchId: Option.some(BRANCH_UUID), + name: Option.some("renamed"), + }), + }), +}); diff --git a/apps/cli/src/commands/branches/update/update.live.test.ts b/apps/cli/src/commands/branches/update/update.live.test.ts index f87713b17a..9791111eaa 100644 --- a/apps/cli/src/commands/branches/update/update.live.test.ts +++ b/apps/cli/src/commands/branches/update/update.live.test.ts @@ -30,6 +30,7 @@ test("renames a preview branch", ({ cliEffect, project }) => renamed, "--output", "json", + "--yes", ]); expect(updated.exitCode, updated.stderr).toBe(0); expect(updated.stderr).toContain("Updated preview branch"); diff --git a/apps/cli/src/commands/domains/activate/activate.handler.ts b/apps/cli/src/commands/domains/activate/activate.handler.ts index 1728215196..940cb7cbe9 100644 --- a/apps/cli/src/commands/domains/activate/activate.handler.ts +++ b/apps/cli/src/commands/domains/activate/activate.handler.ts @@ -7,6 +7,7 @@ import { LinkedProjectCache } from "../../../telemetry/linked-project-cache.serv import { TelemetryState } from "../../../telemetry/telemetry-state.service.ts"; import { emitHostnameResult } from "../domains.emit.ts"; import { mapDomainsHttpError } from "../domains.errors.ts"; +import { requireHostedMutationConsent } from "../../../command-internal/hosted-consent/hosted-consent.ts"; import { gateMapError } from "../../../command-internal/upgrade-suggest.ts"; import type { DomainsActivateFlags } from "./activate.command.ts"; @@ -24,6 +25,11 @@ export const domainsActivate = Effect.fn("domains.activate")(function* ( const ref = yield* resolver.resolve(flags.projectRef); yield* Effect.gen(function* () { + yield* requireHostedMutationConsent({ + action: "Activate the custom hostname", + details: ["Third-party auth providers stop working on the Supabase-provisioned subdomain."], + }); + const activating = output.format === "text" ? yield* output.task("Activating custom hostname...") : undefined; const response = yield* api.v1.activateCustomHostname({ ref }).pipe( diff --git a/apps/cli/src/commands/domains/activate/activate.integration.test.ts b/apps/cli/src/commands/domains/activate/activate.integration.test.ts index a9f8fb6696..f26acb0869 100644 --- a/apps/cli/src/commands/domains/activate/activate.integration.test.ts +++ b/apps/cli/src/commands/domains/activate/activate.integration.test.ts @@ -1,6 +1,6 @@ import { type V1GetHostnameConfigOutput } from "@supabase/api/effect"; import { describe, expect, it } from "@effect/vitest"; -import { Cause, Effect, Exit, Option } from "effect"; +import { Cause, Effect, Exit, Layer, Option } from "effect"; import { mockAnalytics, mockOutput } from "../../../../tests/helpers/mocks.ts"; import { @@ -11,7 +11,10 @@ import { mockTelemetryStateTracked, useTempWorkdir, } from "../../../../tests/helpers/command-mocks.ts"; +import { describeHostedConsent } from "../../../../tests/helpers/hosted-consent.ts"; import { domainsActivate } from "./activate.handler.ts"; +import { YesFlag } from "../../../command-internal/global-flags.ts"; +import { TargetPolicy } from "../../../command-internal/target/target-policy.ts"; const HOSTNAME_RESPONSE: typeof V1GetHostnameConfigOutput.Type = { status: "5_services_reconfigured", @@ -43,6 +46,13 @@ interface SetupOpts { const tempRoot = useTempWorkdir("supabase-domains-activate-int-"); +const buildRuntime = (opts: Parameters<typeof buildTestRuntime>[0]) => + Layer.mergeAll( + buildTestRuntime(opts), + Layer.succeed(YesFlag, true), + Layer.succeed(TargetPolicy, { group: "hosted", consent: "no" }), + ); + function setup(opts: SetupOpts = {}) { const out = mockOutput({ format: opts.format ?? "text" }); const analytics = mockAnalytics(); @@ -53,7 +63,7 @@ function setup(opts: SetupOpts = {}) { const cliSettings = mockCommandSettings({ workdir: tempRoot.current }); const telemetry = mockTelemetryStateTracked(); const linkedProjectCache = mockLinkedProjectCacheTracked(); - const layer = buildTestRuntime({ + const layer = buildRuntime({ out, api, cliSettings, @@ -159,3 +169,8 @@ describe("domains activate integration", () => { }).pipe(Effect.provide(layer)); }); }); + +describeHostedConsent("domains activate", { + api: { response: { status: 201, body: HOSTNAME_RESPONSE } }, + invoke: () => ({ run: domainsActivate(baseFlags) }), +}); diff --git a/apps/cli/src/commands/domains/create/create.handler.ts b/apps/cli/src/commands/domains/create/create.handler.ts index 1d58a47ddf..10c369c5fd 100644 --- a/apps/cli/src/commands/domains/create/create.handler.ts +++ b/apps/cli/src/commands/domains/create/create.handler.ts @@ -10,6 +10,7 @@ import { TelemetryState } from "../../../telemetry/telemetry-state.service.ts"; import { verifyCname } from "../domains.cname.ts"; import { emitHostnameResult } from "../domains.emit.ts"; import { mapDomainsHttpError } from "../domains.errors.ts"; +import { requireHostedMutationConsent } from "../../../command-internal/hosted-consent/hosted-consent.ts"; import { gateMapError } from "../../../command-internal/upgrade-suggest.ts"; import type { DomainsCreateFlags } from "./create.command.ts"; @@ -37,6 +38,10 @@ export const domainsCreate = Effect.fn("domains.create")(function* (flags: Domai customHostname: flags.customHostname, }); + yield* requireHostedMutationConsent({ + action: `Create custom hostname ${flags.customHostname}`, + }); + // 2. Initialize the custom hostname. const creating = output.format === "text" ? yield* output.task("Creating custom hostname...") : undefined; diff --git a/apps/cli/src/commands/domains/create/create.integration.test.ts b/apps/cli/src/commands/domains/create/create.integration.test.ts index 5ce24aedfb..1dcf8c1cec 100644 --- a/apps/cli/src/commands/domains/create/create.integration.test.ts +++ b/apps/cli/src/commands/domains/create/create.integration.test.ts @@ -1,6 +1,6 @@ import { type V1GetHostnameConfigOutput } from "@supabase/api/effect"; import { describe, expect, it } from "@effect/vitest"; -import { Cause, Deferred, Effect, Exit, Fiber, Option } from "effect"; +import { Cause, Deferred, Effect, Exit, Fiber, Layer, Option } from "effect"; import { TestClock } from "effect/testing"; import { mockAnalytics, mockOutput } from "../../../../tests/helpers/mocks.ts"; @@ -12,12 +12,16 @@ import { mockCommandSettings, mockLinkedProjectCacheTracked, mockCommandPlatformApi, + type MockCommandPlatformApiOpts, mockTelemetryStateTracked, useTempWorkdir, unverifiedTargetBanner, } from "../../../../tests/helpers/command-mocks.ts"; import { DomainsCnameError } from "../domains.errors.ts"; +import { describeHostedConsent } from "../../../../tests/helpers/hosted-consent.ts"; import { domainsCreate } from "./create.handler.ts"; +import { YesFlag } from "../../../command-internal/global-flags.ts"; +import { TargetPolicy } from "../../../command-internal/target/target-policy.ts"; const CUSTOM_HOSTNAME = "shop.acme.dev"; const EXPECTED_CNAME = `${VALID_REF}.supabase.co.`; @@ -54,10 +58,18 @@ interface SetupOpts { const tempRoot = useTempWorkdir("supabase-domains-create-int-"); -function setup(opts: SetupOpts = {}) { +const buildRuntime = (opts: Parameters<typeof buildTestRuntime>[0]) => + Layer.mergeAll( + buildTestRuntime(opts), + Layer.succeed(YesFlag, true), + Layer.succeed(TargetPolicy, { group: "hosted", consent: "yes" }), + ); + +const confirmed = `Confirmed by --yes: Create custom hostname ${CUSTOM_HOSTNAME} on project ${VALID_REF} · identity unverified\n`; + +function apiOptions(opts: SetupOpts): MockCommandPlatformApiOpts { const cname = opts.cname ?? "ok"; - const out = mockOutput({ format: opts.format ?? "text" }); - const api = mockCommandPlatformApi({ + return { handler: (request) => { if (request.url.includes("1.1.1.1")) { if (opts.dnsRequested !== undefined) { @@ -82,12 +94,17 @@ function setup(opts: SetupOpts = {}) { jsonResponse(request, opts.apiStatus ?? 201, opts.apiResponse ?? HOSTNAME_RESPONSE), ); }, - }); + }; +} + +function setup(opts: SetupOpts = {}) { + const out = mockOutput({ format: opts.format ?? "text" }); + const api = mockCommandPlatformApi(apiOptions(opts)); const cliSettings = mockCommandSettings({ workdir: tempRoot.current }); const analytics = mockAnalytics(); const telemetry = mockTelemetryStateTracked(); const linkedProjectCache = mockLinkedProjectCacheTracked(); - const layer = buildTestRuntime({ + const layer = buildRuntime({ out, api, cliSettings, @@ -249,7 +266,7 @@ describe("domains create integration", () => { return Effect.gen(function* () { yield* domainsCreate(flags()); expect(out.stdoutText.startsWith("{")).toBe(true); - expect(out.stderrText).toBe(unverifiedTargetBanner()); + expect(out.stderrText).toBe(unverifiedTargetBanner() + confirmed); }).pipe(Effect.provide(layer)); }); @@ -259,7 +276,7 @@ describe("domains create integration", () => { yield* domainsCreate(flags()); // YAML output lowercases the whole field name. expect(out.stdoutText).toContain(`customhostname: ${CUSTOM_HOSTNAME}`); - expect(out.stderrText).toBe(unverifiedTargetBanner()); + expect(out.stderrText).toBe(unverifiedTargetBanner() + confirmed); }).pipe(Effect.provide(layer)); }); @@ -303,3 +320,8 @@ describe("domains create integration", () => { }).pipe(Effect.provide(layer)); }); }); + +describeHostedConsent("domains create", { + api: apiOptions({}), + invoke: () => ({ run: domainsCreate(flags()) }), +}); diff --git a/apps/cli/src/commands/domains/delete/delete.handler.ts b/apps/cli/src/commands/domains/delete/delete.handler.ts index 7a779f49bb..b5a58682d9 100644 --- a/apps/cli/src/commands/domains/delete/delete.handler.ts +++ b/apps/cli/src/commands/domains/delete/delete.handler.ts @@ -5,6 +5,7 @@ import { ProjectRefResolver } from "../../../config/project-ref.service.ts"; import { Output } from "../../../shared/output/output.service.ts"; import { LinkedProjectCache } from "../../../telemetry/linked-project-cache.service.ts"; import { TelemetryState } from "../../../telemetry/telemetry-state.service.ts"; +import { requireHostedMutationConsent } from "../../../command-internal/hosted-consent/hosted-consent.ts"; import { mapDomainsHttpError } from "../domains.errors.ts"; import type { DomainsDeleteFlags } from "./delete.command.ts"; @@ -22,6 +23,8 @@ export const domainsDelete = Effect.fn("domains.delete")(function* (flags: Domai const ref = yield* resolver.resolve(flags.projectRef); yield* Effect.gen(function* () { + yield* requireHostedMutationConsent({ action: "Delete the custom hostname config" }); + const deleting = output.format === "text" ? yield* output.task("Deleting custom hostname config...") diff --git a/apps/cli/src/commands/domains/delete/delete.integration.test.ts b/apps/cli/src/commands/domains/delete/delete.integration.test.ts index 852b3867ac..981245056f 100644 --- a/apps/cli/src/commands/domains/delete/delete.integration.test.ts +++ b/apps/cli/src/commands/domains/delete/delete.integration.test.ts @@ -1,8 +1,9 @@ import { describe, expect, it } from "@effect/vitest"; -import { Cause, Effect, Exit, Option } from "effect"; +import { Cause, Effect, Exit, Layer, Option } from "effect"; import { mockOutput } from "../../../../tests/helpers/mocks.ts"; import { + VALID_REF, buildTestRuntime, mockCommandSettings, mockLinkedProjectCacheTracked, @@ -11,7 +12,10 @@ import { useTempWorkdir, unverifiedTargetBanner, } from "../../../../tests/helpers/command-mocks.ts"; +import { describeHostedConsent } from "../../../../tests/helpers/hosted-consent.ts"; import { domainsDelete } from "./delete.handler.ts"; +import { YesFlag } from "../../../command-internal/global-flags.ts"; +import { TargetPolicy } from "../../../command-internal/target/target-policy.ts"; type OutputFlagValue = "env" | "pretty" | "json" | "toml" | "yaml"; @@ -24,6 +28,15 @@ interface SetupOpts { const tempRoot = useTempWorkdir("supabase-domains-delete-int-"); +const buildRuntime = (opts: Parameters<typeof buildTestRuntime>[0]) => + Layer.mergeAll( + buildTestRuntime(opts), + Layer.succeed(YesFlag, true), + Layer.succeed(TargetPolicy, { group: "hosted", consent: "no" }), + ); + +const confirmed = `Confirmed by --yes: Delete the custom hostname config on project ${VALID_REF} · identity unverified\n`; + function setup(opts: SetupOpts = {}) { const out = mockOutput({ format: opts.format ?? "text" }); const api = mockCommandPlatformApi({ @@ -33,7 +46,7 @@ function setup(opts: SetupOpts = {}) { const cliSettings = mockCommandSettings({ workdir: tempRoot.current }); const telemetry = mockTelemetryStateTracked(); const linkedProjectCache = mockLinkedProjectCacheTracked(); - const layer = buildTestRuntime({ + const layer = buildRuntime({ out, api, cliSettings, @@ -52,7 +65,7 @@ describe("domains delete integration", () => { return Effect.gen(function* () { yield* domainsDelete(baseFlags); expect(out.stderrText).toBe( - unverifiedTargetBanner() + "Deleted custom hostname config successfully.\n", + unverifiedTargetBanner() + confirmed + "Deleted custom hostname config successfully.\n", ); expect(out.stdoutText).toBe(""); expect(api.requests[0]?.method).toBe("DELETE"); @@ -84,7 +97,7 @@ describe("domains delete integration", () => { yield* domainsDelete(baseFlags); expect(out.stdoutText).toBe(""); expect(out.stderrText).toBe( - unverifiedTargetBanner() + "Deleted custom hostname config successfully.\n", + unverifiedTargetBanner() + confirmed + "Deleted custom hostname config successfully.\n", ); }).pipe(Effect.provide(layer)); }); @@ -122,3 +135,8 @@ describe("domains delete integration", () => { }).pipe(Effect.provide(layer)); }); }); + +describeHostedConsent("domains delete", { + api: { response: { status: 200, body: {} } }, + invoke: () => ({ run: domainsDelete(baseFlags) }), +}); diff --git a/apps/cli/src/commands/vanity-subdomains/activate/activate.handler.ts b/apps/cli/src/commands/vanity-subdomains/activate/activate.handler.ts index e11ce285ec..00caa00b66 100644 --- a/apps/cli/src/commands/vanity-subdomains/activate/activate.handler.ts +++ b/apps/cli/src/commands/vanity-subdomains/activate/activate.handler.ts @@ -14,6 +14,7 @@ import { shapeString, shapeStruct, } from "../../../command-internal/struct-output.encoders.ts"; +import { requireHostedMutationConsent } from "../../../command-internal/hosted-consent/hosted-consent.ts"; import { mapHttpError } from "../../../command-internal/http-errors.ts"; import { DesiredSubdomainRequiredError, @@ -55,6 +56,10 @@ export const vanitySubdomainsActivate = Effect.fn("vanity-subdomains.activate")( }); } const desiredSubdomain = flags.desiredSubdomain.value; + yield* requireHostedMutationConsent({ + action: `Activate vanity subdomain ${desiredSubdomain}`, + details: ["Auth services stop working on the project's default hostname."], + }); const activating = output.format === "text" ? yield* output.task("Activating vanity subdomain...") : undefined; const response = yield* api.v1 diff --git a/apps/cli/src/commands/vanity-subdomains/delete/delete.handler.ts b/apps/cli/src/commands/vanity-subdomains/delete/delete.handler.ts index 3b8913c97b..ff323d60c1 100644 --- a/apps/cli/src/commands/vanity-subdomains/delete/delete.handler.ts +++ b/apps/cli/src/commands/vanity-subdomains/delete/delete.handler.ts @@ -6,6 +6,7 @@ import { LinkedProjectCache } from "../../../telemetry/linked-project-cache.serv import { TelemetryState } from "../../../telemetry/telemetry-state.service.ts"; import { OutputFlag } from "../../../command-internal/global-flags.ts"; import { Output } from "../../../shared/output/output.service.ts"; +import { requireHostedMutationConsent } from "../../../command-internal/hosted-consent/hosted-consent.ts"; import { mapHttpError } from "../../../command-internal/http-errors.ts"; import { VanitySubdomainsDeleteNetworkError, @@ -34,6 +35,11 @@ export const vanitySubdomainsDelete = Effect.fn("vanity-subdomains.delete")(func const ref = yield* resolver.resolve(flags.projectRef); yield* Effect.gen(function* () { + yield* requireHostedMutationConsent({ + action: "Delete the vanity subdomain", + details: ["Routing reverts to the project ref hostname."], + }); + const deleting = output.format === "text" ? yield* output.task("Deleting vanity subdomain...") : undefined; yield* api.v1.deactivateVanitySubdomainConfig({ ref }).pipe( diff --git a/apps/cli/src/commands/vanity-subdomains/vanity-subdomains.integration.test.ts b/apps/cli/src/commands/vanity-subdomains/vanity-subdomains.integration.test.ts index c133979b57..84b637d967 100644 --- a/apps/cli/src/commands/vanity-subdomains/vanity-subdomains.integration.test.ts +++ b/apps/cli/src/commands/vanity-subdomains/vanity-subdomains.integration.test.ts @@ -4,7 +4,7 @@ import type { V1GetVanitySubdomainConfigOutput, } from "@supabase/api/effect"; import { describe, expect, it } from "@effect/vitest"; -import { Cause, Effect, Exit, Option } from "effect"; +import { Cause, Effect, Exit, Layer, Option } from "effect"; import { mockAnalytics, mockOutput } from "../../../tests/helpers/mocks.ts"; import { @@ -18,10 +18,13 @@ import { useTempWorkdir, unverifiedTargetBanner, } from "../../../tests/helpers/command-mocks.ts"; +import { describeHostedConsent } from "../../../tests/helpers/hosted-consent.ts"; import { vanitySubdomainsActivate } from "./activate/activate.handler.ts"; import { vanitySubdomainsCheckAvailability } from "./check-availability/check-availability.handler.ts"; import { vanitySubdomainsDelete } from "./delete/delete.handler.ts"; import { vanitySubdomainsGet } from "./get/get.handler.ts"; +import { YesFlag } from "../../command-internal/global-flags.ts"; +import { TargetPolicy } from "../../command-internal/target/target-policy.ts"; type VanityConfig = typeof V1GetVanitySubdomainConfigOutput.Type; type AvailabilityResponse = typeof V1CheckVanitySubdomainAvailabilityOutput.Type; @@ -29,6 +32,15 @@ type ActivateResponse = typeof V1ActivateVanitySubdomainConfigOutput.Type; const tempRoot = useTempWorkdir("supabase-vanity-int-"); +const buildRuntime = (opts: Parameters<typeof buildTestRuntime>[0]) => + Layer.mergeAll( + buildTestRuntime(opts), + Layer.succeed(YesFlag, true), + Layer.succeed(TargetPolicy, { group: "hosted", consent: "no" }), + ); + +const confirmedDelete = `Confirmed by --yes: Delete the vanity subdomain on project ${VALID_REF} · identity unverified\n`; + const SAMPLE_GET: VanityConfig = { status: "custom-domain-used", custom_domain: "example.com", @@ -57,7 +69,7 @@ function runtimeWith(opts: { readonly linkedProjectCache?: ReturnType<typeof mockLinkedProjectCacheTracked>["layer"]; readonly outputFlag?: OutputFlagValue; }) { - return buildTestRuntime({ + return buildRuntime({ out: opts.out, api: opts.api, analytics: opts.analytics, @@ -636,7 +648,7 @@ describe("vanity-subdomains delete", () => { return Effect.gen(function* () { yield* vanitySubdomainsDelete({ projectRef: Option.none() }); expect(out.stderrText).toBe( - unverifiedTargetBanner() + "Deleted vanity subdomain successfully.\n", + unverifiedTargetBanner() + confirmedDelete + "Deleted vanity subdomain successfully.\n", ); expect(api.requests[0]?.method).toBe("DELETE"); expect(api.requests[0]?.url).toContain(`/v1/projects/${VALID_REF}/vanity-subdomain`); @@ -663,7 +675,7 @@ describe("vanity-subdomains delete", () => { return Effect.gen(function* () { yield* vanitySubdomainsDelete({ projectRef: Option.none() }); expect(out.stderrText).toBe( - unverifiedTargetBanner() + "Deleted vanity subdomain successfully.\n", + unverifiedTargetBanner() + confirmedDelete + "Deleted vanity subdomain successfully.\n", ); expect(out.messages.find((m) => m.type === "success")).toBeUndefined(); }).pipe(Effect.provide(layer)); @@ -773,3 +785,18 @@ describe("vanity-subdomains PersistentPostRun parity", () => { }, ); }); + +describeHostedConsent("vanity-subdomains activate", { + api: { response: { status: 201, body: SAMPLE_ACTIVATE } }, + invoke: () => ({ + run: vanitySubdomainsActivate({ + projectRef: Option.none(), + desiredSubdomain: Option.some("example"), + }), + }), +}); + +describeHostedConsent("vanity-subdomains delete", { + api: { response: { status: 200, body: null } }, + invoke: () => ({ run: vanitySubdomainsDelete({ projectRef: Option.none() }) }), +}); diff --git a/apps/cli/tests/helpers/branches-live.ts b/apps/cli/tests/helpers/branches-live.ts index 6100d6e28f..bb8aebe79c 100644 --- a/apps/cli/tests/helpers/branches-live.ts +++ b/apps/cli/tests/helpers/branches-live.ts @@ -147,7 +147,7 @@ export function createLiveBranchEffect<E>( ): Effect.Effect<string, BranchError<E>, never> { const created = command( cli, - ["branches", "create", name, "--project-ref", project.ref, "--output-format", "json"], + ["branches", "create", name, "--project-ref", project.ref, "--output-format", "json", "--yes"], "branches create", undefined, ); From f4d18f9edfd7a603b0d85a2b491c87757717a006 Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Thu, 15 Oct 2026 14:23:01 +0100 Subject: [PATCH 24/92] docs(cli): document hosted consent for branches, backups, domains, vanity (CLI-2695) --- apps/cli/docs/supabase/domains/activate.md | 2 ++ apps/cli/docs/supabase/vanity-subdomains.md | 2 ++ .../src/commands/backups/list/SIDE_EFFECTS.md | 2 ++ .../commands/backups/restore/SIDE_EFFECTS.md | 24 ++++++++----- .../commands/branches/create/SIDE_EFFECTS.md | 21 +++++++---- .../commands/branches/delete/SIDE_EFFECTS.md | 21 +++++++---- .../commands/branches/disable/SIDE_EFFECTS.md | 19 +++++++--- .../src/commands/branches/get/SIDE_EFFECTS.md | 4 +++ .../commands/branches/list/SIDE_EFFECTS.md | 2 ++ .../commands/branches/pause/SIDE_EFFECTS.md | 23 ++++++++---- .../commands/branches/unpause/SIDE_EFFECTS.md | 23 ++++++++---- .../commands/branches/update/SIDE_EFFECTS.md | 20 +++++++---- apps/cli/src/commands/domains/SIDE_EFFECTS.md | 36 ++++++++++++------- .../activate/SIDE_EFFECTS.md | 18 ++++++---- .../check-availability/SIDE_EFFECTS.md | 2 ++ .../vanity-subdomains/delete/SIDE_EFFECTS.md | 18 ++++++---- .../vanity-subdomains/get/SIDE_EFFECTS.md | 2 ++ 17 files changed, 166 insertions(+), 73 deletions(-) diff --git a/apps/cli/docs/supabase/domains/activate.md b/apps/cli/docs/supabase/domains/activate.md index 6624839c45..e093ae30cf 100644 --- a/apps/cli/docs/supabase/domains/activate.md +++ b/apps/cli/docs/supabase/domains/activate.md @@ -5,3 +5,5 @@ Activates the custom hostname configuration for a project. This reconfigures your Supabase project to respond to requests on your custom hostname. After the custom hostname is activated, your project's third-party auth providers will no longer function on the Supabase-provisioned subdomain. Please refer to [Prepare to activate your domain](/docs/guides/platform/custom-domains#prepare-to-activate-your-domain) section in our documentation to learn more about the steps you need to follow. + +Asks for confirmation before activating, with a default of No. Pass `--yes` (or set `SUPABASE_YES` in the shell) to run without a prompt, which JSON output and non-interactive runs require. diff --git a/apps/cli/docs/supabase/vanity-subdomains.md b/apps/cli/docs/supabase/vanity-subdomains.md index 92212a0886..16d003d9f9 100644 --- a/apps/cli/docs/supabase/vanity-subdomains.md +++ b/apps/cli/docs/supabase/vanity-subdomains.md @@ -3,3 +3,5 @@ Manage vanity subdomains for Supabase projects. Usage of vanity subdomains and custom domains is mutually exclusive. + +`activate` and `delete` ask for confirmation, with a default of No. Pass `--yes` (or set `SUPABASE_YES` in the shell) to run without a prompt, which JSON output and non-interactive runs require. diff --git a/apps/cli/src/commands/backups/list/SIDE_EFFECTS.md b/apps/cli/src/commands/backups/list/SIDE_EFFECTS.md index e164ec913d..19e90e3657 100644 --- a/apps/cli/src/commands/backups/list/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/backups/list/SIDE_EFFECTS.md @@ -92,3 +92,5 @@ One `result` NDJSON event on success containing the backup response object. - Requires `--project-ref`, `SUPABASE_PROJECT_ID`, a populated `<workdir>/supabase/.temp/project-ref` file, or a TTY for the interactive project picker. - The interactive picker calls `GET /v1/projects` and writes `"Selected project: <ref>"` to stderr in text mode. It does **not** persist the choice; only `supabase link` and `supabase bootstrap` write the temp file. - Sends `User-Agent: SupabaseCLI/<version>` and Bearer auth. No `X-Supabase-Command` headers. + +- Prints `Target: <project>` once on stderr in every output format, for example `Target: project <name> (<ref>) · production`. `--output-format json|stream-json` object results and error envelopes also carry a `target` object; `--output {json,yaml,toml,env}` payloads are unchanged. Reads never prompt. diff --git a/apps/cli/src/commands/backups/restore/SIDE_EFFECTS.md b/apps/cli/src/commands/backups/restore/SIDE_EFFECTS.md index 9592f72391..06821bd835 100644 --- a/apps/cli/src/commands/backups/restore/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/backups/restore/SIDE_EFFECTS.md @@ -31,18 +31,21 @@ | `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 `<workdir>/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`) | +| `SUPABASE_YES` | approves the hosted-mutation confirmation like `--yes` (shell environment only; `supabase/.env` is ignored) | no | ## Exit Codes -| Code | Condition | -| ---- | --------------------------------------------------------------------------------------- | -| `0` | success — restore initiated | -| `1` | `AccessTokenRequiredError` — no token in env/keyring/file | -| `1` | `InvalidAccessTokenError` — token violates `^sbp_(oauth_\|v0_)?[a-f0-9]{40}$` | -| `1` | `ProjectRefNotLinkedError` — `--project-ref` unset, env/file empty, and stdin not a TTY | -| `1` | `InvalidProjectRefError` — resolved ref violates `^[a-z]{20}$` | -| `1` | `BackupRestoreUnexpectedStatusError` — non-201 response from the restore endpoint | -| `1` | `BackupRestoreNetworkError` — transport-level network failure | +| Code | Condition | +| ---- | ---------------------------------------------------------------------------------------------------------------------- | +| `0` | success — restore initiated | +| `1` | `AccessTokenRequiredError` — no token in env/keyring/file | +| `1` | `InvalidAccessTokenError` — token violates `^sbp_(oauth_\|v0_)?[a-f0-9]{40}$` | +| `1` | `ProjectRefNotLinkedError` — `--project-ref` unset, env/file empty, and stdin not a TTY | +| `1` | `InvalidProjectRefError` — resolved ref violates `^[a-z]{20}$` | +| `1` | `BackupRestoreUnexpectedStatusError` — non-201 response from the restore endpoint | +| `1` | `BackupRestoreNetworkError` — transport-level network failure | +| `1` | `HostedMutationConsentRequiredError` — no `--yes` / shell `SUPABASE_YES` and the run cannot prompt; nothing is changed | +| `1` | `HostedMutationDeclinedError` — the confirmation prompt was declined | ## Telemetry Events Fired @@ -79,3 +82,6 @@ One `result` NDJSON event on success with the project ref payload. - Requires `--project-ref`, `SUPABASE_PROJECT_ID`, a populated `<workdir>/supabase/.temp/project-ref` file, or a TTY for the interactive project picker. - The interactive picker calls `GET /v1/projects` and writes `"Selected project: <ref>"` to stderr in text mode. It does **not** persist the choice; only `supabase link` and `supabase bootstrap` write the temp file. - Sends `User-Agent: SupabaseCLI/<version>` and Bearer auth. No `X-Supabase-Command` headers. + +- Prints `Target: <project>` once on stderr in every output format, for example `Target: project <name> (<ref>) · production`. `--output-format json|stream-json` object results and error envelopes also carry a `target` object; `--output {json,yaml,toml,env}` payloads are unchanged. +- **Consent.** Asks once, after the project is resolved and before the POST, with default No: `Restore the database to Unix timestamp <t> (PITR)`. With no `--timestamp` the action reads `Start a PITR restore without a recovery time target`. `--yes` or a shell `SUPABASE_YES` approves it (`supabase/.env` does not) and echoes `Confirmed by --yes: <action> on <target>` to stderr. Without it, only an interactive text-mode terminal is asked: JSON, `-o`, piped and agent runs exit 1 with `HostedMutationConsentRequiredError` and change nothing, and declining exits 1 with `HostedMutationDeclinedError`. Stdin is never read. diff --git a/apps/cli/src/commands/branches/create/SIDE_EFFECTS.md b/apps/cli/src/commands/branches/create/SIDE_EFFECTS.md index 2b28867a75..f9da4d38ed 100644 --- a/apps/cli/src/commands/branches/create/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/branches/create/SIDE_EFFECTS.md @@ -35,16 +35,18 @@ | `SUPABASE_PROJECT_ID` | PARENT project ref fallback (1st candidate) when `--project-ref` is unset (CLI-2167 follow-up) — see `branches list/SIDE_EFFECTS.md` for the full resolution chain | no | | `SUPABASE_WORKDIR` | base directory for the `.temp/project-ref` lookup | no | | `GITHUB_HEAD_REF` | preferred over walking `.git/HEAD` when detecting the default branch name in CI | no | +| `SUPABASE_YES` | approves the hosted-mutation confirmation like `--yes` (shell environment only; `supabase/.env` is ignored) | no | ## Exit Codes -| Code | Condition | -| ---- | ---------------------------------------------------------------------------------------- | -| `0` | success — branch created | -| `1` | `BranchesCreateCancelledError` — user declined the git-branch confirmation prompt | -| `1` | `BranchesCreateUnexpectedStatusError` — non-201 response from the create branch endpoint | -| `1` | `BranchesCreateNetworkError` — transport-level network failure | -| `1` | Auth / project-ref resolution errors (`AccessTokenRequiredError`, …) | +| Code | Condition | +| ---- | ---------------------------------------------------------------------------------------------------------------------- | +| `0` | success — branch created | +| `1` | `BranchesCreateUnexpectedStatusError` — non-201 response from the create branch endpoint | +| `1` | `BranchesCreateNetworkError` — transport-level network failure | +| `1` | Auth / project-ref resolution errors (`AccessTokenRequiredError`, …) | +| `1` | `HostedMutationConsentRequiredError` — no `--yes` / shell `SUPABASE_YES` and the run cannot prompt; nothing is changed | +| `1` | `HostedMutationDeclinedError` — the confirmation prompt was declined | ## Telemetry Events Fired @@ -62,3 +64,8 @@ In **text mode** (default / `--output pretty`), the header `Created preview bran For `--output {json,yaml,toml,env}`, the same header writes to stdout followed by the encoded `V1CreateABranchOutput` payload. In `--output-format json` / `stream-json`, a `success` event carries the response payload. + +## Notes + +- Resolves the parent project and prints `Target: <project>` once on stderr in every output format, for example `Target: project <name> (<ref>) · production`. `--output-format json|stream-json` object results and error envelopes also carry a `target` object; `--output {json,yaml,toml,env}` payloads are unchanged. +- **Consent.** Asks once, after the parent project is resolved and the name is known (taken from the current git branch when `[name]` is omitted) and before the POST, with default Yes: `Create preview branch <name>`. The question lists the region, size, persistence and `--with-data` values that were set, and says when the name came from the git branch. The separate git-branch confirmation prompt is gone. `--yes` or a shell `SUPABASE_YES` approves it (`supabase/.env` does not) and echoes `Confirmed by --yes: <action> on <target>` to stderr. Without it, only an interactive text-mode terminal is asked: JSON, `-o`, piped and agent runs exit 1 with `HostedMutationConsentRequiredError` and change nothing, and declining exits 1 with `HostedMutationDeclinedError`. Stdin is never read. diff --git a/apps/cli/src/commands/branches/delete/SIDE_EFFECTS.md b/apps/cli/src/commands/branches/delete/SIDE_EFFECTS.md index aebfcdc37f..59316e5a41 100644 --- a/apps/cli/src/commands/branches/delete/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/branches/delete/SIDE_EFFECTS.md @@ -23,14 +23,18 @@ Same auth fallback chain as every Management-API command. Project-ref discovery `SUPABASE_ACCESS_TOKEN`, `SUPABASE_PROFILE`, `SUPABASE_PROJECT_ID`, `SUPABASE_WORKDIR` — same semantics as `branches list` (including the CLI-2167 PARENT-scoped resolution chain). +`SUPABASE_YES` — approves the hosted-mutation confirmation like `--yes`; read from the shell environment only, never `supabase/.env`. + ## Exit Codes -| Code | Condition | -| ---- | ----------------------------------------------------------------------------- | -| `0` | success — branch deleted | -| `1` | `BranchesDeleteUnexpectedStatusError` — non-200 response from delete endpoint | -| `1` | `BranchesDeleteNetworkError` — transport-level network failure | -| `1` | Branch-id resolution errors | +| Code | Condition | +| ---- | ---------------------------------------------------------------------------------------------------------------------- | +| `0` | success — branch deleted | +| `1` | `BranchesDeleteUnexpectedStatusError` — non-200 response from delete endpoint | +| `1` | `BranchesDeleteNetworkError` — transport-level network failure | +| `1` | Branch-id resolution errors | +| `1` | `HostedMutationConsentRequiredError` — no `--yes` / shell `SUPABASE_YES` and the run cannot prompt; nothing is changed | +| `1` | `HostedMutationDeclinedError` — the confirmation prompt was declined | ## Telemetry Events Fired @@ -47,3 +51,8 @@ Same auth fallback chain as every Management-API command. Project-ref discovery ### `--output-format json` / `stream-json` Single `success` event carrying `{project_ref}`. + +## Notes + +- Resolves the parent project and prints `Target: <project>` once on stderr in every output format, for example `Target: project <name> (<ref>) · production`. `--output-format json|stream-json` object results and error envelopes also carry a `target` object; `--output {json,yaml,toml,env}` payloads are unchanged. +- **Consent.** Asks once, after the branch name or ID is resolved to its ref and before the API write, with default No: `Delete preview branch <name-or-id>`. The question also shows the branch project ref. `--yes` or a shell `SUPABASE_YES` approves it (`supabase/.env` does not) and echoes `Confirmed by --yes: <action> on <target>` to stderr. Without it, only an interactive text-mode terminal is asked: JSON, `-o`, piped and agent runs exit 1 with `HostedMutationConsentRequiredError` and change nothing, and declining exits 1 with `HostedMutationDeclinedError`. Stdin is never read. diff --git a/apps/cli/src/commands/branches/disable/SIDE_EFFECTS.md b/apps/cli/src/commands/branches/disable/SIDE_EFFECTS.md index 32a800779f..29338bef36 100644 --- a/apps/cli/src/commands/branches/disable/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/branches/disable/SIDE_EFFECTS.md @@ -23,13 +23,17 @@ Same auth fallback chain as every Management-API command. Project-ref discovery `SUPABASE_ACCESS_TOKEN`, `SUPABASE_PROFILE`, `SUPABASE_PROJECT_ID`, `SUPABASE_WORKDIR` — same semantics as `branches list` (including the CLI-2167 PARENT-scoped resolution chain). +`SUPABASE_YES` — approves the hosted-mutation confirmation like `--yes`; read from the shell environment only, never `supabase/.env`. + ## Exit Codes -| Code | Condition | -| ---- | ----------------------------------------------------------------------------------- | -| `0` | success — preview branching disabled for the project | -| `1` | `BranchesDisableUnexpectedStatusError` — non-200 response from the disable endpoint | -| `1` | `BranchesDisableNetworkError` — transport-level network failure | +| Code | Condition | +| ---- | ---------------------------------------------------------------------------------------------------------------------- | +| `0` | success — preview branching disabled for the project | +| `1` | `BranchesDisableUnexpectedStatusError` — non-200 response from the disable endpoint | +| `1` | `BranchesDisableNetworkError` — transport-level network failure | +| `1` | `HostedMutationConsentRequiredError` — no `--yes` / shell `SUPABASE_YES` and the run cannot prompt; nothing is changed | +| `1` | `HostedMutationDeclinedError` — the confirmation prompt was declined | ## Telemetry Events Fired @@ -46,3 +50,8 @@ Same auth fallback chain as every Management-API command. Project-ref discovery ### `--output-format json` / `stream-json` Single `success` event carrying `{project_ref}`. + +## Notes + +- Resolves the parent project and prints `Target: <project>` once on stderr in every output format, for example `Target: project <name> (<ref>) · production`. `--output-format json|stream-json` object results and error envelopes also carry a `target` object; `--output {json,yaml,toml,env}` payloads are unchanged. +- **Consent.** Asks once, after the parent project is resolved and before the DELETE, with default No: `Disable preview branching`. `--yes` or a shell `SUPABASE_YES` approves it (`supabase/.env` does not) and echoes `Confirmed by --yes: <action> on <target>` to stderr. Without it, only an interactive text-mode terminal is asked: JSON, `-o`, piped and agent runs exit 1 with `HostedMutationConsentRequiredError` and change nothing, and declining exits 1 with `HostedMutationDeclinedError`. Stdin is never read. diff --git a/apps/cli/src/commands/branches/get/SIDE_EFFECTS.md b/apps/cli/src/commands/branches/get/SIDE_EFFECTS.md index d566a13b85..5eff87f859 100644 --- a/apps/cli/src/commands/branches/get/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/branches/get/SIDE_EFFECTS.md @@ -50,3 +50,7 @@ styled 7-column table: `HOST`, `PORT`, `USER`, `PASSWORD`, `JWT SECRET`, `POSTGR ### `--output {json,yaml,toml,env}` / `--output-format json` / `stream-json` Emits the standard-env projection: `POSTGRES_URL` (pooled, falls back to direct on parse failure with `WARNING:` to stderr), `POSTGRES_URL_NON_POOLING` (direct), `SUPABASE_URL = https://<ref>.<project_host>`, `SUPABASE_JWT_SECRET`, plus `SUPABASE_<SUFFIX>_KEY` per API key. Publishable keys named `default` map to `SUPABASE_PUBLISHABLE_KEY` (not `SUPABASE_DEFAULT_KEY`) to avoid colliding with the default secret key. + +## Notes + +- Resolves the parent project and prints `Target: <project>` once on stderr in every output format, for example `Target: project <name> (<ref>) · production`. `--output-format json|stream-json` object results and error envelopes also carry a `target` object; `--output {json,yaml,toml,env}` payloads are unchanged. Reads never prompt. diff --git a/apps/cli/src/commands/branches/list/SIDE_EFFECTS.md b/apps/cli/src/commands/branches/list/SIDE_EFFECTS.md index 7c3df249a0..dc74043383 100644 --- a/apps/cli/src/commands/branches/list/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/branches/list/SIDE_EFFECTS.md @@ -107,3 +107,5 @@ One `result` NDJSON event with `{branches: [...]}`. - Timestamps are formatted as UTC `YYYY-MM-DD HH:MM:SS`. - Sends `User-Agent: SupabaseCLI/<version>` and Bearer auth. No `X-Supabase-Command` headers. + +- Resolves the parent project and prints `Target: <project>` once on stderr in every output format, for example `Target: project <name> (<ref>) · production`. `--output-format json|stream-json` object results and error envelopes also carry a `target` object; `--output {json,yaml,toml,env}` payloads are unchanged. Reads never prompt. diff --git a/apps/cli/src/commands/branches/pause/SIDE_EFFECTS.md b/apps/cli/src/commands/branches/pause/SIDE_EFFECTS.md index aafdd4bce8..dfee42ec8c 100644 --- a/apps/cli/src/commands/branches/pause/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/branches/pause/SIDE_EFFECTS.md @@ -23,14 +23,18 @@ Same auth fallback chain as every Management-API command. Project-ref discovery `SUPABASE_ACCESS_TOKEN`, `SUPABASE_PROFILE`, `SUPABASE_PROJECT_ID`, `SUPABASE_WORKDIR` — same semantics as `branches list` (including the CLI-2167 PARENT-scoped resolution chain). +`SUPABASE_YES` — approves the hosted-mutation confirmation like `--yes`; read from the shell environment only, never `supabase/.env`. + ## Exit Codes -| Code | Condition | -| ---- | --------------------------------------------------------------------------- | -| `0` | success — branch paused | -| `1` | `BranchesPauseUnexpectedStatusError` — non-200 response from pause endpoint | -| `1` | `BranchesPauseNetworkError` — transport-level network failure | -| `1` | Branch-id resolution errors (find / config endpoints failed) | +| Code | Condition | +| ---- | ---------------------------------------------------------------------------------------------------------------------- | +| `0` | success — branch paused | +| `1` | `BranchesPauseUnexpectedStatusError` — non-200 response from pause endpoint | +| `1` | `BranchesPauseNetworkError` — transport-level network failure | +| `1` | Branch-id resolution errors (find / config endpoints failed) | +| `1` | `HostedMutationConsentRequiredError` — no `--yes` / shell `SUPABASE_YES` and the run cannot prompt; nothing is changed | +| `1` | `HostedMutationDeclinedError` — the confirmation prompt was declined | ## Telemetry Events Fired @@ -40,4 +44,9 @@ Same auth fallback chain as every Management-API command. Project-ref discovery ## Output -Silent on success in every mode (no stdout / stderr emission inside the handler). The prompt helper may write `Selected branch ID: <ref>` to stderr in text mode if the TTY picker was used. +Silent on success in every mode (the handler writes nothing itself; the target banner and any `Confirmed by --yes` line go to stderr). The prompt helper may write `Selected branch ID: <ref>` to stderr in text mode if the TTY picker was used. + +## Notes + +- Resolves the parent project and prints `Target: <project>` once on stderr in every output format, for example `Target: project <name> (<ref>) · production`. `--output-format json|stream-json` object results and error envelopes also carry a `target` object; `--output {json,yaml,toml,env}` payloads are unchanged. +- **Consent.** Asks once, after the branch name or ID is resolved to its ref and before the API write, with default No: `Pause preview branch <name-or-id>`. The question also shows the branch project ref. `--yes` or a shell `SUPABASE_YES` approves it (`supabase/.env` does not) and echoes `Confirmed by --yes: <action> on <target>` to stderr. Without it, only an interactive text-mode terminal is asked: JSON, `-o`, piped and agent runs exit 1 with `HostedMutationConsentRequiredError` and change nothing, and declining exits 1 with `HostedMutationDeclinedError`. Stdin is never read. diff --git a/apps/cli/src/commands/branches/unpause/SIDE_EFFECTS.md b/apps/cli/src/commands/branches/unpause/SIDE_EFFECTS.md index 8b59c31ba7..f9c733251f 100644 --- a/apps/cli/src/commands/branches/unpause/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/branches/unpause/SIDE_EFFECTS.md @@ -23,14 +23,18 @@ Same auth fallback chain as every Management-API command. Project-ref discovery `SUPABASE_ACCESS_TOKEN`, `SUPABASE_PROFILE`, `SUPABASE_PROJECT_ID`, `SUPABASE_WORKDIR` — same semantics as `branches list` (including the CLI-2167 PARENT-scoped resolution chain). +`SUPABASE_YES` — approves the hosted-mutation confirmation like `--yes`; read from the shell environment only, never `supabase/.env`. + ## Exit Codes -| Code | Condition | -| ---- | ------------------------------------------------------------------------------- | -| `0` | success — branch unpaused | -| `1` | `BranchesUnpauseUnexpectedStatusError` — non-200 response from restore endpoint | -| `1` | `BranchesUnpauseNetworkError` — transport-level network failure | -| `1` | Branch-id resolution errors | +| Code | Condition | +| ---- | ---------------------------------------------------------------------------------------------------------------------- | +| `0` | success — branch unpaused | +| `1` | `BranchesUnpauseUnexpectedStatusError` — non-200 response from restore endpoint | +| `1` | `BranchesUnpauseNetworkError` — transport-level network failure | +| `1` | Branch-id resolution errors | +| `1` | `HostedMutationConsentRequiredError` — no `--yes` / shell `SUPABASE_YES` and the run cannot prompt; nothing is changed | +| `1` | `HostedMutationDeclinedError` — the confirmation prompt was declined | ## Telemetry Events Fired @@ -40,4 +44,9 @@ Same auth fallback chain as every Management-API command. Project-ref discovery ## Output -Silent on success in every mode. +Silent on success in every mode (the handler writes nothing itself; the target banner and any `Confirmed by --yes` line go to stderr). + +## Notes + +- Resolves the parent project and prints `Target: <project>` once on stderr in every output format, for example `Target: project <name> (<ref>) · production`. `--output-format json|stream-json` object results and error envelopes also carry a `target` object; `--output {json,yaml,toml,env}` payloads are unchanged. +- **Consent.** Asks once, after the branch name or ID is resolved to its ref and before the API write, with default No: `Unpause preview branch <name-or-id>`. The question also shows the branch project ref. `--yes` or a shell `SUPABASE_YES` approves it (`supabase/.env` does not) and echoes `Confirmed by --yes: <action> on <target>` to stderr. Without it, only an interactive text-mode terminal is asked: JSON, `-o`, piped and agent runs exit 1 with `HostedMutationConsentRequiredError` and change nothing, and declining exits 1 with `HostedMutationDeclinedError`. Stdin is never read. diff --git a/apps/cli/src/commands/branches/update/SIDE_EFFECTS.md b/apps/cli/src/commands/branches/update/SIDE_EFFECTS.md index 465d927b75..7bb1ba9b5a 100644 --- a/apps/cli/src/commands/branches/update/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/branches/update/SIDE_EFFECTS.md @@ -25,14 +25,18 @@ Same auth fallback chain as every Management-API command. Project-ref discovery `SUPABASE_ACCESS_TOKEN`, `SUPABASE_PROFILE`, `SUPABASE_PROJECT_ID`, `SUPABASE_WORKDIR` — same semantics as `branches list` (including the CLI-2167 PARENT-scoped resolution chain). +`SUPABASE_YES` — approves the hosted-mutation confirmation like `--yes`; read from the shell environment only, never `supabase/.env`. + ## Exit Codes -| Code | Condition | -| ---- | --------------------------------------------------------------------------------- | -| `0` | success — branch updated | -| `1` | `BranchesUpdateUnexpectedStatusError` — non-200 response from the update endpoint | -| `1` | `BranchesUpdateNetworkError` — transport-level network failure | -| `1` | Branch-id resolution errors (find / config endpoints failed) | +| Code | Condition | +| ---- | ---------------------------------------------------------------------------------------------------------------------- | +| `0` | success — branch updated | +| `1` | `BranchesUpdateUnexpectedStatusError` — non-200 response from the update endpoint | +| `1` | `BranchesUpdateNetworkError` — transport-level network failure | +| `1` | Branch-id resolution errors (find / config endpoints failed) | +| `1` | `HostedMutationConsentRequiredError` — no `--yes` / shell `SUPABASE_YES` and the run cannot prompt; nothing is changed | +| `1` | `HostedMutationDeclinedError` — the confirmation prompt was declined | ## Telemetry Events Fired @@ -51,4 +55,6 @@ For `--output {json,yaml,toml,env}`, the header goes to stderr followed by the e ## Notes -The upgrade-suggest call uses the branch's own resolved project ref (`resolveBranchProjectRef`) — not the parent `--project-ref` value — so the entitlements check is scoped to the branch's org. +- The upgrade-suggest call uses the branch's own resolved project ref (`resolveBranchProjectRef`) — not the parent `--project-ref` value — so the entitlements check is scoped to the branch's org. +- Resolves the parent project and prints `Target: <project>` once on stderr in every output format, for example `Target: project <name> (<ref>) · production`. `--output-format json|stream-json` object results and error envelopes also carry a `target` object; `--output {json,yaml,toml,env}` payloads are unchanged. +- **Consent.** Asks once, after the branch name or ID is resolved to its ref and before the PATCH, with default No: `Update preview branch <name-or-id>`. The question also shows the branch project ref and each change that was requested (`--name`, `--git-branch`, `--persistent`, `--status`, `--notify-url`). `--yes` or a shell `SUPABASE_YES` approves it (`supabase/.env` does not) and echoes `Confirmed by --yes: <action> on <target>` to stderr. Without it, only an interactive text-mode terminal is asked: JSON, `-o`, piped and agent runs exit 1 with `HostedMutationConsentRequiredError` and change nothing, and declining exits 1 with `HostedMutationDeclinedError`. Stdin is never read. diff --git a/apps/cli/src/commands/domains/SIDE_EFFECTS.md b/apps/cli/src/commands/domains/SIDE_EFFECTS.md index 4560aee7ce..13c194a2aa 100644 --- a/apps/cli/src/commands/domains/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/domains/SIDE_EFFECTS.md @@ -31,22 +31,25 @@ Cloudflare DNS-over-HTTPS CNAME pre-check. ## Environment Variables -| Variable | Purpose | Required? | -| ----------------------- | ---------------------------------------------------- | -------------------------------------------------------- | -| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | -| `SUPABASE_PROJECT_ID` | project ref fallback when `--project-ref` is unset | no (falls back to linked-project file) | -| `SUPABASE_PROFILE` | built-in profile name or path to a YAML profile | no (defaults to `supabase`; sets API URL + project host) | +| Variable | Purpose | Required? | +| ----------------------- | ----------------------------------------------------------------------------------------------------------- | -------------------------------------------------------- | +| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | +| `SUPABASE_PROJECT_ID` | project ref fallback when `--project-ref` is unset | no (falls back to linked-project file) | +| `SUPABASE_PROFILE` | built-in profile name or path to a YAML profile | no (defaults to `supabase`; sets API URL + project host) | +| `SUPABASE_YES` | approves the hosted-mutation confirmation like `--yes` (shell environment only; `supabase/.env` is ignored) | no | ## Exit Codes -| Code | Condition | -| ---- | ----------------------------------------------------------------------------------------- | -| `0` | success | -| `1` | project ref cannot be resolved / is malformed | -| `1` | `create`: hostname has no matching CNAME record (Cloudflare DNS pre-check) | -| `1` | API error — unexpected (non-201/200) response from the custom-hostname endpoint | -| `1` | network / connection failure | -| `1` | `--include-raw-output` passed at all (any value, including `=false`) — removed, see Notes | +| Code | Condition | +| ---- | ---------------------------------------------------------------------------------------------------------------------- | +| `0` | success | +| `1` | project ref cannot be resolved / is malformed | +| `1` | `create`: hostname has no matching CNAME record (Cloudflare DNS pre-check) | +| `1` | API error — unexpected (non-201/200) response from the custom-hostname endpoint | +| `1` | network / connection failure | +| `1` | `--include-raw-output` passed at all (any value, including `=false`) — removed, see Notes | +| `1` | `HostedMutationConsentRequiredError` — no `--yes` / shell `SUPABASE_YES` and the run cannot prompt; nothing is changed | +| `1` | `HostedMutationDeclinedError` — the confirmation prompt was declined | ## Telemetry Events Fired @@ -100,3 +103,10 @@ suppressed on stderr. `delete` ignores `-o`. - Text-mode status output is newline-terminated. Without the final newline, interactive shell prompts can redraw over the last status line, hiding the ACME TXT record. - In a structured `-o` mode the human status is suppressed on stderr, keeping stdout clean. - The CNAME pre-check's "failed to locate" error text embeds a readable, 1024-byte-capped JSON dump of the DNS answers, rather than an uncapped decimal byte-value array — see `domains.cname.ts`'s comment at the failure site for detail. + +- Prints `Target: <project>` once on stderr in every output format, for example `Target: project <name> (<ref>) · production`. `--output-format json|stream-json` object results and error envelopes also carry a `target` object; `--output {json,yaml,toml,env}` payloads are unchanged. +- **Consent (`create`).** Asks once, after the CNAME pre-check passes and before the POST, with default Yes: `Create custom hostname <hostname>`. +- **Consent (`activate`).** Asks once before the POST, with default No: `Activate the custom hostname`. The question notes that third-party auth providers stop working on the Supabase-provisioned subdomain. +- **Consent (`delete`).** Asks once before the DELETE, with default No: `Delete the custom hostname config`. +- **Consent behaviour.** `--yes` or a shell `SUPABASE_YES` approves it (`supabase/.env` does not) and echoes `Confirmed by --yes: <action> on <target>` to stderr. Without it, only an interactive text-mode terminal is asked: JSON, `-o`, piped and agent runs exit 1 with `HostedMutationConsentRequiredError` and change nothing, and declining exits 1 with `HostedMutationDeclinedError`. Stdin is never read. +- `get` never prompts. `reverify` never prompts: it re-runs verification and changes no configuration. diff --git a/apps/cli/src/commands/vanity-subdomains/activate/SIDE_EFFECTS.md b/apps/cli/src/commands/vanity-subdomains/activate/SIDE_EFFECTS.md index 62ab4db33b..0682487fbd 100644 --- a/apps/cli/src/commands/vanity-subdomains/activate/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/vanity-subdomains/activate/SIDE_EFFECTS.md @@ -22,12 +22,13 @@ ## Environment Variables -| Variable | Purpose | Required? | -| ----------------------- | -------------------------------------------------------- | -------------------------------------------------------------- | -| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | 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_PROJECT_ID` | project ref fallback when `--project-ref` is unset | no (falls back to `supabase/.temp/project-ref`) | -| `SUPABASE_EXPERIMENTAL` | enables `--experimental`-gated commands without the flag | no (pass `--experimental` instead; one of the two is required) | +| Variable | Purpose | Required? | +| ----------------------- | ----------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------- | +| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | 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_PROJECT_ID` | project ref fallback when `--project-ref` is unset | no (falls back to `supabase/.temp/project-ref`) | +| `SUPABASE_EXPERIMENTAL` | enables `--experimental`-gated commands without the flag | no (pass `--experimental` instead; one of the two is required) | +| `SUPABASE_YES` | approves the hosted-mutation confirmation like `--yes` (shell environment only; `supabase/.env` is ignored) | no | ## Exit Codes @@ -39,6 +40,8 @@ | `1` | `--desired-subdomain` omitted (`DesiredSubdomainRequiredError`) — checked after gate/login/ref resolution; telemetry still fires | | `1` | API non-2xx (`VanitySubdomainsActivateUnexpectedStatusError`) | | `1` | transport failure (`VanitySubdomainsActivateNetworkError`) | +| `1` | `HostedMutationConsentRequiredError` — no `--yes` / shell `SUPABASE_YES` and the run cannot prompt; nothing is changed | +| `1` | `HostedMutationDeclinedError` — the confirmation prompt was declined | ## Telemetry Events Fired @@ -81,3 +84,6 @@ One `result` event with the full response object. flag, and telemetry and `linked-project.json` are still written on this failure. An explicit empty value (`--desired-subdomain ""`) passes the check and reaches the API — only presence is required, not a non-empty value. + +- Prints `Target: <project>` once on stderr in every output format, for example `Target: project <name> (<ref>) · production`. `--output-format json|stream-json` object results and error envelopes also carry a `target` object; `--output {json,yaml,toml,env}` payloads are unchanged. +- **Consent.** Asks once, after `--desired-subdomain` is checked and before the POST, with default No: `Activate vanity subdomain <name>`. The question notes that auth services stop working on the project's default hostname. `--yes` or a shell `SUPABASE_YES` approves it (`supabase/.env` does not) and echoes `Confirmed by --yes: <action> on <target>` to stderr. Without it, only an interactive text-mode terminal is asked: JSON, `-o`, piped and agent runs exit 1 with `HostedMutationConsentRequiredError` and change nothing, and declining exits 1 with `HostedMutationDeclinedError`. Stdin is never read. diff --git a/apps/cli/src/commands/vanity-subdomains/check-availability/SIDE_EFFECTS.md b/apps/cli/src/commands/vanity-subdomains/check-availability/SIDE_EFFECTS.md index fce0b2dea2..d7371a9b33 100644 --- a/apps/cli/src/commands/vanity-subdomains/check-availability/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/vanity-subdomains/check-availability/SIDE_EFFECTS.md @@ -82,3 +82,5 @@ One `result` event with the full response object. flag, and telemetry and `linked-project.json` are still written on this failure. An explicit empty value (`--desired-subdomain ""`) passes the check and reaches the API — only presence is required, not a non-empty value. + +- Prints `Target: <project>` once on stderr in every output format, for example `Target: project <name> (<ref>) · production`. `--output-format json|stream-json` object results and error envelopes also carry a `target` object; `--output {json,yaml,toml,env}` payloads are unchanged. Reads never prompt. diff --git a/apps/cli/src/commands/vanity-subdomains/delete/SIDE_EFFECTS.md b/apps/cli/src/commands/vanity-subdomains/delete/SIDE_EFFECTS.md index ce9582a617..84927fcc5b 100644 --- a/apps/cli/src/commands/vanity-subdomains/delete/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/vanity-subdomains/delete/SIDE_EFFECTS.md @@ -22,12 +22,13 @@ ## Environment Variables -| Variable | Purpose | Required? | -| ----------------------- | -------------------------------------------------------- | -------------------------------------------------------------- | -| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | 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_PROJECT_ID` | project ref fallback when `--project-ref` is unset | no (falls back to `supabase/.temp/project-ref`) | -| `SUPABASE_EXPERIMENTAL` | enables `--experimental`-gated commands without the flag | no (pass `--experimental` instead; one of the two is required) | +| Variable | Purpose | Required? | +| ----------------------- | ----------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------- | +| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | 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_PROJECT_ID` | project ref fallback when `--project-ref` is unset | no (falls back to `supabase/.temp/project-ref`) | +| `SUPABASE_EXPERIMENTAL` | enables `--experimental`-gated commands without the flag | no (pass `--experimental` instead; one of the two is required) | +| `SUPABASE_YES` | approves the hosted-mutation confirmation like `--yes` (shell environment only; `supabase/.env` is ignored) | no | ## Exit Codes @@ -38,6 +39,8 @@ | `1` | project ref unresolved (`ProjectRefNotLinkedError` / `InvalidProjectRefError`) | | `1` | API non-2xx (`VanitySubdomainsDeleteUnexpectedStatusError`) | | `1` | transport failure (`VanitySubdomainsDeleteNetworkError`) | +| `1` | `HostedMutationConsentRequiredError` — no `--yes` / shell `SUPABASE_YES` and the run cannot prompt; nothing is changed | +| `1` | `HostedMutationDeclinedError` — the confirmation prompt was declined | ## Telemetry Events Fired @@ -72,3 +75,6 @@ One `result` event when the `--output` flag is unset. - The `--output` flag wins over TS `--output-format` when both are provided. - `linked-project.json` is written after ref resolution (once the `--experimental` gate is open), even when the API call fails. A closed gate writes nothing. + +- Prints `Target: <project>` once on stderr in every output format, for example `Target: project <name> (<ref>) · production`. `--output-format json|stream-json` object results and error envelopes also carry a `target` object; `--output {json,yaml,toml,env}` payloads are unchanged. +- **Consent.** Asks once, after the project is resolved and before the POST, with default No: `Delete the vanity subdomain`. The question notes that routing reverts to the project ref hostname. `--yes` or a shell `SUPABASE_YES` approves it (`supabase/.env` does not) and echoes `Confirmed by --yes: <action> on <target>` to stderr. Without it, only an interactive text-mode terminal is asked: JSON, `-o`, piped and agent runs exit 1 with `HostedMutationConsentRequiredError` and change nothing, and declining exits 1 with `HostedMutationDeclinedError`. Stdin is never read. diff --git a/apps/cli/src/commands/vanity-subdomains/get/SIDE_EFFECTS.md b/apps/cli/src/commands/vanity-subdomains/get/SIDE_EFFECTS.md index 5c1a47d575..0b973dd751 100644 --- a/apps/cli/src/commands/vanity-subdomains/get/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/vanity-subdomains/get/SIDE_EFFECTS.md @@ -76,3 +76,5 @@ One `result` event with the full response object. - The `--output` flag wins over TS `--output-format` when both are provided. - `linked-project.json` is written after ref resolution (once the `--experimental` gate is open), even when the API call fails. A closed gate writes nothing. + +- Prints `Target: <project>` once on stderr in every output format, for example `Target: project <name> (<ref>) · production`. `--output-format json|stream-json` object results and error envelopes also carry a `target` object; `--output {json,yaml,toml,env}` payloads are unchanged. Reads never prompt. From aae4275100196d67c0fdb97c3986f99629d5cd23 Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Thu, 15 Oct 2026 14:23:51 +0100 Subject: [PATCH 25/92] feat(cli)!: resolve migration list/up/down/repair targets and require hosted consent --- .../commands/migration/down/down.command.ts | 20 +- .../commands/migration/down/down.handler.ts | 103 ++---- .../migration/down/down.integration.test.ts | 263 +++++++------- .../commands/migration/list/list.command.ts | 23 +- .../commands/migration/list/list.handler.ts | 58 +--- .../migration/list/list.integration.test.ts | 124 +++---- .../migration/repair/repair.command.ts | 21 +- .../migration/repair/repair.handler.ts | 172 ++++----- .../repair/repair.integration.test.ts | 326 ++++++++---------- .../src/commands/migration/up/up.command.ts | 21 +- .../src/commands/migration/up/up.handler.ts | 72 ++-- .../migration/up/up.integration.test.ts | 199 +++++------ 12 files changed, 580 insertions(+), 822 deletions(-) diff --git a/apps/cli/src/commands/migration/down/down.command.ts b/apps/cli/src/commands/migration/down/down.command.ts index 98493f716c..5d45525df5 100644 --- a/apps/cli/src/commands/migration/down/down.command.ts +++ b/apps/cli/src/commands/migration/down/down.command.ts @@ -15,6 +15,7 @@ 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"; +import { targetFlags } from "../../../command-internal/target/target-flags.ts"; import { withTargetPolicy } from "../../../command-internal/target/target-policy.ts"; const config = { @@ -33,24 +34,7 @@ const config = { (err) => (err instanceof Error ? err.message : String(err)), ), ), - dbUrl: Flag.String("db-url").pipe( - Flag.withDescription( - "Resets applied migrations on the database specified by the connection string (must be percent-encoded).", - ), - Flag.optional, - ), - linked: Flag.Boolean("linked").pipe( - Flag.withDescription("Resets applied migrations on the linked project."), - Flag.withDefault(false), - ), - local: Flag.Boolean("local").pipe( - Flag.withDescription("Resets applied migrations on the local database."), - Flag.withDefault(true), - ), - projectRef: Flag.String("project-ref").pipe( - Flag.withDescription("Project ref of the Supabase project."), - Flag.optional, - ), + ...targetFlags({ group: "selectable", subject: "database", dbUrl: true }), } as const; export type MigrationDownFlags = CliCommand.Command.Config.Infer<typeof config>; diff --git a/apps/cli/src/commands/migration/down/down.handler.ts b/apps/cli/src/commands/migration/down/down.handler.ts index a17696fbea..9fcff7d826 100644 --- a/apps/cli/src/commands/migration/down/down.handler.ts +++ b/apps/cli/src/commands/migration/down/down.handler.ts @@ -1,41 +1,27 @@ import { Effect, FileSystem, Option, Path } from "effect"; -import { - DnsResolverFlag, - resolveYesWithProjectEnv, -} from "../../../command-internal/global-flags.ts"; -import { CliArgs } from "../../../shared/cli/cli-args.service.ts"; -import { CONTEXT_CANCELED_MESSAGE } from "../../../shared/output/errors.ts"; +import { DnsResolverFlag } from "../../../command-internal/global-flags.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 { aqua, bold, yellow } from "../../../command-internal/colors.ts"; -import { loadProjectEnvValues, readDbToml } from "../../../command-internal/db-config.toml-read.ts"; +import { 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"; import { dropUserSchemas } from "../../../command-internal/drop-objects.ts"; +import { + hostedMutationPreapproval, + requireTargetMutationConsent, +} from "../../../command-internal/hosted-consent/hosted-consent.ts"; import { migrateAndSeed } from "../../../command-internal/migrate-and-seed.ts"; import { listRemoteMigrations } from "../../../command-internal/migration-history.ts"; +import { resolveTarget } from "../../../command-internal/target/resolve-target.ts"; import { upsertVaultSecrets } from "../../../command-internal/vault.ts"; import { LinkedProjectCache } from "../../../telemetry/linked-project-cache.service.ts"; import { TelemetryState } from "../../../telemetry/telemetry-state.service.ts"; -import { MigrationTargetFlagsError, OperationCanceledError } from "../migration.errors.ts"; -import { migrationConfirm } from "../migration.prompt.ts"; import type { MigrationDownFlags } from "./down.command.ts"; import { MigrationLastTooLargeError, MigrationLastZeroError } from "./down.errors.ts"; -const confirmResetAll = (pending: ReadonlyArray<string>): string => { - let title = "Do you want to revert the following migrations?\n"; - for (const version of pending) title += ` • ${bold(version)}\n`; - title += `${yellow("WARNING:")} you will lose all data in this database.`; - return title; -}; - -const runDown = Effect.fnUntraced(function* ( - flags: MigrationDownFlags, - target: ReturnType<typeof resolveDbTargetFlags>, -) { +const runDown = Effect.fnUntraced(function* (flags: MigrationDownFlags) { const output = yield* Output; const resolver = yield* DbConfigResolver; const connection = yield* DbConnection; @@ -44,39 +30,18 @@ const runDown = Effect.fnUntraced(function* ( const path = yield* Path.Path; const dnsResolver = yield* DnsResolverFlag; - // Checked here, ahead of the root pre-run. - if (target.setFlags.length > 1) { - return yield* new MigrationTargetFlagsError({ - 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`, - }); - } - - const connType = target.connType ?? "local"; + const target = yield* resolveTarget(flags); - // `--project-ref` never implies `--linked` and must not be silently - // discarded on a non-linked target; see push.handler.ts's identical guard. - if (Option.isSome(flags.projectRef) && connType !== "linked") { - return yield* new MigrationTargetFlagsError({ - message: - "--project-ref only applies when targeting the linked project; use it with --linked (not --local or --db-url)", + const downFlow = Effect.gen(function* () { + // Resolves before `--last` validation, so an unlinked/invalid target error + // surfaces before the `--last must be greater than 0` error. + const cfg = yield* resolver.resolveConnection({ + target, + dnsResolver, + password: Option.none(), + networkBanRemoval: yield* hostedMutationPreapproval, }); - } - - // Resolves before `--last` validation, so an unlinked/invalid target error - // surfaces before the `--last must be greater than 0` error. - const cfg = yield* resolver.resolve({ - dbUrl: flags.dbUrl, - connType, - dnsResolver, - linkedProjectRef: flags.projectRef, - }); - - // 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* loadProjectEnvValues(fs, path, cliSettings.workdir); - const yes = yield* resolveYesWithProjectEnv(projectEnv); - const downFlow = Effect.gen(function* () { if (flags.last === 0) { return yield* new MigrationLastZeroError({ message: "--last must be greater than 0" }); } @@ -86,10 +51,6 @@ const runDown = Effect.fnUntraced(function* ( yield* Effect.scoped( Effect.gen(function* () { - yield* output.raw( - `Connecting to ${cfg.isLocal ? "local" : "remote"} database...\n`, - "stderr", - ); const session = yield* connection.connect(cfg.conn, { isLocal: cfg.isLocal, dnsResolver, @@ -105,16 +66,14 @@ const runDown = Effect.fnUntraced(function* ( }); } - const confirmed = yield* migrationConfirm( - confirmResetAll(remote.slice(total - flags.last)), - { - defaultValue: false, - yes, - }, - ); - if (!confirmed) { - return yield* new OperationCanceledError({ message: CONTEXT_CANCELED_MESSAGE }); - } + yield* requireTargetMutationConsent(cfg, { + action: `Reset the database to version ${version}`, + details: [ + "Reverts the following migrations:", + ...remote.slice(total - flags.last).map((reverted) => ` • ${bold(reverted)}`), + `${yellow("WARNING:")} you will lose all data in this database.`, + ], + }); yield* output.raw(`Resetting database to version: ${version}\n`, "stderr"); yield* Effect.annotateCurrentSpan({ @@ -142,19 +101,15 @@ const runDown = Effect.fnUntraced(function* ( }); // Attached to the whole flow via `Effect.ensuring` so the cache write still - // runs on the `--last`/cancel failure paths. - if (connType === "linked") { - const projectRef = yield* ProjectRefResolver; + // runs on the `--last`/decline failure paths. + if (target.kind === "linked") { const linkedProjectCache = yield* LinkedProjectCache; - const linkedRef = yield* projectRef.loadProjectRef(flags.projectRef); - return yield* downFlow.pipe(Effect.ensuring(linkedProjectCache.cache(linkedRef))); + return yield* downFlow.pipe(Effect.ensuring(linkedProjectCache.cache(target.ref))); } return yield* downFlow; }); export const migrationDown = Effect.fn("migration.down")(function* (flags: MigrationDownFlags) { const telemetryState = yield* TelemetryState; - const cliArgs = yield* CliArgs; - const target = resolveDbTargetFlags(cliArgs.args); - yield* runDown(flags, target).pipe(Effect.ensuring(telemetryState.flush)); + yield* runDown(flags).pipe(Effect.ensuring(telemetryState.flush)); }); 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 d180df41a4..6206ba072d 100644 --- a/apps/cli/src/commands/migration/down/down.integration.test.ts +++ b/apps/cli/src/commands/migration/down/down.integration.test.ts @@ -22,7 +22,8 @@ import { 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 { describeHostedConsent } from "../../../../tests/helpers/hosted-consent.ts"; +import { mockOutput, mockTty } from "../../../../tests/helpers/mocks.ts"; import { assertPermissionDrift } from "../../../../tests/helpers/permission-drift.ts"; import { asRecordedRequests, @@ -35,17 +36,24 @@ import { type StubbedRequest, } from "../../../../tests/helpers/permission-real-layers.ts"; import { CliArgs } from "../../../shared/cli/cli-args.service.ts"; -import { DnsResolverFlag, YesFlag } from "../../../command-internal/global-flags.ts"; +import { DnsResolverFlag, OutputFlag, YesFlag } from "../../../command-internal/global-flags.ts"; import type { OutputFormat } from "../../../shared/output/types.ts"; import { ProjectRefResolver } from "../../../config/project-ref.service.ts"; import { ProjectRefNotLinkedError } from "../../../config/project-ref.errors.ts"; import { DbConfigResolver } from "../../../command-internal/db-config.service.ts"; -import type { DbConfigFlags, ResolvedDbConfig } from "../../../command-internal/db-config.types.ts"; +import type { + DbConnectionInput, + ResolvedDbConnection, +} from "../../../command-internal/db-config.types.ts"; import { DbExecError } from "../../../command-internal/db-connection.errors.ts"; import { type DbSession, DbConnection } from "../../../command-internal/db-connection.service.ts"; import { MigrationDropError } from "../../../command-internal/drop-objects.ts"; import { MigrationSeedError } from "../../../command-internal/seed.ts"; -import { MigrationTargetFlagsError, OperationCanceledError } from "../migration.errors.ts"; +import { TargetPolicy } from "../../../command-internal/target/target-policy.ts"; +import { + TargetProjectRefConflictError, + TargetSelectorConflictError, +} from "../../../command-internal/target/target.errors.ts"; import { MigrationLastTooLargeError, MigrationLastZeroError } from "./down.errors.ts"; import { migrationDown } from "./down.handler.ts"; import type { MigrationDownFlags } from "./down.command.ts"; @@ -56,10 +64,8 @@ interface SetupOpts { /** Runs the real `DbConfigResolver` and linked-project cache against `workdir`. */ readonly realLayers?: boolean; readonly format?: OutputFormat; - readonly isTTY?: boolean; - readonly pipedInput?: string; - readonly args?: ReadonlyArray<string>; readonly yes?: boolean; + readonly interactive?: boolean; readonly confirm?: boolean; readonly remote?: ReadonlyArray<string>; readonly failResolve?: boolean; @@ -70,36 +76,25 @@ interface SetupOpts { const SELECT_SEED = "SELECT path, hash FROM supabase_migrations.seed_files"; -function setup(workdir: string, opts: SetupOpts = {}) { - const out = mockOutput({ - format: opts.format ?? "text", - promptConfirmResponses: opts.confirm === undefined ? undefined : [opts.confirm], - }); - const telemetry = mockTelemetryStateTracked(); - const cache = mockLinkedProjectCacheTracked(); +function fakeDatabase(opts: SetupOpts = {}) { const execs: Array<string> = []; const queries: Array<{ sql: string; params?: ReadonlyArray<unknown> }> = []; const resolver = Layer.succeed(DbConfigResolver, { - resolve: (_flags: DbConfigFlags) => - opts.failResolve === true - ? Effect.fail( - new ProjectRefNotLinkedError({ - message: "Cannot find project ref. Have you run link?", - }), - ) - : Effect.succeed({ - conn: { - host: "127.0.0.1", - port: 54322, - user: "postgres", - password: "x", - database: "postgres", - }, - isLocal: true, - ref: Option.none(), - } satisfies ResolvedDbConfig), - resolveConnection: () => Effect.die("resolveConnection is not used by this test"), + resolve: () => Effect.die("resolve is not used by this test"), + resolveConnection: (input: DbConnectionInput) => + Effect.succeed({ + conn: { + host: "127.0.0.1", + port: 54322, + user: "postgres", + password: "x", + database: "postgres", + }, + isLocal: input.target.kind === "local", + ref: input.target.kind === "linked" ? Option.some(input.target.ref) : Option.none(), + target: input.target, + } satisfies ResolvedDbConnection), resolvePoolerFallback: () => Effect.succeed(Option.none()), }); @@ -135,23 +130,50 @@ function setup(workdir: string, opts: SetupOpts = {}) { }, }); + return { resolver, connection, execs, queries }; +} + +const droppedSchemas = (execs: ReadonlyArray<string>) => + execs.filter((sql) => sql.startsWith("do $$")).length; + +function setup(workdir: string, opts: SetupOpts = {}) { + const interactive = opts.interactive ?? false; + const out = mockOutput({ + format: opts.format ?? "text", + interactive, + promptConfirmResponses: opts.confirm === undefined ? undefined : [opts.confirm], + }); + const telemetry = mockTelemetryStateTracked(); + const cache = mockLinkedProjectCacheTracked(); + const { resolver, connection, execs, queries } = fakeDatabase(opts); + // Gives an explicit --project-ref flag precedence over the VALID_REF fallback, so a // test can prove the flag drives the linked ref. const projectRef = Layer.succeed(ProjectRefResolver, { resolve: () => Effect.succeed(VALID_REF), resolveForLink: () => Effect.succeed(VALID_REF), - resolveRef: () => Effect.succeed({ ref: VALID_REF, source: "file", matchesLinkedRef: true }), + resolveRef: (flagValue: Option.Option<string>) => + opts.failResolve === true + ? Effect.fail( + new ProjectRefNotLinkedError({ + message: "Cannot find project ref. Have you run link?", + }), + ) + : Effect.succeed({ + ref: Option.getOrElse(flagValue, () => VALID_REF), + source: Option.isSome(flagValue) ? ("flag" as const) : ("file" as const), + matchesLinkedRef: Option.isNone(flagValue), + }), resolveOptional: () => Effect.succeed(Option.some(VALID_REF)), loadProjectRef: (flagValue: Option.Option<string>) => - Effect.succeed( - Option.isSome(flagValue) && flagValue.value.length > 0 ? flagValue.value : VALID_REF, - ), + Effect.succeed(Option.getOrElse(flagValue, () => VALID_REF)), promptProjectRef: () => Effect.succeed(VALID_REF), }); const http = recordingProjectHttpClient(VALID_REF); const layer = Layer.mergeAll( cliConfigValuesTestLayer, + Layer.succeed(TargetPolicy, { group: "selectable", consent: "no" }), out.layer, telemetry.layer, opts.realLayers === true @@ -172,13 +194,9 @@ function setup(workdir: string, opts: SetupOpts = {}) { mockCommandSettings({ workdir }), Layer.succeed(DnsResolverFlag, "native"), Layer.succeed(YesFlag, opts.yes ?? false), - Layer.succeed(CliArgs, { args: opts.args ?? [] }), - mockTty({ stdinIsTty: opts.isTTY ?? true }), - mockStdin( - opts.isTTY ?? true, - // Migration prompts read stdin directly, so the confirm answer is piped in. - opts.pipedInput ?? (opts.confirm === undefined ? undefined : opts.confirm ? "y\n" : "n\n"), - ), + Layer.succeed(OutputFlag, Option.none()), + Layer.succeed(CliArgs, { args: [] }), + mockTty({ stdinIsTty: interactive, stdoutIsTty: interactive }), BunServices.layer, ); return { layer, http, out, telemetry, execs, queries, cache }; @@ -188,7 +206,7 @@ const flags = (over: Partial<MigrationDownFlags> = {}): MigrationDownFlags => ({ last: over.last ?? 1, dbUrl: over.dbUrl ?? Option.none(), linked: over.linked ?? false, - local: over.local ?? true, + local: over.local ?? false, projectRef: over.projectRef ?? Option.none(), }); @@ -198,10 +216,9 @@ const seed = Effect.fnUntraced(function* ( body = "create table a;\n", ) { const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const dir = path.join(workdir, "supabase", "migrations"); + const dir = `${workdir}/supabase/migrations`; yield* fs.makeDirectory(dir, { recursive: true }); - yield* fs.writeFileString(path.join(dir, name), body); + yield* fs.writeFileString(`${dir}/${name}`, body); }); const writeProjectFile = Effect.fnUntraced(function* (workdir: string, name: string, body: string) { @@ -226,7 +243,7 @@ describe("migration down", () => { it.effect("resolves the DB target before rejecting --last 0", () => { const { layer } = setup(tmp.current, { failResolve: true }); return Effect.gen(function* () { - const error = yield* migrationDown(flags({ last: 0 })).pipe(Effect.flip); + const error = yield* migrationDown(flags({ last: 0, linked: true })).pipe(Effect.flip); expect(error).toBeInstanceOf(ProjectRefNotLinkedError); }).pipe(Effect.provide(layer)); }); @@ -239,17 +256,16 @@ describe("migration down", () => { }).pipe(Effect.provide(layer)); }); - it.effect("reverts to the target version on confirm (drop + migrate&seed)", () => { + it.effect("reverts the local database to the target version without prompting", () => { const { layer, out, execs, queries } = setup(tmp.current, { - confirm: true, remote: ["20240101000000", "20240102000000"], }); return Effect.gen(function* () { yield* seed(tmp.current, "20240101000000_a.sql"); yield* migrationDown(flags({ last: 1 })); - expect(stripAnsi(out.stderrText)).toContain("Connecting to local database..."); expect(stripAnsi(out.stderrText)).toContain("Resetting database to version: 20240101000000"); - expect(execs.some((sql) => sql.startsWith("do $$"))).toBe(true); + expect(out.promptConfirmCalls).toEqual([]); + expect(droppedSchemas(execs)).toBe(1); expect( queries.some( (q) => @@ -259,102 +275,73 @@ describe("migration down", () => { }).pipe(Effect.provide(layer)); }); - it.effect( - "reverts on the project given via --project-ref --linked, overriding the linked ref", - () => { - // VALID_REF is the fake resolver's fallback, representing whatever the - // workdir would resolve to without the flag override. - const FLAG_REF = "flagflagflagflagflag"; - const { layer, cache } = setup(tmp.current, { - args: ["--linked"], - confirm: true, - remote: ["20240101000000", "20240102000000"], - }); - return Effect.gen(function* () { - yield* seed(tmp.current, "20240101000000_a.sql"); - yield* migrationDown( - flags({ last: 1, linked: true, local: false, projectRef: Option.some(FLAG_REF) }), - ); - expect(cache.cached).toBe(true); - expect(cache.cachedRef).toBe(FLAG_REF); - expect(cache.cachedRef).not.toBe(VALID_REF); - }).pipe(Effect.provide(layer)); - }, - ); - - it.effect("rejects --project-ref on the default local target", () => { - const FLAG_REF = "flagflagflagflagflag"; - const { layer, execs, queries, cache } = setup(tmp.current, { + it.effect("reverts a linked project after the user accepts the listed migrations", () => { + const { layer, out, execs, cache } = setup(tmp.current, { + interactive: true, remote: ["20240101000000", "20240102000000"], }); return Effect.gen(function* () { - const error = yield* migrationDown( - flags({ last: 1, projectRef: Option.some(FLAG_REF) }), - ).pipe(Effect.flip); - expect(error).toBeInstanceOf(MigrationTargetFlagsError); - expect(error.message).toBe( - "--project-ref only applies when targeting the linked project; use it with --linked (not --local or --db-url)", - ); - expect(execs).toEqual([]); - expect(queries).toEqual([]); - expect(cache.cached).toBe(false); + yield* seed(tmp.current, "20240101000000_a.sql"); + yield* migrationDown(flags({ last: 1, linked: true })); + const stderr = stripAnsi(out.stderrText); + expect(stderr).toContain("20240102000000"); + expect(stderr).toContain("you will lose all data in this database"); + expect(out.promptConfirmCalls[0]?.opts?.defaultValue).toBe(false); + expect(droppedSchemas(execs)).toBe(1); + expect(cache.cachedRef).toBe(VALID_REF); }).pipe(Effect.provide(layer)); }); - it.effect("cancels on a declined prompt", () => { - const { layer, execs } = setup(tmp.current, { - confirm: false, + it.effect("reverts on the project given via --project-ref, which implies --linked", () => { + // VALID_REF is the fake resolver's fallback, representing whatever the + // workdir would resolve to without the flag override. + const FLAG_REF = "flagflagflagflagflag"; + const { layer, cache } = setup(tmp.current, { + yes: true, remote: ["20240101000000", "20240102000000"], }); return Effect.gen(function* () { yield* seed(tmp.current, "20240101000000_a.sql"); - const error = yield* migrationDown(flags({ last: 1 })).pipe(Effect.flip); - expect(error).toBeInstanceOf(OperationCanceledError); - expect(execs.some((sql) => sql.startsWith("do $$"))).toBe(false); + yield* migrationDown(flags({ last: 1, projectRef: Option.some(FLAG_REF) })); + expect(cache.cached).toBe(true); + expect(cache.cachedRef).toBe(FLAG_REF); + expect(cache.cachedRef).not.toBe(VALID_REF); }).pipe(Effect.provide(layer)); }); - it.effect("falls back to NO (cancels) without a TTY and no piped answer", () => { - // isTTY only changes the read timeout; stdin is still read either way. - const { layer, out } = setup(tmp.current, { - isTTY: false, + it.effect("rejects --project-ref combined with --local before touching the database", () => { + const FLAG_REF = "flagflagflagflagflag"; + const { layer, execs, queries, cache } = setup(tmp.current, { remote: ["20240101000000", "20240102000000"], }); return Effect.gen(function* () { - const error = yield* migrationDown(flags({ last: 1 })).pipe(Effect.flip); - expect(error).toBeInstanceOf(OperationCanceledError); - expect(out.promptConfirmCalls.length).toBe(0); + const error = yield* migrationDown( + flags({ last: 1, local: true, projectRef: Option.some(FLAG_REF) }), + ).pipe(Effect.flip); + expect(error).toBeInstanceOf(TargetProjectRefConflictError); + expect(execs).toEqual([]); + expect(queries).toEqual([]); + expect(cache.cached).toBe(false); }).pipe(Effect.provide(layer)); }); - it.effect("emits a structured result in json with --yes", () => { - const { layer, out } = setup(tmp.current, { - format: "json", - yes: true, - remote: ["20240101000000", "20240102000000"], - }); + it.effect("rejects --db-url combined with --local", () => { + const { layer } = setup(tmp.current); return Effect.gen(function* () { - yield* seed(tmp.current, "20240101000000_a.sql"); - yield* migrationDown(flags({ last: 1 })); - expect(out.messages).toContainEqual( - expect.objectContaining({ - type: "success", - message: "Migrations reverted", - data: { version: "20240101000000", last: 1 }, - }), - ); + const error = yield* migrationDown( + flags({ dbUrl: Option.some("postgresql://x"), local: true }), + ).pipe(Effect.flip); + expect(error).toBeInstanceOf(TargetSelectorConflictError); }).pipe(Effect.provide(layer)); }); - it.effect("auto-confirms from SUPABASE_YES in the project .env", () => { - // SUPABASE_YES lives only in supabase/.env; the project env loads it before the prompt. + it.effect("emits a structured result in json on the local database", () => { const { layer, out } = setup(tmp.current, { format: "json", remote: ["20240101000000", "20240102000000"], }); return Effect.gen(function* () { yield* seed(tmp.current, "20240101000000_a.sql"); - yield* writeProjectFile(tmp.current, ".env", "SUPABASE_YES=true\n"); yield* migrationDown(flags({ last: 1 })); expect(out.messages).toContainEqual( expect.objectContaining({ @@ -368,7 +355,6 @@ describe("migration down", () => { it.effect("reports a drop-schema failure", () => { const { layer } = setup(tmp.current, { - confirm: true, remote: ["20240101000000", "20240102000000"], failDrop: true, }); @@ -381,7 +367,6 @@ describe("migration down", () => { it.effect("seeds data from a new seed file and records its hash", () => { const { layer, out, queries } = setup(tmp.current, { - confirm: true, remote: ["20240101000000", "20240102000000"], }); return Effect.gen(function* () { @@ -397,7 +382,6 @@ describe("migration down", () => { it.effect("reports a seed-apply failure", () => { const { layer } = setup(tmp.current, { - confirm: true, remote: ["20240101000000", "20240102000000"], failSeed: true, }); @@ -413,7 +397,6 @@ describe("migration down", () => { const body = "insert into a values (1);\n"; const hash = createHash("sha256").update(body).digest("hex"); const { layer, out, queries } = setup(tmp.current, { - confirm: true, remote: ["20240101000000", "20240102000000"], seedTable: [{ path: "supabase/seed.sql", hash }], }); @@ -430,7 +413,6 @@ describe("migration down", () => { it.effect("updates the recorded hash (without re-running) for a changed seed file", () => { const { layer, out, execs, queries } = setup(tmp.current, { - confirm: true, remote: ["20240101000000", "20240102000000"], seedTable: [{ path: "supabase/seed.sql", hash: "stale-hash-does-not-match" }], }); @@ -448,7 +430,6 @@ describe("migration down", () => { it.effect("skips migration apply when db.migrations.enabled = false", () => { const { layer, queries } = setup(tmp.current, { - confirm: true, remote: ["20240101000000", "20240102000000"], }); return Effect.gen(function* () { @@ -461,8 +442,7 @@ describe("migration down", () => { it.effect("mints a login role and fills the cache through the real resolver and cache", () => { const ctx = setup(tmp.current, { - args: ["migration", "down", "--linked", "--project-ref", VALID_REF], - confirm: true, + yes: true, realLayers: true, remote: ["20240101000000", "20240102000000"], }); @@ -471,9 +451,7 @@ describe("migration down", () => { const fs = yield* FileSystem.FileSystem; yield* stubManagementApi(requests, serveLinkedDatabase(VALID_REF)); yield* seed(tmp.current, "20240101000000_a.sql"); - yield* migrationDown( - flags({ last: 1, linked: true, local: false, projectRef: Option.some(VALID_REF) }), - ); + yield* migrationDown(flags({ last: 1, linked: true, projectRef: Option.some(VALID_REF) })); expect(ctx.out.stderrText).toContain("Resetting database to version: 20240101000000"); expect(requests.map(({ method, path }) => `${method} ${path}`)).toEqual([ `GET /v1/projects/${VALID_REF}/config/database/pooler`, @@ -498,8 +476,6 @@ describe("migration down", () => { it.effect("defaults to the local target and makes no Management API request", () => { const ctx = setup(tmp.current, { - args: ["migration", "down"], - confirm: true, realLayers: true, remote: ["20240101000000", "20240102000000"], }); @@ -534,3 +510,24 @@ describe("migration down", () => { ); }); }); + +describeHostedConsent("migration down", { + setup: (workdir) => seed(workdir, "20240101000000_a.sql"), + invoke: () => { + const db = fakeDatabase({ remote: ["20240101000000", "20240102000000"] }); + return { + run: migrationDown(flags({ last: 1, linked: true })).pipe( + Effect.provide( + Layer.mergeAll( + db.resolver, + db.connection, + cliConfigValuesTestLayer, + mockTelemetryStateTracked().layer, + Layer.succeed(DnsResolverFlag, "native"), + ), + ), + ), + mutations: () => droppedSchemas(db.execs), + }; + }, +}); diff --git a/apps/cli/src/commands/migration/list/list.command.ts b/apps/cli/src/commands/migration/list/list.command.ts index 1d0633f9ad..b8e5388283 100644 --- a/apps/cli/src/commands/migration/list/list.command.ts +++ b/apps/cli/src/commands/migration/list/list.command.ts @@ -1,4 +1,4 @@ -import { Command, Flag } from "effect/cli"; +import { Command } from "effect/cli"; import type * as CliCommand from "effect/cli/Command"; import { withPermissions } from "../../../command-internal/command-permissions/command-permissions.annotation.ts"; @@ -16,28 +16,11 @@ 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"; +import { targetFlags } from "../../../command-internal/target/target-flags.ts"; import { withTargetPolicy } from "../../../command-internal/target/target-policy.ts"; const config = { - dbUrl: Flag.String("db-url").pipe( - Flag.withDescription( - "Lists migrations of the database specified by the connection string (must be percent-encoded).", - ), - Flag.optional, - ), - linked: Flag.Boolean("linked").pipe( - Flag.withDescription("Lists migrations applied to the linked project."), - Flag.withDefault(true), - ), - local: Flag.Boolean("local").pipe( - Flag.withDescription("Lists migrations applied to the local database."), - Flag.withDefault(false), - ), - // 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."), - Flag.optional, - ), + ...targetFlags({ group: "selectable", subject: "database", dbUrl: true }), password: CliConfigKeys.linkedDb.password.flag({ name: "password", alias: "p", diff --git a/apps/cli/src/commands/migration/list/list.handler.ts b/apps/cli/src/commands/migration/list/list.handler.ts index 4543b6765b..bcb02e6587 100644 --- a/apps/cli/src/commands/migration/list/list.handler.ts +++ b/apps/cli/src/commands/migration/list/list.handler.ts @@ -1,33 +1,25 @@ -import { Effect, FileSystem, Option, Path } from "effect"; +import { Effect, FileSystem, Path } from "effect"; import { DnsResolverFlag } from "../../../command-internal/global-flags.ts"; -import { CliArgs } from "../../../shared/cli/cli-args.service.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 { 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 { - rejectPasswordWithDirectTarget, - resolveDbTargetFlags, -} from "../../../command-internal/db-target-flags.ts"; +import { hostedMutationPreapproval } from "../../../command-internal/hosted-consent/hosted-consent.ts"; import { listRemoteMigrations, loadLocalVersions, } from "../../../command-internal/migration-history.ts"; +import { resolveTarget } from "../../../command-internal/target/resolve-target.ts"; import { LinkedProjectCache } from "../../../telemetry/linked-project-cache.service.ts"; import { TelemetryState } from "../../../telemetry/telemetry-state.service.ts"; -import { MigrationTargetFlagsError } from "../migration.errors.ts"; import type { MigrationListFlags } from "./list.command.ts"; import { makeMigrationListRows, migrationListTableCells } from "./list.format.ts"; const LIST_HEADERS = ["Local", "Remote", "Time (UTC)"] as const; -const runList = Effect.fnUntraced(function* ( - flags: MigrationListFlags, - target: ReturnType<typeof resolveDbTargetFlags>, -) { +const runList = Effect.fnUntraced(function* (flags: MigrationListFlags) { const output = yield* Output; const resolver = yield* DbConfigResolver; const connection = yield* DbConnection; @@ -36,39 +28,19 @@ const runList = Effect.fnUntraced(function* ( const path = yield* Path.Path; const dnsResolver = yield* DnsResolverFlag; - // Mutually-exclusive flag groups, checked target group first, then {db-url, - // password}; `setFlags` is already sorted, matching the established error format. - if (target.setFlags.length > 1) { - return yield* new MigrationTargetFlagsError({ - 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`, - }); - } - 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. - if (Option.isSome(flags.projectRef) && (target.connType ?? "linked") !== "linked") { - return yield* new MigrationTargetFlagsError({ - message: - "--project-ref only applies when targeting the linked project; use it with --linked (not --local or --db-url)", - }); - } + const target = yield* resolveTarget(flags); const listBody = Effect.gen(function* () { - const cfg = yield* resolver.resolve({ - dbUrl: flags.dbUrl, - connType: target.connType ?? "linked", + const cfg = yield* resolver.resolveConnection({ + target, dnsResolver, password: flags.password, - linkedProjectRef: flags.projectRef, + hasPasswordFlag: true, + networkBanRemoval: yield* hostedMutationPreapproval, }); const remote = yield* Effect.scoped( Effect.gen(function* () { - yield* output.raw( - `Connecting to ${cfg.isLocal ? "local" : "remote"} database...\n`, - "stderr", - ); const session = yield* connection.connect(cfg.conn, { isLocal: cfg.isLocal, dnsResolver, @@ -95,20 +67,14 @@ const runList = Effect.fnUntraced(function* ( } }); - // `--linked` resolves the project ref and writes the linked-project cache; `--local` - // / `--db-url` leave the ref empty. - if ((target.connType ?? "linked") === "linked") { - const projectRef = yield* ProjectRefResolver; + if (target.kind === "linked") { const linkedProjectCache = yield* LinkedProjectCache; - const ref = yield* projectRef.loadProjectRef(flags.projectRef); - return yield* listBody.pipe(Effect.ensuring(linkedProjectCache.cache(ref))); + return yield* listBody.pipe(Effect.ensuring(linkedProjectCache.cache(target.ref))); } return yield* listBody; }); export const migrationList = Effect.fn("migration.list")(function* (flags: MigrationListFlags) { const telemetryState = yield* TelemetryState; - const cliArgs = yield* CliArgs; - const target = resolveDbTargetFlags(cliArgs.args); - yield* runList(flags, target).pipe(Effect.ensuring(telemetryState.flush)); + yield* runList(flags).pipe(Effect.ensuring(telemetryState.flush)); }); 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 8a490e38bf..e79ce51773 100644 --- a/apps/cli/src/commands/migration/list/list.integration.test.ts +++ b/apps/cli/src/commands/migration/list/list.integration.test.ts @@ -31,17 +31,24 @@ import { stubManagementApi, type StubbedRequest, } from "../../../../tests/helpers/permission-real-layers.ts"; +import { cliConfigValuesTestLayer } from "../../../../tests/helpers/config-values-layer.ts"; import { CliArgs } from "../../../shared/cli/cli-args.service.ts"; -import { DnsResolverFlag } from "../../../command-internal/global-flags.ts"; +import { DnsResolverFlag, YesFlag } from "../../../command-internal/global-flags.ts"; import type { OutputFormat } from "../../../shared/output/types.ts"; import { ProjectRefResolver } from "../../../config/project-ref.service.ts"; -import { DbPasswordFlagsError } from "../../../command-internal/db-config.errors.ts"; import { DbConfigResolver } from "../../../command-internal/db-config.service.ts"; import { MigrationsReadError } from "../../../command-internal/migration.errors.ts"; -import type { DbConfigFlags, ResolvedDbConfig } from "../../../command-internal/db-config.types.ts"; +import type { + DbConnectionInput, + ResolvedDbConnection, +} from "../../../command-internal/db-config.types.ts"; import { DbExecError } from "../../../command-internal/db-connection.errors.ts"; import { DbConnection } from "../../../command-internal/db-connection.service.ts"; -import { MigrationTargetFlagsError } from "../migration.errors.ts"; +import { TargetPolicy } from "../../../command-internal/target/target-policy.ts"; +import { + TargetProjectRefConflictError, + TargetSelectorConflictError, +} from "../../../command-internal/target/target.errors.ts"; import { migrationList } from "./list.handler.ts"; import type { MigrationListFlags } from "./list.command.ts"; @@ -49,7 +56,6 @@ const LIST_SQL = "SELECT version FROM supabase_migrations.schema_migrations ORDE interface SetupOpts { readonly format?: OutputFormat; - readonly args?: ReadonlyArray<string>; readonly isLocal?: boolean; readonly remote?: ReadonlyArray<string>; readonly remoteError?: DbExecError; @@ -62,10 +68,11 @@ function setup(workdir: string, opts: SetupOpts = {}) { const telemetry = mockTelemetryStateTracked(); const cache = mockLinkedProjectCacheTracked(); - const resolverCalls: Array<DbConfigFlags> = []; + const resolverCalls: Array<DbConnectionInput> = []; const resolver = Layer.succeed(DbConfigResolver, { - resolve: (flags: DbConfigFlags) => { - resolverCalls.push(flags); + resolve: () => Effect.die("resolve is not used by this test"), + resolveConnection: (input: DbConnectionInput) => { + resolverCalls.push(input); return Effect.succeed({ conn: { host: "127.0.0.1", @@ -74,11 +81,11 @@ function setup(workdir: string, opts: SetupOpts = {}) { password: "x", database: "postgres", }, - isLocal: opts.isLocal ?? false, - ref: Option.some(VALID_REF), - } satisfies ResolvedDbConfig); + isLocal: opts.isLocal ?? input.target.kind === "local", + ref: input.target.kind === "linked" ? Option.some(input.target.ref) : Option.none(), + target: input.target, + } satisfies ResolvedDbConnection); }, - resolveConnection: () => Effect.die("resolveConnection is not used by this test"), resolvePoolerFallback: () => Effect.succeed(Option.none()), }); @@ -106,17 +113,23 @@ function setup(workdir: string, opts: SetupOpts = {}) { const projectRef = Layer.succeed(ProjectRefResolver, { resolve: () => Effect.succeed(VALID_REF), resolveForLink: () => Effect.succeed(VALID_REF), - resolveRef: () => Effect.succeed({ ref: VALID_REF, source: "file", matchesLinkedRef: true }), + resolveRef: (flagValue: Option.Option<string>) => + Effect.succeed({ + ref: Option.getOrElse(flagValue, () => VALID_REF), + source: Option.isSome(flagValue) ? "flag" : "file", + matchesLinkedRef: Option.isNone(flagValue), + }), resolveOptional: () => Effect.succeed(Option.some(VALID_REF)), loadProjectRef: (flagValue: Option.Option<string>) => - Effect.succeed( - Option.isSome(flagValue) && flagValue.value.length > 0 ? flagValue.value : VALID_REF, - ), + Effect.succeed(Option.getOrElse(flagValue, () => VALID_REF)), promptProjectRef: () => Effect.succeed(VALID_REF), }); const http = recordingProjectHttpClient(VALID_REF); const layer = Layer.mergeAll( + cliConfigValuesTestLayer, + Layer.succeed(TargetPolicy, { group: "selectable", consent: "none" }), + Layer.succeed(YesFlag, false), out.layer, telemetry.layer, opts.realLayers === true @@ -136,7 +149,7 @@ function setup(workdir: string, opts: SetupOpts = {}) { projectRef, mockCommandSettings({ workdir }), Layer.succeed(DnsResolverFlag, "native"), - Layer.succeed(CliArgs, { args: opts.args ?? [] }), + Layer.succeed(CliArgs, { args: [] }), BunServices.layer, ); return { @@ -151,7 +164,7 @@ function setup(workdir: string, opts: SetupOpts = {}) { const flags = (over: Partial<MigrationListFlags> = {}): MigrationListFlags => ({ dbUrl: over.dbUrl ?? Option.none(), - linked: over.linked ?? true, + linked: over.linked ?? false, local: over.local ?? false, projectRef: over.projectRef ?? Option.none(), password: over.password ?? Option.none(), @@ -165,24 +178,41 @@ const seedMigrations = Effect.fnUntraced(function* (workdir: string, names: Read for (const name of names) yield* fs.writeFileString(path.join(dir, name), "select 1;\n"); }); +const writeConfig = Effect.fnUntraced(function* (workdir: string, body: string) { + 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"), body); +}); + const tmp = useTempWorkdir(); describe("migration list", () => { - it.effect("lists merged local + remote migrations for the linked project by default", () => { + it.effect("lists merged local + remote migrations for the local database by default", () => { const ctx = setup(tmp.current, { remote: ["20240101000000", "20240102000000"], }); return Effect.gen(function* () { yield* seedMigrations(tmp.current, ["20240101000000_a.sql", "20240103000000_c.sql"]); yield* migrationList(flags()); - expect(stripAnsi(ctx.out.stderrText)).toContain("Connecting to remote database..."); const stdout = stripAnsi(ctx.out.stdoutText); expect(stdout).toContain("Local"); expect(stdout).toContain("Time (UTC)"); expect(stdout).toContain("`20240101000000`"); expect(stdout).toContain("`20240102000000`"); expect(stdout).toContain("`20240103000000`"); - expect(ctx.resolverCalls[0]?.connType).toBe("linked"); + expect(ctx.resolverCalls[0]?.target).toEqual({ kind: "local" }); + expect(ctx.cache.cachedRef).toBeUndefined(); + }).pipe(Effect.provide(ctx.layer)); + }); + + it.effect("lists the linked project and fills the cache when the workspace target is remote", () => { + const ctx = setup(tmp.current, { remote: ["20240101000000"] }); + return Effect.gen(function* () { + yield* seedMigrations(tmp.current, ["20240101000000_a.sql"]); + yield* writeConfig(tmp.current, 'target = "remote"\n'); + yield* migrationList(flags()); + expect(ctx.resolverCalls[0]?.target).toMatchObject({ kind: "linked", ref: VALID_REF }); expect(ctx.cache.cachedRef).toBe(VALID_REF); }).pipe(Effect.provide(ctx.layer)); }); @@ -218,7 +248,7 @@ describe("migration list", () => { }).pipe(Effect.provide(layer)); }); - it.effect("lists the project given via --project-ref, overriding the default linked ref", () => { + it.effect("lists the project given via --project-ref, which implies --linked", () => { // VALID_REF is the fake resolver's fallback; the flag must win over it and drive // the cached ref. const FLAG_REF = "flagflagflagflagflag"; @@ -231,64 +261,37 @@ describe("migration list", () => { }).pipe(Effect.provide(ctx.layer)); }); - it.effect("rejects --project-ref combined with an explicit --local target", () => { + it.effect("rejects --project-ref combined with --local before connecting", () => { const FLAG_REF = "flagflagflagflagflag"; - const ctx = setup(tmp.current, { args: ["--local"], isLocal: true, remote: [] }); + const ctx = setup(tmp.current, { remote: [] }); return Effect.gen(function* () { yield* seedMigrations(tmp.current, ["20240101000000_a.sql"]); const error = yield* migrationList( - flags({ linked: false, local: true, projectRef: Option.some(FLAG_REF) }), + flags({ local: true, projectRef: Option.some(FLAG_REF) }), ).pipe(Effect.flip); - expect(error).toBeInstanceOf(MigrationTargetFlagsError); - expect(error.message).toBe( - "--project-ref only applies when targeting the linked project; use it with --linked (not --local or --db-url)", - ); + expect(error).toBeInstanceOf(TargetProjectRefConflictError); expect(ctx.resolverCalls).toEqual([]); expect(ctx.cache.cachedRef).toBeUndefined(); }).pipe(Effect.provide(ctx.layer)); }); it.effect("targets the local database with --local and skips the linked cache", () => { - const ctx = setup(tmp.current, { - args: ["--local"], - isLocal: true, - remote: [], - }); + const ctx = setup(tmp.current, { remote: [] }); return Effect.gen(function* () { yield* seedMigrations(tmp.current, ["20240101000000_a.sql"]); - yield* migrationList(flags({ linked: false, local: true })); - expect(ctx.resolverCalls[0]?.connType).toBe("local"); + yield* migrationList(flags({ local: true })); + expect(ctx.resolverCalls[0]?.target).toEqual({ kind: "local" }); expect(ctx.cache.cachedRef).toBeUndefined(); }).pipe(Effect.provide(ctx.layer)); }); it.effect("rejects --db-url combined with --linked", () => { - const { layer } = setup(tmp.current, { args: ["--db-url", "postgresql://x", "--linked"] }); + const { layer } = setup(tmp.current); return Effect.gen(function* () { const error = yield* migrationList( flags({ dbUrl: Option.some("postgresql://x"), linked: true }), ).pipe(Effect.flip); - expect(error).toBeInstanceOf(MigrationTargetFlagsError); - }).pipe(Effect.provide(layer)); - }); - - it.effect("rejects --db-url combined with --password", () => { - const { layer } = setup(tmp.current, { args: ["--db-url", "postgresql://x"] }); - return Effect.gen(function* () { - const error = yield* migrationList( - flags({ dbUrl: Option.some("postgresql://x"), password: Option.some("pw") }), - ).pipe(Effect.flip); - expect(error).toBeInstanceOf(DbPasswordFlagsError); - }).pipe(Effect.provide(layer)); - }); - - it.effect("rejects --local combined with --password", () => { - const { layer } = setup(tmp.current, { args: ["--local"] }); - return Effect.gen(function* () { - const error = yield* migrationList( - flags({ local: true, linked: false, password: Option.some("pw") }), - ).pipe(Effect.flip); - expect(error).toBeInstanceOf(DbPasswordFlagsError); + expect(error).toBeInstanceOf(TargetSelectorConflictError); }).pipe(Effect.provide(layer)); }); @@ -329,7 +332,6 @@ describe("migration list", () => { it.effect("mints a login role and fills the cache through the real resolver and cache", () => { const ctx = setup(tmp.current, { - args: ["migration", "list", "--project-ref", VALID_REF], realLayers: true, remote: ["20240101000000"], }); @@ -362,14 +364,13 @@ describe("migration list", () => { it.effect("makes no Management API request for --local through the real resolver", () => { const ctx = setup(tmp.current, { - args: ["migration", "list", "--local"], isLocal: true, realLayers: true, }); const requests: Array<StubbedRequest> = []; return Effect.gen(function* () { yield* stubManagementApi(requests, () => undefined); - yield* migrationList(flags({ linked: false, local: true })); + yield* migrationList(flags({ local: true })); expect(requests).toEqual([]); expect(ctx.http.calls).toEqual([]); assertPermissionDrift({ @@ -387,7 +388,6 @@ describe("migration list", () => { it.effect("fetches the pooler config but mints no login role when --password is given", () => { const ctx = setup(tmp.current, { - args: ["migration", "list", "--password", "pw", "--project-ref", VALID_REF], realLayers: true, }); const requests: Array<StubbedRequest> = []; diff --git a/apps/cli/src/commands/migration/repair/repair.command.ts b/apps/cli/src/commands/migration/repair/repair.command.ts index a7f0a4cb47..3244ba5000 100644 --- a/apps/cli/src/commands/migration/repair/repair.command.ts +++ b/apps/cli/src/commands/migration/repair/repair.command.ts @@ -15,6 +15,7 @@ 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"; +import { targetFlags } from "../../../command-internal/target/target-flags.ts"; import { withTargetPolicy } from "../../../command-internal/target/target-policy.ts"; const config = { @@ -25,25 +26,7 @@ const config = { status: Flag.Literals("status", ["applied", "reverted"] as const).pipe( Flag.withDescription("Version status to update."), ), - dbUrl: Flag.String("db-url").pipe( - Flag.withDescription( - "Repairs migrations of the database specified by the connection string (must be percent-encoded).", - ), - Flag.optional, - ), - linked: Flag.Boolean("linked").pipe( - Flag.withDescription("Repairs the migration history of the linked project."), - Flag.withDefault(true), - ), - local: Flag.Boolean("local").pipe( - Flag.withDescription("Repairs the migration history of the local database."), - Flag.withDefault(false), - ), - // 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."), - Flag.optional, - ), + ...targetFlags({ group: "selectable", subject: "database", dbUrl: true }), password: CliConfigKeys.linkedDb.password.flag({ name: "password", alias: "p", diff --git a/apps/cli/src/commands/migration/repair/repair.handler.ts b/apps/cli/src/commands/migration/repair/repair.handler.ts index 6872ce73dd..1bb38cc2a3 100644 --- a/apps/cli/src/commands/migration/repair/repair.handler.ts +++ b/apps/cli/src/commands/migration/repair/repair.handler.ts @@ -1,23 +1,16 @@ import { Effect, FileSystem, Option, Path } from "effect"; -import { - DnsResolverFlag, - resolveYesWithProjectEnv, -} from "../../../command-internal/global-flags.ts"; -import { CliArgs } from "../../../shared/cli/cli-args.service.ts"; +import { DnsResolverFlag } from "../../../command-internal/global-flags.ts"; import { emitSuccessTrailer } from "../../../shared/cli/success-trailer.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 { aqua } from "../../../command-internal/colors.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 { - rejectPasswordWithDirectTarget, - resolveDbTargetFlags, -} from "../../../command-internal/db-target-flags.ts"; + hostedMutationPreapproval, + requireTargetMutationConsent, +} from "../../../command-internal/hosted-consent/hosted-consent.ts"; import { DELETE_MIGRATION_VERSION, type MigrationFile, @@ -29,15 +22,13 @@ import { UPSERT_MIGRATION_VERSION, } from "../../../command-internal/migration-history.ts"; import { parseMigrationVersion } from "../../../command-internal/migration-timestamp.format.ts"; +import { resolveTarget } from "../../../command-internal/target/resolve-target.ts"; import { LinkedProjectCache } from "../../../telemetry/linked-project-cache.service.ts"; import { TelemetryState } from "../../../telemetry/telemetry-state.service.ts"; import { MigrationFileNotFoundError, MigrationInvalidVersionError, - MigrationTargetFlagsError, - OperationCanceledError, } from "../migration.errors.ts"; -import { migrationConfirm } from "../migration.prompt.ts"; import { MigrationRepairUpdateError } from "./repair.errors.ts"; export interface MigrationRepairInput { @@ -53,9 +44,7 @@ export interface MigrationRepairInput { /** Creates the migration table, then runs one batch transaction. */ const updateMigrationTable = Effect.fn("MigrationRepair.updateMigrationTable")(function* ( session: DbSession, - fs: FileSystem.FileSystem, - path: Path.Path, - migrationsDir: string, + appliedFiles: ReadonlyArray<MigrationFile>, versions: ReadonlyArray<string>, status: "applied" | "reverted", repairAll: boolean, @@ -63,21 +52,6 @@ const updateMigrationTable = Effect.fn("MigrationRepair.updateMigrationTable")(f const output = yield* Output; yield* createMigrationTable(session); - // Resolve the applied rows up front (each file is read while queueing the - // batch, before sending it — a missing file aborts with no DB mutation). - const appliedFiles: Array<MigrationFile> = []; - if (status === "applied") { - for (const version of versions) { - const resolved = yield* resolveMigrationFile(fs, path, migrationsDir, version); - if (Option.isNone(resolved)) { - return yield* new MigrationFileNotFoundError({ - message: `glob supabase/migrations/${version}_*.sql: file does not exist`, - }); - } - appliedFiles.push(yield* readMigrationFile(fs, path, resolved.value)); - } - } - const txn = Effect.gen(function* () { yield* session.exec("BEGIN"); if (repairAll) yield* session.exec(TRUNCATE_VERSION_TABLE); @@ -109,10 +83,26 @@ const updateMigrationTable = Effect.fn("MigrationRepair.updateMigrationTable")(f } }); -const runRepair = Effect.fnUntraced(function* ( - input: MigrationRepairInput, - target: ReturnType<typeof resolveDbTargetFlags>, -) { +const repairPlan = ( + versions: ReadonlyArray<string>, + status: "applied" | "reverted", + repairAll: boolean, +) => { + if (!repairAll) { + return { + action: `Mark ${versions.length} ${versions.length === 1 ? "migration" : "migrations"} as ${status}`, + details: versions.map((version) => ` • ${version}`), + }; + } + return status === "applied" + ? { + action: `Replace the migration history table with ${versions.length} local ${versions.length === 1 ? "migration" : "migrations"}`, + details: [], + } + : { action: "Clear the migration history table", details: [] }; +}; + +const runRepair = Effect.fnUntraced(function* (input: MigrationRepairInput) { const output = yield* Output; const resolver = yield* DbConfigResolver; const connection = yield* DbConnection; @@ -121,49 +111,28 @@ const runRepair = Effect.fnUntraced(function* ( const path = yield* Path.Path; const dnsResolver = yield* DnsResolverFlag; - if (target.setFlags.length > 1) { - return yield* new MigrationTargetFlagsError({ - 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`, - }); - } - yield* rejectPasswordWithDirectTarget(target.connType ?? "linked", input.password); - const migrationsDir = path.join(cliSettings.workdir, "supabase", "migrations"); const repairAll = input.versions.length === 0; - const connType = target.connType ?? "linked"; - - // `--project-ref` never implies `--linked` and must not be silently - // discarded on a non-linked target; see push.handler.ts's identical guard. - if (Option.isSome(input.projectRef) && connType !== "linked") { - return yield* new MigrationTargetFlagsError({ - message: - "--project-ref only applies when targeting the linked project; use it with --linked (not --local or --db-url)", - }); - } - - // Resolves the DB config (and, for the linked default, the project ref) before the - // version parse and any prompt, so an invalid target surfaces first. - const cfg = yield* resolver.resolve({ - dbUrl: input.dbUrl, - connType, - dnsResolver, - password: input.password, - linkedProjectRef: input.projectRef, - }); - yield* Effect.annotateCurrentSpan({ - "db.conn_type": connType, - "db.is_local": cfg.isLocal, - "migration.repair_all": repairAll, - "migration.status": input.status, - }); - // 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* loadProjectEnvValues(fs, path, cliSettings.workdir); - const yes = yield* resolveYesWithProjectEnv(projectEnv); + const target = yield* resolveTarget(input); const repairFlow = Effect.gen(function* () { + // Resolves the connection before the version parse and any prompt, so an invalid + // target surfaces first. + const cfg = yield* resolver.resolveConnection({ + target, + dnsResolver, + password: input.password, + hasPasswordFlag: true, + networkBanRemoval: yield* hostedMutationPreapproval, + }); + yield* Effect.annotateCurrentSpan({ + "db.conn_type": target.kind, + "db.is_local": cfg.isLocal, + "migration.repair_all": repairAll, + "migration.status": input.status, + }); + // Rejects non-numeric and out-of-int64-range values. for (const version of input.versions) { if (parseMigrationVersion(version) === undefined) { @@ -173,38 +142,33 @@ const runRepair = Effect.fnUntraced(function* ( } } - // repair-all confirmation defaults to declining; then loads every local version. - let versions = input.versions; - if (repairAll) { - const confirmed = yield* migrationConfirm( - "Do you want to repair the entire migration history table to match local migration files?", - { defaultValue: false, yes }, - ); - if (!confirmed) { - return yield* new OperationCanceledError({ message: CONTEXT_CANCELED_MESSAGE }); + const versions = repairAll + ? yield* loadLocalVersions(fs, path, migrationsDir) + : input.versions; + + // Every file is read before the first write, so a missing file aborts with no DB mutation. + const appliedFiles: Array<MigrationFile> = []; + if (input.status === "applied") { + for (const version of versions) { + const resolved = yield* resolveMigrationFile(fs, path, migrationsDir, version); + if (Option.isNone(resolved)) { + return yield* new MigrationFileNotFoundError({ + message: `glob supabase/migrations/${version}_*.sql: file does not exist`, + }); + } + appliedFiles.push(yield* readMigrationFile(fs, path, resolved.value)); } - versions = yield* loadLocalVersions(fs, path, migrationsDir); } + yield* requireTargetMutationConsent(cfg, repairPlan(versions, input.status, repairAll)); + yield* Effect.scoped( Effect.gen(function* () { - yield* output.raw( - `Connecting to ${cfg.isLocal ? "local" : "remote"} database...\n`, - "stderr", - ); const session = yield* connection.connect(cfg.conn, { isLocal: cfg.isLocal, dnsResolver, }); - yield* updateMigrationTable( - session, - fs, - path, - migrationsDir, - versions, - input.status, - repairAll, - ); + yield* updateMigrationTable(session, appliedFiles, versions, input.status, repairAll); }), ); @@ -223,13 +187,11 @@ const runRepair = Effect.fnUntraced(function* ( } }); - // Attached to the whole flow via `Effect.ensuring` below so the cache write still - // runs even when the version parse fails or the repair-all prompt is declined. - if (connType === "linked") { - const projectRef = yield* ProjectRefResolver; + // Attached to the whole flow via `Effect.ensuring` so the cache write still runs even when + // the version parse fails or the consent is declined. + if (target.kind === "linked") { const linkedProjectCache = yield* LinkedProjectCache; - const ref = yield* projectRef.loadProjectRef(input.projectRef); - return yield* repairFlow.pipe(Effect.ensuring(linkedProjectCache.cache(ref))); + return yield* repairFlow.pipe(Effect.ensuring(linkedProjectCache.cache(target.ref))); } return yield* repairFlow; }); @@ -238,7 +200,5 @@ export const migrationRepair = Effect.fn("migration.repair")(function* ( input: MigrationRepairInput, ) { const telemetryState = yield* TelemetryState; - const cliArgs = yield* CliArgs; - const target = resolveDbTargetFlags(cliArgs.args); - yield* runRepair(input, target).pipe(Effect.ensuring(telemetryState.flush)); + yield* runRepair(input).pipe(Effect.ensuring(telemetryState.flush)); }); 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 4787c6466b..90e524fc15 100644 --- a/apps/cli/src/commands/migration/repair/repair.integration.test.ts +++ b/apps/cli/src/commands/migration/repair/repair.integration.test.ts @@ -19,9 +19,10 @@ import { mockTelemetryStateTracked, useTempWorkdir, sequentialExecBatch, - withConfigEnv, } from "../../../../tests/helpers/command-mocks.ts"; -import { mockOutput, mockStdin, mockTty } from "../../../../tests/helpers/mocks.ts"; +import { cliConfigValuesTestLayer } from "../../../../tests/helpers/config-values-layer.ts"; +import { describeHostedConsent } from "../../../../tests/helpers/hosted-consent.ts"; +import { mockOutput, mockTty } from "../../../../tests/helpers/mocks.ts"; import { assertPermissionDrift } from "../../../../tests/helpers/permission-drift.ts"; import { asRecordedRequests, @@ -34,21 +35,24 @@ import { type StubbedRequest, } from "../../../../tests/helpers/permission-real-layers.ts"; import { CliArgs } from "../../../shared/cli/cli-args.service.ts"; -import { DnsResolverFlag, YesFlag } from "../../../command-internal/global-flags.ts"; +import { DnsResolverFlag, OutputFlag, YesFlag } from "../../../command-internal/global-flags.ts"; import type { OutputFormat } from "../../../shared/output/types.ts"; import { ProjectRefNotLinkedError } from "../../../config/project-ref.errors.ts"; import { ProjectRefResolver } from "../../../config/project-ref.service.ts"; -import { DbPasswordFlagsError } from "../../../command-internal/db-config.errors.ts"; import { DbConfigResolver } from "../../../command-internal/db-config.service.ts"; -import type { DbConfigFlags, ResolvedDbConfig } from "../../../command-internal/db-config.types.ts"; +import type { + DbConnectionInput, + ResolvedDbConnection, +} from "../../../command-internal/db-config.types.ts"; import { DbExecError } from "../../../command-internal/db-connection.errors.ts"; import { type DbSession, DbConnection } from "../../../command-internal/db-connection.service.ts"; +import { HostedMutationDeclinedError } from "../../../command-internal/hosted-consent/hosted-consent.errors.ts"; +import { TargetPolicy } from "../../../command-internal/target/target-policy.ts"; import { - MigrationFileNotFoundError, - MigrationInvalidVersionError, - MigrationTargetFlagsError, - OperationCanceledError, -} from "../migration.errors.ts"; + TargetProjectRefConflictError, + TargetSelectorConflictError, +} from "../../../command-internal/target/target.errors.ts"; +import { MigrationFileNotFoundError, MigrationInvalidVersionError } from "../migration.errors.ts"; import { MigrationRepairUpdateError } from "./repair.errors.ts"; import { migrationRepair, type MigrationRepairInput } from "./repair.handler.ts"; @@ -56,46 +60,35 @@ interface SetupOpts { /** Runs the real `DbConfigResolver` and linked-project cache against `workdir`. */ readonly realLayers?: boolean; readonly format?: OutputFormat; - readonly isTTY?: boolean; - readonly pipedInput?: string; + readonly interactive?: boolean; readonly yes?: boolean; readonly confirm?: boolean; - readonly args?: ReadonlyArray<string>; readonly failSql?: string; readonly failResolve?: boolean; } -function setup(workdir: string, opts: SetupOpts = {}) { - const out = mockOutput({ - format: opts.format ?? "text", - promptConfirmResponses: opts.confirm === undefined ? undefined : [opts.confirm], - }); - const telemetry = mockTelemetryStateTracked(); - const cache = mockLinkedProjectCacheTracked(); - +function fakeDatabase(opts: Pick<SetupOpts, "failSql"> = {}) { const execs: Array<string> = []; const queries: Array<{ sql: string; params?: ReadonlyArray<unknown> }> = []; const resolver = Layer.succeed(DbConfigResolver, { - resolve: (_flags: DbConfigFlags) => - opts.failResolve === true - ? Effect.fail( - new ProjectRefNotLinkedError({ - message: "Cannot find project ref. Have you run link?", - }), - ) - : Effect.succeed({ - conn: { - host: "127.0.0.1", - port: 54322, - user: "postgres", - password: "x", - database: "postgres", - }, - isLocal: false, - ref: Option.some(VALID_REF), - } satisfies ResolvedDbConfig), - resolveConnection: () => Effect.die("resolveConnection is not used by this test"), + resolve: () => Effect.die("resolve is not used by this test"), + resolveConnection: (connectionInput: DbConnectionInput) => + Effect.succeed({ + conn: { + host: "127.0.0.1", + port: 54322, + user: "postgres", + password: "x", + database: "postgres", + }, + isLocal: connectionInput.target.kind === "local", + ref: + connectionInput.target.kind === "linked" + ? Option.some(connectionInput.target.ref) + : Option.none(), + target: connectionInput.target, + } satisfies ResolvedDbConnection), resolvePoolerFallback: () => Effect.succeed(Option.none()), }); @@ -126,22 +119,47 @@ function setup(workdir: string, opts: SetupOpts = {}) { }, }); + return { resolver, connection, execs, queries }; +} + +function setup(workdir: string, opts: SetupOpts = {}) { + const interactive = opts.interactive ?? false; + const out = mockOutput({ + format: opts.format ?? "text", + interactive, + promptConfirmResponses: opts.confirm === undefined ? undefined : [opts.confirm], + }); + const telemetry = mockTelemetryStateTracked(); + const cache = mockLinkedProjectCacheTracked(); + const { resolver, connection, execs, queries } = fakeDatabase(opts); + // Gives an explicit --project-ref flag precedence over the VALID_REF fallback, so a // test can prove the flag drives the linked ref. const projectRef = Layer.succeed(ProjectRefResolver, { resolve: () => Effect.succeed(VALID_REF), resolveForLink: () => Effect.succeed(VALID_REF), - resolveRef: () => Effect.succeed({ ref: VALID_REF, source: "file", matchesLinkedRef: true }), + resolveRef: (flagValue: Option.Option<string>) => + opts.failResolve === true + ? Effect.fail( + new ProjectRefNotLinkedError({ + message: "Cannot find project ref. Have you run link?", + }), + ) + : Effect.succeed({ + ref: Option.getOrElse(flagValue, () => VALID_REF), + source: Option.isSome(flagValue) ? ("flag" as const) : ("file" as const), + matchesLinkedRef: Option.isNone(flagValue), + }), resolveOptional: () => Effect.succeed(Option.some(VALID_REF)), loadProjectRef: (flagValue: Option.Option<string>) => - Effect.succeed( - Option.isSome(flagValue) && flagValue.value.length > 0 ? flagValue.value : VALID_REF, - ), + Effect.succeed(Option.getOrElse(flagValue, () => VALID_REF)), promptProjectRef: () => Effect.succeed(VALID_REF), }); const http = recordingProjectHttpClient(VALID_REF); const layer = Layer.mergeAll( + cliConfigValuesTestLayer, + Layer.succeed(TargetPolicy, { group: "selectable", consent: "no" }), out.layer, telemetry.layer, opts.realLayers === true @@ -162,13 +180,9 @@ function setup(workdir: string, opts: SetupOpts = {}) { mockCommandSettings({ workdir }), Layer.succeed(DnsResolverFlag, "native"), Layer.succeed(YesFlag, opts.yes ?? false), - Layer.succeed(CliArgs, { args: opts.args ?? [] }), - mockTty({ stdinIsTty: opts.isTTY ?? true }), - mockStdin( - opts.isTTY ?? true, - // Migration prompts read stdin directly, so the confirm answer is piped in. - opts.pipedInput ?? (opts.confirm === undefined ? undefined : opts.confirm ? "y\n" : "n\n"), - ), + Layer.succeed(OutputFlag, Option.none()), + Layer.succeed(CliArgs, { args: [] }), + mockTty({ stdinIsTty: interactive, stdoutIsTty: interactive }), BunServices.layer, ); return { layer, http, out, telemetry, execs, queries, cache }; @@ -178,7 +192,7 @@ const input = (over: Partial<MigrationRepairInput> = {}): MigrationRepairInput = versions: over.versions ?? [], status: over.status ?? "applied", dbUrl: over.dbUrl ?? Option.none(), - linked: over.linked ?? true, + linked: over.linked ?? false, local: over.local ?? false, projectRef: over.projectRef ?? Option.none(), password: over.password ?? Option.none(), @@ -186,29 +200,19 @@ const input = (over: Partial<MigrationRepairInput> = {}): MigrationRepairInput = const seedMigration = Effect.fnUntraced(function* (workdir: string, name: string, body: string) { const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const dir = path.join(workdir, "supabase", "migrations"); - yield* fs.makeDirectory(dir, { recursive: true }); - yield* fs.writeFileString(path.join(dir, name), body); -}); - -const writeProjectFile = Effect.fnUntraced(function* (workdir: string, name: string, body: string) { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const dir = path.join(workdir, "supabase"); + const dir = `${workdir}/supabase/migrations`; yield* fs.makeDirectory(dir, { recursive: true }); - yield* fs.writeFileString(path.join(dir, name), body); + yield* fs.writeFileString(`${dir}/${name}`, body); }); const tmp = useTempWorkdir(); describe("migration repair", () => { it.effect("marks a version as applied by upserting from its local file", () => { - const { layer, execs, queries, out } = setup(tmp.current); + const { layer, execs, queries } = setup(tmp.current); return Effect.gen(function* () { yield* seedMigration(tmp.current, "20240101000000_init.sql", "create table a;\n"); yield* migrationRepair(input({ versions: ["20240101000000"], status: "applied" })); - expect(stripAnsi(out.stderrText)).toContain("Connecting to remote database..."); expect(execs).toContain("BEGIN"); expect(execs).toContain("COMMIT"); expect(execs).not.toContain("ROLLBACK"); @@ -221,20 +225,20 @@ describe("migration repair", () => { const { layer } = setup(tmp.current, { failResolve: true }); return Effect.gen(function* () { const error = yield* migrationRepair( - input({ versions: ["not-a-number"], status: "applied" }), + input({ versions: ["not-a-number"], status: "applied", linked: true }), ).pipe(Effect.flip); expect(error).toBeInstanceOf(ProjectRefNotLinkedError); }).pipe(Effect.provide(layer)); }); - it.effect("caches the linked project even when the repair-all prompt is declined", () => { - const { layer, cache } = setup(tmp.current, { confirm: false }); + it.effect("caches the linked project even when the consent is declined", () => { + const { layer, cache } = setup(tmp.current, { interactive: true, confirm: false }); return Effect.gen(function* () { - const error = yield* migrationRepair(input({ versions: [], status: "applied" })).pipe( - Effect.flip, - ); - expect(error).toBeInstanceOf(OperationCanceledError); - expect(cache.cached).toBe(true); + yield* seedMigration(tmp.current, "20240101000000_init.sql", "create table a;\n"); + const error = yield* migrationRepair( + input({ versions: ["20240101000000"], status: "applied", linked: true }), + ).pipe(Effect.flip); + expect(error).toBeInstanceOf(HostedMutationDeclinedError); expect(cache.cachedRef).toBe(VALID_REF); }).pipe(Effect.provide(layer)); }); @@ -274,11 +278,12 @@ describe("migration repair", () => { }).pipe(Effect.provide(layer)); }); - it.effect("repair-all truncates and reapplies local files on confirm", () => { - const { layer, execs, queries } = setup(tmp.current, { confirm: true }); + it.effect("repair-all truncates and reapplies local files on the local database without prompting", () => { + const { layer, execs, queries, out } = setup(tmp.current); return Effect.gen(function* () { yield* seedMigration(tmp.current, "20240101000000_init.sql", "create table a;\n"); yield* migrationRepair(input({ versions: [], status: "applied" })); + expect(out.promptConfirmCalls).toEqual([]); expect(execs).toContain("TRUNCATE supabase_migrations.schema_migrations"); expect(queries.some((q) => q.sql.includes("ON CONFLICT"))).toBe(true); }).pipe(Effect.provide(layer)); @@ -289,7 +294,7 @@ describe("migration repair", () => { () => { // repair-all + reverted only queues TRUNCATE; DELETE is the non-repair-all path // and UPSERT is the applied path. - const { layer, execs, queries } = setup(tmp.current, { confirm: true }); + const { layer, execs, queries } = setup(tmp.current); return Effect.gen(function* () { yield* seedMigration(tmp.current, "20240101000000_init.sql", "create table a;\n"); yield* migrationRepair(input({ versions: [], status: "reverted" })); @@ -300,69 +305,24 @@ describe("migration repair", () => { }, ); - it.effect("repair-all cancels on a declined prompt", () => { - const { layer, execs } = setup(tmp.current, { confirm: false }); - return Effect.gen(function* () { - yield* seedMigration(tmp.current, "20240101000000_init.sql", "create table a;\n"); - const error = yield* migrationRepair(input({ versions: [], status: "applied" })).pipe( - Effect.flip, - ); - expect(error).toBeInstanceOf(OperationCanceledError); - expect(execs).not.toContain("TRUNCATE supabase_migrations.schema_migrations"); - }).pipe(Effect.provide(layer)); - }); - - it.effect("repair-all without a TTY and no piped answer falls back to NO (cancel)", () => { - // isTTY only changes the read timeout; stdin is still read either way. - const { layer, out } = setup(tmp.current, { isTTY: false }); - return Effect.gen(function* () { - const error = yield* migrationRepair(input({ versions: [], status: "applied" })).pipe( - Effect.flip, - ); - expect(error).toBeInstanceOf(OperationCanceledError); - expect(out.promptConfirmCalls.length).toBe(0); - }).pipe(Effect.provide(layer)); - }); - - it.effect("repair-all honors a piped 'y' answer without a TTY (proceeds)", () => { - // Piped stdin is read even without a TTY, overriding the default no. - const { layer, execs, queries } = setup(tmp.current, { isTTY: false, pipedInput: "y\n" }); - return Effect.gen(function* () { - yield* seedMigration(tmp.current, "20240101000000_init.sql", "create table a;\n"); - yield* migrationRepair(input({ versions: [], status: "applied" })); - expect(execs).toContain("TRUNCATE supabase_migrations.schema_migrations"); - expect(queries.some((q) => q.sql.includes("ON CONFLICT"))).toBe(true); - }).pipe(Effect.provide(layer)); - }); - - it.effect("auto-confirms repair-all via SUPABASE_YES (no --yes flag)", () => { - const { layer, execs, queries } = setup(tmp.current); - return Effect.gen(function* () { - yield* seedMigration(tmp.current, "20240101000000_init.sql", "create table a;\n"); - yield* migrationRepair(input({ versions: [], status: "applied" })); - expect(execs).toContain("TRUNCATE supabase_migrations.schema_migrations"); - expect(queries.some((q) => q.sql.includes("ON CONFLICT"))).toBe(true); - }).pipe(Effect.provide(layer), (body) => withConfigEnv({ SUPABASE_YES: "1" }, body)); - }); - - it.effect("auto-confirms repair-all via SUPABASE_YES in the project .env", () => { - // SUPABASE_YES lives only in supabase/.env; the project env loads it before the prompt. - const { layer, execs, queries } = setup(tmp.current); + it.effect("repair-all on a linked project is declined at the consent and changes nothing", () => { + const { layer, execs } = setup(tmp.current, { interactive: true, confirm: false }); return Effect.gen(function* () { yield* seedMigration(tmp.current, "20240101000000_init.sql", "create table a;\n"); - yield* writeProjectFile(tmp.current, ".env", "SUPABASE_YES=true\n"); - yield* migrationRepair(input({ versions: [], status: "applied" })); - expect(execs).toContain("TRUNCATE supabase_migrations.schema_migrations"); - expect(queries.some((q) => q.sql.includes("ON CONFLICT"))).toBe(true); + const error = yield* migrationRepair( + input({ versions: [], status: "applied", linked: true }), + ).pipe(Effect.flip); + expect(error).toBeInstanceOf(HostedMutationDeclinedError); + expect(execs).toEqual([]); }).pipe(Effect.provide(layer)); }); it.effect("surfaces a DB-config error before prompting (repair-all, unlinked)", () => { const { layer, out } = setup(tmp.current, { failResolve: true }); return Effect.gen(function* () { - const error = yield* migrationRepair(input({ versions: [], status: "applied" })).pipe( - Effect.flip, - ); + const error = yield* migrationRepair( + input({ versions: [], status: "applied", linked: true }), + ).pipe(Effect.flip); expect(error).toBeInstanceOf(ProjectRefNotLinkedError); expect(out.promptConfirmCalls.length).toBe(0); }).pipe(Effect.provide(layer)); @@ -399,13 +359,14 @@ describe("migration repair", () => { }).pipe(Effect.provide(layer)); }); - it.effect("reports a missing local file in applied mode", () => { - const { layer } = setup(tmp.current); // no seeded file + it.effect("reports a missing local file in applied mode before writing anything", () => { + const { layer, execs } = setup(tmp.current); // no seeded file return Effect.gen(function* () { const error = yield* migrationRepair( input({ versions: ["20240101000000"], status: "applied" }), ).pipe(Effect.flip); expect(error).toBeInstanceOf(MigrationFileNotFoundError); + expect(execs).toEqual([]); }).pipe(Effect.provide(layer)); }); @@ -421,75 +382,53 @@ describe("migration repair", () => { }); it.effect("rejects --db-url combined with --linked", () => { - const { layer } = setup(tmp.current, { args: ["--db-url", "postgresql://x", "--linked"] }); + const { layer } = setup(tmp.current); return Effect.gen(function* () { const error = yield* migrationRepair( input({ versions: ["20240101000000"], status: "applied", + linked: true, dbUrl: Option.some("postgresql://x"), }), ).pipe(Effect.flip); - expect(error).toBeInstanceOf(MigrationTargetFlagsError); + expect(error).toBeInstanceOf(TargetSelectorConflictError); }).pipe(Effect.provide(layer)); }); - it.effect("rejects --password combined with --local", () => { - const { layer } = setup(tmp.current, { args: ["--local"] }); + it.effect("repairs the project given via --project-ref, which implies --linked", () => { + // VALID_REF is the fake resolver's fallback; the flag must win over it and drive + // the cached ref. + const FLAG_REF = "flagflagflagflagflag"; + const { layer, cache } = setup(tmp.current, { yes: true }); return Effect.gen(function* () { - const error = yield* migrationRepair( + yield* seedMigration(tmp.current, "20240101000000_init.sql", "create table a;\n"); + yield* migrationRepair( input({ versions: ["20240101000000"], status: "applied", - local: true, - linked: false, - password: Option.some("pw"), + projectRef: Option.some(FLAG_REF), }), - ).pipe(Effect.flip); - expect(error).toBeInstanceOf(DbPasswordFlagsError); + ); + expect(cache.cached).toBe(true); + expect(cache.cachedRef).toBe(FLAG_REF); + expect(cache.cachedRef).not.toBe(VALID_REF); }).pipe(Effect.provide(layer)); }); - it.effect( - "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. - const FLAG_REF = "flagflagflagflagflag"; - const { layer, cache } = setup(tmp.current); - return Effect.gen(function* () { - yield* seedMigration(tmp.current, "20240101000000_init.sql", "create table a;\n"); - yield* migrationRepair( - input({ - versions: ["20240101000000"], - status: "applied", - projectRef: Option.some(FLAG_REF), - }), - ); - expect(cache.cached).toBe(true); - expect(cache.cachedRef).toBe(FLAG_REF); - expect(cache.cachedRef).not.toBe(VALID_REF); - }).pipe(Effect.provide(layer)); - }, - ); - - it.effect("rejects --project-ref combined with an explicit --local target", () => { + it.effect("rejects --project-ref combined with --local before touching the database", () => { const FLAG_REF = "flagflagflagflagflag"; - const { layer, execs, queries, cache } = setup(tmp.current, { args: ["--local"] }); + const { layer, execs, queries, cache } = setup(tmp.current); return Effect.gen(function* () { const error = yield* migrationRepair( input({ versions: ["20240101000000"], status: "applied", - linked: false, local: true, projectRef: Option.some(FLAG_REF), }), ).pipe(Effect.flip); - expect(error).toBeInstanceOf(MigrationTargetFlagsError); - expect(error.message).toBe( - "--project-ref only applies when targeting the linked project; use it with --linked (not --local or --db-url)", - ); + expect(error).toBeInstanceOf(TargetProjectRefConflictError); expect(execs).toEqual([]); expect(queries).toEqual([]); expect(cache.cached).toBe(false); @@ -511,10 +450,7 @@ describe("migration repair", () => { }); it.effect("mints a login role and fills the cache through the real resolver and cache", () => { - const ctx = setup(tmp.current, { - args: ["migration", "repair", "--project-ref", VALID_REF], - realLayers: true, - }); + const ctx = setup(tmp.current, { realLayers: true, yes: true }); const requests: Array<StubbedRequest> = []; return Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; @@ -549,16 +485,13 @@ describe("migration repair", () => { }); it.effect("makes no Management API request for --local through the real resolver", () => { - const ctx = setup(tmp.current, { - args: ["migration", "repair", "--local"], - realLayers: true, - }); + const ctx = setup(tmp.current, { realLayers: true }); const requests: Array<StubbedRequest> = []; return Effect.gen(function* () { yield* stubManagementApi(requests, () => undefined); yield* seedMigration(tmp.current, "20240101000000_init.sql", "create table a;\n"); yield* migrationRepair( - input({ versions: ["20240101000000"], status: "applied", linked: false, local: true }), + input({ versions: ["20240101000000"], status: "applied", local: true }), ); expect(requests).toEqual([]); expect(ctx.http.calls).toEqual([]); @@ -576,10 +509,7 @@ describe("migration repair", () => { }); it.effect("fetches the pooler config but mints no login role when --password is given", () => { - const ctx = setup(tmp.current, { - args: ["migration", "repair", "--password", "pw", "--project-ref", VALID_REF], - realLayers: true, - }); + const ctx = setup(tmp.current, { realLayers: true, yes: true }); const requests: Array<StubbedRequest> = []; return Effect.gen(function* () { yield* stubManagementApi(requests, serveLinkedDatabase(VALID_REF)); @@ -608,3 +538,27 @@ describe("migration repair", () => { ); }); }); + +describeHostedConsent("migration repair", { + setup: (workdir) => + seedMigration(workdir, "20240101000000_init.sql", "create table a;\n"), + invoke: () => { + const db = fakeDatabase(); + return { + run: migrationRepair( + input({ versions: ["20240101000000"], status: "applied", linked: true }), + ).pipe( + Effect.provide( + Layer.mergeAll( + db.resolver, + db.connection, + cliConfigValuesTestLayer, + mockTelemetryStateTracked().layer, + Layer.succeed(DnsResolverFlag, "native"), + ), + ), + ), + mutations: () => db.execs.length, + }; + }, +}); diff --git a/apps/cli/src/commands/migration/up/up.command.ts b/apps/cli/src/commands/migration/up/up.command.ts index 711bad31b7..e38491ae79 100644 --- a/apps/cli/src/commands/migration/up/up.command.ts +++ b/apps/cli/src/commands/migration/up/up.command.ts @@ -15,6 +15,7 @@ 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"; +import { targetFlags } from "../../../command-internal/target/target-flags.ts"; import { withTargetPolicy } from "../../../command-internal/target/target-policy.ts"; const config = { @@ -22,25 +23,7 @@ const config = { Flag.withDescription("Include all migrations not found on remote history table."), Flag.withDefault(false), ), - dbUrl: Flag.String("db-url").pipe( - Flag.withDescription( - "Applies migrations to the database specified by the connection string (must be percent-encoded).", - ), - Flag.optional, - ), - linked: Flag.Boolean("linked").pipe( - Flag.withDescription("Applies pending migrations to the linked project."), - Flag.withDefault(false), - ), - local: Flag.Boolean("local").pipe( - Flag.withDescription("Applies pending migrations to the local database."), - Flag.withDefault(true), - ), - // 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."), - Flag.optional, - ), + ...targetFlags({ group: "selectable", subject: "database", dbUrl: true }), } as const; export type MigrationUpFlags = CliCommand.Command.Config.Infer<typeof config>; diff --git a/apps/cli/src/commands/migration/up/up.handler.ts b/apps/cli/src/commands/migration/up/up.handler.ts index 6fbb4b1d45..58b8ea4793 100644 --- a/apps/cli/src/commands/migration/up/up.handler.ts +++ b/apps/cli/src/commands/migration/up/up.handler.ts @@ -1,15 +1,16 @@ import { Effect, FileSystem, Match, Option, Path } from "effect"; import { DnsResolverFlag } from "../../../command-internal/global-flags.ts"; -import { CliArgs } from "../../../shared/cli/cli-args.service.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 { bold } from "../../../command-internal/colors.ts"; import { 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"; +import { + hostedMutationPreapproval, + requireTargetMutationConsent, +} from "../../../command-internal/hosted-consent/hosted-consent.ts"; import { MigrationApplyError, applyMigrationFile, @@ -21,10 +22,10 @@ import { sortMigrationPathsByVersion, suggestRevertHistory, } from "../../../command-internal/migration-history.ts"; +import { resolveTarget } from "../../../command-internal/target/resolve-target.ts"; import { upsertVaultSecrets } from "../../../command-internal/vault.ts"; import { LinkedProjectCache } from "../../../telemetry/linked-project-cache.service.ts"; import { TelemetryState } from "../../../telemetry/telemetry-state.service.ts"; -import { MigrationTargetFlagsError } from "../migration.errors.ts"; import type { MigrationUpFlags } from "./up.command.ts"; import { MigrationMissingLocalError, MigrationMissingRemoteError } from "./up.errors.ts"; @@ -32,10 +33,7 @@ const suggestIgnoreFlag = (paths: ReadonlyArray<string>): string => "\nRerun the command with --include-all flag to apply these migrations:\n" + `${bold(paths.join("\n"))}\n`; -const runUp = Effect.fnUntraced(function* ( - flags: MigrationUpFlags, - target: ReturnType<typeof resolveDbTargetFlags>, -) { +const runUp = Effect.fnUntraced(function* (flags: MigrationUpFlags) { const output = yield* Output; const resolver = yield* DbConfigResolver; const connection = yield* DbConnection; @@ -44,43 +42,25 @@ const runUp = Effect.fnUntraced(function* ( const path = yield* Path.Path; const dnsResolver = yield* DnsResolverFlag; - if (target.setFlags.length > 1) { - return yield* new MigrationTargetFlagsError({ - 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`, - }); - } - - // `--project-ref` never implies `--linked` and must not be silently - // discarded on a non-linked target; see push.handler.ts's identical guard. - if (Option.isSome(flags.projectRef) && (target.connType ?? "local") !== "linked") { - return yield* new MigrationTargetFlagsError({ - message: - "--project-ref only applies when targeting the linked project; use it with --linked (not --local or --db-url)", - }); - } - + const target = yield* resolveTarget(flags); const migrationsDir = path.join(cliSettings.workdir, "supabase", "migrations"); const upBody = Effect.gen(function* () { - const cfg = yield* resolver.resolve({ - dbUrl: flags.dbUrl, - connType: target.connType ?? "local", + const cfg = yield* resolver.resolveConnection({ + target, dnsResolver, - linkedProjectRef: flags.projectRef, + password: Option.none(), + networkBanRemoval: yield* hostedMutationPreapproval, }); const ref = Option.getOrUndefined(cfg.ref ?? Option.none()); const toml = yield* readDbToml(fs, path, cliSettings.workdir, ref); yield* Effect.annotateCurrentSpan({ - "db.conn_type": target.connType ?? "local", + "db.conn_type": target.kind, "db.is_local": cfg.isLocal, }); yield* Effect.scoped( Effect.gen(function* () { - yield* output.raw( - `Connecting to ${cfg.isLocal ? "local" : "remote"} database...\n`, - "stderr", - ); const session = yield* connection.connect(cfg.conn, { isLocal: cfg.isLocal, dnsResolver, @@ -94,10 +74,7 @@ const runUp = Effect.fnUntraced(function* ( Effect.fail( new MigrationMissingLocalError({ message: "Remote migration versions not found in local migrations directory.", - suggestion: suggestRevertHistory( - result.versions, - (target.connType ?? "local") === "local", - ), + suggestion: suggestRevertHistory(result.versions, target.kind === "local"), }), ), "missing-remote": (result) => { @@ -122,6 +99,19 @@ const runUp = Effect.fnUntraced(function* ( }), ); + const vaultSecrets = toml.vault.filter((secret) => secret.resolved); + if (pending.length > 0 || vaultSecrets.length > 0) { + yield* requireTargetMutationConsent(cfg, { + action: `Apply ${pending.length} ${pending.length === 1 ? "migration" : "migrations"}`, + details: [ + ...pending.map((migrationPath) => `Apply migration ${path.basename(migrationPath)}`), + ...(vaultSecrets.length > 0 + ? [`Update ${vaultSecrets.length} vault ${vaultSecrets.length === 1 ? "secret" : "secrets"}`] + : []), + ], + }); + } + yield* upsertVaultSecrets(session, toml.vault); yield* Effect.annotateCurrentSpan({ "migration.count": pending.length }); @@ -145,18 +135,14 @@ const runUp = Effect.fnUntraced(function* ( ); }); - if ((target.connType ?? "local") === "linked") { - const projectRef = yield* ProjectRefResolver; + if (target.kind === "linked") { const linkedProjectCache = yield* LinkedProjectCache; - const linkedRef = yield* projectRef.loadProjectRef(flags.projectRef); - return yield* upBody.pipe(Effect.ensuring(linkedProjectCache.cache(linkedRef))); + return yield* upBody.pipe(Effect.ensuring(linkedProjectCache.cache(target.ref))); } return yield* upBody; }); export const migrationUp = Effect.fn("migration.up")(function* (flags: MigrationUpFlags) { const telemetryState = yield* TelemetryState; - const cliArgs = yield* CliArgs; - const target = resolveDbTargetFlags(cliArgs.args); - yield* runUp(flags, target).pipe(Effect.ensuring(telemetryState.flush)); + yield* runUp(flags).pipe(Effect.ensuring(telemetryState.flush)); }); 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 77e913fb50..237d3b10e0 100644 --- a/apps/cli/src/commands/migration/up/up.integration.test.ts +++ b/apps/cli/src/commands/migration/up/up.integration.test.ts @@ -21,7 +21,8 @@ import { sequentialExecBatch, } from "../../../../tests/helpers/command-mocks.ts"; import { cliConfigValuesTestLayer } from "../../../../tests/helpers/config-values-layer.ts"; -import { mockOutput } from "../../../../tests/helpers/mocks.ts"; +import { describeHostedConsent } from "../../../../tests/helpers/hosted-consent.ts"; +import { mockOutput, mockTty } from "../../../../tests/helpers/mocks.ts"; import { assertPermissionDrift } from "../../../../tests/helpers/permission-drift.ts"; import { asRecordedRequests, @@ -34,16 +35,23 @@ import { type StubbedRequest, } from "../../../../tests/helpers/permission-real-layers.ts"; import { CliArgs } from "../../../shared/cli/cli-args.service.ts"; -import { DnsResolverFlag } from "../../../command-internal/global-flags.ts"; +import { DnsResolverFlag, OutputFlag, YesFlag } from "../../../command-internal/global-flags.ts"; import type { OutputFormat } from "../../../shared/output/types.ts"; import { ProjectRefResolver } from "../../../config/project-ref.service.ts"; import { DbConfigResolver } from "../../../command-internal/db-config.service.ts"; -import type { DbConfigFlags, ResolvedDbConfig } from "../../../command-internal/db-config.types.ts"; +import type { + DbConnectionInput, + ResolvedDbConnection, +} from "../../../command-internal/db-config.types.ts"; import { DbExecError } from "../../../command-internal/db-connection.errors.ts"; import { type DbSession, DbConnection } from "../../../command-internal/db-connection.service.ts"; import { MigrationApplyError } from "../../../command-internal/migration-apply.ts"; import { MigrationVaultError } from "../../../command-internal/vault.ts"; -import { MigrationTargetFlagsError } from "../migration.errors.ts"; +import { TargetPolicy } from "../../../command-internal/target/target-policy.ts"; +import { + TargetProjectRefConflictError, + TargetSelectorConflictError, +} from "../../../command-internal/target/target.errors.ts"; import { MigrationMissingLocalError, MigrationMissingRemoteError } from "./up.errors.ts"; import { migrationUp } from "./up.handler.ts"; import type { MigrationUpFlags } from "./up.command.ts"; @@ -55,23 +63,23 @@ interface SetupOpts { /** Runs the real `DbConfigResolver` and linked-project cache against `workdir`. */ readonly realLayers?: boolean; readonly format?: OutputFormat; - readonly args?: ReadonlyArray<string>; + readonly yes?: boolean; readonly remote?: ReadonlyArray<string>; readonly failApply?: boolean; readonly failVault?: boolean; readonly existingVault?: ReadonlyArray<{ id: string; name: string }>; } -function setup(workdir: string, opts: SetupOpts = {}) { - const out = mockOutput({ format: opts.format ?? "text" }); - const telemetry = mockTelemetryStateTracked(); - const cache = mockLinkedProjectCacheTracked(); +function fakeDatabase(opts: SetupOpts = {}) { const execs: Array<string> = []; const queries: Array<{ sql: string; params?: ReadonlyArray<unknown> }> = []; + const resolverCalls: Array<DbConnectionInput> = []; const resolver = Layer.succeed(DbConfigResolver, { - resolve: (_flags: DbConfigFlags) => - Effect.succeed({ + resolve: () => Effect.die("resolve is not used by this test"), + resolveConnection: (input: DbConnectionInput) => { + resolverCalls.push(input); + return Effect.succeed({ conn: { host: "127.0.0.1", port: 54322, @@ -79,10 +87,11 @@ function setup(workdir: string, opts: SetupOpts = {}) { password: "x", database: "postgres", }, - isLocal: true, - ref: Option.none(), - } satisfies ResolvedDbConfig), - resolveConnection: () => Effect.die("resolveConnection is not used by this test"), + isLocal: input.target.kind === "local", + ref: input.target.kind === "linked" ? Option.some(input.target.ref) : Option.none(), + target: input.target, + } satisfies ResolvedDbConnection); + }, resolvePoolerFallback: () => Effect.succeed(Option.none()), }); @@ -118,24 +127,39 @@ function setup(workdir: string, opts: SetupOpts = {}) { return Effect.succeed(session); }, }); + return { resolver, connection, execs, queries, resolverCalls }; +} + +function setup(workdir: string, opts: SetupOpts = {}) { + const out = mockOutput({ format: opts.format ?? "text" }); + const telemetry = mockTelemetryStateTracked(); + const cache = mockLinkedProjectCacheTracked(); + const { resolver, connection, execs, queries, resolverCalls } = fakeDatabase(opts); // The flag value takes precedence over the VALID_REF fallback, so tests can assert the // flag drives the linked ref. const projectRef = Layer.succeed(ProjectRefResolver, { resolve: () => Effect.succeed(VALID_REF), resolveForLink: () => Effect.succeed(VALID_REF), - resolveRef: () => Effect.succeed({ ref: VALID_REF, source: "file", matchesLinkedRef: true }), + resolveRef: (flagValue: Option.Option<string>) => + Effect.succeed({ + ref: Option.getOrElse(flagValue, () => VALID_REF), + source: Option.isSome(flagValue) ? "flag" : "file", + matchesLinkedRef: Option.isNone(flagValue), + }), resolveOptional: () => Effect.succeed(Option.some(VALID_REF)), loadProjectRef: (flagValue: Option.Option<string>) => - Effect.succeed( - Option.isSome(flagValue) && flagValue.value.length > 0 ? flagValue.value : VALID_REF, - ), + Effect.succeed(Option.getOrElse(flagValue, () => VALID_REF)), promptProjectRef: () => Effect.succeed(VALID_REF), }); const http = recordingProjectHttpClient(VALID_REF); const layer = Layer.mergeAll( cliConfigValuesTestLayer, + Layer.succeed(TargetPolicy, { group: "selectable", consent: "no" }), + Layer.succeed(YesFlag, opts.yes ?? false), + Layer.succeed(OutputFlag, Option.none()), + mockTty(), out.layer, telemetry.layer, opts.realLayers === true @@ -155,17 +179,17 @@ function setup(workdir: string, opts: SetupOpts = {}) { projectRef, mockCommandSettings({ workdir }), Layer.succeed(DnsResolverFlag, "native"), - Layer.succeed(CliArgs, { args: opts.args ?? [] }), + Layer.succeed(CliArgs, { args: [] }), BunServices.layer, ); - return { layer, http, out, telemetry, execs, queries, cache }; + return { layer, http, out, telemetry, execs, queries, cache, resolverCalls }; } const flags = (over: Partial<MigrationUpFlags> = {}): MigrationUpFlags => ({ includeAll: over.includeAll ?? false, dbUrl: over.dbUrl ?? Option.none(), linked: over.linked ?? false, - local: over.local ?? true, + local: over.local ?? false, projectRef: over.projectRef ?? Option.none(), }); @@ -175,10 +199,9 @@ const seed = Effect.fnUntraced(function* ( body = "create table a;\n", ) { const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const dir = path.join(workdir, "supabase", "migrations"); + const dir = `${workdir}/supabase/migrations`; yield* fs.makeDirectory(dir, { recursive: true }); - yield* fs.writeFileString(path.join(dir, name), body); + yield* fs.writeFileString(`${dir}/${name}`, body); }); const writeProjectFile = Effect.fnUntraced(function* (workdir: string, name: string, body: string) { @@ -205,7 +228,6 @@ describe("migration up", () => { yield* migrationUp(flags()); const stderr = stripAnsi(out.stderrText); const stdout = stripAnsi(out.stdoutText); - expect(stderr).toContain("Connecting to local database..."); expect(stderr).toContain("Applying migration 20240102000000_b.sql..."); expect(stderr).toContain("Applying migration 20240103000000_c.sql..."); expect(stdout).toContain("Local database is up to date."); @@ -294,49 +316,53 @@ describe("migration up", () => { }); it.effect("rejects --db-url combined with --linked", () => { - const { layer } = setup(tmp.current, { args: ["--db-url", "postgresql://x", "--linked"] }); + const { layer } = setup(tmp.current); return Effect.gen(function* () { const error = yield* migrationUp( - flags({ dbUrl: Option.some("postgresql://x"), linked: true, local: false }), + flags({ dbUrl: Option.some("postgresql://x"), linked: true }), ).pipe(Effect.flip); - expect(error).toBeInstanceOf(MigrationTargetFlagsError); + expect(error).toBeInstanceOf(TargetSelectorConflictError); }).pipe(Effect.provide(layer)); }); - it.effect( - "applies on the project given via --project-ref --linked, overriding the linked ref", - () => { - // VALID_REF is the resolver's fallback if the flag were absent; the flag must win. - const FLAG_REF = "flagflagflagflagflag"; - const { layer, cache } = setup(tmp.current, { args: ["--linked"], remote: [] }); - return Effect.gen(function* () { - yield* migrationUp( - flags({ linked: true, local: false, projectRef: Option.some(FLAG_REF) }), - ); - expect(cache.cached).toBe(true); - expect(cache.cachedRef).toBe(FLAG_REF); - expect(cache.cachedRef).not.toBe(VALID_REF); - }).pipe(Effect.provide(layer)); - }, - ); + it.effect("applies on the project given via --project-ref, which implies --linked", () => { + // VALID_REF is the resolver's fallback if the flag were absent; the flag must win. + const FLAG_REF = "flagflagflagflagflag"; + const { layer, cache, resolverCalls } = setup(tmp.current, { yes: true, remote: [] }); + return Effect.gen(function* () { + yield* migrationUp(flags({ projectRef: Option.some(FLAG_REF) })); + expect(resolverCalls[0]?.target).toMatchObject({ kind: "linked", ref: FLAG_REF }); + expect(cache.cachedRef).toBe(FLAG_REF); + expect(cache.cachedRef).not.toBe(VALID_REF); + }).pipe(Effect.provide(layer)); + }); - it.effect("rejects --project-ref on the default local target", () => { + it.effect("rejects --project-ref combined with --local before touching the database", () => { const FLAG_REF = "flagflagflagflagflag"; const { layer, execs, queries, cache } = setup(tmp.current, { remote: [] }); return Effect.gen(function* () { - const error = yield* migrationUp(flags({ projectRef: Option.some(FLAG_REF) })).pipe( - Effect.flip, - ); - expect(error).toBeInstanceOf(MigrationTargetFlagsError); - expect(error.message).toBe( - "--project-ref only applies when targeting the linked project; use it with --linked (not --local or --db-url)", - ); + const error = yield* migrationUp( + flags({ local: true, projectRef: Option.some(FLAG_REF) }), + ).pipe(Effect.flip); + expect(error).toBeInstanceOf(TargetProjectRefConflictError); expect(execs).toEqual([]); expect(queries).toEqual([]); expect(cache.cached).toBe(false); }).pipe(Effect.provide(layer)); }); + it.effect("applies nothing and asks for no consent on a linked project that is up to date", () => { + const { layer, out, queries } = setup(tmp.current, { format: "json", remote: ["20240101000000"] }); + return Effect.gen(function* () { + yield* seed(tmp.current, "20240101000000_a.sql"); + yield* migrationUp(flags({ linked: true })); + expect(insertedVersions(queries)).toEqual([]); + expect(out.messages).toContainEqual( + expect.objectContaining({ type: "success", message: "Migrations applied" }), + ); + }).pipe(Effect.provide(layer)); + }); + it.effect("emits a structured result in json", () => { const { layer, out } = setup(tmp.current, { format: "json", remote: [] }); return Effect.gen(function* () { @@ -358,17 +384,13 @@ describe("migration up", () => { }); it.effect("mints a login role and fills the cache through the real resolver and cache", () => { - const ctx = setup(tmp.current, { - args: ["migration", "up", "--linked", "--project-ref", VALID_REF], - realLayers: true, - remote: [], - }); + const ctx = setup(tmp.current, { realLayers: true, remote: [], yes: true }); const requests: Array<StubbedRequest> = []; return Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; yield* stubManagementApi(requests, serveLinkedDatabase(VALID_REF)); yield* seed(tmp.current, "20240101000000_a.sql"); - yield* migrationUp(flags({ linked: true, local: false, projectRef: Option.some(VALID_REF) })); + yield* migrationUp(flags({ linked: true, projectRef: Option.some(VALID_REF) })); expect(insertedVersions(ctx.queries)).toEqual(["20240101000000"]); expect(requests.map(({ method, path }) => `${method} ${path}`)).toEqual([ `GET /v1/projects/${VALID_REF}/config/database/pooler`, @@ -391,44 +413,8 @@ describe("migration up", () => { ); }); - it.effect("--linked=false still reaches the linked database through the real resolver", () => { - const ctx = setup(tmp.current, { - args: ["migration", "up", "--linked=false", "--project-ref", VALID_REF], - realLayers: true, - remote: [], - }); - const requests: Array<StubbedRequest> = []; - return Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - yield* stubManagementApi(requests, serveLinkedDatabase(VALID_REF)); - yield* migrationUp(flags({ linked: false, projectRef: Option.some(VALID_REF) })); - expect(requests.map(({ method, path }) => `${method} ${path}`)).toEqual([ - `GET /v1/projects/${VALID_REF}/config/database/pooler`, - `POST /v1/projects/${VALID_REF}/cli/login-role`, - ]); - expect(ctx.http.calls.map(({ method, url }) => `${method} ${url}`)).toEqual([ - `GET https://api.supabase.com/v1/projects/${VALID_REF}`, - ]); - expect(yield* fs.exists(`${tmp.current}/supabase/.temp/linked-project.json`)).toBe(true); - assertPermissionDrift({ - command: "migration up", - activeFlags: { linked: false, "project-ref": VALID_REF }, - requests: [...asRecordedRequests(requests), ...ctx.http.calls], - exact: true, - }); - }).pipe( - Effect.scoped, - Effect.provide(ctx.layer), - provideEnv({ SUPABASE_ACCESS_TOKEN: VALID_TOKEN }), - ); - }); - it.effect("defaults to the local target and makes no Management API request", () => { - const ctx = setup(tmp.current, { - args: ["migration", "up"], - realLayers: true, - remote: [], - }); + const ctx = setup(tmp.current, { realLayers: true, remote: [] }); const requests: Array<StubbedRequest> = []; return Effect.gen(function* () { yield* stubManagementApi(requests, () => undefined); @@ -460,3 +446,24 @@ describe("migration up", () => { ); }); }); + +describeHostedConsent("migration up", { + setup: (workdir) => seed(workdir, "20240101000000_a.sql"), + invoke: () => { + const db = fakeDatabase({ remote: [] }); + return { + run: migrationUp(flags({ linked: true })).pipe( + Effect.provide( + Layer.mergeAll( + db.resolver, + db.connection, + cliConfigValuesTestLayer, + mockTelemetryStateTracked().layer, + Layer.succeed(DnsResolverFlag, "native"), + ), + ), + ), + mutations: () => insertedVersions(db.queries).length, + }; + }, +}); From 6856267fed65a2f83c2b986ae6b1fbabdeb47d6d Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Thu, 15 Oct 2026 14:24:28 +0100 Subject: [PATCH 26/92] feat(cli): resolve db push through the sync target group with one combined consent --- apps/cli/src/command-internal/db-push-core.ts | 110 ++-- apps/cli/src/commands/db/push/push.command.ts | 25 +- .../cli/src/commands/db/push/push.e2e.test.ts | 4 +- apps/cli/src/commands/db/push/push.handler.ts | 85 +-- .../commands/db/push/push.integration.test.ts | 561 +++++++----------- apps/cli/src/commands/db/push/push.layers.ts | 5 - 6 files changed, 298 insertions(+), 492 deletions(-) diff --git a/apps/cli/src/command-internal/db-push-core.ts b/apps/cli/src/command-internal/db-push-core.ts index e00eaff26d..a6d8ebe5e5 100644 --- a/apps/cli/src/command-internal/db-push-core.ts +++ b/apps/cli/src/command-internal/db-push-core.ts @@ -1,14 +1,6 @@ -import { Effect, FileSystem, Path } from "effect"; +import { Effect, FileSystem, Option, 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 type { planDbSeed } from "./seed-remote-consent.ts"; import { Output } from "../shared/output/output.service.ts"; import { listLocalMigrations } from "./migration-list.ts"; import { @@ -21,7 +13,6 @@ import { import { type SeedFile, getPendingSeeds, seedData } from "./seed-ops.ts"; import { DbPushApplyError, - DbPushCancelledError, DbPushMissingLocalError, DbPushMissingRemoteError, DbPushRolesError, @@ -31,6 +22,8 @@ import type { DbTomlValues } from "./db-config.toml-read.ts"; import { DbConnection, type PgConnInput } from "./db-connection.service.ts"; import { applyMigrations, seedGlobals } from "./migration-apply.ts"; import { listRemoteMigrations, suggestRevertHistory } from "./migration-history.ts"; +import { requireTargetMutationConsent } from "./hosted-consent/hosted-consent.ts"; +import type { HostedMutationConsent } from "./hosted-consent/hosted-consent.token.ts"; import { upsertVaultSecrets } from "./vault.ts"; const CUSTOM_ROLES_PATH = "supabase/roles.sql"; @@ -45,6 +38,13 @@ const confirmSeedAll = (seeds: ReadonlyArray<SeedFile>): string => .map((seed) => ` • ${bold(seed.dirty ? `${seed.path} (hash update)` : seed.path)}\n`) .join(""); +const plural = (count: number, noun: string): string => `${count} ${noun}${count === 1 ? "" : "s"}`; + +const joinParts = (parts: ReadonlyArray<string>): string => + parts.length <= 1 ? (parts[0] ?? "") : `${parts.slice(0, -1).join(", ")} and ${parts.at(-1)}`; + +const capitalize = (text: string): string => text.charAt(0).toUpperCase() + text.slice(1); + const applyError = (message: string) => new DbPushApplyError({ message }); /** @@ -78,13 +78,13 @@ export interface DbPushCoreInput { readonly includeAll: boolean; readonly includeRoles: boolean; readonly includeSeed: boolean; - readonly seed: DbSeedInput; + readonly seed: Effect.Success<ReturnType<typeof planDbSeed>>; readonly includeVault: boolean; readonly dnsResolver: "native" | "https"; /** Already loaded + validated `config.toml`, e.g. via `checkDbToml`. */ readonly toml: DbTomlValues; - /** Already resolved confirm-prompt default, e.g. via `resolveYesWithProjectEnv`. */ - readonly yes: boolean; + /** Consent the caller already obtained for this push; `None` asks once after the plan is known (never for a local database). */ + readonly preapproved: Option.Option<HostedMutationConsent>; /** * Standalone `db push` emits a `--output-format` json/stream-json success result for its own * invocation; `bootstrap` suppresses it since it emits its own top-level result instead. @@ -111,7 +111,7 @@ export const dbPushCore = Effect.fn("DbPush.run")(function* (input: DbPushCoreIn includeVault, dnsResolver, toml, - yes, + preapproved, emitStructuredResult, seed, } = input; @@ -124,12 +124,10 @@ export const dbPushCore = Effect.fn("DbPush.run")(function* (input: DbPushCoreIn yield* output.raw("DRY RUN: migrations will *not* be pushed to the database.\n", "stderr"); } - const databaseName = isLocal ? "local database" : "remote database"; const statusTarget = isLocal ? "Local database" : "Remote database"; yield* Effect.scoped( Effect.gen(function* () { - yield* output.raw(`Connecting to ${isLocal ? "local" : "remote"} database...\n`, "stderr"); const session = yield* dbConn.connect(conn, { isLocal, dnsResolver }); let pending: ReadonlyArray<string> = []; @@ -166,13 +164,13 @@ export const dbPushCore = Effect.fn("DbPush.run")(function* (input: DbPushCoreIn let seeds: ReadonlyArray<SeedFile> = []; if (includeSeed) { - if (!seed.enabled) { + if (!seed.input.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, seed.sqlPaths, workdir); + seeds = yield* getPendingSeeds(session, fs, path, seed.input.sqlPaths, workdir); } } @@ -223,35 +221,41 @@ 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 (Option.isNone(preapproved)) { + const parts = [ + ...(globals.length > 0 ? ["create custom roles"] : []), + ...(pending.length > 0 ? [`apply ${plural(pending.length, "migration")}`] : []), + ...(seeds.length > 0 ? [`seed ${plural(seeds.length, "file")}`] : []), + ]; + const seedDetail = seed.detail(seeds.map((s) => s.path)); + yield* requireTargetMutationConsent( + { conn, isLocal }, + { + action: capitalize(joinParts(parts)), + details: [ + ...(globals.length > 0 ? [`Create custom roles from ${bold(globals[0]!)}`] : []), + ...(pending.length > 0 + ? [ + `Apply these migrations:\n${confirmPushAll(pending.map((p) => path.basename(p))).trimEnd()}`, + ...(includeVault && vaultSecrets.some((secret) => secret.resolved) + ? ["Update vault secrets from config.toml"] + : []), + ] + : []), + ...(seeds.length > 0 + ? [ + `Seed these files:\n${confirmSeedAll(seeds).trimEnd()}`, + ...(seedDetail === undefined ? [] : [capitalize(seedDetail)]), + ] + : []), + ], + }, + ); } if (globals.length > 0) { - const ok = yield* promptYesNo( - output, - yes, - "Do you want to create custom roles in the database cluster?", - true, - ); - if (!ok) { - return yield* new DbPushCancelledError({ message: CONTEXT_CANCELED_MESSAGE }); - } yield* seedGlobals( session, fs, @@ -262,15 +266,6 @@ export const dbPushCore = Effect.fn("DbPush.run")(function* (input: DbPushCoreIn } if (pending.length > 0) { - const ok = yield* promptYesNo( - output, - yes, - `Do you want to push these migrations to the ${databaseName}?\n${confirmPushAll(pending.map((p) => path.basename(p)))}`, - true, - ); - if (!ok) { - return yield* new DbPushCancelledError({ message: CONTEXT_CANCELED_MESSAGE }); - } if (includeVault) { yield* upsertVaultSecrets(session, vaultSecrets); } @@ -280,17 +275,6 @@ export const dbPushCore = Effect.fn("DbPush.run")(function* (input: DbPushCoreIn } if (seeds.length > 0) { - 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 }); - } yield* seedData(session, fs, workdir, path, seeds, applyError); } else if (includeSeed) { yield* output.raw("Seed files are up to date.\n", "stderr"); diff --git a/apps/cli/src/commands/db/push/push.command.ts b/apps/cli/src/commands/db/push/push.command.ts index 7e011b85ce..15d19760b3 100644 --- a/apps/cli/src/commands/db/push/push.command.ts +++ b/apps/cli/src/commands/db/push/push.command.ts @@ -16,6 +16,7 @@ import { withJsonErrorHandling } from "../../../shared/output/json-error-handlin import { withCommandTelemetry } from "../../../telemetry/command-telemetry.ts"; import { dbPush } from "./push.handler.ts"; import { dbPushRuntimeLayer } from "./push.layers.ts"; +import { targetFlags } from "../../../command-internal/target/target-flags.ts"; import { withTargetPolicy } from "../../../command-internal/target/target-policy.ts"; const config = { @@ -41,29 +42,7 @@ const config = { ), Flag.withDefault(false), ), - dbUrl: Flag.String("db-url").pipe( - Flag.withDescription( - "Pushes to the database specified by the connection string (must be percent-encoded).", - ), - Flag.optional, - ), - linked: Flag.Boolean("linked").pipe( - Flag.withDescription("Pushes to the linked project."), - Flag.withDefault(false), - ), - local: Flag.Boolean("local").pipe( - Flag.withDescription("Pushes to the local database."), - Flag.withDefault(false), - ), - // Feeds `ProjectRefResolver.loadProjectRef`: flag > `SUPABASE_PROJECT_ID` > - // `supabase/.temp/project-ref`. Only feeds ref resolution — it does not affect local - // container ids or the pg-delta project id (see `db-config.types.ts`'s - // `linkedProjectRef` doc) — and is rejected outright on a non-linked target rather - // than silently ignored (see the handler's guard). - projectRef: Flag.String("project-ref").pipe( - Flag.withDescription("Project ref of the Supabase project."), - Flag.optional, - ), + ...targetFlags({ group: "sync", subject: "database", dbUrl: true }), password: CliConfigKeys.linkedDb.password.flag({ name: "password", alias: "p", diff --git a/apps/cli/src/commands/db/push/push.e2e.test.ts b/apps/cli/src/commands/db/push/push.e2e.test.ts index 0798e6c170..a50119039c 100644 --- a/apps/cli/src/commands/db/push/push.e2e.test.ts +++ b/apps/cli/src/commands/db/push/push.e2e.test.ts @@ -33,7 +33,7 @@ describe("supabase db push --skip-vault", () => { ); expect(exitCode).toBe(1); expect(stderr).toContain("failed to parse config:"); - expect(stderr).not.toContain("Connecting to remote database..."); + expect(stderr).not.toContain("Target: "); }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), E2E_TIMEOUT_MS, ); @@ -48,7 +48,7 @@ describe("supabase db push --skip-vault", () => { { cwd: projectDir }, ); expect(exitCode).toBe(1); - expect(stderr).toContain("Connecting to remote database..."); + expect(stderr).toContain("Target: custom Postgres (127.0.0.1)"); expect(stderr).toContain("failed to connect"); expect(stderr).not.toContain("failed to parse config:"); }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), diff --git a/apps/cli/src/commands/db/push/push.handler.ts b/apps/cli/src/commands/db/push/push.handler.ts index 78c390acf9..262da84f68 100644 --- a/apps/cli/src/commands/db/push/push.handler.ts +++ b/apps/cli/src/commands/db/push/push.handler.ts @@ -1,31 +1,25 @@ import { Effect, FileSystem, Option, Path, Ref } from "effect"; -import { CliArgs } from "../../../shared/cli/cli-args.service.ts"; import { DnsResolverFlag } from "../../../command-internal/global-flags.ts"; -import { resolveYesWithProjectEnv } from "../../../command-internal/global-flags.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 { DbConfigResolver } from "../../../command-internal/db-config.service.ts"; -import { - checkDbToml, - loadProjectEnvValues, -} from "../../../command-internal/db-config.toml-read.ts"; +import { checkDbToml } 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 { hostedMutationPreapproval } from "../../../command-internal/hosted-consent/hosted-consent.ts"; +import { planDbSeed } from "../../../command-internal/seed-remote-consent.ts"; +import { resolveTarget } from "../../../command-internal/target/resolve-target.ts"; import { LinkedProjectCache } from "../../../telemetry/linked-project-cache.service.ts"; import { TelemetryState } from "../../../telemetry/telemetry-state.service.ts"; import type { DbPushFlags } from "./push.command.ts"; -import { DbPushTargetFlagsError } from "./push.errors.ts"; /** * `supabase db push` — apply pending local migrations (and optionally seed data - * and custom roles) to the local or linked/remote database. + * and custom roles) to the linked project, or to the database `--local` / `--db-url` selects. * - * Resolves the `--db-url`/`--linked`/`--local` target and `config.toml`, then - * delegates the actual push to `dbPushCore`, shared with `bootstrap`. + * Resolves the target and `config.toml`, then delegates the push to `dbPushCore`, shared with + * `bootstrap`, which asks one combined consent once the plan is known. */ export const dbPush = Effect.fn("db.push")(function* (flags: DbPushFlags) { const output = yield* Output; @@ -35,56 +29,27 @@ export const dbPush = Effect.fn("db.push")(function* (flags: DbPushFlags) { const linkedProjectCache = yield* LinkedProjectCache; const fs = yield* FileSystem.FileSystem; 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* loadProjectEnvValues(fs, path, workdir); - const yes = yield* resolveYesWithProjectEnv(projectEnv); const linkedRefForCache = yield* Ref.make<string | undefined>(undefined); const body = Effect.gen(function* () { - const target = resolveDbTargetFlags(cliArgs.args); - // Mutually-exclusive db-url/linked/local group, keyed off the - // explicitly-set flags, not the `--linked` default value. - if (target.setFlags.length > 1) { - return yield* new DbPushTargetFlagsError({ - 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`, - }); - } - // push defaults `--linked` to true, so no target flag → linked. - const connType = target.connType ?? "linked"; - - // `--project-ref` never implies `--linked` and must not be silently discarded on a - // non-linked target. This is stricter than `SUPABASE_PROJECT_ID`, which is read - // unconditionally but simply goes unused on `--local`/`--db-url` — an explicitly - // typed flag doing nothing silently would be a footgun the env var doesn't share. - if (Option.isSome(flags.projectRef) && connType !== "linked") { - return yield* new DbPushTargetFlagsError({ - message: - "--project-ref only applies when targeting the linked project; use it with --linked (not --local or --db-url)", - }); - } - - // The linked path resolves the project ref before loading config so a - // matching `[remotes.<ref>]` block merges. For `--local` / `--db-url`, - // the ref stays empty. - let projectRef = ""; - if (connType === "linked") { - const refResolver = yield* ProjectRefResolver; - projectRef = yield* refResolver.loadProjectRef(flags.projectRef); - yield* Ref.set(linkedRefForCache, projectRef); - } + const target = yield* resolveTarget({ + local: flags.local, + linked: flags.linked, + dbUrl: flags.dbUrl, + projectRef: flags.projectRef, + }); + const projectRef = target.kind === "linked" ? target.ref : ""; + if (target.kind === "linked") yield* Ref.set(linkedRefForCache, projectRef); // Single config load (except `--skip-vault`, which omits `[db.vault]` secret // resolution): decodes with env-expansion + weak-typed booleans, applies // `SUPABASE_*` overrides, merges a matching `[remotes.<ref>]` block, and decrypts - // `encrypted:` secrets — aborting here on any invalid config, before - // `resolver.resolve()`'s network activity, so a remote override prints first. + // `encrypted:` secrets — aborting here on any invalid config, before the + // connection's network activity, so a remote override prints first. const toml = yield* checkDbToml(fs, path, workdir, projectRef !== "" ? projectRef : undefined, { resolveVaultSecrets: !flags.skipVault, }); @@ -95,20 +60,20 @@ export const dbPush = Effect.fn("db.push")(function* (flags: DbPushFlags) { workdir, projectRef: projectRef !== "" ? Option.some(projectRef) : Option.none(), }); - const seed = yield* resolveDbSeedInput(resolvedConfig, { workdir, ref: projectRef }); + const seed = yield* planDbSeed(resolvedConfig, { workdir, ref: projectRef }); const includeSeed = Option.getOrElse(flags.includeSeed, () => false); - const cfg = yield* resolver.resolve({ - dbUrl: flags.dbUrl, - connType, + const cfg = yield* resolver.resolveConnection({ + target, dnsResolver, password: flags.password, resolveVaultSecrets: !flags.skipVault, - linkedProjectRef: flags.projectRef, + hasPasswordFlag: true, + networkBanRemoval: yield* hostedMutationPreapproval, }); yield* Effect.annotateCurrentSpan({ - "db.conn_type": connType, + "db.conn_type": target.kind, "db.is_local": cfg.isLocal, "db.push.dry_run": flags.dryRun, "db.push.include_all": flags.includeAll, @@ -121,7 +86,7 @@ export const dbPush = Effect.fn("db.push")(function* (flags: DbPushFlags) { projectRef, conn: cfg.conn, isLocal: cfg.isLocal, - repairSuggestsLocalFlag: connType === "local", + repairSuggestsLocalFlag: target.kind === "local", dryRun: flags.dryRun, includeAll: flags.includeAll, includeRoles: flags.includeRoles, @@ -130,7 +95,7 @@ export const dbPush = Effect.fn("db.push")(function* (flags: DbPushFlags) { includeVault: !flags.skipVault, dnsResolver, toml, - yes, + preapproved: Option.none(), emitStructuredResult: true, }); }); 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 2441ace3e4..f3919fd759 100644 --- a/apps/cli/src/commands/db/push/push.integration.test.ts +++ b/apps/cli/src/commands/db/push/push.integration.test.ts @@ -12,6 +12,7 @@ vi.mock("node:net", (importOriginal) => ]).then(([helper, net]) => helper.refuseInvalidDbHosts(net)), ); +import { describeHostedConsent } from "../../../../tests/helpers/hosted-consent.ts"; import { mockOutput, mockStdin, mockTty } from "../../../../tests/helpers/mocks.ts"; import { assertPermissionDrift } from "../../../../tests/helpers/permission-drift.ts"; import { @@ -34,15 +35,20 @@ import { useTempWorkdir, sequentialExecBatch, withConfigEnv, + withEmptyConfigEnv, withEnvVar, } from "../../../../tests/helpers/command-mocks.ts"; import { CliArgs } from "../../../shared/cli/cli-args.service.ts"; -import { DnsResolverFlag, YesFlag } from "../../../command-internal/global-flags.ts"; +import { DnsResolverFlag, OutputFlag, YesFlag } from "../../../command-internal/global-flags.ts"; +import { TargetPolicy } from "../../../command-internal/target/target-policy.ts"; import type { OutputFormat } from "../../../shared/output/types.ts"; import { ProjectRefResolver } from "../../../config/project-ref.service.ts"; import { ProjectRefNotLinkedError } from "../../../config/project-ref.errors.ts"; import { DbConfigResolver } from "../../../command-internal/db-config.service.ts"; -import type { DbConfigFlags, ResolvedDbConfig } from "../../../command-internal/db-config.types.ts"; +import type { + DbConnectionInput, + ResolvedDbConnection, +} from "../../../command-internal/db-config.types.ts"; import { DbExecError } from "../../../command-internal/db-connection.errors.ts"; import { DbConnection, @@ -82,20 +88,22 @@ const DEFAULT_FLAGS: DbPushFlags = { }; function mockResolver( - opts: { isLocal?: boolean; onResolve?: (flags: DbConfigFlags) => void } = {}, + opts: { isLocal?: boolean; onResolve?: (input: DbConnectionInput) => void } = {}, ) { - const calls: Array<DbConfigFlags> = []; + const calls: Array<DbConnectionInput> = []; const layer = Layer.succeed(DbConfigResolver, { - resolve: (flags: DbConfigFlags) => + resolve: () => Effect.die("resolve is not used by db push"), + resolveConnection: (input: DbConnectionInput) => Effect.sync(() => { - calls.push(flags); - opts.onResolve?.(flags); + calls.push(input); + opts.onResolve?.(input); return { conn: LOCAL_CONN, - isLocal: opts.isLocal ?? true, - } satisfies ResolvedDbConfig; + isLocal: opts.isLocal ?? input.target.kind !== "linked", + ref: input.target.kind === "linked" ? Option.some(input.target.ref) : Option.none(), + target: input.target, + } satisfies ResolvedDbConnection; }), - resolveConnection: () => Effect.die("resolveConnection is not used by this test"), resolvePoolerFallback: () => Effect.succeed(Option.none()), }); return { layer, calls }; @@ -179,8 +187,7 @@ function setup( files?: Readonly<Record<string, string>>; format?: OutputFormat; confirm?: ReadonlyArray<boolean>; - piped?: string; - args?: ReadonlyArray<string>; + interactive?: boolean; yes?: boolean; includeSeed?: boolean; env?: Readonly<Record<string, string>>; @@ -195,7 +202,7 @@ function setup( failExecWith?: { message: string; code?: string; detail?: string; position?: number }; noProjectId?: boolean; // Simulates the real `DbConfigResolver`'s "Initialising login role..." stderr line, - // fired as part of `resolve()`'s own connection work, strictly before `dbPushCore` + // fired as part of `resolveConnection()`'s own work, strictly before `dbPushCore` // runs; `mockResolver` is otherwise silent, so tests pin output ordering against it. simulateInitialisingLoginRole?: boolean; /** Runs the real `DbConfigResolver` and linked-project cache against `workdir`. */ @@ -218,7 +225,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(); @@ -226,26 +237,27 @@ function setup( const projectRefLayer = Layer.succeed(ProjectRefResolver, { resolve: () => Effect.succeed(opts.projectRef ?? VALID_REF), resolveForLink: () => Effect.succeed(opts.projectRef ?? VALID_REF), - resolveRef: () => - Effect.succeed({ ref: opts.projectRef ?? VALID_REF, source: "file", matchesLinkedRef: true }), - resolveOptional: () => Effect.succeed(Option.some(opts.projectRef ?? VALID_REF)), - // An explicit `--project-ref` flag takes top precedence, short-circuiting before - // the "not linked" failure. - loadProjectRef: (flagValue: Option.Option<string>) => + resolveRef: (flagValue: Option.Option<string>) => Option.isSome(flagValue) && flagValue.value.length > 0 - ? Effect.succeed(flagValue.value) + ? Effect.succeed({ ref: flagValue.value, source: "flag", matchesLinkedRef: false }) : opts.linkedFails === true ? Effect.fail( new ProjectRefNotLinkedError({ message: "Cannot find project ref. Have you run supabase link?", }), ) - : Effect.succeed(opts.projectRef ?? VALID_REF), + : Effect.succeed({ + ref: opts.projectRef ?? VALID_REF, + source: "file", + matchesLinkedRef: true, + }), + resolveOptional: () => Effect.succeed(Option.some(opts.projectRef ?? VALID_REF)), + loadProjectRef: () => Effect.die("loadProjectRef is not used by db push"), promptProjectRef: () => Effect.succeed(opts.projectRef ?? VALID_REF), }); const resolver = mockResolver({ - isLocal: opts.isLocal ?? true, + isLocal: opts.isLocal, onResolve: opts.simulateInitialisingLoginRole === true ? () => { @@ -275,12 +287,12 @@ function setup( ...(opts.noProjectId === true ? { projectId: Option.none() } : {}), }), BunServices.layer, - // Prompts are answered through mockOutput's `promptConfirmResponses` (the - // TTY/clack path) unless `piped` feeds promptYesNo's non-TTY branch. - mockTty({ stdinIsTty: opts.piped === undefined }), - mockStdin(opts.piped === undefined, opts.piped), - Layer.succeed(CliArgs, { args: opts.args ?? ["db", "push", "--local"] }), + mockTty({ stdinIsTty: opts.interactive ?? true }), + mockStdin(opts.interactive ?? true), + Layer.succeed(CliArgs, { args: ["db", "push"] }), Layer.succeed(YesFlag, opts.yes ?? false), + Layer.succeed(OutputFlag, Option.none()), + Layer.succeed(TargetPolicy, { group: "sync", consent: "no" }), Layer.succeed(DnsResolverFlag, "native"), projectRefLayer, telemetry.layer, @@ -358,25 +370,27 @@ describe("db push", () => { }); }); - it.effect("rejects mutually exclusive target flags", () => { - const { layer } = setup(tmp.current, { - toml: 'project_id = "test"\n', - args: ["db", "push", "--local", "--linked"], - }); + it.effect("rejects mutually exclusive target flags before connecting", () => { + const { layer, conn, resolver } = setup(tmp.current, { toml: 'project_id = "test"\n' }); return Effect.gen(function* () { - const exit = yield* dbPush(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); - expect(Exit.isFailure(exit)).toBe(true); + const exit = yield* dbPush({ ...DEFAULT_FLAGS, linked: true }).pipe( + Effect.provide(layer), + Effect.exit, + ); + expect(failError(exit)._tag).toBe("TargetSelectorConflictError"); + expect(resolver.calls).toEqual([]); + expect(conn.execs).toEqual([]); }); }); - it.effect("applies a pending migration after confirmation", () => { + it.effect("applies a pending migration to the local database without asking", () => { const { layer, out, conn } = setup(tmp.current, { toml: 'project_id = "test"\n', files: migrationFile("20240101000000"), - confirm: [true], }); return Effect.gen(function* () { yield* dbPush(DEFAULT_FLAGS).pipe(Effect.provide(layer)); + expect(out.promptConfirmCalls).toEqual([]); expect(out.stderrText).toContain("Applying migration 20240101000000_test.sql..."); expect(out.stdoutText).toContain("Finished"); expect(out.stdoutText).toContain("supabase db push"); @@ -397,7 +411,6 @@ describe("db push", () => { "20240101000000", `-- pg-delta: transaction=false\n${set};\n${action};\nRESET ALL;`, ), - confirm: [true], }); return Effect.gen(function* () { yield* dbPush(DEFAULT_FLAGS).pipe(Effect.provide(layer)); @@ -417,53 +430,6 @@ describe("db push", () => { }); }); - it.effect("returns context canceled when the migration prompt is declined", () => { - const { layer, conn } = setup(tmp.current, { - toml: 'project_id = "test"\n', - files: migrationFile("20240101000000"), - confirm: [false], - }); - return Effect.gen(function* () { - const exit = yield* dbPush(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - expect(Cause.pretty(exit.cause)).toContain("context canceled"); - } - expect(conn.execs).not.toContain("BEGIN"); - }); - }); - - it.effect.each(["u", "yess", "nope", " ", "n", "no"])( - "applies nothing when the piped answer is %j", - (answer) => { - const { layer, conn } = setup(tmp.current, { - toml: 'project_id = "test"\n', - files: migrationFile("20240101000000"), - piped: `${answer}\n`, - }); - return Effect.gen(function* () { - const exit = yield* dbPush(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); - expect(Exit.isFailure(exit)).toBe(true); - expect(conn.execs).not.toContain("BEGIN"); - }); - }, - ); - - it.effect.each(["y\n", "yes\n", "\n", ""])( - "applies migrations when the piped answer is %j", - (piped) => { - const { layer, conn } = setup(tmp.current, { - toml: 'project_id = "test"\n', - files: migrationFile("20240101000000"), - piped, - }); - return Effect.gen(function* () { - yield* dbPush(DEFAULT_FLAGS).pipe(Effect.provide(layer)); - expect(conn.execs).toContain("BEGIN"); - }); - }, - ); - it.effect("prints the plan without applying in dry-run mode", () => { const { layer, out, conn } = setup(tmp.current, { toml: 'project_id = "test"\n', @@ -509,9 +475,6 @@ describe("db push", () => { ); expect(loginRoleIndex).toBeGreaterThanOrEqual(0); expect(dryRunIndex).toBeGreaterThan(loginRoleIndex); - // ...and, in turn, before the connect step's own progress line. - const connectingIndex = out.stderrText.indexOf("Connecting to local database..."); - expect(connectingIndex).toBeGreaterThan(dryRunIndex); }); }); @@ -538,7 +501,6 @@ describe("db push", () => { const dbUrl = "postgresql://postgres:postgres@127.0.0.1:54322/postgres"; const { layer } = setup(tmp.current, { toml: 'project_id = "test"\n', - args: ["db", "push", "--db-url", dbUrl], remoteMigrations: ["20240101000000"], }); return Effect.gen(function* () { @@ -575,7 +537,6 @@ describe("db push", () => { toml: 'project_id = "test"\n', files: { ...migrationFile("20240101000000"), ...migrationFile("20240202000000") }, remoteMigrations: ["20240202000000"], - confirm: [true], }); return Effect.gen(function* () { yield* dbPush({ ...DEFAULT_FLAGS, includeAll: true }).pipe(Effect.provide(layer)); @@ -602,7 +563,6 @@ describe("db push", () => { 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( @@ -626,7 +586,6 @@ describe("db push", () => { "supabase/seeds/nested/b.sql": "insert into t values (2);", "supabase/seeds/notes.txt": "not a seed", }, - confirm: [true], }); return Effect.gen(function* () { yield* dbPush({ ...DEFAULT_FLAGS, includeSeed: Option.some(true) }).pipe( @@ -684,7 +643,6 @@ describe("db push", () => { 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: Option.some(true) }).pipe( @@ -708,99 +666,67 @@ describe("db push", () => { }); }); - describe("seeding into a target that matched a [remotes.*] block", () => { - const remoteSeed = ( - opts: { - yes?: boolean; - format?: OutputFormat; - confirm?: ReadonlyArray<boolean>; - 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), + describe("hosted consent on a linked project", () => { + const linked = { ...DEFAULT_FLAGS, local: false, linked: true }; + const allFlags = { ...linked, includeRoles: true, includeSeed: Option.some(true) }; + const allFiles = { + ...migrationFile("20240101000000"), + "supabase/roles.sql": "create role app;", + "supabase/seed.sql": "insert into t values (1);", }; const seeded = (conn: ReturnType<typeof mockConnection>) => conn.queries.some((q) => q.sql.includes("INSERT INTO supabase_migrations.seed_files")); + const previewToml = (remoteBlock = "") => + `project_id = "base"\n\n[remotes.preview]\nproject_id = "${VALID_REF}"\n${remoteBlock}`; - it.effect("asks first, naming the ref, the remote and the files, defaulting to no", () => { - const { layer, out, conn } = remoteSeed({ confirm: [true] }); + it.effect("asks once for roles, migrations and seeds together, defaulting to no", () => { + const { layer, out, conn } = setup(tmp.current, { + includeSeed: true, + toml: 'project_id = "test"\n', + files: allFiles, + confirm: [true], + }); return Effect.gen(function* () { - yield* dbPush(flags).pipe(Effect.provide(layer)); + yield* dbPush(allFlags).pipe(Effect.provide(layer)); + expect(out.promptConfirmCalls).toHaveLength(1); expect(out.promptConfirmCalls[0]?.message).toBe( - `Project ${VALID_REF} matches [remotes.preview]. Run 1 seed file (supabase/seed.sql) against it?`, + "Create custom roles, apply 1 migration and seed 1 file?", ); expect(out.promptConfirmCalls[0]?.opts?.defaultValue).toBe(false); + expect(out.stderrText).toContain("20240101000000_test.sql"); + expect(out.stderrText).toContain("supabase/seed.sql"); + expect(conn.execs).toContain("BEGIN"); expect(seeded(conn)).toBe(true); }); }); - it.effect("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.effect("stops before any write when the question is declined", () => { + const { layer, out, conn } = setup(tmp.current, { + includeSeed: true, + toml: 'project_id = "test"\n', + files: allFiles, + confirm: [false], }); - }); - - it.effect("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"); + const exit = yield* dbPush(allFlags).pipe(Effect.provide(layer), Effect.exit); + expect(failError(exit)._tag).toBe("HostedMutationDeclinedError"); + expect(out.stderrText).not.toContain("Applying migration"); + expect(out.stderrText).not.toContain("Seeding globals"); expect(conn.execs).not.toContain("BEGIN"); expect(seeded(conn)).toBe(false); }); }); - it.effect("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.effect("fails before any write when nothing can ask and --yes is absent", () => { + const { layer, out, conn } = setup(tmp.current, { + includeSeed: true, + toml: 'project_id = "test"\n', + files: allFiles, + format: "json", }); - }); - - it.effect("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.`, - ); + const exit = yield* dbPush(allFlags).pipe(Effect.provide(layer), Effect.exit); + expect(failError(exit)._tag).toBe("HostedMutationConsentRequiredError"); expect(failSuggestion(exit)).toContain("--yes"); expect(out.promptConfirmCalls).toEqual([]); expect(conn.execs).not.toContain("BEGIN"); @@ -808,113 +734,125 @@ describe("db push", () => { }); }); - it.effect("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.effect("runs under --yes without asking and says what it confirmed", () => { + const { layer, out, conn } = setup(tmp.current, { + includeSeed: true, + toml: 'project_id = "test"\n', + files: allFiles, + format: "json", + yes: true, }); - }); - - it.effect("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)); + yield* dbPush(allFlags).pipe(Effect.provide(layer)); + expect(out.promptConfirmCalls).toEqual([]); + expect(out.stderrText).toContain( + "Confirmed by --yes: Create custom roles, apply 1 migration and seed 1 file", + ); + expect(conn.execs).toContain("BEGIN"); expect(seeded(conn)).toBe(true); }); }); - it.effect("does not ask when the matched remote block itself enables seeding", () => { - const { layer, out, conn } = remoteSeed({ + it.effect("asks nothing for --dry-run", () => { + const { layer, out, conn } = setup(tmp.current, { + includeSeed: true, + toml: 'project_id = "test"\n', + files: allFiles, format: "json", - remoteBlock: "\n[remotes.preview.db.seed]\nenabled = true\n", }); return Effect.gen(function* () { - yield* dbPush(flags).pipe(Effect.provide(layer)); + yield* dbPush({ ...allFlags, dryRun: true }).pipe(Effect.provide(layer)); expect(out.promptConfirmCalls).toEqual([]); - expect(seeded(conn)).toBe(true); + expect(out.stderrText).toContain("Would push these migrations:"); + expect(conn.execs).not.toContain("BEGIN"); }); }); - it.effect("does not ask when the matched remote block enables seeding with a number", () => { - const { layer, out, conn } = remoteSeed({ + it.effect("asks nothing when the linked database is already up to date", () => { + const { layer, out } = setup(tmp.current, { + toml: 'project_id = "test"\n', format: "json", - remoteBlock: "\n[remotes.preview.db.seed]\nenabled = 1\n", }); return Effect.gen(function* () { - yield* dbPush(flags).pipe(Effect.provide(layer)); + yield* dbPush(linked).pipe(Effect.provide(layer)); expect(out.promptConfirmCalls).toEqual([]); - expect(seeded(conn)).toBe(true); + expect(out.messages.find((m) => m.type === "success")?.data?.["upToDate"]).toBe(true); }); }); - it.effect("--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.effect( + "names the matched [remotes.*] block and what enabled seeding in the same question", + () => { + const { layer, out, conn } = setup(tmp.current, { + includeSeed: true, + toml: previewToml(), + files: { "supabase/seed.sql": "insert into t values (1);" }, + confirm: [true], + }); + return Effect.gen(function* () { + yield* dbPush({ ...linked, includeSeed: Option.some(true) }).pipe(Effect.provide(layer)); + expect(out.promptConfirmCalls).toHaveLength(1); + expect(out.stderrText).toContain( + "Seed 1 file into [remotes.preview] (seeding enabled by --include-seed)", + ); + expect(seeded(conn)).toBe(true); + }); + }, + ); - it.effect("--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.effect( + "does not credit --include-seed when the matched remote block enables seeding", + () => { + const { layer, out, conn } = setup(tmp.current, { + includeSeed: true, + toml: previewToml("\n[remotes.preview.db.seed]\nenabled = true\n"), + files: { "supabase/seed.sql": "insert into t values (1);" }, + confirm: [true], + }); + return Effect.gen(function* () { + yield* dbPush({ ...linked, includeSeed: Option.some(true) }).pipe(Effect.provide(layer)); + expect(out.stderrText).toContain("Seed 1 file into [remotes.preview]"); + expect(out.stderrText).not.toContain("seeding enabled by"); + expect(seeded(conn)).toBe(true); + }); + }, + ); - it.effect("does not ask when the target matched no remote block", () => { + it.effect("asks before pushing to a --db-url that is not local", () => { const { layer, out, conn } = setup(tmp.current, { - includeSeed: true, toml: 'project_id = "test"\n', - files: { "supabase/seed.sql": "insert into t values (1);" }, + files: migrationFile("20240101000000"), + isLocal: false, 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); + yield* dbPush({ + ...DEFAULT_FLAGS, + local: false, + dbUrl: Option.some("postgresql://postgres:pw@db.example.com:5432/postgres"), + }).pipe(Effect.provide(layer)); + expect(out.promptConfirmCalls).toHaveLength(1); + expect(conn.execs).toContain("BEGIN"); }); }); + + it.effect("lets the connection clear a network ban only under --yes", () => + Effect.gen(function* () { + const withYes = setup(tmp.current, { toml: 'project_id = "test"\n', yes: true }); + yield* dbPush(linked).pipe(Effect.provide(withYes.layer)); + expect(Option.isSome(withYes.resolver.calls[0]!.networkBanRemoval)).toBe(true); + + const withoutYes = setup(tmp.current, { toml: 'project_id = "test"\n' }); + yield* withEmptyConfigEnv(dbPush(linked).pipe(Effect.provide(withoutYes.layer))); + expect(Option.isNone(withoutYes.resolver.calls[0]!.networkBanRemoval)).toBe(true); + }), + ); }); it.effect("creates custom roles with --include-roles", () => { const { layer, out } = setup(tmp.current, { toml: 'project_id = "test"\n', files: { "supabase/roles.sql": "create role app;" }, - confirm: [true], }); return Effect.gen(function* () { yield* dbPush({ ...DEFAULT_FLAGS, includeRoles: true }).pipe(Effect.provide(layer)); @@ -927,7 +865,6 @@ describe("db push", () => { const { layer, out } = setup(tmp.current, { toml: 'project_id = "test"\n', files: migrationFile("20240101000000"), - confirm: [true], }); return Effect.gen(function* () { yield* dbPush({ ...DEFAULT_FLAGS, includeRoles: true }).pipe(Effect.provide(layer)); @@ -961,7 +898,6 @@ describe("db push", () => { const { layer, out } = setup(tmp.current, { toml: 'project_id = "test"\n', files: { "supabase/roles.sql": "create role app;" }, - confirm: [true], }); return Effect.gen(function* () { yield* dbPush({ ...DEFAULT_FLAGS, includeRoles: true }).pipe(Effect.provide(layer)); @@ -969,39 +905,6 @@ describe("db push", () => { }); }); - it.effect("returns context canceled when the roles prompt is declined", () => { - const { layer } = setup(tmp.current, { - toml: 'project_id = "test"\n', - files: { "supabase/roles.sql": "create role app;" }, - confirm: [false], - }); - return Effect.gen(function* () { - const exit = yield* dbPush({ ...DEFAULT_FLAGS, includeRoles: true }).pipe( - Effect.provide(layer), - Effect.exit, - ); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) expect(Cause.pretty(exit.cause)).toContain("context canceled"); - }); - }); - - it.effect("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: Option.some(true) }).pipe( - Effect.provide(layer), - Effect.exit, - ); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) expect(Cause.pretty(exit.cause)).toContain("context canceled"); - }); - }); - it.effect("re-hashes a dirty seed without re-running its statements", () => { const { layer, out, conn } = setup(tmp.current, { includeSeed: true, @@ -1009,7 +912,6 @@ describe("db push", () => { files: { "supabase/seed.sql": "insert into t values (1);" }, // Remote hash differs → dirty. remoteSeeds: { "supabase/seed.sql": "stalehash" }, - confirm: [true], }); return Effect.gen(function* () { yield* dbPush({ ...DEFAULT_FLAGS, includeSeed: Option.some(true) }).pipe( @@ -1026,7 +928,6 @@ describe("db push", () => { 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: Option.some(true) }).pipe( @@ -1055,7 +956,6 @@ describe("db push", () => { 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: Option.some(true) }).pipe( @@ -1071,7 +971,6 @@ describe("db push", () => { files: migrationFile("20240101000000"), // `existing` already present remotely → update; `fresh` → create. vaultRows: [{ id: "id-1", name: "existing" }], - confirm: [true], }); return Effect.gen(function* () { yield* dbPush(DEFAULT_FLAGS).pipe(Effect.provide(layer)); @@ -1088,7 +987,6 @@ describe("db push", () => { toml: 'project_id = "test"\n\n[db.vault]\nexisting = "v1"\nfresh = "v2"\n', files: migrationFile("20240101000000"), vaultRows: [{ id: "id-1", name: "existing" }], - confirm: [true], }); return Effect.gen(function* () { yield* dbPush({ ...DEFAULT_FLAGS, skipVault: true }).pipe(Effect.provide(layer)); @@ -1105,7 +1003,6 @@ describe("db push", () => { const { layer, out, conn } = setup(tmp.current, { toml: 'project_id = "test"\n\n[db.vault]\nmy_secret = "encrypted:not-valid"\n', files: migrationFile("20240101000000"), - confirm: [true], }); return Effect.gen(function* () { const exit = yield* dbPush({ ...DEFAULT_FLAGS, skipVault: true }).pipe( @@ -1131,7 +1028,6 @@ describe("db push", () => { expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) expect(Cause.pretty(exit.cause)).toContain("failed to parse config:"); - expect(out.stderrText).not.toContain("Connecting to local database..."); expect(conn.queries).toEqual([]); }); }); @@ -1150,7 +1046,6 @@ describe("db push", () => { ...migrationFile("20240101000000"), "supabase/.env": `DOTENV_PRIVATE_KEY=${PRIVATE_KEY}\n`, }, - confirm: [true], }); return Effect.gen(function* () { yield* dbPush(DEFAULT_FLAGS).pipe(Effect.provide(layer)); @@ -1163,15 +1058,16 @@ describe("db push", () => { ); it.effect("defaults to the linked target when no target flag is set", () => { - const { layer, out } = setup(tmp.current, { + const { layer, out, resolver } = setup(tmp.current, { toml: 'project_id = "test"\n', - args: ["db", "push"], - isLocal: false, projectRef: VALID_REF, }); return Effect.gen(function* () { yield* dbPush({ ...DEFAULT_FLAGS, local: false }).pipe(Effect.provide(layer)); - expect(out.stderrText).toContain("Connecting to remote database..."); + expect(resolver.calls[0]).toMatchObject({ + target: { kind: "linked", ref: VALID_REF, source: "file", matchesLinkedRef: true }, + hasPasswordFlag: true, + }); expect(out.stdoutText).toBe("Remote database is up to date.\n"); }); }); @@ -1180,8 +1076,6 @@ describe("db push", () => { // No target flag: push defaults to the linked project. const { layer, projectHttp } = setup(tmp.current, { toml: 'project_id = "test"\n', - args: ["db", "push", "--project-ref", VALID_REF], - isLocal: false, realLayers: true, }); const requests: Array<StubbedRequest> = []; @@ -1217,8 +1111,6 @@ describe("db push", () => { it.effect("does not mint a login role when --password is given", () => { const { layer, projectHttp } = setup(tmp.current, { toml: 'project_id = "test"\n', - args: ["db", "push", "--linked", "--project-ref", VALID_REF, "--password", "secret"], - isLocal: false, realLayers: true, }); const requests: Array<StubbedRequest> = []; @@ -1252,7 +1144,6 @@ describe("db push", () => { () => { const { layer, projectHttp } = setup(tmp.current, { toml: 'project_id = "test"\n', - args: ["db", "push", "--local"], realLayers: true, }); const requests: Array<StubbedRequest> = []; @@ -1282,7 +1173,6 @@ describe("db push", () => { toml: 'project_id = "test"\n', files: migrationFile("20240101000000", "BOOM;"), failExec: "BOOM", - confirm: [true], }); return Effect.gen(function* () { const error = yield* dbPush(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.flip); @@ -1308,7 +1198,6 @@ describe("db push", () => { detail: "Detail from the server.", position: 25, }, - confirm: [true], }); return Effect.gen(function* () { const error = yield* dbPush(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.flip); @@ -1371,7 +1260,6 @@ describe("db push", () => { it.effect("uses embedded defaults when no config file is present", () => { const { layer, out } = setup(tmp.current, { files: migrationFile("20240101000000"), - confirm: [true], }); return Effect.gen(function* () { // No config.toml written → the resolved config reports no config file → default config @@ -1381,24 +1269,6 @@ describe("db push", () => { }); }); - it.effect( - "auto-confirms pending migrations via SUPABASE_YES set only in the project .env", - () => { - // The project `.env` is applied before the history prompt reads - // `SUPABASE_YES`, so a `SUPABASE_YES` in supabase/.env auto-confirms without - // any interactive answer. - const { layer, out } = setup(tmp.current, { - toml: 'project_id = "test"\n', - files: { ...migrationFile("20240101000000"), "supabase/.env": "SUPABASE_YES=true\n" }, - // No `confirm` responses; the prompt must be auto-confirmed. - }); - return Effect.gen(function* () { - yield* dbPush(DEFAULT_FLAGS).pipe(Effect.provide(layer)); - expect(out.stderrText).toContain("Applying migration 20240101000000_test.sql..."); - }); - }, - ); - it.effect("fails when config.toml cannot be parsed", () => { const { layer } = setup(tmp.current, { toml: "this is = = not [[[ valid toml" }); return Effect.gen(function* () { @@ -1418,7 +1288,6 @@ describe("db push", () => { const { layer, out } = setup(tmp.current, { 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* () { @@ -1434,8 +1303,6 @@ describe("db push", () => { 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"), - args: ["db", "push", "--linked"], - isLocal: false, projectRef: VALID_REF, confirm: [true], }); @@ -1449,8 +1316,6 @@ describe("db push", () => { it.effect("announces a matching [remotes.*] override on the linked path", () => { const { layer, out } = setup(tmp.current, { toml: `project_id = "base"\n\n[remotes.preview]\nproject_id = "${VALID_REF}"\n`, - args: ["db", "push", "--linked"], - isLocal: false, projectRef: VALID_REF, }); return Effect.gen(function* () { @@ -1464,8 +1329,6 @@ describe("db push", () => { () => { 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 }, }); @@ -1479,8 +1342,6 @@ describe("db push", () => { it.effect("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* () { @@ -1493,15 +1354,13 @@ describe("db push", () => { const { layer, out, linkedCache } = setup(tmp.current, { toml: 'project_id = "test"\n', files: migrationFile("20240101000000"), - args: ["db", "push", "--linked"], - isLocal: false, projectRef: VALID_REF, format: "json", - confirm: [true], + yes: true, }); return Effect.gen(function* () { yield* dbPush({ ...DEFAULT_FLAGS, local: false, linked: true }).pipe(Effect.provide(layer)); - expect(out.stderrText).toContain("Connecting to remote database..."); + expect(out.stderrText).toContain("Confirmed by --yes: Apply 1 migration"); expect(linkedCache.cached).toBe(true); expect(linkedCache.cachedRef).toBe(VALID_REF); const success = out.messages.find((m) => m.type === "success"); @@ -1514,11 +1373,9 @@ describe("db push", () => { const { layer, out, linkedCache, resolver } = setup(tmp.current, { toml: 'project_id = "test"\n', files: migrationFile("20240101000000"), - args: ["db", "push", "--linked"], - isLocal: false, linkedFails: true, format: "json", - confirm: [true], + yes: true, }); return Effect.gen(function* () { yield* dbPush({ @@ -1527,10 +1384,14 @@ describe("db push", () => { linked: true, projectRef: Option.some(FLAG_PROJECT_REF), }).pipe(Effect.provide(layer)); - expect(out.stderrText).toContain("Connecting to remote database..."); expect(linkedCache.cached).toBe(true); expect(linkedCache.cachedRef).toBe(FLAG_PROJECT_REF); - expect(resolver.calls[0]?.linkedProjectRef).toEqual(Option.some(FLAG_PROJECT_REF)); + expect(resolver.calls[0]?.target).toEqual({ + kind: "linked", + ref: FLAG_PROJECT_REF, + source: "flag", + matchesLinkedRef: false, + }); const success = out.messages.find((m) => m.type === "success"); expect(success?.data?.["migrations"]).toEqual(["20240101000000_test.sql"]); }); @@ -1541,8 +1402,6 @@ describe("db push", () => { // `VALID_REF` fallback, so the override only announces if the flag resolved it. const { layer, out } = setup(tmp.current, { toml: `project_id = "base"\n\n[remotes.staging]\nproject_id = "${FLAG_PROJECT_REF}"\n`, - args: ["db", "push", "--linked"], - isLocal: false, }); return Effect.gen(function* () { yield* dbPush({ @@ -1559,8 +1418,6 @@ describe("db push", () => { const { layer, linkedCache } = setup(tmp.current, { toml: 'project_id = "test"\n', files: migrationFile("20240101000000"), - args: ["db", "push", "--linked"], - isLocal: false, // A distinct fixed ref proves the flag, not the workdir's own ref, wins. projectRef: VALID_REF, confirm: [true], @@ -1582,22 +1439,48 @@ describe("db push", () => { const { layer, conn, resolver, linkedCache } = setup(tmp.current, { toml: 'project_id = "test"\n', files: migrationFile("20240101000000"), - args: ["db", "push", "--local"], }); return Effect.gen(function* () { const exit = yield* dbPush({ ...DEFAULT_FLAGS, projectRef: Option.some(FLAG_PROJECT_REF), }).pipe(Effect.provide(layer), Effect.exit); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - expect(Cause.pretty(exit.cause)).toContain( - "--project-ref only applies when targeting the linked project; use it with --linked (not --local or --db-url)", - ); - } + expect(failError(exit)._tag).toBe("TargetProjectRefConflictError"); expect(conn.execs).toEqual([]); expect(resolver.calls).toEqual([]); expect(linkedCache.cached).toBe(false); }); }); }); + +describeHostedConsent("db push", { + setup: (workdir) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* fs.makeDirectory(`${workdir}/supabase/.temp`, { recursive: true }); + yield* fs.writeFileString(`${workdir}/supabase/.temp/project-ref`, VALID_REF); + yield* fs.writeFileString(`${workdir}/supabase/config.toml`, 'project_id = "test"\n'); + yield* fs.makeDirectory(`${workdir}/${MIGRATION_DIR}`, { recursive: true }); + yield* fs.writeFileString( + `${workdir}/${MIGRATION_DIR}/20240101000000_test.sql`, + "create table t ();", + ); + }), + invoke: ({ out }) => { + const conn = mockConnection({}); + return { + run: dbPush({ ...DEFAULT_FLAGS, local: false }).pipe( + Effect.provide( + Layer.mergeAll( + conn.layer, + mockResolver().layer, + configValuesLayer({ output: out.layer }), + Layer.succeed(DnsResolverFlag, "native"), + ), + ), + ), + mutations: () => + conn.queries.filter((q) => q.sql.includes("INSERT INTO supabase_migrations")).length, + }; + }, +}); diff --git a/apps/cli/src/commands/db/push/push.layers.ts b/apps/cli/src/commands/db/push/push.layers.ts index 92008ab9c8..8845ad9a70 100644 --- a/apps/cli/src/commands/db/push/push.layers.ts +++ b/apps/cli/src/commands/db/push/push.layers.ts @@ -9,7 +9,6 @@ import { projectRefLayer } from "../../../config/project-ref.layer.ts"; import { dbConfigLayer } from "../../../command-internal/db-config.layer.ts"; import { dbConnectionLayer } from "../../../command-internal/db-connection.sql-pg.layer.ts"; import { debugLoggerLayer } from "../../../command-internal/debug-logger.layer.ts"; -import { stdinLayer } from "../../../shared/runtime/stdin.layer.ts"; import { identityStitchLayer } from "../../../command-internal/identity-stitch.ts"; import { linkedProjectCacheLayer } from "../../../telemetry/linked-project-cache.layer.ts"; import { telemetryStateLayer } from "../../../telemetry/telemetry-state.layer.ts"; @@ -70,9 +69,5 @@ export const dbPushRuntimeLayer = Layer.mergeAll( linkedProjectCache, identityStitchLayer, telemetryStateLayer, - // `promptYesNo`'s non-TTY branch reads the piped answer via `Stdin`; - // without it a CI/piped `db push` that reaches a confirmation prompt fails - // with a missing-service defect instead of honoring `y`/`n` or the default. - stdinLayer, commandRuntimeLayer(["db", "push"]), ); From c5dc002a6251d7ad31f7a39886472c9d07afcd0c Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Thu, 15 Oct 2026 14:25:27 +0100 Subject: [PATCH 27/92] feat(cli): resolve the inspect target from the workspace target (CLI-2695) --- .../src/commands/inspect/db/SIDE_EFFECTS.md | 62 ++--- .../commands/inspect/db/inspect-db-command.ts | 28 +- .../inspect/db/inspect-db.e2e.test.ts | 6 +- .../db/inspect-deprecated.integration.test.ts | 32 ++- .../db/inspect-query.integration.test.ts | 246 ++++++++++-------- .../src/commands/inspect/db/inspect-query.ts | 62 +---- .../db/inspect-specs.integration.test.ts | 27 +- .../src/commands/inspect/inspect.layers.ts | 27 +- .../commands/inspect/report/SIDE_EFFECTS.md | 40 +-- .../commands/inspect/report/report.command.ts | 21 +- .../inspect/report/report.e2e.test.ts | 4 +- .../commands/inspect/report/report.handler.ts | 38 +-- .../inspect/report/report.integration.test.ts | 197 +++++++------- 13 files changed, 377 insertions(+), 413 deletions(-) diff --git a/apps/cli/src/commands/inspect/db/SIDE_EFFECTS.md b/apps/cli/src/commands/inspect/db/SIDE_EFFECTS.md index dc4efcbd4d..507d8e482d 100644 --- a/apps/cli/src/commands/inspect/db/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/inspect/db/SIDE_EFFECTS.md @@ -1,17 +1,21 @@ # `supabase inspect db <subcommand>` Single shared side-effect document for all 13 active `inspect db` subcommands and -their 12 deprecated aliases. Every subcommand has the same surface — it resolves a -Postgres connection from `--db-url` / `--linked` / `--local`, runs one read-only -`SELECT`, and renders the result as an ASCII table. They differ only in the -SQL run and the columns rendered (see the per-subcommand `<name>.query.ts`). +their 12 deprecated aliases. Every subcommand has the same surface — it resolves the +target from `--db-url` / `--linked` / `--local` / `--project-ref` (else the workspace +`target`, local by default), runs one read-only `SELECT`, and renders the result as an +ASCII table. They differ only in the SQL run and the columns rendered (see the +per-subcommand `<name>.query.ts`). The commands are `selectable` and never ask for +hosted-mutation consent. ## Files Read | Path | Format | When | | -------------------------------- | ---------- | ----------------------------------------------------------------------------------- | -| `<workdir>/supabase/config.toml` | TOML | `--local` (db host/port/password); `--linked` (project ref) | -| `~/.supabase/access-token` | plain text | `--linked` only, lazily, when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | +| `<workdir>/supabase/config.toml` | TOML | always, for the workspace `target` when no selector flag is given; local db host/port/password | +| `<workdir>/supabase/.temp/project-ref` | plain text | linked target without `--project-ref` or `SUPABASE_PROJECT_ID` | +| `<workdir>/supabase/.temp/linked-project.json` | JSON | linked target, for the `Target:` banner (no API call) | +| `~/.supabase/access-token` | plain text | linked target only, lazily, when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | | `.pgpass` / `pg_service.conf` | libpq | only if referenced by a `--db-url` connection string | | `$PGSSLROOTCERT` CA bundle | PEM | only if a `--db-url` sets `sslrootcert` / `PGSSLROOTCERT` | @@ -25,22 +29,25 @@ no new config reads. | ---------------------------- | ------ | ----------------------------------------- | | `~/.supabase/telemetry.json` | JSON | always, post-run (`TelemetryState.flush`) | -## API Routes (MAY fire on `--linked`, inside the resolver) +## API Routes (MAY fire on a linked target, inside the resolver) | Method | Path | Auth | When | | -------- | ------------------------------------------- | ------------ | ------------------------------------------------------------------------------------ | -| `POST` | `/v1/projects/{ref}/cli/login-role` | Bearer token | `--linked` with no database password, to create a temp login role | -| `GET` | `/v1/projects/{ref}/config/database/pooler` | Bearer token | `--linked` with `--project-ref`, direct host unreachable, no usable saved pooler URL | -| `POST` | `/v1/projects/{ref}/network-bans/retrieve` | Bearer token | `--linked`, from the 3rd failed temp-role connect (list bans) | -| `DELETE` | `/v1/projects/{ref}/network-bans` | Bearer token | `--linked`, only when bans are listed (clear a self-ban) | +| `GET` | `/v1/projects` | Bearer token | linked target with no ref from `--project-ref`, `SUPABASE_PROJECT_ID` or `.temp/project-ref`, interactive terminal only (project picker) | +| `POST` | `/v1/projects/{ref}/cli/login-role` | Bearer token | linked target with no database password, to create a temp login role | +| `GET` | `/v1/projects/{ref}/config/database/pooler` | Bearer token | linked target from `--project-ref`, direct host unreachable, no usable saved pooler URL | +| `POST` | `/v1/projects/{ref}/network-bans/retrieve` | Bearer token | linked target, from the 3rd failed temp-role connect (list bans) | +| `DELETE` | `/v1/projects/{ref}/network-bans` | Bearer token | linked target, only when bans are listed and `--yes` or a shell `SUPABASE_YES` is set (clear a self-ban); otherwise the command fails with `LinkedDbNetworkBannedError` | ## Environment Variables | Variable | Purpose | Required? | | ---------------------------------------------------- | --------------------------------------------------------------------- | --------------------------------------- | +| `SUPABASE_TARGET` | workspace target (`local` or `remote`) when no selector flag is given | no (defaults to `local`) | | `SUPABASE_DB_PASSWORD` / `DB_PASSWORD` | database password (linked/local) | no (prompts / config fallback) | | `SUPABASE_ACCESS_TOKEN` | Management API auth (linked only) | no (falls back to keyring / token file) | -| `SUPABASE_PROJECT_ID` | project ref fallback (linked), superseded by `--project-ref` when set | no (config resolution fallback) | +| `SUPABASE_PROJECT_ID` | project ref once the target is linked, superseded by `--project-ref` | no (config resolution fallback) | +| `SUPABASE_YES` | shell value lets a linked connection clear a network ban | no | | libpq vars (`PGSSLROOTCERT`, `PGCONNECT_TIMEOUT`, …) | honored when `--db-url` is used | no | ## Database Queries @@ -82,8 +89,8 @@ flags, run no query and open no connection, and exit 1 with | Code | Condition | | ---- | ------------------------------------------------------------------------ | | `0` | success | -| `1` | mutually-exclusive flags, resolution, connection, or query failure | -| `1` | `--project-ref` set with a resolved target other than linked (see Notes) | +| `1` | conflicting target flags, empty `--db-url`/`--project-ref`, resolution, connection, or query failure | +| `1` | `--project-ref` combined with `--local` or `--db-url` | ## Telemetry Events Fired @@ -101,13 +108,13 @@ An ASCII table: a leading blank line, a decorative line, the header row, a dashes separator, then one row per result. Statement/query cells (locks, blocking, outliers, calls) have their whitespace runs collapsed to single spaces (long-running-queries' query is NOT -collapsed). The "Connecting to local/remote database..." diagnostic is -written to **stderr** before the query runs. +collapsed). One `Target: …` banner naming the resolved target is written to +**stderr** before the query runs. ### `--output-format json` -A single object: `{ "rows": [ <raw driver rows, snake_case keys> ] }`. Additive — -there is no other machine output for inspect. +A single object: `{ "rows": [ <raw driver rows, snake_case keys> ], "target": { … } }`, +where `target` describes the resolved target. The stderr banner is still printed. ### `--output-format stream-json` @@ -120,16 +127,11 @@ Emit one extra stderr line before the table: ## Notes -- The Management API stack is built lazily, only on the `--linked` path, so `--local` - and `--db-url` never require an access token. -- `--linked` defaults to `true`; the runner derives it - from the absence of `--db-url` / `--local` while keeping the mutual-exclusivity check - keyed off explicitly-set flags. +- The target comes from `resolveTarget`: a selector flag wins, then the workspace `target` + (`SUPABASE_TARGET`, then `config.toml`), then local. `--project-ref` implies `--linked`; + combining it with `--local` or `--db-url` is an error, as is combining two selectors. A `false` + switch (`--linked=false`) counts as absent. +- The Management API stack is built lazily, only on a linked target, so a local or `--db-url` + target never requires an access token. - All queries are read-only `SELECT`s; the command performs no writes to the database. -- **`--project-ref`** overrides ONLY the linked-ref resolution `DbConfigResolver` performs - (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 (stricter than - `SUPABASE_PROJECT_ID`, which is simply left unused on - a non-linked target). Shared verbatim by every `inspect db` subcommand via - `INSPECT_DB_FLAGS`. +- Shared verbatim by every `inspect db` subcommand via `INSPECT_DB_FLAGS`. diff --git a/apps/cli/src/commands/inspect/db/inspect-db-command.ts b/apps/cli/src/commands/inspect/db/inspect-db-command.ts index eff736089b..08d89af88d 100644 --- a/apps/cli/src/commands/inspect/db/inspect-db-command.ts +++ b/apps/cli/src/commands/inspect/db/inspect-db-command.ts @@ -1,8 +1,8 @@ import { Effect } from "effect"; -import { Flag } from "effect/cli"; import { withDbTarget } from "../../../command-internal/command-permissions/model.ts"; import { dbConfig } from "../../../command-internal/db-config.layer.ts"; +import { targetFlags } from "../../../command-internal/target/target-flags.ts"; import { withTargetPolicy } from "../../../command-internal/target/target-policy.ts"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; import { withCommandTelemetry } from "../../../telemetry/command-telemetry.ts"; @@ -12,27 +12,11 @@ import type { InspectConnectionFlags } from "./inspect-query.ts"; * The `inspect` persistent flag set, inherited by every `inspect db` subcommand. Shared * verbatim across all 25 commands so flag names and descriptions live in one place. */ -export const INSPECT_DB_FLAGS = { - dbUrl: Flag.String("db-url").pipe( - Flag.withDescription( - "Inspect the database specified by the connection string (must be percent-encoded).", - ), - Flag.optional, - ), - linked: Flag.Boolean("linked").pipe( - Flag.withDescription("Inspect the linked project."), - Flag.withDefault(false), - ), - local: Flag.Boolean("local").pipe( - Flag.withDescription("Inspect the local database."), - Flag.withDefault(false), - ), - // 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."), - Flag.optional, - ), -} as const; +export const INSPECT_DB_FLAGS = targetFlags({ + group: "selectable", + subject: "database", + dbUrl: true, +}); /** The Management API calls every `inspect db` subcommand makes, all through the database-config resolver. */ export const INSPECT_DB_PERMISSIONS = withDbTarget(dbConfig, { default: "local" }); diff --git a/apps/cli/src/commands/inspect/db/inspect-db.e2e.test.ts b/apps/cli/src/commands/inspect/db/inspect-db.e2e.test.ts index 43757628ab..60e6728ff2 100644 --- a/apps/cli/src/commands/inspect/db/inspect-db.e2e.test.ts +++ b/apps/cli/src/commands/inspect/db/inspect-db.e2e.test.ts @@ -26,7 +26,7 @@ describe("supabase inspect db", () => { { home: home.dir, env: { HOME: home.dir } }, ); expect(exitCode).toBe(1); - expect(stderr).toContain("Connecting to remote database..."); + expect(stderr).toContain("Target: custom Postgres (127.0.0.1)"); expect(stderr).toMatch(/failed to connect to postgres|connection refused|ECONNREFUSED/i); }), ), @@ -45,9 +45,9 @@ describe("supabase inspect db", () => { expect(exitCode).toBe(1); expect(stderr).toContain('Command "cache-hit" is deprecated, use "db-stats" instead.'); const deprecationIndex = stderr.indexOf('Command "cache-hit" is deprecated'); - const connectingIndex = stderr.indexOf("Connecting to remote database..."); + const bannerIndex = stderr.indexOf("Target: custom Postgres (127.0.0.1)"); expect(deprecationIndex).toBeGreaterThanOrEqual(0); - expect(connectingIndex).toBeGreaterThan(deprecationIndex); + expect(bannerIndex).toBeGreaterThan(deprecationIndex); }), ), E2E_TIMEOUT_MS, diff --git a/apps/cli/src/commands/inspect/db/inspect-deprecated.integration.test.ts b/apps/cli/src/commands/inspect/db/inspect-deprecated.integration.test.ts index 34f0763fdb..41c3760d04 100644 --- a/apps/cli/src/commands/inspect/db/inspect-deprecated.integration.test.ts +++ b/apps/cli/src/commands/inspect/db/inspect-deprecated.integration.test.ts @@ -2,11 +2,17 @@ import { describe, expect, it } from "@effect/vitest"; import { Effect, Layer, Option } from "effect"; import { mockOutput } from "../../../../tests/helpers/mocks.ts"; -import { mockTelemetryStateTracked } from "../../../../tests/helpers/command-mocks.ts"; -import { CliArgs } from "../../../shared/cli/cli-args.service.ts"; -import { DnsResolverFlag } from "../../../command-internal/global-flags.ts"; +import { + buildTestRuntime, + mockCommandPlatformApi, + mockCommandSettings, + mockTelemetryStateTracked, +} from "../../../../tests/helpers/command-mocks.ts"; +import { configValuesLayer } from "../../../../tests/helpers/config-values-layer.ts"; +import { DnsResolverFlag, YesFlag } from "../../../command-internal/global-flags.ts"; import { DbConfigResolver } from "../../../command-internal/db-config.service.ts"; -import type { DbConfigFlags, ResolvedDbConfig } from "../../../command-internal/db-config.types.ts"; +import type { DbConnectionInput } from "../../../command-internal/db-config.types.ts"; +import { TargetPolicy } from "../../../command-internal/target/target-policy.ts"; import { DbConnection, type PgConnInput } from "../../../command-internal/db-connection.service.ts"; import type { InspectConnectionFlags, InspectQuerySpec } from "./inspect-query.ts"; import { dbStatsSpec } from "./db-stats/db-stats.query.ts"; @@ -36,14 +42,20 @@ function setup() { const telemetry = mockTelemetryStateTracked(); let querySql: string | undefined; const layer = Layer.mergeAll( - out.layer, - telemetry.layer, + buildTestRuntime({ + out, + api: mockCommandPlatformApi(), + cliSettings: mockCommandSettings({ workdir: "/tmp/inspect-deprecated-test" }), + telemetry: telemetry.layer, + }), Layer.succeed(DnsResolverFlag, "native"), - Layer.succeed(CliArgs, { args: [] }), + Layer.succeed(YesFlag, false), + Layer.succeed(TargetPolicy, { group: "selectable", consent: "none" }), + configValuesLayer(), Layer.succeed(DbConfigResolver, { - resolve: (_flags: DbConfigFlags) => - Effect.succeed({ conn: LOCAL_CONN, isLocal: true } satisfies ResolvedDbConfig), - resolveConnection: () => Effect.die("resolveConnection is not used by this test"), + resolve: () => Effect.die("resolve is not used by this test"), + resolveConnection: (input: DbConnectionInput) => + Effect.succeed({ conn: LOCAL_CONN, isLocal: true, target: input.target }), resolvePoolerFallback: () => Effect.succeed(Option.none()), }), Layer.succeed(DbConnection, { diff --git a/apps/cli/src/commands/inspect/db/inspect-query.integration.test.ts b/apps/cli/src/commands/inspect/db/inspect-query.integration.test.ts index 5389d751de..ea44cba89e 100644 --- a/apps/cli/src/commands/inspect/db/inspect-query.integration.test.ts +++ b/apps/cli/src/commands/inspect/db/inspect-query.integration.test.ts @@ -1,6 +1,8 @@ +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 { Cause, Effect, Exit, FileSystem, Layer, Option } from "effect"; +import { Cause, ConfigProvider, Effect, Exit, Layer, Option, Redacted } from "effect"; import { vi } from "vitest"; vi.mock("node:net", (importOriginal) => @@ -15,9 +17,14 @@ import { commandTreeFor } from "../../../../tests/helpers/command-flags.ts"; import { VALID_REF, VALID_TOKEN, + buildTestRuntime, + mockCommandPlatformApi, + mockCommandSettings, mockTelemetryStateTracked, + unverifiedTargetBanner, useTempWorkdir, } from "../../../../tests/helpers/command-mocks.ts"; +import { configValuesLayer } from "../../../../tests/helpers/config-values-layer.ts"; import { assertPermissionDrift } from "../../../../tests/helpers/permission-drift.ts"; import { asRecordedRequests, @@ -27,23 +34,28 @@ import { stubManagementApi, type StubbedRequest, } from "../../../../tests/helpers/permission-real-layers.ts"; -import { CliArgs } from "../../../shared/cli/cli-args.service.ts"; -import { DnsResolverFlag } from "../../../command-internal/global-flags.ts"; +import { DnsResolverFlag, YesFlag } from "../../../command-internal/global-flags.ts"; import { renderGlamourTable } from "../../../output/glamour-table.ts"; import { DbConfigResolver } from "../../../command-internal/db-config.service.ts"; import { DbConfigLoadError } from "../../../command-internal/db-config.errors.ts"; -import type { DbConfigFlags, ResolvedDbConfig } from "../../../command-internal/db-config.types.ts"; +import type { + DbConnectionInput, + ResolvedDbConnection, +} from "../../../command-internal/db-config.types.ts"; import { DbConnectError, DbExecError } from "../../../command-internal/db-connection.errors.ts"; import { DbConnection, type PgConnInput } from "../../../command-internal/db-connection.service.ts"; +import { + TargetEmptyValueError, + TargetProjectRefConflictError, + TargetSelectorConflictError, +} from "../../../command-internal/target/target.errors.ts"; +import { TargetPolicy } from "../../../command-internal/target/target-policy.ts"; import { inspectDbDbStats } from "./db-stats/db-stats.handler.ts"; import { dbStatsSpec } from "./db-stats/db-stats.query.ts"; import { inspectDbLocks } from "./locks/locks.handler.ts"; import { inspectDbRoleStats } from "./role-stats/role-stats.handler.ts"; import { roleStatsSpec } from "./role-stats/role-stats.query.ts"; -import { - InspectMutuallyExclusiveFlagsError, - type InspectConnectionFlags, -} from "./inspect-query.ts"; +import type { InspectConnectionFlags } from "./inspect-query.ts"; const LOCAL_CONN: PgConnInput = { host: "127.0.0.1", @@ -81,25 +93,26 @@ const LOCKS_ROW = { }; function mockResolver(opts: { conn?: PgConnInput; isLocal?: boolean; fails?: boolean }) { - let resolveInput: DbConfigFlags | undefined; + let connectionInput: DbConnectionInput | undefined; const layer = Layer.succeed(DbConfigResolver, { - resolve: (flags: DbConfigFlags) => { - resolveInput = flags; + resolve: () => Effect.die("resolve is not used by this test"), + resolveConnection: (input: DbConnectionInput) => { + connectionInput = input; if (opts.fails === true) { return Effect.fail(new DbConfigLoadError({ message: "cannot load config" })); } return Effect.succeed({ conn: opts.conn ?? LOCAL_CONN, isLocal: opts.isLocal ?? true, - } satisfies ResolvedDbConfig); + target: input.target, + } satisfies ResolvedDbConnection); }, - resolveConnection: () => Effect.die("resolveConnection is not used by this test"), resolvePoolerFallback: () => Effect.succeed(Option.none()), }); return { layer, - get resolveInput() { - return resolveInput; + get connectionInput() { + return connectionInput; }, }; } @@ -164,10 +177,14 @@ interface SetupOpts { connectFails?: boolean; queryFails?: boolean; dnsResolver?: "native" | "https"; + yes?: boolean; + /** The ref `SUPABASE_PROJECT_ID` supplies to the real project-ref resolver. */ + projectId?: string; /** Raw CLI args slice; drives explicitly-passed flag detection. */ cliArgs?: ReadonlyArray<string>; /** Runs the real `DbConfigResolver` against the temp `workdir`. */ realLayers?: { readonly workdir: string }; + workdir?: string; } function setup(opts: SetupOpts = {}) { @@ -183,8 +200,19 @@ function setup(opts: SetupOpts = {}) { queryFails: opts.queryFails, }); const telemetry = mockTelemetryStateTracked(); + const workdir = opts.realLayers?.workdir ?? opts.workdir ?? "/tmp/inspect-db-test"; + const runtime = buildTestRuntime({ + out, + api: mockCommandPlatformApi(), + cliSettings: mockCommandSettings({ + workdir, + projectId: opts.projectId === undefined ? Option.none() : Option.some(opts.projectId), + }), + telemetry: telemetry.layer, + cliArgs: opts.cliArgs, + }); const layer = Layer.mergeAll( - out.layer, + runtime, opts.realLayers === undefined ? resolver.layer : realDbConfigResolver({ @@ -193,9 +221,16 @@ function setup(opts: SetupOpts = {}) { env: { SUPABASE_ACCESS_TOKEN: VALID_TOKEN }, }), connection.layer, - telemetry.layer, Layer.succeed(DnsResolverFlag, opts.dnsResolver ?? "native"), - Layer.succeed(CliArgs, { args: opts.cliArgs ?? [] }), + Layer.succeed(YesFlag, opts.yes ?? false), + Layer.succeed(TargetPolicy, { group: "selectable", consent: "none" }), + ConfigProvider.layer( + ConfigProvider.fromEnvRecord( + opts.realLayers === undefined ? {} : { SUPABASE_ACCESS_TOKEN: VALID_TOKEN }, + { preserveEmptyStrings: true }, + ), + ), + configValuesLayer(), ); return { layer, out, resolver, connection, telemetry }; } @@ -208,6 +243,8 @@ const flags = (over: Partial<InspectConnectionFlags> = {}): InspectConnectionFla }); describe("inspect db query runner", () => { + const workdir = useTempWorkdir("supabase-inspect-db-runner-"); + it.effect("renders a glamour table in text mode (db-stats)", () => { const { layer, out, connection } = setup({ rows: [DB_STATS_ROW] }); return Effect.gen(function* () { @@ -278,114 +315,110 @@ describe("inspect db query runner", () => { }); it.effect("inspects an explicit database url", () => { - const { layer, resolver, connection } = setup({ + const { layer, resolver, connection, out } = setup({ conn: REMOTE_CONN, isLocal: false, rows: [DB_STATS_ROW], - cliArgs: ["--db-url=postgres://x"], }); return Effect.gen(function* () { yield* inspectDbDbStats(flags({ dbUrl: Option.some("postgres://x") })); - expect(resolver.resolveInput?.dbUrl).toEqual(Option.some("postgres://x")); - expect(resolver.resolveInput?.connType).toBe("db-url"); + const target = resolver.connectionInput?.target; + expect(target?.kind).toBe("db-url"); + expect(target?.kind === "db-url" ? Redacted.value(target.url) : undefined).toBe( + "postgres://x", + ); expect(connection.connectCalls[0]?.isLocal).toBe(false); + expect(out.stderrText).not.toContain("Connecting to"); }).pipe(Effect.provide(layer)); }); - it.effect("inspects the local database", () => { - const { layer, resolver, out } = setup({ rows: [DB_STATS_ROW], cliArgs: ["--local"] }); + it.effect("inspects the local database with --local", () => { + const { layer, resolver, out } = setup({ rows: [DB_STATS_ROW] }); return Effect.gen(function* () { yield* inspectDbDbStats(flags({ local: true })); - expect(resolver.resolveInput?.connType).toBe("local"); - expect(out.stderrText).toContain("Connecting to local database..."); + expect(resolver.connectionInput?.target).toEqual({ kind: "local" }); + expect(out.stderrText).not.toContain("Connecting to"); }).pipe(Effect.provide(layer)); }); - it.effect("inspects the linked project by default (no connection flag)", () => { - const { layer, resolver } = setup({ rows: [DB_STATS_ROW] }); + it.effect("inspects the local database when no selector is given", () => { + const { layer, resolver, out } = setup({ rows: [DB_STATS_ROW], projectId: VALID_REF }); return Effect.gen(function* () { yield* inspectDbDbStats(flags()); - expect(resolver.resolveInput?.connType).toBe("linked"); - expect(resolver.resolveInput?.dbUrl).toEqual(Option.none()); + expect(resolver.connectionInput?.target).toEqual({ kind: "local" }); + expect(out.stderrText).not.toContain("Target:"); }).pipe(Effect.provide(layer)); }); - it.effect("labels the diagnostic 'remote' for a non-local connection", () => { - const { layer, out } = setup({ - conn: REMOTE_CONN, - isLocal: false, + it.effect("follows a workspace target of remote to the linked project", () => { + mkdirSync(join(workdir.current, "supabase"), { recursive: true }); + writeFileSync(join(workdir.current, "supabase", "config.toml"), 'target = "remote"\n'); + const { layer, resolver, out } = setup({ rows: [DB_STATS_ROW], - cliArgs: ["--db-url=postgres://x"], + workdir: workdir.current, + projectId: VALID_REF, }); return Effect.gen(function* () { - yield* inspectDbDbStats(flags({ dbUrl: Option.some("postgres://x") })); - expect(out.stderrText).toContain("Connecting to remote database..."); - }).pipe(Effect.provide(layer)); - }); - - it.effect("rejects conflicting connection flags", () => { - const { layer } = setup({ cliArgs: ["--linked", "--local"] }); - return Effect.gen(function* () { - const error = yield* inspectDbDbStats(flags({ linked: true, local: true })).pipe(Effect.flip); - expect(error).toBeInstanceOf(InspectMutuallyExclusiveFlagsError); - expect(error.message).toBe( - "if any flags in the group [db-url linked local] are set none of the others can be; [linked local] were all set", - ); + yield* inspectDbDbStats(flags()); + expect(resolver.connectionInput?.target).toMatchObject({ kind: "linked", ref: VALID_REF }); + expect(out.stderrText).toContain(unverifiedTargetBanner()); }).pipe(Effect.provide(layer)); }); - it.effect("--local=false is Changed and routes to local (not linked)", () => { - const { layer, resolver } = setup({ rows: [DB_STATS_ROW], cliArgs: ["--local=false"] }); + it.effect("--linked routes to the linked project and announces it", () => { + const { layer, resolver, out } = setup({ rows: [DB_STATS_ROW], projectId: VALID_REF }); return Effect.gen(function* () { - yield* inspectDbDbStats(flags({ local: false })); - expect(resolver.resolveInput?.connType).toBe("local"); + yield* inspectDbDbStats(flags({ linked: true })); + expect(resolver.connectionInput?.target).toMatchObject({ kind: "linked", ref: VALID_REF }); + expect(out.stderrText).toContain(unverifiedTargetBanner()); }).pipe(Effect.provide(layer)); }); - it.effect("--linked --local=false raises the mutual-exclusion error", () => { - const { layer } = setup({ cliArgs: ["--linked", "--local=false"] }); + it.effect("--project-ref implies the linked project", () => { + const FLAG_REF = "flagflagflagflagflag"; + const { layer, resolver, out } = setup({ rows: [DB_STATS_ROW] }); return Effect.gen(function* () { - const error = yield* inspectDbDbStats(flags({ linked: true, local: false })).pipe( - Effect.flip, - ); - expect(error).toBeInstanceOf(InspectMutuallyExclusiveFlagsError); - expect(error.message).toBe( - "if any flags in the group [db-url linked local] are set none of the others can be; [linked local] were all set", - ); + yield* inspectDbDbStats(flags({ projectRef: Option.some(FLAG_REF) })); + expect(resolver.connectionInput?.target).toMatchObject({ + kind: "linked", + ref: FLAG_REF, + source: "flag", + }); + expect(out.stderrText).toContain(unverifiedTargetBanner(FLAG_REF)); }).pipe(Effect.provide(layer)); }); - it.effect("--linked routes to linked", () => { - const { layer, resolver } = setup({ rows: [DB_STATS_ROW], cliArgs: ["--linked"] }); + it.effect("rejects conflicting target flags", () => { + const { layer, resolver } = setup(); return Effect.gen(function* () { - yield* inspectDbDbStats(flags({ linked: true })); - expect(resolver.resolveInput?.connType).toBe("linked"); + const error = yield* inspectDbDbStats(flags({ linked: true, local: true })).pipe(Effect.flip); + expect(error).toBeInstanceOf(TargetSelectorConflictError); + expect(resolver.connectionInput).toBeUndefined(); }).pipe(Effect.provide(layer)); }); - it.effect("inspects the project given via --project-ref on the default linked path", () => { - const FLAG_REF = "flagflagflagflagflag"; - const { layer, resolver } = setup({ rows: [DB_STATS_ROW] }); + it.effect("rejects --project-ref combined with --local", () => { + const { layer, resolver } = setup(); return Effect.gen(function* () { - yield* inspectDbDbStats(flags({ projectRef: Option.some(FLAG_REF) })); - expect(resolver.resolveInput?.connType).toBe("linked"); - expect(resolver.resolveInput?.linkedProjectRef).toEqual(Option.some(FLAG_REF)); + const error = yield* inspectDbDbStats( + flags({ local: true, projectRef: Option.some("flagflagflagflagflag") }), + ).pipe(Effect.flip); + expect(error).toBeInstanceOf(TargetProjectRefConflictError); + expect(resolver.connectionInput).toBeUndefined(); }).pipe(Effect.provide(layer)); }); - it.effect("rejects --project-ref combined with an explicit --local target", () => { - const FLAG_REF = "flagflagflagflagflag"; - const { layer, resolver } = setup({ rows: [DB_STATS_ROW], cliArgs: ["--local"] }); + it.effect("passes network-ban removal only under --yes", () => { + const withoutYes = setup({ rows: [DB_STATS_ROW] }); + const withYes = setup({ rows: [DB_STATS_ROW], yes: true }); + const banRemoval = (input: DbConnectionInput | undefined) => + input === undefined ? undefined : Option.isSome(input.networkBanRemoval); return Effect.gen(function* () { - const error = yield* inspectDbDbStats( - flags({ local: true, projectRef: Option.some(FLAG_REF) }), - ).pipe(Effect.flip); - expect(error).toBeInstanceOf(InspectMutuallyExclusiveFlagsError); - expect(error.message).toBe( - "--project-ref only applies when targeting the linked project; use it with --linked (not --local or --db-url)", - ); - expect(resolver.resolveInput).toBeUndefined(); - }).pipe(Effect.provide(layer)); + yield* inspectDbDbStats(flags({ local: true })).pipe(Effect.provide(withoutYes.layer)); + yield* inspectDbDbStats(flags({ local: true })).pipe(Effect.provide(withYes.layer)); + expect(banRemoval(withoutYes.resolver.connectionInput)).toBe(false); + expect(banRemoval(withYes.resolver.connectionInput)).toBe(true); + }); }); it.effect("surfaces a query failure", () => { @@ -559,35 +592,26 @@ describe("inspect db through the real database-config resolver", () => { ); }); - it.effect( - "an empty --project-ref falls back to the saved ref and still fetches the pooler", - () => { - const { layer } = setup({ - rows: [DB_STATS_ROW], - cliArgs: ["inspect", "db", "db-stats", "--project-ref", ""], - realLayers: { workdir: tempRoot.current }, - }); - const requests: Array<StubbedRequest> = []; - return Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - yield* fs.makeDirectory(`${tempRoot.current}/supabase/.temp`, { recursive: true }); - yield* fs.writeFileString(`${tempRoot.current}/supabase/.temp/project-ref`, VALID_REF); - yield* stubManagementApi(requests, serveLinkedDatabase(VALID_REF)); - yield* inspectDbDbStats(flags({ projectRef: Option.some("") })); - expect(asLines(requests)).toEqual([poolerRequest, loginRoleRequest]); - assertPermissionDrift({ - command: "inspect db db-stats", - activeFlags: { "project-ref": "" }, - requests: asRecordedRequests(requests), - exact: true, - }); - }).pipe( - Effect.scoped, - Effect.provide(Layer.mergeAll(layer, BunServices.layer)), - provideEnv({ SUPABASE_ACCESS_TOKEN: VALID_TOKEN }), + it.effect("rejects an empty --project-ref before any Management API request", () => { + const { layer } = setup({ + rows: [DB_STATS_ROW], + cliArgs: ["inspect", "db", "db-stats", "--project-ref", ""], + realLayers: { workdir: tempRoot.current }, + }); + const requests: Array<StubbedRequest> = []; + return Effect.gen(function* () { + yield* stubManagementApi(requests, serveLinkedDatabase(VALID_REF)); + const error = yield* inspectDbDbStats(flags({ projectRef: Option.some("") })).pipe( + Effect.flip, ); - }, - ); + expect(error).toBeInstanceOf(TargetEmptyValueError); + expect(requests).toEqual([]); + }).pipe( + Effect.scoped, + Effect.provide(Layer.mergeAll(layer, BunServices.layer)), + provideEnv({ SUPABASE_ACCESS_TOKEN: VALID_TOKEN }), + ); + }); it.effect("makes no Management API request for --local", () => { const { layer, connection } = setup({ diff --git a/apps/cli/src/commands/inspect/db/inspect-query.ts b/apps/cli/src/commands/inspect/db/inspect-query.ts index da95ad27c7..6f1d53fd17 100644 --- a/apps/cli/src/commands/inspect/db/inspect-query.ts +++ b/apps/cli/src/commands/inspect/db/inspect-query.ts @@ -1,6 +1,5 @@ import { Data, Effect, Option } from "effect"; -import { CliArgs } from "../../../shared/cli/cli-args.service.ts"; import { DnsResolverFlag } from "../../../command-internal/global-flags.ts"; import { Output } from "../../../shared/output/output.service.ts"; import { @@ -12,19 +11,15 @@ import { renderGlamourTable } from "../../../output/glamour-table.ts"; import { DbConfigResolver } from "../../../command-internal/db-config.service.ts"; import type { ResolvedDbConfig } from "../../../command-internal/db-config.types.ts"; import { DbConnection } from "../../../command-internal/db-connection.service.ts"; -import { resolveDbTargetFlags } from "../../../command-internal/db-target-flags.ts"; +import { hostedMutationPreapproval } from "../../../command-internal/hosted-consent/hosted-consent.ts"; +import { resolveTarget } from "../../../command-internal/target/resolve-target.ts"; import { TelemetryState } from "../../../telemetry/telemetry-state.service.ts"; -/** - * The connection selector flags every `inspect db` subcommand inherits: `--db-url` / `--linked` - * / `--local`, mutually exclusive, with `--linked` as the default. Exclusivity is keyed off the - * raw explicitly-set flags, not the resolved boolean values. - */ +/** The target selector flags every `inspect db` subcommand inherits; see `targetFlags`. */ export interface InspectConnectionFlags { readonly dbUrl: Option.Option<string>; readonly linked: boolean; readonly local: boolean; - // TS-only override of the linked project ref — see push.command.ts (db push). readonly projectRef: Option.Option<string>; } @@ -46,11 +41,6 @@ export interface InspectQuerySpec { readonly project: (row: Record<string, unknown>, cfg: ResolvedDbConfig) => ReadonlyArray<string>; } -/** - * Raised when more than one of `--db-url` / `--linked` / `--local` is explicitly set, with the - * established mutually-exclusive-flags message. Not reused from `test db`'s identical error - * type: hoisting would drag that command's test surface into scope for one shared string. - */ export class InspectMutuallyExclusiveFlagsError extends Data.TaggedError( "InspectMutuallyExclusiveFlagsError", )<{ readonly message: string }> { @@ -132,9 +122,9 @@ export function inspectBacktickStmt(value: unknown): string { } /** - * Runs an `inspect db` subcommand's query and renders the result: resolve the connection, - * connect (printing "Connecting to <local|remote> database..." to stderr), run the query, then - * render the table — or, in `json`/`stream-json` mode, emit the raw driver rows instead. + * Runs an `inspect db` subcommand's query and renders the result: resolve the target and its + * connection, run the query, then render the table — or, in `json`/`stream-json` mode, emit the + * raw driver rows instead. */ export const runInspectQuery = Effect.fn("Inspect.runQuery")(function* ( spec: InspectQuerySpec, @@ -144,46 +134,17 @@ export const runInspectQuery = Effect.fn("Inspect.runQuery")(function* ( const output = yield* Output; const resolver = yield* DbConfigResolver; const dbConn = yield* DbConnection; - const cliArgs = yield* CliArgs; - - // Mutual exclusivity is keyed off raw argv (which flags were explicitly passed), not the - // parsed boolean value — `--local=false` counts as explicitly set. - const target = resolveDbTargetFlags(cliArgs.args); - if (target.setFlags.length > 1) { - return yield* new InspectMutuallyExclusiveFlagsError({ - 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`, - }); - } - - // `--linked` is the default; exclusivity above is already keyed off the raw flags, so - // deriving the connType here does not re-trigger it. - const connType = target.connType ?? "linked"; - - // `--project-ref` never implies `--linked` and must not be silently - // discarded on a non-linked target — see push.handler.ts's identical guard - // (db push) for the full TS-only rationale. - if (Option.isSome(flags.projectRef) && connType !== "linked") { - return yield* new InspectMutuallyExclusiveFlagsError({ - message: - "--project-ref only applies when targeting the linked project; use it with --linked (not --local or --db-url)", - }); - } - const cfg = yield* resolver.resolve({ - dbUrl: flags.dbUrl, - connType, + const target = yield* resolveTarget(flags); + const cfg = yield* resolver.resolveConnection({ + target, dnsResolver, - linkedProjectRef: flags.projectRef, + password: Option.none(), + networkBanRemoval: yield* hostedMutationPreapproval, }); const rows = yield* Effect.scoped( Effect.gen(function* () { - // Always written to stderr, even in json/stream-json modes, since stdout is reserved - // for the rendered table / machine payload. - yield* output.raw( - `Connecting to ${cfg.isLocal ? "local" : "remote"} database...\n`, - "stderr", - ); const session = yield* dbConn.connect(cfg.conn, { isLocal: cfg.isLocal, dnsResolver }); return yield* session.query(spec.sql, spec.params(cfg)); }), @@ -196,7 +157,6 @@ export const runInspectQuery = Effect.fn("Inspect.runQuery")(function* ( return; } - // json / stream-json — emit the raw driver rows (snake_case keys). yield* output.success(`inspect db ${spec.name}`, { rows }); }); diff --git a/apps/cli/src/commands/inspect/db/inspect-specs.integration.test.ts b/apps/cli/src/commands/inspect/db/inspect-specs.integration.test.ts index af9ab79e84..36cc4c6b9f 100644 --- a/apps/cli/src/commands/inspect/db/inspect-specs.integration.test.ts +++ b/apps/cli/src/commands/inspect/db/inspect-specs.integration.test.ts @@ -2,9 +2,16 @@ import { describe, expect, it } from "@effect/vitest"; import { Effect, Layer, Option } from "effect"; import { mockOutput } from "../../../../tests/helpers/mocks.ts"; -import { CliArgs } from "../../../shared/cli/cli-args.service.ts"; +import { + buildTestRuntime, + mockCommandPlatformApi, + mockCommandSettings, +} from "../../../../tests/helpers/command-mocks.ts"; +import { configValuesLayer } from "../../../../tests/helpers/config-values-layer.ts"; +import { YesFlag } from "../../../command-internal/global-flags.ts"; import { DbConfigResolver } from "../../../command-internal/db-config.service.ts"; -import type { DbConfigFlags, ResolvedDbConfig } from "../../../command-internal/db-config.types.ts"; +import type { DbConnectionInput } from "../../../command-internal/db-config.types.ts"; +import { TargetPolicy } from "../../../command-internal/target/target-policy.ts"; import { DbConnection, type PgConnInput } from "../../../command-internal/db-connection.service.ts"; import { bloatSpec } from "./bloat/bloat.query.ts"; import { blockingSpec } from "./blocking/blocking.query.ts"; @@ -35,12 +42,18 @@ function setup(rows: ReadonlyArray<Record<string, unknown>>) { let querySql: string | undefined; let queryParams: ReadonlyArray<unknown> | undefined; const layer = Layer.mergeAll( - out.layer, - Layer.succeed(CliArgs, { args: [] }), + buildTestRuntime({ + out, + api: mockCommandPlatformApi(), + cliSettings: mockCommandSettings({ workdir: "/tmp/inspect-specs-test" }), + }), + Layer.succeed(YesFlag, false), + Layer.succeed(TargetPolicy, { group: "selectable", consent: "none" }), + configValuesLayer(), Layer.succeed(DbConfigResolver, { - resolve: (_flags: DbConfigFlags) => - Effect.succeed({ conn: LOCAL_CONN, isLocal: true } satisfies ResolvedDbConfig), - resolveConnection: () => Effect.die("resolveConnection is not used by this test"), + resolve: () => Effect.die("resolve is not used by this test"), + resolveConnection: (input: DbConnectionInput) => + Effect.succeed({ conn: LOCAL_CONN, isLocal: true, target: input.target }), resolvePoolerFallback: () => Effect.succeed(Option.none()), }), Layer.succeed(DbConnection, { diff --git a/apps/cli/src/commands/inspect/inspect.layers.ts b/apps/cli/src/commands/inspect/inspect.layers.ts index 0aa83ff2fb..b7f649a0d1 100644 --- a/apps/cli/src/commands/inspect/inspect.layers.ts +++ b/apps/cli/src/commands/inspect/inspect.layers.ts @@ -1,6 +1,9 @@ import { Layer } from "effect"; +import { commandCredentialsLayer } from "../../auth/command-credentials.layer.ts"; +import { commandPlatformApiFactoryLayer } from "../../auth/command-platform-api-factory.layer.ts"; import { commandSettingsLayer } from "../../config/command-settings.layer.ts"; +import { projectRefLayer } from "../../config/project-ref.layer.ts"; import { dbConfigLayer } from "../../command-internal/db-config.layer.ts"; import { dbConnectionLayer } from "../../command-internal/db-connection.sql-pg.layer.ts"; import { identityStitchLayer } from "../../command-internal/identity-stitch.ts"; @@ -11,6 +14,23 @@ import { telemetryStateLayer } from "../../telemetry/telemetry-state.layer.ts"; // here (for `dbConfig`) and merged again below so both have it. const cliSettings = commandSettingsLayer.pipe(Layer.provide(debugLoggerLayer)); +const credentials = commandCredentialsLayer.pipe( + Layer.provide(cliSettings), + Layer.provide(debugLoggerLayer), +); + +const platformApiFactory = commandPlatformApiFactoryLayer.pipe( + Layer.provide(credentials), + Layer.provide(cliSettings), + Layer.provide(debugLoggerLayer), + Layer.provide(identityStitchLayer), +); + +const projectRef = projectRefLayer.pipe( + Layer.provide(platformApiFactory), + Layer.provide(cliSettings), +); + const dbConfig = dbConfigLayer.pipe( Layer.provide(cliSettings), Layer.provide(dbConnectionLayer), @@ -20,14 +40,15 @@ const dbConfig = dbConfigLayer.pipe( /** * The services every `inspect` leaf shares, minus the command-runtime identity: the DB-config - * resolver, the Postgres connection, the CLI config, and telemetry state. + * resolver, the project-ref resolver, the Postgres connection, the CLI config, and telemetry state. * * The Management API stack is not merged here — it resolves an access token eagerly, which would - * break the auth-free `--local` / `--db-url` paths. The `--linked` path provides it lazily - * inside the resolver. + * break the auth-free `--local` / `--db-url` paths. The linked path reaches it lazily through the + * platform-API factory and the resolver. */ export const inspectBaseLayer = Layer.mergeAll( dbConfig, + projectRef, dbConnectionLayer, cliSettings, // The same instance is provided to `dbConfig` above so both it and `withCommandTelemetry` diff --git a/apps/cli/src/commands/inspect/report/SIDE_EFFECTS.md b/apps/cli/src/commands/inspect/report/SIDE_EFFECTS.md index 26debd732e..74e767b043 100644 --- a/apps/cli/src/commands/inspect/report/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/inspect/report/SIDE_EFFECTS.md @@ -2,17 +2,20 @@ Runs every inspect query against the target Postgres database, writes one CSV per query into `<output-dir>/<YYYY-MM-DD>/`, then prints a "rules" summary table -validating those CSVs. +validating those CSVs. The target is `--db-url` / `--linked` / `--local` / `--project-ref`, +else the workspace `target` (local by default). The command is `selectable` and never asks +for hosted-mutation consent. ## Files Read | Path | Format | When | | ------------------------------------------ | ---------- | -------------------------------------------------------------------------------------- | -| `<workdir>/supabase/config.toml` | TOML | always — `[experimental.inspect.rules]` (custom rules) + `[db]` subtree | +| `<workdir>/supabase/config.toml` | TOML | always — `[experimental.inspect.rules]` (custom rules), `[db]` subtree, workspace `target` | | `<workdir>/supabase/.env*` (nested) | dotenv | always — `env(VAR)` expansion for `[db]` and rule string fields | -| `<workdir>/supabase/.temp/pooler-url` | plain text | `--linked` path (pooler connection string) | -| `<workdir>/supabase/.temp/project-ref` | plain text | `--linked` path, without `--project-ref` or `SUPABASE_PROJECT_ID` (linked project ref) | -| `~/.supabase/access-token` | plain text | `--linked` path, when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | +| `<workdir>/supabase/.temp/pooler-url` | plain text | linked target (pooler connection string) | +| `<workdir>/supabase/.temp/project-ref` | plain text | linked target, without `--project-ref` or `SUPABASE_PROJECT_ID` (linked project ref) | +| `<workdir>/supabase/.temp/linked-project.json` | JSON | linked target, for the `Target:` banner (no API call) | +| `~/.supabase/access-token` | plain text | linked target, when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | | `<output-dir>/<YYYY-MM-DD>/<name>.csv` ×14 | CSV | read back in-memory for rule evaluation | A **missing** `config.toml` is fine (defaults apply); a @@ -57,14 +60,17 @@ wasn't refreshed this run. | — | — | — | — | — | Queries run directly against Postgres (server-side `COPY (<query>) TO STDOUT WITH -CSV HEADER`). The Management API is used lazily only on the `--linked` path, to -resolve the connection (via `DbConfigResolver`). +CSV HEADER`). The Management API is used lazily only on a linked target, to +resolve the connection (via `DbConfigResolver`); a network ban is cleared there only under +`--yes` or a shell `SUPABASE_YES`, otherwise the command fails with `LinkedDbNetworkBannedError`. ## Environment Variables | Variable | Purpose | Required? | | ----------------------- | ---------------------------------------------------- | ------------------------------------------------------ | +| `SUPABASE_TARGET` | workspace target (`local` or `remote`) when no selector flag is given | no (defaults to `local`) | | `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no | +| `SUPABASE_YES` | shell value lets a linked connection clear a network ban | no | | `SUPABASE_PROFILE` | built-in profile name or YAML file path | no (falls back to `~/.supabase/profile` -> `supabase`) | | `SUPABASE_DB_*` | override `[db]` port / shadow_port / password | no | | `SUPABASE_ENV` | selects which project `.env` files load | no | @@ -78,8 +84,8 @@ resolve the connection (via `DbConfigResolver`). | `1` | DB connection / resolution failure (not linked, invalid ref, dial failure) | | `1` | COPY failure (`failed to copy output`) / file-write failure (`failed to create output file`) | | `1` | malformed `config.toml` | -| `1` | more than one of `--db-url` / `--linked` / `--local` | -| `1` | `--project-ref` set with a resolved target other than linked (see Notes) | +| `1` | more than one of `--db-url` / `--linked` / `--local`, or an empty `--db-url` / `--project-ref` | +| `1` | `--project-ref` combined with `--local` or `--db-url` | A **per-rule** csvq evaluation error does **not** fail the command — it becomes the rule's STATUS cell. @@ -91,7 +97,7 @@ rule's STATUS cell. stderr progress, in order: ``` -Connecting to <local|remote> database... +Target: <resolved target> Running queries... Reports saved to <output-dir>/<date> (path bolded when stdout is a TTY) Loading default rules... (only when no custom config.toml rules) @@ -110,17 +116,15 @@ count. ### `--output-format json` / `stream-json` (additive; there is no other machine output) The CSVs are still written. Progress lines are suppressed and no table is printed; -instead a structured result is emitted: +instead a structured result is emitted. The stderr `Target:` banner is still printed. ```json -{ "outputDir": "<abs path>", "files": [{ "name": "locks", "path": "..." }, ...], "rules": [{ "name": "...", "status": "...", "matches": "..." }, ...] } +{ "outputDir": "<abs path>", "files": [{ "name": "locks", "path": "..." }, ...], "rules": [{ "name": "...", "status": "...", "matches": "..." }, ...], "target": { ... } } ``` ## Notes -- **`--project-ref`** overrides ONLY the linked-ref resolution `DbConfigResolver` performs - (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 (stricter than - `SUPABASE_PROJECT_ID`, which is simply left unused on - a non-linked target). +- The target comes from `resolveTarget`: a selector flag wins, then the workspace `target` + (`SUPABASE_TARGET`, then `config.toml`), then local. `--project-ref` implies `--linked`; + combining it with `--local` or `--db-url` is an error, as is combining two selectors. A `false` + switch (`--linked=false`) counts as absent. diff --git a/apps/cli/src/commands/inspect/report/report.command.ts b/apps/cli/src/commands/inspect/report/report.command.ts index 27446535c3..e5100c1229 100644 --- a/apps/cli/src/commands/inspect/report/report.command.ts +++ b/apps/cli/src/commands/inspect/report/report.command.ts @@ -12,28 +12,11 @@ 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"; +import { targetFlags } from "../../../command-internal/target/target-flags.ts"; import { withTargetPolicy } from "../../../command-internal/target/target-policy.ts"; const config = { - dbUrl: Flag.String("db-url").pipe( - Flag.withDescription( - "Inspect the database specified by the connection string (must be percent-encoded).", - ), - Flag.optional, - ), - linked: Flag.Boolean("linked").pipe( - Flag.withDescription("Inspect the linked project."), - Flag.withDefault(false), - ), - local: Flag.Boolean("local").pipe( - Flag.withDescription("Inspect the local database."), - Flag.withDefault(false), - ), - // 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."), - Flag.optional, - ), + ...targetFlags({ group: "selectable", subject: "database", dbUrl: true }), outputDir: Flag.String("output-dir").pipe( Flag.withDescription("Path to save CSV files in."), Flag.withDefault("."), diff --git a/apps/cli/src/commands/inspect/report/report.e2e.test.ts b/apps/cli/src/commands/inspect/report/report.e2e.test.ts index b411202981..16cbef67e0 100644 --- a/apps/cli/src/commands/inspect/report/report.e2e.test.ts +++ b/apps/cli/src/commands/inspect/report/report.e2e.test.ts @@ -16,7 +16,7 @@ const TEXT_MODE = ["--agent", "no"]; describe("supabase inspect report", () => { it.live( - "creates the dated output directory and prints the connect diagnostic before failing on an unreachable database", + "creates the dated output directory and prints the target banner before failing on an unreachable database", () => withTempHome((home) => Effect.gen(function* () { @@ -28,7 +28,7 @@ describe("supabase inspect report", () => { { home: home.dir, env: { HOME: home.dir } }, ); expect(exitCode).toBe(1); - expect(stderr).toContain("Connecting to remote database..."); + expect(stderr).toContain("Target: custom Postgres (127.0.0.1)"); expect(stderr).toMatch(/failed to connect to postgres|connection refused|ECONNREFUSED/i); const dated = (yield* fs.readDirectory(outputDir)).filter((name) => /^\d{4}-\d{2}-\d{2}$/.test(name), diff --git a/apps/cli/src/commands/inspect/report/report.handler.ts b/apps/cli/src/commands/inspect/report/report.handler.ts index 7709c466c4..3818000eb2 100644 --- a/apps/cli/src/commands/inspect/report/report.handler.ts +++ b/apps/cli/src/commands/inspect/report/report.handler.ts @@ -1,6 +1,5 @@ import { Clock, DateTime, Effect, FileSystem, Option, Path } from "effect"; -import { CliArgs } from "../../../shared/cli/cli-args.service.ts"; import { DnsResolverFlag } from "../../../command-internal/global-flags.ts"; import { Output } from "../../../shared/output/output.service.ts"; import { RuntimeInfo } from "../../../shared/runtime/runtime-info.service.ts"; @@ -9,9 +8,9 @@ import { CommandSettings } from "../../../config/command-settings.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 { hostedMutationPreapproval } from "../../../command-internal/hosted-consent/hosted-consent.ts"; +import { resolveTarget } from "../../../command-internal/target/resolve-target.ts"; import { TelemetryState } from "../../../telemetry/telemetry-state.service.ts"; -import { InspectMutuallyExclusiveFlagsError } from "../db/inspect-query.ts"; import type { InspectReportFlags } from "./report.command.ts"; import { type CsvTable, type CsvTableProvider, parseReportCsv } from "./report.csvq.ts"; import { readInspectRules } from "./report.config.ts"; @@ -63,37 +62,18 @@ const runInspectReport = Effect.fnUntraced(function* ( const tty = yield* Tty; const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const cliArgs = yield* CliArgs; const isText = output.format === "text"; - // Mutual exclusivity is keyed off raw argv, not the parsed boolean value: `--local=false` - // was explicitly passed, so value-based detection would miss it and default to linked. - const target = resolveDbTargetFlags(cliArgs.args); - if (target.setFlags.length > 1) { - return yield* new InspectMutuallyExclusiveFlagsError({ - 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`, - }); - } - // 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(cliSettings.workdir); - // `--linked` is the default, so absence of the others resolves to linked. - const connType = target.connType ?? "linked"; - - if (Option.isSome(flags.projectRef) && connType !== "linked") { - return yield* new InspectMutuallyExclusiveFlagsError({ - message: - "--project-ref only applies when targeting the linked project; use it with --linked (not --local or --db-url)", - }); - } - - const cfg = yield* resolver.resolve({ - dbUrl: flags.dbUrl, - connType, + const target = yield* resolveTarget(flags); + const cfg = yield* resolver.resolveConnection({ + target, dnsResolver, - linkedProjectRef: flags.projectRef, + password: Option.none(), + networkBanRemoval: yield* hostedMutationPreapproval, }); // Resolved against the process CWD when relative, not `--workdir`. @@ -110,10 +90,6 @@ const runInspectReport = Effect.fnUntraced(function* ( ), ); - if (isText) { - yield* output.raw(`Connecting to ${cfg.isLocal ? "local" : "remote"} database...\n`, "stderr"); - } - const ignoreSchemas = reportIgnoreSchemas(); const dbLiteral = `'${cfg.conn.database}'`; const csvByFile = new Map<string, Uint8Array>(); 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 8a3c79b375..ae475c577b 100644 --- a/apps/cli/src/commands/inspect/report/report.integration.test.ts +++ b/apps/cli/src/commands/inspect/report/report.integration.test.ts @@ -11,6 +11,7 @@ import { Option, Path, PlatformError, + Redacted, } from "effect"; import { vi } from "vitest"; @@ -25,8 +26,11 @@ import { mockOutput, mockRuntimeInfo, mockTty } from "../../../../tests/helpers/ import { VALID_REF, VALID_TOKEN, + buildTestRuntime, + mockCommandPlatformApi, mockCommandSettings, mockTelemetryStateTracked, + unverifiedTargetBanner, useTempWorkdir, withEnvVar, } from "../../../../tests/helpers/command-mocks.ts"; @@ -38,14 +42,21 @@ import { stubManagementApi, type StubbedRequest, } from "../../../../tests/helpers/permission-real-layers.ts"; -import { CliArgs } from "../../../shared/cli/cli-args.service.ts"; import { configValuesLayer } from "../../../../tests/helpers/config-values-layer.ts"; -import { DnsResolverFlag } from "../../../command-internal/global-flags.ts"; +import { DnsResolverFlag, YesFlag } from "../../../command-internal/global-flags.ts"; import { DbConfigResolver } from "../../../command-internal/db-config.service.ts"; import { DbConfigLoadError } from "../../../command-internal/db-config.errors.ts"; -import type { DbConfigFlags, ResolvedDbConfig } from "../../../command-internal/db-config.types.ts"; +import type { + DbConnectionInput, + ResolvedDbConnection, +} from "../../../command-internal/db-config.types.ts"; import { DbConnectError, DbCopyError } from "../../../command-internal/db-connection.errors.ts"; import { DbConnection, type PgConnInput } from "../../../command-internal/db-connection.service.ts"; +import { + TargetProjectRefConflictError, + TargetSelectorConflictError, +} from "../../../command-internal/target/target.errors.ts"; +import { TargetPolicy } from "../../../command-internal/target/target-policy.ts"; import type { InspectReportFlags } from "./report.command.ts"; import { inspectReport } from "./report.handler.ts"; import { REPORT_QUERIES, reportIgnoreSchemas, wrapReportQuery } from "./report.queries.ts"; @@ -79,25 +90,26 @@ function tempDirLayer<S>( } function mockResolver(opts: { conn?: PgConnInput; isLocal?: boolean; fails?: boolean } = {}) { - let resolveInput: DbConfigFlags | undefined; + let connectionInput: DbConnectionInput | undefined; const layer = Layer.succeed(DbConfigResolver, { - resolve: (flags: DbConfigFlags) => { - resolveInput = flags; + resolve: () => Effect.die("resolve is not used by this test"), + resolveConnection: (input: DbConnectionInput) => { + connectionInput = input; if (opts.fails === true) { return Effect.fail(new DbConfigLoadError({ message: "cannot load config" })); } return Effect.succeed({ conn: opts.conn ?? LOCAL_CONN, isLocal: opts.isLocal ?? true, - } satisfies ResolvedDbConfig); + target: input.target, + } satisfies ResolvedDbConnection); }, - resolveConnection: () => Effect.die("resolveConnection is not used by this test"), resolvePoolerFallback: () => Effect.succeed(Option.none()), }); return { layer, - get resolveInput() { - return resolveInput; + get connectionInput() { + return connectionInput; }, }; } @@ -152,6 +164,8 @@ interface SetupOpts { stdoutIsTty?: boolean; cwd?: string; workdir?: string; + /** The ref `SUPABASE_PROJECT_ID` supplies to the real project-ref resolver. */ + projectId?: string; /** Raw CLI args slice, used to detect which flags were explicitly passed. */ cliArgs?: ReadonlyArray<string>; /** Runs the real `DbConfigResolver` against `workdir`. */ @@ -171,8 +185,29 @@ function setupReport(opts: SetupOpts = {}) { copyFails: opts.copyFails, }); const telemetry = mockTelemetryStateTracked(); + const projectId = opts.projectId === undefined ? Option.none() : Option.some(opts.projectId); + const cliSettings = ( + opts.workdir === undefined + ? tempDirLayer("supabase-report-workdir-", (workdir) => + mockCommandSettings({ workdir, projectId }), + ) + : mockCommandSettings({ workdir: opts.workdir, projectId }) + ).pipe(Layer.orDie); + const runtimeInfo = ( + opts.cwd === undefined + ? tempDirLayer("supabase-report-cwd-", (cwd) => mockRuntimeInfo({ cwd })) + : mockRuntimeInfo({ cwd: opts.cwd }) + ).pipe(Layer.orDie); const layer = Layer.mergeAll( - out.layer, + buildTestRuntime({ + out, + api: mockCommandPlatformApi(), + cliSettings, + tty: mockTty({ stdoutIsTty: opts.stdoutIsTty ?? false }), + runtimeInfo, + telemetry: telemetry.layer, + cliArgs: opts.cliArgs, + }), opts.realLayers !== true || opts.workdir === undefined ? resolver.layer : realDbConfigResolver({ @@ -181,26 +216,16 @@ function setupReport(opts: SetupOpts = {}) { env: { SUPABASE_ACCESS_TOKEN: VALID_TOKEN }, }), connection.layer, - telemetry.layer, Layer.succeed(DnsResolverFlag, "native"), - Layer.succeed(CliArgs, { args: opts.cliArgs ?? [] }), - opts.workdir === undefined - ? tempDirLayer("supabase-report-workdir-", (workdir) => mockCommandSettings({ workdir })) - : mockCommandSettings({ workdir: opts.workdir }), - opts.cwd === undefined - ? tempDirLayer("supabase-report-cwd-", (cwd) => mockRuntimeInfo({ cwd })) - : mockRuntimeInfo({ cwd: opts.cwd }), - mockTty({ stdoutIsTty: opts.stdoutIsTty ?? false }), - BunServices.layer, + Layer.succeed(YesFlag, false), + Layer.succeed(TargetPolicy, { group: "selectable", consent: "none" }), configValuesLayer(), - opts.realLayers !== true - ? Layer.empty - : ConfigProvider.layer( - ConfigProvider.fromEnvRecord( - { SUPABASE_ACCESS_TOKEN: VALID_TOKEN }, - { preserveEmptyStrings: true }, - ), - ), + ConfigProvider.layer( + ConfigProvider.fromEnvRecord( + opts.realLayers === true ? { SUPABASE_ACCESS_TOKEN: VALID_TOKEN } : {}, + { preserveEmptyStrings: true }, + ), + ), ); return { layer, out, resolver, connection, telemetry }; } @@ -245,7 +270,7 @@ const dateFolderContents = Effect.fnUntraced(function* (base: string) { }); describe("inspect report", () => { - it.effect("writes one CSV per inspect query for the linked project", () => { + it.effect("writes one CSV per inspect query", () => { const { layer, connection } = setupReport({ csvs: DEFAULT_RULE_CSVS }); const prevUmask = process.umask(0); return Effect.gen(function* () { @@ -270,131 +295,91 @@ describe("inspect report", () => { }); it.effect("inspects the local database with --local", () => { - const { layer, resolver } = setupReport({ - csvs: DEFAULT_RULE_CSVS, - cliArgs: ["--local"], - }); + const { layer, resolver } = setupReport({ csvs: DEFAULT_RULE_CSVS }); return Effect.gen(function* () { const base = yield* tempDir("supabase-report-out-"); yield* inspectReport(flags({ outputDir: base, local: true })); - expect(resolver.resolveInput?.connType).toBe("local"); + expect(resolver.connectionInput?.target).toEqual({ kind: "local" }); }).pipe(Effect.provide(layer)); }); - it.effect("inspects a custom database with --db-url and labels the diagnostic 'remote'", () => { - const { layer, resolver, out } = setupReport({ - csvs: DEFAULT_RULE_CSVS, - isLocal: false, - cliArgs: ["--db-url=postgres://x"], - }); + it.effect("inspects a custom database with --db-url", () => { + const { layer, resolver, out } = setupReport({ csvs: DEFAULT_RULE_CSVS, isLocal: false }); return Effect.gen(function* () { const base = yield* tempDir("supabase-report-out-"); yield* inspectReport(flags({ outputDir: base, dbUrl: Option.some("postgres://x") })); - expect(resolver.resolveInput?.dbUrl).toEqual(Option.some("postgres://x")); - expect(out.stderrText).toContain("Connecting to remote database..."); + const target = resolver.connectionInput?.target; + expect(target?.kind === "db-url" ? Redacted.value(target.url) : undefined).toBe( + "postgres://x", + ); + expect(out.stderrText).not.toContain("Connecting to"); }).pipe(Effect.provide(layer)); }); - it.effect("inspects the linked project by default when no connection flag is set", () => { - const { layer, resolver } = setupReport({ csvs: DEFAULT_RULE_CSVS }); + it.effect("inspects the local database by default when no selector is set", () => { + const { layer, resolver } = setupReport({ csvs: DEFAULT_RULE_CSVS, projectId: VALID_REF }); return Effect.gen(function* () { const base = yield* tempDir("supabase-report-out-"); yield* inspectReport(flags({ outputDir: base })); - expect(resolver.resolveInput?.connType).toBe("linked"); + expect(resolver.connectionInput?.target).toEqual({ kind: "local" }); }).pipe(Effect.provide(layer)); }); it.effect("rejects more than one of --db-url/--linked/--local", () => { - const { layer } = setupReport({ cliArgs: ["--linked", "--local"] }); + const { layer, resolver } = setupReport(); return Effect.gen(function* () { - const exit = yield* Effect.exit(inspectReport(flags({ linked: true, local: true }))); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - const causeText = Cause.pretty(exit.cause); - expect(causeText).toContain("are set none of the others can be"); - } + const error = yield* inspectReport(flags({ linked: true, local: true })).pipe(Effect.flip); + expect(error).toBeInstanceOf(TargetSelectorConflictError); + expect(resolver.connectionInput).toBeUndefined(); }).pipe(Effect.provide(layer)); }); - it.effect("--local=false is Changed and routes to local (not linked)", () => { - const { layer, resolver } = setupReport({ - csvs: DEFAULT_RULE_CSVS, - cliArgs: ["--local=false"], - }); - return Effect.gen(function* () { - const base = yield* tempDir("supabase-report-out-"); - yield* inspectReport(flags({ outputDir: base, local: false })); - expect(resolver.resolveInput?.connType).toBe("local"); - }).pipe(Effect.provide(layer)); - }); - - it.effect("--linked --local=false raises the mutual-exclusion error", () => { - const { layer } = setupReport({ cliArgs: ["--linked", "--local=false"] }); - return Effect.gen(function* () { - const exit = yield* Effect.exit( - inspectReport(flags({ linked: true, local: false, outputDir: "." })), - ); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - const causeText = Cause.pretty(exit.cause); - expect(causeText).toContain("are set none of the others can be"); - expect(causeText).toContain("[linked local]"); - } - }).pipe(Effect.provide(layer)); - }); - - it.effect("--linked routes to linked", () => { - const { layer, resolver } = setupReport({ + it.effect("--linked routes to the linked project and announces it", () => { + const { layer, resolver, out } = setupReport({ csvs: DEFAULT_RULE_CSVS, - cliArgs: ["--linked"], + projectId: VALID_REF, }); return Effect.gen(function* () { const base = yield* tempDir("supabase-report-out-"); yield* inspectReport(flags({ outputDir: base, linked: true })); - expect(resolver.resolveInput?.connType).toBe("linked"); + expect(resolver.connectionInput?.target).toMatchObject({ kind: "linked", ref: VALID_REF }); + expect(out.stderrText).toContain(unverifiedTargetBanner()); }).pipe(Effect.provide(layer)); }); - it.effect("reports on the project given via --project-ref on the default linked path", () => { + it.effect("--project-ref implies the linked project", () => { const FLAG_REF = "flagflagflagflagflag"; const { layer, resolver } = setupReport({ csvs: DEFAULT_RULE_CSVS }); return Effect.gen(function* () { const base = yield* tempDir("supabase-report-out-"); yield* inspectReport(flags({ outputDir: base, projectRef: Option.some(FLAG_REF) })); - expect(resolver.resolveInput?.connType).toBe("linked"); - expect(resolver.resolveInput?.linkedProjectRef).toEqual(Option.some(FLAG_REF)); + expect(resolver.connectionInput?.target).toMatchObject({ + kind: "linked", + ref: FLAG_REF, + source: "flag", + }); }).pipe(Effect.provide(layer)); }); - it.effect("rejects --project-ref combined with an explicit --local target", () => { - const FLAG_REF = "flagflagflagflagflag"; - const { layer, resolver } = setupReport({ - csvs: DEFAULT_RULE_CSVS, - cliArgs: ["--local"], - }); + it.effect("rejects --project-ref combined with --local", () => { + const { layer, resolver } = setupReport({ csvs: DEFAULT_RULE_CSVS }); return Effect.gen(function* () { - const exit = yield* Effect.exit( - inspectReport(flags({ local: true, projectRef: Option.some(FLAG_REF) })), - ); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - const causeText = Cause.pretty(exit.cause); - expect(causeText).toContain( - "--project-ref only applies when targeting the linked project; use it with --linked (not --local or --db-url)", - ); - } - expect(resolver.resolveInput).toBeUndefined(); + const error = yield* inspectReport( + flags({ local: true, projectRef: Option.some("flagflagflagflagflag") }), + ).pipe(Effect.flip); + expect(error).toBeInstanceOf(TargetProjectRefConflictError); + expect(resolver.connectionInput).toBeUndefined(); }).pipe(Effect.provide(layer)); }); it.effect( - "prints connect + running + saved progress to stderr and the rules table to stdout", + "prints running + saved progress to stderr and the rules table to stdout", () => { const { layer, out } = setupReport({ csvs: DEFAULT_RULE_CSVS, isLocal: true }); return Effect.gen(function* () { const base = yield* tempDir("supabase-report-out-"); yield* inspectReport(flags({ outputDir: base })); - expect(out.stderrText).toContain("Connecting to local database..."); + expect(out.stderrText).not.toContain("Connecting to"); expect(out.stderrText).toContain("Running queries..."); expect(out.stderrText).toContain("Reports saved to "); expect(out.stderrText).toContain("Loading default rules..."); From 1eeb62ad96b3f97465efa30b4ef9cde10971820b Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Thu, 15 Oct 2026 14:25:52 +0100 Subject: [PATCH 28/92] test(cli): update db lint and advisors tests for resolved targets --- .../db/advisors/advisors.integration.test.ts | 239 +++++++----------- .../commands/db/lint/lint.integration.test.ts | 210 ++++++--------- 2 files changed, 177 insertions(+), 272 deletions(-) diff --git a/apps/cli/src/commands/db/advisors/advisors.integration.test.ts b/apps/cli/src/commands/db/advisors/advisors.integration.test.ts index 3b2ae46fe3..aca4f1b985 100644 --- a/apps/cli/src/commands/db/advisors/advisors.integration.test.ts +++ b/apps/cli/src/commands/db/advisors/advisors.integration.test.ts @@ -10,6 +10,7 @@ vi.mock("node:net", (importOriginal) => ]).then(([helper, net]) => helper.refuseInvalidDbHosts(net)), ); +import { cliConfigValuesTestLayer } from "../../../../tests/helpers/config-values-layer.ts"; import { mockOutput, mockProcessControl } from "../../../../tests/helpers/mocks.ts"; import { VALID_REF, @@ -21,6 +22,7 @@ import { mockTelemetryStateTracked, sequentialExecBatch, useTempWorkdir, + withConfigEnv, } from "../../../../tests/helpers/command-mocks.ts"; import { assertPermissionDrift } from "../../../../tests/helpers/permission-drift.ts"; import { @@ -35,14 +37,22 @@ import { import * as HttpClientResponse from "effect/http/HttpClientResponse"; import { CliArgs } from "../../../shared/cli/cli-args.service.ts"; -import { DnsResolverFlag } from "../../../command-internal/global-flags.ts"; +import { DnsResolverFlag, YesFlag } from "../../../command-internal/global-flags.ts"; import { CommandCredentials } from "../../../auth/command-credentials.service.ts"; import { InvalidAccessTokenError } from "../../../auth/errors.ts"; import { ProjectRefResolver } from "../../../config/project-ref.service.ts"; import { DbConfigIpv6Error } from "../../../command-internal/db-config.errors.ts"; import { DbConfigResolver } from "../../../command-internal/db-config.service.ts"; import { IdentityStitch } from "../../../command-internal/identity-stitch.ts"; -import type { DbConfigFlags, ResolvedDbConfig } from "../../../command-internal/db-config.types.ts"; +import type { + DbConnectionInput, + ResolvedDbConnection, +} from "../../../command-internal/db-config.types.ts"; +import { TargetPolicy } from "../../../command-internal/target/target-policy.ts"; +import { + TargetProjectRefConflictError, + TargetSelectorConflictError, +} from "../../../command-internal/target/target.errors.ts"; import { DbExecError } from "../../../command-internal/db-connection.errors.ts"; import { DbConnection, @@ -96,11 +106,12 @@ function lintRow(over: Partial<Record<string, unknown>> = {}) { } function mockResolver(opts: { ipv6Error?: boolean } = {}) { - const resolveFlags: Array<DbConfigFlags> = []; + const resolveInputs: Array<DbConnectionInput> = []; const layer = Layer.succeed(DbConfigResolver, { - resolve: (flags: DbConfigFlags) => + resolve: () => Effect.die("resolve is not used by this test"), + resolveConnection: (input: DbConnectionInput) => Effect.gen(function* () { - resolveFlags.push(flags); + resolveInputs.push(input); if (opts.ipv6Error === true) { return yield* new DbConfigIpv6Error({ message: "IPv6 is not supported on your current network", @@ -109,16 +120,17 @@ function mockResolver(opts: { ipv6Error?: boolean } = {}) { } return { conn: LOCAL_CONN, - isLocal: flags.connType !== "linked", - } satisfies ResolvedDbConfig; + isLocal: input.target.kind !== "linked", + ref: input.target.kind === "linked" ? Option.some(input.target.ref) : Option.none(), + target: input.target, + } satisfies ResolvedDbConnection; }), - resolveConnection: () => Effect.die("resolveConnection is not used by this test"), resolvePoolerFallback: () => Effect.succeed(Option.none()), }); return { layer, - get resolveFlags() { - return resolveFlags; + get resolveInputs() { + return resolveInputs; }, }; } @@ -168,21 +180,21 @@ function mockConnection(opts: { function mockProjectRef() { const calls: Array<string> = []; const layer = Layer.succeed(ProjectRefResolver, { - resolve: () => - Effect.sync(() => { - calls.push("resolve"); - return VALID_REF; - }), + resolve: () => Effect.succeed(VALID_REF), resolveForLink: () => Effect.succeed(VALID_REF), - resolveRef: () => Effect.succeed({ ref: VALID_REF, source: "file", matchesLinkedRef: true }), - resolveOptional: () => Effect.succeed(Option.some(VALID_REF)), - // Gives an explicit `--project-ref` flag top precedence, mirroring the real resolver so a - // test can prove the flag (not just the hardcoded fallback) drives the linked ref. - loadProjectRef: (flagValue: Option.Option<string>) => + // An explicit `--project-ref` wins over the workdir's own ref, so a test can prove the flag + // (not just the fallback) drives the linked ref. + resolveRef: (flagValue: Option.Option<string>) => Effect.sync(() => { - calls.push("loadProjectRef"); - return Option.isSome(flagValue) && flagValue.value.length > 0 ? flagValue.value : VALID_REF; + calls.push("resolveRef"); + return { + ref: Option.getOrElse(flagValue, () => VALID_REF), + source: Option.isSome(flagValue) ? ("flag" as const) : ("file" as const), + matchesLinkedRef: Option.isNone(flagValue), + }; }), + resolveOptional: () => Effect.succeed(Option.some(VALID_REF)), + loadProjectRef: () => Effect.die("loadProjectRef is not used by this test"), promptProjectRef: () => Effect.succeed(VALID_REF), }); return { @@ -250,12 +262,12 @@ interface SetupOpts { securityJsonBody?: string; securityLints?: ReadonlyArray<Record<string, unknown>>; performanceLints?: ReadonlyArray<Record<string, unknown>>; - /** Raw CLI args for `CliArgs` — drives DB target selection (Changed-based). */ - args?: ReadonlyArray<string>; /** Runs the real `DbConfigResolver` and linked-project cache against the temp `workdir`. */ - realLayers?: { readonly workdir: string }; + realLayers?: boolean; } +const tempRoot = useTempWorkdir("supabase-db-advisors-int-"); + function setup(opts: SetupOpts = {}) { const out = mockOutput({ format: opts.format ?? "text" }); const resolver = mockResolver({ ipv6Error: opts.ipv6Error }); @@ -320,7 +332,7 @@ function setup(opts: SetupOpts = {}) { }); const cliSettings = mockCommandSettings({ - workdir: opts.realLayers?.workdir ?? "/tmp/advisors-int", + workdir: tempRoot.current, accessToken: opts.loggedIn === false ? Option.none() : undefined, }); const credentials = mockCredentials({ @@ -330,10 +342,10 @@ function setup(opts: SetupOpts = {}) { const layer = Layer.mergeAll( out.layer, - opts.realLayers === undefined + opts.realLayers !== true ? resolver.layer : realDbConfigResolver({ - workdir: opts.realLayers.workdir, + workdir: tempRoot.current, dbConnection: connection.layer, env: { SUPABASE_ACCESS_TOKEN: VALID_TOKEN }, }), @@ -341,15 +353,18 @@ function setup(opts: SetupOpts = {}) { telemetry.layer, processControl.layer, projectRef.layer, - opts.realLayers === undefined + opts.realLayers !== true ? cache.layer : realLinkedProjectCache({ httpClient: api.httpClientLayer, cliSettings }), cliSettings, credentials.layer, identityStitch.layer, api.httpClientLayer, + cliConfigValuesTestLayer, + Layer.succeed(TargetPolicy, { group: "selectable", consent: "none" }), + Layer.succeed(YesFlag, false), Layer.succeed(DnsResolverFlag, "native"), - Layer.succeed(CliArgs, { args: opts.args ?? [] }), + Layer.succeed(CliArgs, { args: [] }), ); return { layer, @@ -387,7 +402,6 @@ describe("db advisors — local", () => { }); const expected = encodeAdvisorLints([scanAdvisorLintRow(lintRow())]); expect(out.stdoutText).toBe(expected); - expect(out.stderrText).toContain("Connecting to local database..."); expect(connection.execs).toEqual(["begin", SETUP_SQL, "rollback"]); }).pipe(Effect.provide(layer)); }); @@ -462,17 +476,14 @@ describe("db advisors — local", () => { }).pipe(Effect.provide(layer)); }); - it.effect("rejects --db-url together with --local (via args Changed detection)", () => { - const { layer } = setup({ args: ["--db-url=postgres://x", "--local"] }); + it.effect("rejects --db-url together with --local", () => { + const { layer, connection } = setup(); return Effect.gen(function* () { - const exit = yield* Effect.exit(dbAdvisors(flags({ dbUrl: Option.some("postgres://x") }))); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - const causeText = Cause.pretty(exit.cause); - expect(causeText).toContain( - "if any flags in the group [db-url linked local] are set none of the others can be", - ); - } + const error = yield* dbAdvisors( + flags({ dbUrl: Option.some("postgres://x"), local: true }), + ).pipe(Effect.flip); + expect(error).toBeInstanceOf(TargetSelectorConflictError); + expect(connection.execs).toEqual([]); }).pipe(Effect.provide(layer)); }); @@ -509,65 +520,28 @@ describe("db advisors — local", () => { }).pipe(Effect.provide(layer)); }); - it.effect("--linked=false routes to the linked branch (Changed, not value)", () => { - // "Changed" fires when the flag appears on the command line regardless of its value. - const { layer, projectRef, cache } = setup({ - args: ["--linked=false"], - securityLints: [], - performanceLints: [], - }); + it.effect("treats --linked=false as absent and queries the local database", () => { + const { layer, resolver, cache } = setup({ rows: [] }); return Effect.gen(function* () { - yield* dbAdvisors(flags()); - expect(projectRef.calls).toContain("loadProjectRef"); - expect(cache.cached).toBe(true); + yield* dbAdvisors(flags({ linked: false })); + expect(resolver.resolveInputs.map((input) => input.target.kind)).toEqual(["local"]); + expect(cache.cached).toBe(false); }).pipe(Effect.provide(layer)); }); - it.effect("--no-linked routes to the linked branch (boolean negation is still Changed)", () => { - const { layer, projectRef, cache } = setup({ - args: ["--no-linked"], - securityLints: [], - performanceLints: [], - }); + it.effect("follows a remote workspace target when no selector flag is given", () => { + const { layer, resolver, cache } = setup({ securityLints: [], performanceLints: [] }); return Effect.gen(function* () { - yield* dbAdvisors(flags()); - expect(projectRef.calls).toContain("loadProjectRef"); + yield* withConfigEnv({ SUPABASE_TARGET: "remote" }, dbAdvisors(flags())); + expect(resolver.resolveInputs.map((input) => input.target.kind)).toEqual(["linked"]); expect(cache.cached).toBe(true); }).pipe(Effect.provide(layer)); }); - - it.effect( - "--local=false --linked fails with mutual-exclusion (sorted set [linked local])", - () => { - const { layer } = setup({ args: ["--local=false", "--linked"] }); - return Effect.gen(function* () { - const exit = yield* Effect.exit(dbAdvisors(flags())); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - const causeText = Cause.pretty(exit.cause); - expect(causeText).toContain( - "if any flags in the group [db-url linked local] are set none of the others can be; [linked local] were all set", - ); - } - }).pipe(Effect.provide(layer)); - }, - ); - - it.effect( - "--local=false alone routes to the local branch (Changed local, connType=local)", - () => { - const { layer, out, cache } = setup({ rows: [], args: ["--local=false"] }); - return Effect.gen(function* () { - yield* dbAdvisors(flags()); - expect(out.stderrText).toContain("Connecting to local database..."); - expect(cache.cached).toBe(false); - }).pipe(Effect.provide(layer)); - }, - ); }); +const linkedFlags = (over: Partial<DbAdvisorsFlags> = {}) => flags({ linked: true, ...over }); + describe("db advisors — linked", () => { - const tempRoot = useTempWorkdir("supabase-db-advisors-int-"); const securityLint = { name: "rls_disabled_in_public", title: "RLS disabled", @@ -591,14 +565,13 @@ describe("db advisors — linked", () => { const { layer, api } = setup({ securityLints: [securityLint], performanceLints: [performanceLint], - args: ["db", "advisors", "--linked", "--project-ref", VALID_REF], - realLayers: { workdir: tempRoot.current }, + realLayers: true, }); const resolverRequests: Array<StubbedRequest> = []; return Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; yield* stubManagementApi(resolverRequests, serveLinkedDatabase(VALID_REF)); - yield* dbAdvisors(flags({ linked: true, projectRef: Option.some(VALID_REF) })); + yield* dbAdvisors(linkedFlags({ projectRef: Option.some(VALID_REF) })); expect(resolverRequests.map(({ method, path }) => `${method} ${path}`)).toEqual([ `GET /v1/projects/${VALID_REF}/config/database/pooler`, `POST /v1/projects/${VALID_REF}/cli/login-role`, @@ -628,8 +601,7 @@ describe("db advisors — linked", () => { it.effect("makes no Management API request for --local through the real layers", () => { const { layer, api } = setup({ rows: [], - args: ["db", "advisors", "--local"], - realLayers: { workdir: tempRoot.current }, + realLayers: true, }); const resolverRequests: Array<StubbedRequest> = []; return Effect.gen(function* () { @@ -657,9 +629,9 @@ describe("db advisors — linked", () => { it.effect( "fails on a 403 from the security advisors without asking for the performance ones", () => { - const { layer, api } = setup({ securityStatus: 403, args: ["--linked"] }); + const { layer, api } = setup({ securityStatus: 403 }); return Effect.gen(function* () { - const exit = yield* Effect.exit(dbAdvisors(flags({ type: Option.some("all") }))); + const exit = yield* Effect.exit(dbAdvisors(linkedFlags({ type: Option.some("all") }))); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { expect(Cause.pretty(exit.cause)).toContain("unexpected security advisors status 403"); @@ -680,10 +652,9 @@ describe("db advisors — linked", () => { const { layer, out, api, cache } = setup({ securityLints: [securityLint], performanceLints: [performanceLint], - args: ["--linked"], }); return Effect.gen(function* () { - yield* dbAdvisors(flags({ type: Option.some("all"), level: Option.some("info") })); + yield* dbAdvisors(linkedFlags({ type: Option.some("all"), level: Option.some("info") })); const urls = api.requests.map((r) => r.url); expect(urls.some((u) => u.includes("/advisors/security"))).toBe(true); expect(urls.some((u) => u.includes("/advisors/performance"))).toBe(true); @@ -707,7 +678,6 @@ describe("db advisors — linked", () => { const FLAG_REF = "flagflagflagflagflag"; const { layer, api, cache } = setup({ securityLints: [securityLint], - args: ["--linked"], }); return Effect.gen(function* () { yield* dbAdvisors( @@ -723,19 +693,14 @@ describe("db advisors — linked", () => { }, ); - it.effect("rejects --project-ref on the default local target", () => { - // The guard fires from the flag alone; no explicit --local/--db-url is needed. + it.effect("rejects --project-ref together with --local", () => { const FLAG_REF = "flagflagflagflagflag"; const { layer, connection, api, cache } = setup({ rows: [] }); return Effect.gen(function* () { - const exit = yield* Effect.exit(dbAdvisors(flags({ projectRef: Option.some(FLAG_REF) }))); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - const causeText = Cause.pretty(exit.cause); - expect(causeText).toContain( - "--project-ref only applies when targeting the linked project; use it with --linked (not --local or --db-url)", - ); - } + const error = yield* dbAdvisors( + flags({ local: true, projectRef: Option.some(FLAG_REF) }), + ).pipe(Effect.flip); + expect(error).toBeInstanceOf(TargetProjectRefConflictError); expect(connection.execs).toEqual([]); expect(api.requests).toEqual([]); expect(cache.cached).toBe(false); @@ -746,11 +711,10 @@ describe("db advisors — linked", () => { // Resolved even though the linked lint-gathering path discards the connection. const { layer, resolver, api } = setup({ securityLints: [securityLint], - args: ["--linked"], }); return Effect.gen(function* () { - yield* dbAdvisors(flags({ type: Option.some("security") })); - expect(resolver.resolveFlags.some((f) => f.connType === "linked")).toBe(true); + yield* dbAdvisors(linkedFlags({ type: Option.some("security") })); + expect(resolver.resolveInputs.some((input) => input.target.kind === "linked")).toBe(true); expect(api.requests.some((r) => r.url.includes("/advisors/security"))).toBe(true); }).pipe(Effect.provide(layer)); }); @@ -759,9 +723,9 @@ describe("db advisors — linked", () => { // The DB-config resolve fails before the linked lint-gathering path runs, so the advisors // API is never reached — but the ref was already loaded and cached unconditionally on the // error path. - const { layer, api, cache } = setup({ ipv6Error: true, args: ["--linked"] }); + const { layer, api, cache } = setup({ ipv6Error: true }); return Effect.gen(function* () { - const exit = yield* Effect.exit(dbAdvisors(flags())); + const exit = yield* Effect.exit(dbAdvisors(linkedFlags())); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { const causeText = Cause.pretty(exit.cause); @@ -778,31 +742,25 @@ describe("db advisors — linked", () => { const { layer, identityStitch } = setup({ securityLints: [securityLint], performanceLints: [performanceLint], - args: ["--linked"], }); return Effect.gen(function* () { - yield* dbAdvisors(flags({ level: Option.some("info") })); + yield* dbAdvisors(linkedFlags({ level: Option.some("info") })); expect(identityStitch.calls).toBe(2); }).pipe(Effect.provide(layer)); }); - it.effect("resolves the linked ref via the non-prompting load", () => { - // `resolve` opens an interactive project picker on a TTY; `--linked` must avoid it. - const { layer, projectRef } = setup({ - securityLints: [securityLint], - args: ["--linked"], - }); + it.effect("resolves the linked ref through the shared target chain", () => { + const { layer, projectRef } = setup({ securityLints: [securityLint] }); return Effect.gen(function* () { - yield* dbAdvisors(flags({ type: Option.some("security") })); - expect(projectRef.calls).toContain("loadProjectRef"); - expect(projectRef.calls).not.toContain("resolve"); + yield* dbAdvisors(linkedFlags({ type: Option.some("security") })); + expect(projectRef.calls).toEqual(["resolveRef"]); }).pipe(Effect.provide(layer)); }); it.effect("fetches only the security endpoint for --type security", () => { - const { layer, api } = setup({ securityLints: [securityLint], args: ["--linked"] }); + const { layer, api } = setup({ securityLints: [securityLint] }); return Effect.gen(function* () { - yield* dbAdvisors(flags({ type: Option.some("security") })); + yield* dbAdvisors(linkedFlags({ type: Option.some("security") })); const urls = api.requests.map((r) => r.url); expect(urls.some((u) => u.includes("/advisors/security"))).toBe(true); expect(urls.some((u) => u.includes("/advisors/performance"))).toBe(false); @@ -816,9 +774,9 @@ describe("db advisors — linked", () => { }); it.effect("fetches only the performance endpoint for --type performance", () => { - const { layer, api } = setup({ performanceLints: [performanceLint], args: ["--linked"] }); + const { layer, api } = setup({ performanceLints: [performanceLint] }); return Effect.gen(function* () { - yield* dbAdvisors(flags({ type: Option.some("performance") })); + yield* dbAdvisors(linkedFlags({ type: Option.some("performance") })); const urls = api.requests.map((r) => r.url); expect(urls.some((u) => u.includes("/advisors/performance"))).toBe(true); expect(urls.some((u) => u.includes("/advisors/security"))).toBe(false); @@ -832,9 +790,9 @@ describe("db advisors — linked", () => { }); it.effect("fails with a login suggestion when no access token is available", () => { - const { layer } = setup({ loggedIn: false, args: ["--linked"] }); + const { layer } = setup({ loggedIn: false }); return Effect.gen(function* () { - const error = yield* Effect.flip(dbAdvisors(flags())); + const error = yield* Effect.flip(dbAdvisors(linkedFlags())); expect(error).toBeInstanceOf(DbAdvisorsNotLoggedInError); expect(error).toMatchObject({ message: expect.stringContaining("Access token not provided"), @@ -844,9 +802,9 @@ describe("db advisors — linked", () => { }); it.effect("fails with the invalid-token message before any API call", () => { - const { layer, api } = setup({ invalidToken: true, args: ["--linked"] }); + const { layer, api } = setup({ invalidToken: true }); return Effect.gen(function* () { - const error = yield* Effect.flip(dbAdvisors(flags())); + const error = yield* Effect.flip(dbAdvisors(linkedFlags())); expect(error).toBeInstanceOf(DbAdvisorsInvalidTokenError); expect(error).toMatchObject({ message: expect.stringContaining("Invalid access token format"), @@ -860,9 +818,9 @@ describe("db advisors — linked", () => { it.effect("fails on a 200 with a non-JSON content type (a JSON content type is required)", () => { // The body is only decoded when Content-Type contains "json"; otherwise this fails as a // status-200 error. - const { layer } = setup({ securityNonJson: true, args: ["--linked"] }); + const { layer } = setup({ securityNonJson: true }); return Effect.gen(function* () { - const exit = yield* Effect.exit(dbAdvisors(flags({ type: Option.some("security") }))); + const exit = yield* Effect.exit(dbAdvisors(linkedFlags({ type: Option.some("security") }))); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { const causeText = Cause.pretty(exit.cause); @@ -872,9 +830,9 @@ describe("db advisors — linked", () => { }); it.effect("fails with the JSON parse error when a JSON advisors body is malformed", () => { - const { layer } = setup({ securityJsonBody: "{ not json", args: ["--linked"] }); + const { layer } = setup({ securityJsonBody: "{ not json" }); return Effect.gen(function* () { - const exit = yield* Effect.exit(dbAdvisors(flags({ type: Option.some("security") }))); + const exit = yield* Effect.exit(dbAdvisors(linkedFlags({ type: Option.some("security") }))); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { expect(Option.getOrUndefined(Cause.findErrorOption(exit.cause))).toMatchObject({ @@ -886,9 +844,9 @@ describe("db advisors — linked", () => { }); it.effect("fails when the advisors API returns a non-200 status", () => { - const { layer } = setup({ securityStatus: 500, args: ["--linked"] }); + const { layer } = setup({ securityStatus: 500 }); return Effect.gen(function* () { - const exit = yield* Effect.exit(dbAdvisors(flags({ type: Option.some("security") }))); + const exit = yield* Effect.exit(dbAdvisors(linkedFlags({ type: Option.some("security") }))); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { const causeText = Cause.pretty(exit.cause); @@ -901,10 +859,9 @@ describe("db advisors — linked", () => { const { layer, out } = setup({ format: "stream-json", securityLints: [securityLint], - args: ["--linked"], }); return Effect.gen(function* () { - yield* dbAdvisors(flags({ type: Option.some("security") })); + yield* dbAdvisors(linkedFlags({ type: Option.some("security") })); expect(out.messages).toContainEqual( expect.objectContaining({ type: "success", message: "db advisors" }), ); diff --git a/apps/cli/src/commands/db/lint/lint.integration.test.ts b/apps/cli/src/commands/db/lint/lint.integration.test.ts index 84f8e58a98..fa952e79f9 100644 --- a/apps/cli/src/commands/db/lint/lint.integration.test.ts +++ b/apps/cli/src/commands/db/lint/lint.integration.test.ts @@ -10,6 +10,7 @@ vi.mock("node:net", (importOriginal) => ]).then(([helper, net]) => helper.refuseInvalidDbHosts(net)), ); +import { cliConfigValuesTestLayer } from "../../../../tests/helpers/config-values-layer.ts"; import { mockOutput, mockProcessControl } from "../../../../tests/helpers/mocks.ts"; import { VALID_REF, @@ -19,6 +20,7 @@ import { mockTelemetryStateTracked, sequentialExecBatch, useTempWorkdir, + withConfigEnv, } from "../../../../tests/helpers/command-mocks.ts"; import { assertPermissionDrift } from "../../../../tests/helpers/permission-drift.ts"; import { @@ -32,10 +34,18 @@ import { type StubbedRequest, } from "../../../../tests/helpers/permission-real-layers.ts"; import { CliArgs } from "../../../shared/cli/cli-args.service.ts"; -import { DnsResolverFlag } from "../../../command-internal/global-flags.ts"; +import { DnsResolverFlag, YesFlag } from "../../../command-internal/global-flags.ts"; import { ProjectRefResolver } from "../../../config/project-ref.service.ts"; import { DbConfigResolver } from "../../../command-internal/db-config.service.ts"; -import type { DbConfigFlags, ResolvedDbConfig } from "../../../command-internal/db-config.types.ts"; +import type { + DbConnectionInput, + ResolvedDbConnection, +} from "../../../command-internal/db-config.types.ts"; +import { TargetPolicy } from "../../../command-internal/target/target-policy.ts"; +import { + TargetProjectRefConflictError, + TargetSelectorConflictError, +} from "../../../command-internal/target/target.errors.ts"; import { DbExecError } from "../../../command-internal/db-connection.errors.ts"; import { DbConnection, @@ -68,16 +78,18 @@ function checkRow(proname: string, issues: ReadonlyArray<Record<string, unknown> } function mockResolver(opts: { isLocal?: boolean } = {}) { - let resolveInput: DbConfigFlags | undefined; + let resolveInput: DbConnectionInput | undefined; const layer = Layer.succeed(DbConfigResolver, { - resolve: (flags: DbConfigFlags) => { - resolveInput = flags; + resolve: () => Effect.die("resolve is not used by this test"), + resolveConnection: (input: DbConnectionInput) => { + resolveInput = input; return Effect.succeed({ conn: LOCAL_CONN, - isLocal: opts.isLocal ?? true, - } satisfies ResolvedDbConfig); + isLocal: opts.isLocal ?? input.target.kind === "local", + ref: input.target.kind === "linked" ? Option.some(input.target.ref) : Option.none(), + target: input.target, + } satisfies ResolvedDbConnection); }, - resolveConnection: () => Effect.die("resolveConnection is not used by this test"), resolvePoolerFallback: () => Effect.succeed(Option.none()), }); return { @@ -88,6 +100,8 @@ function mockResolver(opts: { isLocal?: boolean } = {}) { }; } +const tempRoot = useTempWorkdir("supabase-db-lint-int-"); + function mockConnection(opts: { schemas?: ReadonlyArray<string>; checkRows?: Record<string, ReadonlyArray<Record<string, unknown>>>; @@ -161,23 +175,23 @@ function mockConnection(opts: { }; } -/** Project-ref resolver mock. `db lint --linked` resolves the ref via the - * non-prompting `loadProjectRef` to write the linked-project cache; the - * other methods are unused by lint. */ +/** Project-ref resolver mock; an explicit `--project-ref` wins over the workdir's own ref. */ function mockProjectRef() { const calls: Array<string> = []; const layer = Layer.succeed(ProjectRefResolver, { resolve: () => Effect.succeed(VALID_REF), resolveForLink: () => Effect.succeed(VALID_REF), - resolveRef: () => Effect.succeed({ ref: VALID_REF, source: "file", matchesLinkedRef: true }), - resolveOptional: () => Effect.succeed(Option.some(VALID_REF)), - // An explicit `--project-ref` flag takes top precedence, mirroring the real - // resolver. - loadProjectRef: (flagValue: Option.Option<string>) => + resolveRef: (flagValue: Option.Option<string>) => Effect.sync(() => { - calls.push("loadProjectRef"); - return Option.isSome(flagValue) && flagValue.value.length > 0 ? flagValue.value : VALID_REF; + calls.push("resolveRef"); + return { + ref: Option.getOrElse(flagValue, () => VALID_REF), + source: Option.isSome(flagValue) ? ("flag" as const) : ("file" as const), + matchesLinkedRef: Option.isNone(flagValue), + }; }), + resolveOptional: () => Effect.succeed(Option.some(VALID_REF)), + loadProjectRef: () => Effect.die("loadProjectRef is not used by this test"), promptProjectRef: () => Effect.succeed(VALID_REF), }); return { @@ -197,10 +211,8 @@ interface SetupOpts { enableFails?: boolean; queryFails?: boolean; listFails?: boolean; - /** Raw CLI args for `CliArgs` — drives DB target selection (Changed-based). */ - args?: ReadonlyArray<string>; /** Runs the real `DbConfigResolver` and linked-project cache against the temp `workdir`. */ - realLayers?: { readonly workdir: string }; + realLayers?: boolean; } function setup(opts: SetupOpts = {}) { @@ -221,10 +233,10 @@ function setup(opts: SetupOpts = {}) { const http = recordingProjectHttpClient(VALID_REF); const layer = Layer.mergeAll( out.layer, - opts.realLayers === undefined + opts.realLayers !== true ? resolver.layer : realDbConfigResolver({ - workdir: opts.realLayers.workdir, + workdir: tempRoot.current, dbConnection: connection.layer, env: { SUPABASE_ACCESS_TOKEN: VALID_TOKEN }, }), @@ -232,14 +244,18 @@ function setup(opts: SetupOpts = {}) { telemetry.layer, processControl.layer, projectRef.layer, - opts.realLayers === undefined + opts.realLayers !== true ? cache.layer : realLinkedProjectCache({ httpClient: http.layer, - cliSettings: mockCommandSettings({ workdir: opts.realLayers.workdir }), + cliSettings: mockCommandSettings({ workdir: tempRoot.current }), }), + mockCommandSettings({ workdir: tempRoot.current }), + cliConfigValuesTestLayer, + Layer.succeed(TargetPolicy, { group: "selectable", consent: "none" }), + Layer.succeed(YesFlag, false), + Layer.succeed(CliArgs, { args: [] }), Layer.succeed(DnsResolverFlag, "native"), - Layer.succeed(CliArgs, { args: opts.args ?? [] }), ); return { layer, out, resolver, connection, telemetry, processControl, projectRef, cache, http }; } @@ -255,8 +271,6 @@ const flags = (over: Partial<DbLintFlags> = {}): DbLintFlags => ({ }); describe("db lint", () => { - const tempRoot = useTempWorkdir("supabase-db-lint-int-"); - it.effect("lints the named schema and prints parsed issues to stdout", () => { const { layer, out, connection } = setup({ schemas: [], @@ -273,7 +287,6 @@ describe("db lint", () => { ), ]); expect(out.stdoutText).toBe(expected); - expect(out.stderrText).toContain("Connecting to local database..."); expect(out.stderrText).toContain("Linting schema: public"); expect(connection.execs).toEqual(["begin", ENABLE_PGSQL_CHECK, "rollback"]); }).pipe(Effect.provide(layer)); @@ -416,17 +429,14 @@ describe("db lint", () => { }).pipe(Effect.provide(layer)); }); - it.effect("rejects --db-url together with --linked (via args Changed detection)", () => { - const { layer } = setup({ args: ["--db-url=postgres://x", "--linked"] }); + it.effect("rejects --db-url together with --linked", () => { + const { layer, connection } = setup(); return Effect.gen(function* () { - const exit = yield* Effect.exit(dbLint(flags({ dbUrl: Option.some("postgres://x") }))); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - const causeText = Cause.pretty(exit.cause); - expect(causeText).toContain( - "if any flags in the group [db-url linked local] are set none of the others can be", - ); - } + const error = yield* dbLint(flags({ dbUrl: Option.some("postgres://x"), linked: true })).pipe( + Effect.flip, + ); + expect(error).toBeInstanceOf(TargetSelectorConflictError); + expect(connection.execs).toEqual([]); }).pipe(Effect.provide(layer)); }); @@ -485,15 +495,11 @@ describe("db lint", () => { }).pipe(Effect.provide(layer)); }); - it.effect("labels the diagnostic 'remote' for a non-local connection", () => { - const { layer, out } = setup({ - isLocal: false, - checkRows: { public: [] }, - args: ["--db-url=postgres://x"], - }); + it.effect("connects to the --db-url target", () => { + const { layer, resolver } = setup({ isLocal: false, checkRows: { public: [] } }); return Effect.gen(function* () { yield* dbLint(flags({ schema: ["public"], dbUrl: Option.some("postgres://x") })); - expect(out.stderrText).toContain("Connecting to remote database..."); + expect(resolver.resolveInput?.target.kind).toBe("db-url"); }).pipe(Effect.provide(layer)); }); @@ -510,14 +516,10 @@ describe("db lint", () => { }); it.effect("writes the linked-project cache for --linked", () => { - const { layer, projectRef, cache } = setup({ - isLocal: false, - checkRows: { public: [] }, - args: ["--linked"], - }); + const { layer, projectRef, cache } = setup({ isLocal: false, checkRows: { public: [] } }); return Effect.gen(function* () { - yield* dbLint(flags({ schema: ["public"] })); - expect(projectRef.calls).toContain("loadProjectRef"); + yield* dbLint(flags({ schema: ["public"], linked: true })); + expect(projectRef.calls).toContain("resolveRef"); expect(cache.cached).toBe(true); }).pipe(Effect.provide(layer)); }); @@ -526,11 +528,7 @@ describe("db lint", () => { // `VALID_REF` stands in for whatever the workdir would resolve to absent the // flag; the flag must win. const FLAG_REF = "flagflagflagflagflag"; - const { layer, cache } = setup({ - isLocal: false, - checkRows: { public: [] }, - args: ["--linked"], - }); + const { layer, cache } = setup({ isLocal: false, checkRows: { public: [] } }); return Effect.gen(function* () { yield* dbLint(flags({ schema: ["public"], projectRef: Option.some(FLAG_REF) })); expect(cache.cached).toBe(true); @@ -543,8 +541,7 @@ describe("db lint", () => { const { layer, http } = setup({ isLocal: false, checkRows: { public: [] }, - args: ["db", "lint", "--linked", "--project-ref", VALID_REF], - realLayers: { workdir: tempRoot.current }, + realLayers: true, }); const requests: Array<StubbedRequest> = []; return Effect.gen(function* () { @@ -577,11 +574,7 @@ describe("db lint", () => { it.effect( "makes no Management API request for --local through the real resolver and cache", () => { - const { layer, http } = setup({ - checkRows: { public: [] }, - args: ["db", "lint", "--local"], - realLayers: { workdir: tempRoot.current }, - }); + const { layer, http } = setup({ checkRows: { public: [] }, realLayers: true }); const requests: Array<StubbedRequest> = []; return Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; @@ -607,11 +600,7 @@ describe("db lint", () => { ); it.effect("defaults to the local target and makes no Management API request", () => { - const { layer, http } = setup({ - checkRows: { public: [] }, - args: ["db", "lint"], - realLayers: { workdir: tempRoot.current }, - }); + const { layer, http } = setup({ checkRows: { public: [] }, realLayers: true }); const requests: Array<StubbedRequest> = []; return Effect.gen(function* () { yield* stubManagementApi(requests, () => undefined); @@ -631,12 +620,11 @@ describe("db lint", () => { ); }); - it.effect("--linked=false still reaches the linked database through the real resolver", () => { + it.effect("--project-ref alone reaches the linked database through the real resolver", () => { const { layer, http } = setup({ isLocal: false, checkRows: { public: [] }, - args: ["db", "lint", "--linked=false", "--project-ref", VALID_REF], - realLayers: { workdir: tempRoot.current }, + realLayers: true, }); const requests: Array<StubbedRequest> = []; return Effect.gen(function* () { @@ -650,7 +638,7 @@ describe("db lint", () => { expect(yield* fs.exists(`${tempRoot.current}/supabase/.temp/linked-project.json`)).toBe(true); assertPermissionDrift({ command: "db lint", - activeFlags: { linked: false, "project-ref": VALID_REF, schema: true }, + activeFlags: { "project-ref": VALID_REF, schema: true }, requests: [...asRecordedRequests(requests), ...http.calls], exact: true, }); @@ -669,20 +657,14 @@ describe("db lint", () => { }).pipe(Effect.provide(layer)); }); - it.effect("rejects --project-ref on the default local target", () => { + it.effect("rejects --project-ref together with --local", () => { const FLAG_REF = "flagflagflagflagflag"; const { layer, connection, cache } = setup({ checkRows: { public: [] } }); return Effect.gen(function* () { - const exit = yield* Effect.exit( - dbLint(flags({ schema: ["public"], projectRef: Option.some(FLAG_REF) })), - ); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - const causeText = Cause.pretty(exit.cause); - expect(causeText).toContain( - "--project-ref only applies when targeting the linked project; use it with --linked (not --local or --db-url)", - ); - } + const error = yield* dbLint( + flags({ schema: ["public"], local: true, projectRef: Option.some(FLAG_REF) }), + ).pipe(Effect.flip); + expect(error).toBeInstanceOf(TargetProjectRefConflictError); expect(connection.execs).toEqual([]); expect(cache.cached).toBe(false); }).pipe(Effect.provide(layer)); @@ -699,58 +681,24 @@ describe("db lint", () => { }); }); - it.effect("--linked=false routes to the linked branch (Changed, not value)", () => { - const { layer, projectRef, cache } = setup({ - isLocal: false, - checkRows: { public: [] }, - args: ["db", "lint", "--linked=false"], - }); + it.effect("treats --linked=false as absent and lints the local database", () => { + const { layer, resolver, cache } = setup({ checkRows: { public: [] } }); return Effect.gen(function* () { - yield* dbLint(flags({ schema: ["public"] })); - expect(projectRef.calls).toContain("loadProjectRef"); - expect(cache.cached).toBe(true); + yield* dbLint(flags({ schema: ["public"], linked: false })); + expect(resolver.resolveInput?.target.kind).toBe("local"); + expect(cache.cached).toBe(false); }).pipe(Effect.provide(layer)); }); - it.effect("--no-linked routes to the linked branch (boolean negation is still Changed)", () => { - const { layer, projectRef, cache } = setup({ - isLocal: false, - checkRows: { public: [] }, - args: ["--no-linked"], - }); + it.effect("follows a remote workspace target when no selector flag is given", () => { + const { layer, resolver, cache } = setup({ isLocal: false, checkRows: { public: [] } }); return Effect.gen(function* () { - yield* dbLint(flags({ schema: ["public"] })); - expect(projectRef.calls).toContain("loadProjectRef"); + yield* withConfigEnv( + { SUPABASE_TARGET: "remote" }, + dbLint(flags({ schema: ["public"] })), + ); + expect(resolver.resolveInput?.target.kind).toBe("linked"); expect(cache.cached).toBe(true); }).pipe(Effect.provide(layer)); }); - - it.effect( - "--local=false --linked fails with mutual-exclusion (sorted set [linked local])", - () => { - const { layer } = setup({ args: ["--local=false", "--linked"] }); - return Effect.gen(function* () { - const exit = yield* Effect.exit(dbLint(flags({ schema: ["public"] }))); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - const causeText = Cause.pretty(exit.cause); - expect(causeText).toContain( - "if any flags in the group [db-url linked local] are set none of the others can be; [linked local] were all set", - ); - } - }).pipe(Effect.provide(layer)); - }, - ); - - it.effect( - "--local=false alone routes to the local branch (Changed local, connType=local)", - () => { - const { layer, out, cache } = setup({ checkRows: { public: [] }, args: ["--local=false"] }); - return Effect.gen(function* () { - yield* dbLint(flags({ schema: ["public"] })); - expect(out.stderrText).toContain("Connecting to local database..."); - expect(cache.cached).toBe(false); - }).pipe(Effect.provide(layer)); - }, - ); }); From 5bbf718b93e852f18bdd6671b3b7d7caa4d2c058 Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Thu, 15 Oct 2026 14:26:57 +0100 Subject: [PATCH 29/92] test(cli): update db dump tests for resolved targets --- .../commands/db/dump/dump.integration.test.ts | 264 ++++++++---------- 1 file changed, 123 insertions(+), 141 deletions(-) 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 95a7492153..b3c4f9f49d 100644 --- a/apps/cli/src/commands/db/dump/dump.integration.test.ts +++ b/apps/cli/src/commands/db/dump/dump.integration.test.ts @@ -50,10 +50,17 @@ import { mockLinkedProjectCacheTracked, mockTelemetryStateTracked, useTempWorkdir, + withConfigEnv, 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 { CliArgs } from "../../../shared/cli/cli-args.service.ts"; +import { DnsResolverFlag, NetworkIdFlag, YesFlag } from "../../../command-internal/global-flags.ts"; +import { TargetPolicy } from "../../../command-internal/target/target-policy.ts"; +import { + TargetProjectRefConflictError, + TargetSelectorConflictError, +} from "../../../command-internal/target/target.errors.ts"; import { RuntimeInfo } from "../../../shared/runtime/runtime-info.service.ts"; import { InvalidProjectRefError, @@ -67,7 +74,11 @@ import { } from "../../../config/project-ref.service.ts"; import { dbConfig } from "../../../command-internal/db-config.layer.ts"; import { DbConfigResolver } from "../../../command-internal/db-config.service.ts"; -import type { DbConfigFlags } from "../../../command-internal/db-config.types.ts"; +import type { + DbConfigFlags, + DbConnectionInput, + ResolvedDbConnection, +} from "../../../command-internal/db-config.types.ts"; import { DbConnection, type DbSession, @@ -234,32 +245,26 @@ function mockResolver(opts: { resolveFails?: boolean; ref?: string; }) { - const calls: DbConfigFlags[] = []; + const calls: DbConnectionInput[] = []; const fallbackCalls: DbConfigFlags[] = []; const layer = Layer.succeed(DbConfigResolver, { - resolve: (flags) => { - calls.push(flags); + resolve: () => Effect.die("resolve is not used by this test"), + resolveConnection: (input: DbConnectionInput) => { + calls.push(input); // Simulates connection resolution failing (IPv6 probe/pooler/temp login-role) - // after the ref is already loaded. + // after the ref is already resolved. if (opts.resolveFails === true) { return Effect.fail( new DbConfigConnectTempRoleError({ message: "failed to create temp role" }), ); } - // A threaded `--project-ref` flag wins over the fixed `opts.ref` fixture, - // matching real resolver precedence. - const linkedProjectRef = flags.linkedProjectRef ?? Option.none(); - const ref = - Option.isSome(linkedProjectRef) && linkedProjectRef.value.length > 0 - ? linkedProjectRef.value - : opts.ref; return Effect.succeed({ conn: opts.conn ?? LOCAL_CONN, isLocal: opts.isLocal ?? true, - ref: ref === undefined ? undefined : Option.some(ref), - }); + ref: input.target.kind === "linked" ? Option.some(input.target.ref) : Option.none(), + target: input.target, + } satisfies ResolvedDbConnection); }, - resolveConnection: () => Effect.die("resolveConnection is not used by this test"), resolvePoolerFallback: (flags) => { fallbackCalls.push(flags); return opts.poolerFallbackFails === true @@ -279,37 +284,39 @@ function mockResolver(opts: { } /** - * Mocks `ProjectRefResolver` for the up-front `loadProjectRef` pre-capture, mirroring - * push/diff's identical mock: an explicit `--project-ref` flag wins, a malformed value - * fails with `InvalidProjectRefError`, and `opts.projectId` stands in for - * `CommandSettings.projectId` consulted before falling back to `opts.ref`. + * Mocks `ProjectRefResolver`'s `resolveRef`: an explicit `--project-ref` flag wins, a malformed + * value fails with `InvalidProjectRefError`, and `opts.projectId` stands in for the project id + * consulted before falling back to `opts.ref`. */ function mockProjectRefResolver(opts: { projectId: Option.Option<string>; ref?: string; linkedFails?: boolean; }) { - const validate = (ref: string) => + const validate = (ref: string, source: "flag" | "env") => PROJECT_REF_PATTERN.test(ref) - ? Effect.succeed(ref) + ? Effect.succeed({ ref, source, matchesLinkedRef: false }) : Effect.fail(new InvalidProjectRefError({ ref, message: INVALID_PROJECT_REF_MESSAGE })); const layer = Layer.succeed(ProjectRefResolver, { resolve: () => Effect.succeed(opts.ref ?? VALID_REF), resolveForLink: () => Effect.succeed(opts.ref ?? VALID_REF), - resolveRef: () => - Effect.succeed({ ref: opts.ref ?? VALID_REF, source: "file", matchesLinkedRef: true }), - resolveOptional: () => Effect.succeed(Option.some(opts.ref ?? VALID_REF)), - loadProjectRef: (flagValue: Option.Option<string>) => { + resolveRef: (flagValue: Option.Option<string>) => { if (Option.isSome(flagValue) && flagValue.value.length > 0) { - return validate(flagValue.value); + return validate(flagValue.value, "flag"); } if (Option.isSome(opts.projectId)) { - return validate(opts.projectId.value); + return validate(opts.projectId.value, "env"); } return opts.linkedFails === true ? Effect.fail(new ProjectRefNotLinkedError({ message: PROJECT_NOT_LINKED_MESSAGE })) - : Effect.succeed(opts.ref ?? VALID_REF); + : Effect.succeed({ + ref: opts.ref ?? VALID_REF, + source: "file" as const, + matchesLinkedRef: true, + }); }, + resolveOptional: () => Effect.succeed(Option.some(opts.ref ?? VALID_REF)), + loadProjectRef: () => Effect.die("loadProjectRef is not used by this test"), promptProjectRef: () => Effect.succeed(opts.ref ?? VALID_REF), }); return { layer }; @@ -517,6 +524,9 @@ function setup(opts: SetupOpts = {}) { NetworkIdFlag, opts.networkId === undefined ? Option.none() : Option.some(opts.networkId), ), + Layer.succeed(TargetPolicy, { group: "selectable", consent: "none" }), + Layer.succeed(YesFlag, false), + Layer.succeed(CliArgs, { args: [] }), Layer.succeed(DnsResolverFlag, "native"), BunServices.layer, ); @@ -532,8 +542,8 @@ const flags = (over: Partial<DbDumpFlags> = {}): DbDumpFlags => ({ keepComments: over.keepComments ?? Option.none(), file: over.file ?? Option.none(), dbUrl: over.dbUrl ?? Option.none(), - linked: over.linked ?? Option.none(), - local: over.local ?? Option.none(), + linked: over.linked ?? false, + local: over.local ?? false, projectRef: over.projectRef ?? Option.none(), password: over.password ?? Option.none(), schema: over.schema ?? [], @@ -560,7 +570,7 @@ describe("db dump integration", () => { it.effect("errors when --use-copy is used without --data-only", () => { const { layer } = setup(); return Effect.gen(function* () { - const exit = yield* dbDump(flags({ useCopy: true, local: Option.some(true) })).pipe( + const exit = yield* dbDump(flags({ useCopy: true, local: true })).pipe( Effect.exit, ); expect(Exit.isFailure(exit)).toBe(true); @@ -574,7 +584,7 @@ describe("db dump integration", () => { const { layer } = setup({ isLocal: true, stdout: "SELECT 1;\n" }); return Effect.gen(function* () { const exit = yield* dbDump( - flags({ useCopy: true, dataOnly: Option.some(false), local: Option.some(true) }), + flags({ useCopy: true, dataOnly: Option.some(false), local: true }), ).pipe(Effect.exit); expect(Exit.isSuccess(exit)).toBe(true); }).pipe(Effect.provide(layer)); @@ -585,7 +595,7 @@ describe("db dump integration", () => { const { layer } = setup(); return Effect.gen(function* () { const exit = yield* dbDump( - flags({ exclude: ["public.users"], local: Option.some(true) }), + flags({ exclude: ["public.users"], local: true }), ).pipe(Effect.exit); expect(Exit.isFailure(exit)).toBe(true); expect(failMessage(exit)).toBe(`required flag(s) "data-only" not set`); @@ -632,56 +642,25 @@ describe("db dump integration", () => { }); it.effect("rejects combining --linked and --local", () => { - const { layer } = setup(); - return Effect.gen(function* () { - const exit = yield* dbDump( - flags({ linked: Option.some(true), local: Option.some(true) }), - ).pipe(Effect.exit); - expect(Exit.isFailure(exit)).toBe(true); - expect(failMessage(exit)).toBe( - "if any flags in the group [db-url linked local] are set none of the others can be; [linked local] were all set", - ); - }).pipe(Effect.provide(layer)); - }); - - it.effect("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); + const error = yield* dbDump(flags({ linked: true, local: true })).pipe(Effect.flip); + expect(error).toBeInstanceOf(TargetSelectorConflictError); + expect(resolver.calls).toEqual([]); }).pipe(Effect.provide(layer)); }); - it.effect("rejects --password combined with --local", () => { + it.effect("hands --password to the resolver, which rejects it for a non-linked target", () => { 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.effect("rejects --linked=false --local as a target conflict", () => { - const { layer } = setup(); - return Effect.gen(function* () { - const exit = yield* dbDump( - flags({ linked: Option.some(false), local: Option.some(true) }), - ).pipe(Effect.exit); - expect(Exit.isFailure(exit)).toBe(true); - expect(failMessage(exit)).toBe( - "if any flags in the group [db-url linked local] are set none of the others can be; [linked local] were all set", + yield* dbDump( + flags({ dbUrl: Option.some("postgresql://x"), password: Option.some("pw"), dryRun: true }), ); + expect(resolver.calls[0]).toMatchObject({ + target: { kind: "db-url" }, + password: Option.some("pw"), + hasPasswordFlag: true, + }); }).pipe(Effect.provide(layer)); }); @@ -698,18 +677,18 @@ describe("db dump integration", () => { }).pipe(Effect.provide(layer)); }); - it.effect("treats --local=false as an explicit local target", () => { + it.effect("treats --local=false as absent and dumps the local database by default", () => { const { layer, resolver } = setup({ isLocal: true }); return Effect.gen(function* () { - yield* dbDump(flags({ local: Option.some(false), dryRun: true })); - expect(resolver.calls[0]?.connType).toBe("local"); + yield* dbDump(flags({ local: false, dryRun: true })); + expect(resolver.calls[0]?.target.kind).toBe("local"); }).pipe(Effect.provide(layer)); }); it.effect("prints the expanded pg_dump script on --dry-run without running a container", () => { const { layer, out, docker } = setup({ isLocal: true }); return Effect.gen(function* () { - yield* dbDump(flags({ dryRun: true, local: Option.some(true) })); + yield* dbDump(flags({ dryRun: true, local: true })); expect(out.stderrText).toContain("DRY RUN: *only* printing the pg_dump script to console."); expect(out.stderrText).toContain("Dumping schemas from local database..."); expect(out.stdoutText).toContain('export PGHOST="127.0.0.1"'); @@ -723,7 +702,7 @@ describe("db dump integration", () => { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const filePath = path.join(tmp.current, "dry.sql"); - yield* dbDump(flags({ dryRun: true, local: Option.some(true), file: Option.some(filePath) })); + yield* dbDump(flags({ dryRun: true, local: true, file: Option.some(filePath) })); expect(out.stderrText).toContain("DRY RUN: *only* printing the pg_dump script to console."); expect(out.stderrText).toContain(`Dumped schema to`); expect(out.stderrText).toContain(filePath); @@ -735,7 +714,7 @@ describe("db dump integration", () => { it.effect("treats an explicit --file '' as stdout on --dry-run", () => { const { layer, out, docker } = setup({ isLocal: true }); return Effect.gen(function* () { - yield* dbDump(flags({ dryRun: true, local: Option.some(true), file: Option.some("") })); + yield* dbDump(flags({ dryRun: true, local: true, file: Option.some("") })); expect(out.stderrText).toContain("DRY RUN: *only* printing the pg_dump script to console."); expect(out.stderrText).not.toContain("Dumped schema to"); expect(docker.lastOpts).toBeUndefined(); @@ -752,7 +731,7 @@ describe("db dump integration", () => { path.join(tmp.current, "supabase", "config.toml"), ["[remotes.staging]", 'project_id = "staging"', ""].join("\n"), ); - const exit = yield* dbDump(flags({ dryRun: true, local: Option.some(true) })).pipe( + const exit = yield* dbDump(flags({ dryRun: true, local: true })).pipe( Effect.exit, ); expect(Exit.isFailure(exit)).toBe(true); @@ -766,7 +745,7 @@ describe("db dump integration", () => { it.effect("dumps schema from the local database to stdout", () => { const { layer, out, docker } = setup({ isLocal: true, stdout: "CREATE SCHEMA public;\n" }); return Effect.gen(function* () { - yield* dbDump(flags({ local: Option.some(true) })); + yield* dbDump(flags({ local: true })); expect(out.stderrText).toContain("Dumping schemas from local database..."); expect(out.stdoutText).toBe("CREATE SCHEMA public;\n"); expect(docker.lastOpts?.cmd).toEqual([ @@ -784,7 +763,7 @@ describe("db dump integration", () => { it.effect("dumps only data with column inserts", () => { const { layer, out, docker } = setup({ isLocal: true, stdout: "INSERT INTO ...;\n" }); return Effect.gen(function* () { - yield* dbDump(flags({ dataOnly: Option.some(true), local: Option.some(true) })); + yield* dbDump(flags({ dataOnly: Option.some(true), local: true })); expect(out.stderrText).toContain("Dumping data from local database..."); expect(docker.lastOpts?.env["EXTRA_FLAGS"]).toBe("--column-inserts --rows-per-insert 100000"); }).pipe(Effect.provide(layer)); @@ -794,7 +773,7 @@ describe("db dump integration", () => { const { layer, docker } = setup({ isLocal: true }); return Effect.gen(function* () { yield* dbDump( - flags({ dataOnly: Option.some(true), useCopy: true, local: Option.some(true) }), + flags({ dataOnly: Option.some(true), useCopy: true, local: true }), ); expect(docker.lastOpts?.env["EXTRA_FLAGS"]).toBeUndefined(); }).pipe(Effect.provide(layer)); @@ -803,7 +782,7 @@ describe("db dump integration", () => { it.effect("dumps only roles", () => { const { layer, out, docker } = setup({ isLocal: true }); return Effect.gen(function* () { - yield* dbDump(flags({ roleOnly: Option.some(true), local: Option.some(true) })); + yield* dbDump(flags({ roleOnly: Option.some(true), local: true })); expect(out.stderrText).toContain("Dumping roles from local database..."); expect(docker.lastOpts?.env["RESERVED_ROLES"]).toBeDefined(); }).pipe(Effect.provide(layer)); @@ -812,7 +791,7 @@ describe("db dump integration", () => { it.effect("limits the dump to selected schemas", () => { const { layer, docker } = setup({ isLocal: true }); return Effect.gen(function* () { - yield* dbDump(flags({ schema: ["public", "auth"], local: Option.some(true) })); + yield* dbDump(flags({ schema: ["public", "auth"], local: true })); expect(docker.lastOpts?.env["EXTRA_FLAGS"]).toBe("--schema=public|auth"); }).pipe(Effect.provide(layer)); }); @@ -822,7 +801,7 @@ describe("db dump integration", () => { // `parseSchemaFlags`) and the env builder pipe-joins it. const { layer, docker } = setup({ isLocal: true }); return Effect.gen(function* () { - yield* dbDump(flags({ schema: ["public", "auth"], local: Option.some(true) })); + yield* dbDump(flags({ schema: ["public", "auth"], local: true })); expect(docker.lastOpts?.env["EXTRA_FLAGS"]).toBe("--schema=public|auth"); }).pipe(Effect.provide(layer)); }); @@ -834,7 +813,7 @@ describe("db dump integration", () => { workdir: tmp.current, }); return Effect.gen(function* () { - yield* dbDump(flags({ local: Option.some(true), file: Option.some("out.sql") })); + yield* dbDump(flags({ local: true, file: Option.some("out.sql") })); const path = yield* Path.Path; expect(yield* readUtf8(path.join(tmp.current, "out.sql"))).toBe("CREATE SCHEMA public;\n"); }).pipe(Effect.provide(layer)); @@ -843,7 +822,7 @@ describe("db dump integration", () => { it.effect("honors --network-id over host networking", () => { const { layer, docker } = setup({ isLocal: true, networkId: "custom_net" }); return Effect.gen(function* () { - yield* dbDump(flags({ local: Option.some(true) })); + yield* dbDump(flags({ local: true })); expect(docker.lastOpts?.network).toEqual({ _tag: "named", name: "custom_net" }); }).pipe(Effect.provide(layer)); }); @@ -862,17 +841,25 @@ describe("db dump integration", () => { path.join(tmp.current, "supabase", ".env"), "SUPABASE_NETWORK_ID=dotenv-net\n", ); - yield* dbDump(flags({ local: Option.some(true) })); + yield* dbDump(flags({ local: true })); expect(docker.lastOpts?.network).toEqual({ _tag: "named", name: "dotenv-net" }); }).pipe((body) => withEnvVar("SUPABASE_NETWORK_ID", undefined, body), Effect.provide(layer)); }, ); - it.effect("defaults to the linked connection when neither --local nor --db-url is set", () => { + it.effect("defaults to the local database when no selector flag is given", () => { + const { layer, resolver } = setup({ isLocal: true }); + return Effect.gen(function* () { + yield* dbDump(flags({ dryRun: true })); + expect(resolver.calls[0]?.target.kind).toBe("local"); + }).pipe(Effect.provide(layer)); + }); + + it.effect("follows a remote workspace target when no selector flag is given", () => { const { layer, resolver } = setup({ conn: REMOTE_CONN, isLocal: false }); return Effect.gen(function* () { - yield* dbDump(flags({})); - expect(resolver.calls[0]).toMatchObject({ connType: "linked" }); + yield* withConfigEnv({ SUPABASE_TARGET: "remote" }, dbDump(flags({ dryRun: true }))); + expect(resolver.calls[0]?.target.kind).toBe("linked"); }).pipe(Effect.provide(layer)); }); @@ -948,19 +935,16 @@ describe("db dump integration", () => { ); it.effect("caches the linked project even when connection resolution fails", () => { - // The project ref is resolved before the connection is built, and the - // linked-project cache is refreshed unconditionally afterward. So an - // IPv6/pooler/login-role failure during resolution still refreshes the - // linked-project cache, because the ref was already loaded — here from - // config.toml project_id. + // The cache is refreshed unconditionally, so an IPv6/pooler/login-role failure + // during resolution still refreshes it, because the ref was already resolved. const { layer, cache, resolver } = setup({ projectId: Option.some("abcdefghijklmnopqrst"), resolveFails: true, }); return Effect.gen(function* () { - const exit = yield* dbDump(flags({ linked: Option.some(true) })).pipe(Effect.exit); + const exit = yield* dbDump(flags({ linked: true })).pipe(Effect.exit); expect(Exit.isFailure(exit)).toBe(true); - expect(resolver.calls[0]).toMatchObject({ connType: "linked" }); + expect(resolver.calls[0]?.target.kind).toBe("linked"); expect(cache.cached).toBe(true); }).pipe(Effect.provide(layer)); }); @@ -968,8 +952,7 @@ describe("db dump integration", () => { it.effect( "caches the flag ref, not the workdir's own config ref, when resolution fails (regression)", () => { - // `linkedRefForCache` must check `flags.projectRef` before config.toml's - // `project_id`, so the flag still wins even when `resolve()` fails first. + // The flag wins over the project id even when the connection resolution fails first. const FLAG_REF = "flagflagflagflagflag"; const { layer, cache } = setup({ projectId: Option.some("abcdefghijklmnopqrst"), @@ -977,7 +960,7 @@ describe("db dump integration", () => { }); return Effect.gen(function* () { const exit = yield* dbDump( - flags({ linked: Option.some(true), projectRef: Option.some(FLAG_REF) }), + flags({ linked: true, projectRef: Option.some(FLAG_REF) }), ).pipe(Effect.exit); expect(Exit.isFailure(exit)).toBe(true); expect(cache.cached).toBe(true); @@ -988,11 +971,11 @@ describe("db dump integration", () => { ); it.effect("does not cache when the linked ref is unknown and resolution fails", () => { - // No config project_id or .temp/project-ref file, so the up-front pre-capture - // itself fails "not linked" before `resolve()` is reached; nothing is cached. + // No project id or .temp/project-ref file, so target resolution fails "not linked" + // before the connection is resolved; nothing is cached. const { layer, cache } = setup({ resolveFails: true, linkedFails: true }); return Effect.gen(function* () { - const exit = yield* dbDump(flags({ linked: Option.some(true) })).pipe(Effect.exit); + const exit = yield* dbDump(flags({ linked: true })).pipe(Effect.exit); expect(Exit.isFailure(exit)).toBe(true); expect(cache.cached).toBe(false); }).pipe(Effect.provide(layer)); @@ -1006,7 +989,7 @@ describe("db dump integration", () => { stdout: "CREATE SCHEMA public;\n", }); return Effect.gen(function* () { - yield* dbDump(flags({ linked: Option.some(true) })); + yield* dbDump(flags({ linked: true })); expect(cache.cached).toBe(true); }).pipe(Effect.provide(layer)); }); @@ -1020,8 +1003,8 @@ describe("db dump integration", () => { stdout: "CREATE SCHEMA public;\n", }); return Effect.gen(function* () { - yield* dbDump(flags({ linked: Option.some(true), projectRef: Option.some(FLAG_REF) })); - expect(resolver.calls[0]?.linkedProjectRef).toEqual(Option.some(FLAG_REF)); + yield* dbDump(flags({ linked: true, projectRef: Option.some(FLAG_REF) })); + expect(resolver.calls[0]?.target).toMatchObject({ kind: "linked", ref: FLAG_REF }); expect(cache.cached).toBe(true); expect(cache.cachedRef).toBe(FLAG_REF); }).pipe(Effect.provide(layer)); @@ -1037,7 +1020,7 @@ describe("db dump integration", () => { stdout: "CREATE SCHEMA public;\n", }); return Effect.gen(function* () { - yield* dbDump(flags({ linked: Option.some(true), projectRef: Option.some(FLAG_REF) })); + yield* dbDump(flags({ linked: true, projectRef: Option.some(FLAG_REF) })); expect(cache.cached).toBe(true); expect(cache.cachedRef).toBe(FLAG_REF); expect(cache.cachedRef).not.toBe("abcdefghijklmnopqrst"); @@ -1047,12 +1030,12 @@ describe("db dump integration", () => { it.effect( "rejects a malformed --project-ref on the linked path before resolving or caching", () => { - // `loadProjectRef` validates before `resolver.resolve()` runs, so a malformed - // flag fails fast with no connection/API work and no cache write. + // The ref is validated before the connection is resolved, so a malformed flag fails + // fast with no connection/API work and no cache write. const { layer, cache, resolver } = setup(); return Effect.gen(function* () { const exit = yield* dbDump( - flags({ linked: Option.some(true), projectRef: Option.some("BADREF") }), + flags({ linked: true, projectRef: Option.some("BADREF") }), ).pipe(Effect.exit); expect(Exit.isFailure(exit)).toBe(true); expect(failMessage(exit)).toBe(INVALID_PROJECT_REF_MESSAGE); @@ -1066,13 +1049,10 @@ describe("db dump integration", () => { const FLAG_REF = "flagflagflagflagflag"; const { layer, resolver, cache } = setup({ isLocal: true }); return Effect.gen(function* () { - const exit = yield* dbDump( - flags({ local: Option.some(true), projectRef: Option.some(FLAG_REF) }), - ).pipe(Effect.exit); - expect(Exit.isFailure(exit)).toBe(true); - expect(failMessage(exit)).toBe( - "--project-ref only applies when targeting the linked project; use it with --linked (not --local or --db-url)", - ); + const error = yield* dbDump( + flags({ local: true, projectRef: Option.some(FLAG_REF) }), + ).pipe(Effect.flip); + expect(error).toBeInstanceOf(TargetProjectRefConflictError); expect(resolver.calls).toEqual([]); expect(cache.cached).toBe(false); }).pipe(Effect.provide(layer)); @@ -1083,7 +1063,7 @@ describe("db dump integration", () => { return Effect.gen(function* () { const path = yield* Path.Path; const filePath = path.join(tmp.current, "out.sql"); - yield* dbDump(flags({ local: Option.some(true), file: Option.some(filePath) })); + yield* dbDump(flags({ local: true, file: Option.some(filePath) })); expect(yield* readUtf8(filePath)).toBe("CREATE SCHEMA public;\n"); expect(out.stderrText).toContain(`Dumped schema to`); expect(out.stderrText).toContain(filePath); @@ -1095,7 +1075,7 @@ describe("db dump integration", () => { it.effect("fails with exit 1 when the container exits non-zero", () => { const { layer } = setup({ isLocal: true, exitCode: 1, stdout: "partial\n" }); return Effect.gen(function* () { - const exit = yield* dbDump(flags({ local: Option.some(true) })).pipe(Effect.exit); + const exit = yield* dbDump(flags({ local: true })).pipe(Effect.exit); expect(Exit.isFailure(exit)).toBe(true); expect(failMessage(exit)).toBe("error running container: exit 1"); }).pipe(Effect.provide(layer)); @@ -1122,10 +1102,13 @@ describe("db dump integration", () => { ], }); return Effect.gen(function* () { - yield* dbDump(flags()); + yield* dbDump(flags({ linked: true })); expect(docker.allOpts).toHaveLength(2); expect(resolver.fallbackCalls).toHaveLength(1); - expect(resolver.fallbackCalls[0]).toMatchObject({ connType: "linked" }); + expect(resolver.fallbackCalls[0]).toMatchObject({ + connType: "linked", + linkedProjectRef: Option.some(VALID_REF), + }); expect(docker.allOpts[1]?.env["PGHOST"]).toBe(POOLER_CONN.host); expect(out.stderrText).toContain("does not support IPv6"); expect(out.stderrText).toContain("Retrying via the IPv4 connection pooler."); @@ -1141,7 +1124,7 @@ describe("db dump integration", () => { results: [{ exitCode: 1, stderr: IPV6_STDERR }], }); return Effect.gen(function* () { - const exit = yield* dbDump(flags()).pipe(Effect.exit); + const exit = yield* dbDump(flags({ linked: true })).pipe(Effect.exit); expect(Exit.isFailure(exit)).toBe(true); expect(failMessage(exit)).toBe("error running container: exit 1"); expect(resolver.fallbackCalls).toHaveLength(1); // attempted @@ -1157,7 +1140,7 @@ describe("db dump integration", () => { results: [{ exitCode: 1, stderr: "permission denied for schema public" }], }); return Effect.gen(function* () { - const exit = yield* dbDump(flags()).pipe(Effect.exit); + const exit = yield* dbDump(flags({ linked: true })).pipe(Effect.exit); expect(Exit.isFailure(exit)).toBe(true); expect(failMessage(exit)).toBe("error running container: exit 1"); expect(docker.allOpts).toHaveLength(1); @@ -1173,7 +1156,7 @@ describe("db dump integration", () => { results: [{ exitCode: 1, stderr: IPV6_STDERR }], }); return Effect.gen(function* () { - const exit = yield* dbDump(flags()).pipe(Effect.exit); + const exit = yield* dbDump(flags({ linked: true })).pipe(Effect.exit); expect(Exit.isFailure(exit)).toBe(true); expect(failMessage(exit)).toBe("error running container: exit 1"); expect(resolver.fallbackCalls).toHaveLength(1); @@ -1196,7 +1179,7 @@ describe("db dump integration", () => { ], }); return Effect.gen(function* () { - const exit = yield* dbDump(flags()).pipe(Effect.exit); + const exit = yield* dbDump(flags({ linked: true })).pipe(Effect.exit); expect(Exit.isFailure(exit)).toBe(true); expect(failMessage(exit)).toBe("error running container: exit 1"); expect(docker.allOpts).toHaveLength(2); // original + failed retry @@ -1212,7 +1195,7 @@ describe("db dump integration", () => { results: [{ exitCode: 1, stderr: "permission denied for schema public" }], }); return Effect.gen(function* () { - const exit = yield* dbDump(flags()).pipe(Effect.exit); + const exit = yield* dbDump(flags({ linked: true })).pipe(Effect.exit); expect(Exit.isFailure(exit)).toBe(true); expect(failSuggestion(exit)).toBeUndefined(); }).pipe(Effect.provide(layer)); @@ -1221,7 +1204,7 @@ describe("db dump integration", () => { it.effect("json mode: emits the SQL to stdout with no machine envelope", () => { const { layer, out } = setup({ format: "json", isLocal: true, stdout: "CREATE SCHEMA x;\n" }); return Effect.gen(function* () { - yield* dbDump(flags({ local: Option.some(true) })); + yield* dbDump(flags({ local: true })); expect(out.stdoutText).toBe("CREATE SCHEMA x;\n"); expect(out.messages.find((m) => m.type === "success")).toBeUndefined(); }).pipe(Effect.provide(layer)); @@ -1234,7 +1217,7 @@ describe("db dump integration", () => { stdout: "CREATE SCHEMA x;\n", }); return Effect.gen(function* () { - yield* dbDump(flags({ local: Option.some(true) })); + yield* dbDump(flags({ local: true })); expect(out.stdoutText).toBe("CREATE SCHEMA x;\n"); }).pipe(Effect.provide(layer)); }); @@ -1309,7 +1292,7 @@ describe("db dump integration", () => { env: { MSYSTEM: "" }, }); return Effect.gen(function* () { - yield* dbDump(flags({ local: Option.some(true) })); + yield* dbDump(flags({ local: true })); expect(out.stdoutText).toBe(UNICODE_SQL); expect(out.stderrText).toContain("WARNING:"); expect(out.stderrText).toContain(NON_ASCII_WARNING); @@ -1325,7 +1308,7 @@ describe("db dump integration", () => { workdir: tmp.current, }); return Effect.gen(function* () { - yield* dbDump(flags({ local: Option.some(true), file: Option.some("out.sql") })); + yield* dbDump(flags({ local: true, file: Option.some("out.sql") })); const path = yield* Path.Path; expect(yield* readUtf8(path.join(tmp.current, "out.sql"))).toBe(UNICODE_SQL); expect(out.stderrText).not.toContain(NON_ASCII_WARNING); @@ -1349,7 +1332,7 @@ describe("db dump integration", () => { it.effect(`stays silent ${scenario}`, () => { const { layer, out } = setup({ isLocal: true, stdout: UNICODE_SQL, ...over }); return Effect.gen(function* () { - yield* dbDump(flags({ local: Option.some(true) })); + yield* dbDump(flags({ local: true })); expect(out.stdoutText).toBe(over.stdout ?? UNICODE_SQL); expect(out.stderrText).not.toContain(NON_ASCII_WARNING); }).pipe(Effect.provide(layer)); @@ -1361,7 +1344,7 @@ describe("db dump integration", () => { it.effect(`streams schema output through the ${runtime} stack tool`, () => { const { layer, out } = setup({ isLocal: true, stdout: "" }); return Effect.gen(function* () { - yield* dbDump(flags({ local: Option.some(true) })); + yield* dbDump(flags({ local: true })); expect(out.stdoutText).toContain('CREATE TABLE IF NOT EXISTS "public"'); }).pipe( Effect.provide( @@ -1382,7 +1365,7 @@ describe("db dump permissions through the real resolver and cache", () => { const poolerRequest = `GET /v1/projects/${VALID_REF}/config/database/pooler`; const loginRoleRequest = `POST /v1/projects/${VALID_REF}/cli/login-role`; - it.effect("mints a login role and fills the cache for the default linked target", () => { + it.effect("mints a login role and fills the cache for a --project-ref target", () => { const { layer, out, projectHttp } = setup({ workdir: tmp.current, realLayers: true, @@ -1426,7 +1409,6 @@ describe("db dump permissions through the real resolver and cache", () => { yield* stubManagementApi(requests, serveLinkedDatabase(VALID_REF)); yield* dbDump( flags({ - linked: Option.some(true), projectRef: Option.some(VALID_REF), password: Option.some("secret"), }), @@ -1458,7 +1440,7 @@ describe("db dump permissions through the real resolver and cache", () => { return Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; yield* stubManagementApi(requests, () => undefined); - yield* dbDump(flags({ local: Option.some(true) })).pipe(Effect.provide(layer)); + yield* dbDump(flags({ local: true })).pipe(Effect.provide(layer)); expect(requests).toEqual([]); expect(projectHttp.calls).toEqual([]); expect(yield* fs.exists(`${tmp.current}/supabase/.temp/linked-project.json`)).toBe(false); @@ -1489,7 +1471,7 @@ describe("db dump permissions through the real resolver and cache", () => { yield* fs.makeDirectory(`${tmp.current}/supabase/.temp`, { recursive: true }); yield* fs.writeFileString(`${tmp.current}/supabase/.temp/project-ref`, VALID_REF); yield* stubManagementApi(requests, serveLinkedDatabase(VALID_REF)); - yield* dbDump(flags()).pipe(Effect.provide(layer)); + yield* dbDump(flags({ linked: true })).pipe(Effect.provide(layer)); expect(docker.allOpts).toHaveLength(2); expect(docker.allOpts[1]?.env["PGHOST"]).toBe("aws-0-us-east-1.pooler.supabase.com"); expect(asLines(requests)).toEqual([loginRoleRequest, poolerRequest, loginRoleRequest]); From 630cc75bb0b412313cd6ade4aaff96c68e18cd6a Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Thu, 15 Oct 2026 14:27:02 +0100 Subject: [PATCH 30/92] feat(cli): require hosted-mutation consent in the hosted command group (CLI-2695) Encryption, network bans/restrictions, SSL enforcement, Postgres config, SSO and notebooks push ask for consent (default No) before their first API write, or accept --yes. Reads never prompt. --- .../update-root-key.handler.ts | 3 + .../update-root-key.integration.test.ts | 20 ++++- .../network-bans/remove/remove.handler.ts | 10 +++ .../remove/remove.integration.test.ts | 78 +++++++++++++---- .../update/update.handler.ts | 8 ++ .../update/update.integration.test.ts | 53 ++++++++---- .../update/update.live.test.ts | 1 + .../commands/notebooks/push/push.handler.ts | 19 ++++- .../notebooks/push/push.integration.test.ts | 84 ++++++++++++++++++- .../postgres-config/delete/delete.handler.ts | 26 ++++-- .../delete/delete.live.test.ts | 2 + .../postgres-config.integration.test.ts | 69 ++++++++++++--- .../postgres-config/update/update.handler.ts | 21 ++++- .../update/update.live.test.ts | 1 + .../ssl-enforcement/update/update.handler.ts | 7 ++ .../update/update.integration.test.ts | 62 ++++++++++---- .../update/update.live.test.ts | 12 ++- apps/cli/src/commands/sso/add/add.handler.ts | 11 +++ .../commands/sso/add/add.integration.test.ts | 15 +++- .../src/commands/sso/remove/remove.handler.ts | 3 + .../sso/remove/remove.integration.test.ts | 34 +++++--- .../src/commands/sso/update/update.handler.ts | 41 ++++++--- .../sso/update/update.integration.test.ts | 22 ++++- apps/cli/tests/helpers/notebooks.ts | 20 ++++- 24 files changed, 525 insertions(+), 97 deletions(-) diff --git a/apps/cli/src/commands/encryption/update-root-key/update-root-key.handler.ts b/apps/cli/src/commands/encryption/update-root-key/update-root-key.handler.ts index 0a776e7914..8931a79a8d 100644 --- a/apps/cli/src/commands/encryption/update-root-key/update-root-key.handler.ts +++ b/apps/cli/src/commands/encryption/update-root-key/update-root-key.handler.ts @@ -2,6 +2,7 @@ import { Effect, Option } from "effect"; import { CommandPlatformApi } from "../../../auth/command-platform-api.service.ts"; import { aqua } from "../../../command-internal/colors.ts"; +import { requireHostedMutationConsent } from "../../../command-internal/hosted-consent/hosted-consent.ts"; import { ProjectRefResolver } from "../../../config/project-ref.service.ts"; import { Output } from "../../../shared/output/output.service.ts"; import { Stdin } from "../../../shared/runtime/stdin.service.ts"; @@ -41,6 +42,8 @@ export const encryptionUpdateRootKey = Effect.fn("encryption.update-root-key")(f // Runs on both success and failure. yield* Effect.gen(function* () { + yield* requireHostedMutationConsent({ action: "Replace the project's root encryption key" }); + const updating = output.format === "text" ? yield* output.task("Updating root key...") : undefined; const response = yield* api.v1.updatePgsodiumConfig({ ref, root_key: rootKey }).pipe( diff --git a/apps/cli/src/commands/encryption/update-root-key/update-root-key.integration.test.ts b/apps/cli/src/commands/encryption/update-root-key/update-root-key.integration.test.ts index 34c0d9cc4a..0d01df0fdc 100644 --- a/apps/cli/src/commands/encryption/update-root-key/update-root-key.integration.test.ts +++ b/apps/cli/src/commands/encryption/update-root-key/update-root-key.integration.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "@effect/vitest"; import { Cause, Effect, Exit, Layer, Option } from "effect"; +import { describeHostedConsent } from "../../../../tests/helpers/hosted-consent.ts"; import { mockOutput, mockStdin } from "../../../../tests/helpers/mocks.ts"; import { buildTestRuntime, @@ -12,9 +13,12 @@ import { useTempWorkdir, unverifiedTargetBanner, } from "../../../../tests/helpers/command-mocks.ts"; +import { YesFlag } from "../../../command-internal/global-flags.ts"; +import { TargetPolicy } from "../../../command-internal/target/target-policy.ts"; import { encryptionUpdateRootKey } from "./update-root-key.handler.ts"; const ROOT_KEY_RESPONSE = { root_key: "new-key" }; +const ACTION = "Replace the project's root encryption key"; interface SetupOpts { readonly format?: "text" | "json" | "stream-json"; @@ -52,6 +56,8 @@ function setup(opts: SetupOpts = {}) { linkedProjectCache: linkedProjectCache.layer, }), mockStdin(opts.stdinIsTty ?? false, opts.pipedInput), + Layer.succeed(YesFlag, true), + Layer.succeed(TargetPolicy, { group: "hosted", consent: "no" }), ); return { layer, out, api, telemetry, linkedProjectCache }; } @@ -104,7 +110,10 @@ describe("encryption update-root-key integration", () => { expect(success?.message).toBe(""); expect(success?.data).toEqual({ root_key: "new-key" }); expect(out.stdoutText).toBe(""); - expect(out.stderrText).toBe(unverifiedTargetBanner()); + expect(out.stderrText).toBe( + unverifiedTargetBanner() + + `Confirmed by --yes: ${ACTION} on project ${VALID_REF} · identity unverified\n`, + ); }).pipe(Effect.provide(layer)); }); @@ -185,3 +194,12 @@ describe("encryption update-root-key integration", () => { }).pipe(Effect.provide(layer)); }); }); + +describeHostedConsent("encryption update-root-key", { + api: { response: { status: 200, body: ROOT_KEY_RESPONSE } }, + invoke: () => ({ + run: encryptionUpdateRootKey({ projectRef: Option.some(VALID_REF) }).pipe( + Effect.provide(mockStdin(false, "new-key")), + ), + }), +}); diff --git a/apps/cli/src/commands/network-bans/remove/remove.handler.ts b/apps/cli/src/commands/network-bans/remove/remove.handler.ts index 99fff0bf30..0525c10919 100644 --- a/apps/cli/src/commands/network-bans/remove/remove.handler.ts +++ b/apps/cli/src/commands/network-bans/remove/remove.handler.ts @@ -6,6 +6,7 @@ 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 { OutputFlag } from "../../../command-internal/global-flags.ts"; +import { requireHostedMutationConsent } from "../../../command-internal/hosted-consent/hosted-consent.ts"; import { Output } from "../../../shared/output/output.service.ts"; import { mapHttpError } from "../../../command-internal/http-errors.ts"; import { @@ -43,6 +44,15 @@ export const networkBansRemove = Effect.fn("network-bans.remove")(function* ( } } + yield* requireHostedMutationConsent({ + action: "Remove network bans", + details: [ + flags.dbUnbanIp.length === 0 + ? "Unban: this machine's IP address" + : `Unban: ${flags.dbUnbanIp.join(", ")}`, + ], + }); + yield* api.v1 .deleteNetworkBans({ ref, diff --git a/apps/cli/src/commands/network-bans/remove/remove.integration.test.ts b/apps/cli/src/commands/network-bans/remove/remove.integration.test.ts index 01fdb4acaf..6203352b84 100644 --- a/apps/cli/src/commands/network-bans/remove/remove.integration.test.ts +++ b/apps/cli/src/commands/network-bans/remove/remove.integration.test.ts @@ -1,9 +1,10 @@ import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; -import { Cause, Effect, Exit, Option } from "effect"; +import { Cause, Effect, Exit, Layer, Option } from "effect"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; -import { mockOutput } from "../../../../tests/helpers/mocks.ts"; +import { describeHostedConsent } from "../../../../tests/helpers/hosted-consent.ts"; +import { mockOutput, mockTty } from "../../../../tests/helpers/mocks.ts"; import { VALID_REF, buildTestRuntime, @@ -12,7 +13,11 @@ import { mockCommandPlatformApi, mockTelemetryStateTracked, useTempWorkdir, + withEmptyConfigEnv, } from "../../../../tests/helpers/command-mocks.ts"; +import { HostedMutationDeclinedError } from "../../../command-internal/hosted-consent/hosted-consent.errors.ts"; +import { YesFlag } from "../../../command-internal/global-flags.ts"; +import { TargetPolicy } from "../../../command-internal/target/target-policy.ts"; import { networkBansRemoveDbUnbanIpFlag } from "./remove.command.ts"; import { networkBansRemove } from "./remove.handler.ts"; @@ -24,6 +29,11 @@ const parseDbUnbanIp = (rawValues: ReadonlyArray<string>) => Effect.provide(BunServices.layer), ); +const consentApproved = Layer.mergeAll( + Layer.succeed(YesFlag, true), + Layer.succeed(TargetPolicy, { group: "hosted", consent: "no" }), +); + interface SetupOpts { format?: "text" | "json" | "stream-json"; outputFlag?: "env" | "pretty" | "json" | "toml" | "yaml"; @@ -40,12 +50,15 @@ function setup(opts: SetupOpts = {}) { network: opts.network, }); const cliSettings = mockCommandSettings({ workdir: tempRoot.current }); - const layer = buildTestRuntime({ - out, - api, - cliSettings, - outputFlag: opts.outputFlag === undefined ? Option.none() : Option.some(opts.outputFlag), - }); + const layer = Layer.mergeAll( + buildTestRuntime({ + out, + api, + cliSettings, + outputFlag: opts.outputFlag === undefined ? Option.none() : Option.some(opts.outputFlag), + }), + consentApproved, + ); return { layer, out, api }; } @@ -58,13 +71,16 @@ function setupTracked(opts: SetupOpts = {}) { const cliSettings = mockCommandSettings({ workdir: tempRoot.current }); const telemetry = mockTelemetryStateTracked(); const cache = mockLinkedProjectCacheTracked(); - const layer = buildTestRuntime({ - out, - api, - cliSettings, - telemetry: telemetry.layer, - linkedProjectCache: cache.layer, - }); + const layer = Layer.mergeAll( + buildTestRuntime({ + out, + api, + cliSettings, + telemetry: telemetry.layer, + linkedProjectCache: cache.layer, + }), + consentApproved, + ); return { layer, out, api, telemetry, cache }; } @@ -245,7 +261,7 @@ describe("network-bans remove integration", () => { workdir: tempRoot.current, projectId: Option.none(), }); - const layer = buildTestRuntime({ out, api, cliSettings }); + const layer = Layer.mergeAll(buildTestRuntime({ out, api, cliSettings }), consentApproved); return Effect.gen(function* () { const exit = yield* Effect.exit( @@ -287,6 +303,30 @@ describe("network-bans remove integration", () => { }, ); + it.effect("lists the IPs to unban before asking and sends nothing when declined", () => { + const out = mockOutput({ format: "text", interactive: true, promptConfirmResponses: [false] }); + const api = mockCommandPlatformApi({ response: { status: 200, body: null } }); + const layer = Layer.mergeAll( + buildTestRuntime({ + out, + api, + cliSettings: mockCommandSettings({ workdir: tempRoot.current }), + tty: mockTty({ stdinIsTty: true, stdoutIsTty: true }), + }), + Layer.succeed(YesFlag, false), + Layer.succeed(TargetPolicy, { group: "hosted", consent: "no" }), + ); + return Effect.gen(function* () { + const error = yield* withEmptyConfigEnv( + networkBansRemove({ projectRef: Option.none(), dbUnbanIp: ["1.2.3.4", "5.6.7.8"] }), + ).pipe(Effect.flip); + expect(error).toBeInstanceOf(HostedMutationDeclinedError); + expect(out.stderrText).toContain("Unban: 1.2.3.4, 5.6.7.8"); + expect(out.promptConfirmCalls[0]?.opts?.defaultValue).toBe(false); + expect(api.requests).toHaveLength(0); + }).pipe(Effect.provide(layer)); + }); + it.effect("fails with NetworkBansRemoveUnexpectedStatusError on HTTP 503", () => { const { layer } = setup({ status: 503 }); return Effect.gen(function* () { @@ -361,3 +401,9 @@ describe("network-bans remove integration", () => { }); }); }); + +describeHostedConsent("network-bans remove", { + invoke: () => ({ + run: networkBansRemove({ projectRef: Option.some(VALID_REF), dbUnbanIp: ["1.2.3.4"] }), + }), +}); diff --git a/apps/cli/src/commands/network-restrictions/update/update.handler.ts b/apps/cli/src/commands/network-restrictions/update/update.handler.ts index 552bb6b6b5..495ad09469 100644 --- a/apps/cli/src/commands/network-restrictions/update/update.handler.ts +++ b/apps/cli/src/commands/network-restrictions/update/update.handler.ts @@ -9,6 +9,7 @@ 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 { OutputFlag } from "../../../command-internal/global-flags.ts"; +import { requireHostedMutationConsent } from "../../../command-internal/hosted-consent/hosted-consent.ts"; import { Output } from "../../../shared/output/output.service.ts"; import { encodeEnv, @@ -63,6 +64,13 @@ export const networkRestrictionsUpdate = Effect.fn("network-restrictions.update" const ref = yield* resolver.resolve(flags.projectRef); yield* Effect.gen(function* () { + yield* requireHostedMutationConsent({ + action: flags.append + ? "Append to the network restrictions" + : "Replace the network restrictions", + details: [`IPv4: ${v4.join(", ") || "(none)"}`, `IPv6: ${v6.join(", ") || "(none)"}`], + }); + const updating = output.format === "text" ? yield* output.task("Updating network restrictions...") diff --git a/apps/cli/src/commands/network-restrictions/update/update.integration.test.ts b/apps/cli/src/commands/network-restrictions/update/update.integration.test.ts index 5b65aff842..d698e6ea06 100644 --- a/apps/cli/src/commands/network-restrictions/update/update.integration.test.ts +++ b/apps/cli/src/commands/network-restrictions/update/update.integration.test.ts @@ -4,9 +4,10 @@ import { type V1UpdateNetworkRestrictionsOutput, } from "@supabase/api/effect"; import { describe, expect, it } from "@effect/vitest"; -import { Cause, Effect, Exit, Option } from "effect"; +import { Cause, Effect, Exit, Layer, Option } from "effect"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; +import { describeHostedConsent } from "../../../../tests/helpers/hosted-consent.ts"; import { mockOutput } from "../../../../tests/helpers/mocks.ts"; import { VALID_REF, @@ -18,6 +19,8 @@ import { mockTelemetryStateTracked, useTempWorkdir, } from "../../../../tests/helpers/command-mocks.ts"; +import { YesFlag } from "../../../command-internal/global-flags.ts"; +import { TargetPolicy } from "../../../command-internal/target/target-policy.ts"; import { type NetworkRestrictionsUpdateFlags, networkRestrictionsUpdateDbAllowCidrFlag, @@ -67,6 +70,11 @@ const PATCH_APPLIED: typeof V1PatchNetworkRestrictionsOutput.Type = { status: "applied", }; +const consentApproved = Layer.mergeAll( + Layer.succeed(YesFlag, true), + Layer.succeed(TargetPolicy, { group: "hosted", consent: "no" }), +); + interface SetupOpts { format?: "text" | "json" | "stream-json"; outputFlag?: "env" | "pretty" | "json" | "toml" | "yaml"; @@ -93,12 +101,15 @@ function setup(opts: SetupOpts = {}) { const out = mockOutput({ format: opts.format ?? "text" }); const api = mockCommandPlatformApi(apiOpts(opts)); const cliSettings = mockCommandSettings({ workdir: tempRoot.current }); - const layer = buildTestRuntime({ - out, - api, - cliSettings, - outputFlag: opts.outputFlag === undefined ? Option.none() : Option.some(opts.outputFlag), - }); + const layer = Layer.mergeAll( + buildTestRuntime({ + out, + api, + cliSettings, + outputFlag: opts.outputFlag === undefined ? Option.none() : Option.some(opts.outputFlag), + }), + consentApproved, + ); return { layer, out, api }; } @@ -108,13 +119,16 @@ function setupTracked(opts: SetupOpts = {}) { const cliSettings = mockCommandSettings({ workdir: tempRoot.current }); const telemetry = mockTelemetryStateTracked(); const cache = mockLinkedProjectCacheTracked(); - const layer = buildTestRuntime({ - out, - api, - cliSettings, - telemetry: telemetry.layer, - linkedProjectCache: cache.layer, - }); + const layer = Layer.mergeAll( + buildTestRuntime({ + out, + api, + cliSettings, + telemetry: telemetry.layer, + linkedProjectCache: cache.layer, + }), + consentApproved, + ); return { layer, out, api, telemetry, cache }; } @@ -680,3 +694,14 @@ describe("network-restrictions update integration", () => { }).pipe(Effect.provide(layer)); }); }); + +describeHostedConsent("network-restrictions update", { + api: { byMethod: { POST: { status: 201, body: POST_APPLIED } } }, + invoke: () => ({ + run: networkRestrictionsUpdate({ + ...baseFlags, + projectRef: Option.some(VALID_REF), + dbAllowCidr: ["12.3.4.5/32"], + }), + }), +}); diff --git a/apps/cli/src/commands/network-restrictions/update/update.live.test.ts b/apps/cli/src/commands/network-restrictions/update/update.live.test.ts index b6bc5220df..bd41178e08 100644 --- a/apps/cli/src/commands/network-restrictions/update/update.live.test.ts +++ b/apps/cli/src/commands/network-restrictions/update/update.live.test.ts @@ -60,6 +60,7 @@ function updateArgs(cidrs: AllowedCidrs, flags: ReadonlyArray<string>): string[] return [ "network-restrictions", "update", + "--yes", ...[...cidrs.v4, ...cidrs.v6].flatMap((cidr) => ["--db-allow-cidr", cidr]), ...flags, ]; diff --git a/apps/cli/src/commands/notebooks/push/push.handler.ts b/apps/cli/src/commands/notebooks/push/push.handler.ts index fcedd7acbe..2d29b4177d 100644 --- a/apps/cli/src/commands/notebooks/push/push.handler.ts +++ b/apps/cli/src/commands/notebooks/push/push.handler.ts @@ -1,4 +1,6 @@ import { Effect, Option } from "effect"; +import { sanitizeInlineName } from "../../../command-internal/http-errors.ts"; +import { requireHostedMutationConsent } from "../../../command-internal/hosted-consent/hosted-consent.ts"; import { Output } from "../../../shared/output/output.service.ts"; import { CommandPlatformApi } from "../../../auth/command-platform-api.service.ts"; import { CommandSettings } from "../../../config/command-settings.service.ts"; @@ -114,9 +116,24 @@ export const notebooksPush = Effect.fn("notebooks.push")(function* (flags: Noteb ) : []; + const remoteByName = new Map(remote.map((notebook) => [notebook.name, notebook])); + if (files.length > 0 || choice === "delete") { + yield* requireHostedMutationConsent({ + action: `Push ${files.length} notebook(s)${choice === "delete" ? ` and delete ${remoteOnly.length} from the project` : ""}`, + details: [ + ...files.map( + ({ name }) => + `${remoteByName.has(name) ? "Update" : "Create"} ${sanitizeInlineName(name)}`, + ), + ...(choice === "delete" + ? remoteOnly.map((notebook) => `Delete ${sanitizeInlineName(notebook.name)}`) + : []), + ], + }); + } + const created: Array<string> = []; const updated: Array<string> = []; - const remoteByName = new Map(remote.map((notebook) => [notebook.name, notebook])); for (const { name, file } of files) { const result = yield* uploadNotebook({ api, diff --git a/apps/cli/src/commands/notebooks/push/push.integration.test.ts b/apps/cli/src/commands/notebooks/push/push.integration.test.ts index 00fc6fe016..c930737941 100644 --- a/apps/cli/src/commands/notebooks/push/push.integration.test.ts +++ b/apps/cli/src/commands/notebooks/push/push.integration.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "@effect/vitest"; import { Effect, FileSystem, Option, Path, Schema } from "effect"; -import { useTempWorkdir } from "../../../../tests/helpers/command-mocks.ts"; +import { useTempWorkdir, VALID_REF } from "../../../../tests/helpers/command-mocks.ts"; +import { describeHostedConsent } from "../../../../tests/helpers/hosted-consent.ts"; import { notebookListPage, notebookResource, @@ -13,7 +14,9 @@ import { NotebookNameConflictError, NotebookNotFoundError, } from "../notebooks.errors.ts"; +import { HostedMutationDeclinedError } from "../../../command-internal/hosted-consent/hosted-consent.errors.ts"; import { notebooksPushHandler as notebooksPush } from "./push.command.ts"; +import { notebooksPush as notebooksPushCore } from "./push.handler.ts"; import type { NotebooksPushFlags } from "./push.command.ts"; const SALES_ID = "44444444-4444-4444-8444-444444444444"; @@ -323,4 +326,83 @@ describe("notebooks push", () => { ); }).pipe(Effect.provide(layer)); }); + + it.effect("asks once for the uploads and the deletion, and changes nothing when declined", () => { + const repo = project(); + const { layer, http, out } = setupNotebooks({ + command: "push", + workdir: repo.dir, + yes: false, + promptSelectResponses: ["delete"], + promptConfirmResponses: [false], + routes: { + [`GET ${notebooksRoute()}`]: { + status: 200, + body: notebookListPage({ + notebooks: [ + { id: SALES_ID, name: "kept" }, + { id: ERRORS_ID, name: "stale" }, + ], + }), + }, + }, + }); + + return Effect.gen(function* () { + yield* repo.write("kept", '{"content":{"cells":[]}}'); + const error = yield* notebooksPush(flags()).pipe(Effect.flip); + + expect(error).toBeInstanceOf(HostedMutationDeclinedError); + expect(out.promptConfirmCalls).toHaveLength(1); + expect(out.stderrText).toContain("Update kept"); + expect(out.stderrText).toContain("Delete stale"); + expect(http.routeKeys).toEqual([`GET ${notebooksRoute()}`]); + }).pipe(Effect.provide(layer)); + }); + + it.effect("asks nothing when there is nothing to upload or delete", () => { + const repo = project(); + const { layer, out } = setupNotebooks({ + command: "push", + workdir: repo.dir, + yes: false, + routes: { + [`GET ${notebooksRoute()}`]: { status: 200, body: notebookListPage({ notebooks: [] }) }, + }, + }); + + return Effect.gen(function* () { + yield* notebooksPush(flags()); + + expect(out.promptConfirmCalls).toEqual([]); + expect(out.stdoutText).toContain("No notebooks in"); + }).pipe(Effect.provide(layer)); + }); +}); + +describeHostedConsent("notebooks push", { + setup: (workdir) => + notebooksProject(workdir) + .write("brand-new", '{"content":{"cells":[]}}') + .pipe(Effect.provide(Path.layer)), + invoke: ({ workdir }) => { + const { http } = setupNotebooks({ + command: "push", + workdir, + routes: { + [`GET ${notebooksRoute()}`]: { status: 200, body: notebookListPage({ notebooks: [] }) }, + [`POST ${notebooksRoute()}`]: { + status: 201, + body: { data: notebookResource({ id: ERRORS_ID, name: "brand-new" }) }, + }, + }, + }); + return { + run: notebooksPushCore({ + notebookName: Option.none(), + projectRef: Option.some(VALID_REF), + }).pipe(Effect.provide(http.layer)), + mutations: () => http.requests.filter((request) => request.method !== "GET").length, + }; + }, }); diff --git a/apps/cli/src/commands/postgres-config/delete/delete.handler.ts b/apps/cli/src/commands/postgres-config/delete/delete.handler.ts index d77e3228da..14b50822cb 100644 --- a/apps/cli/src/commands/postgres-config/delete/delete.handler.ts +++ b/apps/cli/src/commands/postgres-config/delete/delete.handler.ts @@ -1,9 +1,11 @@ import { Effect } from "effect"; +import { requireHostedMutationConsent } from "../../../command-internal/hosted-consent/hosted-consent.ts"; 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 { Output } from "../../../shared/output/output.service.ts"; +import type { OutputTask } from "../../../shared/output/output.service.ts"; import { PostgresConfigDeleteNetworkError, PostgresConfigDeleteSerializeError, @@ -29,20 +31,34 @@ export const postgresConfigDelete = Effect.fn("postgres-config.delete")(function const ref = yield* resolver.resolve(flags.projectRef); yield* Effect.gen(function* () { - const deleting = - output.format === "text" ? yield* output.task("Deleting Postgres config...") : undefined; + const startTask = Effect.suspend((): Effect.Effect<OutputTask | undefined> => + output.format === "text" ? output.task("Deleting Postgres config...") : Effect.undefined, + ); + + const fetching = yield* startTask; const currentConfig = yield* fetchCurrentPostgresConfig(ref).pipe( - Effect.tapError(() => deleting?.fail() ?? Effect.void), + Effect.tapError(() => fetching?.fail() ?? Effect.void), ); + yield* fetching?.clear ?? Effect.void; - for (const key of flags.config) { - delete currentConfig[key.trim()]; + const keys = flags.config.map((key) => key.trim()); + for (const key of keys) { + delete currentConfig[key]; } if (flags.noRestart) { currentConfig["restart_database"] = false; } + yield* requireHostedMutationConsent({ + action: "Delete Postgres config overrides", + details: [ + ...keys.map((key) => `Delete ${key}`), + ...(flags.noRestart ? ["Does not restart the database"] : []), + ], + }); + + const deleting = yield* startTask; const updated = yield* putPostgresConfig(ref, currentConfig, { serializeError: (args) => new PostgresConfigDeleteSerializeError(args), networkError: (args) => new PostgresConfigDeleteNetworkError(args), diff --git a/apps/cli/src/commands/postgres-config/delete/delete.live.test.ts b/apps/cli/src/commands/postgres-config/delete/delete.live.test.ts index 9d3cbff18e..1a66ad96ee 100644 --- a/apps/cli/src/commands/postgres-config/delete/delete.live.test.ts +++ b/apps/cli/src/commands/postgres-config/delete/delete.live.test.ts @@ -29,6 +29,7 @@ test("removes the test-seeded override and get proves it is gone", ({ cli, cliEf const seeded = yield* cliEffect([ "postgres-config", "update", + "--yes", "--config", "maintenance_work_mem=16MB", ...flags, @@ -50,6 +51,7 @@ test("removes the test-seeded override and get proves it is gone", ({ cli, cliEf const removed = yield* cliEffect([ "postgres-config", "delete", + "--yes", "--config", "maintenance_work_mem", ...flags, diff --git a/apps/cli/src/commands/postgres-config/postgres-config.integration.test.ts b/apps/cli/src/commands/postgres-config/postgres-config.integration.test.ts index fcdda400ce..2706ccec9d 100644 --- a/apps/cli/src/commands/postgres-config/postgres-config.integration.test.ts +++ b/apps/cli/src/commands/postgres-config/postgres-config.integration.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "@effect/vitest"; -import { Cause, Effect, Exit, Option } from "effect"; +import { Cause, Effect, Exit, Layer, Option } from "effect"; +import { describeHostedConsent } from "../../../tests/helpers/hosted-consent.ts"; import { mockOutput } from "../../../tests/helpers/mocks.ts"; import { VALID_REF, @@ -14,6 +15,8 @@ import { useTempWorkdir, unverifiedTargetBanner, } from "../../../tests/helpers/command-mocks.ts"; +import { YesFlag } from "../../command-internal/global-flags.ts"; +import { TargetPolicy } from "../../command-internal/target/target-policy.ts"; import { postgresConfigDelete } from "./delete/delete.handler.ts"; import { postgresConfigGet } from "./get/get.handler.ts"; import { postgresConfigUpdate } from "./update/update.handler.ts"; @@ -22,6 +25,11 @@ type OutputFlagValue = "env" | "pretty" | "json" | "toml" | "yaml"; const tempRoot = useTempWorkdir("supabase-postgres-config-int-"); +const confirmedBy = (action: string) => + `Confirmed by --yes: ${action} on project ${VALID_REF} · identity unverified\n`; +const UPDATE_CONFIRMED = confirmedBy("Update the Postgres config overrides"); +const DELETE_CONFIRMED = confirmedBy("Delete Postgres config overrides"); + function runtimeWith(opts: { readonly out: ReturnType<typeof mockOutput>; readonly api: ReturnType<typeof mockCommandPlatformApi>; @@ -29,14 +37,18 @@ function runtimeWith(opts: { readonly linkedProjectCache?: ReturnType<typeof mockLinkedProjectCacheTracked>["layer"]; readonly outputFlag?: OutputFlagValue; }) { - return buildTestRuntime({ - out: opts.out, - api: opts.api, - cliSettings: mockCommandSettings({ workdir: tempRoot.current }), - telemetry: opts.telemetry, - linkedProjectCache: opts.linkedProjectCache, - outputFlag: opts.outputFlag === undefined ? Option.none() : Option.some(opts.outputFlag), - }); + return Layer.mergeAll( + buildTestRuntime({ + out: opts.out, + api: opts.api, + cliSettings: mockCommandSettings({ workdir: tempRoot.current }), + telemetry: opts.telemetry, + linkedProjectCache: opts.linkedProjectCache, + outputFlag: opts.outputFlag === undefined ? Option.none() : Option.some(opts.outputFlag), + }), + Layer.succeed(YesFlag, true), + Layer.succeed(TargetPolicy, { group: "hosted", consent: "no" }), + ); } describe("postgres-config get", () => { @@ -290,6 +302,7 @@ describe("postgres-config update", () => { }); expect(out.stderrText).toBe( unverifiedTargetBanner() + + UPDATE_CONFIRMED + "- Custom Postgres Config -\n- End of Custom Postgres Config -\n", ); }).pipe(Effect.provide(layer)); @@ -356,7 +369,7 @@ describe("postgres-config update", () => { noRestart: false, }).pipe(Effect.provide(layer)); expect(out.stdoutText).toContain(testCase.expected); - expect(out.stderrText).toBe(unverifiedTargetBanner()); + expect(out.stderrText).toBe(unverifiedTargetBanner() + UPDATE_CONFIRMED); } }), ); @@ -505,6 +518,7 @@ describe("postgres-config delete", () => { expect(out.stdoutText).toContain("shared_buffers"); expect(out.stderrText).toBe( unverifiedTargetBanner() + + DELETE_CONFIRMED + "- Custom Postgres Config -\n- End of Custom Postgres Config -\n", ); }).pipe(Effect.provide(layer)); @@ -567,7 +581,7 @@ describe("postgres-config delete", () => { noRestart: false, }).pipe(Effect.provide(layer)); expect(out.stdoutText).toContain(testCase.expected); - expect(out.stderrText).toBe(unverifiedTargetBanner()); + expect(out.stderrText).toBe(unverifiedTargetBanner() + DELETE_CONFIRMED); } }), ); @@ -659,3 +673,36 @@ describe("postgres-config delete", () => { }).pipe(Effect.provide(layer)); }); }); + +describeHostedConsent("postgres-config update", { + api: { + byMethod: { + GET: { status: 200, body: { wal_keep_size: "1GB" } }, + PUT: { status: 200, body: { wal_keep_size: "1GB", max_connections: 100 } }, + }, + }, + invoke: () => ({ + run: postgresConfigUpdate({ + projectRef: Option.some(VALID_REF), + config: ["max_connections=100"], + replaceExistingOverrides: false, + noRestart: false, + }), + }), +}); + +describeHostedConsent("postgres-config delete", { + api: { + byMethod: { + GET: { status: 200, body: { max_connections: 100, shared_buffers: "1GB" } }, + PUT: { status: 200, body: { shared_buffers: "1GB" } }, + }, + }, + invoke: () => ({ + run: postgresConfigDelete({ + projectRef: Option.some(VALID_REF), + config: ["max_connections"], + noRestart: false, + }), + }), +}); diff --git a/apps/cli/src/commands/postgres-config/update/update.handler.ts b/apps/cli/src/commands/postgres-config/update/update.handler.ts index db960d2949..eb0650e200 100644 --- a/apps/cli/src/commands/postgres-config/update/update.handler.ts +++ b/apps/cli/src/commands/postgres-config/update/update.handler.ts @@ -1,9 +1,11 @@ import { Effect } from "effect"; +import { requireHostedMutationConsent } from "../../../command-internal/hosted-consent/hosted-consent.ts"; 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 { Output } from "../../../shared/output/output.service.ts"; +import type { OutputTask } from "../../../shared/output/output.service.ts"; import { PostgresConfigInvalidConfigValueError, PostgresConfigUpdateNetworkError, @@ -41,14 +43,17 @@ export const postgresConfigUpdate = Effect.fn("postgres-config.update")(function const ref = yield* resolver.resolve(flags.projectRef); yield* Effect.gen(function* () { - const updating = - output.format === "text" ? yield* output.task("Updating Postgres config...") : undefined; + const startTask = Effect.suspend((): Effect.Effect<OutputTask | undefined> => + output.format === "text" ? output.task("Updating Postgres config...") : Effect.undefined, + ); + const fetching = flags.replaceExistingOverrides ? undefined : yield* startTask; const finalOverrides = flags.replaceExistingOverrides ? {} : yield* fetchCurrentPostgresConfig(ref).pipe( - Effect.tapError(() => updating?.fail() ?? Effect.void), + Effect.tapError(() => fetching?.fail() ?? Effect.void), ); + yield* fetching?.clear ?? Effect.void; for (const [key, value] of Object.entries(nextOverrides)) { finalOverrides[key] = parseConfigValue(value); @@ -60,6 +65,16 @@ export const postgresConfigUpdate = Effect.fn("postgres-config.update")(function normalizeTimeoutConfig(finalOverrides); + yield* requireHostedMutationConsent({ + action: "Update the Postgres config overrides", + details: [ + ...(flags.replaceExistingOverrides ? ["Replaces all existing overrides"] : []), + ...Object.entries(nextOverrides).map(([key, value]) => `Set ${key} = ${value}`), + ...(flags.noRestart ? ["Does not restart the database"] : []), + ], + }); + + const updating = yield* startTask; const updated = yield* putPostgresConfig(ref, finalOverrides, { serializeError: (args) => new PostgresConfigUpdateSerializeError(args), networkError: (args) => new PostgresConfigUpdateNetworkError(args), diff --git a/apps/cli/src/commands/postgres-config/update/update.live.test.ts b/apps/cli/src/commands/postgres-config/update/update.live.test.ts index 97bcaa123a..a9c3c5076a 100644 --- a/apps/cli/src/commands/postgres-config/update/update.live.test.ts +++ b/apps/cli/src/commands/postgres-config/update/update.live.test.ts @@ -23,6 +23,7 @@ test("applies an override with --no-restart and get proves it", ({ cli, cliEffec const updated = yield* cliEffect([ "postgres-config", "update", + "--yes", "--config", "work_mem=7MB", ...flags, diff --git a/apps/cli/src/commands/ssl-enforcement/update/update.handler.ts b/apps/cli/src/commands/ssl-enforcement/update/update.handler.ts index 9b9577baf2..6355424059 100644 --- a/apps/cli/src/commands/ssl-enforcement/update/update.handler.ts +++ b/apps/cli/src/commands/ssl-enforcement/update/update.handler.ts @@ -5,6 +5,7 @@ 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 { OutputFlag } from "../../../command-internal/global-flags.ts"; +import { requireHostedMutationConsent } from "../../../command-internal/hosted-consent/hosted-consent.ts"; import { Output } from "../../../shared/output/output.service.ts"; import { encodeEnv, encodeSortedJson } from "../../../command-internal/output.encoders.ts"; import { @@ -54,6 +55,12 @@ export const sslEnforcementUpdate = Effect.fn("ssl-enforcement.update")(function const ref = yield* resolver.resolve(flags.projectRef); yield* Effect.gen(function* () { + yield* requireHostedMutationConsent({ + action: flags.enableDbSslEnforcement + ? "Enable SSL enforcement for database connections" + : "Disable SSL enforcement for database connections", + }); + const updating = output.format === "text" ? yield* output.task("Updating SSL enforcement config...") diff --git a/apps/cli/src/commands/ssl-enforcement/update/update.integration.test.ts b/apps/cli/src/commands/ssl-enforcement/update/update.integration.test.ts index 6be7fda23b..7206fee61d 100644 --- a/apps/cli/src/commands/ssl-enforcement/update/update.integration.test.ts +++ b/apps/cli/src/commands/ssl-enforcement/update/update.integration.test.ts @@ -1,9 +1,10 @@ import { BunServices } from "@effect/platform-bun"; import { type V1GetSslEnforcementConfigOutput } from "@supabase/api/effect"; import { describe, expect, it } from "@effect/vitest"; -import { Cause, Effect, Exit, FileSystem, Option, Path } from "effect"; +import { Cause, Effect, Exit, FileSystem, Layer, Option, Path } from "effect"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; +import { describeHostedConsent } from "../../../../tests/helpers/hosted-consent.ts"; import { mockOutput } from "../../../../tests/helpers/mocks.ts"; import { VALID_REF, @@ -14,6 +15,8 @@ import { mockTelemetryStateTracked, useTempWorkdir, } from "../../../../tests/helpers/command-mocks.ts"; +import { YesFlag } from "../../../command-internal/global-flags.ts"; +import { TargetPolicy } from "../../../command-internal/target/target-policy.ts"; import { sslEnforcementUpdate } from "./update.handler.ts"; const SSL_ENFORCED: typeof V1GetSslEnforcementConfigOutput.Type = { @@ -31,6 +34,11 @@ const SSL_DESIRED_BUT_NOT_APPLIED: typeof V1GetSslEnforcementConfigOutput.Type = appliedSuccessfully: false, }; +const consentApproved = Layer.mergeAll( + Layer.succeed(YesFlag, true), + Layer.succeed(TargetPolicy, { group: "hosted", consent: "no" }), +); + interface SetupOpts { format?: "text" | "json" | "stream-json"; outputFlag?: "env" | "pretty" | "json" | "toml" | "yaml"; @@ -48,12 +56,15 @@ function setup(opts: SetupOpts = {}) { network: opts.network, }); const cliSettings = mockCommandSettings({ workdir: tempRoot.current }); - const layer = buildTestRuntime({ - out, - api, - cliSettings, - outputFlag: opts.outputFlag === undefined ? Option.none() : Option.some(opts.outputFlag), - }); + const layer = Layer.mergeAll( + buildTestRuntime({ + out, + api, + cliSettings, + outputFlag: opts.outputFlag === undefined ? Option.none() : Option.some(opts.outputFlag), + }), + consentApproved, + ); return { layer, out, api }; } @@ -66,13 +77,16 @@ function setupTracked(opts: SetupOpts = {}) { const cliSettings = mockCommandSettings({ workdir: tempRoot.current }); const telemetry = mockTelemetryStateTracked(); const cache = mockLinkedProjectCacheTracked(); - const layer = buildTestRuntime({ - out, - api, - cliSettings, - telemetry: telemetry.layer, - linkedProjectCache: cache.layer, - }); + const layer = Layer.mergeAll( + buildTestRuntime({ + out, + api, + cliSettings, + telemetry: telemetry.layer, + linkedProjectCache: cache.layer, + }), + consentApproved, + ); return { layer, out, api, telemetry, cache }; } @@ -381,7 +395,7 @@ describe("ssl-enforcement update integration", () => { workdir: localTempRoot, projectId: Option.none(), }); - const layer = buildTestRuntime({ out, api, cliSettings }); + const layer = Layer.mergeAll(buildTestRuntime({ out, api, cliSettings }), consentApproved); yield* sslEnforcementUpdate({ projectRef: Option.none(), @@ -404,7 +418,7 @@ describe("ssl-enforcement update integration", () => { workdir: localTempRoot, projectId: Option.none(), }); - const layer = buildTestRuntime({ out, api, cliSettings }); + const layer = Layer.mergeAll(buildTestRuntime({ out, api, cliSettings }), consentApproved); const exit = yield* Effect.exit( sslEnforcementUpdate({ @@ -487,3 +501,19 @@ describe("ssl-enforcement update integration", () => { }).pipe(Effect.provide(layer)); }); }); + +describeHostedConsent("ssl-enforcement update", { + api: { + response: { + status: 200, + body: { currentConfig: { database: true }, appliedSuccessfully: true }, + }, + }, + invoke: () => ({ + run: sslEnforcementUpdate({ + projectRef: Option.some(VALID_REF), + enableDbSslEnforcement: true, + disableDbSslEnforcement: false, + }), + }), +}); diff --git a/apps/cli/src/commands/ssl-enforcement/update/update.live.test.ts b/apps/cli/src/commands/ssl-enforcement/update/update.live.test.ts index 96f649e96b..edf98c040a 100644 --- a/apps/cli/src/commands/ssl-enforcement/update/update.live.test.ts +++ b/apps/cli/src/commands/ssl-enforcement/update/update.live.test.ts @@ -182,7 +182,15 @@ test( const toggle = Effect.gen(function* () { const updated = yield* cliEffect( - ["ssl-enforcement", "update", enforcementFlag(!posture), ...flags, "-o", "json"], + [ + "ssl-enforcement", + "update", + "--yes", + enforcementFlag(!posture), + ...flags, + "-o", + "json", + ], { exitTimeoutMs: RESTORE_EXIT_TIMEOUT_MS }, ); expect(updated.exitCode, updated.stderr).toBe(0); @@ -210,7 +218,7 @@ test( const restore = Effect.gen(function* () { const restored = yield* cliEffect( - ["ssl-enforcement", "update", enforcementFlag(posture), ...flags], + ["ssl-enforcement", "update", "--yes", enforcementFlag(posture), ...flags], { exitTimeoutMs: RESTORE_EXIT_TIMEOUT_MS }, ); yield* requireSuccess(restored, "ssl-enforcement update restore of the captured posture"); diff --git a/apps/cli/src/commands/sso/add/add.handler.ts b/apps/cli/src/commands/sso/add/add.handler.ts index 6a1492d294..ee4e327109 100644 --- a/apps/cli/src/commands/sso/add/add.handler.ts +++ b/apps/cli/src/commands/sso/add/add.handler.ts @@ -5,6 +5,7 @@ import * as HttpClientRequest from "effect/http/HttpClientRequest"; import { CommandSettings } from "../../../config/command-settings.service.ts"; import { ProjectRefResolver } from "../../../config/project-ref.service.ts"; import { OutputFlag } from "../../../command-internal/global-flags.ts"; +import { requireHostedMutationConsent } from "../../../command-internal/hosted-consent/hosted-consent.ts"; import { mutuallyExclusiveFlagsMessage, PERSISTENT_VALUE_FLAG_NAMES, @@ -236,6 +237,16 @@ export const ssoAdd = Effect.fn("sso.add")(function* (flags: SsoAddFlags) { body["name_id_format"] = nameIdFormat.value; } + yield* requireHostedMutationConsent({ + action: "Add an SSO provider", + details: [ + `Type: ${flags.type}`, + ...(domains.length > 0 ? [`Domains: ${domains.join(", ")}`] : []), + ...(Option.isSome(metadataFile) ? ["Metadata: from a file"] : []), + ...(Option.isSome(metadataUrl) ? ["Metadata: from a URL"] : []), + ], + }); + const creating = output.format === "text" ? yield* output.task("Adding SSO provider...") : undefined; diff --git a/apps/cli/src/commands/sso/add/add.integration.test.ts b/apps/cli/src/commands/sso/add/add.integration.test.ts index d04cc43123..ba7d03b54b 100644 --- a/apps/cli/src/commands/sso/add/add.integration.test.ts +++ b/apps/cli/src/commands/sso/add/add.integration.test.ts @@ -4,6 +4,7 @@ import { Cause, Effect, Exit, FileSystem, Layer, Option, Path, Predicate, Stdio import * as HttpClientResponse from "effect/http/HttpClientResponse"; import { withEnvVar } from "../../../../tests/helpers/command-mocks.ts"; +import { describeHostedConsent } from "../../../../tests/helpers/hosted-consent.ts"; import { mockAnalytics, mockOutput } from "../../../../tests/helpers/mocks.ts"; import { buildTestRuntime, @@ -15,7 +16,8 @@ import { mockTelemetryStateTracked, useTempWorkdir, } from "../../../../tests/helpers/command-mocks.ts"; -import { ProfileFlag } from "../../../command-internal/global-flags.ts"; +import { ProfileFlag, YesFlag } from "../../../command-internal/global-flags.ts"; +import { TargetPolicy } from "../../../command-internal/target/target-policy.ts"; import { EventUpgradeSuggested } from "../../../shared/telemetry/event-catalog.ts"; import { classifyCliCauseActionability } from "../../../shared/telemetry/error-actionability.ts"; import type { SsoAddFlags } from "./add.command.ts"; @@ -159,6 +161,8 @@ function setup(opts: SetupOpts = {}) { args: Effect.succeed(opts.cliArgs ?? ["sso", "add", "--type", "saml"]), }), opts.profileFlag === undefined ? Layer.empty : Layer.succeed(ProfileFlag, opts.profileFlag), + Layer.succeed(YesFlag, true), + Layer.succeed(TargetPolicy, { group: "hosted", consent: "no" }), ); return { layer, out, api, analytics, telemetry, cache }; @@ -1188,3 +1192,12 @@ describe("sso add integration", () => { }).pipe(Effect.provide(BunServices.layer)), ); }); + +describeHostedConsent("sso add", { + api: { response: { status: 201, body: RESPONSE_PROVIDER } }, + invoke: () => ({ + run: ssoAdd({ ...defaultFlags, projectRef: Option.some(VALID_REF) }).pipe( + Effect.provide(Stdio.layerTest({ args: Effect.succeed(["sso", "add", "--type", "saml"]) })), + ), + }), +}); diff --git a/apps/cli/src/commands/sso/remove/remove.handler.ts b/apps/cli/src/commands/sso/remove/remove.handler.ts index 046648081a..bf79747425 100644 --- a/apps/cli/src/commands/sso/remove/remove.handler.ts +++ b/apps/cli/src/commands/sso/remove/remove.handler.ts @@ -4,6 +4,7 @@ import { Effect, Option } from "effect"; import { CommandPlatformApi } from "../../../auth/command-platform-api.service.ts"; import { ProjectRefResolver } from "../../../config/project-ref.service.ts"; import { OutputFlag } from "../../../command-internal/global-flags.ts"; +import { requireHostedMutationConsent } from "../../../command-internal/hosted-consent/hosted-consent.ts"; import { Output } from "../../../shared/output/output.service.ts"; import { encodeSortedJson } from "../../../command-internal/output.encoders.ts"; import { @@ -71,6 +72,8 @@ export const ssoRemove = Effect.fn("sso.remove")(function* (flags: SsoRemoveFlag const ref = yield* resolver.resolve(flags.projectRef); yield* Effect.gen(function* () { + yield* requireHostedMutationConsent({ action: `Remove SSO provider ${providerId}` }); + const removing = output.format === "text" ? yield* output.task("Removing SSO provider...") : undefined; const response = yield* api.v1.deleteASsoProvider({ ref, provider_id: providerId }).pipe( diff --git a/apps/cli/src/commands/sso/remove/remove.integration.test.ts b/apps/cli/src/commands/sso/remove/remove.integration.test.ts index 3f47e2c0b6..a8bddf451f 100644 --- a/apps/cli/src/commands/sso/remove/remove.integration.test.ts +++ b/apps/cli/src/commands/sso/remove/remove.integration.test.ts @@ -1,7 +1,8 @@ import { describe, expect, it } from "@effect/vitest"; -import { Cause, Effect, Exit, Option } from "effect"; +import { Cause, Effect, Exit, Layer, Option } from "effect"; import * as HttpClientResponse from "effect/http/HttpClientResponse"; +import { describeHostedConsent } from "../../../../tests/helpers/hosted-consent.ts"; import { mockAnalytics, mockOutput } from "../../../../tests/helpers/mocks.ts"; import { buildTestRuntime, @@ -12,6 +13,8 @@ import { mockTelemetryStateTracked, useTempWorkdir, } from "../../../../tests/helpers/command-mocks.ts"; +import { YesFlag } from "../../../command-internal/global-flags.ts"; +import { TargetPolicy } from "../../../command-internal/target/target-policy.ts"; import { EventUpgradeSuggested } from "../../../shared/telemetry/event-catalog.ts"; import { classifyCliCauseActionability } from "../../../shared/telemetry/error-actionability.ts"; import { ssoRemove } from "./remove.handler.ts"; @@ -108,15 +111,19 @@ function setup(opts: SetupOpts = {}) { }); const cliSettings = mockCommandSettings({ workdir: tempRoot.current }); - const layer = buildTestRuntime({ - out, - api, - cliSettings, - telemetry: telemetry.layer, - linkedProjectCache: cache.layer, - analytics, - outputFlag: opts.outputFlag === undefined ? Option.none() : Option.some(opts.outputFlag), - }); + const layer = Layer.mergeAll( + buildTestRuntime({ + out, + api, + cliSettings, + telemetry: telemetry.layer, + linkedProjectCache: cache.layer, + analytics, + outputFlag: opts.outputFlag === undefined ? Option.none() : Option.some(opts.outputFlag), + }), + Layer.succeed(YesFlag, true), + Layer.succeed(TargetPolicy, { group: "hosted", consent: "no" }), + ); return { layer, out, api, analytics, telemetry, cache }; } @@ -293,3 +300,10 @@ describe("sso remove integration", () => { }).pipe(Effect.provide(layer)); }); }); + +describeHostedConsent("sso remove", { + api: { response: { status: 200, body: PROVIDER } }, + invoke: () => ({ + run: ssoRemove({ projectRef: Option.some(VALID_REF), providerId: VALID_PROVIDER_ID }), + }), +}); diff --git a/apps/cli/src/commands/sso/update/update.handler.ts b/apps/cli/src/commands/sso/update/update.handler.ts index 3fae19b7ac..e45d9dd14f 100644 --- a/apps/cli/src/commands/sso/update/update.handler.ts +++ b/apps/cli/src/commands/sso/update/update.handler.ts @@ -8,6 +8,7 @@ import { CommandSettings } from "../../../config/command-settings.service.ts"; import { IdentityStitch } from "../../../command-internal/identity-stitch.ts"; import { ProjectRefResolver } from "../../../config/project-ref.service.ts"; import { OutputFlag } from "../../../command-internal/global-flags.ts"; +import { requireHostedMutationConsent } from "../../../command-internal/hosted-consent/hosted-consent.ts"; import { mutuallyExclusiveFlagsMessage, PERSISTENT_VALUE_FLAG_NAMES, @@ -15,6 +16,7 @@ import { scanArgvFlags, } from "../../../shared/cli/flag-groups.ts"; import { Output } from "../../../shared/output/output.service.ts"; +import type { OutputTask } from "../../../shared/output/output.service.ts"; import { encodeSortedJson, encodeSortedJsonBody, @@ -300,8 +302,10 @@ export const ssoUpdate = Effect.fn("sso.update")(function* (flags: SsoUpdateFlag }); yield* Effect.gen(function* () { - const fetching = - output.format === "text" ? yield* output.task("Updating SSO provider...") : undefined; + const startTask = Effect.suspend((): Effect.Effect<OutputTask | undefined> => + output.format === "text" ? output.task("Updating SSO provider...") : Effect.undefined, + ); + const fetching = yield* startTask; // The typed client bakes the layer's apiUrl in at construction, so when // the reconciled profile differs, the GET must be issued raw against @@ -422,18 +426,31 @@ export const ssoUpdate = Effect.fn("sso.update")(function* (flags: SsoUpdateFlag body["attribute_mapping"] = mapping; } - if (domains.length > 0) { - body["domains"] = [...domains]; - } else { - // `domains` is always recomputed and sent, even when no domain flag - // was passed; an empty merged set serializes as `"domains":[]`, never omitted. - body["domains"] = mergeDomains(existing.domains, addDomains, removeDomains); - } + // `domains` is always recomputed and sent, even when no domain flag + // was passed; an empty merged set serializes as `"domains":[]`, never omitted. + const finalDomains = + domains.length > 0 + ? [...domains] + : mergeDomains(existing.domains, addDomains, removeDomains); + body["domains"] = finalDomains; if (Option.isSome(nameIdFormat)) { body["name_id_format"] = nameIdFormat.value; } + yield* fetching?.clear ?? Effect.void; + yield* requireHostedMutationConsent({ + action: `Update SSO provider ${providerId}`, + details: [ + ...(Option.isSome(metadataFile) ? ["Metadata: from a file"] : []), + ...(Option.isSome(metadataUrl) ? ["Metadata: from a URL"] : []), + ...(Option.isSome(attributeMappingFile) ? ["Attribute mapping: replaced"] : []), + ...(Option.isSome(nameIdFormat) ? [`Name ID format: ${nameIdFormat.value}`] : []), + `Domains: ${finalDomains.join(", ") || "(none)"}`, + ], + }); + const updating = yield* startTask; + const tokenOpt = reconciledTokenCached !== undefined ? yield* Effect.filterOrFail( @@ -456,7 +473,7 @@ export const ssoUpdate = Effect.fn("sso.update")(function* (flags: SsoUpdateFlag ); const response = yield* httpClient.execute(request).pipe( - Effect.tapError(() => fetching?.fail() ?? Effect.void), + Effect.tapError(() => updating?.fail() ?? Effect.void), Effect.mapError( (cause) => new SsoUpdateNetworkError({ @@ -479,7 +496,7 @@ export const ssoUpdate = Effect.fn("sso.update")(function* (flags: SsoUpdateFlag token !== undefined ? { accessToken: token } : {}, )), }); - yield* fetching?.fail() ?? Effect.void; + yield* updating?.fail() ?? Effect.void; // Reuses the GET error message even for PUT. return yield* new SsoUpdateUnexpectedStatusError({ status: response.status, @@ -490,7 +507,7 @@ export const ssoUpdate = Effect.fn("sso.update")(function* (flags: SsoUpdateFlag } const parsedJson = yield* response.json.pipe(Effect.orElseSucceed((): unknown => ({}))); - yield* fetching?.clear ?? Effect.void; + yield* updating?.clear ?? Effect.void; const outputFlagFormat = Option.getOrUndefined(outputFlag); diff --git a/apps/cli/src/commands/sso/update/update.integration.test.ts b/apps/cli/src/commands/sso/update/update.integration.test.ts index 18f32cd05b..b6a385815f 100644 --- a/apps/cli/src/commands/sso/update/update.integration.test.ts +++ b/apps/cli/src/commands/sso/update/update.integration.test.ts @@ -17,6 +17,7 @@ import { import * as HttpClientResponse from "effect/http/HttpClientResponse"; import { withEnvVar } from "../../../../tests/helpers/command-mocks.ts"; +import { describeHostedConsent } from "../../../../tests/helpers/hosted-consent.ts"; import { mockAnalytics, mockOutput, mockRuntimeInfo } from "../../../../tests/helpers/mocks.ts"; import { buildTestRuntime, @@ -27,7 +28,8 @@ import { mockTelemetryStateTracked, useTempWorkdir, } from "../../../../tests/helpers/command-mocks.ts"; -import { ProfileFlag } from "../../../command-internal/global-flags.ts"; +import { ProfileFlag, YesFlag } from "../../../command-internal/global-flags.ts"; +import { TargetPolicy } from "../../../command-internal/target/target-policy.ts"; import { IdentityStitch } from "../../../command-internal/identity-stitch.ts"; import { EventUpgradeSuggested } from "../../../shared/telemetry/event-catalog.ts"; import { classifyCliCauseActionability } from "../../../shared/telemetry/error-actionability.ts"; @@ -199,6 +201,8 @@ function setup(opts: SetupOpts = {}) { args: Effect.succeed(opts.cliArgs ?? ["sso", "update", VALID_PROVIDER_ID]), }), stitchLayer, + Layer.succeed(YesFlag, true), + Layer.succeed(TargetPolicy, { group: "hosted", consent: "no" }), opts.profileFlag === undefined ? Layer.empty : Layer.succeed(ProfileFlag, opts.profileFlag), ConfigProvider.layer( ConfigProvider.fromEnvRecord({ SUPABASE_NO_KEYRING: "1" }, { preserveEmptyStrings: true }), @@ -1742,3 +1746,19 @@ describe("sso update integration", () => { }).pipe(Effect.provide(layer), (body) => withProfileEnv(undefined, body)); }); }); + +describeHostedConsent("sso update", { + api: { + byMethod: { + GET: { status: 200, body: EXISTING_PROVIDER }, + PUT: { status: 200, body: RESPONSE_PROVIDER }, + }, + }, + invoke: () => ({ + run: ssoUpdate({ ...defaultFlags, projectRef: Option.some(VALID_REF) }).pipe( + Effect.provide( + Stdio.layerTest({ args: Effect.succeed(["sso", "update", VALID_PROVIDER_ID]) }), + ), + ), + }), +}); diff --git a/apps/cli/tests/helpers/notebooks.ts b/apps/cli/tests/helpers/notebooks.ts index bd938add82..08536954e8 100644 --- a/apps/cli/tests/helpers/notebooks.ts +++ b/apps/cli/tests/helpers/notebooks.ts @@ -9,14 +9,16 @@ import * as UrlParams from "effect/http/UrlParams"; import { CommandPlatformApi } from "../../src/auth/command-platform-api.service.ts"; import { ProjectRefNotLinkedError } from "../../src/config/project-ref.errors.ts"; import { ProjectRefResolver } from "../../src/config/project-ref.service.ts"; -import { OutputFlag } from "../../src/command-internal/global-flags.ts"; +import { OutputFlag, YesFlag } from "../../src/command-internal/global-flags.ts"; +import { TargetPolicy } from "../../src/command-internal/target/target-policy.ts"; +import { CliArgs } from "../../src/shared/cli/cli-args.service.ts"; import { mockLinkedProjectCacheTracked, mockTelemetryStateTracked, mockCommandSettings, transportFailure, } from "./command-mocks.ts"; -import { mockOutput, mockContextualAnalytics, mockProcessControl } from "./mocks.ts"; +import { mockOutput, mockContextualAnalytics, mockProcessControl, mockTty } from "./mocks.ts"; import { commandRuntimeLayer } from "../../src/shared/runtime/command-runtime.layer.ts"; /** @@ -252,20 +254,28 @@ export interface NotebooksSetupOptions { readonly linked?: boolean; /** Answers the reconciliation prompt: `"keep"`, `"copy"` or `"delete"`. */ readonly promptSelectResponses?: ReadonlyArray<string>; + /** Answers the hosted-mutation confirmation. */ + readonly promptConfirmResponses?: ReadonlyArray<boolean>; readonly routes?: NotebooksHttpRoutes; /** The `-o`/`--output` flag, which every command family here honours. */ readonly outputFlag?: "env" | "pretty" | "json" | "toml" | "yaml" | "table" | "csv"; readonly command?: "pull" | "push"; readonly args?: ReadonlyArray<string>; + /** The global `--yes` flag; on by default so push tests are not blocked by the hosted-mutation consent. */ + readonly yes?: boolean; } export function setupNotebooks(options: NotebooksSetupOptions) { + const interactive = options.interactive ?? (options.format ?? "text") === "text"; const out = mockOutput({ format: options.format ?? "text", - interactive: options.interactive ?? (options.format ?? "text") === "text", + interactive, ...(options.promptSelectResponses === undefined ? {} : { promptSelectResponses: options.promptSelectResponses }), + ...(options.promptConfirmResponses === undefined + ? {} + : { promptConfirmResponses: options.promptConfirmResponses }), }); const http = mockNotebooksHttp(options.routes ?? {}); @@ -297,6 +307,10 @@ export function setupNotebooks(options: NotebooksSetupOptions) { ), BunServices.layer, Stdio.layerTest({ args: Effect.succeed(options.args ?? ["notebooks", command]) }), + Layer.succeed(YesFlag, options.yes ?? true), + Layer.succeed(CliArgs, { args: [] }), + mockTty({ stdinIsTty: interactive, stdoutIsTty: interactive }), + Layer.succeed(TargetPolicy, { group: "hosted", consent: command === "push" ? "no" : "none" }), ), }; } From 4cb5f9c3638ebc586da228128713144b6680e3af Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Thu, 15 Oct 2026 14:27:03 +0100 Subject: [PATCH 31/92] docs(cli): document hosted consent and the target banner for the hosted group (CLI-2695) --- apps/cli/docs/supabase/network-bans.md | 2 + .../docs/supabase/postgres-config/update.md | 1 + .../src/commands/encryption/SIDE_EFFECTS.md | 28 +++++++----- .../commands/network-bans/get/SIDE_EFFECTS.md | 1 + .../network-bans/remove/SIDE_EFFECTS.md | 18 +++++--- .../network-restrictions/get/SIDE_EFFECTS.md | 1 + .../update/SIDE_EFFECTS.md | 17 +++++--- .../commands/notebooks/pull/SIDE_EFFECTS.md | 2 + .../commands/notebooks/push/SIDE_EFFECTS.md | 43 +++++++++++-------- .../postgres-config/delete/SIDE_EFFECTS.md | 18 +++++--- .../postgres-config/get/SIDE_EFFECTS.md | 1 + .../postgres-config/update/SIDE_EFFECTS.md | 18 +++++--- .../snippets/download/SIDE_EFFECTS.md | 1 + .../commands/snippets/list/SIDE_EFFECTS.md | 1 + .../ssl-enforcement/get/SIDE_EFFECTS.md | 1 + .../ssl-enforcement/update/SIDE_EFFECTS.md | 17 +++++--- apps/cli/src/commands/sso/add/SIDE_EFFECTS.md | 13 ++++-- .../cli/src/commands/sso/info/SIDE_EFFECTS.md | 1 + .../cli/src/commands/sso/list/SIDE_EFFECTS.md | 1 + .../src/commands/sso/remove/SIDE_EFFECTS.md | 27 +++++++----- .../cli/src/commands/sso/show/SIDE_EFFECTS.md | 1 + .../src/commands/sso/update/SIDE_EFFECTS.md | 14 ++++-- 22 files changed, 150 insertions(+), 77 deletions(-) diff --git a/apps/cli/docs/supabase/network-bans.md b/apps/cli/docs/supabase/network-bans.md index 7f39d21dbc..a2a5fc4a43 100644 --- a/apps/cli/docs/supabase/network-bans.md +++ b/apps/cli/docs/supabase/network-bans.md @@ -3,3 +3,5 @@ Network bans are IPs that get temporarily blocked if their traffic pattern looks abusive (e.g. multiple failed auth attempts). The subcommands help you view the current bans, and unblock IPs if desired. + +`remove` asks for confirmation (default No) before unblocking. Pass `--yes` to confirm without asking; JSON, stream-json, `-o` and non-interactive runs fail without it. diff --git a/apps/cli/docs/supabase/postgres-config/update.md b/apps/cli/docs/supabase/postgres-config/update.md index c7fde38474..5cf2af8197 100644 --- a/apps/cli/docs/supabase/postgres-config/update.md +++ b/apps/cli/docs/supabase/postgres-config/update.md @@ -2,3 +2,4 @@ Overriding the default Postgres config could result in unstable database behavior. Custom configuration also overrides the optimizations generated based on the compute add-ons in use. +The update asks for confirmation (default No) before changing the overrides. Pass `--yes` to confirm without asking; JSON, stream-json, `-o` and non-interactive runs fail without it. diff --git a/apps/cli/src/commands/encryption/SIDE_EFFECTS.md b/apps/cli/src/commands/encryption/SIDE_EFFECTS.md index 49a0bad373..e6c736ff5a 100644 --- a/apps/cli/src/commands/encryption/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/encryption/SIDE_EFFECTS.md @@ -30,20 +30,23 @@ additionally reads the new key from stdin. ## Environment Variables -| Variable | Purpose | Required? | -| ----------------------- | ---------------------------------------------------- | ------------------------------------------------------- | -| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | -| `SUPABASE_PROJECT_ID` | project ref (fallback when `--project-ref` unset) | no (falls back to linked-project file → prompt) | -| `SUPABASE_PROFILE` | built-in profile name or YAML file path | no (falls back to `~/.supabase/profile` -> `supabase`) | +| Variable | Purpose | Required? | +| ----------------------- | ----------------------------------------------------------------------------------------------------- | ------------------------------------------------------- | +| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | +| `SUPABASE_PROJECT_ID` | project ref (fallback when `--project-ref` unset) | no (falls back to linked-project file → prompt) | +| `SUPABASE_PROFILE` | built-in profile name or YAML file path | no (falls back to `~/.supabase/profile` -> `supabase`) | +| `SUPABASE_YES` | confirms the hosted mutation without asking; only the shell environment counts, never `supabase/.env` | no (falls back to the interactive prompt) | ## Exit Codes -| Code | Condition | -| ---- | ----------------------------------------- | -| `0` | success | -| `1` | project ref unresolved / malformed | -| `1` | network / connection failure | -| `1` | non-200 status from the pgsodium endpoint | +| Code | Condition | +| ---- | --------------------------------------------------------------------------------------------------------------------------------------------- | +| `0` | success | +| `1` | project ref unresolved / malformed | +| `1` | network / connection failure | +| `1` | non-200 status from the pgsodium endpoint | +| `1` | `update-root-key`: declined at the confirmation prompt (`HostedMutationDeclinedError`, `context canceled`) — nothing is changed | +| `1` | `update-root-key`: JSON, stream-json, `-o` or non-interactive run without `--yes` (`HostedMutationConsentRequiredError`) — nothing is changed | ## Telemetry Events Fired @@ -82,3 +85,6 @@ One `result` event carrying `{root_key}` (both subcommands). - The TTY prompt is a clack masked prompt labelled `Enter a new root key: ` (clack adds its own framing). Piped (non-TTY) mode does not print the prompt at all — it reads stdin directly. +- After the project ref resolves, one `Target: project <ref> · <identity>` line is printed on stderr in every output format. +- `update-root-key` asks `Replace the project's root encryption key on <target>?` (default No) before the first write. `--yes` or a shell `SUPABASE_YES` confirms without asking; a piped `y` on stdin is never read. Without `--yes`, a non-interactive, JSON, stream-json or `-o` run fails with `HostedMutationConsentRequiredError`, and declining fails with `HostedMutationDeclinedError`; neither changes anything. +- The new key is read (masked TTY prompt or piped stdin) before the confirmation, which never reads stdin. diff --git a/apps/cli/src/commands/network-bans/get/SIDE_EFFECTS.md b/apps/cli/src/commands/network-bans/get/SIDE_EFFECTS.md index e8e1f972ac..12ce1959ef 100644 --- a/apps/cli/src/commands/network-bans/get/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/network-bans/get/SIDE_EFFECTS.md @@ -84,3 +84,4 @@ One `result` event whose `data` is the full response object. `SUPABASE_EXPERIMENTAL`), checked before the login check. A closed gate exits 1 before project-ref resolution, the API call, the `linked-project.json` write, the `telemetry.json` write, and the `cli_command_executed` event. +- After the project ref resolves, one `Target: project <ref> · <identity>` line is printed on stderr in every output format. diff --git a/apps/cli/src/commands/network-bans/remove/SIDE_EFFECTS.md b/apps/cli/src/commands/network-bans/remove/SIDE_EFFECTS.md index e935a6cf8b..b60c7a6c0b 100644 --- a/apps/cli/src/commands/network-bans/remove/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/network-bans/remove/SIDE_EFFECTS.md @@ -24,12 +24,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` | built-in profile name or YAML file path | no (falls back to `~/.supabase/profile` -> `supabase`) | -| `SUPABASE_PROJECT_ID` | project ref fallback when `--project-ref` is unset | no (falls back to `supabase/.temp/project-ref` → prompt) | -| `SUPABASE_EXPERIMENTAL` | enables `--experimental`-gated commands without the flag | no (pass `--experimental` instead; one of the two is required) | +| Variable | Purpose | Required? | +| ----------------------- | ----------------------------------------------------------------------------------------------------- | -------------------------------------------------------------- | +| `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 fallback when `--project-ref` is unset | no (falls back to `supabase/.temp/project-ref` → prompt) | +| `SUPABASE_EXPERIMENTAL` | enables `--experimental`-gated commands without the flag | no (pass `--experimental` instead; one of the two is required) | +| `SUPABASE_YES` | confirms the hosted mutation without asking; only the shell environment counts, never `supabase/.env` | no (falls back to the interactive prompt) | ## Exit Codes @@ -42,6 +43,8 @@ | `1` | project ref unresolved (`ProjectRefNotLinkedError` / `InvalidProjectRefError`) | | `1` | API non-2xx (`NetworkBansRemoveUnexpectedStatusError`) | | `1` | transport failure (`NetworkBansRemoveNetworkError`) | +| `1` | declined at the confirmation prompt (`HostedMutationDeclinedError`, `context canceled`) — nothing is changed | +| `1` | JSON, stream-json, `-o` or non-interactive run without `--yes` (`HostedMutationConsentRequiredError`) — nothing is changed | ## Telemetry Events Fired @@ -84,3 +87,6 @@ One `result` event on success when the `--output` flag is unset. `SUPABASE_EXPERIMENTAL`), checked before the login check. A closed gate exits 1 before project-ref resolution, the API call, the `linked-project.json` write, the `telemetry.json` write, and the `cli_command_executed` event. +- After the project ref resolves, one `Target: project <ref> · <identity>` line is printed on stderr in every output format. +- Asks `Remove network bans on <target>?` (default No) before the first write, after printing the IPs to unban (or the caller's own IP when `--db-unban-ip` is omitted). `--yes` or a shell `SUPABASE_YES` confirms without asking; a piped `y` on stdin is never read. Without `--yes`, a non-interactive, JSON, stream-json or `-o` run fails with `HostedMutationConsentRequiredError`, and declining fails with `HostedMutationDeclinedError`; neither changes anything. +- The `LinkedDbNetworkBannedError` raised by commands that connect to a linked database points at this command; it needs `--experimental` and the confirmation above like any other run. diff --git a/apps/cli/src/commands/network-restrictions/get/SIDE_EFFECTS.md b/apps/cli/src/commands/network-restrictions/get/SIDE_EFFECTS.md index 2081c28045..c46539403f 100644 --- a/apps/cli/src/commands/network-restrictions/get/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/network-restrictions/get/SIDE_EFFECTS.md @@ -96,3 +96,4 @@ One `result` event whose `data` is the full response object. whether the subsequent API call succeeds. - `telemetry.json` is written on every invocation past the `--experimental` gate, including failures. A closed gate writes nothing. +- After the project ref resolves, one `Target: project <ref> · <identity>` line is printed on stderr in every output format. diff --git a/apps/cli/src/commands/network-restrictions/update/SIDE_EFFECTS.md b/apps/cli/src/commands/network-restrictions/update/SIDE_EFFECTS.md index e805514469..8683fcaf57 100644 --- a/apps/cli/src/commands/network-restrictions/update/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/network-restrictions/update/SIDE_EFFECTS.md @@ -28,12 +28,13 @@ when no `--db-allow-cidr` was supplied). ## 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` | built-in profile name or YAML file path | no (falls back to `~/.supabase/profile` -> `supabase`) | -| `SUPABASE_PROJECT_ID` | project ref fallback when `--project-ref` is unset | no (falls back to `supabase/.temp/project-ref` → prompt) | -| `SUPABASE_EXPERIMENTAL` | enables `--experimental`-gated commands without the flag | no (pass `--experimental` instead; one of the two is required) | +| Variable | Purpose | Required? | +| ----------------------- | ----------------------------------------------------------------------------------------------------- | -------------------------------------------------------------- | +| `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 fallback when `--project-ref` is unset | no (falls back to `supabase/.temp/project-ref` → prompt) | +| `SUPABASE_EXPERIMENTAL` | enables `--experimental`-gated commands without the flag | no (pass `--experimental` instead; one of the two is required) | +| `SUPABASE_YES` | confirms the hosted mutation without asking; only the shell environment counts, never `supabase/.env` | no (falls back to the interactive prompt) | ## Exit Codes @@ -47,6 +48,8 @@ when no `--db-allow-cidr` was supplied). | `1` | project ref unresolved (`ProjectRefNotLinkedError` / `InvalidProjectRefError`) | | `1` | API non-201 (POST) / non-200 (PATCH) — `NetworkRestrictionsUpdateUnexpectedStatusError` | | `1` | transport failure — `NetworkRestrictionsUpdateNetworkError` | +| `1` | declined at the confirmation prompt (`HostedMutationDeclinedError`, `context canceled`) — nothing is changed | +| `1` | JSON, stream-json, `-o` or non-interactive run without `--yes` (`HostedMutationConsentRequiredError`) — nothing is changed | ## Telemetry Events Fired @@ -119,3 +122,5 @@ One `result` event whose `data` is the full response object. writes nothing. A malformed `--db-allow-cidr` CSV value (e.g. an unterminated quote) also writes nothing and fires no telemetry, even with `--experimental` set — it fails during flag parsing, before the gate and the handler. +- After the project ref resolves, one `Target: project <ref> · <identity>` line is printed on stderr in every output format. +- Asks `Replace the network restrictions on <target>?` (`Append to the network restrictions on <target>?` with `--append`; default No) before the first write, after printing the IPv4 and IPv6 CIDRs. `--yes` or a shell `SUPABASE_YES` confirms without asking; a piped `y` on stdin is never read. Without `--yes`, a non-interactive, JSON, stream-json or `-o` run fails with `HostedMutationConsentRequiredError`, and declining fails with `HostedMutationDeclinedError`; neither changes anything. diff --git a/apps/cli/src/commands/notebooks/pull/SIDE_EFFECTS.md b/apps/cli/src/commands/notebooks/pull/SIDE_EFFECTS.md index 7c6a1b5a7d..97b42c2a6c 100644 --- a/apps/cli/src/commands/notebooks/pull/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/notebooks/pull/SIDE_EFFECTS.md @@ -119,3 +119,5 @@ response error. Partial inventories are never used to reconcile notebooks. Network operations show progress in text mode; machine output remains payload-only. `-o table` and `-o csv` are rejected by command instrumentation before the handler runs. `-o env` is rejected by the handler before resolving the project. + +After the project ref resolves, one `Target: project <ref> · <identity>` line is printed on stderr in every output format. diff --git a/apps/cli/src/commands/notebooks/push/SIDE_EFFECTS.md b/apps/cli/src/commands/notebooks/push/SIDE_EFFECTS.md index 8cda1d906e..2b22584db6 100644 --- a/apps/cli/src/commands/notebooks/push/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/notebooks/push/SIDE_EFFECTS.md @@ -52,6 +52,12 @@ business. Notebook files are read only from inside the project: `supabase/notebooks/` and each entry are canonicalized and refused when they resolve outside the project root. +## Confirmation + +- After the project ref resolves, one `Target: project <ref> · <identity>` line is printed on stderr in every output format. +- Asks `Push <n> notebook(s) on <target>?` (`Push <n> notebook(s) and delete <m> from the project on <target>?` for the delete answer; default No) before the first write, after printing one `Create`, `Update` or `Delete` line per notebook. `--yes` or a shell `SUPABASE_YES` confirms without asking; a piped `y` on stdin is never read. Without `--yes`, a non-interactive, JSON, stream-json or `-o` run fails with `HostedMutationConsentRequiredError`, and declining fails with `HostedMutationDeclinedError`; neither changes anything. +- No confirmation is asked when nothing would be uploaded or deleted (an empty notebooks directory with the `keep` or write-locally answer). The reconciliation question above is separate: it chooses what to do with project-only notebooks, and the confirmation covers the resulting uploads and deletes together. + ## API Routes | Method | Path | Auth | Request body | Response (used fields) | @@ -71,26 +77,29 @@ never mentions `favorite` keeps whatever the dashboard set. ## Exit Codes -| Code | Condition | -| ---- | -------------------------------------------------------------------------------- | -| `0` | success | -| `1` | no project ref — not linked and no `--project-ref` | -| `1` | the named notebook is not in `supabase/notebooks/` | -| `1` | a file is unreadable, not JSON, or not a notebook — before anything is sent | -| `1` | duplicate remote names (only the selected name for a named push) | -| `1` | the notebooks path exists but cannot be read as a directory | -| `1` | a project notebook name cannot be stored safely as a local file | -| `1` | a Management API call failed (transport, unexpected status, or undecodable body) | -| `1` | `-o env`, which cannot represent the payload | +| Code | Condition | +| ---- | -------------------------------------------------------------------------------------------------------------------------- | +| `0` | success | +| `1` | no project ref — not linked and no `--project-ref` | +| `1` | the named notebook is not in `supabase/notebooks/` | +| `1` | a file is unreadable, not JSON, or not a notebook — before anything is sent | +| `1` | duplicate remote names (only the selected name for a named push) | +| `1` | the notebooks path exists but cannot be read as a directory | +| `1` | a project notebook name cannot be stored safely as a local file | +| `1` | a Management API call failed (transport, unexpected status, or undecodable body) | +| `1` | `-o env`, which cannot represent the payload | +| `1` | declined at the confirmation prompt (`HostedMutationDeclinedError`, `context canceled`) — nothing is changed | +| `1` | JSON, stream-json, `-o` or non-interactive run without `--yes` (`HostedMutationConsentRequiredError`) — nothing is changed | ## Environment Variables -| Variable | Purpose | Required? | -| ----------------------- | --------------------------------------- | ------------------------------------------------------ | -| `SUPABASE_ACCESS_TOKEN` | Management API bearer token | no (falls back to the stored login) | -| `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_ACCESS_TOKEN` | Management API bearer token | no (falls back to the stored login) | +| `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`) | +| `SUPABASE_YES` | confirms the hosted mutation without asking; only the shell environment counts, never `supabase/.env` | no (falls back to the interactive prompt) | ## Telemetry Events Fired diff --git a/apps/cli/src/commands/postgres-config/delete/SIDE_EFFECTS.md b/apps/cli/src/commands/postgres-config/delete/SIDE_EFFECTS.md index 9eaaaae902..423a102857 100644 --- a/apps/cli/src/commands/postgres-config/delete/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/postgres-config/delete/SIDE_EFFECTS.md @@ -25,12 +25,13 @@ This command does not call a delete endpoint: it fetches the current config, rem ## 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` | built-in profile name or YAML file path | no (falls back to `~/.supabase/profile` -> `supabase`) | -| `SUPABASE_PROJECT_ID` | project ref fallback when `--project-ref` is unset | no (falls back to `supabase/.temp/project-ref` -> prompt) | -| `SUPABASE_EXPERIMENTAL` | enables `--experimental`-gated commands without the flag | no (pass `--experimental` instead; one of the two is required) | +| Variable | Purpose | Required? | +| ----------------------- | ----------------------------------------------------------------------------------------------------- | -------------------------------------------------------------- | +| `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 fallback when `--project-ref` is unset | no (falls back to `supabase/.temp/project-ref` -> prompt) | +| `SUPABASE_EXPERIMENTAL` | enables `--experimental`-gated commands without the flag | no (pass `--experimental` instead; one of the two is required) | +| `SUPABASE_YES` | confirms the hosted mutation without asking; only the shell environment counts, never `supabase/.env` | no (falls back to the interactive prompt) | ## Exit Codes @@ -46,6 +47,8 @@ This command does not call a delete endpoint: it fetches the current config, rem | `1` | PUT transport failure (`PostgresConfigDeleteNetworkError`) | | `1` | request serialization failure (`PostgresConfigDeleteSerializeError`) | | `1` | invalid JSON response (`PostgresConfigGetUnmarshalError` / `PostgresConfigDeleteUnmarshalError`) | +| `1` | declined at the confirmation prompt (`HostedMutationDeclinedError`, `context canceled`) — nothing is changed | +| `1` | JSON, stream-json, `-o` or non-interactive run without `--yes` (`HostedMutationConsentRequiredError`) — nothing is changed | ## Telemetry Events Fired @@ -106,3 +109,6 @@ One `result` event on success. (or `SUPABASE_EXPERIMENTAL`), checked before the login check. A closed gate exits 1 before project-ref resolution, the API calls, the `linked-project.json` write, the `telemetry.json` write, and the `cli_command_executed` event. +- After the project ref resolves, one `Target: project <ref> · <identity>` line is printed on stderr in every output format. +- Asks `Delete Postgres config overrides on <target>?` (default No) before the first write, after printing the keys to delete and whether the restart is skipped. `--yes` or a shell `SUPABASE_YES` confirms without asking; a piped `y` on stdin is never read. Without `--yes`, a non-interactive, JSON, stream-json or `-o` run fails with `HostedMutationConsentRequiredError`, and declining fails with `HostedMutationDeclinedError`; neither changes anything. +- The current overrides are fetched (a read) before the confirmation so the plan is known; the `PUT` happens only after it. diff --git a/apps/cli/src/commands/postgres-config/get/SIDE_EFFECTS.md b/apps/cli/src/commands/postgres-config/get/SIDE_EFFECTS.md index 4ba5db05f7..8d69101c80 100644 --- a/apps/cli/src/commands/postgres-config/get/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/postgres-config/get/SIDE_EFFECTS.md @@ -96,3 +96,4 @@ One `result` event on success. (or `SUPABASE_EXPERIMENTAL`), checked before the login check. A closed gate exits 1 before project-ref resolution, the API call, the `linked-project.json` write, the `telemetry.json` write, and the `cli_command_executed` event. +- After the project ref resolves, one `Target: project <ref> · <identity>` line is printed on stderr in every output format. diff --git a/apps/cli/src/commands/postgres-config/update/SIDE_EFFECTS.md b/apps/cli/src/commands/postgres-config/update/SIDE_EFFECTS.md index a8033e37e7..13140c6ff7 100644 --- a/apps/cli/src/commands/postgres-config/update/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/postgres-config/update/SIDE_EFFECTS.md @@ -25,12 +25,13 @@ The initial `GET` is skipped when `--replace-existing-overrides` is set. Otherwi ## 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` | built-in profile name or YAML file path | no (falls back to `~/.supabase/profile` -> `supabase`) | -| `SUPABASE_PROJECT_ID` | project ref fallback when `--project-ref` is unset | no (falls back to `supabase/.temp/project-ref` -> prompt) | -| `SUPABASE_EXPERIMENTAL` | enables `--experimental`-gated commands without the flag | no (pass `--experimental` instead; one of the two is required) | +| Variable | Purpose | Required? | +| ----------------------- | ----------------------------------------------------------------------------------------------------- | -------------------------------------------------------------- | +| `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 fallback when `--project-ref` is unset | no (falls back to `supabase/.temp/project-ref` -> prompt) | +| `SUPABASE_EXPERIMENTAL` | enables `--experimental`-gated commands without the flag | no (pass `--experimental` instead; one of the two is required) | +| `SUPABASE_YES` | confirms the hosted mutation without asking; only the shell environment counts, never `supabase/.env` | no (falls back to the interactive prompt) | ## Exit Codes @@ -47,6 +48,8 @@ The initial `GET` is skipped when `--replace-existing-overrides` is set. Otherwi | `1` | PUT transport failure (`PostgresConfigUpdateNetworkError`) | | `1` | request serialization failure (`PostgresConfigUpdateSerializeError`) | | `1` | invalid JSON response (`PostgresConfigGetUnmarshalError` / `PostgresConfigUpdateUnmarshalError`) | +| `1` | declined at the confirmation prompt (`HostedMutationDeclinedError`, `context canceled`) — nothing is changed | +| `1` | JSON, stream-json, `-o` or non-interactive run without `--yes` (`HostedMutationConsentRequiredError`) — nothing is changed | ## Telemetry Events Fired @@ -108,3 +111,6 @@ One `result` event on success. (or `SUPABASE_EXPERIMENTAL`), checked before the login check. A closed gate exits 1 before project-ref resolution, the API calls, the `linked-project.json` write, the `telemetry.json` write, and the `cli_command_executed` event. +- After the project ref resolves, one `Target: project <ref> · <identity>` line is printed on stderr in every output format. +- Asks `Update the Postgres config overrides on <target>?` (default No) before the first write, after printing the overrides to set, whether all existing ones are replaced, and whether the restart is skipped. `--yes` or a shell `SUPABASE_YES` confirms without asking; a piped `y` on stdin is never read. Without `--yes`, a non-interactive, JSON, stream-json or `-o` run fails with `HostedMutationConsentRequiredError`, and declining fails with `HostedMutationDeclinedError`; neither changes anything. +- The current overrides are fetched (a read) before the confirmation so the plan is known; the `PUT` happens only after it. diff --git a/apps/cli/src/commands/snippets/download/SIDE_EFFECTS.md b/apps/cli/src/commands/snippets/download/SIDE_EFFECTS.md index 3ca88314b0..8e072bf04a 100644 --- a/apps/cli/src/commands/snippets/download/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/snippets/download/SIDE_EFFECTS.md @@ -82,3 +82,4 @@ NDJSON `success` event with the same full payload as `--output-format json`. - The `--output` flag has **no effect** on this command — `--output` values (`pretty|json|yaml|toml|env`) do not change text-mode rendering. Only the TS-extension `--output-format json|stream-json` produces a structured payload. - UUID validation runs **after** project-ref resolution but **before** the API call. Error messages: `invalid snippet ID: invalid UUID length: N` for malformed lengths, `invalid snippet ID: invalid UUID format` for length-36 inputs with wrong dash positions or hex chars. - The linked-project cache fires after project-ref resolves; the telemetry state always flushes. Both run on success and on every error path — including invalid-UUID early-exit — via the two `Effect.ensuring` blocks in the handler. +- After the project ref resolves, one `Target: project <ref> · <identity>` line is printed on stderr in every output format. diff --git a/apps/cli/src/commands/snippets/list/SIDE_EFFECTS.md b/apps/cli/src/commands/snippets/list/SIDE_EFFECTS.md index 174132ac0e..1557a84b4d 100644 --- a/apps/cli/src/commands/snippets/list/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/snippets/list/SIDE_EFFECTS.md @@ -98,3 +98,4 @@ NDJSON `success` event with the full response as `data`. - When both `--output` and `--output-format` are set, `--output` wins (matches the precedence used elsewhere in ports). - `--output env` is rejected **after** project-ref resolution but **before** the API call. - The linked-project cache fires after project-ref resolves; the telemetry state always flushes. Both run on success and on every error path via the two `Effect.ensuring` blocks in the handler. +- After the project ref resolves, one `Target: project <ref> · <identity>` line is printed on stderr in every output format. diff --git a/apps/cli/src/commands/ssl-enforcement/get/SIDE_EFFECTS.md b/apps/cli/src/commands/ssl-enforcement/get/SIDE_EFFECTS.md index f80037fc67..f4f33d3617 100644 --- a/apps/cli/src/commands/ssl-enforcement/get/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/ssl-enforcement/get/SIDE_EFFECTS.md @@ -103,3 +103,4 @@ One `result` event: (or `SUPABASE_EXPERIMENTAL`), checked before the login check. A closed gate exits 1 before project-ref resolution, the API call, the `linked-project.json` write, the `telemetry.json` write, and the `cli_command_executed` event. +- After the project ref resolves, one `Target: project <ref> · <identity>` line is printed on stderr in every output format. diff --git a/apps/cli/src/commands/ssl-enforcement/update/SIDE_EFFECTS.md b/apps/cli/src/commands/ssl-enforcement/update/SIDE_EFFECTS.md index 42de5cd8b7..2c1bb48b23 100644 --- a/apps/cli/src/commands/ssl-enforcement/update/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/ssl-enforcement/update/SIDE_EFFECTS.md @@ -22,12 +22,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` | built-in profile name or YAML file path | no (falls back to `~/.supabase/profile` -> `supabase`) | -| `SUPABASE_PROJECT_ID` | project ref fallback when `--project-ref` is unset | no (falls back to `supabase/.temp/project-ref` → prompt) | -| `SUPABASE_EXPERIMENTAL` | enables `--experimental`-gated commands without the flag | no (pass `--experimental` instead; one of the two is required) | +| Variable | Purpose | Required? | +| ----------------------- | ----------------------------------------------------------------------------------------------------- | -------------------------------------------------------------- | +| `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 fallback when `--project-ref` is unset | no (falls back to `supabase/.temp/project-ref` → prompt) | +| `SUPABASE_EXPERIMENTAL` | enables `--experimental`-gated commands without the flag | no (pass `--experimental` instead; one of the two is required) | +| `SUPABASE_YES` | confirms the hosted mutation without asking; only the shell environment counts, never `supabase/.env` | no (falls back to the interactive prompt) | ## Exit Codes @@ -40,6 +41,8 @@ | `1` | project ref unresolved (`ProjectRefNotLinkedError` / `InvalidProjectRefError`) | | `1` | API non-200 (`SslEnforcementUpdateUnexpectedStatusError`) | | `1` | transport failure (`SslEnforcementUpdateNetworkError`) | +| `1` | declined at the confirmation prompt (`HostedMutationDeclinedError`, `context canceled`) — nothing is changed | +| `1` | JSON, stream-json, `-o` or non-interactive run without `--yes` (`HostedMutationConsentRequiredError`) — nothing is changed | ## Telemetry Events Fired @@ -101,3 +104,5 @@ One `result` event: A closed gate exits 1 before the enable/disable mutex check, project-ref resolution, the API call, the `linked-project.json` write, the `telemetry.json` write, and the `cli_command_executed` event. +- After the project ref resolves, one `Target: project <ref> · <identity>` line is printed on stderr in every output format. +- Asks `Enable SSL enforcement for database connections on <target>?` (`Disable SSL enforcement for database connections on <target>?` with `--disable-db-ssl-enforcement`; default No) before the first write. `--yes` or a shell `SUPABASE_YES` confirms without asking; a piped `y` on stdin is never read. Without `--yes`, a non-interactive, JSON, stream-json or `-o` run fails with `HostedMutationConsentRequiredError`, and declining fails with `HostedMutationDeclinedError`; neither changes anything. diff --git a/apps/cli/src/commands/sso/add/SIDE_EFFECTS.md b/apps/cli/src/commands/sso/add/SIDE_EFFECTS.md index 48ee9d87b6..424691e0be 100644 --- a/apps/cli/src/commands/sso/add/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/sso/add/SIDE_EFFECTS.md @@ -32,10 +32,11 @@ Bypasses the typed Management API client for the POST so user-supplied keys insi ## 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` | profile selector (built-in name or YAML file path) | no (defaults to `supabase`) | +| Variable | Purpose | Required? | +| ----------------------- | ----------------------------------------------------------------------------------------------------- | ------------------------------------------------------- | +| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | +| `SUPABASE_PROFILE` | profile selector (built-in name or YAML file path) | no (defaults to `supabase`) | +| `SUPABASE_YES` | confirms the hosted mutation without asking; only the shell environment counts, never `supabase/.env` | no (falls back to the interactive prompt) | ## Exit Codes @@ -54,6 +55,8 @@ Bypasses the typed Management API client for the POST so user-supplied keys insi | `1` | `SsoAddSamlDisabledError` — 404 from POST | | `1` | `SsoAddUnexpectedStatusError` — other non-2xx | | `1` | `SsoAddNetworkError` — transport-level failure | +| `1` | declined at the confirmation prompt (`HostedMutationDeclinedError`, `context canceled`) — nothing is changed | +| `1` | JSON, stream-json, `-o` or non-interactive run without `--yes` (`HostedMutationConsentRequiredError`) — nothing is changed | ## Telemetry Events Fired @@ -93,3 +96,5 @@ Single `success` event with the parsed response as data. - Metadata URL validation error message: `only HTTPS Metadata URLs are supported Use --skip-url-validation to suppress this error` (no trailing period; differs from `sso update`'s variant). - The `## Attribute Mapping` / `## SAML 2.0 Metadata XML` sections are emitted as plain markdown (heading + fence). The headings carry no extra styling; the XML body inside the fence is formatted by `formatSsoMetadataXml`. - **Missing `--type` parser error**: the error message itself is `Error: required flag(s) "type" not set` verbatim (mapped in `shared/output/normalize-error.ts`). Effect CLI's parser however dumps the full help block to stdout _before_ the error. The error string is unchanged; the surrounding help dump is an Effect CLI behavior that would require forking the CLI parser to suppress. +- After the project ref resolves, one `Target: project <ref> · <identity>` line is printed on stderr in every output format. +- Asks `Add an SSO provider on <target>?` (default No) before the first write, after printing the type, domains and metadata source (file or URL; the URL itself is not printed). `--yes` or a shell `SUPABASE_YES` confirms without asking; a piped `y` on stdin is never read. Without `--yes`, a non-interactive, JSON, stream-json or `-o` run fails with `HostedMutationConsentRequiredError`, and declining fails with `HostedMutationDeclinedError`; neither changes anything. diff --git a/apps/cli/src/commands/sso/info/SIDE_EFFECTS.md b/apps/cli/src/commands/sso/info/SIDE_EFFECTS.md index 41dff802ca..6800a36567 100644 --- a/apps/cli/src/commands/sso/info/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/sso/info/SIDE_EFFECTS.md @@ -66,3 +66,4 @@ Single `success` event with the payload as data. - All three URLs are deterministic functions of the project ref. - The `Single sign-on URL (ACS URL)` label is emitted without a trailing space, so the flat ASCII renderer does not double it up against the cell padding. +- After the project ref resolves, one `Target: project <ref> · <identity>` line is printed on stderr in every output format. diff --git a/apps/cli/src/commands/sso/list/SIDE_EFFECTS.md b/apps/cli/src/commands/sso/list/SIDE_EFFECTS.md index 1db960dcc1..af5b7779b0 100644 --- a/apps/cli/src/commands/sso/list/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/sso/list/SIDE_EFFECTS.md @@ -69,3 +69,4 @@ Single `success` event with `{providers: items}` as data. - The `SAML 2.0 EntityID` header label is rendered as plain text, without inline-code backticks. - Upgrade-gate side calls only fire on 4xx. +- After the project ref resolves, one `Target: project <ref> · <identity>` line is printed on stderr in every output format. diff --git a/apps/cli/src/commands/sso/remove/SIDE_EFFECTS.md b/apps/cli/src/commands/sso/remove/SIDE_EFFECTS.md index 89695b2ed1..3e10663918 100644 --- a/apps/cli/src/commands/sso/remove/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/sso/remove/SIDE_EFFECTS.md @@ -26,20 +26,23 @@ ## 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` | profile selector (built-in name or YAML file path) | no (defaults to `supabase`) | +| Variable | Purpose | Required? | +| ----------------------- | ----------------------------------------------------------------------------------------------------- | ------------------------------------------------------- | +| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | +| `SUPABASE_PROFILE` | profile selector (built-in name or YAML file path) | no (defaults to `supabase`) | +| `SUPABASE_YES` | confirms the hosted mutation without asking; only the shell environment counts, never `supabase/.env` | no (falls back to the interactive prompt) | ## Exit Codes -| Code | Condition | -| ---- | ----------------------------------------------------------- | -| `0` | success | -| `1` | `SsoInvalidUuidError` — provider ID is not a canonical UUID | -| `1` | `SsoRemoveNotFoundError` — 404 from delete endpoint | -| `1` | `SsoRemoveUnexpectedStatusError` — other non-2xx | -| `1` | `SsoRemoveNetworkError` — transport-level failure | +| Code | Condition | +| ---- | -------------------------------------------------------------------------------------------------------------------------- | +| `0` | success | +| `1` | `SsoInvalidUuidError` — provider ID is not a canonical UUID | +| `1` | `SsoRemoveNotFoundError` — 404 from delete endpoint | +| `1` | `SsoRemoveUnexpectedStatusError` — other non-2xx | +| `1` | `SsoRemoveNetworkError` — transport-level failure | +| `1` | declined at the confirmation prompt (`HostedMutationDeclinedError`, `context canceled`) — nothing is changed | +| `1` | JSON, stream-json, `-o` or non-interactive run without `--yes` (`HostedMutationConsentRequiredError`) — nothing is changed | ## Telemetry Events Fired @@ -70,3 +73,5 @@ Single `success` event with the removed provider's details as data. - Removing a provider will prevent existing SSO users from logging in. - The `<provider-id>` argument must be a valid UUID; UUIDs are accepted case-insensitively. +- After the project ref resolves, one `Target: project <ref> · <identity>` line is printed on stderr in every output format. +- Asks `Remove SSO provider <id> on <target>?` (default No) before the first write. `--yes` or a shell `SUPABASE_YES` confirms without asking; a piped `y` on stdin is never read. Without `--yes`, a non-interactive, JSON, stream-json or `-o` run fails with `HostedMutationConsentRequiredError`, and declining fails with `HostedMutationDeclinedError`; neither changes anything. diff --git a/apps/cli/src/commands/sso/show/SIDE_EFFECTS.md b/apps/cli/src/commands/sso/show/SIDE_EFFECTS.md index 44764120a3..09e77e50f8 100644 --- a/apps/cli/src/commands/sso/show/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/sso/show/SIDE_EFFECTS.md @@ -76,3 +76,4 @@ Single `success` event with the response as data. - The `<provider-id>` argument must be a valid UUID; invalid input produces `identity provider ID "<input>" is not a UUID`. UUIDs are accepted case-insensitively. - `--metadata` short-circuits any `--output` selection. - The `## SAML 2.0 Metadata XML` fenced block is pretty-printed via `formatSsoMetadataXml` (`sso.format.ts`), matching a 2-space-indented XML pretty-printer's output byte-for-byte. +- After the project ref resolves, one `Target: project <ref> · <identity>` line is printed on stderr in every output format. diff --git a/apps/cli/src/commands/sso/update/SIDE_EFFECTS.md b/apps/cli/src/commands/sso/update/SIDE_EFFECTS.md index 4145cfed08..a354f8daea 100644 --- a/apps/cli/src/commands/sso/update/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/sso/update/SIDE_EFFECTS.md @@ -34,10 +34,11 @@ GET still uses the typed client. ## 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` | profile selector (built-in name or YAML file path) | no (defaults to `supabase`) | +| Variable | Purpose | Required? | +| ----------------------- | ----------------------------------------------------------------------------------------------------- | ------------------------------------------------------- | +| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | +| `SUPABASE_PROFILE` | profile selector (built-in name or YAML file path) | no (defaults to `supabase`) | +| `SUPABASE_YES` | confirms the hosted mutation without asking; only the shell environment counts, never `supabase/.env` | no (falls back to the interactive prompt) | ## Exit Codes @@ -57,6 +58,8 @@ GET still uses the typed client. | `1` | `SsoUpdateNotFoundError` — 404 from GET | | `1` | `SsoUpdateUnexpectedStatusError` — non-2xx from GET or PUT | | `1` | `SsoUpdateNetworkError` — transport-level failure | +| `1` | declined at the confirmation prompt (`HostedMutationDeclinedError`, `context canceled`) — nothing is changed | +| `1` | JSON, stream-json, `-o` or non-interactive run without `--yes` (`HostedMutationConsentRequiredError`) — nothing is changed | ## Telemetry Events Fired @@ -98,3 +101,6 @@ Single `success` event with the parsed response as data. - Metadata URL validation error message: `only HTTPS Metadata URLs are supported Use --skip-url-validation to suppress this error.` (single trailing period; differs from `sso add`'s variant which omits the trailing period). - The `## Attribute Mapping` / `## SAML 2.0 Metadata XML` sections are emitted as plain markdown (heading + fence). The headings carry no extra styling; the XML body inside the fence is formatted by `formatSsoMetadataXml`. - **PUT failure message reuses the GET error string**: a non-2xx PUT response produces `unexpected error fetching identity provider: <body>` — note "fetching" not "updating". +- After the project ref resolves, one `Target: project <ref> · <identity>` line is printed on stderr in every output format. +- Asks `Update SSO provider <id> on <target>?` (default No) before the first write, after printing the changed metadata, attribute mapping, name ID format and the resulting domain set. `--yes` or a shell `SUPABASE_YES` confirms without asking; a piped `y` on stdin is never read. Without `--yes`, a non-interactive, JSON, stream-json or `-o` run fails with `HostedMutationConsentRequiredError`, and declining fails with `HostedMutationDeclinedError`; neither changes anything. +- The provider is fetched (a read) before the confirmation so the resulting domain set is known; the `PUT` happens only after it. From a8ac2243530b2bd9e833a01b20ec6717f6bb6b32 Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Thu, 15 Oct 2026 14:29:22 +0100 Subject: [PATCH 32/92] feat(cli): ask for hosted consent before creating or deleting projects, orgs and feedback projects create and orgs create default to yes; projects delete and feedback delete default to no. Consent comes from --yes or a shell SUPABASE_YES, never stdin, so machine output without --yes fails before any write. --- apps/cli/docs/supabase/projects.md | 2 + .../command-internal/project-create-core.ts | 10 ++ .../commands/feedback/delete/SIDE_EFFECTS.md | 9 +- .../feedback/delete/delete.command.ts | 2 - .../feedback/delete/delete.handler.ts | 46 +------ .../delete/delete.integration.test.ts | 114 ++++-------------- apps/cli/src/commands/link/SIDE_EFFECTS.md | 3 + .../src/commands/orgs/create/SIDE_EFFECTS.md | 20 ++- .../commands/orgs/create/create.handler.ts | 3 + .../orgs/create/create.integration.test.ts | 46 +++++-- .../cli/src/commands/projects/SIDE_EFFECTS.md | 26 ++-- .../commands/projects/create/SIDE_EFFECTS.md | 8 ++ .../create/create.integration.test.ts | 43 ++++++- .../commands/projects/delete/SIDE_EFFECTS.md | 32 ++--- .../projects/delete/delete.command.ts | 6 +- .../projects/delete/delete.handler.ts | 20 +-- .../delete/delete.integration.test.ts | 86 ++----------- apps/cli/tests/helpers/account-consent.ts | 10 ++ 18 files changed, 212 insertions(+), 274 deletions(-) create mode 100644 apps/cli/tests/helpers/account-consent.ts diff --git a/apps/cli/docs/supabase/projects.md b/apps/cli/docs/supabase/projects.md index d01c49ed65..b7cb41d639 100644 --- a/apps/cli/docs/supabase/projects.md +++ b/apps/cli/docs/supabase/projects.md @@ -5,3 +5,5 @@ Provides tools for creating and managing your Supabase projects. This command group allows you to list all projects in your organizations, create new projects, delete existing projects, and retrieve API keys. These operations help you manage your Supabase infrastructure programmatically without using the dashboard. Project management via CLI is especially useful for automation scripts and when you need to provision environments in a repeatable way. + +Commands that change your account or projects ask for confirmation first: `create` defaults to yes, `delete` defaults to no. Pass `--yes` (or set `SUPABASE_YES` in your shell) to confirm in scripts and CI; without it, a run that cannot prompt fails before making any change. diff --git a/apps/cli/src/command-internal/project-create-core.ts b/apps/cli/src/command-internal/project-create-core.ts index 1dd095f5f1..31de277254 100644 --- a/apps/cli/src/command-internal/project-create-core.ts +++ b/apps/cli/src/command-internal/project-create-core.ts @@ -4,6 +4,8 @@ import { Effect, Option } from "effect"; import { CommandPlatformApi } from "../auth/command-platform-api.service.ts"; import { CommandSettings } from "../config/command-settings.service.ts"; import { OutputFlag } from "./global-flags.ts"; +import { requireHostedMutationConsent } from "./hosted-consent/hosted-consent.ts"; +import type { HostedMutationConsent } from "./hosted-consent/hosted-consent.token.ts"; import { Output } from "../shared/output/output.service.ts"; import { encodeEnv, encodeSortedJson } from "./output.encoders.ts"; import { @@ -58,6 +60,8 @@ export interface ProjectCreateInput { * `bootstrap` suppresses it since it emits its own top-level result instead. */ readonly emitStructuredResult: boolean; + /** Proof that the caller already confirmed the create; without it the core asks before the POST. */ + readonly consent?: HostedMutationConsent; } /** Formats `key: value`, padding `key:` to width 20. */ @@ -125,6 +129,12 @@ export const projectCreateCore = Effect.fn("ProjectCreate.run")(function* ( ...(input.templateUrl !== undefined ? { template_url: input.templateUrl } : {}), }; + if (input.consent === undefined) { + yield* requireHostedMutationConsent({ + action: `create project "${name}" in organization ${orgId}`, + }); + } + const creating = output.format === "text" ? yield* output.task("Creating project...") : undefined; // `executeRaw` skips output decoding: the 201 response's `ref` can be the cli-e2e diff --git a/apps/cli/src/commands/feedback/delete/SIDE_EFFECTS.md b/apps/cli/src/commands/feedback/delete/SIDE_EFFECTS.md index 467cd213cd..f87199bd05 100644 --- a/apps/cli/src/commands/feedback/delete/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/feedback/delete/SIDE_EFFECTS.md @@ -46,7 +46,7 @@ appear in shareable debug output. | `SUPABASE_WORKDIR` | project directory override | no (falls back to `--workdir` → cwd) | | `SUPABASE_ACCESS_TOKEN` | access token captured by `commandSettingsLayer` | no (unused by this command) | | `SUPABASE_PROJECT_ID` | supplies the project-ref context when `--project-ref` is unset; must be a well-formed ref (exit 1 otherwise) | no (falls back to `<workdir>/supabase/.temp/project-ref` → omitted) | -| `SUPABASE_YES` | auto-confirms the deletion prompt, same as `--yes` | no | +| `SUPABASE_YES` | auto-confirms the deletion prompt, same as `--yes`; a project `.env` value is ignored | no | Global telemetry consent env applies as with every command. @@ -58,7 +58,8 @@ Global telemetry consent env applies as with every command. | `1` | token argument is not a UUID | | `1` | `--project-ref` or `SUPABASE_PROJECT_ID` is set but not a well-formed project ref (no request sent) | | `1` | no feedback matched (wrong token, already deleted, or project-ref/user-id context mismatch) | -| `1` | confirmation declined, or prompt unavailable (non-interactive, `-o json`, or machine mode without `--yes`) | +| `1` | confirmation declined | +| `1` | confirmation needed but this run can't prompt (non-interactive, `-o json`, or machine mode) and no `--yes` | | `1` | backend failure (PostgREST error, network failure, or 10 s timeout) | | `1` | `-o`/`--output` value outside the command's `pretty\|json` enum (validated pre-run) | @@ -86,7 +87,9 @@ Feedback deleted. The confirmation prompt runs before the row's existence is known (the CLI never reads the row), so a wrong token is answered with the not-found error after the prompt. The delete runs behind a "Deleting feedback..." spinner. -`--yes` / `SUPABASE_YES` skips the confirmation prompt. `-o json` requires +`--yes` / a shell `SUPABASE_YES` skips the confirmation prompt and prints +`Confirmed by --yes: permanently delete this feedback` on stderr; a `SUPABASE_YES` in a +project `.env` does not count, and stdin is never read for the answer. `-o json` requires `--yes` like the machine output formats do: it never prompts, even on a TTY, so stdout stays payload-only, and it emits `{ "deleted": true }`. diff --git a/apps/cli/src/commands/feedback/delete/delete.command.ts b/apps/cli/src/commands/feedback/delete/delete.command.ts index 6b62a091aa..6324908cee 100644 --- a/apps/cli/src/commands/feedback/delete/delete.command.ts +++ b/apps/cli/src/commands/feedback/delete/delete.command.ts @@ -2,7 +2,6 @@ import { Argument, Command, Flag } from "effect/cli"; import type * as CliCommand from "effect/cli/Command"; 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"; import { withCommandTelemetry } from "../../../telemetry/command-telemetry.ts"; import { telemetryStateLayer } from "../../../telemetry/telemetry-state.layer.ts"; import { commandFeedbackClientLayer, feedbackCliConfigLayer } from "../feedback.layers.ts"; @@ -48,7 +47,6 @@ export const feedbackDeleteCommand = Command.make("delete", config).pipe( Command.withHandler(feedbackDeleteHandler), Command.provide(commandRuntimeLayer(["feedback", "delete"])), Command.provide(telemetryStateLayer), - Command.provide(stdinLayer), // `Layer.provide` does not share to siblings: the handler and the feedback // client each get their own cli-config provision (CLI Agent Guide invariant 5). Command.provide(commandFeedbackClientLayer), diff --git a/apps/cli/src/commands/feedback/delete/delete.handler.ts b/apps/cli/src/commands/feedback/delete/delete.handler.ts index 520c874fad..3502577b3f 100644 --- a/apps/cli/src/commands/feedback/delete/delete.handler.ts +++ b/apps/cli/src/commands/feedback/delete/delete.handler.ts @@ -1,9 +1,8 @@ import { Effect, Option } from "effect"; import { FeedbackClient } from "../../../shared/feedback/feedback-client.service.ts"; -import { NonInteractiveError } from "../../../shared/output/errors.ts"; import { Output } from "../../../shared/output/output.service.ts"; -import { Stdin } from "../../../shared/runtime/stdin.service.ts"; -import { OutputFlag, resolveYes } from "../../../command-internal/global-flags.ts"; +import { OutputFlag } from "../../../command-internal/global-flags.ts"; +import { requireHostedMutationConsent } from "../../../command-internal/hosted-consent/hosted-consent.ts"; import { BRANCH_UUID_PATTERN } from "../../../command-internal/ref-patterns.ts"; import { TelemetryRuntime } from "../../../shared/telemetry/runtime.service.ts"; import { CommandSettings } from "../../../config/command-settings.service.ts"; @@ -13,10 +12,8 @@ import { resolveFeedbackProjectRef } from "../feedback-project-ref.ts"; import { settleFeedbackTask } from "../feedback-task.ts"; import type { FeedbackDeleteArgs } from "./delete.command.ts"; import { - FEEDBACK_DELETE_CANCELLED_MESSAGE, FEEDBACK_INVALID_TOKEN_MESSAGE, FEEDBACK_NOT_FOUND_MESSAGE, - FeedbackDeleteCancelledError, FeedbackInvalidTokenError, FeedbackNotFoundError, } from "./delete.errors.ts"; @@ -71,42 +68,9 @@ export const feedbackDelete = Effect.fn("feedback.delete")(function* (args: Feed userId: telemetryRuntime.identity.current(), }; - // The CLI never reads the row (CLI-2406 removes the backend read path - // too), so the prompt runs before the row's existence is known; a wrong - // token is reported as "not found" after the DELETE matches zero rows. - // `--yes`/`SUPABASE_YES` auto-confirms; otherwise prompt. Non-interactive - // contexts fail loudly with NonInteractiveError rather than silently - // deleting — pass --yes there. `output.interactive` is stdout-derived and - // clack's confirm answers on a single y/n keypress from any stdin, so both - // streams must be TTYs — otherwise `printf 'y' | feedback delete` could - // confirm a permanent delete without --yes (same gate as the add prompt). - // `-o json` is gated explicitly too: it leaves `output.format === "text"`, - // so the clack prompt would write ANSI and prompt text to stdout ahead of - // the machine payload. - const yes = yield* resolveYes; - if (!yes) { - const stdin = yield* Stdin; - if (outputFlagFormat === "json") { - return yield* new NonInteractiveError({ - detail: "Cannot prompt for confirmation with -o json", - suggestion: "Pass --yes to delete without confirmation", - }); - } - if (!stdin.isTTY || !output.interactive) { - return yield* new NonInteractiveError({ - detail: "Cannot prompt for confirmation in a non-interactive context", - suggestion: "Pass --yes to delete without confirmation", - }); - } - const confirmed = yield* output.promptConfirm("Permanently delete this feedback?", { - defaultValue: false, - }); - if (!confirmed) { - return yield* new FeedbackDeleteCancelledError({ - message: FEEDBACK_DELETE_CANCELLED_MESSAGE, - }); - } - } + // The row is never read, so consent is asked before its existence is known; a wrong token + // is reported as "not found" after the DELETE matches zero rows. + yield* requireHostedMutationConsent({ action: "permanently delete this feedback" }); const deleting = yield* output.task("Deleting feedback..."); const { deleted } = yield* client.delete(token, rowContext).pipe(settleFeedbackTask(deleting)); diff --git a/apps/cli/src/commands/feedback/delete/delete.integration.test.ts b/apps/cli/src/commands/feedback/delete/delete.integration.test.ts index 419c8e4570..4d273d7a26 100644 --- a/apps/cli/src/commands/feedback/delete/delete.integration.test.ts +++ b/apps/cli/src/commands/feedback/delete/delete.integration.test.ts @@ -7,13 +7,16 @@ import { FeedbackClient, } from "../../../shared/feedback/feedback-client.service.ts"; import { OutputFlag, YesFlag } from "../../../command-internal/global-flags.ts"; +import { TargetPolicy } from "../../../command-internal/target/target-policy.ts"; import { commandRuntimeLayer } from "../../../shared/runtime/command-runtime.layer.ts"; +import { withAccountTargetBanner } from "../../../../tests/helpers/account-consent.ts"; +import { describeHostedConsent } from "../../../../tests/helpers/hosted-consent.ts"; import { mockContextualAnalytics, mockOutput, mockProcessControl, - mockStdin, mockTelemetryRuntime, + mockTty, } from "../../../../tests/helpers/mocks.ts"; import { VALID_REF, @@ -100,7 +103,6 @@ function mockFeedbackClient(opts: MockClientOpts = {}) { function setupFeedbackDelete( opts: { output?: Parameters<typeof mockOutput>[0]; - stdinIsTTY?: boolean; client?: MockClientOpts; yes?: boolean; /** Simulates the `-o`/`--output` global flag. */ @@ -119,7 +121,7 @@ function setupFeedbackDelete( out.layer, client.layer, telemetryState.layer, - mockStdin(opts.stdinIsTTY ?? true), + mockTty({ stdinIsTty: true, stdoutIsTty: true }), mockTelemetryRuntime({ cliVersion: "9.9.9", distinctId: opts.distinctId, @@ -133,6 +135,7 @@ function setupFeedbackDelete( Layer.succeed(YesFlag, opts.yes ?? false), Layer.succeed(OutputFlag, Option.fromNullishOr(opts.outputFlag)), Layer.succeed(CliArgs, { args: [] }), + Layer.succeed(TargetPolicy, { group: "account", consent: "no" }), // Real filesystem: the handler reads `supabase/.temp/project-ref` from the // temp workdir, so this must not be stubbed out. BunServices.layer, @@ -167,7 +170,7 @@ describe("feedback delete", () => { // The CLI never reads the row: nothing is shown ahead of the prompt. expect(out.messages).not.toContainEqual(expect.objectContaining({ type: "info" })); expect(out.promptConfirmCalls).toEqual([ - { message: "Permanently delete this feedback?", opts: { defaultValue: false } }, + { message: "permanently delete this feedback?", opts: { defaultValue: false } }, ]); expect(client.deleteCalls).toEqual([{ token: TOKEN, projectRef: undefined }]); expect(out.messages).toContainEqual( @@ -201,69 +204,6 @@ describe("feedback delete", () => { }).pipe(Effect.provide(layer)); }); - it.effect("cancels without deleting when the confirmation is declined", () => { - const { layer, client } = setupFeedbackDelete({ - output: { promptConfirmResponses: [false] }, - }); - return Effect.gen(function* () { - const error = yield* feedbackDelete(deleteArgs()).pipe(Effect.flip); - - expect(error).toMatchObject({ _tag: "FeedbackDeleteCancelledError" }); - expect(client.deleteCalls).toHaveLength(0); - }).pipe(Effect.provide(layer)); - }); - - it.effect("--yes skips the confirmation prompt", () => { - const { layer, out, client } = setupFeedbackDelete({ yes: true }); - return Effect.gen(function* () { - yield* feedbackDelete(deleteArgs()); - - expect(out.promptConfirmCalls).toHaveLength(0); - expect(client.deleteCalls).toHaveLength(1); - }).pipe(Effect.provide(layer)); - }); - - it.effect("refuses to confirm from piped stdin, even with a TTY stdout", () => { - // `printf 'y' | supabase feedback delete <token>` in a terminal: stdout is - // a TTY (so `output.interactive` is true) but the confirm would answer on a - // single keypress read from the pipe, deleting without --yes. It must fail - // as non-interactive instead of consuming the piped byte. - const { layer, out, client } = setupFeedbackDelete({ stdinIsTTY: false }); - return Effect.gen(function* () { - const error = yield* feedbackDelete(deleteArgs()).pipe(Effect.flip); - - expect(error).toMatchObject({ - _tag: "NonInteractiveError", - detail: "Cannot prompt for confirmation in a non-interactive context", - suggestion: "Pass --yes to delete without confirmation", - }); - expect(out.promptConfirmCalls).toHaveLength(0); - expect(client.deleteCalls).toHaveLength(0); - }).pipe(Effect.provide(layer)); - }); - - it.effect("refuses to confirm when stdout is not interactive, even on a TTY stdin", () => { - const { layer, out, client } = setupFeedbackDelete({ output: { interactive: false } }); - return Effect.gen(function* () { - const error = yield* feedbackDelete(deleteArgs()).pipe(Effect.flip); - - expect(error).toMatchObject({ _tag: "NonInteractiveError" }); - expect(out.promptConfirmCalls).toHaveLength(0); - expect(client.deleteCalls).toHaveLength(0); - }).pipe(Effect.provide(layer)); - }); - - it.effect("--yes deletes without prompting when stdin is piped", () => { - // The documented escape hatch for non-interactive contexts. - const { layer, out, client } = setupFeedbackDelete({ yes: true, stdinIsTTY: false }); - return Effect.gen(function* () { - yield* feedbackDelete(deleteArgs()); - - expect(out.promptConfirmCalls).toHaveLength(0); - expect(client.deleteCalls).toHaveLength(1); - }).pipe(Effect.provide(layer)); - }); - it.effect("fails with a remediation hint when the delete matches no feedback", () => { // Wrong token, already deleted, or a project-ref/user-id context mismatch: // the backend cannot tell these apart, and the CLI never reads the row, so @@ -432,9 +372,9 @@ describe("feedback delete", () => { return Effect.gen(function* () { yield* feedbackDelete(deleteArgs()); - expect(out.rawChunks).toHaveLength(1); - expect(out.rawChunks[0]?.stream).toBe("stdout"); - const payload: unknown = yield* jsonValue(out.rawChunks[0]?.text ?? ""); + const stdoutChunks = out.rawChunks.filter((chunk) => chunk.stream === "stdout"); + expect(stdoutChunks).toHaveLength(1); + const payload: unknown = yield* jsonValue(stdoutChunks[0]?.text ?? ""); expect(payload).toEqual({ deleted: true }); // No human-readable acknowledgement — stdout is payload-only. expect(out.messages).not.toContainEqual(expect.objectContaining({ type: "info" })); @@ -442,29 +382,9 @@ describe("feedback delete", () => { }).pipe(Effect.provide(layer)); }); - it.effect("refuses to prompt under -o json without --yes, even on a TTY", () => { - // `-o json` leaves output.format === "text", so the interactive text layer - // would render the clack confirm onto stdout ahead of the raw JSON payload. - // Machine mode must fail loudly instead — the same contract as - // --output-format json. - const { layer, out, client } = setupFeedbackDelete({ outputFlag: "json" }); - return Effect.gen(function* () { - const error = yield* feedbackDelete(deleteArgs()).pipe(Effect.flip); - - expect(error).toMatchObject({ - _tag: "NonInteractiveError", - detail: "Cannot prompt for confirmation with -o json", - suggestion: "Pass --yes to delete without confirmation", - }); - expect(out.promptConfirmCalls).toHaveLength(0); - expect(client.deleteCalls).toHaveLength(0); - expect(out.rawChunks).toHaveLength(0); - }).pipe(Effect.provide(layer)); - }); - it.effect("fails loudly in json mode without --yes instead of silently deleting", () => { const { layer, out, client, processControl } = setupFeedbackDeleteHandler({ - output: { format: "json", promptConfirmFail: true }, + output: { format: "json" }, }); return Effect.gen(function* () { yield* feedbackDeleteHandler(deleteArgs()); @@ -511,3 +431,15 @@ describe("feedback delete", () => { }).pipe(Effect.provide(layer)); }); }); + +describeHostedConsent("feedback delete", { + invoke: () => { + const client = mockFeedbackClient(); + return { + run: withAccountTargetBanner(feedbackDelete(deleteArgs())).pipe( + Effect.provide(Layer.mergeAll(client.layer, mockTelemetryRuntime({ cliVersion: "9.9.9" }))), + ), + mutations: () => client.deleteCalls.length, + }; + }, +}); diff --git a/apps/cli/src/commands/link/SIDE_EFFECTS.md b/apps/cli/src/commands/link/SIDE_EFFECTS.md index 8ae9220bd6..5b84e0cb9e 100644 --- a/apps/cli/src/commands/link/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/link/SIDE_EFFECTS.md @@ -129,6 +129,9 @@ Tenant service gateway (`https://<ref>.<projectHost>`, `apikey: <service-key>` + ### `--output-format text` - stderr: the warning `link ignores --password; remove it from your scripts.` when `--password` is passed (printed before the access token is resolved); `Selected project: <ref>` (prompt path); `WARNING: Project status is <status> instead of Active Healthy. Some operations might fail.`; the dashboard unpause suggestion on a paused project. +- stderr: the target banner `Target: project <ref> · identity unverified`, printed once the ref + resolves and before the project is looked up. `link` only records a link, so it asks for no + confirmation and the banner stays as is once the project verifies. - stderr: `Resolved branch "<name>" of project <parentRef> to project ref <branchRef>.` — 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 diff --git a/apps/cli/src/commands/orgs/create/SIDE_EFFECTS.md b/apps/cli/src/commands/orgs/create/SIDE_EFFECTS.md index 218bb1ec19..68d88d65cb 100644 --- a/apps/cli/src/commands/orgs/create/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/orgs/create/SIDE_EFFECTS.md @@ -31,16 +31,28 @@ linked-project cache is never written. ## 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`, `supabase-staging`, `supabase-local`), or a filesystem path to a YAML profile (used by the cli-e2e harness) | no (defaults to `supabase`) | +| Variable | Purpose | Required? | +| ----------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------- | +| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | +| `SUPABASE_YES` | answers the creation confirmation, as `--yes` does | no (defaults to prompting; a project `.env` value is ignored) | +| `SUPABASE_PROFILE` | selects API base URL (`supabase`, `supabase-staging`, `supabase-local`), or a filesystem path to a YAML profile (used by the cli-e2e harness) | no (defaults to `supabase`) | + +## Confirmation + +Before the `POST /v1/organizations`, the command asks `create organization "<name>"?` +(default **Yes**). `--yes` or a shell `SUPABASE_YES` answers it and prints +`Confirmed by --yes: …` on stderr; a `SUPABASE_YES` in a project `.env` does not count. +A run that can't prompt (piped stdin, `--output-format json`/`stream-json`, or +`-o json`/`yaml`/`toml`/`env`) without `--yes` fails with the consent-required error and +makes no API write. Stdin is never read for the answer. ## Exit Codes | Code | Condition | | ---- | ------------------------------------------------------------------------- | | `0` | success — organization created | +| `1` | confirmation declined | +| `1` | confirmation needed but this run can't prompt, and no `--yes` | | `1` | `AccessTokenRequiredError` — no token in env/keyring/file | | `1` | `OrgsCreateUnexpectedStatusError` — non-201 response from create endpoint | | `1` | `OrgsCreateNetworkError` — transport-level network failure | diff --git a/apps/cli/src/commands/orgs/create/create.handler.ts b/apps/cli/src/commands/orgs/create/create.handler.ts index 4277903397..0cf9c466a0 100644 --- a/apps/cli/src/commands/orgs/create/create.handler.ts +++ b/apps/cli/src/commands/orgs/create/create.handler.ts @@ -4,6 +4,7 @@ import { Effect, Option } from "effect"; import { CommandPlatformApi } from "../../../auth/command-platform-api.service.ts"; import { TelemetryState } from "../../../telemetry/telemetry-state.service.ts"; import { OutputFlag } from "../../../command-internal/global-flags.ts"; +import { requireHostedMutationConsent } from "../../../command-internal/hosted-consent/hosted-consent.ts"; import { Output } from "../../../shared/output/output.service.ts"; import { encodeEnv, encodeSortedJson } from "../../../command-internal/output.encoders.ts"; import { @@ -32,6 +33,8 @@ export const orgsCreate = Effect.fn("orgs.create")(function* (flags: OrgsCreateF const telemetryState = yield* TelemetryState; yield* Effect.gen(function* () { + yield* requireHostedMutationConsent({ action: `create organization "${flags.name}"` }); + // Spinner only runs in text mode, since it would corrupt machine-readable stdout. It // gates on output.format rather than outputFlagFormat because --output pretty keeps the format // "text" while still rendering the table. diff --git a/apps/cli/src/commands/orgs/create/create.integration.test.ts b/apps/cli/src/commands/orgs/create/create.integration.test.ts index 80ebb7b7c7..786c68e365 100644 --- a/apps/cli/src/commands/orgs/create/create.integration.test.ts +++ b/apps/cli/src/commands/orgs/create/create.integration.test.ts @@ -1,7 +1,9 @@ import type { V1CreateAnOrganizationOutput } from "@supabase/api/effect"; import { describe, expect, it } from "@effect/vitest"; -import { Cause, Effect, Exit, Option } from "effect"; +import { Cause, Effect, Exit, Layer, Option } from "effect"; +import { withAccountTargetBanner } from "../../../../tests/helpers/account-consent.ts"; +import { describeHostedConsent } from "../../../../tests/helpers/hosted-consent.ts"; import { mockOutput } from "../../../../tests/helpers/mocks.ts"; import { buildTestRuntime, @@ -10,6 +12,8 @@ import { mockTelemetryStateTracked, useTempWorkdir, } from "../../../../tests/helpers/command-mocks.ts"; +import { YesFlag } from "../../../command-internal/global-flags.ts"; +import { TargetPolicy } from "../../../command-internal/target/target-policy.ts"; import { orgsCreate } from "./create.handler.ts"; type CreatedOrganization = typeof V1CreateAnOrganizationOutput.Type; @@ -22,6 +26,11 @@ const CREATED: CreatedOrganization = { const tempRoot = useTempWorkdir("supabase-orgs-create-int-"); +const consentLayer = Layer.mergeAll( + Layer.succeed(YesFlag, true), + Layer.succeed(TargetPolicy, { group: "account", consent: "yes" }), +); + interface SetupOpts { readonly format?: "text" | "json" | "stream-json"; readonly outputFlag?: "env" | "pretty" | "json" | "toml" | "yaml"; @@ -37,12 +46,15 @@ function setup(opts: SetupOpts = {}) { network: opts.network, }); const cliSettings = mockCommandSettings({ workdir: tempRoot.current }); - const layer = buildTestRuntime({ - out, - api, - cliSettings, - outputFlag: opts.outputFlag === undefined ? Option.none() : Option.some(opts.outputFlag), - }); + const layer = Layer.mergeAll( + buildTestRuntime({ + out, + api, + cliSettings, + outputFlag: opts.outputFlag === undefined ? Option.none() : Option.some(opts.outputFlag), + }), + consentLayer, + ); return { layer, out, api }; } @@ -54,12 +66,15 @@ function setupTracked(opts: SetupOpts = {}) { }); const cliSettings = mockCommandSettings({ workdir: tempRoot.current }); const telemetry = mockTelemetryStateTracked(); - const layer = buildTestRuntime({ - out, - api, - cliSettings, - telemetry: telemetry.layer, - }); + const layer = Layer.mergeAll( + buildTestRuntime({ + out, + api, + cliSettings, + telemetry: telemetry.layer, + }), + consentLayer, + ); return { layer, out, api, telemetry }; } @@ -219,3 +234,8 @@ describe("orgs create integration", () => { }).pipe(Effect.provide(layer)); }); }); + +describeHostedConsent("orgs create", { + api: { response: { status: 201, body: CREATED } }, + invoke: () => ({ run: withAccountTargetBanner(orgsCreate({ name: "Acme" })) }), +}); diff --git a/apps/cli/src/commands/projects/SIDE_EFFECTS.md b/apps/cli/src/commands/projects/SIDE_EFFECTS.md index f2c3321564..3a567e6fbe 100644 --- a/apps/cli/src/commands/projects/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/projects/SIDE_EFFECTS.md @@ -47,13 +47,14 @@ subcommand's own `SIDE_EFFECTS.md`. ## Exit Codes -| Code | Condition | -| ---- | ------------------------------------------------------------------------------ | -| `0` | success | -| `1` | auth / network / non-2xx status (incl. decode failure) / invalid ref | -| `1` | `create`: required params missing in non-interactive mode / empty project name | -| `1` | `delete`: declined confirmation (cancellation) / no ref on a non-TTY | -| `1` | `list`: `--output env` is unsupported | +| Code | Condition | +| ---- | --------------------------------------------------------------------------------------------- | +| `0` | success | +| `1` | auth / network / non-2xx status (incl. decode failure) / invalid ref | +| `1` | `create`: required params missing in non-interactive mode / empty project name | +| `1` | `create` / `delete`: confirmation declined, or needed on a run that can't prompt (no `--yes`) | +| `1` | `delete`: no ref on a non-TTY | +| `1` | `list`: `--output env` is unsupported | ## Telemetry Events Fired @@ -70,8 +71,8 @@ subcommand's own `SIDE_EFFECTS.md`. `--output-format {text|json|stream-json}` applies. - **list** — pretty/text: table `LINKED | ORG ID | REFERENCE ID | NAME | REGION | CREATED AT (UTC)`; `--output json/yaml/toml` encode the `linkedProject[]` (`{projects=[...]}` for toml); `--output env` is an error; `--output-format json/stream-json` `success("", {projects})`. -- **create** — stderr `Created a new project at <dashboard>/project/<id>` for all formats; pretty/text: table `ORG ID | REFERENCE ID | NAME | REGION | CREATED AT (UTC)`; `--output json/yaml/toml/env` encode the created project (env supported here); `--output-format json/stream-json` `success("Created project", {...project})`. -- **delete** — text: confirmation prompt (default No, honours `--yes`) then stdout `Deleted project: <name>`; `--output-format json/stream-json` `success("Deleted project", {name})`. +- **create** — a confirmation question (default Yes) first, unless `--yes`/`SUPABASE_YES` answers it; stderr `Created a new project at <dashboard>/project/<id>` for all formats; pretty/text: table `ORG ID | REFERENCE ID | NAME | REGION | CREATED AT (UTC)`; `--output json/yaml/toml/env` encode the created project (env supported here); `--output-format json/stream-json` `success("Created project", {...project})`. +- **delete** — text: confirmation prompt (default No, `--yes`/`SUPABASE_YES` answers it) then stdout `Deleted project: <name>`; `--output-format json/stream-json` need `--yes` and emit `success("Deleted project", {name})`. - **api-keys** — pretty/text: table `NAME | KEY VALUE` (`******` masks null keys); `--output toml/env` encode the `SUPABASE_<NAME>_KEY` map; `--output json/yaml` encode `ApiKeyResponse[]`; `--output-format json/stream-json` `success("", {keys})`. ## Notes @@ -89,6 +90,11 @@ subcommand's own `SIDE_EFFECTS.md`. - `--plan` on `create` is accepted but ignored (no-op, hidden) — a vestigial flag. - `create` interactivity is gated on `--interactive` (default true) **and** a TTY stdin **and** an interactive (text-mode) `Output`. -- `delete` confirmation defaults to **No** and honours the global `--yes`. +- `create` asks before the `POST` (default **Yes**) and `delete` asks before the `DELETE` + (default **No**, irreversible). `--yes` or a shell `SUPABASE_YES` answers either; + a `SUPABASE_YES` in a project `.env` does not. Without one of them a run that + can't prompt (piped stdin, `--output-format json`/`stream-json`, `-o json`/`yaml`/`toml`/`env`) + fails with the consent-required error before any API write, and stdin is never + read for the answer. - `api-keys` resolves `--project-ref` via the shared resolver (flag → env → `.temp/project-ref` → prompt on a TTY → error when unlinked). diff --git a/apps/cli/src/commands/projects/create/SIDE_EFFECTS.md b/apps/cli/src/commands/projects/create/SIDE_EFFECTS.md index feb16e3d6b..3e052e8d29 100644 --- a/apps/cli/src/commands/projects/create/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/projects/create/SIDE_EFFECTS.md @@ -38,6 +38,8 @@ | `1` | required flags missing in non-interactive mode | | `1` | empty project name (interactive prompt left blank) | | `1` | `--release-channel` or `--postgres-engine` set without `--experimental` | +| `1` | confirmation declined | +| `1` | confirmation needed but this run can't prompt, and no `--yes` | ## Telemetry Events Fired @@ -90,6 +92,12 @@ One `result` event on success. ## Notes +- Before the `POST /v1/projects`, the command asks `create project "<name>" in organization <org>?` + (default **Yes**). `--yes` or a shell `SUPABASE_YES` answers it and prints + `Confirmed by --yes: …` on stderr; a `SUPABASE_YES` in a project `.env` does not count. + A run that can't prompt (piped stdin, `--output-format json`/`stream-json`, or + `-o json`/`yaml`/`toml`/`env`) without `--yes` fails with the consent-required error and + makes no `POST`. Stdin is never read for the answer. - In interactive mode (default when stdin is a TTY), the user is prompted for any missing required fields (`--org-id`, `--db-password`, `--region`, project name). - In non-interactive mode (when stdin is not a TTY or `--interactive=false`), all three diff --git a/apps/cli/src/commands/projects/create/create.integration.test.ts b/apps/cli/src/commands/projects/create/create.integration.test.ts index a5e7047b88..32755fe314 100644 --- a/apps/cli/src/commands/projects/create/create.integration.test.ts +++ b/apps/cli/src/commands/projects/create/create.integration.test.ts @@ -3,9 +3,12 @@ import { describe, expect, it } from "@effect/vitest"; import { Cause, Effect, Exit, Layer, Option, Schema } from "effect"; import { CliError, CliOutput, Command } from "effect/cli"; +import { withAccountTargetBanner } from "../../../../tests/helpers/account-consent.ts"; +import { describeHostedConsent } from "../../../../tests/helpers/hosted-consent.ts"; import { mockOutput, mockTelemetryRuntime, mockTty } from "../../../../tests/helpers/mocks.ts"; import { ExperimentalRequiredError } from "../../../command-internal/experimental-gate.ts"; -import { GLOBAL_FLAGS, ExperimentalFlag } from "../../../command-internal/global-flags.ts"; +import { GLOBAL_FLAGS, ExperimentalFlag, YesFlag } from "../../../command-internal/global-flags.ts"; +import { TargetPolicy } from "../../../command-internal/target/target-policy.ts"; import { textCliOutputFormatter } from "../../../shared/output/text-formatter.ts"; import { type ApiResponse, @@ -18,6 +21,7 @@ import { useTempWorkdir, withConfigEnv, } from "../../../../tests/helpers/command-mocks.ts"; +import { HostedMutationDeclinedError } from "../../../command-internal/hosted-consent/hosted-consent.errors.ts"; import { projectsCreateCommand, type ProjectsCreateFlags } from "./create.command.ts"; import { projectsCreate } from "./create.handler.ts"; @@ -64,6 +68,8 @@ interface SetupOpts { readonly promptPasswordResponses?: ReadonlyArray<string>; readonly tracked?: boolean; readonly experimental?: boolean; + readonly yes?: boolean; + readonly promptConfirmResponses?: ReadonlyArray<boolean>; } function setup(opts: SetupOpts = {}) { @@ -72,6 +78,7 @@ function setup(opts: SetupOpts = {}) { promptTextResponses: opts.promptTextResponses, promptSelectResponses: opts.promptSelectResponses, promptPasswordResponses: opts.promptPasswordResponses, + promptConfirmResponses: opts.promptConfirmResponses, }); const api = mockCommandPlatformApi({ network: opts.network, @@ -99,6 +106,8 @@ function setup(opts: SetupOpts = {}) { const layer = Layer.mergeAll( runtime, Layer.succeed(ExperimentalFlag, opts.experimental ?? false), + Layer.succeed(YesFlag, opts.yes ?? true), + Layer.succeed(TargetPolicy, { group: "account", consent: "yes" }), ); return { layer, out, api, telemetry, cache }; } @@ -372,6 +381,23 @@ describe("projects create integration", () => { }).pipe(Effect.provide(layer)); }); + it.effect("asks for consent after the prompts and creates nothing when declined", () => { + const { layer, api } = setup({ + stdinIsTty: true, + yes: false, + promptTextResponses: ["my-proj"], + promptSelectResponses: ["org-abc", "us-west-2"], + promptPasswordResponses: [""], + promptConfirmResponses: [false], + }); + return Effect.gen(function* () { + const error = yield* Effect.flip(projectsCreate({ ...BASE_FLAGS })); + expect(error).toBeInstanceOf(HostedMutationDeclinedError); + expect(api.requests.some((r) => r.method === "GET")).toBe(true); + expect(api.requests.some((r) => r.method === "POST")).toBe(false); + }).pipe(Effect.provide(layer)); + }); + it.effect("fails with ProjectsCreateNameEmptyError when the name prompt is blank", () => { const { layer } = setup({ stdinIsTty: true, promptTextResponses: [""] }); return Effect.gen(function* () { @@ -633,3 +659,18 @@ function rejectsInvalidSizeChoice(error: unknown): boolean { ) ); } + +describeHostedConsent("projects create", { + api: { byMethod: { POST: { status: 201, body: CREATED } } }, + invoke: () => ({ + run: withAccountTargetBanner( + projectsCreate({ + ...BASE_FLAGS, + name: Option.some("alpha"), + orgId: Option.some("acme"), + dbPassword: Option.some("s3cret-pass"), + region: Option.some("us-east-1"), + }), + ).pipe(Effect.provideService(ExperimentalFlag, false)), + }), +}); diff --git a/apps/cli/src/commands/projects/delete/SIDE_EFFECTS.md b/apps/cli/src/commands/projects/delete/SIDE_EFFECTS.md index 870224ba8d..2a832581d8 100644 --- a/apps/cli/src/commands/projects/delete/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/projects/delete/SIDE_EFFECTS.md @@ -35,14 +35,16 @@ ## Exit Codes -| Code | Condition | -| ---- | ---------------------------------------------------------- | -| `0` | success — project deleted | -| `1` | authentication error — no valid token found | -| `1` | project not found — 404 response from `/v1/projects/{ref}` | -| `1` | API error — non-2xx/404 response from `/v1/projects/{ref}` | -| `1` | network / connection failure | -| `1` | declined confirmation (cancellation) / no ref on a non-TTY | +| Code | Condition | +| ---- | ------------------------------------------------------------- | +| `0` | success — project deleted | +| `1` | authentication error — no valid token found | +| `1` | project not found — 404 response from `/v1/projects/{ref}` | +| `1` | API error — non-2xx/404 response from `/v1/projects/{ref}` | +| `1` | network / connection failure | +| `1` | confirmation declined | +| `1` | confirmation needed but this run can't prompt, and no `--yes` | +| `1` | no ref on a non-TTY | ## Telemetry Events Fired @@ -60,13 +62,15 @@ ### `--output-format text` -Prompts `Do you want to delete project <ref>? This action is irreversible.` (default -**No**, honours the global `--yes`), then on success prints `Deleted project: <name>` -to stdout. +Prints `This action is irreversible.` and asks `delete project <ref>?` (default +**No**), then on success prints `Deleted project: <name>` to stdout. `--yes` or a shell +`SUPABASE_YES` answers the question and prints `Confirmed by --yes: delete project <ref>` +on stderr; a `SUPABASE_YES` in a project `.env` does not count. ### `--output-format json` -`success("Deleted project", { name })`. +Requires `--yes`: a machine format can't prompt, so without it the command fails with +the consent-required error before any `DELETE`. On success, `success("Deleted project", { name })`. ```json { "name": "my-project" } @@ -92,5 +96,5 @@ On failure, an `error` event is emitted instead: user to select a project to delete. - In non-interactive mode (when stdin is not a TTY), the project ref positional argument is required. -- A `PreRun` check is performed before the DELETE call to validate the project ref and - display a confirmation prompt in interactive mode. +- The confirmation runs after the project ref is resolved and before the DELETE call; + stdin is never read for the answer. diff --git a/apps/cli/src/commands/projects/delete/delete.command.ts b/apps/cli/src/commands/projects/delete/delete.command.ts index ce01606c01..b067f7b103 100644 --- a/apps/cli/src/commands/projects/delete/delete.command.ts +++ b/apps/cli/src/commands/projects/delete/delete.command.ts @@ -1,8 +1,6 @@ -import { Layer } from "effect"; import { Argument, Command } from "effect/cli"; import type * as CliCommand from "effect/cli/Command"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; -import { stdinLayer } from "../../../shared/runtime/stdin.layer.ts"; import { managementApiRuntimeLayer } from "../../../command-internal/management-api-runtime.layer.ts"; import { withCommandTelemetry } from "../../../telemetry/command-telemetry.ts"; import { projectsDelete } from "./delete.handler.ts"; @@ -31,8 +29,6 @@ export const projectsDeleteCommand = Command.make("delete", config).pipe( withJsonErrorHandling, ), ), - // `stdinLayer`: the delete confirmation reads piped stdin via `promptYesNo` - // on a non-TTY stdin. - Command.provide(Layer.mergeAll(managementApiRuntimeLayer(["projects", "delete"]), stdinLayer)), + Command.provide(managementApiRuntimeLayer(["projects", "delete"])), withTargetPolicy({ group: "account", consent: "no" }), ); diff --git a/apps/cli/src/commands/projects/delete/delete.handler.ts b/apps/cli/src/commands/projects/delete/delete.handler.ts index ff743b6d66..b7728038cf 100644 --- a/apps/cli/src/commands/projects/delete/delete.handler.ts +++ b/apps/cli/src/commands/projects/delete/delete.handler.ts @@ -12,15 +12,11 @@ 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 { resolveYes } from "../../../command-internal/global-flags.ts"; -import { promptYesNo } from "../../../command-internal/prompt-yes-no.ts"; -import { CONTEXT_CANCELED_MESSAGE } from "../../../shared/output/errors.ts"; +import { requireHostedMutationConsent } from "../../../command-internal/hosted-consent/hosted-consent.ts"; import { Output } from "../../../shared/output/output.service.ts"; import { Tty } from "../../../shared/runtime/tty.service.ts"; -import { aqua } from "../../../command-internal/colors.ts"; import { mapHttpError } from "../../../command-internal/http-errors.ts"; import { - ProjectsDeleteCancelledError, ProjectsDeleteNetworkError, ProjectsDeleteNotFoundError, ProjectsDeleteRefRequiredError, @@ -37,8 +33,6 @@ export const projectsDelete = Effect.fn("projects.delete")(function* (flags: Pro const cliSettings = yield* CommandSettings; const linkedProjectCache = yield* LinkedProjectCache; const telemetryState = yield* TelemetryState; - // `resolveYes` also honors `SUPABASE_YES`, not just the `--yes` flag. - const yes = yield* resolveYes; const tty = yield* Tty; const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -66,14 +60,10 @@ export const projectsDelete = Effect.fn("projects.delete")(function* (flags: Pro return yield* new InvalidProjectRefError({ ref, message: INVALID_PROJECT_REF_MESSAGE }); } - const title = `Do you want to delete project ${aqua(ref)}? This action is irreversible.`; - // `promptYesNo` echoes `<title> [y/N] <answer>` to stderr even on a non-TTY: it scans one - // piped line (100ms timeout) so `echo y | supabase projects delete <ref>` confirms; - // unparseable input falls back to No. - const confirmed = yield* promptYesNo(output, yes, title, false); - if (!confirmed) { - return yield* new ProjectsDeleteCancelledError({ message: CONTEXT_CANCELED_MESSAGE }); - } + yield* requireHostedMutationConsent({ + action: `delete project ${ref}`, + details: ["This action is irreversible."], + }); const mapDeleteError = mapHttpError({ networkError: ProjectsDeleteNetworkError, diff --git a/apps/cli/src/commands/projects/delete/delete.integration.test.ts b/apps/cli/src/commands/projects/delete/delete.integration.test.ts index 2734e7eeb0..2b03ce328e 100644 --- a/apps/cli/src/commands/projects/delete/delete.integration.test.ts +++ b/apps/cli/src/commands/projects/delete/delete.integration.test.ts @@ -2,6 +2,8 @@ import type { V1ListAllProjectsOutput } from "@supabase/api/effect"; import { describe, expect, it } from "@effect/vitest"; import { Cause, Effect, Exit, FileSystem, Layer, Option, Path } from "effect"; +import { withAccountTargetBanner } from "../../../../tests/helpers/account-consent.ts"; +import { describeHostedConsent } from "../../../../tests/helpers/hosted-consent.ts"; import { mockOutput, mockStdin, mockTty } from "../../../../tests/helpers/mocks.ts"; import { type ApiResponse, @@ -13,9 +15,9 @@ import { mockCommandPlatformApi, mockTelemetryStateTracked, useTempWorkdir, - withConfigEnv, } from "../../../../tests/helpers/command-mocks.ts"; import { YesFlag } from "../../../command-internal/global-flags.ts"; +import { TargetPolicy } from "../../../command-internal/target/target-policy.ts"; import { projectsDelete } from "./delete.handler.ts"; const OTHER_REF = "qrstuvwxyzabcdefghij"; @@ -45,8 +47,6 @@ interface SetupOpts { readonly format?: "text" | "json" | "stream-json"; readonly stdinIsTty?: boolean; readonly yes?: boolean; - /** Piped stdin lines consumed by the non-TTY confirm read. */ - readonly stdinInput?: string; readonly byMethod?: Partial<Record<HttpMethod, ApiResponse>>; readonly network?: "fail"; readonly promptConfirmResponses?: ReadonlyArray<boolean>; @@ -79,11 +79,12 @@ function setup(opts: SetupOpts = {}) { api, cliSettings, tty, - stdin: mockStdin(opts.stdinIsTty ?? false, opts.stdinInput), + stdin: mockStdin(opts.stdinIsTty ?? false), telemetry: telemetry.layer, linkedProjectCache: cache.layer, }), Layer.succeed(YesFlag, opts.yes ?? false), + Layer.succeed(TargetPolicy, { group: "account", consent: "no" }), ); return { layer, out, api, telemetry, cache }; } @@ -117,23 +118,11 @@ describe("projects delete integration", () => { const { layer, out, api } = setup({ yes: true }); return Effect.gen(function* () { yield* projectsDelete({ ref: Option.some(VALID_REF) }); - expect(out.stderrText).toContain("[y/N] y"); + expect(out.stderrText).toContain(`Confirmed by --yes: delete project ${VALID_REF}`); expect(hasMethod(api, "DELETE")).toBe(true); }).pipe(Effect.provide(layer)); }); - it.effect("cancels without deleting when the user declines confirmation", () => { - const { layer, api } = setup({ stdinIsTty: true, promptConfirmResponses: [false] }); - return Effect.gen(function* () { - const exit = yield* Effect.exit(projectsDelete({ ref: Option.some(VALID_REF) })); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - expect(Cause.pretty(exit.cause)).toContain("ProjectsDeleteCancelledError"); - } - expect(hasMethod(api, "DELETE")).toBe(false); - }).pipe(Effect.provide(layer)); - }); - it.effect("prompts to select a project when no ref is given on a TTY", () => { const { layer, api } = setup({ stdinIsTty: true, @@ -163,64 +152,6 @@ describe("projects delete integration", () => { }).pipe(Effect.provide(layer)); }); - it.effect("cancels on a non-TTY when a ref is provided but --yes is unset", () => { - const { layer, out, api } = setup({ stdinIsTty: false, yes: false }); - return Effect.gen(function* () { - const exit = yield* Effect.exit(projectsDelete({ ref: Option.some(VALID_REF) })); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - expect(Cause.pretty(exit.cause)).toContain("ProjectsDeleteCancelledError"); - } - expect(out.stderrText).toContain("Do you want to delete project "); - expect(out.stderrText).toContain("? This action is irreversible. [y/N] \n"); - expect(hasMethod(api, "DELETE")).toBe(false); - }).pipe(Effect.provide(layer)); - }); - - it.effect("SUPABASE_YES=1 in the environment auto-confirms with the [y/N] y echo", () => { - const { layer, out, api } = setup({ yes: false }); - return withConfigEnv( - { SUPABASE_YES: "1" }, - Effect.gen(function* () { - yield* projectsDelete({ ref: Option.some(VALID_REF) }); - expect(out.stderrText).toContain("Do you want to delete project "); - expect(out.stderrText).toContain("? This action is irreversible. [y/N] y\n"); - expect(hasMethod(api, "DELETE")).toBe(true); - }).pipe(Effect.provide(layer)), - ); - }); - - it.effect("non-TTY with piped `y` confirms", () => { - const { layer, out, api } = setup({ stdinIsTty: false, stdinInput: "y\n" }); - return Effect.gen(function* () { - yield* projectsDelete({ ref: Option.some(VALID_REF) }); - expect(out.stderrText).toContain("[y/N] y\n"); - expect(hasMethod(api, "DELETE")).toBe(true); - }).pipe(Effect.provide(layer)); - }); - - for (const format of ["json", "stream-json"] as const) { - it.effect(`${format} does not authorize deletion from piped y`, () => { - const { layer, out, api } = setup({ format, stdinIsTty: false, stdinInput: "y\n" }); - return Effect.gen(function* () { - const exit = yield* Effect.exit(projectsDelete({ ref: Option.some(VALID_REF) })); - expect(Exit.isFailure(exit)).toBe(true); - expect(hasMethod(api, "DELETE")).toBe(false); - expect(out.stderrText).not.toContain("[y/N]"); - }).pipe(Effect.provide(layer)); - }); - } - - it.effect("non-TTY with piped `n` declines", () => { - const { layer, out, api } = setup({ stdinIsTty: false, stdinInput: "n\n" }); - return Effect.gen(function* () { - const exit = yield* Effect.exit(projectsDelete({ ref: Option.some(VALID_REF) })); - expect(Exit.isFailure(exit)).toBe(true); - expect(out.stderrText).toContain("[y/N] n\n"); - expect(hasMethod(api, "DELETE")).toBe(false); - }).pipe(Effect.provide(layer)); - }); - it.effect("emits a result for --output-format stream-json", () => { const { layer, out } = setup({ format: "stream-json", yes: true }); return Effect.gen(function* () { @@ -333,3 +264,8 @@ describe("projects delete integration", () => { }).pipe(Effect.provide(layer)); }); }); + +describeHostedConsent("projects delete", { + api: { byMethod: { DELETE: { status: 200, body: DELETED } } }, + invoke: () => ({ run: withAccountTargetBanner(projectsDelete({ ref: Option.some(VALID_REF) })) }), +}); diff --git a/apps/cli/tests/helpers/account-consent.ts b/apps/cli/tests/helpers/account-consent.ts new file mode 100644 index 0000000000..518fff53f4 --- /dev/null +++ b/apps/cli/tests/helpers/account-consent.ts @@ -0,0 +1,10 @@ +import { Effect } from "effect"; + +import { announceTarget } from "../../src/shared/output/command-target.service.ts"; + +/** + * Account commands resolve no target, but the `describeHostedConsent` matrix expects a `Target:` + * banner under `--yes`; this announces a stand-in so those leaves can run the matrix. + */ +export const withAccountTargetBanner = <A, E, R>(run: Effect.Effect<A, E, R>) => + announceTarget({ text: "account", json: { kind: "account" } }).pipe(Effect.andThen(run)); From a158e39b79eb753faec866b7f7b85899a1ba0d10 Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Thu, 15 Oct 2026 14:29:23 +0100 Subject: [PATCH 33/92] feat(cli): ask for hosted consent before compute push and delete compute delete keeps the typed-name confirmation through the shared consent helper. --- apps/cli/docs/compute-commands.md | 6 + .../compute/delete/SIDE_EFFECTS.md | 31 ++- .../compute/delete/delete.handler.ts | 58 +--- .../compute/delete/delete.integration.test.ts | 257 +++--------------- .../experimental/compute/push/SIDE_EFFECTS.md | 13 + .../experimental/compute/push/push.handler.ts | 5 + .../compute/push/push.integration.test.ts | 169 +++++++----- apps/cli/tests/helpers/compute.ts | 24 +- 8 files changed, 209 insertions(+), 354 deletions(-) diff --git a/apps/cli/docs/compute-commands.md b/apps/cli/docs/compute-commands.md index f8ae017494..ae8b3a4395 100644 --- a/apps/cli/docs/compute-commands.md +++ b/apps/cli/docs/compute-commands.md @@ -47,3 +47,9 @@ settings into an ignored file. To deploy a source directory without a Compute configuration entry, `compute push` requires an explicit `--exposure public` or `--exposure private`. Record the entry in the authoritative config to persist its deployment settings. + +`compute push` and `compute delete` change the remote project, so both ask for +confirmation first, and both default to no. `compute delete` asks for the +compute's name to be typed back. Pass `--yes`, or set `SUPABASE_YES` in your +shell, to confirm in scripts and CI; without it, a run that cannot prompt fails +before changing anything. 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 97d3b2f2bb..0b42238e46 100644 --- a/apps/cli/src/commands/experimental/compute/delete/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/experimental/compute/delete/SIDE_EFFECTS.md @@ -39,10 +39,13 @@ on disk; only the remote compute is deleted. ## Confirmation Interactively, the compute's name has to be typed back before anything is -deleted. `--yes` (the root persistent flag) or `SUPABASE_YES` skips that. With -neither — and no interactive terminal to prompt on, which includes a redirected -stdout and any `--output-format json`/`stream-json` run — the command refuses -rather than deleting unasked. +deleted. `--yes` (the root persistent flag) or a shell `SUPABASE_YES` skips that and +prints `Confirmed by --yes: …` on stderr; a `SUPABASE_YES` in a project `.env` does not +count. With neither — and no interactive terminal to prompt on, which includes a +redirected stdout, piped stdin and any `--output-format json`/`stream-json` run — the +command fails with the consent-required error rather than deleting unasked. Stdin is +never read for the answer. The action line and the running-instance tally (when one is +known) are written to stderr before the typed-name prompt. ## API Routes @@ -60,8 +63,8 @@ rather than deleting unasked. | `0` | nothing deployed under that name, with `--yes` (teardown is idempotent) | | `1` | invalid compute name | | `1` | nothing deployed under that name, without `--yes` | -| `1` | the typed confirmation did not match the compute's name | -| `1` | confirmation needed but no interactive terminal to ask on, and no `--yes` | +| `1` | the typed confirmation did not match the compute's name (declined) | +| `1` | confirmation needed but this run can't prompt, and no `--yes` | | `1` | API error, project not enrolled in the alpha, no such project, or a route the API does not serve | ## Environment Variables @@ -87,14 +90,14 @@ wrapper emits for every command. ## Output Formats -| Mode | stdout | stderr | -| ----------------------------- | ---------------------------------------------------------------------------------------------- | ------------------------------------------------------------------- | -| text (default) | the confirmation prompt, then what was deleted and kept | that nothing local was kept when nothing was, and the redeploy hint | -| `--output-format json` | one structured result carrying `compute_name`, `project_ref`, `kept_*` | neither — both are text-only | -| `--output-format stream-json` | the same result as a single terminal event | neither — both are text-only | -| `-o json` / `yaml` / `toml` | the same payload in that encoding, and nothing else | neither — both are text-only | -| `-o pretty` / `table` / `csv` | the text rendering — these fall through rather than encoding | both, as in text | -| `-o env` | refused **before** the DELETE; discovering it at emit time deleted the compute and then failed | the error | +| Mode | stdout | stderr | +| ----------------------------- | ---------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- | +| text (default) | the typed-name prompt, then what was deleted and kept | the action line and instance tally, that nothing local was kept when nothing was, and the redeploy hint | +| `--output-format json` | one structured result carrying `compute_name`, `project_ref`, `kept_*` | neither — both are text-only | +| `--output-format stream-json` | the same result as a single terminal event | neither — both are text-only | +| `-o json` / `yaml` / `toml` | the same payload in that encoding, and nothing else | neither — both are text-only | +| `-o pretty` / `table` / `csv` | the text rendering — these fall through rather than encoding | both, as in text | +| `-o env` | refused **before** the DELETE; discovering it at emit time deleted the compute and then failed | the error | A structured emission is the end of the run: the handler returns at `emitComputeMachineOutput` or at `output.success`, so nothing in the text diff --git a/apps/cli/src/commands/experimental/compute/delete/delete.handler.ts b/apps/cli/src/commands/experimental/compute/delete/delete.handler.ts index 02d4e86192..ef3c0477ad 100644 --- a/apps/cli/src/commands/experimental/compute/delete/delete.handler.ts +++ b/apps/cli/src/commands/experimental/compute/delete/delete.handler.ts @@ -6,21 +6,18 @@ import { renderComputeDetails } from "../compute.format.ts"; import { emitComputeMachineOutput, rejectComputeEnvOutput, - computeMachineOutputRequested, computeProjectRefSuffix, } from "../compute.output.ts"; import { CommandPlatformApi } from "../../../../auth/command-platform-api.service.ts"; import { displayPath } from "../../../../shared/compute/compute-paths.ts"; import { deleteCompute, getCompute } from "../../../../shared/compute/compute-api.ts"; import { - ComputeDeleteConfirmationRequiredError, - ComputeDeleteNotConfirmedError, ComputeNotDeployedError, ComputeApiUnexpectedStatusError, } from "../../../../shared/compute/compute.errors.ts"; import { resolveYes } from "../../../../command-internal/global-flags.ts"; +import { requireHostedMutationConsent } from "../../../../command-internal/hosted-consent/hosted-consent.ts"; import { ProjectRefResolver } from "../../../../config/project-ref.service.ts"; -import { Tty } from "../../../../shared/runtime/tty.service.ts"; import { LinkedProjectCache } from "../../../../telemetry/linked-project-cache.service.ts"; import { TelemetryState } from "../../../../telemetry/telemetry-state.service.ts"; import { @@ -44,7 +41,6 @@ export const computeDelete = Effect.fn("compute.delete")(function* (flags: Compu const resolver = yield* ProjectRefResolver; const linkedProjectCache = yield* LinkedProjectCache; const telemetryState = yield* TelemetryState; - const tty = yield* Tty; const yes = yield* resolveYes; // The ref is resolved outside the finalizers because caching it is one of @@ -86,7 +82,6 @@ export const computeDelete = Effect.fn("compute.delete")(function* (flags: Compu yield* fetching.clear; const deployed = lookup.compute; - const machineOutput = yield* computeMachineOutputRequested(); // `--yes` is the scripted path, and `deleteCompute` already treats a DELETE // 404 as done — "a delete that races another one is still a delete that @@ -104,24 +99,10 @@ export const computeDelete = Effect.fn("compute.delete")(function* (flags: Compu }); } - if (!yes) { - // `-o json` leaves `output.format` as `text`, so the format check alone - // still let the warning and the prompt run — onto the stdout the user had - // asked to carry a payload. A machine format is as non-interactive as a - // redirected stdout, whichever flag asked for it. - // - // `output.interactive` only tracks *stdout*, so on its own it still let - // `printf 'api\n' | supabase compute delete api` feed the pipe straight - // into the prompt and delete without `--yes`. The confirmation is only - // meaningful from a keyboard, so stdin has to be a terminal too — the same - // pair `projects delete` guards its prompt with. - if (output.format !== "text" || machineOutput || !output.interactive || !tty.stdinIsTty) { - return yield* new ComputeDeleteConfirmationRequiredError({ - detail: `Deleting "${name}" from project ${projectRef} needs confirmation, and there is no interactive terminal to ask on.`, - suggestion: `Re-run \`supabase compute delete ${name} --yes${refSuffix}\` to confirm without a prompt.`, - }); - } - + // Skipped only when the GET confirmed nothing exists; an unreadable compute + // still gets the DELETE, since that's the request the credential may hold. + const deleteSent = deployed !== undefined || !lookup.readable; + if (deleteSent) { // Uses the live instance count when known; otherwise falls back to the // declared target so a still-provisioning compute doesn't understate what // gets torn down. Both are absent when the read was refused. @@ -130,28 +111,17 @@ export const computeDelete = Effect.fn("compute.delete")(function* (flags: Compu const terminating = live !== undefined ? live > 0 - ? ` ${live} running instance${live === 1 ? "" : "s"} will be terminated.` - : "" + ? `${live} running instance${live === 1 ? "" : "s"} will be terminated.` + : undefined : declared !== undefined && declared > 0 - ? ` ${declared} declared instance${declared === 1 ? "" : "s"} will be terminated.` - : ""; - yield* output.raw( - `This permanently deletes "${name}" from project ${projectRef}.${terminating}\n`, - ); - const typed = yield* output.promptText(`Type ${name} to confirm`); - // Trimmed so a pasted trailing space doesn't force a re-run. - if (typed.trim() !== name) { - return yield* new ComputeDeleteNotConfirmedError({ - detail: `The confirmation did not match "${name}", so nothing was deleted.`, - suggestion: `Re-run \`supabase compute delete ${name}${refSuffix}\` and type the name exactly, or pass --yes.`, - }); - } - } + ? `${declared} declared instance${declared === 1 ? "" : "s"} will be terminated.` + : undefined; + yield* requireHostedMutationConsent({ + action: `permanently delete compute "${name}"`, + ...(terminating === undefined ? {} : { details: [terminating] }), + confirm: { kind: "typed", expected: name }, + }); - // Skipped only when the GET confirmed nothing exists; an unreadable compute - // still gets the DELETE, since that's the request the credential may hold. - const deleteSent = deployed !== undefined || !lookup.readable; - if (deleteSent) { const deleting = yield* output.task("Deleting compute..."); yield* deleteCompute(api, projectRef, name).pipe(Effect.tapError(() => deleting.fail())); yield* deleting.clear; diff --git a/apps/cli/src/commands/experimental/compute/delete/delete.integration.test.ts b/apps/cli/src/commands/experimental/compute/delete/delete.integration.test.ts index 7f6059c62c..fc1542ffbb 100644 --- a/apps/cli/src/commands/experimental/compute/delete/delete.integration.test.ts +++ b/apps/cli/src/commands/experimental/compute/delete/delete.integration.test.ts @@ -1,23 +1,25 @@ import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; -import { Effect, Option, FileSystem, Path, Schema } from "effect"; +import { Effect, Layer, Option, FileSystem, Path, Schema } from "effect"; import { makeComputeProject, + seedLinkedComputeProject, setupCompute, computeResource, computeRoute, COMPUTE_PROJECT_REF, } from "../../../../../tests/helpers/compute.ts"; import { - ComputeDeleteConfirmationRequiredError, - ComputeDeleteNotConfirmedError, ComputeNotDeployedError, ComputeUnavailableError, ComputeApiUnexpectedStatusError, ComputeProjectNotFoundError, ComputeRouteNotFoundError, } from "../../../../shared/compute/compute.errors.ts"; +import { describeHostedConsent } from "../../../../../tests/helpers/hosted-consent.ts"; +import { configValuesLayer } from "../../../../../tests/helpers/config-values-layer.ts"; import { ComputeEnvNotSupportedError } from "../compute.errors.ts"; +import { computeDeleteCommand } from "./delete.command.ts"; import { computeDelete } from "./delete.handler.ts"; const CONFIG = `project_id = "demo"\n\n[compute.api]\nruntime = "node"\nsize = "2gb"\n`; @@ -64,10 +66,10 @@ describe("compute delete", () => { }); expect(http.routeKeys).toEqual([getRoute, deleteRoute]); - expect(out.stdoutText).toContain("permanently deletes"); + expect(out.stderrText).toContain('permanently delete compute "api"'); // Labelled "declared" because this response carries no live tally. // `spec.instances` is the target, not what is running. - expect(out.stdoutText).toContain("3 declared instances"); + expect(out.stderrText).toContain("3 declared instances"); expect(out.stdoutText).toContain("Kept"); // Nothing local is touched — that is what makes `push` a one-command undo. @@ -150,9 +152,9 @@ describe("compute delete", () => { return yield* Effect.gen(function* () { yield* computeDelete({ name: "api", projectRef: Option.none() }); - expect(out.stdoutText).toContain("permanently deletes"); + expect(out.stderrText).toContain('permanently delete compute "api"'); // No count is quoted: the read that would have supplied one was refused. - expect(out.stdoutText).not.toContain("will be terminated"); + expect(out.stderrText).not.toContain("will be terminated"); expect(http.routeKeys).toEqual([getRoute, deleteRoute]); }).pipe(Effect.provide(layer)); }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), @@ -202,106 +204,6 @@ describe("compute delete", () => { }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), ); - it.effect("deletes nothing when the confirmation does not match", () => - Effect.gen(function* () { - const repo = yield* project(); - const { layer, http } = setupCompute({ - workdir: repo.dir, - routes, - promptTextResponses: ["nope"], - }); - - return yield* Effect.gen(function* () { - const error = yield* computeDelete({ - name: "api", - projectRef: Option.none(), - }).pipe(Effect.flip); - - expect(error).toBeInstanceOf(ComputeDeleteNotConfirmedError); - expect(http.routeKeys).toEqual([getRoute]); - }).pipe(Effect.provide(layer)); - }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), - ); - - // The suggested retry is copy-pasted verbatim and carries `--yes`, so dropping - // an explicit ref points a no-prompt delete at whatever this checkout is - // linked to — a same-named compute in a project the user never named. - it.effect("keeps an explicit --project-ref in the retry it suggests", () => - Effect.gen(function* () { - const repo = yield* project(); - const otherRef = "qrstuvwxyzabcdefghij"; - const { layer } = setupCompute({ - workdir: repo.dir, - format: "json", - routes: { - [`GET /v2/projects/${otherRef}/compute/api`]: { - status: 200, - body: { data: computeResource({ name: "api" }) }, - }, - }, - }); - - return yield* Effect.gen(function* () { - const error = yield* computeDelete({ - name: "api", - projectRef: Option.some(otherRef), - }).pipe(Effect.flip); - - expect(error).toBeInstanceOf(ComputeDeleteConfirmationRequiredError); - const suggestion = - error instanceof ComputeDeleteConfirmationRequiredError ? error.suggestion : ""; - expect(suggestion).toContain(`--project-ref ${otherRef}`); - }).pipe(Effect.provide(layer)); - }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), - ); - - it.effect("leaves the retry bare when the ref came from the link", () => - Effect.gen(function* () { - const repo = yield* project(); - const { layer } = setupCompute({ workdir: repo.dir, format: "json", routes }); - - return yield* Effect.gen(function* () { - const error = yield* computeDelete({ - name: "api", - projectRef: Option.none(), - }).pipe(Effect.flip); - - expect(error).toBeInstanceOf(ComputeDeleteConfirmationRequiredError); - const suggestion = - error instanceof ComputeDeleteConfirmationRequiredError ? error.suggestion : ""; - expect(suggestion).not.toContain("--project-ref"); - }).pipe(Effect.provide(layer)); - }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), - ); - - it.effect("keeps an explicit --project-ref in the confirmation-mismatch retry", () => - Effect.gen(function* () { - const repo = yield* project(); - const otherRef = "qrstuvwxyzabcdefghij"; - const { layer } = setupCompute({ - workdir: repo.dir, - promptTextResponses: ["nope"], - routes: { - [`GET /v2/projects/${otherRef}/compute/api`]: { - status: 200, - body: { data: computeResource({ name: "api" }) }, - }, - }, - }); - - return yield* Effect.gen(function* () { - const error = yield* computeDelete({ - name: "api", - projectRef: Option.some(otherRef), - }).pipe(Effect.flip); - - expect(error).toBeInstanceOf(ComputeDeleteNotConfirmedError); - const suggestion = error instanceof ComputeDeleteNotConfirmedError ? error.suggestion : ""; - expect(suggestion).toContain(`--project-ref ${otherRef}`); - }).pipe(Effect.provide(layer)); - }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), - ); - it.effect("skips the confirmation with --yes", () => Effect.gen(function* () { const repo = yield* project(); @@ -311,90 +213,8 @@ describe("compute delete", () => { yield* computeDelete({ name: "api", projectRef: Option.none() }); expect(http.routeKeys).toEqual([getRoute, deleteRoute]); - expect(out.stdoutText).not.toContain("permanently deletes"); - }).pipe(Effect.provide(layer)); - }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), - ); - - // `printf 'api\n' | supabase compute delete api`: stdout is still a TTY, so - // `output.interactive` stayed true and the prompt read the compute name off the - // pipe — a confirmation the user never typed. - it.effect("refuses to read the confirmation off a piped stdin", () => - Effect.gen(function* () { - const repo = yield* project(); - const { layer, http } = setupCompute({ - workdir: repo.dir, - routes, - stdinIsTty: false, - promptTextResponses: ["api"], - }); - - return yield* Effect.gen(function* () { - const error = yield* computeDelete({ - name: "api", - projectRef: Option.none(), - }).pipe(Effect.flip); - - expect(error).toBeInstanceOf(ComputeDeleteConfirmationRequiredError); - expect(http.routeKeys).not.toContain(deleteRoute); - }).pipe(Effect.provide(layer)); - }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), - ); - - it.effect("refuses to delete unattended rather than skipping the confirmation", () => - Effect.gen(function* () { - const repo = yield* project(); - const { layer, http } = setupCompute({ workdir: repo.dir, format: "json", routes }); - - return yield* Effect.gen(function* () { - const error = yield* computeDelete({ - name: "api", - projectRef: Option.none(), - }).pipe(Effect.flip); - - expect(error).toBeInstanceOf(ComputeDeleteConfirmationRequiredError); - expect(http.routeKeys).toEqual([getRoute]); - }).pipe(Effect.provide(layer)); - }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), - ); - - // `interactive` follows stdout, so a plain `>` redirect reaches this branch even - // from a live terminal — the case where deleting without asking would be worst. - it.effect("refuses when stdout is redirected and no --yes was given", () => - Effect.gen(function* () { - const repo = yield* project(); - const { layer, http } = setupCompute({ - workdir: repo.dir, - interactive: false, - routes, - }); - - return yield* Effect.gen(function* () { - const error = yield* computeDelete({ - name: "api", - projectRef: Option.none(), - }).pipe(Effect.flip); - - expect(error).toBeInstanceOf(ComputeDeleteConfirmationRequiredError); - expect(http.routeKeys).toEqual([getRoute]); - }).pipe(Effect.provide(layer)); - }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), - ); - - it.effect("deletes unattended when SUPABASE_YES or --yes authorises it", () => - Effect.gen(function* () { - const repo = yield* project(); - const { layer, http } = setupCompute({ - workdir: repo.dir, - format: "json", - routes, - yes: true, - }); - - return yield* Effect.gen(function* () { - yield* computeDelete({ name: "api", projectRef: Option.none() }); - - expect(http.routeKeys).toEqual([getRoute, deleteRoute]); + expect(out.promptTextCalls).toEqual([]); + expect(out.stderrText).toContain('Confirmed by --yes: permanently delete compute "api"'); }).pipe(Effect.provide(layer)); }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), ); @@ -674,32 +494,6 @@ describe("compute delete", () => { }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), ); - // `-o json` leaves `output.format` as `text`, so the interactive check alone - // still ran the warning and the prompt — onto the stdout the payload was - // supposed to own. - it.effect("refuses rather than prompting when -o json asked for the stdout", () => - Effect.gen(function* () { - const repo = yield* project(); - const { layer, out, http } = setupCompute({ - workdir: repo.dir, - routes, - outputFlag: "json", - promptTextResponses: ["api"], - }); - - return yield* Effect.gen(function* () { - const error = yield* computeDelete({ - name: "api", - projectRef: Option.none(), - }).pipe(Effect.flip); - - expect(error).toBeInstanceOf(ComputeDeleteConfirmationRequiredError); - expect(out.stdoutText).not.toContain("permanently deletes"); - expect(http.routeKeys).not.toContain(deleteRoute); - }).pipe(Effect.provide(layer)); - }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), - ); - // The live tally is what is actually running; `spec.instances` is the target. // For a compute mid-provision the two differ, and a destructive confirmation is // the worst place to overstate. @@ -727,8 +521,8 @@ describe("compute delete", () => { return yield* Effect.gen(function* () { yield* computeDelete({ name: "api", projectRef: Option.none() }); - expect(out.stdoutText).toContain("1 running instance will be terminated"); - expect(out.stdoutText).not.toContain("3 running"); + expect(out.stderrText).toContain("1 running instance will be terminated"); + expect(out.stderrText).not.toContain("3 running"); }).pipe(Effect.provide(layer)); }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), ); @@ -757,7 +551,7 @@ describe("compute delete", () => { return yield* Effect.gen(function* () { yield* computeDelete({ name: "api", projectRef: Option.none() }); - expect(out.stdoutText).toContain("2 running instances will be terminated"); + expect(out.stderrText).toContain("2 running instances will be terminated"); }).pipe(Effect.provide(layer)); }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), ); @@ -788,8 +582,8 @@ describe("compute delete", () => { return yield* Effect.gen(function* () { yield* computeDelete({ name: "api", projectRef: Option.none() }); - expect(out.stdoutText).toContain("permanently deletes"); - expect(out.stdoutText).not.toContain("will be terminated"); + expect(out.stderrText).toContain('permanently delete compute "api"'); + expect(out.stderrText).not.toContain("will be terminated"); }).pipe(Effect.provide(layer)); }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), ); @@ -886,3 +680,22 @@ describe("compute delete", () => { }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), ); }); + +describeHostedConsent("compute delete", { + command: computeDeleteCommand, + typed: true, + setup: (workdir) => + seedLinkedComputeProject(workdir, { + "supabase/config.toml": CONFIG, + "supabase/compute/api/index.js": "export default {};\n", + }), + invoke: ({ workdir }) => { + const { http } = setupCompute({ workdir, routes }); + return { + run: computeDelete({ name: "api", projectRef: Option.none() }).pipe( + Effect.provide(Layer.mergeAll(http.layer, configValuesLayer())), + ), + mutations: () => http.requests.filter((request) => request.method === "DELETE").length, + }; + }, +}); 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 a159638851..21da4e823f 100644 --- a/apps/cli/src/commands/experimental/compute/push/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/experimental/compute/push/SIDE_EFFECTS.md @@ -31,6 +31,16 @@ and the command handler does not run. See the [Compute command guide](../../../. | `<SUPABASE_HOME or ~/.supabase>/telemetry.json` | JSON | always — flushed on success and on failure | | `<workdir>/supabase/.temp/linked-project.json` | JSON | after the project ref resolves, when the cache does not hold it | +## Confirmation + +Before anything is uploaded, the command asks `deploy compute <names>?` (default +**No**). `--yes` (the root persistent flag) or a shell `SUPABASE_YES` answers it and +prints `Confirmed by --yes: …` on stderr; a `SUPABASE_YES` in a project `.env` does not +count. With neither — and no interactive terminal to prompt on, which includes a +redirected stdout, piped stdin and any `--output-format json`/`stream-json` or `-o` +machine format — the command fails with the consent-required error rather than +deploying unasked. Stdin is never read for the answer. + ## API Routes | Method | Path | Auth | Request body | Response (used fields) | @@ -62,6 +72,8 @@ run reports the accepted spec the deploy response returned. | `1` | build context upload failed | | `1` | the build reached `failed`, or never left `building` | | `1` | with `--no-wait`: the deploy was answered with `build_state: failed` | +| `1` | confirmation declined | +| `1` | confirmation needed but this run can't prompt, and no `--yes` | | `1` | API error, or project not enrolled in the alpha | ## Environment Variables @@ -73,6 +85,7 @@ run reports the accepted spec the deploy response returned. | `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`) | +| `SUPABASE_YES` | answers the deploy confirmation, as `--yes` does | no (defaults to prompting) | ## Telemetry Events Fired diff --git a/apps/cli/src/commands/experimental/compute/push/push.handler.ts b/apps/cli/src/commands/experimental/compute/push/push.handler.ts index 4551975f86..ea727c3ab0 100644 --- a/apps/cli/src/commands/experimental/compute/push/push.handler.ts +++ b/apps/cli/src/commands/experimental/compute/push/push.handler.ts @@ -50,6 +50,7 @@ import { ComputeSourceMissingError, MissingComputeExposureError, } from "../../../../shared/compute/compute.errors.ts"; +import { requireHostedMutationConsent } from "../../../../command-internal/hosted-consent/hosted-consent.ts"; 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"; @@ -593,6 +594,10 @@ export const computePush = Effect.fn("compute.push")(function* ( // that out at the end means failing with the remote project already changed. yield* rejectComputeEnvOutput(); + yield* requireHostedMutationConsent({ + action: `deploy compute ${names.join(", ")}`, + }); + const machineOutput = yield* computeMachineOutputRequested(); const deployed: Array<Record<string, unknown>> = []; // Accepted, but not finished: their builds outlive a failure further down diff --git a/apps/cli/src/commands/experimental/compute/push/push.integration.test.ts b/apps/cli/src/commands/experimental/compute/push/push.integration.test.ts index 1829d6b68a..ed41bd0518 100644 --- a/apps/cli/src/commands/experimental/compute/push/push.integration.test.ts +++ b/apps/cli/src/commands/experimental/compute/push/push.integration.test.ts @@ -1,14 +1,18 @@ import { BunServices } from "@effect/platform-bun"; import { assert, describe, expect, it } from "@effect/vitest"; -import { Effect, Option, Predicate, Schedule, FileSystem, Path, Schema } from "effect"; +import { Effect, Layer, Option, Predicate, Schedule, FileSystem, Path, Schema } from "effect"; import { makeComputeProject, + seedLinkedComputeProject, setupCompute, + type ComputeSetupOptions, computeResource, computeRoute, COMPUTE_PROJECT_REF, type ComputeHttpRoutes, } from "../../../../../tests/helpers/compute.ts"; +import { configValuesLayer } from "../../../../../tests/helpers/config-values-layer.ts"; +import { describeHostedConsent } from "../../../../../tests/helpers/hosted-consent.ts"; import { ProjectRefNotLinkedError } from "../../../../config/project-ref.errors.ts"; import { ComputeEnvNotSupportedError } from "../compute.errors.ts"; import { @@ -28,8 +32,11 @@ import { MissingComputeExposureError, } from "../../../../shared/compute/compute.errors.ts"; import { computePush } from "./push.handler.ts"; +import { computePushCommand } from "./push.command.ts"; import type { ComputePushFlags } from "./push.command.ts"; +const setupPush = (options: ComputeSetupOptions) => setupCompute({ yes: true, ...options }); + const UPLOAD_URL = "https://storage.example/deploy-context/api.tar.gz?signed"; const UPLOAD_ID = "cafe0000000000000000000000000000"; @@ -122,7 +129,7 @@ describe("compute push", () => { it.effect("packages, uploads, deploys and waits for the build to settle", () => Effect.gen(function* () { const repo = yield* project(); - const { layer, out, http } = setupCompute({ workdir: repo.dir, routes: routes() }); + const { layer, out, http } = setupPush({ workdir: repo.dir, routes: routes() }); return yield* Effect.gen(function* () { yield* push(); @@ -168,7 +175,7 @@ describe("compute push", () => { it.effect("returns once the deploy is accepted when --no-wait is passed", () => Effect.gen(function* () { const repo = yield* project(); - const { layer, out, http } = setupCompute({ workdir: repo.dir, routes: routes() }); + const { layer, out, http } = setupPush({ workdir: repo.dir, routes: routes() }); return yield* Effect.gen(function* () { yield* push({ noWait: true }); @@ -194,7 +201,7 @@ describe("compute push", () => { // surface as a timeout instead of the answer the platform already gave. describe("honours a terminal deploy response instead of polling", () => { const settledOnDeploy = (repoDir: string, state: "active" | "failed") => - setupCompute({ + setupPush({ workdir: repoDir, routes: routes({ [`POST ${computeRoute("/api/deploy")}`]: { @@ -247,7 +254,7 @@ describe("compute push", () => { "supabase/config.toml": `project_id = "demo"\n\n[compute.api]\nruntime = "dockerfile"\n`, "supabase/compute/api/Dockerfile": "FROM node:24-alpine\nEXPOSE 8080\n", }); - const { layer, http } = setupCompute({ + const { layer, http } = setupPush({ workdir: repo.dir, routes: routes({ [`GET ${computeRoute("/api")}`]: { @@ -280,7 +287,7 @@ describe("compute push", () => { "supabase/config.toml": `project_id = "demo"\n`, "supabase/compute/api/package.json": "{}\n", }); - const { layer, out, http } = setupCompute({ workdir: repo.dir, routes: routes() }); + const { layer, out, http } = setupPush({ workdir: repo.dir, routes: routes() }); return yield* Effect.gen(function* () { yield* push({ exposure: Option.some("private") }); @@ -306,7 +313,7 @@ describe("compute push", () => { "supabase/config.toml": `project_id = "demo"\n`, "supabase/compute/api/package.json": "{}\n", }); - const { layer, http, out } = setupCompute({ workdir: repo.dir, routes: routes() }); + const { layer, http, out } = setupPush({ workdir: repo.dir, routes: routes() }); return yield* Effect.gen(function* () { const error = yield* push().pipe(Effect.flip); expect(Predicate.isTagged(error, "MissingComputeExposureError")).toBe(true); @@ -324,7 +331,7 @@ describe("compute push", () => { "supabase/config.toml": `project_id = "demo"\n\n[compute.api]\nruntime = "node"\nsize = "2gb"\nexposure = "private"\n`, "supabase/compute/web/package.json": "{}\n", }); - const { layer, http, out } = setupCompute({ + const { layer, http, out } = setupPush({ workdir: repo.dir, routes: routes({ [`POST ${computeRoute("/web/uploads")}`]: { status: 201, body: uploadSlot }, @@ -376,7 +383,7 @@ describe("compute push", () => { const repo = yield* project({ "supabase/config.toml": `project_id = "demo"\n\n[compute.api]\nruntime = "cobol"\n`, }); - const { layer, http } = setupCompute({ workdir: repo.dir, routes: routes() }); + const { layer, http } = setupPush({ workdir: repo.dir, routes: routes() }); return yield* Effect.gen(function* () { const error = yield* push().pipe(Effect.flip); @@ -396,7 +403,7 @@ describe("compute push", () => { const repo = yield* project({ "supabase/config.toml": `project_id = "demo"\n\n[compute.api]\nruntime = "node"\nsize = "huge"\n`, }); - const { layer, http } = setupCompute({ workdir: repo.dir, routes: routes() }); + const { layer, http } = setupPush({ workdir: repo.dir, routes: routes() }); return yield* Effect.gen(function* () { const error = yield* push().pipe(Effect.flip); @@ -416,7 +423,7 @@ describe("compute push", () => { const repo = yield* project({ "supabase/config.toml": `project_id = "demo"\n\n[compute.api]\nruntime = "node"\nsize = "4gb"\n`, }); - const { layer, http } = setupCompute({ workdir: repo.dir, routes: routes() }); + const { layer, http } = setupPush({ workdir: repo.dir, routes: routes() }); return yield* Effect.gen(function* () { yield* push({ instances: Option.some(3) }); @@ -437,7 +444,7 @@ describe("compute push", () => { const repo = yield* project({ "supabase/config.toml": `project_id = "demo"\n\n[compute.api]\nruntime = "node"\nsize = "2gb"\ninstances = 4\n`, }); - const { layer, http } = setupCompute({ workdir: repo.dir, routes: routes() }); + const { layer, http } = setupPush({ workdir: repo.dir, routes: routes() }); return yield* Effect.gen(function* () { yield* push(); @@ -453,7 +460,7 @@ describe("compute push", () => { const repo = yield* project({ "supabase/config.toml": `project_id = "demo"\n\n[compute.api]\nruntime = "node"\nsize = "2gb"\ninstances = 4\n`, }); - const { layer, http } = setupCompute({ workdir: repo.dir, routes: routes() }); + const { layer, http } = setupPush({ workdir: repo.dir, routes: routes() }); return yield* Effect.gen(function* () { yield* push({ instances: Option.some(1) }); @@ -472,7 +479,7 @@ describe("compute push", () => { const repo = yield* project({ "supabase/config.toml": `project_id = "demo"\n\n[compute.api]\nruntime = "node"\nsize = "2gb"\nexposure = "private"\n`, }); - const { layer, http } = setupCompute({ workdir: repo.dir, routes: routes() }); + const { layer, http } = setupPush({ workdir: repo.dir, routes: routes() }); return yield* Effect.gen(function* () { yield* push(); @@ -492,7 +499,7 @@ describe("compute push", () => { const repo = yield* project({ "supabase/config.toml": `project_id = "demo"\n\n[compute.api]\nruntime = "node"\nexposure = "PRIVATE"\n`, }); - const { layer, http } = setupCompute({ workdir: repo.dir, routes: routes() }); + const { layer, http } = setupPush({ workdir: repo.dir, routes: routes() }); return yield* Effect.gen(function* () { yield* push(); @@ -510,7 +517,7 @@ describe("compute push", () => { const repo = yield* project({ "supabase/config.toml": `project_id = "demo"\n\n[compute.api]\nruntime = "node"\nsize = "2gb"\nexposure = "private"\n`, }); - const { layer, http } = setupCompute({ workdir: repo.dir, routes: routes() }); + const { layer, http } = setupPush({ workdir: repo.dir, routes: routes() }); return yield* Effect.gen(function* () { yield* push({ exposure: Option.some("public") }); @@ -531,7 +538,7 @@ describe("compute push", () => { const repo = yield* project({ "supabase/config.toml": `project_id = "demo"\n\n[compute.api]\nruntime = "node"\nexposure = "privat"\n`, }); - const { layer, http } = setupCompute({ workdir: repo.dir, routes: routes() }); + const { layer, http } = setupPush({ workdir: repo.dir, routes: routes() }); return yield* Effect.gen(function* () { const error = yield* push().pipe(Effect.flip); @@ -555,7 +562,7 @@ describe("compute push", () => { const repo = yield* project({ "supabase/config.toml": `project_id = "demo"\n\n[compute.api]\nruntime = "node"\nexposure = ""\n`, }); - const { layer, http } = setupCompute({ workdir: repo.dir, routes: routes() }); + const { layer, http } = setupPush({ workdir: repo.dir, routes: routes() }); return yield* Effect.gen(function* () { const error = yield* push().pipe(Effect.flip); @@ -580,7 +587,7 @@ describe("compute push", () => { const pushWith = (config: string, exposure: "public" | "private") => { return Effect.gen(function* () { const repo = yield* project({ "supabase/config.toml": config }); - const { layer, out } = setupCompute({ workdir: repo.dir, routes: routes() }); + const { layer, out } = setupPush({ workdir: repo.dir, routes: routes() }); return { layer, out, run: () => push({ exposure: Option.some(exposure) }) }; }); }; @@ -677,7 +684,7 @@ describe("compute push", () => { const repo = yield* project({ "supabase/config.toml": `project_id = "demo"\n\n[compute.api]\nruntime = "node"\nexposure = "privat"\n`, }); - const { layer, http } = setupCompute({ workdir: repo.dir, routes: routes() }); + const { layer, http } = setupPush({ workdir: repo.dir, routes: routes() }); return yield* Effect.gen(function* () { yield* push({ exposure: Option.some("private") }); @@ -693,7 +700,7 @@ describe("compute push", () => { it.effect("polls until the build leaves `building`", () => Effect.gen(function* () { const repo = yield* project(); - const { layer, http } = setupCompute({ + const { layer, http } = setupPush({ workdir: repo.dir, routes: routes({ [`GET ${computeRoute("/api")}`]: [ @@ -727,7 +734,7 @@ describe("compute push", () => { it.effect("fails with the build's own reason when the build fails", () => Effect.gen(function* () { const repo = yield* project(); - const { layer } = setupCompute({ + const { layer } = setupPush({ workdir: repo.dir, routes: routes({ [`GET ${computeRoute("/api")}`]: { @@ -760,7 +767,7 @@ describe("compute push", () => { it.effect("reports a failed build that came with no reason", () => Effect.gen(function* () { const repo = yield* project(); - const { layer } = setupCompute({ + const { layer } = setupPush({ workdir: repo.dir, routes: routes({ [`GET ${computeRoute("/api")}`]: { @@ -786,7 +793,7 @@ describe("compute push", () => { it.effect("omits the URL for a compute the platform did not expose publicly", () => Effect.gen(function* () { const repo = yield* project(); - const { layer, out } = setupCompute({ + const { layer, out } = setupPush({ workdir: repo.dir, routes: routes({ [`GET ${computeRoute("/api")}`]: { @@ -820,7 +827,7 @@ describe("compute push", () => { it.effect("deploys when called the way the command wires it, with no test seams", () => Effect.gen(function* () { const repo = yield* project(); - const { layer, out, http } = setupCompute({ workdir: repo.dir, routes: routes() }); + const { layer, out, http } = setupPush({ workdir: repo.dir, routes: routes() }); return yield* Effect.gen(function* () { yield* computePush(flags()); @@ -834,7 +841,7 @@ describe("compute push", () => { it.effect("stops waiting on a build that never settles, and says where to look", () => Effect.gen(function* () { const repo = yield* project(); - const { layer } = setupCompute({ + const { layer } = setupPush({ workdir: repo.dir, routes: routes({ [`GET ${computeRoute("/api")}`]: { @@ -859,7 +866,7 @@ describe("compute push", () => { describe("carries an explicit --project-ref into its hints", () => { const unlinked = (repoDir: string, routeOverrides = {}) => - setupCompute({ + setupPush({ workdir: repoDir, linked: false, routes: routes(routeOverrides), @@ -934,7 +941,7 @@ describe("compute push", () => { it.effect("but leaves it off when the link supplied the ref", () => Effect.gen(function* () { const repo = yield* project(); - const { layer, out } = setupCompute({ workdir: repo.dir, routes: routes() }); + const { layer, out } = setupPush({ workdir: repo.dir, routes: routes() }); return yield* Effect.gen(function* () { yield* push({ noWait: true }); @@ -949,7 +956,7 @@ describe("compute push", () => { it.effect("fails before deploying when the presigned upload is rejected", () => Effect.gen(function* () { const repo = yield* project(); - const { layer, http } = setupCompute({ + const { layer, http } = setupPush({ workdir: repo.dir, routes: routes({ "PUT /deploy-context/api.tar.gz": { status: 403, body: "expired" } }), }); @@ -972,7 +979,7 @@ describe("compute push", () => { it.effect("reports a rejected deploy after the context has been uploaded", () => Effect.gen(function* () { const repo = yield* project(); - const { layer, http } = setupCompute({ + const { layer, http } = setupPush({ workdir: repo.dir, routes: routes({ [`POST ${computeRoute("/api/deploy")}`]: { status: 500, body: { message: "boom" } }, @@ -998,7 +1005,7 @@ describe("compute push", () => { }), "supabase/compute/api/index.js": "export default { fetch: () => new Response('ok') };\n", }); - const { layer, http, out } = setupCompute({ workdir: created.dir, routes: routes() }); + const { layer, http, out } = setupPush({ workdir: created.dir, routes: routes() }); return yield* Effect.gen(function* () { yield* push(); @@ -1023,7 +1030,7 @@ describe("compute push", () => { it.effect("keeps the presigned signature out of an upload transport failure", () => Effect.gen(function* () { const repo = yield* project(); - const { layer, http } = setupCompute({ + const { layer, http } = setupPush({ workdir: repo.dir, routes: routes({ "PUT /deploy-context/api.tar.gz": { transportError: "connection reset by peer" }, @@ -1051,7 +1058,7 @@ describe("compute push", () => { it.effect("still reports the alpha refusal's pre-rollout body as unavailable", () => Effect.gen(function* () { const repo = yield* project(); - const { layer } = setupCompute({ + const { layer } = setupPush({ workdir: repo.dir, routes: routes({ [`POST ${computeRoute("/api/uploads")}`]: { @@ -1078,7 +1085,7 @@ describe("compute push", () => { it.effect("reports the alpha refusal's own code as unavailable, not as a missing project", () => Effect.gen(function* () { const repo = yield* project(); - const { layer } = setupCompute({ + const { layer } = setupPush({ workdir: repo.dir, routes: routes({ [`POST ${computeRoute("/api/uploads")}`]: { @@ -1105,7 +1112,7 @@ describe("compute push", () => { it.effect("points at the project ref when no such project exists", () => Effect.gen(function* () { const repo = yield* project(); - const { layer } = setupCompute({ + const { layer } = setupPush({ workdir: repo.dir, routes: routes({ [`POST ${computeRoute("/api/uploads")}`]: { @@ -1128,7 +1135,7 @@ describe("compute push", () => { it.effect("names the unserved route instead of the project when the API has no such route", () => Effect.gen(function* () { const repo = yield* project(); - const { layer } = setupCompute({ + const { layer } = setupPush({ workdir: repo.dir, routes: routes({ [`POST ${computeRoute("/api/uploads")}`]: { @@ -1156,7 +1163,7 @@ describe("compute push", () => { it.effect("keeps the enrolment answer for a 404 body it does not recognize", () => Effect.gen(function* () { const repo = yield* project(); - const { layer } = setupCompute({ + const { layer } = setupPush({ workdir: repo.dir, routes: routes({ [`POST ${computeRoute("/api/uploads")}`]: { status: 404, body: { unexpected: true } }, @@ -1180,7 +1187,7 @@ describe("compute push", () => { recursive: true, force: true, }); - const { layer, http } = setupCompute({ workdir: repo.dir, routes: routes() }); + const { layer, http } = setupPush({ workdir: repo.dir, routes: routes() }); return yield* Effect.gen(function* () { const error = yield* push().pipe(Effect.flip); @@ -1203,7 +1210,7 @@ describe("compute push", () => { yield* fs.remove(path.join(repo.dir, "supabase", "compute", "api", "index.js"), { force: true, }); - const { layer, http } = setupCompute({ workdir: repo.dir, routes: routes() }); + const { layer, http } = setupPush({ workdir: repo.dir, routes: routes() }); return yield* Effect.gen(function* () { const error = yield* push().pipe(Effect.flip); @@ -1233,7 +1240,7 @@ describe("compute push", () => { recursive: true, force: true, }); - const { layer, http } = setupCompute({ workdir: repo.dir, routes: routes() }); + const { layer, http } = setupPush({ workdir: repo.dir, routes: routes() }); return yield* Effect.gen(function* () { const error = yield* push().pipe(Effect.flip); @@ -1261,7 +1268,7 @@ describe("compute push", () => { recursive: true, force: true, }); - const { layer, http } = setupCompute({ workdir: repo.dir, routes: routes() }); + const { layer, http } = setupPush({ workdir: repo.dir, routes: routes() }); return yield* Effect.gen(function* () { const error = yield* push().pipe(Effect.flip); @@ -1287,7 +1294,7 @@ describe("compute push", () => { const source = path.join(repo.dir, "supabase", "compute", "api"); yield* fs.remove(source, { recursive: true, force: true }); yield* fs.writeFileString(source, "not a directory"); - const { layer, http } = setupCompute({ workdir: repo.dir, routes: routes() }); + const { layer, http } = setupPush({ workdir: repo.dir, routes: routes() }); return yield* Effect.gen(function* () { const error = yield* push().pipe(Effect.flip); @@ -1315,7 +1322,7 @@ describe("compute push", () => { const source = path.join(repo.dir, "supabase", "compute", "api"); yield* fs.remove(source, { recursive: true, force: true }); yield* fs.symlink("api", source); - const { layer, http } = setupCompute({ workdir: repo.dir, routes: routes() }); + const { layer, http } = setupPush({ workdir: repo.dir, routes: routes() }); return yield* Effect.gen(function* () { const error = yield* push().pipe(Effect.flip); @@ -1345,7 +1352,7 @@ describe("compute push", () => { Effect.as(true), Effect.orElseSucceed(() => false), )); - const { layer, http } = setupCompute({ workdir: repo.dir, routes: routes() }); + const { layer, http } = setupPush({ workdir: repo.dir, routes: routes() }); return yield* Effect.gen(function* () { if (!unreadable) { @@ -1376,7 +1383,7 @@ describe("compute push", () => { "../../config.toml", path.join(repo.dir, "supabase", "compute", "api", "escape.toml"), ); - const { layer, http } = setupCompute({ workdir: repo.dir, routes: routes() }); + const { layer, http } = setupPush({ workdir: repo.dir, routes: routes() }); return yield* Effect.gen(function* () { const error = yield* push().pipe(Effect.flip); @@ -1391,7 +1398,7 @@ describe("compute push", () => { it.effect("rides out a transient failure while polling the build", () => Effect.gen(function* () { const repo = yield* project(); - const { layer, http } = setupCompute({ + const { layer, http } = setupPush({ workdir: repo.dir, routes: routes({ [`GET ${computeRoute("/api")}`]: [ @@ -1418,7 +1425,7 @@ describe("compute push", () => { it.effect("surfaces a permanent poll failure on the first read instead of retrying it", () => Effect.gen(function* () { const repo = yield* project(); - const { layer, http } = setupCompute({ + const { layer, http } = setupPush({ workdir: repo.dir, routes: routes({ [`GET ${computeRoute("/api")}`]: { @@ -1456,7 +1463,7 @@ describe("compute push", () => { // process is somewhere else entirely. const repo = yield* project(); const elsewhere = yield* makeComputeProject(); - const { layer, http } = setupCompute({ + const { layer, http } = setupPush({ workdir: repo.dir, cwd: elsewhere.dir, routes: routes(), @@ -1476,7 +1483,7 @@ describe("compute push", () => { "supabase/config.toml": `project_id = "demo"\n\n[compute.api]\nruntime = "node"\n\n[compute.web]\nruntime = "node"\n`, "supabase/compute/web/index.js": "export default {};\n", }); - const { layer, out, http } = setupCompute({ + const { layer, out, http } = setupPush({ workdir: repo.dir, routes: { ...routes(), @@ -1535,7 +1542,7 @@ describe("compute push", () => { Effect.as(true), Effect.orElseSucceed(() => false), ); - const { layer, http } = setupCompute({ workdir: repo.dir, routes: routes() }); + const { layer, http } = setupPush({ workdir: repo.dir, routes: routes() }); return yield* Effect.gen(function* () { if (stattable) { @@ -1561,7 +1568,7 @@ describe("compute push", () => { const fs = yield* FileSystem.FileSystem; const repo = yield* project(); yield* fs.symlink("nowhere", path.join(repo.dir, "supabase", "compute", "ghost")); - const { layer, http } = setupCompute({ workdir: repo.dir, routes: routes() }); + const { layer, http } = setupPush({ workdir: repo.dir, routes: routes() }); return yield* Effect.gen(function* () { yield* push({ names: [] }); @@ -1590,7 +1597,7 @@ describe("compute push", () => { Effect.as(true), Effect.orElseSucceed(() => false), ); - const { layer, http } = setupCompute({ workdir: repo.dir, routes: routes() }); + const { layer, http } = setupPush({ workdir: repo.dir, routes: routes() }); return yield* Effect.gen(function* () { const error = yield* push({ names: [] }).pipe(Effect.flip); @@ -1613,7 +1620,7 @@ describe("compute push", () => { "supabase/config.toml": `project_id = "demo"\n\n[compute.api]\nruntime = "node"\n\n[compute.web]\nruntime = "node"\n`, "supabase/compute/web/index.js": "export default {};\n", }); - const { layer, out, http } = setupCompute({ + const { layer, out, http } = setupPush({ workdir: repo.dir, routes: routes({ // `api` sorts first, so the run stops before `web` is ever touched. @@ -1658,7 +1665,7 @@ describe("compute push", () => { "supabase/compute/web/index.js": "export default {};\n", "supabase/compute/zap/index.js": "export default {};\n", }); - const { layer, out, http } = setupCompute({ + const { layer, out, http } = setupPush({ workdir: repo.dir, routes: routes({ // The upload slot points at one URL for every compute, so `web` reuses @@ -1697,7 +1704,7 @@ describe("compute push", () => { recursive: true, force: true, }); - const { layer, http } = setupCompute({ workdir: repo.dir, routes: routes() }); + const { layer, http } = setupPush({ workdir: repo.dir, routes: routes() }); return yield* Effect.gen(function* () { const error = yield* push({ names: [] }).pipe(Effect.flip); @@ -1711,7 +1718,7 @@ describe("compute push", () => { it.effect("requires a linked project or an explicit --project-ref", () => Effect.gen(function* () { const repo = yield* project(); - const { layer } = setupCompute({ workdir: repo.dir, linked: false, routes: routes() }); + const { layer } = setupPush({ workdir: repo.dir, linked: false, routes: routes() }); return yield* Effect.gen(function* () { const error = yield* push().pipe(Effect.flip); @@ -1733,7 +1740,7 @@ describe("compute push", () => { recursive: true, force: true, }); - const { layer, out } = setupCompute({ workdir: repo.dir, routes: routes() }); + const { layer, out } = setupPush({ workdir: repo.dir, routes: routes() }); return yield* Effect.gen(function* () { yield* push(); @@ -1747,7 +1754,7 @@ describe("compute push", () => { it.effect("emits a structured result in json mode", () => Effect.gen(function* () { const repo = yield* project(); - const { layer, out } = setupCompute({ + const { layer, out } = setupPush({ workdir: repo.dir, format: "json", routes: routes(), @@ -1783,7 +1790,7 @@ describe("compute push", () => { // names an image this deploy did not produce. describe("does not report the previous image while a re-push is still building", () => { const rePush = (repoDir: string, format?: "json") => - setupCompute({ + setupPush({ workdir: repoDir, ...(format === undefined ? {} : { format }), routes: routes({ @@ -1848,7 +1855,7 @@ describe("compute push", () => { it.effect("still reports the image once the build has settled", () => Effect.gen(function* () { const repo = yield* project(); - const { layer, out } = setupCompute({ workdir: repo.dir, routes: routes() }); + const { layer, out } = setupPush({ workdir: repo.dir, routes: routes() }); return yield* Effect.gen(function* () { yield* push(); @@ -1866,7 +1873,7 @@ describe("compute push", () => { it.effect("reports the build as still running in json mode under --no-wait", () => Effect.gen(function* () { const repo = yield* project(); - const { layer, out } = setupCompute({ + const { layer, out } = setupPush({ workdir: repo.dir, format: "json", routes: routes(), @@ -1901,7 +1908,7 @@ describe("compute push", () => { "supabase/config.toml": `project_id = "demo"\n\n[compute.api]\nruntime = "node"\n\n[compute.web]\nruntime = "node"\n`, "supabase/compute/web/index.js": "export default {};\n", }); - const { layer, out } = setupCompute({ + const { layer, out } = setupPush({ workdir: repo.dir, format: "json", routes: { @@ -1935,7 +1942,7 @@ describe("compute push", () => { it.effect("refuses -o env before making any request at all", () => Effect.gen(function* () { const repo = yield* project(); - const { layer, http } = setupCompute({ + const { layer, http } = setupPush({ workdir: repo.dir, routes: routes(), outputFlag: "env", @@ -1960,7 +1967,7 @@ describe("compute push", () => { const repo = yield* project({ "supabase/compute/api/nested/.keep": "" }); yield* fs.remove(path.join(repo.dir, "supabase", "compute", "api", "index.js")); yield* fs.remove(path.join(repo.dir, "supabase", "compute", "api", "nested", ".keep")); - const { layer, http } = setupCompute({ workdir: repo.dir, routes: routes() }); + const { layer, http } = setupPush({ workdir: repo.dir, routes: routes() }); return yield* Effect.gen(function* () { const error = yield* push().pipe(Effect.flip); @@ -1984,7 +1991,7 @@ describe("compute push", () => { recursive: true, force: true, }); - const { layer, out } = setupCompute({ workdir: repo.dir, routes: routes() }); + const { layer, out } = setupPush({ workdir: repo.dir, routes: routes() }); return yield* Effect.gen(function* () { const error = yield* push().pipe(Effect.flip); @@ -2000,7 +2007,7 @@ describe("compute push", () => { it.effect("encodes -o toml when the deployed compute has no image version", () => Effect.gen(function* () { const repo = yield* project(); - const { layer, out } = setupCompute({ + const { layer, out } = setupPush({ workdir: repo.dir, outputFlag: "toml", routes: routes({ @@ -2032,7 +2039,7 @@ describe("compute push", () => { it.effect("leaves the matched paths out of the uploaded context and says how many", () => Effect.gen(function* () { const repo = yield* withExtraFiles('[".env", "node_modules"]'); - const { layer, out } = setupCompute({ workdir: repo.dir, routes: routes() }); + const { layer, out } = setupPush({ workdir: repo.dir, routes: routes() }); return yield* Effect.gen(function* () { yield* push(); @@ -2049,7 +2056,7 @@ describe("compute push", () => { it.effect("says nothing about exclusions when the compute configures none", () => Effect.gen(function* () { const repo = yield* project(); - const { layer, out } = setupCompute({ workdir: repo.dir, routes: routes() }); + const { layer, out } = setupPush({ workdir: repo.dir, routes: routes() }); return yield* Effect.gen(function* () { yield* push(); @@ -2065,7 +2072,7 @@ describe("compute push", () => { it.effect("refuses a re-inclusion pattern before anything is uploaded", () => Effect.gen(function* () { const repo = yield* withExtraFiles('["node_modules", "!node_modules/left-pad"]'); - const { layer, out, http } = setupCompute({ workdir: repo.dir, routes: routes() }); + const { layer, out, http } = setupPush({ workdir: repo.dir, routes: routes() }); return yield* Effect.gen(function* () { const error = yield* push().pipe(Effect.flip); @@ -2082,7 +2089,7 @@ describe("compute push", () => { it.effect("names the patterns when they match every file in the source", () => Effect.gen(function* () { const repo = yield* withExtraFiles('["*"]'); - const { layer, http } = setupCompute({ workdir: repo.dir, routes: routes() }); + const { layer, http } = setupPush({ workdir: repo.dir, routes: routes() }); return yield* Effect.gen(function* () { const error = yield* push().pipe(Effect.flip); @@ -2113,7 +2120,7 @@ describe("compute push", () => { recursive: true, }); yield* fs.makeDirectory(path.join(repo.dir, "supabase", "compute", "api", "nested")); - const { layer, http } = setupCompute({ workdir: repo.dir, routes: routes() }); + const { layer, http } = setupPush({ workdir: repo.dir, routes: routes() }); return yield* Effect.gen(function* () { const error = yield* push().pipe(Effect.flip); @@ -2133,7 +2140,7 @@ describe("compute push", () => { it.effect("refuses a malformed pattern", () => Effect.gen(function* () { const repo = yield* withExtraFiles('["src/[oops"]'); - const { layer, http } = setupCompute({ workdir: repo.dir, routes: routes() }); + const { layer, http } = setupPush({ workdir: repo.dir, routes: routes() }); return yield* Effect.gen(function* () { const error = yield* push().pipe(Effect.flip); @@ -2150,7 +2157,7 @@ describe("compute push", () => { it.effect("flushes telemetry when the project config cannot be loaded", () => Effect.gen(function* () { const repo = yield* project({ "supabase/config.toml": "project_id = [unclosed\n" }); - const { layer, telemetry } = setupCompute({ workdir: repo.dir, routes: routes() }); + const { layer, telemetry } = setupPush({ workdir: repo.dir, routes: routes() }); return yield* Effect.gen(function* () { yield* push().pipe(Effect.flip); @@ -2160,3 +2167,19 @@ describe("compute push", () => { }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), ); }); + +describeHostedConsent("compute push", { + command: computePushCommand, + setup: (workdir) => + seedLinkedComputeProject(workdir, { + "supabase/config.toml": `project_id = "demo"\n\n[compute.api]\nruntime = "node"\nsize = "2gb"\n`, + "supabase/compute/api/index.js": "export default {};\n", + }), + invoke: ({ workdir }) => { + const { http } = setupCompute({ workdir, routes: routes() }); + return { + run: push().pipe(Effect.provide(Layer.mergeAll(http.layer, configValuesLayer()))), + mutations: () => http.requests.filter((request) => request.method !== "GET").length, + }; + }, +}); diff --git a/apps/cli/tests/helpers/compute.ts b/apps/cli/tests/helpers/compute.ts index f2f075315d..13e17f256e 100644 --- a/apps/cli/tests/helpers/compute.ts +++ b/apps/cli/tests/helpers/compute.ts @@ -10,6 +10,7 @@ import { CommandSettings } from "../../src/config/command-settings.service.ts"; import { ProjectRefResolver } from "../../src/config/project-ref.service.ts"; import { CliArgs } from "../../src/shared/cli/cli-args.service.ts"; import { OutputFlag, YesFlag } from "../../src/command-internal/global-flags.ts"; +import { TargetPolicy } from "../../src/command-internal/target/target-policy.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"; @@ -343,6 +344,26 @@ export function makeComputeProject(files: Readonly<Record<string, string>> = {}) }); } +/** + * Writes `files` under `workdir` and links it to {@link COMPUTE_PROJECT_REF}, for + * `describeHostedConsent` scenarios whose runtime resolves the project from the real filesystem. + */ +export const seedLinkedComputeProject = ( + workdir: string, + files: Readonly<Record<string, string>>, +) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const all = { ...files, "supabase/.temp/project-ref": COMPUTE_PROJECT_REF }; + for (const [relativePath, contents] of Object.entries(all)) { + const absolutePath = `${workdir}/${relativePath}`; + yield* fs.makeDirectory(absolutePath.slice(0, absolutePath.lastIndexOf("/")), { + recursive: true, + }); + yield* fs.writeFileString(absolutePath, contents); + } + }); + /** * `CommandSettings` for the compute commands: the workdir they treat as the * project, and the host their URLs are built on, plus every other field @@ -430,7 +451,7 @@ export interface ComputeSetupOptions { * render text for. */ readonly outputFlag?: "env" | "pretty" | "json" | "toml" | "yaml" | "table" | "csv"; - /** The root `--yes`, read by `delete` through `resolveYes`. */ + /** The root `--yes`, which `push` and `delete` read through `resolveYes`. */ readonly yes?: boolean; /** Raw argv, which `resolveYes` scans for an explicit `--yes=false`. */ readonly cliArgs?: ReadonlyArray<string>; @@ -505,6 +526,7 @@ export function setupCompute(options: ComputeSetupOptions) { options.outputFlag === undefined ? Option.none() : Option.some(options.outputFlag), ), Layer.succeed(YesFlag, options.yes ?? false), + Layer.succeed(TargetPolicy, { group: "hosted", consent: "no" }), Layer.succeed(CliArgs, { args: options.cliArgs ?? [] }), processControl.layer, BunServices.layer, From 2be069ac8cc5b5c6f0c5763a28466e98fe322398 Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Thu, 15 Oct 2026 14:29:56 +0100 Subject: [PATCH 34/92] test(cli): update db diff tests for resolved targets --- .../commands/db/diff/diff.integration.test.ts | 161 +++++++++--------- 1 file changed, 78 insertions(+), 83 deletions(-) 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 f708aac488..ccc889bad6 100644 --- a/apps/cli/src/commands/db/diff/diff.integration.test.ts +++ b/apps/cli/src/commands/db/diff/diff.integration.test.ts @@ -66,6 +66,7 @@ import { DnsResolverFlag, ExperimentalFlag, NetworkIdFlag, + YesFlag, } from "../../../command-internal/global-flags.ts"; import type { OutputFormat } from "../../../shared/output/types.ts"; import { ProjectRefNotLinkedError } from "../../../config/project-ref.errors.ts"; @@ -75,6 +76,15 @@ import { } from "../../../config/project-ref.service.ts"; import { DbConfigLoadError } from "../../../command-internal/db-config.errors.ts"; import { DbConfigResolver } from "../../../command-internal/db-config.service.ts"; +import type { + DbConnectionInput, + ResolvedDbConnection, +} from "../../../command-internal/db-config.types.ts"; +import { TargetPolicy } from "../../../command-internal/target/target-policy.ts"; +import { + TargetProjectRefConflictError, + TargetSelectorConflictError, +} from "../../../command-internal/target/target.errors.ts"; import { DbConnection, type DbSession, @@ -397,15 +407,11 @@ function setup(workdir: string, opts: SetupOpts = {}) { }, }); - const resolverCalls: unknown[] = []; + const resolverCalls: DbConnectionInput[] = []; const resolver = Layer.succeed(DbConfigResolver, { - resolve: (resolveFlags) => { - resolverCalls.push(resolveFlags); - // A threaded `--project-ref` flag wins over the fixed `opts.linkedRef` fixture, - // matching real resolver precedence. - const flagRef = resolveFlags.linkedProjectRef ?? Option.none(); - const ref = - Option.isSome(flagRef) && flagRef.value.length > 0 ? flagRef.value : opts.linkedRef; + resolve: () => Effect.die("resolve is not used by this test"), + resolveConnection: (input: DbConnectionInput) => { + resolverCalls.push(input); return Effect.succeed({ conn: { host: "127.0.0.1", @@ -414,29 +420,29 @@ function setup(workdir: string, opts: SetupOpts = {}) { password: "postgres", database: "postgres", }, - isLocal: opts.isLocal ?? true, - ref: ref !== undefined ? Option.some(ref) : Option.none(), - }); + isLocal: opts.isLocal ?? input.target.kind === "local", + ref: input.target.kind === "linked" ? Option.some(input.target.ref) : Option.none(), + target: input.target, + } satisfies ResolvedDbConnection); }, - resolveConnection: () => Effect.die("resolveConnection is not used by this test"), resolvePoolerFallback: () => Effect.succeed(Option.none()), }); - // Mirrors the same ref `resolver`'s own mock embeds above, and gives an explicit - // `--project-ref` flag top precedence over `opts.linkedRef` (mirrors - // `reset.integration.test.ts`'s identical mock). const projectRefResolver = Layer.succeed(ProjectRefResolver, { - resolve: () => Effect.succeed(opts.linkedRef ?? VALID_REF), + resolve: () => Effect.die("resolve is not used by this test"), resolveForLink: () => Effect.succeed(opts.linkedRef ?? VALID_REF), - resolveRef: () => - Effect.succeed({ ref: opts.linkedRef ?? VALID_REF, source: "file", matchesLinkedRef: true }), - resolveOptional: () => Effect.succeed(Option.some(opts.linkedRef ?? VALID_REF)), - loadProjectRef: (flagValue: Option.Option<string>) => + resolveRef: (flagValue: Option.Option<string>) => Option.isSome(flagValue) && flagValue.value.length > 0 - ? Effect.succeed(flagValue.value) + ? Effect.succeed({ ref: flagValue.value, source: "flag", matchesLinkedRef: false }) : opts.linkedFails === true ? Effect.fail(new ProjectRefNotLinkedError({ message: PROJECT_NOT_LINKED_MESSAGE })) - : Effect.succeed(opts.linkedRef ?? VALID_REF), + : Effect.succeed({ + ref: opts.linkedRef ?? VALID_REF, + source: "file", + matchesLinkedRef: true, + }), + resolveOptional: () => Effect.succeed(Option.some(opts.linkedRef ?? VALID_REF)), + loadProjectRef: () => Effect.die("loadProjectRef is not used by this test"), promptProjectRef: () => Effect.succeed(opts.linkedRef ?? VALID_REF), }); @@ -508,6 +514,8 @@ function setup(workdir: string, opts: SetupOpts = {}) { Layer.succeed(ExperimentalFlag, false), Layer.succeed(DebugFlag, false), Layer.succeed(CliArgs, { args: [] }), + Layer.succeed(TargetPolicy, { group: "selectable", consent: "none" }), + Layer.succeed(YesFlag, false), mockRuntimeInfo({ platform: opts.platform ?? "linux" }), workdirFiles, shadowCacheDisabledLayer, @@ -556,8 +564,8 @@ const flags = (over: Partial<DbDiffFlags> = {}): DbDiffFlags => ({ to: over.to ?? Option.none(), output: over.output ?? Option.none(), dbUrl: over.dbUrl ?? Option.none(), - linked: over.linked ?? Option.none(), - local: over.local ?? Option.none(), + linked: over.linked ?? false, + local: over.local ?? false, projectRef: over.projectRef ?? Option.none(), file: over.file ?? Option.none(), schema: over.schema ?? [], @@ -936,7 +944,7 @@ describe("db diff", () => { diffSql: "alter table x;\n", }); return Effect.gen(function* () { - yield* dbDiff(flags({ linked: Option.some(true) })); + yield* dbDiff(flags({ linked: true })); expect(s.databaseDiffCalls).toHaveLength(1); expect(stderr(s.out)).not.toContain("are not read"); }).pipe(Effect.provide(s.layer)); @@ -1151,7 +1159,7 @@ describe("db diff", () => { diffSql: "alter table x;\n", }); return Effect.gen(function* () { - yield* dbDiff(flags({ linked: Option.some(true) })); + yield* dbDiff(flags({ linked: true })); expect(s.databaseDiffCalls[0]?.target.connectOptions.isLocal).toBe(false); expect(s.databaseDiffCalls[0]?.source.connectOptions.isLocal).toBe(true); }).pipe(Effect.provide(s.layer)); @@ -1182,7 +1190,7 @@ describe("db diff", () => { diffSql: "alter table x;\n", }); return Effect.gen(function* () { - yield* dbDiff(flags({ linked: Option.some(true) })); + yield* dbDiff(flags({ linked: true })); const createArgs = s.shadowSpawned.find((c) => c.args[0] === "create")?.args ?? []; expect(createArgs).toContain("--tmpfs"); expect(s.dockerCalls).toEqual([]); @@ -1211,7 +1219,7 @@ describe("db diff", () => { diffSql: "", }); return Effect.gen(function* () { - yield* dbDiff(flags({ linked: Option.some(true), file: Option.some("declared") })); + yield* dbDiff(flags({ linked: true, file: Option.some("declared") })); expect(stderr(s.out)).toContain( "Run supabase db schema declarative sync --experimental to generate", ); @@ -1248,7 +1256,7 @@ describe("db diff", () => { diffSql: "alter table x;\n", }); return Effect.gen(function* () { - yield* dbDiff(flags({ linked: Option.some(true) })); + yield* dbDiff(flags({ linked: true })); expect(s.cache.cached).toBe(true); }).pipe(Effect.provide(s.layer)); }); @@ -1262,7 +1270,7 @@ describe("db diff", () => { linkedFails: true, }); return Effect.gen(function* () { - yield* dbDiff(flags({ linked: Option.some(true), projectRef: Option.some(FLAG_REF) })); + yield* dbDiff(flags({ linked: true, projectRef: Option.some(FLAG_REF) })); expect(s.cache.cached).toBe(true); expect(s.cache.cachedRef).toBe(FLAG_REF); }).pipe(Effect.provide(s.layer)); @@ -1277,7 +1285,7 @@ describe("db diff", () => { diffSql: "alter table x;\n", }); return Effect.gen(function* () { - yield* dbDiff(flags({ linked: Option.some(true), projectRef: Option.some(FLAG_REF) })); + yield* dbDiff(flags({ linked: true, projectRef: Option.some(FLAG_REF) })); expect(s.cache.cached).toBe(true); expect(s.cache.cachedRef).toBe(FLAG_REF); expect(s.cache.cachedRef).not.toBe("abcdefghijklmnopqrst"); @@ -1289,14 +1297,11 @@ describe("db diff", () => { const s = setup(tmp.current); return Effect.gen(function* () { const exit = yield* Effect.exit( - dbDiff(flags({ local: Option.some(true), projectRef: Option.some(FLAG_REF) })), + dbDiff(flags({ local: true, projectRef: Option.some(FLAG_REF) })), ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const causeText = Cause.pretty(exit.cause); - expect(causeText).toContain( - "--project-ref only applies when targeting the linked project; use it with --linked (not --local or --db-url)", - ); + expect(Cause.squash(exit.cause)).toBeInstanceOf(TargetProjectRefConflictError); } expect(s.resolverCalls).toEqual([]); expect(s.cache.cached).toBe(false); @@ -1344,10 +1349,10 @@ describe("db diff", () => { ); it.effect( - "explicit --from local --to migrations --linked --project-ref proceeds and applies the flag ref's remote override", + "explicit --from local --to migrations --project-ref proceeds and applies the flag ref's remote override", () => { - // Same `[remotes.staging]` fixture as the `--from linked` case above, but here a - // changed `--linked` (not a literal "linked" ref) resolves the flag ref instead. + // Same `[remotes.staging]` fixture as the `--from linked` case above, but here + // `--project-ref` alone selects the linked target instead of a literal "linked" ref. const s = setup(tmp.current, { files: { "supabase/config.toml": [ @@ -1370,7 +1375,6 @@ describe("db diff", () => { flags({ from: Option.some("local"), to: Option.some("migrations"), - linked: Option.some(true), projectRef: Option.some("flagflagflagflagflag"), }), ); @@ -1384,33 +1388,6 @@ describe("db diff", () => { }, ); - it.effect( - "explicit --from local --to migrations --project-ref errors (neither side is linked)", - () => { - const FLAG_REF = "flagflagflagflagflag"; - const s = setup(tmp.current); - return Effect.gen(function* () { - const exit = yield* Effect.exit( - dbDiff( - flags({ - from: Option.some("local"), - to: Option.some("migrations"), - projectRef: Option.some(FLAG_REF), - }), - ), - ); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - const causeText = Cause.pretty(exit.cause); - expect(causeText).toContain( - "--project-ref only applies when targeting the linked project; use it with --linked, or --from/--to linked, in explicit mode", - ); - } - expect(s.resolverCalls).toEqual([]); - }).pipe(Effect.provide(s.layer)); - }, - ); - it.effect( "caches the linked ref even when the merged config fails to load afterward (review: PRRT_kwDOErm0O86XLe6s)", () => { @@ -1425,7 +1402,7 @@ describe("db diff", () => { diffSql: "alter table x;\n", }); return Effect.gen(function* () { - const exit = yield* dbDiff(flags({ linked: Option.some(true) })).pipe(Effect.exit); + const exit = yield* dbDiff(flags({ linked: true })).pipe(Effect.exit); expect(Exit.isFailure(exit)).toBe(true); expect(s.cache.cached).toBe(true); expect(s.cache.cachedRef).toBe("abcdefghijklmnopqrst"); @@ -1520,7 +1497,7 @@ describe("db diff", () => { yield* dbDiff( flags({ usePgAdmin: Option.some(true), - linked: Option.some(true), + linked: true, projectRef: Option.some(FLAG_REF), }), ); @@ -1554,7 +1531,7 @@ describe("db diff", () => { pgadminStdout: [JSON.stringify([pgadminEntry()])], }); return Effect.gen(function* () { - yield* dbDiff(flags({ usePgAdmin: Option.some(true), linked: Option.some(true) })); + yield* dbDiff(flags({ usePgAdmin: Option.some(true), linked: true })); expect(stderr(s.out)).toContain("Loading config override: [remotes.staging]"); expect(s.differCalls).toHaveLength(1); }).pipe(Effect.provide(s.layer)); @@ -1581,7 +1558,7 @@ describe("db diff", () => { pgadminStdout: [JSON.stringify([pgadminEntry()])], }); return Effect.gen(function* () { - yield* dbDiff(flags({ usePgAdmin: Option.some(true), linked: Option.some(true) })); + yield* dbDiff(flags({ usePgAdmin: Option.some(true), linked: true })); // `mockShadowContainerCliSpawner` distinguishes this preflight probe from the // shadow's own health-check inspect by the `supabase_db_` container-name prefix. const inspectTargets = s.shadowSpawned @@ -2084,7 +2061,7 @@ describe("db diff", () => { flags({ from: Option.some("local"), to: Option.some("migrations"), - linked: Option.some(true), + linked: true, }), ); expect(s.explicitDiffCalls[0]?.desired).toEqual({ @@ -2120,7 +2097,7 @@ describe("db diff", () => { flags({ from: Option.some("local"), to: Option.some("migrations"), - linked: Option.some(true), + linked: true, }), ).pipe(Effect.exit); expect(Exit.isSuccess(exit)).toBe(true); @@ -2148,9 +2125,7 @@ describe("db diff", () => { }).pipe(Effect.provide(s.layer)); }); - it.effect("explicit mode still runs the target-flag preflight on a changed --db-url", () => { - // A changed target flag is still validated even when the explicit refs drive the - // diff; the preflight resolves --db-url (connType db-url). + it.effect("explicit mode accepts --db-url without minting a connection", () => { const s = setup(tmp.current, { diffSql: "create table p ();\n" }); return Effect.gen(function* () { yield* dbDiff( @@ -2160,7 +2135,26 @@ describe("db diff", () => { dbUrl: Option.some("postgresql://x"), }), ); - expect(s.resolverCalls).toContainEqual(expect.objectContaining({ connType: "db-url" })); + expect(s.resolverCalls).toEqual([]); + expect(stdout(s.out)).toBe("create table p ();\n"); + }).pipe(Effect.provide(s.layer)); + }); + + it.effect("explicit mode rejects conflicting target selectors", () => { + const s = setup(tmp.current); + return Effect.gen(function* () { + const exit = yield* dbDiff( + flags({ + from: Option.some("local"), + to: Option.some("local"), + local: true, + dbUrl: Option.some("postgresql://x"), + }), + ).pipe(Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(Cause.squash(exit.cause)).toBeInstanceOf(TargetSelectorConflictError); + } }).pipe(Effect.provide(s.layer)); }); @@ -2213,10 +2207,11 @@ describe("db diff", () => { it.effect("fails on target mutex (--linked with --local)", () => { const s = setup(tmp.current); return Effect.gen(function* () { - const exit = yield* dbDiff( - flags({ linked: Option.some(true), local: Option.some(true) }), - ).pipe(Effect.exit); + const exit = yield* dbDiff(flags({ linked: true, local: true })).pipe(Effect.exit); expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(Cause.squash(exit.cause)).toBeInstanceOf(TargetSelectorConflictError); + } }).pipe(Effect.provide(s.layer)); }); @@ -2832,8 +2827,8 @@ describe("db diff", () => { const exit = yield* dbDiff( flags({ usePgAdmin: Option.some(true), - linked: Option.some(true), - local: Option.some(true), + linked: true, + local: true, }), ).pipe(Effect.exit); expect(Exit.isFailure(exit)).toBe(true); @@ -2962,7 +2957,7 @@ describe("db diff permissions through the real resolver and cache", () => { return Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; yield* stubManagementApi(requests, serveLinkedDatabase(VALID_REF)); - yield* dbDiff(flags({ linked: Option.some(true), projectRef: Option.some(VALID_REF) })).pipe( + yield* dbDiff(flags({ linked: true, projectRef: Option.some(VALID_REF) })).pipe( Effect.provide(s.layer), ); expect(asLines(requests)).toEqual([poolerRequest, loginRoleRequest]); From c69c0e3ddde9ecff54bfa99de6ef2894d382c749 Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Thu, 15 Oct 2026 14:31:33 +0100 Subject: [PATCH 35/92] feat(cli): ask one hosted-mutation consent question for config push --- .../src/commands/config/push/push.command.ts | 7 +- .../commands/config/push/push.cost-matrix.ts | 2 +- .../src/commands/config/push/push.e2e.test.ts | 16 +- .../src/commands/config/push/push.format.ts | 52 +- .../config/push/push.format.unit.test.ts | 43 +- .../src/commands/config/push/push.handler.ts | 559 ++++++------ .../config/push/push.integration.test.ts | 807 ++++++------------ .../cli/src/commands/config/push/push.plan.ts | 47 +- .../config/push/push.plan.unit.test.ts | 79 +- .../src/commands/config/push/push.types.ts | 7 +- .../commands/config/push/push.unit.test.ts | 35 +- .../deploy/deploy.integration.test.ts | 4 +- 12 files changed, 568 insertions(+), 1090 deletions(-) diff --git a/apps/cli/src/commands/config/push/push.command.ts b/apps/cli/src/commands/config/push/push.command.ts index 75e6340009..fbbef0a92b 100644 --- a/apps/cli/src/commands/config/push/push.command.ts +++ b/apps/cli/src/commands/config/push/push.command.ts @@ -1,4 +1,4 @@ -import { Layer, Option } from "effect"; +import { Option } from "effect"; import { Command, Flag } from "effect/cli"; import { withCliConfigFlags } from "../../../config/cli-config-flags.ts"; @@ -7,7 +7,6 @@ import { PROJECT_REF_PATTERN } from "../../../config/project-ref.service.ts"; import type * as CliCommand from "effect/cli/Command"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; -import { stdinLayer } from "../../../shared/runtime/stdin.layer.ts"; import { managementApiRuntimeLayer } from "../../../command-internal/management-api-runtime.layer.ts"; import { withCommandTelemetry } from "../../../telemetry/command-telemetry.ts"; import { configPush } from "./push.handler.ts"; @@ -44,7 +43,7 @@ export const configPushHandler = (flags: ConfigPushFlags) => export const configPushCommand = Command.make("push", config).pipe( Command.withDescription( - "Pushes the properties your local config.toml declares to the linked project or one of its branches. Properties the file does not declare are left unchanged; run `supabase config diff` to preview. Prompts for confirmation before writing each changed resource, showing the exact diff. Non-interactive runs honor piped y/n answers and skip changes without an affirmative answer or --yes/SUPABASE_YES. Run `supabase config diff` first to review the changes.", + "Pushes the properties your local config.toml declares to the linked project or one of its branches. Properties the file does not declare are left unchanged; run `supabase config diff` to preview. Shows the exact diff for every changed resource, then asks once before anything is written (the default answer is no). Piped answers are not read: non-interactive and JSON runs need --yes or a SUPABASE_YES set in the shell. Run `supabase config diff` first to review the changes.", ), Command.withShortDescription("Push local config to linked project"), Command.withExamples([ @@ -62,7 +61,7 @@ export const configPushCommand = Command.make("push", config).pipe( }, ]), Command.withHandler(configPushHandler), - Command.provide(Layer.mergeAll(managementApiRuntimeLayer(["config", "push"]), stdinLayer)), + Command.provide(managementApiRuntimeLayer(["config", "push"])), Command.provide(cliConfigValuesLayer), withCliConfigFlags(config), withTargetPolicy({ group: "hosted", consent: "no" }), diff --git a/apps/cli/src/commands/config/push/push.cost-matrix.ts b/apps/cli/src/commands/config/push/push.cost-matrix.ts index 97bab8e821..af7a15d1e0 100644 --- a/apps/cli/src/commands/config/push/push.cost-matrix.ts +++ b/apps/cli/src/commands/config/push/push.cost-matrix.ts @@ -12,7 +12,7 @@ import { } from "./push.errors.ts"; /** Cost matrix entry: the addon variant's display name and price description, used to render - * the cost-aware confirmation prompt. */ + * the price line shown before consent. */ export interface CostItem { readonly name: string; readonly price: string; diff --git a/apps/cli/src/commands/config/push/push.e2e.test.ts b/apps/cli/src/commands/config/push/push.e2e.test.ts index c32edccdf8..52da91486a 100644 --- a/apps/cli/src/commands/config/push/push.e2e.test.ts +++ b/apps/cli/src/commands/config/push/push.e2e.test.ts @@ -41,9 +41,9 @@ describe("supabase config push", () => { }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), E2E_TIMEOUT_MS, ); - it.live.each(["n", "y"] as const)( - "agent auto-detection honors piped %s through the built CLI", - (answer) => + it.live.each([{ yes: false }, { yes: true }] as const)( + "agent run with yes=$yes needs --yes for consent and ignores a piped answer through the built CLI", + ({ yes }) => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -104,10 +104,10 @@ describe("supabase config push", () => { `name: config-push-consent-e2e\napi_url: ${server.url.origin}\ndashboard_url: ${server.url.origin}\nproject_host: example.invalid\n`, ); const { exitCode, stdout, stderr } = yield* runSupabaseEffect( - ["config", "push", "--project-ref", TEST_PROJECT_REF], + ["config", "push", "--project-ref", TEST_PROJECT_REF, ...(yes ? ["--yes"] : [])], { cwd, - stdin: `${answer}\n`, + stdin: "y\n", env: { SUPABASE_PROFILE: profilePath, SUPABASE_ACCESS_TOKEN: TEST_TOKEN, @@ -117,9 +117,9 @@ describe("supabase config push", () => { }, }, ); - expect(exitCode, `${stdout}\n${stderr}`).toBe(0); - expect(writes).toEqual(answer === "y" ? ['{"max_rows":500}'] : []); - expect(stdout).toContain(`"status":"${answer === "y" ? "updated" : "skipped"}"`); + expect(exitCode, `${stdout}\n${stderr}`).toBe(yes ? 0 : 1); + expect(writes).toEqual(yes ? ['{"max_rows":500}'] : []); + expect(`${stdout}${stderr}`).toContain(yes ? '"status":"updated"' : "--yes"); expect(stderr).toContain("api.max_rows [update]"); }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), E2E_TIMEOUT_MS, diff --git a/apps/cli/src/commands/config/push/push.format.ts b/apps/cli/src/commands/config/push/push.format.ts index f7908edd5e..c192b4ccfb 100644 --- a/apps/cli/src/commands/config/push/push.format.ts +++ b/apps/cli/src/commands/config/push/push.format.ts @@ -95,7 +95,7 @@ function renderBlock( } /** - * `[secret]` blocks, rendered before the confirmation prompt so a credential that won't be sent + * `[secret]` blocks, rendered before the consent question so a credential that won't be sent * is disclosed alongside one that will. A `send` status alone doesn't mean the secret reached the * request body — check `secretsEncoded`, since its container can still drop it as unencodable. */ @@ -203,7 +203,7 @@ export function pushUpToDateLine(resource: PushResource): string { /** * The `Remote <resource> config has N difference(s) config push cannot write...` line — every - * pushable difference ended up `unencodable`, so nothing was written and no prompt ran. + * pushable difference ended up `unencodable`, so nothing was written and no consent was needed. */ export function pushNotPushableLine(resource: PushResource, count: number): string { return `Remote ${RESOURCE_DISPLAY_NAME[resource]} config has ${configPlural(count, "difference", "differences")} config push cannot write (see notes below).\n`; @@ -309,14 +309,12 @@ export interface PushPayloadInput { * buckets below. */ readonly secrets: ReadonlyArray<PushSecretReport>; /** Whether the auth write ran — decides whether a `status: "send"` secret lands in - * `sent`/`unencodable` or `skipped`; reports what was observed, not the pre-prompt decision. */ + * `sent`/`unencodable` or `skipped`; reports what was observed, not the planned decision. */ readonly authWriteRan: boolean; /** The auth encoder's own `secretsEncoded` — paths a write actually placed a plaintext for. A * `send` decision can still be dropped as `unencodable`, so `sent` reads from here rather than * the raw decision list. Meaningful only when `authWriteRan`. */ readonly secretsSent: ReadonlyArray<ReadonlyArray<string>>; - /** Addon cost prompts (`auth_mfa_phone`, `auth_mfa_web_authn`) declined this run. */ - readonly declinedAddons: ReadonlyArray<string>; readonly remoteOnly: number; readonly scope: ConfigApiScope; /** Pushed paths an environment variable supplied; the payload omits the field when empty. */ @@ -370,13 +368,6 @@ export function pushSummaryMessage(input: PushPayloadInput): string { ); } - const skipped = input.services.filter((service) => service.status === "skipped").length; - if (skipped > 0) { - parts.push( - `${configPlural(skipped, "service", "services")} ${skipped === 1 ? "was" : "were"} skipped at the prompt.`, - ); - } - const notSet = input.secrets.filter((secret) => secret.status === "not_set").length; if (notSet > 0) { parts.push( @@ -384,12 +375,6 @@ export function pushSummaryMessage(input: PushPayloadInput): string { ); } - if (input.declinedAddons.length > 0) { - parts.push( - `${configPlural(input.declinedAddons.length, "add-on prompt", "add-on prompts")} declined.`, - ); - } - return parts.join(" "); } @@ -434,7 +419,6 @@ export function pushPayload(input: PushPayloadInput): Record<string, unknown> { : [], skipped: input.authWriteRan ? [] : sendDecisions.map((secret) => secret.path), }, - declined_addons: input.declinedAddons, remote_only: input.remoteOnly, scope: { present: input.scope.present, missing: input.scope.missing }, ...(input.envSourced === undefined || input.envSourced.length === 0 @@ -473,19 +457,25 @@ export function configPushTargetLines(target: ConfigPushTarget): string { return `${lines.join("\n")}\n`; } -/** - * The confirmation-prompt label gating a branch push. Ends with a self-serve - * hint (`--yes`) so a CI/agent log reading the declined prompt can fix the - * invocation without digging through docs. - */ -export function configPushBranchPromptLabel( - target: ConfigPushTarget & { readonly kind: "branch" }, +const CONSENT_SERVICE_NAME: Readonly<Record<PushResource | "experimental.webhooks", string>> = { + ...RESOURCE_DISPLAY_NAME, + "experimental.webhooks": "Webhooks", +}; + +/** The consent action for a push that will write `services`, phrased to follow "Confirmed by --yes:". */ +export function pushConsentAction( + services: ReadonlyArray<PushResource | "experimental.webhooks">, ): string { - const ref = sanitizeInlineName(target.ref); - const hint = " (skip this check with --yes)"; - return target.branch === undefined - ? `Do you want to push config to branch ${ref}?${hint}` - : `Do you want to push config to branch "${sanitizeInlineName(target.branch)}" (${ref})?${hint}`; + const names = services.map((service) => CONSENT_SERVICE_NAME[service]); + const last = names.at(-1); + const list = + names.length > 1 && last !== undefined ? `${names.slice(0, -1).join(", ")} and ${last}` : last; + return `push config changes to ${list}`; +} + +/** The price line shown before consent for a paid addon the push would switch on. */ +export function pushCostLine(item: { readonly name: string; readonly price: string }): string { + return `Enabling ${item.name} will cost you ${item.price}.\n`; } /** Additive machine-payload fields describing the resolved target. `is_branch` is omitted, not 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 3f22f261d8..1be4789423 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 @@ -576,7 +576,6 @@ describe("pushSummaryMessage", () => { secrets: [], authWriteRan: false, secretsSent: [], - declinedAddons: [], remoteOnly: 0, scope: { present: [], missing: [] }, }; @@ -767,24 +766,6 @@ describe("pushSummaryMessage", () => { ); }); - test("caveat: skipped services, singular and plural", () => { - expect( - pushSummaryMessage({ - ...EMPTY_SUMMARY_INPUT, - services: [{ service: "auth", status: "skipped", changes: [["auth", "site_url"]] }], - }), - ).toBe("Nothing was pushed. 1 service was skipped at the prompt."); - expect( - pushSummaryMessage({ - ...EMPTY_SUMMARY_INPUT, - services: [ - { service: "auth", status: "skipped", changes: [["auth", "site_url"]] }, - { service: "storage", status: "skipped", changes: [["storage", "file_size_limit"]] }, - ], - }), - ).toBe("Nothing was pushed. 2 services were skipped at the prompt."); - }); - test("caveat: not-set credentials, singular and plural", () => { expect(pushSummaryMessage({ ...EMPTY_SUMMARY_INPUT, secrets: [NOT_SET_SECRET] })).toBe( "Nothing to push: the project already matches the declared properties. 1 credential value was not pushed.", @@ -799,27 +780,12 @@ describe("pushSummaryMessage", () => { ); }); - test("caveat: declined add-on prompts, singular and plural", () => { - expect(pushSummaryMessage({ ...EMPTY_SUMMARY_INPUT, declinedAddons: ["auth_mfa_phone"] })).toBe( - "Nothing to push: the project already matches the declared properties. 1 add-on prompt declined.", - ); - expect( - pushSummaryMessage({ - ...EMPTY_SUMMARY_INPUT, - declinedAddons: ["auth_mfa_phone", "auth_mfa_web_authn"], - }), - ).toBe( - "Nothing to push: the project already matches the declared properties. 2 add-on prompts declined.", - ); - }); - test("combines every present caveat in the established order", () => { expect( pushSummaryMessage({ projectRef: "abcdefghijklmnopqrst", services: [ { service: "api", status: "updated", changes: [["api", "max_rows"]] }, - { service: "auth", status: "skipped", changes: [["auth", "site_url"]] }, ], unsupported: [["db", "major_version"]], unencodable: [], @@ -829,7 +795,6 @@ describe("pushSummaryMessage", () => { secrets: [NOT_SET_SECRET], authWriteRan: false, secretsSent: [], - declinedAddons: ["auth_mfa_phone"], remoteOnly: 12, scope: { present: [], missing: ["auth"] }, }), @@ -838,9 +803,7 @@ describe("pushSummaryMessage", () => { "1 declared property could not be pushed. " + "1 declared property is not managed by config push. " + "1 block was not returned by the API. " + - "1 service was skipped at the prompt. " + - "1 credential value was not pushed. " + - "1 add-on prompt declined.", + "1 credential value was not pushed.", ); }); @@ -873,7 +836,6 @@ describe("pushPayload", () => { secrets: [SENT_SECRET, UNCHANGED_SECRET, NOT_SET_SECRET, GATED_SECRET], authWriteRan: true, secretsSent: [["auth", "captcha", "secret"]], - declinedAddons: ["auth_mfa_phone"], remoteOnly: 12, scope: { present: ["api", "auth", "database", "pooler", "realtime", "storage"], missing: [] }, }; @@ -924,7 +886,6 @@ describe("pushPayload", () => { unencodable: [], skipped: [], }, - declined_addons: ["auth_mfa_phone"], remote_only: 12, scope: { present: ["api", "auth", "database", "pooler", "realtime", "storage"], missing: [] }, }); @@ -996,7 +957,6 @@ describe("pushPayload", () => { secrets: [], authWriteRan: false, secretsSent: [], - declinedAddons: [], remoteOnly: 0, scope: { present: [], missing: [] }, }), @@ -1009,7 +969,6 @@ describe("pushPayload", () => { forced: [], unmanaged: [], secrets: { sent: [], unchanged: [], not_set: [], gated: [], unencodable: [], skipped: [] }, - declined_addons: [], remote_only: 0, scope: { present: [], missing: [] }, }); diff --git a/apps/cli/src/commands/config/push/push.handler.ts b/apps/cli/src/commands/config/push/push.handler.ts index 00142c8811..93ba72c41c 100644 --- a/apps/cli/src/commands/config/push/push.handler.ts +++ b/apps/cli/src/commands/config/push/push.handler.ts @@ -9,11 +9,8 @@ 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"; -import { resolveYesWithProjectEnv } from "../../../command-internal/global-flags.ts"; -import { CONTEXT_CANCELED_MESSAGE } from "../../../shared/output/errors.ts"; +import { requireHostedMutationConsent } from "../../../command-internal/hosted-consent/hosted-consent.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 } 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"; @@ -33,7 +30,6 @@ import { 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 { mapConfigLoadError } from "../config.load.ts"; import { configApiScope, @@ -65,7 +61,6 @@ import { ConfigPushBranchNotReadyError, ConfigPushBranchResolveNetworkError, ConfigPushBranchResolveStatusError, - ConfigPushCancelledError, ConfigPushConfigEmptyError, ConfigPushConfigReadNetworkError, ConfigPushConfigReadStatusError, @@ -84,9 +79,10 @@ import { ConfigPushWorkdirError, } from "./push.errors.ts"; import { - configPushBranchPromptLabel, configPushPayloadFields, configPushTargetLines, + pushConsentAction, + pushCostLine, pushEnvSourcedLine, pushNotes, pushNotPushableLine, @@ -102,11 +98,10 @@ import { comparePaths, isRecord } from "./push.paths.ts"; import { PUSH_ADDON_GATES, PUSH_RESOURCES, - applyMfaAddonDecline, changesCommunicated, planConfigPush, - pushAddonPromptNeeded, - pushPromptKey, + pushEnablesAddon, + pushCostKey, pushResourceEnabled, pushResourceForPath, pushResponseBlock, @@ -155,6 +150,24 @@ function toSecretReport(decision: PushSecretDecision) { return report; } +interface ResourcePlan<E> { + readonly changes: ReadonlyArray<ConfigChange>; + readonly encoded: PushEncoded<unknown>; + readonly secrets: ReadonlyArray<PushSecretDecision>; + /** Absent when the encoder produced no body, so there is nothing to send. */ + readonly write: (() => Effect.Effect<unknown, E>) | undefined; +} + +function planResource<Body, E>( + changes: ReadonlyArray<ConfigChange>, + encoded: PushEncoded<Body>, + secrets: ReadonlyArray<PushSecretDecision>, + write: (body: Body) => Effect.Effect<unknown, E>, +): ResourcePlan<E> { + const { body } = encoded; + return { changes, encoded, secrets, write: body === undefined ? undefined : () => write(body) }; +} + /** Loads the resolved config once per push. */ const loadPushConfig = Effect.fn("config.push.loadConfig")(function* ( cliSettings: { readonly workdir: string; readonly explicitWorkdir: boolean }, @@ -175,15 +188,11 @@ const loadPushConfig = Effect.fn("config.push.loadConfig")(function* ( } const loaded = resolvedConfig.loaded; yield* Effect.annotateCurrentSpan("config.remote_applied", loaded.appliedRemote !== undefined); - const projectYes = resolvedConfig.projectEnvValues["SUPABASE_YES"]; - const projectEnv: Record<string, string> = - projectYes === undefined ? {} : { SUPABASE_YES: projectYes }; const referenced = yield* resolvedConfig.envValues(envReferenceNames(loaded.document)); return { loaded, lookup: (name: string) => referenced[name], dotenvPrivateKeys: resolvedConfig.dotenvPrivateKeys, - projectEnv, originFor: configEnvOriginLookup(resolvedConfig.origins, (file) => relativeConfigPath(cliSettings.workdir, file), ), @@ -261,12 +270,10 @@ export const configPush = Effect.fn("config.push")(function* (flags: ConfigPushF // // 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( + const { loaded, lookup, dotenvPrivateKeys, originFor } = yield* loadPushConfig( cliSettings, ref, ); - // `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( @@ -291,14 +298,10 @@ export const configPush = Effect.fn("config.push")(function* (flags: ConfigPushF // silently push empty content over a real hosted customization. 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 - // `--project-ref <name-or-uuid>` this invocation skips the prompt, since the user already - // expressed same-invocation intent. - // - // `resolveConfigTarget` returns only the raw branch name, not its resolved parent, so a name - // target's parent is re-derived here via a second local-only read (env/cache/file, no - // network) of the same chain that just resolved. + // 5. Determine the push target for the echo and the payload. `resolveConfigTarget` returns + // only the raw branch name, not its resolved parent, so a name target's parent is re-derived + // here via a second local-only read (env/cache/file, no network) of the same chain that just + // resolved. let knownBranch: ConfigPushKnownBranch | undefined; if (branch !== undefined) { if (BRANCH_UUID_PATTERN.test(branch)) { @@ -311,53 +314,15 @@ export const configPush = Effect.fn("config.push")(function* (flags: ConfigPushF : { kind: "uuid" }; } } - const tty = yield* Tty; - const confirm = (label: string, defaultValue: boolean) => - !yes && tty.stdinIsTty && !output.interactive - ? Effect.succeed(defaultValue) - : promptYesNo(output, yes, label, defaultValue, true, { readMachineStdin: true }); const target = yield* resolveConfigPushTarget(ref, { knownBranch }).pipe( Effect.withSpan("config.push.probeTarget"), ); yield* Effect.annotateCurrentSpan("config.target_kind", target.kind); yield* output.raw(configPushTargetLines(target), "stderr"); - if (target.kind === "branch" && knownBranch === undefined) { - // Defaults `false`: an unattended run without affirmative consent - // must decline a branch mutation rather than silently proceed. - const proceed = yield* confirm(configPushBranchPromptLabel(target), false); - if (!proceed) { - return yield* new ConfigPushCancelledError({ - message: CONTEXT_CANCELED_MESSAGE, - suggestion: "Pass --yes (or set SUPABASE_YES) to push to a branch without confirmation.", - }); - } - } - // 6. Cost matrix (drives cost-aware prompts). + // 6. Cost matrix (prices the paid add-ons a push would enable). const cost = yield* getCostMatrix(ref); - // `promptYesNo` scans piped stdin on a non-TTY before falling back to the default. - const defaultProceed = - yes || (tty.stdinIsTty && output.interactive && output.format === "text"); - const keep = (name: string) => - Effect.gen(function* () { - const item = cost.get(name); - const title = - item === undefined - ? `Do you want to push ${name} config to remote?` - : `Enabling ${item.name} will cost you ${item.price}. Keep it enabled?`; - const confirmed = yield* confirm(title, defaultProceed); - if (!confirmed && output.format === "text" && (!tty.stdinIsTty || !output.interactive)) { - yield* output.raw( - tty.stdinIsTty - ? `Skipped ${name}: confirmation unavailable with redirected output. Pass --yes (or set SUPABASE_YES) to approve.\n` - : `Skipped ${name}: no affirmative confirmation received. Pass --yes (or set SUPABASE_YES) to approve.\n`, - "stderr", - ); - } - return confirmed; - }); - // 7. Read the project's effective configuration in one call. No spinner, matching the rest // of this command's stderr progress lines. const response = yield* api.executeRaw(operationDefinitions.v2GetProjectConfig, { ref }).pipe( @@ -451,40 +416,143 @@ 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<PushEncoded<unknown>> = PUSH_RESOURCES.filter( - (resource) => - resourceEnabled[resource] && !scope.missing.includes(pushResponseBlock(resource)), - ).map((resource) => { + const planFor = (resource: PushResource) => { const changes = plan.changesByResource[resource]; switch (resource) { case "api": - return encodeApiBody({ changes, local, remote }); + return planResource( + changes, + encodeApiBody({ changes, local, remote }), + [], + (body) => + api.v1.updatePostgrestServiceConfig({ ref, ...body }).pipe( + Effect.catch( + mapHttpError({ + networkError: ConfigPushApiUpdateNetworkError, + statusError: ConfigPushApiUpdateStatusError, + networkMessage: (cause) => `failed to update API config: ${cause}`, + statusMessage: unexpectedStatusMessage, + }), + ), + ), + ); case "db.settings": - return encodeDbSettingsBody({ changes, local, remote }); + return planResource( + changes, + encodeDbSettingsBody({ changes, local, remote }), + [], + (body) => + api.v1.updatePostgresConfig({ ref, ...body }).pipe( + Effect.catch( + mapHttpError({ + networkError: ConfigPushDbUpdateNetworkError, + statusError: ConfigPushDbUpdateStatusError, + networkMessage: (cause) => `failed to update DB config: ${cause}`, + statusMessage: unexpectedStatusMessage, + }), + ), + ), + ); case "db.network_restrictions": - return encodeNetworkRestrictionsBody({ changes, local, remote }); + return planResource( + changes, + encodeNetworkRestrictionsBody({ changes, local, remote }), + [], + (body) => + api.v1.updateNetworkRestrictions({ ref, ...body }).pipe( + Effect.catch( + mapHttpError({ + networkError: ConfigPushNetworkRestrictionsUpdateNetworkError, + statusError: ConfigPushNetworkRestrictionsUpdateStatusError, + networkMessage: (cause) => + `failed to update network restrictions config: ${cause}`, + statusMessage: unexpectedStatusMessage, + }), + ), + ), + ); case "db.ssl_enforcement": - return encodeSslEnforcementBody({ changes, local, remote }); + return planResource( + changes, + encodeSslEnforcementBody({ changes, local, remote }), + [], + (body) => + api.v1.updateSslEnforcementConfig({ ref, ...body }).pipe( + Effect.catch( + mapHttpError({ + networkError: ConfigPushSslEnforcementUpdateNetworkError, + statusError: ConfigPushSslEnforcementUpdateStatusError, + networkMessage: (cause) => `failed to update SSL enforcement config: ${cause}`, + statusMessage: unexpectedStatusMessage, + }), + ), + ), + ); case "auth": - return encodeAuthBody({ + return planResource( changes, - local, - remote, + encodeAuthBody({ + changes, + local, + remote, + secrets, + emailContent: authEmailContent, + remoteAuthAttributes, + now, + }), secrets, - emailContent: authEmailContent, - remoteAuthAttributes, - now, - }); + (body) => + api.v1.updateAuthServiceConfig({ ref, ...body }).pipe( + Effect.catch( + mapHttpError({ + networkError: ConfigPushAuthUpdateNetworkError, + statusError: ConfigPushAuthUpdateStatusError, + networkMessage: (cause) => `failed to update Auth config: ${cause}`, + statusMessage: unexpectedStatusMessage, + }), + ), + ), + ); case "storage": - return encodeStorageBody({ changes, local, remote, config }); + return planResource( + changes, + encodeStorageBody({ changes, local, remote, config }), + [], + (body) => + api.v1.updateStorageConfig({ ref, ...body }).pipe( + Effect.catch( + mapHttpError({ + networkError: ConfigPushStorageUpdateNetworkError, + statusError: ConfigPushStorageUpdateStatusError, + networkMessage: (cause) => `failed to update Storage config: ${cause}`, + statusMessage: unexpectedStatusMessage, + }), + ), + ), + ); } - }); + }; + + // 10. Encode every resource before anything is announced or written. A missing response + // block makes a resource `unavailable` (nothing compared or written); a gated-off local + // `enabled` flag makes it `disabled` — this can only happen for `db.network_restrictions` and + // `db.ssl_enforcement`, since `auth`/`storage`'s local `enabled` toggle only controls a + // Docker service the Management API has no concept of. + const resourcePlans = new Map( + PUSH_RESOURCES.filter( + (resource) => + resourceEnabled[resource] && !scope.missing.includes(pushResponseBlock(resource)), + ).map((resource) => [resource, planFor(resource)] as const), + ); + const webhooksEnabled = config.experimental?.webhooks?.enabled === true; + + // Announced before the consent question 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 envSourced = envSourcedPaths( - plannedEncodings - .filter((encoded) => encoded.body !== undefined) - .flatMap((encoded) => pushServiceChanges(encoded, pushSentSecretPaths(encoded))), + [...resourcePlans.values()] + .filter(({ encoded }) => encoded.body !== undefined) + .flatMap(({ encoded }) => pushServiceChanges(encoded, pushSentSecretPaths(encoded))), originFor, ); if (envSourced.length > 0) { @@ -495,36 +563,20 @@ export const configPush = Effect.fn("config.push")(function* (flags: ConfigPushF const unsupported: Array<ReadonlyArray<string>> = [...plan.unsupported]; const unencodable: Array<PushUnencodable> = []; const forced: Array<PushForced> = []; - const declinedAddons: Array<string> = []; let authWriteRan = false; let secretsSent: ReadonlyArray<ReadonlyArray<string>> = []; - // 10. Prints the resource's `Updating ...`/up-to-date/not-pushable line, prompts, writes, and - // returns the result. `secretsForResource` is the resource's full secret-decision list; only - // `auth` needs to pass anything besides `[]`. - function applyResource<Body, E, R>( - resource: PushResource, - changes: ReadonlyArray<ConfigChange>, - encoded: PushEncoded<Body>, - secretsForResource: ReadonlyArray<PushSecretDecision>, - write: (body: Body) => Effect.Effect<unknown, E, R>, - ): Effect.Effect<ConfigPushServiceResult, E, R | Tty | Stdin> { - return Effect.gen(function* () { - if (encoded.body === undefined) { - if (encoded.unencodable.length > 0) { - yield* output.raw(pushNotPushableLine(resource, encoded.unencodable.length), "stderr"); - return { service: resource, status: "not_pushable", changes: [] }; - } - yield* output.raw(pushUpToDateLine(resource), "stderr"); - return { service: resource, status: "up_to_date", changes: [] }; - } - const body = encoded.body; - const communicated = changesCommunicated(changes, encoded.encoded); + for (const resource of PUSH_RESOURCES) { + const resourcePlan = resourcePlans.get(resource); + if (resourcePlan === undefined) continue; + const { changes, encoded, secrets: resourceSecrets } = resourcePlan; + unencodable.push(...encoded.unencodable); + if (encoded.body !== undefined) { yield* output.raw( pushUpdatingLine({ resource, - changes: communicated, - secrets: secretsForResource.map(toSecretReport), + changes: changesCommunicated(changes, encoded.encoded), + secrets: resourceSecrets.map(toSecretReport), secretsEncoded: encoded.secretsEncoded ?? [], extras: encoded.extras, forced: encoded.forced, @@ -532,221 +584,103 @@ export const configPush = Effect.fn("config.push")(function* (flags: ConfigPushF }), "stderr", ); - if (yield* keep(pushPromptKey(resource))) { - yield* write(body).pipe( - Effect.withSpan("config.push.updateResource", { - attributes: { "config.resource": resource }, - }), - ); - const sentSecretPaths = pushSentSecretPaths(encoded); - return { - service: resource, - status: "updated", - changes: pushServiceChanges(encoded, sentSecretPaths), - }; - } - return { - service: resource, - status: "skipped", - changes: pushServiceChanges(encoded, []), - }; + } else if (encoded.unencodable.length > 0) { + yield* output.raw(pushNotPushableLine(resource, encoded.unencodable.length), "stderr"); + } else { + yield* output.raw(pushUpToDateLine(resource), "stderr"); + } + } + if (webhooksEnabled) { + yield* output.raw(`Enabling webhooks for project: ${sanitizeInlineName(ref)}\n`, "stderr"); + } + + // 11. One consent covers every write; nothing is sent before it. + const pendingServices: ReadonlyArray<ConfigPushServiceResult["service"]> = [ + ...PUSH_RESOURCES.filter((resource) => resourcePlans.get(resource)?.write !== undefined), + ...(webhooksEnabled ? (["experimental.webhooks"] as const) : []), + ]; + if (pendingServices.length > 0) { + const authPlan = resourcePlans.get("auth"); + const costKeys = new Set([ + ...pendingServices.map((service) => + service === "experimental.webhooks" ? "webhooks" : pushCostKey(service), + ), + ...(authPlan?.write === undefined + ? [] + : PUSH_ADDON_GATES.filter((gate) => + pushEnablesAddon(authPlan.changes, gate, remote), + ).map((gate) => gate.costKey)), + ]); + const costLines = [...costKeys].flatMap((key) => { + const item = cost.get(key); + return item === undefined ? [] : [pushCostLine(item)]; }); + if (costLines.length > 0) { + yield* output.raw(costLines.join(""), "stderr"); + } + yield* requireHostedMutationConsent({ action: pushConsentAction(pendingServices) }); } - // 11. Six resources, in the established push order. A missing response block makes a - // resource `unavailable` (nothing compared or written); a gated-off local `enabled` flag - // makes it `disabled` — this can only happen for `db.network_restrictions` and - // `db.ssl_enforcement`, since `auth`/`storage`'s local `enabled` toggle only controls a - // Docker service the Management API has no concept of. + // 12. Writes, in the established push order. for (const resource of PUSH_RESOURCES) { - if (scope.missing.includes(pushResponseBlock(resource))) { - services.push({ service: resource, status: "unavailable", changes: [] }); + const resourcePlan = resourcePlans.get(resource); + if (resourcePlan === undefined) { + services.push({ + service: resource, + status: scope.missing.includes(pushResponseBlock(resource)) ? "unavailable" : "disabled", + changes: [], + }); continue; } - if (!resourceEnabled[resource]) { - services.push({ service: resource, status: "disabled", changes: [] }); + const { encoded, write } = resourcePlan; + if (write === undefined) { + services.push({ + service: resource, + status: encoded.unencodable.length > 0 ? "not_pushable" : "up_to_date", + changes: [], + }); continue; } - - switch (resource) { - case "api": { - const changes = plan.changesByResource.api; - const encoded = encodeApiBody({ changes, local, remote }); - const result = yield* applyResource("api", changes, encoded, [], (body) => - api.v1.updatePostgrestServiceConfig({ ref, ...body }).pipe( - Effect.catch( - mapHttpError({ - networkError: ConfigPushApiUpdateNetworkError, - statusError: ConfigPushApiUpdateStatusError, - networkMessage: (cause) => `failed to update API config: ${cause}`, - statusMessage: unexpectedStatusMessage, - }), - ), - ), - ); - services.push(result); - unencodable.push(...encoded.unencodable); - if (result.status === "updated") forced.push(...encoded.forced); - break; - } - case "db.settings": { - const changes = plan.changesByResource["db.settings"]; - const encoded = encodeDbSettingsBody({ changes, local, remote }); - const result = yield* applyResource("db.settings", changes, encoded, [], (body) => - api.v1.updatePostgresConfig({ ref, ...body }).pipe( - Effect.catch( - mapHttpError({ - networkError: ConfigPushDbUpdateNetworkError, - statusError: ConfigPushDbUpdateStatusError, - networkMessage: (cause) => `failed to update DB config: ${cause}`, - statusMessage: unexpectedStatusMessage, - }), - ), - ), - ); - services.push(result); - unencodable.push(...encoded.unencodable); - if (result.status === "updated") forced.push(...encoded.forced); - break; - } - case "db.network_restrictions": { - const changes = plan.changesByResource["db.network_restrictions"]; - const encoded = encodeNetworkRestrictionsBody({ changes, local, remote }); - const result = yield* applyResource( - "db.network_restrictions", - changes, - encoded, - [], - (body) => - api.v1.updateNetworkRestrictions({ ref, ...body }).pipe( - Effect.catch( - mapHttpError({ - networkError: ConfigPushNetworkRestrictionsUpdateNetworkError, - statusError: ConfigPushNetworkRestrictionsUpdateStatusError, - networkMessage: (cause) => - `failed to update network restrictions config: ${cause}`, - statusMessage: unexpectedStatusMessage, - }), - ), - ), - ); - services.push(result); - unencodable.push(...encoded.unencodable); - if (result.status === "updated") forced.push(...encoded.forced); - break; - } - case "db.ssl_enforcement": { - const changes = plan.changesByResource["db.ssl_enforcement"]; - const encoded = encodeSslEnforcementBody({ changes, local, remote }); - const result = yield* applyResource("db.ssl_enforcement", changes, encoded, [], (body) => - api.v1.updateSslEnforcementConfig({ ref, ...body }).pipe( - Effect.catch( - mapHttpError({ - networkError: ConfigPushSslEnforcementUpdateNetworkError, - statusError: ConfigPushSslEnforcementUpdateStatusError, - networkMessage: (cause) => `failed to update SSL enforcement config: ${cause}`, - statusMessage: unexpectedStatusMessage, - }), - ), - ), - ); - services.push(result); - unencodable.push(...encoded.unencodable); - if (result.status === "updated") forced.push(...encoded.forced); - break; - } - case "auth": { - // MFA addon cost filter runs before anything about auth is printed: a declined paid - // addon carries an explicit disable when the remote currently has it on, or is dropped. - let changes = plan.changesByResource.auth; - for (const gate of PUSH_ADDON_GATES) { - if (pushAddonPromptNeeded(changes, gate, remote) && !(yield* keep(gate.costKey))) { - changes = applyMfaAddonDecline(changes, gate, remote); - declinedAddons.push(gate.costKey); - } - } - - const encoded = encodeAuthBody({ - changes, - local, - remote, - secrets, - emailContent: authEmailContent, - remoteAuthAttributes, - now, - }); - const result = yield* applyResource("auth", changes, encoded, secrets, (body) => - api.v1.updateAuthServiceConfig({ ref, ...body }).pipe( - Effect.catch( - mapHttpError({ - networkError: ConfigPushAuthUpdateNetworkError, - statusError: ConfigPushAuthUpdateStatusError, - networkMessage: (cause) => `failed to update Auth config: ${cause}`, - statusMessage: unexpectedStatusMessage, - }), - ), - ), - ); - services.push(result); - unencodable.push(...encoded.unencodable); - authWriteRan = result.status === "updated"; - secretsSent = pushSentSecretPaths(encoded); - if (result.status === "updated") forced.push(...encoded.forced); - break; - } - case "storage": { - const changes = plan.changesByResource.storage; - const encoded = encodeStorageBody({ changes, local, remote, config }); - const result = yield* applyResource("storage", changes, encoded, [], (body) => - api.v1.updateStorageConfig({ ref, ...body }).pipe( - Effect.catch( - mapHttpError({ - networkError: ConfigPushStorageUpdateNetworkError, - statusError: ConfigPushStorageUpdateStatusError, - networkMessage: (cause) => `failed to update Storage config: ${cause}`, - statusMessage: unexpectedStatusMessage, - }), - ), - ), - ); - services.push(result); - unencodable.push(...encoded.unencodable); - if (result.status === "updated") forced.push(...encoded.forced); - break; - } + yield* Effect.gen(function* () { + yield* Effect.annotateCurrentSpan("config.resource", resource); + yield* write(); + }).pipe(Effect.withSpan("config.push.updateResource")); + services.push({ + service: resource, + status: "updated", + changes: pushServiceChanges(encoded, pushSentSecretPaths(encoded)), + }); + forced.push(...encoded.forced); + if (resource === "auth") { + authWriteRan = true; + secretsSent = pushSentSecretPaths(encoded); } } - // 11g. experimental.webhooks (no read/diff — a fixed enable-only POST). - if (config.experimental?.webhooks?.enabled !== true) { - services.push({ service: "experimental.webhooks", status: "disabled", changes: [] }); + // 12g. experimental.webhooks (no read/diff — a fixed enable-only POST). + if (webhooksEnabled) { + yield* api.v1.enableDatabaseWebhook({ ref }).pipe( + Effect.catch( + mapHttpError({ + networkError: ConfigPushEnableWebhookNetworkError, + statusError: ConfigPushEnableWebhookStatusError, + networkMessage: (cause) => `failed to enable webhooks: ${cause}`, + statusMessage: (status, body) => `unexpected enable webhook status ${status}: ${body}`, + }), + ), + ); + services.push({ + service: "experimental.webhooks", + status: "updated", + // No registry path backs this write, but the summary's property count (and a JSON + // consumer inspecting `changes`) must still see it. + changes: [["experimental", "webhooks", "enabled"]], + }); } else { - yield* output.raw(`Enabling webhooks for project: ${sanitizeInlineName(ref)}\n`, "stderr"); - if (yield* keep("webhooks")) { - yield* api.v1.enableDatabaseWebhook({ ref }).pipe( - Effect.catch( - mapHttpError({ - networkError: ConfigPushEnableWebhookNetworkError, - statusError: ConfigPushEnableWebhookStatusError, - networkMessage: (cause) => `failed to enable webhooks: ${cause}`, - statusMessage: (status, body) => - `unexpected enable webhook status ${status}: ${body}`, - }), - ), - ); - services.push({ - service: "experimental.webhooks", - status: "updated", - // No registry path backs this write, but the summary's property count (and a JSON - // consumer inspecting `changes`) must still see it. - changes: [["experimental", "webhooks", "enabled"]], - }); - } else { - services.push({ service: "experimental.webhooks", status: "skipped", changes: [] }); - } + services.push({ service: "experimental.webhooks", status: "disabled", changes: [] }); } - // 12. Notes (stderr, after the resource loop): unsupported, unencodable, and unmanaged + // 13. Notes (stderr, after the writes): unsupported, unencodable, and unmanaged // (count only, excluding a gated-off resource's own entries) declared properties, forced // companion defaults, empty/unresolved credentials, and the hands-off remote-only count. const unmanagedCount = changeSet.unmanaged.filter((changePath) => { @@ -775,7 +709,7 @@ export const configPush = Effect.fn("config.push")(function* (flags: ConfigPushF services.filter((service) => service.status === "updated").length, ); - // 13. Machine-readable summary in `json` / `stream-json` mode. + // 14. Machine-readable summary in `json` / `stream-json` mode. if (output.format !== "text") { const payloadInput = { projectRef: ref, @@ -788,7 +722,6 @@ export const configPush = Effect.fn("config.push")(function* (flags: ConfigPushF secrets: secrets.map(toSecretReport), authWriteRan, secretsSent, - declinedAddons, remoteOnly: plan.remoteOnly, scope, envSourced: envSourcedPaths( 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 565a5e2882..2136564556 100644 --- a/apps/cli/src/commands/config/push/push.integration.test.ts +++ b/apps/cli/src/commands/config/push/push.integration.test.ts @@ -34,13 +34,20 @@ import { mockLinkedProjectCacheTracked, mockCommandPlatformApi, mockCommandPlatformApiService, + type MockCommandPlatformApiOpts, mockTelemetryStateTracked, useTempWorkdir, withEnvVar, } from "../../../../tests/helpers/command-mocks.ts"; import { v2ProjectConfigResponse } from "../../../../tests/helpers/config-fixtures.ts"; +import { describeHostedConsent } from "../../../../tests/helpers/hosted-consent.ts"; import { mockRuntimeInfo, mockStdin, mockTty } from "../../../../tests/helpers/mocks.ts"; import { YesFlag } from "../../../command-internal/global-flags.ts"; +import { + HostedMutationConsentRequiredError, + HostedMutationDeclinedError, +} from "../../../command-internal/hosted-consent/hosted-consent.errors.ts"; +import { TargetPolicy } from "../../../command-internal/target/target-policy.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"; @@ -241,13 +248,7 @@ const writeLinkedProjectCacheFile = Effect.fnUntraced(function* (json: Record<st yield* writeWorkdirFile(["supabase", ".temp", "linked-project.json"], yield* jsonText(json)); }); -/** - * Real-client setup, routed by URL — for scenarios writing only `api` (PATCH /postgrest) and - * `db.settings` (PUT /config/database/postgres), whose response schemas are simple enough to - * satisfy without hand-decoding a ~200-field record. The v2 project-config read always goes - * through this same mock. - */ -function setup(opts: { +interface SetupOptions { readonly toml: string; readonly v2?: { status: number; body: unknown } | { status: 200; malformedJson: true } | "fail"; readonly addons?: { status: number; body: unknown }; @@ -262,12 +263,9 @@ function setup(opts: { readonly format?: "text" | "json" | "stream-json"; readonly yes?: boolean; readonly confirm?: ReadonlyArray<boolean>; - readonly promptFail?: boolean; readonly interactive?: boolean; /** stdin interactivity; defaults to a TTY so prompt-driven tests reach the confirm. */ readonly stdinIsTty?: boolean; - /** Piped (non-TTY) stdin answers, one consumed per confirmation prompt. */ - readonly pipedAnswers?: ReadonlyArray<string>; /** Working directory the handler runs from; defaults to the temp project root. */ readonly runtimeCwd?: string; /** cliSettings.workdir override (what `--workdir` resolves to); defaults to the temp project root. */ @@ -292,99 +290,122 @@ function setup(opts: { readonly projectId?: Option.Option<string>; /** Extra workdir files (linked-project state) written alongside `config.toml`. */ readonly seed?: WorkdirSeed; -}) { - const out = mockOutput({ - format: opts.format ?? "text", - promptConfirmResponses: opts.confirm, - promptConfirmFail: opts.promptFail, - interactive: - opts.interactive ?? ((opts.format ?? "text") === "text" && (opts.stdinIsTty ?? true)), - }); - const api = mockCommandPlatformApi({ - handler: (request) => { - const url = request.url; - if (url.includes("/billing/addons")) { - const a = opts.addons ?? { status: 200, body: { available_addons: [] } }; - return Effect.succeed(jsonResponse(request, a.status, a.body)); - } - if (url.includes("/v2/projects/")) { - if (opts.v2 === "fail") { - return Effect.fail(transportFailure(request)); - } - if (opts.v2 !== undefined && "malformedJson" in opts.v2) { - return Effect.succeed( - HttpClientResponse.fromWeb( - request, - new Response("{not valid json", { - status: 200, - headers: { "content-type": "application/json" }, - }), - ), - ); - } - const v2 = opts.v2 ?? { status: 200, body: v2Response() }; - return Effect.succeed(jsonResponse(request, v2.status, v2.body)); - } - if (url.includes("/postgrest")) { - if (opts.postgrestPatch === "fail") { - return Effect.fail(transportFailure(request)); - } - const p = opts.postgrestPatch ?? { status: 200, body: POSTGREST_WRITE_RESPONSE }; - return Effect.succeed(jsonResponse(request, p.status, p.body)); +} + +type PushApiRoutes = Pick< + SetupOptions, + | "v2" + | "addons" + | "postgrestPatch" + | "postgresPut" + | "storagePatch" + | "authPatch" + | "project" + | "branchList" + | "branchByName" + | "branchById" +>; + +function pushApiHandler(opts: PushApiRoutes): MockCommandPlatformApiOpts["handler"] { + return (request) => { + const url = request.url; + if (url.includes("/billing/addons")) { + const a = opts.addons ?? { status: 200, body: { available_addons: [] } }; + return Effect.succeed(jsonResponse(request, a.status, a.body)); + } + if (url.includes("/v2/projects/")) { + if (opts.v2 === "fail") { + return Effect.fail(transportFailure(request)); } - if (url.includes("/config/database/postgres")) { - if (opts.postgresPut === "fail") { - return Effect.fail(transportFailure(request)); - } - const p = opts.postgresPut ?? { status: 200, body: {} }; - return Effect.succeed(jsonResponse(request, p.status, p.body)); + if (opts.v2 !== undefined && "malformedJson" in opts.v2) { + return Effect.succeed( + HttpClientResponse.fromWeb( + request, + new Response("{not valid json", { + status: 200, + headers: { "content-type": "application/json" }, + }), + ), + ); } - if (url.includes("/config/storage")) { - if (opts.storagePatch === "fail") { - return Effect.fail(transportFailure(request)); - } - const p = opts.storagePatch ?? { status: 200, body: {} }; - return Effect.succeed(jsonResponse(request, p.status, p.body)); + const v2 = opts.v2 ?? { status: 200, body: v2Response() }; + return Effect.succeed(jsonResponse(request, v2.status, v2.body)); + } + if (url.includes("/postgrest")) { + if (opts.postgrestPatch === "fail") { + return Effect.fail(transportFailure(request)); } - if (url.includes("/config/auth")) { - if (opts.authPatch === "fail") { - return Effect.fail(transportFailure(request)); - } - const p = opts.authPatch ?? { status: 200, body: authWriteResponseFixture() }; - return Effect.succeed(jsonResponse(request, p.status, p.body)); + const p = opts.postgrestPatch ?? { status: 200, body: POSTGREST_WRITE_RESPONSE }; + return Effect.succeed(jsonResponse(request, p.status, p.body)); + } + if (url.includes("/config/database/postgres")) { + if (opts.postgresPut === "fail") { + return Effect.fail(transportFailure(request)); } - const pathname = new URL(url).pathname; - // Live target-detection probe: a bare project ref defaults to a schema-valid, unnamed - // project, so plain-project scenarios keep working without opting in. - if (/^\/v1\/projects\/[a-z0-9-]+$/.test(pathname)) { - if (opts.project === "fail") { - return Effect.fail(transportFailure(request)); - } - const p = opts.project ?? { status: 200, body: PUSH_TEST_PROJECT }; - return Effect.succeed(jsonResponse(request, p.status, p.body)); + const p = opts.postgresPut ?? { status: 200, body: {} }; + return Effect.succeed(jsonResponse(request, p.status, p.body)); + } + if (url.includes("/config/storage")) { + if (opts.storagePatch === "fail") { + return Effect.fail(transportFailure(request)); } - // Branch resolution + the best-effort branch-name lookup — defaults degrade to - // "not found"/empty rather than hanging. - if (/^\/v1\/projects\/[a-z0-9-]+\/branches$/.test(pathname)) { - const b = opts.branchList ?? { status: 200, body: [] }; - return Effect.succeed(jsonResponse(request, b.status, b.body)); + const p = opts.storagePatch ?? { status: 200, body: {} }; + return Effect.succeed(jsonResponse(request, p.status, p.body)); + } + if (url.includes("/config/auth")) { + if (opts.authPatch === "fail") { + return Effect.fail(transportFailure(request)); } - if (/^\/v1\/projects\/[a-z0-9-]+\/branches\/[^/]+$/.test(pathname)) { - if (opts.branchByName === "fail") { - return Effect.fail(transportFailure(request)); - } - const b = opts.branchByName ?? { status: 404, body: {} }; - return Effect.succeed(jsonResponse(request, b.status, b.body)); + const p = opts.authPatch ?? { status: 200, body: authWriteResponseFixture() }; + return Effect.succeed(jsonResponse(request, p.status, p.body)); + } + const pathname = new URL(url).pathname; + // Live target-detection probe: a bare project ref defaults to a schema-valid, unnamed + // project, so plain-project scenarios keep working without opting in. + if (/^\/v1\/projects\/[a-z0-9-]+$/.test(pathname)) { + if (opts.project === "fail") { + return Effect.fail(transportFailure(request)); } - if (/^\/v1\/branches\/[0-9a-f-]+$/.test(pathname)) { - const b = opts.branchById ?? { status: 404, body: {} }; - return Effect.succeed(jsonResponse(request, b.status, b.body)); + const p = opts.project ?? { status: 200, body: PUSH_TEST_PROJECT }; + return Effect.succeed(jsonResponse(request, p.status, p.body)); + } + // Branch resolution + the best-effort branch-name lookup — defaults degrade to + // "not found"/empty rather than hanging. + if (/^\/v1\/projects\/[a-z0-9-]+\/branches$/.test(pathname)) { + const b = opts.branchList ?? { status: 200, body: [] }; + return Effect.succeed(jsonResponse(request, b.status, b.body)); + } + if (/^\/v1\/projects\/[a-z0-9-]+\/branches\/[^/]+$/.test(pathname)) { + if (opts.branchByName === "fail") { + return Effect.fail(transportFailure(request)); } - // Anything else (network-restrictions/ssl/webhooks) — succeed with an empty body; - // scenarios writing to one of those use `setupService()` instead. - return Effect.succeed(jsonResponse(request, 200, {})); - }, + const b = opts.branchByName ?? { status: 404, body: {} }; + return Effect.succeed(jsonResponse(request, b.status, b.body)); + } + if (/^\/v1\/branches\/[0-9a-f-]+$/.test(pathname)) { + const b = opts.branchById ?? { status: 404, body: {} }; + return Effect.succeed(jsonResponse(request, b.status, b.body)); + } + // Anything else (network-restrictions/ssl/webhooks) — succeed with an empty body; + // scenarios writing to one of those use `setupService()` instead. + return Effect.succeed(jsonResponse(request, 200, {})); + }; +} + +/** + * Real-client setup, routed by URL — for scenarios writing only `api` (PATCH /postgrest) and + * `db.settings` (PUT /config/database/postgres), whose response schemas are simple enough to + * satisfy without hand-decoding a ~200-field record. The v2 project-config read always goes + * through this same mock. + */ +function setup(opts: SetupOptions) { + const out = mockOutput({ + format: opts.format ?? "text", + promptConfirmResponses: opts.confirm, + interactive: + opts.interactive ?? ((opts.format ?? "text") === "text" && (opts.stdinIsTty ?? true)), }); + const api = mockCommandPlatformApi({ handler: pushApiHandler(opts) }); const telemetry = mockTelemetryStateTracked(); const linkedProjectCache = mockLinkedProjectCacheTracked(); const layer = Layer.mergeAll( @@ -408,11 +429,9 @@ function setup(opts: { }), ...(opts.analytics === undefined ? {} : { analytics: opts.analytics }), }), - mockStdin( - opts.stdinIsTty ?? true, - opts.pipedAnswers ? `${opts.pipedAnswers.join("\n")}\n` : undefined, - ), + mockStdin(opts.stdinIsTty ?? true), Layer.succeed(YesFlag, opts.yes ?? false), + Layer.succeed(TargetPolicy, { group: "hosted", consent: "no" }), workdirFilesLayer(opts.toml, opts.seed), cliConfigProviderLayer, cliConfigValuesTestLayer, @@ -480,6 +499,7 @@ describe("config push integration", () => { }), mockStdin(true), Layer.succeed(YesFlag, true), + Layer.succeed(TargetPolicy, { group: "hosted", consent: "no" }), cliConfigValuesTestLayer, ); return Effect.gen(function* () { @@ -828,215 +848,63 @@ max_rows = 1000 }).pipe(Effect.provide(layer)); }); - it.effect("stops a service when the user declines the prompt (exit 0)", () => { + it.effect("asks one default-No question naming every changed service before writing", () => { const { layer, out, api } = setup({ - toml: `project_id = "test"\n[api]\nmax_rows = 2000\n`, - confirm: [false], - }); - return Effect.gen(function* () { - yield* configPush({ projectRef: Option.none() }); - expect(out.stderrText).toContain("Updating API service with config:"); - expect(out.stderrText).toContain("api.max_rows [update]"); - expect(api.requests.some((r) => r.method === "PATCH" && r.url.includes("/postgrest"))).toBe( - false, - ); - }).pipe(Effect.provide(layer)); - }); - - // These tests exercise prompt/env-resolution behavior but still write through the real typed - // client, so the encoder's sparse body must clear the schema on every path here too. - - it.effect("auto-confirms with --yes (echoes the prompt)", () => { - const { layer, out } = setup({ - toml: `project_id = "test"\n[api]\nmax_rows = 2000\n`, - yes: true, + toml: `project_id = "test"\n[api]\nmax_rows = 2000\n[auth]\nminimum_password_length = 12\n`, + confirm: [true], }); return Effect.gen(function* () { yield* configPush({ projectRef: Option.none() }); - expect(out.stderrText).toContain("Do you want to push api config to remote? [Y/n] y"); + expect(out.promptConfirmCalls).toHaveLength(1); + expect(out.promptConfirmCalls[0]?.message).toContain("push config changes to API and Auth"); + expect(out.promptConfirmCalls[0]?.opts?.defaultValue).toBe(false); + expect(api.requests.filter((r) => r.method === "PATCH")).toHaveLength(2); }).pipe(Effect.provide(layer)); }); - it.effect("interactive text decline skips without an unattended recovery hint", () => { + it.effect("declining the question shows the diff but writes nothing", () => { const { layer, out, api } = setup({ - toml: 'project_id = "test"\n[api]\nmax_rows = 2000\n', + toml: `project_id = "test"\n[api]\nmax_rows = 2000\n[auth]\nminimum_password_length = 12\n`, confirm: [false], }); return Effect.gen(function* () { - yield* configPush({ projectRef: Option.none() }); + const error = yield* configPush({ projectRef: Option.none() }).pipe(Effect.flip); + expect(error).toBeInstanceOf(HostedMutationDeclinedError); + expect(out.stderrText).toContain("api.max_rows [update]"); + expect(out.stderrText).toContain("auth.minimum_password_length [update]"); expect(api.requests.some((r) => r.method === "PATCH")).toBe(false); - expect(out.promptConfirmCalls).toHaveLength(1); - expect(out.stderrText).not.toContain("Skipped api:"); }).pipe(Effect.provide(layer)); }); - for (const format of ["text", "json", "stream-json"] as const) { - it.effect(`${format} --yes keeps the affirmative echo on piped stdin`, () => { - const { layer, out, api } = setup({ - toml: 'project_id = "test"\n[api]\nmax_rows = 2000\n', - format, - yes: true, - stdinIsTty: false, - pipedAnswers: ["n"], - }); - return Effect.gen(function* () { - yield* configPush({ projectRef: Option.none() }); - expect(api.requests.some((r) => r.method === "PATCH" && r.url.includes("/postgrest"))).toBe( - true, - ); - expect(out.stderrText).toContain("Do you want to push api config to remote? [Y/n] y\n"); - expect(out.stderrText).not.toContain("Skipped api:"); - }).pipe(Effect.provide(layer)); - }); - } - - it.effect("skips changes on empty non-TTY stdin, echoing the prompt", () => { - const { layer, api, out } = setup({ + it.effect("--yes echoes the confirmation with the target and pushes", () => { + const { layer, out, api } = setup({ toml: `project_id = "test"\n[api]\nmax_rows = 2000\n`, - stdinIsTty: false, + yes: true, }); return Effect.gen(function* () { yield* configPush({ projectRef: Option.none() }); - expect(api.requests.some((r) => r.method === "PATCH" && r.url.includes("/postgrest"))).toBe( - false, - ); - expect(out.stderrText).toContain("Do you want to push api config to remote? [y/N] \n"); expect(out.stderrText).toContain( - "Skipped api: no affirmative confirmation received. Pass --yes", + `Confirmed by --yes: push config changes to API on project ${REF}`, + ); + expect(api.requests.some((r) => r.method === "PATCH" && r.url.includes("/postgrest"))).toBe( + true, ); }).pipe(Effect.provide(layer)); }); - it.effect("honors a piped 'n' decline on non-TTY stdin (no update)", () => { - const { layer, api, out } = setup({ - toml: `project_id = "test"\n[api]\nmax_rows = 2000\n`, + it.effect("a project that already matches needs no consent, even unattended", () => { + const { layer, out, api } = setup({ + toml: `project_id = "test"\n`, + format: "json", stdinIsTty: false, - pipedAnswers: ["n"], }); return Effect.gen(function* () { yield* configPush({ projectRef: Option.none() }); - expect(api.requests.some((r) => r.method === "PATCH" && r.url.includes("/postgrest"))).toBe( - false, - ); - expect(out.stderrText).toContain("Do you want to push api config to remote? [y/N] n"); - expect(out.stderrText).toContain( - "Skipped api: no affirmative confirmation received. Pass --yes", - ); + expect(out.promptConfirmCalls).toHaveLength(0); + expect(api.requests.some((r) => r.method !== "GET")).toBe(false); }).pipe(Effect.provide(layer)); }); - for (const format of ["json", "stream-json"] as const) { - for (const answer of ["n", "y", "", "maybe"] as const) { - it.effect(`${format} honors piped ${JSON.stringify(answer)} with a safe fallback`, () => { - const { layer, api, out } = setup({ - toml: 'project_id = "test"\n[auth]\nminimum_password_length = 12\n', - format, - stdinIsTty: false, - pipedAnswers: [answer], - }); - return Effect.gen(function* () { - yield* configPush({ projectRef: Option.none() }); - expect( - api.requests.some((r) => r.method === "PATCH" && r.url.includes("/config/auth")), - ).toBe(answer === "y"); - expect(out.messages.find((m) => m.type === "success")?.data).toMatchObject({ - services: expect.arrayContaining([ - { - service: "auth", - status: answer === "y" ? "updated" : "skipped", - changes: [["auth", "minimum_password_length"]], - }, - ]), - }); - expect(out.stderrText).toContain( - `Do you want to push auth config to remote? [y/N] ${answer}\n`, - ); - }).pipe(Effect.provide(layer)); - }); - } - } - - it.effect( - "non-interactive text output with TTY stdin skips when the prompt is unavailable", - () => { - const { layer, api, out } = setup({ - toml: 'project_id = "test"\n[api]\nmax_rows = 2000\n', - stdinIsTty: true, - interactive: false, - }); - return Effect.gen(function* () { - yield* configPush({ projectRef: Option.none() }); - expect(api.requests.some((r) => r.method === "PATCH")).toBe(false); - expect(out.promptConfirmCalls).toHaveLength(0); - expect(out.stderrText).toContain( - "Skipped api: confirmation unavailable with redirected output. Pass --yes", - ); - }).pipe(Effect.provide(layer)); - }, - ); - - it.effect("honors SUPABASE_YES from supabase/.env even against a piped 'n'", () => { - const { layer, api } = setup({ - toml: `project_id = "test"\n[api]\nmax_rows = 2000\n`, - stdinIsTty: false, - pipedAnswers: ["n"], - }); - return withEnvVar( - "SUPABASE_YES", - undefined, - Effect.gen(function* () { - yield* writeWorkdirFile(["supabase", ".env"], "SUPABASE_YES=true\n"); - yield* configPush({ projectRef: Option.none() }); - expect(api.requests.some((r) => r.method === "PATCH" && r.url.includes("/postgrest"))).toBe( - true, - ); - }), - ).pipe(Effect.provide(layer)); - }); - - it.effect("honors SUPABASE_YES set directly in the shell environment", () => { - const { layer, api } = setup({ - toml: `project_id = "test"\n[api]\nmax_rows = 2000\n`, - stdinIsTty: false, - pipedAnswers: ["n"], - }); - return withEnvVar( - "SUPABASE_YES", - "true", - Effect.gen(function* () { - yield* configPush({ projectRef: Option.none() }); - expect(api.requests.some((r) => r.method === "PATCH" && r.url.includes("/postgrest"))).toBe( - true, - ); - }), - ).pipe(Effect.provide(layer)); - }); - - it.effect("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: resolved.workdir, - explicitWorkdir: resolved.explicit, - }); - yield* writeWorkdirFile(["supabase", ".env"], "SUPABASE_YES=true\n"); - yield* configPush({ projectRef: Option.none() }).pipe(Effect.provide(layer)); - expect(api.requests.some((r) => r.method === "PATCH" && r.url.includes("/postgrest"))).toBe( - true, - ); - }), - ).pipe(Effect.provide(BunServices.layer)), - ); - it.effect( "does not climb to an ancestor project's config when --workdir names a subdirectory with no config of its own", () => @@ -1081,6 +949,7 @@ max_rows = 1000 }), mockStdin(true), Layer.succeed(YesFlag, true), + Layer.succeed(TargetPolicy, { group: "hosted", consent: "no" }), cliConfigValuesTestLayer, ); return Effect.gen(function* () { @@ -1215,6 +1084,7 @@ max_rows = 1000 }), mockStdin(true), Layer.succeed(YesFlag, true), + Layer.succeed(TargetPolicy, { group: "hosted", consent: "no" }), cliConfigValuesTestLayer, ); return Effect.gen(function* () { @@ -1605,6 +1475,15 @@ function addonsHttpLayer( const BASE_DISABLED = `project_id = "test"\n`; +const PHONE_MFA_ADDON = { + available_addons: [ + { + type: "auth_mfa_phone", + variants: [{ name: "Phone MFA", price: { description: "$75.00/ month" } }], + }, + ], +}; + function setupService(opts: { readonly toml: string; readonly v1?: Record<string, (input: unknown) => Effect.Effect<unknown, unknown>>; @@ -1620,8 +1499,6 @@ function setupService(opts: { readonly explicitWorkdir?: boolean; /** stdin interactivity; defaults to a TTY so prompt-driven tests reach the confirm. */ readonly stdinIsTty?: boolean; - /** Piped (non-TTY) stdin answers, one consumed per confirmation prompt. */ - readonly pipedAnswers?: ReadonlyArray<string>; }) { const out = mockOutput({ format: opts.format ?? "text", @@ -1660,11 +1537,9 @@ function setupService(opts: { stdoutIsTty: (opts.format ?? "text") === "text" && (opts.stdinIsTty ?? true), }), }), - mockStdin( - opts.stdinIsTty ?? true, - opts.pipedAnswers ? `${opts.pipedAnswers.join("\n")}\n` : undefined, - ), + mockStdin(opts.stdinIsTty ?? true), Layer.succeed(YesFlag, opts.yes ?? false), + Layer.succeed(TargetPolicy, { group: "hosted", consent: "no" }), workdirFilesLayer(opts.toml), cliConfigProviderLayer, cliConfigValuesTestLayer, @@ -1780,9 +1655,7 @@ secret = "my-plaintext-secret" expect(out.stderrText).toMatch( /\n\nauth\.captcha\.secret \[secret\]\n {2}local: {2}\(set\)\n {2}remote: \(not set\)\n\n/, ); - expect(out.stderrText).toMatch( - /\n\nDo you want to push auth config to remote\? \[Y\/n\] y\n/, - ); + expect(out.stderrText).toContain("Confirmed by --yes: push config changes to Auth on "); }).pipe(Effect.provide(layer)); }, ); @@ -2032,12 +1905,14 @@ allowed_cidrs_v6 = ["::1/128"] }).pipe(Effect.provide(layer)); }); - it.effect("declining the webhooks prompt leaves them disabled", () => { + it.effect("declining the question leaves webhooks disabled", () => { const toml = `${BASE_DISABLED}[experimental.webhooks]\nenabled = true\n`; const { layer, apiMock, out } = setupService({ toml, confirm: [false] }); return Effect.gen(function* () { - yield* configPush({ projectRef: Option.none() }); + const error = yield* configPush({ projectRef: Option.none() }).pipe(Effect.flip); + expect(error).toBeInstanceOf(HostedMutationDeclinedError); expect(out.stderrText).toContain("Enabling webhooks for project:"); + expect(out.promptConfirmCalls[0]?.message).toContain("push config changes to Webhooks"); expect(methodsOf(apiMock)).not.toContain("enableDatabaseWebhook"); }).pipe(Effect.provide(layer)); }); @@ -2315,95 +2190,29 @@ secret = "env(MISSING_CAPTCHA_SECRET)" }).pipe(Effect.provide(layer)); }); - it.effect("declining the phone MFA addon drops only the MFA keys", () => { - const toml = `project_id = "test" -[auth] -site_url = "https://example.com" -[auth.mfa.phone] -verify_enabled = true -enroll_enabled = true -`; - const { layer, apiMock } = setupService({ - toml, - confirm: [false, true], - v1: { updateAuthServiceConfig: () => Effect.succeed({}) }, - }); - return Effect.gen(function* () { - yield* configPush({ projectRef: Option.none() }); - const update = apiMock.requests.find((r) => r.method === "updateAuthServiceConfig"); - expect(update).toBeDefined(); - const input = yield* decodeJsonRecord(update?.input); - expect(input["site_url"]).toBe("https://example.com"); - expect(input["mfa_phone_verify_enabled"]).toBeUndefined(); - expect(input["mfa_phone_enroll_enabled"]).toBeUndefined(); - }).pipe(Effect.provide(layer)); - }); - - it.effect("declining the WebAuthn MFA addon drops only the MFA keys", () => { - const toml = `project_id = "test" -[auth] -site_url = "https://example.com" -[auth.mfa.web_authn] -verify_enabled = true -enroll_enabled = true -`; - const { layer, apiMock } = setupService({ + it.effect("an enroll-only flip (verify_enabled absent) lists the addon's price before asking", () => { + const toml = `project_id = "test"\n[auth.mfa.phone]\nenroll_enabled = true\n`; + const { layer, out } = setupService({ toml, - confirm: [false, true], + confirm: [true], + addons: PHONE_MFA_ADDON, v1: { updateAuthServiceConfig: () => Effect.succeed({}) }, }); return Effect.gen(function* () { yield* configPush({ projectRef: Option.none() }); - const update = apiMock.requests.find((r) => r.method === "updateAuthServiceConfig"); - expect(update).toBeDefined(); - const input = yield* decodeJsonRecord(update?.input); - expect(input["site_url"]).toBe("https://example.com"); - expect(input["mfa_web_authn_verify_enabled"]).toBeUndefined(); - expect(input["mfa_web_authn_enroll_enabled"]).toBeUndefined(); + expect(out.stderrText).toContain("Enabling Phone MFA will cost you $75.00/ month.\n"); + expect(out.promptConfirmCalls).toHaveLength(1); }).pipe(Effect.provide(layer)); }); - it.effect( - "an enroll-only flip (verify_enabled absent) still prompts, and declining drops the change", - () => { - const toml = `project_id = "test"\n[auth.mfa.phone]\nenroll_enabled = true\n`; - const { layer, apiMock, out } = setupService({ - toml, - confirm: [false], - addons: { - available_addons: [ - { - type: "auth_mfa_phone", - variants: [{ name: "Phone MFA", price: { description: "$75.00/ month" } }], - }, - ], - }, - }); - return Effect.gen(function* () { - yield* configPush({ projectRef: Option.none() }); - expect(out.promptConfirmCalls.map((call) => call.message)).toContain( - "Enabling Phone MFA will cost you $75.00/ month. Keep it enabled?", - ); - expect(methodsOf(apiMock)).not.toContain("updateAuthServiceConfig"); - }).pipe(Effect.provide(layer)); - }, - ); - it.effect( "an enroll-only flip (verify_enabled absent) pushes mfa_phone_enroll_enabled when accepted", () => { const toml = `project_id = "test"\n[auth.mfa.phone]\nenroll_enabled = true\n`; const { layer, apiMock } = setupService({ toml, - confirm: [true, true], - addons: { - available_addons: [ - { - type: "auth_mfa_phone", - variants: [{ name: "Phone MFA", price: { description: "$75.00/ month" } }], - }, - ], - }, + confirm: [true], + addons: PHONE_MFA_ADDON, v1: { updateAuthServiceConfig: () => Effect.succeed({}) }, }); return Effect.gen(function* () { @@ -2417,12 +2226,13 @@ enroll_enabled = true ); it.effect( - "an enroll-only flip never prompts when the remote already has verify_enabled on", + "an enroll-only flip never shows a price when the remote already has verify_enabled on", () => { const toml = `project_id = "test"\n[auth.mfa.phone]\nenroll_enabled = true\n`; const { layer, apiMock, out } = setupService({ toml, confirm: [true], + addons: PHONE_MFA_ADDON, v2: { status: 200, body: v2Response({ @@ -2436,9 +2246,7 @@ enroll_enabled = true }); return Effect.gen(function* () { yield* configPush({ projectRef: Option.none() }); - expect(out.promptConfirmCalls.some((call) => call.message.includes("Enabling"))).toBe( - false, - ); + expect(out.stderrText).not.toContain("will cost you"); const update = apiMock.requests.find((r) => r.method === "updateAuthServiceConfig"); expect(update).toBeDefined(); const input = yield* decodeJsonRecord(update?.input); @@ -2447,42 +2255,35 @@ enroll_enabled = true }, ); - it.effect("accepting a costed MFA addon prompt shows its price and pushes the setting", () => { + it.effect("a costed MFA addon shows its price, and declining sends nothing", () => { const toml = `project_id = "test"\n[auth.mfa.phone]\nverify_enabled = true\n`; const { layer, apiMock, out } = setupService({ toml, - confirm: [true, true], - addons: { - available_addons: [ - { - type: "auth_mfa_phone", - variants: [{ name: "Phone MFA", price: { description: "$75.00/ month" } }], - }, - ], - }, + confirm: [false], + addons: PHONE_MFA_ADDON, v1: { updateAuthServiceConfig: () => Effect.succeed({}) }, }); return Effect.gen(function* () { - yield* configPush({ projectRef: Option.none() }); - // A real-TTY confirm goes through `output.promptConfirm` (clack), which the mock resolves - // silently without echoing to stderr, so assert on the recorded call instead. - expect(out.promptConfirmCalls.map((call) => call.message)).toContain( - "Enabling Phone MFA will cost you $75.00/ month. Keep it enabled?", - ); - const update = apiMock.requests.find((r) => r.method === "updateAuthServiceConfig"); - expect(update).toBeDefined(); - const input = yield* decodeJsonRecord(update?.input); - expect(input["mfa_phone_verify_enabled"]).toBe(true); + const error = yield* configPush({ projectRef: Option.none() }).pipe(Effect.flip); + expect(error).toBeInstanceOf(HostedMutationDeclinedError); + expect(out.stderrText).toContain("Enabling Phone MFA will cost you $75.00/ month.\n"); + expect(methodsOf(apiMock)).not.toContain("updateAuthServiceConfig"); }).pipe(Effect.provide(layer)); }); - it.effect("declining MFA with no other auth change reports up to date and never writes", () => { - const toml = `project_id = "test"\n[auth.mfa.phone]\nverify_enabled = true\nenroll_enabled = true\n`; - const { layer, apiMock, out } = setupService({ toml, confirm: [false] }); + it.effect("accepting pushes the costed MFA setting", () => { + const toml = `project_id = "test"\n[auth.mfa.phone]\nverify_enabled = true\n`; + const { layer, apiMock } = setupService({ + toml, + confirm: [true], + addons: PHONE_MFA_ADDON, + v1: { updateAuthServiceConfig: () => Effect.succeed({}) }, + }); return Effect.gen(function* () { yield* configPush({ projectRef: Option.none() }); - expect(out.stderrText).toContain("Remote Auth config is up to date."); - expect(methodsOf(apiMock)).not.toContain("updateAuthServiceConfig"); + const update = apiMock.requests.find((r) => r.method === "updateAuthServiceConfig"); + const input = yield* decodeJsonRecord(update?.input); + expect(input["mfa_phone_verify_enabled"]).toBe(true); }).pipe(Effect.provide(layer)); }); @@ -2801,7 +2602,7 @@ secret = "super-secret" "Comparison scope: api, database, pooler, realtime, storage (not returned: auth)", ); expect(out.stderrText).not.toContain("Updating Auth service with config:"); - expect(out.stderrText).not.toContain("Do you want to push auth config to remote?"); + expect(out.promptConfirmCalls).toHaveLength(0); expect(methodsOf(apiMock)).not.toContain("updateAuthServiceConfig"); const success = out.messages.find((m) => m.type === "success"); const data = yield* decodeJsonRecord(success?.data); @@ -2893,60 +2694,19 @@ secret = "env(MISSING_CAPTCHA_SECRET)" }, ); - it.effect( - "D12: declining the phone MFA addon sends explicit false disables when the remote already had it on", - () => { - // Text mode: the addon-decline prompt only consumes a real confirm answer in text mode — - // `keep()` short-circuits to accept for any machine format. `declined_addons`'s payload - // shape is covered by the "emits a structured summary" test instead. - const toml = `project_id = "test" -[auth.mfa.phone] -verify_enabled = true -`; - const { layer, apiMock } = setupService({ - toml, - confirm: [false, true], - v2: { - status: 200, - body: v2Response({ - attributes: (a) => ({ - ...a, - auth: { ...asJsonRecord(a["auth"]), mfa_phone_enroll_enabled: true }, - }), - }), - }, - v1: { updateAuthServiceConfig: () => Effect.succeed({}) }, - }); - return Effect.gen(function* () { - yield* configPush({ projectRef: Option.none() }); - const update = apiMock.requests.find((r) => r.method === "updateAuthServiceConfig"); - expect(update).toBeDefined(); - const input = yield* decodeJsonRecord(update?.input); - expect(input["mfa_phone_verify_enabled"]).toBe(false); - expect(input["mfa_phone_enroll_enabled"]).toBe(false); - }).pipe(Effect.provide(layer)); - }, - ); - - it.effect("S1/D9: declining the auth prompt leaves the secret unsent (text mode)", () => { - // Text mode: `keep()` only consumes a real decline in text mode, so this asserts the - // write-side effect rather than the json payload. + it.effect("a declined push discloses the secret block and sends nothing", () => { const toml = `project_id = "test" [auth.captcha] enabled = true provider = "hcaptcha" secret = "new-secret" `; - const { layer, apiMock, out } = setupService({ - toml, - stdinIsTty: false, - pipedAnswers: ["n"], - }); + const { layer, apiMock, out } = setupService({ toml, confirm: [false] }); return Effect.gen(function* () { - yield* configPush({ projectRef: Option.none() }); + const error = yield* configPush({ projectRef: Option.none() }).pipe(Effect.flip); + expect(error).toBeInstanceOf(HostedMutationDeclinedError); expect(methodsOf(apiMock)).not.toContain("updateAuthServiceConfig"); expect(out.stderrText).toContain("auth.captcha.secret [secret]"); - expect(out.stderrText).toContain("Do you want to push auth config to remote? [y/N] n"); }).pipe(Effect.provide(layer)); }); @@ -3200,8 +2960,6 @@ function setupLinkedBranchPush( readonly format?: "text" | "json" | "stream-json"; readonly yes?: boolean; readonly confirm?: ReadonlyArray<boolean>; - readonly stdinIsTty?: boolean; - readonly pipedAnswers?: ReadonlyArray<string>; } = {}, ) { return setup({ @@ -3214,8 +2972,6 @@ function setupLinkedBranchPush( format: opts.format, yes: opts.yes, confirm: opts.confirm, - stdinIsTty: opts.stdinIsTty, - pipedAnswers: opts.pipedAnswers, project: { status: 404, body: {} }, branchList: { status: 200, body: [BRANCH_LIST_ITEM] }, v2: { status: 200, body: v2Response({ ref: BRANCH_REF }) }, @@ -3223,7 +2979,7 @@ function setupLinkedBranchPush( } describe("config push branch/project target detection (CLI-2168)", () => { - it.effect("a plain project push never triggers the branch confirmation gate", () => { + it.effect("a plain project push echoes the named project target", () => { const { layer, out, api } = setup({ toml: BRANCH_PUSH_TOML, yes: true, @@ -3321,58 +3077,28 @@ describe("config push branch/project target detection (CLI-2168)", () => { }, ); - it.effect("declining the branch confirmation prompt on a TTY fails before any mutation", () => { - const { layer, api, telemetry, linkedProjectCache } = setupLinkedBranchPush({ + it.effect("declining a branch push fails before any write and still flushes telemetry", () => { + const { layer, out, api, telemetry, linkedProjectCache } = setupLinkedBranchPush({ confirm: [false], }); return Effect.gen(function* () { - const exit = yield* configPush({ projectRef: Option.none() }).pipe(Effect.exit); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - expect(Cause.pretty(exit.cause)).toContain("ConfigPushCancelledError"); - } - expect(api.requests.some((r) => r.url.includes("/billing/addons"))).toBe(false); - expect(api.requests.some((r) => r.url.includes("/v2/projects/"))).toBe(false); - expect(api.requests.some((r) => r.url.includes("/postgrest"))).toBe(false); - // A declined branch gate still flushes telemetry and writes the linked-project cache. + const error = yield* configPush({ projectRef: Option.none() }).pipe(Effect.flip); + expect(error).toBeInstanceOf(HostedMutationDeclinedError); + expect(out.promptConfirmCalls[0]?.message).toContain( + `on project My App (${PARENT_REF}) · branch (${BRANCH_REF})?`, + ); + expect(api.requests.some((r) => ["PATCH", "PUT", "POST"].includes(r.method))).toBe(false); expect(telemetry.flushed).toBe(true); expect(linkedProjectCache.cached).toBe(true); }).pipe(Effect.provide(layer)); }); - it.effect("an unattended run with no --yes and empty stdin declines and fails by default", () => { - const { layer, api } = setupLinkedBranchPush({ stdinIsTty: false }); - return Effect.gen(function* () { - const exit = yield* configPush({ projectRef: Option.none() }).pipe(Effect.exit); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - expect(Cause.pretty(exit.cause)).toContain("ConfigPushCancelledError"); - } - expect(api.requests.some((r) => r.url.includes("/v2/projects/"))).toBe(false); - }).pipe(Effect.provide(layer)); - }); - - it.effect("an explicit piped 'n' on non-TTY stdin declines and fails a branch push", () => { - const { layer, api } = setupLinkedBranchPush({ - stdinIsTty: false, - pipedAnswers: ["n"], - }); - return Effect.gen(function* () { - const exit = yield* configPush({ projectRef: Option.none() }).pipe(Effect.exit); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - expect(Cause.pretty(exit.cause)).toContain("ConfigPushCancelledError"); - } - expect(api.requests.some((r) => r.url.includes("/v2/projects/"))).toBe(false); - }).pipe(Effect.provide(layer)); - }); - - it.effect("--yes auto-confirms a branch push and echoes the prompt", () => { + it.effect("--yes confirms a branch push and echoes the confirmation with the branch target", () => { const { layer, out, api } = setupLinkedBranchPush({ yes: true }); return Effect.gen(function* () { yield* configPush({ projectRef: Option.none() }); expect(out.stderrText).toContain( - `branch "feat-x" (${BRANCH_REF})? (skip this check with --yes) [y/N] y`, + `Confirmed by --yes: push config changes to API on project My App (${PARENT_REF}) · branch (${BRANCH_REF})`, ); expect(api.requests.some((r) => r.method === "PATCH" && r.url.includes("/postgrest"))).toBe( true, @@ -3380,28 +3106,6 @@ describe("config push branch/project target detection (CLI-2168)", () => { }).pipe(Effect.provide(layer)); }); - it.effect( - "json output mode declines and fails a branch push without --yes (CLI-2168 safety default)", - () => { - const { layer, out, api } = setupLinkedBranchPush({ format: "json" }); - return Effect.gen(function* () { - const exit = yield* configPush({ projectRef: Option.none() }).pipe(Effect.exit); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - expect(Cause.pretty(exit.cause)).toContain("ConfigPushCancelledError"); - // A machine-mode/non-TTY decline never renders the interactive prompt's own hint - // (`promptYesNo` returns the default silently) — the cancelled error's own - // `suggestion` field is the only place a script/agent sees the --yes escape hatch. - const error = Option.getOrUndefined(Cause.findErrorOption(exit.cause)); - expect(error).toMatchObject({ suggestion: expect.stringContaining("--yes") }); - expect(error).toMatchObject({ suggestion: expect.stringContaining("SUPABASE_YES") }); - } - expect(out.messages.some((m) => m.type === "success")).toBe(false); - expect(api.requests.some((r) => ["PATCH", "PUT", "POST"].includes(r.method))).toBe(false); - }).pipe(Effect.provide(layer)); - }, - ); - it.effect("json output mode with --yes reports the branch target in the machine payload", () => { const { layer, out } = setupLinkedBranchPush({ format: "json", yes: true }); return Effect.gen(function* () { @@ -3475,13 +3179,8 @@ describe("config push branch/project target detection (CLI-2168)", () => { it.effect( "a transport failure probing the live target degrades to unknown rather than aborting the push", () => { - // The target-detection probe is diagnostic-only: a transport failure - // must never abort a push that would otherwise succeed — including - // for a plain project whose token can write service config but - // happens to fail this one informational read. It degrades to - // "unknown" (never "branch" — that would wrongly gate an ordinary - // push behind a confirmation that auto-declines, and fails, in an - // unattended run) and the push proceeds. + // The target-detection probe is diagnostic-only: a transport failure must never abort a + // push that would otherwise succeed. It degrades to "unknown" and the push proceeds. const { layer, out, api } = setup({ toml: BRANCH_PUSH_TOML, yes: true, @@ -3494,7 +3193,6 @@ describe("config push branch/project target detection (CLI-2168)", () => { expect(out.stderrText).toContain( `Pushing config to: ${PROBE_REF} (could not determine whether this is a branch or the main project)`, ); - expect(out.stderrText).not.toContain("Do you want to push config to branch"); expect(api.requests.some((r) => r.url.includes("/branches"))).toBe(false); expect(api.requests.some((r) => r.method === "PATCH" && r.url.includes("/postgrest"))).toBe( true, @@ -3609,28 +3307,6 @@ describe("config push --project-ref branch name/UUID resolution (CLI-2289)", () }, ); - it.effect( - "--project-ref <branch-name> skips the branch confirmation prompt, with no --yes and no queued answer", - () => { - // `knownBranch` is `{kind: "name", branchName, parentRef}`, so `push.handler.ts`'s - // `knownBranch === undefined` gate is never entered. `promptYesNo` always writes its label - // to stderr before reading an answer, so its total absence in `out.stderrText` is - // conclusive proof no prompt ran. - const { layer, out } = setup({ - toml: BRANCH_PUSH_TOML, - yes: false, - branchByName: { status: 200, body: BRANCH_BY_NAME }, - v2: { status: 200, body: v2Response({ ref: BRANCH_REF }) }, - }); - return Effect.gen(function* () { - const exit = yield* configPush({ projectRef: Option.some("staging") }).pipe(Effect.exit); - expect(Exit.isSuccess(exit)).toBe(true); - expect(out.stderrText).toContain(`Pushing config to branch: staging (${BRANCH_REF})`); - expect(out.stderrText).not.toContain("Do you want to push config to branch"); - }).pipe(Effect.provide(layer)); - }, - ); - it.effect("--project-ref <branch-name> announces the parent project and resolved branch", () => { const { layer, out } = setup({ toml: BRANCH_PUSH_TOML, @@ -3724,33 +3400,23 @@ describe("config push --project-ref branch name/UUID resolution (CLI-2289)", () }, ); - it.effect( - "--project-ref <uuid> never shows the branch confirmation prompt, on an unattended run with no --yes", - () => { - // `knownBranch` is `{kind: "uuid"}`, the same "explicit target this invocation" shape a - // branch name target gets, so `push.handler.ts`'s `knownBranch === undefined` gate is never - // entered. Per-service prompts (`keep()`) default to `false` on empty non-TTY stdin, - // so the mutation is skipped. - const { layer, out, api } = setup({ - toml: BRANCH_PUSH_TOML, - yes: false, - stdinIsTty: false, - projectId: Option.none(), - project: { status: 404, body: {} }, - branchById: { status: 200, body: BRANCH_CONFIG }, - v2: { status: 200, body: v2Response({ ref: UUID_TARGET_REF }) }, - }); - return Effect.gen(function* () { - const exit = yield* configPush({ projectRef: Option.some(BRANCH_UUID) }).pipe(Effect.exit); - expect(Exit.isSuccess(exit)).toBe(true); - expect(out.stderrText).toContain(`Pushing config to branch: ${UUID_TARGET_REF}`); - expect(out.stderrText).not.toContain("Do you want to push config to branch"); - expect(api.requests.some((r) => r.method === "PATCH" && r.url.includes("/postgrest"))).toBe( - false, - ); - }).pipe(Effect.provide(layer)); - }, - ); + it.effect("--project-ref <uuid> is confirmed like any target: unattended without --yes fails", () => { + const { layer, out, api } = setup({ + toml: BRANCH_PUSH_TOML, + yes: false, + stdinIsTty: false, + projectId: Option.none(), + project: { status: 404, body: {} }, + branchById: { status: 200, body: BRANCH_CONFIG }, + v2: { status: 200, body: v2Response({ ref: UUID_TARGET_REF }) }, + }); + return Effect.gen(function* () { + const error = yield* configPush({ projectRef: Option.some(BRANCH_UUID) }).pipe(Effect.flip); + expect(error).toBeInstanceOf(HostedMutationConsentRequiredError); + expect(out.stderrText).toContain(`Pushing config to branch: ${UUID_TARGET_REF}`); + expect(api.requests.some((r) => r.method === "PATCH")).toBe(false); + }).pipe(Effect.provide(layer)); + }); it.effect("--project-ref <unknown-branch-name> fails with a branches-list suggestion", () => { const { layer, api, telemetry, linkedProjectCache } = setup({ @@ -3924,3 +3590,22 @@ describe("config push telemetry wiring", () => { }).pipe(Effect.provide(wiringLayer(analytics, value))); }); }); + +describeHostedConsent("config push", { + api: { handler: pushApiHandler({}) }, + setup: (workdir) => + Effect.gen(function* () { + 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"), + `project_id = "test"\n[api]\nmax_rows = 2000\n`, + ); + }).pipe(Effect.provide(BunServices.layer)), + invoke: () => ({ + run: configPush({ projectRef: Option.none() }).pipe( + Effect.provide(cliConfigValuesTestLayer), + ), + }), +}); diff --git a/apps/cli/src/commands/config/push/push.plan.ts b/apps/cli/src/commands/config/push/push.plan.ts index f59added79..33545370eb 100644 --- a/apps/cli/src/commands/config/push/push.plan.ts +++ b/apps/cli/src/commands/config/push/push.plan.ts @@ -54,8 +54,8 @@ const PUSH_RESOURCE_PREFIXES: ReadonlyArray<{ { prefix: ["storage"], resource: "storage" }, ]; -/** Cost-matrix / confirmation-prompt key per resource — the three `db.*` resources share one prompt. */ -export function pushPromptKey(resource: PushResource): string { +/** Cost-matrix key per resource — the three `db.*` resources share one. */ +export function pushCostKey(resource: PushResource): string { switch (resource) { case "api": return "api"; @@ -184,7 +184,7 @@ export interface PushAddonGate { readonly enrollPath: ReadonlyArray<string>; } -/** The two paid MFA addons whose enablement is gated behind a cost-aware prompt. */ +/** The two paid MFA addons a push can enable. */ export const PUSH_ADDON_GATES: ReadonlyArray<PushAddonGate> = [ { costKey: "auth_mfa_phone", @@ -199,11 +199,11 @@ export const PUSH_ADDON_GATES: ReadonlyArray<PushAddonGate> = [ ]; /** - * Whether an addon gate's cost-aware prompt should fire: the routed changes turn on - * `verify_enabled` or `enroll_enabled` (enrolment alone already starts SMS/WebAuthn charges), - * unless the addon is already active on the project (`remote`'s `verify_enabled` is `true`). + * Whether the routed changes switch a paid addon on: `verify_enabled` or `enroll_enabled` turns + * on (enrolment alone already starts SMS/WebAuthn charges), unless the addon is already active + * on the project (`remote`'s `verify_enabled` is `true`). */ -export function pushAddonPromptNeeded( +export function pushEnablesAddon( changes: ReadonlyArray<ConfigChange>, gate: PushAddonGate, remote: ProjectConfig, @@ -223,36 +223,3 @@ export function changesCommunicated( ): ReadonlyArray<ConfigChange> { return changes.filter((change) => pathIn(change.path, encodedPaths)); } - -/** - * Applies a declined paid-MFA-addon prompt to the routed auth change list. Drops the addon's - * `verify_enabled`/`enroll_enabled` changes; when the remote currently has either flag `true`, - * replaces them with synthetic `update` changes setting both to `false`, so the request body - * carries an explicit disable rather than silently omitting a currently-enabled addon. - */ -export function applyMfaAddonDecline( - changes: ReadonlyArray<ConfigChange>, - gate: PushAddonGate, - remote: ProjectConfig, -): ReadonlyArray<ConfigChange> { - const withoutGate = changes.filter( - (change) => !samePath(change.path, gate.verifyPath) && !samePath(change.path, gate.enrollPath), - ); - const remoteVerify = valueAtPath(remote, gate.verifyPath); - const remoteEnroll = valueAtPath(remote, gate.enrollPath); - if (remoteVerify !== true && remoteEnroll !== true) { - return withoutGate; - } - const disableChange = (path: ReadonlyArray<string>, remoteValue: unknown): ConfigChange => ({ - path, - class: "update", - local: false, - remote: remoteValue, - declared: true, - }); - return [ - ...withoutGate, - disableChange(gate.verifyPath, remoteVerify), - disableChange(gate.enrollPath, remoteEnroll), - ]; -} diff --git a/apps/cli/src/commands/config/push/push.plan.unit.test.ts b/apps/cli/src/commands/config/push/push.plan.unit.test.ts index c439a1d368..9845986f69 100644 --- a/apps/cli/src/commands/config/push/push.plan.unit.test.ts +++ b/apps/cli/src/commands/config/push/push.plan.unit.test.ts @@ -6,11 +6,10 @@ import { PUSH_ADDON_GATES, PUSH_RESOURCES, PUSH_UNSUPPORTED_PREFIXES, - applyMfaAddonDecline, changesCommunicated, planConfigPush, - pushAddonPromptNeeded, - pushPromptKey, + pushEnablesAddon, + pushCostKey, pushResourceEnabled, pushResourceForPath, pushResponseBlock, @@ -67,17 +66,17 @@ describe("PUSH_RESOURCES", () => { }); }); -describe("pushPromptKey", () => { +describe("pushCostKey", () => { it("returns the resource's own key for api/auth/storage", () => { - expect(pushPromptKey("api")).toBe("api"); - expect(pushPromptKey("auth")).toBe("auth"); - expect(pushPromptKey("storage")).toBe("storage"); + expect(pushCostKey("api")).toBe("api"); + expect(pushCostKey("auth")).toBe("auth"); + expect(pushCostKey("storage")).toBe("storage"); }); it("routes every db.* resource to the shared 'db' cost-matrix key", () => { - expect(pushPromptKey("db.settings")).toBe("db"); - expect(pushPromptKey("db.network_restrictions")).toBe("db"); - expect(pushPromptKey("db.ssl_enforcement")).toBe("db"); + expect(pushCostKey("db.settings")).toBe("db"); + expect(pushCostKey("db.network_restrictions")).toBe("db"); + expect(pushCostKey("db.ssl_enforcement")).toBe("db"); }); }); @@ -268,17 +267,17 @@ describe("PUSH_ADDON_GATES", () => { }); }); -describe("pushAddonPromptNeeded", () => { +describe("pushEnablesAddon", () => { it.each(PUSH_ADDON_GATES)("prompts when $costKey's verify_enabled flips to true", (gate) => { const changes = [change(gate.verifyPath, "update", true, false)]; - expect(pushAddonPromptNeeded(changes, gate, {})).toBe(true); + expect(pushEnablesAddon(changes, gate, {})).toBe(true); }); it.each(PUSH_ADDON_GATES)( "prompts when only $costKey's enroll_enabled flips to true and verify_enabled is false on the remote", (gate) => { const changes = [change(gate.enrollPath, "update", true, false)]; - expect(pushAddonPromptNeeded(changes, gate, {})).toBe(true); + expect(pushEnablesAddon(changes, gate, {})).toBe(true); }, ); @@ -287,13 +286,13 @@ describe("pushAddonPromptNeeded", () => { (gate) => { const changes = [change(gate.enrollPath, "update", true, false)]; const remote = remoteWithVerifyEnabled(gate, true); - expect(pushAddonPromptNeeded(changes, gate, remote)).toBe(false); + expect(pushEnablesAddon(changes, gate, remote)).toBe(false); }, ); it.each(PUSH_ADDON_GATES)("does not prompt when neither $costKey flag is turning on", (gate) => { const changes = [change(gate.verifyPath, "update", false, false)]; - expect(pushAddonPromptNeeded(changes, gate, {})).toBe(false); + expect(pushEnablesAddon(changes, gate, {})).toBe(false); }); it.each(PUSH_ADDON_GATES)( @@ -301,7 +300,7 @@ describe("pushAddonPromptNeeded", () => { (gate) => { const changes = [change(gate.verifyPath, "update", true, true)]; const remote = remoteWithVerifyEnabled(gate, true); - expect(pushAddonPromptNeeded(changes, gate, remote)).toBe(false); + expect(pushEnablesAddon(changes, gate, remote)).toBe(false); }, ); }); @@ -320,51 +319,3 @@ describe("changesCommunicated", () => { expect(changesCommunicated([change(["auth", "site_url"], "update")], [])).toEqual([]); }); }); - -describe("applyMfaAddonDecline", () => { - const gate = PUSH_ADDON_GATES[0]; - if (gate === undefined) throw new Error("expected at least one addon gate"); - - it("drops the addon's changes outright when the remote already has both flags false", () => { - const changes = [ - change(["auth", "site_url"], "update"), - change(gate.verifyPath, "update", true, false), - change(gate.enrollPath, "update", true, false), - ]; - const remote: ProjectConfig = { - auth: { mfa: { phone: { verify_enabled: false, enroll_enabled: false } } }, - }; - const result = applyMfaAddonDecline(changes, gate, remote); - expect(result.map((c) => c.path)).toEqual([["auth", "site_url"]]); - }); - - it("drops the addon's changes outright when the remote has neither flag declared", () => { - const changes = [change(gate.verifyPath, "update", true, undefined)]; - const result = applyMfaAddonDecline(changes, gate, {}); - expect(result).toEqual([]); - }); - - it("replaces the addon's changes with explicit falses when the remote currently has verify_enabled true", () => { - const changes = [ - change(["auth", "site_url"], "update"), - change(gate.verifyPath, "update", true, true), - ]; - const remote: ProjectConfig = { auth: { mfa: { phone: { verify_enabled: true } } } }; - const result = applyMfaAddonDecline(changes, gate, remote); - expect(result).toEqual([ - change(["auth", "site_url"], "update"), - { path: gate.verifyPath, class: "update", local: false, remote: true, declared: true }, - { path: gate.enrollPath, class: "update", local: false, remote: undefined, declared: true }, - ]); - }); - - it("replaces the addon's changes with explicit falses when the remote currently has enroll_enabled true", () => { - const changes = [change(gate.enrollPath, "update", true, true)]; - const remote: ProjectConfig = { auth: { mfa: { phone: { enroll_enabled: true } } } }; - const result = applyMfaAddonDecline(changes, gate, remote); - expect(result).toEqual([ - { path: gate.verifyPath, class: "update", local: false, remote: undefined, declared: true }, - { path: gate.enrollPath, class: "update", local: false, remote: true, declared: true }, - ]); - }); -}); diff --git a/apps/cli/src/commands/config/push/push.types.ts b/apps/cli/src/commands/config/push/push.types.ts index e971ba6379..d3dd1af776 100644 --- a/apps/cli/src/commands/config/push/push.types.ts +++ b/apps/cli/src/commands/config/push/push.types.ts @@ -6,9 +6,8 @@ import type { PushResource } from "./push.plan.ts"; * `service` is a dotted key mirroring `config.toml` paths, plus the fixed * `experimental.webhooks` identifier — see `push.format.ts`'s doc comment for why it's an opaque * identifier, not itself a config path. Status: - * - `updated` — a pushable difference existed, the user kept it, the write ran. + * - `updated` — a pushable difference existed and the write ran. * - `up_to_date` — no pushable difference existed for this resource. - * - `skipped` — a pushable difference existed but the user declined the prompt. * - `disabled` — the service's local gate was off, so it was not touched. * - `unavailable` — the effective-config response omitted this resource's * block, so there was nothing to compare against; nothing was written. @@ -19,7 +18,6 @@ import type { PushResource } from "./push.plan.ts"; type ConfigPushServiceStatus = | "updated" | "up_to_date" - | "skipped" | "disabled" | "unavailable" | "not_pushable"; @@ -28,8 +26,7 @@ export interface ConfigPushServiceResult { readonly service: PushResource | "experimental.webhooks"; readonly status: ConfigPushServiceStatus; /** Change paths this service's write communicated (empty for `up_to_date`/`disabled`/ - * `unavailable`/`not_pushable`; `skipped` carries what the declined write would have - * communicated, excluding secrets). For `experimental.webhooks` — which has no + * `unavailable`/`not_pushable`). For `experimental.webhooks` — which has no * registry-comparable path of its own — `changes` is `[["experimental","webhooks","enabled"]]` * once the enable POST ran, `[]` otherwise, so a webhook-only push is still counted as a * pushed property in the json/stream-json summary. */ diff --git a/apps/cli/src/commands/config/push/push.unit.test.ts b/apps/cli/src/commands/config/push/push.unit.test.ts index f4c3a1600f..a0d66826c1 100644 --- a/apps/cli/src/commands/config/push/push.unit.test.ts +++ b/apps/cli/src/commands/config/push/push.unit.test.ts @@ -2,9 +2,10 @@ import { describe, expect, test } from "vitest"; import type { ConfigPushTarget } from "./push.branch-target.ts"; import { - configPushBranchPromptLabel, configPushPayloadFields, configPushTargetLines, + pushConsentAction, + pushCostLine, } from "./push.format.ts"; const REF = "abcdefghijklmnopqrst"; @@ -98,29 +99,27 @@ describe("configPushTargetLines", () => { }); }); -describe("configPushBranchPromptLabel", () => { - const skipHint = " (skip this check with --yes)"; +describe("pushConsentAction", () => { + test("names a single service", () => { + expect(pushConsentAction(["auth"])).toBe("push config changes to Auth"); + }); - test("a branch with no known name prompts against the bare ref", () => { - expect(configPushBranchPromptLabel({ kind: "branch", ref: REF })).toBe( - `Do you want to push config to branch ${REF}?${skipHint}`, - ); + test("joins two services with and", () => { + expect(pushConsentAction(["api", "storage"])).toBe("push config changes to API and Storage"); }); - test("a branch with a known name quotes it alongside the ref", () => { - expect(configPushBranchPromptLabel({ kind: "branch", ref: REF, branch: "feat-x" })).toBe( - `Do you want to push config to branch "feat-x" (${REF})?${skipHint}`, + test("joins three or more with commas and a final and, including webhooks", () => { + expect(pushConsentAction(["api", "db.settings", "experimental.webhooks"])).toBe( + "push config changes to API, DB and Webhooks", ); }); +}); - test("a hostile branch name cannot inject control characters into the prompt label", () => { - expect( - configPushBranchPromptLabel({ - kind: "branch", - ref: REF, - branch: "evil\x1b[31m\nname", - }), - ).toBe(`Do you want to push config to branch "evil[31m name" (${REF})?${skipHint}`); +describe("pushCostLine", () => { + test("states the addon and its price", () => { + expect(pushCostLine({ name: "Phone MFA", price: "$75.00/ month" })).toBe( + "Enabling Phone MFA will cost you $75.00/ month.\n", + ); }); }); 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 ff56c0861c..ebe34ab190 100644 --- a/apps/cli/src/commands/functions/deploy/deploy.integration.test.ts +++ b/apps/cli/src/commands/functions/deploy/deploy.integration.test.ts @@ -1724,7 +1724,6 @@ describe("functions deploy", () => { supabaseDir: path.join(tempRoot.current, "supabase"), dashboardUrl: "https://supabase.com/dashboard", localConfigLoader: functionsLocalConfigLoader, - yes: false, rawArgs: ["functions", "deploy"], edgeRuntimeVersion: "1.69.12", resolveProjectRef: () => Effect.succeed("abcdefghijklmnopqrst"), @@ -2335,7 +2334,6 @@ describe("functions deploy", () => { supabaseDir: path.join(tempRoot.current, "supabase"), dashboardUrl: "https://supabase.com/dashboard", localConfigLoader: functionsLocalConfigLoader, - yes: false, rawArgs: ["functions", "deploy", "hello-world", "--use-api=false"], edgeRuntimeVersion: "1.69.12", resolveProjectRef: () => Effect.succeed("abcdefghijklmnopqrst"), @@ -2362,7 +2360,7 @@ describeHostedConsent("functions deploy", { Effect.gen(function* () { yield* writeCliConfig(workdir); yield* writeLocalFunction(workdir, "hello-world"); - }), + }).pipe(Effect.provide(BunServices.layer)), invoke: () => ({ run: functionsDeploy(baseFlags).pipe( Effect.provide( From f827bae40b0a71dfed7aa24460fa3e1e8ff0e883 Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Thu, 15 Oct 2026 14:32:10 +0100 Subject: [PATCH 36/92] feat(cli): resolve migration fetch through the sync target group --- .../command-internal/migration-fetch-run.ts | 86 ++----- .../commands/migration/fetch/SIDE_EFFECTS.md | 60 ++--- .../commands/migration/fetch/fetch.command.ts | 25 +- .../commands/migration/fetch/fetch.handler.ts | 10 +- .../migration/fetch/fetch.integration.test.ts | 218 +++++++++++------- 5 files changed, 194 insertions(+), 205 deletions(-) diff --git a/apps/cli/src/command-internal/migration-fetch-run.ts b/apps/cli/src/command-internal/migration-fetch-run.ts index 3ee569443e..6d948b0c7f 100644 --- a/apps/cli/src/command-internal/migration-fetch-run.ts +++ b/apps/cli/src/command-internal/migration-fetch-run.ts @@ -4,30 +4,21 @@ import { DnsResolverFlag, resolveYesWithProjectEnv } from "./global-flags.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 { bold } from "./colors.ts"; import { DbConfigResolver } from "./db-config.service.ts"; import { DbConnection } from "./db-connection.service.ts"; import { loadProjectEnvValues } from "./db-config.toml-read.ts"; -import { type DbTargetSelection } from "./db-target-flags.ts"; +import { hostedMutationPreapproval } from "./hosted-consent/hosted-consent.ts"; import { readMigrationTable } from "./migration-history.ts"; +import { promptYesNo } from "./prompt-yes-no.ts"; +import type { ResolvedTarget } from "./target/resolve-target.ts"; import { LinkedProjectCache } from "../telemetry/linked-project-cache.service.ts"; -import { - MigrationTargetFlagsError, - OperationCanceledError, -} from "../commands/migration/migration.errors.ts"; -import { migrationConfirm } from "../commands/migration/migration.prompt.ts"; -import type { MigrationFetchFlags } from "../commands/migration/fetch/fetch.command.ts"; +import { OperationCanceledError } from "../commands/migration/migration.errors.ts"; import { MigrationFetchWriteError } from "../commands/migration/fetch/fetch.errors.ts"; -// Re-exported so an in-process caller (`pull`) can build a `MigrationFetchInput` without -// reaching into `commands/migration/**` directly. -export type { MigrationFetchFlags }; - export interface MigrationFetchInput { - readonly flags: MigrationFetchFlags; - readonly target: DbTargetSelection; - /** Overrides `--yes`/`SUPABASE_YES`/`supabase/.env` resolution. */ + readonly target: ResolvedTarget; + /** Overrides `--yes`/`SUPABASE_YES`/`supabase/.env` resolution for the overwrite prompt. */ readonly assumeYes?: boolean; } @@ -39,7 +30,7 @@ export interface MigrationFetchOutcome { export const runMigrationFetch = Effect.fn("MigrationFetch.run")(function* ( input: MigrationFetchInput, ) { - const { flags, target, assumeYes } = input; + const { target, assumeYes } = input; const output = yield* Output; const resolver = yield* DbConfigResolver; const connection = yield* DbConnection; @@ -48,61 +39,30 @@ export const runMigrationFetch = Effect.fn("MigrationFetch.run")(function* ( const path = yield* Path.Path; const dnsResolver = yield* DnsResolverFlag; - // Validated first, before any other work — same ordering as `migration down`/`repair`. - if (target.setFlags.length > 1) { - return yield* new MigrationTargetFlagsError({ - 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`, - }); - } - - const connType = target.connType ?? "linked"; // fetch defaults to `--linked`. - yield* Effect.annotateCurrentSpan("db.conn_type", connType); + yield* Effect.annotateCurrentSpan("db.conn_type", target.kind); - // `--project-ref` never implies `--linked` and must not be silently - // discarded on a non-linked target — see push.handler.ts's identical guard - // (db push) for the full TS-only rationale. - if (Option.isSome(flags.projectRef) && connType !== "linked") { - return yield* new MigrationTargetFlagsError({ - message: - "--project-ref only applies when targeting the linked project; use it with --linked (not --local or --db-url)", - }); - } - - // Resolve the DB config BEFORE any filesystem/prompt side effects — an invalid - // `--db-url`/`config.toml` then fails immediately, instead of first creating - // `supabase/migrations` or letting a declined overwrite prompt mask the real error - // with `context canceled`. Same fix as `migration repair`. - const cfg = yield* resolver.resolve({ - dbUrl: flags.dbUrl, - connType, + // Resolved before any filesystem or prompt side effect so an invalid `--db-url` or + // `config.toml` fails immediately instead of behind a declined overwrite prompt. + const cfg = yield* resolver.resolveConnection({ + target, dnsResolver, - linkedProjectRef: flags.projectRef, + password: Option.none(), + networkBanRemoval: yield* hostedMutationPreapproval, }); - // The project .env loads after the parse-time flag-group validation above — so a - // 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* loadProjectEnvValues(fs, path, cliSettings.workdir); const yes = yield* resolveYesWithProjectEnv(projectEnv); - // Linked fetch caches the project ref on success. The ref is - // loaded now (pre-run), but the cache write is attached to the body via `Effect.ensuring`, - // so a declined prompt returns before it runs. + // The cache write is attached to the body so a declined prompt returns before it runs. let cacheLinkedRef: Effect.Effect<void> | undefined; - if (connType === "linked") { - const projectRef = yield* ProjectRefResolver; + if (target.kind === "linked") { const linkedProjectCache = yield* LinkedProjectCache; - const ref = yield* projectRef.loadProjectRef(flags.projectRef); - cacheLinkedRef = linkedProjectCache.cache(ref); + cacheLinkedRef = linkedProjectCache.cache(target.ref); } const fetchBody = Effect.gen(function* () { const migrationsDir = path.join(cliSettings.workdir, "supabase", "migrations"); - yield* fs - .makeDirectory(migrationsDir, { recursive: true }) - .pipe(Effect.mapError((cause) => new MigrationFetchWriteError({ message: cause.message }))); // Only a missing directory counts as "empty"; any other read error must propagate — // collapsing it to empty would skip the confirmation and risk clobbering existing migrations. const existing = yield* fs.readDirectory(migrationsDir).pipe( @@ -121,7 +81,7 @@ export const runMigrationFetch = Effect.fn("MigrationFetch.run")(function* ( const overwrite = assumeYes !== undefined ? assumeYes - : yield* migrationConfirm(title, { defaultValue: true, yes }); + : yield* promptYesNo(output, yes, title, true, true, { readMachineStdin: true }); if (!overwrite) { return yield* new OperationCanceledError({ message: CONTEXT_CANCELED_MESSAGE }); } @@ -129,12 +89,6 @@ export const runMigrationFetch = Effect.fn("MigrationFetch.run")(function* ( const migrations = yield* Effect.scoped( Effect.gen(function* () { - // The connect diagnostic prints to stderr before dialing, - // local/remote per the resolved connection. - yield* output.raw( - `Connecting to ${cfg.isLocal ? "local" : "remote"} database...\n`, - "stderr", - ); const session = yield* connection.connect(cfg.conn, { isLocal: cfg.isLocal, dnsResolver, @@ -143,6 +97,10 @@ export const runMigrationFetch = Effect.fn("MigrationFetch.run")(function* ( }), ); + yield* fs + .makeDirectory(migrationsDir, { recursive: true }) + .pipe(Effect.mapError((cause) => new MigrationFetchWriteError({ message: cause.message }))); + const written: Array<string> = []; for (const file of migrations) { // The version/name come from the remote `schema_migrations` table. A hostile remote could diff --git a/apps/cli/src/commands/migration/fetch/SIDE_EFFECTS.md b/apps/cli/src/commands/migration/fetch/SIDE_EFFECTS.md index d45c39f0b8..1cb52b3b26 100644 --- a/apps/cli/src/commands/migration/fetch/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/migration/fetch/SIDE_EFFECTS.md @@ -2,17 +2,19 @@ ## Files Read -| Path | Format | When | -| --------------------------------------------- | ---------- | --------------------------------------------------------------------------------------------------------- | -| `~/.supabase/access-token` | plain text | when `SUPABASE_ACCESS_TOKEN` unset and `--linked` | -| `<workdir>/supabase/.temp/project-ref` | plain text | `--linked` (default), to resolve the ref — skipped when `--project-ref` (or `SUPABASE_PROJECT_ID`) is set | -| `<workdir>/supabase/.env*`, `<workdir>/.env*` | dotenv | always, to resolve `SUPABASE_YES` (CLI-1878) | +| Path | Format | When | +| ---------------------------------------------- | ---------- | ------------------------------------------------------------------------------------------------------------ | +| `~/.supabase/access-token` | plain text | when `SUPABASE_ACCESS_TOKEN` unset and the target is the linked project | +| `<workdir>/supabase/.temp/project-ref` | plain text | linked target (default), to resolve the ref — skipped when `--project-ref` (or `SUPABASE_PROJECT_ID`) is set | +| `<workdir>/supabase/.temp/linked-project.json` | JSON | linked target, to name the project in the `Target:` banner | +| `<workdir>/supabase/.env*`, `<workdir>/.env*` | dotenv | always, to resolve `SUPABASE_YES` for the overwrite prompt | ## Files Written -| Path | Format | When | -| ---------------------------------------------------- | -------- | ----------------------------------------------- | -| `<workdir>/supabase/migrations/<version>_<name>.sql` | SQL text | always — writes fetched migration files locally | +| Path | Format | When | +| ---------------------------------------------------- | -------- | ----------------------------------------------------------------------------------------------------- | +| `<workdir>/supabase/migrations/<version>_<name>.sql` | SQL text | writes fetched migration files locally; the directory is created only after the history table is read | +| `<workdir>/supabase/.temp/linked-project.json` | JSON | linked target, on completion — caches the project identity | ## API Routes @@ -22,25 +24,25 @@ ## Environment Variables -| Variable | Purpose | Required? | -| ----------------------- | --------------------------------- | ----------------------------------------------------------------------------------------------------------------- | -| `SUPABASE_ACCESS_TOKEN` | auth token for `--linked` mode | no (falls back to keyring → `~/.supabase/access-token`) | -| `SUPABASE_YES` | auto-confirm the overwrite prompt | no — read from the shell env OR the project `.env`/`.env.local`/`.env.<env>[.local]` files (shell wins; CLI-1878) | +| Variable | Purpose | Required? | +| ----------------------- | --------------------------------- | ------------------------------------------------------------------------------------------------------- | +| `SUPABASE_ACCESS_TOKEN` | auth token for the linked target | no (falls back to keyring → `~/.supabase/access-token`) | +| `SUPABASE_YES` | auto-confirm the overwrite prompt | no — read from the shell env OR the project `.env`/`.env.local`/`.env.<env>[.local]` files (shell wins) | ## Exit Codes -| Code | Condition | -| ---- | ------------------------------------------------------------------------ | -| `0` | success | -| `1` | database connection failure | -| `1` | failed to write migration files | -| `1` | `--project-ref` set with a resolved target other than linked (see Notes) | +| Code | Condition | +| ---- | ------------------------------------ | +| `0` | success | +| `1` | database connection failure | +| `1` | failed to write migration files | +| `1` | conflicting target flags (see Notes) | ## Output ### `--output-format text` -Silent on success. Reads +Prints one `Target: …` line on stderr naming the resolved database, then is silent on success. Reads `SELECT version, coalesce(name, '') as name, statements FROM supabase_migrations.schema_migrations` and writes each row to `<workdir>/supabase/migrations/<version>_<name>.sql` (statements joined with @@ -48,7 +50,7 @@ supabase_migrations.schema_migrations` and writes each row to ### `--output-format json` -Emits `output.success("Migration history fetched", { files: [<absolute path>] })`. +Emits `output.success("Migration history fetched", { files: [<absolute path>] })`, with the resolved `target` merged in. ### `--output-format stream-json` @@ -60,17 +62,19 @@ Same structured `files` result delivered as an NDJSON `result` event. `Do you want to overwrite existing files in supabase/migrations directory?` (default **YES**). Declining exits non-zero (`context canceled`). `--yes` or `SUPABASE_YES` (shell env or project `.env`) auto-confirms; a non-interactive / - machine-output run takes the default (YES). An unrecognised answer declines. + machine-output run takes the default (YES). An unrecognised answer declines. This is a + prompt about local files, so a `SUPABASE_YES` in the project `.env` still answers it. +- The command only reads the remote history, so it asks for no hosted-mutation consent. + `--yes` (or a shell `SUPABASE_YES`) additionally lets the connection remove a network ban + that blocks the temporary login role. ## Notes -- `--linked` (default true), `--local`, and `--db-url` 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 - a non-linked target). +- The default target is the linked project; `--local` selects the local database and `--db-url` + selects another database. `--local`, `--linked` and `--db-url` are mutually exclusive. +- **`--project-ref`** selects a linked project (flag > `SUPABASE_PROJECT_ID` > + `.temp/project-ref`) and implies `--linked`. Combining it with `--local` or `--db-url` is + a hard error. - Fetches migration file contents from the `supabase_migrations.schema_migrations` history table. - **Empty-statements rows:** a row whose `statements` array is empty (NULL/`{}` — possible on older projects or manually-inserted rows) is written as diff --git a/apps/cli/src/commands/migration/fetch/fetch.command.ts b/apps/cli/src/commands/migration/fetch/fetch.command.ts index a664130b7a..29c6d31c15 100644 --- a/apps/cli/src/commands/migration/fetch/fetch.command.ts +++ b/apps/cli/src/commands/migration/fetch/fetch.command.ts @@ -1,4 +1,4 @@ -import { Command, Flag } from "effect/cli"; +import { Command } from "effect/cli"; import type * as CliCommand from "effect/cli/Command"; import { withPermissions } from "../../../command-internal/command-permissions/command-permissions.annotation.ts"; @@ -15,29 +15,10 @@ 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"; +import { targetFlags } from "../../../command-internal/target/target-flags.ts"; import { withTargetPolicy } from "../../../command-internal/target/target-policy.ts"; -const config = { - dbUrl: Flag.String("db-url").pipe( - Flag.withDescription( - "Fetches migrations from the database specified by the connection string (must be percent-encoded).", - ), - Flag.optional, - ), - linked: Flag.Boolean("linked").pipe( - Flag.withDescription("Fetches migration history from the linked project."), - Flag.withDefault(true), - ), - local: Flag.Boolean("local").pipe( - Flag.withDescription("Fetches migration history from the local database."), - Flag.withDefault(false), - ), - // 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."), - Flag.optional, - ), -} as const; +const config = targetFlags({ group: "sync", subject: "database", dbUrl: true }); export type MigrationFetchFlags = CliCommand.Command.Config.Infer<typeof config>; diff --git a/apps/cli/src/commands/migration/fetch/fetch.handler.ts b/apps/cli/src/commands/migration/fetch/fetch.handler.ts index cea9ee3147..e65f0831c5 100644 --- a/apps/cli/src/commands/migration/fetch/fetch.handler.ts +++ b/apps/cli/src/commands/migration/fetch/fetch.handler.ts @@ -1,8 +1,7 @@ import { Effect } from "effect"; -import { resolveDbTargetFlags } from "../../../command-internal/db-target-flags.ts"; import { runMigrationFetch } from "../../../command-internal/migration-fetch-run.ts"; -import { CliArgs } from "../../../shared/cli/cli-args.service.ts"; +import { resolveTarget } from "../../../command-internal/target/resolve-target.ts"; import { Output } from "../../../shared/output/output.service.ts"; import { TelemetryState } from "../../../telemetry/telemetry-state.service.ts"; import type { MigrationFetchFlags } from "./fetch.command.ts"; @@ -10,11 +9,8 @@ import type { MigrationFetchFlags } from "./fetch.command.ts"; export const migrationFetch = Effect.fn("migration.fetch")(function* (flags: MigrationFetchFlags) { const output = yield* Output; const telemetryState = yield* TelemetryState; - const cliArgs = yield* CliArgs; - const target = resolveDbTargetFlags(cliArgs.args); - const outcome = yield* runMigrationFetch({ flags, target, assumeYes: undefined }).pipe( - Effect.ensuring(telemetryState.flush), - ); + const target = yield* resolveTarget(flags); + const outcome = yield* runMigrationFetch({ target }).pipe(Effect.ensuring(telemetryState.flush)); yield* Effect.annotateCurrentSpan({ "file.count": outcome.files.length }); // Silent on success in text mode. diff --git a/apps/cli/src/commands/migration/fetch/fetch.integration.test.ts b/apps/cli/src/commands/migration/fetch/fetch.integration.test.ts index a82d6d1eac..0b12a5eb42 100644 --- a/apps/cli/src/commands/migration/fetch/fetch.integration.test.ts +++ b/apps/cli/src/commands/migration/fetch/fetch.integration.test.ts @@ -30,15 +30,25 @@ import { stubManagementApi, type StubbedRequest, } from "../../../../tests/helpers/permission-real-layers.ts"; +import { configValuesLayer } from "../../../../tests/helpers/config-values-layer.ts"; import { CliArgs } from "../../../shared/cli/cli-args.service.ts"; -import { DnsResolverFlag, YesFlag } from "../../../command-internal/global-flags.ts"; +import { DnsResolverFlag, OutputFlag, YesFlag } from "../../../command-internal/global-flags.ts"; import type { OutputFormat } from "../../../shared/output/types.ts"; import { ProjectRefResolver } from "../../../config/project-ref.service.ts"; import { DbConfigLoadError } from "../../../command-internal/db-config.errors.ts"; import { DbConfigResolver } from "../../../command-internal/db-config.service.ts"; -import type { DbConfigFlags, ResolvedDbConfig } from "../../../command-internal/db-config.types.ts"; +import type { + DbConnectionInput, + ResolvedDbConnection, +} from "../../../command-internal/db-config.types.ts"; +import { DbConnectError } from "../../../command-internal/db-connection.errors.ts"; import { DbConnection } from "../../../command-internal/db-connection.service.ts"; -import { MigrationTargetFlagsError, OperationCanceledError } from "../migration.errors.ts"; +import { TargetPolicy } from "../../../command-internal/target/target-policy.ts"; +import { + TargetProjectRefConflictError, + TargetSelectorConflictError, +} from "../../../command-internal/target/target.errors.ts"; +import { OperationCanceledError } from "../migration.errors.ts"; import { MigrationFetchWriteError } from "./fetch.errors.ts"; import { migrationFetch } from "./fetch.handler.ts"; import type { MigrationFetchFlags } from "./fetch.command.ts"; @@ -62,8 +72,7 @@ interface SetupOpts { readonly confirm?: boolean; readonly rows?: ReadonlyArray<MigrationRow>; readonly resolveFails?: boolean; - /** Raw argv seen by `resolveDbTargetFlags` (e.g. to exercise a flag conflict). */ - readonly cliArgs?: ReadonlyArray<string>; + readonly connectFails?: boolean; } function setup(workdir: string, opts: SetupOpts = {}) { @@ -74,63 +83,71 @@ function setup(workdir: string, opts: SetupOpts = {}) { const telemetry = mockTelemetryStateTracked(); const cache = mockLinkedProjectCacheTracked(); + const resolveCalls: Array<DbConnectionInput> = []; const resolver = Layer.succeed(DbConfigResolver, { - resolve: (_flags: DbConfigFlags) => + resolve: () => Effect.die("resolve is not used by migration fetch"), + resolveConnection: (input: DbConnectionInput) => opts.resolveFails === true ? Effect.fail( new DbConfigLoadError({ message: "failed to parse config: invalid connection string", }), ) - : Effect.succeed({ - conn: { - host: "127.0.0.1", - port: 54322, - user: "postgres", - password: "x", - database: "postgres", - }, - isLocal: false, - ref: Option.some(VALID_REF), - } satisfies ResolvedDbConfig), - resolveConnection: () => Effect.die("resolveConnection is not used by this test"), + : Effect.sync(() => { + resolveCalls.push(input); + return { + conn: { + host: "127.0.0.1", + port: 54322, + user: "postgres", + password: "x", + database: "postgres", + }, + isLocal: input.target.kind === "local", + ref: input.target.kind === "linked" ? Option.some(input.target.ref) : Option.none(), + target: input.target, + } satisfies ResolvedDbConnection; + }), resolvePoolerFallback: () => Effect.succeed(Option.none()), }); const connection = Layer.succeed(DbConnection, { connect: () => - Effect.succeed({ - exec: () => Effect.void, - execBatch: () => Effect.void, - query: (sql: string) => - Effect.suspend(() => - sql === SELECT_SQL - ? Effect.succeed((opts.rows ?? []).map((r) => ({ ...r }))) - : Effect.succeed([]), - ), - extensionExists: () => Effect.succeed(false), - copyToCsv: () => Effect.succeed(new Uint8Array()), - queryRaw: () => Effect.succeed({ fields: [], rows: [], commandTag: "" }), - }), + opts.connectFails === true + ? Effect.fail(new DbConnectError({ message: "connection refused" })) + : Effect.succeed({ + exec: () => Effect.void, + execBatch: () => Effect.void, + query: (sql: string) => + Effect.suspend(() => + sql === SELECT_SQL + ? Effect.succeed((opts.rows ?? []).map((r) => ({ ...r }))) + : Effect.succeed([]), + ), + extensionExists: () => Effect.succeed(false), + copyToCsv: () => Effect.succeed(new Uint8Array()), + queryRaw: () => Effect.succeed({ fields: [], rows: [], commandTag: "" }), + }), }); - // Gives an explicit --project-ref flag precedence over the VALID_REF fallback, so a - // test can prove the flag drives the linked ref. const projectRef = Layer.succeed(ProjectRefResolver, { resolve: () => Effect.succeed(VALID_REF), resolveForLink: () => Effect.succeed(VALID_REF), - resolveRef: () => Effect.succeed({ ref: VALID_REF, source: "file", matchesLinkedRef: true }), - resolveOptional: () => Effect.succeed(Option.some(VALID_REF)), - loadProjectRef: (flagValue: Option.Option<string>) => + resolveRef: (flagValue: Option.Option<string>) => Effect.succeed( - Option.isSome(flagValue) && flagValue.value.length > 0 ? flagValue.value : VALID_REF, + Option.isSome(flagValue) && flagValue.value.length > 0 + ? { ref: flagValue.value, source: "flag", matchesLinkedRef: false } + : { ref: VALID_REF, source: "file", matchesLinkedRef: true }, ), + resolveOptional: () => Effect.succeed(Option.some(VALID_REF)), + loadProjectRef: () => Effect.die("loadProjectRef is not used by migration fetch"), promptProjectRef: () => Effect.succeed(VALID_REF), }); const http = recordingProjectHttpClient(VALID_REF); const layer = Layer.mergeAll( out.layer, + configValuesLayer({ output: out.layer }), telemetry.layer, opts.realLayers === true ? realLinkedProjectCache({ @@ -150,7 +167,9 @@ function setup(workdir: string, opts: SetupOpts = {}) { mockCommandSettings({ workdir }), Layer.succeed(DnsResolverFlag, "native"), Layer.succeed(YesFlag, opts.yes ?? false), - Layer.succeed(CliArgs, { args: opts.cliArgs ?? [] }), + Layer.succeed(CliArgs, { args: ["migration", "fetch"] }), + Layer.succeed(OutputFlag, Option.none()), + Layer.succeed(TargetPolicy, { group: "sync", consent: "none" }), mockTty({ stdinIsTty: opts.isTTY ?? true }), mockStdin( opts.isTTY ?? true, @@ -159,12 +178,12 @@ function setup(workdir: string, opts: SetupOpts = {}) { ), BunServices.layer, ); - return { layer, http, out, telemetry, cache }; + return { layer, http, out, telemetry, cache, resolveCalls }; } const flags = (over: Partial<MigrationFetchFlags> = {}): MigrationFetchFlags => ({ dbUrl: over.dbUrl ?? Option.none(), - linked: over.linked ?? true, + linked: over.linked ?? false, local: over.local ?? false, projectRef: over.projectRef ?? Option.none(), }); @@ -212,7 +231,7 @@ const tmp = useTempWorkdir(); describe("migration fetch", () => { it.effect("writes migration files joined with the separator when the dir is empty", () => { - const { layer, out } = setup(tmp.current, { + const { layer } = setup(tmp.current, { rows: [ { version: "20240101000000", @@ -223,7 +242,6 @@ describe("migration fetch", () => { }); return Effect.gen(function* () { yield* migrationFetch(flags()); - expect(out.stderrText).toContain("Connecting to remote database..."); expect(yield* listMigrations(tmp.current)).toEqual(["20240101000000_init.sql"]); expect(yield* readMigration(tmp.current, "20240101000000_init.sql")).toBe( "create table a;\ncreate index b;\n", @@ -283,16 +301,11 @@ describe("migration fetch", () => { }).pipe(Effect.provide(layer)); }); - it.effect.each( - ["u", "yess", "nope", " "].flatMap((answer) => [ - { answer, isTTY: false }, - { answer, isTTY: true }, - ]), - )( - "cancels the overwrite on the unrecognised answer $answer (isTTY $isTTY)", - ({ answer, isTTY }) => { + it.effect.each(["u", "yess", "nope", " "])( + "cancels the overwrite on the unrecognised piped answer %j", + (answer) => { const { layer } = setup(tmp.current, { - isTTY, + isTTY: false, pipedInput: `${answer}\n`, rows: [{ version: "20240101000000", name: "init", statements: ["create table a"] }], }); @@ -351,6 +364,7 @@ describe("migration fetch", () => { // not silently auto-accept. const { layer, out } = setup(tmp.current, { format: "json", + isTTY: false, pipedInput: "y\n", rows: [{ version: "20240101000000", name: "init", statements: ["create table a"] }], }); @@ -371,6 +385,7 @@ describe("migration fetch", () => { it.effect("honors a piped no in json mode (cancels the overwrite, no auto-accept)", () => { const { layer } = setup(tmp.current, { format: "json", + isTTY: false, pipedInput: "n\n", rows: [{ version: "20240101000000", name: "init", statements: ["create table a"] }], }); @@ -419,10 +434,7 @@ describe("migration fetch", () => { }).pipe(Effect.provide(layer)); }); - it.effect("reports a write failure", () => { - // A file at .../migrations makes makeDirectory fail; supabase itself must stay a - // real directory, since the handler's project-env load reads supabase/.env* before - // this mkdir and would hit ENOTDIR first otherwise. + it.effect("reports a failure when the migrations path is not a directory", () => { const { layer } = setup(tmp.current, { rows: [] }); return Effect.gen(function* () { yield* writeProjectFile(tmp.current, "migrations", "not a directory"); @@ -441,29 +453,72 @@ describe("migration fetch", () => { }).pipe(Effect.provide(layer)); }); - it.effect( - "rejects --db-url combined with --linked before reading the project .env (CLI-1878)", - () => { - // A flag conflict must surface even when supabase/.env is malformed, which would - // otherwise abort with a different error (DbConfigLoadError) if the env load ran first. - const { layer } = setup(tmp.current, { - cliArgs: ["--db-url", "postgresql://x", "--linked"], - }); - return Effect.gen(function* () { - yield* writeProjectFile(tmp.current, ".env", "!=broken\n"); - const error = yield* migrationFetch(flags({ dbUrl: Option.some("postgresql://x") })).pipe( - Effect.flip, - ); - expect(error).toBeInstanceOf(MigrationTargetFlagsError); - }).pipe(Effect.provide(layer)); - }, - ); + it.effect("leaves no migrations directory behind when the connection fails", () => { + const { layer } = setup(tmp.current, { connectFails: true }); + return Effect.gen(function* () { + const error = yield* migrationFetch(flags()).pipe(Effect.flip); + expect(error).toBeInstanceOf(DbConnectError); + expect(yield* migrationsDirExists(tmp.current)).toBe(false); + }).pipe(Effect.provide(layer)); + }); + + it.effect("targets the linked project by default and caches its ref", () => { + const { layer, resolveCalls, cache } = setup(tmp.current, { rows: [] }); + return Effect.gen(function* () { + yield* migrationFetch(flags()); + expect(resolveCalls).toHaveLength(1); + expect(resolveCalls[0]?.target).toMatchObject({ kind: "linked", ref: VALID_REF }); + expect(Option.isNone(resolveCalls[0]?.networkBanRemoval ?? Option.none())).toBe(true); + expect(cache.cachedRef).toBe(VALID_REF); + }).pipe(Effect.provide(layer)); + }); + + it.effect("hands network-ban removal to the connection only under --yes", () => { + const { layer, resolveCalls } = setup(tmp.current, { yes: true, rows: [] }); + return Effect.gen(function* () { + yield* migrationFetch(flags()); + expect(Option.isSome(resolveCalls[0]?.networkBanRemoval ?? Option.none())).toBe(true); + }).pipe(Effect.provide(layer)); + }); + + it.effect("fetches from the local database with --local without caching a project", () => { + const { layer, resolveCalls, cache } = setup(tmp.current, { + rows: [{ version: "20240101000000", name: "init", statements: ["create table a"] }], + }); + return Effect.gen(function* () { + yield* migrationFetch(flags({ local: true })); + expect(resolveCalls[0]?.target).toEqual({ kind: "local" }); + expect(cache.cached).toBe(false); + expect(yield* listMigrations(tmp.current)).toEqual(["20240101000000_init.sql"]); + }).pipe(Effect.provide(layer)); + }); + + it.effect("fetches from the database given via --db-url", () => { + const { layer, resolveCalls, cache } = setup(tmp.current, { rows: [] }); + return Effect.gen(function* () { + yield* migrationFetch( + flags({ dbUrl: Option.some("postgresql://u:p@db.example.com/postgres") }), + ); + expect(resolveCalls[0]?.target.kind).toBe("db-url"); + expect(cache.cached).toBe(false); + }).pipe(Effect.provide(layer)); + }); + + it.effect("rejects --db-url combined with --linked before reading the project .env", () => { + // A flag conflict must surface even when supabase/.env is malformed. + const { layer } = setup(tmp.current); + return Effect.gen(function* () { + yield* writeProjectFile(tmp.current, ".env", "!=broken\n"); + const error = yield* migrationFetch( + flags({ dbUrl: Option.some("postgresql://x"), linked: true }), + ).pipe(Effect.flip); + expect(error).toBeInstanceOf(TargetSelectorConflictError); + }).pipe(Effect.provide(layer)); + }); it.effect( "fetches from 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. const FLAG_REF = "flagflagflagflagflag"; const { layer, cache } = setup(tmp.current, { rows: [] }); return Effect.gen(function* () { @@ -475,17 +530,14 @@ describe("migration fetch", () => { }, ); - it.effect("rejects --project-ref combined with an explicit --local target", () => { + it.effect("rejects --project-ref combined with --local", () => { const FLAG_REF = "flagflagflagflagflag"; - const { layer, out, cache } = setup(tmp.current, { cliArgs: ["--local"] }); + const { layer, out, cache } = setup(tmp.current); return Effect.gen(function* () { const error = yield* migrationFetch( - flags({ linked: false, local: true, projectRef: Option.some(FLAG_REF) }), + flags({ local: true, projectRef: Option.some(FLAG_REF) }), ).pipe(Effect.flip); - expect(error).toBeInstanceOf(MigrationTargetFlagsError); - expect(error.message).toBe( - "--project-ref only applies when targeting the linked project; use it with --linked (not --local or --db-url)", - ); + expect(error).toBeInstanceOf(TargetProjectRefConflictError); expect(yield* migrationsDirExists(tmp.current)).toBe(false); expect(out.promptConfirmCalls.length).toBe(0); expect(cache.cached).toBe(false); @@ -494,7 +546,6 @@ describe("migration fetch", () => { it.effect("mints a login role and fills the cache through the real resolver and cache", () => { const ctx = setup(tmp.current, { - cliArgs: ["migration", "fetch", "--project-ref", VALID_REF], realLayers: true, rows: [{ version: "20240101000000", name: "init", statements: ["create table a"] }], }); @@ -527,14 +578,13 @@ describe("migration fetch", () => { it.effect("makes no Management API request for --local through the real resolver", () => { const ctx = setup(tmp.current, { - cliArgs: ["migration", "fetch", "--local"], realLayers: true, rows: [], }); const requests: Array<StubbedRequest> = []; return Effect.gen(function* () { yield* stubManagementApi(requests, () => undefined); - yield* migrationFetch(flags({ linked: false, local: true })); + yield* migrationFetch(flags({ local: true })); expect(requests).toEqual([]); expect(ctx.http.calls).toEqual([]); assertPermissionDrift({ From 48eb1918d243d2a345ed58c61ffa6502eb19ec74 Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Thu, 15 Oct 2026 14:33:27 +0100 Subject: [PATCH 37/92] feat(cli)!: make migration squash target-aware with hosted consent, update docs --- apps/cli/docs/supabase/migration/list.md | 2 +- apps/cli/docs/supabase/migration/repair.md | 14 +- apps/cli/docs/supabase/migration/squash.md | 2 + .../commands/migration/down/SIDE_EFFECTS.md | 61 +- .../commands/migration/list/SIDE_EFFECTS.md | 53 +- .../migration/list/list.integration.test.ts | 34 +- .../commands/migration/list/list.live.test.ts | 2 +- .../commands/migration/repair/SIDE_EFFECTS.md | 72 ++- .../migration/repair/repair.handler.ts | 9 +- .../repair/repair.integration.test.ts | 46 +- .../migration/repair/repair.live.test.ts | 3 + .../commands/migration/squash/SIDE_EFFECTS.md | 85 +-- .../migration/squash/squash.command.ts | 21 +- .../migration/squash/squash.handler.ts | 182 ++---- .../squash/squash.integration.test.ts | 552 ++++++++---------- .../squash/squash.native.integration.test.ts | 14 +- .../src/commands/migration/up/SIDE_EFFECTS.md | 59 +- .../src/commands/migration/up/up.handler.ts | 4 +- .../migration/up/up.integration.test.ts | 28 +- .../src/commands/migration/up/up.live.test.ts | 3 +- 20 files changed, 595 insertions(+), 651 deletions(-) diff --git a/apps/cli/docs/supabase/migration/list.md b/apps/cli/docs/supabase/migration/list.md index 343bbd4322..a3cc54a303 100644 --- a/apps/cli/docs/supabase/migration/list.md +++ b/apps/cli/docs/supabase/migration/list.md @@ -2,7 +2,7 @@ Lists migration history in both local and remote databases. -Requires your local project to be linked to a remote database by running `supabase link`. For self-hosted databases, you can pass in the connection parameters using `--db-url` flag. +By default, it lists the migration history of your local database. Pass `--linked` (or `--project-ref`) to list the linked project instead, or set `target = "remote"` in your project config (or `SUPABASE_TARGET=remote`) to make the linked project the default. Linking requires running `supabase link`. For self-hosted databases, you can pass in the connection parameters using `--db-url` flag. > Note that URL strings must be escaped according to [RFC 3986](https://www.rfc-editor.org/rfc/rfc3986). diff --git a/apps/cli/docs/supabase/migration/repair.md b/apps/cli/docs/supabase/migration/repair.md index 981bb9cb8e..80fcd53b7f 100644 --- a/apps/cli/docs/supabase/migration/repair.md +++ b/apps/cli/docs/supabase/migration/repair.md @@ -1,8 +1,8 @@ ## supabase-migration-repair -Repairs the remote migration history table. +Repairs the migration history table. -Requires your local project to be linked to a remote database by running `supabase link`. +By default, it repairs your local database. Pass `--linked` (or `--project-ref`) to repair the linked project, which requires running `supabase link`, or `--db-url` to repair a specific database. Repairing a hosted database asks for confirmation before it changes anything (the default answer is No); pass `--yes` to confirm in scripts and other non-interactive runs. If your local and remote migration history goes out of sync, you can repair the remote history by marking specific migrations as `--status applied` or `--status reverted`. Marking as `reverted` will delete an existing record from the migration history table while marking as `applied` will insert a new record. @@ -30,9 +30,10 @@ $ supabase migration list Then mark the remote migration `20230103054303` as reverted. ```bash -$ supabase migration repair 20230103054303 --status reverted -Connecting to remote database... -Repaired migration history: [20220810154537] => reverted +$ supabase migration repair 20230103054303 --status reverted --linked +Target: project My Project (abcdefghijklmnopqrst) · production +Mark 1 migration as reverted on project My Project (abcdefghijklmnopqrst) · production? [y/N] +Repaired migration history: [20230103054303] => reverted Finished supabase migration repair. $ supabase migration list @@ -44,10 +45,7 @@ Now you can run `db pull` again to dump the remote schema as a local migration f ```bash $ supabase db pull -Connecting to remote database... Schema written to supabase/migrations/20240414044403_remote_schema.sql -Update remote migration history table? [Y/n] -Repaired migration history: [20240414044403] => applied Finished supabase db pull. $ supabase migration list diff --git a/apps/cli/docs/supabase/migration/squash.md b/apps/cli/docs/supabase/migration/squash.md index 150d510e5b..ff0643b986 100644 --- a/apps/cli/docs/supabase/migration/squash.md +++ b/apps/cli/docs/supabase/migration/squash.md @@ -9,3 +9,5 @@ However, one limitation is that data manipulation statements, such as insert, up By default, the latest `<timestamp>_<name>.sql` file will be updated to contain the squashed migration. You can override the target version using the `--version <timestamp>` flag. If your `supabase/migrations` directory is empty, running `supabase squash` will do nothing. + +Squashing rewrites your local migration files. When the target is a hosted database (`--linked`, `--project-ref`, or a non-local `--db-url`), it also resets the remote migration history table to the squashed migration. That asks for confirmation before any file is changed (the default answer is No); pass `--yes` to confirm in scripts and other non-interactive runs. With the default local target it only squashes the files and suggests running `supabase migration repair --status applied` for the remote history. diff --git a/apps/cli/src/commands/migration/down/SIDE_EFFECTS.md b/apps/cli/src/commands/migration/down/SIDE_EFFECTS.md index b9a89f0d10..47d9629a29 100644 --- a/apps/cli/src/commands/migration/down/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/migration/down/SIDE_EFFECTS.md @@ -2,11 +2,12 @@ ## Files Read -| Path | Format | When | -| -------------------------------------- | ---------- | ----------------------------------------------------------------------------------------------- | -| `<workdir>/supabase/migrations/` | directory | always, to read migration files | -| `~/.supabase/access-token` | plain text | when `SUPABASE_ACCESS_TOKEN` unset and `--linked` | -| `<workdir>/supabase/.temp/project-ref` | plain text | `--linked`, to resolve the ref — skipped when `--project-ref` (or `SUPABASE_PROJECT_ID`) is set | +| Path | Format | When | +| -------------------------------------- | ---------- | -------------------------------------------------------------------------------------------------- | +| `<workdir>/supabase/migrations/` | directory | always, to read migration files | +| `<workdir>/supabase/config.toml` | TOML | always, for the workspace `target` (flag > `SUPABASE_TARGET` > project `.env*` > config) | +| `~/.supabase/access-token` | plain text | when `SUPABASE_ACCESS_TOKEN` unset and the target is linked | +| `<workdir>/supabase/.temp/project-ref` | plain text | linked target, to resolve the ref — skipped when `--project-ref` (or `SUPABASE_PROJECT_ID`) is set | ## Files Written @@ -22,25 +23,29 @@ ## Environment Variables -| Variable | Purpose | Required? | -| ------------------------ | --------------------------------------------------------------------------------- | ------------------------------------------------------- | -| `SUPABASE_ACCESS_TOKEN` | auth token for `--linked` mode | no (falls back to keyring → `~/.supabase/access-token`) | -| `DOTENV_PRIVATE_KEY[_*]` | dotenvx private key(s) to decrypt `encrypted:` `[db.vault]` secrets before upsert | no (required only if a `[db.vault]` value is encrypted) | +| Variable | Purpose | Required? | +| ------------------------ | ------------------------------------------------------------------------------------ | ------------------------------------------------------- | +| `SUPABASE_ACCESS_TOKEN` | auth token for a linked target | no (falls back to keyring → `~/.supabase/access-token`) | +| `SUPABASE_TARGET` | workspace target (`local` or `remote`) when no selector flag is given | no | +| `SUPABASE_YES` | confirms a hosted run when exported in the shell (the project `.env` does not count) | no | +| `DOTENV_PRIVATE_KEY[_*]` | dotenvx private key(s) to decrypt `encrypted:` `[db.vault]` secrets before upsert | no (required only if a `[db.vault]` value is encrypted) | ## Exit Codes -| Code | Condition | -| ---- | ------------------------------------------------------------------------ | -| `0` | success | -| `1` | database connection failure | -| `1` | migration SQL execution error | -| `1` | `--project-ref` set with a resolved target other than linked (see Notes) | +| Code | Condition | +| ---- | ------------------------------------------------------------------------------------------------ | +| `0` | success | +| `1` | database connection failure | +| `1` | migration SQL execution error | +| `1` | conflicting target selectors (see Notes) | +| `1` | hosted target without `--yes` in a run that cannot prompt (`HostedMutationConsentRequiredError`) | +| `1` | hosted confirmation declined (`context canceled`) | ## Output ### `--output-format text` -Prints `Resetting database to version: <version>` to stderr, then drops every +Prints `Target: …` to stderr, then `Resetting database to version: <version>`, then drops every user schema/object (the embedded `dropObjectsSql` DO-block), upserts `[db.vault]` secrets, and re-applies local migrations `<= version` plus seed files (each gated on `db.migrations.enabled` / `db.seed.enabled`). Nothing is written to stdout. @@ -55,22 +60,24 @@ Same structured result delivered as an NDJSON `result` event. ## Prompts -- Prompts `Do you want to revert the following migrations?` with the bulleted - versions + a yellow `WARNING:` line (default **NO**). Declining exits non-zero - (`context canceled`). `--yes` auto-confirms; a non-interactive / machine-output - run takes the default (NO → cancel). +- A hosted target (the linked project or a non-local `--db-url`) asks once, after the migrations + to revert are known and before the first drop or write: it lists the bulleted versions and a + yellow `WARNING:` line, then asks `Reset the database to version <version> on <target>?` + (default **NO**). Only `--yes` or an exported `SUPABASE_YES` approves a run that cannot prompt; + a piped answer and a `SUPABASE_YES` in the project `.env` do not. Declining exits 1 + (`context canceled`). +- A local target never prompts. ## Notes +- **Target.** With no selector the command follows the workspace target: the local database, or the + linked project when `target = "remote"` is set in `supabase/config.toml` (or `SUPABASE_TARGET=remote` + is exported). `--local`, `--linked` and `--db-url` are mutually exclusive and `--linked=false` + selects nothing. `--project-ref` implies `--linked` and is rejected with `--local` or `--db-url`; + an empty `--db-url` or `--project-ref` is rejected. The resolved target is announced on stderr + (`Target: …`) in every output format and, in JSON results, as a `target` object. - `--last` (default 1) resets up to the last n migration versions; must be `> 0` and `<` the number of applied migrations. -- `--local` (default true), `--linked`, and `--db-url` 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 - a non-linked target). - Takes no positional arguments. - Performs no best-effort migrations-catalog cache optimization. - Dotenvx-encrypted (`encrypted:`) `[db.vault]` values are decrypted during config diff --git a/apps/cli/src/commands/migration/list/SIDE_EFFECTS.md b/apps/cli/src/commands/migration/list/SIDE_EFFECTS.md index 1513e71b04..330aa5ae0a 100644 --- a/apps/cli/src/commands/migration/list/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/migration/list/SIDE_EFFECTS.md @@ -2,11 +2,12 @@ ## Files Read -| Path | Format | When | -| -------------------------------------- | ---------- | --------------------------------------------------------------------------------------------------------- | -| `~/.supabase/access-token` | plain text | when `SUPABASE_ACCESS_TOKEN` unset and `--linked` | -| `<workdir>/supabase/.temp/project-ref` | plain text | `--linked` (default), to resolve the ref — skipped when `--project-ref` (or `SUPABASE_PROJECT_ID`) is set | -| `<workdir>/supabase/migrations/` | directory | always, to list local migration files | +| Path | Format | When | +| -------------------------------------- | ---------- | -------------------------------------------------------------------------------------------------- | +| `<workdir>/supabase/config.toml` | TOML | always, for the workspace `target` (flag > `SUPABASE_TARGET` > project `.env*` > config) | +| `~/.supabase/access-token` | plain text | when `SUPABASE_ACCESS_TOKEN` unset and the target is linked | +| `<workdir>/supabase/.temp/project-ref` | plain text | linked target, to resolve the ref — skipped when `--project-ref` (or `SUPABASE_PROJECT_ID`) is set | +| `<workdir>/supabase/migrations/` | directory | always, to list local migration files | ## Files Written @@ -24,24 +25,25 @@ | Variable | Purpose | Required? | | ----------------------- | -------------------------------------------------------------------------------- | ------------------------------------------------------- | -| `SUPABASE_ACCESS_TOKEN` | auth token for `--linked` mode | no (falls back to keyring → `~/.supabase/access-token`) | +| `SUPABASE_ACCESS_TOKEN` | auth token for a linked target | no (falls back to keyring → `~/.supabase/access-token`) | | `SUPABASE_DB_PASSWORD` | password for the linked database connection (`--password`/`-p` takes precedence) | no | +| `SUPABASE_TARGET` | workspace target (`local` or `remote`) when no selector flag is given | no | ## Exit Codes -| Code | Condition | -| ---- | ------------------------------------------------------------------------ | -| `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) | +| Code | Condition | +| ---- | -------------------------------------------- | +| `0` | success | +| `1` | database connection failure | +| `1` | `--password` with a target other than linked | +| `1` | failed to open migrations directory | +| `1` | conflicting target selectors (see Notes) | ## Output ### `--output-format text` -Prints an ASCII table `|Local|Remote|Time (UTC)|` to stdout (cells are backtick-wrapped inline code). Queries `SELECT version FROM +Prints `Target: …` to stderr first, then an ASCII table `|Local|Remote|Time (UTC)|` to stdout (cells are backtick-wrapped inline code). Queries `SELECT version FROM supabase_migrations.schema_migrations ORDER BY version` (a missing table → empty Remote column). @@ -56,18 +58,17 @@ Same structured `migrations` result delivered as an NDJSON `result` event. ## Notes -- `--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`** 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 +- **Target.** With no selector the command follows the workspace target: the local database, or the + linked project when `target = "remote"` is set in `supabase/config.toml` (or `SUPABASE_TARGET=remote` + is exported). `--local`, `--linked` and `--db-url` are mutually exclusive and `--linked=false` + selects nothing. `--project-ref` implies `--linked` and is rejected with `--local` or `--db-url`; + an empty `--db-url` or `--project-ref` is rejected. The resolved target is announced on stderr + (`Target: …`) in every output format and, in JSON results, as a `target` object. +- Listing is a read: it never asks for confirmation, on any target. +- **`--password`** is rejected unless the target is the linked project: with `--db-url` the error is + `--password can't be used with --db-url. Put the password in the connection string: postgres://USER:PASSWORD@HOST:PORT/postgres`, + and with a local target `--password only applies to a linked project. The local database uses [db].password from supabase/config.toml.` + (exit 1). For the linked project 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 <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>.`), and a temporary login role is minted instead (ADR 0032). -- `--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 unused on - a non-linked target). 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 e79ce51773..f264783c29 100644 --- a/apps/cli/src/commands/migration/list/list.integration.test.ts +++ b/apps/cli/src/commands/migration/list/list.integration.test.ts @@ -46,6 +46,7 @@ import { DbExecError } from "../../../command-internal/db-connection.errors.ts"; import { DbConnection } from "../../../command-internal/db-connection.service.ts"; import { TargetPolicy } from "../../../command-internal/target/target-policy.ts"; import { + TargetPasswordConflictError, TargetProjectRefConflictError, TargetSelectorConflictError, } from "../../../command-internal/target/target.errors.ts"; @@ -206,16 +207,19 @@ describe("migration list", () => { }).pipe(Effect.provide(ctx.layer)); }); - it.effect("lists the linked project and fills the cache when the workspace target is remote", () => { - const ctx = setup(tmp.current, { remote: ["20240101000000"] }); - return Effect.gen(function* () { - yield* seedMigrations(tmp.current, ["20240101000000_a.sql"]); - yield* writeConfig(tmp.current, 'target = "remote"\n'); - yield* migrationList(flags()); - expect(ctx.resolverCalls[0]?.target).toMatchObject({ kind: "linked", ref: VALID_REF }); - expect(ctx.cache.cachedRef).toBe(VALID_REF); - }).pipe(Effect.provide(ctx.layer)); - }); + it.effect( + "lists the linked project and fills the cache when the workspace target is remote", + () => { + const ctx = setup(tmp.current, { remote: ["20240101000000"] }); + return Effect.gen(function* () { + yield* seedMigrations(tmp.current, ["20240101000000_a.sql"]); + yield* writeConfig(tmp.current, 'target = "remote"\n'); + yield* migrationList(flags()); + expect(ctx.resolverCalls[0]?.target).toMatchObject({ kind: "linked", ref: VALID_REF }); + expect(ctx.cache.cachedRef).toBe(VALID_REF); + }).pipe(Effect.provide(ctx.layer)); + }, + ); it.effect("shows an empty Remote column when the history table is absent (42P01)", () => { const { layer, out } = setup(tmp.current, { @@ -386,6 +390,16 @@ describe("migration list", () => { ); }); + it.effect("rejects --password combined with --db-url through the real resolver", () => { + const ctx = setup(tmp.current, { realLayers: true }); + return Effect.gen(function* () { + const error = yield* migrationList( + flags({ dbUrl: Option.some("postgresql://x"), password: Option.some("pw") }), + ).pipe(Effect.flip); + expect(error).toBeInstanceOf(TargetPasswordConflictError); + }).pipe(Effect.scoped, Effect.provide(ctx.layer)); + }); + it.effect("fetches the pooler config but mints no login role when --password is given", () => { const ctx = setup(tmp.current, { realLayers: true, diff --git a/apps/cli/src/commands/migration/list/list.live.test.ts b/apps/cli/src/commands/migration/list/list.live.test.ts index 3d32733ce3..858e97ce64 100644 --- a/apps/cli/src/commands/migration/list/list.live.test.ts +++ b/apps/cli/src/commands/migration/list/list.live.test.ts @@ -27,7 +27,7 @@ test("lists a seeded remote migration", ({ cli, cliEffect, project, signal }) => "select 1;\n", ); const seeded = yield* cliEffect( - ["migration", "repair", version, "--status", "applied", ...targetArgs], + ["migration", "repair", version, "--status", "applied", "--yes", ...targetArgs], { cwd: seedDir }, ); requireLiveSuccess(seeded, "migration repair setup"); diff --git a/apps/cli/src/commands/migration/repair/SIDE_EFFECTS.md b/apps/cli/src/commands/migration/repair/SIDE_EFFECTS.md index a0e696a248..5d6f80678d 100644 --- a/apps/cli/src/commands/migration/repair/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/migration/repair/SIDE_EFFECTS.md @@ -2,10 +2,12 @@ ## Files Read -| Path | Format | When | -| -------------------------------------- | ---------- | --------------------------------------------------------------------------------------------------------- | -| `~/.supabase/access-token` | plain text | when `SUPABASE_ACCESS_TOKEN` unset and `--linked` | -| `<workdir>/supabase/.temp/project-ref` | plain text | `--linked` (default), to resolve the ref — skipped when `--project-ref` (or `SUPABASE_PROJECT_ID`) is set | +| Path | Format | When | +| -------------------------------------- | ---------- | ---------------------------------------------------------------------------------------------------------- | +| `<workdir>/supabase/config.toml` | TOML | always, for the workspace `target` (flag > `SUPABASE_TARGET` > project `.env*` > config) | +| `~/.supabase/access-token` | plain text | when `SUPABASE_ACCESS_TOKEN` unset and the target is linked | +| `<workdir>/supabase/.temp/project-ref` | plain text | linked target, to resolve the ref — skipped when `--project-ref` (or `SUPABASE_PROJECT_ID`) is set | +| `<workdir>/supabase/migrations/` | directory | `--status applied` (the matching file per version) and repair-all (every local version), before connecting | ## Files Written @@ -21,26 +23,31 @@ ## Environment Variables -| 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 | +| Variable | Purpose | Required? | +| ----------------------- | --------------------------------------------------------------------------------------- | ------------------------------------------------------- | +| `SUPABASE_ACCESS_TOKEN` | auth token for a linked target | no (falls back to keyring → `~/.supabase/access-token`) | +| `SUPABASE_DB_PASSWORD` | password for the linked database connection (`--password`/`-p` takes precedence) | no | +| `SUPABASE_TARGET` | workspace target (`local` or `remote`) when no selector flag is given | no | +| `SUPABASE_YES` | confirms a hosted repair when exported in the shell (the project `.env` does not count) | no | ## Exit Codes -| Code | Condition | -| ---- | ------------------------------------------------------------------------ | -| `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) | +| Code | Condition | +| ---- | ------------------------------------------------------------------------------------------------ | +| `0` | success | +| `1` | database connection failure | +| `1` | `--password` with a target other than linked | +| `1` | invalid or missing `--status` flag | +| `1` | conflicting target selectors (see Notes) | +| `1` | a version's local migration file is missing (`--status applied`) | +| `1` | hosted target without `--yes` in a run that cannot prompt (`HostedMutationConsentRequiredError`) | +| `1` | hosted confirmation declined (`context canceled`) | ## Output ### `--output-format text` -When repairing specific versions, prints `Repaired migration history: [<versions>] +Prints `Target: …` to stderr first. When repairing specific versions, prints `Repaired migration history: [<versions>] => <status>` to stderr, then `Finished supabase migration repair.` to stdout and the suggestion `Run supabase migration list to show the updated migration history.` to stderr. The DB work is the history-table provisioning (its own transaction, @@ -64,28 +71,33 @@ Same structured result delivered as an NDJSON `result` event. ## Prompts -- With no version arguments (repair-all), prompts `Do you want to repair the entire -migration history table to match local migration files?` (default **NO**). - Declining exits non-zero (`context canceled`). `--yes` auto-confirms; a - non-interactive / machine-output run takes the default (NO → cancel). +- A hosted target (the linked project or a non-local `--db-url`) asks once, after the versions and + local files are resolved and before the history table is created or changed. The plan lists + each version (`Mark <n> migrations as <status>`), or for repair-all the table replacement or + clearing. The default is **NO**. Only `--yes` or an exported `SUPABASE_YES` approves a run that + cannot prompt; a piped answer and a `SUPABASE_YES` in the project `.env` do not. Declining exits + 1 (`context canceled`) and changes nothing. +- A local target never prompts, including repair-all. ## Notes +- **Target.** With no selector the command follows the workspace target: the local database, or the + linked project when `target = "remote"` is set in `supabase/config.toml` (or `SUPABASE_TARGET=remote` + is exported). `--local`, `--linked` and `--db-url` are mutually exclusive and `--linked=false` + selects nothing. `--project-ref` implies `--linked` and is rejected with `--local` or `--db-url`; + an empty `--db-url` or `--project-ref` is rejected. The resolved target is announced on stderr + (`Target: …`) in every output format and, in JSON results, as a `target` object. - `--status` flag is required and accepts `applied` or `reverted`. - Accepts zero or more migration version arguments; each must be numeric (`failed to parse <v>: invalid version number` otherwise). Zero versions enables repair-all. - In `applied` mode, reads the matching `supabase/migrations/<version>_*.sql` file - for the name + statements; a missing file exits non-zero. -- `--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 + for the name + statements before connecting; a missing file exits non-zero without touching the + database. +- **`--password`** is rejected unless the target is the linked project: with `--db-url` the error is + `--password can't be used with --db-url. Put the password in the connection string: postgres://USER:PASSWORD@HOST:PORT/postgres`, + and with a local target `--password only applies to a linked project. The local database uses [db].password from supabase/config.toml.` + (exit 1). For the linked project 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 <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>.`), and a temporary login role is minted instead (ADR 0032). -- **`--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 unused on - a non-linked target). diff --git a/apps/cli/src/commands/migration/repair/repair.handler.ts b/apps/cli/src/commands/migration/repair/repair.handler.ts index 1bb38cc2a3..8aa2c69082 100644 --- a/apps/cli/src/commands/migration/repair/repair.handler.ts +++ b/apps/cli/src/commands/migration/repair/repair.handler.ts @@ -25,10 +25,7 @@ import { parseMigrationVersion } from "../../../command-internal/migration-times import { resolveTarget } from "../../../command-internal/target/resolve-target.ts"; import { LinkedProjectCache } from "../../../telemetry/linked-project-cache.service.ts"; import { TelemetryState } from "../../../telemetry/telemetry-state.service.ts"; -import { - MigrationFileNotFoundError, - MigrationInvalidVersionError, -} from "../migration.errors.ts"; +import { MigrationFileNotFoundError, MigrationInvalidVersionError } from "../migration.errors.ts"; import { MigrationRepairUpdateError } from "./repair.errors.ts"; export interface MigrationRepairInput { @@ -142,9 +139,7 @@ const runRepair = Effect.fnUntraced(function* (input: MigrationRepairInput) { } } - const versions = repairAll - ? yield* loadLocalVersions(fs, path, migrationsDir) - : input.versions; + const versions = repairAll ? yield* loadLocalVersions(fs, path, migrationsDir) : input.versions; // Every file is read before the first write, so a missing file aborts with no DB mutation. const appliedFiles: Array<MigrationFile> = []; 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 90e524fc15..3c5c24c600 100644 --- a/apps/cli/src/commands/migration/repair/repair.integration.test.ts +++ b/apps/cli/src/commands/migration/repair/repair.integration.test.ts @@ -1,6 +1,6 @@ import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; -import { Effect, FileSystem, Layer, Option, Path } from "effect"; +import { Effect, FileSystem, Layer, Option } from "effect"; import { vi } from "vitest"; vi.mock("node:net", (importOriginal) => @@ -49,6 +49,7 @@ import { type DbSession, DbConnection } from "../../../command-internal/db-conne import { HostedMutationDeclinedError } from "../../../command-internal/hosted-consent/hosted-consent.errors.ts"; import { TargetPolicy } from "../../../command-internal/target/target-policy.ts"; import { + TargetPasswordConflictError, TargetProjectRefConflictError, TargetSelectorConflictError, } from "../../../command-internal/target/target.errors.ts"; @@ -278,16 +279,19 @@ describe("migration repair", () => { }).pipe(Effect.provide(layer)); }); - it.effect("repair-all truncates and reapplies local files on the local database without prompting", () => { - const { layer, execs, queries, out } = setup(tmp.current); - return Effect.gen(function* () { - yield* seedMigration(tmp.current, "20240101000000_init.sql", "create table a;\n"); - yield* migrationRepair(input({ versions: [], status: "applied" })); - expect(out.promptConfirmCalls).toEqual([]); - expect(execs).toContain("TRUNCATE supabase_migrations.schema_migrations"); - expect(queries.some((q) => q.sql.includes("ON CONFLICT"))).toBe(true); - }).pipe(Effect.provide(layer)); - }); + it.effect( + "repair-all truncates and reapplies local files on the local database without prompting", + () => { + const { layer, execs, queries, out } = setup(tmp.current); + return Effect.gen(function* () { + yield* seedMigration(tmp.current, "20240101000000_init.sql", "create table a;\n"); + yield* migrationRepair(input({ versions: [], status: "applied" })); + expect(out.promptConfirmCalls).toEqual([]); + expect(execs).toContain("TRUNCATE supabase_migrations.schema_migrations"); + expect(queries.some((q) => q.sql.includes("ON CONFLICT"))).toBe(true); + }).pipe(Effect.provide(layer)); + }, + ); it.effect( "repair-all with --status reverted wipes the whole history (no upserts, no deletes)", @@ -508,6 +512,23 @@ describe("migration repair", () => { ); }); + it.effect("rejects --password combined with --db-url through the real resolver", () => { + const ctx = setup(tmp.current, { realLayers: true }); + return Effect.gen(function* () { + yield* seedMigration(tmp.current, "20240101000000_init.sql", "create table a;\n"); + const error = yield* migrationRepair( + input({ + versions: ["20240101000000"], + status: "applied", + dbUrl: Option.some("postgresql://x"), + password: Option.some("pw"), + }), + ).pipe(Effect.flip); + expect(error).toBeInstanceOf(TargetPasswordConflictError); + expect(ctx.execs).toEqual([]); + }).pipe(Effect.scoped, Effect.provide(ctx.layer)); + }); + it.effect("fetches the pooler config but mints no login role when --password is given", () => { const ctx = setup(tmp.current, { realLayers: true, yes: true }); const requests: Array<StubbedRequest> = []; @@ -540,8 +561,7 @@ describe("migration repair", () => { }); describeHostedConsent("migration repair", { - setup: (workdir) => - seedMigration(workdir, "20240101000000_init.sql", "create table a;\n"), + setup: (workdir) => seedMigration(workdir, "20240101000000_init.sql", "create table a;\n"), invoke: () => { const db = fakeDatabase(); return { diff --git a/apps/cli/src/commands/migration/repair/repair.live.test.ts b/apps/cli/src/commands/migration/repair/repair.live.test.ts index 5ba9237af2..49e3c7ee80 100644 --- a/apps/cli/src/commands/migration/repair/repair.live.test.ts +++ b/apps/cli/src/commands/migration/repair/repair.live.test.ts @@ -39,6 +39,7 @@ test("amends the migration history status on the remote database", ({ version, "--status", "applied", + "--yes", "--db-url", project.dbUrl, ]); @@ -59,6 +60,7 @@ test("amends the migration history status on the remote database", ({ version, "--status", "reverted", + "--yes", "--db-url", project.dbUrl, ]); @@ -89,6 +91,7 @@ test("amends the migration history status on the remote database", ({ version, "--status", "reverted", + "--yes", "--db-url", project.dbUrl, ]); diff --git a/apps/cli/src/commands/migration/squash/SIDE_EFFECTS.md b/apps/cli/src/commands/migration/squash/SIDE_EFFECTS.md index 26e11243d9..649d9c0b3e 100644 --- a/apps/cli/src/commands/migration/squash/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/migration/squash/SIDE_EFFECTS.md @@ -4,8 +4,9 @@ Squashes every local migration up to (optionally) `--version` into the last one — diffing a natively-provisioned shadow database's `auth`/`storage` schemas before and after applying every migration, dumping the full schema into the target file, and deleting the merged files — then either -suggests `migration repair` (local target) or prompts to baseline the remote -migration-history table to match. +suggests `migration repair` (local target) or baselines the remote +migration-history table to match (hosted target, after one confirmation that comes before any +file is rewritten). When `[experimental].stack` is on, each shadow is a fresh database in an invocation-owned, unique temporary stack namespace. The command applies the catalog and project migrations as needed, @@ -28,8 +29,8 @@ and uses an automatically assigned port; `db.shadow_port` applies only to the le | `<workdir>/supabase/roles.sql` | SQL | shadow `SetupDatabase` (custom-roles seed); missing file tolerated | | managed snapshot key `<key>` | backend | warm stack-shadow baseline; managed retention may evict entries and snapshots survive stack destruction | | `<workdir>/supabase/.env`, `.env.local`, `SUPABASE_ENV`-selected dotenv | dotenv | always (`--yes`/registry/network-id overrides) | -| `<workdir>/supabase/.temp/{project-ref,postgres-version,pooler-url}` | plain text | `--linked` / linked path — skipped when `--project-ref` (or `SUPABASE_PROJECT_ID`) is set | -| `~/.supabase/access-token` | plain text | `--linked` without `--password`/`SUPABASE_ACCESS_TOKEN` | +| `<workdir>/supabase/.temp/{project-ref,postgres-version,pooler-url}` | plain text | linked target — skipped when `--project-ref` (or `SUPABASE_PROJECT_ID`) is set | +| `~/.supabase/access-token` | plain text | linked target without `--password`/`SUPABASE_ACCESS_TOKEN` | | `~/.docker/config.json` + Docker context store | JSON | resolving the Docker hostname for shadow/pg_dump containers | ## Files Written @@ -39,7 +40,7 @@ and uses an automatically assigned port; `db.shadow_port` applies only to the le | `<workdir>/supabase/migrations/<target>.sql` | SQL text | ≥2 migrations squash — **truncated** (0644) then rewritten as the full dump + separator + `auth`/`storage` line diff | | `<workdir>/supabase/migrations/<earlier>.sql` (×N) | — | **deleted** — every earlier merged migration; a per-file failure is non-fatal (printed, not raised) | | scoped temp dir | SQL | shadow's `initSchema`/`ApplyApiPrivileges` SQL (PG≤14) — removed when the scope closes | -| `<workdir>/supabase/.temp/linked-project.json` | JSON | `--linked` (post-run cache, even when the command itself fails) | +| `<workdir>/supabase/.temp/linked-project.json` | JSON | linked target (post-run cache, even when the command itself fails or the confirmation is declined) | | managed snapshot key `<key>` | backend | stack-mode cold shadow baseline; managed retention may evict entries and snapshots survive stack destruction | | `~/.supabase/telemetry.json` | JSON | every invocation (post-run) | @@ -74,30 +75,30 @@ live shadow available. ## API Routes -| Method | Path | Auth | Purpose | -| ----------- | ----------------------------------------------------------------------------- | ------ | ---------------------------------------------------------- | -| — | — | — | local target: none | -| POST | `/v1/projects/{ref}/cli/login-role` | Bearer | `--linked`: temp login role when no password | -| GET | `/v1/projects/{ref}/config/database/pooler` | Bearer | `--linked`: IPv4 pooler fallback (IPv6-only network) | -| POST/DELETE | `/v1/projects/{ref}/network-bans/retrieve`, `/v1/projects/{ref}/network-bans` | Bearer | `--linked`: unban during pooler login retry | -| GET | `/v1/projects/{ref}` | Bearer | `--linked`: linked-project cache (post-run, unconditional) | +| Method | Path | Auth | Purpose | +| ----------- | ----------------------------------------------------------------------------- | ------ | ------------------------------------------------------------------ | +| — | — | — | local target: none | +| POST | `/v1/projects/{ref}/cli/login-role` | Bearer | linked target: temp login role when no password | +| GET | `/v1/projects/{ref}/config/database/pooler` | Bearer | linked target: IPv4 pooler fallback (IPv6-only network) | +| POST/DELETE | `/v1/projects/{ref}/network-bans/retrieve`, `/v1/projects/{ref}/network-bans` | Bearer | linked target: unban during pooler login retry, only under `--yes` | +| GET | `/v1/projects/{ref}` | Bearer | linked target: linked-project cache (post-run, unconditional) | ## Environment Variables -`SUPABASE_YES`, `SUPABASE_DB_PASSWORD` (`--linked` only), `SUPABASE_ACCESS_TOKEN`, `SUPABASE_SERVICES_HOSTNAME`, +`SUPABASE_YES` (shell only; confirms a hosted run), `SUPABASE_TARGET`, `SUPABASE_DB_PASSWORD` (linked target 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). ## Exit Codes -| Code | Condition | -| ----- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `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 | +| Code | Condition | +| ----- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `0` | success — **including** the single-migration no-op | +| `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; conflicting target selectors (see Notes) | +| `1` | `--password` with a target other than linked | +| `1` | hosted target without `--yes` in a run that cannot prompt (`HostedMutationConsentRequiredError`); hosted confirmation declined (`context canceled`) — nothing is changed | +| `130` | SIGINT | ## Output @@ -106,7 +107,9 @@ live shadow available. stderr, in order (path-dependent): ``` -Loading config override: [remotes.<ref>] (only when --linked resolves a [remotes.<ref>] block) +Target: … (the resolved target; local and custom Postgres lines come from the connection) +Loading config override: [remotes.<ref>] (only when a linked target resolves a [remotes.<ref>] block) +Confirmed by --yes: Squash local migrations and update the remote migration history table on <target> (hosted target under --yes) Initialising schema... Seeding globals from roles.sql... (unconditional — printed even when roles.sql is absent) Applying migration <base>... (once per migration applied to the shadow) @@ -115,11 +118,11 @@ Applying migration <base>... (once per migration applied t Squashed local migrations to <bold path> <removal error> (per merged-file removal failure, non-fatal) Failed to remove container: <id> <err> (shadow cleanup failure, non-fatal) -Update remote migration history table? [Y/n] (remote target only) -Baselining migration history to <version> (remote target, prompt confirmed — BEFORE connecting) -Connecting to remote database... +Baselining migration history to <version> (hosted target, after the squash — BEFORE connecting) ``` +A hosted target asks (or is confirmed by `--yes`) right after the target, config override and migration list are resolved, listing the squash plan and the history reset — before the shadow database is created and before any migration file is rewritten or removed. + stdout: only `Finished supabase migration squash.` (aqua), printed inline by the handler itself (there is no shared group-level epilogue that prints it). Local target additionally prints `Run @@ -128,8 +131,8 @@ history table.` to stderr, after the stdout line. ### `--output-format json` / `stream-json` -Progress lines stay on stderr (including the confirmation prompt, regardless of -`--output`/`--output-format`); stdout carries +Progress lines stay on stderr; a hosted target needs `--yes`, since a machine-output run cannot +prompt. stdout carries `output.success("Migrations squashed", { squashedInto, removed, removeFailures, alreadyEarliest, isLocal, baselinedVersion })` instead of the `Finished …` line and (for the local target) the repair suggestion — both suppressed in machine @@ -143,17 +146,25 @@ code or the rest of the payload. ## Notes -- `--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 +- **Target.** With no selector the command follows the workspace target: the local database, or the + linked project when `target = "remote"` is set in `supabase/config.toml` (or `SUPABASE_TARGET=remote` + is exported). `--local`, `--linked` and `--db-url` are mutually exclusive and `--linked=false` + selects nothing. `--project-ref` implies `--linked` and is rejected with `--local` or `--db-url`; + an empty `--db-url` or `--project-ref` is rejected. The resolved target is announced on stderr + (`Target: …`) in every output format and, in JSON results, as a `target` object. The shadow database + is provisioned locally whatever the target; the target only decides whether the remote history + table is baselined. +- **Consent.** A hosted target (the linked project or a non-local `--db-url`) needs one confirmation + (default **NO**) because the local checkout rewrite precedes the remote history reset. Only `--yes` + or an exported `SUPABASE_YES` approves a run that cannot prompt; a piped answer and a `SUPABASE_YES` + in the project `.env` do not. A local target never prompts. +- **`--password`** is rejected unless the target is the linked project: with `--db-url` the error is + `--password can't be used with --db-url. Put the password in the connection string: postgres://USER:PASSWORD@HOST:PORT/postgres`, + and with a local target `--password only applies to a linked project. The local database uses [db].password from supabase/config.toml.` + (exit 1). For the linked project 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 <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>.`), and a temporary login role is minted instead (ADR 0032). -- **`--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 simply goes unused on a non-linked target). - The shadow gets `setupDatabase` only — **no** `CREATE DATABASE contrib_regression` (unlike `db diff`/`db pull`). - `--version` is compared **lexically** against zero-padded timestamps. @@ -162,10 +173,8 @@ code or the rest of the payload. baseline to target the surviving **older** version, not the original squash target. - A failed full-schema dump leaves the target migration truncated (not recoverable — the file was already truncated before the dump began). -- A declined "Update remote migration history table?" prompt (`n`, or any unrecognised - 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. +- A declined hosted confirmation exits 1 (`context canceled`) before the shadow database is created: + no migration file is rewritten or removed and the remote history is untouched. - **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 --git a/apps/cli/src/commands/migration/squash/squash.command.ts b/apps/cli/src/commands/migration/squash/squash.command.ts index 898642e0c0..4f76c720d9 100644 --- a/apps/cli/src/commands/migration/squash/squash.command.ts +++ b/apps/cli/src/commands/migration/squash/squash.command.ts @@ -16,6 +16,7 @@ import { withJsonErrorHandling } from "../../../shared/output/json-error-handlin import { withCommandTelemetry } from "../../../telemetry/command-telemetry.ts"; import { migrationSquashRuntimeLayer } from "../migration.layers.ts"; import { migrationSquash } from "./squash.handler.ts"; +import { targetFlags } from "../../../command-internal/target/target-flags.ts"; import { withTargetPolicy } from "../../../command-internal/target/target-policy.ts"; const config = { @@ -23,31 +24,13 @@ const config = { Flag.withDescription("Squash up to the specified version."), Flag.optional, ), - dbUrl: Flag.String("db-url").pipe( - Flag.withDescription( - "Squashes migrations of the database specified by the connection string (must be percent-encoded).", - ), - Flag.optional, - ), - linked: Flag.Boolean("linked").pipe( - Flag.withDescription("Squashes the migration history of the linked project."), - Flag.withDefault(false), - ), - local: Flag.Boolean("local").pipe( - Flag.withDescription("Squashes the migration history of the local database."), - Flag.withDefault(true), - ), + ...targetFlags({ group: "selectable", subject: "database", dbUrl: true }), 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."), - Flag.optional, - ), } as const; export type MigrationSquashFlags = CliCommand.Command.Config.Infer<typeof config>; diff --git a/apps/cli/src/commands/migration/squash/squash.handler.ts b/apps/cli/src/commands/migration/squash/squash.handler.ts index dc9017be75..e7a9dcdacf 100644 --- a/apps/cli/src/commands/migration/squash/squash.handler.ts +++ b/apps/cli/src/commands/migration/squash/squash.handler.ts @@ -1,19 +1,15 @@ -import { Effect, FileSystem, Option, Path, Ref } from "effect"; +import { Effect, FileSystem, Option, Path } from "effect"; import { ChildProcessSpawner } from "effect/process"; import type { ChildProcessSpawner as ChildProcessSpawnerType } from "effect/process/ChildProcessSpawner"; -import { mutuallyExclusiveFlagsMessage } from "../../../shared/cli/flag-groups.ts"; -import { CliArgs } from "../../../shared/cli/cli-args.service.ts"; import { DebugFlag, DnsResolverFlag, NetworkIdFlag, - resolveYesWithProjectEnv, } from "../../../command-internal/global-flags.ts"; 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 { aqua, bold } from "../../../command-internal/colors.ts"; import { buildLocalDbContainerInputs, @@ -32,17 +28,13 @@ import { shadowRunInputFromLocalContainerInputs, } from "../../../command-internal/db-bootstrap/shadow-database.ts"; import { DbConfigResolver } from "../../../command-internal/db-config.service.ts"; -import { - loadProjectEnvValues, - readDbToml, - type DbTomlValues, -} from "../../../command-internal/db-config.toml-read.ts"; +import { 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 { - rejectPasswordWithDirectTarget, - resolveDbTargetFlags, -} from "../../../command-internal/db-target-flags.ts"; + hostedMutationPreapproval, + requireTargetMutationConsent, +} from "../../../command-internal/hosted-consent/hosted-consent.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"; @@ -60,14 +52,10 @@ import { resolveMigrationFile, } from "../../../command-internal/migration-history.ts"; import { parseMigrationVersion } from "../../../command-internal/migration-timestamp.format.ts"; +import { resolveTarget } from "../../../command-internal/target/resolve-target.ts"; import { LinkedProjectCache } from "../../../telemetry/linked-project-cache.service.ts"; import { TelemetryState } from "../../../telemetry/telemetry-state.service.ts"; -import { - MigrationFileNotFoundError, - MigrationInvalidVersionError, - MigrationTargetFlagsError, -} from "../migration.errors.ts"; -import { migrationConfirm } from "../migration.prompt.ts"; +import { MigrationFileNotFoundError, MigrationInvalidVersionError } from "../migration.errors.ts"; import type { MigrationSquashFlags } from "./squash.command.ts"; import { SQUASH_SEPARATOR_COMMENT, squashLineByLineDiff } from "./squash.diff.ts"; import { squashDumpSchema, squashDumpSchemaToString } from "./squash.dump.ts"; @@ -316,28 +304,20 @@ interface SquashToVersionResult { } /** - * Loads the local migrations up to `version` (all when empty), squashes every one but - * the last into the shadow-produced dump, then removes the merged files. A removal - * failure is non-fatal: only printed to stderr, then continues. + * Squashes every migration but the last into the shadow-produced dump, then removes the merged + * files. A removal failure is non-fatal: only printed to stderr, then continues. */ const squashToVersion = Effect.fn("MigrationSquash.squashToVersion")(function* ( spawner: Spawner, fs: FileSystem.FileSystem, path: Path.Path, workdir: string, - migrationsDir: string, - version: string, + migrations: ReadonlyArray<string>, + local: string, localInputs: LocalDbContainerInputs, toml: DbTomlValues, ) { const output = yield* Output; - const migrations = yield* loadPartialMigrations(fs, path, migrationsDir, version); - yield* Effect.annotateCurrentSpan({ "migration.count": migrations.length }); - const local = migrations.at(-1); - if (local === undefined) { - return yield* new MigrationSquashMissingVersionError({ message: "version not found" }); - } - const rel = path.relative(workdir, local); if (migrations.length === 1) { yield* output.raw(`${bold(rel)} is already the earliest migration.\n`, "stderr"); @@ -415,9 +395,6 @@ const baselineMigrations = Effect.fn("MigrationSquash.baselineMigrations")(funct yield* Effect.scoped( Effect.gen(function* () { - // Always remote: runSquash already returns early on a local target, so - // cfg.isLocal is always false when this function runs. - yield* output.raw("Connecting to remote database...\n", "stderr"); const session = yield* connection.connect(cfg.conn, { isLocal: cfg.isLocal, dnsResolver }); yield* createMigrationTable(session); @@ -452,14 +429,10 @@ const baselineMigrations = Effect.fn("MigrationSquash.baselineMigrations")(funct return resolvedVersion; }); -const runSquash = Effect.fnUntraced(function* ( - flags: MigrationSquashFlags, - target: ReturnType<typeof resolveDbTargetFlags>, -) { +const runSquash = Effect.fnUntraced(function* (flags: MigrationSquashFlags) { const output = yield* Output; const resolver = yield* DbConfigResolver; const cliSettings = yield* CommandSettings; - const linkedProjectCache = yield* LinkedProjectCache; const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const dnsResolver = yield* DnsResolverFlag; @@ -468,50 +441,20 @@ const runSquash = Effect.fnUntraced(function* ( const runtimeInfo = yield* RuntimeInfo; const networkIdFlag = yield* NetworkIdFlag; - // Resolved linked ref, captured so the post-run finalizer caches the project - // (GET /v1/projects/{ref}). - const linkedRefForCache = yield* Ref.make<string | undefined>(undefined); - - yield* Effect.gen(function* () { - // Checked here, ahead of the root pre-run. - if (target.setFlags.length > 1) { - return yield* new MigrationTargetFlagsError({ - message: mutuallyExclusiveFlagsMessage(["db-url", "linked", "local"], target.setFlags), - }); - } - yield* rejectPasswordWithDirectTarget( - target.connType ?? "local", - flags.password, - target.connType === undefined ? { localByDefaultFor: "migration squash" } : {}, - ); + const target = yield* resolveTarget(flags); + const linkedRef = target.kind === "linked" ? target.ref : undefined; + const squashFlow = Effect.gen(function* () { const migrationsDir = path.join(cliSettings.workdir, "supabase", "migrations"); - const connType = target.connType ?? "local"; - - // `--project-ref` never implies `--linked` and must not be silently - // discarded on a non-linked target; see push.handler.ts's identical guard. - if (Option.isSome(flags.projectRef) && connType !== "linked") { - return yield* new MigrationTargetFlagsError({ - message: - "--project-ref only applies when targeting the linked project; use it with --linked (not --local or --db-url)", - }); - } - // Resolves and caches the project ref, then reads the remote-merged config before - // resolver.resolve() below (like db diff --linked); read unconditionally since the - // shadow is provisioned locally either way. - let linkedRef: string | undefined; - if (connType === "linked") { - const projectRefResolver = yield* ProjectRefResolver; - linkedRef = yield* projectRefResolver.loadProjectRef(flags.projectRef); - yield* Ref.set(linkedRefForCache, linkedRef); - } + // Reads the remote-merged config before resolving the connection (like db diff --linked); + // read unconditionally since the shadow is provisioned locally either way. const toml = yield* readDbToml(fs, path, cliSettings.workdir, linkedRef); if (toml.appliedRemote !== undefined) { yield* output.raw(`Loading config override: [remotes.${toml.appliedRemote}]\n`, "stderr"); } - // The shadow's container spec is always built before resolver.resolve() below, so + // The shadow's container spec is always built before the connection resolves, so // all config load/validation happens ahead of the actual connection resolution. const localInputs = yield* buildLocalDbContainerInputs( spawner, @@ -519,31 +462,21 @@ const runSquash = Effect.fnUntraced(function* ( networkIdFlag, runtimeInfo.platform, debug, - connType === "linked" ? linkedRef : undefined, + linkedRef, ); // The resolver owns --password/DB_PASSWORD/temp-login-role/IPv6 handling for // --linked, so squash needs no bespoke password prompt. - const cfg = yield* resolver.resolve({ - dbUrl: flags.dbUrl, - connType, + const cfg = yield* resolver.resolveConnection({ + target, dnsResolver, password: flags.password, - linkedProjectRef: flags.projectRef, + hasPasswordFlag: true, + networkBanRemoval: yield* hostedMutationPreapproval, }); - if (linkedRef === undefined) { - linkedRef = Option.getOrUndefined(cfg.ref ?? Option.none()); - } - if (linkedRef !== undefined) yield* Ref.set(linkedRefForCache, linkedRef); - yield* Effect.annotateCurrentSpan({ "db.conn_type": connType, "db.is_local": cfg.isLocal }); + yield* Effect.annotateCurrentSpan({ "db.conn_type": target.kind, "db.is_local": cfg.isLocal }); - // 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* loadProjectEnvValues(fs, path, cliSettings.workdir); - const yes = yield* resolveYesWithProjectEnv(projectEnv); - - // Runs after DB-config resolution, so an invalid target surfaces first. + // Runs after connection resolution, so an invalid target surfaces first. const version = Option.getOrElse(flags.version, () => ""); if (version.length > 0) { if (parseMigrationVersion(version) === undefined) { @@ -558,13 +491,31 @@ const runSquash = Effect.fnUntraced(function* ( } } + const migrations = yield* loadPartialMigrations(fs, path, migrationsDir, version); + yield* Effect.annotateCurrentSpan({ "migration.count": migrations.length }); + const squashInto = migrations.at(-1); + if (squashInto === undefined) { + return yield* new MigrationSquashMissingVersionError({ message: "version not found" }); + } + + // The checkout rewrite precedes the remote history update, so both sit behind one consent. + yield* requireTargetMutationConsent(cfg, { + action: "Squash local migrations and update the remote migration history table", + details: [ + migrations.length > 1 + ? `Squash ${migrations.length} local migrations into ${path.relative(cliSettings.workdir, squashInto)} and remove the ${migrations.length - 1} merged ${migrations.length === 2 ? "file" : "files"}` + : `${path.relative(cliSettings.workdir, squashInto)} is already the earliest migration`, + "Reset the remote migration history table to the squashed migration", + ], + }); + const squashResult = yield* squashToVersion( spawner, fs, path, cliSettings.workdir, - migrationsDir, - version, + migrations, + squashInto, localInputs, toml, ); @@ -590,23 +541,14 @@ const runSquash = Effect.fnUntraced(function* ( return; } - // A declined prompt is still a success path here (returns cleanly, not a - // cancellation), unlike repair/fetch/down. - const confirmed = yield* migrationConfirm("Update remote migration history table?", { - defaultValue: true, - yes, - }); - let baselinedVersion: string | null = null; - if (confirmed) { - baselinedVersion = yield* baselineMigrations( - fs, - path, - migrationsDir, - cfg, - dnsResolver, - version, - ); - } + const baselinedVersion = yield* baselineMigrations( + fs, + path, + migrationsDir, + cfg, + dnsResolver, + version, + ); if (output.format === "text") { yield* output.raw(`Finished ${aqua("supabase migration squash")}.\n`); @@ -620,20 +562,18 @@ const runSquash = Effect.fnUntraced(function* ( baselinedVersion, }); } - }).pipe( - Effect.ensuring( - Effect.flatMap(Ref.get(linkedRefForCache), (ref) => - ref !== undefined ? linkedProjectCache.cache(ref) : Effect.void, - ), - ), - ); + }); + + if (target.kind === "linked") { + const linkedProjectCache = yield* LinkedProjectCache; + return yield* squashFlow.pipe(Effect.ensuring(linkedProjectCache.cache(target.ref))); + } + return yield* squashFlow; }); export const migrationSquash = Effect.fn("migration.squash")(function* ( flags: MigrationSquashFlags, ) { const telemetryState = yield* TelemetryState; - const cliArgs = yield* CliArgs; - const target = resolveDbTargetFlags(cliArgs.args); - yield* runSquash(flags, target).pipe(Effect.ensuring(telemetryState.flush)); + yield* runSquash(flags).pipe(Effect.ensuring(telemetryState.flush)); }); 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 97d151570c..2d43959511 100644 --- a/apps/cli/src/commands/migration/squash/squash.integration.test.ts +++ b/apps/cli/src/commands/migration/squash/squash.integration.test.ts @@ -2,7 +2,7 @@ import { cliConfigValuesTestLayer } from "../../../../tests/helpers/config-value import { unusedStackServices } from "../../../../tests/helpers/unused-stack.ts"; import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; -import { Config, ConfigProvider, Effect, Exit, FileSystem, Layer, Option, Path } from "effect"; +import { Config, ConfigProvider, Effect, FileSystem, Layer, Option, Path } from "effect"; import { PlatformError, SystemError } from "effect/PlatformError"; import * as HttpClient from "effect/http/HttpClient"; import * as HttpClientResponse from "effect/http/HttpClientResponse"; @@ -30,12 +30,8 @@ import { withEnvVar, sequentialExecBatch, } from "../../../../tests/helpers/command-mocks.ts"; -import { - mockOutput, - mockRuntimeInfo, - mockStdin, - mockTty, -} from "../../../../tests/helpers/mocks.ts"; +import { describeHostedConsent } from "../../../../tests/helpers/hosted-consent.ts"; +import { mockOutput, mockRuntimeInfo, mockTty } from "../../../../tests/helpers/mocks.ts"; import { assertPermissionDrift } from "../../../../tests/helpers/permission-drift.ts"; import { asRecordedRequests, @@ -56,6 +52,7 @@ import { DnsResolverFlag, ExperimentalFlag, NetworkIdFlag, + OutputFlag, YesFlag, } from "../../../command-internal/global-flags.ts"; import type { OutputFormat } from "../../../shared/output/types.ts"; @@ -63,9 +60,11 @@ import { ProjectRefNotLinkedError } from "../../../config/project-ref.errors.ts" import { ProjectRefResolver } from "../../../config/project-ref.service.ts"; import { INTERNAL_SCHEMAS } from "../../../command-internal/pg-dump.env.ts"; import { dumpSchemaScript } from "../../../command-internal/pg-dump.scripts.ts"; -import { DbPasswordFlagsError } from "../../../command-internal/db-config.errors.ts"; import { DbConfigResolver } from "../../../command-internal/db-config.service.ts"; -import type { DbConfigFlags, ResolvedDbConfig } from "../../../command-internal/db-config.types.ts"; +import type { + DbConnectionInput, + ResolvedDbConnection, +} from "../../../command-internal/db-config.types.ts"; import { DbExecError } from "../../../command-internal/db-connection.errors.ts"; import { DbConnection, @@ -79,11 +78,14 @@ import { DbSetupError } from "../../../command-internal/db-bootstrap/db-setup.ts import { HealthCheckTimeoutError } from "../../../command-internal/db-bootstrap/health-check.ts"; import { ShadowDbError } from "../../../command-internal/db-bootstrap/shadow-database.ts"; import { MigrationApplyError } from "../../../command-internal/migration-apply.ts"; +import { HostedMutationDeclinedError } from "../../../command-internal/hosted-consent/hosted-consent.errors.ts"; +import { TargetPolicy } from "../../../command-internal/target/target-policy.ts"; import { - MigrationFileNotFoundError, - MigrationInvalidVersionError, - MigrationTargetFlagsError, -} from "../migration.errors.ts"; + TargetPasswordConflictError, + TargetProjectRefConflictError, + TargetSelectorConflictError, +} from "../../../command-internal/target/target.errors.ts"; +import { MigrationFileNotFoundError, MigrationInvalidVersionError } from "../migration.errors.ts"; import type { MigrationSquashFlags } from "./squash.command.ts"; import { MigrationSquashBaselineError, @@ -234,15 +236,11 @@ interface SetupOpts { /** Runs the real `DbConfigResolver` and linked-project cache against `workdir`. */ readonly realLayers?: boolean; readonly format?: OutputFormat; - readonly isTTY?: boolean; - readonly pipedInput?: string; + readonly interactive?: boolean; readonly yes?: boolean; readonly confirm?: boolean; - readonly args?: ReadonlyArray<string>; + /** Overrides whether the resolved connection counts as local (a `--db-url` can point either way). */ readonly isLocal?: boolean; - readonly linkedRef?: string; - /** Omits `ref` entirely from the resolved config, matching the real resolver's own `--local`/`--db-url` shape (`ref` is an optional field, not always `None` — see `db-config.types.ts`). */ - readonly omitRef?: boolean; readonly failResolve?: boolean; readonly failSql?: string; readonly networkId?: string; @@ -257,11 +255,7 @@ interface SetupOpts { readonly fsFaults?: FsFaultOpts; } -function setup(workdir: string, opts: SetupOpts = {}) { - const out = mockOutput({ format: opts.format ?? "text" }); - const telemetry = mockTelemetryStateTracked(); - const cache = mockLinkedProjectCacheTracked(); - +function fakeServices(workdir: string, opts: SetupOpts = {}) { const spawner = mockShadowContainerCliSpawner({ neverHealthy: opts.neverHealthyShadow ?? false, failCreate: opts.failCreateShadow ?? false, @@ -314,17 +308,11 @@ function setup(workdir: string, opts: SetupOpts = {}) { }), }); - const resolverCalls: Array<DbConfigFlags> = []; + const resolverCalls: Array<DbConnectionInput> = []; const resolver = Layer.succeed(DbConfigResolver, { - resolve: (flags: DbConfigFlags) => { - resolverCalls.push(flags); - if (opts.failResolve === true) { - return Effect.fail( - new ProjectRefNotLinkedError({ - message: "Cannot find project ref. Have you run link?", - }), - ); - } + resolve: () => Effect.die("resolve is not used by this test"), + resolveConnection: (input: DbConnectionInput) => { + resolverCalls.push(input); return Effect.succeed({ conn: { host: "127.0.0.1", @@ -333,35 +321,14 @@ function setup(workdir: string, opts: SetupOpts = {}) { password: "x", database: "postgres", }, - isLocal: opts.isLocal ?? true, - // A real --local/--db-url resolution can omit ref altogether (not just None); - // omitRef reproduces that so the cfg.ref ?? Option.none() fallback stays exercised. - ...(opts.omitRef === true - ? {} - : { ref: opts.linkedRef !== undefined ? Option.some(opts.linkedRef) : Option.none() }), - } satisfies ResolvedDbConfig); + isLocal: opts.isLocal ?? input.target.kind === "local", + ref: input.target.kind === "linked" ? Option.some(input.target.ref) : Option.none(), + target: input.target, + } satisfies ResolvedDbConnection); }, - resolveConnection: () => Effect.die("resolveConnection is not used by this test"), resolvePoolerFallback: () => Effect.succeed(Option.none()), }); - // Gives an explicit --project-ref flag precedence over the opts.linkedRef/VALID_REF - // fallback, so a test can prove the flag drives the linked ref. - const projectRef = Layer.succeed(ProjectRefResolver, { - resolve: () => Effect.succeed(opts.linkedRef ?? VALID_REF), - resolveForLink: () => Effect.succeed(opts.linkedRef ?? VALID_REF), - resolveRef: () => - Effect.succeed({ ref: opts.linkedRef ?? VALID_REF, source: "file", matchesLinkedRef: true }), - resolveOptional: () => Effect.succeed(Option.some(opts.linkedRef ?? VALID_REF)), - loadProjectRef: (flagValue: Option.Option<string>) => - Effect.succeed( - Option.isSome(flagValue) && flagValue.value.length > 0 - ? flagValue.value - : (opts.linkedRef ?? VALID_REF), - ), - promptProjectRef: () => Effect.succeed(opts.linkedRef ?? VALID_REF), - }); - const debugLogs: Array<string> = []; const debugLogger = Layer.succeed(DebugLogger, { debug: (message: string) => @@ -372,20 +339,12 @@ function setup(workdir: string, opts: SetupOpts = {}) { }); const http = recordingProjectHttpClient(VALID_REF); - const baseLayer = Layer.mergeAll( + const baseServices = Layer.mergeAll( unusedStackServices, // 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, - opts.realLayers === true - ? realLinkedProjectCache({ - httpClient: http.layer, - cliSettings: mockCommandSettings({ workdir }), - }) - : cache.layer, opts.realLayers === true ? realDbConfigResolver({ workdir, @@ -394,7 +353,6 @@ function setup(workdir: string, opts: SetupOpts = {}) { }) : resolver, connection, - projectRef, spawner.layer, docker.layer, Layer.succeed(BundledPostgresClient, { @@ -406,32 +364,22 @@ function setup(workdir: string, opts: SetupOpts = {}) { Layer.succeed(DnsResolverFlag, "native"), Layer.succeed(DebugFlag, false), Layer.succeed(ExperimentalFlag, false), - Layer.succeed(YesFlag, opts.yes ?? false), Layer.succeed( NetworkIdFlag, opts.networkId === undefined ? Option.none() : Option.some(opts.networkId), ), - Layer.succeed(CliArgs, { args: opts.args ?? [] }), - mockTty({ stdinIsTty: opts.isTTY ?? true }), - mockStdin( - opts.isTTY ?? true, - opts.pipedInput ?? (opts.confirm === undefined ? undefined : opts.confirm ? "y\n" : "n\n"), - ), mockRuntimeInfo(), shadowCacheDisabledLayer, ); - const layer = + const services = opts.fsFaults === undefined - ? baseLayer - : Layer.merge(baseLayer, faultyFsLayer(workdir, opts.fsFaults)); + ? baseServices + : Layer.merge(baseServices, faultyFsLayer(workdir, opts.fsFaults)); return { - layer, + services, http, - out, - telemetry, - cache, execs, queries, statements, @@ -444,11 +392,66 @@ function setup(workdir: string, opts: SetupOpts = {}) { }; } +function setup(workdir: string, opts: SetupOpts = {}) { + const interactive = opts.interactive ?? false; + const out = mockOutput({ + format: opts.format ?? "text", + interactive, + promptConfirmResponses: opts.confirm === undefined ? undefined : [opts.confirm], + }); + const telemetry = mockTelemetryStateTracked(); + const cache = mockLinkedProjectCacheTracked(); + const { services, http, ...fake } = fakeServices(workdir, opts); + + // An explicit --project-ref takes precedence over the VALID_REF fallback, so a test + // can prove the flag drives the linked ref. + const projectRef = Layer.succeed(ProjectRefResolver, { + resolve: () => Effect.succeed(VALID_REF), + resolveForLink: () => Effect.succeed(VALID_REF), + resolveRef: (flagValue: Option.Option<string>) => + opts.failResolve === true + ? Effect.fail( + new ProjectRefNotLinkedError({ + message: "Cannot find project ref. Have you run link?", + }), + ) + : Effect.succeed({ + ref: Option.getOrElse(flagValue, () => VALID_REF), + source: Option.isSome(flagValue) ? ("flag" as const) : ("file" as const), + matchesLinkedRef: Option.isNone(flagValue), + }), + resolveOptional: () => Effect.succeed(Option.some(VALID_REF)), + loadProjectRef: (flagValue: Option.Option<string>) => + Effect.succeed(Option.getOrElse(flagValue, () => VALID_REF)), + promptProjectRef: () => Effect.succeed(VALID_REF), + }); + + const layer = Layer.mergeAll( + services, + out.layer, + telemetry.layer, + opts.realLayers === true + ? realLinkedProjectCache({ + httpClient: http.layer, + cliSettings: mockCommandSettings({ workdir }), + }) + : cache.layer, + projectRef, + Layer.succeed(YesFlag, opts.yes ?? false), + Layer.succeed(OutputFlag, Option.none()), + Layer.succeed(TargetPolicy, { group: "selectable", consent: "no" }), + Layer.succeed(CliArgs, { args: [] }), + mockTty({ stdinIsTty: interactive, stdoutIsTty: interactive }), + ); + + return { layer, http, out, telemetry, cache, ...fake }; +} + const flags = (over: Partial<MigrationSquashFlags> = {}): MigrationSquashFlags => ({ version: over.version ?? Option.none(), dbUrl: over.dbUrl ?? Option.none(), linked: over.linked ?? false, - local: over.local ?? true, + local: over.local ?? false, password: over.password ?? Option.none(), projectRef: over.projectRef ?? Option.none(), }); @@ -507,42 +510,34 @@ const tmp = useTempWorkdir(); describe("migration squash", () => { describe("flag surface & ordering", () => { it.effect("rejects --linked combined with --local", () => { - const s = setup(tmp.current, { args: ["--linked", "--local"] }); + const s = setup(tmp.current); return Effect.gen(function* () { const error = yield* migrationSquash(flags({ linked: true, local: true })).pipe( Effect.flip, ); - expect(error).toBeInstanceOf(MigrationTargetFlagsError); - expect(error.message).toBe( - "if any flags in the group [db-url linked local] are set none of the others can be; [linked local] were all set", - ); + expect(error).toBeInstanceOf(TargetSelectorConflictError); + expect(s.resolverCalls).toEqual([]); }).pipe(Effect.provide(s.layer)); }); - it.effect("rejects --db-url combined with --password", () => { - const s = setup(tmp.current, { args: ["--db-url", "postgresql://x", "--password", "y"] }); + it.effect("rejects --db-url combined with --linked", () => { + const s = setup(tmp.current); return Effect.gen(function* () { const error = yield* migrationSquash( - flags({ dbUrl: Option.some("postgresql://x"), password: Option.some("y") }), + flags({ dbUrl: Option.some("postgresql://x"), linked: true }), ).pipe(Effect.flip); - expect(error).toBeInstanceOf(DbPasswordFlagsError); - expect(error.message).toBe( - "--password can't be used with --db-url. Put the password in the connection string: postgres://USER:PASSWORD@HOST:PORT/postgres", - ); + expect(error).toBeInstanceOf(TargetSelectorConflictError); + expect(s.resolverCalls).toEqual([]); }).pipe(Effect.provide(s.layer)); }); - it.effect("rejects --project-ref on the default local target", () => { + it.effect("rejects --project-ref combined with --local before any shadow or cache work", () => { const s = setup(tmp.current); return Effect.gen(function* () { - const error = yield* migrationSquash(flags({ projectRef: Option.some(VALID_REF) })).pipe( - Effect.flip, - ); - expect(error).toBeInstanceOf(MigrationTargetFlagsError); - expect(error.message).toBe( - "--project-ref only applies when targeting the linked project; use it with --linked (not --local or --db-url)", - ); - // The guard fires before any resolver call, shadow/dump work, or cache write. + const error = yield* migrationSquash( + flags({ local: true, projectRef: Option.some(VALID_REF) }), + ).pipe(Effect.flip); + expect(error).toBeInstanceOf(TargetProjectRefConflictError); expect(s.resolverCalls).toEqual([]); expect(s.shadowSpawned).toEqual([]); expect(s.dumpCalls).toEqual([]); @@ -551,29 +546,30 @@ describe("migration squash", () => { }).pipe(Effect.provide(s.layer)); }); - it.effect("rejects --project-ref combined with an explicit --db-url target", () => { - const s = setup(tmp.current, { - args: ["--db-url", "postgresql://x", "--project-ref", VALID_REF], - }); + it.effect("rejects --project-ref combined with --db-url", () => { + const s = setup(tmp.current); return Effect.gen(function* () { const error = yield* migrationSquash( - flags({ - dbUrl: Option.some("postgresql://x"), - projectRef: Option.some(VALID_REF), - }), + flags({ dbUrl: Option.some("postgresql://x"), projectRef: Option.some(VALID_REF) }), ).pipe(Effect.flip); - expect(error).toBeInstanceOf(MigrationTargetFlagsError); - expect(error.message).toBe( - "--project-ref only applies when targeting the linked project; use it with --linked (not --local or --db-url)", - ); + expect(error).toBeInstanceOf(TargetProjectRefConflictError); expect(s.resolverCalls).toEqual([]); - expect(s.shadowSpawned).toEqual([]); - expect(s.dumpCalls).toEqual([]); - expect(s.setupJobCalls).toEqual([]); expect(s.cache.cached).toBe(false); }).pipe(Effect.provide(s.layer)); }); + it.effect("rejects --password combined with --db-url through the real resolver", () => { + const s = setup(tmp.current, { realLayers: true }); + return Effect.gen(function* () { + yield* seedMigration(tmp.current, "0_init.sql"); + const error = yield* migrationSquash( + flags({ dbUrl: Option.some("postgresql://x"), password: Option.some("y") }), + ).pipe(Effect.flip); + expect(error).toBeInstanceOf(TargetPasswordConflictError); + expect(s.shadowSpawned).toEqual([]); + }).pipe(Effect.scoped, Effect.provide(s.layer)); + }); + it.effect( "rejects a non-numeric --version with the bare message (no 'failed to parse' prefix)", () => { @@ -611,25 +607,22 @@ describe("migration squash", () => { }).pipe(Effect.provide(s.layer)); }); - it.effect("surfaces a db-config resolution failure before validating --version", () => { - // DB target resolution happens before the version check, matching migration - // repair's identical ordering test. + it.effect("surfaces a target resolution failure before validating --version", () => { const s = setup(tmp.current, { failResolve: true }); return Effect.gen(function* () { - const error = yield* migrationSquash(flags({ version: Option.some("not-a-number") })).pipe( - Effect.flip, - ); + const error = yield* migrationSquash( + flags({ linked: true, version: Option.some("not-a-number") }), + ).pipe(Effect.flip); expect(error).toBeInstanceOf(ProjectRefNotLinkedError); }).pipe(Effect.provide(s.layer)); }); it.effect("defaults to the local database when no target flag is given", () => { - // omitRef matches the real resolver's --local shape: no ref at all, not merely None. - const s = setup(tmp.current, { args: [], omitRef: true }); + const s = setup(tmp.current); return Effect.gen(function* () { yield* seedMigration(tmp.current, "0_init.sql"); yield* migrationSquash(flags()); - expect(s.resolverCalls[0]?.connType).toBe("local"); + expect(s.resolverCalls[0]?.target).toEqual({ kind: "local" }); }).pipe(Effect.provide(s.layer)); }); }); @@ -1142,7 +1135,7 @@ describe("migration squash", () => { it.effect( "prints Finished on stdout and the repair suggestion on stderr, and never prompts", () => { - const s = setup(tmp.current, { isLocal: true }); + const s = setup(tmp.current, { interactive: true }); return Effect.gen(function* () { yield* seedMigration(tmp.current, "0_init.sql"); yield* migrationSquash(flags()); @@ -1150,49 +1143,87 @@ describe("migration squash", () => { expect(stderr(s.out)).toContain( "Run supabase migration repair --status applied to update your remote migration history table.", ); - expect(stderr(s.out)).not.toContain("Update remote migration history table?"); + expect(s.out.promptConfirmCalls).toEqual([]); }).pipe(Effect.provide(s.layer)); }, ); it.effect("a --db-url pointing at the local stack also takes the local-suggestion path", () => { - const s = setup(tmp.current, { isLocal: true, args: ["--db-url", "postgresql://local"] }); + const s = setup(tmp.current, { interactive: true, isLocal: true }); return Effect.gen(function* () { yield* seedMigration(tmp.current, "0_init.sql"); yield* migrationSquash(flags({ dbUrl: Option.some("postgresql://local") })); expect(stdout(s.out)).toContain("Finished supabase migration squash."); + expect(s.out.promptConfirmCalls).toEqual([]); }).pipe(Effect.provide(s.layer)); }); }); - describe("remote target", () => { + describe("hosted target", () => { function setupRemote(opts: SetupOpts = {}) { - return setup(tmp.current, { isLocal: false, linkedRef: VALID_REF, ...opts }); + return setup(tmp.current, { yes: true, ...opts }); } + const hosted = (over: Partial<MigrationSquashFlags> = {}) => flags({ linked: true, ...over }); - it.effect("prompts to update the remote history table and baselines on 'y'", () => { - const s = setupRemote({ confirm: true, args: ["--linked"] }); + it.effect("baselines the remote history table once the consent is given", () => { + const s = setupRemote(); return Effect.gen(function* () { yield* seedMigration(tmp.current, "0_init.sql"); - yield* migrationSquash(flags({ linked: true })); - expect(stderr(s.out)).toContain("Update remote migration history table? [Y/n] "); + yield* migrationSquash(hosted()); + expect(stderr(s.out)).toContain( + "Confirmed by --yes: Squash local migrations and update the remote migration history table", + ); expect(s.queries.some((q) => q.sql.includes("DELETE FROM supabase_migrations"))).toBe(true); expect(s.queries.some((q) => q.sql.includes("INSERT INTO supabase_migrations"))).toBe(true); }).pipe(Effect.provide(s.layer)); }); it.effect( - "prints 'Baselining migration history to <v>' BEFORE 'Connecting to remote database...'", + "asks once, lists the squash plan, defaults to No, and writes after the accept", () => { - const s = setupRemote({ confirm: true }); + const s = setup(tmp.current, { interactive: true, confirm: true }); return Effect.gen(function* () { - yield* seedMigration(tmp.current, "0_init.sql"); - yield* migrationSquash(flags()); + yield* seedMigration(tmp.current, "0_init.sql", "create table a (id int);\n"); + yield* seedMigration(tmp.current, "1_target.sql", "create table b (id int);\n"); + yield* migrationSquash(hosted()); + expect(s.out.promptConfirmCalls).toHaveLength(1); + expect(s.out.promptConfirmCalls[0]?.opts?.defaultValue).toBe(false); const text = stderr(s.out); - const baseliningAt = text.indexOf("Baselining migration history to 0"); - const connectingAt = text.indexOf("Connecting to remote database..."); - expect(baseliningAt).toBeGreaterThanOrEqual(0); - expect(connectingAt).toBeGreaterThan(baseliningAt); + expect(text).toContain( + "Squash 2 local migrations into supabase/migrations/1_target.sql and remove the 1 merged file", + ); + expect(text).toContain( + "Reset the remote migration history table to the squashed migration", + ); + expect(yield* migrationExists(tmp.current, "0_init.sql")).toBe(false); + expect(s.queries.some((q) => q.sql.includes("INSERT INTO supabase_migrations"))).toBe( + true, + ); + expect(s.cache.cachedRef).toBe(VALID_REF); + }).pipe(Effect.provide(s.layer)); + }, + ); + + it.effect( + "declining aborts before the checkout is rewritten or the remote history is touched", + () => { + const s = setup(tmp.current, { interactive: true, confirm: false }); + return Effect.gen(function* () { + yield* seedMigration(tmp.current, "0_init.sql", "create table a (id int);\n"); + yield* seedMigration(tmp.current, "1_target.sql", "create table b (id int);\n"); + const error = yield* migrationSquash(hosted()).pipe(Effect.flip); + expect(error).toBeInstanceOf(HostedMutationDeclinedError); + expect(s.shadowSpawned).toEqual([]); + expect(s.dumpCalls).toEqual([]); + expect(s.execs).toEqual([]); + expect(s.queries).toEqual([]); + expect(yield* readMigration(tmp.current, "0_init.sql")).toBe( + "create table a (id int);\n", + ); + expect(yield* readMigration(tmp.current, "1_target.sql")).toBe( + "create table b (id int);\n", + ); + expect(s.cache.cachedRef).toBe(VALID_REF); }).pipe(Effect.provide(s.layer)); }, ); @@ -1200,13 +1231,12 @@ describe("migration squash", () => { it.effect( "baselines via one transaction: BEGIN, DELETE ... WHERE version <= $1, INSERT ..., COMMIT", () => { - const s = setupRemote({ confirm: true }); + const s = setupRemote(); return Effect.gen(function* () { yield* seedMigration(tmp.current, "0_init.sql"); - yield* migrationSquash(flags()); - // A single ordered log, since execs/queries alone are order-blind and would - // still pass an INSERT-before-DELETE regression; the baseline transaction is - // the last 4 statements, after createMigrationTable's setup transaction. + yield* migrationSquash(hosted()); + // The ordered log is the only way to catch an INSERT-before-DELETE regression; + // the baseline transaction is the last 4 statements, after createMigrationTable's own. const baseline = s.statements.slice(-4); expect(baseline.map((entry) => entry.sql)).toEqual([ "BEGIN", @@ -1223,78 +1253,28 @@ describe("migration squash", () => { it.effect( "rolls back and reports a baseline failure when the history-table batch fails", () => { - const s = setupRemote({ confirm: true, failSql: "INSERT INTO supabase_migrations" }); + const s = setupRemote({ failSql: "INSERT INTO supabase_migrations" }); return Effect.gen(function* () { yield* seedMigration(tmp.current, "0_init.sql"); - const error = yield* migrationSquash(flags()).pipe(Effect.flip); + const error = yield* migrationSquash(hosted()).pipe(Effect.flip); expect(error).toBeInstanceOf(MigrationSquashBaselineError); expect(error.message).toContain("failed to update migration history:"); expect(s.execs).toContain("ROLLBACK"); - // Exactly one COMMIT: createMigrationTable's own setup transaction, which runs - // before the baseline's BEGIN/DELETE/INSERT batch and never itself reaches COMMIT. + // createMigrationTable's own setup transaction is the only COMMIT. expect(s.execs.filter((e) => e === "COMMIT")).toHaveLength(1); expect(s.execs.filter((e) => e === "BEGIN")).toHaveLength(2); }).pipe(Effect.provide(s.layer)); }, ); - it.effect( - "declining the prompt exits 0, runs no baseline query, and still prints Finished", - () => { - const s = setupRemote({ confirm: false }); - return Effect.gen(function* () { - yield* seedMigration(tmp.current, "0_init.sql"); - const exit = yield* migrationSquash(flags()).pipe(Effect.exit); - expect(Exit.isSuccess(exit)).toBe(true); - expect(s.execs).not.toContain("BEGIN"); - expect(s.queries).toEqual([]); - expect(stdout(s.out)).toContain("Finished supabase migration squash."); - }).pipe(Effect.provide(s.layer)); - }, - ); - - it.effect("--yes auto-confirms by echoing the prompt with 'y' and reads no stdin", () => { - const s = setupRemote({ yes: true, pipedInput: undefined }); - return Effect.gen(function* () { - yield* seedMigration(tmp.current, "0_init.sql"); - yield* migrationSquash(flags()); - expect(stderr(s.out)).toContain("Update remote migration history table? [Y/n] y"); - expect(s.execs).toContain("BEGIN"); - }).pipe(Effect.provide(s.layer)); - }); - - it.effect("a non-TTY run with no piped answer takes the default (yes) and baselines", () => { - const s = setupRemote({ isTTY: false, pipedInput: undefined }); - return Effect.gen(function* () { - yield* seedMigration(tmp.current, "0_init.sql"); - yield* migrationSquash(flags()); - expect(s.execs).toContain("BEGIN"); - expect(s.queries.some((q) => q.sql.includes("INSERT INTO supabase_migrations"))).toBe(true); - }).pipe(Effect.provide(s.layer)); - }); - - it.effect("an unrecognised piped answer leaves the remote migration history alone", () => { - const s = setupRemote({ isTTY: false, pipedInput: "nope\n" }); - return Effect.gen(function* () { - yield* seedMigration(tmp.current, "0_init.sql"); - yield* migrationSquash(flags()); - expect(s.execs).not.toContain("BEGIN"); - expect(s.queries).toEqual([]); - }).pipe(Effect.provide(s.layer)); - }); - it.effect( "--version 0 baselines exactly version 0 even though a newer migration survives", () => { - const s = setup(tmp.current, { - isLocal: false, - linkedRef: VALID_REF, - confirm: true, - }); + const s = setupRemote(); return Effect.gen(function* () { yield* seedMigration(tmp.current, "0_init.sql"); yield* seedMigration(tmp.current, "1_newer.sql"); - yield* migrationSquash(flags({ version: Option.some("0") })); + yield* migrationSquash(hosted({ version: Option.some("0") })); const insert = s.queries.find((q) => q.sql.includes("INSERT INTO supabase_migrations")); expect(insert?.params?.[0]).toBe("0"); }).pipe(Effect.provide(s.layer)); @@ -1305,10 +1285,7 @@ describe("migration squash", () => { // Local versions are re-listed after the file removals, so a failed removal leaves // the older merged file's version as what an empty --version baselines to, not the // squash target. - const s = setup(tmp.current, { - isLocal: false, - linkedRef: VALID_REF, - confirm: true, + const s = setupRemote({ beforeDumpSql: "before;\n", afterDumpSql: "after;\n", fullDumpSql: "full;\n", @@ -1317,7 +1294,7 @@ describe("migration squash", () => { return Effect.gen(function* () { yield* seedMigration(tmp.current, "0_init.sql"); yield* seedMigration(tmp.current, "1_target.sql"); - yield* migrationSquash(flags()); + yield* migrationSquash(hosted()); const insert = s.queries.find((q) => q.sql.includes("INSERT INTO supabase_migrations")); expect(insert?.params?.[0]).toBe("0"); }).pipe(Effect.provide(s.layer)); @@ -1326,29 +1303,25 @@ describe("migration squash", () => { it.effect( "debug-logs and baselines with an empty version when the post-squash version reload fails", () => { - const s = setup(tmp.current, { - isLocal: false, - linkedRef: VALID_REF, - confirm: true, + const s = setupRemote({ beforeDumpSql: "before;\n", afterDumpSql: "after;\n", fullDumpSql: "full;\n", - // Call 1 is squashToVersion's own listing (must succeed); call 2 is - // baselineMigrations's post-removal re-list, which this fails; call 3 (inside - // resolveMigrationFile) must succeed again to isolate the reload failure. + // Read 1 is the up-front listing (must succeed); read 2 is the baseline's + // post-removal re-list, which this fails; read 3 (inside resolveMigrationFile) + // must succeed again to isolate the reload failure. fsFaults: { failMigrationsReadDirectoryAtCall: 2 }, }); return Effect.gen(function* () { yield* seedMigration(tmp.current, "0_init.sql"); yield* seedMigration(tmp.current, "1_target.sql"); - const error = yield* migrationSquash(flags()).pipe(Effect.flip); + const error = yield* migrationSquash(hosted()).pipe(Effect.flip); expect(s.debugLogs).toHaveLength(1); expect(s.debugLogs[0]).toContain("failed to read directory"); expect(s.debugLogs[0]).toContain("simulated failure"); expect(stderr(s.out)).toContain("Baselining migration history to \n"); // The empty-version glob fails, surfacing as the baseline's missing-file error - // and proving resolvedVersion genuinely stayed "" rather than falling back to - // the squash target. + // and proving resolvedVersion stayed "" rather than falling back to the squash target. expect(error).toBeInstanceOf(MigrationFileNotFoundError); expect(error.message).toBe("glob supabase/migrations/_*.sql: file does not exist"); }).pipe(Effect.provide(s.layer)); @@ -1356,15 +1329,11 @@ describe("migration squash", () => { ); it.effect("--linked caches the linked project ref even when the squash fails", () => { - const s = setup(tmp.current, { - isLocal: false, - linkedRef: VALID_REF, - args: ["--linked"], - }); + const s = setup(tmp.current); return Effect.gen(function* () { - const error = yield* migrationSquash( - flags({ linked: true, version: Option.some("bad") }), - ).pipe(Effect.flip); + const error = yield* migrationSquash(hosted({ version: Option.some("bad") })).pipe( + Effect.flip, + ); expect(error).toBeInstanceOf(MigrationInvalidVersionError); expect(s.cache.cached).toBe(true); expect(s.cache.cachedRef).toBe(VALID_REF); @@ -1372,21 +1341,17 @@ describe("migration squash", () => { }); it.effect( - "--linked --project-ref overrides the workdir's own linked ref for resolution and caching", + "--project-ref implies --linked and overrides the workdir's own ref for resolution and caching", () => { - // opts.linkedRef (VALID_REF) is what the workdir would resolve to without the - // flag; --project-ref must win over it for both the resolver call and the cache. + // VALID_REF is what the workdir would resolve to without the flag. const FLAG_REF = "flagflagflagflagflag"; - const s = setup(tmp.current, { - isLocal: false, - linkedRef: VALID_REF, - args: ["--linked", "--project-ref", FLAG_REF], - }); + const s = setup(tmp.current); return Effect.gen(function* () { const error = yield* migrationSquash( - flags({ linked: true, projectRef: Option.some(FLAG_REF), version: Option.some("bad") }), + flags({ projectRef: Option.some(FLAG_REF), version: Option.some("bad") }), ).pipe(Effect.flip); expect(error).toBeInstanceOf(MigrationInvalidVersionError); + expect(s.resolverCalls[0]?.target).toMatchObject({ kind: "linked", ref: FLAG_REF }); expect(s.cache.cached).toBe(true); expect(s.cache.cachedRef).toBe(FLAG_REF); expect(s.cache.cachedRef).not.toBe(VALID_REF); @@ -1395,14 +1360,9 @@ describe("migration squash", () => { ); it.effect( - "--linked reads [remotes.<ref>] and prints the config-override line before resolving", + "--linked reads [remotes.<ref>] and prints the config-override line before the consent", () => { - const s = setup(tmp.current, { - isLocal: false, - linkedRef: VALID_REF, - confirm: true, - args: ["--linked"], - }); + const s = setupRemote(); return Effect.gen(function* () { yield* writeProjectFile( tmp.current, @@ -1410,29 +1370,24 @@ describe("migration squash", () => { ["[remotes.dev]", `project_id = "${VALID_REF}"`, ""].join("\n"), ); yield* seedMigration(tmp.current, "0_init.sql"); - yield* migrationSquash(flags({ linked: true })); + yield* migrationSquash(hosted()); const text = stderr(s.out); - expect(text).toContain("Loading config override: [remotes.dev]"); const overrideAt = text.indexOf("Loading config override: [remotes.dev]"); - const promptAt = text.indexOf("Update remote migration history table?"); - expect(promptAt).toBeGreaterThan(overrideAt); + const consentAt = text.indexOf("Confirmed by --yes: "); + expect(overrideAt).toBeGreaterThanOrEqual(0); + expect(consentAt).toBeGreaterThan(overrideAt); }).pipe(Effect.provide(s.layer)); }, ); + it.effect("mints a login role and fills the cache through the real resolver and cache", () => { - const s = setupRemote({ - args: ["migration", "squash", "--linked", "--project-ref", VALID_REF], - confirm: true, - realLayers: true, - }); + const s = setupRemote({ realLayers: true }); const requests: Array<StubbedRequest> = []; return Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; yield* stubManagementApi(requests, serveLinkedDatabase(VALID_REF)); yield* seedMigration(tmp.current, "0_init.sql"); - yield* migrationSquash( - flags({ linked: true, local: false, projectRef: Option.some(VALID_REF) }), - ); + yield* migrationSquash(flags({ projectRef: Option.some(VALID_REF) })); expect(s.queries.some((q) => q.sql.includes("INSERT INTO supabase_migrations"))).toBe(true); expect(requests.map(({ method, path }) => `${method} ${path}`)).toEqual([ `GET /v1/projects/${VALID_REF}/config/database/pooler`, @@ -1456,22 +1411,13 @@ describe("migration squash", () => { }); it.effect("fetches the pooler config but mints no login role when --password is given", () => { - const s = setupRemote({ - args: ["migration", "squash", "--linked", "--password", "pw", "--project-ref", VALID_REF], - confirm: true, - realLayers: true, - }); + const s = setupRemote({ realLayers: true }); const requests: Array<StubbedRequest> = []; return Effect.gen(function* () { yield* stubManagementApi(requests, serveLinkedDatabase(VALID_REF)); yield* seedMigration(tmp.current, "0_init.sql"); yield* migrationSquash( - flags({ - linked: true, - local: false, - password: Option.some("pw"), - projectRef: Option.some(VALID_REF), - }), + flags({ password: Option.some("pw"), projectRef: Option.some(VALID_REF) }), ); expect(requests.map(({ method, path }) => `${method} ${path}`)).toEqual([ `GET /v1/projects/${VALID_REF}/config/database/pooler`, @@ -1492,7 +1438,7 @@ describe("migration squash", () => { describe("real resolver on the local target", () => { it.effect("defaults to the local target and makes no Management API request", () => { - const s = setup(tmp.current, { args: ["migration", "squash"], realLayers: true }); + const s = setup(tmp.current, { realLayers: true }); const requests: Array<StubbedRequest> = []; return Effect.gen(function* () { yield* stubManagementApi(requests, () => undefined); @@ -1527,7 +1473,7 @@ describe("migration squash", () => { describe("output formats", () => { it.effect("json emits the squash payload on stdout and keeps progress on stderr", () => { - const s = setup(tmp.current, { format: "json", isLocal: true }); + const s = setup(tmp.current, { format: "json" }); return Effect.gen(function* () { yield* seedMigration(tmp.current, "0_init.sql"); yield* migrationSquash(flags()); @@ -1549,7 +1495,7 @@ describe("migration squash", () => { }); it.effect("json suppresses the Finished line and the repair suggestion", () => { - const s = setup(tmp.current, { format: "json", isLocal: true }); + const s = setup(tmp.current, { format: "json" }); return Effect.gen(function* () { yield* seedMigration(tmp.current, "0_init.sql"); yield* migrationSquash(flags()); @@ -1559,7 +1505,7 @@ describe("migration squash", () => { }); it.effect("stream-json emits the result event on stdout with progress lines on stderr", () => { - const s = setup(tmp.current, { format: "stream-json", isLocal: true }); + const s = setup(tmp.current, { format: "stream-json" }); return Effect.gen(function* () { yield* seedMigration(tmp.current, "0_init.sql"); yield* migrationSquash(flags()); @@ -1568,45 +1514,10 @@ describe("migration squash", () => { }).pipe(Effect.provide(s.layer)); }); - it.effect("json still writes the prompt label to stderr and reads the piped answer", () => { - const s = setup(tmp.current, { - format: "json", - isLocal: false, - linkedRef: VALID_REF, - confirm: true, - }); - return Effect.gen(function* () { - yield* seedMigration(tmp.current, "0_init.sql"); - yield* migrationSquash(flags()); - expect(stderr(s.out)).toContain("Update remote migration history table? [Y/n] "); - expect(s.execs).toContain("BEGIN"); - }).pipe(Effect.provide(s.layer)); - }); - - it.effect( - "json on the declined-prompt path reports success with baselinedVersion: null", - () => { - const s = setup(tmp.current, { - format: "json", - isLocal: false, - linkedRef: VALID_REF, - confirm: false, - }); - return Effect.gen(function* () { - yield* seedMigration(tmp.current, "0_init.sql"); - yield* migrationSquash(flags()); - const success = s.out.messages.find((m) => m.type === "success"); - expect(success?.data).toMatchObject({ isLocal: false, baselinedVersion: null }); - }).pipe(Effect.provide(s.layer)); - }, - ); - - it.effect("json on the remote-confirmed 2-migration path reports the full real payload", () => { + it.effect("json on the hosted 2-migration path reports the full real payload", () => { const s = setup(tmp.current, { format: "json", - isLocal: false, - linkedRef: VALID_REF, - confirm: true, + yes: true, beforeDumpSql: "before;\n", afterDumpSql: "after;\n", fullDumpSql: "full;\n", @@ -1614,7 +1525,7 @@ describe("migration squash", () => { return Effect.gen(function* () { yield* seedMigration(tmp.current, "0_init.sql", "create table a (id int);\n"); yield* seedMigration(tmp.current, "1_target.sql", "create table b (id int);\n"); - yield* migrationSquash(flags()); + yield* migrationSquash(flags({ linked: true })); const success = s.out.messages.find((m) => m.type === "success"); // "1_target.sql" is the sole surviving file once "0_init.sql" is removed, so the // empty-version baseline reload (run after the removal) resolves to its own @@ -1631,3 +1542,24 @@ describe("migration squash", () => { }); }); }); + +describeHostedConsent("migration squash", { + setup: (workdir) => + Effect.gen(function* () { + yield* seedMigration(workdir, "0_init.sql", "create table a (id int);\n"); + yield* seedMigration(workdir, "1_target.sql", "create table b (id int);\n"); + }).pipe(Effect.provide(Path.layer)), + invoke: ({ workdir }) => { + const fake = fakeServices(workdir); + const historyWrites = () => + fake.statements.filter( + (entry) => + entry.sql.includes("INSERT INTO supabase_migrations") || + entry.sql.includes("DELETE FROM supabase_migrations"), + ).length; + return { + run: migrationSquash(flags({ linked: true })).pipe(Effect.provide(fake.services)), + mutations: () => fake.shadowSpawned.length + historyWrites(), + }; + }, +}); 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 8aa25fd4fc..a38accd133 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 @@ -9,16 +9,18 @@ import { 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 { mockOutput, mockRuntimeInfo, mockTty } from "../../../../tests/helpers/mocks.ts"; import { CliArgs } from "../../../shared/cli/cli-args.service.ts"; import { DebugFlag, DnsResolverFlag, ExperimentalFlag, NetworkIdFlag, + OutputFlag, YesFlag, } from "../../../command-internal/global-flags.ts"; import { DbConfigResolver } from "../../../command-internal/db-config.service.ts"; +import type { DbConnectionInput } from "../../../command-internal/db-config.types.ts"; import { dbConnectionLayer } from "../../../command-internal/db-connection.sql-pg.layer.ts"; import { DebugLogger } from "../../../shared/output/debug-logger.service.ts"; import { DockerRun } from "../../../command-internal/docker-run.service.ts"; @@ -26,6 +28,7 @@ import { StackApi, stackApiLayer } from "../../../command-internal/stack-api.ts" import { stackBackendLayer } from "../../../command-internal/stack-backend.ts"; import { stackCatalogSetupLayer } from "../../../command-internal/stack-catalog-setup.ts"; import { BundledPostgresClient } from "../../../command-internal/bundled-postgres-client.ts"; +import { TargetPolicy } from "../../../command-internal/target/target-policy.ts"; import { ProjectRefResolver } from "../../../config/project-ref.service.ts"; import { migrationSquash } from "./squash.handler.ts"; import type { MigrationSquashFlags } from "./squash.command.ts"; @@ -62,7 +65,8 @@ const testLayer = (root: string) => mockRuntimeInfo(), Layer.succeed(CliArgs, { args: [] }), Layer.succeed(DbConfigResolver, { - resolve: () => + resolve: () => Effect.die("resolve is not used by this test"), + resolveConnection: (input: DbConnectionInput) => Effect.succeed({ conn: { host: "127.0.0.1", @@ -72,8 +76,8 @@ const testLayer = (root: string) => database: "postgres", }, isLocal: true, + target: input.target, }), - resolveConnection: () => Effect.die("resolveConnection is not used by this test"), resolvePoolerFallback: () => Effect.succeed(Option.none()), }), Layer.succeed(DebugFlag, false), @@ -81,6 +85,8 @@ const testLayer = (root: string) => Layer.succeed(ExperimentalFlag, false), Layer.succeed(NetworkIdFlag, Option.none()), Layer.succeed(YesFlag, true), + Layer.succeed(OutputFlag, Option.none()), + Layer.succeed(TargetPolicy, { group: "selectable", consent: "no" }), Layer.succeed(DebugLogger, { debug: () => Effect.void, http: () => Effect.void }), Layer.succeed(ProjectRefResolver, { resolve: () => Effect.succeed("abcdefghijklmnopqrst"), @@ -91,7 +97,7 @@ const testLayer = (root: string) => loadProjectRef: () => Effect.succeed("abcdefghijklmnopqrst"), promptProjectRef: () => Effect.succeed("abcdefghijklmnopqrst"), }), - mockStdin(false), + mockTty(), Layer.succeed(BundledPostgresClient, { run: () => Effect.die("unused") }), Layer.succeed(DockerRun, { run: () => Effect.die("unused"), diff --git a/apps/cli/src/commands/migration/up/SIDE_EFFECTS.md b/apps/cli/src/commands/migration/up/SIDE_EFFECTS.md index b1557d03d7..cc375d4a5e 100644 --- a/apps/cli/src/commands/migration/up/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/migration/up/SIDE_EFFECTS.md @@ -2,11 +2,12 @@ ## Files Read -| Path | Format | When | -| -------------------------------------- | ---------- | ----------------------------------------------------------------------------------------------- | -| `<workdir>/supabase/migrations/` | directory | always, to read pending migration files | -| `~/.supabase/access-token` | plain text | when `SUPABASE_ACCESS_TOKEN` unset and `--linked` | -| `<workdir>/supabase/.temp/project-ref` | plain text | `--linked`, to resolve the ref — skipped when `--project-ref` (or `SUPABASE_PROJECT_ID`) is set | +| Path | Format | When | +| -------------------------------------- | ---------- | -------------------------------------------------------------------------------------------------- | +| `<workdir>/supabase/migrations/` | directory | always, to read pending migration files | +| `<workdir>/supabase/config.toml` | TOML | always, for the workspace `target` (flag > `SUPABASE_TARGET` > project `.env*` > config) | +| `~/.supabase/access-token` | plain text | when `SUPABASE_ACCESS_TOKEN` unset and the target is linked | +| `<workdir>/supabase/.temp/project-ref` | plain text | linked target, to resolve the ref — skipped when `--project-ref` (or `SUPABASE_PROJECT_ID`) is set | ## Files Written @@ -22,25 +23,29 @@ ## Environment Variables -| Variable | Purpose | Required? | -| ------------------------ | --------------------------------------------------------------------------------- | ------------------------------------------------------- | -| `SUPABASE_ACCESS_TOKEN` | auth token for `--linked` mode | no (falls back to keyring → `~/.supabase/access-token`) | -| `DOTENV_PRIVATE_KEY[_*]` | dotenvx private key(s) to decrypt `encrypted:` `[db.vault]` secrets before upsert | no (required only if a `[db.vault]` value is encrypted) | +| Variable | Purpose | Required? | +| ------------------------ | ------------------------------------------------------------------------------------ | ------------------------------------------------------- | +| `SUPABASE_ACCESS_TOKEN` | auth token for a linked target | no (falls back to keyring → `~/.supabase/access-token`) | +| `SUPABASE_TARGET` | workspace target (`local` or `remote`) when no selector flag is given | no | +| `SUPABASE_YES` | confirms a hosted run when exported in the shell (the project `.env` does not count) | no | +| `DOTENV_PRIVATE_KEY[_*]` | dotenvx private key(s) to decrypt `encrypted:` `[db.vault]` secrets before upsert | no (required only if a `[db.vault]` value is encrypted) | ## Exit Codes -| Code | Condition | -| ---- | ------------------------------------------------------------------------ | -| `0` | success | -| `1` | database connection failure | -| `1` | migration SQL execution error | -| `1` | `--project-ref` set with a resolved target other than linked (see Notes) | +| Code | Condition | +| ---- | ------------------------------------------------------------------------------------------------ | +| `0` | success | +| `1` | database connection failure | +| `1` | migration SQL execution error | +| `1` | conflicting target selectors (see Notes) | +| `1` | hosted target without `--yes` in a run that cannot prompt (`HostedMutationConsentRequiredError`) | +| `1` | hosted confirmation declined (`context canceled`) | ## Output ### `--output-format text` -Prints `Applying migration <file>...` to stderr per pending migration, then +Prints `Target: …` to stderr, then `Applying migration <file>...` per pending migration, then `Local database is up to date.` to stdout. Connects, lists remote + local migrations, computes the pending set, upserts `[db.vault]` secrets, and applies each pending migration transactionally. Does **not** seed. @@ -53,15 +58,23 @@ Emits `output.success("Migrations applied", { applied: [<path>] })`. Same structured `applied` result delivered as an NDJSON `result` event. +## Prompts + +- A hosted target (the linked project or a non-local `--db-url`) asks once, after the pending set + is computed and before the first SQL or vault write, listing each migration to apply and the + vault secrets to update (default **NO**). Nothing is asked when there is nothing to apply. Only + `--yes` or an exported `SUPABASE_YES` approves a run that cannot prompt; a piped answer and a + `SUPABASE_YES` in the project `.env` do not. Declining exits 1 (`context canceled`). +- A local target never prompts. + ## Notes -- `--local` (default true), `--linked`, and `--db-url` 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 - a non-linked target). +- **Target.** With no selector the command follows the workspace target: the local database, or the + linked project when `target = "remote"` is set in `supabase/config.toml` (or `SUPABASE_TARGET=remote` + is exported). `--local`, `--linked` and `--db-url` are mutually exclusive and `--linked=false` + selects nothing. `--project-ref` implies `--linked` and is rejected with `--local` or `--db-url`; + an empty `--db-url` or `--project-ref` is rejected. The resolved target is announced on stderr + (`Target: …`) in every output format and, in JSON results, as a `target` object. - `--include-all` applies all migrations not found on the remote history table. - Pipeline-incompatible statements (`CREATE [UNIQUE] INDEX CONCURRENTLY`, `REINDEX … CONCURRENTLY`, `VACUUM`, `ALTER SYSTEM`, `CLUSTER`) run standalone outside diff --git a/apps/cli/src/commands/migration/up/up.handler.ts b/apps/cli/src/commands/migration/up/up.handler.ts index 58b8ea4793..18a72e6ced 100644 --- a/apps/cli/src/commands/migration/up/up.handler.ts +++ b/apps/cli/src/commands/migration/up/up.handler.ts @@ -106,7 +106,9 @@ const runUp = Effect.fnUntraced(function* (flags: MigrationUpFlags) { details: [ ...pending.map((migrationPath) => `Apply migration ${path.basename(migrationPath)}`), ...(vaultSecrets.length > 0 - ? [`Update ${vaultSecrets.length} vault ${vaultSecrets.length === 1 ? "secret" : "secrets"}`] + ? [ + `Update ${vaultSecrets.length} vault ${vaultSecrets.length === 1 ? "secret" : "secrets"}`, + ] : []), ], }); 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 237d3b10e0..4942645780 100644 --- a/apps/cli/src/commands/migration/up/up.integration.test.ts +++ b/apps/cli/src/commands/migration/up/up.integration.test.ts @@ -351,17 +351,23 @@ describe("migration up", () => { }).pipe(Effect.provide(layer)); }); - it.effect("applies nothing and asks for no consent on a linked project that is up to date", () => { - const { layer, out, queries } = setup(tmp.current, { format: "json", remote: ["20240101000000"] }); - return Effect.gen(function* () { - yield* seed(tmp.current, "20240101000000_a.sql"); - yield* migrationUp(flags({ linked: true })); - expect(insertedVersions(queries)).toEqual([]); - expect(out.messages).toContainEqual( - expect.objectContaining({ type: "success", message: "Migrations applied" }), - ); - }).pipe(Effect.provide(layer)); - }); + it.effect( + "applies nothing and asks for no consent on a linked project that is up to date", + () => { + const { layer, out, queries } = setup(tmp.current, { + format: "json", + remote: ["20240101000000"], + }); + return Effect.gen(function* () { + yield* seed(tmp.current, "20240101000000_a.sql"); + yield* migrationUp(flags({ linked: true })); + expect(insertedVersions(queries)).toEqual([]); + expect(out.messages).toContainEqual( + expect.objectContaining({ type: "success", message: "Migrations applied" }), + ); + }).pipe(Effect.provide(layer)); + }, + ); it.effect("emits a structured result in json", () => { const { layer, out } = setup(tmp.current, { format: "json", remote: [] }); diff --git a/apps/cli/src/commands/migration/up/up.live.test.ts b/apps/cli/src/commands/migration/up/up.live.test.ts index cc3bb27e72..5b43155a98 100644 --- a/apps/cli/src/commands/migration/up/up.live.test.ts +++ b/apps/cli/src/commands/migration/up/up.live.test.ts @@ -51,7 +51,7 @@ test("applies a test-written migration to the remote database", ({ ); const target = Effect.gen(function* () { - const applied = yield* cliEffect(["migration", "up", "--db-url", project.dbUrl]); + const applied = yield* cliEffect(["migration", "up", "--yes", "--db-url", project.dbUrl]); expect(applied.exitCode, applied.stderr).toBe(0); expect(applied.stderr, applied.stdout).toContain("Applying migration"); @@ -91,6 +91,7 @@ test("applies a test-written migration to the remote database", ({ version, "--status", "reverted", + "--yes", "--db-url", project.dbUrl, ]); From ef1ab50bf5c4cfa4172ec53db07638a7a3ddda6d Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Thu, 15 Oct 2026 14:33:37 +0100 Subject: [PATCH 38/92] feat(cli): add the linked target identity to config diff and pull JSON --- apps/cli/src/commands/config/config.target.ts | 14 +++++++++++ .../src/commands/config/diff/diff.handler.ts | 5 +++- .../config/diff/diff.integration.test.ts | 24 ++++++++++++++++++- .../src/commands/config/pull/pull.handler.ts | 11 +++++++-- .../config/pull/pull.integration.test.ts | 19 +++++++++++++++ 5 files changed, 69 insertions(+), 4 deletions(-) create mode 100644 apps/cli/src/commands/config/config.target.ts diff --git a/apps/cli/src/commands/config/config.target.ts b/apps/cli/src/commands/config/config.target.ts new file mode 100644 index 0000000000..128c51ccba --- /dev/null +++ b/apps/cli/src/commands/config/config.target.ts @@ -0,0 +1,14 @@ +import { Effect, Option } from "effect"; + +import { CommandTarget } from "../../shared/output/command-target.service.ts"; +import { configIsRecord } from "./config.paths.ts"; + +/** Puts the announced target's identity fields (`kind`, `project_name`, `environment`, …) in front of the payload's own `target` fields, which win on a clash. */ +export const configPayloadWithTargetIdentity = Effect.fnUntraced(function* ( + payload: Record<string, unknown>, +) { + const announced = yield* CommandTarget.use((cell) => cell.json); + if (Option.isNone(announced)) return payload; + const own = configIsRecord(payload["target"]) ? payload["target"] : {}; + return { ...payload, target: { ...announced.value, ...own } }; +}); diff --git a/apps/cli/src/commands/config/diff/diff.handler.ts b/apps/cli/src/commands/config/diff/diff.handler.ts index ba03689193..adb6236428 100644 --- a/apps/cli/src/commands/config/diff/diff.handler.ts +++ b/apps/cli/src/commands/config/diff/diff.handler.ts @@ -32,6 +32,7 @@ import { } from "../config.load.ts"; import { configApiScope, configEnvOriginLookup, configScopeLine } from "../config.format.ts"; import { configProjectConfigTry } from "../config.project-config.ts"; +import { configPayloadWithTargetIdentity } from "../config.target.ts"; import { configReadStatusMessage } from "../config.read-status.ts"; import { configDiffComparisonLine, @@ -202,7 +203,9 @@ export const configDiff = Effect.fn("config.diff")(function* (flags: ConfigDiffF if (output.format !== "text") { yield* output.success( configDiffSummaryMessage(changeSet, scope), - configDiffPayload(changeSet, scope, context, originFor), + yield* configPayloadWithTargetIdentity( + configDiffPayload(changeSet, scope, context, originFor), + ), ); } else { yield* output.raw(renderConfigDiffText(changeSet, scope, originFor)); 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 542adc9052..df3762d801 100644 --- a/apps/cli/src/commands/config/diff/diff.integration.test.ts +++ b/apps/cli/src/commands/config/diff/diff.integration.test.ts @@ -959,8 +959,10 @@ describe("config diff integration", () => { expect(success).toBeDefined(); expect(success?.message).toContain("1 config difference found."); const data = yield* decodeJsonRecord(success?.data); - expect(data["target"]).toMatchObject({ + expect(data["target"]).toEqual({ + kind: "linked", project_ref: VALID_REF, + environment: "unverified", local_scope: "base", }); // schema_version is the payload contract's version; the user's $schema reference travels @@ -1030,6 +1032,26 @@ describe("config diff integration", () => { }).pipe(Effect.provide(layer)); }); + it.effect("json target for a branch name carries the branch, its parent and the identity", () => { + const { layer, out } = setup({ + toml: 'project_id = "test"\n', + format: "json", + }); + return Effect.gen(function* () { + yield* configDiff({ ...noFlags, projectRef: Option.some("staging") }); + const success = out.messages.find((message) => message.type === "success"); + const data = yield* decodeJsonRecord(success?.data); + expect(data["target"]).toEqual({ + kind: "linked", + project_ref: BRANCH_REF, + environment: "branch", + parent_project_ref: VALID_REF, + branch: "staging", + local_scope: "base", + }); + }).pipe(Effect.provide(layer)); + }); + it.effect("json payload carries the remotes scope and env variable annotations", () => { const { layer, out } = setup({ toml: [ diff --git a/apps/cli/src/commands/config/pull/pull.handler.ts b/apps/cli/src/commands/config/pull/pull.handler.ts index 72bea220c8..8d3ff53191 100644 --- a/apps/cli/src/commands/config/pull/pull.handler.ts +++ b/apps/cli/src/commands/config/pull/pull.handler.ts @@ -29,6 +29,7 @@ import { type ConfigPullOutcome, type ConfigPullSource, } from "../../../command-internal/config-pull-run.ts"; +import { configPayloadWithTargetIdentity } from "../config.target.ts"; import { configPullCreatedBlockLabel, configPullSummaryMessage } from "./pull.format.ts"; import type { ConfigPullPlan } from "./pull.plan.ts"; import { @@ -111,9 +112,15 @@ export const runConfigPull = Effect.fn("config.pull.run")(function* (input: Conf // keeps them. const emitOutcome = (planForOutput: ConfigPullPlan, outcome: ConfigPullOutcome) => output.format !== "text" - ? output.success( - configPullSummaryMessage(changeSet, scope, planForOutput, outcome), + ? configPayloadWithTargetIdentity( configPullPayload(changeSet, scope, planForOutput, context, outcome), + ).pipe( + Effect.flatMap((payload) => + output.success( + configPullSummaryMessage(changeSet, scope, planForOutput, outcome), + payload, + ), + ), ) : output.raw( `${configPullSummaryMessage(changeSet, scope, planForOutput, outcome, { withCaveats: false })}\n`, 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 e77747e51e..497c67e0ae 100644 --- a/apps/cli/src/commands/config/pull/pull.integration.test.ts +++ b/apps/cli/src/commands/config/pull/pull.integration.test.ts @@ -501,6 +501,25 @@ describe("config pull integration", () => { }).pipe(Effect.provide(layer)); }); + it.effect("the json target carries the linked identity alongside the scope fields", () => { + const { layer, out } = setup({ + toml: 'project_id = "test"\n[api]\nmax_rows = 500\n', + format: "json", + yes: true, + }); + return Effect.gen(function* () { + yield* configPull({ ...noFlags, dryRun: true }); + const success = out.messages.find((message) => message.type === "success"); + const data = yield* decodeJsonRecord(success?.data); + expect(data["target"]).toEqual({ + kind: "linked", + project_ref: VALID_REF, + environment: "unverified", + local_scope: "base", + }); + }).pipe(Effect.provide(layer)); + }); + it.effect("a branch-named target creates a new [remotes.<name>] block at EOF", () => { const before = 'project_id = "test"\n'; const { layer } = setup({ From 52f189c253cf1decb03533a291f5599126653ca5 Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Thu, 15 Oct 2026 14:34:23 +0100 Subject: [PATCH 39/92] test(cli): restore the ResolvedDbConfig import in the inspect specs test --- .../commands/inspect/db/inspect-specs.integration.test.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/cli/src/commands/inspect/db/inspect-specs.integration.test.ts b/apps/cli/src/commands/inspect/db/inspect-specs.integration.test.ts index 36cc4c6b9f..19e8cf6f19 100644 --- a/apps/cli/src/commands/inspect/db/inspect-specs.integration.test.ts +++ b/apps/cli/src/commands/inspect/db/inspect-specs.integration.test.ts @@ -10,7 +10,10 @@ import { import { configValuesLayer } from "../../../../tests/helpers/config-values-layer.ts"; import { YesFlag } from "../../../command-internal/global-flags.ts"; import { DbConfigResolver } from "../../../command-internal/db-config.service.ts"; -import type { DbConnectionInput } from "../../../command-internal/db-config.types.ts"; +import type { + DbConnectionInput, + ResolvedDbConfig, +} from "../../../command-internal/db-config.types.ts"; import { TargetPolicy } from "../../../command-internal/target/target-policy.ts"; import { DbConnection, type PgConnInput } from "../../../command-internal/db-connection.service.ts"; import { bloatSpec } from "./bloat/bloat.query.ts"; From 0967a0c9ddfc3834349c926502f4683279a4c520 Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Thu, 15 Oct 2026 14:34:23 +0100 Subject: [PATCH 40/92] feat(cli): resolve the gen types target from the workspace target (CLI-2695) --- .../src/commands/gen/types/types.command.ts | 40 +- .../src/commands/gen/types/types.e2e.test.ts | 4 +- .../src/commands/gen/types/types.handler.ts | 138 +++--- .../gen/types/types.integration.test.ts | 467 +++++++++++------- 4 files changed, 389 insertions(+), 260 deletions(-) diff --git a/apps/cli/src/commands/gen/types/types.command.ts b/apps/cli/src/commands/gen/types/types.command.ts index b4b66c6ed6..d249549aa8 100644 --- a/apps/cli/src/commands/gen/types/types.command.ts +++ b/apps/cli/src/commands/gen/types/types.command.ts @@ -1,3 +1,4 @@ +import { Option } from "effect"; import { Argument, Command, Flag, Param } from "effect/cli"; import type * as CliCommand from "effect/cli/Command"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; @@ -13,25 +14,16 @@ 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"; +import { + deprecatedProjectIdFlag, + targetFlags, +} from "../../../command-internal/target/target-flags.ts"; import { withTargetPolicy } from "../../../command-internal/target/target-policy.ts"; +import { PROJECT_REF_PATTERN } from "../../../config/project-ref.service.ts"; const config = { - local: Flag.Boolean("local").pipe( - Flag.withDescription("Generate types from the local dev database."), - Flag.withDefault(false), - ), - linked: Flag.Boolean("linked").pipe( - Flag.withDescription("Generate types from the linked project."), - Flag.withDefault(false), - ), - dbUrl: Flag.String("db-url").pipe( - Flag.withDescription("Generate types from a database url."), - Flag.optional, - ), - projectId: Flag.String("project-id").pipe( - Flag.withDescription("Generate types from a project ID."), - Flag.optional, - ), + ...targetFlags({ group: "selectable", subject: "database", dbUrl: true }), + projectId: deprecatedProjectIdFlag, lang: Flag.Literals("lang", GEN_TYPES_LANGUAGES).pipe( Flag.withDescription("Output language of the generated types. (default typescript)"), Flag.withDefault("typescript"), @@ -62,6 +54,7 @@ const GEN_TYPES_RESERVED_FLAG_NAMES: ReadonlyArray<string> = [ "local", "linked", "db-url", + "project-ref", "project-id", "lang", "schema", @@ -104,8 +97,8 @@ export const genTypesCommand = Command.make("types", commandConfig).pipe( description: "Generate Go types from the linked project", }, { - command: "supabase gen types --project-id abc-def-123 --schema public --schema private", - description: "Generate types from a project ID with specific schemas", + command: "supabase gen types --project-ref abc-def-123 --schema public --schema private", + description: "Generate types from a project ref with specific schemas", }, { command: "supabase gen types --db-url 'postgresql://...' --schema public --schema auth", @@ -114,7 +107,16 @@ export const genTypesCommand = Command.make("types", commandConfig).pipe( ]), Command.withHandler((flags) => genTypes(flags).pipe( - withCommandTelemetry({ flags, safeFlags: ["project-id"], config: flagsConfig }), + withCommandTelemetry({ + flags, + safeFlags: [ + "project-id", + ...(Option.isSome(flags.projectRef) && PROJECT_REF_PATTERN.test(flags.projectRef.value) + ? ["project-ref"] + : []), + ], + config: flagsConfig, + }), withJsonErrorHandling, ), ), diff --git a/apps/cli/src/commands/gen/types/types.e2e.test.ts b/apps/cli/src/commands/gen/types/types.e2e.test.ts index 78997f9979..810815a6cd 100644 --- a/apps/cli/src/commands/gen/types/types.e2e.test.ts +++ b/apps/cli/src/commands/gen/types/types.e2e.test.ts @@ -387,7 +387,7 @@ describe("gen types e2e", () => { [ "gen", "types", - "--project-id", + "--project-ref", remoteProjectRef, "--lang", lang, @@ -401,7 +401,7 @@ describe("gen types e2e", () => { exitTimeoutMs: TYPEGEN_TIMEOUT_MS, }, ); - expectSucceeded(`supabase gen types --project-id <ref> --lang ${lang}`, result); + expectSucceeded(`supabase gen types --project-ref <ref> --lang ${lang}`, result); expectLanguageShape(lang, result.stdout); } }), diff --git a/apps/cli/src/commands/gen/types/types.handler.ts b/apps/cli/src/commands/gen/types/types.handler.ts index 3f802fae25..fda7109883 100644 --- a/apps/cli/src/commands/gen/types/types.handler.ts +++ b/apps/cli/src/commands/gen/types/types.handler.ts @@ -2,6 +2,7 @@ import { ChildProcessSpawner } from "effect/process"; import { Effect, FileSystem, Match, Option, Path, Predicate, Stdio, Stream } from "effect"; import { getDomain } from "tldts"; import { DnsResolverFlag } from "../../../command-internal/global-flags.ts"; +import { announceTarget } from "../../../shared/output/command-target.service.ts"; import { Output } from "../../../shared/output/output.service.ts"; import { mutuallyExclusiveFlagsMessage, @@ -12,10 +13,6 @@ import { 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, -} from "../../../config/project-ref.service.ts"; import { spawnContainerCli } from "../../../command-internal/container-cli.ts"; import { isIPv6ConnectivityErrorCause } from "../../../command-internal/connect-errors.ts"; import { isDockerDaemonUnreachable } from "../../../command-internal/docker-suggest.ts"; @@ -28,6 +25,10 @@ import type { DbConfigFlags } from "../../../command-internal/db-config.types.ts import { poolerConfigFromConnectionString } from "../../../command-internal/db-config.parse.ts"; import { readDbToml } from "../../../command-internal/db-config.toml-read.ts"; import { getHostname } from "../../../command-internal/hostname.ts"; +import { hostedMutationPreapproval } from "../../../command-internal/hosted-consent/hosted-consent.ts"; +import { resolveTarget, type ResolvedTarget } from "../../../command-internal/target/resolve-target.ts"; +import { renderLocalTarget } from "../../../command-internal/target/target-identity.ts"; +import { TargetSelectorConflictError } from "../../../command-internal/target/target.errors.ts"; import type { DbConnectError } from "../../../command-internal/db-connection.errors.ts"; import type { PgConnInput } from "../../../command-internal/db-connection.service.ts"; import { tempPaths } from "../../../shared/config/temp-paths.ts"; @@ -112,16 +113,15 @@ function isPoolerHost(host: string, poolerHost: string): boolean { const GEN_TYPES_COMMAND_PATH = ["gen", "types"] as const; -// Validation reports only the first violated group, in this listed order — e.g. `--db-url X -// --postgrest-v9-compat --project-id Y` reports the postgrest group, not the -// local/linked/project-id/db-url group. +// Validation reports only the first violated group, in this listed order. const GEN_TYPES_MUTEX_GROUPS: ReadonlyArray<ReadonlyArray<string>> = [ - ["linked", "project-id", "postgrest-v9-compat"], - ["linked", "project-id", "query-timeout"], - ...GEN_TYPES_LANGUAGE_FLAG_NAMES.map((name) => ["linked", "project-id", name]), - ["local", "linked", "project-id", "db-url"], + ["linked", "project-ref", "postgrest-v9-compat"], + ["linked", "project-ref", "query-timeout"], + ...GEN_TYPES_LANGUAGE_FLAG_NAMES.map((name) => ["linked", "project-ref", name]), ]; +const PROJECT_ID_DEPRECATION_LINE = "--project-id is deprecated; use --project-ref instead."; + const POSTGREST_V9_COMPAT_DEPRECATION_LINE = "Flag --postgrest-v9-compat has been deprecated, PostgREST 9 reached end of life; the flag still disables one-to-one relationship detection."; @@ -133,6 +133,7 @@ const POSTGREST_V9_COMPAT_DEPRECATION_LINE = const GEN_TYPES_SCAN_SPEC = { valueFlagNames: new Set([ "db-url", + "project-ref", "project-id", "lang", "schema", @@ -159,6 +160,7 @@ function collectByteStream<E>(stream: Stream.Stream<Uint8Array, E>) { // positional language argument (e.g. `gen types typescript`). const LONG_FLAGS_WITH_VALUES = new Set([ "db-url", + "project-ref", "project-id", "lang", "schema", @@ -229,7 +231,6 @@ export const genTypes = Effect.fn("gen.types")(function* (flags: GenTypesFlags) const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const rawArgs = yield* stdio.args; const platformApi = yield* CommandPlatformApiFactory; - const projectRef = yield* ProjectRefResolver; const linkedProjectCache = yield* LinkedProjectCache; const dbConfig = yield* DbConfigResolver; const configValues = yield* CliConfigValues; @@ -252,8 +253,8 @@ export const genTypes = Effect.fn("gen.types")(function* (flags: GenTypesFlags) const toRelativeConfigPath = (path: string) => relativeConfigPath(cliSettings.workdir, path); - // `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`. + // `projectRef` is passed only for the linked path, so a matching `[remotes.*]` overlay is + // merged in the same load; omitted for the local and `--db-url` paths. const loadApiSchemas = (projectRef?: string) => configValues .load({ @@ -397,14 +398,21 @@ export const genTypes = Effect.fn("gen.types")(function* (flags: GenTypesFlags) yield* output.raw(types); }); + const connectTo = Effect.fnUntraced(function* (target: ResolvedTarget) { + return yield* dbConfig.resolveConnection({ + target, + dnsResolver, + password: Option.none(), + networkBanRemoval: yield* hostedMutationPreapproval, + }); + }); + const runProjectTypes = ( - projectRef: string, + target: Extract<ResolvedTarget, { readonly kind: "linked" }>, includedSchemas: ReadonlyArray<string>, - // True for an explicit `--project-id <ref>` (an ad-hoc remote project that may - // differ from the current workdir); false for `--linked` / the linked fallback. - adHocProjectRef: boolean, - ) => - Effect.gen(function* () { + ) => { + const projectRef = target.ref; + return Effect.gen(function* () { yield* Effect.annotateCurrentSpan("project.ref", projectRef); const api = yield* platformApi.make; @@ -418,15 +426,15 @@ export const genTypes = Effect.fn("gen.types")(function* (flags: GenTypesFlags) ); if (projectResult === "branch") return; - const resolveFlags: DbConfigFlags = { + const resolved = yield* connectTo(target); + const conn = pinSupabaseTls(resolved.conn); + const poolerFlags: DbConfigFlags = { dbUrl: Option.none(), connType: "linked", dnsResolver, linkedProjectRef: Option.some(projectRef), - adHocProjectRef, + adHocProjectRef: !target.matchesLinkedRef, }; - const resolved = yield* dbConfig.resolve(resolveFlags); - const conn = pinSupabaseTls(resolved.conn); yield* runGenerate({ conn, isLocal: resolved.isLocal, @@ -436,7 +444,7 @@ export const genTypes = Effect.fn("gen.types")(function* (flags: GenTypesFlags) directHost: conn.host, eligible: !resolved.isLocal && isDirectDbHost(conn.host, cliSettings.projectHost), resolve: dbConfig - .resolvePoolerFallback(resolveFlags) + .resolvePoolerFallback(poolerFlags) .pipe(Effect.map(Option.map(pinSupabaseTls))), }, }); @@ -452,6 +460,7 @@ export const genTypes = Effect.fn("gen.types")(function* (flags: GenTypesFlags) yield* output.raw(response.types); }).pipe(Effect.ensuring(linkedProjectCache.cache(projectRef))); + }; const runPreviewBranchTypes = (branchRef: string, includedSchemas: ReadonlyArray<string>) => Effect.gen(function* () { @@ -566,6 +575,17 @@ export const genTypes = Effect.fn("gen.types")(function* (flags: GenTypesFlags) yield* output.raw(`${POSTGREST_V9_COMPAT_DEPRECATION_LINE}\n`, "stderr"); } + if (Option.isSome(flags.projectId)) { + yield* output.raw(`${PROJECT_ID_DEPRECATION_LINE}\n`, "stderr"); + if (Option.isSome(flags.projectRef)) { + return yield* new TargetSelectorConflictError({ + flags: ["--project-id", "--project-ref"], + message: "--project-id is a deprecated alias of --project-ref; pass only one of them.", + }); + } + } + const projectRefFlag = Option.orElse(flags.projectRef, () => flags.projectId); + // This guard runs before flag-group validation, so its error wins when both apply. Both // run after the telemetry context is installed, so every return here must stay inside the // `Effect.ensuring(telemetryState.flush)` below. @@ -587,14 +607,10 @@ export const genTypes = Effect.fn("gen.types")(function* (flags: GenTypesFlags) }); } - // A flag counts as set once passed explicitly, regardless of value (`--linked=false` - // still trips its group). `project-id`/`db-url` are read straight off parsed flags since - // they have no boolean-vs-default ambiguity. + // `--linked=false` counts as absent; every other flag counts once passed, whatever its value. const changedMutexFlags: Record<string, boolean> = { - local: occurrences.has("local"), - linked: occurrences.has("linked"), - "project-id": Option.isSome(flags.projectId), - "db-url": Option.isSome(flags.dbUrl), + linked: flags.linked, + "project-ref": Option.isSome(projectRefFlag), "postgrest-v9-compat": occurrences.has("postgrest-v9-compat"), "query-timeout": occurrences.has("query-timeout"), ...Object.fromEntries( @@ -612,7 +628,14 @@ export const genTypes = Effect.fn("gen.types")(function* (flags: GenTypesFlags) yield* Effect.annotateCurrentSpan("typegen.lang", lang); - if (flags.local) { + const target = yield* resolveTarget({ + local: flags.local, + linked: flags.linked, + dbUrl: flags.dbUrl, + projectRef: projectRefFlag, + }); + + if (target.kind === "local") { yield* Effect.annotateCurrentSpan({ "typegen.source": "local", "stack.backend": backend.kind, @@ -633,11 +656,7 @@ export const genTypes = Effect.fn("gen.types")(function* (flags: GenTypesFlags) const includedSchemas = schemas.length > 0 ? schemas : defaultSchemas(config.apiSchemas); if (backend.kind === "stack") { - const resolved = yield* dbConfig.resolve({ - dbUrl: Option.none(), - connType: "local", - dnsResolver, - }); + const resolved = yield* connectTo(target); yield* runGenerate({ conn: resolved.conn, isLocal: true, @@ -648,11 +667,13 @@ export const genTypes = Effect.fn("gen.types")(function* (flags: GenTypesFlags) } yield* assertLocalDbRunning(projectId); + const host = yield* getHostname( + yield* configValues.load({ workdir: cliSettings.workdir, projectRef: Option.none() }), + ); + yield* announceTarget(renderLocalTarget(host, config.port)); yield* runGenerate({ conn: { - host: yield* getHostname( - yield* configValues.load({ workdir: cliSettings.workdir, projectRef: Option.none() }), - ), + host, port: config.port, user: "postgres", password: config.password, @@ -665,14 +686,10 @@ export const genTypes = Effect.fn("gen.types")(function* (flags: GenTypesFlags) return; } - if (Option.isSome(flags.dbUrl)) { + if (target.kind === "db-url") { yield* Effect.annotateCurrentSpan("typegen.source", "db_url"); const includedSchemas = yield* resolveIncludedSchemas(); - const resolved = yield* dbConfig.resolve({ - dbUrl: flags.dbUrl, - connType: "db-url", - dnsResolver, - }); + const resolved = yield* connectTo(target); // 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. @@ -694,32 +711,7 @@ export const genTypes = Effect.fn("gen.types")(function* (flags: GenTypesFlags) return; } - if (flags.linked) { - yield* Effect.annotateCurrentSpan("typegen.source", "linked"); - const ref = yield* projectRef.resolve(Option.none()); - 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); - yield* runProjectTypes(ref, yield* resolveIncludedSchemas(ref), true); - return; - } - yield* Effect.annotateCurrentSpan("typegen.source", "linked"); - const resolvedRef = yield* projectRef.resolve(Option.none()).pipe( - Effect.catchTag("ProjectRefNotLinkedError", (cause) => - Effect.fail( - cause.message === PROJECT_NOT_LINKED_MESSAGE - ? new GenTypesFlagUsageError({ - message: "Must specify one of --local, --linked, --project-id, or --db-url", - }) - : cause, - ), - ), - ); - yield* runProjectTypes(resolvedRef, yield* resolveIncludedSchemas(resolvedRef), false); + yield* runProjectTypes(target, yield* resolveIncludedSchemas(target.ref)); }).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 1ac6271bbe..1813a6993c 100644 --- a/apps/cli/src/commands/gen/types/types.integration.test.ts +++ b/apps/cli/src/commands/gen/types/types.integration.test.ts @@ -23,12 +23,13 @@ import { Path, PlatformError, Predicate, + Redacted, Sink, Stdio, Stream, } from "effect"; import { resolveWorkdir } from "../../../config/command-settings.layer.ts"; -import { DnsResolverFlag, OutputFlag } from "../../../command-internal/global-flags.ts"; +import { DnsResolverFlag, OutputFlag, YesFlag } 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"; import { mockOutput } from "../../../../tests/helpers/mocks.ts"; @@ -39,6 +40,7 @@ import { mockLinkedProjectCacheTracked, mockCommandPlatformApiService, mockTelemetryStateTracked, + unverifiedTargetBanner, withEnvVar, } from "../../../../tests/helpers/command-mocks.ts"; import { cliConfigValuesTestLayer } from "../../../../tests/helpers/config-values-layer.ts"; @@ -48,12 +50,18 @@ import { toConnectError } from "../../../command-internal/db-connection.sql-pg.l import type { DbConfigError } from "../../../command-internal/db-config.service.ts"; import { DbConfigResolver } from "../../../command-internal/db-config.service.ts"; import { DbConfigLoadError } from "../../../command-internal/db-config.errors.ts"; -import type { DbConfigFlags, ResolvedDbConfig } from "../../../command-internal/db-config.types.ts"; +import type { + DbConfigFlags, + DbConnectionInput, + ResolvedDbConfig, + ResolvedDbConnection, +} from "../../../command-internal/db-config.types.ts"; import type { GenTypesFlags } from "./types.command.ts"; import { GenTypesLocalDbInspectError, GenTypesLocalDbNotRunningError } from "./types.errors.ts"; import { genTypes } from "./types.handler.ts"; import { localDbContainerId, parseQueryTimeoutMillis, rootCaBundle } from "./types.shared.ts"; import { stackBackendLayer } from "../../../command-internal/stack-backend.ts"; +import { TargetPolicy } from "../../../command-internal/target/target-policy.ts"; import { type GenTypesGenerateError, GenTypesGenerationError, @@ -115,6 +123,7 @@ function defaultFlags(overrides: Partial<GenTypesFlags> = {}): GenTypesFlags { local: false, linked: false, dbUrl: Option.none(), + projectRef: Option.none(), projectId: Option.none(), lang: "typescript" as const, schema: [], @@ -149,19 +158,22 @@ function localResolvedConfig(conn: PgConnInput): ResolvedDbConfig { function mockDbConfigResolver( opts: { - readonly resolve?: (flags: DbConfigFlags) => Effect.Effect<ResolvedDbConfig, DbConfigError>; + readonly resolve?: ( + input: DbConnectionInput, + ) => Effect.Effect<ResolvedDbConfig, DbConfigError>; readonly poolerFallback?: Option.Option<PgConnInput>; readonly poolerFallbackFails?: boolean; } = {}, ) { - const resolves: Array<DbConfigFlags> = []; + const resolves: Array<DbConnectionInput> = []; const poolerFallbacks: Array<DbConfigFlags> = []; const layer = Layer.succeed(DbConfigResolver, { - resolve: (flags) => + resolve: () => Effect.die("resolve is not used by this test"), + resolveConnection: (input) => Effect.gen(function* () { - resolves.push(flags); - return yield* ( - opts.resolve?.(flags) ?? + resolves.push(input); + const resolved = yield* ( + opts.resolve?.(input) ?? Effect.succeed( remoteResolvedConfig({ host: "127.0.0.1", @@ -172,8 +184,8 @@ function mockDbConfigResolver( }), ) ); + return { ...resolved, target: input.target } satisfies ResolvedDbConnection; }), - resolveConnection: () => Effect.die("resolveConnection is not used by this test"), resolvePoolerFallback: (flags) => opts.poolerFallbackFails === true ? Effect.fail(new DbConfigLoadError({ message: "pooler fallback failed" })) @@ -381,7 +393,7 @@ const setup = Effect.fnUntraced(function* ( readonly read_only: boolean; }) => Effect.Effect<LoginRole, unknown>; readonly dbConfigResolve?: ( - flags: DbConfigFlags, + input: DbConnectionInput, ) => Effect.Effect<ResolvedDbConfig, DbConfigError>; readonly poolerFallback?: Option.Option<PgConnInput>; readonly poolerFallbackFails?: boolean; @@ -502,6 +514,8 @@ const setup = Effect.fnUntraced(function* ( Stdio.layerTest({ args: Effect.succeed(opts.args ?? ["gen", "types"]) }), Layer.succeed(OutputFlag, opts.outputFlag ?? Option.none()), Layer.succeed(DnsResolverFlag, "native" as const), + Layer.succeed(YesFlag, false), + Layer.succeed(TargetPolicy, { group: "selectable", consent: "none" }), Layer.succeed(CommandPlatformApiFactory, { make: CommandPlatformApi.pipe(Effect.provide(api.layer)), }), @@ -539,15 +553,17 @@ describe("gen types", () => { }), ); - it.effect("generates typescript types from a project ref", () => + it.effect("generates typescript types from --project-ref", () => Effect.gen(function* () { const { layer, out, api, linkedProjectCache, telemetry } = yield* setup({ - projectId: Option.some(VALID_REF), projectTypes: "export type Database = {};", }); - yield* genTypes(defaultFlags()).pipe(Effect.provide(layer)); + yield* genTypes(defaultFlags({ projectRef: Option.some(VALID_REF) })).pipe( + Effect.provide(layer), + ); expect(out.stdoutText).toBe("export type Database = {};"); + expect(out.stderrText).toContain(unverifiedTargetBanner()); expect(api.requests).toEqual([ { method: "generateTypescriptTypes", @@ -568,6 +584,7 @@ describe("gen types", () => { yield* genTypes(defaultFlags({ linked: true })).pipe(Effect.provide(layer)); expect(out.stdoutText).toBe("export type Database = {};"); + expect(out.stderrText).toContain(unverifiedTargetBanner()); expect(api.requests).toEqual([ { method: "generateTypescriptTypes", @@ -586,7 +603,7 @@ describe("gen types", () => { }); yield* genTypes( defaultFlags({ - projectId: Option.some(VALID_REF), + projectRef: Option.some(VALID_REF), schema: ["auth", "storage"], }), ).pipe(Effect.provide(layer)); @@ -599,10 +616,10 @@ describe("gen types", () => { ); it.effect( - "uses configured api schemas for explicit project-id generation when --schema is unset", + "uses configured api schemas for explicit project-ref generation when --schema is unset", () => Effect.gen(function* () { - const workdir = yield* makeWorkdir("supabase-gen-types-project-id-"); + const workdir = yield* makeWorkdir("supabase-gen-types-project-ref-"); yield* writeConfig( workdir, ['project_id = "demo"', "", "[api]", 'schemas = ["auth", "storage"]'].join("\n"), @@ -613,7 +630,7 @@ describe("gen types", () => { }); yield* genTypes( defaultFlags({ - projectId: Option.some(VALID_REF), + projectRef: Option.some(VALID_REF), }), ).pipe(Effect.provide(layer)); @@ -625,7 +642,7 @@ describe("gen types", () => { ); it.effect( - "uses configured api schemas for resolved linked generation when --schema is unset", + "uses configured api schemas for --linked generation when --schema is unset", () => Effect.gen(function* () { const workdir = yield* makeWorkdir("supabase-gen-types-linked-"); @@ -638,7 +655,7 @@ describe("gen types", () => { projectId: Option.some(VALID_REF), projectTypes: "ok", }); - yield* genTypes(defaultFlags()).pipe(Effect.provide(layer)); + yield* genTypes(defaultFlags({ linked: true })).pipe(Effect.provide(layer)); expect(api.requests[0]).toEqual({ method: "generateTypescriptTypes", @@ -665,7 +682,7 @@ describe("gen types", () => { projectId: Option.some(VALID_REF), projectTypes: "ok", }); - const exit = yield* genTypes(defaultFlags({ projectId: Option.some(VALID_REF) })).pipe( + const exit = yield* genTypes(defaultFlags({ projectRef: Option.some(VALID_REF) })).pipe( Effect.provide(layer), Effect.exit, ); @@ -701,7 +718,7 @@ describe("gen types", () => { projectId: Option.some(VALID_REF), projectTypes: "ok", }); - yield* genTypes(defaultFlags({ projectId: Option.some(VALID_REF) })).pipe( + yield* genTypes(defaultFlags({ projectRef: Option.some(VALID_REF) })).pipe( Effect.provide(layer), ); @@ -723,7 +740,7 @@ describe("gen types", () => { explicitWorkdir: true, projectId: Option.some(VALID_REF), }); - const exit = yield* genTypes(defaultFlags({ projectId: Option.some(VALID_REF) })).pipe( + const exit = yield* genTypes(defaultFlags({ projectRef: Option.some(VALID_REF) })).pipe( Effect.provide(layer), Effect.exit, ); @@ -748,7 +765,7 @@ describe("gen types", () => { skipConfig: true, projectId: Option.some(VALID_REF), }); - const exit = yield* genTypes(defaultFlags({ projectId: Option.some(VALID_REF) })).pipe( + const exit = yield* genTypes(defaultFlags({ projectRef: Option.some(VALID_REF) })).pipe( Effect.provide(layer), Effect.exit, ); @@ -765,31 +782,37 @@ describe("gen types", () => { }).pipe(Effect.provide(BunServices.layer)), ); - it.effect("fails when no target resolves", () => + it.effect("generates from the local database when no target is selected", () => Effect.gen(function* () { - const { layer } = yield* setup(); - const exit = yield* genTypes(defaultFlags()).pipe(Effect.provide(layer), Effect.exit); + const { layer, out, api, generator, child } = yield* setup({ + generatorOutput: "local types", + }); + yield* genTypes(defaultFlags()).pipe(Effect.provide(layer)); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - expect(String(exit.cause)).toContain( - "Must specify one of --local, --linked, --project-id, or --db-url", - ); - expect( - Option.exists( - Cause.findErrorOption(exit.cause), - Predicate.isTagged("GenTypesFlagUsageError"), - ), - ).toBe(true); - } + expect(out.stdoutText).toBe("local types"); + expect(out.stderrText).toContain("Target: local stack ("); + expect(child.calls).toHaveLength(1); + expect(generator.calls).toHaveLength(1); + expect(generator.calls[0]?.isLocal).toBe(true); + expect(api.requests).toHaveLength(0); }).pipe(Effect.provide(BunServices.layer)), ); - it.effect("generates from --project-id without a local project config", () => + it.effect("ignores a linked project id when no target is selected", () => + Effect.gen(function* () { + const { layer, api, generator } = yield* setup({ projectId: Option.some(VALID_REF) }); + yield* genTypes(defaultFlags()).pipe(Effect.provide(layer)); + + expect(generator.calls[0]?.isLocal).toBe(true); + expect(api.requests).toHaveLength(0); + }).pipe(Effect.provide(BunServices.layer)), + ); + + it.effect("generates from --project-ref without a local project config", () => Effect.gen(function* () { const workdir = yield* makeWorkdir("supabase-gen-types-pid-no-config-"); const { layer, out, api } = yield* setup({ workdir, skipConfig: true, projectTypes: "ok" }); - yield* genTypes(defaultFlags({ projectId: Option.some(VALID_REF) })).pipe( + yield* genTypes(defaultFlags({ projectRef: Option.some(VALID_REF) })).pipe( Effect.provide(layer), ); @@ -801,16 +824,16 @@ describe("gen types", () => { }).pipe(Effect.provide(BunServices.layer)), ); - it.effect("resolves the linked fallback without a local project config", () => + it.effect("resolves --linked without a local project config", () => Effect.gen(function* () { - const workdir = yield* makeWorkdir("supabase-gen-types-fallback-no-config-"); + const workdir = yield* makeWorkdir("supabase-gen-types-linked-no-config-"); const { layer, api } = yield* setup({ workdir, skipConfig: true, projectId: Option.some(VALID_REF), projectTypes: "ok", }); - yield* genTypes(defaultFlags()).pipe(Effect.provide(layer)); + yield* genTypes(defaultFlags({ linked: true })).pipe(Effect.provide(layer)); expect(api.requests[0]).toEqual({ method: "generateTypescriptTypes", @@ -826,7 +849,7 @@ describe("gen types", () => { projectId: Option.some(VALID_REF), projectTypes: "ok", }); - yield* genTypes(defaultFlags({ projectId: Option.some(VALID_REF) })).pipe( + yield* genTypes(defaultFlags({ projectRef: Option.some(VALID_REF) })).pipe( Effect.provide(layer), ); @@ -848,20 +871,6 @@ describe("gen types", () => { }).pipe(Effect.provide(BunServices.layer)), ); - it.effect("prefers explicit --schema on the linked fallback path", () => - Effect.gen(function* () { - const { layer, api } = yield* setup({ - projectId: Option.some(VALID_REF), - projectTypes: "ok", - }); - yield* genTypes(defaultFlags({ schema: ["auth"] })).pipe(Effect.provide(layer)); - expect(api.requests[0]).toEqual({ - method: "generateTypescriptTypes", - input: { ref: VALID_REF, included_schemas: "auth" }, - }); - }).pipe(Effect.provide(BunServices.layer)), - ); - describe("SUPABASE_API_SCHEMAS", () => { const withApiSchemas = <A, E, R>(body: Effect.Effect<A, E, R>) => withEnvVar("SUPABASE_API_SCHEMAS", "auth,storage", body); @@ -888,11 +897,11 @@ describe("gen types", () => { ).pipe(Effect.provide(BunServices.layer)), ); - it.effect("selects the schemas for --project-id without a project config", () => + it.effect("selects the schemas for --project-ref 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( + yield* genTypes(defaultFlags({ projectRef: Option.some(VALID_REF) })).pipe( Effect.provide(layer), ); expect(api.requests[0]).toEqual({ @@ -938,20 +947,145 @@ describe("gen types", () => { ); }); - it.effect("silently ignores --query-timeout for implicit linked TypeScript generation", () => + describe("target selection", () => { + it.effect("follows a workspace target of remote to the linked project", () => + Effect.gen(function* () { + const workdir = yield* makeWorkdir("supabase-gen-types-target-remote-"); + yield* writeConfig( + workdir, + ['project_id = "demo"', 'target = "remote"', "", "[api]", "schemas = []"].join("\n"), + ); + const { layer, api, out } = yield* setup({ + workdir, + projectId: Option.some(VALID_REF), + projectTypes: "ok", + }); + yield* genTypes(defaultFlags()).pipe(Effect.provide(layer)); + + expect(out.stderrText).toContain(unverifiedTargetBanner()); + expect(api.requests).toContainEqual({ + method: "generateTypescriptTypes", + input: { ref: VALID_REF, included_schemas: "public" }, + }); + }).pipe(Effect.provide(BunServices.layer)), + ); + + it.effect("lets --local override a workspace target of remote", () => + Effect.gen(function* () { + const workdir = yield* makeWorkdir("supabase-gen-types-target-override-"); + yield* writeConfig( + workdir, + ['project_id = "demo"', 'target = "remote"', "", "[api]", "schemas = []"].join("\n"), + ); + const { layer, api, generator } = yield* setup({ + workdir, + projectId: Option.some(VALID_REF), + }); + yield* genTypes(defaultFlags({ local: true })).pipe(Effect.provide(layer)); + + expect(generator.calls[0]?.isLocal).toBe(true); + expect(api.requests).toHaveLength(0); + }).pipe(Effect.provide(BunServices.layer)), + ); + + it.effect("treats --project-id as a deprecated alias of --project-ref", () => + Effect.gen(function* () { + const { layer, out, api } = yield* setup({ + args: ["gen", "types", "--project-id", VALID_REF], + projectTypes: "ok", + }); + yield* genTypes(defaultFlags({ projectId: Option.some(VALID_REF) })).pipe( + Effect.provide(layer), + ); + + expect(out.stderrText).toContain("--project-id is deprecated; use --project-ref instead."); + expect(out.stderrText).toContain(unverifiedTargetBanner()); + expect(api.requests).toContainEqual({ + method: "generateTypescriptTypes", + input: { ref: VALID_REF, included_schemas: "public" }, + }); + }).pipe(Effect.provide(BunServices.layer)), + ); + + it.effect("rejects --project-id together with --project-ref", () => + Effect.gen(function* () { + const { layer, api, telemetry } = yield* setup({ + args: ["gen", "types", "--project-id", VALID_REF, "--project-ref", VALID_REF], + }); + const exit = yield* genTypes( + defaultFlags({ + projectId: Option.some(VALID_REF), + projectRef: Option.some(VALID_REF), + }), + ).pipe(Effect.provide(layer), Effect.exit); + + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(String(exit.cause)).toContain( + "--project-id is a deprecated alias of --project-ref; pass only one of them.", + ); + } + expect(api.requests).toHaveLength(0); + expect(telemetry.flushed).toBe(true); + }).pipe(Effect.provide(BunServices.layer)), + ); + + it.effect("rejects --project-ref together with --local", () => + Effect.gen(function* () { + const { layer, api, generator } = yield* setup({ + args: ["gen", "types", "--local", "--project-ref", VALID_REF], + }); + const exit = yield* genTypes( + defaultFlags({ local: true, projectRef: Option.some(VALID_REF) }), + ).pipe(Effect.provide(layer), Effect.exit); + + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect( + Option.exists( + Cause.findErrorOption(exit.cause), + Predicate.isTagged("TargetProjectRefConflictError"), + ), + ).toBe(true); + } + expect(api.requests).toHaveLength(0); + expect(generator.calls).toHaveLength(0); + }).pipe(Effect.provide(BunServices.layer)), + ); + + it.effect("rejects --project-ref together with --db-url", () => + Effect.gen(function* () { + const dbUrl = "postgresql://postgres:postgres@127.0.0.1:5432/postgres"; + const { layer, generator } = yield* setup({ + args: ["gen", "types", "--db-url", dbUrl, "--project-ref", VALID_REF], + }); + const exit = yield* genTypes( + defaultFlags({ dbUrl: Option.some(dbUrl), projectRef: Option.some(VALID_REF) }), + ).pipe(Effect.provide(layer), Effect.exit); + + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect( + Option.exists( + Cause.findErrorOption(exit.cause), + Predicate.isTagged("TargetProjectRefConflictError"), + ), + ).toBe(true); + } + expect(generator.calls).toHaveLength(0); + }).pipe(Effect.provide(BunServices.layer)), + ); + }); + + it.effect("applies --query-timeout to the default local database", () => Effect.gen(function* () { - const { layer, out, api } = yield* setup({ + const { layer, generator, api } = yield* setup({ args: ["gen", "types", "--query-timeout", "20s"], - projectId: Option.some(VALID_REF), - projectTypes: "ok", }); yield* genTypes(defaultFlags({ queryTimeout: "20s" })).pipe(Effect.provide(layer)); - expect(out.stderrText).not.toContain("--query-timeout"); - expect(api.requests).toContainEqual({ - method: "generateTypescriptTypes", - input: { ref: VALID_REF, included_schemas: "public" }, - }); + expect(generator.calls[0]?.conn.runtimeParams?.["statement_timeout"]).toBe("20000"); + expect(api.requests).toHaveLength(0); }).pipe(Effect.provide(BunServices.layer)), ); @@ -963,7 +1097,7 @@ describe("gen types", () => { }); const exit = yield* genTypes( defaultFlags({ - projectId: Option.some(VALID_REF), + projectRef: Option.some(VALID_REF), }), ).pipe(Effect.provide(layer), Effect.exit); @@ -980,8 +1114,6 @@ describe("gen types", () => { Effect.gen(function* () { const { layer } = yield* setup({ args: ["gen", "types", "typescript"], - projectId: Option.some(VALID_REF), - projectTypes: "ok", }); yield* genTypes(defaultFlags()).pipe(Effect.provide(layer)); }).pipe(Effect.provide(BunServices.layer)), @@ -1001,12 +1133,12 @@ describe("gen types", () => { expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { expect(String(exit.cause)).toContain( - "if any flags in the group [local linked project-id db-url] are set none of the others can be; [linked local] were all set", + "Only one of --local, --linked and --db-url can be set; got --local and --linked.", ); expect( Option.exists( Cause.findErrorOption(exit.cause), - Predicate.isTagged("GenTypesFlagUsageError"), + Predicate.isTagged("TargetSelectorConflictError"), ), ).toBe(true); } @@ -1016,22 +1148,19 @@ describe("gen types", () => { it.effect("does not misdetect a mutex flag consumed as -s's value", () => Effect.gen(function* () { - // `childExitCode: 1` fails the local target's `container inspect`, keeping the - // downstream failure deterministic once `--linked` is consumed as `-s`'s value. - const { layer } = yield* setup({ - args: ["gen", "types", "-s", "--linked", "--local"], - childExitCode: 1, + const { layer, api } = yield* setup({ + args: ["gen", "types", "--linked", "-s", "--query-timeout"], + projectId: Option.some(VALID_REF), + projectTypes: "ok", }); - const exit = yield* genTypes(defaultFlags({ local: true, linked: true })).pipe( + yield* genTypes(defaultFlags({ linked: true, schema: ["--query-timeout"] })).pipe( Effect.provide(layer), - Effect.exit, ); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - expect(String(exit.cause)).toContain("failed to inspect service"); - expect(String(exit.cause)).not.toContain("if any flags in the group"); - } + expect(api.requests[0]).toEqual({ + method: "generateTypescriptTypes", + input: { ref: VALID_REF, included_schemas: "--query-timeout" }, + }); }).pipe(Effect.provide(BunServices.layer)), ); @@ -1051,19 +1180,19 @@ describe("gen types", () => { expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { expect(String(exit.cause)).toContain( - "if any flags in the group [linked project-id swift-access-control] are set none of the others can be; [linked swift-access-control] were all set", + "if any flags in the group [linked project-ref swift-access-control] are set none of the others can be; [linked swift-access-control] were all set", ); } }).pipe(Effect.provide(BunServices.layer)), ); - it.effect("rejects --swift-access-control with --project-id (mutex group)", () => + it.effect("rejects --swift-access-control with --project-ref (mutex group)", () => Effect.gen(function* () { const { layer } = yield* setup({ args: [ "gen", "types", - "--project-id", + "--project-ref", VALID_REF, "--swift-access-control", "public", @@ -1073,7 +1202,7 @@ describe("gen types", () => { }); const exit = yield* genTypes( defaultFlags({ - projectId: Option.some(VALID_REF), + projectRef: Option.some(VALID_REF), lang: "swift", "swift-access-control": Option.some("public"), }), @@ -1082,19 +1211,19 @@ describe("gen types", () => { expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { expect(String(exit.cause)).toContain( - "if any flags in the group [linked project-id swift-access-control] are set none of the others can be; [project-id swift-access-control] were all set", + "if any flags in the group [linked project-ref swift-access-control] are set none of the others can be; [project-ref swift-access-control] were all set", ); } }).pipe(Effect.provide(BunServices.layer)), ); - it.effect("rejects --postgrest-v9-compat without --db-url for project-id generation", () => + it.effect("rejects --postgrest-v9-compat without --db-url for project-ref generation", () => Effect.gen(function* () { const { layer } = yield* setup({ - args: ["gen", "types", "--project-id", VALID_REF, "--postgrest-v9-compat"], + args: ["gen", "types", "--project-ref", VALID_REF, "--postgrest-v9-compat"], }); const exit = yield* genTypes( - defaultFlags({ projectId: Option.some(VALID_REF), postgrestV9Compat: true }), + defaultFlags({ projectRef: Option.some(VALID_REF), postgrestV9Compat: true }), ).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isFailure(exit)).toBe(true); @@ -1133,19 +1262,19 @@ describe("gen types", () => { }).pipe(Effect.provide(BunServices.layer)), ); - it.effect("rejects --query-timeout with --project-id (mutex group)", () => + it.effect("rejects --query-timeout with --project-ref (mutex group)", () => Effect.gen(function* () { const { layer } = yield* setup({ - args: ["gen", "types", "--project-id", VALID_REF, "--query-timeout", "20s"], + args: ["gen", "types", "--project-ref", VALID_REF, "--query-timeout", "20s"], }); const exit = yield* genTypes( - defaultFlags({ projectId: Option.some(VALID_REF), queryTimeout: "20s" }), + defaultFlags({ projectRef: Option.some(VALID_REF), queryTimeout: "20s" }), ).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { expect(String(exit.cause)).toContain( - "if any flags in the group [linked project-id query-timeout] are set none of the others can be; [project-id query-timeout] were all set", + "if any flags in the group [linked project-ref query-timeout] are set none of the others can be; [project-ref query-timeout] were all set", ); } }).pipe(Effect.provide(BunServices.layer)), @@ -1165,27 +1294,26 @@ describe("gen types", () => { expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { expect(String(exit.cause)).toContain( - "if any flags in the group [linked project-id query-timeout] are set none of the others can be; [linked query-timeout] were all set", + "if any flags in the group [linked project-ref query-timeout] are set none of the others can be; [linked query-timeout] were all set", ); } }).pipe(Effect.provide(BunServices.layer)), ); - it.effect("counts explicitly negated booleans as set for mutex groups", () => + it.effect("treats --linked=false as absent", () => Effect.gen(function* () { - const { layer } = yield* setup({ - args: ["gen", "types", "--linked=false", "--project-id", VALID_REF], + const { layer, api } = yield* setup({ + args: ["gen", "types", "--linked=false", "--project-ref", VALID_REF], + projectTypes: "ok", }); - const exit = yield* genTypes( - defaultFlags({ linked: false, projectId: Option.some(VALID_REF) }), - ).pipe(Effect.provide(layer), Effect.exit); + yield* genTypes( + defaultFlags({ linked: false, projectRef: Option.some(VALID_REF) }), + ).pipe(Effect.provide(layer)); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - expect(String(exit.cause)).toContain( - "if any flags in the group [linked project-id postgrest-v9-compat] are set none of the others can be; [linked project-id] were all set", - ); - } + expect(api.requests).toContainEqual({ + method: "generateTypescriptTypes", + input: { ref: VALID_REF, included_schemas: "public" }, + }); }).pipe(Effect.provide(BunServices.layer)), ); @@ -1259,14 +1387,14 @@ describe("gen types", () => { "--db-url", dbUrl, "--postgrest-v9-compat", - "--project-id", + "--project-ref", VALID_REF, ], }); const exit = yield* genTypes( defaultFlags({ dbUrl: Option.some(dbUrl), - projectId: Option.some(VALID_REF), + projectRef: Option.some(VALID_REF), postgrestV9Compat: true, }), ).pipe(Effect.provide(layer), Effect.exit); @@ -1274,7 +1402,7 @@ describe("gen types", () => { expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { expect(String(exit.cause)).toContain( - "if any flags in the group [linked project-id postgrest-v9-compat] are set none of the others can be; [postgrest-v9-compat project-id] were all set", + "if any flags in the group [linked project-ref postgrest-v9-compat] are set none of the others can be; [postgrest-v9-compat project-ref] were all set", ); } }).pipe(Effect.provide(BunServices.layer)), @@ -1295,11 +1423,11 @@ describe("gen types", () => { it.effect("treats a trailing -- with no operand as no positional language", () => Effect.gen(function* () { const { layer, api } = yield* setup({ - args: ["gen", "types", "--"], + args: ["gen", "types", "--linked", "--"], projectId: Option.some(VALID_REF), projectTypes: "ok", }); - yield* genTypes(defaultFlags()).pipe(Effect.provide(layer)); + yield* genTypes(defaultFlags({ linked: true })).pipe(Effect.provide(layer)); expect(api.requests).toHaveLength(1); }).pipe(Effect.provide(BunServices.layer)), ); @@ -1407,7 +1535,7 @@ describe("gen types", () => { it.effect(`generates ${scenario.lang} types from a project ref through the DB resolver`, () => Effect.gen(function* () { const { layer, out, api, linkedProjectCache, dbConfig, generator } = yield* setup({ - args: ["gen", "types", "--lang", scenario.lang, "--project-id", VALID_REF], + args: ["gen", "types", "--lang", scenario.lang, "--project-ref", VALID_REF], generatorOutput: scenario.stdout, dbConfigResolve: (input) => Effect.succeed( @@ -1419,9 +1547,7 @@ describe("gen types", () => { password: "temporary-password", database: "postgres", }, - (input.linkedProjectRef !== undefined - ? Option.getOrUndefined(input.linkedProjectRef) - : undefined) ?? VALID_REF, + input.target.kind === "linked" ? input.target.ref : VALID_REF, ), ), getABranchConfig: ({ branch_id_or_ref }) => @@ -1437,7 +1563,7 @@ describe("gen types", () => { }); yield* genTypes( defaultFlags({ - projectId: Option.some(VALID_REF), + projectRef: Option.some(VALID_REF), lang: scenario.lang, }), ).pipe(Effect.provide(layer)); @@ -1456,12 +1582,12 @@ describe("gen types", () => { expect(out.stderrText).not.toContain("unformatted"); expect(out.stdoutText).toContain(scenario.stdout); expect(dbConfig.resolves).toHaveLength(1); - expect(dbConfig.resolves[0]?.connType).toBe("linked"); - expect(dbConfig.resolves[0]?.adHocProjectRef).toBe(true); - const linkedProjectRef = dbConfig.resolves[0]?.linkedProjectRef; - expect( - linkedProjectRef !== undefined ? Option.getOrUndefined(linkedProjectRef) : undefined, - ).toBe(VALID_REF); + expect(dbConfig.resolves[0]?.target).toEqual({ + kind: "linked", + ref: VALID_REF, + source: "flag", + matchesLinkedRef: false, + }); expect(generator.calls).toHaveLength(1); const call = generator.calls[0]; expect(call?.lang).toBe(scenario.lang); @@ -1477,9 +1603,11 @@ describe("gen types", () => { it.effect("resolves the linked workdir DB without ad-hoc project-ref semantics", () => Effect.gen(function* () { + const workdir = yield* makeWorkdir("supabase-gen-types-linked-workdir-"); + yield* writeTempFile(workdir, "project-ref", VALID_REF); const { layer, dbConfig } = yield* setup({ + workdir, args: ["gen", "types", "--lang", "go", "--linked"], - projectId: Option.some(VALID_REF), dbConfigResolve: () => Effect.succeed( remoteResolvedConfig({ @@ -1494,15 +1622,19 @@ describe("gen types", () => { yield* genTypes(defaultFlags({ linked: true, lang: "go" })).pipe(Effect.provide(layer)); expect(dbConfig.resolves).toHaveLength(1); - expect(dbConfig.resolves[0]?.connType).toBe("linked"); - expect(dbConfig.resolves[0]?.adHocProjectRef).toBe(false); + expect(dbConfig.resolves[0]?.target).toEqual({ + kind: "linked", + ref: VALID_REF, + source: "file", + matchesLinkedRef: true, + }); }).pipe(Effect.provide(BunServices.layer)), ); it.effect("preserves resolver connection options for remote non-TypeScript typegen", () => Effect.gen(function* () { const { layer, generator } = yield* setup({ - args: ["gen", "types", "--lang", "go", "--project-id", VALID_REF], + args: ["gen", "types", "--lang", "go", "--project-ref", VALID_REF], dbConfigResolve: () => Effect.succeed( remoteResolvedConfig({ @@ -1517,7 +1649,7 @@ describe("gen types", () => { }); yield* genTypes( defaultFlags({ - projectId: Option.some(VALID_REF), + projectRef: Option.some(VALID_REF), lang: "go", }), ).pipe(Effect.provide(layer)); @@ -1528,10 +1660,10 @@ describe("gen types", () => { ); it.effect( - "forwards --query-timeout and --swift-access-control to the generator for implicit linked non-TypeScript generation", + "forwards --query-timeout and --swift-access-control to the generator for the default local database", () => Effect.gen(function* () { - const { layer, dbConfig, generator } = yield* setup({ + const { layer, generator } = yield* setup({ args: [ "gen", "types", @@ -1542,7 +1674,6 @@ describe("gen types", () => { "--swift-access-control", "public", ], - projectId: Option.some(VALID_REF), }); yield* genTypes( defaultFlags({ @@ -1552,8 +1683,8 @@ describe("gen types", () => { }), ).pipe(Effect.provide(layer)); - expect(dbConfig.resolves[0]?.adHocProjectRef).toBe(false); const call = generator.calls[0]; + expect(call?.isLocal).toBe(true); expect(call?.options["swift-access-control"]).toBe("public"); expect(call?.conn.runtimeParams?.["statement_timeout"]).toBe("20000"); expect(call?.conn.connectTimeoutSeconds).toBe(20); @@ -1581,11 +1712,11 @@ describe("gen types", () => { ); const { layer, generator } = yield* setup({ workdir, - args: ["gen", "types", "--lang", "go", "--project-id", VALID_REF], + args: ["gen", "types", "--lang", "go", "--project-ref", VALID_REF], }); yield* genTypes( defaultFlags({ - projectId: Option.some(VALID_REF), + projectRef: Option.some(VALID_REF), lang: "go", }), ).pipe(Effect.provide(layer)); @@ -1634,7 +1765,7 @@ describe("gen types", () => { it.effect("falls back to preview branch config for non-TypeScript project refs", () => Effect.gen(function* () { const { layer, api, dbConfig, generator } = yield* setup({ - args: ["gen", "types", "--lang", "python", "--project-id", VALID_REF], + args: ["gen", "types", "--lang", "python", "--project-ref", VALID_REF], generatorOutput: "class PublicMovies(BaseModel):", getProject: () => Effect.fail(statusApiError(404, `{"message":"Preview branch not found"}`)), @@ -1658,7 +1789,7 @@ describe("gen types", () => { }); yield* genTypes( defaultFlags({ - projectId: Option.some(VALID_REF), + projectRef: Option.some(VALID_REF), lang: "python", }), ).pipe(Effect.provide(layer)); @@ -1685,7 +1816,7 @@ describe("gen types", () => { it.effect("falls back to preview branch config for any project 404 body", () => Effect.gen(function* () { const { layer, api, dbConfig, generator } = yield* setup({ - args: ["gen", "types", "--lang", "python", "--project-id", VALID_REF], + args: ["gen", "types", "--lang", "python", "--project-ref", VALID_REF], generatorOutput: "class PublicMovies(BaseModel):", getProject: () => Effect.fail(statusApiError(404, `{"message":"Not found"}`)), getABranchConfig: ({ branch_id_or_ref }) => @@ -1704,7 +1835,7 @@ describe("gen types", () => { }); yield* genTypes( defaultFlags({ - projectId: Option.some(VALID_REF), + projectRef: Option.some(VALID_REF), lang: "python", }), ).pipe(Effect.provide(layer)); @@ -1721,7 +1852,7 @@ describe("gen types", () => { it.effect("fails clearly when preview branch config does not include DB credentials", () => Effect.gen(function* () { const { layer } = yield* setup({ - args: ["gen", "types", "--lang", "python", "--project-id", VALID_REF], + args: ["gen", "types", "--lang", "python", "--project-ref", VALID_REF], getProject: () => Effect.fail(statusApiError(404, `{"message":"Preview branch not found"}`)), getABranchConfig: ({ branch_id_or_ref }) => @@ -1738,7 +1869,7 @@ describe("gen types", () => { }); const exit = yield* genTypes( defaultFlags({ - projectId: Option.some(VALID_REF), + projectRef: Option.some(VALID_REF), lang: "python", }), ).pipe(Effect.provide(layer), Effect.exit); @@ -1774,7 +1905,7 @@ describe("gen types", () => { () => Effect.succeed("type RetriedViaPooler struct {}"), ]); const { layer, out, dbConfig } = yield* setup({ - args: ["gen", "types", "--lang", "go", "--project-id", VALID_REF], + args: ["gen", "types", "--lang", "go", "--project-ref", VALID_REF], generator, dbConfigResolve: () => Effect.succeed( @@ -1790,7 +1921,7 @@ describe("gen types", () => { }); yield* genTypes( defaultFlags({ - projectId: Option.some(VALID_REF), + projectRef: Option.some(VALID_REF), lang: "go", }), ).pipe(Effect.provide(layer)); @@ -1821,7 +1952,7 @@ describe("gen types", () => { () => Effect.succeed("type RetriedViaPooler struct {}"), ]); const { layer, out, dbConfig } = yield* setup({ - args: ["gen", "types", "--lang", "go", "--project-id", VALID_REF], + args: ["gen", "types", "--lang", "go", "--project-ref", VALID_REF], generator, dbConfigResolve: () => Effect.succeed( @@ -1837,7 +1968,7 @@ describe("gen types", () => { }); yield* genTypes( defaultFlags({ - projectId: Option.some(VALID_REF), + projectRef: Option.some(VALID_REF), lang: "go", }), ).pipe(Effect.provide(layer)); @@ -1854,7 +1985,7 @@ describe("gen types", () => { Effect.gen(function* () { const generator = sequentialGenerator([() => Effect.fail(nonIpv6Failure())]); const { layer, dbConfig } = yield* setup({ - args: ["gen", "types", "--lang", "go", "--project-id", VALID_REF], + args: ["gen", "types", "--lang", "go", "--project-ref", VALID_REF], generator, dbConfigResolve: () => Effect.succeed( @@ -1875,7 +2006,7 @@ describe("gen types", () => { }), }); const exit = yield* genTypes( - defaultFlags({ projectId: Option.some(VALID_REF), lang: "go" }), + defaultFlags({ projectRef: Option.some(VALID_REF), lang: "go" }), ).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isFailure(exit)).toBe(true); @@ -1893,7 +2024,7 @@ describe("gen types", () => { () => Effect.fail(ipv6Failure()), ]); const { layer, dbConfig } = yield* setup({ - args: ["gen", "types", "--lang", "go", "--project-id", VALID_REF], + args: ["gen", "types", "--lang", "go", "--project-ref", VALID_REF], generator, dbConfigResolve: () => Effect.succeed( @@ -1914,7 +2045,7 @@ describe("gen types", () => { }), }); const exit = yield* genTypes( - defaultFlags({ projectId: Option.some(VALID_REF), lang: "go" }), + defaultFlags({ projectRef: Option.some(VALID_REF), lang: "go" }), ).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isFailure(exit)).toBe(true); @@ -1929,7 +2060,7 @@ describe("gen types", () => { Effect.gen(function* () { const generator = sequentialGenerator([() => Effect.fail(ipv6Failure())]); const { layer, out, dbConfig } = yield* setup({ - args: ["gen", "types", "--lang", "go", "--project-id", VALID_REF], + args: ["gen", "types", "--lang", "go", "--project-ref", VALID_REF], generator, dbConfigResolve: () => Effect.succeed( @@ -1950,7 +2081,7 @@ describe("gen types", () => { }), }); const exit = yield* genTypes( - defaultFlags({ projectId: Option.some(VALID_REF), lang: "go" }), + defaultFlags({ projectRef: Option.some(VALID_REF), lang: "go" }), ).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isFailure(exit)).toBe(true); @@ -1964,7 +2095,7 @@ describe("gen types", () => { Effect.gen(function* () { const generator = sequentialGenerator([() => Effect.fail(toolFailureNamingAHost())]); const { layer, out, dbConfig } = yield* setup({ - args: ["gen", "types", "--lang", "dart", "--project-id", VALID_REF], + args: ["gen", "types", "--lang", "dart", "--project-ref", VALID_REF], generator, dbConfigResolve: () => Effect.succeed( @@ -1985,7 +2116,7 @@ describe("gen types", () => { }), }); const exit = yield* genTypes( - defaultFlags({ projectId: Option.some(VALID_REF), lang: "dart" }), + defaultFlags({ projectRef: Option.some(VALID_REF), lang: "dart" }), ).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isFailure(exit)).toBe(true); @@ -1999,7 +2130,7 @@ describe("gen types", () => { Effect.gen(function* () { const generator = sequentialGenerator([() => Effect.fail(ipv6Failure())]); const { layer } = yield* setup({ - args: ["gen", "types", "--lang", "go", "--project-id", VALID_REF], + args: ["gen", "types", "--lang", "go", "--project-ref", VALID_REF], generator, dbConfigResolve: () => Effect.succeed( @@ -2014,7 +2145,7 @@ describe("gen types", () => { poolerFallbackFails: true, }); const exit = yield* genTypes( - defaultFlags({ projectId: Option.some(VALID_REF), lang: "go" }), + defaultFlags({ projectRef: Option.some(VALID_REF), lang: "go" }), ).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isFailure(exit)).toBe(true); @@ -2034,7 +2165,7 @@ describe("gen types", () => { () => Effect.succeed("class RetriedViaBranchPooler(BaseModel):"), ]); const { layer, api } = yield* setup({ - args: ["gen", "types", "--lang", "python", "--project-id", VALID_REF], + args: ["gen", "types", "--lang", "python", "--project-ref", VALID_REF], generator, getProject: () => Effect.fail(statusApiError(404, `{"message":"Not found"}`)), getABranchConfig: ({ branch_id_or_ref }) => @@ -2070,7 +2201,7 @@ describe("gen types", () => { }); yield* genTypes( defaultFlags({ - projectId: Option.some(VALID_REF), + projectRef: Option.some(VALID_REF), lang: "python", }), ).pipe(Effect.provide(layer)); @@ -2089,7 +2220,7 @@ describe("gen types", () => { Effect.gen(function* () { const generator = sequentialGenerator([() => Effect.fail(ipv6Failure())]); const { layer, api } = yield* setup({ - args: ["gen", "types", "--lang", "python", "--project-id", VALID_REF], + args: ["gen", "types", "--lang", "python", "--project-ref", VALID_REF], generator, getProject: () => Effect.fail(statusApiError(404, `{"message":"Not found"}`)), getABranchConfig: ({ branch_id_or_ref }) => @@ -2125,7 +2256,7 @@ describe("gen types", () => { }); const exit = yield* genTypes( defaultFlags({ - projectId: Option.some(VALID_REF), + projectRef: Option.some(VALID_REF), lang: "python", }), ).pipe(Effect.provide(layer), Effect.exit); @@ -2850,7 +2981,7 @@ describe("gen types", () => { expect(out.stderrText).toContain("Connecting to 127.0.0.1 54321"); expect(child.calls).toHaveLength(0); expect(dbConfig.resolves).toHaveLength(1); - expect(dbConfig.resolves[0]?.connType).toBe("local"); + expect(dbConfig.resolves[0]?.target).toEqual({ kind: "local" }); expect(generator.calls).toHaveLength(1); const call = generator.calls[0]; expect(call?.conn).toMatchObject({ @@ -2893,7 +3024,7 @@ describe("gen types", () => { ).pipe(Effect.provide(layer)); expect(dbConfig.resolves).toHaveLength(1); - expect(dbConfig.resolves[0]?.connType).toBe("db-url"); + expect(dbConfig.resolves[0]?.target.kind).toBe("db-url"); expect(generator.calls[0]?.includedSchemas).toEqual(["public"]); }).pipe(Effect.provide(BunServices.layer)), ); @@ -2910,9 +3041,13 @@ describe("gen types", () => { ).pipe(Effect.provide(layer)); expect(dbConfig.resolves[0]).toEqual({ - dbUrl: Option.some("postgresql://postgres:postgres@127.0.0.1:5432/postgres"), - connType: "db-url", + target: { + kind: "db-url", + url: Redacted.make("postgresql://postgres:postgres@127.0.0.1:5432/postgres"), + }, dnsResolver: "native", + password: Option.none(), + networkBanRemoval: Option.none(), }); expect(generator.calls[0]?.includedSchemas).toEqual(["public"]); expect(generator.calls[0]?.isLocal).toBe(false); From b4dca689700ffb41f8ff701dc458bb22d2ab6b2e Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Thu, 15 Oct 2026 14:34:50 +0100 Subject: [PATCH 41/92] test(cli): cover storage and seed buckets target selection and hosted consent Update the integration, e2e and live tests and SIDE_EFFECTS.md for the workspace target default, the Target banner and the single hosted-write consent. --- .../src/command-internal/storage-gateway.ts | 1 - .../src/commands/seed/buckets/SIDE_EFFECTS.md | 83 ++++++--- .../commands/seed/buckets/buckets.e2e.test.ts | 6 +- .../seed/buckets/buckets.integration.test.ts | 162 +++++++++++++----- .../src/commands/storage/cp/SIDE_EFFECTS.md | 49 ++++-- .../storage/cp/cp.command.integration.test.ts | 2 +- .../storage/cp/cp.integration.test.ts | 58 ++++++- .../src/commands/storage/cp/cp.live.test.ts | 9 +- .../src/commands/storage/ls/SIDE_EFFECTS.md | 33 ++-- .../storage/ls/ls.integration.test.ts | 25 ++- .../src/commands/storage/ls/ls.live.test.ts | 9 +- .../src/commands/storage/mv/SIDE_EFFECTS.md | 49 ++++-- .../storage/mv/mv.integration.test.ts | 32 +++- .../src/commands/storage/mv/mv.live.test.ts | 18 +- .../src/commands/storage/rm/SIDE_EFFECTS.md | 81 +++++---- .../cli/src/commands/storage/rm/rm.handler.ts | 5 +- .../storage/rm/rm.integration.test.ts | 87 ++++++---- .../src/commands/storage/rm/rm.live.test.ts | 9 +- .../src/commands/storage/storage.e2e.test.ts | 10 +- ...rage.experimental-gate.integration.test.ts | 14 +- apps/cli/tests/helpers/storage.ts | 115 ++++++++----- 21 files changed, 596 insertions(+), 261 deletions(-) diff --git a/apps/cli/src/command-internal/storage-gateway.ts b/apps/cli/src/command-internal/storage-gateway.ts index 6ba70e4b61..6190f17106 100644 --- a/apps/cli/src/command-internal/storage-gateway.ts +++ b/apps/cli/src/command-internal/storage-gateway.ts @@ -153,7 +153,6 @@ function localGatewayHint(port: string): string { /** Announces the local Storage API endpoint as the run's target. */ export const announceLocalStorageTarget = (baseUrl: string) => { - if (!URL.canParse(baseUrl)) return Effect.void; const { hostname, port, protocol } = new URL(baseUrl); const defaultPort = protocol === "https:" ? 443 : 80; return announceTarget( diff --git a/apps/cli/src/commands/seed/buckets/SIDE_EFFECTS.md b/apps/cli/src/commands/seed/buckets/SIDE_EFFECTS.md index 804bc89836..48ccca2178 100644 --- a/apps/cli/src/commands/seed/buckets/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/seed/buckets/SIDE_EFFECTS.md @@ -1,8 +1,11 @@ # `supabase seed buckets` Seeds Supabase Storage buckets from `[storage.buckets]` and -`[storage.vector]` in `supabase/config.toml`. Without `--linked` the local -stack is used; with `--linked` the remote project is used. +`[storage.vector]` in `supabase/config.toml`. With no target flag the command follows the +workspace target (`SUPABASE_TARGET`, then `target` in `config.toml`, default `local`; see +[ADR 0033](../../../../../../docs/adr/0033-target-selection-and-hosted-mutation-consent.md)); `--local` selects the local stack, and `--linked` or +`--project-ref` the remote project. A run against the remote project asks for consent before its +first write. The local path resolves the Storage gateway through the `experimental.stack` feature flag, same `SUPABASE_EXPERIMENTAL_STACK=1|0` env precedence as @@ -14,20 +17,20 @@ credential, capability-state, and error behavior. `db reset --local`'s bucket se ## Files Read -| Path | Format | When | -| --------------------------------------------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `<workdir>/supabase/config.toml` | TOML | always, to read `[storage.buckets]` / `[storage.vector]` config; on `--linked`, the matching `[remotes.<name>]` block (whose `project_id` equals the resolved project ref) is merged over the base config before decode, so remote-specific storage config takes effect. With an explicit `--workdir`/`SUPABASE_WORKDIR` that holds no project config, the standalone `seed buckets` command now fails (`SeedMissingProjectConfigError`) instead of authenticating and seeding nothing while still exiting `0`; a DEFAULTED workdir keeps the embedded-default fallback, and `start`/`db reset`'s reuse of this seeding core (which supplies its own already-resolved config) is unaffected either way | -| `<workdir>/supabase/<objects_path>/**` | any (bytes) | per configured bucket with a non-empty `objects_path`, recursively; a relative `objects_path` resolves under `supabase/`, an absolute path is used as-is | -| `<workdir>/supabase/<api.tls.cert_path>` | PEM text | legacy backend, local runs only, when `[api.tls] enabled = true` AND `api.tls.cert_path` is set; the file is read to obtain the CA certificate for trusting the local Kong HTTPS gateway. If `cert_path` is not set, the embedded `kong.local.crt` constant is used instead (no file read). Not read on the stack backend — the stack owns its own gateway CA. | -| `<workdir>/supabase/<api.tls.key_path>` | PEM text | legacy backend, local runs only, when `[api.tls] enabled = true` AND `api.tls.key_path` is set; read purely to validate the cert/key pairing — the key content is not used by the CLI. If `cert_path` is set without `key_path` (or vice-versa), the command exits `1`. Not read on the stack backend. | -| `<workdir>/supabase/.temp/project-ref` | plain text | `--linked` only, to resolve the ref — skipped when `--project-ref` (or `SUPABASE_PROJECT_ID`) is set | -| `<workdir>/supabase/.env*`, `<workdir>/.env*` | dotenv | once per run, unless the caller already resolved the map (`db reset --local`, the legacy `start` command, and `stack start`'s first-create seeding all pass their own `resolvedConfig`/`projectEnvValues` through): resolves `SUPABASE_YES` for the overwrite/prune prompts on either target (CLI-1878) and, on local, legacy-backend runs, the `SUPABASE_API_*` overrides for the gateway URL and TLS material (#6452) plus the `SUPABASE_AUTH_{JWT_SECRET,SERVICE_ROLE_KEY}` overrides for the service-role key | +| Path | Format | When | +| --------------------------------------------- | ----------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `<workdir>/supabase/config.toml` | TOML | always, to read `[storage.buckets]` / `[storage.vector]` config; on a linked target, the matching `[remotes.<name>]` block (whose `project_id` equals the resolved project ref) is merged over the base config before decode, so remote-specific storage config takes effect. With an explicit `--workdir`/`SUPABASE_WORKDIR` that holds no project config, the standalone `seed buckets` command now fails (`SeedMissingProjectConfigError`) instead of authenticating and seeding nothing while still exiting `0`; a DEFAULTED workdir keeps the embedded-default fallback, and `start`/`db reset`'s reuse of this seeding core (which supplies its own already-resolved config) is unaffected either way | +| `<workdir>/supabase/<objects_path>/**` | any (bytes) | per configured bucket with a non-empty `objects_path`, recursively; a relative `objects_path` resolves under `supabase/`, an absolute path is used as-is | +| `<workdir>/supabase/<api.tls.cert_path>` | PEM text | legacy backend, local runs only, when `[api.tls] enabled = true` AND `api.tls.cert_path` is set; the file is read to obtain the CA certificate for trusting the local Kong HTTPS gateway. If `cert_path` is not set, the embedded `kong.local.crt` constant is used instead (no file read). Not read on the stack backend — the stack owns its own gateway CA. | +| `<workdir>/supabase/<api.tls.key_path>` | PEM text | legacy backend, local runs only, when `[api.tls] enabled = true` AND `api.tls.key_path` is set; read purely to validate the cert/key pairing — the key content is not used by the CLI. If `cert_path` is set without `key_path` (or vice-versa), the command exits `1`. Not read on the stack backend. | +| `<workdir>/supabase/.temp/project-ref` | plain text | linked target only, to resolve the ref — skipped when `--project-ref` (or `SUPABASE_PROJECT_ID`) is set | +| `<workdir>/supabase/.env*`, `<workdir>/.env*` | dotenv | once per run, unless the caller already resolved the map (`db reset --local`, the legacy `start` command, and `stack start`'s first-create seeding all pass their own `resolvedConfig`/`projectEnvValues` through): resolves `SUPABASE_YES` for the overwrite/prune prompts on either target (CLI-1878) and, on local, legacy-backend runs, the `SUPABASE_API_*` overrides for the gateway URL and TLS material (#6452) plus the `SUPABASE_AUTH_{JWT_SECRET,SERVICE_ROLE_KEY}` overrides for the service-role key | ## Files Written -| Path | Format | When | -| ---------------------------------------------- | ------ | --------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `<workdir>/supabase/.temp/linked-project.json` | JSON | `--linked` only, once the project ref resolves and no cache exists yet. Best-effort (auth/network/write errors are swallowed). Local runs never write it. | +| Path | Format | When | +| ---------------------------------------------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `<workdir>/supabase/.temp/linked-project.json` | JSON | linked target only, once the project ref resolves and no cache exists yet. Best-effort (auth/network/write errors are swallowed). Local runs never write it. | ## API Routes @@ -74,9 +77,9 @@ before any Storage call. `allowed_mime_types` is omitted when empty. Analytics bucket routes (`/storage/v1/iceberg/...`) are only reached when -`[storage.analytics].enabled = true` AND `--linked` is passed. +`[storage.analytics].enabled = true` AND the target is linked. -### Management API routes (remote `--linked` only, when env var not set) +### Management API routes (remote linked target only, when env var not set) | Method | Path | When | Response (used fields) | | ------ | ----------------------------------------- | ------------------------------------------- | ---------------------------------------------- | @@ -91,7 +94,7 @@ Analytics bucket routes (`/storage/v1/iceberg/...`) are only reached when | `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.<env>[.local]` files (shell wins; CLI-1878) | no | +| `SUPABASE_YES` | local runs: auto-confirms the overwrite/prune prompts, same as `--yes`, read from the shell env OR the project `.env`/`.env.local`/`.env.<env>[.local]` files (shell wins); remote runs: grants the hosted consent, shell env only (a project `.env` value does not count) | no | | `SUPABASE_API_PORT`, `SUPABASE_API_EXTERNAL_URL`, `SUPABASE_API_TLS_ENABLED`, `SUPABASE_API_TLS_CERT_PATH`, `SUPABASE_API_TLS_KEY_PATH`, `SUPABASE_API_ENABLED` | legacy backend, local runs only: override the matching `[api]` config fields for the gateway URL derivation and TLS validation, shell env OR project dotenv files (shell wins; #6452) | no | ## Exit Codes @@ -112,7 +115,8 @@ Analytics bucket routes (`/storage/v1/iceberg/...`) are only reached when | `1` | network / connection failure to the Storage gateway | | `1` | malformed list response (a 200 body whose shape doesn't decode) | | `1` | unreadable `objects_path` (filesystem error during walk/upload) | -| `1` | `--project-ref` set without `--linked` (see Notes) | +| `1` | `--local` with `--linked`, or `--project-ref` with `--local` (`TargetSelectorConflictError`, `TargetProjectRefConflictError`) — after the workdir checks and before any Storage call | +| `1` | remote run without consent: a JSON, piped or non-interactive run without `--yes`/shell `SUPABASE_YES` (`HostedMutationConsentRequiredError`), or a declined prompt (`HostedMutationDeclinedError`) — nothing is written | | `1` | stack backend: Storage disabled or its stack `failed`/`stopped` (`StackStorageCapabilityError`) | | `1` | stack backend: stack is not registered/ready, has no primary database, or the stack API is unavailable (`StackStorageUnavailableError`); a missing Storage endpoint is a capability failure (`StackStorageCapabilityError`) | @@ -131,6 +135,7 @@ No custom telemetry events beyond `cli_command_executed`. All progress is written to **stderr** (stdout stays empty): ``` +Target: <linked project> | local stack (<host>:<port>) Creating Storage bucket: <name> Updating Storage bucket: <id> Updating analytics buckets... @@ -148,18 +153,32 @@ WARNING: Vector buckets are not available in this project's region yet. Skipping WARNING: Vector buckets are not available in the local storage service. If this project is linked, run `supabase link` to update service versions, then restart the local stack. Skipping vector bucket seeding. ``` -Interactive (TTY) prompts: +Interactive (TTY) prompts on the local stack: ``` Bucket <id> already exists. Do you want to overwrite its properties? [Y/n] Bucket <name> not found in supabase/config.toml. Do you want to prune it? [y/N] ``` +A remote run asks one question instead, after printing what it will write (default No): + +``` +Create buckets: <names> +Overwrite the properties of buckets: <names> +Upload objects, overwriting existing ones, to buckets: <names> +Create analytics buckets: <names> +Delete analytics buckets not declared in supabase/config.toml: <names> +Create vector buckets: <names> +Delete vector buckets not declared in supabase/config.toml: <names> +Seed storage buckets on <target>? [y/N] +``` + ### `--output-format json` A final `result` object summarising the run is -emitted on stdout; progress/prompts are suppressed (prompts use their defaults: -overwrite → yes, prune → no). +emitted on stdout; progress/prompts are suppressed. On the local stack the per-item prompts use +their defaults (overwrite → yes, prune → no); a remote run needs `--yes` or a shell `SUPABASE_YES` +and otherwise fails with `HostedMutationConsentRequiredError` before writing anything. ### `--output-format stream-json` @@ -168,10 +187,22 @@ stdout and a terminal `result`/`error` event is emitted. ## Notes -- **`--project-ref`** overrides ONLY the linked-ref resolution used above - (flag > `SUPABASE_PROJECT_ID` > `.temp/project-ref`). It never implies - `--linked`: passing it without `--linked` (i.e. targeting local) is a hard - error rather than a silently discarded flag. +- **Target.** `--local`, `--linked` and `--project-ref` go through the shared target selector: + `--local` and `--linked` are mutually exclusive, `--project-ref` selects the linked project and + conflicts with `--local`, and a `false` switch counts as absent. `--project-ref` only supplies the + ref (flag > `SUPABASE_PROJECT_ID` > `.temp/project-ref`). With no selector the workspace target + decides. `--linked`/`--local` are scoped globals on the `seed` group, so they are also accepted + before the subcommand token. +- **Target banner.** A linked target prints `Target: …` to stderr once its ref resolves; the local + target prints `Target: local stack (<host>:<port>)` once the Storage endpoint is known, before the + first Storage request. `start` and `db reset` seed through the same core without this banner. +- **Remote consent.** After listing the existing buckets and before the first write, a remote run + asks once, with default No, listing every create, overwrite, upload and prune it will make. The one + answer covers all of them, so no per-bucket overwrite or prune prompt follows; a run that would + write nothing does not ask. `--yes` or a shell `SUPABASE_YES` grants it and echoes + `Confirmed by --yes: …` to stderr; otherwise only an interactive text-mode terminal is asked. A + piped answer on stdin and a `SUPABASE_YES` in `supabase/.env` do not count. The local stack never + asks for consent and keeps its per-item prompts. - **Remote (`--linked`) — config override merge.** The project ref is resolved BEFORE config is loaded. The config load then merges the `[remotes.<name>]` block whose `project_id` equals the resolved ref over the base config (including @@ -194,13 +225,13 @@ stdout and a terminal `result`/`error` event is emitted. rejection applies to the resolved secret (env or config value). Like the `[api]` checks, these run on the empty-config short-circuit too. - **Analytics buckets.** Analytics bucket upsert (`/storage/v1/iceberg/...`) is - gated on `[storage.analytics].enabled = true` AND `--linked`. It is never + gated on `[storage.analytics].enabled = true` AND a linked target. It is never reached for local runs. Errors from analytics routes propagate (no graceful skip). - **Vector graceful skip.** When vector buckets are configured but the local service does not support them (`FeatureNotEnabled`, `Vector service not configured`, or a 404 on `ListVectorBuckets`), a WARNING is printed and object upload still proceeds; the command exits `0`. -- **Idempotent.** Existing buckets are updated (after an overwrite confirm), +- **Idempotent.** Existing buckets are updated (after an overwrite confirm locally, or the remote consent), objects are uploaded with `x-upsert: true`. - **Content-Type** for uploaded objects: the first 512 bytes are sniffed using the WHATWG-style content-type sniffing algorithm diff --git a/apps/cli/src/commands/seed/buckets/buckets.e2e.test.ts b/apps/cli/src/commands/seed/buckets/buckets.e2e.test.ts index 643d76dd70..7598e6c4c0 100644 --- a/apps/cli/src/commands/seed/buckets/buckets.e2e.test.ts +++ b/apps/cli/src/commands/seed/buckets/buckets.e2e.test.ts @@ -22,7 +22,7 @@ const makeProjectDir = Effect.fnUntraced(function* (prefix: string) { * Golden-path e2e: exercises the real compiled-binary boundary for the two * network-free paths of `seed buckets`: * - an empty `[storage]` config is a no-op (exit 0, no stdout); - * - `--local --linked` is rejected by the mutually-exclusive flag check. + * - `--local --linked` is rejected by the shared target selector. * Bucket/object seeding is covered by the integration and unit suites. */ describe("supabase seed buckets", () => { @@ -51,7 +51,7 @@ describe("supabase seed buckets", () => { ); expect(exitCode).toBe(1); expect(`${stdout}${stderr}`).toContain( - "if any flags in the group [local linked] are set none of the others can be", + "Only one of --local, --linked and --db-url can be set; got --local and --linked.", ); }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), E2E_TIMEOUT_MS, @@ -86,7 +86,7 @@ describe("supabase seed buckets", () => { ); expect(exitCode).toBe(1); expect(`${stdout}${stderr}`).toContain( - "if any flags in the group [local linked] are set none of the others can be", + "Only one of --local, --linked and --db-url can be set; got --local and --linked.", ); }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), E2E_TIMEOUT_MS, 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 5e12021d32..b6ed62f945 100644 --- a/apps/cli/src/commands/seed/buckets/buckets.integration.test.ts +++ b/apps/cli/src/commands/seed/buckets/buckets.integration.test.ts @@ -31,7 +31,10 @@ import { useTempWorkdir, } from "../../../../tests/helpers/command-mocks.ts"; import { CliArgs } from "../../../shared/cli/cli-args.service.ts"; -import { YesFlag } from "../../../command-internal/global-flags.ts"; +import { HostedMutationDeclinedError } from "../../../command-internal/hosted-consent/hosted-consent.errors.ts"; +import { OutputFlag, YesFlag } from "../../../command-internal/global-flags.ts"; +import { TargetPolicy } from "../../../command-internal/target/target-policy.ts"; +import { commandTargetLayer } from "../../../shared/output/command-target.layer.ts"; import type { OutputFormat } from "../../../shared/output/types.ts"; import { ProjectRefResolver } from "../../../config/project-ref.service.ts"; import { ProjectRefNotLinkedError } from "../../../config/project-ref.errors.ts"; @@ -47,9 +50,12 @@ import { StackStorageCapabilityError, StackStorageUnavailableError, } from "../../../command-internal/stack-storage.ts"; +import { describeHostedConsent } from "../../../../tests/helpers/hosted-consent.ts"; import { buildStorageStackApi, STORAGE_TEST_JWT_SECRET, + storageConsentApi, + storageConsentServices, type SetupStorageStackApiOptions, } from "../../../../tests/helpers/storage.ts"; import { unusedStackServices } from "../../../../tests/helpers/unused-stack.ts"; @@ -191,14 +197,16 @@ const setupSeedBuckets = Effect.fnUntraced(function* ( }), ) : Effect.succeed(projectRefRef), - resolveRef: () => - opts.linkedFails === true - ? Effect.fail( - new ProjectRefNotLinkedError({ - message: "Cannot find project ref. Have you run supabase link?", - }), - ) - : Effect.succeed({ ref: projectRefRef, source: "file" as const, matchesLinkedRef: true }), + resolveRef: (flagValue: Option.Option<string>) => + Option.isSome(flagValue) && flagValue.value.length > 0 + ? Effect.succeed({ ref: flagValue.value, source: "flag" as const, matchesLinkedRef: false }) + : opts.linkedFails === true + ? Effect.fail( + new ProjectRefNotLinkedError({ + message: "Cannot find project ref. Have you run supabase link?", + }), + ) + : Effect.succeed({ ref: projectRefRef, source: "file" as const, matchesLinkedRef: true }), resolveOptional: () => Effect.succeed(Option.some(projectRefRef)), // An explicit `--project-ref` flag takes precedence and short-circuits // before `linkedFails`, so a test can prove the flag resolves a ref even @@ -257,6 +265,9 @@ const setupSeedBuckets = Effect.fnUntraced(function* ( const layer = Layer.mergeAll( out.layer, + commandTargetLayer.pipe(Layer.provide(out.layer)), + Layer.succeed(TargetPolicy, { group: "selectable", consent: "no" }), + Layer.succeed(OutputFlag, opts.format === "json" ? Option.some("json") : Option.none()), httpLayer, telemetry.layer, ConfigProvider.layer(ConfigProvider.fromEnvRecord(process.env, { preserveEmptyStrings: true })), @@ -2287,7 +2298,7 @@ describe("seed buckets", () => { expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { expect(Cause.pretty(exit.cause)).toContain( - "--project-ref only applies when targeting the linked project; use it with --linked (not --local)", + "--project-ref selects a linked project and cannot be combined with --local.", ); } expect(requests).toEqual([]); @@ -2295,42 +2306,6 @@ describe("seed buckets", () => { }).pipe(seedScenario), ); - it.effect("--linked=false still takes the linked path (flag presence, not value)", () => - Effect.gen(function* () { - const { layer, requests, managementRequests } = yield* setupSeedBuckets(tmp.current, { - toml: "[storage.buckets.test]\npublic = true\n", - projectRef: VALID_REF, - recordManagementApi: true, - args: ["seed", "buckets", "--linked=false"], - routes: [ - { method: "GET", match: "/storage/v1/bucket", body: [] }, - { method: "POST", match: "/storage/v1/bucket", body: { name: "test" } }, - ], - }); - const exit = yield* seedBuckets({ - linked: false, - local: true, - projectRef: Option.none(), - }).pipe(Effect.provide(layer), Effect.exit); - expect(Exit.isSuccess(exit)).toBe(true); - expect(requests.every((r) => r.url.startsWith(`https://${VALID_REF}.supabase.co`))).toBe( - true, - ); - expect( - managementRequests.map(({ method, urlWithParams }) => `${method} ${urlWithParams}`), - ).toEqual([ - `GET https://api.supabase.com/v1/projects/${VALID_REF}/api-keys?reveal=true`, - `GET https://api.supabase.com/v1/projects/${VALID_REF}`, - ]); - assertPermissionDrift({ - command: "seed buckets", - activeFlags: { linked: false }, - requests: managementRequests, - exact: true, - }); - }).pipe(seedScenario), - ); - it.effect("--local=false stays on the local path", () => Effect.gen(function* () { const { layer, requests } = yield* setupSeedBuckets(tmp.current, { @@ -2539,7 +2514,7 @@ describe("seed buckets", () => { }).pipe(seedScenario), ); - it.effect("prunes a stale analytics bucket when the prompt is accepted", () => + it.effect("prunes a stale analytics bucket after the single hosted confirmation", () => Effect.gen(function* () { const flags: BucketsFlags = { linked: true, local: false, projectRef: Option.none() }; const { layer, out, requests } = yield* setupSeedBuckets(tmp.current, { @@ -2570,6 +2545,10 @@ describe("seed buckets", () => { const exit = yield* seedBuckets(flags).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isSuccess(exit)).toBe(true); expect(out.stderrText).toContain("Pruning analytics bucket: stale-analytics"); + expect(out.stderrText).toContain( + "Delete analytics buckets not declared in supabase/config.toml: stale-analytics", + ); + expect(out.promptConfirmCalls.map((call) => call.message)).toEqual(["Seed storage buckets?"]); expect( requests.some( (r) => r.method === "DELETE" && r.url.includes("/iceberg/bucket/stale-analytics"), @@ -2578,6 +2557,68 @@ describe("seed buckets", () => { }).pipe(seedScenario), ); + it.effect( + "overwrites an existing hosted bucket under one confirmation, with no per-bucket prompt", + () => + Effect.gen(function* () { + const { layer, out, requests } = yield* setupSeedBuckets(tmp.current, { + toml: "[storage.buckets.test]\npublic = true\n", + projectRef: VALID_REF, + args: ["seed", "buckets", "--linked"], + routes: [ + { method: "GET", match: "/storage/v1/bucket", body: [{ name: "test", id: "test" }] }, + { method: "PUT", match: "/storage/v1/bucket/test", body: {} }, + ], + }); + const exit = yield* seedBuckets({ + linked: true, + local: false, + projectRef: Option.none(), + }).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isSuccess(exit)).toBe(true); + expect(out.stderrText).toContain("Overwrite the properties of buckets: test"); + expect(out.promptConfirmCalls.map((call) => call.message)).toEqual([ + "Seed storage buckets?", + ]); + expect(requests.some((r) => r.method === "PUT")).toBe(true); + }).pipe(seedScenario), + ); + + it.effect("writes nothing to the hosted project when the confirmation is declined", () => + Effect.gen(function* () { + const { layer, requests } = yield* setupSeedBuckets(tmp.current, { + toml: "[storage.buckets.test]\npublic = true\n", + projectRef: VALID_REF, + args: ["seed", "buckets", "--linked"], + confirm: [false], + routes: [{ method: "GET", match: "/storage/v1/bucket", body: [] }], + }); + const exit = yield* seedBuckets({ + linked: true, + local: false, + projectRef: Option.none(), + }).pipe(Effect.provide(layer), Effect.exit); + expect(failureErrors(exit).some((e) => e instanceof HostedMutationDeclinedError)).toBe(true); + expect(requests.every((r) => r.method === "GET")).toBe(true); + }).pipe(seedScenario), + ); + + it.effect("announces the local stack target and asks for no consent", () => + Effect.gen(function* () { + const { layer, out } = yield* setupSeedBuckets(tmp.current, { + toml: "[storage.buckets.test]\npublic = true\n", + routes: [ + { method: "GET", match: "/storage/v1/bucket", body: [] }, + { method: "POST", match: "/storage/v1/bucket", body: { name: "test" } }, + ], + }); + const exit = yield* seedBuckets(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isSuccess(exit)).toBe(true); + expect(out.stderrText).toContain("Target: local stack ("); + expect(out.promptConfirmCalls).toEqual([]); + }).pipe(seedScenario), + ); + it.effect("--linked fails when the project is not linked", () => Effect.gen(function* () { const flags: BucketsFlags = { linked: true, local: false, projectRef: Option.none() }; @@ -3138,3 +3179,30 @@ describe("stack backend", () => { }).pipe(Effect.provide(BunServices.layer)), ); }); + +describeHostedConsent("seed buckets", { + api: storageConsentApi, + setup: (workdir) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* fs.makeDirectory(`${workdir}/supabase`, { recursive: true }); + yield* fs.writeFileString( + `${workdir}/supabase/config.toml`, + "[storage.buckets.test]\npublic = true\n", + ); + }), + invoke: ({ workdir }) => { + const storage = storageConsentServices(workdir, [ + { method: "GET", match: "/storage/v1/bucket", body: [], persist: true }, + { method: "POST", match: "/storage/v1/bucket", body: { name: "test" } }, + ]); + return { + run: seedBuckets({ + linked: false, + local: false, + projectRef: Option.some(VALID_REF), + }).pipe(Effect.provide(storage.layer)), + mutations: storage.writes, + }; + }, +}); diff --git a/apps/cli/src/commands/storage/cp/SIDE_EFFECTS.md b/apps/cli/src/commands/storage/cp/SIDE_EFFECTS.md index f54daf7a9d..2b3b848045 100644 --- a/apps/cli/src/commands/storage/cp/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/storage/cp/SIDE_EFFECTS.md @@ -3,11 +3,12 @@ Copies objects between local paths and the Storage service. The scheme of `src`/`dst` selects the operation: `ss://`→local download, local→`ss://` upload, both `ss://` → error, both local → unsupported. -`--local` resolves the Storage gateway through the `experimental.stack` -feature flag, same `SUPABASE_EXPERIMENTAL_STACK=1|0` env precedence as -[`docs/stack-commands.md`](../../../../docs/stack-commands.md). `--linked`/`--project-ref` -targeting is unchanged under either backend; see Notes for the stack-backend endpoint, -credential, capability-state, and error behavior. +With no target flag the command follows the workspace target (`SUPABASE_TARGET`, then `target` in +`config.toml`, default `local`; see [ADR 0033](../../../../../../docs/adr/0033-target-selection-and-hosted-mutation-consent.md)). `--local` selects +the local stack; `--linked` or `--project-ref` select the linked project. The local Storage gateway +resolves through the `experimental.stack` feature flag, same `SUPABASE_EXPERIMENTAL_STACK=1|0` env +precedence as [`docs/stack-commands.md`](../../../../docs/stack-commands.md); see Notes for the +stack-backend endpoint, credential, capability-state, and error behavior. ## Files Read @@ -56,15 +57,16 @@ backend — same roles as `storage ls`. ## Exit Codes -| Code | Condition | -| ---- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `0` | success | -| `1` | resolved `--workdir`/`SUPABASE_WORKDIR` doesn't exist or isn't a directory (`StorageWorkdirError`) — beats every other guard | -| `1` | an explicit `--workdir`/`SUPABASE_WORKDIR` on a LOCAL target holds no project config (`StorageMissingProjectConfigError`) | -| `1` | invalid/parse url, unsupported operation (local→local), copy-between-buckets, object-not-found (recursive download), file create/read failure, API non-2xx, network, auth, config parse | -| `1` | `--project-ref` set with `--local` (see Notes) | -| `1` | stack backend: Storage disabled or its stack `failed`/`stopped` (`StackStorageCapabilityError`) | -| `1` | stack backend: stack is not registered/ready, has no primary database, or the stack API is unavailable (`StackStorageUnavailableError`); a missing Storage endpoint is a capability failure (`StackStorageCapabilityError`) | +| Code | Condition | +| ---- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `0` | success | +| `1` | resolved `--workdir`/`SUPABASE_WORKDIR` doesn't exist or isn't a directory (`StorageWorkdirError`) — beats every other guard | +| `1` | an explicit `--workdir`/`SUPABASE_WORKDIR` on a LOCAL target holds no project config (`StorageMissingProjectConfigError`) | +| `1` | invalid/parse url, unsupported operation (local→local), copy-between-buckets, object-not-found (recursive download), file create/read failure, API non-2xx, network, auth, config parse | +| `1` | `--local` with `--linked`, or `--project-ref` with `--local` (`TargetSelectorConflictError`, `TargetProjectRefConflictError`), after the experimental gate and the workdir check and before any Storage request | +| `1` | upload to a linked project without consent: a JSON, piped or non-interactive run without `--yes`/shell `SUPABASE_YES` (`HostedMutationConsentRequiredError`), or a declined prompt (`HostedMutationDeclinedError`) — nothing is sent | +| `1` | stack backend: Storage disabled or its stack `failed`/`stopped` (`StackStorageCapabilityError`) | +| `1` | stack backend: stack is not registered/ready, has no primary database, or the stack API is unavailable (`StackStorageUnavailableError`); a missing Storage endpoint is a capability failure (`StackStorageCapabilityError`) | ## Output @@ -95,10 +97,21 @@ backend — same roles as `storage ls`. ## Notes -- **`--project-ref`** overrides ONLY the linked-ref - resolution used above (flag > `SUPABASE_PROJECT_ID` > `.temp/project-ref`). - It never implies `--linked`: passing it with `--local` is a hard error - rather than a silently discarded flag. +- **Target.** `--local`, `--linked` and `--project-ref` go through the shared target selector: + `--local` and `--linked` are mutually exclusive, `--project-ref` selects the linked project and + conflicts with `--local`, and a `false` switch counts as absent. `--project-ref` only supplies the + ref (flag > `SUPABASE_PROJECT_ID` > `.temp/project-ref`). With no selector the workspace target decides. +- **Target banner.** A linked target prints `Target: …` to stderr once its ref resolves; the local + target prints `Target: local stack (<host>:<port>)` once the Storage endpoint is known. Both come + before the first Storage request. +- **Hosted consent.** Uploading to a linked project asks once, before any Storage request: + `Upload <src> to <dst>?`, default No; a recursive upload adds a line saying existing objects + under the destination are overwritten. `--yes` or a shell `SUPABASE_YES` grants it and echoes + `Confirmed by --yes: …` to stderr; otherwise only an interactive text-mode terminal is asked, and + JSON, piped, agent and non-TTY runs exit 1 with `HostedMutationConsentRequiredError`. + A piped answer on stdin and a `SUPABASE_YES` in `supabase/.env` do not count. Declining exits 1 with + `HostedMutationDeclinedError` (`context canceled`); no Storage request is sent. + Downloads and uploads to the local stack never ask. - Single upload does NOT send `x-upsert`; recursive upload sets it. - `--content-type` overrides the sniffed type; an explicit value is still refined when it is a generic `text/plain`. diff --git a/apps/cli/src/commands/storage/cp/cp.command.integration.test.ts b/apps/cli/src/commands/storage/cp/cp.command.integration.test.ts index 48404d37d0..7bb410a154 100644 --- a/apps/cli/src/commands/storage/cp/cp.command.integration.test.ts +++ b/apps/cli/src/commands/storage/cp/cp.command.integration.test.ts @@ -86,7 +86,7 @@ describe("storage cp --jobs negative rejection (command-tree wiring)", () => { expect(Option.isSome(failure)).toBe(true); const causeText = Cause.pretty(exit.cause); expect(causeText).not.toContain("must set the --experimental flag to run this command"); - expect(causeText).not.toContain("StorageMutuallyExclusiveFlags"); + expect(causeText).not.toContain("TargetSelectorConflictError"); // `normalizeCause` is the same rendering path `runCli` uses for parse failures. expect(normalizeCause(exit.cause).message).toBe( 'invalid argument "-1" for "-j, --jobs" flag: expected an unsigned integer', diff --git a/apps/cli/src/commands/storage/cp/cp.integration.test.ts b/apps/cli/src/commands/storage/cp/cp.integration.test.ts index eddb2d36c3..d01c559a2e 100644 --- a/apps/cli/src/commands/storage/cp/cp.integration.test.ts +++ b/apps/cli/src/commands/storage/cp/cp.integration.test.ts @@ -1,7 +1,14 @@ import { describe, expect, it } from "@effect/vitest"; import { Cause, Effect, Exit, FileSystem, Option, Path, Predicate } from "effect"; - -import { setupStorage, STORAGE_TEST_JWT_SECRET } from "../../../../tests/helpers/storage.ts"; +import { join } from "node:path"; + +import { describeHostedConsent } from "../../../../tests/helpers/hosted-consent.ts"; +import { + setupStorage, + STORAGE_TEST_JWT_SECRET, + storageConsentApi, + storageConsentServices, +} from "../../../../tests/helpers/storage.ts"; import { VALID_REF, useTempWorkdir } from "../../../../tests/helpers/command-mocks.ts"; import { StackStorageCapabilityError } from "../../../command-internal/stack-storage.ts"; import { generateLocalJwt } from "../../../command-internal/local-jwt.ts"; @@ -29,7 +36,7 @@ function cpFlags(opts: { cacheControl: opts.cacheControl === undefined ? Option.none() : Option.some(opts.cacheControl), contentType: opts.contentType === undefined ? Option.none() : Option.some(opts.contentType), jobs: opts.jobs === undefined ? Option.none() : Option.some(opts.jobs), - linked: true, + linked: opts.local === false, local: opts.local ?? true, projectRef: Option.none(), }; @@ -513,6 +520,7 @@ describe("storage cp", () => { it.effect("targets the linked project's Storage host and flushes telemetry on upload", () => { const { layer, requests, telemetry, linkedCache } = setupStorage(tmp.current, { // No `--local`, so the linked path resolves the ref + service-role key. + yes: true, routes: [{ method: "POST", match: OBJECT("private/readme.md"), body: {} }], }); return Effect.gen(function* () { @@ -534,11 +542,30 @@ describe("storage cp", () => { }).pipe(Effect.provide(layer)); }); + it.effect("downloads from the linked project without asking for consent", () => { + const { layer, out } = setupStorage(tmp.current, { + format: "json", + routes: [{ method: "GET", match: OBJECT("private/readme.md"), rawBody: "downloaded-bytes" }], + }); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const dst = path.join(tmp.current, "out.md"); + const exit = yield* storageCp( + cpFlags({ src: "ss:///private/readme.md", dst, local: false }), + ).pipe(Effect.exit); + expect(Exit.isSuccess(exit)).toBe(true); + expect(yield* fs.readFileString(dst)).toBe("downloaded-bytes"); + expect(out.promptConfirmCalls).toEqual([]); + }).pipe(Effect.provide(layer)); + }); + it.effect("uploads to 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. const FLAG_REF = "flagflagflagflagflag"; const { layer, requests, linkedCache } = setupStorage(tmp.current, { + yes: true, routes: [{ method: "POST", match: OBJECT("private/readme.md"), body: {} }], }); return Effect.gen(function* () { @@ -576,7 +603,7 @@ describe("storage cp", () => { expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { expect(Cause.pretty(exit.cause)).toContain( - "--project-ref only applies when targeting the linked project; use it with --linked (not --local)", + "--project-ref selects a linked project and cannot be combined with --local.", ); } expect(requests).toHaveLength(0); @@ -774,3 +801,26 @@ describe("stack backend", () => { }, ); }); + +describeHostedConsent("storage cp", { + api: storageConsentApi, + setup: (workdir) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* fs.writeFileString(join(workdir, "readme.md"), "hello world"); + }), + invoke: ({ workdir }) => { + const storage = storageConsentServices(workdir, [ + { method: "POST", match: OBJECT("private/readme.md"), body: {} }, + ]); + return { + run: storageCp({ + ...cpFlags({ src: join(workdir, "readme.md"), dst: "ss:///private/readme.md" }), + local: false, + linked: false, + projectRef: Option.some(VALID_REF), + }).pipe(Effect.provide(storage.layer)), + mutations: storage.writes, + }; + }, +}); diff --git a/apps/cli/src/commands/storage/cp/cp.live.test.ts b/apps/cli/src/commands/storage/cp/cp.live.test.ts index 811666fe68..8376988844 100644 --- a/apps/cli/src/commands/storage/cp/cp.live.test.ts +++ b/apps/cli/src/commands/storage/cp/cp.live.test.ts @@ -28,7 +28,14 @@ test("copies a local file to the remote bucket", ({ cli, cliEffect, project, wor }); requireLiveSuccess(linked, "link setup for storage cp"); - const result = yield* cliEffect(["storage", "cp", local, remote, ...storageLiveFlags]); + const result = yield* cliEffect([ + "storage", + "cp", + local, + remote, + "--yes", + ...storageLiveFlags, + ]); expect(result.exitCode, result.stderr).toBe(0); }); diff --git a/apps/cli/src/commands/storage/ls/SIDE_EFFECTS.md b/apps/cli/src/commands/storage/ls/SIDE_EFFECTS.md index 3feb26a624..6d1b9a0dc0 100644 --- a/apps/cli/src/commands/storage/ls/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/storage/ls/SIDE_EFFECTS.md @@ -2,11 +2,12 @@ Lists objects/buckets by path prefix against the Storage gateway (local stack or linked project). -`--local` resolves the Storage gateway through the `experimental.stack` -feature flag, same `SUPABASE_EXPERIMENTAL_STACK=1|0` env precedence as -[`docs/stack-commands.md`](../../../../docs/stack-commands.md). `--linked`/`--project-ref` -targeting is unchanged under either backend; see Notes for the stack-backend endpoint, -credential, capability-state, and error behavior. +With no target flag the command follows the workspace target (`SUPABASE_TARGET`, then `target` in +`config.toml`, default `local`; see [ADR 0033](../../../../../../docs/adr/0033-target-selection-and-hosted-mutation-consent.md)). `--local` selects +the local stack; `--linked` or `--project-ref` select the linked project. The local Storage gateway +resolves through the `experimental.stack` feature flag, same `SUPABASE_EXPERIMENTAL_STACK=1|0` env +precedence as [`docs/stack-commands.md`](../../../../docs/stack-commands.md); see Notes for the +stack-backend endpoint, credential, capability-state, and error behavior. ## Files Read @@ -60,7 +61,7 @@ Auth: `apikey` header always; `Authorization: Bearer <key>` unless the key is `s | `1` | resolved `--workdir`/`SUPABASE_WORKDIR` doesn't exist or isn't a directory (`StorageWorkdirError`) — beats every other guard | | `1` | an explicit `--workdir`/`SUPABASE_WORKDIR` on a LOCAL target holds no project config (`StorageMissingProjectConfigError`) | | `1` | invalid URL / url-parse error / API non-2xx / network / auth / config parse | -| `1` | `--project-ref` set with `--local` (see Notes) | +| `1` | `--local` with `--linked`, or `--project-ref` with `--local` (`TargetSelectorConflictError`, `TargetProjectRefConflictError`), after the experimental gate and the workdir check and before any Storage request | | `1` | stack backend: Storage disabled or its stack `failed`/`stopped` (`StackStorageCapabilityError`) | | `1` | stack backend: stack is not registered/ready, has no primary database, or the stack API is unavailable (`StackStorageUnavailableError`); a missing Storage endpoint is a capability failure (`StackStorageCapabilityError`) | @@ -96,16 +97,18 @@ No custom storage telemetry events. - Default path is `ss:///` (all buckets root) → remotePath `/`; recursive file paths then carry a leading slash, while an empty bucket is reported bare as `<bucket>/`. - `--recursive`/`-r` walks the tree (BFS). -- **`--project-ref`** overrides ONLY the linked-ref - resolution used above (flag > `SUPABASE_PROJECT_ID` > `.temp/project-ref`). - It never implies `--linked`: passing it with `--local` is a hard error - rather than a silently discarded flag. -- `--local` / `--linked` are mutually exclusive; `--local` routes to the local stack, - otherwise the linked project is used. They are declared **per-leaf** (not as - `storage`-group scoped globals) because Effect CLI requires global-flag names to be - unique tree-wide and `seed` already owns `linked`/`local`; the only behavioural cost - is that they must follow the subcommand token +- **Target.** `--local`, `--linked` and `--project-ref` go through the shared target selector: + `--local` and `--linked` are mutually exclusive, `--project-ref` selects the linked project and + conflicts with `--local`, and a `false` switch counts as absent. `--project-ref` only supplies the + ref (flag > `SUPABASE_PROJECT_ID` > `.temp/project-ref`). With no selector the workspace target decides. +- **Target banner.** A linked target prints `Target: …` to stderr once its ref resolves; the local + target prints `Target: local stack (<host>:<port>)` once the Storage endpoint is known. Both come + before the first Storage request. +- `--local` and `--linked` are declared **per-leaf** (not as `storage`-group scoped globals) + because Effect CLI requires global-flag names to be unique tree-wide and `seed` already owns + `linked`/`local`; the only behavioural cost is that they must follow the subcommand token (`storage ls --local`, not `storage --local ls`) — the same shape the `db` family uses. +- Listing is a read: it never asks for consent. - **Stack backend (`--local`).** The Storage endpoint comes from the selected composition's Storage member observation, and the service-role JWT is generated from the primary database's observed JWT secret. The stack is opened by project identity through the `@supabase/stack` API, 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 4d20b423f0..3bcd61e1c1 100644 --- a/apps/cli/src/commands/storage/ls/ls.integration.test.ts +++ b/apps/cli/src/commands/storage/ls/ls.integration.test.ts @@ -37,7 +37,7 @@ function lsFlags( return { path: opts.path === undefined ? Option.none() : Option.some(opts.path), recursive: opts.recursive ?? false, - linked: true, + linked: opts.local === false, local: opts.local ?? true, projectRef: Option.none(), }; @@ -299,7 +299,7 @@ project_id = "env(STAGING_REF)" expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { expect(Cause.pretty(exit.cause)).toContain( - "--project-ref only applies when targeting the linked project; use it with --linked (not --local)", + "--project-ref selects a linked project and cannot be combined with --local.", ); } expect(requests).toHaveLength(0); @@ -475,7 +475,7 @@ describe("stack backend", () => { const tmp = useTempWorkdir("supabase-storage-ls-stack-"); it.effect("routes local requests through the stack's api endpoint and JWT", () => { - const { layer, requests } = setupStorage(tmp.current, { + const { layer, out, requests } = setupStorage(tmp.current, { toml: 'project_id = "test"\n[api]\nport = 65000\n', local: true, stackBackend: true, @@ -484,6 +484,7 @@ describe("stack backend", () => { return Effect.gen(function* () { const exit = yield* storageLs(lsFlags()).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isSuccess(exit)).toBe(true); + expect(out.stderrText).toContain("Target: local stack (127.0.0.1:59999)\n"); expect(requests).toHaveLength(1); expect(requests[0]?.url.startsWith("http://127.0.0.1:59999")).toBe(true); const serviceRoleJwt = generateLocalJwt(STORAGE_TEST_JWT_SECRET, "service_role"); @@ -884,7 +885,7 @@ describe("stack backend", () => { }); it.effect("legacy default still derives the gateway URL from [api] port", () => { - const { layer, requests } = setupStorage(tmp.current, { + const { layer, out, requests } = setupStorage(tmp.current, { toml: 'project_id = "test"\n[api]\nport = 65432\n', local: true, routes: [{ method: "GET", match: BUCKET, body: [{ name: "test", id: "test" }] }], @@ -893,6 +894,22 @@ describe("stack backend", () => { const exit = yield* storageLs(lsFlags()).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isSuccess(exit)).toBe(true); expect(requests[0]?.url.includes(":65432")).toBe(true); + expect(out.stderrText).toContain("Target: local stack (127.0.0.1:65432)\n"); + }); + }); + + it.effect("targets the local stack when no target flag is set", () => { + const { layer, requests } = setupStorage(tmp.current, { + toml: 'project_id = "test"\n[api]\nport = 65432\n', + routes: [{ method: "GET", match: BUCKET, body: [{ name: "test", id: "test" }] }], + }); + return Effect.gen(function* () { + const exit = yield* storageLs({ ...lsFlags(), local: false, linked: false }).pipe( + Effect.provide(layer), + Effect.exit, + ); + expect(Exit.isSuccess(exit)).toBe(true); + expect(requests[0]?.url.startsWith("http://127.0.0.1:65432")).toBe(true); }); }); }); diff --git a/apps/cli/src/commands/storage/ls/ls.live.test.ts b/apps/cli/src/commands/storage/ls/ls.live.test.ts index 6ed30df274..120f0e0bc0 100644 --- a/apps/cli/src/commands/storage/ls/ls.live.test.ts +++ b/apps/cli/src/commands/storage/ls/ls.live.test.ts @@ -27,7 +27,14 @@ test("lists an uploaded object", ({ cli, cliEffect, project, workspace }) => env: { SUPABASE_DB_PASSWORD: project.dbPassword }, }); requireLiveSuccess(linked, "link setup for storage ls"); - const uploaded = yield* cliEffect(["storage", "cp", local, remote, ...storageLiveFlags]); + const uploaded = yield* cliEffect([ + "storage", + "cp", + local, + remote, + "--yes", + ...storageLiveFlags, + ]); requireLiveSuccess(uploaded, "storage cp setup for storage ls"); const result = yield* cliEffect([ diff --git a/apps/cli/src/commands/storage/mv/SIDE_EFFECTS.md b/apps/cli/src/commands/storage/mv/SIDE_EFFECTS.md index 74d6a284e0..7a72c4556b 100644 --- a/apps/cli/src/commands/storage/mv/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/storage/mv/SIDE_EFFECTS.md @@ -4,11 +4,12 @@ Moves objects within a bucket. Both paths must be `ss://` and resolve to the sam A direct move that returns `not_found` falls back to a recursive per-object move when `--recursive` is set. -`--local` resolves the Storage gateway through the `experimental.stack` -feature flag, same `SUPABASE_EXPERIMENTAL_STACK=1|0` env precedence as -[`docs/stack-commands.md`](../../../../docs/stack-commands.md). `--linked`/`--project-ref` -targeting is unchanged under either backend; see Notes for the stack-backend endpoint, -credential, capability-state, and error behavior. +With no target flag the command follows the workspace target (`SUPABASE_TARGET`, then `target` in +`config.toml`, default `local`; see [ADR 0033](../../../../../../docs/adr/0033-target-selection-and-hosted-mutation-consent.md)). `--local` selects +the local stack; `--linked` or `--project-ref` select the linked project. The local Storage gateway +resolves through the `experimental.stack` feature flag, same `SUPABASE_EXPERIMENTAL_STACK=1|0` env +precedence as [`docs/stack-commands.md`](../../../../docs/stack-commands.md); see Notes for the +stack-backend endpoint, credential, capability-state, and error behavior. ## Files Read @@ -51,15 +52,16 @@ backend — same roles as `storage ls`. ## Exit Codes -| Code | Condition | -| ---- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `0` | success | -| `1` | resolved `--workdir`/`SUPABASE_WORKDIR` doesn't exist or isn't a directory (`StorageWorkdirError`) — beats every other guard | -| `1` | an explicit `--workdir`/`SUPABASE_WORKDIR` on a LOCAL target holds no project config (`StorageMissingProjectConfigError`) | -| `1` | invalid/parse url, missing object path (both roots), cross-bucket move, object-not-found (recursive empty), API non-2xx, network, auth, config parse | -| `1` | `--project-ref` set with `--local` (see Notes) | -| `1` | stack backend: Storage disabled or its stack `failed`/`stopped` (`StackStorageCapabilityError`) | -| `1` | stack backend: stack is not registered/ready, has no primary database, or the stack API is unavailable (`StackStorageUnavailableError`); a missing Storage endpoint is a capability failure (`StackStorageCapabilityError`) | +| Code | Condition | +| ---- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `0` | success | +| `1` | resolved `--workdir`/`SUPABASE_WORKDIR` doesn't exist or isn't a directory (`StorageWorkdirError`) — beats every other guard | +| `1` | an explicit `--workdir`/`SUPABASE_WORKDIR` on a LOCAL target holds no project config (`StorageMissingProjectConfigError`) | +| `1` | invalid/parse url, missing object path (both roots), cross-bucket move, object-not-found (recursive empty), API non-2xx, network, auth, config parse | +| `1` | `--local` with `--linked`, or `--project-ref` with `--local` (`TargetSelectorConflictError`, `TargetProjectRefConflictError`), after the experimental gate and the workdir check and before any Storage request | +| `1` | move on a linked project without consent: a JSON, piped or non-interactive run without `--yes`/shell `SUPABASE_YES` (`HostedMutationConsentRequiredError`), or a declined prompt (`HostedMutationDeclinedError`) — nothing is sent | +| `1` | stack backend: Storage disabled or its stack `failed`/`stopped` (`StackStorageCapabilityError`) | +| `1` | stack backend: stack is not registered/ready, has no primary database, or the stack API is unavailable (`StackStorageUnavailableError`); a missing Storage endpoint is a capability failure (`StackStorageCapabilityError`) | ## Output @@ -90,10 +92,21 @@ backend — same roles as `storage ls`. ## Notes -- **`--project-ref`** overrides ONLY the linked-ref - resolution used above (flag > `SUPABASE_PROJECT_ID` > `.temp/project-ref`). - It never implies `--linked`: passing it with `--local` is a hard error - rather than a silently discarded flag. +- **Target.** `--local`, `--linked` and `--project-ref` go through the shared target selector: + `--local` and `--linked` are mutually exclusive, `--project-ref` selects the linked project and + conflicts with `--local`, and a `false` switch counts as absent. `--project-ref` only supplies the + ref (flag > `SUPABASE_PROJECT_ID` > `.temp/project-ref`). With no selector the workspace target decides. +- **Target banner.** A linked target prints `Target: …` to stderr once its ref resolves; the local + target prints `Target: local stack (<host>:<port>)` once the Storage endpoint is known. Both come + before the first Storage request. +- **Hosted consent.** Moving on a linked project asks once, after the path validation and before any + Storage request: `Move <src> to <dst>?`, default No; a recursive move adds a line saying every + object under the source path is moved. `--yes` or a shell `SUPABASE_YES` grants it and echoes + `Confirmed by --yes: …` to stderr; otherwise only an interactive text-mode terminal is asked, and + JSON, piped, agent and non-TTY runs exit 1 with `HostedMutationConsentRequiredError`. + A piped answer on stdin and a `SUPABASE_YES` in `supabase/.env` do not count. Declining exits 1 with + `HostedMutationDeclinedError` (`context canceled`); no Storage request is sent. + A move on the local stack never asks. - Both `src` and `dst` must be `ss://` URLs (a stricter parse than the lenient one `cp` uses). - **Stack backend (`--local`).** The Storage endpoint comes from the selected composition's diff --git a/apps/cli/src/commands/storage/mv/mv.integration.test.ts b/apps/cli/src/commands/storage/mv/mv.integration.test.ts index e7d370ab82..c2cd370c93 100644 --- a/apps/cli/src/commands/storage/mv/mv.integration.test.ts +++ b/apps/cli/src/commands/storage/mv/mv.integration.test.ts @@ -3,7 +3,13 @@ import { Cause, Effect, Exit, Option, Predicate } from "effect"; import { StackStorageCapabilityError } from "../../../command-internal/stack-storage.ts"; import { generateLocalJwt } from "../../../command-internal/local-jwt.ts"; -import { setupStorage, STORAGE_TEST_JWT_SECRET } from "../../../../tests/helpers/storage.ts"; +import { describeHostedConsent } from "../../../../tests/helpers/hosted-consent.ts"; +import { + setupStorage, + STORAGE_TEST_JWT_SECRET, + storageConsentApi, + storageConsentServices, +} from "../../../../tests/helpers/storage.ts"; import { VALID_REF, useTempWorkdir } from "../../../../tests/helpers/command-mocks.ts"; import { storageMv } from "./mv.handler.ts"; import type { StorageMvFlags } from "./mv.command.ts"; @@ -21,7 +27,7 @@ function mvFlags(opts: { src: opts.src, dst: opts.dst, recursive: opts.recursive ?? false, - linked: true, + linked: opts.local === false, local: opts.local ?? true, projectRef: Option.none(), }; @@ -266,6 +272,7 @@ describe("storage mv", () => { it.effect("targets the linked project's Storage host and flushes telemetry", () => { const { layer, requests, telemetry, linkedCache } = setupStorage(tmp.current, { // No `--local`, so the linked path resolves the ref + service-role key. + yes: true, routes: [{ method: "POST", match: MOVE, body: { message: "Successfully moved" } }], }); return Effect.gen(function* () { @@ -284,6 +291,7 @@ describe("storage mv", () => { // The fake's default projectRef is VALID_REF; the flag must win over it. const FLAG_REF = "flagflagflagflagflag"; const { layer, requests, linkedCache } = setupStorage(tmp.current, { + yes: true, routes: [{ method: "POST", match: MOVE, body: { message: "Successfully moved" } }], }); return Effect.gen(function* () { @@ -315,7 +323,7 @@ describe("storage mv", () => { expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { expect(Cause.pretty(exit.cause)).toContain( - "--project-ref only applies when targeting the linked project; use it with --linked (not --local)", + "--project-ref selects a linked project and cannot be combined with --local.", ); } expect(requests).toHaveLength(0); @@ -463,3 +471,21 @@ describe("stack backend", () => { }, ); }); + +describeHostedConsent("storage mv", { + api: storageConsentApi, + invoke: ({ workdir }) => { + const storage = storageConsentServices(workdir, [ + { method: "POST", match: MOVE, body: { message: "Successfully moved" } }, + ]); + return { + run: storageMv({ + ...mvFlags({ src: "ss:///private/a", dst: "ss:///private/b" }), + local: false, + linked: false, + projectRef: Option.some(VALID_REF), + }).pipe(Effect.provide(storage.layer)), + mutations: storage.writes, + }; + }, +}); diff --git a/apps/cli/src/commands/storage/mv/mv.live.test.ts b/apps/cli/src/commands/storage/mv/mv.live.test.ts index c6f7757c74..7de19da405 100644 --- a/apps/cli/src/commands/storage/mv/mv.live.test.ts +++ b/apps/cli/src/commands/storage/mv/mv.live.test.ts @@ -28,10 +28,24 @@ test("moves an uploaded object to a new path", ({ cli, cliEffect, project, works env: { SUPABASE_DB_PASSWORD: project.dbPassword }, }); requireLiveSuccess(linked, "link setup for storage mv"); - const uploaded = yield* cliEffect(["storage", "cp", local, source, ...storageLiveFlags]); + const uploaded = yield* cliEffect([ + "storage", + "cp", + local, + source, + "--yes", + ...storageLiveFlags, + ]); requireLiveSuccess(uploaded, "storage cp setup for storage mv"); - const moved = yield* cliEffect(["storage", "mv", source, destination, ...storageLiveFlags]); + const moved = yield* cliEffect([ + "storage", + "mv", + source, + destination, + "--yes", + ...storageLiveFlags, + ]); expect(moved.exitCode, moved.stderr).toBe(0); expect(moved.stderr, moved.stderr).toContain("Moving object:"); diff --git a/apps/cli/src/commands/storage/rm/SIDE_EFFECTS.md b/apps/cli/src/commands/storage/rm/SIDE_EFFECTS.md index fd9b773c20..ebfe421857 100644 --- a/apps/cli/src/commands/storage/rm/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/storage/rm/SIDE_EFFECTS.md @@ -1,14 +1,16 @@ # `supabase storage rm <file> ...` -Removes objects by path. Paths are grouped by bucket; each bucket is confirmed, its explicit prefixes are deleted +Removes objects by path. Paths are grouped by bucket; its explicit prefixes are deleted (chunked at 1000), and any prefix that resolved to a directory is removed recursively -when `-r` is set. With no paths and `-r`, every bucket is cleared and deleted. +when `-r` is set. With no paths and `-r`, every bucket is cleared and deleted. The local stack +confirms each bucket; a linked project asks once for the whole run. -`--local` resolves the Storage gateway through the `experimental.stack` -feature flag, same `SUPABASE_EXPERIMENTAL_STACK=1|0` env precedence as -[`docs/stack-commands.md`](../../../../docs/stack-commands.md). `--linked`/`--project-ref` -targeting is unchanged under either backend; see Notes for the stack-backend endpoint, -credential, capability-state, and error behavior. +With no target flag the command follows the workspace target (`SUPABASE_TARGET`, then `target` in +`config.toml`, default `local`; see [ADR 0033](../../../../../../docs/adr/0033-target-selection-and-hosted-mutation-consent.md)). `--local` selects +the local stack; `--linked` or `--project-ref` select the linked project. The local Storage gateway +resolves through the `experimental.stack` feature flag, same `SUPABASE_EXPERIMENTAL_STACK=1|0` env +precedence as [`docs/stack-commands.md`](../../../../docs/stack-commands.md); see Notes for the +stack-backend endpoint, credential, capability-state, and error behavior. ## Files Read @@ -43,10 +45,10 @@ Auth: `apikey` always; `Authorization: Bearer <key>` unless the key is `sb_`-pre `SUPABASE_AUTH_SERVICE_ROLE_KEY`, `SUPABASE_AUTH_JWT_SECRET`, `SUPABASE_SERVICES_HOSTNAME`, and the `SUPABASE_API_*` override family are legacy backend only; `SUPABASE_ACCESS_TOKEN`, -`SUPABASE_PROJECT_ID`, `SUPABASE_EXPERIMENTAL_STACK` (backend selection), and `SUPABASE_YES` -(auto-confirm) apply to either backend — -read from the shell env OR the project `.env`/`.env.local`/`.env.<env>[.local]` files -(shell wins; CLI-1878). +`SUPABASE_PROJECT_ID`, and `SUPABASE_EXPERIMENTAL_STACK` (backend selection) apply to either +backend. `SUPABASE_YES` auto-confirms the local per-bucket prompt from the shell env OR the project +`.env`/`.env.local`/`.env.<env>[.local]` files (shell wins); for a linked project only `--yes` or a +shell `SUPABASE_YES` counts. `SUPABASE_PROJECT_ID`'s linked-ref resolution is superseded by `--project-ref` when set. `storage` is an experimental command: `rm` requires `--experimental` @@ -55,32 +57,38 @@ read from the shell env OR the project `.env`/`.env.local`/`.env.<env>[.local]` ## Exit Codes -| Code | Condition | -| ---- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `0` | success (including a confirmation declined at the text-mode prompt, and a tolerated `Bucket not found`) | -| `1` | any resolved non-text output mode without `--yes`/`SUPABASE_YES` (`StorageRmConfirmationRequiredError`) — there is no prompt to ask on, so nothing is deleted and no Storage request is sent (a run with no paths and no `-r` reports the missing-`-r` failure first) | -| `1` | resolved `--workdir`/`SUPABASE_WORKDIR` doesn't exist or isn't a directory (`StorageWorkdirError`) — beats every other guard, including any `DELETE` call | -| `1` | an explicit `--workdir`/`SUPABASE_WORKDIR` on a LOCAL target holds no project config (`StorageMissingProjectConfigError`) — also beats any `DELETE` call | -| `1` | invalid/parse url, missing bucket (root path), missing `-r` flag (directory or no args), object-not-found (recursive empty prefix), API non-2xx, network, auth, config parse | -| `1` | `--project-ref` set with `--local` (see Notes) | -| `1` | stack backend: Storage disabled or its stack `failed`/`stopped` (`StackStorageCapabilityError`) | -| `1` | stack backend: stack is not registered/ready, has no primary database, or the stack API is unavailable (`StackStorageUnavailableError`); a missing Storage endpoint is a capability failure (`StackStorageCapabilityError`) | +| Code | Condition | +| ---- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `0` | success (including a confirmation declined at the text-mode prompt, and a tolerated `Bucket not found`) | +| `1` | local target, any resolved non-text output mode without `--yes`/`SUPABASE_YES` (`StorageRmConfirmationRequiredError`) — there is no prompt to ask on, so nothing is deleted and no Storage request is sent (a run with no paths and no `-r` reports the missing-`-r` failure first) | +| `1` | resolved `--workdir`/`SUPABASE_WORKDIR` doesn't exist or isn't a directory (`StorageWorkdirError`) — beats every other guard, including any `DELETE` call | +| `1` | an explicit `--workdir`/`SUPABASE_WORKDIR` on a LOCAL target holds no project config (`StorageMissingProjectConfigError`) — also beats any `DELETE` call | +| `1` | invalid/parse url, missing bucket (root path), missing `-r` flag (directory or no args), object-not-found (recursive empty prefix), API non-2xx, network, auth, config parse | +| `1` | `--local` with `--linked`, or `--project-ref` with `--local` (`TargetSelectorConflictError`, `TargetProjectRefConflictError`), after the experimental gate and the workdir check and before any Storage request | +| `1` | linked project without consent: a JSON, piped or non-interactive run without `--yes`/shell `SUPABASE_YES` (`HostedMutationConsentRequiredError`), or a declined prompt (`HostedMutationDeclinedError`) — nothing is deleted | +| `1` | stack backend: Storage disabled or its stack `failed`/`stopped` (`StackStorageCapabilityError`) | +| `1` | stack backend: stack is not registered/ready, has no primary database, or the stack API is unavailable (`StackStorageUnavailableError`); a missing Storage endpoint is a capability failure (`StackStorageCapabilityError`) | ## Output ### `--output-format text` -- `Confirm deleting files in bucket <bold bucket>?` prompt (default no); `--yes`/`SUPABASE_YES` - echoes `<label> [y/N] y` and proceeds. +- Local target: `Confirm deleting files in bucket <bold bucket>?` prompt (default no); + `--yes`/`SUPABASE_YES` echoes `<label> [y/N] y` and proceeds. +- Linked project: one plan line per bucket (`Delete from <bucket>: <prefixes>`), then a single + `Delete objects in <N> bucket(s)?` prompt (default No); `--yes`/shell `SUPABASE_YES` echoes + `Confirmed by --yes: …` and proceeds. - `Deleting objects: [<space-separated prefixes>]` per delete batch (stderr). - `Object not found: <prefix>` (non-recursive) / `Deleting bucket: <bucket>` / `Bucket not found: <bucket>` (stderr). ### `--output-format json` -Requires `--yes`/`SUPABASE_YES`; without it the run fails before any Storage request rather -than defaulting the unaskable confirmation to no and reporting an empty deletion. This -applies to the resolved output mode, so it also covers the JSON a detected or +Requires `--yes`/`SUPABASE_YES`; without it the run fails rather than defaulting the unaskable +confirmation to no and reporting an empty deletion. On the local stack it fails with +`StorageRmConfirmationRequiredError` before any Storage request; on a linked project it fails with +`HostedMutationConsentRequiredError` once the bucket plan is known and before anything is deleted. +This applies to the resolved output mode, so it also covers the JSON a detected or `--agent yes` coding agent selects when neither `--output-format` nor `-o`/`--output` is given. ```json @@ -103,15 +111,24 @@ Requires `--yes`/`SUPABASE_YES` on the same terms as `json`. ## Notes -- **`--project-ref`** overrides ONLY the linked-ref - resolution used above (flag > `SUPABASE_PROJECT_ID` > `.temp/project-ref`). - It never implies `--linked`: passing it with `--local` is a hard error - rather than a silently discarded flag. +- **Target.** `--local`, `--linked` and `--project-ref` go through the shared target selector: + `--local` and `--linked` are mutually exclusive, `--project-ref` selects the linked project and + conflicts with `--local`, and a `false` switch counts as absent. `--project-ref` only supplies the + ref (flag > `SUPABASE_PROJECT_ID` > `.temp/project-ref`). With no selector the workspace target decides. +- **Target banner.** A linked target prints `Target: …` to stderr once its ref resolves; the local + target prints `Target: local stack (<host>:<port>)` once the Storage endpoint is known. Both come + before the first Storage request. - Validation (missing bucket, missing `-r` for a directory) runs before any network call; the no-args missing-`-r` error runs after the client is built, and keeps beating the confirmation refusal in every output mode. -- A confirmation declined at the text-mode prompt skips that bucket and is not an error; - a resolved non-text output mode has no prompt to decline, so it hard-fails without +- **Hosted consent.** A linked project asks once, after the bucket plan is known (for `-r` with no + paths, after the bucket list) and before the first delete, with default No. A shell + `SUPABASE_YES` or `--yes` grants it; a `SUPABASE_YES` in `supabase/.env` and a piped answer do not. + JSON, piped, agent and non-TTY runs without `--yes` exit 1 with + `HostedMutationConsentRequiredError`; a declined prompt exits 1 with `HostedMutationDeclinedError` + (`context canceled`) and deletes nothing. +- On the local stack, a confirmation declined at the text-mode prompt skips that bucket and is not an + error; a resolved non-text output mode has no prompt to decline, so it hard-fails without `--yes` instead (`StorageRmConfirmationRequiredError`), before the Storage gateway is contacted. "Resolved" covers `--output-format json|stream-json` and agent-selected JSON alike; `-o`/`--output` does not select an output format for `rm` (it stays text), so `rm` diff --git a/apps/cli/src/commands/storage/rm/rm.handler.ts b/apps/cli/src/commands/storage/rm/rm.handler.ts index 5e9435525f..2f289bf7d2 100644 --- a/apps/cli/src/commands/storage/rm/rm.handler.ts +++ b/apps/cli/src/commands/storage/rm/rm.handler.ts @@ -66,9 +66,8 @@ export const storageRm = Effect.fn("storage.rm")(function* (flags: StorageRmFlag if (projectRef !== "") { yield* Ref.set(linkedRef, Option.some(projectRef)); } - // A local delete keeps its per-bucket prompt, which honours a project `.env` `SUPABASE_YES`; - // a hosted delete asks for consent instead. Loading it after the target resolves makes an - // unlinked workdir fail with the not-linked message, not an env-parse error. + // Only the local per-bucket prompt honours a project `.env` `SUPABASE_YES`; loaded after the + // target so an unlinked workdir fails as not linked, not as an env-parse error. const localYes = target.kind === "local" ? yield* resolveYesWithProjectEnv( diff --git a/apps/cli/src/commands/storage/rm/rm.integration.test.ts b/apps/cli/src/commands/storage/rm/rm.integration.test.ts index d67c053e52..92f309f8b0 100644 --- a/apps/cli/src/commands/storage/rm/rm.integration.test.ts +++ b/apps/cli/src/commands/storage/rm/rm.integration.test.ts @@ -7,7 +7,13 @@ import { StackStorageCapabilityError } from "../../../command-internal/stack-sto import { generateLocalJwt } from "../../../command-internal/local-jwt.ts"; import { ProjectRefNotLinkedError } from "../../../config/project-ref.errors.ts"; import { StorageRmConfirmationRequiredError } from "../storage.errors.ts"; -import { setupStorage, STORAGE_TEST_JWT_SECRET } from "../../../../tests/helpers/storage.ts"; +import { describeHostedConsent } from "../../../../tests/helpers/hosted-consent.ts"; +import { + setupStorage, + STORAGE_TEST_JWT_SECRET, + storageConsentApi, + storageConsentServices, +} from "../../../../tests/helpers/storage.ts"; import { VALID_REF, useTempWorkdir, @@ -65,7 +71,7 @@ describe("storage rm", () => { const exit = yield* storageRm({ files: ["ss:///private/abstract.pdf", "ss:///private/docs/readme.md"], recursive: false, - linked: true, + linked: false, local: true, projectRef: Option.none(), }).pipe(Effect.provide(layer), Effect.exit); @@ -88,7 +94,7 @@ describe("storage rm", () => { const exit = yield* storageRm({ files: ["ss:///private/a.pdf"], recursive: false, - linked: true, + linked: false, local: true, projectRef: Option.none(), }).pipe(Effect.provide(layer), Effect.exit); @@ -112,7 +118,7 @@ describe("storage rm", () => { const exit = yield* storageRm({ files: ["ss:///private/a.pdf"], recursive: false, - linked: true, + linked: false, local: true, projectRef: Option.none(), }).pipe(Effect.provide(layer), Effect.exit); @@ -135,7 +141,7 @@ describe("storage rm", () => { const exit = yield* storageRm({ files: ["ss:///private/a.pdf"], recursive: false, - linked: true, + linked: false, local: true, projectRef: Option.none(), }).pipe(Effect.provide(layer), Effect.exit); @@ -185,7 +191,7 @@ describe("storage rm", () => { const exit = yield* storageRm({ files: ["ss:///private/a.pdf"], recursive: false, - linked: true, + linked: false, local: true, projectRef: Option.none(), }).pipe(Effect.provide(layer), Effect.exit); @@ -206,7 +212,7 @@ describe("storage rm", () => { const exit = yield* storageRm({ files: ["ss:///private/a.pdf"], recursive: false, - linked: true, + linked: false, local: true, projectRef: Option.none(), }).pipe(Effect.provide(layer), Effect.exit); @@ -228,7 +234,7 @@ describe("storage rm", () => { const exit = yield* storageRm({ files: ["ss:///private/a.pdf"], recursive: false, - linked: true, + linked: false, local: true, projectRef: Option.none(), }).pipe(Effect.provide(layer), Effect.exit); @@ -257,7 +263,7 @@ describe("storage rm", () => { const exit = yield* storageRm({ files: ["ss:///private/a.pdf"], recursive: false, - linked: true, + linked: false, local: true, projectRef: Option.none(), }).pipe(Effect.provide(layer), Effect.exit); @@ -291,7 +297,7 @@ describe("storage rm", () => { const exit = yield* storageRm({ files: [], recursive: true, - linked: true, + linked: false, local: true, projectRef: Option.none(), }).pipe(Effect.provide(layer), Effect.exit); @@ -320,7 +326,7 @@ describe("storage rm", () => { const exit = yield* storageRm({ files: [], recursive: false, - linked: true, + linked: false, local: true, projectRef: Option.none(), }).pipe(Effect.provide(layer), Effect.exit); @@ -360,7 +366,7 @@ describe("storage rm", () => { const exit = yield* storageRm({ files, recursive: false, - linked: true, + linked: false, local: true, projectRef: Option.none(), }).pipe(Effect.provide(layer), Effect.exit); @@ -383,7 +389,7 @@ describe("storage rm", () => { const exit = yield* storageRm({ files: ["ss:///"], recursive: false, - linked: true, + linked: false, local: true, projectRef: Option.none(), }).pipe(Effect.provide(layer), Effect.exit); @@ -404,7 +410,7 @@ describe("storage rm", () => { const exit = yield* storageRm({ files: ["ss:///private/"], recursive: false, - linked: true, + linked: false, local: true, projectRef: Option.none(), }).pipe(Effect.provide(layer), Effect.exit); @@ -427,7 +433,7 @@ describe("storage rm", () => { const exit = yield* storageRm({ files: [], recursive: false, - linked: true, + linked: false, local: true, projectRef: Option.none(), }).pipe(Effect.provide(layer), Effect.exit); @@ -456,7 +462,7 @@ describe("storage rm", () => { const exit = yield* storageRm({ files: [], recursive: true, - linked: true, + linked: false, local: true, projectRef: Option.none(), }).pipe(Effect.provide(layer), Effect.exit); @@ -491,7 +497,7 @@ describe("storage rm", () => { const exit = yield* storageRm({ files: ["ss:///test"], recursive: true, - linked: true, + linked: false, local: true, projectRef: Option.none(), }).pipe(Effect.provide(layer), Effect.exit); @@ -549,7 +555,7 @@ describe("storage rm", () => { const exit = yield* storageRm({ files: ["ss:///private/dir"], recursive: true, - linked: true, + linked: false, local: true, projectRef: Option.none(), }).pipe(Effect.provide(layer), Effect.exit); @@ -581,7 +587,7 @@ describe("storage rm", () => { const exit = yield* storageRm({ files: ["ss:///test"], recursive: true, - linked: true, + linked: false, local: true, projectRef: Option.none(), }).pipe(Effect.provide(layer), Effect.exit); @@ -605,7 +611,7 @@ describe("storage rm", () => { const exit = yield* storageRm({ files: ["ss:///private/dir"], recursive: true, - linked: true, + linked: false, local: true, projectRef: Option.none(), }).pipe(Effect.provide(layer), Effect.exit); @@ -628,7 +634,7 @@ describe("storage rm", () => { const exit = yield* storageRm({ files: ["ss:///private/a.pdf"], recursive: false, - linked: true, + linked: false, local: true, projectRef: Option.none(), }).pipe(Effect.provide(layer), Effect.exit); @@ -659,7 +665,7 @@ describe("storage rm", () => { const exit = yield* storageRm({ files: ["ss:///private/a.pdf"], recursive: false, - linked: true, + linked: false, local: true, projectRef: Option.none(), }).pipe(Effect.provide(layer), Effect.exit); @@ -681,7 +687,7 @@ describe("storage rm", () => { const exit = yield* storageRm({ files: [], recursive: true, - linked: true, + linked: false, local: true, projectRef: Option.none(), }).pipe(Effect.provide(layer), Effect.exit); @@ -756,7 +762,7 @@ describe("storage rm", () => { expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { expect(Cause.pretty(exit.cause)).toContain( - "--project-ref only applies when targeting the linked project; use it with --linked (not --local)", + "--project-ref selects a linked project and cannot be combined with --local.", ); } expect(requests).toHaveLength(0); @@ -786,7 +792,7 @@ describe("storage rm", () => { const exit = yield* storageRm({ files: ["ss:///private/a.pdf"], recursive: false, - linked: true, + linked: false, local: true, projectRef: Option.none(), }).pipe(Effect.provide(layer), Effect.exit); @@ -811,7 +817,7 @@ describe("storage rm", () => { const exit = yield* storageRm({ files: ["ss:///private/a.pdf"], recursive: false, - linked: true, + linked: false, local: true, projectRef: Option.none(), }).pipe(Effect.provide(layer), Effect.exit); @@ -835,7 +841,7 @@ describe("storage rm", () => { const exit = yield* storageRm({ files: ["ss:///private/a.pdf"], recursive: false, - linked: true, + linked: false, local: true, projectRef: Option.none(), }).pipe(Effect.provide(layer), Effect.exit); @@ -861,7 +867,7 @@ describe("storage rm", () => { const exit = yield* storageRm({ files: ["ss:///private/a.pdf"], recursive: false, - linked: true, + linked: false, local: true, projectRef: Option.none(), }).pipe(Effect.provide(layer), Effect.exit); @@ -888,7 +894,7 @@ describe("stack backend", () => { const exit = yield* storageRm({ files: ["ss:///private/a.pdf"], recursive: false, - linked: true, + linked: false, local: true, projectRef: Option.none(), }).pipe(Effect.provide(layer), Effect.exit); @@ -917,7 +923,7 @@ describe("stack backend", () => { const exit = yield* storageRm({ files: ["ss:///private/a.pdf"], recursive: false, - linked: true, + linked: false, local: true, projectRef: Option.none(), }).pipe(Effect.provide(layer), Effect.exit); @@ -940,7 +946,7 @@ describe("stack backend", () => { const exit = yield* storageRm({ files: ["ss:///private/a.pdf"], recursive: false, - linked: true, + linked: false, local: true, projectRef: Option.none(), }).pipe(Effect.provide(layer), Effect.exit); @@ -962,3 +968,22 @@ describe("stack backend", () => { }, ); }); + +describeHostedConsent("storage rm", { + api: storageConsentApi, + invoke: ({ workdir }) => { + const storage = storageConsentServices(workdir, [ + { method: "DELETE", match: DELETE_OBJECT("private"), body: [{ name: "a.pdf" }] }, + ]); + return { + run: storageRm({ + files: ["ss:///private/a.pdf"], + recursive: false, + linked: false, + local: false, + projectRef: Option.some(VALID_REF), + }).pipe(Effect.provide(storage.layer)), + mutations: storage.writes, + }; + }, +}); diff --git a/apps/cli/src/commands/storage/rm/rm.live.test.ts b/apps/cli/src/commands/storage/rm/rm.live.test.ts index 5fff375634..d06b7c1229 100644 --- a/apps/cli/src/commands/storage/rm/rm.live.test.ts +++ b/apps/cli/src/commands/storage/rm/rm.live.test.ts @@ -27,7 +27,14 @@ test("removes an uploaded object", ({ cli, cliEffect, project, workspace }) => env: { SUPABASE_DB_PASSWORD: project.dbPassword }, }); requireLiveSuccess(linked, "link setup for storage rm"); - const uploaded = yield* cliEffect(["storage", "cp", local, remote, ...storageLiveFlags]); + const uploaded = yield* cliEffect([ + "storage", + "cp", + local, + remote, + "--yes", + ...storageLiveFlags, + ]); requireLiveSuccess(uploaded, "storage cp setup for storage rm"); const result = yield* cliEffect(["storage", "rm", remote, "--yes", ...storageLiveFlags]); diff --git a/apps/cli/src/commands/storage/storage.e2e.test.ts b/apps/cli/src/commands/storage/storage.e2e.test.ts index f3affa8929..dbf71a1873 100644 --- a/apps/cli/src/commands/storage/storage.e2e.test.ts +++ b/apps/cli/src/commands/storage/storage.e2e.test.ts @@ -23,8 +23,8 @@ const withStorageProject = <A, E, R>( }).pipe(Effect.scoped, Effect.provide(BunServices.layer)); /** - * Golden-path e2e for `storage`: the compiled binary and `--linked`/`--local` flag - * parsing. Object list/copy/move/remove behavior is covered by the integration and + * Golden-path e2e for `storage`: the compiled binary and `--linked`/`--local` target + * selection. Object list/copy/move/remove behavior is covered by the integration and * unit suites, which don't need a live local stack. */ describe("supabase storage", () => { @@ -50,15 +50,15 @@ describe("supabase storage", () => { () => withStorageProject("supabase-storage-e2e-mutex-", (projectDir) => Effect.gen(function* () { - // The experimental gate runs before the mutex check, so --experimental must be - // set here to reach the mutex check at all. + // The experimental gate runs before the target check, so --experimental must be + // set here to reach the target check at all. const { exitCode, stdout, stderr } = yield* runSupabaseEffect( ["storage", "ls", "--local", "--linked", "ss:///", "--experimental"], { cwd: projectDir }, ); expect(exitCode).toBe(1); expect(`${stdout}${stderr}`).toContain( - "if any flags in the group [linked local] are set none of the others can be", + "Only one of --local, --linked and --db-url can be set; got --local and --linked.", ); }), ), diff --git a/apps/cli/src/commands/storage/storage.experimental-gate.integration.test.ts b/apps/cli/src/commands/storage/storage.experimental-gate.integration.test.ts index ae5409114b..710b8dbca3 100644 --- a/apps/cli/src/commands/storage/storage.experimental-gate.integration.test.ts +++ b/apps/cli/src/commands/storage/storage.experimental-gate.integration.test.ts @@ -10,13 +10,15 @@ import { mockAnalytics, mockOutput, mockProcessControl, + mockRuntimeInfo, mockTelemetryRuntime, mockTty, + processEnvLayer, } from "../../../tests/helpers/mocks.ts"; -import { isolatedHomeLayer, useTempWorkdir } from "../../../tests/helpers/command-mocks.ts"; +import { useTempWorkdir } from "../../../tests/helpers/command-mocks.ts"; import { ExperimentalRequiredError } from "../../command-internal/experimental-gate.ts"; import { storageCommand } from "./storage.command.ts"; -import { StorageMutuallyExclusiveFlagsError } from "./storage.errors.ts"; +import { TargetSelectorConflictError } from "../../command-internal/target/target.errors.ts"; import { commandTargetLayer } from "../../shared/output/command-target.layer.ts"; const tempRoot = useTempWorkdir("supabase-storage-experimental-int-"); @@ -34,9 +36,9 @@ function setup(args: ReadonlyArray<string>) { out.layer, commandTargetLayer.pipe(Layer.provide(out.layer)), Layer.succeed(CliArgs, { args }), - // Isolates ambient env/homeDir defensively even though neither check here reaches - // storageGatewayRuntimeLayer's lazy cliSettings/credentials factory. - isolatedHomeLayer(tempRoot.current, { SUPABASE_NO_KEYRING: "1" }), + // The cwd is a real directory so the handler's workdir check passes before the target conflict. + mockRuntimeInfo({ homeDir: tempRoot.current, cwd: tempRoot.current }), + processEnvLayer({ SUPABASE_NO_KEYRING: "1" }), mockProcessControl().layer, mockTty({ stdinIsTty: false, stdoutIsTty: false }), mockAnalytics().layer, @@ -88,7 +90,7 @@ describe("storage experimental gate vs mutual-exclusivity ordering", () => { if (Exit.isFailure(exit)) { const failure = Cause.findErrorOption(exit.cause); expect( - Option.isSome(failure) && failure.value instanceof StorageMutuallyExclusiveFlagsError, + Option.isSome(failure) && failure.value instanceof TargetSelectorConflictError, ).toBe(true); } }).pipe(Effect.provide(layer)); diff --git a/apps/cli/tests/helpers/storage.ts b/apps/cli/tests/helpers/storage.ts index 8ca1562730..ac2e56b915 100644 --- a/apps/cli/tests/helpers/storage.ts +++ b/apps/cli/tests/helpers/storage.ts @@ -21,7 +21,9 @@ import { ProjectRefNotLinkedError } from "../../src/config/project-ref.errors.ts import { unusedGateway } from "./unused-stack.ts"; import { ProjectRefResolver } from "../../src/config/project-ref.service.ts"; import { generateLocalJwt } from "../../src/command-internal/local-jwt.ts"; -import { YesFlag } from "../../src/command-internal/global-flags.ts"; +import { OutputFlag, YesFlag } from "../../src/command-internal/global-flags.ts"; +import { TargetPolicy } from "../../src/command-internal/target/target-policy.ts"; +import { commandTargetLayer } from "../../src/shared/output/command-target.layer.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"; @@ -304,34 +306,12 @@ export function buildStorageStackApi( return { layer, stackCalls: { findStack: findStackCalls } }; } -/** - * Builds the layer and recorded state for a `storage` command integration test: - * a recording `HttpClient` for the Storage gateway, an on-disk `config.toml`, - * project-ref resolution, tracked telemetry/linked-project cache, and a lazy - * Management API factory for api-keys. - */ -export function setupStorage(workdir: string, opts: SetupStorageOptions) { - if (opts.toml !== undefined) { - mkdirSync(join(workdir, "supabase"), { recursive: true }); - writeFileSync(join(workdir, "supabase", "config.toml"), opts.toml); - } - for (const [rel, content] of Object.entries(opts.files ?? {})) { - const abs = join(workdir, rel); - mkdirSync(dirname(abs), { recursive: true }); - writeFileSync(abs, content); - } - - const out = mockOutput({ - format: opts.format ?? "text", - promptConfirmResponses: opts.confirm, - promptConfirmFail: opts.promptConfirmFail, - }); - +/** A recording `HttpClient` that answers Storage requests from `routes`, first match first. */ +export function recordingStorageHttp(routes: ReadonlyArray<StorageRoute>) { const requests: Array<RecordedStorageRequest> = []; const consumed = new Set<number>(); - const routes = opts.routes ?? []; - const httpLayer = Layer.succeed( + const layer = Layer.succeed( HttpClient.HttpClient, HttpClient.make((request) => { let body: unknown; @@ -379,6 +359,66 @@ export function setupStorage(workdir: string, opts: SetupStorageOptions) { }), ); + return { layer, requests }; +} + +/** Storage requests that change state; the object and vector list endpoints are POST reads. */ +export const isStorageWrite = (request: { readonly method: string; readonly url: string }) => + request.method !== "GET" && + !request.url.includes("/storage/v1/object/list/") && + !request.url.includes("/storage/v1/vector/ListVectorBuckets"); + +const STORAGE_TEST_API_KEYS = [ + { + name: "service_role", + api_key: "test-service-role-key", + type: "secret", + secret_jwt_template: { role: "service_role" }, + }, +]; + +/** `describeHostedConsent` api option answering the api-keys lookup a hosted Storage run makes. */ +export const storageConsentApi = { + byMethod: { GET: { status: 200, body: STORAGE_TEST_API_KEYS } }, +} as const; + +/** The Storage-side services a handler needs under `describeHostedConsent`, plus its write count. */ +export function storageConsentServices(workdir: string, routes: ReadonlyArray<StorageRoute>) { + const http = recordingStorageHttp(routes); + const layer = Layer.mergeAll( + http.layer, + cliConfigValuesTestLayer, + unusedStackServices, + buildStorageStackApi(workdir, undefined).layer, + ); + return { layer, writes: () => http.requests.filter(isStorageWrite).length }; +} + +/** + * Builds the layer and recorded state for a `storage` command integration test: + * a recording `HttpClient` for the Storage gateway, an on-disk `config.toml`, + * project-ref resolution, tracked telemetry/linked-project cache, and a lazy + * Management API factory for api-keys. + */ +export function setupStorage(workdir: string, opts: SetupStorageOptions) { + if (opts.toml !== undefined) { + mkdirSync(join(workdir, "supabase"), { recursive: true }); + writeFileSync(join(workdir, "supabase", "config.toml"), opts.toml); + } + for (const [rel, content] of Object.entries(opts.files ?? {})) { + const abs = join(workdir, rel); + mkdirSync(dirname(abs), { recursive: true }); + writeFileSync(abs, content); + } + + const out = mockOutput({ + format: opts.format ?? "text", + promptConfirmResponses: opts.confirm, + promptConfirmFail: opts.promptConfirmFail, + }); + + const { layer: httpLayer, requests } = recordingStorageHttp(opts.routes ?? []); + const telemetry = mockTelemetryStateTracked(); const linkedCache = mockLinkedProjectCacheTracked(); @@ -390,10 +430,12 @@ export function setupStorage(workdir: string, opts: SetupStorageOptions) { const projectRefLayer = Layer.succeed(ProjectRefResolver, { resolve: () => opts.linkedFails === true ? Effect.fail(notLinked()) : Effect.succeed(projectRefRef), - resolveRef: () => - opts.linkedFails === true - ? Effect.fail(notLinked()) - : Effect.succeed({ ref: projectRefRef, source: "file" as const, matchesLinkedRef: true }), + resolveRef: (flagValue: Option.Option<string>) => + Option.isSome(flagValue) && flagValue.value.length > 0 + ? Effect.succeed({ ref: flagValue.value, source: "flag" as const, matchesLinkedRef: false }) + : opts.linkedFails === true + ? Effect.fail(notLinked()) + : Effect.succeed({ ref: projectRefRef, source: "file" as const, matchesLinkedRef: true }), resolveForLink: () => opts.linkedFails === true ? Effect.fail(notLinked()) : Effect.succeed(projectRefRef), resolveOptional: () => Effect.succeed(Option.some(projectRefRef)), @@ -408,17 +450,9 @@ export function setupStorage(workdir: string, opts: SetupStorageOptions) { promptProjectRef: () => Effect.succeed(projectRefRef), }); - const defaultApiKeys = [ - { - name: "service_role", - api_key: "test-service-role-key", - type: "secret", - secret_jwt_template: { role: "service_role" }, - }, - ]; const managementApi = mockCommandPlatformApiService({ v1: { - getProjectApiKeys: () => Effect.succeed(opts.apiKeys ?? defaultApiKeys), + getProjectApiKeys: () => Effect.succeed(opts.apiKeys ?? STORAGE_TEST_API_KEYS), }, }); @@ -426,6 +460,9 @@ export function setupStorage(workdir: string, opts: SetupStorageOptions) { const layer = Layer.mergeAll( out.layer, + commandTargetLayer.pipe(Layer.provide(out.layer)), + Layer.succeed(TargetPolicy, { group: "selectable", consent: "no" }), + Layer.succeed(OutputFlag, opts.format === "json" ? Option.some("json") : Option.none()), httpLayer, telemetry.layer, linkedCache.layer, From 917e2dcc3dde04e21ee4194271af489cf73cc07e Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Thu, 15 Oct 2026 14:36:14 +0100 Subject: [PATCH 42/92] docs(cli): document the gen types target defaults (CLI-2695) --- apps/cli/docs/supabase/gen.md | 2 +- .../src/commands/gen/types/SIDE_EFFECTS.md | 179 +++++++++--------- 2 files changed, 95 insertions(+), 86 deletions(-) diff --git a/apps/cli/docs/supabase/gen.md b/apps/cli/docs/supabase/gen.md index f88cbf8c9e..aff58b4e60 100644 --- a/apps/cli/docs/supabase/gen.md +++ b/apps/cli/docs/supabase/gen.md @@ -2,7 +2,7 @@ Automatically generates type definitions based on your Postgres database schema. -This command connects to your database (local or remote) and generates typed definitions that match your database tables, views, and stored procedures. By default, it generates TypeScript definitions, but also supports Go and Swift. +This command connects to your database and generates typed definitions that match your database tables, views, and stored procedures. It reads the local database unless you pass `--linked`, `--project-ref`, or `--db-url`, or set `target = "remote"` in your project configuration. By default, it generates TypeScript definitions, but also supports Go and Swift. Generated types give you type safety and autocompletion when working with your database in code, helping prevent runtime errors and improving developer experience. diff --git a/apps/cli/src/commands/gen/types/SIDE_EFFECTS.md b/apps/cli/src/commands/gen/types/SIDE_EFFECTS.md index 56387fdc9c..a207984e35 100644 --- a/apps/cli/src/commands/gen/types/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/gen/types/SIDE_EFFECTS.md @@ -7,23 +7,27 @@ either calls that language's generator in-process (TypeScript, Go, Python, Swift today) or runs the language's own tool in the directory the command was run from, with the introspected document on stdin (`--lang dart` runs `dart run supabase_typegen --output -`). A bump of that dependency can add a `--lang` value or a language -flag; the CLI names no language itself. `--linked`/`--project-id` TypeScript -output still comes from the Management API; every other language and target -(including `--local` and `--db-url`) connects to the database and introspects -it directly — no pg-meta container is involved. When `[experimental].stack` -is on, `--local` resolves the project stack through `DbConfigResolver` -(`connType: "local"`) instead of inspecting `supabase_db_*`. +flag; the CLI names no language itself. The command is `selectable` and never asks for +hosted-mutation consent: the target is `--local`, `--linked`, `--project-ref <ref>` (implies +linked), or `--db-url`, else the workspace `target` (`SUPABASE_TARGET`, then `config.toml`), +which defaults to the local database. Linked TypeScript output still comes from the Management +API; every other language and target (including local and `--db-url`) connects to the database +and introspects it directly — no pg-meta container is involved. When `[experimental].stack` +is on, the local target resolves the project stack through `DbConfigResolver` instead of +inspecting `supabase_db_*`. `--project-id` is a deprecated alias of `--project-ref`. ## Files Read -| Path | Format | When | -| ----------------------------------------------------------------------- | ------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `~/.supabase/access-token` | plain text | when `SUPABASE_ACCESS_TOKEN` unset and `--linked` or `--project-id` | -| `<workdir>/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 | -| `<workdir>/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. | -| `<workdir>{/supabase}/.env*` | dotenv | `--local`; resolves the same nested environment overrides as the CLI. Every `--db-url` resolution also reads it (same `.env.<SUPABASE_ENV>.local`/`.env.local`/`.env.<SUPABASE_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 | -| `<workdir>/supabase/.temp/rest-version` | plain text | `--local` only, when `db.major_version > 14` — forces v9 compat if the tag contains `v9` | +| Path | Format | When | +| ----------------------------------------------------------------------- | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `<workdir>/supabase/.temp/project-ref` | plain text | a linked target with no `--project-ref` and no `SUPABASE_PROJECT_ID` | +| `<workdir>/supabase/.temp/linked-project.json` | JSON | a linked target, for the `Target:` banner (no API call) | +| `~/.supabase/access-token` | plain text | when `SUPABASE_ACCESS_TOKEN` unset and the target is linked | +| `<workdir>/supabase/config.toml` or `config.json` | TOML/JSON | always, for the workspace `target` when no selector flag is given, and when selecting schemas on a linked target (but not when `--schema` is also given with `--linked` or `--project-ref`, which skip the schema 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 | +| `<workdir>/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. | +| `<workdir>{/supabase}/.env*` | dotenv | `--local`; resolves the same nested environment overrides as the CLI. Every `--db-url` resolution also reads it (same `.env.<SUPABASE_ENV>.local`/`.env.local`/`.env.<SUPABASE_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 | +| `<workdir>/supabase/.temp/rest-version` | plain text | `--local` only, when `db.major_version > 14` — forces v9 compat if the tag contains `v9` | ## Files Written @@ -43,16 +47,16 @@ No files are written. | `POST` | `/v1/projects/{ref}/cli/login-role` | Bearer token | `{ read_only: false }` | temporary `role` and `password` | | `GET` | `/v1/projects/{ref}/config/database/pooler` | Bearer token | none | primary pooler `connection_string` | -The TypeScript endpoint is called for `--linked`, `--project-id`, and the implicit -linked-project fallback when `--lang=typescript`. For other languages on those -project-ref paths (see Notes, CLI-1988), the project endpoint is probed first: a `404` means the ref is a +The TypeScript endpoint is called for a linked target (`--linked`, `--project-ref`, or a +workspace `target` of `remote`) when `--lang=typescript`. For other languages on a linked +target (see Notes), the project endpoint is probed first: a `404` means the ref is a preview branch (any 404 body), so the branch endpoint supplies the branch database host/port and credentials for the direct connection. Otherwise the database connection is resolved for the ref and the login-role endpoint supplies temporary credentials. On an IPv4-only network where the direct database host is unreachable, project-ref generation retries once through the IPv4 pooler only when the current target host is the project's direct `db.<ref>` host and the pooler URL matches the -expected tenant and pooler domain. An explicit `--project-id` ref fetches the +expected tenant and pooler domain. A `--project-ref` that is not the workdir's linked project fetches the primary pooler config for that ref to build the fallback connection (the saved workdir `.temp/pooler-url` is ignored because the ref may differ from the linked workdir). `--local` and `--db-url` do not call the Management API. @@ -75,52 +79,56 @@ 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` | 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) | +| Variable | Purpose | Required? | +| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `SUPABASE_ACCESS_TOKEN` | auth token for a linked target | no (falls back to keyring → `~/.supabase/access-token`) | +| `SUPABASE_PROJECT_ID` | local Docker container and network project ID; the project ref of a linked target when `--project-ref` is absent | no (falls back to the workdir name) | +| `SUPABASE_TARGET` | workspace target (`local` or `remote`) when no selector flag is given | no (defaults to `local`) | +| `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, `--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 a linked target; 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 -| Code | Condition | -| ---- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `0` | success — types printed to stdout | -| `1` | no target specified (must use one flag) (`GenTypesFlagUsageError`) | -| `1` | mutually exclusive flags combined (all four flag groups) (`GenTypesFlagUsageError`) | -| `1` | `--postgrest-v9-compat` used without `--db-url` (`GenTypesFlagUsageError`) | -| `1` | a positional language other than `typescript` passed without `--lang` (`GenTypesFlagUsageError`) | -| `1` | invalid `--query-timeout` duration or invalid `--db-url` | -| `1` | `--network-id` set (`GenTypesNetworkIdUnsupportedError`) — generation runs in-process and cannot join a Docker network | -| `1` | `supabase start` not running (`--local` on the legacy Docker Compose stack) (`GenTypesLocalDbNotRunningError`) | -| `1` | `--local` on the legacy Docker Compose stack and `container inspect` exits non-zero for any other reason (`GenTypesLocalDbInspectError`) | -| `1` | resolved `--workdir`/`SUPABASE_WORKDIR` doesn't exist or isn't a directory (`GenTypesWorkdirError`) — beats every other guard | -| `1` | an explicit `--workdir`/`SUPABASE_WORKDIR` holds no project config on a schema-selecting path (`GenTypesMissingProjectConfigError`) — a DEFAULTED workdir keeps the embedded-default fallback instead | -| `1` | a resolved preview branch config has no `db_user`/`db_pass` (`GenTypesBranchCredentialsUnavailableError`) | -| `1` | API error or database connection/introspection/generation failure (`GenTypesGenerationError`) | -| `1` | an out-of-process language's toolchain or package is missing from the current directory; the message carries the registry's install hint (`GenTypesToolNotInstalledError`) | -| `1` | an out-of-process language's tool exited unsuccessfully or rejected the metadata document; the message carries its stderr (`GenTypesToolFailedError`) | +| Code | Condition | +| ---- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `0` | success — types printed to stdout | +| `1` | conflicting target flags (`--local`, `--linked`, `--db-url`), `--project-ref` combined with `--local`/`--db-url`, or `--project-id` combined with `--project-ref` (`TargetSelectorConflictError`, `TargetProjectRefConflictError`) | +| `1` | `--linked`/`--project-ref` combined with `--postgrest-v9-compat`, `--query-timeout` or a language flag (`GenTypesFlagUsageError`) | +| `1` | `--postgrest-v9-compat` used without `--db-url` (`GenTypesFlagUsageError`) | +| `1` | a positional language other than `typescript` passed without `--lang` (`GenTypesFlagUsageError`) | +| `1` | invalid `--query-timeout` duration or invalid `--db-url` | +| `1` | `--network-id` set (`GenTypesNetworkIdUnsupportedError`) — generation runs in-process and cannot join a Docker network | +| `1` | `supabase start` not running (`--local` on the legacy Docker Compose stack) (`GenTypesLocalDbNotRunningError`) | +| `1` | `--local` on the legacy Docker Compose stack and `container inspect` exits non-zero for any other reason (`GenTypesLocalDbInspectError`) | +| `1` | resolved `--workdir`/`SUPABASE_WORKDIR` doesn't exist or isn't a directory (`GenTypesWorkdirError`) — beats every other guard | +| `1` | an explicit `--workdir`/`SUPABASE_WORKDIR` holds no project config on a schema-selecting path (`GenTypesMissingProjectConfigError`) — a DEFAULTED workdir keeps the embedded-default fallback instead | +| `1` | a resolved preview branch config has no `db_user`/`db_pass` (`GenTypesBranchCredentialsUnavailableError`) | +| `1` | API error or database connection/introspection/generation failure (`GenTypesGenerationError`) | +| `1` | an out-of-process language's toolchain or package is missing from the current directory; the message carries the registry's install hint (`GenTypesToolNotInstalledError`) | +| `1` | an out-of-process language's tool exited unsuccessfully or rejected the metadata document; the message carries its stderr (`GenTypesToolFailedError`) | ## Output ### `--output-format text` Prints generated TypeScript (or other language) type definitions to stdout. -Diagnostics (`Connecting to …`) go to stderr. In-process TypeScript also prints a stderr -note that the output is unformatted. +Diagnostics go to stderr: one `Target: …` banner naming the resolved target (the project +ref and whether its identity is verified, the local stack host and port, or the custom +Postgres host) and `Connecting to …` for database introspection. In-process TypeScript also +prints a stderr note that the output is unformatted. `--project-id` additionally prints +`--project-id is deprecated; use --project-ref instead.` on stderr. ### `--output-format json` -Not applicable. +Not applicable. The output is the generated artifact, so no `target` field is added to it. ### `--output-format stream-json` @@ -129,49 +137,50 @@ Not applicable. ## Notes - TypeScript generated in-process is unformatted. stderr suggests `npx oxfmt <generated-file.ts>`. - `--linked` and `--project-id` TypeScript for a project still come from the Management API. -- Exactly one of `--local`, `--linked`, `--project-id`, or `--db-url` must be specified. - All four mutually exclusive flag groups are enforced with the exact error text and - 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`. + Linked TypeScript for a project still comes from the Management API. +- The target comes from `resolveTarget`: a selector flag wins, then the workspace `target` + (`SUPABASE_TARGET`, then `config.toml`), then local. With no selector the command generates + from the local database; it neither prompts for a target nor fails. `--project-ref` implies + `--linked`; combining it with `--local` or `--db-url` is an error, as is combining two of + `--local`, `--linked` and `--db-url`. A `false` switch (`--linked=false`) counts as absent. +- `--project-id` is a deprecated alias of `--project-ref`; passing both is an error. +- `--linked`/`--project-ref` are mutually exclusive with `postgrest-v9-compat`, `query-timeout`, + and each language flag (`swift-access-control`), enforced with the exact error text and sorted + group order `linked/project-ref/<flag>`. - With `--local`, a missing `supabase/config.toml` uses the embedded config defaults plus 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 0032): 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, + a linked target) > 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. - **TLS.** There is no SSL probe on any path. - - `--linked` / `--project-id` / the implicit linked fallback / a resolved preview - branch connect with `sslmode=require` and the bundled Supabase CA pinned + - A linked target (including a resolved preview branch) connects with `sslmode=require` and the bundled Supabase CA pinned (promoted to `verify-ca`), matching prior behavior. - `--db-url` honors the DSN's own `sslmode`/`sslrootcert` when either is set, even against a target classified local (e.g. a TLS tunnel on the loopback stack); otherwise, a known Supabase host gets the Supabase CA pinned the same way, and any 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. + - The local target uses no TLS. - **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, 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 - **implicit** linked fallback (none of `--local`/`--linked`/`--project-id`/`--db-url` - passed), neither mutex key is set, so `--swift-access-control public` / - `--query-timeout 20s` clear every guard and ARE applied for `--lang -go`/`--lang swift`/`--lang python` — the defaults-only claim above holds only for the - explicit `--linked`/`--project-id` paths. `--postgrest-v9-compat` is unaffected by this - corner: its own gate requires `--db-url` regardless of how the project ref is resolved, - so it stays blocked on every project-ref path. Use `--db-url` for guaranteed control - over any of these three flags. + `--lang` accepts `typescript` (default), `go`, `swift`, or `python`. A linked target + uses the Management API for TypeScript, and connects directly to the project database + (temporary 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` or `--project-ref` is passed + _explicitly_ on the command line — that combination still always generates with defaults + (`internal` access control, one-to-one detection on, 15s timeout). When the linked target + comes from the workspace `target` instead, neither mutex key is set, so + `--swift-access-control public` / `--query-timeout 20s` clear every guard and ARE applied + for `--lang go`/`--lang swift`/`--lang python`. `--postgrest-v9-compat` is unaffected by + this corner: its own gate requires `--db-url` regardless of how the target is resolved, so + it stays blocked on every linked path. Use `--db-url` for guaranteed control over any of + these three flags. - **Output compatibility with the previous pg-meta-based generator.** Measured against pg-meta's reference generators on the same schema: - Go and Swift output is byte-identical to the reference generators. @@ -182,14 +191,14 @@ go`/`--lang swift`/`--lang python` — the defaults-only claim above holds only which expected an unparsed JSON _string_ rather than the already-decoded row value typegen produces; `NotRequired` and `TypeAlias` are now imported from `typing_extensions` instead of `typing`. - - `--linked`/`--project-id` TypeScript is still generated server-side by the + - Linked TypeScript is still generated server-side by the Management API and is unaffected by this change, so it can differ from local output on these jsonb cases until the hosted service adopts the same generator. - `--schema` / `-s` accepts a comma-separated list of schemas to include. - Language flags are rendered from the registry's user-facing option specs; today that is `--swift-access-control`, which accepts `internal` (default), `public`, `private` or `package`. Every such flag is mutually exclusive with an _explicit_ - `--linked`/`--project-id`; on the `--local`, `--db-url`, and implicit-linked-fallback + `--linked`/`--project-ref`; on the local, `--db-url`, and workspace-target-linked paths the flags are parsed regardless of `--lang`, and only the values the chosen language declares reach its generator. - `--postgrest-v9-compat` is deprecated and hidden from help and docs. It still turns @@ -206,11 +215,11 @@ go`/`--lang swift`/`--lang python` — the defaults-only claim above holds only - `--query-timeout` sets the maximum time allowed for introspection (default 15s), applied both as the connection's server-side `statement_timeout` and as a connect - timeout. It is mutually exclusive with an _explicit_ `--linked`/`--project-id`; on - the implicit linked fallback it is accepted and applied for - `--lang go`/`--lang swift`/`--lang python` (silently unused only for the implicit - linked TypeScript case, since that path never connects to the database directly). + timeout. It is mutually exclusive with an _explicit_ `--linked`/`--project-ref`; on + the local, `--db-url`, and workspace-target-linked paths it is accepted and applied for + every language that connects to the database (silently unused only for linked + TypeScript, since that path never connects to the database directly). - The legacy positional language argument (`supabase gen types typescript`) is still accepted; any other positional language requires an explicit `--lang` flag. -- The linked-project telemetry cache is written only when a project ref is resolved - (`--linked`/`--project-id`/fallback) — it's skipped when no ref is available. +- The linked-project telemetry cache is written only when a linked target resolves a + project ref — it's skipped for the local and `--db-url` targets. From 81ed1ca2c534e3a01cf9eca0450bb693ad60c088 Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Thu, 15 Oct 2026 14:36:32 +0100 Subject: [PATCH 43/92] test(cli): update db reset tests for resolved targets and hosted consent --- .../src/commands/db/reset/reset.e2e.test.ts | 12 +- .../db/reset/reset.integration.test.ts | 305 +++++++++--------- 2 files changed, 152 insertions(+), 165 deletions(-) diff --git a/apps/cli/src/commands/db/reset/reset.e2e.test.ts b/apps/cli/src/commands/db/reset/reset.e2e.test.ts index 0a994d3dd1..46e906e51d 100644 --- a/apps/cli/src/commands/db/reset/reset.e2e.test.ts +++ b/apps/cli/src/commands/db/reset/reset.e2e.test.ts @@ -9,7 +9,7 @@ const E2E_TIMEOUT_MS = 30_000; describe("supabase db reset", () => { // Docker-free: the confirmation fires after config load and before any connection is dialed. it.live( - "declining the remote reset prompt prints only context canceled, no --debug hint", + "refuses a remote reset that cannot prompt, even with a piped answer, and points at --yes", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; @@ -22,13 +22,13 @@ describe("supabase db reset", () => { ); const { exitCode, stderr } = yield* runSupabaseEffect( ["db", "reset", "--db-url", "postgresql://postgres:postgres@127.0.0.1:9999/postgres"], - { cwd: workdir, stdin: "n\n" }, + { cwd: workdir, stdin: "y\n" }, ); expect(exitCode).toBe(1); - expect(stripAnsi(stderr)).toContain("[y/N]"); - const lines = stripAnsi(stderr).trimEnd().split("\n"); - expect(lines.at(-1)).toBe("context canceled"); - expect(stderr).not.toContain("Try rerunning the command with --debug"); + const text = stripAnsi(stderr); + expect(text).toContain("needs confirmation"); + expect(text).toContain("--yes"); + expect(text).not.toContain("Resetting remote database"); }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), E2E_TIMEOUT_MS, ); 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 fd49181673..ac2db3a960 100644 --- a/apps/cli/src/commands/db/reset/reset.integration.test.ts +++ b/apps/cli/src/commands/db/reset/reset.integration.test.ts @@ -61,7 +61,12 @@ import { stubManagementApi, type StubbedRequest, } from "../../../../tests/helpers/permission-real-layers.ts"; -import { configValuesLayer, flagInput } from "../../../../tests/helpers/config-values-layer.ts"; +import { + cliConfigValuesTestLayer, + configValuesLayer, + flagInput, +} from "../../../../tests/helpers/config-values-layer.ts"; +import { describeHostedConsent } from "../../../../tests/helpers/hosted-consent.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"; @@ -76,6 +81,7 @@ import { DnsResolverFlag, ExperimentalFlag, NetworkIdFlag, + OutputFlag, YesFlag, } from "../../../command-internal/global-flags.ts"; import type { OutputFormat } from "../../../shared/output/types.ts"; @@ -96,7 +102,16 @@ import { type Observation, } from "@supabase/stack/effect"; import { DbConfigResolver } from "../../../command-internal/db-config.service.ts"; -import type { DbConfigFlags, ResolvedDbConfig } from "../../../command-internal/db-config.types.ts"; +import type { + DbConnectionInput, + ResolvedDbConnection, +} from "../../../command-internal/db-config.types.ts"; +import { HostedMutationDeclinedError } from "../../../command-internal/hosted-consent/hosted-consent.errors.ts"; +import { TargetPolicy } from "../../../command-internal/target/target-policy.ts"; +import { + TargetProjectRefConflictError, + TargetSelectorConflictError, +} from "../../../command-internal/target/target.errors.ts"; import { DbConfigConnectTempRoleError } from "../../../command-internal/db-config.errors.ts"; import { generateLocalJwt } from "../../../command-internal/local-jwt.ts"; import { LocalDockerEngine } from "../../../command-internal/db-bootstrap/local-db-running.ts"; @@ -135,8 +150,8 @@ const DEFAULT_FLAGS: DbResetFlags = { }; /** - * Tracks every `resolve`/`resolvePoolerFallback` call so tests can prove a connection was - * resolved exactly once per reset. + * Tracks every `resolveConnection`/`resolvePoolerFallback` call so tests can prove a connection + * was resolved exactly once per reset. */ function mockResolver(opts: { isLocal: boolean; @@ -145,33 +160,29 @@ function mockResolver(opts: { resolveFails?: boolean; }) { let calls = 0; + const inputs: Array<DbConnectionInput> = []; const layer = Layer.succeed(DbConfigResolver, { - resolve: (flags: DbConfigFlags) => { + resolve: () => Effect.die("resolve is not used by this test"), + resolveConnection: (input: DbConnectionInput) => { calls++; - // A threaded `--project-ref` flag takes the same precedence a real resolver gives it, so - // a test can prove the flag (not just `opts.ref`) drives the resolved ref. - const linkedProjectRef = flags.linkedProjectRef ?? Option.none(); - const resolvedRef = - Option.isSome(linkedProjectRef) && linkedProjectRef.value.length > 0 - ? linkedProjectRef.value - : opts.ref; + inputs.push(input); + const resolvedRef = input.target.kind === "linked" ? input.target.ref : opts.ref; return opts.resolveFails === true ? Effect.fail( new DbConfigConnectTempRoleError({ message: "failed to create login role: network error", }), ) - : Effect.succeed( - (opts.omitRef === true - ? { conn: CONN, isLocal: opts.isLocal } - : { - conn: CONN, - isLocal: opts.isLocal, - ref: resolvedRef !== undefined ? Option.some(resolvedRef) : Option.none(), - }) satisfies ResolvedDbConfig, - ); + : Effect.succeed({ + conn: CONN, + isLocal: opts.isLocal, + ref: + opts.omitRef === true || resolvedRef === undefined + ? Option.none() + : Option.some(resolvedRef), + target: input.target, + } satisfies ResolvedDbConnection); }, - resolveConnection: () => Effect.die("resolveConnection is not used by this test"), resolvePoolerFallback: () => { calls++; return Effect.succeed(Option.none()); @@ -182,6 +193,9 @@ function mockResolver(opts: { get calls() { return calls; }, + get inputs() { + return inputs; + }, }; } @@ -1055,20 +1069,21 @@ function setup( // real pipe for the non-interactive bucket-seed prompts instead. mockTty({ stdinIsTty: opts.stdinIsTty ?? true }), mockStdin(opts.stdinIsTty ?? true, opts.pipedStdin), - // `loadProjectRef` gives an explicit `--project-ref` flag top precedence, mirrored here so a - // test can prove the flag (not just `opts.ref`) drives the linked ref. Layer.succeed(ProjectRefResolver, { - resolve: () => Effect.succeed(opts.ref ?? VALID_REF), + resolve: () => Effect.die("resolve is not used by this test"), resolveForLink: () => Effect.succeed(opts.ref ?? VALID_REF), - resolveRef: () => - Effect.succeed({ ref: opts.ref ?? VALID_REF, source: "file", matchesLinkedRef: true }), - resolveOptional: () => Effect.succeed(Option.some(opts.ref ?? VALID_REF)), - loadProjectRef: (flagValue: Option.Option<string>) => + resolveRef: (flagValue: Option.Option<string>) => Option.isSome(flagValue) && flagValue.value.length > 0 - ? Effect.succeed(flagValue.value) + ? Effect.succeed({ ref: flagValue.value, source: "flag", matchesLinkedRef: false }) : opts.linkedFails === true ? Effect.fail(new ProjectRefNotLinkedError({ message: PROJECT_NOT_LINKED_MESSAGE })) - : Effect.succeed(opts.ref ?? VALID_REF), + : Effect.succeed({ + ref: opts.ref ?? VALID_REF, + source: "file", + matchesLinkedRef: true, + }), + resolveOptional: () => Effect.succeed(Option.some(opts.ref ?? VALID_REF)), + loadProjectRef: () => Effect.die("loadProjectRef is not used by this test"), promptProjectRef: () => Effect.succeed(opts.ref ?? VALID_REF), }), Layer.succeed(CommandPlatformApiFactory, { @@ -1076,6 +1091,8 @@ function setup( }), Layer.succeed(CliArgs, { args: opts.args ?? ["db", "reset", "--linked"] }), Layer.succeed(YesFlag, opts.yes ?? false), + Layer.succeed(OutputFlag, Option.none()), + Layer.succeed(TargetPolicy, { group: "selectable", consent: "no" }), Layer.succeed(DnsResolverFlag, "native"), Layer.succeed(ExperimentalFlag, opts.experimental ?? false), Layer.succeed(DebugFlag, opts.debug ?? false), @@ -1852,7 +1869,9 @@ describe("db reset", () => { const exit = yield* dbReset(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(Cause.pretty(exit.cause)).toContain("failed to load config"); + expect(Option.getOrUndefined(Cause.findErrorOption(exit.cause))).toMatchObject({ + _tag: "CliConfigParseError", + }); } expect(child.spawned.some((s) => s.args[0] === "container" && s.args[1] === "rm")).toBe( false, @@ -2053,7 +2072,7 @@ describe("db reset", () => { return Effect.gen(function* () { yield* dbReset(DEFAULT_FLAGS).pipe(Effect.provide(layer)); const success = out.messages.find((m) => m.type === "success"); - expect(success?.data?.["target"]).toBe("local"); + expect(success?.data).toEqual({ version: "" }); }); }); @@ -2560,13 +2579,17 @@ describe("db reset", () => { }); it.effect("rejects mutually exclusive target flags", () => { - const { layer } = setup(tmp.current, { - toml: 'project_id = "test"\n', - args: ["db", "reset", "--linked", "--local"], - }); + const { layer, resolver } = setup(tmp.current, { toml: 'project_id = "test"\n' }); return Effect.gen(function* () { - const exit = yield* dbReset(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); + const exit = yield* dbReset({ ...DEFAULT_FLAGS, linked: true, local: true }).pipe( + Effect.provide(layer), + Effect.exit, + ); expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(Cause.squash(exit.cause)).toBeInstanceOf(TargetSelectorConflictError); + } + expect(resolver.calls).toBe(0); }); }); @@ -2894,22 +2917,19 @@ describe("db reset", () => { }); }); - it.effect("rejects --project-ref on the default local target", () => { - const FLAG_REF = "flagflagflagflagflag"; + it.effect("rejects --project-ref combined with --local", () => { const { layer, conn, resolver, linkedCache } = setup(tmp.current, { toml: 'project_id = "test"\n', - args: ["db", "reset"], }); return Effect.gen(function* () { const exit = yield* dbReset({ ...DEFAULT_FLAGS, - projectRef: Option.some(FLAG_REF), + local: true, + projectRef: Option.some("flagflagflagflagflag"), }).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(Cause.pretty(exit.cause)).toContain( - "--project-ref only applies when targeting the linked project; use it with --linked (not --local or --db-url)", - ); + expect(Cause.squash(exit.cause)).toBeInstanceOf(TargetProjectRefConflictError); } expect(conn.execs).toEqual([]); expect(resolver.calls).toBe(0); @@ -2993,8 +3013,7 @@ describe("db reset", () => { it.effect( "applies configured schema files instead of replaying migrations on an experimental remote reset", () => { - // `--linked=false` still selects the linked/remote target, exercised here alongside the - // schema-files branch itself. + // `--linked=false` is absent, so the remote workspace target selects the linked project. const { layer, out, conn, resolver, linkedCache } = setup(tmp.current, { toml: 'project_id = "test"\n\n[db.migrations]\nschema_paths = ["schemas/*.sql"]\n\n[experimental.pgdelta]\nenabled = false\n', files: { @@ -3005,6 +3024,7 @@ describe("db reset", () => { experimental: true, args: ["db", "reset", "--linked=false"], confirm: [true], + env: { SUPABASE_TARGET: "remote" }, ref: VALID_REF, }); return Effect.gen(function* () { @@ -3129,7 +3149,7 @@ describe("db reset", () => { expect(Exit.isFailure(exit)).toBe(true); if (!Exit.isFailure(exit)) return; expect(Option.getOrUndefined(Cause.findErrorOption(exit.cause))).toMatchObject({ - _tag: "DbResetCancelledError", + _tag: "HostedMutationDeclinedError", }); expect(out.stderrText).toContain( "[db.migrations].schema_paths is not applied while pg-delta is enabled", @@ -3507,7 +3527,7 @@ describe("db reset", () => { return Effect.gen(function* () { yield* dbReset({ ...DEFAULT_FLAGS, linked: true }).pipe(Effect.provide(layer)); const success = out.messages.find((m) => m.type === "success"); - expect(success?.data?.["target"]).toBe("remote"); + expect(success?.data).toEqual({ version: "" }); }); }); @@ -3533,13 +3553,10 @@ describe("db reset", () => { const remoteSeed = ( opts: { yes?: boolean; - format?: OutputFormat; confirm?: ReadonlyArray<boolean>; env?: Readonly<Record<string, string>>; sqlPaths?: ReadonlyArray<string>; remoteBlock?: string; - interactive?: boolean; - pipedStdin?: string; } = {}, ) => setup(tmp.current, { @@ -3551,136 +3568,63 @@ describe("db reset", () => { }, 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<typeof mockOutput>) => - out.promptConfirmCalls.some((call) => call.message.includes("[remotes.")); - - it.effect( - "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); - }); - }, - ); + const SEED_PLAN = "seed 1 file into [remotes.preview] (seeding enabled by --sql-paths)"; - it.effect("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.effect("proceeds without asking when --yes is passed, naming what enabled seeding", () => { - const { layer, out } = remoteSeed({ yes: true }); + it.effect("names the seed files and the matched remote in the single reset question", () => { + const { layer, out } = remoteSeed({ confirm: [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(out.promptConfirmCalls).toHaveLength(1); + expect(out.promptConfirmCalls[0]?.opts?.defaultValue).toBe(false); + expect(out.stderrText).toContain(SEED_PLAN); expect(seeded(out)).toBe(true); }); }); - it.effect("fails with SeedConsentRequiredError before any write when non-interactive", () => { - const { layer, out, conn } = remoteSeed({ interactive: false, confirm: [true] }); + it.effect("seeds nothing and writes nothing when the question is declined", () => { + const { layer, out, conn } = remoteSeed({ confirm: [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({ - _tag: "SeedConsentRequiredError", - message: `Seeding ${VALID_REF} ([remotes.preview]) needs confirmation and this run can't prompt. Nothing was changed.`, - suggestion: expect.stringContaining("--yes"), - }); + expect(Cause.squash(exit.cause)).toBeInstanceOf(HostedMutationDeclinedError); } expect(out.stderrText).not.toContain("Resetting remote database"); expect(conn.execs).toEqual([]); }); }); - it.effect( - "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.effect("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.effect("seeds when piped y answers both prompts", () => { - const { layer, out } = remoteSeed({ pipedStdin: "y\ny\n" }); + it.effect("proceeds without asking under --yes", () => { + const { layer, out } = remoteSeed({ yes: true }); return Effect.gen(function* () { yield* dbReset(flags).pipe(Effect.provide(layer)); + expect(out.promptConfirmCalls).toHaveLength(0); + expect(out.stderrText).toContain("Confirmed by --yes: Reset the remote database"); expect(seeded(out)).toBe(true); }); }); - it.effect("does not ask when the matched remote block itself enables seeding", () => { + it.effect("names the seed in the single question when the matched remote enables it", () => { 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.promptConfirmCalls).toHaveLength(1); + expect(out.stderrText).toContain("seed 1 file into [remotes.preview]"); expect(out.stderrText).toContain("Seeding data from supabase/seed.sql..."); }); }); - it.effect("does not ask when the matched remote leaves seeding disabled", () => { + it.effect("leaves seeding out of the question when the matched remote disables it", () => { const { layer, out } = setup(tmp.current, { toml: REMOTE_TOML, files: migrationFile("20240101000000"), @@ -3688,12 +3632,13 @@ describe("db reset", () => { }); return Effect.gen(function* () { yield* dbReset({ ...DEFAULT_FLAGS, linked: true }).pipe(Effect.provide(layer)); - expect(askedAboutRemote(out)).toBe(false); + expect(out.promptConfirmCalls).toHaveLength(1); + expect(out.stderrText).not.toContain("seed 1 file"); expect(out.stderrText).not.toContain("Seeding data from"); }); }); - it.effect("does not ask when the target matched no remote block", () => { + it.effect("names the seed without a remote when the target matched no remote block", () => { const { layer, out } = setup(tmp.current, { toml: 'project_id = "test"\n', files: { @@ -3705,25 +3650,25 @@ describe("db reset", () => { }); return Effect.gen(function* () { yield* dbReset(flags).pipe(Effect.provide(layer)); - expect(askedAboutRemote(out)).toBe(false); + expect(out.promptConfirmCalls).toHaveLength(1); + expect(out.stderrText).toContain("seed 1 file\n"); + expect(out.stderrText).not.toContain("[remotes."); expect(seeded(out)).toBe(true); }); }); - it.effect( - "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.effect("SUPABASE_DB_SEED_ENABLED beats the remote's disabled default", () => { + const { layer, out } = remoteSeed({ + confirm: [true], + env: { SUPABASE_DB_SEED_ENABLED: "true" }, + sqlPaths: undefined, + }); + return Effect.gen(function* () { + yield* dbReset({ ...DEFAULT_FLAGS, linked: true }).pipe(Effect.provide(layer)); + expect(out.promptConfirmCalls).toHaveLength(1); + expect(out.stderrText).toContain("seed 1 file into [remotes.preview]"); + }); + }); }); it.effect("rejects --no-seed together with --sql-paths", () => { @@ -3848,3 +3793,45 @@ describe("db reset", () => { ); }); }); + +describeHostedConsent("db reset", { + setup: (workdir) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const dir = `${workdir}/supabase/migrations`; + yield* fs.makeDirectory(dir, { recursive: true }); + yield* fs.writeFileString(`${dir}/20240101000000_test.sql`, "create table t ();"); + }), + invoke: ({ workdir }) => { + const conn = mockConnection(); + const child = mockContainerCliSpawner(defaultLocalResetRoute()); + const stackApi = mockResetStackApi({ workdir, ready: true }); + return { + run: dbReset({ ...DEFAULT_FLAGS, linked: true, projectRef: Option.some(VALID_REF) }).pipe( + Effect.scoped, + Effect.provide( + Layer.mergeAll( + mockResolver({ isLocal: false, ref: VALID_REF }).layer, + conn.layer, + cliConfigValuesTestLayer, + child.layer, + mockLocalDockerEngineUnavailableLayer, + alwaysReadyHttpClientLayer, + dockerRunLayer.pipe( + Layer.provide(child.layer), + Layer.provide(mockProcessControl().layer), + ), + Layer.succeed(DnsResolverFlag, "native"), + Layer.succeed(NetworkIdFlag, Option.none()), + Layer.succeed(ExperimentalFlag, false), + Layer.succeed(DebugFlag, false), + stackBackendLayer("legacy"), + stackApi.layer, + Layer.succeed(StackCatalogSetup, { apply: () => Effect.void }), + ), + ), + ), + mutations: () => conn.execs.filter((sql) => sql.includes("drop schema if exists")).length, + }; + }, +}); From eb677fe2806b6d7a93932df459734c18a9ad36ee Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Thu, 15 Oct 2026 14:37:47 +0100 Subject: [PATCH 44/92] chore(cli): satisfy effect lint and knip for the storage and seed target change --- apps/cli/src/command-internal/seed-buckets.ts | 9 +++++---- apps/cli/src/commands/seed/buckets/buckets.errors.ts | 5 ++++- apps/cli/src/commands/storage/cp/cp.integration.test.ts | 5 ++--- apps/cli/src/commands/storage/storage.errors.ts | 5 ++++- apps/cli/tests/helpers/storage.ts | 4 ++-- 5 files changed, 17 insertions(+), 11 deletions(-) diff --git a/apps/cli/src/command-internal/seed-buckets.ts b/apps/cli/src/command-internal/seed-buckets.ts index 6120df81c2..2c3beee63e 100644 --- a/apps/cli/src/command-internal/seed-buckets.ts +++ b/apps/cli/src/command-internal/seed-buckets.ts @@ -553,17 +553,18 @@ const planHostedWrites = Effect.fnUntraced(function* ( const declaredVector = Object.keys(config.storage.vector.buckets); if (config.storage.vector.enabled && declaredVector.length > 0) { const listed = yield* gateway.listVectorBuckets.pipe( + Effect.map(Option.some), Effect.catch((error) => isVectorBucketsFeatureNotEnabled(error.message) || isLocalVectorBucketsUnavailable(error.message) - ? Effect.succeed(undefined) + ? Effect.succeed(Option.none<ReadonlyArray<string>>()) : Effect.fail(error), ), ); - if (listed !== undefined) { - const present = new Set(listed); + if (Option.isSome(listed)) { + const present = new Set(listed.value); const created = declaredVector.filter((name) => !present.has(name)); - const pruned = listed.filter((name) => !declaredVector.includes(name)); + const pruned = listed.value.filter((name) => !declaredVector.includes(name)); if (created.length > 0) details.push(`Create vector buckets: ${joinNames(created)}`); if (pruned.length > 0) { details.push(`Delete vector buckets not declared in ${CONFIG_PATH}: ${joinNames(pruned)}`); diff --git a/apps/cli/src/commands/seed/buckets/buckets.errors.ts b/apps/cli/src/commands/seed/buckets/buckets.errors.ts index 38a34c2c64..64d8b0b7c6 100644 --- a/apps/cli/src/commands/seed/buckets/buckets.errors.ts +++ b/apps/cli/src/commands/seed/buckets/buckets.errors.ts @@ -24,7 +24,10 @@ export class SeedConfigLoadError extends Data.TaggedError("SeedConfigLoadError") } } -/** Raised when both `--local` and `--linked` are passed. */ +/** + * Unused: target conflicts raise `TargetSelectorConflictError`. + * @public + */ export class SeedMutuallyExclusiveFlagsError extends Data.TaggedError( "SeedMutuallyExclusiveFlagsError", )<{ diff --git a/apps/cli/src/commands/storage/cp/cp.integration.test.ts b/apps/cli/src/commands/storage/cp/cp.integration.test.ts index d01c559a2e..014b48f44f 100644 --- a/apps/cli/src/commands/storage/cp/cp.integration.test.ts +++ b/apps/cli/src/commands/storage/cp/cp.integration.test.ts @@ -1,6 +1,5 @@ import { describe, expect, it } from "@effect/vitest"; import { Cause, Effect, Exit, FileSystem, Option, Path, Predicate } from "effect"; -import { join } from "node:path"; import { describeHostedConsent } from "../../../../tests/helpers/hosted-consent.ts"; import { @@ -807,7 +806,7 @@ describeHostedConsent("storage cp", { setup: (workdir) => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; - yield* fs.writeFileString(join(workdir, "readme.md"), "hello world"); + yield* fs.writeFileString(`${workdir}/readme.md`, "hello world"); }), invoke: ({ workdir }) => { const storage = storageConsentServices(workdir, [ @@ -815,7 +814,7 @@ describeHostedConsent("storage cp", { ]); return { run: storageCp({ - ...cpFlags({ src: join(workdir, "readme.md"), dst: "ss:///private/readme.md" }), + ...cpFlags({ src: `${workdir}/readme.md`, dst: "ss:///private/readme.md" }), local: false, linked: false, projectRef: Option.some(VALID_REF), diff --git a/apps/cli/src/commands/storage/storage.errors.ts b/apps/cli/src/commands/storage/storage.errors.ts index 98f6790405..b8dd415118 100644 --- a/apps/cli/src/commands/storage/storage.errors.ts +++ b/apps/cli/src/commands/storage/storage.errors.ts @@ -172,7 +172,10 @@ export class StorageFileError extends Data.TaggedError("StorageFileError")<{ } } -/** Conflicting target flags: `--linked` with `--local`, or `--project-ref` with `--local`. */ +/** + * Unused: target conflicts raise `TargetSelectorConflictError`. + * @public + */ export class StorageMutuallyExclusiveFlagsError extends Data.TaggedError( "StorageMutuallyExclusiveFlagsError", )<{ diff --git a/apps/cli/tests/helpers/storage.ts b/apps/cli/tests/helpers/storage.ts index ac2e56b915..33b608be6b 100644 --- a/apps/cli/tests/helpers/storage.ts +++ b/apps/cli/tests/helpers/storage.ts @@ -307,7 +307,7 @@ export function buildStorageStackApi( } /** A recording `HttpClient` that answers Storage requests from `routes`, first match first. */ -export function recordingStorageHttp(routes: ReadonlyArray<StorageRoute>) { +function recordingStorageHttp(routes: ReadonlyArray<StorageRoute>) { const requests: Array<RecordedStorageRequest> = []; const consumed = new Set<number>(); @@ -363,7 +363,7 @@ export function recordingStorageHttp(routes: ReadonlyArray<StorageRoute>) { } /** Storage requests that change state; the object and vector list endpoints are POST reads. */ -export const isStorageWrite = (request: { readonly method: string; readonly url: string }) => +const isStorageWrite = (request: { readonly method: string; readonly url: string }) => request.method !== "GET" && !request.url.includes("/storage/v1/object/list/") && !request.url.includes("/storage/v1/vector/ListVectorBuckets"); From 7fd95ba0a9bcaa9848ec5b6d581abed33a671630 Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Thu, 15 Oct 2026 14:38:25 +0100 Subject: [PATCH 45/92] feat(cli): resolve db pull through the sync target group and consent before writing --- apps/cli/docs/supabase/db/pull.md | 4 +- apps/cli/src/command-internal/db-pull-run.ts | 217 +++----- apps/cli/src/commands/db/pull/SIDE_EFFECTS.md | 75 ++- apps/cli/src/commands/db/pull/pull.command.ts | 23 +- apps/cli/src/commands/db/pull/pull.handler.ts | 11 +- .../commands/db/pull/pull.integration.test.ts | 480 +++++++++--------- apps/cli/src/commands/db/pull/pull.layers.ts | 2 - 7 files changed, 374 insertions(+), 438 deletions(-) diff --git a/apps/cli/docs/supabase/db/pull.md b/apps/cli/docs/supabase/db/pull.md index 23d7bd636e..bbad95ea48 100644 --- a/apps/cli/docs/supabase/db/pull.md +++ b/apps/cli/docs/supabase/db/pull.md @@ -2,11 +2,11 @@ Pulls schema changes from a remote database. A new migration file will be created under `supabase/migrations` directory. -Requires your local project to be linked to a remote database by running `supabase link`. For self-hosted databases, you can pass in the connection parameters using `--db-url` flag. +Pulls from the linked project by default, so your local project must be linked by running `supabase link`. Pass `--local` to pull from the local database, or `--db-url` to pull from another database such as a self-hosted one. The command prints a `Target:` line on stderr naming the database it resolved. > Note this command requires Docker Desktop (or a running Docker daemon), as it starts a local Postgres container to diff your remote schema. -Optionally, a new row can be inserted into the migration history table to reflect the current state of the remote database. +After the migration file is written, a new row is inserted into the migration history table to reflect the current state of the remote database. A hosted database asks for one confirmation first (default No), after the diff and before any file is written; declining stops the command without writing anything. Non-interactive, JSON and agent runs need `--yes` (or `SUPABASE_YES` in the shell environment, not in `supabase/.env`). A pull from the local database and `--declarative` ask nothing. If no entries exist in the migration history table, pg-delta (the default diff engine) produces the full migration from the shadow diff alone; with `--diff-engine migra`, the initial pull instead uses `pg_dump` to capture all contents of the remote schemas you have created. Otherwise, this command will only diff schema changes against the remote database, similar to running `db diff --linked`. diff --git a/apps/cli/src/command-internal/db-pull-run.ts b/apps/cli/src/command-internal/db-pull-run.ts index 3dc830dc87..49c8cac146 100644 --- a/apps/cli/src/command-internal/db-pull-run.ts +++ b/apps/cli/src/command-internal/db-pull-run.ts @@ -1,26 +1,24 @@ import { Clock, Effect, FileSystem, Option, Path } from "effect"; import { ChildProcessSpawner } from "effect/process"; -import { - DebugFlag, - DnsResolverFlag, - NetworkIdFlag, - resolveYesWithProjectEnv, -} from "./global-flags.ts"; +import { DebugFlag, DnsResolverFlag, NetworkIdFlag } from "./global-flags.ts"; 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 { + hostedMutationPreapproval, + requireTargetMutationConsent, +} from "./hosted-consent/hosted-consent.ts"; +import type { HostedMutationConsent } from "./hosted-consent/hosted-consent.token.ts"; +import type { ResolvedTarget } from "./target/resolve-target.ts"; import { resolveDbImage } from "./db-image.ts"; import { DbConnection, type PgConnInput } from "./db-connection.service.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"; import { buildLocalDbContainerInputs } from "./db-bootstrap/local-container-inputs.ts"; @@ -60,12 +58,7 @@ import { currentStackBackend } from "./stack-backend.ts"; import { stackRejectNativeDockerDiffEngine } from "./stack-local-database.ts"; import { stackPrepareShadowSource, stackWithShadowDatabase } from "./stack-shadow.ts"; import type { DbPullFlags as DbPullCommandFlags } from "../commands/db/pull/pull.command.ts"; -import { - DbPullDumpError, - DbPullEngineConflictError, - DbPullTargetFlagsError, - DbPullWriteError, -} from "../commands/db/pull/pull.errors.ts"; +import { DbPullDumpError, DbPullWriteError } from "../commands/db/pull/pull.errors.ts"; import { DbPullInSyncError, DbPullMigrationConflictError } from "./db-pull-run.errors.ts"; import { listRemoteMigrations, @@ -76,10 +69,13 @@ import { updateMigrationHistory } from "../commands/db/pull/pull.sync.ts"; /** * `runDbPull`'s parameter type: the `db pull` command flags without the tombstoned - * `--use-pg-delta`, so an in-process caller (`pull`) can build one without reaching into - * `commands/db/**` directly. + * `--use-pg-delta` and the target selectors, so an in-process caller (`pull`) can build one + * without reaching into `commands/db/**` directly. */ -export type DbPullRunFlags = Omit<DbPullCommandFlags, "usePgDelta">; +export type DbPullRunFlags = Omit< + DbPullCommandFlags, + "usePgDelta" | "dbUrl" | "linked" | "local" | "projectRef" +>; /** * Explains the in-sync non-zero exit instead of the generic "Try rerunning the command with @@ -93,11 +89,10 @@ const MIGRATION_FILE_MODE = 0o644; export type DbPullInvoke = { /** - * Overrides `--yes`/`SUPABASE_YES`/`supabase/.env` resolution for an - * in-process caller that already ran its own aggregated confirmation. - * `undefined` keeps the existing resolution. + * The consent an in-process caller already collected for the remote history update; `Some` + * skips this run's own question. */ - readonly assumeYes?: boolean; + readonly preapproved?: Option.Option<HostedMutationConsent>; /** Project `.env*` values the caller already loaded; `undefined` loads them here. */ readonly projectEnv?: Record<string, string>; }; @@ -116,13 +111,14 @@ export type DbPullOutcome = }; /** - * Runs `db pull`'s target resolution and pull (declarative or migration mode) - * without emitting the final `output.success`/"Finished" line — the caller - * (`dbPull` for the standalone command, or an in-process orchestrator) - * owns that emission based on the returned {@link DbPullOutcome}. + * Runs `db pull` against an already-resolved target (declarative or migration mode) without + * emitting the final `output.success`/"Finished" line — the caller (`dbPull` for the + * standalone command, or an in-process orchestrator) owns that emission based on the returned + * {@link DbPullOutcome}. */ export const runDbPull = Effect.fn("db.pull.run")(function* ( flags: DbPullRunFlags, + target: ResolvedTarget, invoke?: DbPullInvoke, ) { const output = yield* Output; @@ -138,71 +134,25 @@ export const runDbPull = Effect.fn("db.pull.run")(function* ( const debug = yield* DebugFlag; 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 = invoke?.projectEnv ?? (yield* loadProjectEnvValues(fs, path, cliSettings.workdir)); - const yes = yield* resolveYesWithProjectEnv(projectEnv); + const preapproved = invoke?.preapproved ?? Option.none<HostedMutationConsent>(); - let linkedRefForCache: string | undefined; + const connType = target.kind; + // `--local`/`--db-url` never merge a `[remotes.*]` block, so only a linked target has a ref. + const linkedRef = target.kind === "linked" ? target.ref : undefined; return yield* Effect.gen(function* () { const name = Option.getOrElse(flags.name, () => "remote_schema"); const useDeclarative = Option.getOrElse(flags.declarative, () => false); - // Mutually exclusive flag groups: `[db-url linked local]`, `[declarative diff-engine]`. - // "set" means the flag was explicitly passed. - const targetSet: Array<string> = []; - if (Option.isSome(flags.dbUrl)) targetSet.push("db-url"); - if (Option.isSome(flags.linked)) targetSet.push("linked"); - if (Option.isSome(flags.local)) targetSet.push("local"); - if (targetSet.length > 1) { - return yield* new DbPullTargetFlagsError({ - message: `if any flags in the group [db-url linked local] are set none of the others can be; [${[...targetSet].sort().join(" ")}] were all set`, - }); - } - if (Option.isSome(flags.declarative) && Option.isSome(flags.diffEngine)) { - return yield* new DbPullEngineConflictError({ - message: - "if any flags in the group [declarative diff-engine] are set none of the others can be; [declarative diff-engine] were all set", - }); - } - - const connType: DbConnType = Option.isSome(flags.dbUrl) - ? "db-url" - : Option.isSome(flags.local) - ? "local" - : "linked"; yield* Effect.annotateCurrentSpan({ "db.conn_type": connType, "db.pull.mode": useDeclarative ? "declarative" : "migration", }); - // `--project-ref` never implies `--linked` and must not be silently - // discarded on a non-linked target — see push.handler.ts's identical guard - // for the full TS-only rationale. - if (Option.isSome(flags.projectRef) && connType !== "linked") { - return yield* new DbPullTargetFlagsError({ - message: - "--project-ref only applies when targeting the linked project; use it with --linked (not --local or --db-url)", - }); - } - - // Pre-load the ref and re-read config here, before `resolver.resolve()` below, so the - // override print and merged-config validation happen before the connection attempt: a - // `resolve()` failure (bad password, unreachable host, network-ban lookup, …) must still - // tell the user which `[remotes.*]` block matched. `--local`/`--db-url` never merge a - // remote block, so only the linked path pre-resolves a ref. - let linkedRef: string | undefined; - if (connType === "linked") { - const projectRefResolver = yield* ProjectRefResolver; - linkedRef = yield* projectRefResolver.loadProjectRef(flags.projectRef); - // Cache the ref the moment it's known, not after `toml`/`localInputs` below resolve - // (both fallible): the project cache should still be written even when a later step - // (config validation, connection, the pull itself) fails. - linkedRefForCache = linkedRef; - } + // Read the config for the matching `[remotes.<ref>]` block before connecting, so a + // connection failure still reports which override matched. const toml = yield* readDbToml(fs, path, cliSettings.workdir, linkedRef); if (toml.appliedRemote !== undefined) { yield* output.raw(`Loading config override: [remotes.${toml.appliedRemote}]\n`, "stderr"); @@ -224,20 +174,34 @@ export const runDbPull = Effect.fn("db.pull.run")(function* ( debug, // So the shadow's own container spec reflects the matching `[remotes.<ref>]` // override, same as `toml` above. - connType === "linked" ? linkedRef : undefined, + linkedRef, ); - const resolved = yield* resolver.resolve({ - dbUrl: flags.dbUrl, - connType, + const resolved = yield* resolver.resolveConnection({ + target, dnsResolver, password: flags.password ?? Option.none(), - linkedProjectRef: flags.projectRef, + hasPasswordFlag: true, + networkBanRemoval: yield* hostedMutationPreapproval, }); - if (linkedRef === undefined) { - linkedRef = Option.getOrUndefined(resolved.ref ?? Option.none()); - } - if (linkedRef !== undefined) linkedRefForCache = linkedRef; + const poolerFallbackFlags = { + dbUrl: Option.none<string>(), + connType: "linked", + dnsResolver, + password: flags.password ?? Option.none(), + linkedProjectRef: Option.fromNullishOr(linkedRef), + adHocProjectRef: target.kind === "linked" && !target.matchesLinkedRef, + } as const; + const requireHistoryConsent = (migrations: number) => + Option.isSome(preapproved) + ? Effect.void + : requireTargetMutationConsent(resolved, { + action: "Update the remote migration history table", + details: [ + `Write ${migrations} migration file${migrations === 1 ? "" : "s"} to supabase/migrations.`, + "Record them as applied in the remote migration history.", + ], + }).pipe(Effect.asVoid); const targetUrl = toPostgresURL(resolved.conn); const ctx: PgDeltaContext = { projectId: (yield* resolvedConfig.get(CliConfigKeys.projectId)).value, @@ -273,13 +237,7 @@ export const runDbPull = Effect.fn("db.pull.run")(function* ( // A pooler resolution failure is treated as "no fallback" (re-fail the // original diff error), not surfaced as its own error. const pooler = yield* resolver - .resolvePoolerFallback({ - dbUrl: flags.dbUrl, - connType: "linked", - dnsResolver, - password: flags.password ?? Option.none(), - linkedProjectRef: flags.projectRef, - }) + .resolvePoolerFallback(poolerFallbackFlags) .pipe(Effect.orElseSucceed(() => Option.none())); if (Option.isSome(pooler)) { yield* emitPoolerFallbackWarning(resolved.conn.host); @@ -309,13 +267,8 @@ export const runDbPull = Effect.fn("db.pull.run")(function* ( const diffEngine = usePgDeltaDiff ? "pg-delta" : "migra"; const pgDeltaDebug = yield* isPgDeltaDebugEnabled; - // Connectivity check, run before dialing. return yield* Effect.scoped( Effect.gen(function* () { - yield* output.raw( - `Connecting to ${resolved.isLocal ? "local" : "remote"} database...\n`, - "stderr", - ); const session = yield* connection.connect(resolved.conn, { isLocal: resolved.isLocal, dnsResolver, @@ -331,9 +284,7 @@ export const runDbPull = Effect.fn("db.pull.run")(function* ( target, schema: flags.schema, formatOptions, - ...(connType === "linked" && linkedRef !== undefined - ? { projectRef: linkedRef } - : {}), + ...(linkedRef !== undefined ? { projectRef: linkedRef } : {}), debug: pgDeltaDebug, strictCoverage: flags.strictCoverage, }); @@ -402,6 +353,7 @@ export const runDbPull = Effect.fn("db.pull.run")(function* ( let seedWroteBytes = false; if (seededFromDump) { + yield* requireHistoryConsent(1); yield* makeDir(fs, path.dirname(migrationPath)).pipe( Effect.mapError((cause) => new DbPullWriteError({ message: cause.message })), ); @@ -499,13 +451,7 @@ export const runDbPull = Effect.fn("db.pull.run")(function* ( projectHost: cliSettings.projectHost, resolvePooler: () => resolver - .resolvePoolerFallback({ - dbUrl: flags.dbUrl, - connType: "linked", - dnsResolver, - password: flags.password ?? Option.none(), - linkedProjectRef: flags.projectRef, - }) + .resolvePoolerFallback(poolerFallbackFlags) .pipe(Effect.orElseSucceed(() => Option.none())), runWithConn: runSchemaDump, reprintOnRetry: Effect.void, @@ -644,6 +590,11 @@ export const runDbPull = Effect.fn("db.pull.run")(function* ( }); } + // The initial migra pull asked before its dump wrote the file. + if (!seededFromDump) { + yield* requireHistoryConsent(usePgDeltaDiff ? (diffOutcome.files?.length ?? 0) : 1); + } + // Build the list of migration files to record in the remote history. The // migra engine writes exactly one file (the dump-seeded or freshly written // migrationPath); the pg-delta engine writes one ordered file per @@ -734,34 +685,18 @@ export const runDbPull = Effect.fn("db.pull.run")(function* ( ); } - // Prompt to update the remote migration history table. Returns the default - // (`true`) on `--yes`, on an empty non-interactive stdin, or on any prompt error — - // it never fails the command. - let remoteHistoryUpdated = false; - const updateHistoryTitle = "Update remote migration history table?"; - // `invoke?.assumeYes` overrides this resolution entirely for an in-process - // caller that already ran its own aggregated confirmation. Otherwise, honors - // `--yes`, scans piped stdin on a non-TTY before falling back to the default, - // and otherwise prompts on a real TTY. - const shouldUpdate = - invoke?.assumeYes !== undefined - ? invoke.assumeYes - : yield* promptYesNo(output, yes, updateHistoryTitle, true); - if (shouldUpdate) { - // The migration file(s) in `writtenMigrations` are already on disk at this point, so - // a failure here must still report them as written: re-raise the same - // `DbPullWriteError` `updateMigrationHistory` fails with, carrying their paths. - yield* updateMigrationHistory(session, fs, path, writtenMigrations).pipe( - Effect.mapError( - (cause) => - new DbPullWriteError({ - message: cause.message, - writtenSoFar: writtenMigrations.map((written) => written.path), - }), - ), - ); - remoteHistoryUpdated = true; - } + // The migration file(s) in `writtenMigrations` are already on disk at this point, so + // a failure here must still report them as written: re-raise the same + // `DbPullWriteError` `updateMigrationHistory` fails with, carrying their paths. + yield* updateMigrationHistory(session, fs, path, writtenMigrations).pipe( + Effect.mapError( + (cause) => + new DbPullWriteError({ + message: cause.message, + writtenSoFar: writtenMigrations.map((written) => written.path), + }), + ), + ); return { kind: "migration", @@ -769,17 +704,13 @@ export const runDbPull = Effect.fn("db.pull.run")(function* ( // writes one file per unit); `dbPull`'s emission derives the released `schemaWritten` // string field from its first entry. schemaFiles: writtenMigrations.map((written) => written.path), - remoteHistoryUpdated, + remoteHistoryUpdated: true, engine: diffEngine, } as const; }), ); }).pipe( - Effect.ensuring( - Effect.suspend(() => - linkedRefForCache !== undefined ? linkedProjectCache.cache(linkedRefForCache) : Effect.void, - ), - ), + Effect.ensuring(linkedRef !== undefined ? linkedProjectCache.cache(linkedRef) : Effect.void), Effect.ensuring(telemetryState.flush), ); }); diff --git a/apps/cli/src/commands/db/pull/SIDE_EFFECTS.md b/apps/cli/src/commands/db/pull/SIDE_EFFECTS.md index 06db3472da..d4e764c6f6 100644 --- a/apps/cli/src/commands/db/pull/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/db/pull/SIDE_EFFECTS.md @@ -10,6 +10,12 @@ with IPv4 transaction-pooler fallback) and then appends the migra diff. `SUPABASE_EXPERIMENTAL` resolving true without `--declarative`) are removed — see "Removed surfaces" below. +The default target is the linked project; `--local` selects the local database and `--db-url` +another database, and `--project-ref` selects a linked project. One `Target: …` line on stderr +names the resolved database. In migration mode a hosted target (the linked project or a +non-local `--db-url`) needs one confirmation (default **No**) that covers writing the migration +file(s) and recording them in the remote migration history; see "Consent" below. + When `[experimental].stack` is on, each shadow is a fresh database in an invocation-owned, unique temporary stack namespace. The command applies the catalog and project migrations as needed, then destroys its namespace when the Effect scope closes. If its container engine is unreachable @@ -111,15 +117,15 @@ at all, so nothing is cached for it. ## API Routes / DB -| Method | Path / SQL | Auth | Purpose | -| ------ | --------------------------------------------------- | ------ | ----------------------------------- | -| POST | `/v1/projects/{ref}/cli/login-role` | Bearer | Temp login role when no password | -| GET | `/v1/projects/{ref}/config/database/pooler` | Bearer | IPv4 pooler fallback | -| POST | `/v1/projects/{ref}/network-bans/retrieve` | Bearer | List bans during pooler login retry | -| DELETE | `/v1/projects/{ref}/network-bans` | Bearer | Unban during pooler login retry | -| GET | `/v1/projects/{ref}` | Bearer | Linked-project cache (post-run) | -| SQL | `SELECT version FROM …schema_migrations` | — | history reconciliation (remote) | -| SQL | `INSERT … ON CONFLICT … schema_migrations` (UPSERT) | — | history update (on confirmation) | +| Method | Path / SQL | Auth | Purpose | +| ------ | --------------------------------------------------- | ------ | ---------------------------------------------------- | +| POST | `/v1/projects/{ref}/cli/login-role` | Bearer | Temp login role when no password | +| GET | `/v1/projects/{ref}/config/database/pooler` | Bearer | IPv4 pooler fallback | +| POST | `/v1/projects/{ref}/network-bans/retrieve` | Bearer | List bans during pooler login retry | +| DELETE | `/v1/projects/{ref}/network-bans` | Bearer | Unban during pooler login retry (only under `--yes`) | +| GET | `/v1/projects/{ref}` | Bearer | Linked-project cache (post-run) | +| SQL | `SELECT version FROM …schema_migrations` | — | history reconciliation (remote) | +| SQL | `INSERT … ON CONFLICT … schema_migrations` (UPSERT) | — | history update (after consent) | ## Environment Variables @@ -139,12 +145,13 @@ at all, so nothing is cached for it. ## Exit Codes -| Code | Condition | -| ---- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `0` | success (migration written + optional history update; declarative export) | -| `1` | target mutex; `--declarative` with `--diff-engine`; migration-history conflict; **no schema changes ("No schema changes found")**; connection/shadow/engine failure; file IO error | -| `1` | `--use-pg-delta` passed at all (any value, including `=false`); `--experimental`/`SUPABASE_EXPERIMENTAL` resolving true without `--declarative` — removed, see Removed surfaces | -| `1` | `--project-ref` set with a resolved target other than linked | +| Code | Condition | +| ---- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `0` | success (migration written and recorded in the history; declarative export) | +| `1` | conflicting target flags; `--declarative` with `--diff-engine`; migration-history conflict; **no schema changes ("No schema changes found")**; connection/shadow/engine failure; file IO error | +| `1` | `--use-pg-delta` passed at all (any value, including `=false`); `--experimental`/`SUPABASE_EXPERIMENTAL` resolving true without `--declarative` — removed, see Removed surfaces | +| `1` | `--project-ref` combined with `--local` or `--db-url` | +| `1` | hosted confirmation declined (`context canceled`), or needed in a run that cannot prompt (non-interactive, JSON, stream-json, agent) without `--yes` | > Note: unlike `db diff`, an empty diff (`No schema changes found`) is a **non-zero > exit** for `db pull`. Instead of the generic @@ -159,7 +166,7 @@ at all, so nothing is cached for it. Progress to stderr. Migration path: `Creating shadow database...`, `Diffing schemas[: <list>]`, `Schema written to <path>`. Declarative path: `Preparing declarative schema export using pg-delta...`, `Declarative schema -written to <dir>`. Plus the history-update prompt. On success the PostRun line `Finished supabase db pull.` is printed to +written to <dir>`. On a hosted target the confirmation question precedes any file write. On success the PostRun line `Finished supabase db pull.` is printed to stdout. A configured `[db.migrations].schema_paths` also warns on the migration path @@ -168,8 +175,25 @@ that it no longer replaces the migrations baseline. ### `--output-format json` / `stream-json` Progress strings still go to stderr; stdout carries a single structured envelope -`{ declarative, schemaWritten, remoteHistoryUpdated, engine }` and suppresses the -`Finished supabase db pull.` line. +`{ declarative, schemaWritten, remoteHistoryUpdated, engine }` (`remoteHistoryUpdated` is `true` +for every migration-mode pull) and suppresses the `Finished supabase db pull.` line. The +resolved `target` object is merged into the payload. + +## Consent + +- Declarative pulls and pulls against the local stack ask nothing. +- A migration pull against a hosted target asks `Update the remote migration history table on +<target>?` (default **No**), listing the number of migration files it will write. It is asked + after the diff and the in-sync check, so an in-sync pull asks nothing. The initial migra pull + asks before its `pg_dump` seed writes the file. +- Declining exits non-zero with `context canceled` before any file is written; the migration + history is never updated without the files and the files are never written without the history + update. +- `--yes` or a `SUPABASE_YES` in the shell environment confirms it (`Confirmed by --yes: …`). A + `SUPABASE_YES` in the project `supabase/.env` does not. JSON, stream-json, `-o` resource formats and + non-TTY runs cannot prompt and fail without `--yes`; a piped answer on stdin is ignored. +- `--yes` also lets the connection remove a network ban that blocks the temporary login role; + without it the pull fails and points to `supabase network-bans remove`. ## Notes @@ -179,18 +203,15 @@ Progress strings still go to stderr; stdout carries a single structured envelope block. The linked-database password env is withheld when the target differs from `.temp/project-ref`, with a `Not sending SUPABASE_DB_PASSWORD to <target>: ...` warning and a temporary login role instead. `--password` is rejected with `--db-url` or `--local`. -- `--declarative` is mutually exclusive with - `--diff-engine`; `--db-url` / `--linked` (default) / `--local` are a target group. +- `--declarative` is mutually exclusive with `--diff-engine`; `--db-url`, `--linked` and `--local` + are mutually exclusive, and the default target is the linked project. - `--diff-engine` defaults to pg-delta. Without the flag, migra is selected only by `[experimental.pgdelta] enabled = false`; the historical `SUPABASE_EXPERIMENTAL_PG_DELTA` opt-in is not read. -- **`--project-ref`** overrides ONLY the linked-ref resolution `ProjectRefResolver` - performs (flag > `SUPABASE_PROJECT_ID` > `.temp/project-ref`) — unlike - `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 (deliberately stricter than `SUPABASE_PROJECT_ID`, which is simply - unused on a non-linked target). +- **`--project-ref`** selects a linked project (flag > `SUPABASE_PROJECT_ID` > + `.temp/project-ref`) and implies `--linked`; unlike `SUPABASE_PROJECT_ID`, it does not + affect the shadow container's project id/labels. Combining it with `--local` or `--db-url` + is a hard error. - 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 bf18b3492d..e120f049ac 100644 --- a/apps/cli/src/commands/db/pull/pull.command.ts +++ b/apps/cli/src/commands/db/pull/pull.command.ts @@ -21,6 +21,7 @@ import { withCommandTelemetry } from "../../../telemetry/command-telemetry.ts"; import { dbPull, resolveRemovedExperimental } from "./pull.handler.ts"; import { dbPullRuntimeLayer } from "./pull.layers.ts"; import { cliConfigValuesLayer } from "../../../config/cli-config-values.layer.ts"; +import { targetFlags } from "../../../command-internal/target/target-flags.ts"; import { withTargetPolicy } from "../../../command-internal/target/target-policy.ts"; const config = { @@ -62,25 +63,7 @@ const config = { (err) => (err instanceof Error ? err.message : String(err)), ), ), - dbUrl: Flag.String("db-url").pipe( - Flag.withDescription( - "Pulls from the database specified by the connection string (must be percent-encoded).", - ), - Flag.optional, - ), - linked: Flag.Boolean("linked").pipe( - Flag.withDescription("Pulls from the linked project."), - Flag.optional, - ), - local: Flag.Boolean("local").pipe( - Flag.withDescription("Pulls from the local database."), - Flag.optional, - ), - // TS-only override of the linked project ref — see push.command.ts. - projectRef: Flag.String("project-ref").pipe( - Flag.withDescription("Project ref of the Supabase project."), - Flag.optional, - ), + ...targetFlags({ group: "sync", subject: "database", dbUrl: true }), password: CliConfigKeys.linkedDb.password.flag({ name: "password", alias: "p", @@ -122,7 +105,7 @@ const telemetryOptions = (flags: DbPullFlags) => ({ export const dbPullCommand = Command.make("pull", config).pipe( Command.withDescription( - "Migration mode compares supabase/migrations with the selected live database (--linked by default), writes the complete difference as migration files, and may record them in that database's migration history. --declarative instead replaces the declarative schema tree and does not create migrations or update migration history.", + "Migration mode compares supabase/migrations with the selected live database (the linked project by default), writes the complete difference as migration files, and records them in that database's migration history; a hosted database asks for confirmation first, or needs --yes in a non-interactive run. --declarative instead replaces the declarative schema tree and does not create migrations or update migration history.", ), Command.withShortDescription("Pull schema from the remote database"), withPermissions( diff --git a/apps/cli/src/commands/db/pull/pull.handler.ts b/apps/cli/src/commands/db/pull/pull.handler.ts index 21ec74ba35..937632f570 100644 --- a/apps/cli/src/commands/db/pull/pull.handler.ts +++ b/apps/cli/src/commands/db/pull/pull.handler.ts @@ -10,6 +10,8 @@ import { resolveWorkdir } from "../../../config/command-settings.layer.ts"; import { Output } from "../../../shared/output/output.service.ts"; import { RuntimeInfo } from "../../../shared/runtime/runtime-info.service.ts"; import { runDbPull } from "../../../command-internal/db-pull-run.ts"; +import { resolveTarget } from "../../../command-internal/target/resolve-target.ts"; +import { DbPullEngineConflictError } from "./pull.errors.ts"; import type { DbPullFlags } from "./pull.command.ts"; /** @@ -48,7 +50,14 @@ export const dbPull = Effect.fn("db.pull")(function* ( projectEnv?: Record<string, string>, ) { const output = yield* Output; - const outcome = yield* runDbPull(flags, { projectEnv }); + if (Option.isSome(flags.declarative) && Option.isSome(flags.diffEngine)) { + return yield* new DbPullEngineConflictError({ + message: + "if any flags in the group [declarative diff-engine] are set none of the others can be; [declarative diff-engine] were all set", + }); + } + const target = yield* resolveTarget(flags); + const outcome = yield* runDbPull(flags, target, { projectEnv }); yield* Effect.annotateCurrentSpan({ "db.pull.outcome": outcome.kind, 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 022a2dacaf..263946fd76 100644 --- a/apps/cli/src/commands/db/pull/pull.integration.test.ts +++ b/apps/cli/src/commands/db/pull/pull.integration.test.ts @@ -28,6 +28,7 @@ vi.mock("node:net", (importOriginal) => const reachableDirectHost = withReachableDirectHost(fixtureNet); import { stripAnsi } from "../../../../tests/helpers/ansi.ts"; +import { describeHostedConsent } from "../../../../tests/helpers/hosted-consent.ts"; import { withReachableDirectHost } from "../../../../tests/helpers/refuse-invalid-db-hosts.ts"; import { assertBlockDrift, @@ -69,8 +70,19 @@ import { DnsResolverFlag, ExperimentalFlag, NetworkIdFlag, + OutputFlag, YesFlag, } from "../../../command-internal/global-flags.ts"; +import { + HostedMutationConsentRequiredError, + HostedMutationDeclinedError, +} from "../../../command-internal/hosted-consent/hosted-consent.errors.ts"; +import { TargetPolicy } from "../../../command-internal/target/target-policy.ts"; +import { TargetProjectRefConflictError } from "../../../command-internal/target/target.errors.ts"; +import type { + DbConnectionInput, + ResolvedDbConnection, +} from "../../../command-internal/db-config.types.ts"; import { CliArgs } from "../../../shared/cli/cli-args.service.ts"; import type { OutputFormat } from "../../../shared/output/types.ts"; import { ProjectRefNotLinkedError } from "../../../config/project-ref.errors.ts"; @@ -400,26 +412,27 @@ function setup(workdir: string, opts: SetupOpts = {}) { }); const poolerFallbackCalls: unknown[] = []; - const resolveCalls: unknown[] = []; + const resolveCalls: Array<DbConnectionInput> = []; const resolver = Layer.succeed(DbConfigResolver, { - resolve: (resolveFlags) => { - resolveCalls.push(resolveFlags); - const { connType } = resolveFlags; + resolve: () => Effect.die("resolve is not used by db pull"), + resolveConnection: (input: DbConnectionInput) => { + resolveCalls.push(input); + const isLocal = input.target.kind === "local"; return Effect.succeed({ conn: { // A direct `db.<ref>.<projectHost>` host so the pooler-fallback gate // matches on the linked path. - host: connType === "local" ? "127.0.0.1" : "db.abcdefghijklmnopqrst.supabase.co", + host: isLocal ? "127.0.0.1" : "db.abcdefghijklmnopqrst.supabase.co", port: 5432, user: "postgres", password: "x", database: "postgres", }, - isLocal: connType === "local", - ref: opts.resolvedRef !== undefined ? Option.some(opts.resolvedRef) : Option.none(), - }); + isLocal, + ref: input.target.kind === "linked" ? Option.some(input.target.ref) : Option.none(), + target: input.target, + } satisfies ResolvedDbConnection); }, - resolveConnection: () => Effect.die("resolveConnection is not used by this test"), resolvePoolerFallback: (resolveFlags) => { poolerFallbackCalls.push(resolveFlags); return Effect.succeed( @@ -436,25 +449,21 @@ function setup(workdir: string, opts: SetupOpts = {}) { }, }); - // Mirrors the same ref `resolver`'s own mock embeds above, and gives an explicit - // `--project-ref` flag top precedence over `opts.resolvedRef` (mirrors - // `reset.integration.test.ts`'s identical mock). const projectRefResolver = Layer.succeed(ProjectRefResolver, { resolve: () => Effect.succeed(opts.resolvedRef ?? VALID_REF), resolveForLink: () => Effect.succeed(opts.resolvedRef ?? VALID_REF), - resolveRef: () => - Effect.succeed({ - ref: opts.resolvedRef ?? VALID_REF, - source: "file", - matchesLinkedRef: true, - }), - resolveOptional: () => Effect.succeed(Option.some(opts.resolvedRef ?? VALID_REF)), - loadProjectRef: (flagValue: Option.Option<string>) => + resolveRef: (flagValue: Option.Option<string>) => Option.isSome(flagValue) && flagValue.value.length > 0 - ? Effect.succeed(flagValue.value) + ? Effect.succeed({ ref: flagValue.value, source: "flag", matchesLinkedRef: false }) : opts.linkedFails === true ? Effect.fail(new ProjectRefNotLinkedError({ message: PROJECT_NOT_LINKED_MESSAGE })) - : Effect.succeed(opts.resolvedRef ?? VALID_REF), + : Effect.succeed({ + ref: opts.resolvedRef ?? VALID_REF, + source: "file", + matchesLinkedRef: true, + }), + resolveOptional: () => Effect.succeed(Option.some(opts.resolvedRef ?? VALID_REF)), + loadProjectRef: () => Effect.die("loadProjectRef is not used by db pull"), promptProjectRef: () => Effect.succeed(opts.resolvedRef ?? VALID_REF), }); @@ -473,11 +482,35 @@ function setup(workdir: string, opts: SetupOpts = {}) { ).pipe(Layer.provide(BunServices.layer)); const projectHttp = recordingProjectHttpClient(VALID_REF); - const baseLayer = Layer.mergeAll( + const engineLayer = Layer.mergeAll( unusedStackServices, // Listed first so the fake service layers below (`Layer.mergeAll` is last-wins) // override its real implementations, matching `start.integration.test.ts`. BunServices.layer, + pgDeltaEngine, + edge, + docker, + Layer.succeed(BundledPostgresClient, { + run: () => Effect.die("bundled postgres client unused"), + }), + dbConnection, + dockerDaemon?.layer ?? shadowSpawner.layer, + alwaysReadyHttpClientLayer, + resolver, + Layer.succeed(ExperimentalFlag, opts.experimental ?? false), + Layer.succeed(DebugFlag, false), + Layer.succeed(DnsResolverFlag, "native"), + Layer.succeed(NetworkIdFlag, Option.fromUndefinedOr(opts.networkId)), + Layer.succeed(PgDeltaSslProbe, { + requireSsl: () => Effect.succeed(false), + requireSslForHost: () => Effect.succeed(false), + }), + mockRuntimeInfo(opts.platform === undefined ? {} : { platform: opts.platform }), + workdirFiles, + shadowCacheDisabledLayer, + ); + const baseLayer = Layer.mergeAll( + engineLayer, out.layer, configValuesLayer({ output: out.layer, @@ -490,22 +523,15 @@ function setup(workdir: string, opts: SetupOpts = {}) { cliSettings: mockCommandSettings({ workdir }), }) : cache.layer, - pgDeltaEngine, - edge, - docker, - Layer.succeed(BundledPostgresClient, { - run: () => Effect.die("bundled postgres client unused"), - }), - dbConnection, - dockerDaemon?.layer ?? shadowSpawner.layer, - alwaysReadyHttpClientLayer, - opts.realLayers === true - ? realDbConfigResolver({ - workdir, - dbConnection, - env: { SUPABASE_ACCESS_TOKEN: VALID_TOKEN }, - }) - : resolver, + ...(opts.realLayers === true + ? [ + realDbConfigResolver({ + workdir, + dbConnection, + env: { SUPABASE_ACCESS_TOKEN: VALID_TOKEN }, + }), + ] + : []), projectRefResolver, mockCommandSettings({ workdir, @@ -518,21 +544,13 @@ function setup(workdir: string, opts: SetupOpts = {}) { opts.pipedAnswers ? `${opts.pipedAnswers.join("\n")}\n` : undefined, ), Layer.succeed(YesFlag, opts.yes ?? false), - Layer.succeed(ExperimentalFlag, opts.experimental ?? false), - Layer.succeed(DebugFlag, false), - Layer.succeed(DnsResolverFlag, "native"), - Layer.succeed(NetworkIdFlag, Option.fromUndefinedOr(opts.networkId)), - Layer.succeed(PgDeltaSslProbe, { - requireSsl: () => Effect.succeed(false), - requireSslForHost: () => Effect.succeed(false), - }), + Layer.succeed(OutputFlag, Option.none()), + Layer.succeed(TargetPolicy, { group: "sync", consent: "no" }), Layer.succeed(CliArgs, { args: opts.args ?? [] }), - mockRuntimeInfo(opts.platform === undefined ? {} : { platform: opts.platform }), - workdirFiles, - shadowCacheDisabledLayer, ); return { layer: baseLayer, + engineLayer, out, historyUpserts, execLog, @@ -562,8 +580,8 @@ const flags = (over: Partial<DbPullFlags> = {}): DbPullFlags => ({ strictCoverage: over.strictCoverage ?? false, schema: over.schema ?? [], dbUrl: over.dbUrl ?? Option.none(), - linked: over.linked ?? Option.none(), - local: over.local ?? Option.none(), + linked: over.linked ?? false, + local: over.local ?? false, projectRef: over.projectRef ?? Option.none(), password: over.password ?? Option.none(), }); @@ -585,6 +603,14 @@ const seedMigration = (workdir: string, version: string) => yield* fs.writeFileString(path.join(dir, `${version}_local.sql`), "create table local ();\n"); }); +const migrationFiles = (workdir: string) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const dir = path.join(workdir, "supabase", "migrations"); + return (yield* fs.exists(dir)) ? (yield* fs.readDirectory(dir)).sort() : []; + }); + const readFileText = (file: string) => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; @@ -680,16 +706,10 @@ describe("db pull", () => { const FLAG_REF = "flagflagflagflagflag"; const s = setup(tmp.current, {}); return Effect.gen(function* () { - const exit = yield* dbPull( - flags({ local: Option.some(true), projectRef: Option.some(FLAG_REF) }), - ).pipe(Effect.exit); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - const causeText = Cause.pretty(exit.cause); - expect(causeText).toContain( - "--project-ref only applies when targeting the linked project; use it with --linked (not --local or --db-url)", - ); - } + const error = yield* dbPull(flags({ local: true, projectRef: Option.some(FLAG_REF) })).pipe( + Effect.flip, + ); + expect(error).toBeInstanceOf(TargetProjectRefConflictError); expect(s.resolveCalls).toEqual([]); expect(s.cache.cached).toBe(false); }).pipe(Effect.provide(s.layer)); @@ -771,6 +791,7 @@ describe("db pull", () => { sql: "-- unit 3\n\ncreate index concurrently i on t (c);", }, ]), + yes: true, }); return Effect.gen(function* () { yield* dbPull(flags({ diffEngine: Option.some("pg-delta") })); @@ -860,11 +881,6 @@ describe("db pull", () => { expect(s.shadowSpawned.filter((call) => call.args[0] === "create")).toHaveLength(1); const err = streamText(s.out, "stderr"); expect(err).not.toContain("schema_paths no longer changes the migrations baseline"); - // Connecting must print before shadow creation. - expect(err).toContain("Connecting to remote database...\n"); - expect(err.indexOf("Connecting to remote database...")).toBeLessThan( - err.indexOf("Creating shadow database..."), - ); const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const dir = path.join(tmp.current, "supabase", "migrations"); @@ -981,10 +997,6 @@ describe("db pull", () => { expect(s.engineCalls[0]?.strictCoverage).toBe(true); expect(s.edgeRunCount).toBe(0); const err = streamText(s.out, "stderr"); - expect(err).toContain("Connecting to remote database...\n"); - expect(err.indexOf("Connecting to remote database...")).toBeLessThan( - err.indexOf("Preparing declarative schema export"), - ); const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; // Prints the relative default, not the resolved absolute directory @@ -1123,7 +1135,7 @@ describe("db pull", () => { projectId: Option.some("unrelated-env-project"), }); return Effect.gen(function* () { - yield* dbPull(flags({ declarative: Option.some(true), linked: Option.some(true) })); + yield* dbPull(flags({ declarative: Option.some(true), linked: true })); expect(s.engineCalls[0]?.projectId).toBe("abcdefghijklmnopqrst"); }).pipe(Effect.provide(s.layer)); }, @@ -1166,18 +1178,13 @@ describe("db pull", () => { expect(content).toContain("create table dumped ();"); expect(content).toContain("create table diffed ();"); expect(content.indexOf("dumped")).toBeLessThan(content.indexOf("diffed")); - // stderr order: connect → dump → shadow → diff → written. The Connecting - // line comes first. + // stderr order: dump → shadow → diff → written. const err = streamText(s.out, "stderr"); - expect(err).toContain("Connecting to remote database...\n"); expect(err).toContain("Dumping schema from remote database..."); expect(err).toContain("Creating shadow database..."); expect(err).toContain( `Schema written to ${path.join("supabase", "migrations", file ?? "")}\n`, ); - expect(err.indexOf("Connecting to remote database")).toBeLessThan( - err.indexOf("Dumping schema"), - ); expect(err.indexOf("Dumping schema")).toBeLessThan(err.indexOf("Creating shadow")); expect(s.historyUpserts.length).toBe(1); }).pipe(Effect.provide(s.layer)); @@ -1190,12 +1197,12 @@ describe("db pull", () => { remoteVersions: [], dumpStdout: "create table dumped ();\n", edgeStdout: "create table diffed ();\n", + yes: true, }); return Effect.gen(function* () { yield* dbPull(flags({ diffEngine: Option.some("migra") })); const success = s.out.messages.find((m) => m.type === "success"); - // Machine mode never prompts, so history updates by default (true); `schemaWritten` - // is the real native path (not null as when delegated). + // `schemaWritten` is the real native path (not null as when delegated). expect(success?.data).toMatchObject({ declarative: false, remoteHistoryUpdated: true, @@ -1253,7 +1260,12 @@ describe("db pull", () => { // A leftover zero-byte `<timestamp>_remote_schema.sql` seed file is a phantom local // migration with no remote counterpart; `--with-migration-history` can't clear it // since fetching empty remote history never deletes local files. - const s = setup(tmp.current, { remoteVersions: [], dumpStdout: "", edgeStdout: "" }); + const s = setup(tmp.current, { + remoteVersions: [], + dumpStdout: "", + edgeStdout: "", + yes: true, + }); return Effect.gen(function* () { const error = yield* dbPull(flags({ diffEngine: Option.some("migra") })).pipe(Effect.flip); expect(error.message).toBe("No schema changes found"); @@ -1267,7 +1279,12 @@ describe("db pull", () => { it.effect( "two consecutive initial pulls against an empty remote history both report 'No schema changes found' with no leftover seed file", () => { - const s = setup(tmp.current, { remoteVersions: [], dumpStdout: "", edgeStdout: "" }); + const s = setup(tmp.current, { + remoteVersions: [], + dumpStdout: "", + edgeStdout: "", + yes: true, + }); return Effect.gen(function* () { const path = yield* Path.Path; const dir = path.join(tmp.current, "supabase", "migrations"); @@ -1318,6 +1335,7 @@ describe("db pull", () => { remoteVersions: [], dumpExitCode: 1, dumpStderr: "connection refused", + yes: true, }); return Effect.gen(function* () { const error = yield* dbPull(flags({ diffEngine: Option.some("migra") })).pipe(Effect.flip); @@ -1356,6 +1374,7 @@ describe("db pull", () => { dumpExitCode: 1, dumpStderr: "could not translate host name: network is unreachable", poolerAvailable: false, + yes: true, }); return Effect.gen(function* () { const error = yield* dbPull(flags({ diffEngine: Option.some("migra") })).pipe(Effect.flip); @@ -1433,7 +1452,7 @@ describe("db pull", () => { }).pipe(Effect.provide(BunServices.layer)), ); - it.effect("prompts to update history and inserts on yes (tty)", () => { + it.effect("asks once before writing and records the migration on yes (tty)", () => { const s = setup(tmp.current, { migrations: ["20240101000000"], remoteVersions: ["20240101000000"], @@ -1443,162 +1462,162 @@ describe("db pull", () => { }); return Effect.gen(function* () { yield* dbPull(flags({ diffEngine: Option.some("migra") })); + expect(s.out.promptConfirmCalls).toHaveLength(1); + expect(s.out.promptConfirmCalls[0]?.message).toContain( + "Update the remote migration history table", + ); expect(s.historyUpserts.length).toBe(1); }).pipe(Effect.provide(s.layer)); }); - it.effect("declining the history prompt does not insert (tty)", () => { - const s = setup(tmp.current, { - migrations: ["20240101000000"], - remoteVersions: ["20240101000000"], - edgeStdout: "create table remote ();\n", - stdinIsTty: true, - promptConfirmResponses: [false], - }); - return Effect.gen(function* () { - yield* dbPull(flags({ diffEngine: Option.some("migra") })); - expect(s.historyUpserts.length).toBe(0); - }).pipe(Effect.provide(s.layer)); - }); - it.effect( - "a remote-history update failure after a successful write reports the written migration path (CLI-1272)", + "declining the history update aborts before any migration file is written (tty)", () => { - // The migration write succeeds; only the subsequent history-table write fails. - // The resulting `DbPullWriteError` must still carry the already-written path via - // `writtenSoFar`, not report the write as if nothing happened. const s = setup(tmp.current, { - remoteVersions: [], - dumpStdout: "", + migrations: ["20240101000000"], + remoteVersions: ["20240101000000"], edgeStdout: "create table remote ();\n", - yes: true, - historyUpdateFailWith: "connection reset by peer", + stdinIsTty: true, + promptConfirmResponses: [false], }); return Effect.gen(function* () { const error = yield* dbPull(flags({ diffEngine: Option.some("migra") })).pipe(Effect.flip); - expect(error).toMatchObject({ _tag: "DbPullWriteError" }); - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const dir = path.join(tmp.current, "supabase", "migrations"); - const file = (yield* fs.readDirectory(dir)).find((f) => f.endsWith("_remote_schema.sql")); - expect(file).toBeDefined(); - const writtenPath = path.join(dir, file ?? ""); - // Confirms the failure happened after the write, not instead of it. - expect(yield* fs.exists(writtenPath)).toBe(true); - expect(error).toMatchObject({ writtenSoFar: [writtenPath] }); + expect(error).toBeInstanceOf(HostedMutationDeclinedError); + expect(yield* migrationFiles(tmp.current)).toEqual(["20240101000000_local.sql"]); expect(s.historyUpserts.length).toBe(0); + expect(streamText(s.out, "stderr")).not.toContain("Schema written to"); }).pipe(Effect.provide(s.layer)); }, ); - it.effect("updates history on an empty non-interactive stdin", () => { - // Only falls back to the default (`true`) when the piped scan is empty/exhausted; - // a non-interactive pull without piped input therefore updates history. (The clack - // prompt would hang on a non-TTY; see `pull.live.test.ts` for that end-to-end proof.) + it.effect("an initial migra pull asks before the dump writes anything", () => { const s = setup(tmp.current, { - migrations: ["20240101000000"], - remoteVersions: ["20240101000000"], - edgeStdout: "create table remote ();\n", - stdinIsTty: false, + remoteVersions: [], + dumpStdout: "create table dumped ();\n", + edgeStdout: "create table diffed ();\n", + stdinIsTty: true, + promptConfirmResponses: [false], }); return Effect.gen(function* () { - yield* dbPull(flags({ diffEngine: Option.some("migra") })); - expect(s.historyUpserts.length).toBe(1); + const error = yield* dbPull(flags({ diffEngine: Option.some("migra") })).pipe(Effect.flip); + expect(error).toBeInstanceOf(HostedMutationDeclinedError); + expect(s.dumpCalls).toHaveLength(0); + expect(yield* migrationFiles(tmp.current)).toEqual([]); }).pipe(Effect.provide(s.layer)); }); - it.effect("declines the history update on a piped 'n' (non-tty)", () => { - // Piped stdin is scanned before defaulting, so a piped `n` cancels even on a - // non-terminal. + it.effect("a pg-delta pull lists the planned file count and writes nothing when declined", () => { const s = setup(tmp.current, { migrations: ["20240101000000"], remoteVersions: ["20240101000000"], - edgeStdout: "create table remote ();\n", - stdinIsTty: false, - pipedAnswers: ["n"], + edgeStdout: pgDeltaDiffEnvelope([ + { name: "schema_changes", sql: "alter type mood add value 'ok';" }, + { name: "after_enum_values", sql: "insert into t values ('ok');" }, + ]), + stdinIsTty: true, + promptConfirmResponses: [false], }); return Effect.gen(function* () { - yield* dbPull(flags({ diffEngine: Option.some("migra") })); - expect(s.historyUpserts.length).toBe(0); + const error = yield* dbPull(flags({ diffEngine: Option.some("pg-delta") })).pipe(Effect.flip); + expect(error).toBeInstanceOf(HostedMutationDeclinedError); expect(streamText(s.out, "stderr")).toContain( - "Update remote migration history table? [Y/n] n", + "Write 2 migration files to supabase/migrations.", ); + expect(yield* migrationFiles(tmp.current)).toEqual(["20240101000000_local.sql"]); + expect(s.historyUpserts.length).toBe(0); }).pipe(Effect.provide(s.layer)); }); - it.effect("emits a json envelope and suppresses 'Finished' in machine mode", () => { + it.effect("an in-sync pull asks for no consent", () => { const s = setup(tmp.current, { migrations: ["20240101000000"], - format: "json", remoteVersions: ["20240101000000"], - edgeStdout: "create table remote ();\n", - yes: true, + edgeStdout: "", + stdinIsTty: true, }); return Effect.gen(function* () { - yield* dbPull(flags({ diffEngine: Option.some("migra") })); - expect(streamText(s.out, "stdout")).not.toContain("Finished supabase db pull."); - // Diagnostics still go to stderr in machine mode; stdout stays payload-only. - expect(streamText(s.out, "stderr")).toContain("Connecting to remote database...\n"); - const success = s.out.messages.find((m) => m.type === "success"); - expect(success?.data).toMatchObject({ declarative: false, remoteHistoryUpdated: true }); + const error = yield* dbPull(flags({ diffEngine: Option.some("migra") })).pipe(Effect.flip); + expect(error).toMatchObject({ _tag: "DbPullInSyncError" }); + expect(s.out.promptConfirmCalls).toHaveLength(0); }).pipe(Effect.provide(s.layer)); }); - it.effect("auto-accepts the history update in non-tty mode without --yes", () => { + it.effect("a non-interactive run without --yes writes no migration file", () => { const s = setup(tmp.current, { migrations: ["20240101000000"], remoteVersions: ["20240101000000"], edgeStdout: "create table remote ();\n", stdinIsTty: false, - // no --yes: a non-interactive prompt falls back to the default (true). + pipedAnswers: ["y"], }); return Effect.gen(function* () { - yield* dbPull(flags({ diffEngine: Option.some("migra") })); - expect(s.historyUpserts.length).toBe(1); + const error = yield* dbPull(flags({ diffEngine: Option.some("migra") })).pipe(Effect.flip); + expect(error).toBeInstanceOf(HostedMutationConsentRequiredError); + expect(yield* migrationFiles(tmp.current)).toEqual(["20240101000000_local.sql"]); + expect(s.historyUpserts.length).toBe(0); }).pipe(Effect.provide(s.layer)); }); - it.effect("honors SUPABASE_YES for the initial-pull history update", () => { - // `SUPABASE_YES` auto-confirms even on a TTY with no piped answer, via the native - // path's `resolveYesWithProjectEnv`. + it.effect("a local target records the migration without asking", () => { const s = setup(tmp.current, { migrations: ["20240101000000"], remoteVersions: ["20240101000000"], edgeStdout: "create table remote ();\n", - // A TTY with no scripted prompt response: only SUPABASE_YES makes this pass. stdinIsTty: true, }); return Effect.gen(function* () { - yield* dbPull(flags({ diffEngine: Option.some("migra") })); + yield* dbPull(flags({ local: true, diffEngine: Option.some("migra") })); + expect(s.out.promptConfirmCalls).toHaveLength(0); expect(s.historyUpserts.length).toBe(1); - expect(streamText(s.out, "stderr")).toContain( - "Update remote migration history table? [Y/n] y", - ); - }).pipe(Effect.provide(s.layer), (body) => withConfigEnv({ SUPABASE_YES: "1" }, body)); + }).pipe(Effect.provide(s.layer)); }); - it.effect("honors SUPABASE_YES from supabase/.env for the initial-pull history update", () => { - // The project .env is loaded before the history prompt, so `SUPABASE_YES` there - // auto-confirms with no shell env or --yes (`resolveYesWithProjectEnv`). + it.effect( + "a remote-history update failure after a successful write reports the written migration path (CLI-1272)", + () => { + // The migration write succeeds; only the subsequent history-table write fails. + // The resulting `DbPullWriteError` must still carry the already-written path via + // `writtenSoFar`, not report the write as if nothing happened. + const s = setup(tmp.current, { + remoteVersions: [], + dumpStdout: "", + edgeStdout: "create table remote ();\n", + yes: true, + historyUpdateFailWith: "connection reset by peer", + }); + return Effect.gen(function* () { + const error = yield* dbPull(flags({ diffEngine: Option.some("migra") })).pipe(Effect.flip); + expect(error).toMatchObject({ _tag: "DbPullWriteError" }); + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const dir = path.join(tmp.current, "supabase", "migrations"); + const file = (yield* fs.readDirectory(dir)).find((f) => f.endsWith("_remote_schema.sql")); + expect(file).toBeDefined(); + const writtenPath = path.join(dir, file ?? ""); + // Confirms the failure happened after the write, not instead of it. + expect(yield* fs.exists(writtenPath)).toBe(true); + expect(error).toMatchObject({ writtenSoFar: [writtenPath] }); + expect(s.historyUpserts.length).toBe(0); + }).pipe(Effect.provide(s.layer)); + }, + ); + + it.effect("emits a json envelope and suppresses 'Finished' in machine mode", () => { const s = setup(tmp.current, { migrations: ["20240101000000"], - files: { - "supabase/.env": "SUPABASE_YES=true\n", - }, + format: "json", remoteVersions: ["20240101000000"], edgeStdout: "create table remote ();\n", - // Pipe `n` on a non-TTY: only honoring the .env SUPABASE_YES (which is read - // before stdin, so it wins over the piped decline) still updates history. - stdinIsTty: false, - pipedAnswers: ["n"], + yes: true, }); return Effect.gen(function* () { yield* dbPull(flags({ diffEngine: Option.some("migra") })); - expect(s.historyUpserts.length).toBe(1); - }).pipe(Effect.provide(s.layer), (body) => - // only the project .env value must apply - withEmptyConfigEnv(body), - ); + expect(streamText(s.out, "stdout")).not.toContain("Finished supabase db pull."); + // Diagnostics still go to stderr in machine mode; stdout stays payload-only. + expect(streamText(s.out, "stderr")).toContain("Schema written to"); + const success = s.out.messages.find((m) => m.type === "success"); + expect(success?.data).toMatchObject({ declarative: false, remoteHistoryUpdated: true }); + }).pipe(Effect.provide(s.layer)); }); it.effect( @@ -1666,7 +1685,7 @@ describe("db pull", () => { yes: true, }); return Effect.gen(function* () { - yield* dbPull(flags({ local: Option.some(true), diffEngine: Option.some("migra") })); + yield* dbPull(flags({ local: true, diffEngine: Option.some("migra") })); expect(s.dumpCalls[0]?.network).toEqual({ _tag: "named", name: "dotenv-net" }); expect(s.dumpCalls[0]?.env["PGHOST"]).toBe("host.docker.internal"); }).pipe(Effect.provide(s.layer), withEmptyConfigEnv); @@ -1682,52 +1701,11 @@ describe("db pull", () => { yes: true, }); return Effect.gen(function* () { - yield* dbPull(flags({ local: Option.some(true), diffEngine: Option.some("migra") })); + yield* dbPull(flags({ local: true, diffEngine: Option.some("migra") })); expect(s.dumpCalls[0]?.env["PGHOST"]).toBe("127.0.0.1"); }).pipe(Effect.provide(s.layer), withEmptyConfigEnv); }); - it.effect("an explicit --yes=false overrides SUPABASE_YES and honors the piped answer", () => { - // An explicit `--yes=false` wins over the SUPABASE_YES env — a piped `n` still - // declines the history update rather than auto-confirming. - const s = setup(tmp.current, { - migrations: ["20240101000000"], - remoteVersions: ["20240101000000"], - edgeStdout: "create table remote ();\n", - stdinIsTty: false, - pipedAnswers: ["n"], - args: ["db", "pull", "--yes=false"], - }); - return Effect.gen(function* () { - yield* dbPull(flags({ diffEngine: Option.some("migra") })); - expect(s.historyUpserts.length).toBe(0); - }).pipe(Effect.provide(s.layer), (body) => withConfigEnv({ SUPABASE_YES: "1" }, body)); - }); - - it.effect( - "a bare --password consumes the following token, so SUPABASE_YES still auto-confirms", - () => { - // `--password --yes=false` parses as `--password`'s value being the literal string - // "--yes=false" — `--yes` itself was never set — so SUPABASE_YES=1 must still - // auto-confirm rather than the scanner misreading an explicit `--yes=false`. - const s = setup(tmp.current, { - migrations: ["20240101000000"], - remoteVersions: ["20240101000000"], - edgeStdout: "create table remote ();\n", - // A TTY with no scripted prompt response: only SUPABASE_YES makes this pass. - stdinIsTty: true, - args: ["db", "pull", "--password", "--yes=false"], - }); - return Effect.gen(function* () { - yield* dbPull(flags({ diffEngine: Option.some("migra") })); - expect(s.historyUpserts.length).toBe(1); - expect(streamText(s.out, "stderr")).toContain( - "Update remote migration history table? [Y/n] y", - ); - }).pipe(Effect.provide(s.layer), (body) => withConfigEnv({ SUPABASE_YES: "1" }, body)); - }, - ); - it.effect("--experimental --declarative runs the declarative export without an error", () => { const s = setup(tmp.current, { experimental: true, @@ -1755,7 +1733,6 @@ describe("db pull", () => { }); return Effect.gen(function* () { yield* dbPull(flags({ diffEngine: Option.some("migra") })); - expect(streamText(s.out, "stderr")).toContain("Connecting to remote database...\n"); expect(s.historyUpserts.length).toBe(1); }).pipe(Effect.provide(s.layer), (body) => withConfigEnv({ SUPABASE_EXPERIMENTAL: "true" }, body), @@ -1842,7 +1819,7 @@ describe("db pull", () => { yes: true, }); return Effect.gen(function* () { - yield* dbPull(flags({ local: Option.some(true), diffEngine: Option.some("pg-delta") })); + yield* dbPull(flags({ local: true, diffEngine: Option.some("pg-delta") })); expect(s.connectedDatabases).not.toContain("contrib_regression"); expect(s.engineCalls[0]?.targetRef).toContain("@127.0.0.1:5432/postgres"); }).pipe(Effect.provide(s.layer)); @@ -1862,10 +1839,9 @@ describe("db pull", () => { yes: true, }); return Effect.gen(function* () { - yield* dbPull(flags({ local: Option.some(true), diffEngine: Option.some("migra") })); + yield* dbPull(flags({ local: true, diffEngine: Option.some("migra") })); expect(s.connectedDatabases).toContain("contrib_regression"); - // A local target prints the local wording (established output contract). - expect(streamText(s.out, "stderr")).toContain("Connecting to local database...\n"); + expect(s.resolveCalls[0]?.target).toEqual({ kind: "local" }); }).pipe(Effect.provide(s.layer)); }); @@ -1875,7 +1851,7 @@ describe("db pull", () => { remoteVersions: ["20240101000000"], }); return Effect.gen(function* () { - const exit = yield* dbPull(flags({ local: Option.some(true) })).pipe(Effect.exit); + const exit = yield* dbPull(flags({ local: true })).pipe(Effect.exit); expect(Exit.isFailure(exit)).toBe(true); const failure = Exit.isFailure(exit) ? Option.getOrUndefined(Cause.findErrorOption(exit.cause)) @@ -1935,23 +1911,19 @@ describe("db pull", () => { }, ); - it.effect("machine output in a TTY without --yes skips the prompt and emits the payload", () => { - // json/stream-json layers fail every prompt as non-interactive, so the - // history-update prompt must be skipped (default = yes) instead of failing before - // the payload emits. + it.effect("machine output without --yes refuses the history update and emits no payload", () => { const s = setup(tmp.current, { migrations: ["20240101000000"], format: "json", remoteVersions: ["20240101000000"], edgeStdout: "create table remote ();\n", stdinIsTty: true, - // no --yes }); return Effect.gen(function* () { - yield* dbPull(flags({ diffEngine: Option.some("migra") })); - expect(s.historyUpserts.length).toBe(1); - const success = s.out.messages.find((m) => m.type === "success"); - expect(success?.data).toMatchObject({ remoteHistoryUpdated: true }); + const error = yield* dbPull(flags({ diffEngine: Option.some("migra") })).pipe(Effect.flip); + expect(error).toBeInstanceOf(HostedMutationConsentRequiredError); + expect(s.historyUpserts.length).toBe(0); + expect(s.out.messages.find((m) => m.type === "success")).toBeUndefined(); }).pipe(Effect.provide(s.layer)); }); @@ -1981,7 +1953,7 @@ describe("db pull", () => { resolvedRef: "abcdefghijklmnopqrst", }); return Effect.gen(function* () { - yield* dbPull(flags({ linked: Option.some(true) })); + yield* dbPull(flags({ linked: true })); expect(s.engineCalls[0]?.operation).toBe("diff"); expect(streamText(s.out, "stderr")).toMatch( /Schema written to supabase[/\\]migrations[/\\]\d{14}_remote_schema\.sql\n/u, @@ -2003,7 +1975,7 @@ describe("db pull", () => { resolvedRef: "abcdefghijklmnopqrst", }); return Effect.gen(function* () { - const exit = yield* dbPull(flags({ linked: Option.some(true) })).pipe(Effect.exit); + const exit = yield* dbPull(flags({ linked: true })).pipe(Effect.exit); expect(Exit.isFailure(exit)).toBe(true); expect(s.cache.cached).toBe(true); expect(s.cache.cachedRef).toBe("abcdefghijklmnopqrst"); @@ -2038,7 +2010,7 @@ describe("db pull", () => { resolvedRef: "abcdefghijklmnopqrst", }); return Effect.gen(function* () { - yield* dbPull(flags({ linked: Option.some(true), diffEngine: Option.some("migra") })); + yield* dbPull(flags({ linked: true, diffEngine: Option.some("migra") })); const createArgs = s.shadowSpawned.find((c) => c.args[0] === "create")?.args ?? []; expect(createArgs).toContain("--tmpfs"); }).pipe(Effect.provide(s.layer)); @@ -2058,7 +2030,7 @@ describe("db pull", () => { poolerAvailable: true, }); return Effect.gen(function* () { - yield* dbPull(flags({ linked: Option.some(true), diffEngine: Option.some("pg-delta") })); + yield* dbPull(flags({ linked: true, diffEngine: Option.some("pg-delta") })); const err = streamText(s.out, "stderr"); expect(err).toContain("does not support IPv6"); expect(err).toContain("Retrying via the IPv4 connection pooler"); @@ -2083,7 +2055,7 @@ describe("db pull", () => { poolerAvailable: true, }); return Effect.gen(function* () { - yield* dbPull(flags({ linked: Option.some(true), declarative: Option.some(true) })); + yield* dbPull(flags({ linked: true, declarative: Option.some(true) })); expect(streamText(s.out, "stderr")).toContain("Retrying via the IPv4 connection pooler"); expect(s.engineCalls.filter((call) => call.operation === "export")).toHaveLength(2); const path = yield* Path.Path; @@ -2103,9 +2075,9 @@ describe("db pull", () => { poolerAvailable: false, }); return Effect.gen(function* () { - const exit = yield* dbPull( - flags({ linked: Option.some(true), diffEngine: Option.some("pg-delta") }), - ).pipe(Effect.exit); + const exit = yield* dbPull(flags({ linked: true, diffEngine: Option.some("pg-delta") })).pipe( + Effect.exit, + ); expect(Exit.isFailure(exit)).toBe(true); expect(streamText(s.out, "stderr")).not.toContain("Retrying via the IPv4 connection pooler"); expect(s.engineCalls.filter((call) => call.operation === "diff")).toHaveLength(1); @@ -2121,9 +2093,9 @@ describe("db pull", () => { poolerAvailable: true, }); return Effect.gen(function* () { - const exit = yield* dbPull( - flags({ linked: Option.some(true), diffEngine: Option.some("pg-delta") }), - ).pipe(Effect.exit); + const exit = yield* dbPull(flags({ linked: true, diffEngine: Option.some("pg-delta") })).pipe( + Effect.exit, + ); expect(Exit.isFailure(exit)).toBe(true); expect(s.poolerFallbackCalls).toHaveLength(0); expect(s.engineCalls.filter((call) => call.operation === "diff")).toHaveLength(1); @@ -2260,7 +2232,7 @@ describe("db pull permissions through the real resolver and cache", () => { yield* dbPull( flags({ declarative: Option.some(true), - linked: Option.some(true), + linked: true, projectRef: Option.some(VALID_REF), password: Option.some("secret"), }), @@ -2297,7 +2269,7 @@ describe("db pull permissions through the real resolver and cache", () => { return Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; yield* stubManagementApi(requests, () => undefined); - yield* dbPull(flags({ declarative: Option.some(true), local: Option.some(true) })).pipe( + yield* dbPull(flags({ declarative: Option.some(true), local: true })).pipe( Effect.provide(s.layer), ); expect(s.engineCalls.map((call) => call.operation)).toEqual(["export"]); @@ -2354,3 +2326,25 @@ describe("db pull permissions through the real resolver and cache", () => { ); }); }); + +describeHostedConsent("db pull", { + setup: (workdir) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* fs.makeDirectory(`${workdir}/supabase/.temp`, { recursive: true }); + yield* fs.writeFileString(`${workdir}/supabase/.temp/project-ref`, VALID_REF); + }), + invoke: ({ workdir, out }) => { + const s = setup(workdir, { + migrations: ["20240101000000"], + remoteVersions: ["20240101000000"], + edgeStdout: "create table remote ();\n", + }); + return { + run: dbPull(flags({ diffEngine: Option.some("migra") })).pipe( + Effect.provide(Layer.mergeAll(s.engineLayer, configValuesLayer({ output: out.layer }))), + ), + mutations: () => s.historyUpserts.length, + }; + }, +}); diff --git a/apps/cli/src/commands/db/pull/pull.layers.ts b/apps/cli/src/commands/db/pull/pull.layers.ts index c2145f6b56..b482763533 100644 --- a/apps/cli/src/commands/db/pull/pull.layers.ts +++ b/apps/cli/src/commands/db/pull/pull.layers.ts @@ -4,7 +4,6 @@ import { commandRuntimeLayer } from "../../../shared/runtime/command-runtime.lay import { identityStitchLayer } from "../../../command-internal/identity-stitch.ts"; import { linkedDbResolverRuntimeLayer } from "../../../command-internal/management-api-runtime.layer.ts"; import { telemetryStateLayer } from "../../../telemetry/telemetry-state.layer.ts"; -import { stdinLayer } from "../../../shared/runtime/stdin.layer.ts"; import { migraRuntimeLayer, pgDeltaCommandRuntimeLayer, @@ -20,7 +19,6 @@ const dbSchemaPullRuntimeLayer = (command: ReadonlyArray<string>) => telemetryStateLayer, linkedDbResolverRuntimeLayer(command).pipe(Layer.provide(identityStitchLayer)), commandRuntimeLayer(command), - stdinLayer, ); export const dbPullRuntimeLayer = dbSchemaPullRuntimeLayer(["db", "pull"]); From ed548e27ff649ee3f489da18eef283645319c092 Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Thu, 15 Oct 2026 14:39:12 +0100 Subject: [PATCH 46/92] test(cli): cover db query resolved targets, read-only and hosted consent --- .../db/query/query.integration.test.ts | 477 +++++++++++++----- .../src/commands/db/query/query.live.test.ts | 23 + 2 files changed, 373 insertions(+), 127 deletions(-) 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 edb6ecb952..0ff37a17cc 100644 --- a/apps/cli/src/commands/db/query/query.integration.test.ts +++ b/apps/cli/src/commands/db/query/query.integration.test.ts @@ -27,13 +27,22 @@ vi.mock("node:net", (importOriginal) => import { VALID_TOKEN, + withConfigEnv, mockCommandSettings, mockLinkedProjectCacheTracked, mockTelemetryStateTracked, useTempWorkdir, } from "../../../../tests/helpers/command-mocks.ts"; -import { mockOutput } from "../../../../tests/helpers/mocks.ts"; -import { AgentFlag, DnsResolverFlag, OutputFlag } from "../../../command-internal/global-flags.ts"; +import { cliConfigValuesTestLayer } from "../../../../tests/helpers/config-values-layer.ts"; +import { describeHostedConsent } from "../../../../tests/helpers/hosted-consent.ts"; +import { mockOutput, mockTty } from "../../../../tests/helpers/mocks.ts"; +import { CliArgs } from "../../../shared/cli/cli-args.service.ts"; +import { + AgentFlag, + DnsResolverFlag, + OutputFlag, + YesFlag, +} from "../../../command-internal/global-flags.ts"; import { Random } from "../../../shared/runtime/random.service.ts"; import { Stdin } from "../../../shared/runtime/stdin.service.ts"; import { AiTool } from "../../../shared/telemetry/ai-tool.service.ts"; @@ -48,6 +57,16 @@ 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"; +import type { + DbConnectionInput, + ResolvedDbConnection, +} from "../../../command-internal/db-config.types.ts"; +import { HostedMutationConsentRequiredError } from "../../../command-internal/hosted-consent/hosted-consent.errors.ts"; +import { TargetPolicy } from "../../../command-internal/target/target-policy.ts"; +import { + TargetProjectRefConflictError, + TargetSelectorConflictError, +} from "../../../command-internal/target/target.errors.ts"; import { DbExecError } from "../../../command-internal/db-connection.errors.ts"; import { DbConnection, @@ -80,28 +99,37 @@ const decodeJson = Schema.decodeEffect(Schema.fromJsonString(Schema.Unknown)); const failMessage = (exit: Exit.Exit<unknown, { readonly message: string }>): string | undefined => Exit.isFailure(exit) ? exit.cause.reasons.find(Cause.isFailReason)?.error.message : undefined; -function mockResolver(isLocal = true, resolveFails = false) { +function mockResolver(isLocal: boolean | undefined, resolveFails = false) { return Layer.succeed(DbConfigResolver, { - resolve: () => + resolve: () => Effect.die("resolve is not used by this test"), + resolveConnection: (input: DbConnectionInput) => resolveFails ? Effect.fail( new DbConfigParseUrlError({ message: "failed to parse connection string: invalid dsn", }), ) - : Effect.succeed({ conn: LOCAL_CONN, isLocal }), - resolveConnection: () => Effect.die("resolveConnection is not used by this test"), + : Effect.succeed({ + conn: LOCAL_CONN, + isLocal: isLocal ?? input.target.kind === "local", + ref: input.target.kind === "linked" ? Option.some(input.target.ref) : Option.none(), + target: input.target, + } satisfies ResolvedDbConnection), resolvePoolerFallback: () => Effect.succeed(Option.none()), }); } +const WRITE_STATEMENT = /^\s*(insert|update|delete|create|drop|alter)\b/iu; + function mockDbConnection(opts: { result?: QueryResult; rlsTables?: ReadonlyArray<string>; rlsFails?: boolean; queryFails?: boolean; }) { - return Layer.succeed(DbConnection, { + const statements: Array<string> = []; + let inReadOnly = false; + const layer = Layer.succeed(DbConnection, { connect: () => Effect.succeed({ exec: () => Effect.void, @@ -109,22 +137,45 @@ function mockDbConnection(opts: { query: () => Effect.succeed([]), extensionExists: () => Effect.succeed(false), copyToCsv: () => Effect.succeed(new Uint8Array()), - queryRaw: (sql: string) => { - if (sql === RLS_CHECK_SQL) { - return opts.rlsFails === true - ? Effect.fail(new DbExecError({ message: "advisory failed" })) - : Effect.succeed({ - fields: ["format"], - rows: (opts.rlsTables ?? []).map((table) => [table]), - commandTag: `SELECT ${(opts.rlsTables ?? []).length}`, - }); - } - return opts.queryFails === true - ? Effect.fail(new DbExecError({ message: "failed to execute query: boom" })) - : Effect.succeed(opts.result ?? { fields: [], rows: [], commandTag: "CREATE TABLE" }); - }, + queryRaw: (sql: string) => + Effect.suspend(() => { + if (sql === RLS_CHECK_SQL) { + return opts.rlsFails === true + ? Effect.fail(new DbExecError({ message: "advisory failed" })) + : Effect.succeed({ + fields: ["format"], + rows: (opts.rlsTables ?? []).map((table) => [table]), + commandTag: `SELECT ${(opts.rlsTables ?? []).length}`, + }); + } + statements.push(sql); + if (sql === "BEGIN READ ONLY") { + inReadOnly = true; + return Effect.succeed({ fields: [], rows: [], commandTag: "BEGIN" }); + } + if (sql === "ROLLBACK") { + inReadOnly = false; + return Effect.succeed({ fields: [], rows: [], commandTag: "ROLLBACK" }); + } + if (inReadOnly && WRITE_STATEMENT.test(sql)) { + return Effect.fail( + new DbExecError({ message: "cannot execute INSERT in a read-only transaction" }), + ); + } + return opts.queryFails === true + ? Effect.fail(new DbExecError({ message: "failed to execute query: boom" })) + : Effect.succeed( + opts.result ?? { fields: [], rows: [], commandTag: "CREATE TABLE" }, + ); + }), }), }); + return { + layer, + get statements() { + return statements; + }, + }; } function mockTelemetryOutputFormat() { @@ -144,11 +195,11 @@ function mockTelemetryOutputFormat() { } function mockProjectRef(unlinked = false, refReadFails = false) { - // `loadProjectRef` mirrors the real preflight: an explicit `--project-ref` flag wins first, - // then ErrNotLinked when absent, or a read error (ProjectRefReadError) for an unreadable file. - const loadProjectRef = (flagValue: Option.Option<string>) => + // An explicit `--project-ref` wins first, then ProjectRefNotLinkedError when unlinked, or a + // read error for an unreadable ref file. + const resolveRef = (flagValue: Option.Option<string>) => Option.isSome(flagValue) && flagValue.value.length > 0 - ? Effect.succeed(flagValue.value) + ? Effect.succeed({ ref: flagValue.value, source: "flag" as const, matchesLinkedRef: false }) : refReadFails ? Effect.fail( new ProjectRefReadError({ @@ -157,13 +208,13 @@ function mockProjectRef(unlinked = false, refReadFails = false) { ) : unlinked ? Effect.fail(new ProjectRefNotLinkedError({ message: PROJECT_NOT_LINKED_MESSAGE })) - : Effect.succeed(REF); + : Effect.succeed({ ref: REF, source: "file" as const, matchesLinkedRef: true }); return Layer.succeed(ProjectRefResolver, { - resolve: () => Effect.succeed(REF), + resolve: () => Effect.die("resolve is not used by this test"), resolveForLink: () => Effect.succeed(REF), - resolveRef: () => Effect.succeed({ ref: REF, source: "file", matchesLinkedRef: true }), + resolveRef, resolveOptional: () => Effect.succeed(unlinked ? Option.none() : Option.some(REF)), - loadProjectRef, + loadProjectRef: () => Effect.die("loadProjectRef is not used by this test"), promptProjectRef: () => Effect.succeed(REF), }); } @@ -203,11 +254,15 @@ function mockHttpClient(opts: { }) { const requests: Array<string> = []; const calls: Array<{ method: string; url: string }> = []; + const bodies: Array<unknown> = []; const layer = Layer.succeed( HttpClient.HttpClient, HttpClient.make((request) => { requests.push(request.url); calls.push({ method: request.method, url: request.url }); + if (request.body._tag === "Uint8Array") { + bodies.push(JSON.parse(new TextDecoder().decode(request.body.body))); + } const routed = opts.route?.({ method: request.method, url: request.url }); if (routed !== undefined) { return Effect.succeed( @@ -245,9 +300,25 @@ function mockHttpClient(opts: { get calls() { return calls; }, + get bodies() { + return bodies; + }, }; } +// `invalid` exercises the real `sbp_` token validator via `getAccessToken`. +function mockCredentials(invalid = false, accessToken?: Option.Option<Redacted.Redacted<string>>) { + return Layer.succeed(CommandCredentials, { + getAccessToken: invalid + ? validateAccessToken("not_sbp").pipe(Effect.map((t) => Option.some(Redacted.make(t)))) + : Effect.succeed(accessToken ?? Option.some(Redacted.make(VALID_TOKEN))), + saveAccessToken: () => Effect.die("unexpected credentials write in test"), + deleteAccessToken: Effect.die("unexpected credentials delete in test"), + deleteAllProjectCredentials: Effect.die("unexpected project-credential sweep in test"), + deleteProjectCredential: () => Effect.die("unexpected project-credential delete in test"), + }); +} + interface SetupOpts { format?: "text" | "json" | "stream-json"; isLocal?: boolean; @@ -273,10 +344,19 @@ interface SetupOpts { realCache?: boolean; /** Runs the real `DbConfigResolver` against the temp `workdir`; the test serves its Management API calls. */ realDbConfig?: boolean; + /** `--yes`; on by default so hosted runs reach the query. */ + yes?: boolean; + interactive?: boolean; + confirm?: ReadonlyArray<boolean>; } function setup(opts: SetupOpts = {}) { - const out = mockOutput({ format: opts.format ?? "text" }); + const out = mockOutput({ + format: opts.format ?? "text", + interactive: opts.interactive, + promptConfirmResponses: opts.confirm, + }); + const dbConnection = mockDbConnection(opts); const telemetry = mockTelemetryStateTracked(); const cache = mockLinkedProjectCacheTracked(); const telemetryOutputFormat = mockTelemetryOutputFormat(); @@ -312,12 +392,17 @@ function setup(opts: SetupOpts = {}) { opts.realDbConfig === true ? realDbConfigResolver({ workdir: opts.workdir ?? "/work/project", - dbConnection: mockDbConnection(opts), + dbConnection: dbConnection.layer, env: { SUPABASE_ACCESS_TOKEN: VALID_TOKEN }, }) : mockResolver(opts.isLocal, opts.resolveFails), - mockDbConnection(opts), + dbConnection.layer, mockProjectRef(opts.unlinked, opts.refReadFails), + cliConfigValuesTestLayer, + Layer.succeed(TargetPolicy, { group: "selectable", consent: "no" }), + Layer.succeed(YesFlag, opts.yes ?? true), + Layer.succeed(CliArgs, { args: [] }), + mockTty({ stdinIsTty: opts.interactive ?? false }), mockStdin({ isTTY: opts.stdinTTY, piped: opts.piped }), Layer.succeed(Random, { randomHex: () => Effect.succeed(BOUNDARY) }), Layer.succeed(AiTool, { @@ -333,30 +418,21 @@ function setup(opts: SetupOpts = {}) { workdir: opts.workdir ?? "/work/project", accessToken: opts.accessToken, }), - // `accessTokenInvalid` exercises the real `sbp_` token validator via `getAccessToken`. - Layer.succeed(CommandCredentials, { - getAccessToken: - opts.accessTokenInvalid === true - ? validateAccessToken("not_sbp").pipe(Effect.map((t) => Option.some(Redacted.make(t)))) - : Effect.succeed(opts.accessToken ?? Option.some(Redacted.make(VALID_TOKEN))), - saveAccessToken: () => Effect.die("unexpected credentials write in test"), - deleteAccessToken: Effect.die("unexpected credentials delete in test"), - deleteAllProjectCredentials: Effect.die("unexpected project-credential sweep in test"), - deleteProjectCredential: () => Effect.die("unexpected project-credential delete in test"), - }), + mockCredentials(opts.accessTokenInvalid === true, opts.accessToken), httpClient.layer, BunServices.layer, ); - return { layer, out, telemetry, cache, telemetryOutputFormat, httpClient }; + return { layer, out, telemetry, cache, telemetryOutputFormat, httpClient, dbConnection }; } const flags = (over: Partial<DbQueryFlags> = {}): DbQueryFlags => ({ sql: over.sql ?? Option.none(), dbUrl: over.dbUrl ?? Option.none(), - linked: over.linked ?? Option.none(), - local: over.local ?? Option.none(), + linked: over.linked ?? false, + local: over.local ?? false, projectRef: over.projectRef ?? Option.none(), file: over.file ?? Option.none(), + readOnly: over.readOnly ?? false, }); const SELECT_RESULT: QueryResult = { @@ -374,8 +450,7 @@ describe("db query integration", () => { it.effect("runs SQL passed as a positional argument and renders a table for humans", () => { const { layer, out, cache } = setup({ result: SELECT_RESULT }); return Effect.gen(function* () { - yield* dbQuery(flags({ sql: Option.some("select * from users"), local: Option.some(true) })); - expect(out.stderrText).toContain("Connecting to local database..."); + yield* dbQuery(flags({ sql: Option.some("select * from users"), local: true })); expect(out.stdoutText).toContain("│ id │ name │"); expect(out.stdoutText).toContain("│ 1 │ alice │"); expect(cache.cached).toBe(false); @@ -393,23 +468,42 @@ describe("db query integration", () => { }, }); return Effect.gen(function* () { - yield* dbQuery(flags({ sql: Option.some("select 1"), local: Option.some(true) })); + yield* dbQuery(flags({ sql: Option.some("select 1"), local: true })); expect(out.stdoutText).toContain("│ 1000000 │ 1e+06 │"); }).pipe(Effect.provide(layer)); }); - it.effect("reports connecting to the remote database for a --db-url target", () => { - const { layer, out } = setup({ result: SELECT_RESULT, isLocal: false }); + it.effect("asks with a preview of the SQL before running it on a hosted --db-url target", () => { + const { layer, out, dbConnection } = setup({ + result: SELECT_RESULT, + isLocal: false, + yes: false, + interactive: true, + confirm: [true], + }); return Effect.gen(function* () { - yield* dbQuery(flags({ sql: Option.some("select 1"), dbUrl: Option.some("postgres://x/y") })); - expect(out.stderrText).toContain("Connecting to remote database..."); + yield* dbQuery( + flags({ sql: Option.some("delete from\n users"), dbUrl: Option.some("postgres://x/y") }), + ); + expect(out.stderrText).toContain("sql: delete from users"); + expect(out.promptConfirmCalls).toHaveLength(1); + expect(dbConnection.statements).toEqual(["delete from\n users"]); + }).pipe(Effect.provide(layer)); + }); + + it.effect("runs a local query without asking for consent", () => { + const { layer, out } = setup({ result: SELECT_RESULT, yes: false }); + return Effect.gen(function* () { + yield* dbQuery(flags({ sql: Option.some("delete from users"), local: true })); + expect(out.promptConfirmCalls).toEqual([]); + expect(out.stdoutText).toContain("alice"); }).pipe(Effect.provide(layer)); }); it.effect("errors when no SQL is provided on a TTY", () => { const { layer } = setup({ stdinTTY: true }); return Effect.gen(function* () { - const exit = yield* dbQuery(flags({ local: Option.some(true) })).pipe(Effect.exit); + const exit = yield* dbQuery(flags({ local: true })).pipe(Effect.exit); expect(failMessage(exit)).toBe( "no SQL query provided. Pass SQL as an argument, via --file, or pipe to stdin", ); @@ -419,7 +513,7 @@ describe("db query integration", () => { it.effect("reads SQL piped via stdin", () => { const { layer, out } = setup({ result: SELECT_RESULT, stdinTTY: false, piped: "select 1\n" }); return Effect.gen(function* () { - yield* dbQuery(flags({ local: Option.some(true) })); + yield* dbQuery(flags({ local: true })); expect(out.stdoutText).toContain("alice"); }).pipe(Effect.provide(layer)); }); @@ -432,7 +526,7 @@ describe("db query integration", () => { const dir = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-query-" }); const filePath = path.join(dir, "q.sql"); yield* fs.writeFileString(filePath, "select * from users"); - yield* dbQuery(flags({ local: Option.some(true), file: Option.some(filePath) })); + yield* dbQuery(flags({ local: true, file: Option.some(filePath) })); expect(out.stdoutText).toContain("alice"); }).pipe(Effect.scoped, Effect.provide(layer)); }); @@ -444,7 +538,7 @@ describe("db query integration", () => { const dir = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-query-wd-" }); yield* fs.writeFileString(path.join(dir, "q.sql"), "select * from users"); const { layer, out } = setup({ result: SELECT_RESULT, workdir: dir }); - yield* dbQuery(flags({ local: Option.some(true), file: Option.some("q.sql") })).pipe( + yield* dbQuery(flags({ local: true, file: Option.some("q.sql") })).pipe( Effect.provide(layer), ); expect(out.stdoutText).toContain("alice"); @@ -455,7 +549,7 @@ describe("db query integration", () => { const { layer } = setup(); return Effect.gen(function* () { const exit = yield* dbQuery( - flags({ local: Option.some(true), file: Option.some("/no/such/file.sql") }), + flags({ local: true, file: Option.some("/no/such/file.sql") }), ).pipe(Effect.exit); expect(failMessage(exit)).toContain("failed to read SQL file"); }).pipe(Effect.provide(layer)); @@ -464,7 +558,7 @@ describe("db query integration", () => { it.effect("errors on empty stdin", () => { const { layer } = setup({ stdinTTY: false, piped: " " }); return Effect.gen(function* () { - const exit = yield* dbQuery(flags({ local: Option.some(true) })).pipe(Effect.exit); + const exit = yield* dbQuery(flags({ local: true })).pipe(Effect.exit); expect(failMessage(exit)).toBe("no SQL provided via stdin"); }).pipe(Effect.provide(layer)); }); @@ -472,7 +566,7 @@ describe("db query integration", () => { it.effect("prints the command tag for DDL with no result columns", () => { const { layer, out } = setup({ result: { fields: [], rows: [], commandTag: "CREATE TABLE" } }); return Effect.gen(function* () { - yield* dbQuery(flags({ sql: Option.some("create table t()"), local: Option.some(true) })); + yield* dbQuery(flags({ sql: Option.some("create table t()"), local: true })); expect(out.stdoutText).toBe("CREATE TABLE\n"); }).pipe(Effect.provide(layer)); }); @@ -480,7 +574,7 @@ describe("db query integration", () => { it.effect("renders JSON for agents by default with the untrusted-data envelope", () => { const { layer, out } = setup({ result: SELECT_RESULT, agent: "yes" }); return Effect.gen(function* () { - yield* dbQuery(flags({ sql: Option.some("select 1"), local: Option.some(true) })); + yield* dbQuery(flags({ sql: Option.some("select 1"), local: true })); const parsed = yield* decodeJson(out.stdoutText); expect(parsed).toHaveProperty("boundary", BOUNDARY); expect(parsed).toHaveProperty("rows", [ @@ -494,7 +588,7 @@ describe("db query integration", () => { it.effect("auto-detects an agent from AiTool and defaults to JSON", () => { const { layer, out } = setup({ result: SELECT_RESULT, agent: "auto", aiTool: "cursor" }); return Effect.gen(function* () { - yield* dbQuery(flags({ sql: Option.some("select 1"), local: Option.some(true) })); + yield* dbQuery(flags({ sql: Option.some("select 1"), local: true })); expect(yield* decodeJson(out.stdoutText)).toHaveProperty("boundary", BOUNDARY); }).pipe(Effect.provide(layer)); }); @@ -502,7 +596,7 @@ describe("db query integration", () => { it.effect("renders plain JSON (no envelope) for a human with -o json", () => { const { layer, out } = setup({ result: SELECT_RESULT, agent: "no", outputFlag: "json" }); return Effect.gen(function* () { - yield* dbQuery(flags({ sql: Option.some("select 1"), local: Option.some(true) })); + yield* dbQuery(flags({ sql: Option.some("select 1"), local: true })); const parsed = yield* decodeJson(out.stdoutText); expect(Array.isArray(parsed)).toBe(true); expect(parsed).toEqual([ @@ -515,7 +609,7 @@ describe("db query integration", () => { it.effect("renders plain JSON for --output-format json with --agent no", () => { const { layer, out } = setup({ result: SELECT_RESULT, agent: "no", format: "json" }); return Effect.gen(function* () { - yield* dbQuery(flags({ sql: Option.some("select 1"), local: Option.some(true) })); + yield* dbQuery(flags({ sql: Option.some("select 1"), local: true })); expect(yield* decodeJson(out.stdoutText)).toEqual([ { id: 1, name: "alice" }, { id: 2, name: "bob" }, @@ -526,7 +620,7 @@ describe("db query integration", () => { it.effect("emits a result event for --output-format stream-json with --agent no", () => { const { layer, out } = setup({ result: SELECT_RESULT, agent: "no", format: "stream-json" }); return Effect.gen(function* () { - yield* dbQuery(flags({ sql: Option.some("select 1"), local: Option.some(true) })); + yield* dbQuery(flags({ sql: Option.some("select 1"), local: true })); expect(out.stdoutText.trimEnd().split("\n")).toHaveLength(1); expect(yield* decodeJson(out.stdoutText)).toEqual( expect.objectContaining({ @@ -555,7 +649,7 @@ describe("db query integration", () => { format: "stream-json", }); return Effect.gen(function* () { - yield* dbQuery(flags({ sql: Option.some("select 1"), local: Option.some(true) })); + yield* dbQuery(flags({ sql: Option.some("select 1"), local: true })); expect(out.stdoutText.trimEnd().split("\n")).toHaveLength(1); expect(out.stdoutText).toContain('"n": 9223372036854775807'); }).pipe(Effect.provide(layer)); @@ -569,7 +663,7 @@ describe("db query integration", () => { outputFlag: "pretty", }); return Effect.gen(function* () { - yield* dbQuery(flags({ sql: Option.some("select 1"), local: Option.some(true) })); + yield* dbQuery(flags({ sql: Option.some("select 1"), local: true })); expect(out.stdoutText).toContain("│ id │ name │"); expect(out.messages.find((message) => message.type === "success")).toBeUndefined(); }).pipe(Effect.provide(layer)); @@ -583,7 +677,7 @@ describe("db query integration", () => { }); return Effect.gen(function* () { const exit = yield* dbQuery( - flags({ sql: Option.some("select 'NaN'::float8"), local: Option.some(true) }), + flags({ sql: Option.some("select 'NaN'::float8"), local: true }), ).pipe(Effect.exit); expect(Exit.isFailure(exit)).toBe(true); expect(failMessage(exit)).toContain("json: unsupported value: NaN"); @@ -596,15 +690,15 @@ describe("db query integration", () => { const agent = setup({ result: SELECT_RESULT, agent: "yes" }); const csv = setup({ result: SELECT_RESULT, agent: "no", outputFlag: "csv" }); return Effect.gen(function* () { - yield* dbQuery(flags({ sql: Option.some("select 1"), local: Option.some(true) })).pipe( + yield* dbQuery(flags({ sql: Option.some("select 1"), local: true })).pipe( Effect.provide(human.layer), ); expect(human.telemetryOutputFormat.format).toBe("table"); - yield* dbQuery(flags({ sql: Option.some("select 1"), local: Option.some(true) })).pipe( + yield* dbQuery(flags({ sql: Option.some("select 1"), local: true })).pipe( Effect.provide(agent.layer), ); expect(agent.telemetryOutputFormat.format).toBe("json"); - yield* dbQuery(flags({ sql: Option.some("select 1"), local: Option.some(true) })).pipe( + yield* dbQuery(flags({ sql: Option.some("select 1"), local: true })).pipe( Effect.provide(csv.layer), ); expect(csv.telemetryOutputFormat.format).toBe("csv"); @@ -614,7 +708,7 @@ describe("db query integration", () => { it.effect("renders CSV with -o csv", () => { const { layer, out } = setup({ result: SELECT_RESULT, agent: "no", outputFlag: "csv" }); return Effect.gen(function* () { - yield* dbQuery(flags({ sql: Option.some("select 1"), local: Option.some(true) })); + yield* dbQuery(flags({ sql: Option.some("select 1"), local: true })); expect(out.stdoutText).toBe("id,name\n1,alice\n2,bob\n"); }).pipe(Effect.provide(layer)); }); @@ -622,7 +716,7 @@ describe("db query integration", () => { it.effect("honors an explicit -o table over the agent JSON default", () => { const { layer, out } = setup({ result: SELECT_RESULT, agent: "yes", outputFlag: "table" }); return Effect.gen(function* () { - yield* dbQuery(flags({ sql: Option.some("select 1"), local: Option.some(true) })); + yield* dbQuery(flags({ sql: Option.some("select 1"), local: true })); expect(out.stdoutText).toContain("│ id │ name │"); expect(out.stdoutText).not.toContain("boundary"); }).pipe(Effect.provide(layer)); @@ -631,7 +725,7 @@ describe("db query integration", () => { it.effect("honors an explicit -o csv over the agent JSON default", () => { const { layer, out } = setup({ result: SELECT_RESULT, agent: "yes", outputFlag: "csv" }); return Effect.gen(function* () { - yield* dbQuery(flags({ sql: Option.some("select 1"), local: Option.some(true) })); + yield* dbQuery(flags({ sql: Option.some("select 1"), local: true })); expect(out.stdoutText).toBe("id,name\n1,alice\n2,bob\n"); }).pipe(Effect.provide(layer)); }); @@ -643,7 +737,7 @@ describe("db query integration", () => { rlsTables: ["public.users"], }); return Effect.gen(function* () { - yield* dbQuery(flags({ sql: Option.some("select 1"), local: Option.some(true) })); + yield* dbQuery(flags({ sql: Option.some("select 1"), local: true })); expect(yield* decodeJson(out.stdoutText)).toHaveProperty(["advisory", "id"], "rls_disabled"); }).pipe(Effect.provide(layer)); }); @@ -651,7 +745,7 @@ describe("db query integration", () => { it.effect("omits the advisory when the RLS check fails", () => { const { layer, out } = setup({ result: SELECT_RESULT, agent: "yes", rlsFails: true }); return Effect.gen(function* () { - yield* dbQuery(flags({ sql: Option.some("select 1"), local: Option.some(true) })); + yield* dbQuery(flags({ sql: Option.some("select 1"), local: true })); expect(yield* decodeJson(out.stdoutText)).not.toHaveProperty("advisory"); }).pipe(Effect.provide(layer)); }); @@ -671,44 +765,33 @@ describe("db query integration", () => { const { layer } = setup({ queryFails: true }); return Effect.gen(function* () { const exit = yield* dbQuery( - flags({ sql: Option.some("bad"), local: Option.some(true) }), + flags({ sql: Option.some("bad"), local: true }), ).pipe(Effect.exit); expect(failMessage(exit)).toContain("failed to execute query"); }).pipe(Effect.provide(layer)); }); it.effect("rejects conflicting targets (--linked --local) before running any SQL", () => { - const { layer, cache } = setup(); + const { layer, cache, dbConnection } = setup(); return Effect.gen(function* () { const exit = yield* dbQuery( - flags({ - sql: Option.some("select 1"), - linked: Option.some(true), - local: Option.some(true), - }), + flags({ sql: Option.some("select 1"), linked: true, local: true }), ).pipe(Effect.exit); expect(Exit.isFailure(exit)).toBe(true); - expect(failMessage(exit)).toBe( - "if any flags in the group [db-url linked local] are set none of the others can be; [linked local] were all set", - ); + if (Exit.isFailure(exit)) { + expect(Cause.squash(exit.cause)).toBeInstanceOf(TargetSelectorConflictError); + } + expect(dbConnection.statements).toEqual([]); expect(cache.cached).toBe(false); }).pipe(Effect.provide(layer)); }); - it.effect("rejects --local=false --linked=false as a target conflict", () => { - const { layer } = setup(); + it.effect("treats --local=false --linked=false as absent and queries the local target", () => { + const { layer, out, cache } = setup({ result: SELECT_RESULT }); return Effect.gen(function* () { - const exit = yield* dbQuery( - flags({ - sql: Option.some("select 1"), - linked: Option.some(false), - local: Option.some(false), - }), - ).pipe(Effect.exit); - expect(Exit.isFailure(exit)).toBe(true); - expect(failMessage(exit)).toBe( - "if any flags in the group [db-url linked local] are set none of the others can be; [linked local] were all set", - ); + yield* dbQuery(flags({ sql: Option.some("select 1") })); + expect(out.stdoutText).toContain("alice"); + expect(cache.cached).toBe(false); }).pipe(Effect.provide(layer)); }); @@ -716,7 +799,7 @@ describe("db query integration", () => { const { layer } = setup({ unlinked: true }); return Effect.gen(function* () { const exit = yield* dbQuery( - flags({ sql: Option.some("select 1"), linked: Option.some(true) }), + flags({ sql: Option.some("select 1"), linked: true }), ).pipe(Effect.exit); expect(Exit.isFailure(exit)).toBe(true); expect(failMessage(exit)).toBe("Cannot find project ref. Have you run supabase link?"); @@ -727,7 +810,7 @@ describe("db query integration", () => { const { layer } = setup({ refReadFails: true }); return Effect.gen(function* () { const exit = yield* dbQuery( - flags({ sql: Option.some("select 1"), linked: Option.some(true) }), + flags({ sql: Option.some("select 1"), linked: true }), ).pipe(Effect.exit); expect(Exit.isFailure(exit)).toBe(true); expect(failMessage(exit)).toContain("failed to load project ref"); @@ -741,7 +824,7 @@ describe("db query integration", () => { linkedBody: '[{"name":"alice","id":1}]', }); return Effect.gen(function* () { - yield* dbQuery(flags({ sql: Option.some("select 1"), linked: Option.some(true) })); + yield* dbQuery(flags({ sql: Option.some("select 1"), linked: true })); expect(out.stdoutText).toContain("│ name │ id │"); expect(cache.cached).toBe(true); assertPermissionDrift({ @@ -764,7 +847,7 @@ describe("db query integration", () => { const fs = yield* FileSystem.FileSystem; const cachePath = `${tempRoot.current}/supabase/.temp/linked-project.json`; expect(yield* fs.exists(cachePath)).toBe(false); - yield* dbQuery(flags({ sql: Option.some("select 1"), linked: Option.some(true) })); + yield* dbQuery(flags({ sql: Option.some("select 1"), linked: true })); expect(yield* decodeJson(yield* fs.readFileString(cachePath))).toMatchObject({ ref: REF }); expect(httpClient.calls.map(({ method, url }) => `${method} ${url}`)).toEqual([ `POST https://api.supabase.com/v1/projects/${REF}/database/query`, @@ -804,7 +887,7 @@ describe("db query integration", () => { yield* dbQuery( flags({ sql: Option.some("select 1"), - linked: Option.some(true), + linked: true, projectRef: Option.some(REF), }), ); @@ -848,7 +931,7 @@ describe("db query integration", () => { yield* dbQuery( flags({ sql: Option.some("select 1"), - linked: Option.some(true), + linked: true, projectRef: Option.some(FLAG_REF), }), ); @@ -877,7 +960,7 @@ describe("db query integration", () => { yield* dbQuery( flags({ sql: Option.some("select 1"), - linked: Option.some(true), + linked: true, projectRef: Option.some(FLAG_REF), }), ); @@ -890,30 +973,51 @@ describe("db query integration", () => { }).pipe(Effect.provide(layer)); }); - it.effect("rejects --project-ref without --linked (query defaults to the local target)", () => { + it.effect("selects the linked project from --project-ref alone", () => { const FLAG_REF = "flagflagflagflagflag"; + const { layer, cache, httpClient } = setup({ + linkedStatus: 201, + linkedBody: '[{"name":"alice","id":1}]', + }); + return Effect.gen(function* () { + yield* dbQuery(flags({ sql: Option.some("select 1"), projectRef: Option.some(FLAG_REF) })); + expect( + httpClient.requests.some((url) => url.includes(`/v1/projects/${FLAG_REF}/database/query`)), + ).toBe(true); + expect(cache.cachedRef).toBe(FLAG_REF); + }).pipe(Effect.provide(layer)); + }); + + it.effect("rejects --project-ref combined with --local", () => { const { layer, out, cache, httpClient } = setup({ result: SELECT_RESULT }); return Effect.gen(function* () { const exit = yield* dbQuery( - flags({ sql: Option.some("select 1"), projectRef: Option.some(FLAG_REF) }), + flags({ + sql: Option.some("select 1"), + local: true, + projectRef: Option.some("flagflagflagflagflag"), + }), ).pipe(Effect.exit); expect(Exit.isFailure(exit)).toBe(true); - expect(failMessage(exit)).toBe( - "--project-ref only applies when targeting the linked project; use it with --linked (not --local or --db-url)", - ); + if (Exit.isFailure(exit)) { + expect(Cause.squash(exit.cause)).toBeInstanceOf(TargetProjectRefConflictError); + } expect(out.stdoutText).toBe(""); expect(httpClient.requests).toEqual([]); expect(cache.cached).toBe(false); }).pipe(Effect.provide(layer)); }); - it.effect("treats --linked=false as an explicit linked target", () => { + it.effect("follows a remote workspace target when no selector flag is given", () => { const { layer, out, cache } = setup({ linkedStatus: 201, linkedBody: '[{"name":"alice","id":1}]', }); return Effect.gen(function* () { - yield* dbQuery(flags({ sql: Option.some("select 1"), linked: Option.some(false) })); + yield* withConfigEnv( + { SUPABASE_TARGET: "remote" }, + dbQuery(flags({ sql: Option.some("select 1"), linked: false })), + ); expect(out.stdoutText).toContain("│ name │ id │"); expect(cache.cached).toBe(true); }).pipe(Effect.provide(layer)); @@ -927,7 +1031,7 @@ describe("db query integration", () => { }); return Effect.gen(function* () { const exit = yield* dbQuery( - flags({ sql: Option.some("select 1"), linked: Option.some(true) }), + flags({ sql: Option.some("select 1"), linked: true }), ).pipe(Effect.exit); expect(Exit.isFailure(exit)).toBe(true); expect(failMessage(exit)).toContain("failed to parse connection string"); @@ -939,7 +1043,7 @@ describe("db query integration", () => { it.effect("caches the linked project even when SQL resolution fails", () => { const { layer, cache } = setup({ stdinTTY: true }); return Effect.gen(function* () { - const exit = yield* dbQuery(flags({ linked: Option.some(true) })).pipe(Effect.exit); + const exit = yield* dbQuery(flags({ linked: true })).pipe(Effect.exit); expect(Exit.isFailure(exit)).toBe(true); expect(failMessage(exit)).toContain("no SQL query provided"); expect(cache.cached).toBe(true); @@ -955,7 +1059,7 @@ describe("db query integration", () => { }); return Effect.gen(function* () { const exit = yield* dbQuery( - flags({ sql: Option.some("bad"), linked: Option.some(true) }), + flags({ sql: Option.some("bad"), linked: true }), ).pipe(Effect.exit); expect(failMessage(exit)).toContain("unexpected status 400"); expect(cache.cached).toBe(true); @@ -967,7 +1071,7 @@ describe("db query integration", () => { const { layer, out } = setup({ linkedStatus: 201, linkedBody: "[]" }); return Effect.gen(function* () { yield* dbQuery( - flags({ sql: Option.some("select 1 where false"), linked: Option.some(true) }), + flags({ sql: Option.some("select 1 where false"), linked: true }), ); expect(out.stdoutText).toBe(""); }).pipe(Effect.provide(layer)); @@ -976,7 +1080,7 @@ describe("db query integration", () => { it.effect("prints the raw body when the linked response is not a JSON array", () => { const { layer, out } = setup({ linkedStatus: 201, linkedBody: '{"command":"INSERT"}' }); return Effect.gen(function* () { - yield* dbQuery(flags({ sql: Option.some("insert ..."), linked: Option.some(true) })); + yield* dbQuery(flags({ sql: Option.some("insert ..."), linked: true })); expect(out.stdoutText).toBe('{"command":"INSERT"}\n'); }).pipe(Effect.provide(layer)); }); @@ -984,7 +1088,7 @@ describe("db query integration", () => { it.effect("prints the raw body when the linked response is not valid JSON", () => { const { layer, out } = setup({ linkedStatus: 201, linkedBody: "CREATE TABLE" }); return Effect.gen(function* () { - yield* dbQuery(flags({ sql: Option.some("create ..."), linked: Option.some(true) })); + yield* dbQuery(flags({ sql: Option.some("create ..."), linked: true })); expect(out.stdoutText).toBe("CREATE TABLE\n"); }).pipe(Effect.provide(layer)); }); @@ -996,7 +1100,7 @@ describe("db query integration", () => { linkedBody: '[{"id":1}]', }); return Effect.gen(function* () { - yield* dbQuery(flags({ sql: Option.some("select 1"), linked: Option.some(true) })); + yield* dbQuery(flags({ sql: Option.some("select 1"), linked: true })); const parsed = yield* decodeJson(out.stdoutText); expect(parsed).toHaveProperty("boundary", BOUNDARY); expect(parsed).toHaveProperty("rows", [{ id: 1 }]); @@ -1007,7 +1111,7 @@ describe("db query integration", () => { it.effect("falls back to map keys when the first linked row has no orderable keys", () => { const { layer, out } = setup({ linkedStatus: 201, linkedBody: "[null]" }); return Effect.gen(function* () { - yield* dbQuery(flags({ sql: Option.some("select 1"), linked: Option.some(true) })); + yield* dbQuery(flags({ sql: Option.some("select 1"), linked: true })); expect(out.stdoutText).toBe(""); }).pipe(Effect.provide(layer)); }); @@ -1015,7 +1119,7 @@ describe("db query integration", () => { it.effect("renders NULL for a null row object in a linked result", () => { const { layer, out } = setup({ linkedStatus: 201, linkedBody: '[{"a":1},null]' }); return Effect.gen(function* () { - yield* dbQuery(flags({ sql: Option.some("select 1"), linked: Option.some(true) })); + yield* dbQuery(flags({ sql: Option.some("select 1"), linked: true })); expect(out.stdoutText).toContain("NULL"); expect(out.stdoutText).toContain("│ 1"); }).pipe(Effect.provide(layer)); @@ -1025,7 +1129,7 @@ describe("db query integration", () => { const { layer } = setup({ networkFail: true }); return Effect.gen(function* () { const exit = yield* dbQuery( - flags({ sql: Option.some("select 1"), linked: Option.some(true) }), + flags({ sql: Option.some("select 1"), linked: true }), ).pipe(Effect.exit); expect(failMessage(exit)).toContain("failed to execute query"); }).pipe(Effect.provide(layer)); @@ -1035,7 +1139,7 @@ describe("db query integration", () => { const { layer } = setup({ accessToken: Option.none() }); return Effect.gen(function* () { const exit = yield* dbQuery( - flags({ sql: Option.some("select 1"), linked: Option.some(true) }), + flags({ sql: Option.some("select 1"), linked: true }), ).pipe(Effect.exit); expect(failMessage(exit)).toContain("Access token not provided"); }).pipe(Effect.provide(layer)); @@ -1045,7 +1149,7 @@ describe("db query integration", () => { const { layer, out } = setup({ accessTokenInvalid: true, linkedStatus: 201 }); return Effect.gen(function* () { const exit = yield* dbQuery( - flags({ sql: Option.some("select 1"), linked: Option.some(true) }), + flags({ sql: Option.some("select 1"), linked: true }), ).pipe(Effect.exit); expect(Exit.isFailure(exit)).toBe(true); expect(failMessage(exit)).toContain("Invalid access token format"); @@ -1057,7 +1161,7 @@ describe("db query integration", () => { const { layer } = setup({ accessToken: Option.none() }); return Effect.gen(function* () { const exit = yield* dbQuery( - flags({ linked: Option.some(true), file: Option.some("/no/such/file.sql") }), + flags({ linked: true, file: Option.some("/no/such/file.sql") }), ).pipe(Effect.exit); expect(failMessage(exit)).toContain("Access token not provided"); expect(failMessage(exit)).not.toContain("failed to read SQL file"); @@ -1068,10 +1172,129 @@ describe("db query integration", () => { const { layer } = setup({ accessToken: Option.none(), resolveFails: true }); return Effect.gen(function* () { const exit = yield* dbQuery( - flags({ sql: Option.some("select 1"), linked: Option.some(true) }), + flags({ sql: Option.some("select 1"), linked: true }), ).pipe(Effect.exit); expect(failMessage(exit)).toContain("failed to parse connection string"); expect(failMessage(exit)).not.toContain("Access token not provided"); }).pipe(Effect.provide(layer)); }); + describe("--read-only", () => { + it.effect("brackets the statement in a read-only transaction on a local target", () => { + const { layer, out, dbConnection } = setup({ result: SELECT_RESULT }); + return Effect.gen(function* () { + yield* dbQuery(flags({ sql: Option.some("select 1"), local: true, readOnly: true })); + expect(dbConnection.statements).toEqual(["BEGIN READ ONLY", "select 1", "ROLLBACK"]); + expect(out.stdoutText).toContain("alice"); + }).pipe(Effect.provide(layer)); + }); + + it.effect("fails a write and still rolls the transaction back", () => { + const { layer, dbConnection } = setup(); + return Effect.gen(function* () { + const exit = yield* dbQuery( + flags({ sql: Option.some("insert into t values (1)"), local: true, readOnly: true }), + ).pipe(Effect.exit); + expect(failMessage(exit)).toContain("read-only transaction"); + expect(dbConnection.statements).toEqual([ + "BEGIN READ ONLY", + "insert into t values (1)", + "ROLLBACK", + ]); + }).pipe(Effect.provide(layer)); + }); + + it.effect("lets the same write through without --read-only", () => { + const { layer, out } = setup(); + return Effect.gen(function* () { + yield* dbQuery(flags({ sql: Option.some("insert into t values (1)"), local: true })); + expect(out.stdoutText).toBe("CREATE TABLE\n"); + }).pipe(Effect.provide(layer)); + }); + + it.effect("runs in a read-only transaction on a --db-url target", () => { + const { layer, dbConnection } = setup({ + result: SELECT_RESULT, + isLocal: false, + yes: false, + }); + return Effect.gen(function* () { + yield* dbQuery( + flags({ + sql: Option.some("select 1"), + dbUrl: Option.some("postgres://x/y"), + readOnly: true, + }), + ); + expect(dbConnection.statements).toEqual(["BEGIN READ ONLY", "select 1", "ROLLBACK"]); + }).pipe(Effect.provide(layer)); + }); + + it.effect("sends read_only to the Management API for a linked target", () => { + const { layer, httpClient } = setup({ linkedStatus: 201, linkedBody: '[{"id":1}]' }); + return Effect.gen(function* () { + yield* dbQuery(flags({ sql: Option.some("select 1"), linked: true, readOnly: true })); + expect(httpClient.bodies).toEqual([{ query: "select 1", read_only: true }]); + }).pipe(Effect.provide(layer)); + }); + + it.effect("omits read_only from the Management API request by default", () => { + const { layer, httpClient } = setup({ linkedStatus: 201, linkedBody: '[{"id":1}]' }); + return Effect.gen(function* () { + yield* dbQuery(flags({ sql: Option.some("select 1"), linked: true })); + expect(httpClient.bodies).toEqual([{ query: "select 1" }]); + }).pipe(Effect.provide(layer)); + }); + + it.effect("skips the hosted consent that a plain linked query needs", () => { + const refused = setup({ linkedStatus: 201, linkedBody: '[{"id":1}]', yes: false }); + const allowed = setup({ linkedStatus: 201, linkedBody: '[{"id":1}]', yes: false }); + return Effect.gen(function* () { + const exit = yield* dbQuery(flags({ sql: Option.some("select 1"), linked: true })).pipe( + Effect.provide(refused.layer), + Effect.exit, + ); + expect(Exit.isFailure(exit) && Cause.squash(exit.cause)).toBeInstanceOf( + HostedMutationConsentRequiredError, + ); + expect(refused.httpClient.requests).toEqual([]); + + yield* dbQuery(flags({ sql: Option.some("select 1"), linked: true, readOnly: true })).pipe( + Effect.provide(allowed.layer), + ); + expect(allowed.out.promptConfirmCalls).toEqual([]); + expect(allowed.httpClient.requests).toHaveLength(1); + }); + }); + }); +}); + +describeHostedConsent("db query", { + invoke: () => { + const httpClient = mockHttpClient({ status: 201, body: '[{"id":1}]' }); + return { + run: dbQuery( + flags({ + sql: Option.some("insert into t values (1)"), + linked: true, + projectRef: Option.some(REF), + }), + ).pipe( + Effect.provide( + Layer.mergeAll( + mockResolver(false), + mockDbConnection({}).layer, + httpClient.layer, + mockCredentials(), + cliConfigValuesTestLayer, + mockTelemetryOutputFormat().layer, + Layer.succeed(Random, { randomHex: () => Effect.succeed(BOUNDARY) }), + Layer.succeed(AiTool, { name: Option.none() }), + Layer.succeed(AgentFlag, "no"), + Layer.succeed(DnsResolverFlag, "native"), + ), + ), + ), + mutations: () => httpClient.calls.filter(({ method }) => method === "POST").length, + }; + }, }); diff --git a/apps/cli/src/commands/db/query/query.live.test.ts b/apps/cli/src/commands/db/query/query.live.test.ts index 8370cd98b0..9f8053652b 100644 --- a/apps/cli/src/commands/db/query/query.live.test.ts +++ b/apps/cli/src/commands/db/query/query.live.test.ts @@ -21,6 +21,7 @@ test("runs SQL against the remote database and returns its rows", ({ `select '${marker}' as marker`, "--db-url", project.dbUrl, + "--read-only", "-o", "json", "--agent", @@ -32,3 +33,25 @@ test("runs SQL against the remote database and returns its rows", ({ }), { signal }, )); + +test("rejects a write against the remote database under --read-only", ({ + cliEffect, + project, + signal, +}) => + Effect.runPromise( + Effect.gen(function* () { + const table = `e2e_query_ro_${randomUUID().slice(0, 8)}`; + const result = yield* cliEffect([ + "db", + "query", + `create table ${table} (id int)`, + "--db-url", + project.dbUrl, + "--read-only", + ]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("read-only transaction"); + }), + { signal }, + )); From 7bb7ee31663fd9fd6eb0db507d49ddea4f925fa3 Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Thu, 15 Oct 2026 14:41:44 +0100 Subject: [PATCH 47/92] docs(cli): document hosted consent and target identity for functions, secrets and config (CLI-2695) Update the SIDE_EFFECTS and docs pages for functions deploy/delete/list/download, secrets set/unset/list and config push/pull/diff: the Target banner, the single consent question, --yes / shell SUPABASE_YES only, and the JSON target identity. Pass --yes in the live and recorded e2e invocations that mutate a hosted project. --- apps/cli-e2e/src/tests/functions.e2e.test.ts | 10 ++ apps/cli-e2e/src/tests/secrets.e2e.test.ts | 25 ++- apps/cli/docs/supabase/config/diff.md | 2 +- apps/cli/docs/supabase/config/pull.md | 2 +- apps/cli/docs/supabase/config/push.md | 6 +- apps/cli/docs/supabase/functions.md | 2 + apps/cli/docs/supabase/secrets.md | 2 + .../src/commands/config/diff/SIDE_EFFECTS.md | 6 +- .../src/commands/config/pull/SIDE_EFFECTS.md | 6 +- .../src/commands/config/push/SIDE_EFFECTS.md | 145 ++++++++---------- .../config/push/push.branch-target.ts | 9 +- .../src/commands/config/push/push.handler.ts | 32 ++-- .../commands/functions/delete/SIDE_EFFECTS.md | 23 +-- .../functions/delete/delete.live.test.ts | 4 +- .../commands/functions/deploy/SIDE_EFFECTS.md | 35 +++-- .../functions/deploy/deploy.live.test.ts | 10 +- .../functions/download/SIDE_EFFECTS.md | 3 +- .../functions/download/download.live.test.ts | 10 +- .../commands/functions/list/SIDE_EFFECTS.md | 9 +- .../src/commands/secrets/list/SIDE_EFFECTS.md | 6 +- .../commands/secrets/list/list.live.test.ts | 1 + .../src/commands/secrets/set/SIDE_EFFECTS.md | 41 ++--- .../src/commands/secrets/set/set.live.test.ts | 1 + .../commands/secrets/unset/SIDE_EFFECTS.md | 45 +++--- .../commands/secrets/unset/unset.live.test.ts | 1 + 25 files changed, 252 insertions(+), 184 deletions(-) diff --git a/apps/cli-e2e/src/tests/functions.e2e.test.ts b/apps/cli-e2e/src/tests/functions.e2e.test.ts index bfba4a5e38..d246343641 100644 --- a/apps/cli-e2e/src/tests/functions.e2e.test.ts +++ b/apps/cli-e2e/src/tests/functions.e2e.test.ts @@ -95,6 +95,7 @@ describe("functions", () => { "--use-api", "--project-ref", PROJECT_REF, + "--yes", ]); expect(result.exitCode).not.toBe(0); expect(result.stderr).toContain("Invalid token"); @@ -114,6 +115,7 @@ describe("functions", () => { "--use-api", "--project-ref", PROJECT_REF, + "--yes", ]); expect(result.exitCode).not.toBe(0); expect(result.stderr).toContain("Forbidden"); @@ -136,6 +138,7 @@ describe("functions", () => { "--use-api", "--project-ref", PROJECT_REF, + "--yes", ]); expect(result.exitCode).not.toBe(0); expect(result.stderr).toContain("Too Many Requests"); @@ -155,6 +158,7 @@ describe("functions", () => { "--use-api", "--project-ref", PROJECT_REF, + "--yes", ]); expect(result.exitCode).not.toBe(0); expect(result.stderr).toContain("Internal Server Error"); @@ -171,6 +175,7 @@ describe("functions", () => { FUNCTION_NAME, "--project-ref", projectRef, + "--yes", ]); expect(result.exitCode).toBe(0); expect(result.stdout).toContain("Deleted Function"); @@ -189,6 +194,7 @@ describe("functions", () => { FUNCTION_NAME, "--project-ref", PROJECT_REF, + "--yes", ]); expect(result.exitCode).not.toBe(0); expect(result.stderr).toContain("does not exist"); @@ -206,6 +212,7 @@ describe("functions", () => { FUNCTION_NAME, "--project-ref", PROJECT_REF, + "--yes", ]); expect(result.exitCode).not.toBe(0); expect(result.stderr).toContain("Invalid token"); @@ -223,6 +230,7 @@ describe("functions", () => { FUNCTION_NAME, "--project-ref", PROJECT_REF, + "--yes", ]); expect(result.exitCode).not.toBe(0); expect(result.stderr).toContain("Forbidden"); @@ -240,6 +248,7 @@ describe("functions", () => { FUNCTION_NAME, "--project-ref", PROJECT_REF, + "--yes", ]); expect(result.exitCode).not.toBe(0); expect(result.stderr).toContain("Too Many Requests"); @@ -257,6 +266,7 @@ describe("functions", () => { FUNCTION_NAME, "--project-ref", PROJECT_REF, + "--yes", ]); expect(result.exitCode).not.toBe(0); expect(result.stderr).toContain("Internal Server Error"); diff --git a/apps/cli-e2e/src/tests/secrets.e2e.test.ts b/apps/cli-e2e/src/tests/secrets.e2e.test.ts index 9ace9e4e12..72f87d279c 100644 --- a/apps/cli-e2e/src/tests/secrets.e2e.test.ts +++ b/apps/cli-e2e/src/tests/secrets.e2e.test.ts @@ -85,7 +85,7 @@ describe("secrets", () => { describe("secrets:set", () => { testBehaviour("sets a single secret", async ({ run, projectRef }) => { - const result = await run(["secrets", "set", "FOO=bar", "--project-ref", projectRef]); + const result = await run(["secrets", "set", "FOO=bar", "--project-ref", projectRef, "--yes"]); expect(result.exitCode).toBe(0); expect(result.stdout).toContain("Finished"); }); @@ -98,6 +98,7 @@ describe("secrets", () => { "BAZ=qux", "--project-ref", projectRef, + "--yes", ]); expect(result.exitCode).toBe(0); expect(result.stdout).toContain("Finished"); @@ -112,6 +113,7 @@ describe("secrets", () => { ".env.local", "--project-ref", projectRef, + "--yes", ]); expect(result.exitCode).toBe(0); expect(result.stdout).toContain("Finished"); @@ -126,7 +128,14 @@ describe("secrets", () => { body: { message: "Validation failed", errors: [{ message: "Invalid secret name" }] }, }), }); - const result = await run(["secrets", "set", "FOO=bar", "--project-ref", PROJECT_REF]); + const result = await run([ + "secrets", + "set", + "FOO=bar", + "--project-ref", + PROJECT_REF, + "--yes", + ]); expect(result.exitCode).not.toBe(0); }); @@ -138,6 +147,7 @@ describe("secrets", () => { "nonexistent.env", "--project-ref", PROJECT_REF, + "--yes", ]); expect(result.exitCode).not.toBe(0); }); @@ -148,7 +158,14 @@ describe("secrets", () => { headers: { "Content-Type": "application/json" }, body: JSON.stringify({ status: 401, body: { message: "Invalid token" } }), }); - const result = await run(["secrets", "set", "FOO=bar", "--project-ref", PROJECT_REF]); + const result = await run([ + "secrets", + "set", + "FOO=bar", + "--project-ref", + PROJECT_REF, + "--yes", + ]); expect(result.exitCode).not.toBe(0); }); }); @@ -161,7 +178,7 @@ describe("secrets", () => { }); testBehaviour("removes multiple secrets", async ({ run, projectRef }) => { - await run(["secrets", "set", "FOO=bar", "BAR=baz", "--project-ref", projectRef]); + await run(["secrets", "set", "FOO=bar", "BAR=baz", "--project-ref", projectRef, "--yes"]); const result = await run([ "secrets", "unset", diff --git a/apps/cli/docs/supabase/config/diff.md b/apps/cli/docs/supabase/config/diff.md index 229783224c..503d6849dd 100644 --- a/apps/cli/docs/supabase/config/diff.md +++ b/apps/cli/docs/supabase/config/diff.md @@ -12,4 +12,4 @@ Local values are shown as the configuration your file would produce once pushed, With `--exit-code`, the command exits `2` when any difference is found, keeping exit `1` for errors — so scripts can distinguish drift from failure. -Machine-readable output is available through `--output-format json|stream-json` — a versioned payload (`schema_version`, `config_schema`, `target`, `scope`, `changes[]`, `masked[]`, `unmanaged[]`, `counts`) with per-change `path`s as segment arrays. The legacy global `-o`/`--output` flag is not supported by this command; use `--output-format json|stream-json` instead. +Machine-readable output is available through `--output-format json|stream-json` — a versioned payload (`schema_version`, `config_schema`, `target`, `scope`, `changes[]`, `masked[]`, `unmanaged[]`, `counts`) with per-change `path`s as segment arrays. `target` identifies the compared project: `kind` (`linked`), `project_ref`, `project_name` and `environment` (`production`, `branch` or `unverified`) when known, `parent_project_ref` for a branch, `branch` when it was named, and `local_scope` (`base` or `remotes.<name>`). The legacy global `-o`/`--output` flag is not supported by this command; use `--output-format json|stream-json` instead. diff --git a/apps/cli/docs/supabase/config/pull.md b/apps/cli/docs/supabase/config/pull.md index 3e238c1d59..7cfcfdcd72 100644 --- a/apps/cli/docs/supabase/config/pull.md +++ b/apps/cli/docs/supabase/config/pull.md @@ -46,4 +46,4 @@ max_rows = 250 Running the same command again finds no differences and writes nothing. -Machine-readable output is available through `--output-format json|stream-json` — a versioned payload (`schema_version`, `config_schema`, `config_path`, `format`, `target`, `destination`, `dry_run`, `wrote`, `scope`, `changes[]`, `warnings[]`, `masked[]`, `unmanaged[]`, `counts`) with per-change `path`s as segment arrays, each carrying `written` and, when unwritten, a `skipped_reason` (`env_reference | local_only | remote_env_reference | unwritable | would_invalidate | declined | dry_run`). A `would_invalidate` warning additionally carries `missing_fields` (`[{path, env_variable?}]`) naming what actually blocked the family. The legacy global `-o`/`--output` flag is not supported by this command; use `--output-format json|stream-json` instead. Note: the payload spells the root scope `"base"` (`target.local_scope`), while text output calls the same thing `config root`. +Machine-readable output is available through `--output-format json|stream-json` — a versioned payload (`schema_version`, `config_schema`, `config_path`, `format`, `target`, `destination`, `dry_run`, `wrote`, `scope`, `changes[]`, `warnings[]`, `masked[]`, `unmanaged[]`, `counts`) — `target` identifies the pulled project the same way `config diff` does (`kind`, `project_ref`, `project_name`, `environment`, `parent_project_ref`, `branch`, `local_scope`) — with per-change `path`s as segment arrays, each carrying `written` and, when unwritten, a `skipped_reason` (`env_reference | local_only | remote_env_reference | unwritable | would_invalidate | declined | dry_run`). A `would_invalidate` warning additionally carries `missing_fields` (`[{path, env_variable?}]`) naming what actually blocked the family. The legacy global `-o`/`--output` flag is not supported by this command; use `--output-format json|stream-json` instead. Note: the payload spells the root scope `"base"` (`target.local_scope`), while text output calls the same thing `config root`. diff --git a/apps/cli/docs/supabase/config/push.md b/apps/cli/docs/supabase/config/push.md index 5f912a93e8..8c94c34399 100644 --- a/apps/cli/docs/supabase/config/push.md +++ b/apps/cli/docs/supabase/config/push.md @@ -6,6 +6,8 @@ This command allows you to manage project configuration as code by defining sett Pass `--project-ref` to push to a specific project, or the name (or UUID) of a branch of the currently linked project — values that are exactly 20 lowercase letters are always treated as project refs. Without it, the linked project is the target. -Before anything is pushed, the command reports the project it resolved — its name and ref. When the resolved target is a preview branch that was only _inferred_ (for example after `supabase link <branch-name>`, or via `SUPABASE_PROJECT_ID`) rather than named on this invocation, it says so, names the branch and its parent project where they can be resolved, and asks for confirmation first. Declining — including on an unattended run with no `--yes` and no piped answer — fails the command rather than pushing. Pass `--yes` (or set `SUPABASE_YES`) to skip that confirmation in CI and scripted runs. +Before anything is pushed, the command prints the `Target:` line for the project or branch it resolved (name, ref, and for a branch its parent project), then shows the exact diff for every resource that will change. It then asks once — a single question covering every changed resource, with a default answer of no — and sends nothing until you confirm. If a change enables a paid add-on, its price is printed before the question. -An explicit `--project-ref <branch-name-or-uuid>` on the same invocation skips the confirmation entirely (the target is still echoed) — naming the branch yourself already counts as confirming it. Because the confirmation reads one line of piped input just like any other prompt, a script piping multiple `y`/`n` answers to an _inferred_ branch target needs one extra leading answer for it, ahead of the per-service prompts it already answers. +A push with nothing to change never asks. Declining fails the command and writes nothing. Piped answers are never read, so a script, a JSON run (`-o json`, `--output-format json`) or an agent must pass `--yes` or set `SUPABASE_YES` in the shell; a `SUPABASE_YES` in `supabase/.env` does not count. With `--yes` the command echoes `Confirmed by --yes: …` to stderr and pushes. + +In `json` output the result carries a `target` object with the resolved identity (`kind`, `project_ref`, `project_name`, `environment`, and `parent_project_ref` for a branch) next to the push summary. diff --git a/apps/cli/docs/supabase/functions.md b/apps/cli/docs/supabase/functions.md index d0a08de06c..42f0632de3 100644 --- a/apps/cli/docs/supabase/functions.md +++ b/apps/cli/docs/supabase/functions.md @@ -7,3 +7,5 @@ Supabase Edge Functions are server-less functions that run close to your users. Edge Functions allow you to execute custom server-side code without deploying or scaling a traditional server. They're ideal for handling webhooks, custom API endpoints, data validation, and serving personalized content. Edge Functions are written in TypeScript and run on Deno compatible edge runtime, which is a secure runtime with no package management needed, fast cold starts, and built-in security. + +Commands that change the hosted project — `functions deploy` (including `--prune`) and `functions delete` — print the `Target:` line, then ask for confirmation before changing anything; the default answer is no. `functions deploy` asks one question covering every function it will deploy and every function `--prune` will delete. Piped answers are never read, so scripts, JSON runs and agents must pass `--yes` or set `SUPABASE_YES` in the shell (a `SUPABASE_YES` in `supabase/.env` does not count). `functions list` and `functions download` only read and never ask. diff --git a/apps/cli/docs/supabase/secrets.md b/apps/cli/docs/supabase/secrets.md index 975f3456cf..94dd5704b1 100644 --- a/apps/cli/docs/supabase/secrets.md +++ b/apps/cli/docs/supabase/secrets.md @@ -10,3 +10,5 @@ Secrets management through the CLI is useful for: - Managing sensitive credentials securely Secrets can be set individually or loaded from .env files for convenience. + +`secrets set` and `secrets unset` print the `Target:` line, then ask for confirmation before changing anything; the default answer is no, and the question lists the secret names (never their values). Piped answers are never read, so scripts, JSON runs and agents must pass `--yes` or set `SUPABASE_YES` in the shell (a `SUPABASE_YES` in `supabase/.env` does not count). `secrets unset --dry-run` and `secrets list` only read and never ask. diff --git a/apps/cli/src/commands/config/diff/SIDE_EFFECTS.md b/apps/cli/src/commands/config/diff/SIDE_EFFECTS.md index 7661a11c9f..39b24cf5d7 100644 --- a/apps/cli/src/commands/config/diff/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/config/diff/SIDE_EFFECTS.md @@ -82,7 +82,8 @@ same as any other non-zero exit. ## Output -Diagnostics on **stderr**: `Comparing against …` (resolved target + local +Diagnostics on **stderr**: the `Target:` banner (project name, ref and, for a +branch, the parent project), `Comparing against …` (resolved target + local scope, i.e. `[remotes.<name>]` or `base config`) before the fetch, then `Comparison scope: <blocks>` listing the blocks the response carried (missing blocks are called out). The payload is on **stdout**. @@ -113,7 +114,7 @@ masked/unmanaged caveats too, so echoing it never claims "in sync" on a partial response (e.g. a scoped token returning `auth: {}`) or while masked values may have drifted. The payload contains `schema_version` (integer version of THIS payload contract, currently `1`), `config_schema` (the file's -`$schema` URL), `target` (`project_ref`, optional `branch`, `local_scope`), +`$schema` URL), `target` (`kind: "linked"`, `project_ref`, `project_name` and `environment` (`production`, `branch` or `unverified`) when known, `parent_project_ref` for a branch, optional `branch`, and `local_scope`), `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 @@ -145,6 +146,7 @@ and emit nothing. ## Notes +- Never asks for confirmation: the command reads and compares only, so `--yes` and `SUPABASE_YES` have no effect. - Run from the project root (or pass `--workdir`); `config.toml` is read relative to it. An explicit `--workdir`/`SUPABASE_WORKDIR` is never climbed past — see the `SUPABASE_WORKDIR` row above. - **Local operand per target (ADR 0018/0022):** when the resolved target ref matches a diff --git a/apps/cli/src/commands/config/pull/SIDE_EFFECTS.md b/apps/cli/src/commands/config/pull/SIDE_EFFECTS.md index 2138a8fc49..eff1f77248 100644 --- a/apps/cli/src/commands/config/pull/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/config/pull/SIDE_EFFECTS.md @@ -107,7 +107,8 @@ exit `0`: neither is a failure. ## Output -Diagnostics on **stderr**: `Pulling config from <target> → <destination>` (the destination echoed +Diagnostics on **stderr**: the `Target:` banner (project name, ref and, for a branch, the parent +project), then `Pulling config from <target> → <destination>` (the destination echoed BEFORE any network call — `config root` or `[remotes.<label>]`), then `Comparison scope: <blocks>` once the response arrives (same line `config diff` prints). The confirmation prompt and the change-set body are on **stdout**, followed by the payload. @@ -147,7 +148,7 @@ apply.`, `[remotes.<label>] would be created (dry run); no config differences to `output.success(message, payload)`. `schema_version` is this command's OWN payload version (`CONFIG_PULL_PAYLOAD_VERSION`, currently `1`) — independent of `config diff`'s. The payload -contains `config_schema`, `config_path`, `format`, `target` (`project_ref`, optional `branch`, +contains `config_schema`, `config_path`, `format`, `target` (`kind: "linked"`, `project_ref`, `project_name` and `environment` (`production`, `branch` or `unverified`) when known, `parent_project_ref` for a branch, optional `branch`, and `local_scope` — the block the LOCAL comparison operand came from, `"base"` when pulling into a brand-new `[remotes.*]` block since there is nothing to overlay yet), `destination` (`scope`, optional `label`, `created` — WHERE this run writes, independent of `target.local_scope`), @@ -181,6 +182,7 @@ the -o/--output flag is not supported by config pull; use --output-format json|s ## Notes +- **No hosted-mutation consent.** Pull changes only local files, so the one prompt is the local overwrite question above; `--yes` and `SUPABASE_YES` answer it, and nothing about the linked project is ever modified. - Run from the project root (or pass `--workdir`); `config.toml`/`config.json` is read and written relative to it. - **Scope resolution (ADR 0023) is pure and precedes any network call**: `--remote-label`, when diff --git a/apps/cli/src/commands/config/push/SIDE_EFFECTS.md b/apps/cli/src/commands/config/push/SIDE_EFFECTS.md index 2bc89794d6..f4eca2a38f 100644 --- a/apps/cli/src/commands/config/push/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/config/push/SIDE_EFFECTS.md @@ -2,15 +2,15 @@ Pushes the local `supabase/config.toml`'s declared, pushable properties to the linked project's (or, via `--project-ref <branch-name-or-uuid>`, one of its -branches') Management API. Always reports which project/branch it resolved first -(CLI-2168), gating an implicitly-resolved branch target behind a confirmation. -Reads the project's effective configuration -once, diffs it against the local file's projection, and for each resource -(api / db.settings / db.network_restrictions / db.ssl_enforcement / auth / -storage) that has a pushable difference, prints the change and confirms -before writing — the request body carries only the changed properties, plus -any keys the target endpoint requires alongside them (see the API Routes -notes below). A property your file doesn't declare is never written. +branches') Management API. Reports which project/branch it resolved first, reads +the project's effective configuration once, diffs it against the local file's +projection, and prints the exact change for every resource (api / db.settings / +db.network_restrictions / db.ssl_enforcement / auth / storage, plus the webhooks +enable) that has a pushable difference. It then asks one consent question +covering every changed resource and writes nothing until it is confirmed — the +request body carries only the changed properties, plus any keys the target +endpoint requires alongside them (see the API Routes notes below). A property +your file doesn't declare is never written. ## Files Read @@ -36,8 +36,8 @@ No writes to `config.toml`. All Bearer-authenticated. A write is skipped (no request at all) when its resource has no pushable difference, when its response block was omitted -from the read, when the resource's local gate is off, or when the user -declines the confirmation prompt. +from the read, when the resource's local gate is off, or when the consent +question is declined (a decline writes nothing at all). | # | Resource | Method | Path | Success | Notes | | --- | ------------------------ | ------ | ----------------------------------------------- | ---------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | @@ -84,7 +84,7 @@ value — read from the same row-1 response, so it does not change — never a value your file doesn't declare. Only when that response did not report a member's current value at all does it fall back to a local/schema-default value, and that fallback is always disclosed as a `[group-write]` block in -the confirmation output and as a `forced` entry in the JSON payload (see +the diff output and as a `forced` entry in the JSON payload (see Output below) — never applied silently. A property outside one of these groups that your file doesn't declare never appears in any write, even when the group it belongs to ships for another reason. @@ -123,7 +123,7 @@ cannot be resolved from either the read or the file — reported in their own | -------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `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_YES` | confirms the push without a prompt, same as `--yes`; read from the shell only (a project `.env*` value does not count) | 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_<PATH>` 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 | @@ -134,10 +134,11 @@ cannot be resolved from either the read or the file — reported in their own | Code | Condition | | ---- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `0` | success, **including** declining one of the per-service `keep()` confirmation prompts (`api`/`db`/`auth`/`storage`/`webhooks`/MFA addon prompts) | +| `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` | user declined the branch confirmation gate (cancellation, `ConfigPushCancelledError`) — see Output 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` | `--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` | `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 | @@ -155,35 +156,25 @@ cannot be resolved from either the read or the file — reported in their own ### `--output-format text` -All diagnostics on **stderr**, no stdout. When a `[remotes.<name>]` block matches the -target ref, `Loading config override: [remotes.<name>]` prints first. Then the -target-echo block (CLI-2168) — for a plain project, `Pushing config to project: <name> -(<ref>)` (degrades to the bare `Pushing config to project: <ref>` when no name could be -resolved); for a branch, `Pushing config to branch: <name> (<ref>)` followed, only when -known, by a second line ` Parent project: <name> (<ref>)` (either half degrades to a -bare ref on its own when its name isn't known; the parent line is omitted entirely when -no parent could be determined at all) — this line always prints for a branch target, -whether or not the confirmation gate below is shown; or, when the target-detection probe -couldn't tell at all (a timeout, a transport failure, or an unexpected status), `Pushing -config to: <ref> (could not determine whether this is a branch or the main project)` — -this shape never gates a confirmation and the push proceeds. When the target is a -branch AND it was resolved IMPLICITLY (no explicit `--project-ref <name-or-uuid>` this -invocation — e.g. a stale `.temp/project-ref` from an old `link`, or -`SUPABASE_PROJECT_ID` pointing somewhere forgotten), a confirmation gate follows -immediately — `Do you want to push config to branch "<name>" (<ref>)? (skip this check -with --yes) [y/N] ` (bare ref, no quotes, when the name is unknown). An EXPLICIT -`--project-ref <branch-name-or-uuid>` this invocation skips the gate entirely -(same-invocation intent already expressed once) — only the target-echo line above -prints, and the push proceeds immediately. Declining fails the command -(`ConfigPushCancelledError`, exit `1`) — the rendered text is `context canceled` -(`Output.fail`'s standard text-mode rendering, no `--debug` hint) — before any further -network call (not even the cost-matrix fetch). This gate's default is **no**: a non-TTY run with no affirmative piped answer, or -a machine-mode TTY run or text run with TTY stdin and redirected stdout, declines (and fails) rather than proceeding, -unless `--yes`/`SUPABASE_YES` is set. A plain-project target never shows this prompt. +All diagnostics on **stderr**, no stdout. The `Target:` banner prints first +(`Target: project <name> (<ref>) · production`, `Target: project <name> (<parent ref>) · +branch (<ref>)` for a branch, or `Target: project <ref> · identity unverified` when the +live probe could not tell). When a `[remotes.<name>]` block matches the target ref, +`Loading config override: [remotes.<name>]` follows. Then the target-echo block — for a +plain project, `Pushing config to project: <name> (<ref>)` (degrades to the bare `Pushing +config to project: <ref>` when no name could be resolved); for a branch, `Pushing config +to branch: <name> (<ref>)` followed, only when known, by a second line ` Parent project: +<name> (<ref>)` (either half degrades to a bare ref on its own when its name isn't known; +the parent line is omitted entirely when no parent could be determined at all); or, when +the target-detection probe couldn't tell at all (a timeout, a transport failure, or an +unexpected status), `Pushing config to: <ref> (could not determine whether this is a +branch or the main project)`. No target shape is gated separately: a branch is confirmed +through the one consent question below, which names the branch and its parent project, +whether it was resolved implicitly or named with `--project-ref`. Then `Comparison scope: <present> (not returned: <missing>)` — printed EVERY run, not just when a block is missing (family consistency 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 +pushed, one line follows — printed even with `--yes`, before the consent question: `Pushing N values set by environment variables: <path> (<VAR>[, <file>]), …` (`<file>` 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 @@ -194,7 +185,7 @@ ends `(from <VAR>)` or `(from <VAR> in <file>)`. Then, per resource, one of: a pushable difference existed, but every one of it was unencodable; nothing written. - nothing (silently skipped) — the resource's own response block was omitted from the read (`unavailable`), or its local gate is off (`disabled`). -- the confirmation block: +- the diff block: ``` Updating <X> service with config: @@ -220,23 +211,27 @@ Updating <X> service with config: `supabase config diff` prints; a changed secret renders `[secret]` with no value, just whether the remote digest was present or absent (a secret that will NOT be sent because it's empty or an unresolved `env(...)` reference discloses that here too, before the -prompt); `[content]` is a mailer template/notification body with no config path of its +consent question); `[content]` is a mailer template/notification body with no config path of its own; `[group-write]` is an undeclared companion the endpoint required alongside a declared change, sent at its schema default because the read didn't report the project's current value for it. Secret values never appear in output. Every block ends -on a blank line. Experimental prints `Enabling webhooks for project: <ref>`. The -per-service confirmations use a clack confirm widget with a yes default in interactive -text output; piped text input renders `<title> [y/N] `. For resource confirmations, -`--yes` renders `<title> [Y/n] y` in every output mode and TTY shape. Piped answers also echo the question and answer to stderr in machine modes, -matching `--yes`; stdout remains structured. With TTY stdin and redirected text stdout, -no prompt is rendered and a skipped message explains how to approve with `--yes`. -Non-TTY text declines also print `Skipped <name>: no affirmative confirmation received. -Pass --yes (or set SUPABASE_YES) to approve.` on stderr, including empty/invalid input. -Interactive declines do not print this unattended recovery hint. -Resource declines still exit **0**; only the branch confirmation gate -described above fails. - -After the resource loop, up to six `Note:` lines report anything the push couldn't do +on a blank line. Experimental prints `Enabling webhooks for project: <ref>`. Every +diff is printed before anything is written, so the consent question always follows the +full plan. + +When at least one resource (or the webhooks enable) will be written, a price line +`Enabling <name> will cost you <price>.` prints for each paid add-on the push would +switch on, then one question is asked: `push config changes to <API, DB and Auth…> on +<target>?` — the services are the ones that will be written, in push order — with a +default answer of **no**. `--yes` or a `SUPABASE_YES` set in the shell skips the question +and echoes `Confirmed by --yes: push config changes to … on <target>` to stderr; a +`SUPABASE_YES` in a project `.env*` file does not count. Piped answers are never read, and +a run that cannot prompt (stdin not a TTY, redirected stdout, or machine output) fails +without `--yes`. A push with nothing to write never asks. Declining fails the command +(`HostedMutationDeclinedError`, exit `1`, rendered as `context canceled`) with nothing +written. + +Afterwards, up to six `Note:` lines report anything the push couldn't do or had to work around: ``` @@ -256,14 +251,14 @@ through the same control-character sanitizing `config diff` uses. ### `--output-format json` / `stream-json` -Per-service diagnostics stay on stderr. All confirmations honor piped `y`/`n` answers -in every output mode. Without `--yes`/`SUPABASE_YES`, empty, unparseable, timed-out, -or failed reads skip the resource; machine-mode TTYs also skip without reading stdin. -Interactive text prompts retain their yes default; the branch gate retains its no default. -A structured summary is emitted on stdout via `output.success(message, data)`; -a declined/failed branch gate instead emits this command's standard machine error -envelope (`{_tag: "Error", error: {...}}` in `json` mode, a `{type: "error", ...}` NDJSON -event in `stream-json` mode) with no success payload. +Per-service diagnostics stay on stderr. Machine output never prompts, so a run with +something to write needs `--yes` or a shell `SUPABASE_YES`; without them the command fails +(exit `1`) with this command's standard machine error envelope (`{_tag: "Error", error: +{...}}` in `json` mode, a `{type: "error", ...}` NDJSON event in `stream-json` mode) and no +success payload. A structured summary is emitted on stdout via `output.success(message, +data)`, and carries a `target` object (`kind: "linked"`, `project_ref`, and when known +`project_name`, `environment` — `production`, `branch` or `unverified` — and +`parent_project_ref`) next to the push summary. `env_sourced` is additive and present only when a `SUPABASE_*` variable supplied a pushed value: `[{path: [...], origin: {source: "shell" | "project_env", env_variable, file?}}]`. @@ -298,7 +293,6 @@ asserting `false` would be as misleading as asserting `true`: "unencodable": [], "skipped": [], }, - "declined_addons": [], "remote_only": 12, "scope": { "present": ["api", "auth", "database", "pooler", "realtime", "storage"], @@ -327,8 +321,7 @@ service's `changes`), `Nothing to push: the project already matches the declared properties.` when every service is `up_to_date`/`disabled`, or `Nothing was pushed.` otherwise. A caveat sentence is appended, in this fixed order, for each non-empty category: unsupported+unencodable, unmanaged, -`scope.missing`, skipped services, not-set credentials, declined add-on -prompts — so an agent echoing just `.message` never reports success while +`scope.missing`, not-set credentials — so an agent echoing just `.message` never reports success while something declared was withheld. The `scope.missing` caveat counts only the blocks this command actually reads from (`api`, `database`, `auth`, `storage`); an omitted `pooler`/`realtime` block — which `config push` never @@ -342,34 +335,30 @@ line above always lists every missing block. { "type": "result", "data": { "project_ref": "…", "is_branch": false, "services": […], "message": "1 property pushed to abcdefghijklmnopqrst." }, "timestamp": "…" } ``` -`status ∈ "updated" | "up_to_date" | "skipped" | "disabled" | "unavailable" | +`status ∈ "updated" | "up_to_date" | "disabled" | "unavailable" | "not_pushable"` — `unavailable` means the read omitted this resource's block (nothing compared, nothing written); `not_pushable` means a pushable difference existed but none of it was encodable. `services[].service` is an OPAQUE IDENTIFIER (a dotted key mirroring — but not equal to — a `config.toml` path, plus the fixed string `"experimental.webhooks"`), never itself a config path to be parsed or compared. `project_ref`, `services[].service`, and `services[].status` are the established -contract; every other field is additive. When the branch gate declines (machine format -without `--yes`), the command fails (exit `1`) with the standard error envelope in place -of the success payload — see above. `secrets` partitions every declared secret +contract; every other field is additive. When consent is required but not +granted, the command fails (exit `1`) with the standard error envelope in place of the +success payload — see above. `secrets` partitions every declared secret (`changeSet.masked`) across its six buckets, reporting what was OBSERVED to happen — `sent` only when the auth write actually ran AND the container carrying that secret placed it in the request body. A `send`-decided secret whose write did not run -(declined prompt, or auth not written for any other reason) lands in `skipped` -instead; a `send`-decided secret whose container was dropped as `unencodable` (its +(auth was not written) lands in `skipped` instead; a `send`-decided secret whose container was dropped as `unencodable` (its required-together group unresolvable, even while OTHER auth fields still triggered a write) lands in neither `sent` nor `skipped` — it is reported in `unencodable` instead, -so it is never silently swallowed. `declined_addons` lists which paid MFA addon prompts -(`auth_mfa_phone`, -`auth_mfa_web_authn`) were declined this run. Paths are segment arrays — a record key +so it is never silently swallowed. Paths are segment arrays — a record key may itself contain a `.`. ## Notes - **`--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 <name-or-uuid>` this invocation) is gated behind a confirmation before any further network call; a target resolved from an EXPLICIT `--project-ref <name-or-uuid>` 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. +- **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 assert an identity the probe could not verify) 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. Every target shape goes through the same single consent question before the first write, so a branch resolved implicitly and one named with `--project-ref` are treated alike, and the question names the branch and its parent project. The target-echo line always prints regardless of which shape it is. - **Resolution runs BEFORE the config load**, not after: a `[remotes.<name>]` 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 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 <name-or-uuid>`; 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. - Auth email `content_path` resolution: `[auth.email.template.*]` and `[auth.email.notification.*]` paths are relative to the discovered project root; notification paths fall back to the legacy `supabase/`-relative location when the root-resolved file is missing. Notification HTML is read only when `enabled = true`. **Every resolved path — relative (after collapsing `..`), absolute, or reached through an in-root symlink — must stay inside the project root** (CLI-2320; symlinks are dereferenced with `realpathSync` before the check, so an in-root symlink pointing outside can't bypass it); a path that resolves outside it aborts with `Invalid config for auth.email.<kind>.<name>.content_path: resolves outside the project root (<resolved path>)`, before any file read. This containment check now lives centrally in `resolveEmailTemplateContentPath` (`config-validate.ts`) rather than in this command's own module (CLI-2339), and applies unconditionally to every caller of that resolver — config validation and `start`'s eager pre-Docker pass, not just `config push`. 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 42d5c9be74..66d837e74f 100644 --- a/apps/cli/src/commands/config/push/push.branch-target.ts +++ b/apps/cli/src/commands/config/push/push.branch-target.ts @@ -13,7 +13,7 @@ import { readProjectRefFile, tempPaths } from "../../../shared/config/temp-paths import { Output } from "../../../shared/output/output.service.ts"; /** - * What `ref` actually is, resolved for `config push`'s target-echo and branch confirmation gate: + * What `ref` actually is, resolved for `config push`'s target echo: * * - `"project"` — `ref` is the linked project itself; `name` is present when the live probe * found it. @@ -21,9 +21,8 @@ import { Output } from "../../../shared/output/output.service.ts"; * <name-or-uuid>`, or a live 404); `parentRef`/`parentName`/`branch` are each present only * when they could be determined. * - `"unknown"` — the live probe couldn't tell (a timeout, a transport failure, or an - * unexpected status). Never asserted as a branch, since an uncertain outcome must not gate an - * unattended push behind a confirmation that then auto-declines it; never asserted as a - * project either — the target-echo line says plainly that it doesn't know. + * unexpected status). Asserted as neither a branch nor a project — the target-echo line says + * plainly that it doesn't know. */ export type ConfigPushTarget = | { readonly kind: "project"; readonly ref: string; readonly name?: string } @@ -76,7 +75,7 @@ const ProbeOutcome = Data.taggedEnum<ProbeOutcome>(); /** * Resolves what `ref` actually is, so `config push` can tell the user whether they're pushing to - * the linked project or a branch, and gate a branch push behind confirmation. Never fails: this + * the linked project or a branch. Never fails: this * probe is diagnostic-only and must never abort a push that would otherwise succeed. * * A known branch name/UUID skips the live probe. Otherwise `GET /v1/projects/{ref}` (bounded at diff --git a/apps/cli/src/commands/config/push/push.handler.ts b/apps/cli/src/commands/config/push/push.handler.ts index 93ba72c41c..8c5d4265a6 100644 --- a/apps/cli/src/commands/config/push/push.handler.ts +++ b/apps/cli/src/commands/config/push/push.handler.ts @@ -112,7 +112,7 @@ import type { ConfigPushFlags } from "./push.command.ts"; import type { ConfigPushServiceResult } from "./push.types.ts"; /** The `services[].changes` union: encoded paths ∪ content extras ∪ secret paths the write - * actually sent, path-sorted. `sentSecretPaths` is `[]` for a declined/skipped write. */ + * actually sent, path-sorted. `sentSecretPaths` is `[]` for a skipped write. */ function pushServiceChanges( encoded: PushEncoded<unknown>, sentSecretPaths: ReadonlyArray<ReadonlyArray<string>>, @@ -420,21 +420,17 @@ export const configPush = Effect.fn("config.push")(function* (flags: ConfigPushF const changes = plan.changesByResource[resource]; switch (resource) { case "api": - return planResource( - changes, - encodeApiBody({ changes, local, remote }), - [], - (body) => - api.v1.updatePostgrestServiceConfig({ ref, ...body }).pipe( - Effect.catch( - mapHttpError({ - networkError: ConfigPushApiUpdateNetworkError, - statusError: ConfigPushApiUpdateStatusError, - networkMessage: (cause) => `failed to update API config: ${cause}`, - statusMessage: unexpectedStatusMessage, - }), - ), + return planResource(changes, encodeApiBody({ changes, local, remote }), [], (body) => + api.v1.updatePostgrestServiceConfig({ ref, ...body }).pipe( + Effect.catch( + mapHttpError({ + networkError: ConfigPushApiUpdateNetworkError, + statusError: ConfigPushApiUpdateStatusError, + networkMessage: (cause) => `failed to update API config: ${cause}`, + statusMessage: unexpectedStatusMessage, + }), ), + ), ); case "db.settings": return planResource( @@ -607,9 +603,9 @@ export const configPush = Effect.fn("config.push")(function* (flags: ConfigPushF ), ...(authPlan?.write === undefined ? [] - : PUSH_ADDON_GATES.filter((gate) => - pushEnablesAddon(authPlan.changes, gate, remote), - ).map((gate) => gate.costKey)), + : PUSH_ADDON_GATES.filter((gate) => pushEnablesAddon(authPlan.changes, gate, remote)).map( + (gate) => gate.costKey, + )), ]); const costLines = [...costKeys].flatMap((key) => { const item = cost.get(key); diff --git a/apps/cli/src/commands/functions/delete/SIDE_EFFECTS.md b/apps/cli/src/commands/functions/delete/SIDE_EFFECTS.md index 58df33833f..718e625bfa 100644 --- a/apps/cli/src/commands/functions/delete/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/functions/delete/SIDE_EFFECTS.md @@ -34,16 +34,19 @@ | `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 `<workdir>/supabase/.temp/project-ref`) | +| `SUPABASE_YES` | confirms the delete without a prompt, same as `--yes` | no (read from the shell only; a project `.env` value does not count) | | `SUPABASE_WORKDIR` | sets `<workdir>` for local Supabase temp files | no (falls back to `--workdir` -> nearest ancestor with `supabase/config.toml` or `supabase/config.json` -> cwd) | ## Exit Codes -| Code | Condition | -| ---- | ------------------------------------- | -| `0` | success | -| `1` | API error (non-2xx response) | -| `1` | authentication error (no token found) | -| `1` | network / connection failure | +| Code | Condition | +| ---- | ------------------------------------------------------------------------------------------------------------------------------------- | +| `0` | success | +| `1` | API error (non-2xx response) | +| `1` | authentication error (no token found) | +| `1` | network / connection failure | +| `1` | `HostedMutationDeclinedError` — the confirmation was declined; nothing is deleted | +| `1` | `HostedMutationConsentRequiredError` — no `--yes`/shell `SUPABASE_YES` on a non-interactive or machine-output run; nothing is deleted | ## Telemetry Events Fired @@ -55,20 +58,22 @@ ### `--output-format text` -Prints a success message after the function is deleted. +Prints a success message after the function is deleted. Stderr carries the `Target:` banner, then either the confirmation question (`delete Function <slug> on <target>?`, default No) on an interactive terminal or `Confirmed by --yes: delete Function <slug> on <target>` under `--yes`. ### `--output-format json` -Prints a structured success result with the function slug and project ref. +Prints a structured success result with the function slug and project ref, plus a `target` object (`kind: "linked"`, `project_ref`, `project_name`, `environment`, `parent_project_ref` when known). The run needs `--yes` or a shell `SUPABASE_YES`, because machine output never prompts. ### `--output-format stream-json` -Prints a structured success result with the function slug and project ref. +Prints the same structured success result, including `target`, and needs `--yes` or a shell `SUPABASE_YES`. ## Notes - Requires exactly one argument: the function slug/name. - Does NOT remove the function from the local filesystem. - Requires a linked project (`--project-ref` or linked project config). +- Asks one question before the DELETE request: `delete Function <slug>` on the announced target, default No. The slug is validated and the project resolved first, so a usage error never prompts. +- `--yes` or a `SUPABASE_YES` set in the shell skips the question. A `SUPABASE_YES` in a project `.env*` file does not count, and piped stdin answers are never read. - Runs natively in TypeScript through the Management API. - Refreshes the linked-project telemetry cache and flushes telemetry state after resolving a project ref. diff --git a/apps/cli/src/commands/functions/delete/delete.live.test.ts b/apps/cli/src/commands/functions/delete/delete.live.test.ts index 5c18fac981..6f8f079f2e 100644 --- a/apps/cli/src/commands/functions/delete/delete.live.test.ts +++ b/apps/cli/src/commands/functions/delete/delete.live.test.ts @@ -14,7 +14,7 @@ class FunctionsDeleteLiveError extends Data.TaggedError("FunctionsDeleteLiveErro function cleanupFunction(cliEffect: LiveCliEffect, slug: string, ref: string) { return Effect.gen(function* () { - const deleted = yield* cliEffect(["functions", "delete", slug, "--project-ref", ref]); + const deleted = yield* cliEffect(["functions", "delete", slug, "--project-ref", ref, "--yes"]); if ( deleted.exitCode !== 0 && !/not found|does not exist/i.test(`${deleted.stdout}\n${deleted.stderr}`) @@ -48,6 +48,7 @@ test("deletes a deployed function", ({ cliEffect, project, workspace }) => "--project-ref", project.ref, "--use-api", + "--yes", ]); if (deployed.exitCode !== 0) { return yield* new FunctionsDeleteLiveError({ @@ -61,6 +62,7 @@ test("deletes a deployed function", ({ cliEffect, project, workspace }) => slug, "--project-ref", project.ref, + "--yes", ]); expect(result.exitCode, result.stderr).toBe(0); expect(result.stdout).toContain("Deleted Function"); diff --git a/apps/cli/src/commands/functions/deploy/SIDE_EFFECTS.md b/apps/cli/src/commands/functions/deploy/SIDE_EFFECTS.md index fb5fb98fbc..0e0d35de51 100644 --- a/apps/cli/src/commands/functions/deploy/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/functions/deploy/SIDE_EFFECTS.md @@ -64,15 +64,16 @@ Docker bundling may pull or run the configured edge-runtime image and uses the ## Exit Codes -| Code | Condition | -| ---- | ----------------------------------------------------------------- | -| `0` | success | -| `1` | authentication / project-ref resolution | -| `1` | API error or unexpected HTTP status | -| `1` | build / bundle failure | -| `1` | invalid function slug or flag conflict | -| `1` | a function directory has `import_map.json` instead of `deno.json` | -| `1` | prune confirmation cancelled | +| Code | Condition | +| ---- | ------------------------------------------------------------------------------------------------------------------------------------------------- | +| `0` | success | +| `1` | authentication / project-ref resolution | +| `1` | API error or unexpected HTTP status | +| `1` | build / bundle failure | +| `1` | invalid function slug or flag conflict | +| `1` | a function directory has `import_map.json` instead of `deno.json` | +| `1` | `HostedMutationDeclinedError` — the consent question was declined | +| `1` | `HostedMutationConsentRequiredError` — no `--yes`/shell `SUPABASE_YES` on a non-interactive or machine-output run; nothing is deployed or deleted | ## Output @@ -111,8 +112,20 @@ The `--output`/`-o` flag does not change deploy output. - Bundles locally with Docker by default (`--use-docker` defaults to true and is hidden); `--use-api` selects server-side bundling, and a stopped Docker daemon falls back to it after a `WARNING: Docker is not running`. - Local Docker bundling mounts existing local values declared under an import map's `scopes` read-only, including targets outside the nearest Git root; each such out-of-root mount prints a `WARN` naming the host path. The mounted target itself is bound as declared; imports reached from inside an out-of-root target are not additionally bound. API source uploads retain their existing source-root restrictions. - `--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. +- Prints the `Target:` banner (project name, ref and, for a branch, the parent project) before + anything else. +- **Consent:** one question, `deploy N Function(s)` on the target, asked after planning and + before the first bundle, upload or delete. The default answer is No. `--prune` adds + `delete N Function(s) with --prune` to the same question, so a deploy with pruning never asks + twice. The question lists the Function slugs on stderr. +- `--yes` or a `SUPABASE_YES` set in the shell skips the question and echoes + `Confirmed by --yes: <action> on <target>` to stderr. A `SUPABASE_YES` in a project `.env*` + file does not count. Piped stdin answers are never read, and `json`/`stream-json` runs need + `--yes`. +- The question lists every enabled Function, including ones the upload later finds unchanged; + a deploy with nothing enabled and nothing to prune does not ask. +- `--prune` deletes deployed Functions that are not present locally, and runs even when every + local Function is already up to date. - **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 diff --git a/apps/cli/src/commands/functions/deploy/deploy.live.test.ts b/apps/cli/src/commands/functions/deploy/deploy.live.test.ts index c36b9e4190..3a47228130 100644 --- a/apps/cli/src/commands/functions/deploy/deploy.live.test.ts +++ b/apps/cli/src/commands/functions/deploy/deploy.live.test.ts @@ -21,7 +21,7 @@ class FunctionsDeployLiveError extends Data.TaggedError("FunctionsDeployLiveErro function cleanupFunction(cliEffect: LiveCliEffect, slug: string, ref: string) { return Effect.gen(function* () { - const deleted = yield* cliEffect(["functions", "delete", slug, "--project-ref", ref]); + const deleted = yield* cliEffect(["functions", "delete", slug, "--project-ref", ref, "--yes"]); if ( deleted.exitCode !== 0 && !/not found|does not exist/i.test(`${deleted.stdout}\n${deleted.stderr}`) @@ -53,7 +53,13 @@ describe("functions deploy (live)", () => { yield* fs.writeFileString(path.join(directory, "deno.json"), '{\n "imports": {}\n}\n'); const target = Effect.gen(function* () { - const result = yield* cliEffect(["functions", "deploy", "--project-ref", project.ref]); + const result = yield* cliEffect([ + "functions", + "deploy", + "--project-ref", + project.ref, + "--yes", + ]); expect(result.exitCode, result.stderr).toBe(0); expect(result.stdout).toMatch(/Deployed Function/i); diff --git a/apps/cli/src/commands/functions/download/SIDE_EFFECTS.md b/apps/cli/src/commands/functions/download/SIDE_EFFECTS.md index f344e3ee01..1e331507f3 100644 --- a/apps/cli/src/commands/functions/download/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/functions/download/SIDE_EFFECTS.md @@ -92,7 +92,7 @@ line only appears on the `--use-api` path. ### `--output-format json` -Prints a structured success result with the downloaded function slugs and project ref. On the +Prints a structured success result with the downloaded function slugs and project ref, plus a `target` object (`kind: "linked"`, `project_ref`, and when known `project_name`, `environment`, `parent_project_ref`). On the Docker-unbundle path, the `unbundle` container's own stdout is routed to stderr instead of stdout so it can't corrupt the envelope. @@ -107,6 +107,7 @@ Same envelope as `json` above (including on the Docker-unbundle path). 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). +- Prints the `Target:` banner (project name, ref and, for a branch, the parent project) to stderr before any download. It never asks for confirmation, so `--yes` and `SUPABASE_YES` have no effect. - The `--use-api` path rejects path traversal and symlink escapes before writing source files (`resolveDownloadDestination`/`ensureContainedPath`) — the Docker-unbundle path has no equivalent check of its own; it delegates the actual file writes to the `unbundle` subcommand running inside diff --git a/apps/cli/src/commands/functions/download/download.live.test.ts b/apps/cli/src/commands/functions/download/download.live.test.ts index c79c454517..4041dcd178 100644 --- a/apps/cli/src/commands/functions/download/download.live.test.ts +++ b/apps/cli/src/commands/functions/download/download.live.test.ts @@ -19,7 +19,7 @@ class FunctionsDownloadLiveError extends Data.TaggedError("FunctionsDownloadLive function cleanupFunction(cliEffect: LiveCliEffect, slug: string, ref: string) { return Effect.gen(function* () { - const deleted = yield* cliEffect(["functions", "delete", slug, "--project-ref", ref]); + const deleted = yield* cliEffect(["functions", "delete", slug, "--project-ref", ref, "--yes"]); if ( deleted.exitCode !== 0 && !/not found|does not exist/i.test(`${deleted.stdout}\n${deleted.stderr}`) @@ -51,7 +51,13 @@ describe("functions download (live)", () => { yield* fs.writeFileString(path.join(directory, "deno.json"), '{\n "imports": {}\n}\n'); const target = Effect.gen(function* () { - const deployed = yield* cliEffect(["functions", "deploy", "--project-ref", project.ref]); + const deployed = yield* cliEffect([ + "functions", + "deploy", + "--project-ref", + project.ref, + "--yes", + ]); requireLiveSuccess(deployed, "functions deploy setup"); yield* fs.remove(directory, { recursive: true, force: true }); diff --git a/apps/cli/src/commands/functions/list/SIDE_EFFECTS.md b/apps/cli/src/commands/functions/list/SIDE_EFFECTS.md index 68ad5aa315..dfb90ed6bc 100644 --- a/apps/cli/src/commands/functions/list/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/functions/list/SIDE_EFFECTS.md @@ -59,18 +59,19 @@ Prints a styled ASCII table with columns `ID`, `NAME`, `SLUG`, `STATUS`, `VERSIO ### `--output-format json` -Prints a structured success result shaped as `{ "functions": [...] }`. +Prints a structured success result shaped as `{ "functions": [...], "target": {...} }`, where `target` is `kind: "linked"` with `project_ref` and, when known, `project_name`, `environment` and `parent_project_ref`. ### `--output-format stream-json` -Prints a structured success result shaped as `{ "functions": [...] }`. +Prints the same structured success result, including `target`. ## Notes - Requires a linked project (`--project-ref`, `SUPABASE_PROJECT_ID`, or `<workdir>/supabase/.temp/project-ref`). - Uses the Management API. +- Prints the `Target:` banner (project name, ref and, for a branch, the parent project) to stderr before the request. It never asks for confirmation, so `--yes` and `SUPABASE_YES` have no effect. - `--output` behavior: - - `json` emits the raw array. - - `yaml` emits the raw array. + - `json` emits the raw array, without `target`. + - `yaml` emits the raw array, without `target`. - `toml` emits `{ functions = [...] }`. - `env` fails with `--output env flag is not supported`. diff --git a/apps/cli/src/commands/secrets/list/SIDE_EFFECTS.md b/apps/cli/src/commands/secrets/list/SIDE_EFFECTS.md index a2c0e947ff..b950faae69 100644 --- a/apps/cli/src/commands/secrets/list/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/secrets/list/SIDE_EFFECTS.md @@ -78,14 +78,16 @@ Fails immediately with `SecretsEnvNotSupportedError("--output env flag is not su ### `--output-format json` -Single JSON object emitted via `Output.success` with `{secrets: [...]}` as the `data` field. +Single JSON object emitted via `Output.success` with `{secrets: [...]}` as the `data` field, plus a `target` object (`kind: "linked"`, `project_ref`, and when known `project_name`, `environment`, `parent_project_ref`). ### `--output-format stream-json` -One `result` NDJSON event on success containing `{secrets: [...]}`. +One `result` NDJSON event on success containing `{secrets: [...]}` and `target`. ## Notes +- Prints the `Target:` banner (project name, ref and, for a branch, the parent project) to stderr before the request. It never asks for confirmation, so `--yes` and `SUPABASE_YES` have no effect. +- The `--output {json,yaml,toml}` renderings emit the secret array as-is, without `target`. - The `value` field returned by the API is a digest/hash of the secret, not the plaintext value. - Results are sorted alphabetically by `name` before any encoding. - Sends `User-Agent: SupabaseCLI/<version>` and Bearer auth. No `X-Supabase-Command` headers. diff --git a/apps/cli/src/commands/secrets/list/list.live.test.ts b/apps/cli/src/commands/secrets/list/list.live.test.ts index 17d5507a55..fc65bb98d0 100644 --- a/apps/cli/src/commands/secrets/list/list.live.test.ts +++ b/apps/cli/src/commands/secrets/list/list.live.test.ts @@ -25,6 +25,7 @@ test("lists a secret created on the remote project", ({ cliEffect, project }) => `${name}=live-value`, "--project-ref", project.ref, + "--yes", ]); yield* requireSuccess(created, "secrets set setup"); diff --git a/apps/cli/src/commands/secrets/set/SIDE_EFFECTS.md b/apps/cli/src/commands/secrets/set/SIDE_EFFECTS.md index a7a5de3082..511d63b32e 100644 --- a/apps/cli/src/commands/secrets/set/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/secrets/set/SIDE_EFFECTS.md @@ -34,26 +34,29 @@ | `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 `<workdir>/supabase/.temp/project-ref` then prompts on TTY) | +| `SUPABASE_YES` | confirms the set without a prompt, same as `--yes`; read from the shell only (a project `.env` value does not count) | | `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 -| Code | Condition | -| ---- | --------------------------------------------------------------------------------------- | -| `0` | success — secrets set on the linked project | -| `1` | `AccessTokenRequiredError` — no token in env/keyring/file | -| `1` | `InvalidAccessTokenError` — token violates `^sbp_(oauth_\|v0_)?[a-f0-9]{40}$` | -| `1` | `ProjectRefNotLinkedError` — `--project-ref` unset, env/file empty, and stdin not a TTY | -| `1` | `InvalidProjectRefError` — resolved ref violates `^[a-z]{20}$` | -| `1` | `SecretsNoArgumentsError` — no positional pairs and no entries from env-file or config | -| `1` | `InvalidSecretPairError` — positional argument missing `=` | -| `1` | `SecretsEnvFileOpenError` — `--env-file` cannot be opened | -| `1` | `SecretsEnvFileParseError` — `--env-file` cannot be parsed | -| `1` | `SecretsSetUnexpectedStatusError` — non-2xx response from POST | -| `1` | `SecretsSetNetworkError` — transport-level network failure | -| `1` | `CliConfigRemovedKeyError` — config uses a removed key, named with its replacement | -| `1` | `CliConfigRemovedEnvError` — non-empty `SUPABASE_EXPERIMENTAL_ORIOLEDB_VERSION` set | +| Code | Condition | +| ---- | ---------------------------------------------------------------------------------------------------------------------------------- | +| `0` | success — secrets set on the linked project | +| `1` | `AccessTokenRequiredError` — no token in env/keyring/file | +| `1` | `InvalidAccessTokenError` — token violates `^sbp_(oauth_\|v0_)?[a-f0-9]{40}$` | +| `1` | `ProjectRefNotLinkedError` — `--project-ref` unset, env/file empty, and stdin not a TTY | +| `1` | `InvalidProjectRefError` — resolved ref violates `^[a-z]{20}$` | +| `1` | `SecretsNoArgumentsError` — no positional pairs and no entries from env-file or config | +| `1` | `InvalidSecretPairError` — positional argument missing `=` | +| `1` | `SecretsEnvFileOpenError` — `--env-file` cannot be opened | +| `1` | `SecretsEnvFileParseError` — `--env-file` cannot be parsed | +| `1` | `HostedMutationDeclinedError` — the confirmation was declined; nothing is sent | +| `1` | `HostedMutationConsentRequiredError` — no `--yes`/shell `SUPABASE_YES` on a non-interactive or machine-output run; nothing is sent | +| `1` | `SecretsSetUnexpectedStatusError` — non-2xx response from POST | +| `1` | `SecretsSetNetworkError` — transport-level network failure | +| `1` | `CliConfigRemovedKeyError` — config uses a removed key, named with its replacement | +| `1` | `CliConfigRemovedEnvError` — non-empty `SUPABASE_EXPERIMENTAL_ORIOLEDB_VERSION` set | ## Telemetry Events Fired @@ -65,20 +68,22 @@ ### `--output pretty` (default) / `--output-format text` -Stdout: `Finished supabase secrets set.\n`. Stderr: one `Env name cannot start with SUPABASE_, skipping: <name>` line per filtered entry. +Stdout: `Finished supabase secrets set.\n`. Stderr: the `Target:` banner, one `Env name cannot start with SUPABASE_, skipping: <name>` line per filtered entry, then either the confirmation (`Secrets to set:` with the bulleted names, then `set N secret(s) on <target>?`, default No) on an interactive terminal or `Confirmed by --yes: set N secret(s) on <target>` under `--yes`. Secret values are never printed. The `--output {json,yaml,toml,env}` flags all collapse to the same text-mode `Finished` message. ### `--output-format json` -Single JSON object emitted via `Output.success` with `{project_ref, count}` as the `data` field. +Single JSON object emitted via `Output.success` with `{project_ref, count}` as the `data` field, plus a `target` object (`kind: "linked"`, `project_ref`, and when known `project_name`, `environment`, `parent_project_ref`). Needs `--yes` or a shell `SUPABASE_YES`, because machine output never prompts. ### `--output-format stream-json` -One `result` NDJSON event on success containing `{project_ref, count}`. +One `result` NDJSON event on success containing `{project_ref, count}` and `target`. Needs `--yes` or a shell `SUPABASE_YES`. ## Notes +- Asks one question before the first POST: `set N secret(s)` on the announced target, default No, listing the secret names (never values). It runs after the entries are merged, so a usage or parse error never prompts, and a decline sends nothing. +- `--yes` or a `SUPABASE_YES` set in the shell skips the question. A `SUPABASE_YES` in a project `.env*` file does not count, and piped stdin answers are never read. - 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 through the `CliConfigValues` resolved config (shell env > project `.env*` > config for `env(VAR)` references) + `resolveCliSubtree`. Resolved secret values arrive wrapped in `Redacted<string>`; 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). diff --git a/apps/cli/src/commands/secrets/set/set.live.test.ts b/apps/cli/src/commands/secrets/set/set.live.test.ts index c7598a4b88..6a4b372b5d 100644 --- a/apps/cli/src/commands/secrets/set/set.live.test.ts +++ b/apps/cli/src/commands/secrets/set/set.live.test.ts @@ -21,6 +21,7 @@ test("sets a secret on the remote project", ({ cliEffect, project }) => `${name}=live-value`, "--project-ref", project.ref, + "--yes", ]); expect(result.exitCode, result.stderr).toBe(0); expect(result.stdout).toContain("Finished"); diff --git a/apps/cli/src/commands/secrets/unset/SIDE_EFFECTS.md b/apps/cli/src/commands/secrets/unset/SIDE_EFFECTS.md index 6c8bcbbf59..cfe9ebb4ef 100644 --- a/apps/cli/src/commands/secrets/unset/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/secrets/unset/SIDE_EFFECTS.md @@ -32,29 +32,29 @@ | ----------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------- | | `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_YES` | confirms without a prompt, same as `--yes` | no | +| `SUPABASE_YES` | confirms without a prompt, same as `--yes`; read from the shell only (a project `.env` value does not count) | no | | `SUPABASE_PROJECT_ID` | project ref fallback when `--project-ref` is unset | no (also reads `<workdir>/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 -| Code | Condition | -| ---- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `0` | success — secrets unset from the linked project | -| `0` | `--dry-run` — the secrets that would be unset were printed, no DELETE call | -| `0` | `--all` resolved to zero non-`SUPABASE_` secrets (no DELETE call; text writes the stderr no-op, machine formats an empty result) | -| `1` | `SecretsNoArgumentsError` — no names and no `--all`; fails before the project ref resolves and before any API call | -| `1` | `SecretsUnsetInputError` — names given together with `--all`; fails before the project ref resolves and before any API call | -| `1` | `AccessTokenRequiredError` — no token in env/keyring/file | -| `1` | `InvalidAccessTokenError` — token violates `^sbp_(oauth_\|v0_)?[a-f0-9]{40}$` | -| `1` | `ProjectRefNotLinkedError` — `--project-ref` unset, env/file empty, and stdin not a TTY | -| `1` | `InvalidProjectRefError` — resolved ref violates `^[a-z]{20}$` | -| `1` | `SecretsUnsetConfirmationRequiredError` — no `--yes`/`SUPABASE_YES` and stdin is not a TTY or output is not interactive text; no secrets request (the linked-project cache fill may still run) | -| `1` | `SecretsListUnexpectedStatusError` — non-2xx response from GET (`--all` path) | -| `1` | `SecretsListNetworkError` — GET transport failure (`--all` path) | -| `1` | `SecretsUnsetCancelledError` — user declined the confirmation prompt | -| `1` | `SecretsUnsetUnexpectedStatusError` — non-2xx response from DELETE | -| `1` | `SecretsUnsetNetworkError` — DELETE transport failure | +| Code | Condition | +| ---- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `0` | success — secrets unset from the linked project | +| `0` | `--dry-run` — the secrets that would be unset were printed, no DELETE call | +| `0` | `--all` resolved to zero non-`SUPABASE_` secrets (no DELETE call; text writes the stderr no-op, machine formats an empty result) | +| `1` | `SecretsNoArgumentsError` — no names and no `--all`; fails before the project ref resolves and before any API call | +| `1` | `SecretsUnsetInputError` — names given together with `--all`; fails before the project ref resolves and before any API call | +| `1` | `AccessTokenRequiredError` — no token in env/keyring/file | +| `1` | `InvalidAccessTokenError` — token violates `^sbp_(oauth_\|v0_)?[a-f0-9]{40}$` | +| `1` | `ProjectRefNotLinkedError` — `--project-ref` unset, env/file empty, and stdin not a TTY | +| `1` | `InvalidProjectRefError` — resolved ref violates `^[a-z]{20}$` | +| `1` | `HostedMutationConsentRequiredError` — no `--yes`/shell `SUPABASE_YES` and stdin is not a TTY or output is not interactive text; nothing is unset. An explicit-name run makes no secrets request; `--all` has already listed the secrets (GET) to build the question | +| `1` | `SecretsListUnexpectedStatusError` — non-2xx response from GET (`--all` path) | +| `1` | `SecretsListNetworkError` — GET transport failure (`--all` path) | +| `1` | `HostedMutationDeclinedError` — the confirmation was declined; nothing is unset | +| `1` | `SecretsUnsetUnexpectedStatusError` — non-2xx response from DELETE | +| `1` | `SecretsUnsetNetworkError` — DELETE transport failure | ## Telemetry Events Fired @@ -66,7 +66,7 @@ ### `--output pretty` (default) / `--output-format text` -Stdout: `Finished supabase secrets unset.\n`. Stderr: the confirmation prompt (`Do you want to unset these function secrets from project <ref>?`, the bulleted names, then `[y/N]`; default No) on an interactive TTY without `--yes`, the same label with `[y/N] y` echoed with `--yes`, or the no-op message when `--all` resolves to no secrets. +Stdout: `Finished supabase secrets unset.\n`. Stderr: the `Target:` banner, then either the confirmation (`Secrets to unset:` with the bulleted names, then `unset N secret(s) on <target>?`, default No) on an interactive terminal or `Confirmed by --yes: unset N secret(s) on <target>` under `--yes`. `--dry-run` stdout, with no `Finished` line and no prompt: @@ -84,6 +84,8 @@ Single JSON object emitted via `Output.success` with `{project_ref, count, names With `--dry-run`, the message is `Dry run: no secrets were unset.` and `data` is `{project_ref, count, names, dry_run: true}`. +Every `data` object also carries a `target` object (`kind: "linked"`, `project_ref`, and when known `project_name`, `environment`, `parent_project_ref`). A real unset needs `--yes` or a shell `SUPABASE_YES`, because machine output never prompts. + When `--all` resolves to no secrets, the message is `You have not set any function secrets, nothing to do.` and `data` is `{project_ref, count: 0, names: [], dry_run}`. ### `--output-format stream-json` @@ -94,7 +96,8 @@ One `result` NDJSON event on success containing `{project_ref, count, names, dry - Names and `--all` are mutually exclusive; with neither, the command fails. `--all` unsets every secret that does not have a `SUPABASE_` prefix. - Argument validation runs before the project ref resolves, so a usage error never opens the project picker and makes no API call. -- The prompt defaults to No and names the project ref. `--yes` (or `SUPABASE_YES`) skips the prompt with a stderr label echo. -- Without `--yes`, a run whose stdin is not a TTY, or whose output is not interactive text (`json`, `stream-json`), fails before any secrets request. Piped stdin answers are not read. +- Asks one question before the DELETE request: `unset N secret(s)` on the announced target, default No, listing the names. It runs after the names are resolved (`--all` lists the secrets first), so a usage error never prompts and a decline deletes nothing. +- `--yes` or a `SUPABASE_YES` set in the shell skips the question with a stderr echo. A `SUPABASE_YES` in a project `.env*` file does not count, and piped stdin answers are never read. +- Without `--yes`, a run whose stdin is not a TTY, or whose output is not interactive text (`json`, `stream-json`), fails before any DELETE. - `--dry-run` never prompts, never calls DELETE, and works without `--yes` in every output mode. With `--all` it still calls GET to list the secrets. - Sends `User-Agent: SupabaseCLI/<version>` and Bearer auth. No `X-Supabase-Command` headers. diff --git a/apps/cli/src/commands/secrets/unset/unset.live.test.ts b/apps/cli/src/commands/secrets/unset/unset.live.test.ts index 63e18cc439..a657a75e8f 100644 --- a/apps/cli/src/commands/secrets/unset/unset.live.test.ts +++ b/apps/cli/src/commands/secrets/unset/unset.live.test.ts @@ -21,6 +21,7 @@ test("unsets a secret from the remote project", ({ cliEffect, project }) => `${name}=live-value`, "--project-ref", project.ref, + "--yes", ]); yield* requireSuccess(created, "secrets set setup"); From d4ad440db89eb2fd058d5d65bd1dd0c5d6e5e71e Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Thu, 15 Oct 2026 14:44:28 +0100 Subject: [PATCH 48/92] chore(cli): keep retired consent error classes exported for knip and fix test formatting (CLI-2695) The prompt-era cancelled/confirmation-required classes are no longer thrown but stay defined until the error-tag snapshot is updated. Format the earlier test commits and drop an unused import. --- .../src/commands/config/push/push.errors.ts | 10 +- .../config/push/push.format.unit.test.ts | 4 +- .../config/push/push.integration.test.ts | 100 ++++++++++-------- .../deploy/deploy.integration.test.ts | 29 ++--- .../src/commands/secrets/secrets.errors.ts | 4 + .../secrets/set/set.integration.test.ts | 4 +- .../secrets/unset/unset.integration.test.ts | 19 ++-- .../cli/src/shared/functions/deploy.errors.ts | 2 + 8 files changed, 92 insertions(+), 80 deletions(-) diff --git a/apps/cli/src/commands/config/push/push.errors.ts b/apps/cli/src/commands/config/push/push.errors.ts index 45be9f6872..dddbf504b8 100644 --- a/apps/cli/src/commands/config/push/push.errors.ts +++ b/apps/cli/src/commands/config/push/push.errors.ts @@ -120,14 +120,8 @@ export class ConfigPushBranchResolveStatusError extends Data.TaggedError( // transport failure, or an unexpected status) degrades to an uncertain target rather than // aborting the push. There is no error class for this probe — it never fails the command. -/** - * The user declined the branch confirmation gate. Mirrors `projects delete`/`db reset`'s - * identical top-level "are you sure" cancellation shape: declining fails (exit 1), matching - * every other top-level confirmation gate in this codebase; the per-service `keep()` prompts - * below are unrelated and still exit 0 on decline. `suggestion` always names the - * `--yes`/`SUPABASE_YES` escape hatch, since a machine-mode or non-TTY decline never renders the - * interactive prompt's own inline hint. - */ +// TODO(CLI-2695): delete with its entry in the error-tag snapshot once consent replaced it. +/** @public */ export class ConfigPushCancelledError extends Data.TaggedError("ConfigPushCancelledError")<{ readonly message: string; readonly suggestion?: string; 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 1be4789423..e9cc47e4d8 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 @@ -784,9 +784,7 @@ describe("pushSummaryMessage", () => { expect( pushSummaryMessage({ projectRef: "abcdefghijklmnopqrst", - services: [ - { service: "api", status: "updated", changes: [["api", "max_rows"]] }, - ], + services: [{ service: "api", status: "updated", changes: [["api", "max_rows"]] }], unsupported: [["db", "major_version"]], unencodable: [], forced: [], 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 2136564556..98b23b70be 100644 --- a/apps/cli/src/commands/config/push/push.integration.test.ts +++ b/apps/cli/src/commands/config/push/push.integration.test.ts @@ -48,7 +48,6 @@ import { HostedMutationDeclinedError, } from "../../../command-internal/hosted-consent/hosted-consent.errors.ts"; import { TargetPolicy } from "../../../command-internal/target/target-policy.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"; @@ -2190,20 +2189,23 @@ secret = "env(MISSING_CAPTCHA_SECRET)" }).pipe(Effect.provide(layer)); }); - it.effect("an enroll-only flip (verify_enabled absent) lists the addon's price before asking", () => { - const toml = `project_id = "test"\n[auth.mfa.phone]\nenroll_enabled = true\n`; - const { layer, out } = setupService({ - toml, - confirm: [true], - addons: PHONE_MFA_ADDON, - v1: { updateAuthServiceConfig: () => Effect.succeed({}) }, - }); - return Effect.gen(function* () { - yield* configPush({ projectRef: Option.none() }); - expect(out.stderrText).toContain("Enabling Phone MFA will cost you $75.00/ month.\n"); - expect(out.promptConfirmCalls).toHaveLength(1); - }).pipe(Effect.provide(layer)); - }); + it.effect( + "an enroll-only flip (verify_enabled absent) lists the addon's price before asking", + () => { + const toml = `project_id = "test"\n[auth.mfa.phone]\nenroll_enabled = true\n`; + const { layer, out } = setupService({ + toml, + confirm: [true], + addons: PHONE_MFA_ADDON, + v1: { updateAuthServiceConfig: () => Effect.succeed({}) }, + }); + return Effect.gen(function* () { + yield* configPush({ projectRef: Option.none() }); + expect(out.stderrText).toContain("Enabling Phone MFA will cost you $75.00/ month.\n"); + expect(out.promptConfirmCalls).toHaveLength(1); + }).pipe(Effect.provide(layer)); + }, + ); it.effect( "an enroll-only flip (verify_enabled absent) pushes mfa_phone_enroll_enabled when accepted", @@ -3093,18 +3095,21 @@ describe("config push branch/project target detection (CLI-2168)", () => { }).pipe(Effect.provide(layer)); }); - it.effect("--yes confirms a branch push and echoes the confirmation with the branch target", () => { - const { layer, out, api } = setupLinkedBranchPush({ yes: true }); - return Effect.gen(function* () { - yield* configPush({ projectRef: Option.none() }); - expect(out.stderrText).toContain( - `Confirmed by --yes: push config changes to API on project My App (${PARENT_REF}) · branch (${BRANCH_REF})`, - ); - expect(api.requests.some((r) => r.method === "PATCH" && r.url.includes("/postgrest"))).toBe( - true, - ); - }).pipe(Effect.provide(layer)); - }); + it.effect( + "--yes confirms a branch push and echoes the confirmation with the branch target", + () => { + const { layer, out, api } = setupLinkedBranchPush({ yes: true }); + return Effect.gen(function* () { + yield* configPush({ projectRef: Option.none() }); + expect(out.stderrText).toContain( + `Confirmed by --yes: push config changes to API on project My App (${PARENT_REF}) · branch (${BRANCH_REF})`, + ); + expect(api.requests.some((r) => r.method === "PATCH" && r.url.includes("/postgrest"))).toBe( + true, + ); + }).pipe(Effect.provide(layer)); + }, + ); it.effect("json output mode with --yes reports the branch target in the machine payload", () => { const { layer, out } = setupLinkedBranchPush({ format: "json", yes: true }); @@ -3400,23 +3405,26 @@ describe("config push --project-ref branch name/UUID resolution (CLI-2289)", () }, ); - it.effect("--project-ref <uuid> is confirmed like any target: unattended without --yes fails", () => { - const { layer, out, api } = setup({ - toml: BRANCH_PUSH_TOML, - yes: false, - stdinIsTty: false, - projectId: Option.none(), - project: { status: 404, body: {} }, - branchById: { status: 200, body: BRANCH_CONFIG }, - v2: { status: 200, body: v2Response({ ref: UUID_TARGET_REF }) }, - }); - return Effect.gen(function* () { - const error = yield* configPush({ projectRef: Option.some(BRANCH_UUID) }).pipe(Effect.flip); - expect(error).toBeInstanceOf(HostedMutationConsentRequiredError); - expect(out.stderrText).toContain(`Pushing config to branch: ${UUID_TARGET_REF}`); - expect(api.requests.some((r) => r.method === "PATCH")).toBe(false); - }).pipe(Effect.provide(layer)); - }); + it.effect( + "--project-ref <uuid> is confirmed like any target: unattended without --yes fails", + () => { + const { layer, out, api } = setup({ + toml: BRANCH_PUSH_TOML, + yes: false, + stdinIsTty: false, + projectId: Option.none(), + project: { status: 404, body: {} }, + branchById: { status: 200, body: BRANCH_CONFIG }, + v2: { status: 200, body: v2Response({ ref: UUID_TARGET_REF }) }, + }); + return Effect.gen(function* () { + const error = yield* configPush({ projectRef: Option.some(BRANCH_UUID) }).pipe(Effect.flip); + expect(error).toBeInstanceOf(HostedMutationConsentRequiredError); + expect(out.stderrText).toContain(`Pushing config to branch: ${UUID_TARGET_REF}`); + expect(api.requests.some((r) => r.method === "PATCH")).toBe(false); + }).pipe(Effect.provide(layer)); + }, + ); it.effect("--project-ref <unknown-branch-name> fails with a branches-list suggestion", () => { const { layer, api, telemetry, linkedProjectCache } = setup({ @@ -3604,8 +3612,6 @@ describeHostedConsent("config push", { ); }).pipe(Effect.provide(BunServices.layer)), invoke: () => ({ - run: configPush({ projectRef: Option.none() }).pipe( - Effect.provide(cliConfigValuesTestLayer), - ), + run: configPush({ projectRef: Option.none() }).pipe(Effect.provide(cliConfigValuesTestLayer)), }), }); 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 ebe34ab190..2e75dde6e8 100644 --- a/apps/cli/src/commands/functions/deploy/deploy.integration.test.ts +++ b/apps/cli/src/commands/functions/deploy/deploy.integration.test.ts @@ -1212,22 +1212,25 @@ describe("functions deploy", () => { const mutations = (api: { readonly requests: ReadonlyArray<{ readonly method: string }> }) => api.requests.filter((request) => request.method !== "GET"); - it.effect("deletes pruned functions under --yes after echoing one combined confirmation", () => { - const { out, api, layer } = setupPrune({ yes: true }); + it.effect( + "deletes pruned functions under --yes after echoing one combined confirmation", + () => { + const { out, api, layer } = setupPrune({ yes: true }); - return Effect.gen(function* () { - yield* writeCliConfig(tempRoot.current); - yield* writeLocalFunction(tempRoot.current, "hello-world"); + return Effect.gen(function* () { + yield* writeCliConfig(tempRoot.current); + yield* writeLocalFunction(tempRoot.current, "hello-world"); - yield* functionsDeploy({ ...baseFlags, prune: true }); + yield* functionsDeploy({ ...baseFlags, prune: true }); - expect(stripControlSequences(out.stderrText)).toContain( - "Confirmed by --yes: deploy 1 Function and delete 1 Function with --prune on project", - ); - expect(out.promptConfirmCalls).toHaveLength(0); - expect(api.requests.some((request) => request.method === "DELETE")).toBe(true); - }).pipe(Effect.provide(layer), Effect.ensuring(removeTempRoot)); - }); + expect(stripControlSequences(out.stderrText)).toContain( + "Confirmed by --yes: deploy 1 Function and delete 1 Function with --prune on project", + ); + expect(out.promptConfirmCalls).toHaveLength(0); + expect(api.requests.some((request) => request.method === "DELETE")).toBe(true); + }).pipe(Effect.provide(layer), Effect.ensuring(removeTempRoot)); + }, + ); it.effect("asks one question covering the deploy and the prune before deploying", () => { const { out, api, layer } = setupPrune({ diff --git a/apps/cli/src/commands/secrets/secrets.errors.ts b/apps/cli/src/commands/secrets/secrets.errors.ts index 3c2f951b98..9f932d319f 100644 --- a/apps/cli/src/commands/secrets/secrets.errors.ts +++ b/apps/cli/src/commands/secrets/secrets.errors.ts @@ -133,6 +133,8 @@ export class SecretsUnsetInputError extends Data.TaggedError("SecretsUnsetInputE } } +// TODO(CLI-2695): delete with its entry in the error-tag snapshot once consent replaced it. +/** @public */ export class SecretsUnsetConfirmationRequiredError extends Data.TaggedError( "SecretsUnsetConfirmationRequiredError", )<{ @@ -152,6 +154,8 @@ export class SecretsEnvNotSupportedError extends Data.TaggedError("SecretsEnvNot } } +// TODO(CLI-2695): delete with its entry in the error-tag snapshot once consent replaced it. +/** @public */ export class SecretsUnsetCancelledError extends Data.TaggedError("SecretsUnsetCancelledError")<{ readonly message: string; }> { 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 7bd75563e9..3b55124568 100644 --- a/apps/cli/src/commands/secrets/set/set.integration.test.ts +++ b/apps/cli/src/commands/secrets/set/set.integration.test.ts @@ -1070,6 +1070,8 @@ describeHostedConsent("secrets set", { projectRef: Option.none(), envFile: Option.none(), secrets: ["FOO=bar"], - }).pipe(Effect.provide(Layer.mergeAll(cliConfigValuesTestLayer, mockDebugLoggerTracked().layer))), + }).pipe( + Effect.provide(Layer.mergeAll(cliConfigValuesTestLayer, mockDebugLoggerTracked().layer)), + ), }), }); diff --git a/apps/cli/src/commands/secrets/unset/unset.integration.test.ts b/apps/cli/src/commands/secrets/unset/unset.integration.test.ts index 61858c2eaa..b195ddcfeb 100644 --- a/apps/cli/src/commands/secrets/unset/unset.integration.test.ts +++ b/apps/cli/src/commands/secrets/unset/unset.integration.test.ts @@ -175,14 +175,17 @@ describe("secrets unset integration", () => { }).pipe(Effect.provide(layer)); }); - it.effect("fails with HostedMutationDeclinedError and sends no DELETE when the user declines", () => { - const { layer, api } = setup({ stdinIsTty: true, confirm: false }); - return Effect.gen(function* () { - const error = yield* unset({ names: ["FOO"] }).pipe(Effect.flip); - expect(error).toBeInstanceOf(HostedMutationDeclinedError); - expect(api.requests).toEqual([]); - }).pipe(Effect.provide(layer)); - }); + it.effect( + "fails with HostedMutationDeclinedError and sends no DELETE when the user declines", + () => { + const { layer, api } = setup({ stdinIsTty: true, confirm: false }); + return Effect.gen(function* () { + const error = yield* unset({ names: ["FOO"] }).pipe(Effect.flip); + expect(error).toBeInstanceOf(HostedMutationDeclinedError); + expect(api.requests).toEqual([]); + }).pipe(Effect.provide(layer)); + }, + ); }); describe("--all", () => { diff --git a/apps/cli/src/shared/functions/deploy.errors.ts b/apps/cli/src/shared/functions/deploy.errors.ts index ce7e18d4d0..e43e07ed37 100644 --- a/apps/cli/src/shared/functions/deploy.errors.ts +++ b/apps/cli/src/shared/functions/deploy.errors.ts @@ -33,6 +33,8 @@ export class NoFunctionsToDeployError extends Data.TaggedError("NoFunctionsToDep } } +// TODO(CLI-2695): delete with its entry in the error-tag snapshot once consent replaced it. +/** @public */ export class FunctionDeployCancelledError extends Data.TaggedError("FunctionDeployCancelledError")<{ readonly message: string; }> { From 51688b77682db97c3304bb57fbadbd5d3a1054e4 Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Thu, 15 Oct 2026 14:44:37 +0100 Subject: [PATCH 49/92] test(cli): update test db and db test for resolved targets and hosted consent --- .../test-db.integration.test.ts | 204 ++++++++++++------ .../test-db.native.integration.test.ts | 29 ++- .../commands/db/test/test.integration.test.ts | 102 ++++++--- 3 files changed, 235 insertions(+), 100 deletions(-) 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 3d37b7c71b..145a9d5a38 100644 --- a/apps/cli/src/command-internal/test-db.integration.test.ts +++ b/apps/cli/src/command-internal/test-db.integration.test.ts @@ -2,7 +2,7 @@ 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 { ConfigProvider, Effect, Exit, Layer, Option } from "effect"; +import { Cause, ConfigProvider, Effect, Exit, Layer, Option } from "effect"; import { vi } from "vitest"; vi.mock("node:net", (importOriginal) => @@ -12,11 +12,12 @@ vi.mock("node:net", (importOriginal) => ]).then(([helper, net]) => helper.refuseInvalidDbHosts(net)), ); -import { mockOutput } from "../../tests/helpers/mocks.ts"; +import { mockOutput, mockTty } from "../../tests/helpers/mocks.ts"; import { VALID_REF, VALID_TOKEN, mockCommandSettings, + withConfigEnv, mockTelemetryStateTracked, useTempWorkdir, } from "../../tests/helpers/command-mocks.ts"; @@ -30,10 +31,22 @@ import { type StubbedRequest, } from "../../tests/helpers/permission-real-layers.ts"; import { cliConfigValuesTestLayer } from "../../tests/helpers/config-values-layer.ts"; +import { describeHostedConsent } from "../../tests/helpers/hosted-consent.ts"; import { CliArgs } from "../shared/cli/cli-args.service.ts"; -import { DebugFlag, DnsResolverFlag, NetworkIdFlag } from "./global-flags.ts"; +import { + DebugFlag, + DnsResolverFlag, + NetworkIdFlag, + OutputFlag, + YesFlag, +} from "./global-flags.ts"; +import { ProjectRefResolver } from "../config/project-ref.service.ts"; import { RuntimeInfo } from "../shared/runtime/runtime-info.service.ts"; import { DbConfigResolver } from "./db-config.service.ts"; +import type { DbConnectionInput, ResolvedDbConnection } from "./db-config.types.ts"; +import { HostedMutationDeclinedError } from "./hosted-consent/hosted-consent.errors.ts"; +import { TargetPolicy } from "./target/target-policy.ts"; +import { TargetProjectRefConflictError, TargetSelectorConflictError } from "./target/target.errors.ts"; import { DbConnectError, DbExecError } from "./db-connection.errors.ts"; import { DbConnection, type DbSession, type PgConnInput } from "./db-connection.service.ts"; import { DockerRunError } from "./docker-run.errors.ts"; @@ -67,29 +80,42 @@ const stackApiStub = Layer.succeed(StackApi, { }); function mockResolver(opts: { conn?: PgConnInput; isLocal?: boolean } = {}) { - const calls: Array<{ - readonly connType: string; - readonly linkedProjectRef: Option.Option<string>; - }> = []; + const inputs: Array<DbConnectionInput> = []; const layer = Layer.succeed(DbConfigResolver, { - resolve: (flags) => { - calls.push({ - connType: flags.connType ?? "", - linkedProjectRef: flags.linkedProjectRef ?? Option.none(), - }); - return Effect.succeed({ conn: opts.conn ?? LOCAL_CONN, isLocal: opts.isLocal ?? true }); + resolve: () => Effect.die("resolve is not used by this test"), + resolveConnection: (input: DbConnectionInput) => { + inputs.push(input); + return Effect.succeed({ + conn: opts.conn ?? LOCAL_CONN, + isLocal: opts.isLocal ?? input.target.kind === "local", + ref: input.target.kind === "linked" ? Option.some(input.target.ref) : Option.none(), + target: input.target, + } satisfies ResolvedDbConnection); }, - resolveConnection: () => Effect.die("resolveConnection is not used by this test"), resolvePoolerFallback: () => Effect.succeed(Option.none()), }); return { layer, - get calls() { - return calls; + get inputs() { + return inputs; }, }; } +const projectRefLayer = Layer.succeed(ProjectRefResolver, { + resolve: () => Effect.die("resolve is not used by this test"), + resolveForLink: () => Effect.succeed(VALID_REF), + resolveRef: (flagValue: Option.Option<string>) => + Effect.succeed( + Option.isSome(flagValue) && flagValue.value.length > 0 + ? { ref: flagValue.value, source: "flag" as const, matchesLinkedRef: false } + : { ref: VALID_REF, source: "file" as const, matchesLinkedRef: true }, + ), + resolveOptional: () => Effect.succeed(Option.some(VALID_REF)), + loadProjectRef: () => Effect.die("loadProjectRef is not used by this test"), + promptProjectRef: () => Effect.succeed(VALID_REF), +}); + function mockDbConnection(opts: { existed?: boolean; connectFails?: boolean; @@ -267,14 +293,20 @@ interface SetupOpts { workdir?: string; dnsResolver?: "native" | "https"; dockerInstalled?: boolean; - /** Raw CLI args for `CliArgs` — drives DB target selection (Changed-based). */ - args?: ReadonlyArray<string>; /** Runs the real `DbConfigResolver` against `workdir`. */ realLayers?: boolean; + /** `--yes`; on by default so hosted runs reach pg_prove. */ + yes?: boolean; + interactive?: boolean; + confirm?: ReadonlyArray<boolean>; } function setup(opts: SetupOpts = {}) { - const out = mockOutput({ format: opts.format ?? "text" }); + const out = mockOutput({ + format: opts.format ?? "text", + interactive: opts.interactive, + promptConfirmResponses: opts.confirm, + }); const telemetry = mockTelemetryStateTracked(); const resolver = mockResolver({ conn: opts.conn, isLocal: opts.isLocal }); const connection = mockDbConnection(opts); @@ -303,7 +335,12 @@ function setup(opts: SetupOpts = {}) { opts.networkId === undefined ? Option.none() : Option.some(opts.networkId), ), Layer.succeed(DnsResolverFlag, opts.dnsResolver ?? "native"), - Layer.succeed(CliArgs, { args: opts.args ?? [] }), + Layer.succeed(CliArgs, { args: [] }), + projectRefLayer, + Layer.succeed(TargetPolicy, { group: "selectable", consent: "no" }), + Layer.succeed(YesFlag, opts.yes ?? true), + Layer.succeed(OutputFlag, Option.none()), + mockTty({ stdinIsTty: opts.interactive ?? false }), BunServices.layer, ); return { layer, out, telemetry, connection, docker, bundled, resolver }; @@ -313,7 +350,7 @@ const flags = (over: Partial<Parameters<typeof testDb>[0]> = {}) => ({ paths: over.paths ?? [], dbUrl: over.dbUrl ?? Option.none<string>(), linked: over.linked ?? false, - local: over.local ?? true, + local: over.local ?? false, projectRef: over.projectRef ?? Option.none<string>(), }); @@ -419,7 +456,6 @@ describe("test db integration", () => { const { layer, docker, connection } = setup({ conn: REMOTE_CONN, isLocal: false, - args: ["--db-url=postgres://x"], }); return Effect.gen(function* () { yield* testDb(flags({ dbUrl: Option.some("postgres://x") })); @@ -436,7 +472,6 @@ describe("test db integration", () => { const { layer, docker, bundled } = setup({ conn: LOCAL_CONN, isLocal: true, - args: ["--db-url=postgresql://postgres:postgres@127.0.0.1:54322/postgres"], }); return Effect.gen(function* () { yield* testDb( @@ -459,7 +494,6 @@ describe("test db integration", () => { conn: { ...LOCAL_CONN, host: "db.internal" }, isLocal: true, dockerInstalled: false, - args: ["--db-url=postgresql://postgres:postgres@db.internal:54322/postgres"], }); return Effect.gen(function* () { yield* testDb( @@ -479,7 +513,6 @@ describe("test db integration", () => { conn: REMOTE_CONN, isLocal: false, dockerInstalled: false, - args: ["--linked"], }); return Effect.gen(function* () { yield* testDb(flags({ local: false, linked: true })); @@ -494,7 +527,6 @@ describe("test db integration", () => { conn: REMOTE_CONN, isLocal: false, dnsResolver: "https", - args: ["--db-url=postgres://x"], }); return Effect.gen(function* () { yield* testDb(flags({ dbUrl: Option.some("postgres://x") })); @@ -670,66 +702,56 @@ describe("test db integration", () => { }).pipe(Effect.provide(layer)); }); - it.live("rejects mutually exclusive connection flags (--linked + --local via args)", () => { - const { layer } = setup({ args: ["--linked", "--local"] }); + it.live("rejects mutually exclusive target flags (--linked + --local)", () => { + const { layer, resolver, connection } = setup(); return Effect.gen(function* () { - const exit = yield* Effect.exit(testDb(flags())); + const exit = yield* Effect.exit(testDb(flags({ linked: true, local: true }))); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain( - "if any flags in the group [db-url linked local] are set none of the others can be; [linked local] were all set", - ); + expect(Cause.squash(exit.cause)).toBeInstanceOf(TargetSelectorConflictError); } + expect(resolver.inputs).toEqual([]); + expect(connection.connectCalls).toEqual([]); }).pipe(Effect.provide(layer)); }); - it.live("--local=false --linked fails with mutual-exclusion (sorted set [linked local])", () => { - const { layer } = setup({ args: ["--local=false", "--linked"] }); + it.live("treats --linked=false as absent and runs against the default local target", () => { + const { layer, resolver } = setup(); return Effect.gen(function* () { - const exit = yield* Effect.exit(testDb(flags())); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain( - "if any flags in the group [db-url linked local] are set none of the others can be; [linked local] were all set", - ); - } + yield* testDb(flags({ linked: false, local: false })); + expect(resolver.inputs[0]?.target.kind).toBe("local"); }).pipe(Effect.provide(layer)); }); - it.live("--linked=false routes to the linked branch (Changed, not value)", () => { - const { layer } = setup({ args: ["--linked=false"] }); + it.live("follows a remote workspace target when no selector flag is given", () => { + const { layer, resolver } = setup({ conn: REMOTE_CONN, isLocal: false }); return Effect.gen(function* () { - // No explicit assertion: success means routing reached resolver.resolve - // with connType "linked" instead of failing on mutual exclusion or the - // local-target guard. - yield* testDb(flags()); + yield* withConfigEnv({ SUPABASE_TARGET: "remote" }, testDb(flags())); + expect(resolver.inputs[0]?.target.kind).toBe("linked"); }).pipe(Effect.provide(layer)); }); - it.live("tests the project given via --project-ref --linked", () => { - // Only reaches the resolver as `linkedProjectRef` when --linked is set. + it.live("selects the project given via --project-ref alone", () => { const FLAG_REF = "flagflagflagflagflag"; - const { layer, resolver } = setup({ conn: REMOTE_CONN, isLocal: false, args: ["--linked"] }); + const { layer, resolver } = setup({ conn: REMOTE_CONN, isLocal: false }); return Effect.gen(function* () { - yield* testDb(flags({ linked: true, local: false, projectRef: Option.some(FLAG_REF) })); - expect(resolver.calls[0]?.connType).toBe("linked"); - expect(resolver.calls[0]?.linkedProjectRef).toEqual(Option.some(FLAG_REF)); + yield* testDb(flags({ projectRef: Option.some(FLAG_REF) })); + expect(resolver.inputs[0]?.target).toMatchObject({ kind: "linked", ref: FLAG_REF }); }).pipe(Effect.provide(layer)); }); - it.live("rejects --project-ref on the default local target", () => { - // No explicit --local/--db-url flag; the guard must fire from the default alone. + it.live("rejects --project-ref combined with --local", () => { const FLAG_REF = "flagflagflagflagflag"; const { layer, connection, docker, resolver } = setup(); return Effect.gen(function* () { - const exit = yield* Effect.exit(testDb(flags({ projectRef: Option.some(FLAG_REF) }))); + const exit = yield* Effect.exit( + testDb(flags({ local: true, projectRef: Option.some(FLAG_REF) })), + ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain( - "--project-ref only applies when targeting the linked project; use it with --linked (not --local or --db-url)", - ); + expect(Cause.squash(exit.cause)).toBeInstanceOf(TargetProjectRefConflictError); } - expect(resolver.calls).toEqual([]); + expect(resolver.inputs).toEqual([]); expect(connection.execCalls).toEqual([]); expect(docker.lastOpts).toBeUndefined(); }).pipe(Effect.provide(layer)); @@ -751,25 +773,42 @@ describe("test db integration", () => { }).pipe(Effect.provide(layer)); }); - it.live("writes the connection diagnostic to stderr, keeping stdout for TAP", () => { + it.live("keeps stdout for the TAP stream only", () => { const { layer, out } = setup(); return Effect.gen(function* () { yield* testDb(flags()); - expect(out.stderrText).toContain("Connecting to local database..."); expect(out.stdoutText).toBe(""); expect(out.messages).toEqual([]); }).pipe(Effect.provide(layer)); }); - it.live("labels the connection diagnostic 'remote' for non-local connections", () => { - const { layer, out } = setup({ + it.live("asks before enabling pgTAP on a hosted target and stops when declined", () => { + const { layer, out, connection, docker } = setup({ conn: REMOTE_CONN, isLocal: false, - args: ["--db-url=postgres://x"], + yes: false, + interactive: true, + confirm: [false], }); return Effect.gen(function* () { - yield* testDb(flags({ dbUrl: Option.some("postgres://x") })); - expect(out.stderrText).toContain("Connecting to remote database..."); + const exit = yield* Effect.exit( + testDb(flags({ dbUrl: Option.some("postgres://x"), paths: ["/abs/a_test.sql"] })), + ); + expect(Exit.isFailure(exit) && Cause.squash(exit.cause)).toBeInstanceOf( + HostedMutationDeclinedError, + ); + expect(out.stderrText).toContain("enable the pgtap extension"); + expect(out.stderrText).toContain("run pg_prove against /abs/a_test.sql"); + expect(connection.connectCalls).toEqual([]); + expect(docker.lastOpts).toBeUndefined(); + }).pipe(Effect.provide(layer)); + }); + + it.live("runs against a local target without asking for consent", () => { + const { layer, out } = setup({ yes: false }); + return Effect.gen(function* () { + yield* testDb(flags()); + expect(out.promptConfirmCalls).toEqual([]); }).pipe(Effect.provide(layer)); }); @@ -790,7 +829,6 @@ describe("test db integration", () => { it.effect("mints a login role through the real resolver for --linked --project-ref", () => { const { layer, connection } = setup({ - args: ["test", "db", "--linked", "--project-ref", VALID_REF], realLayers: true, workdir: tempWorkdir.current, }); @@ -818,7 +856,6 @@ describe("test db integration", () => { it.effect("makes no Management API request for the default local target", () => { const { layer } = setup({ - args: ["test", "db"], realLayers: true, workdir: tempWorkdir.current, }); @@ -850,3 +887,32 @@ describe("test db integration", () => { ); }); }); + +describeHostedConsent("test db", { + invoke: () => { + const connection = mockDbConnection({}); + return { + run: testDb( + flags({ linked: true, projectRef: Option.some(VALID_REF), paths: ["supabase/tests"] }), + ).pipe( + Effect.provide( + Layer.mergeAll( + mockResolver({ conn: REMOTE_CONN, isLocal: false }).layer, + connection.layer, + mockDockerRun({}).layer, + mockBundledPostgresClient({}).layer, + stackApiStub, + cliConfigValuesTestLayer, + mockTelemetryStateTracked().layer, + runtimeInfoLayer("linux"), + Layer.succeed(DebugFlag, false), + Layer.succeed(NetworkIdFlag, Option.none()), + Layer.succeed(DnsResolverFlag, "native"), + ), + ), + Effect.scoped, + ), + mutations: () => connection.execCalls.length, + }; + }, +}); 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 fbc7901e31..3358bba2aa 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 @@ -4,19 +4,21 @@ import { FetchHttpClient } from "effect/http"; import { Cause, Effect, Exit, FileSystem, Layer, Option, Redacted } from "effect"; import { Analytics } from "../shared/telemetry/analytics.service.ts"; import { CommandRuntime } from "../shared/runtime/command-runtime.service.ts"; -import { mockOutput, mockProcessControl } from "../../tests/helpers/mocks.ts"; +import { mockOutput, mockProcessControl, mockTty } from "../../tests/helpers/mocks.ts"; import { mockCommandSettings, mockTelemetryStateTracked, } from "../../tests/helpers/command-mocks.ts"; import { CliArgs } from "../shared/cli/cli-args.service.ts"; +import { ProjectRefResolver } from "../config/project-ref.service.ts"; import { RuntimeInfo } from "../shared/runtime/runtime-info.service.ts"; import { DbConfigResolver } from "./db-config.service.ts"; import { BundledPostgresClient } from "./bundled-postgres-client.ts"; import { dbConnectionLayer } from "./db-connection.sql-pg.layer.ts"; import { DbConnection } from "./db-connection.service.ts"; import { parseConnectionString } from "./db-config.parse.ts"; -import { DebugFlag, DnsResolverFlag, NetworkIdFlag } from "./global-flags.ts"; +import { DebugFlag, DnsResolverFlag, NetworkIdFlag, OutputFlag, YesFlag } from "./global-flags.ts"; +import { TargetPolicy } from "./target/target-policy.ts"; import { StackApi, stackApiLayer } from "./stack-api.ts"; import { stackBackendLayer } from "./stack-backend.ts"; import { testDb } from "./test-db.handler.ts"; @@ -102,8 +104,14 @@ describe("managed test db pgTAP", { timeout: 180_000 }, () => { output.layer, mockTelemetryStateTracked().layer, Layer.succeed(DbConfigResolver, { - resolve: () => Effect.succeed({ conn: connection, isLocal: true }), - resolveConnection: () => Effect.die("resolveConnection is not used by this test"), + resolve: () => Effect.die("resolve is not used by this test"), + resolveConnection: (input) => + Effect.succeed({ + conn: connection, + isLocal: true, + ref: Option.none(), + target: input.target, + }), resolvePoolerFallback: () => Effect.succeed(Option.none()), }), Layer.succeed(RuntimeInfo, { @@ -118,6 +126,18 @@ describe("managed test db pgTAP", { timeout: 180_000 }, () => { Layer.succeed(DnsResolverFlag, "native" as const), Layer.succeed(NetworkIdFlag, Option.none()), Layer.succeed(CliArgs, { args: [] }), + Layer.succeed(TargetPolicy, { group: "selectable", consent: "no" }), + Layer.succeed(YesFlag, false), + Layer.succeed(OutputFlag, Option.none()), + mockTty({ stdinIsTty: false }), + Layer.succeed(ProjectRefResolver, { + resolve: () => Effect.die("unused"), + resolveForLink: () => Effect.die("unused"), + resolveRef: () => Effect.die("unused"), + resolveOptional: () => Effect.die("unused"), + loadProjectRef: () => Effect.die("unused"), + promptProjectRef: () => Effect.die("unused"), + }), Layer.succeed(BundledPostgresClient, { run: () => Effect.die("unused") }), Layer.succeed(DockerRun, { run: () => Effect.die("unused"), @@ -157,7 +177,6 @@ describe("managed test db pgTAP", { timeout: 180_000 }, () => { } } expect(failedOutput.stdoutText).toContain("not ok"); - expect(failedOutput.stderrText).toContain("Connecting to local database"); yield* assertPgtapDropped.pipe(Effect.provide(services)); if (runtime !== "native") return; 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 416129b310..239ac0abde 100644 --- a/apps/cli/src/commands/db/test/test.integration.test.ts +++ b/apps/cli/src/commands/db/test/test.integration.test.ts @@ -1,7 +1,7 @@ /** * `db test` is a hidden alias that reuses `test db`'s flag config and handler verbatim (see * `test.command.ts`). The core pgTAP behavior is already covered by - * `../../../shared/test-db.integration.test.ts`, so this file only covers alias-specific + * `../../../command-internal/test-db.integration.test.ts`, so this file only covers alias-specific * behavior: end-to-end delegation, telemetry recording `"db test"` (not `"test db"`) as the * invoked path, and the non-text `onRunFailure` branch. */ @@ -42,6 +42,7 @@ import { type StubbedRequest, } from "../../../../tests/helpers/permission-real-layers.ts"; import { cliConfigValuesTestLayer } from "../../../../tests/helpers/config-values-layer.ts"; +import { describeHostedConsent } from "../../../../tests/helpers/hosted-consent.ts"; import { CliArgs } from "../../../shared/cli/cli-args.service.ts"; import { commandTargetLayer } from "../../../shared/output/command-target.layer.ts"; import { commandRuntimeLayer } from "../../../shared/runtime/command-runtime.layer.ts"; @@ -62,7 +63,13 @@ import { import { RuntimeInfo } from "../../../shared/runtime/runtime-info.service.ts"; import { CurrentAnalyticsContext } from "../../../shared/telemetry/analytics-context.ts"; import { Analytics } from "../../../shared/telemetry/analytics.service.ts"; +import { ProjectRefResolver } from "../../../config/project-ref.service.ts"; import { DbConfigResolver } from "../../../command-internal/db-config.service.ts"; +import type { + DbConnectionInput, + ResolvedDbConnection, +} from "../../../command-internal/db-config.types.ts"; +import { TargetPolicy } from "../../../command-internal/target/target-policy.ts"; import { DbConnection, type DbSession, @@ -76,6 +83,7 @@ import { runTestDbCommand, type TestDbFlags, } from "../../../command-internal/test-db.command-handler.ts"; +import { testDb } from "../../../command-internal/test-db.handler.ts"; import { dbCommand } from "../db.command.ts"; import { unusedStackServices } from "../../../../tests/helpers/unused-stack.ts"; @@ -105,14 +113,34 @@ function mockContextualAnalytics() { return { layer, captured }; } -function mockResolver() { +function mockResolver(conn: PgConnInput = LOCAL_CONN) { return Layer.succeed(DbConfigResolver, { - resolve: () => Effect.succeed({ conn: LOCAL_CONN, isLocal: true }), - resolveConnection: () => Effect.die("resolveConnection is not used by this test"), + resolve: () => Effect.die("resolve is not used by this test"), + resolveConnection: (input: DbConnectionInput) => + Effect.succeed({ + conn, + isLocal: input.target.kind === "local", + ref: input.target.kind === "linked" ? Option.some(input.target.ref) : Option.none(), + target: input.target, + } satisfies ResolvedDbConnection), resolvePoolerFallback: () => Effect.succeed(Option.none()), }); } +const projectRefLayer = Layer.succeed(ProjectRefResolver, { + resolve: () => Effect.die("resolve is not used by this test"), + resolveForLink: () => Effect.die("resolveForLink is not used by this test"), + resolveRef: (flagValue: Option.Option<string>) => + Effect.succeed({ + ref: Option.getOrElse(flagValue, () => REF), + source: "flag" as const, + matchesLinkedRef: false, + }), + resolveOptional: () => Effect.die("resolveOptional is not used by this test"), + loadProjectRef: () => Effect.die("loadProjectRef is not used by this test"), + promptProjectRef: () => Effect.die("promptProjectRef is not used by this test"), +}); + function mockDbConnection() { const execCalls: string[] = []; const session: DbSession = { @@ -181,11 +209,11 @@ interface SetupOpts { format?: "text" | "json" | "stream-json"; exitCode?: number; stdout?: ReadonlyArray<string>; - /** The command-line arguments the handler reads its database target from. */ - args?: ReadonlyArray<string>; /** Runs the real `DbConfigResolver` against the temp `workdir`; the test serves its Management API calls. */ realDbConfig?: boolean; workdir?: string; + /** `--yes`; on by default so hosted runs reach pg_prove. */ + yes?: boolean; } function setup(opts: SetupOpts = {}) { @@ -222,7 +250,12 @@ function setup(opts: SetupOpts = {}) { Layer.succeed(DebugFlag, false), Layer.succeed(NetworkIdFlag, Option.none()), Layer.succeed(DnsResolverFlag, "native"), - Layer.succeed(CliArgs, { args: opts.args ?? [] }), + Layer.succeed(CliArgs, { args: [] }), + projectRefLayer, + Layer.succeed(TargetPolicy, { group: "selectable", consent: "no" }), + Layer.succeed(YesFlag, opts.yes ?? true), + Layer.succeed(OutputFlag, Option.none()), + mockTty(), Stdio.layerTest({ args: Effect.succeed(args) }), commandRuntimeLayer(["db", "test"]).pipe(Layer.provide(BunServices.layer)), BunServices.layer, @@ -230,12 +263,13 @@ function setup(opts: SetupOpts = {}) { return { layer, out, analytics, processControl, connection, docker }; } -const flags = (): TestDbFlags => ({ +const flags = (over: Partial<TestDbFlags> = {}): TestDbFlags => ({ paths: [], dbUrl: Option.none(), linked: false, - local: true, + local: false, projectRef: Option.none(), + ...over, }); describe("db test (alias) integration", () => { @@ -254,20 +288,11 @@ describe("db test (alias) integration", () => { }); it.effect("mints a login role through the real DB config resolver for --linked", () => { - const { layer, docker } = setup({ - args: ["db", "test", "--linked", "--project-ref", REF], - realDbConfig: true, - workdir: tempRoot.current, - }); + const { layer, docker } = setup({ realDbConfig: true, workdir: tempRoot.current }); const requests: Array<StubbedRequest> = []; return Effect.gen(function* () { yield* stubManagementApi(requests, serveLinkedDatabase(REF)); - yield* runTestDbCommand({ - ...flags(), - linked: true, - local: false, - projectRef: Option.some(REF), - }); + yield* runTestDbCommand(flags({ linked: true, projectRef: Option.some(REF) })); expect(requests.map(({ method, path }) => `${method} ${path}`)).toEqual([ `GET /v1/projects/${REF}/config/database/pooler`, `POST /v1/projects/${REF}/cli/login-role`, @@ -303,15 +328,11 @@ describe("db test (alias) integration", () => { }); it.effect("makes no Management API request for --local through the real resolver", () => { - const { layer, docker } = setup({ - args: ["db", "test", "--local"], - realDbConfig: true, - workdir: tempRoot.current, - }); + const { layer, docker } = setup({ realDbConfig: true, workdir: tempRoot.current }); const requests: Array<StubbedRequest> = []; return Effect.gen(function* () { yield* stubManagementApi(requests, () => undefined); - yield* runTestDbCommand(flags()); + yield* runTestDbCommand(flags({ local: true })); expect(docker.lastOpts?.cmd[0]).toBe("pg_prove"); expect(requests).toEqual([]); assertPermissionDrift({ @@ -435,3 +456,32 @@ describe("db test (alias) integration", () => { }).pipe(Effect.provide(layer)); }); }); + +describeHostedConsent("db test", { + invoke: () => { + const connection = mockDbConnection(); + return { + run: testDb(flags({ linked: true, projectRef: Option.some(REF) })).pipe( + Effect.provide( + Layer.mergeAll( + mockResolver({ ...LOCAL_CONN, host: `db.${REF}.supabase.co` }), + connection.layer, + mockDockerRun().layer, + Layer.succeed(BundledPostgresClient, { + run: () => Effect.die("bundled postgres client unused"), + }), + unusedStackServices, + cliConfigValuesTestLayer, + mockTelemetryStateTracked().layer, + runtimeInfoLayer, + Layer.succeed(DebugFlag, false), + Layer.succeed(NetworkIdFlag, Option.none()), + Layer.succeed(DnsResolverFlag, "native"), + ), + ), + Effect.scoped, + ), + mutations: () => connection.execCalls.length, + }; + }, +}); From 92c9cd4b6f9f14da1a84ec6cc92166012b4f1bd6 Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Thu, 15 Oct 2026 14:47:55 +0100 Subject: [PATCH 50/92] feat(cli): consent once before pull writes and report the linked target identity --- apps/cli/docs/supabase/pull.md | 8 +- apps/cli/src/commands/pull/SIDE_EFFECTS.md | 121 +++--- apps/cli/src/commands/pull/pull.aggregate.ts | 12 +- .../commands/pull/pull.aggregate.unit.test.ts | 30 +- apps/cli/src/commands/pull/pull.command.ts | 2 +- apps/cli/src/commands/pull/pull.format.ts | 6 +- .../commands/pull/pull.format.unit.test.ts | 88 ++-- apps/cli/src/commands/pull/pull.handler.ts | 72 ++-- .../commands/pull/pull.integration.test.ts | 377 ++++++++++++------ apps/cli/src/commands/pull/pull.live.test.ts | 2 +- apps/cli/src/commands/pull/pull.steps.ts | 26 +- apps/cli/src/commands/pull/pull.types.ts | 13 +- 12 files changed, 417 insertions(+), 340 deletions(-) diff --git a/apps/cli/docs/supabase/pull.md b/apps/cli/docs/supabase/pull.md index 3ac0b53dd7..edaedd474c 100644 --- a/apps/cli/docs/supabase/pull.md +++ b/apps/cli/docs/supabase/pull.md @@ -6,9 +6,11 @@ Pass `--project-ref` to target a specific project, or the name (or UUID) of a br On a fresh checkout, the migration history step runs automatically even without `--with-migration-history`, whenever `supabase/migrations` is missing or a raw directory listing of it turns up no entries at all — the same "existing files" check `migration fetch` itself uses, so a directory holding only a `.gitkeep`/`README.md` already counts as non-empty for this purpose (the listing is unfiltered — any entry at all disqualifies it) and the two commands never disagree about it. There's nothing local to overwrite when the directory is truly empty or missing, and it's what lets the database step run at all: without it, `db pull` would hard-fail comparing an empty local directory against a remote project that already has migration history. Pass `--with-migration-history` to force a re-fetch whenever the directory already has any files in it, including only non-migration ones — unlike the bootstrap case, this DOES overwrite: any local migration file that shares a name with a remote history entry is replaced. -`supabase pull` shows one confirmation before writing anything, covering all four steps. It opens by naming the target (`Pulling from project <ref>`, or with `(branch "<name>")` appended when the target is a branch), then lists the four steps in the order they run: the config step's part is a real diff, computed the same way `config diff`/`config pull` compute theirs (or "No config differences found." when there is nothing to change); the migration-history line only appears when that step will actually run this invocation, and calls out that it overwrites same-named local files when it's running because of `--with-migration-history` rather than the bootstrap case; the database line notes that it also updates the remote migration history table and requires Docker; the functions line describes the download. `--dry-run` prints this exact same disclosure body — not a placeholder or a status-only summary — before exiting without writing or changing anything. `--yes` skips the confirmation and takes its default answer. +`supabase pull` prints the resolved target once (`Target: …` on stderr), then shows one confirmation before writing anything, covering all four steps. It opens by naming the target (`Pulling from project <ref>`, or with `(branch "<name>")` appended when the target is a branch), then lists the four steps in the order they run: the config step's part is a real diff, computed the same way `config diff`/`config pull` compute theirs (or "No config differences found." when there is nothing to change); the migration-history line only appears when that step will actually run this invocation, and calls out that it overwrites same-named local files when it's running because of `--with-migration-history` rather than the bootstrap case; the database line notes that it also updates the remote migration history table and requires Docker; the functions line describes the download. `--dry-run` prints this exact same disclosure body — not a placeholder or a status-only summary — before exiting without writing or changing anything. -`supabase pull` checks THREE locations for uncommitted or untracked changes in git before writing: `supabase/config.toml` (or `config.json`), `supabase/migrations`, and `supabase/functions`. The config check only fires when the config step itself has work to write — a config that's already fully converged with the target never triggers it. The other two fire unconditionally: the database step always attempts to run and has no way to know in advance whether it will find schema drift and write into `supabase/migrations`, and the functions step always runs with no equivalent "nothing to do" signal available without calling the API first — so both are checked every run to avoid silently missing real uncommitted work. Each location's dirty state is tracked separately: the confirmation body and any abort message name exactly which path(s) are actually dirty, e.g. `supabase/config.toml has uncommitted or untracked changes...` for one, or `supabase/config.toml, supabase/migrations, and supabase/functions have uncommitted or untracked changes...` when all three are. `--force` writes even when any of the three is dirty — without it, an interactive run's confirmation defaults to declining instead of proceeding, and every other case (non-interactive, machine output format, or `--yes` on any TTY) aborts outright rather than silently overwriting local edits. +Because the database step records the pulled migration in the remote migration history table, the confirmation is a hosted-mutation consent that defaults to **No**: `Pull project state and update the remote migration history table on <target>?`. Declining exits non-zero with `context canceled` and nothing is written. `--yes`, or `SUPABASE_YES` set in the shell environment, confirms it; a `SUPABASE_YES` in `supabase/.env` does not. `--output-format json|stream-json` and non-interactive runs cannot prompt, so they need `--yes`; an answer piped to stdin is ignored. `--yes` also lets the database connection remove a network ban that blocks the temporary login role; without it the step fails and points to `supabase network-bans remove`. + +`supabase pull` checks THREE locations for uncommitted or untracked changes in git before writing: `supabase/config.toml` (or `config.json`), `supabase/migrations`, and `supabase/functions`. The config check only fires when the config step itself has work to write — a config that's already fully converged with the target never triggers it. The other two fire unconditionally: the database step always attempts to run and has no way to know in advance whether it will find schema drift and write into `supabase/migrations`, and the functions step always runs with no equivalent "nothing to do" signal available without calling the API first — so both are checked every run to avoid silently missing real uncommitted work. Each location's dirty state is tracked separately: the confirmation body and any abort message name exactly which path(s) are actually dirty, e.g. `supabase/config.toml has uncommitted or untracked changes...` for one, or `supabase/config.toml, supabase/migrations, and supabase/functions have uncommitted or untracked changes...` when all three are. `--force` writes even when any of the three is dirty — without it, an interactive run's confirmation body carries the warning, and every other case (non-interactive, machine output format, or `--yes` on any TTY) aborts outright rather than silently overwriting local edits. **This command is not purely a local, read-only refresh.** Pulling the database schema also updates that database's own migration history table on the **remote** project — the same behavior `db pull` has always had — not just a local file write. The database step also requires Docker (it runs a shadow Postgres container, and — on the very first pull — a `pg_dump` container) to compute the schema diff. Without Docker running, that one step fails and is reported as such, but the other three steps (config, migration history, functions) still run and complete normally; a project with nothing in its database yet can still successfully pull its config and functions without Docker installed. @@ -42,4 +44,4 @@ supabase pull --remote-label staging Pulls into the `[remotes.staging]` block instead of whatever block the target would otherwise reuse or create. -Machine-readable output is available through `--output-format json|stream-json` — a single object (`schema_version`, `target`, `dry_run`, `confirmed`, `wrote`, `step_order`, `steps`, `counts`) with one entry per step under `steps`, keyed `config`, `migration_history`, `db`, and `functions`, each carrying its own `status` (`changed`, `unchanged`, `skipped`, `planned`, or `failed`), the paths it wrote, and a step-specific `detail` object. The legacy global `-o`/`--output` flag is not supported by this command; use `--output-format json|stream-json` instead. +Machine-readable output is available through `--output-format json|stream-json` — a single object (`schema_version`, `target`, `dry_run`, `confirmed`, `wrote`, `step_order`, `steps`, `counts`) with one entry per step under `steps`, keyed `config`, `migration_history`, `db`, and `functions`, each carrying its own `status` (`changed`, `unchanged`, `skipped`, `planned`, or `failed`), the paths it wrote, and a step-specific `detail` object. `target` carries `kind: "linked"`, `project_ref`, `environment` (`production`, `branch`, or `unverified`), `project_name` and `parent_project_ref` when known, and `branch` when `--project-ref` named one. The legacy global `-o`/`--output` flag is not supported by this command; use `--output-format json|stream-json` instead. diff --git a/apps/cli/src/commands/pull/SIDE_EFFECTS.md b/apps/cli/src/commands/pull/SIDE_EFFECTS.md index 547e86dfd3..7669da6882 100644 --- a/apps/cli/src/commands/pull/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/pull/SIDE_EFFECTS.md @@ -1,11 +1,11 @@ # `supabase pull` Orchestrates four existing pull-style commands — `config pull`, an optional `migration fetch`, -`db pull`, and `functions download` — behind one target resolution, one confirmation, and one +`db pull`, and `functions download` — behind one target resolution, one consent, and one aggregated result (ADR 0024). Each reused step is invoked through its own **run-core** (a typed -outcome, no `output.success` call of its own), so only `pull` itself resolves the target, prompts, -and emits — the sub-steps never fire their own prompt, confirmation, or telemetry event when run -this way. This file documents only what `pull` itself owns; for a sub-step's own file-by-file/API +outcome, no `output.success` call of its own), so only `pull` itself resolves the target, asks for +consent, and emits — the sub-steps never fire their own prompt, confirmation, or telemetry event +when run this way. This file documents only what `pull` itself owns; for a sub-step's own file-by-file/API detail, see its own `SIDE_EFFECTS.md`: - [`config pull`](../config/pull/SIDE_EFFECTS.md) @@ -16,7 +16,10 @@ detail, see its own `SIDE_EFFECTS.md`: **This command is not purely a local-file refresh.** The `db` step writes `supabase_migrations.schema_migrations` on the **remote** database (not just local files) whenever it actually pulls a migration-mode schema change, and that step requires Docker (shadow database + -initial-pull `pg_dump` container). See "Database writes" and "Docker" below. +initial-pull `pg_dump` container). See "Consent", "Database writes" and "Docker" below. + +The target is always a linked project or branch, announced once as `Target: …` on stderr (the +banner comes from the target resolution, not from the sub-steps). ## Files Read @@ -29,25 +32,25 @@ initial-pull `pg_dump` container). See "Database writes" and "Docker" below. | `~/.supabase/access-token` | plain text | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | | `<workdir>/supabase/config.toml` or `config.json` (reload) | TOML/JSON | re-loaded WITH the `[remotes.*]` overlay applied, only when the resolved target ref matches an existing `[remotes.*]` block — the config step's own conditional reload (`config/pull/SIDE_EFFECTS.md`) | | `<workdir>/supabase/migrations` (directory listing) | filenames | once, in the preview phase, to decide whether the migration-history step auto-runs — skipped when `--with-migration-history` is already set (see Notes); a read failure other than "directory missing" fails the whole command at this point | -| same config file, raw on-disk text (TOCTOU re-read) | TOML/JSON | immediately before the config step writes, once the aggregated confirmation is accepted — only reached when the config plan has work; see `config/pull/SIDE_EFFECTS.md`'s TOCTOU row | +| same config file, raw on-disk text (TOCTOU re-read) | TOML/JSON | immediately before the config step writes, once consent is granted — only reached when the config plan has work; see `config/pull/SIDE_EFFECTS.md`'s TOCTOU row | Each executed sub-step also performs its own file reads exactly as documented in its own `SIDE_EFFECTS.md` (config, db, functions, and — when it runs — migration history), since `pull` -threads the already-resolved `ref` into each step's existing `--project-ref`-shaped input rather -than having each step re-resolve it (ADR 0024's "resolve once" decision) — most of a sub-step's own -target-resolution file reads are therefore skipped in practice (the ref is already ref-shaped, so -`ProjectRefResolver.loadProjectRef` short-circuits with no file/network access). +passes the already-resolved linked target to each step instead of having each step re-resolve it +(ADR 0024's "resolve once" decision). The only extra read is `<workdir>/supabase/.temp/project-ref`, +which decides whether the resolved ref is the checkout's linked project (a missing or unreadable +file counts as "not linked"). ## Files Written -| Path | Format | When | -| ------------------------------------------------------ | --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `<workdir>/supabase/config.toml` or `config.json` | TOML/JSON | config step only, once the aggregated confirmation is accepted AND the plan has work (never on `--dry-run`, never on a declined confirmation) — same atomic surgical-edit write as standalone `config pull` (`config/pull/SIDE_EFFECTS.md`) | -| `<workdir>/supabase/migrations/<version>_<name>.sql` | SQL | migration-history step only, when it actually runs (bootstrap or `--with-migration-history`) and the confirmation is accepted — see `migration/fetch/SIDE_EFFECTS.md` | -| `<workdir>/supabase/migrations/<timestamp>_<name>.sql` | SQL | db step, migration mode, when it finds schema drift — see `db/pull/SIDE_EFFECTS.md` | -| `<workdir>/supabase/functions/<slug>/...` | bytes | functions step, for each function the linked project has — see `functions/download/SIDE_EFFECTS.md` | -| `<workdir>/supabase/.temp/linked-project.json` | JSON | `Effect.ensuring` after `pull`'s own run (success and failure), once a target ref has resolved | -| `~/.supabase/telemetry.json` | JSON | `Effect.ensuring` after `pull`'s own run (success and failure) | +| Path | Format | When | +| ------------------------------------------------------ | --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `<workdir>/supabase/config.toml` or `config.json` | TOML/JSON | config step only, once consent is granted AND the plan has work (never on `--dry-run`, never when consent is declined or missing) — same atomic surgical-edit write as standalone `config pull` (`config/pull/SIDE_EFFECTS.md`) | +| `<workdir>/supabase/migrations/<version>_<name>.sql` | SQL | migration-history step only, when it actually runs (bootstrap or `--with-migration-history`) and consent is granted — see `migration/fetch/SIDE_EFFECTS.md` | +| `<workdir>/supabase/migrations/<timestamp>_<name>.sql` | SQL | db step, migration mode, when it finds schema drift — see `db/pull/SIDE_EFFECTS.md` | +| `<workdir>/supabase/functions/<slug>/...` | bytes | functions step, for each function the linked project has — see `functions/download/SIDE_EFFECTS.md` | +| `<workdir>/supabase/.temp/linked-project.json` | JSON | `Effect.ensuring` after `pull`'s own run (success and failure), once a target ref has resolved | +| `~/.supabase/telemetry.json` | JSON | `Effect.ensuring` after `pull`'s own run (success and failure) | The two rows above are `pull`'s own top-level writes. Because the db and migration-history steps are invoked as plain library functions (their run-cores), not as wrapped standalone commands, each @@ -78,12 +81,12 @@ untracked changes...` for two, `supabase/config.toml, supabase/migrations, and s have uncommitted or untracked changes...` for all three). A dirty (or untracked) result on ANY of the three changes behavior by output mode, identically to -`config pull`'s own single-path guard: an interactive TTY text run without `--yes` downgrades the -aggregated confirmation prompt's default answer from yes to no and adds the warning (naming every -dirty path) to the confirmation body; every other case — a non-interactive or machine-format run, -or `--yes` passed on any TTY — aborts before the prompt (`PullUncommittedChangesError`, exit -1, naming every dirty path in its message). `--yes`/`SUPABASE_YES` never bypasses this guard; only -`--force` does, for all three locations at once. +`config pull`'s own single-path guard: an interactive TTY text run without `--yes` adds the warning +(naming every dirty path) to the confirmation body before the consent prompt; every other case — a +non-interactive or machine-format run, or `--yes` passed on any TTY — aborts before the prompt +(`PullUncommittedChangesError`, exit 1, naming every dirty path in its message). +`--yes`/`SUPABASE_YES` never bypasses this guard; only `--force` does, for all three locations at +once. A non-zero exit, a spawn failure, or the directory not being a git working tree all degrade silently to "clean" for that one location (same degrade-on-uncertainty policy `config pull` uses) — @@ -103,16 +106,32 @@ metadata — `db/pull/SIDE_EFFECTS.md`) and the functions step's own routes (fun per-function metadata/body, linked-project metadata — `functions/download/SIDE_EFFECTS.md`). `migration fetch` calls no HTTP route of its own (it reads the remote database directly). +## Consent + +The db step always runs in migration mode, so every non-dry-run `pull` can record a migration in +the remote history. `pull` therefore asks once, after planning and the dirty-tree guard and before +any step writes: `Pull project state and update the remote migration history table on <target>?` +(default **No**). + +- Declining exits non-zero with `context canceled` (`HostedMutationDeclinedError`) before any + file is written or any database is touched. +- `--yes` or a `SUPABASE_YES` in the shell environment confirms it (`Confirmed by --yes: …`). A + `SUPABASE_YES` in the project `supabase/.env` does not. JSON, stream-json and non-TTY runs + cannot prompt and fail with `HostedMutationConsentRequiredError` without `--yes`; a piped answer + on stdin is ignored. `--dry-run` asks nothing. +- The consent is passed to the db step, so `db pull` does not ask again. The migration-history + step only writes local files and asks nothing. +- `--yes` also lets a step's connection remove a network ban that blocks the temporary login role; + without it the step fails and points to `supabase network-bans remove`. + ## Database writes **The db step writes `supabase_migrations.schema_migrations` on the remote database, not just local files, whenever it actually pulls a migration-mode schema change during this `pull` -invocation.** `pull` always suppresses `db pull`'s own "Update remote migration history table?" -prompt (`assumeYes: true` — the aggregated confirmation already covers this), so a migration-mode -pull that finds drift updates the remote history table unconditionally, not just locally. This -holds even though `pull`'s own top-level effect never asks about it a second time — see -`db/pull/SIDE_EFFECTS.md`'s own "API Routes / DB" table for the exact `UPSERT`. A declarative-mode -pull does not provision a shadow database and never touches this table. +invocation.** The consent above covers it, so a migration-mode pull that finds drift updates the +remote history table, not just the local files. See `db/pull/SIDE_EFFECTS.md`'s own "API Routes / +DB" table for the exact `UPSERT`. A declarative-mode pull does not provision a shadow database and +never touches this table. ## Docker @@ -129,14 +148,14 @@ Docker-dependent. ## Environment Variables -| Variable | Purpose | Required? | -| ----------------------- | ------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `SUPABASE_YES` | answers `pull`'s own aggregated confirmation "yes" (same as `--yes`); does NOT bypass the dirty guard | no — resolved via the GLOBAL flag (`resolveYes`), no project-`.env` fallback (unlike the sub-steps' own internal prompts, which are all bypassed by `pull` before they can read it) | -| `SUPABASE_PROJECT_ID` | project ref (flag → this → `.temp/project-ref` → prompt) | no | -| `SUPABASE_WORKDIR` | working directory `config.toml`/`.json`/`supabase/migrations`/`supabase/functions` are resolved against | 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 by the config step's loader | no | +| Variable | Purpose | Required? | +| ----------------------- | ------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------- | +| `SUPABASE_YES` | grants `pull`'s consent (same as `--yes`); does NOT bypass the dirty guard | no — shell environment only (`resolveYes`); a value in the project `supabase/.env` does not count | +| `SUPABASE_PROJECT_ID` | project ref (flag → this → `.temp/project-ref` → prompt) | no | +| `SUPABASE_WORKDIR` | working directory `config.toml`/`.json`/`supabase/migrations`/`supabase/functions` are resolved against | 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 by the config step's loader | no | Once the db and functions steps run, they consume their own established environment variables exactly as documented in their own `SIDE_EFFECTS.md` files (e.g. `SUPABASE_DB_PASSWORD`, @@ -152,7 +171,7 @@ functions) — `pull` does not read or override any of these itself, it only sup | ---- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `0` | every step succeeded (any mix of `changed`/`unchanged`/`skipped`) | | `0` | `--dry-run` (every step reports `planned`/`skipped`, nothing written) | -| `0` | the aggregated confirmation was declined (every step reports `planned`/`skipped`, nothing written) | +| `1` | consent declined at the prompt (`HostedMutationDeclinedError`, `context canceled`), or needed in a run that cannot prompt (JSON, stream-json, non-interactive) without `--yes` (`HostedMutationConsentRequiredError`) — nothing is written | | `1` | the `-o`/`--output` global flag passed (any value — not supported by this command, `PullOutputFlagUnsupportedError`) | | `1` | resolved `--workdir`/`SUPABASE_WORKDIR` doesn't exist or isn't a directory (`PullWorkdirError`) | | `1` | branch-name `--project-ref` target-resolution failure (`PullBranchNotLinkedError` / `PullParentRefInvalidError` / `PullBranchNotFoundError` / `PullBranchNotReadyError` / `PullBranchResolveNetworkError` / `PullBranchResolveStatusError`) | @@ -203,12 +222,12 @@ from project <ref> (branch "<branch>")` when a branch is set), then one section migration-history line, only when that step will actually run this invocation, calling out that it overwrites same-named local files when the reason is `--with-migration-history` rather than the bootstrap case; a db line noting it also updates the remote migration history table and requires -Docker; a functions line). The aggregated "Proceed with pull?" prompt follows, unless `--yes` is -set or the run is `--dry-run`/declined. +Docker; a functions line). The consent prompt follows (see "Consent"), unless `--yes` is set or +the run is `--dry-run`. ### `--output-format json` / `stream-json` -`output.success(message, payload)` on success/dry-run/declined; on a partial failure, the payload +`output.success(message, payload)` on success/dry-run; on a partial failure, the payload is instead attached to the single JSON/stream-json error envelope via `MachineErrorContext` (spread onto the envelope's top level, alongside `_tag`/`error`) before the process fails — never a second, separate JSON object. `schema_version` is `pull`'s own payload version @@ -218,7 +237,13 @@ Shape (`pull.format.ts`): ```jsonc { "schema_version": 1, - "target": { "project_ref": "...", "branch": "..." }, // branch omitted if absent + "target": { + "kind": "linked", + "project_ref": "...", + "project_name": "...", // from the linked-project cache, when it names this project + "environment": "production", // or "branch" (with "parent_project_ref") or "unverified" + "branch": "...", // only when --project-ref named a branch + }, "dry_run": false, "confirmed": true, "dirty_paths": [], // always present, even on a clean tree — populated when the git-dirty guard found uncommitted/untracked paths @@ -307,12 +332,10 @@ the -o/--output flag is not supported by pull; use --output-format json|stream-j `pull` calls `planConfigPullRun`/`applyConfigPullRun` directly (the plan/apply split), never `runConfigPull` — so standalone `config pull`'s own git check and its "nothing to do, skip the prompt entirely" behavior are both bypassed in favor of `pull`'s own single guard and - single aggregated prompt (which always runs, even when the config step itself has no work, since - the other three steps might). -- `pull`'s own `--yes`/`SUPABASE_YES` resolution has no project-`.env` fallback (unlike `db -pull`/`migration fetch`'s own internal resolution) — this doesn't matter in practice, since every - reused step's own prompt is unconditionally bypassed (`assumeYes: true`) once `pull`'s own - confirmation is accepted. + single consent (which always runs, even when the config step itself has no work, since the + other three steps might). +- The migration-history step's own overwrite prompt is bypassed (`assumeYes: true`) once consent is + granted. - **The db step always runs in migration mode when orchestrated by `pull`, never the declarative path.** `pull` never sets `--declarative`/`--diff-engine` on its own db-step invocation and calls `runDbPull` (`command-internal/db-pull-run.ts`), where the experimental gate no longer selects diff --git a/apps/cli/src/commands/pull/pull.aggregate.ts b/apps/cli/src/commands/pull/pull.aggregate.ts index 7e91d148a5..6a46d70652 100644 --- a/apps/cli/src/commands/pull/pull.aggregate.ts +++ b/apps/cli/src/commands/pull/pull.aggregate.ts @@ -1,6 +1,7 @@ import type { DownloadFunctionsResult } from "../../shared/functions/download.ts"; import type { MigrationFetchOutcome } from "../../command-internal/migration-fetch-run.ts"; import type { DbPullOutcome } from "../../command-internal/db-pull-run.ts"; +import type { TargetJson } from "../../shared/output/command-target.service.ts"; import type { PullAggregate, PullStepFailure, @@ -34,7 +35,7 @@ export interface PullConfigStepOutcome { /** * `config` step: `unchanged` with no work at all, `planned` when work exists but was skipped - * (dry run or a declined confirmation), and `changed` once the plan applied. `detail` is always + * (dry run), and `changed` once the plan applied. `detail` is always * the payload passed in, since `config pull` computes a diff report even when nothing applied. */ export function pullConfigStepResult( @@ -54,12 +55,11 @@ export function pullConfigStepResult( export type PullMigrationHistoryStepOutcome = | { /** - * Never attempted: `"not_needed"` when migrations already existed and - * `--with-migration-history` wasn't passed, or `"declined"` when the aggregated - * confirmation was declined before this step could run. + * Never attempted: migrations already existed and `--with-migration-history` wasn't + * passed. */ readonly kind: "skipped"; - readonly reason: "not_needed" | "declined"; + readonly reason: "not_needed"; } | { /** @@ -339,6 +339,7 @@ export function pullCounts(results: ReadonlyArray<PullStepResult>): PullCounts { export function pullAggregate(input: { readonly ref: string; readonly branch: string | undefined; + readonly targetIdentity: TargetJson; readonly dryRun: boolean; readonly confirmed: boolean; readonly dirtyPaths: ReadonlyArray<string>; @@ -347,6 +348,7 @@ export function pullAggregate(input: { return { ref: input.ref, branch: input.branch, + targetIdentity: input.targetIdentity, dryRun: input.dryRun, confirmed: input.confirmed, dirtyPaths: input.dirtyPaths, diff --git a/apps/cli/src/commands/pull/pull.aggregate.unit.test.ts b/apps/cli/src/commands/pull/pull.aggregate.unit.test.ts index b321a79df9..ebb6a5acf3 100644 --- a/apps/cli/src/commands/pull/pull.aggregate.unit.test.ts +++ b/apps/cli/src/commands/pull/pull.aggregate.unit.test.ts @@ -53,21 +53,6 @@ describe("pullConfigStepResult", () => { }); }); - it("reports planned for a declined confirmation that has work, identically to a dry run", () => { - const outcome: PullConfigStepOutcome = { - dryRun: false, - hasWork: true, - confirmed: false, - configFilePath: "supabase/config.toml", - }; - expect(pullConfigStepResult(outcome, payload)).toEqual({ - step: "config", - status: "planned", - written: [], - detail: payload, - }); - }); - it("reports changed and writes the config file path once actually applied", () => { const outcome: PullConfigStepOutcome = { dryRun: false, @@ -110,17 +95,6 @@ describe("pullMigrationHistoryStepResult", () => { }); }); - it("reports skipped with reason declined", () => { - const outcome: PullMigrationHistoryStepOutcome = { kind: "skipped", reason: "declined" }; - expect(pullMigrationHistoryStepResult(outcome)).toEqual({ - step: "migration_history", - status: "skipped", - written: [], - detail: { files: [] }, - reason: "declined", - }); - }); - it("reports planned for a dry run that would have fetched", () => { const outcome: PullMigrationHistoryStepOutcome = { kind: "planned" }; expect(pullMigrationHistoryStepResult(outcome)).toEqual({ @@ -564,6 +538,7 @@ describe("pullAggregate", () => { pullAggregate({ ref: "abcdefghijklmnopqrst", branch: "staging", + targetIdentity: { kind: "linked", environment: "branch" }, dryRun: true, confirmed: false, dirtyPaths: ["supabase/config.toml"], @@ -572,6 +547,7 @@ describe("pullAggregate", () => { ).toEqual({ ref: "abcdefghijklmnopqrst", branch: "staging", + targetIdentity: { kind: "linked", environment: "branch" }, dryRun: true, confirmed: false, dirtyPaths: ["supabase/config.toml"], @@ -584,6 +560,7 @@ describe("pullAggregate", () => { pullAggregate({ ref: "abcdefghijklmnopqrst", branch: undefined, + targetIdentity: {}, dryRun: false, confirmed: true, dirtyPaths: [], @@ -609,6 +586,7 @@ describe("pullAggregate", () => { const aggregate = pullAggregate({ ref: "abcdefghijklmnopqrst", branch: undefined, + targetIdentity: {}, dryRun: false, confirmed: true, dirtyPaths: [], diff --git a/apps/cli/src/commands/pull/pull.command.ts b/apps/cli/src/commands/pull/pull.command.ts index 272d0955d3..cfa3ec5d12 100644 --- a/apps/cli/src/commands/pull/pull.command.ts +++ b/apps/cli/src/commands/pull/pull.command.ts @@ -68,7 +68,7 @@ export const pullHandler = (flags: PullFlags) => export const pullCommand = Command.make("pull", config).pipe( Command.withDescription( - "Refreshes local project state from a linked Supabase project or branch in one step: pulls config into supabase/config.toml, optionally fetches the remote migration history table, pulls the database schema into supabase/migrations (also updating that database's migration history), and downloads every Edge Function's source. Prompts for confirmation before writing on an interactive TTY, unless --yes is set; --output-format json|stream-json skips the prompt entirely and takes its default answer, while a non-interactive text run still prints the prompt to stderr and reads one line from piped stdin (y/yes/n/no honored, empty takes the default, anything else declines) — use --dry-run to preview first.", + "Refreshes local project state from a linked Supabase project or branch in one step: pulls config into supabase/config.toml, optionally fetches the remote migration history table, pulls the database schema into supabase/migrations (also updating that database's migration history), and downloads every Edge Function's source. Asks for confirmation (default No) before writing, because the database step records the migration in the remote migration history table: an interactive terminal is prompted, and --output-format json|stream-json or a non-interactive run needs --yes (or SUPABASE_YES in the shell environment) — use --dry-run to preview first.", ), Command.withShortDescription("Pull remote project state into the local checkout"), Command.withExamples([ diff --git a/apps/cli/src/commands/pull/pull.format.ts b/apps/cli/src/commands/pull/pull.format.ts index 734a67ac78..004a03a4a5 100644 --- a/apps/cli/src/commands/pull/pull.format.ts +++ b/apps/cli/src/commands/pull/pull.format.ts @@ -34,6 +34,7 @@ export function pullPayload(aggregate: PullAggregate): Record<string, unknown> { return { schema_version: PULL_PAYLOAD_VERSION, target: { + ...aggregate.targetIdentity, project_ref: aggregate.ref, ...(aggregate.branch === undefined ? {} : { branch: aggregate.branch }), }, @@ -55,15 +56,12 @@ export function pullPayload(aggregate: PullAggregate): Record<string, unknown> { /** * One-line `message` for `output.success`. Every branch names its own disposition up front - * (dry-run, declined, partial-failure, or complete) so an agent reading just `.message` can't + * (dry-run, partial-failure, or complete) so an agent reading just `.message` can't * mistake one for a completed pull. */ export function pullSummaryMessage(aggregate: PullAggregate): string { const counts = pullCounts(aggregate.results); const countsText = `${counts.changed} changed, ${counts.unchanged} unchanged, ${counts.skipped} skipped, ${counts.planned} planned, ${counts.failed} failed`; - if (!aggregate.confirmed && !aggregate.dryRun) { - return `Pull declined: nothing was changed (${countsText}).`; - } if (aggregate.dryRun) { return `Pull preview (dry run): nothing was changed (${countsText}).`; } diff --git a/apps/cli/src/commands/pull/pull.format.unit.test.ts b/apps/cli/src/commands/pull/pull.format.unit.test.ts index 1ecbdd0702..fa41e6a2ae 100644 --- a/apps/cli/src/commands/pull/pull.format.unit.test.ts +++ b/apps/cli/src/commands/pull/pull.format.unit.test.ts @@ -53,6 +53,31 @@ describe("pullPayload", () => { }); }); + it("merges the announced target's identity fields into target, keeping project_ref and branch", () => { + const aggregate: PullAggregate = { + ref: PROJECT_REF, + branch: "staging", + targetIdentity: { + kind: "linked", + project_ref: "ignored-in-favour-of-ref", + environment: "branch", + parent_project_ref: "bbbbbbbbbbbbbbbbbbbb", + }, + dryRun: true, + confirmed: false, + dirtyPaths: [], + results: [], + }; + + expect(pullPayload(aggregate)["target"]).toEqual({ + kind: "linked", + project_ref: PROJECT_REF, + environment: "branch", + parent_project_ref: "bbbbbbbbbbbbbbbbbbbb", + branch: "staging", + }); + }); + it("shapes a fully-succeeded aggregate: every step changed, branch present, wrote:true", () => { const results: ReadonlyArray<PullStepResult> = [ { @@ -123,48 +148,6 @@ describe("pullPayload", () => { }); }); - it("shapes a declined aggregate: config/db/functions planned, migration_history skipped(declined), wrote:false", () => { - const results: ReadonlyArray<PullStepResult> = [ - { step: "config", status: "planned", written: [], detail: { schema_version: 1 } }, - { - step: "migration_history", - status: "skipped", - written: [], - detail: { files: [] }, - reason: "declined", - }, - { step: "db", status: "planned", written: [], detail: {} }, - { step: "functions", status: "planned", written: [], detail: {} }, - ]; - const aggregate: PullAggregate = { - ref: PROJECT_REF, - branch: undefined, - dryRun: false, - confirmed: false, - dirtyPaths: ["supabase/config.toml"], - results, - }; - - const payload = pullPayload(aggregate); - expect(payload["wrote"]).toBe(false); - expect(payload["confirmed"]).toBe(false); - expect(payload["dry_run"]).toBe(false); - expect(payload["dirty_paths"]).toEqual(["supabase/config.toml"]); - expect(payload["steps"]).toHaveProperty("migration_history", { - status: "skipped", - written: [], - detail: { files: [] }, - reason: "declined", - }); - expect(payload["counts"]).toEqual({ - changed: 0, - unchanged: 0, - skipped: 1, - planned: 3, - failed: 0, - }); - }); - it("shapes a mixed-failure aggregate: one step failed, wrote:true from the OTHER changed steps", () => { const results: ReadonlyArray<PullStepResult> = [ { @@ -284,27 +267,6 @@ describe("pullPayload", () => { }); describe("pullSummaryMessage", () => { - it("reads 'declined' and lists the actual counts when the confirmation was declined outside a dry run", () => { - const results: ReadonlyArray<PullStepResult> = [ - { step: "config", status: "planned", written: [], detail: {} }, - { step: "migration_history", status: "skipped", written: [], detail: {}, reason: "declined" }, - { step: "db", status: "planned", written: [], detail: {} }, - { step: "functions", status: "planned", written: [], detail: {} }, - ]; - const aggregate: PullAggregate = { - ref: PROJECT_REF, - branch: undefined, - dryRun: false, - confirmed: false, - dirtyPaths: [], - results, - }; - const message = pullSummaryMessage(aggregate); - expect(message).toBe( - "Pull declined: nothing was changed (0 changed, 0 unchanged, 1 skipped, 3 planned, 0 failed).", - ); - }); - it("reads 'preview (dry run)' and lists the actual counts for --dry-run", () => { const results: ReadonlyArray<PullStepResult> = [ { step: "config", status: "planned", written: [], detail: {} }, diff --git a/apps/cli/src/commands/pull/pull.handler.ts b/apps/cli/src/commands/pull/pull.handler.ts index d0ea91a60d..40991c601b 100644 --- a/apps/cli/src/commands/pull/pull.handler.ts +++ b/apps/cli/src/commands/pull/pull.handler.ts @@ -8,7 +8,10 @@ import { unsupportedOutputFlagMessage } from "../../command-internal/output-form import { validateWorkdirIsDirectory } from "../../command-internal/workdir-validation.ts"; import { MachineErrorContext } from "../../shared/output/machine-error-context.service.ts"; import { resolveYes, OutputFlag } from "../../command-internal/global-flags.ts"; -import { promptYesNo } from "../../command-internal/prompt-yes-no.ts"; +import { requireHostedMutationConsent } from "../../command-internal/hosted-consent/hosted-consent.ts"; +import type { ResolvedTarget } from "../../command-internal/target/resolve-target.ts"; +import { CommandTarget, type TargetJson } from "../../shared/output/command-target.service.ts"; +import { readProjectRefFile } from "../../shared/config/temp-paths.ts"; import { Output } from "../../shared/output/output.service.ts"; import { Tty } from "../../shared/runtime/tty.service.ts"; import { LinkedProjectCache } from "../../telemetry/linked-project-cache.service.ts"; @@ -95,9 +98,9 @@ const configPullPayloadFor = (runPlan: ConfigPullRunPlan, outcome: ConfigPullOut configPullPayload(runPlan.changeSet, runPlan.scope, runPlan.plan, runPlan.context, outcome); /** - * Emits the aggregate for every non-failure disposition (dry run, declined, or fully - * succeeded). The mixed-failure disposition is inlined separately instead, since machine mode - * there feeds the payload into `MachineErrorContext` ahead of a re-failed cause. + * Emits the aggregate for every non-failure disposition (dry run or fully succeeded). The + * mixed-failure disposition is inlined separately instead, since machine mode there feeds the + * payload into `MachineErrorContext` ahead of a re-failed cause. */ const pullEmit = (output: typeof Output.Service, aggregate: PullAggregate) => output.format === "text" @@ -192,6 +195,7 @@ export const pull = Effect.fn("pull")(function* (flags: PullFlags) { const linkedProjectCache = yield* LinkedProjectCache; const telemetryState = yield* TelemetryState; const machineErrorContext = yield* MachineErrorContext; + const commandTarget = yield* CommandTarget; const requested = Option.filter(flags.projectRef, (value) => value.length > 0); const remoteLabel = Option.getOrUndefined( @@ -231,6 +235,18 @@ export const pull = Effect.fn("pull")(function* (flags: PullFlags) { return { ref: target.ref, branch: target.branch, runPlan: plan, source: pullSource }; }).pipe(Effect.withSpan("pull.resolveTarget")); + // `resolveConfigTarget` already announced the target; wrapping its ref here avoids a second banner. + const linkedRef = yield* readProjectRefFile(fs, path, cliSettings.workdir).pipe( + Effect.orElseSucceed(() => Option.none<string>()), + ); + const linkedTarget: ResolvedTarget = { + kind: "linked", + ref, + source: "flag", + matchesLinkedRef: Option.contains(linkedRef, ref), + }; + const targetIdentity: TargetJson = Option.getOrElse(yield* commandTarget.json, () => ({})); + // Retry hints below use the planned remote destination's label, not the raw `remoteLabel` // flag: for a branch-derived implicit target, that's the `[remotes.<branch>]` block the // plan actually targets, which may differ from an unset `--remote-label`. @@ -349,6 +365,7 @@ export const pull = Effect.fn("pull")(function* (flags: PullFlags) { const aggregate = pullAggregate({ ref, branch, + targetIdentity, dryRun: true, confirmed: false, dirtyPaths, @@ -367,40 +384,22 @@ export const pull = Effect.fn("pull")(function* (flags: PullFlags) { } } - const confirmed = yield* promptYesNo(output, yes, "Proceed with pull?", dirty ? false : true); - if (!confirmed) { - const results: ReadonlyArray<PullStepResult> = [ - pullConfigStepResult( - { - dryRun: false, - hasWork: runPlan.hasWork, - confirmed: false, - configFilePath: runPlan.context.configPath, - }, - configPullPayloadFor(runPlan, { dryRun: false, declined: true }), - ), - pullMigrationHistoryStepResult({ - kind: "skipped", - reason: shouldFetchMigrationHistory ? "declined" : "not_needed", - }), - pullDbStepResult({ kind: "planned" }), - pullFunctionsStepResult({ kind: "planned" }), - ]; - const aggregate = pullAggregate({ - ref, - branch, - dryRun: false, - confirmed: false, - dirtyPaths, - results, - }); - yield* pullEmit(output, aggregate); - return; - } + const preapproved = yield* requireHostedMutationConsent({ + action: "Pull project state and update the remote migration history table", + details: [ + "Write a migration file for the remote schema to supabase/migrations.", + "Record it as applied in the remote migration history.", + ], + }).pipe(Effect.map(Option.some)); // Each step is failure-isolated: one failing doesn't stop the rest from running and being // reported. - const stepContext: PullStepContext = { ref, assumeYes: true }; + const stepContext: PullStepContext = { + ref, + assumeYes: true, + target: linkedTarget, + preapproved, + }; const { results, firstFailureCause } = yield* Effect.gen(function* () { let firstFailureCause: Cause.Cause<PullStepFailureCause> | undefined; const results: Array<PullStepResult> = []; @@ -410,7 +409,7 @@ export const pull = Effect.fn("pull")(function* (flags: PullFlags) { results.push( pullConfigStepResult( // `pullConfigStep` returns the plan's absolute `configFilePath`; overridden here to - // the workdir-relative path, matching the dry-run/declined branches above. + // the workdir-relative path, matching the dry-run branch above. { ...configCapture.value, configFilePath: runPlan.context.configPath }, configPullPayloadFor(runPlan, { dryRun: false, declined: false }), ), @@ -494,6 +493,7 @@ export const pull = Effect.fn("pull")(function* (flags: PullFlags) { const aggregate = pullAggregate({ ref, branch, + targetIdentity, dryRun: false, confirmed: true, dirtyPaths, diff --git a/apps/cli/src/commands/pull/pull.integration.test.ts b/apps/cli/src/commands/pull/pull.integration.test.ts index 88a6125e93..1da3a11859 100644 --- a/apps/cli/src/commands/pull/pull.integration.test.ts +++ b/apps/cli/src/commands/pull/pull.integration.test.ts @@ -31,6 +31,7 @@ import { configValuesLayer } from "../../../tests/helpers/config-values-layer.ts import { buildTestRuntime, VALID_REF, + type ApiHandler, jsonResponse, mockCommandSettings, mockLinkedProjectCacheTracked, @@ -51,10 +52,17 @@ import { DnsResolverFlag, ExperimentalFlag, NetworkIdFlag, + OutputFlag, YesFlag, } from "../../command-internal/global-flags.ts"; import { CliArgs } from "../../shared/cli/cli-args.service.ts"; import { DbConfigResolver } from "../../command-internal/db-config.service.ts"; +import type { + DbConnectionInput, + ResolvedDbConnection, +} from "../../command-internal/db-config.types.ts"; +import { HostedMutationDeclinedError } from "../../command-internal/hosted-consent/hosted-consent.errors.ts"; +import { TargetPolicy } from "../../command-internal/target/target-policy.ts"; import { DbConnection, type DbSession } from "../../command-internal/db-connection.service.ts"; import { DbExecError } from "../../command-internal/db-connection.errors.ts"; import { DbPullMigrationConflictError } from "../../command-internal/db-pull-run.errors.ts"; @@ -64,6 +72,8 @@ import { EdgeRuntimeScript } from "../../command-internal/edge-runtime-script.se import { MigrationFetchWriteError } from "../migration/fetch/fetch.errors.ts"; import { PgDeltaSslProbe } from "../../command-internal/pgdelta-ssl-probe.service.ts"; import { Output } from "../../shared/output/output.service.ts"; +import { describeHostedConsent } from "../../../tests/helpers/hosted-consent.ts"; +import { pull } from "./pull.handler.ts"; import { PgDeltaEngine, PgDeltaEngineError } from "../db/shared/pgdelta-engine.service.ts"; import type { PullFlags } from "./pull.command.ts"; import { pullHandler } from "./pull.command.ts"; @@ -80,6 +90,8 @@ const tempRoot = useTempWorkdir("supabase-pull-int-"); const BRANCH_REF = "cccccccccccccccccccc"; +const CONSENT_ACTION = "Pull project state and update the remote migration history table"; + const BRANCH_BY_NAME = { id: "11111111-1111-4111-8111-111111111111", name: "staging", @@ -378,21 +390,20 @@ function makeDbConfigLayers( }); const resolver = Layer.succeed(DbConfigResolver, { - resolve: (resolveFlags) => { - const { connType } = resolveFlags; - return Effect.succeed({ + resolve: () => Effect.die("resolve is not used by pull"), + resolveConnection: ({ target }: DbConnectionInput) => + Effect.succeed({ conn: { - host: connType === "local" ? "127.0.0.1" : `db.${VALID_REF}.supabase.co`, + host: `db.${VALID_REF}.supabase.co`, port: TARGET_PORT, user: "postgres", password: "x", database: "postgres", }, - isLocal: connType === "local", + isLocal: false, ref: Option.some(VALID_REF), - }); - }, - resolveConnection: () => Effect.die("resolveConnection is not used by this test"), + target, + } satisfies ResolvedDbConnection), resolvePoolerFallback: () => Effect.succeed(Option.none()), }); @@ -484,61 +495,61 @@ interface ApiOpts { readonly functionBodyFailsForSlug?: string; } -function makeApiMock(opts: ApiOpts) { - return mockCommandPlatformApi({ - handler: (request) => { - const url = request.url; - if (url.includes("/v2/projects/") && url.endsWith("/config")) { - return Effect.succeed( - jsonResponse(request, 200, opts.configResponse ?? v2ProjectConfigResponse()), - ); - } - if (url.includes("/v1/branches/")) { - return Effect.succeed(jsonResponse(request, 200, {})); - } - if (url.includes("/branches/")) { - if (opts.branchNetworkFails === true) { - return Effect.fail(transportFailure(request)); - } - return Effect.succeed(jsonResponse(request, 200, BRANCH_BY_NAME)); +function apiHandlerFor(opts: ApiOpts): ApiHandler { + return (request) => { + const url = request.url; + if (url.includes("/v2/projects/") && url.endsWith("/config")) { + return Effect.succeed( + jsonResponse(request, 200, opts.configResponse ?? v2ProjectConfigResponse()), + ); + } + if (url.includes("/v1/branches/")) { + return Effect.succeed(jsonResponse(request, 200, {})); + } + if (url.includes("/branches/")) { + if (opts.branchNetworkFails === true) { + return Effect.fail(transportFailure(request)); } - if (url.endsWith("/body")) { - if ( - opts.functionBodyFailsForSlug !== undefined && - url.includes(`/functions/${opts.functionBodyFailsForSlug}/body`) - ) { - return Effect.succeed(jsonResponse(request, 500, "download failed")); - } - const { boundary, body } = multipartFixture("console.log('pull');\n"); - return Effect.succeed( - HttpClientResponse.fromWeb( - request, - new Response(body, { - status: 200, - headers: { "content-type": `multipart/form-data; boundary=${boundary}` }, - }), - ), - ); + return Effect.succeed(jsonResponse(request, 200, BRANCH_BY_NAME)); + } + if (url.endsWith("/body")) { + if ( + opts.functionBodyFailsForSlug !== undefined && + url.includes(`/functions/${opts.functionBodyFailsForSlug}/body`) + ) { + return Effect.succeed(jsonResponse(request, 500, "download failed")); } - if (url.endsWith("/functions")) { - if (opts.functionsListStatus !== undefined && opts.functionsListStatus !== 200) { - return Effect.succeed( - jsonResponse(request, opts.functionsListStatus, "list functions failed"), - ); - } + const { boundary, body } = multipartFixture("console.log('pull');\n"); + return Effect.succeed( + HttpClientResponse.fromWeb( + request, + new Response(body, { + status: 200, + headers: { "content-type": `multipart/form-data; boundary=${boundary}` }, + }), + ), + ); + } + if (url.endsWith("/functions")) { + if (opts.functionsListStatus !== undefined && opts.functionsListStatus !== 200) { return Effect.succeed( - jsonResponse( - request, - 200, - (opts.functionSlugs ?? []).map((slug) => ({ slug })), - ), + jsonResponse(request, opts.functionsListStatus, "list functions failed"), ); } - return Effect.succeed(jsonResponse(request, 200, {})); - }, - }); + return Effect.succeed( + jsonResponse( + request, + 200, + (opts.functionSlugs ?? []).map((slug) => ({ slug })), + ), + ); + } + return Effect.succeed(jsonResponse(request, 200, {})); + }; } +const makeApiMock = (opts: ApiOpts) => mockCommandPlatformApi({ handler: apiHandlerFor(opts) }); + // A real captured `Stdio` layer — only json/stream-json envelope tests need it, since // `MachineErrorContext` merging lives inside the real output layers' `fail`/`success` // implementations, which `mockOutput` never replicates. @@ -600,7 +611,7 @@ interface SetupOpts { /** Fails the db step's own remote-history UPSERT after the migration file has already been * written to disk. */ readonly dbHistoryUpdateFails?: boolean; - /** Runs as a side effect of the orchestrator's "Proceed with pull?" confirmation, before it + /** Runs as a side effect of the orchestrator's consent prompt, before it * resolves — simulates a concurrent edit to `supabase/config.toml` while that prompt is on * screen (text-mode, interactive TTY only). */ readonly confirmSideEffect?: Effect.Effect<void, PlatformError.PlatformError>; @@ -634,17 +645,11 @@ function setupMachine(opts: MachineSetupOpts) { return { ...setupRuntime(opts, outputLayer, capturingStdio.layer), capturingStdio }; } -function setupRuntime( +function setupSubstrate( opts: SetupOpts, outputLayer: Layer.Layer<Output>, stdioLayer: Layer.Layer<Stdio.Stdio>, ) { - const telemetry = mockTelemetryStateTracked(); - const linkedProjectCache = mockLinkedProjectCacheTracked(); - const processControl = mockProcessControl(); - const analytics = mockContextualAnalytics(); - - const api = makeApiMock(opts.api ?? {}); const spawner = composeSpawner({ gitDirty: opts.gitDirty, gitDirtyMigrations: opts.gitDirtyMigrations, @@ -659,26 +664,8 @@ function setupRuntime( ); const pgDelta = makePgDeltaEngine(opts.diffOutcome ?? (() => ({ changes: false }))); - const cliSettings = mockCommandSettings({ - workdir: opts.workdir ?? tempRoot.current, - projectId: Option.some(VALID_REF), - }); - const layer = Layer.mergeAll( configValuesLayer({ output: outputLayer }), - buildTestRuntime({ - out: { layer: outputLayer }, - api, - cliSettings, - tty: mockTty({ stdinIsTty: opts.stdinIsTty ?? false, stdoutIsTty: false }), - stdin: mockStdin(opts.stdinIsTty ?? false), - runtimeInfo: mockRuntimeInfo({ cwd: tempRoot.current }), - telemetry: telemetry.layer, - linkedProjectCache: linkedProjectCache.layer, - processControl: { layer: processControl.layer }, - analytics: { layer: analytics.layer }, - outputFlag: opts.outputFlag ?? Option.none(), - }), machineErrorContextLayer, commandRuntimeLayer(["pull"]).pipe(Layer.provide(BunServices.layer)), stdioLayer, @@ -702,19 +689,58 @@ function setupRuntime( requireSsl: () => Effect.succeed(false), requireSslForHost: () => Effect.succeed(false), }), - Layer.succeed(YesFlag, opts.yes ?? false), Layer.succeed(ExperimentalFlag, opts.experimental ?? false), Layer.succeed(DebugFlag, false), Layer.succeed(DnsResolverFlag, "native"), Layer.succeed(NetworkIdFlag, Option.none()), - Layer.succeed(CliArgs, { args: [] }), unusedStackServices, - // Listed after `buildTestRuntime` so it overrides the real spawner `BunServices.layer` - // provides (last-wins). spawner.layer, shadowCacheDisabledLayer, ); + return { layer, spawner, dbConfig, pgDelta, callOrder }; +} + +function setupRuntime( + opts: SetupOpts, + outputLayer: Layer.Layer<Output>, + stdioLayer: Layer.Layer<Stdio.Stdio>, +) { + const telemetry = mockTelemetryStateTracked(); + const linkedProjectCache = mockLinkedProjectCacheTracked(); + const processControl = mockProcessControl(); + const analytics = mockContextualAnalytics(); + + const api = makeApiMock(opts.api ?? {}); + const substrate = setupSubstrate(opts, outputLayer, stdioLayer); + + const cliSettings = mockCommandSettings({ + workdir: opts.workdir ?? tempRoot.current, + projectId: Option.some(VALID_REF), + }); + + const layer = Layer.mergeAll( + buildTestRuntime({ + out: { layer: outputLayer }, + api, + cliSettings, + tty: mockTty({ stdinIsTty: opts.stdinIsTty ?? false, stdoutIsTty: false }), + stdin: mockStdin(opts.stdinIsTty ?? false), + runtimeInfo: mockRuntimeInfo({ cwd: tempRoot.current }), + telemetry: telemetry.layer, + linkedProjectCache: linkedProjectCache.layer, + processControl: { layer: processControl.layer }, + analytics: { layer: analytics.layer }, + outputFlag: opts.outputFlag ?? Option.none(), + }), + Layer.succeed(YesFlag, opts.yes ?? false), + Layer.succeed(TargetPolicy, { group: "hosted", consent: "no" }), + Layer.succeed(CliArgs, { args: [] }), + // Listed after `buildTestRuntime` so its spawner overrides the real one `BunServices.layer` + // provides (last-wins). + substrate.layer, + ); + return { layer, api, @@ -722,10 +748,10 @@ function setupRuntime( linkedProjectCache, processControl, analytics, - spawner, - dbConfig, - pgDelta, - callOrder, + spawner: substrate.spawner, + dbConfig: substrate.dbConfig, + pgDelta: substrate.pgDelta, + callOrder: substrate.callOrder, }; } @@ -809,6 +835,61 @@ describe("pull integration", () => { }, ); + it.effect( + "the json payload's target keeps project_ref and adds kind, project_name and environment", + () => { + const { layer, capturingStdio } = setupMachine({ + format: "json", + yes: true, + remoteMigrations: [{ version: "20260101000000", name: "init", statements: ["select 1;"] }], + }); + return Effect.gen(function* () { + yield* writeConfig(); + yield* seedLocalMigration("20260101000000"); + const fs = yield* FileSystem.FileSystem; + yield* fs.makeDirectory(yield* projectPath("supabase", ".temp"), { recursive: true }); + yield* fs.writeFileString( + yield* projectPath("supabase", ".temp", "linked-project.json"), + JSON.stringify({ ref: VALID_REF, name: "Acme" }), + ); + yield* runPull(pullFlags()); + + const payload = yield* Schema.decodeUnknownEffect(jsonEnvelope)(capturingStdio.stdout[0]); + expect(payload["target"]).toEqual({ + kind: "linked", + project_ref: VALID_REF, + project_name: "Acme", + environment: "production", + }); + }).pipe(Effect.provide(layer)); + }, + ); + + it.effect( + "a branch-name --project-ref reports the branch and its parent in the json target", + () => { + const { layer, capturingStdio } = setupMachine({ + format: "json", + yes: true, + remoteMigrations: [{ version: "20260101000000", name: "init", statements: ["select 1;"] }], + }); + return Effect.gen(function* () { + yield* writeConfig(); + yield* seedLocalMigration("20260101000000"); + yield* runPull(pullFlags({ projectRef: Option.some("staging") })); + + const payload = yield* Schema.decodeUnknownEffect(jsonEnvelope)(capturingStdio.stdout[0]); + expect(payload["target"]).toEqual({ + kind: "linked", + project_ref: BRANCH_REF, + environment: "branch", + parent_project_ref: VALID_REF, + branch: "staging", + }); + }).pipe(Effect.provide(layer)); + }, + ); + it.effect( "renders the config diff body (not 'No config differences found.') when the only diff is skipped as an env() reference", () => { @@ -865,7 +946,7 @@ describe("pull integration", () => { yield* runPull(pullFlags()); expect(out.promptConfirmCalls).toHaveLength(1); - expect(out.promptConfirmCalls[0]?.message).toBe("Proceed with pull?"); + expect(out.promptConfirmCalls[0]?.message).toContain(CONSENT_ACTION); }).pipe(Effect.provide(layer)); }, ); @@ -906,7 +987,7 @@ describe("pull integration", () => { yield* runPull(pullFlags({ withMigrationHistory: true })); expect(out.promptConfirmCalls).toHaveLength(1); - expect(out.promptConfirmCalls[0]?.message).toBe("Proceed with pull?"); + expect(out.promptConfirmCalls[0]?.message).toContain(CONSENT_ACTION); const rewritten = yield* readTextFile(preExisting); expect(rewritten).toContain("create table remote_only"); @@ -971,27 +1052,30 @@ describe("pull integration", () => { }, ); - it.effect( - "declining the confirmation writes nothing; migration history reports declined, db/functions report planned", - () => { - const { layer, out } = setup({ stdinIsTty: true, confirm: [false] }); - return Effect.gen(function* () { - yield* writeConfig("[api]\nmax_rows = 500\n"); - const before = yield* readConfigFile; - const exit = yield* Effect.exit(runPull(pullFlags())); - expect(Exit.isSuccess(exit)).toBe(true); - - expect(yield* readConfigFile).toBe(before); - expect(yield* projectFileExists("supabase", "migrations")).toBe(false); + it.effect("declining the consent prompt aborts before any step writes", () => { + const { layer, out, api, dbConfig } = setup({ + stdinIsTty: true, + confirm: [false], + remoteMigrations: [{ version: "20260101000000", name: "init", statements: ["select 1;"] }], + diffOutcome: () => ({ + changes: true, + files: [{ name: "pull", sql: "alter table foo add column bar text;" }], + }), + }); + return Effect.gen(function* () { + yield* writeConfig("[api]\nmax_rows = 500\n"); + const before = yield* readConfigFile; + const error = yield* Effect.flip(runPull(pullFlags())); + expect(error).toBeInstanceOf(HostedMutationDeclinedError); - expect(stepLine(out.stdoutText, "config")).toContain("planned"); - expect(stepLine(out.stdoutText, "migration_history")).toContain("skipped"); - expect(stepLine(out.stdoutText, "migration_history")).toContain("declined"); - expect(stepLine(out.stdoutText, "db")).toContain("planned"); - expect(stepLine(out.stdoutText, "functions")).toContain("planned"); - }).pipe(Effect.provide(layer)); - }, - ); + expect(yield* readConfigFile).toBe(before); + expect(yield* projectFileExists("supabase", "migrations")).toBe(false); + expect(yield* projectFileExists("supabase", "functions")).toBe(false); + expect(dbConfig.historyUpserts).toHaveLength(0); + expect(api.requests.some((request) => request.method !== "GET")).toBe(false); + expect(out.stdoutText).not.toContain("Pull summary"); + }).pipe(Effect.provide(layer)); + }); it.effect( "a functions-step failure still reports every other step, exits non-zero, with exactly one JSON envelope", @@ -1094,7 +1178,9 @@ describe("pull integration", () => { return Effect.gen(function* () { yield* writeConfig("[api]\nmax_rows = 500\n"); const before = yield* readConfigFile; - yield* runPull(pullFlags()); + expect(yield* Effect.flip(runPull(pullFlags()))).toBeInstanceOf( + HostedMutationDeclinedError, + ); expect(out.promptConfirmCalls[0]?.opts?.defaultValue).toBe(false); expect(yield* readConfigFile).toBe(before); }).pipe(Effect.provide(layer)); @@ -1241,7 +1327,9 @@ describe("pull integration", () => { return Effect.gen(function* () { yield* writeConfig(); yield* seedLocalMigration("20260101000000"); - yield* runPull(pullFlags()); + expect(yield* Effect.flip(runPull(pullFlags()))).toBeInstanceOf( + HostedMutationDeclinedError, + ); expect(out.promptConfirmCalls[0]?.opts?.defaultValue).toBe(false); expect(out.stdoutText).toContain( "supabase/migrations has uncommitted or untracked changes. Commit or stash them (-u for untracked), or rerun with --force.", @@ -1287,7 +1375,9 @@ describe("pull integration", () => { return Effect.gen(function* () { yield* writeConfig(); yield* seedLocalMigration("20260101000000"); - yield* runPull(pullFlags()); + expect(yield* Effect.flip(runPull(pullFlags()))).toBeInstanceOf( + HostedMutationDeclinedError, + ); expect(out.promptConfirmCalls[0]?.opts?.defaultValue).toBe(false); expect(out.stdoutText).toContain( "supabase/functions has uncommitted or untracked changes. Commit or stash them (-u for untracked), or rerun with --force.", @@ -1314,7 +1404,9 @@ describe("pull integration", () => { return Effect.gen(function* () { yield* writeConfig("[api]\nmax_rows = 500\n"); yield* seedLocalMigration("20260101000000"); - yield* runPull(pullFlags()); + expect(yield* Effect.flip(runPull(pullFlags()))).toBeInstanceOf( + HostedMutationDeclinedError, + ); expect(out.stdoutText).toContain( "supabase/config.toml and supabase/functions have uncommitted or untracked changes. Commit or stash them (-u for untracked), or rerun with --force.", ); @@ -1981,7 +2073,7 @@ describe("pull integration", () => { }); // Exercises the "not_needed" reason branch (migrations already populated), distinct from the - // bootstrap ("declined"/"planned" via an empty directory) cases above. + // bootstrap ("planned" via an empty directory) cases above. it.effect( "--dry-run over an already-populated supabase/migrations reports migration_history as not_needed", () => { @@ -1995,20 +2087,6 @@ describe("pull integration", () => { }).pipe(Effect.provide(layer)); }, ); - - it.effect( - "declining over an already-populated supabase/migrations reports migration_history as not_needed", - () => { - const { layer, out } = setup({ stdinIsTty: true, confirm: [false] }); - return Effect.gen(function* () { - yield* writeConfig("[api]\nmax_rows = 500\n"); - yield* seedLocalMigration("20260101000000"); - yield* runPull(pullFlags()); - expect(stepLine(out.stdoutText, "migration_history")).toContain("skipped"); - expect(stepLine(out.stdoutText, "migration_history")).toContain("not_needed"); - }).pipe(Effect.provide(layer)); - }, - ); }); // The raw `--project-ref` value in `cli_command_executed`'s `flags` map is read from the @@ -2107,3 +2185,40 @@ describe("pull stream-json output", () => { }, ); }); + +describeHostedConsent("pull", { + api: { handler: apiHandlerFor({}) }, + setup: (workdir) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* fs.makeDirectory(`${workdir}/supabase/.temp`, { recursive: true }); + yield* fs.writeFileString(`${workdir}/supabase/.temp/project-ref`, VALID_REF); + yield* fs.writeFileString( + `${workdir}/supabase/config.toml`, + `project_id = "${VALID_REF}"\n\n[experimental.pgdelta]\nenabled = true\n[api]\nmax_rows = 500\n`, + ); + }), + invoke: ({ out }) => { + const substrate = setupSubstrate( + { + remoteMigrations: [{ version: "20260101000000", name: "init", statements: ["select 1;"] }], + diffOutcome: () => ({ + changes: true, + files: [{ name: "pull", sql: "alter table foo add column bar text;" }], + }), + }, + out.layer, + Stdio.layerTest({ args: Effect.succeed(["pull"]) }), + ); + return { + run: Effect.gen(function* () { + yield* TestClock.setTime(AFTER_SEEDED_MIGRATIONS); + return yield* pull(pullFlags()); + }).pipe( + // `pull` rejects `-o`; machine output only comes from `--output-format`. + Effect.provide(Layer.mergeAll(substrate.layer, Layer.succeed(OutputFlag, Option.none()))), + ), + mutations: () => substrate.dbConfig.historyUpserts.length, + }; + }, +}); diff --git a/apps/cli/src/commands/pull/pull.live.test.ts b/apps/cli/src/commands/pull/pull.live.test.ts index 384fa670be..cb80061511 100644 --- a/apps/cli/src/commands/pull/pull.live.test.ts +++ b/apps/cli/src/commands/pull/pull.live.test.ts @@ -42,7 +42,7 @@ test("pulls config, migration history, db schema, and functions from a fresh pro expect(payload).toEqual( expect.objectContaining({ schema_version: 1, - target: expect.objectContaining({ project_ref: project.ref }), + target: expect.objectContaining({ kind: "linked", project_ref: project.ref }), steps: expect.objectContaining({ config: expect.objectContaining({ status: expect.not.stringMatching(/^failed$/) }), migration_history: expect.objectContaining({ diff --git a/apps/cli/src/commands/pull/pull.steps.ts b/apps/cli/src/commands/pull/pull.steps.ts index 36583c8448..50d1eb91d7 100644 --- a/apps/cli/src/commands/pull/pull.steps.ts +++ b/apps/cli/src/commands/pull/pull.steps.ts @@ -15,10 +15,7 @@ import { type ConfigPullSource, } from "../../command-internal/config-pull-run.ts"; import { runDbPull, type DbPullRunFlags } from "../../command-internal/db-pull-run.ts"; -import { - runMigrationFetch, - type MigrationFetchFlags, -} from "../../command-internal/migration-fetch-run.ts"; +import { runMigrationFetch } from "../../command-internal/migration-fetch-run.ts"; import type { PullConfigStepOutcome, PullDbStepOutcome, @@ -56,21 +53,14 @@ export const pullConfigStep = Effect.fnUntraced(function* (input: { /** * `migration_history` step: fetches the remote migration history table into - * `supabase/migrations`, targeting the already-resolved `context.ref` directly (ADR 0024) and + * `supabase/migrations`, targeting the already-resolved `context.target` directly (ADR 0024) and * suppressing its own overwrite prompt with `assumeYes` since the orchestrator's confirmation * already covers it. */ export const pullMigrationHistoryStep = Effect.fnUntraced(function* (context: PullStepContext) { const cliSettings = yield* CommandSettings; - const flags: MigrationFetchFlags = { - dbUrl: Option.none(), - linked: true, - local: false, - projectRef: Option.some(context.ref), - }; const outcome = yield* runMigrationFetch({ - flags, - target: { setFlags: [], connType: "linked" }, + target: context.target, assumeYes: context.assumeYes, }); return { @@ -81,8 +71,8 @@ export const pullMigrationHistoryStep = Effect.fnUntraced(function* (context: Pu }); /** - * `db` step: pulls the linked project's schema in migration mode, targeting `context.ref` - * directly and suppressing `db pull`'s remote-history-update prompt with `assumeYes`. + * `db` step: pulls the linked project's schema in migration mode, targeting `context.target` + * directly and reusing the orchestrator's consent for the remote-history update. * `DbPullInSyncError` is caught and reported as `in_sync`, a finding rather than a failure at the * `pull` level (ADR 0024). */ @@ -94,13 +84,9 @@ export const pullDbStep = Effect.fnUntraced(function* (context: PullStepContext) diffEngine: Option.none(), strictCoverage: false, schema: [], - dbUrl: Option.none(), - linked: Option.none(), - local: Option.none(), - projectRef: Option.some(context.ref), password: Option.none(), }; - return yield* runDbPull(flags, { assumeYes: context.assumeYes }).pipe( + return yield* runDbPull(flags, context.target, { preapproved: context.preapproved }).pipe( Effect.map((outcome): PullDbStepOutcome => ({ kind: "applied", outcome, diff --git a/apps/cli/src/commands/pull/pull.types.ts b/apps/cli/src/commands/pull/pull.types.ts index ece1f2da2c..598145a9e6 100644 --- a/apps/cli/src/commands/pull/pull.types.ts +++ b/apps/cli/src/commands/pull/pull.types.ts @@ -1,3 +1,9 @@ +import type { Option } from "effect"; + +import type { HostedMutationConsent } from "../../command-internal/hosted-consent/hosted-consent.token.ts"; +import type { ResolvedTarget } from "../../command-internal/target/resolve-target.ts"; +import type { TargetJson } from "../../shared/output/command-target.service.ts"; + export const PULL_STEP_ORDER = ["config", "migration_history", "db", "functions"] as const; export type PullStepId = (typeof PULL_STEP_ORDER)[number]; @@ -19,7 +25,7 @@ export interface PullStepResult { readonly written: ReadonlyArray<string>; /** Per-step machine detail; shape varies by step. See SIDE_EFFECTS.md's JSON payload shape. */ readonly detail: Record<string, unknown>; - /** Present for `status: "skipped"` (e.g. "not_needed", "declined", "--with-migration-history not set"). */ + /** Present for `status: "skipped"` (e.g. "not_needed", "--with-migration-history not set"). */ readonly reason?: string; /** Present for `status: "failed"`. */ readonly failure?: PullStepFailure; @@ -28,11 +34,16 @@ export interface PullStepResult { export interface PullStepContext { readonly ref: string; readonly assumeYes: boolean; + readonly target: ResolvedTarget; + /** The consent collected for the whole pull; `db pull` reuses it instead of asking again. */ + readonly preapproved: Option.Option<HostedMutationConsent>; } export interface PullAggregate { readonly ref: string; readonly branch: string | undefined; + /** The announced target's machine form (`kind`, identity fields), merged into the payload's `target`. */ + readonly targetIdentity?: TargetJson; readonly dryRun: boolean; readonly confirmed: boolean; /** Workdir-relative paths that had uncommitted or untracked changes at the start of this From 57ee6db6312dfad5dddb6bee240b1925fbe9247f Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Thu, 15 Oct 2026 14:49:38 +0100 Subject: [PATCH 51/92] docs(cli): document workspace target defaults and hosted consent for db commands --- apps/cli/docs/supabase/db/advisors.md | 2 + apps/cli/docs/supabase/db/diff.md | 2 +- apps/cli/docs/supabase/db/dump.md | 2 +- apps/cli/docs/supabase/db/lint.md | 2 +- apps/cli/docs/supabase/db/query.md | 6 ++ apps/cli/docs/supabase/db/reset.md | 4 + apps/cli/docs/supabase/test/db.md | 4 + .../src/commands/db/advisors/SIDE_EFFECTS.md | 75 ++++++++------- apps/cli/src/commands/db/diff/SIDE_EFFECTS.md | 72 ++++++++------- apps/cli/src/commands/db/dump/SIDE_EFFECTS.md | 61 +++++++------ apps/cli/src/commands/db/lint/SIDE_EFFECTS.md | 69 +++++++------- .../cli/src/commands/db/query/SIDE_EFFECTS.md | 91 ++++++++++++------- .../cli/src/commands/db/reset/SIDE_EFFECTS.md | 77 +++++++++------- apps/cli/src/commands/db/test/SIDE_EFFECTS.md | 11 +-- apps/cli/src/commands/test/db/SIDE_EFFECTS.md | 88 ++++++++++-------- 15 files changed, 325 insertions(+), 241 deletions(-) diff --git a/apps/cli/docs/supabase/db/advisors.md b/apps/cli/docs/supabase/db/advisors.md index b434c9f26f..aa9efb02f9 100644 --- a/apps/cli/docs/supabase/db/advisors.md +++ b/apps/cli/docs/supabase/db/advisors.md @@ -1,3 +1,5 @@ ## supabase-db-advisors Inspects the database for common security and performance issues such as missing RLS policies, unindexed foreign keys, exposed auth.users, and more. + +With no target flag the command inspects the workspace target, which is the local database unless `SUPABASE_TARGET` or `target` in `config.toml` says otherwise. Pass `--linked` for the linked project or `--db-url` for another database. The command prints a `Target:` line on stderr naming the database it resolved, and never asks for confirmation. diff --git a/apps/cli/docs/supabase/db/diff.md b/apps/cli/docs/supabase/db/diff.md index cdcae8eb42..3090bfc92d 100644 --- a/apps/cli/docs/supabase/db/diff.md +++ b/apps/cli/docs/supabase/db/diff.md @@ -2,7 +2,7 @@ Diffs schema changes made to the local or remote database. -Requires the local development stack to be running when diffing against the local database. To diff against a remote or self-hosted database, specify the `--linked` or `--db-url` flag respectively. +Requires the local development stack to be running when diffing against the local database. To diff against a remote or self-hosted database, specify the `--linked` or `--db-url` flag respectively. With no target flag the command diffs the workspace target, which is the local database unless `SUPABASE_TARGET` or `target` in `config.toml` says otherwise. The command prints a `Target:` line on stderr naming the database it resolved, and never asks for confirmation. Compares schema differences between the target database and a shadow database, using the bundled pg-delta engine by default. The legacy [djrobstep/migra](https://github.com/djrobstep/migra) engine, which runs in a container, remains available as a fallback (see below). The shadow database is created by applying migrations in local `supabase/migrations` directory in a separate container. Output is written to stdout by default. For convenience, you can also save the schema diff as a new migration file by passing in `-f` flag. diff --git a/apps/cli/docs/supabase/db/dump.md b/apps/cli/docs/supabase/db/dump.md index 3d5c4fae54..7cdd3b6629 100644 --- a/apps/cli/docs/supabase/db/dump.md +++ b/apps/cli/docs/supabase/db/dump.md @@ -2,7 +2,7 @@ Dumps contents from a remote database. -Requires your local project to be linked to a remote database by running `supabase link`. For self-hosted databases, you can pass in the connection parameters using `--db-url` flag. +Dumps the workspace target by default, which is the local database unless `SUPABASE_TARGET` or `target` in `config.toml` says otherwise. Pass `--linked` to dump the linked project, which requires your local project to be linked by running `supabase link`. For self-hosted databases, you can pass in the connection parameters using `--db-url` flag. The command prints a `Target:` line on stderr naming the database it resolved, and never asks for confirmation. Runs `pg_dump` in a container with additional flags to exclude Supabase managed schemas. The ignored schemas include auth, storage, and those created by extensions. diff --git a/apps/cli/docs/supabase/db/lint.md b/apps/cli/docs/supabase/db/lint.md index 5858d29113..16201908b2 100644 --- a/apps/cli/docs/supabase/db/lint.md +++ b/apps/cli/docs/supabase/db/lint.md @@ -2,7 +2,7 @@ Lints local database for schema errors. -Requires the local development stack to be running when linting against the local database. To lint against a remote or self-hosted database, specify the `--linked` or `--db-url` flag respectively. +Requires the local development stack to be running when linting against the local database. To lint against a remote or self-hosted database, specify the `--linked` or `--db-url` flag respectively. With no target flag the command lints the workspace target, which is the local database unless `SUPABASE_TARGET` or `target` in `config.toml` says otherwise. The command prints a `Target:` line on stderr naming the database it resolved, and never asks for confirmation. Runs the `plpgsql_check` extension against the local database (native or container) to check for errors in all schemas. The default lint level is `warning` and can be raised to error via the `--level` flag. diff --git a/apps/cli/docs/supabase/db/query.md b/apps/cli/docs/supabase/db/query.md index 35d3bf6224..7eddc54c33 100644 --- a/apps/cli/docs/supabase/db/query.md +++ b/apps/cli/docs/supabase/db/query.md @@ -2,6 +2,12 @@ Execute a SQL query against the local or linked database. +With no target flag the command runs against the workspace target, which is the local database unless `SUPABASE_TARGET` or `target` in `config.toml` says otherwise. Pass `--linked` for the linked project or `--db-url` for another database. The command prints a `Target:` line on stderr naming the database it resolved. + +The command cannot tell a read from a write, so a hosted database (the linked project or a remote `--db-url`) asks for one confirmation (default No) before the statement is sent. Piped answers are never read, so a script, a JSON run or an agent must pass `--yes` or set `SUPABASE_YES` in the shell; a `SUPABASE_YES` in `supabase/.env` does not count. The local database never asks. + +Pass `--read-only` to run a statement that must not change anything. It skips the confirmation on a hosted database and makes the statement unable to write: a linked project runs it as a read-only query through the Management API, and the local database or a `--db-url` database runs it in a read-only transaction that is always rolled back. A statement that ends the transaction itself, such as `COMMIT`, can leave the read-only guard on a direct connection. + When used by an AI coding agent (auto-detected or via --agent=yes), the default output format is JSON with an untrusted data warning envelope. When used by a human (--agent=no or no agent detected), the default output format is table diff --git a/apps/cli/docs/supabase/db/reset.md b/apps/cli/docs/supabase/db/reset.md index 9a60a67711..58033d5c98 100644 --- a/apps/cli/docs/supabase/db/reset.md +++ b/apps/cli/docs/supabase/db/reset.md @@ -8,6 +8,10 @@ Recreates the local Postgres container and applies all local migrations found in Use the `--no-seed` flag to skip seeding entirely. To override `[db.seed].sql_paths` for a single reset, pass one or more `--sql-paths` flags. Each value accepts the same file path or glob pattern syntax as `sql_paths`, relative to the `supabase` directory. Passing `--sql-paths` force-enables seeding for that reset even when `[db.seed].enabled = false`. +With no target flag the command resets the workspace target, which is the local database unless `SUPABASE_TARGET` or `target` in `config.toml` says otherwise. The command prints a `Target:` line on stderr naming the database it resolved. + When running db reset with `--linked` or `--db-url` flag, a SQL script is executed to identify and drop all user created entities in the remote database. Since Postgres roles are cluster level entities, any custom roles created through the dashboard or `supabase/roles.sql` will not be deleted by remote reset. +A remote reset asks for one confirmation (default No) after it has worked out what it will do and before anything is dropped. The question lists dropping every user schema, any vault secrets it will upsert, the migrations or schema files it will apply, and the seed files it will run. Declining stops the command without changing the database. Piped answers are never read, so a script, a JSON run or an agent must pass `--yes` or set `SUPABASE_YES` in the shell; a `SUPABASE_YES` in `supabase/.env` does not count. The local database never asks. + If you combine `--sql-paths` with `--linked` or `--db-url`, the override seed files are applied to the selected remote database after migrations. Use this only when you intend to seed that remote target. diff --git a/apps/cli/docs/supabase/test/db.md b/apps/cli/docs/supabase/test/db.md index 9978bb4535..c79b87ac57 100644 --- a/apps/cli/docs/supabase/test/db.md +++ b/apps/cli/docs/supabase/test/db.md @@ -4,6 +4,10 @@ Executes pgTAP tests against the local database. Requires the local development stack to be started by running `supabase start`. +With no target flag the command tests the workspace target, which is the local database unless `SUPABASE_TARGET` or `target` in `config.toml` says otherwise. Pass `--linked` for the linked project or `--db-url` for another database. The command prints a `Target:` line on stderr naming the database it resolved. + +Running the tests enables the pgTAP extension and drops it again afterwards, so a hosted database (the linked project or a remote `--db-url`) asks for one confirmation (default No) before anything runs. Piped answers are never read, so a script, a JSON run or an agent must pass `--yes` or set `SUPABASE_YES` in the shell; a `SUPABASE_YES` in `supabase/.env` does not count. The local database never asks. + Runs `pg_prove` in a container with unit test files volume mounted from `supabase/tests` directory. The test file can be suffixed by either `.sql` or `.pg` extension. Since each test is wrapped in its own transaction, it will be individually rolled back regardless of success or failure. diff --git a/apps/cli/src/commands/db/advisors/SIDE_EFFECTS.md b/apps/cli/src/commands/db/advisors/SIDE_EFFECTS.md index b86b6e2ba5..07c6660788 100644 --- a/apps/cli/src/commands/db/advisors/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/db/advisors/SIDE_EFFECTS.md @@ -1,27 +1,32 @@ # `supabase db advisors` -Checks a database for security and performance issues. Two backends: `--local` -/ `--db-url` query the database directly; `--linked` fetches from the +Checks a database for security and performance issues. Two backends: the local +stack and `--db-url` query the database directly; the linked project fetches from the Management API. +With no target flag the command follows the workspace target (`SUPABASE_TARGET`, then `target` +in `config.toml`, default `local`; see +[ADR 0033](../../../../../../docs/adr/0033-target-selection-and-hosted-mutation-consent.md)). +It only reads, so it never asks for confirmation, on any target. + ## Files Read -| Path | Format | When | -| -------------------------------------- | ---------- | ------------------------------------------------------------------------------------------------------------ | -| `<workdir>/supabase/config.toml` | TOML | local / `--db-url` — to resolve the DB connection config | -| `~/.supabase/access-token` | plain text | `--linked` only, when `SUPABASE_ACCESS_TOKEN` unset (keyring → file) | -| `<workdir>/supabase/.temp/project-ref` | plain text | `--linked` only — to resolve the project ref; skipped when `--project-ref` (or `SUPABASE_PROJECT_ID`) is set | +| Path | Format | When | +| -------------------------------------- | ---------- | ----------------------------------------------------------------------------------------------------------------- | +| `<workdir>/supabase/config.toml` | TOML | local / `--db-url` — to resolve the DB connection config | +| `~/.supabase/access-token` | plain text | a linked target only, when `SUPABASE_ACCESS_TOKEN` unset (keyring → file) | +| `<workdir>/supabase/.temp/project-ref` | plain text | a linked target only — to resolve the project ref; skipped when `--project-ref` (or `SUPABASE_PROJECT_ID`) is set | ## Files Written -| Path | Format | When | -| ---------------------------------------------- | ------ | ----------------------------------------------- | -| `~/.supabase/telemetry.json` | JSON | always (PostHog state flush) | -| `<workdir>/supabase/.temp/linked-project.json` | JSON | `--linked` only, via `LinkedProjectCache.cache` | +| Path | Format | When | +| ---------------------------------------------- | ------ | ---------------------------------------------------- | +| `~/.supabase/telemetry.json` | JSON | always (PostHog state flush) | +| `<workdir>/supabase/.temp/linked-project.json` | JSON | a linked target only, via `LinkedProjectCache.cache` | The local lint query runs inside a transaction that is **always rolled back**. -## API Routes (`--linked` only) +## API Routes (linked target only) | Method | Path | Auth | Request | Response (used fields) | | ------ | ----------------------------------------- | ------ | ------- | ---------------------- | @@ -33,7 +38,7 @@ Issued via **raw HTTP** (not the typed client) with a tolerant parse, so advisor selects which endpoints run: `security` → security only, `performance` → performance only, `all` → both. -## Database (`--local` / `--db-url`) +## Database (local / `--db-url`) One connection. Within one transaction: `BEGIN` → `set local search_path = ''` (setup half of `templates/lints.sql`) → the multi-CTE lints query → `ROLLBACK`. @@ -41,33 +46,35 @@ One connection. Within one transaction: `BEGIN` → `set local search_path = ''` ## Environment Variables -| Variable | Purpose | Required? | -| ----------------------- | -------------------------------------------------------------------- | ----------------------------------------- | -| `SUPABASE_ACCESS_TOKEN` | auth token for `--linked` | no (keyring → `~/.supabase/access-token`) | -| `SUPABASE_PROJECT_ID` | linked project ref override (superseded by `--project-ref` when set) | no | -| `SUPABASE_PROFILE` | API profile (built-in name or YAML path) | no | -| `PGHOST` / `PGPORT` / … | connection overrides (local / `--db-url`) | no | +| Variable | Purpose | Required? | +| ----------------------- | -------------------------------------------------------------------- | ------------------------------------------ | +| `SUPABASE_ACCESS_TOKEN` | auth token for a linked target | no (keyring → `~/.supabase/access-token`) | +| `SUPABASE_TARGET` | workspace target when no target flag is set | no (→ `target` in `config.toml` → `local`) | +| `SUPABASE_PROJECT_ID` | linked project ref override (superseded by `--project-ref` when set) | no | +| `SUPABASE_PROFILE` | API profile (built-in name or YAML path) | no | +| `PGHOST` / `PGPORT` / … | connection overrides (local / `--db-url`) | no | The API base URL is derived from `SUPABASE_PROFILE`; `SUPABASE_API_URL` is **not** honored (see `command-settings.layer.unit.test.ts`). ## Exit Codes -| Code | Condition | -| ---- | ------------------------------------------------------------------------ | -| `0` | success — no issues at or above `--fail-on` (empty result is still `0`) | -| `1` | mutually-exclusive `--db-url` / `--linked` / `--local` | -| `1` | `--linked` with no access token (suggests `supabase login`) | -| `1` | connection / `BEGIN` / setup / query failure (local) | -| `1` | advisors API non-200 (linked) | -| `1` | a lint's level is at or above `--fail-on` | -| `1` | `--project-ref` set with a resolved target other than linked (see Notes) | +| Code | Condition | +| ---- | ------------------------------------------------------------------------------------ | +| `0` | success — no issues at or above `--fail-on` (empty result is still `0`) | +| `1` | more than one of `--db-url` / `--linked` / `--local` (`TargetSelectorConflictError`) | +| `1` | a linked target with no access token (suggests `supabase login`) | +| `1` | connection / `BEGIN` / setup / query failure (local) | +| `1` | advisors API non-200 (linked) | +| `1` | a lint's level is at or above `--fail-on` | +| `1` | `--project-ref` with `--local` or `--db-url` (`TargetProjectRefConflictError`) | ## Output ### `--output-format text` -Diagnostics on **stderr**: `Connecting to <local\|remote> database...` (local) and +Diagnostics on **stderr**: the `Target: …` line (linked project `Target: <name> (<ref>)`, +`Target: local stack (<host>:<port>)`, or `Target: custom Postgres (<host>)`) and `No issues found` (when no lints). The result is a pretty-printed 2-space JSON array on **stdout** (struct-order keys, `metadata` omitted when absent, trailing newline). @@ -89,11 +96,9 @@ the process exits non-zero (no error envelope is written over the payload). - `--type` (`all` default): `security` / `performance` select endpoints (linked) or filter categories (local). - `--level` (`warn` default) sets the minimum issue level to display. - `--fail-on` (`none` default) sets the level that forces a non-zero exit. -- `--db-url`, `--linked`, and `--local` (default true) are mutually exclusive. -- **`--project-ref`** overrides ONLY the linked-ref resolution used for the connection and - the linked-project cache (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 (stricter than `SUPABASE_PROJECT_ID`, which is simply left unused on a non-linked target). +- **Target selection.** `--db-url`, `--linked` and `--local` are mutually exclusive and a `false` + switch counts as absent. `--project-ref` selects the linked project and supplies only the ref + (flag > `SUPABASE_PROJECT_ID` > `.temp/project-ref`) for the connection and the linked-project + cache; combining it with `--local` or `--db-url` is a hard error. - Not-logged-in suggestion: `Run supabase login first.` - Telemetry: only the standard `cli_command_executed` event (no custom events). diff --git a/apps/cli/src/commands/db/diff/SIDE_EFFECTS.md b/apps/cli/src/commands/db/diff/SIDE_EFFECTS.md index aaed23e195..8de535fa12 100644 --- a/apps/cli/src/commands/db/diff/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/db/diff/SIDE_EFFECTS.md @@ -8,6 +8,12 @@ edge-runtime involved). `--use-pg-schema` is removed: the flag stays parsed (hidden) only so using it fails with an actionable removal error instead of an unknown-flag parse error — see "`--use-pg-schema` is removed" below. +With no target flag the command diffs the workspace target (`SUPABASE_TARGET`, then `target` in +`config.toml`, default `local`; see +[ADR 0033](../../../../../../docs/adr/0033-target-selection-and-hosted-mutation-consent.md)). +`--local` selects the local stack, `--linked` or `--project-ref` the linked project, and `--db-url` +a custom database. It only reads, so it never asks for confirmation, on any target. + When `[experimental].stack` is on, each shadow is a fresh database in an invocation-owned, unique temporary stack namespace. The command applies the catalog and project migrations as needed, then destroys its namespace when the Effect scope closes. If its container engine is unreachable @@ -39,8 +45,8 @@ it, and JSON `null` disables formatting without disabling safe compaction. | managed snapshot key `<key>` | backend | warm stack-shadow cache hit; managed retention may evict entries and snapshots survive stack destruction | | `~/.supabase/cache/shadow-baseline/shadow-baseline-<key>.tar.<pid>.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) | | `[db.migrations].schema_paths` globs / `<workdir>/supabase/database/**` / `<workdir>/supabase/schemas/**` | SQL | migra engine only, for the local-target declarative-schema fallback; pg-delta always compares the migrations baseline directly to the live target | -| `~/.supabase/access-token` | plain text | `--linked` / `--db-url` with no `SUPABASE_ACCESS_TOKEN` | -| `<workdir>/supabase/.temp/project-ref` | plain text | `--linked` ref resolution — skipped when `--project-ref` (or `SUPABASE_PROJECT_ID`) is set | +| `~/.supabase/access-token` | plain text | a linked target with no `SUPABASE_ACCESS_TOKEN` | +| `<workdir>/supabase/.temp/project-ref` | plain text | a linked target, ref resolution — skipped when `--project-ref` (or `SUPABASE_PROJECT_ID`) is set | ## Files Written @@ -52,7 +58,7 @@ it, and JSON `null` disables formatting without disabling safe compaction. | `~/.supabase/cache/shadow-baseline/shadow-baseline-<key>.tar` | tar | cache-enabled COLD shadow provision creates the current key's snapshot (native diff targets + the explicit `--from/--to migrations` shadow; never `--use-pgadmin`/`--use-pg-schema`); a warm hit `touch`es its mtime (LRU); every cache-eligible acquire may delete other keys under LRU keep-3 + 2-day mtime TTL — ~90MB (`SUPABASE_HOME` overrides the root) | | managed snapshot key `<key>` | backend | stack-mode cache-enabled cold shadow provision saves the database baseline; the last completed save replaces the key | | `~/.supabase/cache/shadow-baseline/shadow-baseline-<key>.tar.<pid>.partial` | tar | during a cold export — the in-flight temp file, `rename`d into the tar above on success and removed on failure; only a crash/SIGKILL leaves it behind, and later cold exports / warm hits sweep leftovers older than 5 minutes | -| `~/.supabase/<workdir-hash>/linked-project.json` | JSON | `--linked` (post-run cache) | +| `~/.supabase/<workdir-hash>/linked-project.json` | JSON | a linked target (post-run cache) | | `~/.supabase/telemetry.json` | JSON | every invocation (post-run) | ## Docker @@ -98,14 +104,15 @@ it, and JSON `null` disables formatting without disabling safe compaction. | DELETE | `/v1/projects/{ref}/network-bans` | Bearer | Unban during pooler login retry | | GET | `/v1/projects/{ref}` | Bearer | Linked-project cache (post-run) | -`--use-pgadmin --linked` performs every one of these calls natively (CLI-1968), as part +`--use-pgadmin` with a linked target performs every one of these calls natively (CLI-1968), as part of this command's own target resolve, ahead of the differ container. ## Environment Variables | Variable | Purpose | Required? | | ------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------- | -| `SUPABASE_ACCESS_TOKEN` | auth for `--linked` | no | +| `SUPABASE_ACCESS_TOKEN` | auth for a linked target | no | +| `SUPABASE_TARGET` | workspace target when no `--local`/`--linked`/`--db-url` is given | 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 | @@ -137,18 +144,20 @@ migra/pg-delta-engine-specific). ## Exit Codes -| Code | Condition | -| ---- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `0` | success; empty diff ("No schema changes found") | -| `1` | `--from` without `--to`; engine-flag mutex; target mutex; unknown explicit target; connection/shadow/engine failure; file IO error; local db not running (`--use-pgadmin`); differ container non-zero exit; unparseable `--json-diff` output | -| `1` | `--project-ref` set with a resolved target other than linked; (in explicit mode) `--project-ref` with `--linked` unchanged and neither `--from` nor `--to` being `linked` | -| `1` | `--use-pg-schema` passed at all (any value, including `=false`) — removed, see Notes | +| Code | Condition | +| ---- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `0` | success; empty diff ("No schema changes found") | +| `1` | `--from` without `--to`; engine-flag mutex; unknown explicit target; connection/shadow/engine failure; file IO error; local db not running (`--use-pgadmin`); differ container non-zero exit; unparseable `--json-diff` output | +| `1` | more than one of `--db-url`/`--linked`/`--local` (`TargetSelectorConflictError`); `--project-ref` with `--local` or `--db-url` (`TargetProjectRefConflictError`) | +| `1` | `--use-pg-schema` passed at all (any value, including `=false`) — removed, see Notes | ## Output ### `--output-format text` -Progress to stderr (`Creating shadow database...`, `Diffing schemas[: <list>]`, +Progress to stderr (the `Target: …` line — linked project `Target: <name> (<ref>)`, +`Target: local stack (<host>:<port>)` or `Target: custom Postgres (<host>)`, printed once per +distinct target — then `Creating shadow database...`, `Diffing schemas[: <list>]`, `Finished supabase db diff on branch <branch>.`, drop-statement warning, and the `--file` write warning). A configured `[db.migrations].schema_paths` also warns that it no longer changes the migrations baseline. The SQL diff prints to stdout @@ -211,7 +220,7 @@ transaction metadata. - **Config value precedence** (ADR 0032): 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.*]` + 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 <target>: ...` warning and a temporary login role instead. `--password` is rejected with `--db-url` or `--local`. @@ -220,28 +229,21 @@ transaction metadata. 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 - `--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` - performs (flag > `SUPABASE_PROJECT_ID` > `.temp/project-ref`) — unlike - `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 (native mode) is a hard error, as is a plain - `--project-ref` with no `--from`/`--to` (defaults to `--local`). Two - exceptions apply in explicit mode: `--from linked` / `--to linked` resolves a - linked ref without any target flag at all, so the guard does not fire there; - and a changed `--linked` (even `--linked=false`) genuinely consumes - `--project-ref` via the preflight, so the guard does not fire whenever - `--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 - (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. + (pg-delta is the default unless `[experimental.pgdelta] enabled = false`). `--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. +- **Target selection.** `--db-url`, `--linked` and `--local` are mutually exclusive and a `false` + switch counts as absent. `--project-ref` selects the linked project and supplies only the ref + (flag > `SUPABASE_PROJECT_ID` > `.temp/project-ref`); unlike `SUPABASE_PROJECT_ID`, it does not + affect the shadow container's project id/labels, and combining it with `--local` or `--db-url` + is a hard error. In explicit `--from`/`--to` mode the endpoints come from the two refs: a + target flag (or `--project-ref` alone, which implies the linked project) only validates the + selection and merges the linked project's `[remotes.<ref>]` override, `--from linked` / + `--to linked` resolve the linked project without any target flag, and the workspace target is + not consulted. The linked project resolves once, so `--from linked --to linked` announces it + once. `--use-pgadmin` honors the same selection (CLI-1968 — same target resolve); + `--use-pg-schema` is removed and rejects before any target resolution happens (see Notes + below). - Explicit `--from`/`--to` mode always uses pg-delta and writes the flattened review representation to `--output` (or stdout). It ignores `--file`; normal mode retains per-unit migration files for the CLI apply paths. diff --git a/apps/cli/src/commands/db/dump/SIDE_EFFECTS.md b/apps/cli/src/commands/db/dump/SIDE_EFFECTS.md index cb353316bd..62bfb797ad 100644 --- a/apps/cli/src/commands/db/dump/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/db/dump/SIDE_EFFECTS.md @@ -5,16 +5,22 @@ script run inside the compose Postgres image, or catalog `pg_dump` / `pg_dumpall` on the stack backend (native artifact or a one-shot of the same image), to stdout or `--file`. +With no target flag the command dumps the workspace target (`SUPABASE_TARGET`, then `target` in +`config.toml`, default `local`; see +[ADR 0033](../../../../../../docs/adr/0033-target-selection-and-hosted-mutation-consent.md)). +`--local` selects the local stack, `--linked` or `--project-ref` the linked project, and `--db-url` +a custom database. It only reads, so it never asks for confirmation, on any target. + ## Files Read -| 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) | +| 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 | a linked target when the direct host is unreachable (pooler URL) | +| `~/.supabase/access-token` | plain text | a linked target when `SUPABASE_ACCESS_TOKEN` unset | +| `supabase/.temp/project-ref` | plain text | a linked 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 @@ -24,20 +30,21 @@ image), to stdout or `--file`. ## API Routes -| Method | Path | Auth | When | -| ------ | ------------------------------------------ | ------ | ------------------------------------------------------------ | -| POST | `/v1/projects/{ref}/cli/login-role` | Bearer | `--linked` with no `DB_PASSWORD` (mint a temp postgres role) | -| POST | `/v1/projects/{ref}/network-bans/retrieve` | Bearer | `--linked` pooler temp-role retry (list bans) | -| DELETE | `/v1/projects/{ref}/network-bans` | Bearer | `--linked` pooler temp-role retry (clear self ban) | +| Method | Path | Auth | When | +| ------ | ------------------------------------------ | ------ | --------------------------------------------------------------- | +| POST | `/v1/projects/{ref}/cli/login-role` | Bearer | linked target with no `DB_PASSWORD` (mint a temp postgres role) | +| POST | `/v1/projects/{ref}/network-bans/retrieve` | Bearer | linked pooler temp-role retry (list bans) | +| DELETE | `/v1/projects/{ref}/network-bans` | Bearer | linked pooler temp-role retry (clear self ban) | -(All via the shared `DbConfigResolver` `--linked` path.) +(All via the shared `DbConfigResolver` linked path.) ## Environment Variables | Variable | Purpose | | ---------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `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 | +| `SUPABASE_ACCESS_TOKEN` | linked-target auth | +| `SUPABASE_TARGET` | workspace target when no `--local`/`--linked`/`--db-url` is given | | `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 | | `SUPABASE_USE_SLIM_IMAGES` | resolve the current Postgres pin 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, and flag-off `15.8.1.085` stay on docker.io | @@ -50,14 +57,17 @@ 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` | +| `1` | more than one of `--db-url`/`--linked`/`--local` (`TargetSelectorConflictError`) | +| `1` | `--project-ref` with `--local` or `--db-url` (`TargetProjectRefConflictError`) | +| `1` | `--password` with `--db-url` or `--local` (`TargetPasswordConflictError`) | ## Output SQL goes to **stdout** (or `--file`) in **all** `--output-format` modes — there is no machine envelope for `db dump` (same rationale as `test db`). Diagnostics go -to **stderr**: `Dumping {schemas|data| roles} from {local|remote} database...`, +to **stderr**: the `Target: …` line (linked project `Target: <name> (<ref>)`, +`Target: local stack (<host>:<port>)`, or `Target: custom Postgres (<host>)`), +`Dumping {schemas|data| roles} from {local|remote} database...`, the `--dry-run` notice, and the `Dumped schema to <abs>.` confirmation when `--file` is used. `--dry-run` prints the env-expanded script to stdout without running a container; with `--file` it still prints the `Dumped schema to <abs>.` @@ -89,15 +99,12 @@ shell inherits the suppressing variables and is missed. from `.temp/project-ref`: stderr gets `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>.` 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. -- **`--project-ref`** overrides ONLY the linked-ref resolution used for the connection and - the linked-project cache (flag > `SUPABASE_PROJECT_ID`/config.toml - `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 (deliberately stricter than `SUPABASE_PROJECT_ID`, which is simply - unused on a non-linked target). + `--schema` XOR `--role-only`. `--use-copy` / `--exclude` require `--data-only`. +- **Target selection.** `--db-url`, `--linked` and `--local` are mutually exclusive and a `false` + switch counts as absent. `--project-ref` selects the linked project and supplies only the ref + (flag > `SUPABASE_PROJECT_ID`/config.toml `project_id` > `.temp/project-ref`) for the connection + and the linked-project cache; it does not affect any local container id, and combining it with + `--local` or `--db-url` is a hard error. - **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/lint/SIDE_EFFECTS.md b/apps/cli/src/commands/db/lint/SIDE_EFFECTS.md index 6f8ef70922..91bbaaf2bd 100644 --- a/apps/cli/src/commands/db/lint/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/db/lint/SIDE_EFFECTS.md @@ -2,31 +2,36 @@ Checks a database for PL/pgSQL typing errors via the `plpgsql_check` extension. +With no target flag the command follows the workspace target (`SUPABASE_TARGET`, then `target` +in `config.toml`, default `local`; see +[ADR 0033](../../../../../../docs/adr/0033-target-selection-and-hosted-mutation-consent.md)). +It only reads, so it never asks for confirmation, on any target. + ## Files Read -| Path | Format | When | -| -------------------------------------- | ---------- | ------------------------------------------------------------------------------------------------------------ | -| `<workdir>/supabase/config.toml` | TOML | always — to resolve the local / linked DB connection config | -| `~/.supabase/access-token` | plain text | `--linked` only, when `SUPABASE_ACCESS_TOKEN` unset (keyring → file) | -| `<workdir>/supabase/.temp/project-ref` | plain text | `--linked` only, to resolve the project ref — skipped when `--project-ref` (or `SUPABASE_PROJECT_ID`) is set | +| Path | Format | When | +| -------------------------------------- | ---------- | ----------------------------------------------------------------------------------------------------------------- | +| `<workdir>/supabase/config.toml` | TOML | always — to resolve the local / linked DB connection config | +| `~/.supabase/access-token` | plain text | a linked target only, when `SUPABASE_ACCESS_TOKEN` unset (keyring → file) | +| `<workdir>/supabase/.temp/project-ref` | plain text | a linked target only, to resolve the project ref — skipped when `--project-ref` (or `SUPABASE_PROJECT_ID`) is set | ## Files Written -| Path | Format | When | -| ---------------------------------------------- | ------ | ----------------------------------------------- | -| `<workdir>/supabase/.temp/linked-project.json` | JSON | `--linked` only, via `LinkedProjectCache.cache` | -| `~/.supabase/telemetry.json` | JSON | always (PostHog state flush) | +| Path | Format | When | +| ---------------------------------------------- | ------ | ---------------------------------------------------- | +| `<workdir>/supabase/.temp/linked-project.json` | JSON | a linked target only, via `LinkedProjectCache.cache` | +| `~/.supabase/telemetry.json` | JSON | always (PostHog state flush) | No user data is written: the lint runs inside a transaction that is **always rolled back** (`BEGIN` … `ROLLBACK`) — including `CREATE EXTENSION plpgsql_check`, which is issued on the same connection inside the open transaction and so is rolled back too. `db lint` DOES write the -linked-project cache when run with `--linked`; the default `--local` and +linked-project cache when it targets the linked project; the local and `--db-url` paths never populate a project ref, so no cache write occurs there. ## API Routes -None called directly. `--linked` resolves the project's direct-DB connection +None called directly. A linked target resolves the project's direct-DB connection through the shared db-config resolver (which may call the Management API to resolve credentials); the lint itself issues no advisor/API requests. @@ -47,29 +52,31 @@ container stacks can enable it. Compose uses the Compose Postgres image. ## Environment Variables -| Variable | Purpose | Required? | -| ----------------------- | ---------------------------------------- | ----------------------------------------- | -| `SUPABASE_ACCESS_TOKEN` | auth token for `--linked` resolution | no (keyring → `~/.supabase/access-token`) | -| `SUPABASE_DB_PASSWORD` | linked direct-DB password | no | -| `SUPABASE_PROFILE` | API profile (built-in name or YAML path) | no | -| `PGHOST` / `PGPORT` / … | connection overrides | no | +| Variable | Purpose | Required? | +| ----------------------- | ---------------------------------------- | ------------------------------------------ | +| `SUPABASE_ACCESS_TOKEN` | auth token for linked-target resolution | no (keyring → `~/.supabase/access-token`) | +| `SUPABASE_TARGET` | workspace target when no target flag set | no (→ `target` in `config.toml` → `local`) | +| `SUPABASE_DB_PASSWORD` | linked direct-DB password | no | +| `SUPABASE_PROFILE` | API profile (built-in name or YAML path) | no | +| `PGHOST` / `PGPORT` / … | connection overrides | no | ## Exit Codes -| Code | Condition | -| ---- | -------------------------------------------------------------------------- | -| `0` | success — no issues at or above `--fail-on` (an empty result is still `0`) | -| `1` | mutually-exclusive `--db-url` / `--linked` / `--local` | -| `1` | connection / `BEGIN` / list-schemas / enable-extension / query failure | -| `1` | malformed `plpgsql_check` JSON | -| `1` | an issue's level is at or above `--fail-on` | -| `1` | `--project-ref` set with a resolved target other than linked (see Notes) | +| Code | Condition | +| ---- | ------------------------------------------------------------------------------------ | +| `0` | success — no issues at or above `--fail-on` (an empty result is still `0`) | +| `1` | more than one of `--db-url` / `--linked` / `--local` (`TargetSelectorConflictError`) | +| `1` | connection / `BEGIN` / list-schemas / enable-extension / query failure | +| `1` | malformed `plpgsql_check` JSON | +| `1` | an issue's level is at or above `--fail-on` | +| `1` | `--project-ref` with `--local` or `--db-url` (`TargetProjectRefConflictError`) | ## Output ### `--output-format text` -Diagnostics on **stderr**: `Connecting to <local\|remote> database...`, +Diagnostics on **stderr**: the `Target: …` line (linked project `Target: <name> (<ref>)`, +`Target: local stack (<host>:<port>)`, or `Target: custom Postgres (<host>)`), `Linting schema: <s>`, and `\nNo schema errors found` (when no issues). The result is a pretty-printed 2-space JSON array on **stdout** (struct-order keys, `omitempty` fields dropped, trailing newline). A filtered-empty result @@ -92,10 +99,8 @@ the process exits non-zero (no error envelope is written over the payload). - `--level` (`warning` default) sets the minimum issue level to emit. - `--fail-on` (`none` default) sets the level that forces a non-zero exit. - `--schema` / `-s` restricts linting to specific schemas; omitted ⇒ all user schemas. -- `--db-url`, `--linked`, and `--local` (default true) are mutually exclusive. -- **`--project-ref`** overrides ONLY the linked-ref resolution used for the connection and - the linked-project cache (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 (stricter than `SUPABASE_PROJECT_ID`, which is simply left unused on a non-linked target). +- **Target selection.** `--db-url`, `--linked` and `--local` are mutually exclusive and a `false` + switch counts as absent. `--project-ref` selects the linked project and supplies only the ref + (flag > `SUPABASE_PROJECT_ID` > `.temp/project-ref`) for the connection and the linked-project + cache; combining it with `--local` or `--db-url` is a hard error. - Telemetry: only the standard `cli_command_executed` event (no custom events). diff --git a/apps/cli/src/commands/db/query/SIDE_EFFECTS.md b/apps/cli/src/commands/db/query/SIDE_EFFECTS.md index afe7c5c7a6..3118304145 100644 --- a/apps/cli/src/commands/db/query/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/db/query/SIDE_EFFECTS.md @@ -1,54 +1,68 @@ # `supabase db query` Implemented in `query.handler.ts`. Executes SQL against the local -database (direct connection) or the linked project (Management API), then renders -the result as a table or JSON. +database (direct connection), a `--db-url` database (direct connection) or the linked project +(Management API), then renders the result as a table or JSON. + +With no target flag the command follows the workspace target (`SUPABASE_TARGET`, then `target` +in `config.toml`, default `local`; see +[ADR 0033](../../../../../../docs/adr/0033-target-selection-and-hosted-mutation-consent.md)). +`--local` selects the local stack, `--linked` or `--project-ref` the linked project. + +Any statement sent to a hosted target (linked project or remote `--db-url`) asks for one +confirmation first, because the command cannot tell a read from a write. `--read-only` skips that +confirmation and makes the statement unable to write (see Notes). The local stack never prompts. ## Files Read -| Path | Format | When | -| ------------------------------------ | ---------- | ------------------------------------------------------------------------------------------ | -| `<path>` (from `--file`) | SQL | when `--file` / `-f` is set (takes precedence over arg/stdin) | -| stdin | SQL | when piped (not a TTY) and no `--file`/positional SQL | -| `supabase/config.toml` | TOML | local / `--db-url` connection resolution | -| `~/.supabase/access-token` | plain text | `--linked` when `SUPABASE_ACCESS_TOKEN` unset | -| `supabase/.temp/project-ref` | plain text | `--linked` ref resolution — skipped when `--project-ref` (or `SUPABASE_PROJECT_ID`) is set | -| `supabase/.temp/linked-project.json` | JSON | `--linked` existence check before the cache write (see below) | +| Path | Format | When | +| ------------------------------------ | ---------- | -------------------------------------------------------------------------------------- | +| `<path>` (from `--file`) | SQL | when `--file` / `-f` is set (takes precedence over arg/stdin) | +| stdin | SQL | when piped (not a TTY) and no `--file`/positional SQL | +| `supabase/config.toml` | TOML | local / `--db-url` connection resolution | +| `~/.supabase/access-token` | plain text | a linked target when `SUPABASE_ACCESS_TOKEN` unset | +| `supabase/.temp/project-ref` | plain text | linked ref resolution — skipped when `--project-ref` (or `SUPABASE_PROJECT_ID`) is set | +| `supabase/.temp/linked-project.json` | JSON | a linked target, existence check before the cache write (see below) | ## Files Written -| Path | Format | When | -| ------------------------------------ | ------ | --------------------------------------------------------------------------------------------------------------- | -| `supabase/.temp/linked-project.json` | JSON | `--linked`, after the query runs, when the file does not already exist and `GET /v1/projects/{ref}` returns 200 | +| Path | Format | When | +| ------------------------------------ | ------ | ------------------------------------------------------------------------------------------------------------------ | +| `supabase/.temp/linked-project.json` | JSON | linked target, after the query runs, when the file does not already exist and `GET /v1/projects/{ref}` returns 200 | ## API Routes -| Method | Path | Auth | Request body | Response | -| ------ | ----------------------------------- | ------ | ------------------- | ------------------------------------------------------------------------------------------------------------ | -| POST | `/v1/projects/{ref}/database/query` | Bearer | `{"query":"<sql>"}` | 201, JSON array of row objects (raw — the typed client voids the body, so the linked path uses raw HTTP). | -| GET | `/v1/projects/{ref}` | Bearer | — | 200 → linked-project cache write; any other status → no write. Fired after the query on the `--linked` path. | +| Method | Path | Auth | Request body | Response | +| ------ | ----------------------------------- | ------ | --------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------- | +| POST | `/v1/projects/{ref}/database/query` | Bearer | `{"query":"<sql>"}`, plus `"read_only":true` with `--read-only` | 201, JSON array of row objects (raw — the typed client voids the body, so the linked path uses raw HTTP). | +| GET | `/v1/projects/{ref}` | Bearer | — | 200 → linked-project cache write; any other status → no write. Fired after the query on the linked path. | ## Environment Variables -| Variable | Purpose | -| ----------------------- | ---------------------------------------------------------------------------- | -| `SUPABASE_ACCESS_TOKEN` | `--linked` auth | -| agent-detection signals | `--agent=auto` (e.g. `CURSOR_*`, `CLAUDECODE`, …) via `@vercel/detect-agent` | +| Variable | Purpose | +| ----------------------- | ------------------------------------------------------------------------------ | +| `SUPABASE_ACCESS_TOKEN` | linked-target auth | +| `SUPABASE_TARGET` | workspace target when no `--local`/`--linked`/`--db-url` is given | +| `SUPABASE_YES` | grants the hosted-target confirmation (shell only; `supabase/.env` is ignored) | +| agent-detection signals | `--agent=auto` (e.g. `CURSOR_*`, `CLAUDECODE`, …) via `@vercel/detect-agent` | ## Exit Codes -| Code | Condition | -| ---- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `0` | success | -| `1` | conflicting `--db-url`/`--linked`/`--local`; no SQL provided; empty stdin; unreadable `--file`; `--linked` without login; query exec failure; non-201 linked status | -| `1` | `--project-ref` set without `--linked` (see Notes) | +| Code | Condition | +| ---- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `0` | success | +| `1` | more than one of `--db-url`/`--linked`/`--local` (`TargetSelectorConflictError`); no SQL provided; empty stdin; unreadable `--file`; linked target without login; query exec failure; non-201 linked status | +| `1` | `--project-ref` with `--local` or `--db-url` (`TargetProjectRefConflictError`) | +| `1` | hosted target without `--read-only` in a JSON, piped, agent or non-TTY run without `--yes` or a shell `SUPABASE_YES` (`HostedMutationConsentRequiredError`); nothing is sent | +| `1` | hosted target confirmation declined (`HostedMutationDeclinedError`, `context canceled`); nothing is sent | ## Output The query payload goes to **stdout** in every `--output-format` mode (there is no `--output-format` for `db query`; there is no machine envelope around the -payload). Diagnostics (`Connecting to {local|remote} database...`) go to -**stderr**. DDL/DML with no result columns prints the command tag. +payload). Diagnostics go to **stderr**: the `Target: …` line (linked project +`Target: <name> (<ref>)`, `Target: local stack (<host>:<port>)`, `Target: custom Postgres (<host>)`) +and the confirmation question. DDL/DML with no result columns prints the command tag. - **table** (default for humans): `olekukonko/tablewriter` v1 box layout, NULL for nil. - **json**: a plain rows array for humans, or — in agent mode — the untrusted-data @@ -82,12 +96,21 @@ from the environment. Agent mode defaults the format to JSON (table for humans). See `output-formats.ts`. - **Local DDL command tags** use the raw `commandComplete` protocol tag (so `CREATE TABLE` etc. survive node-postgres' first-word-only parse of the tag). -- **`--project-ref`** overrides ONLY the linked-ref resolution used for the connection and - the linked-project cache (flag > `SUPABASE_PROJECT_ID` > - `.temp/project-ref`). It never implies `--linked`: passing it without - `--linked` (i.e. targeting local or `--db-url`) 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). +- **Target selection.** `--db-url`, `--linked` and `--local` are mutually exclusive and a `false` + switch counts as absent. `--project-ref` selects the linked project and supplies only the ref + (flag > `SUPABASE_PROJECT_ID` > `.temp/project-ref`) for the connection and the linked-project + cache; combining it with `--local` or `--db-url` is a hard error. +- **Hosted consent.** A linked project or remote `--db-url` asks once, after the SQL is known and + before it is sent: `Run a SQL statement`, with the statement (collapsed to one line, at most 120 + characters) as the detail and default No. A shell `SUPABASE_YES` or `--yes` grants it + (`Confirmed by --yes: …`); a `SUPABASE_YES` in `supabase/.env` and a piped answer do not. Stdin + is never read for the answer. Reading a missing SQL source (no SQL, empty stdin, unreadable + `--file`) fails before the question. +- **`--read-only`.** Skips the hosted confirmation entirely and prints no question. A linked + target sends `"read_only": true` in the Management API request, so the server rejects writes. + The local stack and a `--db-url` database run the statement inside `BEGIN READ ONLY` … `ROLLBACK`, + so Postgres rejects writes. A statement that ends the transaction itself (`COMMIT`, + `SET TRANSACTION READ WRITE`) can leave that guard on a direct connection. - **Linked-project cache.** On the `--linked` path, after the query runs — whether it succeeds or fails — the handler issues `GET /v1/projects/{ref}` and writes `supabase/.temp/linked-project.json`. The diff --git a/apps/cli/src/commands/db/reset/SIDE_EFFECTS.md b/apps/cli/src/commands/db/reset/SIDE_EFFECTS.md index 7d45764baf..4a989a2671 100644 --- a/apps/cli/src/commands/db/reset/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/db/reset/SIDE_EFFECTS.md @@ -18,6 +18,13 @@ git-branch line are all native TS — including the local target's own `--experimental` schema-files apply (`migrateAndSeed`, shared with the remote path's branch above). +With no target flag the command follows the workspace target (`SUPABASE_TARGET`, then `target` +in `config.toml`, default `local`; see +[ADR 0033](../../../../../../docs/adr/0033-target-selection-and-hosted-mutation-consent.md)). +`--local` selects the local stack, `--linked` or `--project-ref` the linked project, and +`--db-url` a custom database. A hosted target (linked, or a remote `--db-url`) asks for one +confirmation before anything is dropped; the local path never prompts for the reset itself. + The whole local-reset composition is hoisted into `command-internal/db-bootstrap/ reset-local-database.ts`'s `resetLocalDatabase` (CLI-2062), which this handler's own `cfg.isLocal` branch calls as a thin wrapper (keeping only version/ @@ -67,7 +74,7 @@ or migrations fail, companion services remain stopped. Fix the reported error an | `<workdir>/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 | | `<workdir>/supabase/.env`, `.env.local`, project-root/`SUPABASE_ENV`-selected dotenv file | dotenv | always, resolved before the local prelude (config values, bootstrap config) | | `<workdir>/.git/HEAD` (walked upward) | plain text | local path, for the `Finished … on branch <branch>.` line | -| `~/.supabase/<hash>/project-ref` | plain text | `--linked`, to resolve the ref — skipped when `--project-ref` (or `SUPABASE_PROJECT_ID`) is set | +| `~/.supabase/<hash>/project-ref` | plain text | a linked target, 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) | @@ -164,7 +171,8 @@ the whole reset** (not just "skip buckets"). | `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 | | `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_YES` | grants the hosted reset consent (shell only; a `SUPABASE_YES` in `supabase/.env` does not count) and auto-confirms the local path's bucket-seed overwrite/prune prompts (shell or project dotenv, same as `seed buckets`) | no (also `--yes`) | +| `SUPABASE_TARGET` | workspace target used when no `--local`/`--linked`/`--db-url` is given (`local` or `remote`; `remote` means the linked project) | no (→ `target` in `config.toml` → `local`) | | `SUPABASE_EXPERIMENTAL` | selects the schema-files apply branch on either target | no (also `--experimental`) | | `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) | @@ -192,30 +200,33 @@ echoed, because those may genuinely have reached the server. | Code | Condition | | ---- | ------------------------------------------------------------------------------------------------------------------------------------------ | | `0` | success | -| `1` | mutually exclusive target flags (`[db-url linked local]`) | +| `1` | more than one of `--db-url`, `--linked`, `--local` (`TargetSelectorConflictError`) | | `1` | `--version` + `--last` together (`[last version]`) | | `1` | `--version` not an integer (`invalid version number`) | | `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` | hosted target, reset confirmation declined (`HostedMutationDeclinedError`, `context canceled`); nothing is changed | +| `1` | hosted target in a JSON, piped, agent or non-TTY run without `--yes` or a shell `SUPABASE_YES` (`HostedMutationConsentRequiredError`) | | `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) | +| `1` | `--project-ref` with `--local` or `--db-url` (`TargetProjectRefConflictError`) | Neither target delegates to a child process — every failure is a native, typed error surfaced as `1`. ## Output -The remote path prints `Resetting remote database…` to **stderr**, then either the -schema-files branch's apply (no per-file progress, CLI-1958) or the migrate/seed -progress (`Applying migration …`, `Seeding data from …`). There is **no** -`Connecting to … database…` line and **no** `Finished …` line on the remote path. +Every target prints a `Target: …` line to **stderr** before the first database change: the +linked project (`Target: <name> (<ref>)`), `Target: local stack (<host>:<port>)`, or +`Target: custom Postgres (<host>)`. There is no `Connecting to … database…` line. + +The remote path then asks for confirmation (see Notes), prints `Resetting remote database…` to +**stderr**, then either the schema-files branch's apply (no per-file progress, CLI-1958) or the +migrate/seed progress (`Applying migration …`, `Seeding data from …`). There is **no** +`Finished …` line on the remote path. The local path prints `Resetting local database…` to **stderr**, then `Recreating database...` (PG15) or nothing extra (PG14, until the restart step) / @@ -229,14 +240,14 @@ silent (no-progress-line) `--experimental` schema-files apply. ### `--output-format json` / `stream-json` -stdout is payload-only; a `result` object is emitted: +stdout is payload-only; a `result` object is emitted, carrying the announced `target` object: ```json -{ "target": "remote" | "local", "version": "<resolved version or empty>" } +{ "target": { "kind": "local" | "linked" | "db-url", … }, "version": "<resolved version or empty>" } ``` -In machine modes the remote confirmation prompt is non-interactive and takes its -default (`false`), so a remote reset is declined unless `--yes` is set. The local +A remote reset in machine modes cannot prompt, so it needs `--yes` or a shell `SUPABASE_YES` +and otherwise fails with `HostedMutationConsentRequiredError` before anything is dropped. The local path has no reset confirmation, but its bucket-seed step carries the seed-buckets overwrite/prune confirmations: in machine modes they take their defaults silently (overwrite → yes, prune → no) unless `--yes`/`SUPABASE_YES` auto-confirms; in text @@ -259,16 +270,19 @@ to those defaults (the usual outcome for an interactive terminal). its own suggestion (missing API gateway endpoint, Auth disabled, gateway 502/503), that suggestion is the next step instead of the generic one. The stack's service-role JWT is never printed or logged. +- **Target selection.** `--db-url`, `--linked` and `--local` are mutually exclusive and a + `false` switch counts as absent; with none set the workspace target decides. `--project-ref` + selects the linked project and supplies only the ref (flag > `SUPABASE_PROJECT_ID` > + `~/.supabase/<hash>/project-ref`); unlike `SUPABASE_PROJECT_ID`, it does not affect the local + container id, and combining it with `--local` or `--db-url` is a hard error. - **Target/local split** follows whether the resolved config points at the local stack, not the flag name: a `--db-url` pointing at the local stack is treated - as a local reset. -- **`--project-ref`** overrides ONLY the linked-ref resolution `ProjectRefResolver` - performs (flag > `SUPABASE_PROJECT_ID` > `~/.supabase/<hash>/project-ref`) — - unlike `SUPABASE_PROJECT_ID`, it does not affect the 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 simply goes unused on a - non-linked target). + as a local reset and never prompts. +- **Hosted consent.** A linked or remote `--db-url` reset asks once, after the plan is known + and before the first drop, with default No. The question lists dropping every user schema, + the vault upsert count, applying migrations (or the declarative schema files), and the seed + plan. A shell `SUPABASE_YES` or `--yes` grants it (`Confirmed by --yes: …`); a `SUPABASE_YES` + in `supabase/.env` and a piped answer do not. Stdin is never read for the answer. - **Pipeline-incompatible statements** (`CREATE INDEX CONCURRENTLY`, `VACUUM`, …) run 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, @@ -277,14 +291,12 @@ to those defaults (the usual outcome for an interactive terminal). `.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 <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>.` 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.<name>]` block that does not itself declare `db.seed.enabled = true`, with seeding enabled and at least one seed file, asks - `Project <ref> matches [remotes.<name>]. Run <n> seed file(s) (<paths>) against it?` (default no). `--yes` or - `SUPABASE_YES` answers yes and prints `Seeding enabled by <origin>` (for example `SUPABASE_DB_SEED_ENABLED (shell)`); a TTY stdin with - non-interactive output, or machine output on a TTY, fails with `SeedConsentRequiredError` - (`Seeding <ref> ([remotes.<name>]) 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.` + `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).` and a temporary login role is minted. +- **Seed plan (remote).** The consent question includes the seed line: `seed <n> file(s)`, and for + a linked target that matched a `[remotes.<name>]` block it names the block and the origin that + enabled seeding (for example `seed 1 file into [remotes.preview] (seeding enabled by --sql-paths)`). + There is no separate seed question; declining the reset declines the seeding. `--no-seed` + removes the line. - `--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 @@ -297,7 +309,6 @@ to those defaults (the usual outcome for an interactive terminal). `--linked` / `--db-url`). - `--last n` reverts the most recent `n` migrations; if `n ≥ total`, the reset target version becomes `-` (revert everything). Mutually exclusive with `--version`. -- `--db-url`, `--linked`, and `--local` (default true) are mutually exclusive. - **`--experimental` schema-files apply** (the `MigrateAndSeed` EXPERIMENTAL branch) is taken on EITHER target when `--experimental`/`SUPABASE_EXPERIMENTAL` is set, no @@ -324,7 +335,7 @@ to those defaults (the usual outcome for an interactive terminal). - When a versionless `--experimental` reset skips the schema-files branch only because pg-delta is enabled and `schema_paths` is non-empty, it prints a stderr warning that `schema_paths` is not applied and that `[experimental.pgdelta] enabled = false` restores it - (before the confirmation prompt on the remote path). + (before the confirmation on the remote path). - `db schema declarative`/`db schema sync`'s own local-reset paths call `resetLocalDatabase` in-process too, so the reset runs inside a single telemetry/linked-project-cache finalizer cycle (the outer `db schema diff --git a/apps/cli/src/commands/db/test/SIDE_EFFECTS.md b/apps/cli/src/commands/db/test/SIDE_EFFECTS.md index 459322f033..c6fc61b52e 100644 --- a/apps/cli/src/commands/db/test/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/db/test/SIDE_EFFECTS.md @@ -36,10 +36,10 @@ Identical to `test db`. See Identical to `test db`. See [`../../test/db/SIDE_EFFECTS.md`](../../test/db/SIDE_EFFECTS.md#docker). -## API Routes (`--linked` only) +## API Routes (linked target only) Identical to `test db`. See -[`../../test/db/SIDE_EFFECTS.md`](../../test/db/SIDE_EFFECTS.md#api-routes---linked-only). +[`../../test/db/SIDE_EFFECTS.md`](../../test/db/SIDE_EFFECTS.md#api-routes-linked-target-only). ## Environment Variables @@ -74,9 +74,8 @@ Identical to `test db`. See - Hidden command — registered with `.pipe(Command.unlisted)` in `../db.command.ts`. -- `--local` defaults to `true` on both `db test` and `test db` — bare - `supabase db test` always targets the local stack. The flag drives - `resolveDbTargetFlags`'s presence-based selection directly, the same - mechanism `test db` uses, so the true default is reflected exactly. +- Target selection and the hosted-target confirmation are identical to `test db`: with no target + flag, bare `supabase db test` follows the workspace target (default `local`), and a linked or + remote `--db-url` run asks for confirmation (or needs `--yes`) before pgTAP is enabled. - Shares every behavior documented on `test db` (pg_prove image pin, `pg_extension`-based "already exists" detection, global `--network-id` handling, etc.). diff --git a/apps/cli/src/commands/test/db/SIDE_EFFECTS.md b/apps/cli/src/commands/test/db/SIDE_EFFECTS.md index 2e6f79da54..66a22a1a40 100644 --- a/apps/cli/src/commands/test/db/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/test/db/SIDE_EFFECTS.md @@ -1,16 +1,23 @@ # `supabase test db [path...]` +Runs pgTAP tests against a database. With no target flag the command follows the workspace target +(`SUPABASE_TARGET`, then `target` in `config.toml`, default `local`; see +[ADR 0033](../../../../../../docs/adr/0033-target-selection-and-hosted-mutation-consent.md)). +`--local` selects the local stack, `--linked` or `--project-ref` the linked project, and `--db-url` +a custom database. The run enables pgTAP and drops it again, so a hosted target (linked project or +remote `--db-url`) asks for one confirmation before anything runs; the local stack never prompts. + ## Files Read -| Path | Format | When | -| -------------------------------------- | ------ | ----------------------------------------------------------------------------------------------------------------------------------------- | -| `<cwd>/supabase/tests/**/*.{sql,pg}` | SQL | default test discovery when no `[path]` given | -| `<path...>` | SQL | when explicit test files/dirs are passed | -| `<workdir>/supabase/config.toml` | TOML | always: `db.port`, `db.shadow_port`, `db.password`, `project_id`. Absent → defaults; **present but malformed → command fails** | -| `<workdir>/supabase/.temp/pooler-url` | text | `--linked` pooler fallback only — the connection-pooler URL written by `supabase link` (read from this file rather than from config.toml) | -| `~/.supabase/access-token` | text | `--linked` only, when `SUPABASE_ACCESS_TOKEN` unset | -| `<workdir>/supabase/.temp/project-ref` | text | `--linked` only, to resolve the ref — skipped when `--project-ref` (or `SUPABASE_PROJECT_ID`) is set | -| `<supabaseHome>/stacks/*/state.json` | JSON | managed `--local`: discover the project stack and open its saved definition | +| Path | Format | When | +| -------------------------------------- | ------ | -------------------------------------------------------------------------------------------------------------------------------------------- | +| `<cwd>/supabase/tests/**/*.{sql,pg}` | SQL | default test discovery when no `[path]` given | +| `<path...>` | SQL | when explicit test files/dirs are passed | +| `<workdir>/supabase/config.toml` | TOML | always: `db.port`, `db.shadow_port`, `db.password`, `project_id`. Absent → defaults; **present but malformed → command fails** | +| `<workdir>/supabase/.temp/pooler-url` | text | linked-target pooler fallback only — the connection-pooler URL written by `supabase link` (read from this file rather than from config.toml) | +| `~/.supabase/access-token` | text | a linked target only, when `SUPABASE_ACCESS_TOKEN` unset | +| `<workdir>/supabase/.temp/project-ref` | text | a linked target only, to resolve the ref — skipped when `--project-ref` (or `SUPABASE_PROJECT_ID`) is set | +| `<supabaseHome>/stacks/*/state.json` | JSON | managed `--local`: discover the project stack and open its saved definition | ## Files Written @@ -20,12 +27,12 @@ ## Database -| Statement | When | -| ------------------------------------------------------------- | ----------------------------------------------------------------------------------------- | -| `select 1 from pg_extension where extname = 'pgtap'` | always, before enabling — pre-existence check (by extension name, any schema) | -| `set session role postgres` | after connect when the user is `supabase_admin` / `cli_login_*` (remote linked temp role) | -| `create extension if not exists pgtap with schema extensions` | always, before running tests | -| `drop extension if exists pgtap` | only if pgTAP did not already exist; failure is logged to stderr, non-fatal | +| Statement | When | +| ------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------- | +| `select 1 from pg_extension where extname = 'pgtap'` | always, before enabling — pre-existence check (by extension name, any schema); on a hosted target only after the confirmation | +| `set session role postgres` | after connect when the user is `supabase_admin` / `cli_login_*` (remote linked temp role) | +| `create extension if not exists pgtap with schema extensions` | always, before running tests | +| `drop extension if exists pgtap` | only if pgTAP did not already exist; failure is logged to stderr, non-fatal | ## Docker @@ -39,7 +46,7 @@ Managed stack `--local` prove runs the catalog `pg_prove` tool through the ownin - `-e PGHOST/PGPORT/PGUSER/PGPASSWORD/PGDATABASE` - cmd `pg_prove --ext .pg --ext .sql -r <paths> [--verbose]` (`--verbose` when `--debug`) -## API Routes (`--linked` only) +## API Routes (linked target only) | Method | Path | Auth | Request body | Response (used fields) | | ------ | ----------------------------------- | ------------ | ----------------------------------------- | --------------------------- | @@ -49,14 +56,16 @@ Managed stack `--local` prove runs the catalog `pg_prove` tool through the ownin ## Environment Variables -| Variable | Purpose | Required? | -| ---------------------------- | -------------------------------------------------------------------- | --------------------------------------------- | -| `SUPABASE_DB_PASSWORD` | `--linked`: skip temporary login-role creation | no | -| `SUPABASE_ACCESS_TOKEN` | `--linked`: Management API auth | no (falls back to keyring/file) | -| `SUPABASE_SERVICES_HOSTNAME` | `--local`: overrides the local DB host (dev-container/remote Docker) | no (defaults via `DOCKER_HOST` → `127.0.0.1`) | -| `DOCKER_HOST` | `--local`: tcp daemon host used when no services-hostname override | no | -| `BITBUCKET_CLONE_DIR` | when set, omit `--security-opt label:disable` (Bitbucket rejects it) | no | -| `DEBUG` / `--debug` | append `--verbose` to `pg_prove` | no | +| Variable | Purpose | Required? | +| ---------------------------- | ------------------------------------------------------------------------------ | --------------------------------------------- | +| `SUPABASE_DB_PASSWORD` | linked target: skip temporary login-role creation | no | +| `SUPABASE_ACCESS_TOKEN` | linked target: Management API auth | no (falls back to keyring/file) | +| `SUPABASE_TARGET` | workspace target when no `--local`/`--linked`/`--db-url` is given | no (→ `target` in `config.toml` → `local`) | +| `SUPABASE_YES` | grants the hosted-target confirmation (shell only; `supabase/.env` is ignored) | no (also `--yes`) | +| `SUPABASE_SERVICES_HOSTNAME` | `--local`: overrides the local DB host (dev-container/remote Docker) | no (defaults via `DOCKER_HOST` → `127.0.0.1`) | +| `DOCKER_HOST` | `--local`: tcp daemon host used when no services-hostname override | no | +| `BITBUCKET_CLONE_DIR` | when set, omit `--security-opt label:disable` (Bitbucket rejects it) | no | +| `DEBUG` / `--debug` | append `--verbose` to `pg_prove` | no | ## Exit Codes @@ -65,9 +74,11 @@ Managed stack `--local` prove runs the catalog `pg_prove` tool through the ownin | `0` | all pgTAP tests pass | | `1` | `pg_prove` exits non-zero (test failures) — `error running container: exit N`, or `error running pg_prove: exit N` on a managed stack or native external client | | `1` | `pg_prove` ran no tests (`Result: NOTESTS`) — `no pgTAP tests found in <paths>` | -| `1` | `--db-url` / `--linked` / `--local` set together (mutually exclusive) | -| `1` | database connection failure / pgTAP enable failure / docker failure / `--linked` auth or IPv6 errors | -| `1` | `--project-ref` set with a resolved target other than linked (see Notes) | +| `1` | more than one of `--db-url` / `--linked` / `--local` (`TargetSelectorConflictError`) | +| `1` | database connection failure / pgTAP enable failure / docker failure / linked-target auth or IPv6 errors | +| `1` | `--project-ref` with `--local` or `--db-url` (`TargetProjectRefConflictError`) | +| `1` | hosted target in a JSON, piped, agent or non-TTY run without `--yes` or a shell `SUPABASE_YES` (`HostedMutationConsentRequiredError`); nothing runs | +| `1` | hosted target confirmation declined (`HostedMutationDeclinedError`, `context canceled`); nothing runs | ## Telemetry Events Fired @@ -91,14 +102,15 @@ subprocess's stdout is forwarded chunk-by-chunk, byte-exact and unframed) — ### `--output-format text` -TAP streams to stdout. The connection diagnostic `Connecting to {local|remote} database...` -is written to **stderr**, never to stdout — no +TAP streams to stdout. The `Target: …` line (linked project `Target: <name> (<ref>)`, +`Target: local stack (<host>:<port>)`, or `Target: custom Postgres (<host>)`) and the hosted +confirmation question are written to **stderr**, never to stdout — no spinner is used, so stdout carries only the raw TAP bytes. ### `--output-format json` / `stream-json` No machine envelope is emitted. stdout carries the raw TAP stream only; the -connection diagnostic still goes to stderr (no task JSON-log events are written to stdout, +target line still goes to stderr (no task JSON-log events are written to stdout, which would otherwise corrupt the TAP stream). A non-zero `pg_prove` exit still fails the command (exit 1). @@ -106,12 +118,16 @@ command (exit 1). - Hidden command. - Stack `test db` runs catalog `pg_prove` through the owning stack runtime with runtime credentials for native and container runtimes. External targets use the catalog Postgres client. Compose stays on `supabase/pg_prove:3.36`. There is no PATH fallback or host major-version check. -- **`--project-ref`** (shared by `db test` via `testDbConfig`) 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 - simply goes unused on a non-linked target). +- **Target selection** (shared by `db test` via `testDbConfig`). `--db-url`, `--linked` and + `--local` are mutually exclusive and a `false` switch counts as absent. `--project-ref` selects + the linked project and supplies only the ref (flag > `SUPABASE_PROJECT_ID` > + `.temp/project-ref`); combining it with `--local` or `--db-url` is a hard error. +- **Hosted consent.** A linked project or remote `--db-url` asks once, after the target and the + test paths are resolved and before pgTAP is enabled: `Run pgTAP tests`, listing the pgTAP + enable, the `pg_prove` run against the test paths, and the pgTAP drop, with default No. A + shell `SUPABASE_YES` or `--yes` grants it (`Confirmed by --yes: …`); a `SUPABASE_YES` in + `supabase/.env` and a piped answer do not. Stdin is never read for the answer. A `--db-url` + that points at the local stack never prompts. - Postgres TLS behavior: local connections disable TLS (`ConnectLocalPostgres` sets `cc.TLSConfig = nil`); remote (`--db-url` / `--linked`) connections honor the URL's `sslmode` — From 37129ed32eda11eff489682897dfd865cd79f5f2 Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Thu, 15 Oct 2026 14:49:38 +0100 Subject: [PATCH 52/92] style(cli): format db command target migration files --- .../src/command-internal/test-db.handler.ts | 11 ++-- .../test-db.integration.test.ts | 13 ++-- apps/cli/src/commands/db/dump/dump.handler.ts | 3 +- .../commands/db/dump/dump.integration.test.ts | 36 +++++------ apps/cli/src/commands/db/lint/lint.handler.ts | 5 +- .../commands/db/lint/lint.integration.test.ts | 5 +- .../db/query/query.integration.test.ts | 62 +++++++++---------- .../src/commands/db/reset/reset.handler.ts | 4 +- 8 files changed, 61 insertions(+), 78 deletions(-) diff --git a/apps/cli/src/command-internal/test-db.handler.ts b/apps/cli/src/command-internal/test-db.handler.ts index e4506afc29..b5d3ce9f6b 100644 --- a/apps/cli/src/command-internal/test-db.handler.ts +++ b/apps/cli/src/command-internal/test-db.handler.ts @@ -10,17 +10,16 @@ import { readDbToml } from "./db-config.toml-read.ts"; import { DbConnection, type PgConnInput } from "./db-connection.service.ts"; import { LocalDbRunningError } from "./db-bootstrap/local-db-running.ts"; import { DockerRun } from "./docker-run.service.ts"; -import { requireTargetMutationConsent, hostedMutationPreapproval } from "./hosted-consent/hosted-consent.ts"; +import { + requireTargetMutationConsent, + hostedMutationPreapproval, +} from "./hosted-consent/hosted-consent.ts"; import { resolveTarget } from "./target/resolve-target.ts"; import { DebugFlag, DnsResolverFlag, NetworkIdFlag } from "./global-flags.ts"; import { Output } from "../shared/output/output.service.ts"; import { RuntimeInfo } from "../shared/runtime/runtime-info.service.ts"; import type { TestDbFlags } from "./test-db.command-handler.ts"; -import { - TestDbEnablePgtapError, - TestDbNoTestsError, - TestDbRunError, -} from "./test-db.errors.ts"; +import { TestDbEnablePgtapError, TestDbNoTestsError, TestDbRunError } from "./test-db.errors.ts"; import { buildPgProveArgs, pgProveMajor } from "./test-db.pg-prove-args.ts"; import { currentStackBackend } from "./stack-backend.ts"; import { 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 145a9d5a38..6af8effba7 100644 --- a/apps/cli/src/command-internal/test-db.integration.test.ts +++ b/apps/cli/src/command-internal/test-db.integration.test.ts @@ -33,20 +33,17 @@ import { import { cliConfigValuesTestLayer } from "../../tests/helpers/config-values-layer.ts"; import { describeHostedConsent } from "../../tests/helpers/hosted-consent.ts"; import { CliArgs } from "../shared/cli/cli-args.service.ts"; -import { - DebugFlag, - DnsResolverFlag, - NetworkIdFlag, - OutputFlag, - YesFlag, -} from "./global-flags.ts"; +import { DebugFlag, DnsResolverFlag, NetworkIdFlag, OutputFlag, YesFlag } from "./global-flags.ts"; import { ProjectRefResolver } from "../config/project-ref.service.ts"; import { RuntimeInfo } from "../shared/runtime/runtime-info.service.ts"; import { DbConfigResolver } from "./db-config.service.ts"; import type { DbConnectionInput, ResolvedDbConnection } from "./db-config.types.ts"; import { HostedMutationDeclinedError } from "./hosted-consent/hosted-consent.errors.ts"; import { TargetPolicy } from "./target/target-policy.ts"; -import { TargetProjectRefConflictError, TargetSelectorConflictError } from "./target/target.errors.ts"; +import { + TargetProjectRefConflictError, + TargetSelectorConflictError, +} from "./target/target.errors.ts"; import { DbConnectError, DbExecError } from "./db-connection.errors.ts"; import { DbConnection, type DbSession, type PgConnInput } from "./db-connection.service.ts"; import { DockerRunError } from "./docker-run.errors.ts"; diff --git a/apps/cli/src/commands/db/dump/dump.handler.ts b/apps/cli/src/commands/db/dump/dump.handler.ts index e372cda0da..4e680b01e4 100644 --- a/apps/cli/src/commands/db/dump/dump.handler.ts +++ b/apps/cli/src/commands/db/dump/dump.handler.ts @@ -411,7 +411,8 @@ export const dbDump = Effect.fn("db.dump")(function* (flags: DbDumpFlags) { connType: "linked", dnsResolver, password: flags.password, - linkedProjectRef: target.kind === "linked" ? Option.some(target.ref) : Option.none(), + linkedProjectRef: + target.kind === "linked" ? Option.some(target.ref) : Option.none(), adHocProjectRef: target.kind === "linked" && !target.matchesLinkedRef, }) .pipe(Effect.orElseSucceed(() => Option.none())), 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 b3c4f9f49d..220c641292 100644 --- a/apps/cli/src/commands/db/dump/dump.integration.test.ts +++ b/apps/cli/src/commands/db/dump/dump.integration.test.ts @@ -570,9 +570,7 @@ describe("db dump integration", () => { it.effect("errors when --use-copy is used without --data-only", () => { const { layer } = setup(); return Effect.gen(function* () { - const exit = yield* dbDump(flags({ useCopy: true, local: true })).pipe( - Effect.exit, - ); + const exit = yield* dbDump(flags({ useCopy: true, local: true })).pipe(Effect.exit); expect(Exit.isFailure(exit)).toBe(true); expect(failMessage(exit)).toBe(`required flag(s) "data-only" not set`); }).pipe(Effect.provide(layer)); @@ -594,9 +592,9 @@ describe("db dump integration", () => { it.effect("errors when --exclude is used without --data-only", () => { const { layer } = setup(); return Effect.gen(function* () { - const exit = yield* dbDump( - flags({ exclude: ["public.users"], local: true }), - ).pipe(Effect.exit); + const exit = yield* dbDump(flags({ exclude: ["public.users"], local: true })).pipe( + Effect.exit, + ); expect(Exit.isFailure(exit)).toBe(true); expect(failMessage(exit)).toBe(`required flag(s) "data-only" not set`); }).pipe(Effect.provide(layer)); @@ -731,9 +729,7 @@ describe("db dump integration", () => { path.join(tmp.current, "supabase", "config.toml"), ["[remotes.staging]", 'project_id = "staging"', ""].join("\n"), ); - const exit = yield* dbDump(flags({ dryRun: true, local: true })).pipe( - Effect.exit, - ); + const exit = yield* dbDump(flags({ dryRun: true, local: true })).pipe(Effect.exit); expect(Exit.isFailure(exit)).toBe(true); expect(failMessage(exit)).toContain( "Invalid config for remotes.staging.project_id. Must be like: abcdefghijklmnopqrst", @@ -772,9 +768,7 @@ describe("db dump integration", () => { it.effect("dumps only data without column inserts when --use-copy is set", () => { const { layer, docker } = setup({ isLocal: true }); return Effect.gen(function* () { - yield* dbDump( - flags({ dataOnly: Option.some(true), useCopy: true, local: true }), - ); + yield* dbDump(flags({ dataOnly: Option.some(true), useCopy: true, local: true })); expect(docker.lastOpts?.env["EXTRA_FLAGS"]).toBeUndefined(); }).pipe(Effect.provide(layer)); }); @@ -959,9 +953,9 @@ describe("db dump integration", () => { resolveFails: true, }); return Effect.gen(function* () { - const exit = yield* dbDump( - flags({ linked: true, projectRef: Option.some(FLAG_REF) }), - ).pipe(Effect.exit); + const exit = yield* dbDump(flags({ linked: true, projectRef: Option.some(FLAG_REF) })).pipe( + Effect.exit, + ); expect(Exit.isFailure(exit)).toBe(true); expect(cache.cached).toBe(true); expect(cache.cachedRef).toBe(FLAG_REF); @@ -1034,9 +1028,9 @@ describe("db dump integration", () => { // fast with no connection/API work and no cache write. const { layer, cache, resolver } = setup(); return Effect.gen(function* () { - const exit = yield* dbDump( - flags({ linked: true, projectRef: Option.some("BADREF") }), - ).pipe(Effect.exit); + const exit = yield* dbDump(flags({ linked: true, projectRef: Option.some("BADREF") })).pipe( + Effect.exit, + ); expect(Exit.isFailure(exit)).toBe(true); expect(failMessage(exit)).toBe(INVALID_PROJECT_REF_MESSAGE); expect(resolver.calls).toEqual([]); @@ -1049,9 +1043,9 @@ describe("db dump integration", () => { const FLAG_REF = "flagflagflagflagflag"; const { layer, resolver, cache } = setup({ isLocal: true }); return Effect.gen(function* () { - const error = yield* dbDump( - flags({ local: true, projectRef: Option.some(FLAG_REF) }), - ).pipe(Effect.flip); + const error = yield* dbDump(flags({ local: true, projectRef: Option.some(FLAG_REF) })).pipe( + Effect.flip, + ); expect(error).toBeInstanceOf(TargetProjectRefConflictError); expect(resolver.calls).toEqual([]); expect(cache.cached).toBe(false); diff --git a/apps/cli/src/commands/db/lint/lint.handler.ts b/apps/cli/src/commands/db/lint/lint.handler.ts index 1e77bd958e..e98a9acb63 100644 --- a/apps/cli/src/commands/db/lint/lint.handler.ts +++ b/apps/cli/src/commands/db/lint/lint.handler.ts @@ -95,10 +95,7 @@ const lintDatabase = Effect.fn("DbLint.lintDatabase")(function* ( return results; }); -const runLint = Effect.fnUntraced(function* ( - flags: DbLintFlags, - dnsResolver: "native" | "https", -) { +const runLint = Effect.fnUntraced(function* (flags: DbLintFlags, dnsResolver: "native" | "https") { const output = yield* Output; const resolver = yield* DbConfigResolver; const dbConn = yield* DbConnection; diff --git a/apps/cli/src/commands/db/lint/lint.integration.test.ts b/apps/cli/src/commands/db/lint/lint.integration.test.ts index fa952e79f9..60b729cbe0 100644 --- a/apps/cli/src/commands/db/lint/lint.integration.test.ts +++ b/apps/cli/src/commands/db/lint/lint.integration.test.ts @@ -693,10 +693,7 @@ describe("db lint", () => { it.effect("follows a remote workspace target when no selector flag is given", () => { const { layer, resolver, cache } = setup({ isLocal: false, checkRows: { public: [] } }); return Effect.gen(function* () { - yield* withConfigEnv( - { SUPABASE_TARGET: "remote" }, - dbLint(flags({ schema: ["public"] })), - ); + yield* withConfigEnv({ SUPABASE_TARGET: "remote" }, dbLint(flags({ schema: ["public"] }))); expect(resolver.resolveInput?.target.kind).toBe("linked"); expect(cache.cached).toBe(true); }).pipe(Effect.provide(layer)); 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 0ff37a17cc..a6291f45df 100644 --- a/apps/cli/src/commands/db/query/query.integration.test.ts +++ b/apps/cli/src/commands/db/query/query.integration.test.ts @@ -164,9 +164,7 @@ function mockDbConnection(opts: { } return opts.queryFails === true ? Effect.fail(new DbExecError({ message: "failed to execute query: boom" })) - : Effect.succeed( - opts.result ?? { fields: [], rows: [], commandTag: "CREATE TABLE" }, - ); + : Effect.succeed(opts.result ?? { fields: [], rows: [], commandTag: "CREATE TABLE" }); }), }), }); @@ -764,9 +762,9 @@ describe("db query integration", () => { it.effect("fails with DbQueryExecError when the query errors", () => { const { layer } = setup({ queryFails: true }); return Effect.gen(function* () { - const exit = yield* dbQuery( - flags({ sql: Option.some("bad"), local: true }), - ).pipe(Effect.exit); + const exit = yield* dbQuery(flags({ sql: Option.some("bad"), local: true })).pipe( + Effect.exit, + ); expect(failMessage(exit)).toContain("failed to execute query"); }).pipe(Effect.provide(layer)); }); @@ -798,9 +796,9 @@ describe("db query integration", () => { it.effect("fails an unlinked --linked query without prompting for a project", () => { const { layer } = setup({ unlinked: true }); return Effect.gen(function* () { - const exit = yield* dbQuery( - flags({ sql: Option.some("select 1"), linked: true }), - ).pipe(Effect.exit); + const exit = yield* dbQuery(flags({ sql: Option.some("select 1"), linked: true })).pipe( + Effect.exit, + ); expect(Exit.isFailure(exit)).toBe(true); expect(failMessage(exit)).toBe("Cannot find project ref. Have you run supabase link?"); }).pipe(Effect.provide(layer)); @@ -809,9 +807,9 @@ describe("db query integration", () => { it.effect("surfaces a project-ref read failure instead of reporting not-linked", () => { const { layer } = setup({ refReadFails: true }); return Effect.gen(function* () { - const exit = yield* dbQuery( - flags({ sql: Option.some("select 1"), linked: true }), - ).pipe(Effect.exit); + const exit = yield* dbQuery(flags({ sql: Option.some("select 1"), linked: true })).pipe( + Effect.exit, + ); expect(Exit.isFailure(exit)).toBe(true); expect(failMessage(exit)).toContain("failed to load project ref"); expect(failMessage(exit)).not.toContain("Cannot find project ref"); @@ -1030,9 +1028,9 @@ describe("db query integration", () => { linkedBody: '[{"id":1}]', }); return Effect.gen(function* () { - const exit = yield* dbQuery( - flags({ sql: Option.some("select 1"), linked: true }), - ).pipe(Effect.exit); + const exit = yield* dbQuery(flags({ sql: Option.some("select 1"), linked: true })).pipe( + Effect.exit, + ); expect(Exit.isFailure(exit)).toBe(true); expect(failMessage(exit)).toContain("failed to parse connection string"); expect(out.stdoutText).toBe(""); @@ -1058,9 +1056,9 @@ describe("db query integration", () => { linkedBody: '{"message":"syntax error"}', }); return Effect.gen(function* () { - const exit = yield* dbQuery( - flags({ sql: Option.some("bad"), linked: true }), - ).pipe(Effect.exit); + const exit = yield* dbQuery(flags({ sql: Option.some("bad"), linked: true })).pipe( + Effect.exit, + ); expect(failMessage(exit)).toContain("unexpected status 400"); expect(cache.cached).toBe(true); }).pipe(Effect.provide(layer)); @@ -1070,9 +1068,7 @@ describe("db query integration", () => { it.effect("handles an empty linked result array", () => { const { layer, out } = setup({ linkedStatus: 201, linkedBody: "[]" }); return Effect.gen(function* () { - yield* dbQuery( - flags({ sql: Option.some("select 1 where false"), linked: true }), - ); + yield* dbQuery(flags({ sql: Option.some("select 1 where false"), linked: true })); expect(out.stdoutText).toBe(""); }).pipe(Effect.provide(layer)); }); @@ -1128,9 +1124,9 @@ describe("db query integration", () => { it.effect("maps a linked HTTP transport failure to an exec error", () => { const { layer } = setup({ networkFail: true }); return Effect.gen(function* () { - const exit = yield* dbQuery( - flags({ sql: Option.some("select 1"), linked: true }), - ).pipe(Effect.exit); + const exit = yield* dbQuery(flags({ sql: Option.some("select 1"), linked: true })).pipe( + Effect.exit, + ); expect(failMessage(exit)).toContain("failed to execute query"); }).pipe(Effect.provide(layer)); }); @@ -1138,9 +1134,9 @@ describe("db query integration", () => { it.effect("requires login before querying --linked", () => { const { layer } = setup({ accessToken: Option.none() }); return Effect.gen(function* () { - const exit = yield* dbQuery( - flags({ sql: Option.some("select 1"), linked: true }), - ).pipe(Effect.exit); + const exit = yield* dbQuery(flags({ sql: Option.some("select 1"), linked: true })).pipe( + Effect.exit, + ); expect(failMessage(exit)).toContain("Access token not provided"); }).pipe(Effect.provide(layer)); }); @@ -1148,9 +1144,9 @@ describe("db query integration", () => { it.effect("rejects an invalid env access token before the linked query", () => { const { layer, out } = setup({ accessTokenInvalid: true, linkedStatus: 201 }); return Effect.gen(function* () { - const exit = yield* dbQuery( - flags({ sql: Option.some("select 1"), linked: true }), - ).pipe(Effect.exit); + const exit = yield* dbQuery(flags({ sql: Option.some("select 1"), linked: true })).pipe( + Effect.exit, + ); expect(Exit.isFailure(exit)).toBe(true); expect(failMessage(exit)).toContain("Invalid access token format"); expect(out.stdoutText).toBe(""); @@ -1171,9 +1167,9 @@ describe("db query integration", () => { it.effect("surfaces a linked config/connection failure before the missing-token error", () => { const { layer } = setup({ accessToken: Option.none(), resolveFails: true }); return Effect.gen(function* () { - const exit = yield* dbQuery( - flags({ sql: Option.some("select 1"), linked: true }), - ).pipe(Effect.exit); + const exit = yield* dbQuery(flags({ sql: Option.some("select 1"), linked: true })).pipe( + Effect.exit, + ); expect(failMessage(exit)).toContain("failed to parse connection string"); expect(failMessage(exit)).not.toContain("Access token not provided"); }).pipe(Effect.provide(layer)); diff --git a/apps/cli/src/commands/db/reset/reset.handler.ts b/apps/cli/src/commands/db/reset/reset.handler.ts index 32a0d679ba..a2ba065a00 100644 --- a/apps/cli/src/commands/db/reset/reset.handler.ts +++ b/apps/cli/src/commands/db/reset/reset.handler.ts @@ -237,7 +237,9 @@ export const dbReset = Effect.fn("db.reset")(function* (flags: DbResetFlags) { action: `Reset the remote database${resolvedVersion === "" ? "" : ` to version ${resolvedVersion}`}`, details: [ "drop every user schema", - ...(vaultCount > 0 ? [`upsert ${vaultCount} vault secret${vaultCount === 1 ? "" : "s"}`] : []), + ...(vaultCount > 0 + ? [`upsert ${vaultCount} vault secret${vaultCount === 1 ? "" : "s"}`] + : []), useSchemaFiles ? "apply the declarative schema files" : `apply local migrations${resolvedVersion === "" ? "" : ` up to version ${resolvedVersion}`}`, From d3b22057a317468cd68cddaada9e55ce82ac17d6 Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Thu, 15 Oct 2026 14:52:20 +0100 Subject: [PATCH 53/92] chore(cli): keep unused db target errors out of the knip report --- apps/cli/src/commands/db/advisors/advisors.errors.ts | 5 ++++- apps/cli/src/commands/db/diff/diff.errors.ts | 4 ++-- apps/cli/src/commands/db/lint/lint.errors.ts | 5 ++++- apps/cli/src/commands/db/query/query.errors.ts | 5 ++--- apps/cli/src/commands/db/reset/reset.errors.ts | 8 ++++---- 5 files changed, 16 insertions(+), 11 deletions(-) diff --git a/apps/cli/src/commands/db/advisors/advisors.errors.ts b/apps/cli/src/commands/db/advisors/advisors.errors.ts index 2c5895dc29..25fec4cc61 100644 --- a/apps/cli/src/commands/db/advisors/advisors.errors.ts +++ b/apps/cli/src/commands/db/advisors/advisors.errors.ts @@ -15,7 +15,10 @@ import { * `InvalidProjectRefError`. */ -/** Conflicting `db-url`/`linked`/`local` flags; message text is an established output contract. */ +/** + * Unused: target conflicts raise `TargetSelectorConflictError`. + * @public + */ export class DbAdvisorsMutuallyExclusiveFlagsError extends Data.TaggedError( "DbAdvisorsMutuallyExclusiveFlagsError", )<{ readonly message: string }> { diff --git a/apps/cli/src/commands/db/diff/diff.errors.ts b/apps/cli/src/commands/db/diff/diff.errors.ts index 4073e4f32a..0e550fcadb 100644 --- a/apps/cli/src/commands/db/diff/diff.errors.ts +++ b/apps/cli/src/commands/db/diff/diff.errors.ts @@ -7,8 +7,8 @@ import { } from "../../../shared/telemetry/error-actionability.ts"; /** - * Conflicting database-target flags (`db-url`/`linked`/`local`); message text - * is an established output contract. + * Unused: target conflicts raise `TargetSelectorConflictError`. + * @public */ export class DbDiffTargetFlagsError extends Data.TaggedError("DbDiffTargetFlagsError")<{ readonly message: string; diff --git a/apps/cli/src/commands/db/lint/lint.errors.ts b/apps/cli/src/commands/db/lint/lint.errors.ts index 57712f832a..fa256d8b48 100644 --- a/apps/cli/src/commands/db/lint/lint.errors.ts +++ b/apps/cli/src/commands/db/lint/lint.errors.ts @@ -14,7 +14,10 @@ import { * connection layer — not re-wrapped here. */ -/** Conflicting `db-url`/`linked`/`local` flags; message text is an established output contract. */ +/** + * Unused: target conflicts raise `TargetSelectorConflictError`. + * @public + */ export class DbLintMutuallyExclusiveFlagsError extends Data.TaggedError( "DbLintMutuallyExclusiveFlagsError", )<{ readonly message: string }> { diff --git a/apps/cli/src/commands/db/query/query.errors.ts b/apps/cli/src/commands/db/query/query.errors.ts index 9833be6c60..ba73122991 100644 --- a/apps/cli/src/commands/db/query/query.errors.ts +++ b/apps/cli/src/commands/db/query/query.errors.ts @@ -70,9 +70,8 @@ export class DbQueryExecError extends Data.TaggedError("DbQueryExecError")<{ } /** - * More than one of `--db-url` / `--linked` / `--local` was set; message text - * is an established output contract, so the invocation fails before any SQL - * runs. + * Unused: target conflicts raise `TargetSelectorConflictError`. + * @public */ export class DbQueryMutuallyExclusiveFlagsError extends Data.TaggedError( "DbQueryMutuallyExclusiveFlagsError", diff --git a/apps/cli/src/commands/db/reset/reset.errors.ts b/apps/cli/src/commands/db/reset/reset.errors.ts index cc765d4343..79f0fb1d5a 100644 --- a/apps/cli/src/commands/db/reset/reset.errors.ts +++ b/apps/cli/src/commands/db/reset/reset.errors.ts @@ -7,8 +7,8 @@ import { } from "../../../shared/telemetry/error-actionability.ts"; /** - * Conflicting database-target flags (`db-url`/`linked`/`local`); message text - * is an established output contract. + * Unused: target conflicts raise `TargetSelectorConflictError`. + * @public */ export class DbResetTargetFlagsError extends Data.TaggedError("DbResetTargetFlagsError")<{ readonly message: string; @@ -57,8 +57,8 @@ export class DbResetMigrationFileError extends Data.TaggedError("DbResetMigratio } /** - * The user declined the reset confirmation; message text (`context canceled`) - * is an established output contract. + * Unused: a declined hosted confirmation raises `HostedMutationDeclinedError`. + * @public */ export class DbResetCancelledError extends Data.TaggedError("DbResetCancelledError")<{ readonly message: string; From ee7bd8e157f976f20a7e4f124b5e6807afe43a7d Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Thu, 15 Oct 2026 14:53:51 +0100 Subject: [PATCH 54/92] feat(cli): resolve the declarative generate target from the workspace target (CLI-2695) `db schema declarative generate` takes the shared target flags (`--local`, `--linked`, `--project-ref`, `--db-url`) and falls back to the workspace `target`, which defaults to the local database. The no-flag target prompt, the up-front overwrite prompt and the custom database URL prompt are gone; `--reset` now applies to every local target. Existing files are still confirmed before they are overwritten, after the export succeeds. `db schema declarative sync` bootstraps a missing declarative tree from the local database only, offering a local reset when migrations exist. It no longer resolves a linked ref or fills the linked-project cache. --- .../schema/declarative/declarative.errors.ts | 16 +- .../declarative/declarative.smart-target.ts | 239 +----- .../declarative/generate/generate.command.ts | 73 +- .../declarative/generate/generate.handler.ts | 193 +---- .../generate/generate.integration.test.ts | 707 +++++++----------- .../schema/declarative/sync/sync.command.ts | 38 +- .../schema/declarative/sync/sync.handler.ts | 107 +-- .../declarative/sync/sync.integration.test.ts | 229 ++---- 8 files changed, 444 insertions(+), 1158 deletions(-) diff --git a/apps/cli/src/commands/db/schema/declarative/declarative.errors.ts b/apps/cli/src/commands/db/schema/declarative/declarative.errors.ts index 8a501f1a70..020732b47a 100644 --- a/apps/cli/src/commands/db/schema/declarative/declarative.errors.ts +++ b/apps/cli/src/commands/db/schema/declarative/declarative.errors.ts @@ -22,10 +22,7 @@ export class DeclarativeNotEnabledError extends Data.TaggedError("DeclarativeNot } } -/** - * A target could not be resolved in non-interactive mode; message text is an established output - * contract, shared by `generate` and the `sync` variants requiring `generate` first. - */ +/** `sync` found no declarative schema and cannot offer to generate one without a terminal. */ export class DeclarativeNonInteractiveError extends Data.TaggedError( "DeclarativeNonInteractiveError", )<{ @@ -36,11 +33,7 @@ export class DeclarativeNonInteractiveError extends Data.TaggedError( } } -/** - * A mutually-exclusive flag group was violated: `generate`'s `db-url`/`linked`/`local`, or - * `sync`'s `apply`/`no-apply`. Message text is an established output contract; both fail before - * any side effects run. - */ +/** `sync`'s `--apply` and `--no-apply` were both set; fails before any side effects run. */ export class DeclarativeMutuallyExclusiveFlagsError extends Data.TaggedError( "DeclarativeMutuallyExclusiveFlagsError", )<{ @@ -52,8 +45,9 @@ export class DeclarativeMutuallyExclusiveFlagsError extends Data.TaggedError( } /** - * The interactive custom-database-URL prompt was empty or unparseable; message text is an - * established output contract. + * Retained for its telemetry tag; no command raises it now that no prompt asks for a URL. + * + * @public */ export class DeclarativeInvalidDbUrlError extends Data.TaggedError("DeclarativeInvalidDbUrlError")<{ readonly message: 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 b7bd4672bb..35726da471 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 @@ -1,112 +1,29 @@ -import { Effect, FileSystem, Option, Path } from "effect"; +import { Effect, Option } from "effect"; -import { - DnsResolverFlag, - resolveYesWithProjectEnv, -} 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 { DnsResolverFlag } from "../../../../command-internal/global-flags.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 { loadProjectEnvValues } from "../../../../command-internal/db-config.toml-read.ts"; -import { - layeredParseEnv, - parseConnectionString, - redactConnectionString, -} from "../../../../command-internal/db-config.parse.ts"; -import { getHostname } from "../../../../command-internal/hostname.ts"; +import { hostedMutationPreapproval } from "../../../../command-internal/hosted-consent/hosted-consent.ts"; import { toPostgresURL } from "../../../../command-internal/postgres-url.ts"; +import type { ResolvedTarget } from "../../../../command-internal/target/resolve-target.ts"; import type { PgDeltaDatabaseEndpoint } from "../../shared/pgdelta-engine.service.ts"; -import { - DeclarativeApplyError, - DeclarativeInvalidDbUrlError, - readErrorSuggestion, -} from "./declarative.errors.ts"; -import type { DeclarativeShadowDbError } from "../../shared/pgdelta.errors.ts"; +import { DeclarativeApplyError, readErrorSuggestion } from "./declarative.errors.ts"; import { DeclarativeSeam } from "../../shared/pgdelta.seam.service.ts"; -/** The local connection bits the smart-target resolver needs. */ -export interface LocalConn { - readonly port: number; - readonly password: string; -} - -/** - * The flag surface the smart-target resolver reads. Both `generate` (passing its full flags) and - * `sync` (constructing a target-less value for its bootstrap) satisfy this. - */ -export interface SmartTargetFlags { - readonly dbUrl: Option.Option<string>; - // Presence-modelled like `--db-url`. The resolver only reads `dbUrl` to pick db-url vs linked, - // so this is carried for type-compat. - readonly linked: Option.Option<boolean>; - readonly password: Option.Option<string>; - readonly reset: boolean; -} - -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(resolvedConfig), - port: local.port, - user: "postgres", - password: local.password, - database: "postgres", - }; -}); - -const localEndpoint = Effect.fnUntraced(function* ( - local: LocalConn, - dnsResolver: "native" | "https", +/** Resolves a target's connection, which announces it, without discarding TLS and connection options. */ +export const resolveTargetEndpoint = Effect.fnUntraced(function* ( + target: ResolvedTarget, + password: Option.Option<string> = Option.none(), + hasPasswordFlag = false, ) { - const connection = yield* localConnection(local); - return { - kind: "database", - ref: toPostgresURL(connection), - connection, - connectOptions: { isLocal: true, dnsResolver }, - } satisfies PgDeltaDatabaseEndpoint; -}); - -/** Local target URL: stack credentials when the stack backend is on, else config.toml `[db]`. */ -export const resolveLocalTargetEndpoint = Effect.fnUntraced(function* ( - local: LocalConn, - dnsResolver: "native" | "https", -) { - const backend = yield* currentStackBackend; - if (backend.kind !== "stack") return yield* localEndpoint(local, dnsResolver); - const resolver = yield* DbConfigResolver; - const resolved = yield* resolver.resolve({ - dbUrl: Option.none(), - connType: "local", - dnsResolver, - }); - return { - kind: "database", - ref: toPostgresURL(resolved.conn), - connection: resolved.conn, - connectOptions: { isLocal: true, dnsResolver }, - } satisfies PgDeltaDatabaseEndpoint; -}); - -/** Resolves a remote target without discarding TLS and connection options. */ -export const resolveRemoteEndpoint = Effect.fnUntraced(function* (flags: SmartTargetFlags) { const resolver = yield* DbConfigResolver; const dnsResolver = yield* DnsResolverFlag; - const resolved = yield* resolver.resolve({ - dbUrl: flags.dbUrl, - connType: Option.isSome(flags.dbUrl) ? "db-url" : "linked", + const resolved = yield* resolver.resolveConnection({ + target, dnsResolver, - password: flags.password, + password, + hasPasswordFlag, + networkBanRemoval: yield* hostedMutationPreapproval, }); return { kind: "database", @@ -116,108 +33,30 @@ export const resolveRemoteEndpoint = Effect.fnUntraced(function* (flags: SmartTa } satisfies PgDeltaDatabaseEndpoint; }); +const resetLocalDatabaseForDeclarative = () => + resetLocalDatabase().pipe( + Effect.mapError( + (error) => + new DeclarativeApplyError({ + message: `database reset failed: ${error.message}`, + suggestion: readErrorSuggestion(error), + }), + ), + ); + /** - * Smart-mode (no explicit target) interactive target resolution, shared by `generate` (smart - * mode) and `sync` (no-declarative-files bootstrap) so both offer the same local/linked/custom - * choice and local-reset prompt. + * The local target for `generate` and for `sync`'s bootstrap: checks the Postgres image, starts a + * stopped stack, optionally resets it, then resolves its connection. */ -export const resolveSmartTargetEndpoint = Effect.fn("DeclarativeSchema.smartTarget")(function* ( - flags: SmartTargetFlags, - local: LocalConn, - hasMigrations: boolean, - fs: FileSystem.FileSystem, - path: Path.Path, - workdir: string, - linkedRef: Option.Option<string>, - beforeLocalTarget: Effect.Effect<void, DeclarativeShadowDbError> = Effect.void, -) { - if (!hasMigrations) { - yield* Effect.annotateCurrentSpan("declarative.target", "local"); - // No migrations: generate from local, starting a stopped stack first. - yield* beforeLocalTarget; - yield* (yield* DeclarativeSeam).ensureLocalDatabaseStarted; - return yield* resolveLocalTargetEndpoint(local, yield* DnsResolverFlag); - } - - 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* 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. - const showLinked = Option.isSome(linkedRef) && PROJECT_REF_PATTERN.test(linkedRef.value); - const choice = yield* output.promptSelect("Generate declarative schema from:", [ - { value: "local", label: "Local database", hint: "generate from local Postgres" }, - ...(showLinked && Option.isSome(linkedRef) - ? [ - { - value: "linked", - label: "Linked project", - hint: `generate from remote linked project (${linkedRef.value})`, - }, - ] - : []), - { value: "custom", label: "Custom database URL", hint: "enter a connection string" }, - ]); - yield* Effect.annotateCurrentSpan("declarative.target", choice); - - if (choice === "linked") { - // Same path as an explicit `--linked`: login-role mint + pooler fallback, then the resolved URL. - return yield* resolveRemoteEndpoint({ ...flags, linked: Option.some(true) }); - } - - if (choice === "custom") { - const dbURL = yield* output.promptText("Enter database URL: "); - if (dbURL.trim().length === 0) { - return yield* new DeclarativeInvalidDbUrlError({ message: "database URL cannot be empty" }); - } - // Layers the project env (loaded once above) under the shell env like the --db-url path so - // libpq PG* fallbacks resolve; malformed input fails with a redacted connection string - // (CWE-209). - const conn = yield* parseConnectionString(dbURL, yield* layeredParseEnv(projectEnv)); - if (conn === undefined) { - return yield* new DeclarativeInvalidDbUrlError({ - message: `failed to parse connection string: ${redactConnectionString(dbURL)}`, - }); - } - return { - kind: "database", - ref: toPostgresURL(conn), - connection: conn, - connectOptions: { isLocal: false, dnsResolver: yield* DnsResolverFlag }, - } satisfies PgDeltaDatabaseEndpoint; - } - - // "Local database" choice: starts a stopped stack before the reset prompt. - yield* beforeLocalTarget; - yield* (yield* DeclarativeSeam).ensureLocalDatabaseStarted; - - let shouldReset = flags.reset; - if (!shouldReset) { - // `--yes`/`SUPABASE_YES` auto-resets, but still echoes the `<label> [y/N] y` stderr line via - // `promptYesNo` rather than skipping it. - shouldReset = yield* promptYesNo( - output, - yes, - "Reset local database to match migrations first? (local data will be lost)", - false, - ); - } - yield* Effect.annotateCurrentSpan("declarative.local_reset", shouldReset); - if (shouldReset) { - // `resetLocalDatabase` runs in-process, sharing this command's own context: it resolves - // `NetworkIdFlag` itself, so no argv-forwarding is needed to stay on a custom Docker network, - // and a real failure propagates through the effect's own failure channel. - yield* resetLocalDatabase().pipe( - Effect.mapError( - (error) => - new DeclarativeApplyError({ - message: `database reset failed: ${error.message}`, - suggestion: readErrorSuggestion(error), - }), - ), - ); - } - return yield* resolveLocalTargetEndpoint(local, yield* DnsResolverFlag); +export const prepareLocalTargetEndpoint = Effect.fn("DeclarativeSchema.localTarget")(function* < + E, + R, +>(shouldReset: Effect.Effect<boolean, E, R>, password: Option.Option<string> = Option.none()) { + const seam = yield* DeclarativeSeam; + yield* seam.ensureLocalPostgresImageCurrent; + yield* seam.ensureLocalDatabaseStarted; + const reset = yield* shouldReset; + yield* Effect.annotateCurrentSpan("declarative.local_reset", reset); + if (reset) yield* resetLocalDatabaseForDeclarative(); + return yield* resolveTargetEndpoint({ kind: "local" }, password, true); }); 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 373132eb44..fc1edc7134 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 @@ -6,10 +6,9 @@ import { withPermissions } from "../../../../../command-internal/command-permiss import { compose, withCondition, - withContext, - type PermissionFragment, + withDbTarget, } from "../../../../../command-internal/command-permissions/model.ts"; -import { dbConfigLinked } from "../../../../../command-internal/db-config.layer.ts"; +import { dbConfig } from "../../../../../command-internal/db-config.layer.ts"; import { linkedProjectCache } from "../../../../../telemetry/linked-project-cache.layer.ts"; import { withCliConfigFlags } from "../../../../../config/cli-config-flags.ts"; import { CliConfigKeys } from "../../../../../config/cli-config-keys.ts"; @@ -22,9 +21,11 @@ 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"; +import { targetFlags } from "../../../../../command-internal/target/target-flags.ts"; import { withTargetPolicy } from "../../../../../command-internal/target/target-policy.ts"; const config = { + ...targetFlags({ group: "selectable", subject: "database", dbUrl: true }), overwrite: Flag.Boolean("overwrite").pipe( Flag.withDescription("Overwrite declarative schema files without confirmation."), Flag.withDefault(false), @@ -53,22 +54,6 @@ const config = { (err) => (err instanceof Error ? err.message : String(err)), ), ), - dbUrl: Flag.String("db-url").pipe( - Flag.withDescription( - "Generates declarative schema from the database specified by the connection string (must be percent-encoded).", - ), - Flag.optional, - ), - // Explicit-target selection keys off presence, not the bool value, so model `--linked`/ - // `--local` as `Option` (like `--db-url`) so `--linked=false` still takes the explicit path. - linked: Flag.Boolean("linked").pipe( - Flag.withDescription("Generates declarative schema from the linked project."), - Flag.optional, - ), - local: Flag.Boolean("local").pipe( - Flag.withDescription("Generates declarative schema from the local database."), - Flag.optional, - ), password: CliConfigKeys.linkedDb.password.flag({ name: "password", alias: "p", @@ -77,13 +62,6 @@ const config = { }), } as const; -// Without a target flag, a run whose workdir has migrations can ask where to generate from. -const noTargetFlag = (fragment: PermissionFragment) => - (["db-url", "local", "linked"] as const).reduce( - (current, flag) => withCondition(current, { flag, present: false }), - fragment, - ); - // `--no-cache` is a shared flag on the `declarative` group (read from the parent), // so the handler input merges it in alongside the leaf's own flags. export type DbSchemaDeclarativeGenerateFlags = CliCommand.Command.Config.Infer<typeof config> & { @@ -93,35 +71,27 @@ export type DbSchemaDeclarativeGenerateFlags = CliCommand.Command.Config.Infer<t export const dbSchemaDeclarativeGenerateCommand = Command.make("generate", config).pipe( Command.withDescription( - "Exports a live database into the complete declarative schema tree. This replaces declarative files only; it does not create migration files or update migration history. Use --output-dir to stage an export without changing the configured declarative path. In non-interactive use, pass --local, --linked, or --db-url explicitly.", + "Exports a live database into the complete declarative schema tree. This replaces declarative files only; it does not create migration files or update migration history. Use --output-dir to stage an export without changing the configured declarative path. Generates from the local database unless --linked, --project-ref, or --db-url is passed or the workspace target is remote.", ), Command.withShortDescription("Generate declarative schema from a database"), withPermissions( - compose( - { - operations: [], - noApiEffectFlags: [ - "overwrite", - "output-dir", - "reset", - "schema", - "no-cache", - "strict-coverage", - ], - }, - withCondition(compose(dbConfigLinked, linkedProjectCache), { flag: "linked" }), - noTargetFlag( - compose( - withContext( - dbConfigLinked, - "migrations exist and the user picks the linked project in the target prompt, which needs an interactive terminal or --yes and a linked project ref", - ), - withContext( - linkedProjectCache, - "migrations exist and SUPABASE_PROJECT_ID or supabase/.temp/project-ref gives a ref, whichever target is picked", - ), - ), + withDbTarget( + compose( + { + operations: [], + noApiEffectFlags: [ + "overwrite", + "output-dir", + "reset", + "schema", + "no-cache", + "strict-coverage", + ], + }, + dbConfig, + withCondition(linkedProjectCache, { dbTarget: ["linked"] }), ), + { default: "local" }, ), ), Command.withHandler((flags) => @@ -157,6 +127,7 @@ export const dbSchemaDeclarativeGenerateCommand = Command.make("generate", confi "db-url": merged.dbUrl, linked: merged.linked, local: merged.local, + "project-ref": merged.projectRef, // `password` must never be added to `safeFlags`: it's a credential and must always // reach telemetry as `<redacted>`. password: merged.password, 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 51f153c486..dafc92b7bd 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 @@ -7,27 +7,17 @@ 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 { 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 "../../../../../shared/config/temp-paths.ts"; import { loadProjectEnvValues, readDbToml, resolveDeclarativeDir, } from "../../../../../command-internal/db-config.toml-read.ts"; -import { rejectPasswordWithDirectTarget } from "../../../../../command-internal/db-target-flags.ts"; +import { resolveTarget } from "../../../../../command-internal/target/resolve-target.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 } from "../../../../../command-internal/pgdelta.ts"; -import type { PgDeltaDatabaseEndpoint } from "../../../shared/pgdelta-engine.service.ts"; import { DeclarativeWriteError } from "../../../shared/pgdelta.errors.ts"; -import { - DeclarativeMutuallyExclusiveFlagsError, - DeclarativeNonInteractiveError, -} from "../declarative.errors.ts"; -import { DeclarativeSeam } from "../../../shared/pgdelta.seam.service.ts"; import { warnFormerDeclarativeDefault } from "../declarative.former-default.ts"; import { requirePgDelta } from "../declarative.gate.ts"; import { @@ -40,18 +30,12 @@ import { writeDeclarativeSchemas, } from "../../../shared/pgdelta.write.ts"; import type { DbSchemaDeclarativeGenerateFlags } from "./generate.command.ts"; -import { - type LocalConn, - resolveLocalTargetEndpoint, - resolveRemoteEndpoint, - resolveSmartTargetEndpoint, -} from "../declarative.smart-target.ts"; +import { prepareLocalTargetEndpoint, resolveTargetEndpoint } from "../declarative.smart-target.ts"; export const dbSchemaDeclarativeGenerate = Effect.fn("db.schema.declarative.generate")(function* ( flags: DbSchemaDeclarativeGenerateFlags, ) { const output = yield* Output; - const tty = yield* Tty; const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const cliSettings = yield* CommandSettings; @@ -65,14 +49,12 @@ export const dbSchemaDeclarativeGenerate = Effect.fn("db.schema.declarative.gene // `--yes` or `SUPABASE_YES` (shell env or project `.env`) must auto-confirm the prompts below. const yes = yield* resolveYesWithProjectEnv(projectEnv); - // The resolved linked ref (explicit `--linked` only), hoisted so the post-run - // linked-project cache finalizer can read it after the body resolves it. + // Hoisted so the post-run linked-project cache finalizer can read the ref the body resolves. const linkedProjectRef = yield* Ref.make<string | undefined>(undefined); yield* Effect.gen(function* () { const baseToml = yield* readDbToml(fs, path, cliSettings.workdir); - // Gate before the mutex check below (see `requirePgDelta`'s doc comment for why), and on the - // base config: a remote `experimental.pgdelta.enabled = true` must not enable a + // Gate on the base config: a remote `experimental.pgdelta.enabled = true` must not enable a // base-disabled command without `--experimental`. yield* requirePgDelta({ experimental, @@ -80,39 +62,14 @@ export const dbSchemaDeclarativeGenerate = Effect.fn("db.schema.declarative.gene configPath: path.join("supabase", "config.toml"), }); - // Mutually-exclusive db-url/linked/local group, checked after the gate above (see - // `requirePgDelta`'s doc comment for the ordering). "Set" means explicitly set: Option - // `Some`, or boolean `true`. - const exclusive: Array<string> = []; - if (Option.isSome(flags.dbUrl)) exclusive.push("db-url"); - if (Option.isSome(flags.linked)) exclusive.push("linked"); - if (Option.isSome(flags.local)) exclusive.push("local"); - if (exclusive.length > 1) { - return yield* new DeclarativeMutuallyExclusiveFlagsError({ - message: `if any flags in the group [db-url linked local] are set none of the others can be; [${exclusive.join(" ")}] were all set`, - }); - } - - yield* rejectPasswordWithDirectTarget( - Option.isSome(flags.dbUrl) ? "db-url" : Option.isSome(flags.local) ? "local" : "linked", - flags.password, - ); + const target = yield* resolveTarget(flags); - // Explicit `--linked` re-loads config with the resolved ref, so a matching `[remotes.<ref>]` - // 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. + // A linked target re-loads config with its ref, so a matching `[remotes.<ref>]` block + // overrides `experimental.pgdelta.*` downstream only, not the gate above. let toml = baseToml; - // The resolved linked ref (explicit `--linked` only) is threaded into the raw-shadow export - // source, so its platform setup uses the remote-merged config, and into the post-run - // linked-project cache finalizer below. - if (Option.isSome(flags.linked)) { - const linkedRef = Option.isSome(cliSettings.projectId) - ? cliSettings.projectId - : yield* readProjectRefFile(fs, path, cliSettings.workdir); - if (Option.isSome(linkedRef)) { - yield* Ref.set(linkedProjectRef, linkedRef.value); - toml = yield* readDbToml(fs, path, cliSettings.workdir, linkedRef.value); - } + if (target.kind === "linked") { + yield* Ref.set(linkedProjectRef, target.ref); + toml = yield* readDbToml(fs, path, cliSettings.workdir, target.ref); } // Preserve the selected value for user-facing output: invocation-local `--output` wins, @@ -136,16 +93,11 @@ export const dbSchemaDeclarativeGenerate = Effect.fn("db.schema.declarative.gene }); } yield* warnFormerDeclarativeDefault(fs, path, cliSettings.workdir, toml.pgDelta); - const migrationsDir = path.join(cliSettings.workdir, "supabase", "migrations"); - const local: LocalConn = { port: toml.port, password: toml.password }; - - const resolvedLinkedProjectRef = yield* Ref.get(linkedProjectRef); const run: DeclarativeRunContext = { pgDelta: { 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. + // The merged config's deno_version, so a linked run uses the remote-configured Deno image. denoVersion: toml.denoVersion, projectEnv: toml.projectEnv, }, @@ -157,90 +109,22 @@ export const dbSchemaDeclarativeGenerate = Effect.fn("db.schema.declarative.gene debug: yield* isPgDeltaDebugEnabled, strictCoverage: flags.strictCoverage, dnsResolver, - ...(resolvedLinkedProjectRef !== undefined - ? { linkedProjectRef: resolvedLinkedProjectRef } - : {}), + ...(target.kind === "linked" ? { linkedProjectRef: target.ref } : {}), }; - const hasExplicitTarget = - Option.isSome(flags.local) || Option.isSome(flags.linked) || Option.isSome(flags.dbUrl); yield* Effect.annotateCurrentSpan({ - "declarative.target_mode": hasExplicitTarget ? "explicit" : "smart", + "declarative.target": target.kind, "schema.count": flags.schema.length, }); - let target: PgDeltaDatabaseEndpoint; - let overwrite: boolean; - if (hasExplicitTarget) { - const seam = yield* DeclarativeSeam; - if (Option.isSome(flags.local)) { - // Target selection keys off flag presence, but auto-start gates on the boolean value, so - // `--local=false` selects the local target but must not start a stopped stack. - yield* seam.ensureLocalPostgresImageCurrent; - if (Option.getOrElse(flags.local, () => false)) { - yield* seam.ensureLocalDatabaseStarted; - } - target = yield* resolveLocalTargetEndpoint(local, dnsResolver); - } else { - target = yield* resolveRemoteEndpoint(flags); - } - overwrite = flags.overwrite; - } else { - if (!tty.stdinIsTty && !yes) { - return yield* new DeclarativeNonInteractiveError({ - message: "in non-interactive mode, specify a target: --local, --linked, or --db-url", - }); - } - if ((yield* hasDeclarativeFiles(fs, declarativeDir)) && !flags.overwrite) { - // `--yes`/`SUPABASE_YES` auto-confirms, but still echoes the `<label> [y/N] y` stderr - // line via `promptYesNo` rather than skipping it. - const ok = yield* promptYesNo( - output, - yes, - `Declarative schema already exists at ${bold( - declarativeDirRel, - )}. Regenerate from database? This will overwrite existing files.`, - false, - ); - if (!ok) { - yield* output.raw("Skipped generating declarative schema.\n", "stderr"); - return; - } - } - const hasMigrations = yield* hasMigrationFiles(fs, path, migrationsDir); - // Only when migrations exist, resolve the ref (config `project_id` → `.temp/project-ref`) - // and record it for the post-run cache finalizer, so smart generate in a linked workdir - // caches the project regardless of which target the user picks. - let linkedRef = Option.none<string>(); - if (hasMigrations) { - // Only decides whether to offer the linked choice, so swallow a broken - // `.temp/project-ref` here (omit the choice) rather than aborting; the explicit - // `--linked` branch above still propagates a real failure. - linkedRef = Option.isSome(cliSettings.projectId) - ? cliSettings.projectId - : yield* readProjectRefFile(fs, path, cliSettings.workdir).pipe( - Effect.orElseSucceed(() => Option.none<string>()), - ); - if (Option.isSome(linkedRef)) { - yield* Ref.set(linkedProjectRef, linkedRef.value); - } - } - target = yield* resolveSmartTargetEndpoint( - flags, - local, - hasMigrations, - fs, - path, - cliSettings.workdir, - linkedRef, - (yield* DeclarativeSeam).ensureLocalPostgresImageCurrent, - ); - overwrite = true; - } + const endpoint = + target.kind === "local" + ? yield* prepareLocalTargetEndpoint(Effect.succeed(flags.reset), flags.password) + : yield* resolveTargetEndpoint(target, flags.password, true); - const result = yield* generateDeclarativeOutput(run, target); + const result = yield* generateDeclarativeOutput(run, endpoint); - if (!overwrite && (yield* confirmOverwriteHasFiles(fs, declarativeDir))) { + if (!flags.overwrite && (yield* confirmOverwriteHasFiles(fs, declarativeDir))) { // `--yes`/`SUPABASE_YES` auto-confirms, but still echoes the `<label> [y/N] y` stderr line // via `promptYesNo` rather than skipping it. const ok = yield* promptYesNo( @@ -256,15 +140,12 @@ export const dbSchemaDeclarativeGenerate = Effect.fn("db.schema.declarative.gene } const written = yield* writeDeclarativeSchemas(fs, path, declarativeDir, result); - // The overwrite prompts above promise existing files may be deleted, but the - // next writer only prunes what an export manifest claimed — say so when a - // manifest-less directory kept files the export did not replace. + // The overwrite prompt promises existing files may be deleted, but the writer only prunes + // what an export manifest claimed, so say so when a manifest-less directory kept files. yield* warnPreservedUnmanagedDeclarativeFiles(declarativeDirRel, written); yield* output.raw(declarativeSchemaWrittenLine(declarativeDirRel), "stderr"); }).pipe( - // Writes the linked-project cache for any resolved ref, on success and failure. Only - // explicit `--linked` resolves a ref here; the cache layer no-ops when the file exists, the - // token is missing, or the GET is non-200. + // The cache layer no-ops when the file exists, the token is missing, or the GET is non-200. Effect.ensuring( Effect.flatMap(Ref.get(linkedProjectRef), (ref) => ref !== undefined ? linkedProjectCache.cache(ref) : Effect.void, @@ -274,19 +155,9 @@ export const dbSchemaDeclarativeGenerate = Effect.fn("db.schema.declarative.gene ); }); -const hasDeclarativeFiles = Effect.fnUntraced(function* (fs: FileSystem.FileSystem, dir: string) { - const exists = yield* fs.exists(dir).pipe(Effect.orElseSucceed(() => false)); - if (!exists) return false; - const entries = yield* fs - .readDirectory(dir) - .pipe(Effect.orElseSucceed((): ReadonlyArray<string> => [])); - return entries.length > 0; -}); - -// The overwrite-confirmation guard. Unlike the smart-mode `hasDeclarativeFiles` above (which -// swallows read errors), an unreadable-but-existing declarative dir must abort here rather than -// read as "empty" and get silently overwritten by `writeDeclarativeSchemas`; only a not-exist -// directory means "no confirmation needed", so let any other `PlatformError` propagate unwrapped. +// An unreadable-but-existing declarative dir must abort rather than read as "empty" and get +// silently overwritten by `writeDeclarativeSchemas`; only a not-exist directory means "no +// confirmation needed", so any other `PlatformError` propagates unwrapped. const confirmOverwriteHasFiles = Effect.fnUntraced(function* ( fs: FileSystem.FileSystem, dir: string, @@ -300,17 +171,3 @@ const confirmOverwriteHasFiles = Effect.fnUntraced(function* ( ); return entries.length > 0; }); - -const hasMigrationFiles = Effect.fnUntraced(function* ( - fs: FileSystem.FileSystem, - path: Path.Path, - migrationsDir: string, -) { - // Smart-mode presence/prompt probe only: returns `false` on any error (unreadable dir, - // path-is-a-file, not-exist, …), so generate continues into the no-migrations local flow. The - // real diff path keeps `listLocalMigrations`'s hard error behavior instead. - const migrations = yield* listLocalMigrations(fs, path, migrationsDir).pipe( - Effect.orElseSucceed((): ReadonlyArray<string> => []), - ); - return migrations.length > 0; -}); 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 6ecce6f456..26c550e968 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 @@ -47,6 +47,7 @@ import { configValuesLayer } from "../../../../../../tests/helpers/config-values import { VALID_REF, VALID_TOKEN, + buildTestRuntime, mockCommandSettings, mockLinkedProjectCacheTracked, mockLocalDockerEngineUnavailableLayer, @@ -70,8 +71,13 @@ import { CommandPlatformApiFactory } from "../../../../../auth/command-platform- import { dockerRunLayer } from "../../../../../command-internal/docker-run.layer.ts"; import { stackBackendLayer } from "../../../../../command-internal/stack-backend.ts"; import { StackApi } from "../../../../../command-internal/stack-api.ts"; -import { DbPasswordFlagsError } from "../../../../../command-internal/db-config.errors.ts"; import { DbConfigResolver } from "../../../../../command-internal/db-config.service.ts"; +import type { + DbConnectionInput, + ResolvedDbConnection, +} from "../../../../../command-internal/db-config.types.ts"; +import { TargetPasswordConflictError } from "../../../../../command-internal/target/target.errors.ts"; +import { TargetPolicy } from "../../../../../command-internal/target/target-policy.ts"; import { type DbSession, DbConnection, @@ -89,10 +95,8 @@ interface SetupOpts { yes?: boolean; stdinIsTty?: boolean; promptConfirmResponses?: ReadonlyArray<boolean>; - promptSelectResponses?: ReadonlyArray<string>; - promptTextResponses?: ReadonlyArray<string>; /** - * Makes the local-reset prompt's `resetLocalDatabase` fail immediately + * Makes `--reset`'s `resetLocalDatabase` fail immediately * with `ResetLocalDbNotRunningError` (the local `db` container reports as * not running) instead of completing a real recreate. */ @@ -212,16 +216,13 @@ function generateStackApi(workdir: string) { function setup(workdir: string, opts: SetupOpts = {}) { const out = mockOutput({ promptConfirmResponses: opts.promptConfirmResponses, - promptSelectResponses: opts.promptSelectResponses, - promptTextResponses: opts.promptTextResponses, }); const telemetry = mockTelemetryStateTracked(); const cache = mockLinkedProjectCacheTracked(); const localPostgresImageChecks: Array<true> = []; let ensureStartedCalls = 0; const platformApi = mockCommandPlatformApiService({}); - // Backs `resetLocalDatabase`'s real, native container-recreate, reached when the smart-target - // local-reset prompt is confirmed. + // Backs `resetLocalDatabase`'s real, native container-recreate, reached by `--reset`. const child = mockContainerCliSpawner( defaultLocalResetRoute("test", { running: opts.resetShouldFail !== true }), ); @@ -295,34 +296,40 @@ function setup(workdir: string, opts: SetupOpts = {}) { }), }), ); - const resolverCalls: unknown[] = []; + const resolverCalls: DbConnectionInput[] = []; const resolver = Layer.succeed(DbConfigResolver, { - resolve: (flags) => { - resolverCalls.push(flags); - if (flags.connType === "local") { - return Effect.succeed({ + resolve: () => Effect.die("resolve is not used by this test"), + resolveConnection: (input) => + Effect.sync(() => { + resolverCalls.push(input); + const { target } = input; + if (target.kind === "local") { + return { + conn: { + host: "127.0.0.1", + port: opts.stackBackend === true ? STACK_GENERATE_PORT : 54322, + user: "postgres", + password: opts.stackBackend === true ? "stack-secret" : "postgres", + database: "postgres", + }, + isLocal: true, + ref: Option.none(), + target, + } satisfies ResolvedDbConnection; + } + return { conn: { - host: "127.0.0.1", - port: 54329, + host: "db.remote", + port: 5432, user: "postgres", - password: "stack-secret", + password: "x", database: "postgres", }, - isLocal: true, - }); - } - return Effect.succeed({ - conn: { - host: "db.remote", - port: 5432, - user: "postgres", - password: "x", - database: "postgres", - }, - isLocal: false, - }); - }, - resolveConnection: () => Effect.die("resolveConnection is not used by this test"), + isLocal: false, + ref: target.kind === "linked" ? Option.some(target.ref) : Option.none(), + target, + } satisfies ResolvedDbConnection; + }), resolvePoolerFallback: () => Effect.succeed(Option.none()), }); const runtimeInfo = mockRuntimeInfo({ platform: "linux" }); @@ -338,7 +345,34 @@ function setup(workdir: string, opts: SetupOpts = {}) { Layer.provide(processControl.layer), ); const projectHttp = recordingProjectHttpClient(VALID_REF); + const cliSettings = mockCommandSettings({ + workdir, + projectId: opts.projectId ?? Option.some("test"), + }); + const tty = mockTty({ stdinIsTty: opts.stdinIsTty ?? false, stdoutIsTty: false }); + const stdin = mockStdin(opts.stdinIsTty ?? false); + const linkedProjectCache = + opts.realLayers === true + ? realLinkedProjectCache({ + httpClient: projectHttp.layer, + cliSettings: mockCommandSettings({ workdir }), + }) + : cache.layer; + const runtime = buildTestRuntime({ + out, + api: platformApi, + cliSettings, + tty, + stdin, + processControl, + runtimeInfo, + telemetry: telemetry.layer, + linkedProjectCache, + cliArgs: opts.args ?? ["db", "schema", "declarative", "generate"], + }); const layer = Layer.mergeAll( + runtime, + Layer.succeed(TargetPolicy, { group: "selectable", consent: "none" }), out.layer, configValuesLayer({ output: out.layer, @@ -348,12 +382,7 @@ function setup(workdir: string, opts: SetupOpts = {}) { }), }), telemetry.layer, - opts.realLayers === true - ? realLinkedProjectCache({ - httpClient: projectHttp.layer, - cliSettings: mockCommandSettings({ workdir }), - }) - : cache.layer, + linkedProjectCache, seam, engine, mockLocalDockerEngineUnavailableLayer, @@ -365,9 +394,9 @@ function setup(workdir: string, opts: SetupOpts = {}) { }) : resolver, dbConn, - mockCommandSettings({ workdir, projectId: opts.projectId ?? Option.some("test") }), - mockTty({ stdinIsTty: opts.stdinIsTty ?? false, stdoutIsTty: false }), - mockStdin(opts.stdinIsTty ?? false), + cliSettings, + tty, + stdin, experimentalFlag, cliArgs, Layer.succeed(YesFlag, opts.yes ?? false), @@ -419,8 +448,9 @@ const flags = ( reset: over.reset ?? false, schema: over.schema ?? [], dbUrl: over.dbUrl ?? Option.none(), - linked: over.linked ?? Option.none(), - local: over.local ?? Option.none(), + linked: over.linked ?? false, + local: over.local ?? false, + projectRef: over.projectRef ?? Option.none(), password: over.password ?? Option.none(), }); @@ -435,15 +465,6 @@ const disablePgDelta = (workdir: string) => ); }); -const seedMigration = (workdir: string) => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const migrationsDir = path.join(workdir, "supabase", "migrations"); - yield* fs.makeDirectory(migrationsDir, { recursive: true }); - yield* fs.writeFileString(path.join(migrationsDir, "0001_init.sql"), "select 1;"); - }); - describe("db schema declarative generate integration", () => { const tmp = useTempWorkdir(); @@ -452,7 +473,7 @@ describe("db schema declarative generate integration", () => { return Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; - yield* dbSchemaDeclarativeGenerate(flags({ local: Option.some(true) })); + yield* dbSchemaDeclarativeGenerate(flags({ local: true })); expect( yield* fs.exists( path.join(tmp.current, "supabase", "schemas", "public", "tables", "players.sql"), @@ -465,37 +486,32 @@ describe("db schema declarative generate integration", () => { const { layer } = setup(tmp.current, { experimental: false }); return Effect.gen(function* () { yield* disablePgDelta(tmp.current); - const error = yield* Effect.flip( - dbSchemaDeclarativeGenerate(flags({ local: Option.some(true) })), - ); + const error = yield* Effect.flip(dbSchemaDeclarativeGenerate(flags({ local: true }))); expect(error).toBeInstanceOf(DeclarativeNotEnabledError); }).pipe(Effect.provide(layer)); }); - it.effect("--local --linked with --experimental fails with the mutex error", () => { + it.effect("--local --linked with --experimental fails with the selector conflict", () => { const { layer } = setup(tmp.current, { experimental: true }); return Effect.gen(function* () { const error = yield* Effect.flip( - dbSchemaDeclarativeGenerate(flags({ local: Option.some(true), linked: Option.some(true) })), + dbSchemaDeclarativeGenerate(flags({ local: true, linked: true })), ); expect(error).toMatchObject({ - _tag: "DeclarativeMutuallyExclusiveFlagsError", - message: - "if any flags in the group [db-url linked local] are set none of the others can be; [linked local] were all set", + _tag: "TargetSelectorConflictError", + message: "Only one of --local, --linked and --db-url can be set; got --local and --linked.", }); }).pipe(Effect.provide(layer)); }); it.effect( - "--local --linked without --experimental fails with the gate error, not the mutex error", + "--local --linked without --experimental fails with the gate error, not the selector conflict", () => { const { layer } = setup(tmp.current, { experimental: false }); return Effect.gen(function* () { yield* disablePgDelta(tmp.current); const error = yield* Effect.flip( - dbSchemaDeclarativeGenerate( - flags({ local: Option.some(true), linked: Option.some(true) }), - ), + dbSchemaDeclarativeGenerate(flags({ local: true, linked: true })), ); expect(error).toBeInstanceOf(DeclarativeNotEnabledError); }).pipe(Effect.provide(layer)); @@ -503,23 +519,19 @@ describe("db schema declarative generate integration", () => { ); it.effect( - "--local --linked with SUPABASE_EXPERIMENTAL env (no --experimental flag) fails with the mutex error", + "--local --linked with SUPABASE_EXPERIMENTAL env (no --experimental flag) fails with the selector conflict", () => { const { layer } = setup(tmp.current, { experimental: false }); const ENV = "SUPABASE_EXPERIMENTAL"; return Effect.gen(function* () { const error = yield* withConfigEnv( { [ENV]: "1" }, - Effect.flip( - dbSchemaDeclarativeGenerate( - flags({ local: Option.some(true), linked: Option.some(true) }), - ), - ), + Effect.flip(dbSchemaDeclarativeGenerate(flags({ local: true, linked: true }))), ); expect(error).toMatchObject({ - _tag: "DeclarativeMutuallyExclusiveFlagsError", + _tag: "TargetSelectorConflictError", message: - "if any flags in the group [db-url linked local] are set none of the others can be; [linked local] were all set", + "Only one of --local, --linked and --db-url can be set; got --local and --linked.", }); }).pipe(Effect.provide(layer)); }, @@ -537,7 +549,7 @@ describe("db schema declarative generate integration", () => { yield* disablePgDelta(tmp.current); const error = yield* withConfigEnv( { [ENV]: "1" }, - Effect.flip(dbSchemaDeclarativeGenerate(flags({ local: Option.some(true) }))), + Effect.flip(dbSchemaDeclarativeGenerate(flags({ local: true }))), ); expect(error).toBeInstanceOf(DeclarativeNotEnabledError); }).pipe(Effect.provide(layer)); @@ -545,7 +557,7 @@ describe("db schema declarative generate integration", () => { ); it.effect( - "--local --linked with SUPABASE_EXPERIMENTAL set only in the project .env fails with the mutex error", + "--local --linked with SUPABASE_EXPERIMENTAL set only in the project .env fails with the selector conflict", () => { const { layer } = setup(tmp.current, { experimental: false }); return withEmptyConfigEnv( @@ -558,14 +570,12 @@ describe("db schema declarative generate integration", () => { "SUPABASE_EXPERIMENTAL=true\n", ); const error = yield* Effect.flip( - dbSchemaDeclarativeGenerate( - flags({ local: Option.some(true), linked: Option.some(true) }), - ), + dbSchemaDeclarativeGenerate(flags({ local: true, linked: true })), ); expect(error).toMatchObject({ - _tag: "DeclarativeMutuallyExclusiveFlagsError", + _tag: "TargetSelectorConflictError", message: - "if any flags in the group [db-url linked local] are set none of the others can be; [linked local] were all set", + "Only one of --local, --linked and --db-url can be set; got --local and --linked.", }); }).pipe(Effect.provide(layer)), ); @@ -585,7 +595,7 @@ describe("db schema declarative generate integration", () => { path.join(tmp.current, "supabase", "database", "public.sql"), "create table a();", ); - yield* dbSchemaDeclarativeGenerate(flags({ local: Option.some(true) })); + yield* dbSchemaDeclarativeGenerate(flags({ local: true })); expect(stripAnsi(s.out.stderrText)).toContain( "WARNING: found declarative schema files in supabase/database, but the default declarative directory is now supabase/schemas.", ); @@ -598,7 +608,7 @@ describe("db schema declarative generate integration", () => { return Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; - yield* dbSchemaDeclarativeGenerate(flags({ local: Option.some(true) })); + yield* dbSchemaDeclarativeGenerate(flags({ local: true })); expect(s.engineExportCalls[0]?.targetRef).toContain( "postgresql://postgres:postgres@127.0.0.1:54322", ); @@ -622,7 +632,7 @@ describe("db schema declarative generate integration", () => { () => { const s = setup(tmp.current, { experimental: true, stackBackend: true }); return Effect.gen(function* () { - yield* dbSchemaDeclarativeGenerate(flags({ local: Option.some(true) })); + yield* dbSchemaDeclarativeGenerate(flags({ local: true })); expect(s.engineExportCalls[0]?.targetRef).toContain( "postgresql://postgres:stack-secret@127.0.0.1:54329", ); @@ -631,6 +641,97 @@ describe("db schema declarative generate integration", () => { }, ); + it.effect("defaults to the local database with no target flag and no prompt", () => { + const s = setup(tmp.current, { experimental: true, stdinIsTty: false, yes: false }); + return Effect.gen(function* () { + yield* dbSchemaDeclarativeGenerate(flags()); + expect(s.resolverCalls.map((call) => call.target.kind)).toEqual(["local"]); + expect(s.engineExportCalls[0]?.targetRef).toContain("@127.0.0.1:54322"); + expect(s.ensureStartedCalls).toBe(1); + expect(localResetRemovedContainers(s.child.spawned)).toEqual([]); + expect(s.out.promptSelectCalls).toEqual([]); + }).pipe(Effect.provide(s.layer)); + }); + + it.effect("selects the linked project when the workspace target is remote", () => { + const s = setup(tmp.current, { experimental: true, projectId: Option.some(VALID_REF) }); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* fs.makeDirectory(path.join(tmp.current, "supabase"), { recursive: true }); + yield* fs.writeFileString( + path.join(tmp.current, "supabase", "config.toml"), + 'project_id = "test"\ntarget = "remote"\n', + ); + yield* dbSchemaDeclarativeGenerate(flags()); + expect(s.resolverCalls.map((call) => call.target)).toEqual([ + expect.objectContaining({ kind: "linked", ref: VALID_REF }), + ]); + expect(s.ensureStartedCalls).toBe(0); + expect(s.engineExportCalls[0]?.projectRef).toBe(VALID_REF); + }).pipe(Effect.provide(s.layer)); + }); + + it.effect("--project-ref selects that project without a linked workdir", () => { + const s = setup(tmp.current, { experimental: true, projectId: Option.none() }); + return Effect.gen(function* () { + yield* dbSchemaDeclarativeGenerate(flags({ projectRef: Option.some(VALID_REF) })); + expect(s.resolverCalls.map((call) => call.target)).toEqual([ + expect.objectContaining({ kind: "linked", ref: VALID_REF, source: "flag" }), + ]); + expect(s.engineExportCalls[0]?.projectRef).toBe(VALID_REF); + expect(s.ensureStartedCalls).toBe(0); + }).pipe(Effect.provide(s.layer)); + }); + + it.effect("--reset recreates the local database before exporting", () => { + const s = setup(tmp.current, { experimental: true }); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* fs.makeDirectory(path.join(tmp.current, "supabase"), { recursive: true }); + yield* fs.writeFileString( + path.join(tmp.current, "supabase", "config.toml"), + 'project_id = "test"\n', + ); + yield* dbSchemaDeclarativeGenerate(flags({ reset: true })); + expect(localResetRemovedContainers(s.child.spawned)).toContain("supabase_db_test"); + expect(localResetCreateArgs(s.child.spawned)).not.toBeUndefined(); + expect(s.out.rawChunks.some((c) => c.text.includes("Resetting local database"))).toBe(true); + expect(s.engineExportCalls).toHaveLength(1); + }).pipe(Effect.provide(s.layer)); + }); + + it.effect("--reset forwards --network-id to the local reset", () => { + const s = setup(tmp.current, { experimental: true, networkId: Option.some("my-net") }); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* fs.makeDirectory(path.join(tmp.current, "supabase"), { recursive: true }); + yield* fs.writeFileString( + path.join(tmp.current, "supabase", "config.toml"), + 'project_id = "test"\n', + ); + yield* dbSchemaDeclarativeGenerate(flags({ reset: true })); + const createArgs = localResetCreateArgs(s.child.spawned); + const networkIndex = createArgs?.indexOf("--network") ?? -1; + expect(networkIndex).toBeGreaterThanOrEqual(0); + expect(createArgs?.[networkIndex + 1]).toBe("my-net"); + }).pipe(Effect.provide(s.layer)); + }); + + it.effect("--reset propagates a reset failure instead of exiting the process", () => { + const s = setup(tmp.current, { experimental: true, resetShouldFail: true }); + return Effect.gen(function* () { + const error = yield* Effect.flip(dbSchemaDeclarativeGenerate(flags({ reset: true }))); + expect(error).toMatchObject({ + message: "database reset failed: supabase start is not running.", + }); + expect(localResetRemovedContainers(s.child.spawned)).toEqual([]); + expect(s.engineExportCalls).toEqual([]); + }).pipe(Effect.provide(s.layer)); + }); + it.effect( "--output-dir writes a complete export relative to the project without activating it", () => { @@ -655,7 +756,7 @@ describe("db schema declarative generate integration", () => { yield* fs.writeFileString(configPath, config); const destination = path.join("supabase", "database-next"); yield* dbSchemaDeclarativeGenerate( - flags({ local: Option.some(true), outputDir: Option.some(destination) }), + flags({ local: true, outputDir: Option.some(destination) }), ); expect( @@ -689,17 +790,17 @@ describe("db schema declarative generate integration", () => { ); for (const [label, target] of [ - ["--local", { local: Option.some(true) }], + ["--local", { local: true }], ["--db-url", { dbUrl: Option.some("postgresql://x") }], ] as const) { it.effect(`rejects --password combined with ${label}`, () => { - const s = setup(tmp.current, { experimental: true }); + const s = setup(tmp.current, { experimental: true, realLayers: true }); return Effect.gen(function* () { const error = yield* dbSchemaDeclarativeGenerate( flags({ ...target, password: Option.some("pw") }), ).pipe(Effect.flip); - expect(error).toBeInstanceOf(DbPasswordFlagsError); - expect(s.localPostgresImageChecks).toEqual([]); + expect(error).toBeInstanceOf(TargetPasswordConflictError); + expect(s.engineExportCalls).toEqual([]); }).pipe(Effect.provide(s.layer)); }); } @@ -716,7 +817,7 @@ describe("db schema declarative generate integration", () => { yield* fs.makeDirectory(destination, { recursive: true }); yield* fs.writeFileString(path.join(destination, "keep.sql"), "select 'keep';"); yield* dbSchemaDeclarativeGenerate( - flags({ local: Option.some(true), outputDir: Option.some(destination) }), + flags({ local: true, outputDir: Option.some(destination) }), ); expect(yield* fs.readFileString(path.join(destination, "keep.sql"))).toBe("select 'keep';"); expect(yield* fs.exists(path.join(destination, ".pgdelta-export.json"))).toBe(false); @@ -736,7 +837,7 @@ describe("db schema declarative generate integration", () => { yield* Effect.gen(function* () { for (const output of ["", ".", "..", path.dirname(projectDir)]) { const error = yield* dbSchemaDeclarativeGenerate( - flags({ local: Option.some(true), outputDir: Option.some(output), overwrite: true }), + flags({ local: true, outputDir: Option.some(output), overwrite: true }), ).pipe(Effect.flip); expect(error).toMatchObject({ _tag: "DeclarativeWriteError", @@ -756,7 +857,7 @@ describe("db schema declarative generate integration", () => { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; yield* dbSchemaDeclarativeGenerate( - flags({ local: Option.some(true), outputDir: Option.some("staged-schema") }), + flags({ local: true, outputDir: Option.some("staged-schema") }), ); expect( yield* fs.exists( @@ -770,9 +871,7 @@ describe("db schema declarative generate integration", () => { it.effect("explicit --local checks the local Postgres image before generating", () => { const s = setup(tmp.current, { experimental: true, staleLocalImage: true }); return Effect.gen(function* () { - const error = yield* Effect.flip( - dbSchemaDeclarativeGenerate(flags({ local: Option.some(true) })), - ); + const error = yield* Effect.flip(dbSchemaDeclarativeGenerate(flags({ local: true }))); expect(error).toMatchObject({ _tag: "DeclarativeShadowDbError", message: "local Postgres container image is stale", @@ -796,7 +895,7 @@ describe("db schema declarative generate integration", () => { path.join(tmp.current, "supabase", "schemas", "existing.sql"), "create table x ();", ); - yield* dbSchemaDeclarativeGenerate(flags({ local: Option.some(true) })); + yield* dbSchemaDeclarativeGenerate(flags({ local: true })); const written = yield* fs.readFileString( path.join(tmp.current, "supabase", "schemas", "public", "tables", "players.sql"), ); @@ -812,9 +911,7 @@ describe("db schema declarative generate integration", () => { const path = yield* Path.Path; yield* fs.makeDirectory(path.join(tmp.current, "supabase"), { recursive: true }); yield* fs.writeFileString(path.join(tmp.current, "supabase", "schemas"), "not a directory"); - const exit = yield* Effect.exit( - dbSchemaDeclarativeGenerate(flags({ local: Option.some(true) })), - ); + const exit = yield* Effect.exit(dbSchemaDeclarativeGenerate(flags({ local: true }))); expect(Exit.isFailure(exit)).toBe(true); expect(yield* fs.readFileString(path.join(tmp.current, "supabase", "schemas"))).toBe( "not a directory", @@ -851,7 +948,7 @@ describe("db schema declarative generate integration", () => { "", ].join("\n"), ); - yield* dbSchemaDeclarativeGenerate(flags({ local: Option.some(true) })); + yield* dbSchemaDeclarativeGenerate(flags({ local: true })); expect(yield* fs.exists(path.join(absSchema, "public", "tables", "players.sql"))).toBe(true); expect( yield* fs.readFileString(path.join(absSchema, "public", "tables", "players.sql")), @@ -885,7 +982,7 @@ describe("db schema declarative generate integration", () => { "", ].join("\n"), ); - yield* dbSchemaDeclarativeGenerate(flags({ linked: Option.some(true) })); + yield* dbSchemaDeclarativeGenerate(flags({ linked: true })); const written = yield* fs.readFileString( path.join(tmp.current, "supabase", "remote_schema", "public", "tables", "players.sql"), ); @@ -894,35 +991,23 @@ describe("db schema declarative generate integration", () => { }).pipe(Effect.provide(s.layer)); }); - it.effect("--linked=false is an explicit linked target", () => { - // Non-interactive (no TTY, no --yes), so a smart-mode fall-through would fail with "specify - // a target" instead. - const s = setup(tmp.current, { experimental: true, stdinIsTty: false }); - return Effect.gen(function* () { - const exit = yield* Effect.exit( - dbSchemaDeclarativeGenerate(flags({ linked: Option.some(false) })), - ); - expect(Exit.isSuccess(exit)).toBe(true); - expect(s.resolverCalls).toContainEqual(expect.objectContaining({ connType: "linked" })); - }).pipe(Effect.provide(s.layer)); - }); - it.effect("caches the linked project after generate --linked", () => { const ref = "abcdefghijklmnopqrst"; const s = setup(tmp.current, { experimental: true, projectId: Option.some(ref) }); return Effect.gen(function* () { - yield* dbSchemaDeclarativeGenerate(flags({ linked: Option.some(true) })); + yield* dbSchemaDeclarativeGenerate(flags({ linked: true })); expect(s.cache.cached).toBe(true); + expect(s.cache.cacheCount).toBe(1); + expect(s.telemetry.flushCount).toBe(1); }).pipe(Effect.provide(s.layer)); }); - it.effect("--local=false selects the local target but does NOT auto-start the stack", () => { - const s = setup(tmp.current, { experimental: true }); + it.effect("does not cache the linked project for a local run in a linked workdir", () => { + const s = setup(tmp.current, { experimental: true, projectId: Option.some(VALID_REF) }); return Effect.gen(function* () { - yield* dbSchemaDeclarativeGenerate(flags({ local: Option.some(false) })); - expect(s.engineExportCalls).toHaveLength(1); - expect(s.ensureStartedCalls).toBe(0); - expect(s.localPostgresImageChecks).toHaveLength(1); + yield* dbSchemaDeclarativeGenerate(flags()); + expect(s.cache.cached).toBe(false); + expect(s.telemetry.flushCount).toBe(1); }).pipe(Effect.provide(s.layer)); }); @@ -948,66 +1033,37 @@ describe("db schema declarative generate integration", () => { "", ].join("\n"), ); - const error = yield* Effect.flip( - dbSchemaDeclarativeGenerate(flags({ linked: Option.some(true) })), - ); + const error = yield* Effect.flip(dbSchemaDeclarativeGenerate(flags({ linked: true }))); expect(error).toBeInstanceOf(DeclarativeNotEnabledError); }).pipe(Effect.provide(s.layer)); }, ); - it.effect("smart mode: non-TTY without --yes fails with the target hint", () => { - const s = setup(tmp.current, { experimental: true, stdinIsTty: false, yes: false }); - return Effect.gen(function* () { - const error = yield* Effect.flip(dbSchemaDeclarativeGenerate(flags())); - expect(error).toMatchObject({ - message: expect.stringContaining("in non-interactive mode, specify a target"), + it.effect( + "existing declarative files without --overwrite are skipped when the prompt is declined", + () => { + const s = setup(tmp.current, { + experimental: true, + stdinIsTty: true, + promptConfirmResponses: [false], }); - }).pipe(Effect.provide(s.layer)); - }); - - it.effect("smart mode: existing files + decline regenerate → skips", () => { - const s = setup(tmp.current, { - experimental: true, - stdinIsTty: true, - promptConfirmResponses: [false], - }); - return Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const declDir = path.join(tmp.current, "supabase", "schemas"); - yield* fs.makeDirectory(declDir, { recursive: true }); - yield* fs.writeFileString(path.join(declDir, "existing.sql"), "-- existing"); - yield* dbSchemaDeclarativeGenerate(flags()); - expect(s.engineExportCalls).toEqual([]); - expect( - s.out.rawChunks.some((c) => c.text.includes("Skipped generating declarative schema")), - ).toBe(true); - }).pipe(Effect.provide(s.layer)); - }); - - it.effect("smart mode: --yes regenerates over existing files without prompting", () => { - // No migrations, so the smart target resolves to local without a further prompt. No - // `promptConfirmResponses` are queued, so a prompt would throw. - const s = setup(tmp.current, { experimental: true, stdinIsTty: false, yes: true }); - return Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const declDir = path.join(tmp.current, "supabase", "schemas"); - yield* fs.makeDirectory(declDir, { recursive: true }); - yield* fs.writeFileString(path.join(declDir, "existing.sql"), "-- existing"); - yield* dbSchemaDeclarativeGenerate(flags()); - expect(s.engineExportCalls).toHaveLength(1); - expect(stripAnsi(s.out.stderrText)).toContain( - `Declarative schema already exists at ${path.join("supabase", "schemas")}. Regenerate from database? This will overwrite existing files. [y/N] y\n`, - ); - expect( - s.out.rawChunks.some((c) => c.text.includes("Skipped generating declarative schema")), - ).toBe(false); - }).pipe(Effect.provide(s.layer)); - }); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const declDir = path.join(tmp.current, "supabase", "schemas"); + yield* fs.makeDirectory(declDir, { recursive: true }); + yield* fs.writeFileString(path.join(declDir, "existing.sql"), "-- existing"); + yield* dbSchemaDeclarativeGenerate(flags()); + expect(yield* fs.readFileString(path.join(declDir, "existing.sql"))).toBe("-- existing"); + expect(yield* fs.exists(path.join(declDir, "public", "tables", "players.sql"))).toBe(false); + expect( + s.out.rawChunks.some((c) => c.text.includes("Skipped writing declarative schema")), + ).toBe(true); + }).pipe(Effect.provide(s.layer)); + }, + ); - it.effect("smart mode: SUPABASE_YES=1 regenerates over existing files like --yes", () => { + it.effect("SUPABASE_YES=1 overwrites existing declarative files like --yes", () => { const s = setup(tmp.current, { experimental: true, stdinIsTty: false, yes: false }); return withConfigEnv( { SUPABASE_YES: "1" }, @@ -1020,8 +1076,9 @@ describe("db schema declarative generate integration", () => { yield* dbSchemaDeclarativeGenerate(flags()); expect(s.engineExportCalls).toHaveLength(1); expect(stripAnsi(s.out.stderrText)).toContain( - `Declarative schema already exists at ${path.join("supabase", "schemas")}. Regenerate from database? This will overwrite existing files. [y/N] y\n`, + "Overwrite declarative schema? Existing files may be deleted. [y/N] y\n", ); + expect(yield* fs.exists(path.join(declDir, "public", "tables", "players.sql"))).toBe(true); }).pipe(Effect.provide(s.layer)), ); }); @@ -1030,7 +1087,7 @@ describe("db schema declarative generate integration", () => { const s = setup(tmp.current, { experimental: true }); return Effect.gen(function* () { yield* dbSchemaDeclarativeGenerate( - flags({ local: Option.some(true), noCache: true, strictCoverage: true }), + flags({ local: true, noCache: true, strictCoverage: true }), ); expect(s.engineExportCalls).toEqual([expect.objectContaining({ strictCoverage: true })]); }).pipe(Effect.provide(s.layer)); @@ -1042,7 +1099,7 @@ describe("db schema declarative generate integration", () => { ])('passes PGDELTA_DEBUG="$value" through to the engine export', ({ value, debug }) => { const s = setup(tmp.current, { experimental: true }); return Effect.gen(function* () { - yield* dbSchemaDeclarativeGenerate(flags({ local: Option.some(true) })); + yield* dbSchemaDeclarativeGenerate(flags({ local: true })); expect(s.engineExportCalls.map((call) => call.debug)).toEqual([debug]); }).pipe(Effect.provide(s.layer), (body) => withConfigEnv({ PGDELTA_DEBUG: value }, body)); }); @@ -1050,9 +1107,7 @@ describe("db schema declarative generate integration", () => { it.effect("fails generate when the engine export fails", () => { const s = setup(tmp.current, { experimental: true, exportFails: true }); return Effect.gen(function* () { - const error = yield* dbSchemaDeclarativeGenerate(flags({ local: Option.some(true) })).pipe( - Effect.flip, - ); + const error = yield* dbSchemaDeclarativeGenerate(flags({ local: true })).pipe(Effect.flip); expect(error).toMatchObject({ _tag: "PgDeltaEngineError", message: "declarative export failed", @@ -1063,238 +1118,12 @@ describe("db schema declarative generate integration", () => { }).pipe(Effect.provide(s.layer)); }); - it.effect("smart mode: propagates a reset failure instead of exiting the process", () => { - const s = setup(tmp.current, { - experimental: true, - stdinIsTty: true, - promptSelectResponses: ["local"], - resetShouldFail: true, - }); - return Effect.gen(function* () { - yield* seedMigration(tmp.current); - const error = yield* Effect.flip(dbSchemaDeclarativeGenerate(flags({ reset: true }))); - expect(error).toMatchObject({ - message: "database reset failed: supabase start is not running.", - }); - expect(localResetRemovedContainers(s.child.spawned)).toEqual([]); - }).pipe(Effect.provide(s.layer)); - }); - - it.effect("smart mode: offers and resolves the linked project when the workdir is linked", () => { - // A valid 20-char ref is required for the linked choice to show. - const s = setup(tmp.current, { - experimental: true, - stdinIsTty: true, - projectId: Option.some("abcdefghijklmnopqrst"), - promptSelectResponses: ["linked"], - }); - return Effect.gen(function* () { - yield* seedMigration(tmp.current); - yield* dbSchemaDeclarativeGenerate(flags()); - const options = s.out.promptSelectCalls[0]?.options ?? []; - expect(options.map((o) => o.value)).toEqual(["local", "linked", "custom"]); - expect(s.resolverCalls).toContainEqual(expect.objectContaining({ connType: "linked" })); - }).pipe(Effect.provide(s.layer)); - }); - - it.effect("smart mode: local target checks the local Postgres image before generating", () => { - const s = setup(tmp.current, { - experimental: true, - stdinIsTty: true, - staleLocalImage: true, - promptSelectResponses: ["local"], - }); - return Effect.gen(function* () { - yield* seedMigration(tmp.current); - const error = yield* Effect.flip(dbSchemaDeclarativeGenerate(flags())); - expect(error).toMatchObject({ - _tag: "DeclarativeShadowDbError", - message: "local Postgres container image is stale", - }); - expect(s.localPostgresImageChecks).toHaveLength(1); - expect(s.engineExportCalls).toEqual([]); - }).pipe(Effect.provide(s.layer)); - }); - - it.effect("smart mode: caches the linked project even when the user picks local", () => { - const s = setup(tmp.current, { - experimental: true, - stdinIsTty: true, - yes: true, - projectId: Option.some("abcdefghijklmnopqrst"), - promptSelectResponses: ["local"], - }); - return Effect.gen(function* () { - yield* seedMigration(tmp.current); - yield* dbSchemaDeclarativeGenerate(flags()); - expect(s.cache.cached).toBe(true); - // The in-process local reset's own body never touches the linked-project cache or - // telemetry, so the outer command's single `Effect.ensuring` finalizer must still fire - // exactly once each, not twice. - expect(s.cache.cacheCount).toBe(1); - expect(s.telemetry.flushCount).toBe(1); - }).pipe(Effect.provide(s.layer)); - }); - - it.effect("smart mode: does not cache when no migrations exist", () => { - const s = setup(tmp.current, { - experimental: true, - yes: true, - projectId: Option.some("abcdefghijklmnopqrst"), - }); - return Effect.gen(function* () { - yield* dbSchemaDeclarativeGenerate(flags()); - expect(s.cache.cached).toBe(false); - }).pipe(Effect.provide(s.layer)); - }); - - it.effect("smart mode: hides the linked choice when the workdir is not linked", () => { - const s = setup(tmp.current, { - experimental: true, - stdinIsTty: true, - projectId: Option.none(), - promptSelectResponses: ["local"], - }); - return Effect.gen(function* () { - yield* seedMigration(tmp.current); - yield* dbSchemaDeclarativeGenerate(flags()); - const options = s.out.promptSelectCalls[0]?.options ?? []; - expect(options.map((o) => o.value)).toEqual(["local", "custom"]); - }).pipe(Effect.provide(s.layer)); - }); - - it.effect("smart mode: an unreadable migrations path is treated as no migrations", () => { - // Seeding `supabase/migrations` as a file makes the list fail with ENOTDIR. - const s = setup(tmp.current, { experimental: true, yes: true }); - return Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - yield* fs.makeDirectory(path.join(tmp.current, "supabase"), { recursive: true }); - yield* fs.writeFileString( - path.join(tmp.current, "supabase", "migrations"), - "not a directory", - ); - const exit = yield* Effect.exit(dbSchemaDeclarativeGenerate(flags())); - expect(Exit.isSuccess(exit)).toBe(true); - expect(s.ensureStartedCalls).toBe(1); - }).pipe(Effect.provide(s.layer)); - }); - - it.effect("smart mode: an unreadable ref file just omits the linked choice", () => { - // Seeding `.temp/project-ref` as a directory makes the read fail. - const s = setup(tmp.current, { - experimental: true, - stdinIsTty: true, - yes: true, - projectId: Option.none(), - promptSelectResponses: ["local"], - }); - return Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - yield* seedMigration(tmp.current); - yield* fs.makeDirectory(path.join(tmp.current, "supabase", ".temp", "project-ref"), { - recursive: true, - }); - const exit = yield* Effect.exit(dbSchemaDeclarativeGenerate(flags())); - expect(Exit.isSuccess(exit)).toBe(true); - expect((s.out.promptSelectCalls[0]?.options ?? []).map((o) => o.value)).toEqual([ - "local", - "custom", - ]); - expect(s.cache.cached).toBe(false); - }).pipe(Effect.provide(s.layer)); - }); - - it.effect("smart mode: --yes auto-resets the local database without prompting", () => { - // No `promptConfirmResponses` are supplied, so a prompt would throw. - // `resetLocalDatabase`'s container-recreate resolves its own project id from - // `@supabase/config`, independently of the mocked `CommandSettings.projectId` — pin it to - // "test" so the recreated container name matches the spawner route's assumption. - const s = setup(tmp.current, { - experimental: true, - stdinIsTty: true, - yes: true, - promptSelectResponses: ["local"], - }); - return Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - yield* seedMigration(tmp.current); - yield* fs.writeFileString( - path.join(tmp.current, "supabase", "config.toml"), - 'project_id = "test"\n', - ); - yield* dbSchemaDeclarativeGenerate(flags()); - expect(localResetRemovedContainers(s.child.spawned)).toContain("supabase_db_test"); - expect(localResetCreateArgs(s.child.spawned)).not.toBeUndefined(); - expect(s.out.rawChunks.some((c) => c.text.includes("Resetting local database"))).toBe(true); - }).pipe(Effect.provide(s.layer)); - }); - - it.effect("smart mode: forwards --network-id to the local reset", () => { - const s = setup(tmp.current, { - experimental: true, - stdinIsTty: true, - yes: true, - networkId: Option.some("my-net"), - promptSelectResponses: ["local"], - }); - return Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - yield* seedMigration(tmp.current); - yield* fs.writeFileString( - path.join(tmp.current, "supabase", "config.toml"), - 'project_id = "test"\n', - ); - yield* dbSchemaDeclarativeGenerate(flags()); - const createArgs = localResetCreateArgs(s.child.spawned); - const networkIndex = createArgs?.indexOf("--network") ?? -1; - expect(networkIndex).toBeGreaterThanOrEqual(0); - expect(createArgs?.[networkIndex + 1]).toBe("my-net"); - }).pipe(Effect.provide(s.layer)); - }); - - it.effect("smart mode: rejects a malformed custom database URL", () => { - const s = setup(tmp.current, { - experimental: true, - stdinIsTty: true, - promptSelectResponses: ["custom"], - promptTextResponses: ["not a url"], - }); - return Effect.gen(function* () { - yield* seedMigration(tmp.current); - const error = yield* Effect.flip(dbSchemaDeclarativeGenerate(flags())); - expect(error).toMatchObject({ - _tag: "DeclarativeInvalidDbUrlError", - message: "failed to parse connection string: not a url", - }); - }).pipe(Effect.provide(s.layer)); - }); - - it.effect("smart mode: normalizes a valid custom database URL before pg-delta", () => { - const s = setup(tmp.current, { - experimental: true, - stdinIsTty: true, - promptSelectResponses: ["custom"], - promptTextResponses: ["postgres://user:secret@db.example.com:5432/app"], - }); - return Effect.gen(function* () { - yield* seedMigration(tmp.current); - yield* dbSchemaDeclarativeGenerate(flags()); - expect(s.engineExportCalls[0]?.targetRef).toContain( - "@db.example.com:5432/app?connect_timeout=", - ); - }).pipe(Effect.provide(s.layer)); - }); - it.effect("writes the engine's export manifest alongside the declarative tree", () => { const s = setup(tmp.current, { experimental: true }); return Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; - yield* dbSchemaDeclarativeGenerate(flags({ local: Option.some(true) })); + yield* dbSchemaDeclarativeGenerate(flags({ local: true })); const manifest = yield* Schema.decodeEffect(Schema.fromJsonString(Schema.Unknown))( yield* fs.readFileString( path.join(tmp.current, "supabase", "schemas", ".pgdelta-export.json"), @@ -1315,6 +1144,7 @@ describe("db schema declarative generate permissions through the real resolver a const asLines = (requests: ReadonlyArray<StubbedRequest>) => requests.map(({ method, path }) => `${method} ${path}`); const loginRoleRequest = `POST /v1/projects/${VALID_REF}/cli/login-role`; + const poolerRequest = `GET /v1/projects/${VALID_REF}/config/database/pooler`; const cacheRequest = `GET https://api.supabase.com/v1/projects/${VALID_REF}`; const cacheCalls = (calls: ReadonlyArray<{ readonly method: string; readonly url: string }>) => calls.map(({ method, url }) => `${method} ${url}`); @@ -1332,9 +1162,7 @@ describe("db schema declarative generate permissions through the real resolver a yield* reachableDirectHost; yield* saveLinkedRef; yield* stubManagementApi(requests, serveLinkedDatabase(VALID_REF)); - yield* dbSchemaDeclarativeGenerate(flags({ linked: Option.some(true) })).pipe( - Effect.provide(s.layer), - ); + yield* dbSchemaDeclarativeGenerate(flags({ linked: true })).pipe(Effect.provide(s.layer)); expect(s.engineExportCalls).toHaveLength(1); expect(asLines(requests)).toEqual([loginRoleRequest]); expect(cacheCalls(s.projectHttp.calls)).toEqual([cacheRequest]); @@ -1360,7 +1188,7 @@ describe("db schema declarative generate permissions through the real resolver a yield* saveLinkedRef; yield* stubManagementApi(requests, serveLinkedDatabase(VALID_REF)); yield* dbSchemaDeclarativeGenerate( - flags({ linked: Option.some(true), password: Option.some("secret") }), + flags({ linked: true, password: Option.some("secret") }), ).pipe(Effect.provide(s.layer)); expect(requests).toEqual([]); expect(cacheCalls(s.projectHttp.calls)).toEqual([cacheRequest]); @@ -1383,9 +1211,7 @@ describe("db schema declarative generate permissions through the real resolver a return Effect.gen(function* () { yield* saveLinkedRef; yield* stubManagementApi(requests, () => undefined); - yield* dbSchemaDeclarativeGenerate(flags({ local: Option.some(true) })).pipe( - Effect.provide(s.layer), - ); + yield* dbSchemaDeclarativeGenerate(flags({ local: true })).pipe(Effect.provide(s.layer)); expect(s.engineExportCalls).toHaveLength(1); expect(requests).toEqual([]); expect(s.projectHttp.calls).toEqual([]); @@ -1427,41 +1253,26 @@ describe("db schema declarative generate permissions through the real resolver a ); }); - it.effect( - "mints a login role and fills the cache when the prompt picks the linked project", - () => { - const s = setup(tmp.current, { - realLayers: true, - stdinIsTty: true, - projectId: Option.some(VALID_REF), - promptSelectResponses: ["linked"], - }); - const requests: Array<StubbedRequest> = []; - return Effect.gen(function* () { - yield* reachableDirectHost; - yield* saveLinkedRef; - yield* seedMigration(tmp.current); - yield* stubManagementApi(requests, serveLinkedDatabase(VALID_REF)); - yield* dbSchemaDeclarativeGenerate(flags()).pipe(Effect.provide(s.layer)); - expect(s.out.promptSelectCalls[0]?.options.map((o) => o.value)).toEqual([ - "local", - "linked", - "custom", - ]); - // The prompt path's calls carry a prerequisite, so the exact check skips them: assert them - // directly. - expect(asLines(requests)).toEqual([loginRoleRequest]); - expect(cacheCalls(s.projectHttp.calls)).toEqual([cacheRequest]); - assertPermissionDrift({ - command: "db schema declarative generate", - requests: [...asRecordedRequests(requests), ...s.projectHttp.calls], - exact: true, - }); - }).pipe( - Effect.scoped, - Effect.provide(BunServices.layer), - provideEnv({ SUPABASE_ACCESS_TOKEN: VALID_TOKEN }), + it.effect("fetches the pooler and mints a login role for --project-ref", () => { + const s = setup(tmp.current, { realLayers: true, projectId: Option.none() }); + const requests: Array<StubbedRequest> = []; + return Effect.gen(function* () { + yield* stubManagementApi(requests, serveLinkedDatabase(VALID_REF)); + yield* dbSchemaDeclarativeGenerate(flags({ projectRef: Option.some(VALID_REF) })).pipe( + Effect.provide(s.layer), ); - }, - ); + expect(s.engineExportCalls).toHaveLength(1); + expect(asLines(requests)).toEqual([poolerRequest, loginRoleRequest]); + assertPermissionDrift({ + command: "db schema declarative generate", + activeFlags: { "project-ref": VALID_REF }, + requests: [...asRecordedRequests(requests), ...s.projectHttp.calls], + exact: true, + }); + }).pipe( + Effect.scoped, + Effect.provide(BunServices.layer), + provideEnv({ SUPABASE_ACCESS_TOKEN: VALID_TOKEN }), + ); + }); }); 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 da5dd7d111..e20ac6ff4c 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 @@ -3,9 +3,7 @@ import { Command, Flag } from "effect/cli"; import type * as CliCommand from "effect/cli/Command"; import { withPermissions } from "../../../../../command-internal/command-permissions/command-permissions.annotation.ts"; -import { compose, withContext } from "../../../../../command-internal/command-permissions/model.ts"; -import { dbConfigLinked } from "../../../../../command-internal/db-config.layer.ts"; -import { linkedProjectCache } from "../../../../../telemetry/linked-project-cache.layer.ts"; +import { compose } from "../../../../../command-internal/command-permissions/model.ts"; 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"; @@ -64,28 +62,18 @@ export const dbSchemaDeclarativeSyncCommand = Command.make("sync", config).pipe( ), Command.withShortDescription("Generate a new migration from declarative schema"), withPermissions( - compose( - { - operations: [], - noApiEffectFlags: [ - "schema", - "file", - "name", - "apply", - "no-apply", - "no-cache", - "strict-coverage", - ], - }, - withContext( - dbConfigLinked, - "no declarative schema files yet, migrations exist, and the user picks the linked project in the bootstrap target prompt", - ), - withContext( - linkedProjectCache, - "the bootstrap runs with migrations present and SUPABASE_PROJECT_ID or supabase/.temp/project-ref gives a ref, whichever target is picked", - ), - ), + compose({ + operations: [], + noApiEffectFlags: [ + "schema", + "file", + "name", + "apply", + "no-apply", + "no-cache", + "strict-coverage", + ], + }), ), Command.withHandler((flags) => Effect.gen(function* () { 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 b312ed5666..671f93a621 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 @@ -1,4 +1,4 @@ -import { Clock, DateTime, Effect, FileSystem, Option, Path, Ref, Result } from "effect"; +import { Clock, DateTime, Effect, FileSystem, Option, Path, Result } from "effect"; import { DnsResolverFlag, @@ -24,17 +24,12 @@ import { 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 "../../../../../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 } from "../../../../../command-internal/pgdelta.ts"; import { writePgDeltaMigrations } from "../../../shared/pgdelta-migrations.write.ts"; -import { - resolveLocalTargetEndpoint, - resolveSmartTargetEndpoint, -} from "../declarative.smart-target.ts"; +import { prepareLocalTargetEndpoint } from "../declarative.smart-target.ts"; import { type DebugBundle, collectMigrationsList, @@ -101,11 +96,6 @@ export const dbSchemaDeclarativeSync = Effect.fn("db.schema.declarative.sync")(f const yes = yield* resolveYesWithProjectEnv(projectEnv); const dnsResolver = yield* DnsResolverFlag; const seam = yield* DeclarativeSeam; - const linkedProjectCache = yield* LinkedProjectCache; - - // Set when the bootstrap branch below resolves a linked ref, so the `Effect.ensuring` - // finalizer at the end of this handler can refresh the linked-project cache. - const linkedProjectRef = yield* Ref.make<string | undefined>(undefined); yield* Effect.gen(function* () { const toml = yield* readDbToml(fs, path, cliSettings.workdir); @@ -157,6 +147,12 @@ export const dbSchemaDeclarativeSync = Effect.fn("db.schema.declarative.sync")(f dnsResolver, }; const ensureLocalPostgresImageCurrent = seam.ensureLocalPostgresImageCurrent; + const promptLocalReset = promptYesNo( + output, + yes, + "Reset local database to match migrations first? (local data will be lost)", + false, + ); yield* warnFormerDeclarativeDefault(fs, path, cliSettings.workdir, toml.pgDelta); const declarativeFilesExist = yield* declarativeDirHasFiles(fs, declarativeDir); yield* Effect.annotateCurrentSpan({ @@ -191,42 +187,14 @@ export const dbSchemaDeclarativeSync = Effect.fn("db.schema.declarative.sync")(f true, ); if (!ok) return yield* noFiles; - // Delegates to the full smart-generate flow: with migrations present it offers the - // local/linked/custom target choice plus a local-reset prompt. The presence probe below - // swallows read errors rather than aborting the bootstrap; the diff path further down - // keeps the hard list behavior. + // The presence probe swallows read errors rather than aborting the bootstrap; the diff + // path further down keeps the hard list behavior. const hasMigrations = (yield* listLocalMigrations(fs, path, migrationsDir).pipe( Effect.orElseSucceed((): ReadonlyArray<string> => []), )).length > 0; - // Only when migrations exist, resolve the ref (config `project_id` → `.temp/project-ref`) - // and record it for the finalizer, so a linked-workdir bootstrap caches regardless of the - // chosen target. - let linkedRef = Option.none<string>(); - if (hasMigrations) { - // Only decides whether to offer the linked choice, so swallow a broken - // `.temp/project-ref` here; `linkedProjectRef` then stays unset so the post-run cache - // correctly does not fire. - linkedRef = Option.isSome(cliSettings.projectId) - ? cliSettings.projectId - : yield* readProjectRefFile(fs, path, cliSettings.workdir).pipe( - Effect.orElseSucceed(() => Option.none<string>()), - ); - if (Option.isSome(linkedRef)) { - yield* Ref.set(linkedProjectRef, linkedRef.value); - } - } - // sync has no target flags, so reset stays interactive (the prompt fires under the local - // choice). - const target = yield* resolveSmartTargetEndpoint( - { dbUrl: Option.none(), linked: Option.none(), password: Option.none(), reset: false }, - { port: toml.port, password: toml.password }, - hasMigrations, - fs, - path, - cliSettings.workdir, - linkedRef, - ensureLocalPostgresImageCurrent, + const target = yield* prepareLocalTargetEndpoint( + hasMigrations ? promptLocalReset : Effect.succeed(false), ); const generated = yield* generateDeclarativeOutput(run, target); const written = yield* writeDeclarativeSchemas(fs, path, declarativeDir, generated); @@ -272,37 +240,18 @@ export const dbSchemaDeclarativeSync = Effect.fn("db.schema.declarative.sync")(f }); } } - yield* ensureLocalPostgresImageCurrent; - yield* seam.ensureLocalDatabaseStarted; - // The staged export snapshots the running local database verbatim, not a shadow built - // from migrations (what the failed plan compared) — offer the same reset the smart-target - // local path offers, so stale Studio-made drift doesn't silently become the staged tree. - yield* output.raw( - `Exporting from the running local database (not the migrations state). Review ${stagedDirRel} before adopting it.\n`, - "stderr", - ); - const shouldReset = yield* promptYesNo( - output, - yes, - "Reset local database to match migrations first? (local data will be lost)", - false, - ); - if (shouldReset) { - yield* resetLocalDatabase().pipe( - Effect.mapError( - (error) => - new DeclarativeApplyError({ - message: `database reset failed: ${error.message}`, - suggestion: readErrorSuggestion(error), - }), - ), - ); - } + // The staged export snapshots the running local database verbatim, not a shadow built from + // migrations (what the failed plan compared), so offer a reset first; stale Studio-made + // drift would otherwise silently become the staged tree. const generated = yield* generateDeclarativeOutput( { ...run, declarativeDir: stagedDir }, - yield* resolveLocalTargetEndpoint( - { port: toml.port, password: toml.password }, - dnsResolver, + yield* prepareLocalTargetEndpoint( + output + .raw( + `Exporting from the running local database (not the migrations state). Review ${stagedDirRel} before adopting it.\n`, + "stderr", + ) + .pipe(Effect.andThen(promptLocalReset)), ), ); const written = yield* writeDeclarativeSchemas(fs, path, stagedDir, generated); @@ -665,17 +614,7 @@ export const dbSchemaDeclarativeSync = Effect.fn("db.schema.declarative.sync")(f yield* output.raw(debugBundleMessage(debugDir), "stderr"); } return yield* applyError; - }).pipe( - // Writes the linked-project cache whenever the bootstrap path resolved a linked ref, whether - // sync succeeds or fails; only the linked bootstrap sets `linkedProjectRef`, so non-linked - // syncs never trigger this. - Effect.ensuring( - Effect.flatMap(Ref.get(linkedProjectRef), (ref) => - ref !== undefined ? linkedProjectCache.cache(ref) : Effect.void, - ), - ), - Effect.ensuring(telemetryState.flush), - ); + }).pipe(Effect.ensuring(telemetryState.flush)); }); const declarativeDirHasFiles = Effect.fnUntraced(function* ( 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 9b6f208d75..fe2e669d78 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 @@ -12,24 +12,10 @@ import { Stream, Redacted, } from "effect"; -import { vi } from "vitest"; - -const fixtureNet = vi.hoisted(() => ({ directHostReachable: false })); - -vi.mock("node:net", (importOriginal) => - Promise.all([ - import("../../../../../../tests/helpers/refuse-invalid-db-hosts.ts"), - importOriginal<typeof import("node:net")>(), - ]).then(([helper, net]) => helper.refuseInvalidDbHosts(net, fixtureNet)), -); - -const reachableDirectHost = withReachableDirectHost(fixtureNet); import { stripAnsi } from "../../../../../../tests/helpers/ansi.ts"; -import { withReachableDirectHost } from "../../../../../../tests/helpers/refuse-invalid-db-hosts.ts"; import { assertPermissionDrift } from "../../../../../../tests/helpers/permission-drift.ts"; import { - asRecordedRequests, provideEnv, realDbConfigResolver, realLinkedProjectCache, @@ -303,8 +289,6 @@ function setup(workdir: string, opts: SetupOpts = {}) { }); }, }); - // The no-files bootstrap delegates to the shared smart-target resolver; its - // local path never calls `resolve`, but the linked/custom branches would. const resolver = Layer.succeed(DbConfigResolver, { resolve: () => Effect.succeed({ @@ -317,7 +301,19 @@ function setup(workdir: string, opts: SetupOpts = {}) { }, isLocal: false, }), - resolveConnection: () => Effect.die("resolveConnection is not used by this test"), + resolveConnection: ({ target }) => + Effect.succeed({ + conn: { + host: "127.0.0.1", + port: opts.stackBackend === true ? STACK_APPLY_PORT : 54322, + user: "postgres", + password: "postgres", + database: "postgres", + }, + isLocal: true, + ref: Option.none(), + target, + }), resolvePoolerFallback: () => Effect.succeed(Option.none()), }); const runtimeInfo = mockRuntimeInfo({ platform: "linux" }); @@ -822,75 +818,62 @@ describe("db schema declarative sync integration", () => { }).pipe(Effect.provide(s.layer)); }); - it.effect("bootstrap with migrations offers the smart target choice (not local-only)", () => { - // `projectId: "test"` is an invalid ref, so the linked choice is hidden. + it.effect("bootstrap with migrations offers a local reset and never a target choice", () => { const s = setup(tmp.current, { experimental: true, stdinIsTty: true, diffSql: "", promptConfirmResponses: [true, false], // [generate a new one? yes][reset? no] - promptSelectResponses: ["local"], - }); - return Effect.gen(function* () { - yield* seedMigration(tmp.current); - yield* Effect.exit(dbSchemaDeclarativeSync(flags({ noApply: Option.some(true) }))); - const options = s.out.promptSelectCalls[0]?.options ?? []; - expect(options.map((o) => o.value)).toEqual(["local", "custom"]); - }).pipe(Effect.provide(s.layer)); - }); - - it.effect("bootstrap linked target does not run the local Postgres image check", () => { - const s = setup(tmp.current, { - experimental: true, - stdinIsTty: true, - staleLocalImage: true, - projectId: Option.some("abcdefghijklmnopqrst"), - promptConfirmResponses: [true], // generate a new one? yes - promptSelectResponses: ["linked"], }); return Effect.gen(function* () { yield* seedMigration(tmp.current); - const exit = yield* Effect.exit( - dbSchemaDeclarativeSync(flags({ noCache: true, noApply: Option.some(true) })), - ); - expect(s.localPostgresImageChecks).toEqual([]); - const causeText = Exit.isFailure(exit) ? Cause.pretty(exit.cause) : ""; - expect(causeText).not.toContain("local Postgres container image is stale"); - expect((s.out.promptSelectCalls[0]?.options ?? []).map((o) => o.value)).toEqual([ - "local", - "linked", - "custom", + yield* dbSchemaDeclarativeSync(flags({ noApply: Option.some(true) })); + expect(s.out.promptSelectCalls).toEqual([]); + expect(s.out.promptConfirmCalls.map((call) => call.message)).toEqual([ + "No declarative schema found. Generate a new one ?", + "Reset local database to match migrations first? (local data will be lost)", ]); + expect(localResetRemovedContainers(s.child.spawned)).toEqual([]); + expect(s.declarativeExportCalls).toHaveLength(1); }).pipe(Effect.provide(s.layer)); }); - it.effect("bootstrap linked target checks the local Postgres image before apply", () => { + it.effect( + "bootstrap without migrations generates from the local database without a reset offer", + () => { + const s = setup(tmp.current, { + experimental: true, + stdinIsTty: true, + diffSql: "", + promptConfirmResponses: [true], + }); + return Effect.gen(function* () { + yield* dbSchemaDeclarativeSync(flags({ noApply: Option.some(true) })); + expect(s.out.promptConfirmCalls).toHaveLength(1); + expect(s.localPostgresImageChecks).toHaveLength(1); + expect(s.declarativeExportCalls).toHaveLength(1); + }).pipe(Effect.provide(s.layer)); + }, + ); + + it.effect("bootstrap --yes with migrations resets the local database without prompting", () => { const s = setup(tmp.current, { experimental: true, - stdinIsTty: true, - staleLocalImage: true, - projectId: Option.some("abcdefghijklmnopqrst"), - diffSql: "ALTER TABLE a ADD COLUMN b int;\n", - promptConfirmResponses: [true], // generate a new one? yes - promptSelectResponses: ["linked"], + stdinIsTty: false, + yes: true, + diffSql: "", }); return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; yield* seedMigration(tmp.current); - const error = yield* Effect.flip( - dbSchemaDeclarativeSync( - flags({ - noCache: true, - apply: Option.some(true), - name: Option.some("bootstrap_apply"), - }), - ), + yield* fs.writeFileString( + path.join(tmp.current, "supabase", "config.toml"), + 'project_id = "test"\n', ); - expect(error).toMatchObject({ - _tag: "DeclarativeShadowDbError", - message: "local Postgres container image is stale", - }); - expect(s.localPostgresImageChecks).toHaveLength(1); - expect(s.dbExec).toEqual([]); + yield* dbSchemaDeclarativeSync(flags({ noApply: Option.some(true) })); + expect(localResetRemovedContainers(s.child.spawned)).toContain("supabase_db_test"); + expect(s.declarativeExportCalls).toHaveLength(1); }).pipe(Effect.provide(s.layer)); }); @@ -900,7 +883,6 @@ describe("db schema declarative sync integration", () => { stdinIsTty: true, staleLocalImage: true, promptConfirmResponses: [true], // generate a new one? yes - promptSelectResponses: ["local"], }); return Effect.gen(function* () { yield* seedMigration(tmp.current); @@ -945,64 +927,19 @@ describe("db schema declarative sync integration", () => { }).pipe(Effect.provide(s.layer)); }); - it.effect("bootstrap: an unreadable ref file just omits the linked choice", () => { - // Seeding `.temp/project-ref` as a directory makes the read fail. - const s = setup(tmp.current, { - experimental: true, - stdinIsTty: true, - diffSql: "", - projectId: Option.none(), - promptConfirmResponses: [true, false], // [generate a new one? yes][reset? no] - promptSelectResponses: ["local"], - }); - return Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - yield* seedMigration(tmp.current); - yield* fs.makeDirectory(path.join(tmp.current, "supabase", ".temp", "project-ref"), { - recursive: true, - }); - const exit = yield* Effect.exit( - dbSchemaDeclarativeSync(flags({ noApply: Option.some(true) })), - ); - expect((s.out.promptSelectCalls[0]?.options ?? []).map((o) => o.value)).toEqual([ - "local", - "custom", - ]); - const causeText = Exit.isFailure(exit) ? Cause.pretty(exit.cause) : ""; - expect(causeText).not.toContain("failed to load project ref"); - }).pipe(Effect.provide(s.layer)); - }); - - it.effect("bootstrap caches the linked project after resolving the ref", () => { + it.effect("bootstrap never fills the linked-project cache, even in a linked workdir", () => { const s = setup(tmp.current, { experimental: true, stdinIsTty: true, diffSql: "", - projectId: Option.some("abcdefghijklmnopqrst"), + projectId: Option.some(VALID_REF), promptConfirmResponses: [true, false], // [generate a new one? yes][reset? no] - promptSelectResponses: ["local"], - }); - return Effect.gen(function* () { - yield* seedMigration(tmp.current); - yield* Effect.exit(dbSchemaDeclarativeSync(flags({ noApply: Option.some(true) }))); - expect(s.cache.cached).toBe(true); - }).pipe(Effect.provide(s.layer)); - }); - - it.effect("does not cache when the workdir is not linked", () => { - const s = setup(tmp.current, { - experimental: true, - stdinIsTty: true, - diffSql: "", - projectId: Option.none(), - promptConfirmResponses: [true, false], - promptSelectResponses: ["local"], }); return Effect.gen(function* () { yield* seedMigration(tmp.current); - yield* Effect.exit(dbSchemaDeclarativeSync(flags({ noApply: Option.some(true) }))); + yield* dbSchemaDeclarativeSync(flags({ noApply: Option.some(true) })); expect(s.cache.cached).toBe(false); + expect(s.telemetry.flushCount).toBe(1); }).pipe(Effect.provide(s.layer)); }); @@ -1626,62 +1563,13 @@ describe("db schema declarative sync integration", () => { describe("db schema declarative sync permissions through the real resolver and cache", () => { const tmp = useTempWorkdir(); - const asLines = (requests: ReadonlyArray<StubbedRequest>) => - requests.map(({ method, path }) => `${method} ${path}`); - const loginRoleRequest = `POST /v1/projects/${VALID_REF}/cli/login-role`; - const cacheRequest = `GET https://api.supabase.com/v1/projects/${VALID_REF}`; - const cacheCalls = (calls: ReadonlyArray<{ readonly method: string; readonly url: string }>) => - calls.map(({ method, url }) => `${method} ${url}`); const saveLinkedRef = Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; yield* fs.makeDirectory(`${tmp.current}/supabase/.temp`, { recursive: true }); yield* fs.writeFileString(`${tmp.current}/supabase/.temp/project-ref`, VALID_REF); }); - it.effect( - "the bootstrap mints a login role and fills the cache when it picks the linked project", - () => { - const s = setup(tmp.current, { - realLayers: true, - experimental: true, - stdinIsTty: true, - diffSql: "", - projectId: Option.some(VALID_REF), - promptConfirmResponses: [true], // generate a new one? yes - promptSelectResponses: ["linked"], - }); - const requests: Array<StubbedRequest> = []; - return Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - yield* reachableDirectHost; - yield* saveLinkedRef; - yield* seedMigration(tmp.current); - yield* stubManagementApi(requests, serveLinkedDatabase(VALID_REF)); - const exit = yield* Effect.exit( - dbSchemaDeclarativeSync(flags({ noCache: true, noApply: Option.some(true) })), - ).pipe(Effect.provide(s.layer)); - expect(Exit.isSuccess(exit)).toBe(true); - expect(s.declarativeExportCalls).toHaveLength(1); - // The bootstrap's calls carry a prerequisite, so the exact check skips them: assert them - // directly. - expect(asLines(requests)).toEqual([loginRoleRequest]); - expect(cacheCalls(s.projectHttp.calls)).toEqual([cacheRequest]); - expect(yield* fs.exists(`${tmp.current}/supabase/.temp/linked-project.json`)).toBe(true); - assertPermissionDrift({ - command: "db schema declarative sync", - activeFlags: { "no-cache": true, "no-apply": true }, - requests: [...asRecordedRequests(requests), ...s.projectHttp.calls], - exact: true, - }); - }).pipe( - Effect.scoped, - Effect.provide(BunServices.layer), - provideEnv({ SUPABASE_ACCESS_TOKEN: VALID_TOKEN }), - ); - }, - ); - - it.effect("the bootstrap fills the cache but mints no login role when it picks local", () => { + it.effect("the bootstrap makes no Management API request and leaves the cache alone", () => { const s = setup(tmp.current, { realLayers: true, experimental: true, @@ -1689,7 +1577,6 @@ describe("db schema declarative sync permissions through the real resolver and c diffSql: "", projectId: Option.some(VALID_REF), promptConfirmResponses: [true, false], // [generate a new one? yes][reset? no] - promptSelectResponses: ["local"], }); const requests: Array<StubbedRequest> = []; return Effect.gen(function* () { @@ -1702,11 +1589,11 @@ describe("db schema declarative sync permissions through the real resolver and c expect(Exit.isSuccess(exit)).toBe(true); expect(s.declarativeExportCalls).toHaveLength(1); expect(requests).toEqual([]); - expect(cacheCalls(s.projectHttp.calls)).toEqual([cacheRequest]); + expect(s.projectHttp.calls).toEqual([]); assertPermissionDrift({ command: "db schema declarative sync", activeFlags: { "no-apply": true }, - requests: [...asRecordedRequests(requests), ...s.projectHttp.calls], + requests: [], exact: true, }); }).pipe( From 29c139b1b42078419d45d4b1d80622d2a148606c Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Thu, 15 Oct 2026 14:53:52 +0100 Subject: [PATCH 55/92] docs(cli): document the declarative generate target defaults and the local sync bootstrap (CLI-2695) --- .../db/schema-declarative-generate.md | 4 +- .../supabase/db/schema-declarative-sync.md | 2 +- .../declarative/generate/SIDE_EFFECTS.md | 76 ++++++++++++------- .../schema/declarative/sync/SIDE_EFFECTS.md | 26 ++++--- 4 files changed, 68 insertions(+), 40 deletions(-) diff --git a/apps/cli/docs/supabase/db/schema-declarative-generate.md b/apps/cli/docs/supabase/db/schema-declarative-generate.md index 92313503b3..4438444124 100644 --- a/apps/cli/docs/supabase/db/schema-declarative-generate.md +++ b/apps/cli/docs/supabase/db/schema-declarative-generate.md @@ -2,7 +2,9 @@ Generate declarative schema files from a database. -Exports the schema of a live database (local, linked, or custom URL) into SQL files under the declarative schema directory. This is the entrypoint for bootstrapping declarative mode. +Exports the schema of a live database into SQL files under the declarative schema directory. This is the entrypoint for bootstrapping declarative mode. + +The database is the local one unless you pass `--linked`, `--project-ref`, or `--db-url`, or the workspace `target` is `remote`. The command never asks which database to use. With the local database, `--reset` recreates it from your migrations first. When the destination already holds files, the command asks before overwriting them (pass `--overwrite` or `--yes` to skip the question). The bundled pg-delta engine writes one directory per schema at the root of that directory (`supabase/schemas/public/tables/users.sql`, `supabase/schemas/public/schema.sql`), with cluster-level objects that belong to no schema under a reserved `_cluster/` directory (`supabase/schemas/_cluster/roles.sql`). When platform default grants (`anon`/`authenticated`/`service_role`) need an overlay `REVOKE ALL`, the export writes `adp_wipes.sql` after `schema.sql` in that schema — or `_cluster/adp_wipes.sql` for cluster-wide overlays — so a load onto a fresh project keeps privileges that were revoked in the source. A schema literally named `_cluster` or `_custom`, in any casing, has its leading underscore percent-encoded (`%5Fcluster/`) so it can never claim a directory the export owns. Hand-authored SQL that pg-delta does not model belongs in `_custom/`, which the export never writes to and never prunes. diff --git a/apps/cli/docs/supabase/db/schema-declarative-sync.md b/apps/cli/docs/supabase/db/schema-declarative-sync.md index ea7e7faa23..ff65de4c31 100644 --- a/apps/cli/docs/supabase/db/schema-declarative-sync.md +++ b/apps/cli/docs/supabase/db/schema-declarative-sync.md @@ -2,6 +2,6 @@ Generate a new migration by diffing your declarative schema files against the current migration state. -When no declarative schema exists yet, the command offers to run `generate` first. After computing the diff, you can optionally name the migration and apply it to the local database. +When no declarative schema exists yet, the command offers to run `generate` first, exporting from the local database. To start from a linked project or a database URL, run `generate --linked` or `generate --db-url` yourself first. After computing the diff, you can optionally name the migration and apply it to the local database. Enabled by default. When `[experimental.pgdelta] enabled = false` is set in config, pass the `--experimental` flag to run it. diff --git a/apps/cli/src/commands/db/schema/declarative/generate/SIDE_EFFECTS.md b/apps/cli/src/commands/db/schema/declarative/generate/SIDE_EFFECTS.md index 77b4593596..230954142d 100644 --- a/apps/cli/src/commands/db/schema/declarative/generate/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/db/schema/declarative/generate/SIDE_EFFECTS.md @@ -6,9 +6,13 @@ platform view. Pg-delta runs in-process. Coverage gaps warn; `--strict-coverage` makes them fatal, and `PGDELTA_DEBUG` writes diagnostic JSON under `supabase/.temp/pgdelta/v2/debug/<id>/`. -When `[experimental].stack` is on, `--local` connects to the running primary database in the -project stack's composition. It does not launch a stopped database or choose a standalone -shadow database. +The command is `selectable` and never asks for hosted-mutation consent: the target is `--local`, +`--linked`, `--project-ref <ref>` (implies linked), or `--db-url`, else the workspace `target` +(`SUPABASE_TARGET`, then `config.toml`), which defaults to the local database. It never asks which +database to use. A local target checks the local Postgres image, starts a stopped local database, +and resets it first only with `--reset`. When `[experimental].stack` is on, the local target +connects to the running primary database in the project stack's composition. It does not launch a +stopped database or choose a standalone shadow database. `--no-cache` (a flag shared across the `declarative` group) has no effect on `generate` — the export connects directly to the target and provisions no @@ -18,12 +22,13 @@ formatting without disabling safe compaction. ## Files Read -| Path | Format | When | -| ------------------------------------------- | ---------- | ------------------------------------------------------------------------------------ | -| `<workdir>/supabase/config.toml` | TOML | always — pg-delta gate, ports, format options | -| `<workdir>/supabase/.temp/postgres-version` | plain text | smart-mode Local flow — the local Postgres image-currency check's version-pin lookup | -| `<workdir>/supabase/migrations/*.sql` | SQL | smart mode — detect whether migrations exist | -| `~/.supabase/access-token` | plain text | `--linked` (token resolution) | +| Path | Format | When | +| ---------------------------------------------- | ---------- | ----------------------------------------------------------------------------- | +| `<workdir>/supabase/config.toml` | TOML | always — pg-delta gate, workspace `target`, ports, format options | +| `<workdir>/supabase/.temp/postgres-version` | plain text | a local target — the local Postgres image-currency check's version-pin lookup | +| `<workdir>/supabase/.temp/project-ref` | plain text | a linked target with no `--project-ref` and no `SUPABASE_PROJECT_ID` | +| `<workdir>/supabase/.temp/linked-project.json` | JSON | a linked target, for the `Target:` banner (no API call) | +| `~/.supabase/access-token` | plain text | a linked target (token resolution) | ## Files Written @@ -35,40 +40,43 @@ formatting without disabling safe compaction. ## Subprocesses / Containers -| What | When | -| --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------- | -| `docker`/`podman` container recreate for the local `db` (+ satellite restarts, Kong reload) — the same primitives `db start`/`db reset` use, via `resetLocalDatabase` | smart-mode Local choice when reset is confirmed (or `--reset`) | +| What | When | +| --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------- | +| `docker`/`podman` container recreate for the local `db` (+ satellite restarts, Kong reload) — the same primitives `db start`/`db reset` use, via `resetLocalDatabase` | a local target with `--reset` | ## Environment Variables | Variable | Purpose | Required? | | ---------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------- | -| `SUPABASE_ACCESS_TOKEN` | auth token for `--linked` | no | -| `DB_PASSWORD` | password for `--linked` / `--db-url` | no | +| `SUPABASE_ACCESS_TOKEN` | auth token for a linked target | no | +| `SUPABASE_DB_PASSWORD` | remote database password for a linked target (`--password`/`-p` overrides) | +| `SUPABASE_PROJECT_ID` | the project ref of a linked target when `--project-ref` is absent | +| `SUPABASE_TARGET` | workspace target (`local` or `remote`) when no selector flag is given | no | | `SUPABASE_HOME` | overrides the `~/.supabase` root (access token and other CLI state) | no | | `PGDELTA_DEBUG` | bundled-engine debug artifacts | no | -| `SUPABASE_SERVICES_HOSTNAME` | local DB host for `--local` | no | +| `SUPABASE_SERVICES_HOSTNAME` | local DB host for a local target | no | | `DOCKER_HOST` | tcp daemon host used as the local DB host fallback | no | | `SUPABASE_USE_SLIM_IMAGES` | resolves the expected local `db` image for the stale-container guard 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, and flag-off `15.8.1.085` stay on docker.io | no | ## Exit Codes -| Code | Condition | -| ---- | ------------------------------------------------------------------------------------ | -| `0` | success (files written, or skipped after a declined prompt) | -| `1` | pg-delta disabled (`[experimental.pgdelta] enabled = false` and no `--experimental`) | -| `1` | conflicting `--db-url`/`--linked`/`--local` (mutually exclusive) | -| `1` | non-interactive mode with no explicit target | -| `1` | local-database bring-up / pg-delta engine / export failure | +| Code | Condition | +| ---- | ------------------------------------------------------------------------------------------------------------------- | +| `0` | success (files written, or skipped after a declined prompt) | +| `1` | pg-delta disabled (`[experimental.pgdelta] enabled = false` and no `--experimental`) | +| `1` | conflicting target flags (`--local`, `--linked`, `--db-url`), or `--project-ref` combined with `--local`/`--db-url` | +| `1` | `--password` combined with `--local` or `--db-url` | +| `1` | local-database bring-up / pg-delta engine / export failure | -The pg-delta gate and the mutex check are both raised before any side effects run, +The pg-delta gate and the selector checks are both raised before any side effects run, but the gate wins when both conditions apply simultaneously: the gate check runs first, so a closed gate (`enabled = false` and no `--experimental`) surfaces before a -`--db-url`/`--linked`/`--local` conflict is ever checked. +target-flag conflict is ever checked. `--password` with a local target is rejected after the +local Postgres image check and database start. ## Output -Diagnostics (target resolution, prompts, `Declarative schema written to <dir>`) +Diagnostics (the `Target: …` banner, prompts, `Declarative schema written to <dir>`) always go to stderr, in every `--output-format`. On success: - `text` mode prints `Finished supabase db schema declarative generate.` to @@ -82,8 +90,20 @@ always go to stderr, in every `--output-format`. On success: - Open by default. The gate closes only when `[experimental.pgdelta] enabled = false` and `--experimental` is omitted. -- `--db-url` / `--linked` / `--local` are mutually exclusive; absent all three, - smart mode prompts (existing-files overwrite → Local/Custom choice + reset offer). +- The target comes from `resolveTarget`: a selector flag wins, then the workspace `target` + (`SUPABASE_TARGET`, then `config.toml`), then local. With no selector the command exports + the local database; it neither prompts for a target nor fails in non-interactive mode. + `--project-ref` implies `--linked`; combining it with `--local` or `--db-url` is an error, as + is combining two of `--local`, `--linked` and `--db-url`. A `false` switch (`--linked=false`) + counts as absent. +- `--reset` recreates the local database from the migrations before exporting; it has no effect + on a linked or `--db-url` target. +- Existing declarative files are overwritten only after the export succeeds: without + `--overwrite`, a non-empty destination asks `Overwrite declarative schema? Existing files may be +deleted.` (default no); a decline, or no answer, prints `Skipped writing declarative schema.` + and exits `0`. `--yes` and `SUPABASE_YES` answer it. +- A linked target caches the project in `supabase/.temp/linked-project.json` on success and + failure, like other linked commands. - `--output-dir <dir>` selects a destination for this invocation without changing config or activating it for later syncs (deliberately not `--output`/`-o`, which the root command reserves for the global machine-format flag). @@ -94,7 +114,7 @@ always go to stderr, in every `--output-format`. On success: codes. - Remote Supabase targets use the shared connection/TLS behavior. - **Architecture:** the engine extracts and renders the target in-process. -- **Stale local-container guard.** `--local`/smart-mode's Local target inspects +- **Stale local-container guard.** A local target inspects the running local `db` container's actual image and compares it against the currently-configured/resolved one before reading from it. A same-tag family mismatch (slim vs docker.io, e.g. after toggling `SUPABASE_USE_SLIM_IMAGES` diff --git a/apps/cli/src/commands/db/schema/declarative/sync/SIDE_EFFECTS.md b/apps/cli/src/commands/db/schema/declarative/sync/SIDE_EFFECTS.md index 51951160a0..caf5fba70f 100644 --- a/apps/cli/src/commands/db/schema/declarative/sync/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/db/schema/declarative/sync/SIDE_EFFECTS.md @@ -28,7 +28,7 @@ disabling safe compaction. | --------------------------------------------------------------------------- | ------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `<workdir>/supabase/config.toml` | TOML | always — pg-delta gate, format options | | `<workdir>/supabase/schemas/**/*.sql` (default declarative dir) | SQL | always — must exist (else error) | -| `<workdir>/supabase/migrations/*.sql` | SQL | applied to the live migrations shadow | +| `<workdir>/supabase/migrations/*.sql` | SQL | applied to the live migrations shadow; the no-files bootstrap also lists them to decide whether to offer a local reset | | `<workdir>/supabase/roles.sql` | SQL | hashed into the shadow-baseline cache key on every cache-eligible acquire, warm hits included, and applied to a cold shadow's baseline; missing file tolerated (hashed as empty) | | `<workdir>/supabase/schemas/.pgdelta-export.json` | JSON | export metadata, when present | | `~/.supabase/cache/shadow-baseline/shadow-baseline-<key>.tar` | tar | warm shadow-cache hit (migrations/declarative shadows); 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) | @@ -46,10 +46,10 @@ disabling safe compaction. ## Subprocesses / Containers -| What | When | -| -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------- | -| Two natively-provisioned shadows (migrated source + declarative target) via `acquireShadowDatabase` — ephemeral host ports, settings-keyed global baseline cache | always | -| `docker`/`podman` container recreate for the local `db` (+ satellite restarts, Kong reload) — the same primitives `db start`/`db reset` use, via `resetLocalDatabase` — only on the failed-apply recovery path | TTY only, apply failed, and the user confirms "reset and reapply" | +| What | When | +| --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| Two natively-provisioned shadows (migrated source + declarative target) via `acquireShadowDatabase` — ephemeral host ports, settings-keyed global baseline cache | always | +| `docker`/`podman` container recreate for the local `db` (+ satellite restarts, Kong reload) — the same primitives `db start`/`db reset` use, via `resetLocalDatabase` | the failed-apply recovery (TTY only, apply failed, the user confirms "reset and reapply"); the no-files bootstrap with migrations and the staged export's reset offer, when confirmed or under `--yes` | ## Environment Variables @@ -113,14 +113,20 @@ existing SQL or creates an export manifest. and `--experimental` is omitted. - `--file` sets the migration filename stem (default `declarative_sync`); `--name` overrides it. In a TTY without `--name`/`--yes`, the name is prompted. -- When no declarative files exist, a TTY offers to generate them (from local) first. +- When no declarative files exist, a TTY offers to generate them first (`--yes`/`SUPABASE_YES` + accepts). The bootstrap always exports from the local database: it checks the local Postgres + image, starts a stopped local database and, when `supabase/migrations` holds migrations, asks + `Reset local database to match migrations first? (local data will be lost)` (default no; + `--yes` answers yes). It never asks which database to use, and `sync` has no target flags. Run + `supabase db schema declarative generate --linked` (or `--db-url`) first to start from a hosted + database. - The declarative directory is the complete desired state: omitted objects, including extensions, are removals. Use `generate --output-dir <staging-dir>` to review a next-compatible tree without changing config or activating it. - The interactive staged export announces that it snapshots the RUNNING local database (not the migrations state) and offers the same - "Reset local database to match migrations first?" prompt as the smart-target - local path before exporting. The printed staged-upgrade/adoption commands are + "Reset local database to match migrations first?" prompt as the no-files + bootstrap before exporting. The printed staged-upgrade/adoption commands are rendered for the host platform: POSIX shells get `rm -rf`/`mv`, Windows gets single-line PowerShell (`Remove-Item`/`Move-Item`). - When `declarative_schema_path` is unset, the new `supabase/schemas` default is @@ -132,8 +138,8 @@ existing SQL or creates an export manifest. history). On apply failure a debug bundle is written under `supabase/.temp/pgdelta/debug/` and, in a TTY, a reset-and-reapply is offered (the reset itself is native too — `resetLocalDatabase` — run in-process, - sharing this command's own telemetry/linked-project-cache finalizer cycle - rather than firing a second one from a child process). + sharing this command's own telemetry finalizer cycle rather than firing a + second one from a child process). - **Architecture:** the engine plans and renders in-process from two live shadows. - **Stale local-container guard.** Before diffing against the running local `db` From 2731e1d4790aa770119a30601570c22875f37926 Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Thu, 15 Oct 2026 14:53:52 +0100 Subject: [PATCH 56/92] chore(cli): fix format, effect lint and knip in the inspect and gen types target work (CLI-2695) --- .../src/commands/gen/types/types.handler.ts | 5 +- .../gen/types/types.integration.test.ts | 48 ++++++++---------- .../src/commands/inspect/db/SIDE_EFFECTS.md | 36 ++++++------- .../db/inspect-query.integration.test.ts | 23 +++++++-- .../src/commands/inspect/db/inspect-query.ts | 5 ++ .../commands/inspect/report/SIDE_EFFECTS.md | 50 +++++++++---------- .../inspect/report/report.integration.test.ts | 33 ++++++------ 7 files changed, 107 insertions(+), 93 deletions(-) diff --git a/apps/cli/src/commands/gen/types/types.handler.ts b/apps/cli/src/commands/gen/types/types.handler.ts index fda7109883..4942be5950 100644 --- a/apps/cli/src/commands/gen/types/types.handler.ts +++ b/apps/cli/src/commands/gen/types/types.handler.ts @@ -26,7 +26,10 @@ import { poolerConfigFromConnectionString } from "../../../command-internal/db-c import { readDbToml } from "../../../command-internal/db-config.toml-read.ts"; import { getHostname } from "../../../command-internal/hostname.ts"; import { hostedMutationPreapproval } from "../../../command-internal/hosted-consent/hosted-consent.ts"; -import { resolveTarget, type ResolvedTarget } from "../../../command-internal/target/resolve-target.ts"; +import { + resolveTarget, + type ResolvedTarget, +} from "../../../command-internal/target/resolve-target.ts"; import { renderLocalTarget } from "../../../command-internal/target/target-identity.ts"; import { TargetSelectorConflictError } from "../../../command-internal/target/target.errors.ts"; import type { DbConnectError } from "../../../command-internal/db-connection.errors.ts"; 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 1813a6993c..0ae3a8ecb0 100644 --- a/apps/cli/src/commands/gen/types/types.integration.test.ts +++ b/apps/cli/src/commands/gen/types/types.integration.test.ts @@ -158,9 +158,7 @@ function localResolvedConfig(conn: PgConnInput): ResolvedDbConfig { function mockDbConfigResolver( opts: { - readonly resolve?: ( - input: DbConnectionInput, - ) => Effect.Effect<ResolvedDbConfig, DbConfigError>; + readonly resolve?: (input: DbConnectionInput) => Effect.Effect<ResolvedDbConfig, DbConfigError>; readonly poolerFallback?: Option.Option<PgConnInput>; readonly poolerFallbackFails?: boolean; } = {}, @@ -641,27 +639,25 @@ describe("gen types", () => { }).pipe(Effect.provide(BunServices.layer)), ); - it.effect( - "uses configured api schemas for --linked generation when --schema is unset", - () => - Effect.gen(function* () { - const workdir = yield* makeWorkdir("supabase-gen-types-linked-"); - yield* writeConfig( - workdir, - ['project_id = "demo"', "", "[api]", 'schemas = ["auth", "storage"]'].join("\n"), - ); - const { layer, api } = yield* setup({ - workdir, - projectId: Option.some(VALID_REF), - projectTypes: "ok", - }); - yield* genTypes(defaultFlags({ linked: true })).pipe(Effect.provide(layer)); + it.effect("uses configured api schemas for --linked generation when --schema is unset", () => + Effect.gen(function* () { + const workdir = yield* makeWorkdir("supabase-gen-types-linked-"); + yield* writeConfig( + workdir, + ['project_id = "demo"', "", "[api]", 'schemas = ["auth", "storage"]'].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)), + expect(api.requests[0]).toEqual({ + method: "generateTypescriptTypes", + input: { ref: VALID_REF, included_schemas: "public,auth,storage" }, + }); + }).pipe(Effect.provide(BunServices.layer)), ); it.effect( @@ -1306,9 +1302,9 @@ describe("gen types", () => { args: ["gen", "types", "--linked=false", "--project-ref", VALID_REF], projectTypes: "ok", }); - yield* genTypes( - defaultFlags({ linked: false, projectRef: Option.some(VALID_REF) }), - ).pipe(Effect.provide(layer)); + yield* genTypes(defaultFlags({ linked: false, projectRef: Option.some(VALID_REF) })).pipe( + Effect.provide(layer), + ); expect(api.requests).toContainEqual({ method: "generateTypescriptTypes", diff --git a/apps/cli/src/commands/inspect/db/SIDE_EFFECTS.md b/apps/cli/src/commands/inspect/db/SIDE_EFFECTS.md index 507d8e482d..181d38b375 100644 --- a/apps/cli/src/commands/inspect/db/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/inspect/db/SIDE_EFFECTS.md @@ -10,14 +10,14 @@ hosted-mutation consent. ## Files Read -| Path | Format | When | -| -------------------------------- | ---------- | ----------------------------------------------------------------------------------- | -| `<workdir>/supabase/config.toml` | TOML | always, for the workspace `target` when no selector flag is given; local db host/port/password | -| `<workdir>/supabase/.temp/project-ref` | plain text | linked target without `--project-ref` or `SUPABASE_PROJECT_ID` | -| `<workdir>/supabase/.temp/linked-project.json` | JSON | linked target, for the `Target:` banner (no API call) | -| `~/.supabase/access-token` | plain text | linked target only, lazily, when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | -| `.pgpass` / `pg_service.conf` | libpq | only if referenced by a `--db-url` connection string | -| `$PGSSLROOTCERT` CA bundle | PEM | only if a `--db-url` sets `sslrootcert` / `PGSSLROOTCERT` | +| Path | Format | When | +| ---------------------------------------------- | ---------- | ---------------------------------------------------------------------------------------------- | +| `<workdir>/supabase/config.toml` | TOML | always, for the workspace `target` when no selector flag is given; local db host/port/password | +| `<workdir>/supabase/.temp/project-ref` | plain text | linked target without `--project-ref` or `SUPABASE_PROJECT_ID` | +| `<workdir>/supabase/.temp/linked-project.json` | JSON | linked target, for the `Target:` banner (no API call) | +| `~/.supabase/access-token` | plain text | linked target only, lazily, when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | +| `.pgpass` / `pg_service.conf` | libpq | only if referenced by a `--db-url` connection string | +| `$PGSSLROOTCERT` CA bundle | PEM | only if a `--db-url` sets `sslrootcert` / `PGSSLROOTCERT` | Connection resolution and all of the above are handled inside `DbConfigResolver` (`command-internal/db-config.layer.ts`); the command adds @@ -31,12 +31,12 @@ no new config reads. ## API Routes (MAY fire on a linked target, inside the resolver) -| Method | Path | Auth | When | -| -------- | ------------------------------------------- | ------------ | ------------------------------------------------------------------------------------ | -| `GET` | `/v1/projects` | Bearer token | linked target with no ref from `--project-ref`, `SUPABASE_PROJECT_ID` or `.temp/project-ref`, interactive terminal only (project picker) | -| `POST` | `/v1/projects/{ref}/cli/login-role` | Bearer token | linked target with no database password, to create a temp login role | -| `GET` | `/v1/projects/{ref}/config/database/pooler` | Bearer token | linked target from `--project-ref`, direct host unreachable, no usable saved pooler URL | -| `POST` | `/v1/projects/{ref}/network-bans/retrieve` | Bearer token | linked target, from the 3rd failed temp-role connect (list bans) | +| Method | Path | Auth | When | +| -------- | ------------------------------------------- | ------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `GET` | `/v1/projects` | Bearer token | linked target with no ref from `--project-ref`, `SUPABASE_PROJECT_ID` or `.temp/project-ref`, interactive terminal only (project picker) | +| `POST` | `/v1/projects/{ref}/cli/login-role` | Bearer token | linked target with no database password, to create a temp login role | +| `GET` | `/v1/projects/{ref}/config/database/pooler` | Bearer token | linked target from `--project-ref`, direct host unreachable, no usable saved pooler URL | +| `POST` | `/v1/projects/{ref}/network-bans/retrieve` | Bearer token | linked target, from the 3rd failed temp-role connect (list bans) | | `DELETE` | `/v1/projects/{ref}/network-bans` | Bearer token | linked target, only when bans are listed and `--yes` or a shell `SUPABASE_YES` is set (clear a self-ban); otherwise the command fails with `LinkedDbNetworkBannedError` | ## Environment Variables @@ -86,11 +86,11 @@ flags, run no query and open no connection, and exit 1 with ## Exit Codes -| Code | Condition | -| ---- | ------------------------------------------------------------------------ | -| `0` | success | +| Code | Condition | +| ---- | ---------------------------------------------------------------------------------------------------- | +| `0` | success | | `1` | conflicting target flags, empty `--db-url`/`--project-ref`, resolution, connection, or query failure | -| `1` | `--project-ref` combined with `--local` or `--db-url` | +| `1` | `--project-ref` combined with `--local` or `--db-url` | ## Telemetry Events Fired diff --git a/apps/cli/src/commands/inspect/db/inspect-query.integration.test.ts b/apps/cli/src/commands/inspect/db/inspect-query.integration.test.ts index ea44cba89e..7ac00ce877 100644 --- a/apps/cli/src/commands/inspect/db/inspect-query.integration.test.ts +++ b/apps/cli/src/commands/inspect/db/inspect-query.integration.test.ts @@ -1,8 +1,16 @@ -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 { Cause, ConfigProvider, Effect, Exit, Layer, Option, Redacted } from "effect"; +import { + Cause, + ConfigProvider, + Effect, + Exit, + FileSystem, + Layer, + Option, + Path, + Redacted, +} from "effect"; import { vi } from "vitest"; vi.mock("node:net", (importOriginal) => @@ -351,14 +359,19 @@ describe("inspect db query runner", () => { }); it.effect("follows a workspace target of remote to the linked project", () => { - mkdirSync(join(workdir.current, "supabase"), { recursive: true }); - writeFileSync(join(workdir.current, "supabase", "config.toml"), 'target = "remote"\n'); const { layer, resolver, out } = setup({ rows: [DB_STATS_ROW], workdir: workdir.current, projectId: VALID_REF, }); return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* fs.makeDirectory(path.join(workdir.current, "supabase"), { recursive: true }); + yield* fs.writeFileString( + path.join(workdir.current, "supabase", "config.toml"), + 'target = "remote"\n', + ); yield* inspectDbDbStats(flags()); expect(resolver.connectionInput?.target).toMatchObject({ kind: "linked", ref: VALID_REF }); expect(out.stderrText).toContain(unverifiedTargetBanner()); diff --git a/apps/cli/src/commands/inspect/db/inspect-query.ts b/apps/cli/src/commands/inspect/db/inspect-query.ts index 6f1d53fd17..7789591353 100644 --- a/apps/cli/src/commands/inspect/db/inspect-query.ts +++ b/apps/cli/src/commands/inspect/db/inspect-query.ts @@ -41,6 +41,11 @@ export interface InspectQuerySpec { readonly project: (row: Record<string, unknown>, cfg: ResolvedDbConfig) => ReadonlyArray<string>; } +/** + * Retained for its telemetry tag; the shared target selector now rejects conflicting target flags. + * + * @public + */ export class InspectMutuallyExclusiveFlagsError extends Data.TaggedError( "InspectMutuallyExclusiveFlagsError", )<{ readonly message: string }> { diff --git a/apps/cli/src/commands/inspect/report/SIDE_EFFECTS.md b/apps/cli/src/commands/inspect/report/SIDE_EFFECTS.md index 74e767b043..337be721c6 100644 --- a/apps/cli/src/commands/inspect/report/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/inspect/report/SIDE_EFFECTS.md @@ -8,15 +8,15 @@ for hosted-mutation consent. ## Files Read -| Path | Format | When | -| ------------------------------------------ | ---------- | -------------------------------------------------------------------------------------- | -| `<workdir>/supabase/config.toml` | TOML | always — `[experimental.inspect.rules]` (custom rules), `[db]` subtree, workspace `target` | -| `<workdir>/supabase/.env*` (nested) | dotenv | always — `env(VAR)` expansion for `[db]` and rule string fields | -| `<workdir>/supabase/.temp/pooler-url` | plain text | linked target (pooler connection string) | -| `<workdir>/supabase/.temp/project-ref` | plain text | linked target, without `--project-ref` or `SUPABASE_PROJECT_ID` (linked project ref) | -| `<workdir>/supabase/.temp/linked-project.json` | JSON | linked target, for the `Target:` banner (no API call) | -| `~/.supabase/access-token` | plain text | linked target, when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | -| `<output-dir>/<YYYY-MM-DD>/<name>.csv` ×14 | CSV | read back in-memory for rule evaluation | +| Path | Format | When | +| ---------------------------------------------- | ---------- | ------------------------------------------------------------------------------------------ | +| `<workdir>/supabase/config.toml` | TOML | always — `[experimental.inspect.rules]` (custom rules), `[db]` subtree, workspace `target` | +| `<workdir>/supabase/.env*` (nested) | dotenv | always — `env(VAR)` expansion for `[db]` and rule string fields | +| `<workdir>/supabase/.temp/pooler-url` | plain text | linked target (pooler connection string) | +| `<workdir>/supabase/.temp/project-ref` | plain text | linked target, without `--project-ref` or `SUPABASE_PROJECT_ID` (linked project ref) | +| `<workdir>/supabase/.temp/linked-project.json` | JSON | linked target, for the `Target:` banner (no API call) | +| `~/.supabase/access-token` | plain text | linked target, when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | +| `<output-dir>/<YYYY-MM-DD>/<name>.csv` ×14 | CSV | read back in-memory for rule evaluation | A **missing** `config.toml` is fine (defaults apply); a **malformed** file, an invalid value (including a non-string rule field such as `query = 123` or @@ -66,26 +66,26 @@ resolve the connection (via `DbConfigResolver`); a network ban is cleared there ## Environment Variables -| Variable | Purpose | Required? | -| ----------------------- | ---------------------------------------------------- | ------------------------------------------------------ | -| `SUPABASE_TARGET` | workspace target (`local` or `remote`) when no selector flag is given | no (defaults to `local`) | -| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no | -| `SUPABASE_YES` | shell value lets a linked connection clear a network ban | no | -| `SUPABASE_PROFILE` | built-in profile name or YAML file path | no (falls back to `~/.supabase/profile` -> `supabase`) | -| `SUPABASE_DB_*` | override `[db]` port / shadow_port / password | no | -| `SUPABASE_ENV` | selects which project `.env` files load | no | +| Variable | Purpose | Required? | +| ----------------------- | --------------------------------------------------------------------- | ------------------------------------------------------ | +| `SUPABASE_TARGET` | workspace target (`local` or `remote`) when no selector flag is given | no (defaults to `local`) | +| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no | +| `SUPABASE_YES` | shell value lets a linked connection clear a network ban | no | +| `SUPABASE_PROFILE` | built-in profile name or YAML file path | no (falls back to `~/.supabase/profile` -> `supabase`) | +| `SUPABASE_DB_*` | override `[db]` port / shadow_port / password | no | +| `SUPABASE_ENV` | selects which project `.env` files load | no | ## Exit Codes -| Code | Condition | -| ---- | -------------------------------------------------------------------------------------------- | -| `0` | success | -| `1` | mkdir failure (`failed to mkdir`) | -| `1` | DB connection / resolution failure (not linked, invalid ref, dial failure) | -| `1` | COPY failure (`failed to copy output`) / file-write failure (`failed to create output file`) | -| `1` | malformed `config.toml` | +| Code | Condition | +| ---- | ---------------------------------------------------------------------------------------------- | +| `0` | success | +| `1` | mkdir failure (`failed to mkdir`) | +| `1` | DB connection / resolution failure (not linked, invalid ref, dial failure) | +| `1` | COPY failure (`failed to copy output`) / file-write failure (`failed to create output file`) | +| `1` | malformed `config.toml` | | `1` | more than one of `--db-url` / `--linked` / `--local`, or an empty `--db-url` / `--project-ref` | -| `1` | `--project-ref` combined with `--local` or `--db-url` | +| `1` | `--project-ref` combined with `--local` or `--db-url` | A **per-rule** csvq evaluation error does **not** fail the command — it becomes the rule's STATUS cell. 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 ae475c577b..4becb71d4c 100644 --- a/apps/cli/src/commands/inspect/report/report.integration.test.ts +++ b/apps/cli/src/commands/inspect/report/report.integration.test.ts @@ -372,24 +372,21 @@ describe("inspect report", () => { }).pipe(Effect.provide(layer)); }); - it.effect( - "prints running + saved progress to stderr and the rules table to stdout", - () => { - const { layer, out } = setupReport({ csvs: DEFAULT_RULE_CSVS, isLocal: true }); - return Effect.gen(function* () { - const base = yield* tempDir("supabase-report-out-"); - yield* inspectReport(flags({ outputDir: base })); - expect(out.stderrText).not.toContain("Connecting to"); - expect(out.stderrText).toContain("Running queries..."); - expect(out.stderrText).toContain("Reports saved to "); - expect(out.stderrText).toContain("Loading default rules..."); - expect(out.stdoutText).toContain("RULE"); - expect(out.stdoutText).toContain("STATUS"); - expect(out.stdoutText).toContain("MATCHES"); - expect(out.stdoutText).toContain("No old locks"); - }).pipe(Effect.provide(layer)); - }, - ); + it.effect("prints running + saved progress to stderr and the rules table to stdout", () => { + const { layer, out } = setupReport({ csvs: DEFAULT_RULE_CSVS, isLocal: true }); + return Effect.gen(function* () { + const base = yield* tempDir("supabase-report-out-"); + yield* inspectReport(flags({ outputDir: base })); + expect(out.stderrText).not.toContain("Connecting to"); + expect(out.stderrText).toContain("Running queries..."); + expect(out.stderrText).toContain("Reports saved to "); + expect(out.stderrText).toContain("Loading default rules..."); + expect(out.stdoutText).toContain("RULE"); + expect(out.stdoutText).toContain("STATUS"); + expect(out.stdoutText).toContain("MATCHES"); + expect(out.stdoutText).toContain("No old locks"); + }).pipe(Effect.provide(layer)); + }); it.effect("shows a passing rule as ✔/- and a failing rule with its message and matches", () => { const { layer, out } = setupReport({ csvs: DEFAULT_RULE_CSVS }); From b90cfd07333e242b74cd1cda0a9382600cc4a12c Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Thu, 15 Oct 2026 14:54:49 +0100 Subject: [PATCH 57/92] feat(cli): confirm once before bootstrap creates the project and pushes to it Bootstrap asks for hosted-mutation consent (default yes) before writing anything and hands the same proof to the native push, which no longer prompts on its own. --- .../src/commands/bootstrap/SIDE_EFFECTS.md | 33 +++-- .../commands/bootstrap/bootstrap.handler.ts | 31 ++--- .../bootstrap/bootstrap.integration.test.ts | 127 ++++++++++++------ ...ootstrap.workdir-cache.integration.test.ts | 14 +- 4 files changed, 128 insertions(+), 77 deletions(-) diff --git a/apps/cli/src/commands/bootstrap/SIDE_EFFECTS.md b/apps/cli/src/commands/bootstrap/SIDE_EFFECTS.md index 4e42e962a8..3f616a2200 100644 --- a/apps/cli/src/commands/bootstrap/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/bootstrap/SIDE_EFFECTS.md @@ -1,7 +1,7 @@ # `supabase bootstrap [template]` -`bootstrap` is a meta-orchestrator: it chains a workdir prompt → template fetch/download → -blank `init` → ensure-login → `projects create` → `projects api-keys` → `link` services → +`bootstrap` is a meta-orchestrator: it chains a workdir prompt → hosted-mutation consent → +template fetch/download → blank `init` → ensure-login → `projects create` → `projects api-keys` → `link` services → health poll → write `.env` → `db push` → start suggestion. Every step is native TypeScript, including the migration push (`dbPushCore`, shared with the standalone `supabase db push` command — see Notes). @@ -68,15 +68,15 @@ neither branch ever reaches the temp-login-role/Management-API path a passwordle | `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_YES` | shell value confirms the hosted-mutation consent and the overwrite prompt; a `SUPABASE_YES` in the project `supabase/.env` does not confirm the consent | 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 -| Code | Condition | -| ---- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `0` | success | -| `1` | invalid template arg; overwrite declined (`context canceled`); template list/download failure; login failure; create failure; api-keys exhausted; health unhealthy / error status; native push failure (missing local/remote migrations, cancelled confirmation, connect/apply failure); invalid `SUPABASE_EXPERIMENTAL_STACK` on the blank path; any network failure. The `.env` derive/write is **non-fatal** (prints `Failed to create .env file: <err>` and continues). | +| Code | Condition | +| ---- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `0` | success | +| `1` | invalid template arg; overwrite declined (`context canceled`); template list/download failure; login failure; create failure; api-keys exhausted; health unhealthy / error status; hosted-mutation consent declined, or required but unavailable (JSON, piped stdin or agent run without `--yes`/shell `SUPABASE_YES`); native push failure (missing local/remote migrations, connect/apply failure); invalid `SUPABASE_EXPERIMENTAL_STACK` on the blank path; any network failure. The `.env` derive/write is **non-fatal** (prints `Failed to create .env file: <err>` and continues). | ## Telemetry @@ -96,9 +96,9 @@ neither branch ever reaches the temp-login-role/Management-API path a passwordle ### `--output-format text` stderr progress: `Using workdir …`, `Created a new project at …`, `Linking project…`, -`Checking project health…`, the native push step's own progress (`Connecting to remote -database…`, `Applying migration …`, `Seeding…`, skip/up-to-date notices, confirmation prompts — -byte-identical to the standalone `db push`, see its own `SIDE_EFFECTS.md`), and the final +`Checking project health…`, the native push step's own progress (`Applying migration …`, `Seeding…`, +skip/up-to-date notices, as in the standalone `db push`, see its own `SIDE_EFFECTS.md`; it asks +no confirmation of its own), and the final `To start your app:` suggestion (Aqua command lines). `Downloading: <url>` goes to stdout (text mode only). The `create` sub-step also echoes the new project per `-o` (`pretty|json|yaml|toml|env`); bootstrap adds no `-o` output of its own. The push step's own @@ -123,9 +123,18 @@ suppressed; a single structured result is emitted for the whole command: ## Notes +- **Hosted-mutation consent.** Bootstrap declares the `account` target group with consent + default Yes. After the template is chosen and before anything is written, it asks once: + `Create a new Supabase project from the "<template>" template?`. Declining stops the command + with nothing written. JSON, piped-stdin and agent runs never prompt and need `--yes` or a shell + `SUPABASE_YES`; under either, stderr echoes `Confirmed by --yes: Create a new Supabase project +from the "<template>" template`. A `SUPABASE_YES` in `supabase/.env` is not consent. The one + confirmation also covers the push into the new project (migrations, roles, seed, vault), so the + native push step asks nothing further and every retry reuses it. - **Native migration push, sharing `db push`'s own core (CLI-1953).** The push step calls `dbPushCore` — the same handler `db push`'s own command extracts its business logic into - — directly with `{ includeAll: false, includeRoles: true, includeSeed: true, dryRun: false }`. + — directly with `{ includeAll: false, includeRoles: true, includeSeed: true, includeVault: true, +dryRun: false }` and the consent above as its `preapproved` proof. Bootstrap never re-resolves the project ref for push: the earlier `create` step already resolved it, and it's reused as-is (passed as a plain value, never re-derived via `ProjectRefResolver`, which keys off `CommandSettings.workdir`, stale after this handler's @@ -158,7 +167,7 @@ suppressed; a single structured result is emitted for the whole command: delegation, CLI-1617, which had to route the resolved password across process boundaries). - **Retry**: the native push step is wrapped in the same `bootstrapRetryNotify` + `Effect.retry(retry)` policy as the api-keys and health-poll steps. A retried attempt re-runs - the whole push (connect, list pending migrations/seeds/roles, prompt, apply). + the whole push (connect, list pending migrations/seeds/roles, apply). - The api-keys, health, and push retries use the same backoff policy: exponential backoff, 3s initial interval, multiplier 1.5, 60s max interval (capped before jitter), ±50% jitter (randomization factor 0.5), 15m max-elapsed cap, and 8 retries (9 total diff --git a/apps/cli/src/commands/bootstrap/bootstrap.handler.ts b/apps/cli/src/commands/bootstrap/bootstrap.handler.ts index f5bcad544d..ca1bffb807 100644 --- a/apps/cli/src/commands/bootstrap/bootstrap.handler.ts +++ b/apps/cli/src/commands/bootstrap/bootstrap.handler.ts @@ -7,12 +7,7 @@ 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"; -import { - DnsResolverFlag, - WorkdirFlag, - resolveYes, - resolveYesWithProjectEnv, -} from "../../command-internal/global-flags.ts"; +import { DnsResolverFlag, WorkdirFlag, resolveYes } from "../../command-internal/global-flags.ts"; import { emitSuccessTrailer, setSuccessWorkingDirectory, @@ -28,9 +23,10 @@ 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, loadProjectEnvValues } from "../../command-internal/db-config.toml-read.ts"; +import { checkDbToml } 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 { requireHostedMutationConsent } from "../../command-internal/hosted-consent/hosted-consent.ts"; +import { planDbSeed } 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 "../../shared/config/temp-paths.ts"; @@ -140,6 +136,11 @@ export const bootstrap = Effect.fn("bootstrap")(function* ( }) : false; + const consent = yield* requireHostedMutationConsent({ + action: `Create a new Supabase project from the "${starter.name}" template`, + details: ["Push the template's migrations, roles, seed data and vault secrets to it."], + }); + yield* fs.makeDirectory(workdir, { recursive: true }); const entries = yield* fs .readDirectory(workdir) @@ -222,13 +223,9 @@ 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* loadProjectEnvValues(fs, path, workdir); - return { - pushYes: yield* resolveYesWithProjectEnv(projectEnv), - toml: yield* checkDbToml(fs, path, workdir, projectRef), - }; - }).pipe(Effect.withSpan("bootstrap.loadConfig")); + const toml = yield* checkDbToml(fs, path, workdir, projectRef).pipe( + Effect.withSpan("bootstrap.loadConfig"), + ); if (toml.appliedRemote !== undefined) { yield* output.raw(`Loading config override: [remotes.${toml.appliedRemote}]\n`, "stderr"); } @@ -333,7 +330,7 @@ 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 = yield* bootstrapRetryNotify; - const seed = yield* resolveDbSeedInput( + const seed = yield* planDbSeed( yield* configValues.load({ workdir, projectRef: Option.some(projectRef) }), { workdir, ref: projectRef }, ); @@ -351,7 +348,7 @@ export const bootstrap = Effect.fn("bootstrap")(function* ( includeVault: true, dnsResolver, toml, - yes: pushYes, + preapproved: Option.some(consent), emitStructuredResult: false, }).pipe(pushNotify, Effect.retry(retry), Effect.withSpan("bootstrap.pushMigrations")); diff --git a/apps/cli/src/commands/bootstrap/bootstrap.integration.test.ts b/apps/cli/src/commands/bootstrap/bootstrap.integration.test.ts index a594c789dd..41720e23ea 100644 --- a/apps/cli/src/commands/bootstrap/bootstrap.integration.test.ts +++ b/apps/cli/src/commands/bootstrap/bootstrap.integration.test.ts @@ -42,13 +42,16 @@ import { YesFlag, OutputFlag, } from "../../command-internal/global-flags.ts"; -import { flagInput } from "../../../tests/helpers/config-values-layer.ts"; +import { configValuesLayer, flagInput } from "../../../tests/helpers/config-values-layer.ts"; +import { describeHostedConsent } from "../../../tests/helpers/hosted-consent.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 { announceTarget } from "../../shared/output/command-target.service.ts"; import { DbConnectError } from "../../command-internal/db-connection.errors.ts"; import { DbConnection, type PgConnInput } from "../../command-internal/db-connection.service.ts"; import { debugLoggerLayer } from "../../command-internal/debug-logger.layer.ts"; +import { TargetPolicy } from "../../command-internal/target/target-policy.ts"; import { TemplateService, type StarterTemplate } from "./bootstrap.templates.ts"; import { bootstrap } from "./bootstrap.handler.ts"; import type { BootstrapFlags } from "./bootstrap.command.ts"; @@ -111,18 +114,7 @@ interface SetupOpts { readonly env?: Readonly<Record<string, string | undefined>>; } -function setup(path: Path.Path, opts: SetupOpts = {}) { - const out = mockOutput({ - format: opts.format ?? "text", - promptTextResponses: opts.promptTextResponses, - promptConfirmResponses: opts.promptConfirmResponses, - promptPasswordResponses: opts.promptPasswordResponses, - }); - const telemetry = mockTelemetryStateTracked(); - const linkedCache = mockLinkedProjectCacheTracked(); - const analytics = mockAnalytics(); - const credentials = mockCommandCredentialsTracked(); - +function bootstrapApiHandler(opts: SetupOpts) { let apiKeysCalls = 0; const createBodies: Array<unknown> = []; const handler: ApiHandler = (request, recorded) => { @@ -176,6 +168,49 @@ function setup(path: Path.Path, opts: SetupOpts = {}) { // storage/tenant version probes — best-effort, ignored. return Effect.succeed(jsonResponse(request, 404, {})); }; + return { handler, createBodies, apiKeyCalls: () => apiKeysCalls }; +} + +function fakeDbConnection(opts: SetupOpts) { + // The scratch/downloaded-template fixtures never scaffold migrations/seed.sql/roles.sql, so + // `dbPushCore` always reaches the "up to date" short-circuit right after connecting — no query + // results or edge-runtime invocation are needed. + const pushConnectCalls: Array<PgConnInput> = []; + const dbConnectionLayer = Layer.succeed(DbConnection, { + connect: (conn: PgConnInput) => + Effect.suspend(() => { + pushConnectCalls.push(conn); + // Fails the first N connect attempts (retry coverage for the push step's own retry + // wrap), succeeding after, mirroring `apiKeysFailTimes` above. + if (pushConnectCalls.length <= (opts.pushConnectFailTimes ?? 0)) { + return Effect.fail(new DbConnectError({ message: "connection refused" })); + } + return Effect.succeed({ + extensionExists: () => Effect.succeed(false), + copyToCsv: () => Effect.succeed(new Uint8Array()), + queryRaw: () => Effect.succeed({ fields: [], rows: [], commandTag: "" }), + exec: () => Effect.void, + execBatch: () => Effect.void, + query: () => Effect.succeed([]), + }); + }), + }); + return { dbConnectionLayer, pushConnectCalls }; +} + +function setup(path: Path.Path, opts: SetupOpts = {}) { + const out = mockOutput({ + format: opts.format ?? "text", + promptTextResponses: opts.promptTextResponses, + promptConfirmResponses: opts.promptConfirmResponses, + promptPasswordResponses: opts.promptPasswordResponses, + }); + const telemetry = mockTelemetryStateTracked(); + const linkedCache = mockLinkedProjectCacheTracked(); + const analytics = mockAnalytics(); + const credentials = mockCommandCredentialsTracked(); + + const { handler, createBodies, apiKeyCalls } = bootstrapApiHandler(opts); const api = mockCommandPlatformApi({ handler }); const cliSettings = mockCommandSettings({ @@ -214,29 +249,7 @@ function setup(path: Path.Path, opts: SetupOpts = {}) { }), }); - // The scratch/downloaded-template fixtures never scaffold migrations/seed.sql/roles.sql, so - // `dbPushCore` always reaches the "up to date" short-circuit right after connecting — no query - // results or edge-runtime invocation are needed. - const pushConnectCalls: Array<PgConnInput> = []; - const dbConnectionLayer = Layer.succeed(DbConnection, { - connect: (conn: PgConnInput) => - Effect.suspend(() => { - pushConnectCalls.push(conn); - // Fails the first N connect attempts (retry coverage for the push step's own retry - // wrap), succeeding after, mirroring `apiKeysFailTimes` above. - if (pushConnectCalls.length <= (opts.pushConnectFailTimes ?? 0)) { - return Effect.fail(new DbConnectError({ message: "connection refused" })); - } - return Effect.succeed({ - extensionExists: () => Effect.succeed(false), - copyToCsv: () => Effect.succeed(new Uint8Array()), - queryRaw: () => Effect.succeed({ fields: [], rows: [], commandTag: "" }), - exec: () => Effect.void, - execBatch: () => Effect.void, - query: () => Effect.succeed([]), - }); - }), - }); + const { dbConnectionLayer, pushConnectCalls } = fakeDbConnection(opts); const loginApi = mockLoginApi({ gotrueId: "gotrue-user" }); const loginCrypto = mockLoginCrypto(); @@ -263,6 +276,7 @@ function setup(path: Path.Path, opts: SetupOpts = {}) { Layer.succeed(OutputFlag, Option.none()), Layer.succeed(WorkdirFlag, opts.workdir ?? Option.some(tempRoot.current)), Layer.succeed(YesFlag, opts.yes ?? false), + Layer.succeed(TargetPolicy, { group: "account", consent: "yes" }), Layer.succeed(DebugFlag, opts.debug ?? false), Layer.succeed(DnsResolverFlag, "native"), Layer.succeed(NetworkIdFlag, Option.none()), @@ -286,7 +300,7 @@ function setup(path: Path.Path, opts: SetupOpts = {}) { pushConnectCalls, loginApi, get apiKeysCalls() { - return apiKeysCalls; + return apiKeyCalls(); }, }; } @@ -457,7 +471,7 @@ describe("bootstrap integration", () => { Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const s = setup(path, { promptConfirmResponses: [false] }); + const s = setup(path, { promptConfirmResponses: [true, false] }); yield* fs.writeFileString(path.join(tempRoot.current, "existing.txt"), "keep me"); const exit = yield* Effect.exit( bootstrap(flags({ template: Option.some("scratch") }), FAST_BACKOFF).pipe( @@ -624,7 +638,6 @@ describe("bootstrap integration", () => { expect(s.pushConnectCalls).toHaveLength(1); expect(s.pushConnectCalls[0]?.host).toBe("aws-0-us-east-1.pooler.supabase.com"); expect(s.pushConnectCalls[0]?.user).toBe(`postgres.${VALID_REF}`); - expect(s.out.stderrText).toContain("Connecting to remote database..."); expect(s.out.stdoutText).toContain("Remote database is up to date."); }).pipe(Effect.provide(BunServices.layer)), ); @@ -720,7 +733,7 @@ describe("bootstrap integration", () => { it.live("emits a single structured result in json mode", () => Effect.gen(function* () { const path = yield* Path.Path; - const s = setup(path, { format: "json" }); + const s = setup(path, { format: "json", yes: true }); yield* bootstrap(flags({ template: Option.some("scratch") }), FAST_BACKOFF).pipe( Effect.provide(s.layer), ); @@ -740,7 +753,7 @@ describe("bootstrap integration", () => { Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const s = setup(path, { format: "json" }); + const s = setup(path, { format: "json", yes: true }); yield* fs.makeDirectory(tempRoot.current, { recursive: true }); yield* fs.writeFileString(path.join(tempRoot.current, ".env.example"), "!="); yield* bootstrap(flags({ template: Option.some("scratch") }), FAST_BACKOFF).pipe( @@ -751,3 +764,35 @@ describe("bootstrap integration", () => { }).pipe(Effect.provide(BunServices.layer)), ); }); + +describeHostedConsent("bootstrap", { + api: { handler: bootstrapApiHandler({}).handler }, + invoke: ({ workdir, out }) => { + const login = mockLoginApi({ gotrueId: "gotrue-user" }); + const substrate = Layer.mergeAll( + Layer.succeed(TemplateService, { + listSamples: Effect.succeed([]), + download: () => Effect.void, + }), + fakeDbConnection({}).dbConnectionLayer, + login.layer, + mockLoginCrypto().layer, + mockBrowser(), + Layer.succeed(WorkdirFlag, Option.some(workdir)), + Layer.succeed(DnsResolverFlag, "native"), + Layer.succeed(NetworkIdFlag, Option.none()), + Layer.succeed(DebugFlag, false), + debugLoggerLayer.pipe(Layer.provide(Layer.succeed(DebugFlag, false))), + configValuesLayer({ + output: out.layer, + flags: [flagInput("linkedDb.password", "password", "s3cret")], + }), + ); + return { + run: announceTarget({ text: "account", json: { kind: "account" } }).pipe( + Effect.andThen(bootstrap(flags({ template: Option.some("scratch") }), FAST_BACKOFF)), + Effect.provide(substrate), + ), + }; + }, +}); 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 e6c90d1f07..b7cbc2b184 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 @@ -35,6 +35,7 @@ import { CliArgs } from "../../shared/cli/cli-args.service.ts"; import { SuccessTrailer, successTrailerLayer } from "../../shared/cli/success-trailer.ts"; import { DbConnection, type PgConnInput } from "../../command-internal/db-connection.service.ts"; import { debugLoggerLayer } from "../../command-internal/debug-logger.layer.ts"; +import { TargetPolicy } from "../../command-internal/target/target-policy.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"; @@ -167,6 +168,7 @@ describe("bootstrap linked-project cache location", () => { Layer.succeed(ProfileFlag, "supabase"), Layer.succeed(WorkdirFlag, Option.none()), Layer.succeed(YesFlag, false), + Layer.succeed(TargetPolicy, { group: "account", consent: "yes" }), Layer.succeed(OutputFlag, Option.none()), Layer.succeed(DebugFlag, false), Layer.succeed(DnsResolverFlag, "native"), @@ -298,13 +300,11 @@ describe("bootstrap linked-project cache location", () => { expect(pushConnectCalls[0]?.host).toBe("aws-0-us-east-1.pooler.supabase.com"); expect(pushConnectCalls[0]?.user).toBe(`postgres.${VALID_REF}`); expect(out.stderrText).toContain("Applying migration 20240101000000_test.sql..."); - // The confirm prompt is interactive UI (clack), not `output.raw` text, so it's recorded - // in `promptConfirmCalls`, not `stderrText`. - expect( - out.promptConfirmCalls.some((c) => - c.message.includes("Do you want to create custom roles in the database cluster?"), - ), - ).toBe(true); + const questions = out.promptConfirmCalls.map((c) => c.message); + expect(questions).toEqual([ + expect.stringContaining("Create a new Supabase project from the"), + expect.stringContaining("overwrite existing files"), + ]); expect(out.stderrText).toContain("Seeding globals from roles.sql..."); // With no `supabase/seed.sql` file, the seed glob matches nothing, so this line only // prints when `includeSeed` is true. From 2673ef9b4e9a04d1a06f56af23eecb948e2453c0 Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Thu, 15 Oct 2026 14:56:15 +0100 Subject: [PATCH 58/92] docs(cli): describe db push's default target and combined consent --- apps/cli/docs/supabase/db/push.md | 4 +- apps/cli/src/commands/db/push/SIDE_EFFECTS.md | 91 +++++++++---------- 2 files changed, 47 insertions(+), 48 deletions(-) diff --git a/apps/cli/docs/supabase/db/push.md b/apps/cli/docs/supabase/db/push.md index fe0893f90c..19526da48e 100644 --- a/apps/cli/docs/supabase/db/push.md +++ b/apps/cli/docs/supabase/db/push.md @@ -2,7 +2,9 @@ Pushes all local migrations to a remote database. -Requires your local project to be linked to a remote database by running `supabase link`. For self-hosted databases, you can pass in the connection parameters using `--db-url` flag. +Pushes to the linked project by default, so your local project must be linked to a remote database by running `supabase link`. For self-hosted databases, you can pass in the connection parameters using `--db-url` flag; use `--local` to push to the local database instead. + +Before changing a remote database, the command lists what it will do (migrations, custom roles, seed files and vault secrets) and asks once for confirmation, defaulting to No. Pass `--yes` to confirm without a prompt; JSON output, piped input and agent runs need it. A `SUPABASE_YES` set in `supabase/.env` does not confirm, but one set in your shell does. The first time this command is run, a migration history table will be created under `supabase_migrations.schema_migrations`. After successfully applying a migration, a new row will be inserted into the migration history table with timestamp as its unique id. Subsequent pushes will skip migrations that have already been applied. diff --git a/apps/cli/src/commands/db/push/SIDE_EFFECTS.md b/apps/cli/src/commands/db/push/SIDE_EFFECTS.md index 2146375456..4cf48e6c00 100644 --- a/apps/cli/src/commands/db/push/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/db/push/SIDE_EFFECTS.md @@ -1,8 +1,9 @@ # `supabase db push` Applies pending local migrations (and optionally seed data and custom roles) to -the local or linked/remote Postgres database, updating configured Vault secrets -before migrations unless `--skip-vault` is set. +the linked project's Postgres database, or to the database `--local` or `--db-url` +selects, updating configured Vault secrets before migrations unless `--skip-vault` +is set. A hosted target asks one combined confirmation before the first write. ## Files Read @@ -49,38 +50,36 @@ before migrations unless `--skip-vault` is set. | `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_YES` | shell value confirms the hosted-mutation consent and lets the connection clear a network ban; a `SUPABASE_YES` in `supabase/.env` does not | 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` | 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) | +| Code | Condition | +| ---- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `0` | success (including "up to date") | +| `1` | more than one of `--local`, `--linked`, `--db-url`, or `--project-ref` with `--local`/`--db-url` (target selector conflict) | +| `1` | `ErrMissingLocal` — remote versions absent locally (suggests repair/pull) | +| `1` | `ErrMissingRemote` without `--include-all` (suggests `--include-all`) | +| `1` | the combined confirmation declined at the prompt (`HostedMutationDeclinedError`); nothing is written | +| `1` | the combined confirmation needed but the run can't prompt (JSON or machine output, piped stdin, agent) and neither `--yes` nor a shell `SUPABASE_YES` is set (`HostedMutationConsentRequiredError`); nothing is written | +| `1` | `--password` with `--db-url` or `--local` (`--password can't be used with --db-url. …` / `--password only applies to a linked project. …`) | +| `1` | an invalid config value, including an unparsable `SUPABASE_*` override | +| `1` | `config.toml` parse failure | +| `1` | database connection / migration / seed / roles / vault apply failure | ## Output -Diagnostics ("Connecting to…", "Applying migration…", "Seeding…", "Updating vault -secrets…", skip/up-to-date notices, dry-run plan, prompts) go to **stderr**. The +Diagnostics (the `Target: …` banner, "Applying migration…", "Seeding…", "Updating vault +secrets…", skip/up-to-date notices, dry-run plan, the confirmation) go to **stderr**. The two summary lines — `<Target> is up to date.` and `Finished supabase db push.` (the command name in Aqua) — go to stdout in text mode; in machine modes they are suppressed and a structured result is emitted. ### `--output-format text` -Connection status, per-item progress, prompts, and the stdout +The `Target:` banner, per-item progress, the confirmation, and the stdout summary line, including ANSI color (Aqua command name, Bold file paths). ### `--output-format json` / `stream-json` @@ -93,20 +92,22 @@ stdout is payload-only. A single `result` object is emitted: "dryRun": false, "migrations": ["<file>.sql"], "seeds": ["supabase/seed.sql"], - "roles": ["supabase/roles.sql"] + "roles": ["supabase/roles.sql"], + "target": { "kind": "linked", "project_ref": "<ref>", "environment": "production" } } ``` +`target` is the announced target (linked, local, or `db-url`); the linked shape carries the +project ref and its environment. + ## Notes -- **Targets**: `--db-url`, `--linked` (default), and `--local` are mutually - exclusive; with no flag the target defaults to linked. -- **`--project-ref`** overrides ONLY the linked-ref resolution `ProjectRefResolver` - performs (flag > `SUPABASE_PROJECT_ID` > `~/.supabase/<hash>/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 simply goes unused on a - non-linked target). +- **Targets**: `--db-url`, `--linked`, and `--local` are mutually exclusive; with no + flag the target is the linked project. The target is announced as a `Target:` banner on + stderr before anything is connected. +- **`--project-ref`** selects the linked project (flag > `SUPABASE_PROJECT_ID` > + `~/.supabase/<hash>/project-ref`) and implies `--linked`; combining it with `--local` or + `--db-url` is a hard error rather than a silently discarded flag. - **Config value precedence** (ADR 0032): 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 @@ -117,26 +118,22 @@ stdout is payload-only. A single `result` object is emitted: 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.<name>]` block that does not itself declare `db.seed.enabled = true`, - and there are seeds to apply, the command asks - `Project <ref> matches [remotes.<name>]. Run <n> seed file(s) (<paths>) against it?` - (default no) before the roles prompt. `--yes`/`SUPABASE_YES` answers yes and - prints `Seeding enabled by <origin>` (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 <ref> ([remotes.<name>]) 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 `</dev/null`) fails the same way. An - answer of no exits 1 with `Seeding cancelled; nothing was changed.` - `--dry-run` prints whether a real run will ask or will need `--yes`. +- **One combined confirmation**: after the plan is known and before the first write, a hosted + target asks once, for everything the run will do — custom roles, migrations, vault secrets, + seed files — e.g. `Create custom roles, apply 1 migration and seed 1 file?`, listing each + item. The default is No; declining exits 1 with nothing written. A `--local` target and + `--dry-run` ask nothing, and neither does a run that finds nothing to apply. JSON or machine + output, piped stdin and agent runs never prompt; they need `--yes` or a shell + `SUPABASE_YES`, which echoes `Confirmed by --yes: <action>` to stderr. A `SUPABASE_YES` in + `supabase/.env` is not consent. The same approval lets the connection remove a network ban + that blocks the temporary login role; without it the run fails and suggests + `supabase network-bans remove`. - **Seeding** requires `--include-seed`; `SUPABASE_DB_SEED_ENABLED=true` alone does not make push seed. `--include-seed` beats `[db.seed] enabled = false` - in the base config, and the matched remote still asks for consent unless it - declares `enabled = true` itself. -- **Prompt order**: seed consent (matched remote only) → custom roles → - migrations → seeds (not asked again once seed consent is given); each prompt except - seed consent defaults to "yes" and declining returns `context canceled`. + in the base config. When the target matches a `[remotes.<name>]` block that does not + declare `db.seed.enabled = true` itself, the confirmation names that block and the + setting that enabled seeding (`seed 1 file into [remotes.<name>] (seeding enabled by +<origin>)`). - **`--dry-run`** prints the plan (roles / migrations / seeds) and applies nothing. - **`[db.migrations].enabled = false`** / **`[db.seed].enabled = false`** print a skip notice naming the project ref (empty for local/db-url). From 79751978a0f518d3e26cb186c31217faf7b47f46 Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Thu, 15 Oct 2026 14:58:42 +0100 Subject: [PATCH 59/92] chore(cli): retire the prompt-era target and cancel errors and tidy sync-group tests --- apps/cli/src/commands/db/pull/pull.errors.ts | 6 ++---- apps/cli/src/commands/db/push/push.errors.ts | 12 ++++-------- .../src/commands/db/push/push.integration.test.ts | 2 +- apps/cli/src/commands/pull/pull.integration.test.ts | 2 +- 4 files changed, 8 insertions(+), 14 deletions(-) diff --git a/apps/cli/src/commands/db/pull/pull.errors.ts b/apps/cli/src/commands/db/pull/pull.errors.ts index 93b9bd9596..b55cf7ab0e 100644 --- a/apps/cli/src/commands/db/pull/pull.errors.ts +++ b/apps/cli/src/commands/db/pull/pull.errors.ts @@ -6,10 +6,8 @@ import { ErrorActionabilityId, } from "../../../shared/telemetry/error-actionability.ts"; -/** - * Conflicting database-target flags (`db-url`/`linked`/`local`); message text - * is an established output contract. - */ +// TODO(CLI-2695): delete with its entry in the error-tag snapshot; the target selector replaced it. +/** @public */ export class DbPullTargetFlagsError extends Data.TaggedError("DbPullTargetFlagsError")<{ readonly message: string; }> { diff --git a/apps/cli/src/commands/db/push/push.errors.ts b/apps/cli/src/commands/db/push/push.errors.ts index e99dabf19c..e9e068a5bf 100644 --- a/apps/cli/src/commands/db/push/push.errors.ts +++ b/apps/cli/src/commands/db/push/push.errors.ts @@ -5,10 +5,8 @@ import { ErrorActionabilityId, } from "../../../shared/telemetry/error-actionability.ts"; -/** - * Conflicting database-target flags (`db-url`/`linked`/`local`); message text - * is an established output contract. - */ +// TODO(CLI-2695): delete with its entry in the error-tag snapshot; the target selector replaced it. +/** @public */ export class DbPushTargetFlagsError extends Data.TaggedError("DbPushTargetFlagsError")<{ readonly message: string; }> { @@ -45,10 +43,8 @@ export class DbPushMissingRemoteError extends Data.TaggedError("DbPushMissingRem } } -/** - * The user declined a confirmation prompt; message text (`context canceled`) - * is an established output contract. - */ +// TODO(CLI-2695): delete with its entry in the error-tag snapshot; hosted consent replaced it. +/** @public */ export class DbPushCancelledError extends Data.TaggedError("DbPushCancelledError")<{ readonly message: string; readonly suggestion?: string; 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 f3919fd759..0b9332e8fe 100644 --- a/apps/cli/src/commands/db/push/push.integration.test.ts +++ b/apps/cli/src/commands/db/push/push.integration.test.ts @@ -1016,7 +1016,7 @@ describe("db push", () => { }); it.effect("still validates non-vault secrets with --skip-vault", () => { - const { layer, out, conn } = setup(tmp.current, { + const { layer, conn } = setup(tmp.current, { toml: 'project_id = "test"\n\n[db]\nroot_key = "encrypted:not-valid"\n', files: migrationFile("20240101000000"), }); diff --git a/apps/cli/src/commands/pull/pull.integration.test.ts b/apps/cli/src/commands/pull/pull.integration.test.ts index 1da3a11859..3b8df18dac 100644 --- a/apps/cli/src/commands/pull/pull.integration.test.ts +++ b/apps/cli/src/commands/pull/pull.integration.test.ts @@ -850,7 +850,7 @@ describe("pull integration", () => { yield* fs.makeDirectory(yield* projectPath("supabase", ".temp"), { recursive: true }); yield* fs.writeFileString( yield* projectPath("supabase", ".temp", "linked-project.json"), - JSON.stringify({ ref: VALID_REF, name: "Acme" }), + `{"ref":"${VALID_REF}","name":"Acme"}`, ); yield* runPull(pullFlags()); From 12fc793f37df66e77520b53fab03df065871a156 Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Thu, 15 Oct 2026 15:02:02 +0100 Subject: [PATCH 60/92] fix(cli): reject transaction control under db query --read-only (CLI-2695) --- apps/cli/docs/supabase/db/query.md | 2 +- .../src/command-internal/migration-apply.ts | 30 +----- apps/cli/src/command-internal/sql-split.ts | 29 +++-- .../command-internal/sql-split.unit.test.ts | 34 +++++- .../cli/src/commands/db/query/SIDE_EFFECTS.md | 10 +- .../cli/src/commands/db/query/query.errors.ts | 12 +++ .../src/commands/db/query/query.handler.ts | 15 +++ .../db/query/query.integration.test.ts | 58 +++++++++- .../src/commands/db/query/query.read-only.ts | 67 ++++++++++++ .../db/query/query.read-only.unit.test.ts | 102 ++++++++++++++++++ .../telemetry/__fixtures__/error-tags.txt | 1 + 11 files changed, 320 insertions(+), 40 deletions(-) create mode 100644 apps/cli/src/commands/db/query/query.read-only.ts create mode 100644 apps/cli/src/commands/db/query/query.read-only.unit.test.ts diff --git a/apps/cli/docs/supabase/db/query.md b/apps/cli/docs/supabase/db/query.md index 7eddc54c33..0759a2964b 100644 --- a/apps/cli/docs/supabase/db/query.md +++ b/apps/cli/docs/supabase/db/query.md @@ -6,7 +6,7 @@ With no target flag the command runs against the workspace target, which is the The command cannot tell a read from a write, so a hosted database (the linked project or a remote `--db-url`) asks for one confirmation (default No) before the statement is sent. Piped answers are never read, so a script, a JSON run or an agent must pass `--yes` or set `SUPABASE_YES` in the shell; a `SUPABASE_YES` in `supabase/.env` does not count. The local database never asks. -Pass `--read-only` to run a statement that must not change anything. It skips the confirmation on a hosted database and makes the statement unable to write: a linked project runs it as a read-only query through the Management API, and the local database or a `--db-url` database runs it in a read-only transaction that is always rolled back. A statement that ends the transaction itself, such as `COMMIT`, can leave the read-only guard on a direct connection. +Pass `--read-only` to run a statement that must not change anything. It skips the confirmation on a hosted database and makes the statement unable to write: a linked project runs it as a read-only query through the Management API, and the local database or a `--db-url` database runs it in a read-only transaction that is always rolled back. `--read-only` rejects, before the statement is sent or any question is asked, any statement that begins or ends a transaction (`BEGIN`, `START TRANSACTION`, `COMMIT`, `END`, `ABORT`, `ROLLBACK`, `PREPARE TRANSACTION`) or changes read-only mode (`SET TRANSACTION`, `SET SESSION CHARACTERISTICS`, `SET` or `RESET` of `transaction_read_only` or `default_transaction_read_only`, `RESET ALL`). Drop `--read-only` to run one of these. When used by an AI coding agent (auto-detected or via --agent=yes), the default output format is JSON with an untrusted data warning envelope. When used by a diff --git a/apps/cli/src/command-internal/migration-apply.ts b/apps/cli/src/command-internal/migration-apply.ts index b93c51b7da..9c69929a0e 100644 --- a/apps/cli/src/command-internal/migration-apply.ts +++ b/apps/cli/src/command-internal/migration-apply.ts @@ -19,7 +19,7 @@ import { } from "./migration-history.ts"; import { parseMigrationContent } from "./migration-file.ts"; import { sqlFilesGlob } from "./sql-files-glob.ts"; -import { splitSqlTokens } from "./sql-split.ts"; +import { splitSqlTokens, trimLeadingSqlComments } from "./sql-split.ts"; /** * A migration file failed to apply. Used by `migration up`/`down`'s migrate-and-seed step; the @@ -40,9 +40,6 @@ export class MigrationApplyError extends Data.TaggedError("MigrationApplyError") } } -// Byte order mark (U+FEFF), stripped from the head of a statement. -const BOM_CODE_POINT = 0xfeff; - // Statements that PostgreSQL refuses to run inside a transaction block / extended-query // pipeline (SQLSTATE 25001). Matched against the upper-cased, comment-stripped statement. // @@ -58,31 +55,6 @@ const CLUSTER_PATTERN = /^CLUSTER(?:\s|$)/u; const TRANSACTION_CONTROL_PATTERN = /^(?:BEGIN|START\s+TRANSACTION|COMMIT|END|ABORT|PREPARE\s+TRANSACTION)(?:\s|$)/u; -/** - * Strips a leading BOM, whitespace, and SQL line (`--`) and block comments from the - * front of a statement so the keyword check below sees the real first token. - */ -const trimLeadingSqlComments = (sql: string): string => { - // Stripped via code point comparison so it isn't relied on to match a whitespace regex class. - let trimmed = sql.replace(/^[ \t\n\r]+/u, ""); - while (trimmed.charCodeAt(0) === BOM_CODE_POINT) { - trimmed = trimmed.slice(1).replace(/^[ \t\n\r]+/u, ""); - } - for (;;) { - if (trimmed.startsWith("--")) { - const idx = trimmed.indexOf("\n"); - if (idx < 0) return ""; - trimmed = trimmed.slice(idx + 1).replace(/^[ \t\n\r]+/u, ""); - } else if (trimmed.startsWith("/*")) { - const idx = trimmed.indexOf("*/"); - if (idx < 0) return trimmed; - trimmed = trimmed.slice(idx + 2).replace(/^[ \t\n\r]+/u, ""); - } else { - return trimmed.trim(); - } - } -}; - /** * Whether a migration statement cannot run inside a transaction block — `CREATE [UNIQUE] INDEX * CONCURRENTLY`, `DROP INDEX CONCURRENTLY`, `REINDEX … CONCURRENTLY`, `VACUUM`, `ALTER SYSTEM`, diff --git a/apps/cli/src/command-internal/sql-split.ts b/apps/cli/src/command-internal/sql-split.ts index 85b1b799db..f1423b9bf4 100644 --- a/apps/cli/src/command-internal/sql-split.ts +++ b/apps/cli/src/command-internal/sql-split.ts @@ -50,14 +50,16 @@ function isBeginAtomic(data: string): boolean { return endsWithKeyword(data.slice(0, end), "BEGIN"); } -function isCommentsAndWhitespace(text: string): boolean { +// Index of the first character past leading blanks and comments, or `text.length` when only +// trivia (or an unterminated comment) remains. +function skipLeadingTrivia(text: string, isBlank: (char: string) => boolean): number { let i = 0; while (i < text.length) { - if (isSqlWhitespace(text[i]!)) { + if (isBlank(text[i]!)) { i += 1; } else if (text.startsWith("--", i)) { const newline = text.slice(i + 2).search(/[\n\r]/u); - if (newline === -1) return true; + if (newline === -1) return text.length; i += newline + 3; } else if (text.startsWith("/*", i)) { // Match `BlockState`'s sliding-window scan so both agree on overlapping delimiters. @@ -69,14 +71,29 @@ function isCommentsAndWhitespace(text: string): boolean { else if (window === "*/") depth -= 1; i += 1; } - if (depth > 0) return true; + if (depth > 0) return text.length; } else { - return false; + return i; } } - return true; + return text.length; } +const isCommentsAndWhitespace = (text: string): boolean => + skipLeadingTrivia(text, isSqlWhitespace) === text.length; + +const BYTE_ORDER_MARK = "\uFEFF"; + +/** + * Strips leading whitespace, byte order marks and line/block comments (block comments nest, as + * PostgreSQL scans them) so a keyword check sees the statement's first token. Empty when only + * trivia or an unterminated comment remains. + */ +export const trimLeadingSqlComments = (sql: string): string => + sql + .slice(skipLeadingTrivia(sql, (char) => isSqlWhitespace(char) || char === BYTE_ORDER_MARK)) + .trim(); + class ReadyState implements State { next(char: string, data: string): State | null { switch (char) { diff --git a/apps/cli/src/command-internal/sql-split.unit.test.ts b/apps/cli/src/command-internal/sql-split.unit.test.ts index 374275125c..4739dff325 100644 --- a/apps/cli/src/command-internal/sql-split.unit.test.ts +++ b/apps/cli/src/command-internal/sql-split.unit.test.ts @@ -1,6 +1,12 @@ import { describe, expect, it } from "vitest"; -import { findDropStatements, splitAndTrim, splitSql, splitSqlTokens } from "./sql-split.ts"; +import { + findDropStatements, + splitAndTrim, + splitSql, + splitSqlTokens, + trimLeadingSqlComments, +} from "./sql-split.ts"; describe("splitAndTrim", () => { it("splits simple statements and trims trailing ; + whitespace", () => { @@ -280,3 +286,29 @@ describe("findDropStatements", () => { expect(findDropStatements(sql)).toEqual(["DROP TABLE real"]); }); }); + +describe("trimLeadingSqlComments", () => { + it.each([ + ["whitespace", " \t\n\r\f\vSELECT 1"], + ["a byte order mark", "\uFEFF SELECT 1"], + ["a line comment", "-- a\nSELECT 1"], + ["a carriage-return line comment", "-- a\rSELECT 1"], + ["a block comment", "/* a */SELECT 1"], + ["a nested block comment", "/* a /* b */ c */ SELECT 1"], + ["stacked comments and blanks", "\uFEFF-- a\n /* b */ \n-- c\nSELECT 1"], + ])("strips %s", (_name, sql) => { + expect(trimLeadingSqlComments(sql)).toBe("SELECT 1"); + }); + + it("keeps comments that are not at the start", () => { + expect(trimLeadingSqlComments("SELECT 1 -- a\n")).toBe("SELECT 1 -- a"); + }); + + it.each([ + ["a line comment without a newline", "-- a"], + ["an unterminated block comment", "/* a SELECT 1"], + ["only whitespace", " \n"], + ])("returns empty for %s", (_name, sql) => { + expect(trimLeadingSqlComments(sql)).toBe(""); + }); +}); diff --git a/apps/cli/src/commands/db/query/SIDE_EFFECTS.md b/apps/cli/src/commands/db/query/SIDE_EFFECTS.md index 3118304145..79e4e00c20 100644 --- a/apps/cli/src/commands/db/query/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/db/query/SIDE_EFFECTS.md @@ -54,6 +54,7 @@ confirmation and makes the statement unable to write (see Notes). The local stac | `1` | more than one of `--db-url`/`--linked`/`--local` (`TargetSelectorConflictError`); no SQL provided; empty stdin; unreadable `--file`; linked target without login; query exec failure; non-201 linked status | | `1` | `--project-ref` with `--local` or `--db-url` (`TargetProjectRefConflictError`) | | `1` | hosted target without `--read-only` in a JSON, piped, agent or non-TTY run without `--yes` or a shell `SUPABASE_YES` (`HostedMutationConsentRequiredError`); nothing is sent | +| `1` | `--read-only` with transaction control or a read-only setting change (`DbQueryReadOnlyStatementError`); nothing is sent | | `1` | hosted target confirmation declined (`HostedMutationDeclinedError`, `context canceled`); nothing is sent | ## Output @@ -109,8 +110,13 @@ from the environment. Agent mode defaults the format to JSON (table for humans). - **`--read-only`.** Skips the hosted confirmation entirely and prints no question. A linked target sends `"read_only": true` in the Management API request, so the server rejects writes. The local stack and a `--db-url` database run the statement inside `BEGIN READ ONLY` … `ROLLBACK`, - so Postgres rejects writes. A statement that ends the transaction itself (`COMMIT`, - `SET TRANSACTION READ WRITE`) can leave that guard on a direct connection. + so Postgres rejects writes. On every target, before the statement is sent or any question is asked, the + SQL is split into statements and any whose leading keywords (after whitespace, a BOM and comments) + are `BEGIN`, `START TRANSACTION`, `COMMIT`, `END`, `ABORT`, `ROLLBACK`, `PREPARE TRANSACTION`, + `SET TRANSACTION`, `SET SESSION CHARACTERISTICS`, `RESET ALL`, or a `SET`/`SET SESSION`/`SET LOCAL`/ + `RESET` of `transaction_read_only` or `default_transaction_read_only` fails with + `DbQueryReadOnlyStatementError` naming only that keyword. Keywords inside strings, dollar quotes + and comments, and reads such as `SELECT set_config(...)`, pass. - **Linked-project cache.** On the `--linked` path, after the query runs — whether it succeeds or fails — the handler issues `GET /v1/projects/{ref}` and writes `supabase/.temp/linked-project.json`. The diff --git a/apps/cli/src/commands/db/query/query.errors.ts b/apps/cli/src/commands/db/query/query.errors.ts index ba73122991..5b4ba04c7d 100644 --- a/apps/cli/src/commands/db/query/query.errors.ts +++ b/apps/cli/src/commands/db/query/query.errors.ts @@ -50,6 +50,18 @@ export class DbQueryLoginRequiredError extends Data.TaggedError("DbQueryLoginReq } } +/** `--read-only` was combined with SQL that ends the transaction or changes its read-only mode. */ +export class DbQueryReadOnlyStatementError extends Data.TaggedError( + "DbQueryReadOnlyStatementError", +)<{ + readonly message: string; + readonly suggestion: string; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.invalidInput; + } +} + /** Query execution failed; message text (`"failed to execute query: " + err`) is an established output contract. */ export class DbQueryExecError extends Data.TaggedError("DbQueryExecError")<{ readonly message: string; diff --git a/apps/cli/src/commands/db/query/query.handler.ts b/apps/cli/src/commands/db/query/query.handler.ts index aa01b1d864..b4a2fe7e87 100644 --- a/apps/cli/src/commands/db/query/query.handler.ts +++ b/apps/cli/src/commands/db/query/query.handler.ts @@ -35,12 +35,14 @@ import { Stdin } from "../../../shared/runtime/stdin.service.ts"; import { AiTool } from "../../../shared/telemetry/ai-tool.service.ts"; import type { DbQueryFlags } from "./query.command.ts"; import { RLS_CHECK_SQL, buildRlsAdvisory } from "./query.advisory.ts"; +import { findReadOnlyViolation } from "./query.read-only.ts"; import { DbQueryExecError, DbQueryLoginRequiredError, DbQueryNoSqlError, DbQueryNoStdinSqlError, DbQueryReadFileError, + DbQueryReadOnlyStatementError, DbQueryUnexpectedStatusError, } from "./query.errors.ts"; import { @@ -350,6 +352,19 @@ export const dbQuery = Effect.fn("db.query")(function* (flags: DbQueryFlags) { "query.read_only": flags.readOnly, }); + // `--read-only` skips consent only because the statement cannot write, so SQL that ends the + // transaction or flips its read-only mode is refused before anything connects or prompts. + if (flags.readOnly) { + const violation = findReadOnlyViolation(sql); + if (violation !== undefined) { + return yield* new DbQueryReadOnlyStatementError({ + message: `--read-only does not allow transaction control or read-only setting changes (found: ${violation})`, + suggestion: + "Drop --read-only to run this statement; a hosted database then asks you to confirm the write.", + }); + } + } + // 3. A hosted statement may write, so it needs consent unless `--read-only` confines it. if (!flags.readOnly) { yield* requireTargetMutationConsent(connection, { 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 a6291f45df..0aace4c835 100644 --- a/apps/cli/src/commands/db/query/query.integration.test.ts +++ b/apps/cli/src/commands/db/query/query.integration.test.ts @@ -82,6 +82,7 @@ import { } from "../../../../tests/helpers/permission-real-layers.ts"; import { RLS_CHECK_SQL } from "./query.advisory.ts"; import type { DbQueryFlags } from "./query.command.ts"; +import { DbQueryReadOnlyStatementError } from "./query.errors.ts"; import { dbQuery } from "./query.handler.ts"; const LOCAL_CONN: PgConnInput = { @@ -128,6 +129,7 @@ function mockDbConnection(opts: { queryFails?: boolean; }) { const statements: Array<string> = []; + let connects = 0; let inReadOnly = false; const layer = Layer.succeed(DbConnection, { connect: () => @@ -166,13 +168,22 @@ function mockDbConnection(opts: { ? Effect.fail(new DbExecError({ message: "failed to execute query: boom" })) : Effect.succeed(opts.result ?? { fields: [], rows: [], commandTag: "CREATE TABLE" }); }), - }), + }).pipe( + Effect.tap(() => + Effect.sync(() => { + connects += 1; + }), + ), + ), }); return { layer, get statements() { return statements; }, + get connects() { + return connects; + }, }; } @@ -1233,6 +1244,51 @@ describe("db query integration", () => { }).pipe(Effect.provide(layer)); }); + const rejectedTargets = [ + ["a local target", { local: true }, {}], + ["a --db-url target", { dbUrl: Option.some("postgres://x/y") }, { isLocal: false }], + ["a linked target", { linked: true }, {}], + ] as const; + for (const [name, target, opts] of rejectedTargets) { + it.effect( + `refuses COMMIT; DELETE before connecting, calling the API or asking on ${name}`, + () => { + const sessionOpts = { + ...opts, + result: SELECT_RESULT, + linkedStatus: 201, + linkedBody: '[{"id":1}]', + yes: false, + interactive: true, + confirm: [true], + }; + const rejected = setup(sessionOpts); + const read = setup(sessionOpts); + return Effect.gen(function* () { + const exit = yield* dbQuery( + flags({ ...target, sql: Option.some("COMMIT; DELETE FROM users"), readOnly: true }), + ).pipe(Effect.provide(rejected.layer), Effect.exit); + expect(Exit.isFailure(exit) && Cause.squash(exit.cause)).toBeInstanceOf( + DbQueryReadOnlyStatementError, + ); + expect(failMessage(exit)).toBe( + "--read-only does not allow transaction control or read-only setting changes (found: COMMIT)", + ); + expect(rejected.dbConnection.connects).toBe(0); + expect(rejected.dbConnection.statements).toEqual([]); + expect(rejected.httpClient.requests).toEqual([]); + expect(rejected.out.promptConfirmCalls).toEqual([]); + + yield* dbQuery( + flags({ ...target, sql: Option.some("select * from users"), readOnly: true }), + ).pipe(Effect.provide(read.layer)); + expect(read.out.promptConfirmCalls).toEqual([]); + expect(read.out.stdoutText).not.toBe(""); + }); + }, + ); + } + it.effect("omits read_only from the Management API request by default", () => { const { layer, httpClient } = setup({ linkedStatus: 201, linkedBody: '[{"id":1}]' }); return Effect.gen(function* () { diff --git a/apps/cli/src/commands/db/query/query.read-only.ts b/apps/cli/src/commands/db/query/query.read-only.ts new file mode 100644 index 0000000000..979dd935c1 --- /dev/null +++ b/apps/cli/src/commands/db/query/query.read-only.ts @@ -0,0 +1,67 @@ +import { splitAndTrim, trimLeadingSqlComments } from "../../../command-internal/sql-split.ts"; + +const TRANSACTION_CONTROL = new Set(["BEGIN", "COMMIT", "END", "ABORT", "ROLLBACK"]); +const READ_ONLY_SETTINGS = new Set(["default_transaction_read_only", "transaction_read_only"]); + +// A quoted identifier (optionally `U&"…"`) or an unquoted one; PostgreSQL treats every code point +// at or above 0x80 as an identifier character. +const IDENTIFIER = + /^(?:[uU]&)?"(?:[^"]|"")*"|^[A-Za-z_\u{80}-\u{10FFFF}][A-Za-z0-9_$\u{80}-\u{10FFFF}]*/u; + +interface Word { + readonly text: string; + readonly rest: string; +} + +const nextWord = (sql: string): Word | undefined => { + const trimmed = trimLeadingSqlComments(sql); + const text = IDENTIFIER.exec(trimmed)?.[0]; + return text === undefined ? undefined : { text, rest: trimmed.slice(text.length) }; +}; + +// `U&"…"` names can't be decoded cheaply, so every one counts as a possible read-only setting. +const readOnlySettingLabel = (word: Word): string | undefined => { + if (/^u&/iu.test(word.text)) return 'U&"..."'; + const name = word.text.replace(/^"|"$/gu, "").toLowerCase(); + return READ_ONLY_SETTINGS.has(name) ? name : undefined; +}; + +const statementViolation = (statement: string): string | undefined => { + const first = nextWord(statement); + if (first === undefined) return undefined; + const keyword = first.text.toUpperCase(); + if (TRANSACTION_CONTROL.has(keyword)) return keyword; + if (keyword === "START" || keyword === "PREPARE") { + return nextWord(first.rest)?.text.toUpperCase() === "TRANSACTION" + ? `${keyword} TRANSACTION` + : undefined; + } + if (keyword !== "SET" && keyword !== "RESET") return undefined; + + let subject = nextWord(first.rest); + const scope = subject?.text.toUpperCase(); + if (subject !== undefined && keyword === "SET" && (scope === "SESSION" || scope === "LOCAL")) { + subject = nextWord(subject.rest); + if (scope === "SESSION" && subject?.text.toUpperCase() === "CHARACTERISTICS") { + return "SET SESSION CHARACTERISTICS"; + } + } + if (subject === undefined) return undefined; + const subjectKeyword = subject.text.toUpperCase(); + if (keyword === "SET" && subjectKeyword === "TRANSACTION") return "SET TRANSACTION"; + if (keyword === "RESET" && subjectKeyword === "ALL") return "RESET ALL"; + const setting = readOnlySettingLabel(subject); + return setting === undefined ? undefined : `${keyword} ${setting}`; +}; + +/** + * The first statement `--read-only` refuses, named by a fixed label (never the SQL): transaction + * control, or a change to the transaction's or session's read-only characteristics. + */ +export const findReadOnlyViolation = (sql: string): string | undefined => { + for (const statement of splitAndTrim(sql)) { + const violation = statementViolation(statement); + if (violation !== undefined) return violation; + } + return undefined; +}; diff --git a/apps/cli/src/commands/db/query/query.read-only.unit.test.ts b/apps/cli/src/commands/db/query/query.read-only.unit.test.ts new file mode 100644 index 0000000000..e7dcdcd540 --- /dev/null +++ b/apps/cli/src/commands/db/query/query.read-only.unit.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, it } from "vitest"; + +import { findReadOnlyViolation } from "./query.read-only.ts"; + +describe("findReadOnlyViolation", () => { + it.each([ + ["BEGIN", "BEGIN"], + ["begin read write", "BEGIN"], + ["START TRANSACTION ISOLATION LEVEL SERIALIZABLE", "START TRANSACTION"], + ["start\n\ttransaction", "START TRANSACTION"], + ["COMMIT", "COMMIT"], + ["commit and chain", "COMMIT"], + ["COMMIT PREPARED 'tx'", "COMMIT"], + ["END", "END"], + ["ABORT", "ABORT"], + ["ROLLBACK", "ROLLBACK"], + ["ROLLBACK TO SAVEPOINT s", "ROLLBACK"], + ["ROLLBACK PREPARED 'tx'", "ROLLBACK"], + ["PREPARE TRANSACTION 'tx'", "PREPARE TRANSACTION"], + ])("rejects transaction control: %s", (sql, found) => { + expect(findReadOnlyViolation(sql)).toBe(found); + }); + + it.each([ + ["SET TRANSACTION READ WRITE", "SET TRANSACTION"], + ["set transaction isolation level serializable", "SET TRANSACTION"], + ["SET SESSION CHARACTERISTICS AS TRANSACTION READ WRITE", "SET SESSION CHARACTERISTICS"], + ["SET default_transaction_read_only = off", "SET default_transaction_read_only"], + ["SET SESSION default_transaction_read_only TO off", "SET default_transaction_read_only"], + ["SET LOCAL default_transaction_read_only = off", "SET default_transaction_read_only"], + ["SET transaction_read_only = off", "SET transaction_read_only"], + ["SET LOCAL TRANSACTION_READ_ONLY TO DEFAULT", "SET transaction_read_only"], + ['SET "Transaction_Read_Only" = off', "SET transaction_read_only"], + ['SET U&"transaction\\005fread\\005fonly" = off', 'SET U&"..."'], + ["RESET default_transaction_read_only", "RESET default_transaction_read_only"], + ["RESET transaction_read_only", "RESET transaction_read_only"], + ["RESET ALL", "RESET ALL"], + ["reset\nall", "RESET ALL"], + ])("rejects read-only characteristic changes: %s", (sql, found) => { + expect(findReadOnlyViolation(sql)).toBe(found); + }); + + it.each([ + ["line comment", "-- note\nCOMMIT"], + ["carriage-return line comment", "-- note\rCOMMIT"], + ["block comment", "/* note */ COMMIT"], + ["nested block comment", "/* a /* b */ c */ COMMIT"], + ["stacked comments", "-- a\n/* b */\n-- c\nCOMMIT"], + ["byte order mark", "COMMIT"], + ["byte order mark before a comment", "-- note\nCOMMIT"], + ["form feed", "\fCOMMIT"], + ["comment between keywords", "SET /* rw */ TRANSACTION READ WRITE"], + ["comment before the setting", "SET -- rw\n transaction_read_only = off"], + ])("sees through a %s", (_name, sql) => { + expect(findReadOnlyViolation(sql)).toBeDefined(); + }); + + it("finds a rejected statement after other statements", () => { + expect(findReadOnlyViolation("SELECT 1; select 2; /* x */ COMMIT; DELETE FROM users")).toBe( + "COMMIT", + ); + expect(findReadOnlyViolation("SELECT 1;\n-- c\nset transaction read write;\nSELECT 2")).toBe( + "SET TRANSACTION", + ); + }); + + it.each([ + ["a keyword inside a string", "SELECT 'COMMIT; SET TRANSACTION READ WRITE'"], + ["a keyword inside an escape string", String.raw`SELECT E'it\'s; COMMIT; --'`], + ["a keyword inside a dollar quote", "SELECT $$COMMIT; ROLLBACK$$"], + ["a keyword inside a tagged dollar quote", "SELECT $q$; BEGIN; COMMIT; $q$"], + ["a keyword inside a line comment", "SELECT 1 -- COMMIT\n; SELECT 2"], + ["a keyword inside a block comment", "SELECT 1 /* ROLLBACK; */ ; SELECT 2"], + ["a statement that is only a comment", "-- COMMIT"], + ["an unterminated block comment", "/* COMMIT"], + ["a keyword as a quoted identifier", 'SELECT "commit", "begin" FROM t'], + ["a keyword as a column", "SELECT begin, commit, rollback FROM t"], + ["an identifier that starts with a keyword", "COMMITTED_ROWS"], + ["an identifier that starts with BEGIN", "BEGINNERS"], + ["a function body", "CREATE FUNCTION f() RETURNS void LANGUAGE sql BEGIN ATOMIC SELECT 1; END"], + [ + "a plpgsql body", + "DO $$ BEGIN PERFORM set_config('transaction_read_only', 'off', true); END $$", + ], + ["set_config", "SELECT set_config('default_transaction_read_only', 'off', false)"], + ["current_setting", "SELECT current_setting('transaction_read_only')"], + ["an unrelated SET", "SET search_path TO public"], + ["an unrelated SET LOCAL", "SET LOCAL statement_timeout = '5s'"], + ["SET SESSION AUTHORIZATION", "SET SESSION AUTHORIZATION postgres"], + ["an unrelated RESET", "RESET search_path"], + ["a similarly named setting", "SET transaction_read_only_hint = 1"], + ["a custom setting", "SET my.transaction_read_only = 1"], + ["a SAVEPOINT", "SAVEPOINT s; RELEASE SAVEPOINT s"], + ["a PREPARE of a query", "PREPARE q AS SELECT 1"], + ["START as an identifier", "START_DATE"], + ["a plain multi-statement read", "SELECT 1; SELECT 2;\nWITH t AS (SELECT 1) SELECT * FROM t"], + ["an empty statement", ";;"], + ["blank SQL", " \n"], + ])("lets through %s", (_name, sql) => { + expect(findReadOnlyViolation(sql)).toBeUndefined(); + }); +}); diff --git a/apps/cli/src/shared/telemetry/__fixtures__/error-tags.txt b/apps/cli/src/shared/telemetry/__fixtures__/error-tags.txt index 1095c5dd57..ccb7dde638 100644 --- a/apps/cli/src/shared/telemetry/__fixtures__/error-tags.txt +++ b/apps/cli/src/shared/telemetry/__fixtures__/error-tags.txt @@ -198,6 +198,7 @@ DbQueryMutuallyExclusiveFlagsError DbQueryNoSqlError DbQueryNoStdinSqlError DbQueryReadFileError +DbQueryReadOnlyStatementError DbQueryUnexpectedStatusError DbResetApplyError DbResetCancelledError From ad58e126a9766f4076cd97a4acf1078bb8a75b42 Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Thu, 15 Oct 2026 15:08:46 +0100 Subject: [PATCH 61/92] test(cli): require a hosted-consent matrix for every consenting leaf --- .../hosted-consent-coverage.unit.test.ts | 76 +++++++++++++++++++ 1 file changed, 76 insertions(+) create mode 100644 apps/cli/src/command-internal/hosted-consent/hosted-consent-coverage.unit.test.ts diff --git a/apps/cli/src/command-internal/hosted-consent/hosted-consent-coverage.unit.test.ts b/apps/cli/src/command-internal/hosted-consent/hosted-consent-coverage.unit.test.ts new file mode 100644 index 0000000000..01b294ea19 --- /dev/null +++ b/apps/cli/src/command-internal/hosted-consent/hosted-consent-coverage.unit.test.ts @@ -0,0 +1,76 @@ +import { readFileSync, readdirSync } from "node:fs"; +import { join, sep } from "node:path"; +import { fileURLToPath } from "node:url"; +import { Option } from "effect"; +import { describe, expect, it } from "vitest"; + +import { commandTreeFor } from "../../../tests/helpers/command-flags.ts"; +import type { PermissionVariant } from "../../../tests/helpers/permission-registry.ts"; +import { readTargetPolicy } from "../target/target-policy.ts"; + +const srcDir = fileURLToPath(new URL("../..", import.meta.url)); + +const VARIANTS: ReadonlyArray<PermissionVariant> = [ + undefined, + { stackBackend: "stack", computeEnabled: true }, +]; + +const consentLeafPaths: ReadonlySet<string> = new Set( + VARIANTS.flatMap((variant) => + [...commandTreeFor(variant)] + .filter(([, node]) => node.isLeaf) + .filter(([, node]) => { + const policy = readTargetPolicy(node.command); + return Option.isSome(policy) && policy.value.consent !== "none"; + }) + .map(([path]) => path), + ), +); + +const LITERAL_CALL = /\bdescribeHostedConsent\(\s*"([^"]+)"/g; +const ANY_CALL = /\bdescribeHostedConsent\(/g; + +const testFiles = readdirSync(srcDir, { recursive: true, encoding: "utf8" }) + .filter((file) => file.endsWith(".test.ts")) + .map((file) => file.split(sep).join("/")); + +const callsByLeaf = new Map<string, Array<string>>(); +const nonLiteralCallSites: Array<string> = []; + +for (const file of testFiles) { + const source = readFileSync(join(srcDir, file), "utf8"); + const literalCalls = [...source.matchAll(LITERAL_CALL)]; + for (const [, leaf] of literalCalls) { + callsByLeaf.set(leaf!, [...(callsByLeaf.get(leaf!) ?? []), file]); + } + const anyCalls = [...source.matchAll(ANY_CALL)].length; + if (anyCalls !== literalCalls.length) { + nonLiteralCallSites.push(`${file} (${anyCalls - literalCalls.length})`); + } +} + +describe("hosted consent coverage", () => { + it("scans real test files and finds consenting leaves in the command tree", () => { + expect(testFiles.length).toBeGreaterThan(0); + expect(consentLeafPaths.size).toBeGreaterThan(0); + }); + + it("has a consent matrix for every leaf whose policy asks for consent", () => { + const missing = [...consentLeafPaths].filter((path) => !callsByLeaf.has(path)).sort(); + expect(missing, "leaves with consent but no hosted-consent matrix in src/**/*.test.ts").toEqual( + [], + ); + }); + + it("has no consent matrix for a path that is not a leaf with consent", () => { + const extra = [...callsByLeaf] + .filter(([path]) => !consentLeafPaths.has(path)) + .map(([path, files]) => `${path} (${files.join(", ")})`) + .sort(); + expect(extra, "hosted-consent matrices for paths that are not consenting leaves").toEqual([]); + }); + + it("names the leaf as a string literal at every call site so the scan can see it", () => { + expect(nonLiteralCallSites).toEqual([]); + }); +}); From 1cd82703062e1f153cab8ea68d41b158a31fb756 Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Thu, 15 Oct 2026 15:08:47 +0100 Subject: [PATCH 62/92] test(cli): drop the account target banner stopgap from hosted-consent tests --- .../bootstrap/bootstrap.integration.test.ts | 4 +--- .../delete/delete.integration.test.ts | 3 +-- .../orgs/create/create.integration.test.ts | 3 +-- .../create/create.integration.test.ts | 17 ++++++------- .../delete/delete.integration.test.ts | 3 +-- apps/cli/tests/helpers/account-consent.ts | 10 -------- apps/cli/tests/helpers/hosted-consent.ts | 24 ++++++++++++------- 7 files changed, 27 insertions(+), 37 deletions(-) delete mode 100644 apps/cli/tests/helpers/account-consent.ts diff --git a/apps/cli/src/commands/bootstrap/bootstrap.integration.test.ts b/apps/cli/src/commands/bootstrap/bootstrap.integration.test.ts index 41720e23ea..a5f5bf7861 100644 --- a/apps/cli/src/commands/bootstrap/bootstrap.integration.test.ts +++ b/apps/cli/src/commands/bootstrap/bootstrap.integration.test.ts @@ -47,7 +47,6 @@ import { describeHostedConsent } from "../../../tests/helpers/hosted-consent.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 { announceTarget } from "../../shared/output/command-target.service.ts"; import { DbConnectError } from "../../command-internal/db-connection.errors.ts"; import { DbConnection, type PgConnInput } from "../../command-internal/db-connection.service.ts"; import { debugLoggerLayer } from "../../command-internal/debug-logger.layer.ts"; @@ -789,8 +788,7 @@ describeHostedConsent("bootstrap", { }), ); return { - run: announceTarget({ text: "account", json: { kind: "account" } }).pipe( - Effect.andThen(bootstrap(flags({ template: Option.some("scratch") }), FAST_BACKOFF)), + run: bootstrap(flags({ template: Option.some("scratch") }), FAST_BACKOFF).pipe( Effect.provide(substrate), ), }; diff --git a/apps/cli/src/commands/feedback/delete/delete.integration.test.ts b/apps/cli/src/commands/feedback/delete/delete.integration.test.ts index 4d273d7a26..d79ea95331 100644 --- a/apps/cli/src/commands/feedback/delete/delete.integration.test.ts +++ b/apps/cli/src/commands/feedback/delete/delete.integration.test.ts @@ -9,7 +9,6 @@ import { import { OutputFlag, YesFlag } from "../../../command-internal/global-flags.ts"; import { TargetPolicy } from "../../../command-internal/target/target-policy.ts"; import { commandRuntimeLayer } from "../../../shared/runtime/command-runtime.layer.ts"; -import { withAccountTargetBanner } from "../../../../tests/helpers/account-consent.ts"; import { describeHostedConsent } from "../../../../tests/helpers/hosted-consent.ts"; import { mockContextualAnalytics, @@ -436,7 +435,7 @@ describeHostedConsent("feedback delete", { invoke: () => { const client = mockFeedbackClient(); return { - run: withAccountTargetBanner(feedbackDelete(deleteArgs())).pipe( + run: feedbackDelete(deleteArgs()).pipe( Effect.provide(Layer.mergeAll(client.layer, mockTelemetryRuntime({ cliVersion: "9.9.9" }))), ), mutations: () => client.deleteCalls.length, diff --git a/apps/cli/src/commands/orgs/create/create.integration.test.ts b/apps/cli/src/commands/orgs/create/create.integration.test.ts index 786c68e365..1d86459000 100644 --- a/apps/cli/src/commands/orgs/create/create.integration.test.ts +++ b/apps/cli/src/commands/orgs/create/create.integration.test.ts @@ -2,7 +2,6 @@ import type { V1CreateAnOrganizationOutput } from "@supabase/api/effect"; import { describe, expect, it } from "@effect/vitest"; import { Cause, Effect, Exit, Layer, Option } from "effect"; -import { withAccountTargetBanner } from "../../../../tests/helpers/account-consent.ts"; import { describeHostedConsent } from "../../../../tests/helpers/hosted-consent.ts"; import { mockOutput } from "../../../../tests/helpers/mocks.ts"; import { @@ -237,5 +236,5 @@ describe("orgs create integration", () => { describeHostedConsent("orgs create", { api: { response: { status: 201, body: CREATED } }, - invoke: () => ({ run: withAccountTargetBanner(orgsCreate({ name: "Acme" })) }), + invoke: () => ({ run: orgsCreate({ name: "Acme" }) }), }); diff --git a/apps/cli/src/commands/projects/create/create.integration.test.ts b/apps/cli/src/commands/projects/create/create.integration.test.ts index 32755fe314..10c12e53f2 100644 --- a/apps/cli/src/commands/projects/create/create.integration.test.ts +++ b/apps/cli/src/commands/projects/create/create.integration.test.ts @@ -3,7 +3,6 @@ import { describe, expect, it } from "@effect/vitest"; import { Cause, Effect, Exit, Layer, Option, Schema } from "effect"; import { CliError, CliOutput, Command } from "effect/cli"; -import { withAccountTargetBanner } from "../../../../tests/helpers/account-consent.ts"; import { describeHostedConsent } from "../../../../tests/helpers/hosted-consent.ts"; import { mockOutput, mockTelemetryRuntime, mockTty } from "../../../../tests/helpers/mocks.ts"; import { ExperimentalRequiredError } from "../../../command-internal/experimental-gate.ts"; @@ -663,14 +662,12 @@ function rejectsInvalidSizeChoice(error: unknown): boolean { describeHostedConsent("projects create", { api: { byMethod: { POST: { status: 201, body: CREATED } } }, invoke: () => ({ - run: withAccountTargetBanner( - projectsCreate({ - ...BASE_FLAGS, - name: Option.some("alpha"), - orgId: Option.some("acme"), - dbPassword: Option.some("s3cret-pass"), - region: Option.some("us-east-1"), - }), - ).pipe(Effect.provideService(ExperimentalFlag, false)), + run: projectsCreate({ + ...BASE_FLAGS, + name: Option.some("alpha"), + orgId: Option.some("acme"), + dbPassword: Option.some("s3cret-pass"), + region: Option.some("us-east-1"), + }).pipe(Effect.provideService(ExperimentalFlag, false)), }), }); diff --git a/apps/cli/src/commands/projects/delete/delete.integration.test.ts b/apps/cli/src/commands/projects/delete/delete.integration.test.ts index 2b03ce328e..eefb7e3a61 100644 --- a/apps/cli/src/commands/projects/delete/delete.integration.test.ts +++ b/apps/cli/src/commands/projects/delete/delete.integration.test.ts @@ -2,7 +2,6 @@ import type { V1ListAllProjectsOutput } from "@supabase/api/effect"; import { describe, expect, it } from "@effect/vitest"; import { Cause, Effect, Exit, FileSystem, Layer, Option, Path } from "effect"; -import { withAccountTargetBanner } from "../../../../tests/helpers/account-consent.ts"; import { describeHostedConsent } from "../../../../tests/helpers/hosted-consent.ts"; import { mockOutput, mockStdin, mockTty } from "../../../../tests/helpers/mocks.ts"; import { @@ -267,5 +266,5 @@ describe("projects delete integration", () => { describeHostedConsent("projects delete", { api: { byMethod: { DELETE: { status: 200, body: DELETED } } }, - invoke: () => ({ run: withAccountTargetBanner(projectsDelete({ ref: Option.some(VALID_REF) })) }), + invoke: () => ({ run: projectsDelete({ ref: Option.some(VALID_REF) }) }), }); diff --git a/apps/cli/tests/helpers/account-consent.ts b/apps/cli/tests/helpers/account-consent.ts deleted file mode 100644 index 518fff53f4..0000000000 --- a/apps/cli/tests/helpers/account-consent.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { Effect } from "effect"; - -import { announceTarget } from "../../src/shared/output/command-target.service.ts"; - -/** - * Account commands resolve no target, but the `describeHostedConsent` matrix expects a `Target:` - * banner under `--yes`; this announces a stand-in so those leaves can run the matrix. - */ -export const withAccountTargetBanner = <A, E, R>(run: Effect.Effect<A, E, R>) => - announceTarget({ text: "account", json: { kind: "account" } }).pipe(Effect.andThen(run)); diff --git a/apps/cli/tests/helpers/hosted-consent.ts b/apps/cli/tests/helpers/hosted-consent.ts index a6dc1d417c..9e9346dd4a 100644 --- a/apps/cli/tests/helpers/hosted-consent.ts +++ b/apps/cli/tests/helpers/hosted-consent.ts @@ -187,14 +187,22 @@ export function describeHostedConsent(path: string, scenario: HostedConsentScena }), ); - it.effect("runs under --yes, echoes the confirmation, and shows the target banner", () => - Effect.gen(function* () { - const { exit, out, mutations } = yield* runCase({ json: true, yes: true }); - expectSuccess(exit); - expect(mutations).toBeGreaterThan(0); - expect(out.stderrText).toContain("Confirmed by --yes: "); - expect(out.stderrText).toContain("Target: "); - }), + it.effect( + policy.group === "account" + ? "runs under --yes and echoes the confirmation" + : "runs under --yes, echoes the confirmation, and shows the target banner", + () => + Effect.gen(function* () { + const { exit, out, mutations } = yield* runCase({ json: true, yes: true }); + expectSuccess(exit); + expect(mutations).toBeGreaterThan(0); + expect(out.stderrText).toContain("Confirmed by --yes: "); + if (policy.group === "account") { + expect(out.stderrText).not.toContain("Target: "); + } else { + expect(out.stderrText).toContain("Target: "); + } + }), ); it.effect("runs under a shell SUPABASE_YES", () => From 44f556eee7b5358f9578fa7eb2ee7640918059b1 Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Thu, 15 Oct 2026 15:08:48 +0100 Subject: [PATCH 63/92] test(cli): assert db query --linked as a defaulted switch in the flag table --- apps/cli/tests/helpers/command-flags.unit.test.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/apps/cli/tests/helpers/command-flags.unit.test.ts b/apps/cli/tests/helpers/command-flags.unit.test.ts index 252b8c9ee2..8375f88d86 100644 --- a/apps/cli/tests/helpers/command-flags.unit.test.ts +++ b/apps/cli/tests/helpers/command-flags.unit.test.ts @@ -33,7 +33,16 @@ describe("commandTreeFor", () => { it("tells a defaulted switch from an optional one", () => { expect(tree.get("link")?.flagInfo.get("skip-pooler")?.isOptional).toBe(false); - expect(tree.get("db query")?.flagInfo.get("linked")?.isOptional).toBe(true); + expect(tree.get("db query")?.flagInfo.get("linked")).toEqual({ + isBoolean: true, + isOptional: false, + choices: undefined, + }); + expect(tree.get("db dump")?.flagInfo.get("data-only")).toEqual({ + isBoolean: true, + isOptional: true, + choices: undefined, + }); }); it("marks a group as a non-leaf and its subcommand as a leaf", () => { From 1f65a1bc18e2768399e097b742d8e876c4d8d284 Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Thu, 15 Oct 2026 15:23:23 +0100 Subject: [PATCH 64/92] refactor(cli): remove the legacy db target resolution surface Drops DbConfigResolver.resolve, DbConfigFlags, ProjectRefResolver.loadProjectRef, db-target-flags and migration.prompt. The pooler fallback now takes a DbConnectionInput, and --password with a local target is rejected before declarative generate starts or resets the stack. Removes the migration-pending guard allowlists. --- .../src/command-internal/db-config.errors.ts | 9 - .../db-config.integration.test.ts | 491 +++++------------- .../src/command-internal/db-config.layer.ts | 176 ++----- .../src/command-internal/db-config.service.ts | 18 +- .../src/command-internal/db-config.types.ts | 47 -- .../db-password-rejection.integration.test.ts | 10 +- apps/cli/src/command-internal/db-pull-run.ts | 17 +- .../src/command-internal/db-target-flags.ts | 167 ------ .../db-target-flags.unit.test.ts | 251 --------- .../target/resolve-target.integration.test.ts | 1 - .../target/target-password.ts | 20 + .../target/target-policy.unit.test.ts | 15 - .../test-db.integration.test.ts | 2 - .../test-db.layers.unit.test.ts | 1 - .../test-db.native.integration.test.ts | 2 - .../bootstrap/bootstrap.layers.unit.test.ts | 1 - .../db/advisors/advisors.integration.test.ts | 2 - .../commands/db/diff/diff.integration.test.ts | 2 - apps/cli/src/commands/db/dump/dump.handler.ts | 19 +- .../commands/db/dump/dump.integration.test.ts | 14 +- .../commands/db/lint/lint.integration.test.ts | 2 - .../commands/db/lint/lint.layers.unit.test.ts | 2 - .../commands/db/pull/pull.integration.test.ts | 2 - .../commands/db/push/push.integration.test.ts | 2 - .../db/query/query.integration.test.ts | 2 - .../db/reset/reset.integration.test.ts | 2 - .../declarative/declarative.smart-target.ts | 2 + .../generate/generate.integration.test.ts | 1 - .../commands/db/test/test.integration.test.ts | 2 - .../functions/list/list.integration.test.ts | 1 - .../src/commands/gen/types/types.handler.ts | 23 +- .../gen/types/types.integration.test.ts | 14 +- .../gen/types/types.layers.unit.test.ts | 1 - .../db/inspect-deprecated.integration.test.ts | 1 - .../db/inspect-query.integration.test.ts | 1 - .../db/inspect-specs.integration.test.ts | 1 - .../inspect/inspect.layers.unit.test.ts | 1 - .../inspect/report/report.integration.test.ts | 1 - .../migration/down/down.integration.test.ts | 3 - .../migration/fetch/fetch.integration.test.ts | 2 - .../migration/list/list.integration.test.ts | 3 - .../commands/migration/migration.errors.ts | 13 - .../commands/migration/migration.prompt.ts | 40 -- .../repair/repair.integration.test.ts | 3 - .../squash/squash.integration.test.ts | 3 - .../squash/squash.native.integration.test.ts | 2 - .../migration/up/up.integration.test.ts | 3 - .../commands/pull/pull.integration.test.ts | 1 - .../seed/buckets/buckets.integration.test.ts | 10 - apps/cli/src/config/project-ref.layer.ts | 20 +- .../src/config/project-ref.layer.unit.test.ts | 67 +-- apps/cli/src/config/project-ref.service.ts | 14 - .../shared/cli/code-structure.unit.test.ts | 73 +-- apps/cli/src/telemetry/command-telemetry.ts | 3 +- apps/cli/tests/helpers/compute.ts | 2 - apps/cli/tests/helpers/notebooks.ts | 1 - apps/cli/tests/helpers/storage.ts | 6 - 57 files changed, 239 insertions(+), 1356 deletions(-) delete mode 100644 apps/cli/src/command-internal/db-target-flags.ts delete mode 100644 apps/cli/src/command-internal/db-target-flags.unit.test.ts create mode 100644 apps/cli/src/command-internal/target/target-password.ts delete mode 100644 apps/cli/src/commands/migration/migration.prompt.ts diff --git a/apps/cli/src/command-internal/db-config.errors.ts b/apps/cli/src/command-internal/db-config.errors.ts index 8a401f8c80..b30c63bfee 100644 --- a/apps/cli/src/command-internal/db-config.errors.ts +++ b/apps/cli/src/command-internal/db-config.errors.ts @@ -16,15 +16,6 @@ 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. 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 c58cdeb32e..cd041f5d02 100644 --- a/apps/cli/src/command-internal/db-config.integration.test.ts +++ b/apps/cli/src/command-internal/db-config.integration.test.ts @@ -65,7 +65,7 @@ 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 { DbConfigResolver } from "./db-config.service.ts"; -import type { DbConfigFlags, DbConnectionInput } from "./db-config.types.ts"; +import type { DbConnectionInput } from "./db-config.types.ts"; import { hostedMutationPreapproval } from "./hosted-consent/hosted-consent.ts"; import type { ResolvedTarget } from "./target/resolve-target.ts"; import { DbConnectError } from "./db-connection.errors.ts"; @@ -172,14 +172,49 @@ function withWorkdir(toml?: string) { }); } -const resolve = ( +const inputFor = ( + target: ResolvedTarget, + overrides: Partial<DbConnectionInput> = {}, +): DbConnectionInput => ({ + target, + dnsResolver: "native", + password: Option.none(), + networkBanRemoval: Option.none(), + ...overrides, +}); + +const localTarget: ResolvedTarget = { kind: "local" }; +const dbUrlTarget = (url: string): ResolvedTarget => ({ + kind: "db-url", + url: Redacted.make(url), +}); +const linkedTarget = ( + ref: string, + overrides: Partial<Extract<ResolvedTarget, { readonly kind: "linked" }>> = {}, +): ResolvedTarget => ({ + kind: "linked", + ref, + source: "file", + matchesLinkedRef: true, + ...overrides, +}); +const explicitRefTarget = (ref: string, overrides: { readonly matchesLinkedRef?: boolean } = {}) => + linkedTarget(ref, { source: "flag", ...overrides }); + +const approval = hostedMutationPreapproval.pipe( + Effect.provide( + Layer.mergeAll(Layer.succeed(YesFlag, true), Layer.succeed(CliArgs, { args: [] })), + ), +); + +const resolveConnection = ( workdir: string, - flags: DbConfigFlags, + input: DbConnectionInput, opts?: Parameters<typeof buildResolver>[1], ) => Effect.gen(function* () { const resolver = yield* DbConfigResolver; - return yield* resolver.resolve(flags); + return yield* resolver.resolveConnection(input); }).pipe( Effect.provide(buildResolver(workdir, opts)), Effect.provideServiceEffect( @@ -190,12 +225,12 @@ const resolve = ( const resolvePoolerFallback = ( workdir: string, - flags: DbConfigFlags, + input: DbConnectionInput, opts?: Parameters<typeof buildResolver>[1], ) => Effect.gen(function* () { const resolver = yield* DbConfigResolver; - return yield* resolver.resolvePoolerFallback(flags); + return yield* resolver.resolvePoolerFallback(input); }).pipe( Effect.provide(buildResolver(workdir, opts)), Effect.provideServiceEffect( @@ -204,22 +239,6 @@ const resolvePoolerFallback = ( ), ); -const localFlags: DbConfigFlags = { - dbUrl: Option.none(), - connType: "local", - dnsResolver: "native", -}; -const dbUrlFlags = (url: string): DbConfigFlags => ({ - dbUrl: Option.some(url), - connType: "db-url", - dnsResolver: "native", -}); -const linkedFlags: DbConfigFlags = { - dbUrl: Option.none(), - connType: "linked", - dnsResolver: "native", -}; - const assertDbConfigDrift = ( requests: ReadonlyArray<{ readonly method: string; readonly path: string }>, activeFlags: ActiveFlags, @@ -238,7 +257,7 @@ describe("dbConfigResolver (local + db-url)", () => { const dir = yield* withWorkdir( ["[db]", "port = 55555", 'password = "hunter2"', ""].join("\n"), ); - return yield* resolve(dir, localFlags); + return yield* resolveConnection(dir, inputFor(localTarget)); }).pipe( Effect.tap((r) => Effect.sync(() => { @@ -266,7 +285,7 @@ describe("dbConfigResolver (local + db-url)", () => { it.effect("local mode: honors SUPABASE_SERVICES_HOSTNAME for the connection host", () => Effect.gen(function* () { const dir = yield* withWorkdir(); - return yield* resolve(dir, localFlags, { + return yield* resolveConnection(dir, inputFor(localTarget), { configEnv: { SUPABASE_SERVICES_HOSTNAME: "host.docker.internal" }, }); }).pipe( @@ -284,7 +303,7 @@ describe("dbConfigResolver (local + db-url)", () => { it.effect("local mode: falls back to default port/password without a config.toml", () => Effect.gen(function* () { const dir = yield* withWorkdir(); - return yield* resolve(dir, localFlags); + return yield* resolveConnection(dir, inputFor(localTarget)); }).pipe( Effect.tap((r) => Effect.sync(() => { @@ -301,7 +320,10 @@ describe("dbConfigResolver (local + db-url)", () => { it.effect("db-url mode: parses the connection string and percent-decodes the password", () => Effect.gen(function* () { const dir = yield* withWorkdir(); - return yield* resolve(dir, dbUrlFlags("postgres://alice:p%40ss@example.com:6543/appdb")); + return yield* resolveConnection( + dir, + inputFor(dbUrlTarget("postgres://alice:p%40ss@example.com:6543/appdb")), + ); }).pipe( Effect.tap((r) => Effect.sync(() => { @@ -327,9 +349,9 @@ describe("dbConfigResolver (local + db-url)", () => { it.effect("db-url mode: a 127.0.0.1 url on the configured db.port is detected as local", () => Effect.gen(function* () { const dir = yield* withWorkdir(); - return yield* resolve( + return yield* resolveConnection( dir, - dbUrlFlags("postgres://postgres:postgres@127.0.0.1:54322/postgres"), + inputFor(dbUrlTarget("postgres://postgres:postgres@127.0.0.1:54322/postgres")), ); }).pipe( Effect.tap((r) => @@ -345,9 +367,11 @@ describe("dbConfigResolver (local + db-url)", () => { it.effect("db-url mode: a multi-host url stays remote even when its primary is local", () => Effect.gen(function* () { const dir = yield* withWorkdir(); - return yield* resolve( + return yield* resolveConnection( dir, - dbUrlFlags("postgres://postgres:pw@127.0.0.1:54322,db.example.com:5432/postgres"), + inputFor( + dbUrlTarget("postgres://postgres:pw@127.0.0.1:54322,db.example.com:5432/postgres"), + ), ); }).pipe( Effect.tap((r) => @@ -366,7 +390,10 @@ describe("dbConfigResolver (local + db-url)", () => { const dir = yield* withWorkdir( ["[db]", "port = 54322", 'password = "hunter2"', ""].join("\n"), ); - return yield* resolve(dir, dbUrlFlags("postgres://postgres@127.0.0.1:54322/postgres")); + return yield* resolveConnection( + dir, + inputFor(dbUrlTarget("postgres://postgres@127.0.0.1:54322/postgres")), + ); }).pipe( Effect.tap((r) => Effect.sync(() => { @@ -384,7 +411,10 @@ describe("dbConfigResolver (local + db-url)", () => { () => { return Effect.gen(function* () { const dir = yield* withWorkdir(); - return yield* resolve(dir, dbUrlFlags("postgres://user:s3cret@ bad host/db")); + return yield* resolveConnection( + dir, + inputFor(dbUrlTarget("postgres://user:s3cret@ bad host/db")), + ); }).pipe( Effect.exit, Effect.tap((exit) => @@ -409,7 +439,7 @@ describe("dbConfigResolver (local + db-url)", () => { "postgres://postgres:pw@example.com:5432/postgres?sslmode=verify-full&options=reference%3Dabcdefghijklmnop"; return Effect.gen(function* () { const dir = yield* withWorkdir(); - return yield* resolve(dir, dbUrlFlags(url)); + return yield* resolveConnection(dir, inputFor(dbUrlTarget(url))); }).pipe( Effect.tap((r) => Effect.sync(() => { @@ -425,7 +455,10 @@ describe("dbConfigResolver (local + db-url)", () => { it.effect("db-url mode: accepts a libpq keyword/value DSN", () => Effect.gen(function* () { const dir = yield* withWorkdir(); - return yield* resolve(dir, dbUrlFlags("host=pg.example.com port=6543 user=admin dbname=app")); + return yield* resolveConnection( + dir, + inputFor(dbUrlTarget("host=pg.example.com port=6543 user=admin dbname=app")), + ); }).pipe( Effect.tap((r) => Effect.sync(() => { @@ -448,7 +481,10 @@ describe("dbConfigResolver (local + db-url)", () => { // throws. It must surface as a normal parse failure, not an untyped defect. return Effect.gen(function* () { const dir = yield* withWorkdir(); - return yield* resolve(dir, dbUrlFlags("postgres://user:p%zz@example.com/db")); + return yield* resolveConnection( + dir, + inputFor(dbUrlTarget("postgres://user:p%zz@example.com/db")), + ); }).pipe( Effect.exit, Effect.tap((exit) => @@ -470,44 +506,6 @@ describe("dbConfigResolver (local + db-url)", () => { ); }); -describe("dbConfigResolver --password rejection", () => { - const failureTag = (exit: Exit.Exit<unknown, { readonly _tag: string }>) => - Exit.isFailure(exit) ? JSON.stringify(exit.cause) : ""; - - const targets: ReadonlyArray<readonly [string, DbConfigFlags]> = [ - ["--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) => @@ -606,7 +604,10 @@ describe("dbConfigResolver (db-url under the stack backend)", () => { dir: string, url: string, stackApi: Layer.Layer<StackApi> = projectStackApi(dir, "running"), - ) => resolve(dir, dbUrlFlags(url), { stackApi }).pipe(Effect.provide(stackBackendLayer("stack"))); + ) => + resolveConnection(dir, inputFor(dbUrlTarget(url)), { stackApi }).pipe( + Effect.provide(stackBackendLayer("stack")), + ); it.effect("treats the url printed by `stack status --env` as the local database", () => Effect.gen(function* () { @@ -711,8 +712,7 @@ describe("dbConfigResolver (linked config ordering)", () => { "", ].join("\n"), ); - // The linked ref is sourced via the project-ref resolver's env fallback. - return yield* resolve(dir, linkedFlags, { configEnv: { SUPABASE_PROJECT_ID: ref } }); + return yield* resolveConnection(dir, inputFor(linkedTarget(ref))); }).pipe( Effect.exit, Effect.tap((exit) => @@ -730,34 +730,6 @@ describe("dbConfigResolver (linked config ordering)", () => { ); }); - it.effect("surfaces a project-ref read failure instead of reporting not-linked", () => { - // The ref file is seeded as a directory (not a file), with no project_id or env fallback, - // to force a real read error. - return Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const dir = yield* withWorkdir(); - yield* fs.makeDirectory(path.join(dir, "supabase", ".temp", "project-ref"), { - recursive: true, - }); - return yield* resolve(dir, linkedFlags); - }).pipe( - Effect.exit, - Effect.tap((exit) => - Effect.sync(() => { - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - const causeText = Cause.pretty(exit.cause); - expect(causeText).toContain("failed to load project ref"); - expect(causeText).not.toContain("Cannot find project ref"); - } - }), - ), - Effect.scoped, - Effect.provide(BunServices.layer), - ); - }); - it.effect("ad-hoc project refs ignore the linked workdir password and saved pooler URL", () => { const linkedRef = "abcdefghijklmnopqrst"; const adHocRef = "qrstabcdefghijklmnop"; @@ -854,13 +826,9 @@ describe("dbConfigResolver (linked config ordering)", () => { path.join(dir, "supabase", ".temp", "pooler-url"), `postgres://postgres.${linkedRef}:saved-workdir-password@stale.pooler.supabase.com:6543/postgres`, ); - return yield* resolve( + return yield* resolveConnection( dir, - { - ...linkedFlags, - linkedProjectRef: Option.some(adHocRef), - adHocProjectRef: true, - }, + inputFor(explicitRefTarget(adHocRef, { matchesLinkedRef: false })), { projectHost: "invalid", dbConnection, @@ -1022,11 +990,7 @@ describe("dbConfigResolver (linked config ordering)", () => { ); return yield* resolvePoolerFallback( dir, - { - ...linkedFlags, - linkedProjectRef: Option.some(adHocRef), - adHocProjectRef: true, - }, + inputFor(explicitRefTarget(adHocRef, { matchesLinkedRef: false })), { dbConnection, configEnv: { @@ -1170,21 +1134,14 @@ describe("dbConfigResolver (linked config ordering)", () => { path.join(dir, "supabase", ".temp", "pooler-url"), "postgres://postgres.qrstabcdefghijklmnop:saved-workdir-password@aws-0-us-east-1.pooler.supabase.com:6543/postgres", ); - return yield* resolvePoolerFallback( - dir, - { - ...linkedFlags, - linkedProjectRef: Option.some(linkedRef), - }, - { - projectHost: "supabase.co", - dbConnection, - configEnv: { - SUPABASE_ACCESS_TOKEN: VALID_TOKEN, - SUPABASE_DB_PASSWORD: "linked-password", - }, + return yield* resolvePoolerFallback(dir, inputFor(explicitRefTarget(linkedRef)), { + projectHost: "supabase.co", + dbConnection, + configEnv: { + SUPABASE_ACCESS_TOKEN: VALID_TOKEN, + SUPABASE_DB_PASSWORD: "linked-password", }, - ); + }); }).pipe( Effect.tap((connOpt) => Effect.sync(() => { @@ -1222,11 +1179,10 @@ describe("dbConfigResolver (linked config ordering)", () => { }); }); -// An explicit `--project-ref`/`linkedProjectRef` on a non-ad-hoc `db` command must -// independently unlock the Management API pooler fetch on an IPv4-only network — it must not -// stay confined to the workdir's saved `.temp/pooler-url` the way the plain `--linked` default -// path is. -describe("dbConfigResolver (--project-ref pooler fetch decoupled from adHocProjectRef)", () => { +// An explicit `--project-ref` must unlock the Management API pooler fetch on an IPv4-only +// network, even when it names the linked project, rather than staying confined to the workdir's +// saved `.temp/pooler-url` the way the plain `--linked` path is. +describe("dbConfigResolver (an explicit --project-ref fetches the pooler config)", () => { it.effect( "an unlinked workdir + explicit --project-ref resolves via the API pooler config, honoring the ambient password with no login-role mint", () => { @@ -1286,12 +1242,9 @@ describe("dbConfigResolver (--project-ref pooler fetch decoupled from adHocProje // so there is no `.temp/project-ref` and no `.temp/pooler-url` to reuse. return Effect.gen(function* () { const dir = yield* withWorkdir(); - return yield* resolve( + return yield* resolveConnection( dir, - { - ...linkedFlags, - linkedProjectRef: Option.some(ref), - }, + inputFor(explicitRefTarget(ref, { matchesLinkedRef: false })), { projectHost: "invalid", dbConnection, @@ -1404,12 +1357,9 @@ describe("dbConfigResolver (--project-ref pooler fetch decoupled from adHocProje path.join(dir, "supabase", ".temp", "pooler-url"), `postgres://postgres.${linkedRef}:saved-workdir-password@stale.pooler.supabase.com:6543/postgres`, ); - return yield* resolve( + return yield* resolveConnection( dir, - { - ...linkedFlags, - linkedProjectRef: Option.some(targetRef), - }, + inputFor(explicitRefTarget(targetRef, { matchesLinkedRef: false })), { projectHost: "invalid", dbConnection, @@ -1452,136 +1402,6 @@ describe("dbConfigResolver (--project-ref pooler fetch decoupled from adHocProje }, ); - it.effect( - "an explicit --project-ref with no password mints a login role and clears network bans after three failed temp-role connects", - () => { - const ref = "banrecoveryrefabcdef"; - - const previousFetch = globalThis.fetch; - const requests: Array<{ readonly method: string; readonly path: string }> = []; - let connectAttempts = 0; - const session: DbSession = { - exec: () => Effect.void, - execBatch: () => Effect.void, - query: () => Effect.succeed([]), - extensionExists: () => Effect.succeed(false), - copyToCsv: () => Effect.succeed(new Uint8Array()), - queryRaw: () => Effect.succeed({ fields: [], rows: [], commandTag: "SELECT 1" }), - }; - const dbConnection = Layer.succeed(DbConnection, { - connect: () => - Effect.suspend(() => { - connectAttempts += 1; - return connectAttempts <= 3 - ? Effect.fail(new DbConnectError({ message: "password authentication failed" })) - : Effect.succeed(session); - }), - }); - const json = (status: number, body: unknown) => - Promise.resolve( - new Response(JSON.stringify(body), { - status, - headers: { "content-type": "application/json" }, - }), - ); - const fetchMock = Object.assign( - (input: string | URL | Request, init?: RequestInit): Promise<Response> => { - const url = new URL( - typeof input === "string" || input instanceof URL ? input : input.url, - ); - const method = init?.method ?? (input instanceof Request ? input.method : "GET"); - requests.push({ method, path: url.pathname }); - - if (method === "GET" && url.pathname === `/v1/projects/${ref}/config/database/pooler`) { - return json(200, [ - { - identifier: "primary", - database_type: "PRIMARY", - is_using_scram_auth: true, - db_user: "postgres", - db_host: "db.example", - db_port: 5432, - db_name: "postgres", - connection_string: `postgres://postgres.${ref}:[YOUR-PASSWORD]@aws-0-us-east-1.pooler.supabase.com:6543/postgres`, - connectionString: `postgres://postgres.${ref}:[YOUR-PASSWORD]@aws-0-us-east-1.pooler.supabase.com:6543/postgres`, - default_pool_size: null, - max_client_conn: null, - pool_mode: "transaction", - }, - ]); - } - if (method === "POST" && url.pathname === `/v1/projects/${ref}/cli/login-role`) { - return json(201, { - role: "cli_login_role", - password: "temporary-role-password", - ttl_seconds: 3600, - }); - } - if (method === "POST" && url.pathname === `/v1/projects/${ref}/network-bans/retrieve`) { - return json(200, { banned_ipv4_addresses: ["203.0.113.7"] }); - } - if (method === "DELETE" && url.pathname === `/v1/projects/${ref}/network-bans`) { - return json(200, {}); - } - return json(404, { message: "unexpected request" }); - }, - { preconnect: previousFetch.preconnect }, - ); - - globalThis.fetch = fetchMock; - - return Effect.gen(function* () { - const retries = yield* Queue.unbounded<string>(); - const debugLogger = Layer.succeed(DebugLogger, { - debug: (message) => - message.startsWith("Retry (") ? Queue.offer(retries, message) : Effect.void, - http: () => Effect.void, - }); - const dir = yield* withWorkdir(); - const resolved = yield* resolve( - dir, - { ...linkedFlags, linkedProjectRef: Option.some(ref) }, - { - projectHost: "invalid", - dbConnection, - debugLogger, - configEnv: { SUPABASE_ACCESS_TOKEN: VALID_TOKEN }, - }, - ).pipe(Effect.forkChild({ startImmediately: true })); - for (let failedConnects = 1; failedConnects <= 3; failedConnects++) { - yield* Queue.take(retries).pipe(Effect.raceFirst(Fiber.join(resolved))); - yield* TestClock.adjust("10 seconds"); - } - return yield* Fiber.join(resolved); - }).pipe( - Effect.tap((r) => - Effect.sync(() => { - expect(r.conn).toMatchObject({ - host: "aws-0-us-east-1.pooler.supabase.com", - user: `cli_login_role.${ref}`, - password: "temporary-role-password", - }); - expect(connectAttempts).toBe(4); - expect(requests).toEqual([ - { method: "GET", path: `/v1/projects/${ref}/config/database/pooler` }, - { method: "POST", path: `/v1/projects/${ref}/cli/login-role` }, - { method: "POST", path: `/v1/projects/${ref}/network-bans/retrieve` }, - { method: "DELETE", path: `/v1/projects/${ref}/network-bans` }, - ]); - assertDbConfigDrift(requests, { linked: true, "project-ref": ref }); - }), - ), - Effect.ensuring( - Effect.sync(() => { - globalThis.fetch = previousFetch; - }), - ), - Effect.scoped, - Effect.provide(BunServices.layer), - ); - }, - ); - it.effect( "the plain --linked path (no --project-ref) keeps the IPv6 error when no pooler URL is saved", () => { @@ -1595,7 +1415,9 @@ describe("dbConfigResolver (--project-ref pooler fetch decoupled from adHocProje ); yield* fs.makeDirectory(path.join(dir, "supabase", ".temp"), { recursive: true }); yield* fs.writeFileString(path.join(dir, "supabase", ".temp", "project-ref"), ref); - return yield* resolve(dir, linkedFlags, { projectHost: "invalid" }); + return yield* resolveConnection(dir, inputFor(linkedTarget(ref)), { + projectHost: "invalid", + }); }).pipe( Effect.exit, Effect.tap((exit) => @@ -1645,7 +1467,7 @@ describe("dbConfigResolver (Management API calls)", () => { return Effect.gen(function* () { yield* stubManagementApi(requests, () => undefined); const dir = yield* withWorkdir(); - const r = yield* resolve(dir, localFlags); + const r = yield* resolveConnection(dir, inputFor(localTarget)); expect(r.isLocal).toBe(true); noApiCalls(requests, { local: true }); }).pipe(Effect.scoped, Effect.provide(BunServices.layer)); @@ -1656,7 +1478,10 @@ describe("dbConfigResolver (Management API calls)", () => { return Effect.gen(function* () { yield* stubManagementApi(requests, () => undefined); const dir = yield* withWorkdir(); - const r = yield* resolve(dir, dbUrlFlags("postgres://alice:secret@example.com:6543/appdb")); + const r = yield* resolveConnection( + dir, + inputFor(dbUrlTarget("postgres://alice:secret@example.com:6543/appdb")), + ); expect(r.isLocal).toBe(false); noApiCalls(requests, { "db-url": true }); }).pipe(Effect.scoped, Effect.provide(BunServices.layer)); @@ -1674,9 +1499,9 @@ describe("dbConfigResolver (Management API calls)", () => { : undefined, ); const dir = yield* withWorkdir(); - return yield* resolve( + return yield* resolveConnection( dir, - { ...linkedFlags, linkedProjectRef: Option.some(ref) }, + inputFor(explicitRefTarget(ref, { matchesLinkedRef: false })), { projectHost: "invalid", dbConnection: noDirectConnect, @@ -1706,40 +1531,6 @@ describe("dbConfigResolver (Management API calls)", () => { }, ); - it.effect("an empty --project-ref still fetches the pooler config for the linked project", () => { - const ref = "emptyprojectrefabcde"; - const requests: Array<{ readonly method: string; readonly path: string }> = []; - return Effect.gen(function* () { - yield* stubManagementApi(requests, (method, path) => - method === "GET" && path === `/v1/projects/${ref}/config/database/pooler` - ? Response.json(primaryPoolerConfigs(ref)) - : undefined, - ); - const dir = yield* withWorkdir(); - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - yield* fs.makeDirectory(path.join(dir, "supabase", ".temp"), { recursive: true }); - yield* fs.writeFileString(path.join(dir, "supabase", ".temp", "project-ref"), ref); - const r = yield* resolve( - dir, - { ...linkedFlags, linkedProjectRef: Option.some("") }, - { - projectHost: "invalid", - dbConnection: noDirectConnect, - configEnv: { - SUPABASE_ACCESS_TOKEN: VALID_TOKEN, - SUPABASE_DB_PASSWORD: "ambient-password", - }, - }, - ); - expect(r.conn).toMatchObject({ host: "aws-0-us-east-1.pooler.supabase.com", port: 5432 }); - expect(requests).toEqual([ - { method: "GET", path: `/v1/projects/${ref}/config/database/pooler` }, - ]); - assertDbConfigDrift(requests, { linked: true, "project-ref": "" }); - }).pipe(Effect.scoped, Effect.provide(BunServices.layer)); - }); - it.effect("an empty --password still mints a login role", () => { const ref = "emptypasswordrefabcd"; const requests: Array<{ readonly method: string; readonly path: string }> = []; @@ -1765,9 +1556,11 @@ describe("dbConfigResolver (Management API calls)", () => { return undefined; }); const dir = yield* withWorkdir(); - const r = yield* resolve( + const r = yield* resolveConnection( dir, - { ...linkedFlags, linkedProjectRef: Option.some(ref), password: Option.some("") }, + inputFor(explicitRefTarget(ref, { matchesLinkedRef: false }), { + password: Option.some(""), + }), { projectHost: "invalid", dbConnection: Layer.succeed(DbConnection, { connect: () => Effect.succeed(session) }), @@ -1841,9 +1634,11 @@ describe("dbConfigResolver (Management API calls)", () => { http: () => Effect.void, }); const dir = yield* withWorkdir(); - const resolved = yield* resolve( + const resolved = yield* resolveConnection( dir, - { ...linkedFlags, linkedProjectRef: Option.some(ref) }, + inputFor(explicitRefTarget(ref, { matchesLinkedRef: false }), { + networkBanRemoval: yield* approval, + }), { projectHost: "invalid", dbConnection, @@ -1911,11 +1706,10 @@ describe("dbConfigResolver (container pooler fallback)", () => { return undefined; }); const dir = yield* withWorkdir(); - const pooler = yield* resolvePoolerFallback( - dir, - { ...linkedFlags, linkedProjectRef: Option.some(ref) }, - { dbConnection: connects, configEnv: { SUPABASE_ACCESS_TOKEN: VALID_TOKEN } }, - ); + const pooler = yield* resolvePoolerFallback(dir, inputFor(explicitRefTarget(ref)), { + dbConnection: connects, + configEnv: { SUPABASE_ACCESS_TOKEN: VALID_TOKEN }, + }); expect(Option.isSome(pooler)).toBe(true); if (Option.isSome(pooler)) { expect(pooler.value).toMatchObject({ @@ -1943,7 +1737,7 @@ describe("dbConfigResolver (container pooler fallback)", () => { const dir = yield* withWorkdir(); const pooler = yield* resolvePoolerFallback( dir, - { ...linkedFlags, linkedProjectRef: Option.some(ref), password: Option.some("secret") }, + inputFor(explicitRefTarget(ref), { password: Option.some("secret") }), { dbConnection: connects, configEnv: { SUPABASE_ACCESS_TOKEN: VALID_TOKEN } }, ); expect(pooler).toEqual( @@ -1963,11 +1757,10 @@ describe("dbConfigResolver (container pooler fallback)", () => { : undefined, ); const dir = yield* withWorkdir(); - const pooler = yield* resolvePoolerFallback( - dir, - { ...linkedFlags, linkedProjectRef: Option.some(ref) }, - { dbConnection: connects, configEnv: { SUPABASE_ACCESS_TOKEN: VALID_TOKEN } }, - ); + const pooler = yield* resolvePoolerFallback(dir, inputFor(explicitRefTarget(ref)), { + dbConnection: connects, + configEnv: { SUPABASE_ACCESS_TOKEN: VALID_TOKEN }, + }); expect(Option.isNone(pooler)).toBe(true); expect(requests).toEqual([{ method: "GET", path: poolerPath }]); assertFallbackDrift(requests, { linked: true }); @@ -1979,7 +1772,7 @@ describe("dbConfigResolver (container pooler fallback)", () => { return Effect.gen(function* () { yield* stubManagementApi(requests, () => undefined); const dir = yield* withWorkdir(); - const pooler = yield* resolvePoolerFallback(dir, localFlags); + const pooler = yield* resolvePoolerFallback(dir, inputFor(localTarget)); expect(Option.isNone(pooler)).toBe(true); expect(requests).toEqual([]); assertFallbackDrift(requests, { local: true }); @@ -1988,46 +1781,6 @@ describe("dbConfigResolver (container pooler fallback)", () => { }); describe("dbConfigResolver.resolveConnection", () => { - const approval = hostedMutationPreapproval.pipe( - Effect.provide( - Layer.mergeAll(Layer.succeed(YesFlag, true), Layer.succeed(CliArgs, { args: [] })), - ), - ); - const inputFor = ( - target: ResolvedTarget, - overrides: Partial<DbConnectionInput> = {}, - ): DbConnectionInput => ({ - target, - dnsResolver: "native", - password: Option.none(), - networkBanRemoval: Option.none(), - ...overrides, - }); - const resolveConnection = ( - workdir: string, - input: DbConnectionInput, - opts?: Parameters<typeof buildResolver>[1], - ) => - Effect.gen(function* () { - const resolver = yield* DbConfigResolver; - return yield* resolver.resolveConnection(input); - }).pipe( - Effect.provide(buildResolver(workdir, opts)), - Effect.provideServiceEffect( - ConfigProvider.ConfigProvider, - pinnedConfigProvider(explicitEnv(opts?.configEnv)), - ), - ); - const linkedTarget = ( - ref: string, - overrides: Partial<Extract<ResolvedTarget, { readonly kind: "linked" }>> = {}, - ): ResolvedTarget => ({ - kind: "linked", - ref, - source: "file", - matchesLinkedRef: true, - ...overrides, - }); const failureText = (exit: Exit.Exit<unknown, unknown>) => Exit.isFailure(exit) ? Cause.pretty(exit.cause) : ""; const session: DbSession = { diff --git a/apps/cli/src/command-internal/db-config.layer.ts b/apps/cli/src/command-internal/db-config.layer.ts index 03c63a2530..e76a11cb66 100644 --- a/apps/cli/src/command-internal/db-config.layer.ts +++ b/apps/cli/src/command-internal/db-config.layer.ts @@ -9,7 +9,6 @@ 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 { projectRefPicker } from "../config/project-ref.layer.ts"; -import { ProjectRefResolver, PROJECT_REF_PATTERN } from "../config/project-ref.service.ts"; import { DebugFlag, DnsResolverFlag, @@ -39,8 +38,7 @@ import { } from "./db-config.parse.ts"; import { DbConfigResolver, type DbConfigError } from "./db-config.service.ts"; import { loadProjectEnvValues, readDbToml } from "./db-config.toml-read.ts"; -import type { DbConfigFlags, DbConnectionInput } from "./db-config.types.ts"; -import { rejectPasswordWithDirectTarget } from "./db-target-flags.ts"; +import type { DbConnectionInput } from "./db-config.types.ts"; import { DebugLogger } from "../shared/output/debug-logger.service.ts"; import { getHostname } from "./hostname.ts"; import { mapHttpError } from "./http-errors.ts"; @@ -49,7 +47,7 @@ import { StackApi, stackApiLayer } from "./stack-api.ts"; import { stackLocalDatabaseConn } from "./stack-local-database.ts"; import { block, type OperationEntry } from "./command-permissions/model.ts"; import { renderDbUrlTarget, renderLocalTarget } from "./target/target-identity.ts"; -import { TargetPasswordConflictError } from "./target/target.errors.ts"; +import { rejectTargetPassword } from "./target/target-password.ts"; const linkedEntries: ReadonlyArray<Omit<OperationEntry, "source">> = [ { @@ -66,13 +64,6 @@ const linkedEntries: ReadonlyArray<Omit<OperationEntry, "source">> = [ context: "the direct database host is unreachable (IPv4-only network) and there is no usable saved supabase/.temp/pooler-url", }, - { - operationId: "v1-get-pooler-config", - kind: "required", - when: [{ flag: "project-ref", oneOf: [""] }], - context: - "an empty --project-ref falls back to the linked project but still allows the pooler fetch, when the direct database host is unreachable (IPv4-only network) and there is no usable saved supabase/.temp/pooler-url", - }, { operationId: "v1-list-all-network-bans", kind: "best-effort", @@ -92,16 +83,14 @@ const linkedEntries: ReadonlyArray<Omit<OperationEntry, "source">> = [ /** * The same calls as {@link dbConfig} without its target condition, for a command that reaches the - * linked path some other way: `db diff --from linked`, a `--linked` flag with no default target, or - * a target prompt. + * linked path some other way, such as `db diff --from linked`. */ export const dbConfigLinked = block("db-config", linkedEntries); /** * `DbConfigResolver` on the linked path; `--local` and `--db-url` make no API calls. Commands * include it with `withDbTarget`. The container retry that `db dump` and `db pull` run is - * {@link dbConfigPoolerFallback}; `gen types --project-id` fetches the - * pooler config without `--project-ref`, so that command declares its own entry. + * {@link dbConfigPoolerFallback}. */ export const dbConfig = block( "db-config", @@ -237,7 +226,7 @@ const listAndUnban = Effect.fnUntraced(function* (ref: string, removeBans: boole return yield* new Errors.LinkedDbNetworkBannedError({ message: `Network bans (${addrs.join(", ")}) may be blocking the connection, and removing them needs approval.`, suggestion: - "Re-run with --yes to remove the bans, or run supabase network-bans remove --db-unban-ip <ip> first.", + "Re-run with --yes to remove the bans, or run supabase network-bans remove --db-unban-ip <ip> --experimental --yes first.", }); } yield* api.v1 @@ -330,32 +319,6 @@ const describePasswordOrigin = (origin: CliConfigKeyOrigin): string => { } }; -/** - * A `--password` the caller bound on {@link DbConfigFlags} is passed to - * {@link resolveLinkedPassword} as the caller's password, so it wins over `SUPABASE_DB_PASSWORD` and - * the project `.env` just like the flag tier does, and no login role is minted for it. An - * explicitly empty `--password` asks for a temporary login role even when the env holds one; the - * flag binding drops empty strings, so this is the only way that request reaches the resolver. - */ -const passwordFlag = (password: Option.Option<string> | undefined): Option.Option<string> => - password ?? Option.none(); - -const rejectTargetPassword = ( - target: "local" | "db-url", - password: Option.Option<string>, -): Effect.Effect<void, TargetPasswordConflictError> => - Option.isNone(Option.filter(password, (value) => value.length > 0)) - ? Effect.void - : Effect.fail( - new TargetPasswordConflictError({ - target, - 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" - : "--password only applies to a linked project. The local database uses [db].password from supabase/config.toml.", - }), - ); - const loadFailureToDbConfigError = <E extends { readonly _tag: string; readonly message: string }>( error: E, ) => @@ -429,7 +392,7 @@ const resolvePoolerConn = Effect.fn("DbConfig.resolvePoolerConn")(function* ( // no `.temp/pooler-url` is saved; the resolve-time IPv6 path uses the saved URL only and // errors otherwise, so this defaults off. fetchFromApi = false, - // For an ad-hoc `--project-id` ref the saved `.temp/pooler-url` belongs to the + // For an ad-hoc `--project-ref` the saved `.temp/pooler-url` belongs to the // (possibly different) linked workdir, so ignore it and resolve the pooler for // `ref` from the Management API instead. ignoreSavedUrl = false, @@ -439,9 +402,9 @@ const resolvePoolerConn = Effect.fn("DbConfig.resolvePoolerConn")(function* ( const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; // Linked-path read: merge the `[remotes.<ref>]` 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 - // necessarily the explicit ref. + // on the main linked branch rather than validating base config. For an ad-hoc `--project-ref`, + // skip the saved workdir pooler URL because it belongs to the linked project, not necessarily + // the explicit ref. const tomlValues = yield* readDbToml(fs, path, workdir, ref, { resolveVaultSecrets, }); @@ -510,12 +473,10 @@ export const resolveLinkedConn = Effect.fn("DbConfig.resolveLinkedConn")(functio readonly resolveVaultSecrets?: boolean; /** * Requests the Management API pooler-config fetch on an IPv4-only network - * 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 - * workdir still reaches the primary pooler config instead of dead-ending in - * the "run supabase link" IPv6 error. + * independent of `adHocProjectRef`'s saved-URL semantics. Set when the caller + * supplied an explicit `--project-ref` rather than falling back to + * `.temp/project-ref`, so an unlinked or mismatched-tenant workdir still reaches + * the primary pooler config instead of dead-ending in the "run supabase link" IPv6 error. */ readonly fetchPoolerFromApi?: boolean; /** Whether the calling command binds `--password`, so the withheld-password notice can name it. */ @@ -548,8 +509,8 @@ export const resolveLinkedConn = Effect.fn("DbConfig.resolveLinkedConn")(functio return yield* initLoginRole(ref, base); } - // Direct host unreachable (IPv6-only network) → try the pooler. For an ad-hoc `--project-id` - // ref the command already holds a Management API token, so fall back to the API pooler config + // Direct host unreachable (IPv6-only network) → try the pooler. For an ad-hoc `--project-ref` + // the command already holds a Management API token, so fall back to the API pooler config // (and ignore the workdir's saved pooler URL) rather than failing with the IPv6 suggestion. An // explicit `--project-ref` on a non-ad-hoc `db` command keeps `ignoreSavedUrl` at the // saved-URL-first default (the tenant-mismatch check in `poolerConfigFrom` still rejects a @@ -594,7 +555,7 @@ export const dbConfigResolverLayer = Layer.effect( // `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 - // services into `resolve`/`resolvePoolerFallback`'s R (the interface promises `never`), so + // services into `resolveConnection`/`resolvePoolerFallback`'s R (the interface promises `never`), so // every call site below re-closes the gap by additionally providing this layer of // already-resolved values alongside `linkedDbResolverRuntimeLayer`. const localAmbientServices = Layer.mergeAll( @@ -615,7 +576,7 @@ export const dbConfigResolverLayer = Layer.effect( }; // Capture the ambient services the Management API stack needs, so the - // lazily-built linked stack is fully self-provided and `resolve`'s R stays + // lazily-built linked stack is fully self-provided and `resolveConnection`'s R stays // `never` (handler tests can mock this resolver without wiring the whole // management runtime). None of these resolves an access token — only the // platform API layer does, and that is built only on the `--linked` branch. @@ -629,7 +590,7 @@ export const dbConfigResolverLayer = Layer.effect( Layer.succeed(DebugFlag, yield* DebugFlag), // `linkedDbResolverRuntimeLayer`'s platform-API factory provides a DoH fetch layer that // reads `DnsResolverFlag`; snapshot it so the lazily built linked stack stays fully - // self-provided (`resolve`'s R stays `never`). + // self-provided (`resolveConnection`'s R stays `never`). Layer.succeed(DnsResolverFlag, yield* DnsResolverFlag), Layer.succeed(RuntimeInfo, yield* RuntimeInfo), Layer.succeed(Analytics, yield* Analytics), @@ -652,13 +613,13 @@ export const dbConfigResolverLayer = Layer.effect( // Compile-time guard: if `linkedDbResolverRuntimeLayer`'s requirements ever // grow a service not captured above, this assignment fails to type-check (the // lazy `Effect.provide` in the `--linked` branch would otherwise leak that - // service into `resolve`'s R and only surface as a runtime panic). Mirrors the + // service into `resolveConnection`'s R and only surface as a runtime panic). Mirrors the // `_serviceCoverageCheck` pattern in `management-api-runtime.layer.ts`. const _ambientCoverageCheck: Layer.Layer<LinkedDbResolverRuntimeRequirements, never, never> = ambientLayer; void _ambientCoverageCheck; - // `resolve`'s R is `never`, so capture StackApi at layer build. + // `resolveConnection`'s R is `never`, so capture StackApi at layer build. const stackDatabaseConn = stackLocalDatabaseConn.pipe( Effect.provideService(CommandSettings, cliSettings), Effect.provideService(StackApi, stackApi), @@ -802,56 +763,6 @@ 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.<ref>]`-merged config. A base read here would - // validate base config (db.major_version, deno_version, …) before the ref is known, - // failing a linked run that should succeed once the ref is known. Only - // `--db-url`/`--local` read base config, since neither merges a remote block. - const localHost = yield* resolveLocalHost; - - // --db-url (direct) takes precedence. - if (flags.connType === "db-url" && Option.isSome(flags.dbUrl)) { - return yield* resolveDbUrl(flags.dbUrl.value, localHost, resolveVaultSecrets); - } - - if (flags.connType === "linked") { - const linked = yield* withLinkedRuntime( - Effect.gen(function* () { - const projectRef = yield* ProjectRefResolver; - // Load-or-fail with no prompt: use `loadProjectRef` (not the soft - // `resolveOptional`, which swallows a read error to `None`) — an unlinked workdir - // fails with a not-linked error, a bad ref with the invalid-ref error, and an - // unreadable ref file surfaces the filesystem problem, for every caller of this - // resolver (`test db --linked`, dump, declarative). - const ref = yield* projectRef.loadProjectRef(flags.linkedProjectRef ?? Option.none()); - const conn = yield* resolveLinkedConnection(ref, { - dnsResolver: flags.dnsResolver, - password: passwordFlag(flags.password), - resolveVaultSecrets, - adHocProjectRef: flags.adHocProjectRef ?? false, - // 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 saved-URL semantics - // — see `DbConfigFlags.linkedProjectRef`'s doc comment. - fetchPoolerFromApi: Option.isSome(flags.linkedProjectRef ?? Option.none()), - hasPasswordFlag: flags.password !== undefined, - removeNetworkBans: true, - }); - return { conn, ref }; - }), - ); - // Surface the resolved ref so the caller can re-read config with a matching - // `[remotes.<ref>]` override applied (merged into the linked config). - return { conn: linked.conn, isLocal: false, ref: Option.some(linked.ref) }; - } - - return yield* resolveLocalStack(localHost, resolveVaultSecrets); - }); - const resolveConnection = (input: DbConnectionInput) => Effect.gen(function* () { const { target } = input; @@ -890,37 +801,31 @@ export const dbConfigResolverLayer = Layer.effect( // When a linked dump's pg_dump container fails with an IPv6 connectivity error (the direct // host is reachable from the CLI process but not from inside Docker), it resolves the - // project's IPv4 transaction pooler and retries once. This exposes that pooler resolution - // for the dump handler to invoke on demand. Returns `None` when the path is not - // pooler-eligible (`--linked` only) or no pooler URL is configured, so the caller keeps the - // original container error. - const resolvePoolerFallback = (flags: DbConfigFlags) => + // project's IPv4 transaction pooler and retries once. Returns `None` for a local or `--db-url` + // target or when no pooler URL is configured, so the caller keeps the original container error. + const resolvePoolerFallback = (input: DbConnectionInput) => Effect.gen(function* () { - if (flags.connType !== "linked") return Option.none<PgConnInput>(); + const { target } = input; + if (target.kind !== "linked") return Option.none<PgConnInput>(); return yield* Effect.gen(function* () { - const projectRef = yield* ProjectRefResolver; - const refOpt = yield* projectRef.resolveOptional(flags.linkedProjectRef ?? Option.none()); - if (Option.isNone(refOpt)) return Option.none<PgConnInput>(); - const ref = refOpt.value; - if (!PROJECT_REF_PATTERN.test(ref)) return Option.none<PgConnInput>(); - const adHocProjectRef = flags.adHocProjectRef ?? false; const password = yield* resolveLinkedPassword( - ref, + target.ref, cliSettings.workdir, - passwordFlag(flags.password), - flags.password !== undefined, + input.password, + input.hasPasswordFlag ?? false, ); // Container-fallback: fetch the primary pooler config from the Management API when // no `.temp/pooler-url` is saved. return yield* resolvePoolerConn( - ref, + target.ref, cliSettings.workdir, cliSettings.poolerHost, - flags.dnsResolver, + input.dnsResolver, password, true, - adHocProjectRef, - flags.resolveVaultSecrets ?? true, + !target.matchesLinkedRef, + input.resolveVaultSecrets ?? true, + Option.isSome(input.networkBanRemoval), ); }).pipe( Effect.provide( @@ -939,15 +844,6 @@ export const dbConfigResolverLayer = Layer.effect( suggestionContext, }); 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", { - attributes: { "db.conn_type": flags.connType ?? "local" }, - }), - ), resolveConnection: (input) => resolveConnection(input).pipe( Effect.provideService(CliConfigValues, configValues), @@ -957,15 +853,15 @@ export const dbConfigResolverLayer = Layer.effect( attributes: { "db.conn_type": input.target.kind }, }), ), - resolvePoolerFallback: (flags) => - resolvePoolerFallback(flags).pipe( + resolvePoolerFallback: (input) => + resolvePoolerFallback(input).pipe( Effect.provideService(CliConfigValues, configValues), Effect.tap((pooler) => Effect.annotateCurrentSpan("db.pooler.found", Option.isSome(pooler)), ), Effect.map(Option.map(withSuggestion)), Effect.withSpan("DbConfig.resolvePoolerFallback", { - attributes: { "db.conn_type": flags.connType ?? "local" }, + attributes: { "db.conn_type": input.target.kind }, }), ), }); diff --git a/apps/cli/src/command-internal/db-config.service.ts b/apps/cli/src/command-internal/db-config.service.ts index 4e13cda94e..6300c0e81d 100644 --- a/apps/cli/src/command-internal/db-config.service.ts +++ b/apps/cli/src/command-internal/db-config.service.ts @@ -23,15 +23,9 @@ import type { DbConfigPoolerLoginError, DbConfigUnbanNetworkError, DbConfigUnbanStatusError, - DbPasswordFlagsError, LinkedDbNetworkBannedError, } from "./db-config.errors.ts"; -import type { - DbConfigFlags, - DbConnectionInput, - ResolvedDbConfig, - ResolvedDbConnection, -} from "./db-config.types.ts"; +import type { DbConnectionInput, ResolvedDbConnection } from "./db-config.types.ts"; import type { CliConfigFlagConflictError, CliConfigValueError, @@ -42,7 +36,6 @@ import type { TargetPasswordConflictError } from "./target/target.errors.ts"; export type DbConfigError = | Config.ConfigError | DbConfigParseUrlError - | DbPasswordFlagsError | DbConfigLoadError | CliConfigValueError | CliConfigFlagConflictError @@ -81,7 +74,6 @@ export type DbConfigError = // first API use, so its auth-required error surfaces through the resolver effect (folded into // `DbConfigError`) rather than a layer-build error channel. interface DbConfigResolverShape { - readonly resolve: (flags: DbConfigFlags) => Effect.Effect<ResolvedDbConfig, DbConfigError>; /** * Connects to an already-resolved target and announces it: the local stack, a `--db-url` * database, or the linked project's temp-role connection. @@ -91,15 +83,15 @@ interface DbConfigResolverShape { ) => Effect.Effect<ResolvedDbConnection, DbConfigError>; /** * Resolves the IPv4 transaction pooler connection for a linked dump's container-level - * fallback. Returns `None` when the path is not pooler-eligible (`--linked` only) or no - * pooler URL is configured, so the caller keeps the original error. + * fallback. Returns `None` when the target is not the linked project or no pooler URL is + * configured, so the caller keeps the original error. */ readonly resolvePoolerFallback: ( - flags: DbConfigFlags, + input: DbConnectionInput, ) => Effect.Effect<Option.Option<PgConnInput>, DbConfigError>; } -/** Resolves a Postgres connection from the `--db-url` / `--local` / `--linked` flags. */ +/** Resolves a Postgres connection for a resolved `local`, `db-url` or `linked` target. */ export class DbConfigResolver extends Context.Service<DbConfigResolver, DbConfigResolverShape>()( "supabase/cli/DbConfigResolver", ) {} diff --git a/apps/cli/src/command-internal/db-config.types.ts b/apps/cli/src/command-internal/db-config.types.ts index e6ed6a92d7..a63e8dcae9 100644 --- a/apps/cli/src/command-internal/db-config.types.ts +++ b/apps/cli/src/command-internal/db-config.types.ts @@ -1,55 +1,8 @@ import type { Option } from "effect"; import type { PgConnInput } from "./db-connection.service.ts"; -import type { DbConnType } from "./db-target-flags.ts"; import type { HostedMutationConsent } from "./hosted-consent/hosted-consent.token.ts"; import type { ResolvedTarget } from "./target/resolve-target.ts"; -/** - * The connection-resolution flags shared by `db lint`, `db advisors`, and `test db`. - * - * `connType` records which selector flag the user explicitly set, derived from raw argv via - * `resolveDbTargetFlags`: - * - "db-url" → `--db-url` was set (read `dbUrl.value`) - * - "linked" → `--linked` was set (Management API path) - * - "local" → `--local` was set (explicit local path) - * - undefined → no selector was set; resolver defaults to local - * - * `--db-url` / `--linked` / `--local` are mutually exclusive. `dnsResolver` carries the global - * `--dns-resolver` value, used when the resolver opens its own remote connection (the linked - * pooler temp-role probe); the handler passes the same value to its primary `connect`. - */ -export interface DbConfigFlags { - readonly dbUrl: Option.Option<string>; - readonly connType: DbConnType | undefined; - readonly dnsResolver: "native" | "https"; - /** - * Whether config resolution should decrypt and materialize `[db.vault]` values. - * Defaults to true; `db push --skip-vault` is the only caller that disables it. - */ - readonly resolveVaultSecrets?: boolean; - /** - * 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<string>; - /** - * Optional explicit linked project ref override, letting e.g. `gen types --project-id <ref>` - * reuse the linked resolver's temp-role and pooler fallback without the workdir being linked. - * Shares only `SUPABASE_PROJECT_ID`'s ref-resolution precedence (flag > env > - * `.temp/project-ref`) — it never drives the local container id or pg-delta project id the way - * that env var does. `None` preserves the normal `--linked` fallback. - */ - readonly linkedProjectRef?: Option.Option<string>; - /** - * Marks `linkedProjectRef` as an ad-hoc remote target (e.g. `gen types --project-id <ref>`) - * 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; -} - /** * A resolved Postgres connection plus whether it points at the local stack. `isLocal` decides * the pg_prove docker network/host rewrite in the `test db` handler, so it is computed once here. 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 index bda9747d08..b7ce56150b 100644 --- a/apps/cli/src/command-internal/db-password-rejection.integration.test.ts +++ b/apps/cli/src/command-internal/db-password-rejection.integration.test.ts @@ -103,18 +103,18 @@ describe("--password with a direct database target", () => { const failure = yield* runCommand(entry, selector); expect(failure).toMatchObject({ - _tag: "DbPasswordFlagsError", + _tag: "TargetPasswordConflictError", 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.", + : "--password only applies to a linked project. The local database uses [db].password from supabase/config.toml.", }); }), ); } } - it.effect("explains that migration squash defaults to local when --password has no target", () => + it.effect("rejects migration squash --password when the workspace target is local", () => Effect.gen(function* () { const entry = passwordCommands.find( (candidate) => candidate.path.join(" ") === "migration squash", @@ -123,9 +123,9 @@ describe("--password with a direct database target", () => { const failure = yield* runCommand(entry!, "none"); expect(failure).toMatchObject({ - _tag: "DbPasswordFlagsError", + _tag: "TargetPasswordConflictError", message: - "migration squash targets the local database unless you pass --linked, and --password only applies to a linked project. Pass --linked, or drop --password.", + "--password only applies to a linked project. The local database uses [db].password from supabase/config.toml.", }); }), ); diff --git a/apps/cli/src/command-internal/db-pull-run.ts b/apps/cli/src/command-internal/db-pull-run.ts index 49c8cac146..7b1c163623 100644 --- a/apps/cli/src/command-internal/db-pull-run.ts +++ b/apps/cli/src/command-internal/db-pull-run.ts @@ -177,21 +177,14 @@ export const runDbPull = Effect.fn("db.pull.run")(function* ( linkedRef, ); - const resolved = yield* resolver.resolveConnection({ + const connectionInput = { target, dnsResolver, password: flags.password ?? Option.none(), hasPasswordFlag: true, networkBanRemoval: yield* hostedMutationPreapproval, - }); - const poolerFallbackFlags = { - dbUrl: Option.none<string>(), - connType: "linked", - dnsResolver, - password: flags.password ?? Option.none(), - linkedProjectRef: Option.fromNullishOr(linkedRef), - adHocProjectRef: target.kind === "linked" && !target.matchesLinkedRef, - } as const; + }; + const resolved = yield* resolver.resolveConnection(connectionInput); const requireHistoryConsent = (migrations: number) => Option.isSome(preapproved) ? Effect.void @@ -237,7 +230,7 @@ export const runDbPull = Effect.fn("db.pull.run")(function* ( // A pooler resolution failure is treated as "no fallback" (re-fail the // original diff error), not surfaced as its own error. const pooler = yield* resolver - .resolvePoolerFallback(poolerFallbackFlags) + .resolvePoolerFallback(connectionInput) .pipe(Effect.orElseSucceed(() => Option.none())); if (Option.isSome(pooler)) { yield* emitPoolerFallbackWarning(resolved.conn.host); @@ -451,7 +444,7 @@ export const runDbPull = Effect.fn("db.pull.run")(function* ( projectHost: cliSettings.projectHost, resolvePooler: () => resolver - .resolvePoolerFallback(poolerFallbackFlags) + .resolvePoolerFallback(connectionInput) .pipe(Effect.orElseSucceed(() => Option.none())), runWithConn: runSchemaDump, reprintOnRetry: Effect.void, diff --git a/apps/cli/src/command-internal/db-target-flags.ts b/apps/cli/src/command-internal/db-target-flags.ts deleted file mode 100644 index f76b13da65..0000000000 --- a/apps/cli/src/command-internal/db-target-flags.ts +++ /dev/null @@ -1,167 +0,0 @@ -/** - * Flag-presence helpers for the `--db-url / --linked / --local` target selection shared by - * `db lint`, `db advisors`, and `test db`. - * - * 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 { - VALUE_CONSUMING_LONG_FLAGS, - VALUE_CONSUMING_SHORT_FLAGS, -} from "../shared/cli/value-consuming-flags.ts"; -import { DbPasswordFlagsError } from "./db-config.errors.ts"; - -/** @public */ -export { VALUE_CONSUMING_LONG_FLAGS, VALUE_CONSUMING_SHORT_FLAGS }; - -export type DbConnType = "db-url" | "linked" | "local"; - -export interface DbTargetSelection { - /** Explicitly-set selector flags, alphabetically sorted. */ - readonly setFlags: ReadonlyArray<string>; - /** db-url > local > linked; `undefined` when no selector was set (callers default to local). */ - readonly connType: DbConnType | undefined; -} - -/** - * 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<string>): ReadonlyArray<string> { - let linked = false; - let local = false; - let skipNext = false; - - for (const token of args) { - if (skipNext) { - skipNext = false; - continue; - } - if (token === "--") break; - - if (token.startsWith("--")) { - const eqIdx = token.indexOf("="); - const name = eqIdx === -1 ? token.slice(2) : token.slice(2, eqIdx); - const isBare = eqIdx === -1; - if (name === "linked" || name === "no-linked") { - linked = true; - continue; - } - if (name === "local" || name === "no-local") { - local = true; - continue; - } - if (isBare && VALUE_CONSUMING_LONG_FLAGS.has(name)) skipNext = true; - continue; - } - - if (token.startsWith("-") && token.length >= 2 && token.charAt(1) !== "-") { - if (token.length === 2 && VALUE_CONSUMING_SHORT_FLAGS.has(token.charAt(1))) { - skipNext = true; - } - } - } - - const setFlags: Array<string> = []; - if (linked) setFlags.push("linked"); - if (local) setFlags.push("local"); - return setFlags; -} - -/** Resolves the DB target selection from raw args; `setFlags` is sorted so conflict text is stable. */ -export function resolveDbTargetFlags(args: ReadonlyArray<string>): DbTargetSelection { - let dbUrlChanged = false; - let linkedChanged = false; - let localChanged = false; - - let skipNext = false; - for (const token of args) { - // A pending value is consumed even when it is "--". - if (skipNext) { - skipNext = false; - continue; - } - - if (token === "--") break; - - if (token.startsWith("--")) { - const eqIdx = token.indexOf("="); - const name = eqIdx === -1 ? token.slice(2) : token.slice(2, eqIdx); - const isBare = eqIdx === -1; - - if (name === "db-url") { - dbUrlChanged = true; - if (isBare) skipNext = true; - continue; - } - if (name === "linked" || name === "no-linked") { - linkedChanged = true; - continue; - } - if (name === "local" || name === "no-local") { - localChanged = true; - continue; - } - - if (isBare && VALUE_CONSUMING_LONG_FLAGS.has(name)) { - skipNext = true; - } - continue; - } - - if (token.startsWith("-") && token.length >= 2 && token.charAt(1) !== "-") { - const shortName = token.charAt(1); - // `-svalue` carries its value attached; only bare `-s` skips the next token. - if (token.length === 2 && VALUE_CONSUMING_SHORT_FLAGS.has(shortName)) { - skipNext = true; - } - } - } - - const setFlags: Array<string> = []; - if (dbUrlChanged) setFlags.push("db-url"); - if (linkedChanged) setFlags.push("linked"); - if (localChanged) setFlags.push("local"); - - let connType: DbConnType | undefined; - if (dbUrlChanged) { - connType = "db-url"; - } else if (localChanged) { - connType = "local"; - } else if (linkedChanged) { - connType = "linked"; - } - - 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<string> | undefined, - options: { readonly localByDefaultFor?: string } = {}, -): Effect.Effect<void, DbPasswordFlagsError> => { - 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 deleted file mode 100644 index 25f29fe58d..0000000000 --- a/apps/cli/src/command-internal/db-target-flags.unit.test.ts +++ /dev/null @@ -1,251 +0,0 @@ -import { describe, expect, it } from "@effect/vitest"; -import { Effect, Exit, Option } from "effect"; -import { - changedLinkedLocalFlags, - rejectPasswordWithDirectTarget, - resolveDbTargetFlags, -} from "./db-target-flags.ts"; - -describe("resolveDbTargetFlags", () => { - it("returns empty setFlags and undefined connType when no args", () => { - const result = resolveDbTargetFlags([]); - expect(result.setFlags).toEqual([]); - expect(result.connType).toBeUndefined(); - }); - - it("detects --linked as changed (connType='linked')", () => { - const result = resolveDbTargetFlags(["--linked"]); - expect(result.connType).toBe("linked"); - expect(result.setFlags).toEqual(["linked"]); - }); - - it("detects --linked=false as changed (Changed, not value)", () => { - const result = resolveDbTargetFlags(["db", "lint", "--linked=false"]); - expect(result.connType).toBe("linked"); - expect(result.setFlags).toEqual(["linked"]); - }); - - it("detects --no-linked as changed (boolean negation is still Changed)", () => { - const result = resolveDbTargetFlags(["--no-linked"]); - expect(result.connType).toBe("linked"); - expect(result.setFlags).toEqual(["linked"]); - }); - - it("detects --db-url as changed", () => { - const result = resolveDbTargetFlags(["--db-url", "postgres://x"]); - expect(result.connType).toBe("db-url"); - expect(result.setFlags).toEqual(["db-url"]); - }); - - it("detects --db-url=<value> as changed", () => { - const result = resolveDbTargetFlags(["--db-url=postgres://x"]); - expect(result.connType).toBe("db-url"); - expect(result.setFlags).toEqual(["db-url"]); - }); - - it("--local=false --linked produces setFlags length 2 with alphabetical order [linked local]", () => { - const result = resolveDbTargetFlags(["--local=false", "--linked"]); - expect(result.setFlags).toEqual(["linked", "local"]); - expect(result.setFlags).toHaveLength(2); - expect(result.connType).toBe("local"); - }); - - it("--db-url=postgres://x --linked produces setFlags [db-url linked] with connType=db-url", () => { - const result = resolveDbTargetFlags(["--db-url=postgres://x", "--linked"]); - expect(result.setFlags).toEqual(["db-url", "linked"]); - expect(result.connType).toBe("db-url"); - }); - - it("tokens after bare -- are not scanned (end-of-options sentinel)", () => { - const result = resolveDbTargetFlags(["--", "--linked"]); - expect(result.setFlags).toEqual([]); - expect(result.connType).toBeUndefined(); - }); - - it("--db-url (key only, value as next arg) is still detected as changed", () => { - const result = resolveDbTargetFlags(["--db-url", "postgres://x"]); - expect(result.connType).toBe("db-url"); - expect(result.setFlags).toEqual(["db-url"]); - }); - - it("setFlags order is always alphabetical [db-url, linked, local] regardless of argv order", () => { - const result = resolveDbTargetFlags(["--local", "--db-url=x", "--linked"]); - expect(result.setFlags).toEqual(["db-url", "linked", "local"]); - }); - - it("Changed-first precedence: db-url > local > linked", () => { - const all = resolveDbTargetFlags(["--db-url=x", "--linked", "--local"]); - expect(all.connType).toBe("db-url"); - - const localLinked = resolveDbTargetFlags(["--linked", "--local"]); - expect(localLinked.connType).toBe("local"); - - const linkedOnly = resolveDbTargetFlags(["--linked"]); - expect(linkedOnly.connType).toBe("linked"); - }); - - it("skips value token after bare --schema so --linked is not a false positive", () => { - const result = resolveDbTargetFlags(["db", "lint", "--schema", "--linked"]); - expect(result.connType).toBeUndefined(); - expect(result.setFlags).toEqual([]); - }); - - it("skips value token after bare --level so following flags are not false positives", () => { - const result = resolveDbTargetFlags(["--level", "error", "--local"]); - expect(result.connType).toBe("local"); - expect(result.setFlags).toEqual(["local"]); - }); - - it("--schema=value (attached form) does NOT skip the next token", () => { - const result = resolveDbTargetFlags(["--schema=public", "--linked"]); - expect(result.connType).toBe("linked"); - expect(result.setFlags).toEqual(["linked"]); - }); - - it("skips value token after bare -s (short for --schema)", () => { - const result = resolveDbTargetFlags(["-s", "--linked"]); - expect(result.connType).toBeUndefined(); - expect(result.setFlags).toEqual([]); - }); - - it("-svalue (attached short form) does NOT skip the next token", () => { - const result = resolveDbTargetFlags(["-spublic", "--linked"]); - expect(result.connType).toBe("linked"); - expect(result.setFlags).toEqual(["linked"]); - }); - - it("skips value token after bare --output so following flags are not false positives", () => { - const result = resolveDbTargetFlags(["--output", "json", "--local"]); - expect(result.connType).toBe("local"); - expect(result.setFlags).toEqual(["local"]); - }); - - it("--output-dir <value> does NOT mark --local as changed (value consumed)", () => { - const result = resolveDbTargetFlags(["--output-dir", "--local"]); - expect(result.connType).toBeUndefined(); - expect(result.setFlags).toEqual([]); - }); - - it("--output-dir=<value> (attached form) DOES mark --local as changed", () => { - const result = resolveDbTargetFlags(["--output-dir=./reports", "--local"]); - expect(result.connType).toBe("local"); - expect(result.setFlags).toEqual(["local"]); - }); - - it("--schema -- --linked: -- consumed as schema value, --linked is a real flag", () => { - const result = resolveDbTargetFlags(["db", "lint", "--schema", "--", "--linked"]); - expect(result.connType).toBe("linked"); - expect(result.setFlags).toEqual(["linked"]); - }); - - it("bare -- with no pending skip still stops the scan", () => { - const result = resolveDbTargetFlags(["--linked", "--", "--local"]); - expect(result.connType).toBe("linked"); - expect(result.setFlags).toEqual(["linked"]); - }); - - it("skips value token after bare -p so --local is the password value, not a target", () => { - const result = resolveDbTargetFlags(["migration", "list", "-p", "--local"]); - expect(result.connType).toBeUndefined(); - expect(result.setFlags).toEqual([]); - }); - - it("skips value token after bare --password so --linked is its value, not a flag", () => { - const result = resolveDbTargetFlags(["--password", "--linked"]); - expect(result.connType).toBeUndefined(); - expect(result.setFlags).toEqual([]); - }); - - it("-ppwd (attached short password) does NOT consume the next token", () => { - const result = resolveDbTargetFlags(["-ppwd", "--local"]); - expect(result.connType).toBe("local"); - expect(result.setFlags).toEqual(["local"]); - }); - - it("--password=pwd (attached long form) does NOT consume the next token", () => { - const result = resolveDbTargetFlags(["--password=pwd", "--local"]); - expect(result.connType).toBe("local"); - expect(result.setFlags).toEqual(["local"]); - }); -}); - -describe("changedLinkedLocalFlags", () => { - it("returns nothing when neither selector is present", () => { - expect(changedLinkedLocalFlags(["seed", "buckets"])).toEqual([]); - expect(changedLinkedLocalFlags(["storage", "ls", "ss:///"])).toEqual([]); - }); - - it("returns a single selector", () => { - expect(changedLinkedLocalFlags(["seed", "buckets", "--linked"])).toEqual(["linked"]); - expect(changedLinkedLocalFlags(["seed", "buckets", "--local"])).toEqual(["local"]); - }); - - it("returns both selectors in sorted order when both are set", () => { - expect(changedLinkedLocalFlags(["seed", "buckets", "--local", "--linked"])).toEqual([ - "linked", - "local", - ]); - }); - - it("handles = forms", () => { - expect(changedLinkedLocalFlags(["--local=true", "--linked=false"])).toEqual([ - "linked", - "local", - ]); - }); - - it("treats the --no-* negation form as changed", () => { - expect(changedLinkedLocalFlags(["seed", "buckets", "--no-linked"])).toEqual(["linked"]); - expect(changedLinkedLocalFlags(["storage", "ls", "--no-local"])).toEqual(["local"]); - expect(changedLinkedLocalFlags(["seed", "buckets", "--no-local", "--linked"])).toEqual([ - "linked", - "local", - ]); - }); - - it("does not treat a value-consuming flag's value as a selector", () => { - expect(changedLinkedLocalFlags(["seed", "buckets", "--workdir", "--linked"])).toEqual([]); - expect(changedLinkedLocalFlags(["-o", "--linked", "--local"])).toEqual(["local"]); - expect( - changedLinkedLocalFlags(["storage", "cp", "--content-type", "--local", "a", "b"]), - ).toEqual([]); - expect( - changedLinkedLocalFlags(["storage", "cp", "--cache-control", "--linked", "a", "b"]), - ).toEqual([]); - expect(changedLinkedLocalFlags(["storage", "cp", "--jobs", "--local", "a", "b"])).toEqual([]); - expect(changedLinkedLocalFlags(["storage", "cp", "-j", "--linked", "a", "b"])).toEqual([]); - }); - - it("still detects a selector after a value-consuming flag's value", () => { - expect(changedLinkedLocalFlags(["storage", "cp", "--jobs", "5", "--local", "a", "b"])).toEqual([ - "local", - ]); - }); - - it("stops scanning at the -- terminator", () => { - expect(changedLinkedLocalFlags(["seed", "buckets", "--", "--local", "--linked"])).toEqual([]); - }); - - it("detects selectors given after positional arguments", () => { - 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/target/resolve-target.integration.test.ts b/apps/cli/src/command-internal/target/resolve-target.integration.test.ts index 984fcd0970..93055c8a89 100644 --- a/apps/cli/src/command-internal/target/resolve-target.integration.test.ts +++ b/apps/cli/src/command-internal/target/resolve-target.integration.test.ts @@ -40,7 +40,6 @@ const projectRefResolver = (options: { readonly linked: boolean }) => { resolve: () => Effect.die("resolve is not used by resolveTarget"), resolveForLink: () => Effect.die("resolveForLink is not used by resolveTarget"), resolveOptional: () => Effect.die("resolveOptional is not used by resolveTarget"), - loadProjectRef: () => Effect.die("loadProjectRef is not used by resolveTarget"), promptProjectRef: () => Effect.die("promptProjectRef is not used by resolveTarget"), }); return { layer, flags }; diff --git a/apps/cli/src/command-internal/target/target-password.ts b/apps/cli/src/command-internal/target/target-password.ts new file mode 100644 index 0000000000..a0b701fa78 --- /dev/null +++ b/apps/cli/src/command-internal/target/target-password.ts @@ -0,0 +1,20 @@ +import { Effect, Option } from "effect"; + +import { TargetPasswordConflictError } from "./target.errors.ts"; + +/** Fails when a non-empty `--password` accompanies a target that is not a linked project. */ +export const rejectTargetPassword = ( + target: "local" | "db-url", + password: Option.Option<string>, +): Effect.Effect<void, TargetPasswordConflictError> => + Option.isNone(Option.filter(password, (value) => value.length > 0)) + ? Effect.void + : Effect.fail( + new TargetPasswordConflictError({ + target, + 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" + : "--password only applies to a linked project. The local database uses [db].password from supabase/config.toml.", + }), + ); diff --git a/apps/cli/src/command-internal/target/target-policy.unit.test.ts b/apps/cli/src/command-internal/target/target-policy.unit.test.ts index 70ed587733..3d072e79d4 100644 --- a/apps/cli/src/command-internal/target/target-policy.unit.test.ts +++ b/apps/cli/src/command-internal/target/target-policy.unit.test.ts @@ -21,12 +21,6 @@ const VARIANTS: ReadonlyArray<{ readonly name: string; readonly variant: Permiss }, ]; -/** Leaves whose flags have not yet moved to the shared target flags. */ -const FLAG_MIGRATION_PENDING: ReadonlySet<string> = new Set([ - "gen types", - "db schema declarative generate", -]); - /** Non-GET operations a leaf may call without hosted-mutation consent, with the reason. */ const CONSENT_FREE_OPERATIONS: ReadonlyMap<string, string> = new Map([ [ @@ -77,7 +71,6 @@ const leavesByVariant = VARIANTS.map(({ name, variant }) => { const leaves = [...tree].filter(([, node]) => node.isLeaf); return { name, - leafPaths: new Set(leaves.map(([path]) => path)), withPolicy: leaves.flatMap(([path, node]): ReadonlyArray<Leaf> => { const policy = readTargetPolicy(node.command); return Option.isSome(policy) ? [{ path, node, policy: policy.value }] : []; @@ -88,8 +81,6 @@ const leavesByVariant = VARIANTS.map(({ name, variant }) => { }; }); -const everyLeafPath = new Set(leavesByVariant.flatMap(({ leafPaths }) => [...leafPaths])); - const lastWord = (path: string) => path.split(" ").at(-1) ?? path; describe.each(leavesByVariant)( @@ -113,7 +104,6 @@ describe.each(leavesByVariant)( it("gives sync and selectable leaves local, linked and project-ref flags", () => { const offenders = withPolicy .filter(({ policy }) => policy.group === "sync" || policy.group === "selectable") - .filter(({ path }) => !FLAG_MIGRATION_PENDING.has(path)) .flatMap(({ path, node }) => (["local", "linked", "project-ref"] as const) .filter((flag) => !node.flags.has(flag)) @@ -170,11 +160,6 @@ describe.each(leavesByVariant)( ); describe("target policy allowlists", () => { - it("name only real leaves", () => { - const stale = [...FLAG_MIGRATION_PENDING].filter((path) => !everyLeafPath.has(path)); - expect(stale).toEqual([]); - }); - it("name only operations in the API spec", () => { const unknown = [...CONSENT_FREE_OPERATIONS.keys()].filter( (operationId) => !OPERATIONS.has(operationId), 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 6af8effba7..e5464c9414 100644 --- a/apps/cli/src/command-internal/test-db.integration.test.ts +++ b/apps/cli/src/command-internal/test-db.integration.test.ts @@ -79,7 +79,6 @@ const stackApiStub = Layer.succeed(StackApi, { function mockResolver(opts: { conn?: PgConnInput; isLocal?: boolean } = {}) { const inputs: Array<DbConnectionInput> = []; const layer = Layer.succeed(DbConfigResolver, { - resolve: () => Effect.die("resolve is not used by this test"), resolveConnection: (input: DbConnectionInput) => { inputs.push(input); return Effect.succeed({ @@ -109,7 +108,6 @@ const projectRefLayer = Layer.succeed(ProjectRefResolver, { : { ref: VALID_REF, source: "file" as const, matchesLinkedRef: true }, ), resolveOptional: () => Effect.succeed(Option.some(VALID_REF)), - loadProjectRef: () => Effect.die("loadProjectRef is not used by this test"), promptProjectRef: () => Effect.succeed(VALID_REF), }); 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 1fa2254da9..8a171bb7c5 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 @@ -69,7 +69,6 @@ function ambientStubs() { connect: () => Effect.die("db-connection not needed for layer-exposure test"), }), Layer.succeed(DbConfigResolver, { - resolve: () => Effect.die("db-config-resolver not needed for layer-exposure test"), resolveConnection: () => Effect.die("resolveConnection is not used by this test"), resolvePoolerFallback: () => Effect.die("db-config-resolver not needed for layer-exposure test"), 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 3358bba2aa..19025b11c6 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 @@ -104,7 +104,6 @@ describe("managed test db pgTAP", { timeout: 180_000 }, () => { output.layer, mockTelemetryStateTracked().layer, Layer.succeed(DbConfigResolver, { - resolve: () => Effect.die("resolve is not used by this test"), resolveConnection: (input) => Effect.succeed({ conn: connection, @@ -135,7 +134,6 @@ describe("managed test db pgTAP", { timeout: 180_000 }, () => { resolveForLink: () => Effect.die("unused"), resolveRef: () => Effect.die("unused"), resolveOptional: () => Effect.die("unused"), - loadProjectRef: () => Effect.die("unused"), promptProjectRef: () => Effect.die("unused"), }), Layer.succeed(BundledPostgresClient, { run: () => Effect.die("unused") }), 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 0b0b47f06c..433d13d5e1 100644 --- a/apps/cli/src/commands/bootstrap/bootstrap.layers.unit.test.ts +++ b/apps/cli/src/commands/bootstrap/bootstrap.layers.unit.test.ts @@ -77,7 +77,6 @@ function ambientStubs() { resolveForLink: () => Effect.die("project-ref-resolver not needed for layer-exposure test"), resolveRef: () => Effect.die("project-ref-resolver not needed for layer-exposure test"), resolveOptional: () => Effect.die("project-ref-resolver not needed for layer-exposure test"), - loadProjectRef: () => Effect.die("project-ref-resolver not needed for layer-exposure test"), promptProjectRef: () => Effect.die("project-ref-resolver not needed for layer-exposure test"), }), Layer.succeed(TemplateService, { diff --git a/apps/cli/src/commands/db/advisors/advisors.integration.test.ts b/apps/cli/src/commands/db/advisors/advisors.integration.test.ts index aca4f1b985..a0d6bb770b 100644 --- a/apps/cli/src/commands/db/advisors/advisors.integration.test.ts +++ b/apps/cli/src/commands/db/advisors/advisors.integration.test.ts @@ -108,7 +108,6 @@ function lintRow(over: Partial<Record<string, unknown>> = {}) { function mockResolver(opts: { ipv6Error?: boolean } = {}) { const resolveInputs: Array<DbConnectionInput> = []; const layer = Layer.succeed(DbConfigResolver, { - resolve: () => Effect.die("resolve is not used by this test"), resolveConnection: (input: DbConnectionInput) => Effect.gen(function* () { resolveInputs.push(input); @@ -194,7 +193,6 @@ function mockProjectRef() { }; }), resolveOptional: () => Effect.succeed(Option.some(VALID_REF)), - loadProjectRef: () => Effect.die("loadProjectRef is not used by this test"), promptProjectRef: () => Effect.succeed(VALID_REF), }); return { 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 ccc889bad6..77722e5ed0 100644 --- a/apps/cli/src/commands/db/diff/diff.integration.test.ts +++ b/apps/cli/src/commands/db/diff/diff.integration.test.ts @@ -409,7 +409,6 @@ function setup(workdir: string, opts: SetupOpts = {}) { const resolverCalls: DbConnectionInput[] = []; const resolver = Layer.succeed(DbConfigResolver, { - resolve: () => Effect.die("resolve is not used by this test"), resolveConnection: (input: DbConnectionInput) => { resolverCalls.push(input); return Effect.succeed({ @@ -442,7 +441,6 @@ function setup(workdir: string, opts: SetupOpts = {}) { matchesLinkedRef: true, }), resolveOptional: () => Effect.succeed(Option.some(opts.linkedRef ?? VALID_REF)), - loadProjectRef: () => Effect.die("loadProjectRef is not used by this test"), promptProjectRef: () => Effect.succeed(opts.linkedRef ?? VALID_REF), }); diff --git a/apps/cli/src/commands/db/dump/dump.handler.ts b/apps/cli/src/commands/db/dump/dump.handler.ts index 4e680b01e4..34990b0f3f 100644 --- a/apps/cli/src/commands/db/dump/dump.handler.ts +++ b/apps/cli/src/commands/db/dump/dump.handler.ts @@ -145,17 +145,14 @@ export const dbDump = Effect.fn("db.dump")(function* (flags: DbDumpFlags) { if (target.kind === "linked") { yield* Ref.set(linkedRefForCache, target.ref); } - const { - conn, - isLocal, - ref: resolvedRef, - } = yield* resolver.resolveConnection({ + const connectionInput = { target, dnsResolver, password: flags.password, hasPasswordFlag: true, networkBanRemoval: yield* hostedMutationPreapproval, - }); + }; + const { conn, isLocal, ref: resolvedRef } = yield* resolver.resolveConnection(connectionInput); const db = isLocal ? "local" : "remote"; // On the linked path, re-read config with the resolved ref so a matching // `[remotes.<ref>]` block overrides `db.major_version` for the pg_dump image. @@ -406,15 +403,7 @@ export const dbDump = Effect.fn("db.dump")(function* (flags: DbDumpFlags) { projectHost: cliSettings.projectHost, resolvePooler: () => resolver - .resolvePoolerFallback({ - dbUrl: Option.none(), - connType: "linked", - dnsResolver, - password: flags.password, - linkedProjectRef: - target.kind === "linked" ? Option.some(target.ref) : Option.none(), - adHocProjectRef: target.kind === "linked" && !target.matchesLinkedRef, - }) + .resolvePoolerFallback(connectionInput) .pipe(Effect.orElseSucceed(() => Option.none())), runWithConn: (c) => runContainer(mode.buildEnv(c, opt)), reprintOnRetry: output.raw(`Dumping ${mode.verb} from ${db} database...\n`, "stderr"), 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 220c641292..2d8ec82112 100644 --- a/apps/cli/src/commands/db/dump/dump.integration.test.ts +++ b/apps/cli/src/commands/db/dump/dump.integration.test.ts @@ -75,7 +75,6 @@ import { import { dbConfig } from "../../../command-internal/db-config.layer.ts"; import { DbConfigResolver } from "../../../command-internal/db-config.service.ts"; import type { - DbConfigFlags, DbConnectionInput, ResolvedDbConnection, } from "../../../command-internal/db-config.types.ts"; @@ -246,9 +245,8 @@ function mockResolver(opts: { ref?: string; }) { const calls: DbConnectionInput[] = []; - const fallbackCalls: DbConfigFlags[] = []; + const fallbackCalls: DbConnectionInput[] = []; const layer = Layer.succeed(DbConfigResolver, { - resolve: () => Effect.die("resolve is not used by this test"), resolveConnection: (input: DbConnectionInput) => { calls.push(input); // Simulates connection resolution failing (IPv6 probe/pooler/temp login-role) @@ -265,8 +263,8 @@ function mockResolver(opts: { target: input.target, } satisfies ResolvedDbConnection); }, - resolvePoolerFallback: (flags) => { - fallbackCalls.push(flags); + resolvePoolerFallback: (input) => { + fallbackCalls.push(input); return opts.poolerFallbackFails === true ? Effect.fail(new DbConfigConnectTempRoleError({ message: "failed to create temp role" })) : Effect.succeed(opts.poolerFallback ?? Option.none()); @@ -316,7 +314,6 @@ function mockProjectRefResolver(opts: { }); }, resolveOptional: () => Effect.succeed(Option.some(opts.ref ?? VALID_REF)), - loadProjectRef: () => Effect.die("loadProjectRef is not used by this test"), promptProjectRef: () => Effect.succeed(opts.ref ?? VALID_REF), }); return { layer }; @@ -1099,10 +1096,7 @@ describe("db dump integration", () => { yield* dbDump(flags({ linked: true })); expect(docker.allOpts).toHaveLength(2); expect(resolver.fallbackCalls).toHaveLength(1); - expect(resolver.fallbackCalls[0]).toMatchObject({ - connType: "linked", - linkedProjectRef: Option.some(VALID_REF), - }); + expect(resolver.fallbackCalls[0]?.target).toMatchObject({ kind: "linked", ref: VALID_REF }); expect(docker.allOpts[1]?.env["PGHOST"]).toBe(POOLER_CONN.host); expect(out.stderrText).toContain("does not support IPv6"); expect(out.stderrText).toContain("Retrying via the IPv4 connection pooler."); diff --git a/apps/cli/src/commands/db/lint/lint.integration.test.ts b/apps/cli/src/commands/db/lint/lint.integration.test.ts index 60b729cbe0..c83656513f 100644 --- a/apps/cli/src/commands/db/lint/lint.integration.test.ts +++ b/apps/cli/src/commands/db/lint/lint.integration.test.ts @@ -80,7 +80,6 @@ function checkRow(proname: string, issues: ReadonlyArray<Record<string, unknown> function mockResolver(opts: { isLocal?: boolean } = {}) { let resolveInput: DbConnectionInput | undefined; const layer = Layer.succeed(DbConfigResolver, { - resolve: () => Effect.die("resolve is not used by this test"), resolveConnection: (input: DbConnectionInput) => { resolveInput = input; return Effect.succeed({ @@ -191,7 +190,6 @@ function mockProjectRef() { }; }), resolveOptional: () => Effect.succeed(Option.some(VALID_REF)), - loadProjectRef: () => Effect.die("loadProjectRef is not used by this test"), promptProjectRef: () => Effect.succeed(VALID_REF), }); return { 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 83ec23c9c5..020745f2df 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 @@ -80,7 +80,6 @@ function ambientStubs() { connect: () => Effect.die("db-connection not needed for layer-exposure test"), }), Layer.succeed(DbConfigResolver, { - resolve: () => Effect.die("db-config-resolver not needed for layer-exposure test"), resolveConnection: () => Effect.die("resolveConnection is not used by this test"), resolvePoolerFallback: () => Effect.die("db-config-resolver not needed for layer-exposure test"), @@ -90,7 +89,6 @@ function ambientStubs() { resolveForLink: () => Effect.die("project-ref-resolver not needed for layer-exposure test"), resolveRef: () => Effect.die("project-ref-resolver not needed for layer-exposure test"), resolveOptional: () => Effect.die("project-ref-resolver not needed for layer-exposure test"), - loadProjectRef: () => Effect.die("project-ref-resolver not needed for layer-exposure test"), promptProjectRef: () => Effect.die("project-ref-resolver not needed for layer-exposure test"), }), Layer.succeed(CommandPlatformApiFactory, { 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 263946fd76..eea3edc8ba 100644 --- a/apps/cli/src/commands/db/pull/pull.integration.test.ts +++ b/apps/cli/src/commands/db/pull/pull.integration.test.ts @@ -414,7 +414,6 @@ function setup(workdir: string, opts: SetupOpts = {}) { const poolerFallbackCalls: unknown[] = []; const resolveCalls: Array<DbConnectionInput> = []; const resolver = Layer.succeed(DbConfigResolver, { - resolve: () => Effect.die("resolve is not used by db pull"), resolveConnection: (input: DbConnectionInput) => { resolveCalls.push(input); const isLocal = input.target.kind === "local"; @@ -463,7 +462,6 @@ function setup(workdir: string, opts: SetupOpts = {}) { matchesLinkedRef: true, }), resolveOptional: () => Effect.succeed(Option.some(opts.resolvedRef ?? VALID_REF)), - loadProjectRef: () => Effect.die("loadProjectRef is not used by db pull"), promptProjectRef: () => Effect.succeed(opts.resolvedRef ?? VALID_REF), }); 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 0b9332e8fe..6b106c8144 100644 --- a/apps/cli/src/commands/db/push/push.integration.test.ts +++ b/apps/cli/src/commands/db/push/push.integration.test.ts @@ -92,7 +92,6 @@ function mockResolver( ) { const calls: Array<DbConnectionInput> = []; const layer = Layer.succeed(DbConfigResolver, { - resolve: () => Effect.die("resolve is not used by db push"), resolveConnection: (input: DbConnectionInput) => Effect.sync(() => { calls.push(input); @@ -252,7 +251,6 @@ function setup( matchesLinkedRef: true, }), resolveOptional: () => Effect.succeed(Option.some(opts.projectRef ?? VALID_REF)), - loadProjectRef: () => Effect.die("loadProjectRef is not used by db push"), promptProjectRef: () => Effect.succeed(opts.projectRef ?? VALID_REF), }); 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 0aace4c835..54040c1b26 100644 --- a/apps/cli/src/commands/db/query/query.integration.test.ts +++ b/apps/cli/src/commands/db/query/query.integration.test.ts @@ -102,7 +102,6 @@ const failMessage = (exit: Exit.Exit<unknown, { readonly message: string }>): st function mockResolver(isLocal: boolean | undefined, resolveFails = false) { return Layer.succeed(DbConfigResolver, { - resolve: () => Effect.die("resolve is not used by this test"), resolveConnection: (input: DbConnectionInput) => resolveFails ? Effect.fail( @@ -223,7 +222,6 @@ function mockProjectRef(unlinked = false, refReadFails = false) { resolveForLink: () => Effect.succeed(REF), resolveRef, resolveOptional: () => Effect.succeed(unlinked ? Option.none() : Option.some(REF)), - loadProjectRef: () => Effect.die("loadProjectRef is not used by this test"), promptProjectRef: () => Effect.succeed(REF), }); } 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 ac2db3a960..765905bf73 100644 --- a/apps/cli/src/commands/db/reset/reset.integration.test.ts +++ b/apps/cli/src/commands/db/reset/reset.integration.test.ts @@ -162,7 +162,6 @@ function mockResolver(opts: { let calls = 0; const inputs: Array<DbConnectionInput> = []; const layer = Layer.succeed(DbConfigResolver, { - resolve: () => Effect.die("resolve is not used by this test"), resolveConnection: (input: DbConnectionInput) => { calls++; inputs.push(input); @@ -1083,7 +1082,6 @@ function setup( matchesLinkedRef: true, }), resolveOptional: () => Effect.succeed(Option.some(opts.ref ?? VALID_REF)), - loadProjectRef: () => Effect.die("loadProjectRef is not used by this test"), promptProjectRef: () => Effect.succeed(opts.ref ?? VALID_REF), }), Layer.succeed(CommandPlatformApiFactory, { 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 35726da471..a0db23aa5b 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,6 +6,7 @@ import { DbConfigResolver } from "../../../../command-internal/db-config.service import { hostedMutationPreapproval } from "../../../../command-internal/hosted-consent/hosted-consent.ts"; import { toPostgresURL } from "../../../../command-internal/postgres-url.ts"; import type { ResolvedTarget } from "../../../../command-internal/target/resolve-target.ts"; +import { rejectTargetPassword } from "../../../../command-internal/target/target-password.ts"; import type { PgDeltaDatabaseEndpoint } from "../../shared/pgdelta-engine.service.ts"; import { DeclarativeApplyError, readErrorSuggestion } from "./declarative.errors.ts"; import { DeclarativeSeam } from "../../shared/pgdelta.seam.service.ts"; @@ -52,6 +53,7 @@ export const prepareLocalTargetEndpoint = Effect.fn("DeclarativeSchema.localTarg E, R, >(shouldReset: Effect.Effect<boolean, E, R>, password: Option.Option<string> = Option.none()) { + yield* rejectTargetPassword("local", password); const seam = yield* DeclarativeSeam; yield* seam.ensureLocalPostgresImageCurrent; yield* seam.ensureLocalDatabaseStarted; 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 26c550e968..19dd4caecd 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 @@ -298,7 +298,6 @@ function setup(workdir: string, opts: SetupOpts = {}) { ); const resolverCalls: DbConnectionInput[] = []; const resolver = Layer.succeed(DbConfigResolver, { - resolve: () => Effect.die("resolve is not used by this test"), resolveConnection: (input) => Effect.sync(() => { resolverCalls.push(input); 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 239ac0abde..5904f74138 100644 --- a/apps/cli/src/commands/db/test/test.integration.test.ts +++ b/apps/cli/src/commands/db/test/test.integration.test.ts @@ -115,7 +115,6 @@ function mockContextualAnalytics() { function mockResolver(conn: PgConnInput = LOCAL_CONN) { return Layer.succeed(DbConfigResolver, { - resolve: () => Effect.die("resolve is not used by this test"), resolveConnection: (input: DbConnectionInput) => Effect.succeed({ conn, @@ -137,7 +136,6 @@ const projectRefLayer = Layer.succeed(ProjectRefResolver, { matchesLinkedRef: false, }), resolveOptional: () => Effect.die("resolveOptional is not used by this test"), - loadProjectRef: () => Effect.die("loadProjectRef is not used by this test"), promptProjectRef: () => Effect.die("promptProjectRef is not used by this test"), }); diff --git a/apps/cli/src/commands/functions/list/list.integration.test.ts b/apps/cli/src/commands/functions/list/list.integration.test.ts index 111027d281..66cbf751aa 100644 --- a/apps/cli/src/commands/functions/list/list.integration.test.ts +++ b/apps/cli/src/commands/functions/list/list.integration.test.ts @@ -413,7 +413,6 @@ describe("functions list integration", () => { resolveForLink: () => Effect.die("not used in functions list test"), resolveRef: () => Effect.die("not used in functions list test"), resolveOptional: () => Effect.die("not used in functions list test"), - loadProjectRef: () => Effect.die("not used in functions list test"), promptProjectRef: () => Effect.die("not used in functions list test"), }), ); diff --git a/apps/cli/src/commands/gen/types/types.handler.ts b/apps/cli/src/commands/gen/types/types.handler.ts index 4942be5950..f1f8315c47 100644 --- a/apps/cli/src/commands/gen/types/types.handler.ts +++ b/apps/cli/src/commands/gen/types/types.handler.ts @@ -21,7 +21,6 @@ import { type DbConfigError, DbConfigResolver, } from "../../../command-internal/db-config.service.ts"; -import type { DbConfigFlags } from "../../../command-internal/db-config.types.ts"; import { poolerConfigFromConnectionString } from "../../../command-internal/db-config.parse.ts"; import { readDbToml } from "../../../command-internal/db-config.toml-read.ts"; import { getHostname } from "../../../command-internal/hostname.ts"; @@ -401,13 +400,17 @@ export const genTypes = Effect.fn("gen.types")(function* (flags: GenTypesFlags) yield* output.raw(types); }); - const connectTo = Effect.fnUntraced(function* (target: ResolvedTarget) { - return yield* dbConfig.resolveConnection({ + const connectionInputFor = Effect.fnUntraced(function* (target: ResolvedTarget) { + return { target, dnsResolver, password: Option.none(), networkBanRemoval: yield* hostedMutationPreapproval, - }); + }; + }); + + const connectTo = Effect.fnUntraced(function* (target: ResolvedTarget) { + return yield* dbConfig.resolveConnection(yield* connectionInputFor(target)); }); const runProjectTypes = ( @@ -429,15 +432,9 @@ export const genTypes = Effect.fn("gen.types")(function* (flags: GenTypesFlags) ); if (projectResult === "branch") return; - const resolved = yield* connectTo(target); + const connectionInput = yield* connectionInputFor(target); + const resolved = yield* dbConfig.resolveConnection(connectionInput); const conn = pinSupabaseTls(resolved.conn); - const poolerFlags: DbConfigFlags = { - dbUrl: Option.none(), - connType: "linked", - dnsResolver, - linkedProjectRef: Option.some(projectRef), - adHocProjectRef: !target.matchesLinkedRef, - }; yield* runGenerate({ conn, isLocal: resolved.isLocal, @@ -447,7 +444,7 @@ export const genTypes = Effect.fn("gen.types")(function* (flags: GenTypesFlags) directHost: conn.host, eligible: !resolved.isLocal && isDirectDbHost(conn.host, cliSettings.projectHost), resolve: dbConfig - .resolvePoolerFallback(poolerFlags) + .resolvePoolerFallback(connectionInput) .pipe(Effect.map(Option.map(pinSupabaseTls))), }, }); 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 0ae3a8ecb0..6d79f478f8 100644 --- a/apps/cli/src/commands/gen/types/types.integration.test.ts +++ b/apps/cli/src/commands/gen/types/types.integration.test.ts @@ -51,7 +51,6 @@ import type { DbConfigError } from "../../../command-internal/db-config.service. import { DbConfigResolver } from "../../../command-internal/db-config.service.ts"; import { DbConfigLoadError } from "../../../command-internal/db-config.errors.ts"; import type { - DbConfigFlags, DbConnectionInput, ResolvedDbConfig, ResolvedDbConnection, @@ -164,9 +163,8 @@ function mockDbConfigResolver( } = {}, ) { const resolves: Array<DbConnectionInput> = []; - const poolerFallbacks: Array<DbConfigFlags> = []; + const poolerFallbacks: Array<DbConnectionInput> = []; const layer = Layer.succeed(DbConfigResolver, { - resolve: () => Effect.die("resolve is not used by this test"), resolveConnection: (input) => Effect.gen(function* () { resolves.push(input); @@ -184,11 +182,11 @@ function mockDbConfigResolver( ); return { ...resolved, target: input.target } satisfies ResolvedDbConnection; }), - resolvePoolerFallback: (flags) => + resolvePoolerFallback: (input) => opts.poolerFallbackFails === true ? Effect.fail(new DbConfigLoadError({ message: "pooler fallback failed" })) : Effect.sync(() => { - poolerFallbacks.push(flags); + poolerFallbacks.push(input); return opts.poolerFallback ?? Option.none<PgConnInput>(); }), }); @@ -1929,8 +1927,10 @@ describe("gen types", () => { expect(generator.calls[0]?.conn.host).toBe(`db.${VALID_REF}.supabase.co`); expect(generator.calls[1]?.conn.host).toBe("127.0.0.1"); expect(dbConfig.poolerFallbacks).toHaveLength(1); - expect(dbConfig.poolerFallbacks[0]?.connType).toBe("linked"); - expect(dbConfig.poolerFallbacks[0]?.adHocProjectRef).toBe(true); + expect(dbConfig.poolerFallbacks[0]?.target).toMatchObject({ + kind: "linked", + matchesLinkedRef: false, + }); }).pipe(Effect.provide(BunServices.layer)), ); 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 ed21f4c5d2..20fdc34ae8 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 @@ -66,7 +66,6 @@ function ambientStubs() { resolveForLink: () => Effect.die("project-ref-resolver not needed for layer-exposure test"), resolveRef: () => Effect.die("project-ref-resolver not needed for layer-exposure test"), resolveOptional: () => Effect.die("project-ref-resolver not needed for layer-exposure test"), - loadProjectRef: () => Effect.die("project-ref-resolver not needed for layer-exposure test"), promptProjectRef: () => Effect.die("project-ref-resolver not needed for layer-exposure test"), }), Layer.succeed(CommandPlatformApiFactory, { diff --git a/apps/cli/src/commands/inspect/db/inspect-deprecated.integration.test.ts b/apps/cli/src/commands/inspect/db/inspect-deprecated.integration.test.ts index 41c3760d04..088f62c597 100644 --- a/apps/cli/src/commands/inspect/db/inspect-deprecated.integration.test.ts +++ b/apps/cli/src/commands/inspect/db/inspect-deprecated.integration.test.ts @@ -53,7 +53,6 @@ function setup() { Layer.succeed(TargetPolicy, { group: "selectable", consent: "none" }), configValuesLayer(), Layer.succeed(DbConfigResolver, { - resolve: () => Effect.die("resolve is not used by this test"), resolveConnection: (input: DbConnectionInput) => Effect.succeed({ conn: LOCAL_CONN, isLocal: true, target: input.target }), resolvePoolerFallback: () => Effect.succeed(Option.none()), diff --git a/apps/cli/src/commands/inspect/db/inspect-query.integration.test.ts b/apps/cli/src/commands/inspect/db/inspect-query.integration.test.ts index 7ac00ce877..e4ff059e77 100644 --- a/apps/cli/src/commands/inspect/db/inspect-query.integration.test.ts +++ b/apps/cli/src/commands/inspect/db/inspect-query.integration.test.ts @@ -103,7 +103,6 @@ const LOCKS_ROW = { function mockResolver(opts: { conn?: PgConnInput; isLocal?: boolean; fails?: boolean }) { let connectionInput: DbConnectionInput | undefined; const layer = Layer.succeed(DbConfigResolver, { - resolve: () => Effect.die("resolve is not used by this test"), resolveConnection: (input: DbConnectionInput) => { connectionInput = input; if (opts.fails === true) { diff --git a/apps/cli/src/commands/inspect/db/inspect-specs.integration.test.ts b/apps/cli/src/commands/inspect/db/inspect-specs.integration.test.ts index 19e8cf6f19..639f7b936f 100644 --- a/apps/cli/src/commands/inspect/db/inspect-specs.integration.test.ts +++ b/apps/cli/src/commands/inspect/db/inspect-specs.integration.test.ts @@ -54,7 +54,6 @@ function setup(rows: ReadonlyArray<Record<string, unknown>>) { Layer.succeed(TargetPolicy, { group: "selectable", consent: "none" }), configValuesLayer(), Layer.succeed(DbConfigResolver, { - resolve: () => Effect.die("resolve is not used by this test"), resolveConnection: (input: DbConnectionInput) => Effect.succeed({ conn: LOCAL_CONN, isLocal: true, target: input.target }), resolvePoolerFallback: () => Effect.succeed(Option.none()), 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 6af551a24e..2ccc447aca 100644 --- a/apps/cli/src/commands/inspect/inspect.layers.unit.test.ts +++ b/apps/cli/src/commands/inspect/inspect.layers.unit.test.ts @@ -56,7 +56,6 @@ function ambientStubs() { connect: () => Effect.die("db-connection not needed for layer-exposure test"), }), Layer.succeed(DbConfigResolver, { - resolve: () => Effect.die("db-config-resolver not needed for layer-exposure test"), resolveConnection: () => Effect.die("resolveConnection is not used by this test"), resolvePoolerFallback: () => Effect.die("db-config-resolver not needed for layer-exposure test"), 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 4becb71d4c..c890c192f8 100644 --- a/apps/cli/src/commands/inspect/report/report.integration.test.ts +++ b/apps/cli/src/commands/inspect/report/report.integration.test.ts @@ -92,7 +92,6 @@ function tempDirLayer<S>( function mockResolver(opts: { conn?: PgConnInput; isLocal?: boolean; fails?: boolean } = {}) { let connectionInput: DbConnectionInput | undefined; const layer = Layer.succeed(DbConfigResolver, { - resolve: () => Effect.die("resolve is not used by this test"), resolveConnection: (input: DbConnectionInput) => { connectionInput = input; if (opts.fails === true) { 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 6206ba072d..19ba2a95a9 100644 --- a/apps/cli/src/commands/migration/down/down.integration.test.ts +++ b/apps/cli/src/commands/migration/down/down.integration.test.ts @@ -81,7 +81,6 @@ function fakeDatabase(opts: SetupOpts = {}) { const queries: Array<{ sql: string; params?: ReadonlyArray<unknown> }> = []; const resolver = Layer.succeed(DbConfigResolver, { - resolve: () => Effect.die("resolve is not used by this test"), resolveConnection: (input: DbConnectionInput) => Effect.succeed({ conn: { @@ -165,8 +164,6 @@ function setup(workdir: string, opts: SetupOpts = {}) { matchesLinkedRef: Option.isNone(flagValue), }), resolveOptional: () => Effect.succeed(Option.some(VALID_REF)), - loadProjectRef: (flagValue: Option.Option<string>) => - Effect.succeed(Option.getOrElse(flagValue, () => VALID_REF)), promptProjectRef: () => Effect.succeed(VALID_REF), }); diff --git a/apps/cli/src/commands/migration/fetch/fetch.integration.test.ts b/apps/cli/src/commands/migration/fetch/fetch.integration.test.ts index 0b12a5eb42..adb7547d60 100644 --- a/apps/cli/src/commands/migration/fetch/fetch.integration.test.ts +++ b/apps/cli/src/commands/migration/fetch/fetch.integration.test.ts @@ -85,7 +85,6 @@ function setup(workdir: string, opts: SetupOpts = {}) { const resolveCalls: Array<DbConnectionInput> = []; const resolver = Layer.succeed(DbConfigResolver, { - resolve: () => Effect.die("resolve is not used by migration fetch"), resolveConnection: (input: DbConnectionInput) => opts.resolveFails === true ? Effect.fail( @@ -140,7 +139,6 @@ function setup(workdir: string, opts: SetupOpts = {}) { : { ref: VALID_REF, source: "file", matchesLinkedRef: true }, ), resolveOptional: () => Effect.succeed(Option.some(VALID_REF)), - loadProjectRef: () => Effect.die("loadProjectRef is not used by migration fetch"), promptProjectRef: () => Effect.succeed(VALID_REF), }); 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 f264783c29..b7e322f2bb 100644 --- a/apps/cli/src/commands/migration/list/list.integration.test.ts +++ b/apps/cli/src/commands/migration/list/list.integration.test.ts @@ -71,7 +71,6 @@ function setup(workdir: string, opts: SetupOpts = {}) { const resolverCalls: Array<DbConnectionInput> = []; const resolver = Layer.succeed(DbConfigResolver, { - resolve: () => Effect.die("resolve is not used by this test"), resolveConnection: (input: DbConnectionInput) => { resolverCalls.push(input); return Effect.succeed({ @@ -121,8 +120,6 @@ function setup(workdir: string, opts: SetupOpts = {}) { matchesLinkedRef: Option.isNone(flagValue), }), resolveOptional: () => Effect.succeed(Option.some(VALID_REF)), - loadProjectRef: (flagValue: Option.Option<string>) => - Effect.succeed(Option.getOrElse(flagValue, () => VALID_REF)), promptProjectRef: () => Effect.succeed(VALID_REF), }); diff --git a/apps/cli/src/commands/migration/migration.errors.ts b/apps/cli/src/commands/migration/migration.errors.ts index 33082fa6a0..e77d7ef1ad 100644 --- a/apps/cli/src/commands/migration/migration.errors.ts +++ b/apps/cli/src/commands/migration/migration.errors.ts @@ -6,19 +6,6 @@ import { ErrorActionabilityId, } from "../../shared/telemetry/error-actionability.ts"; -/** - * Conflicting database-target flags. Matches the established - * mutually-exclusive-flags error text for `db-url`/`linked`/`local`. Shared by - * list / fetch / repair / up / down / squash. - */ -export class MigrationTargetFlagsError extends Data.TaggedError("MigrationTargetFlagsError")<{ - readonly message: string; -}> { - get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { - return actionability.provideFlags; - } -} - /** * A positional version argument is not a valid integer. Matches the established * `failed to parse <v>: invalid version number` text. diff --git a/apps/cli/src/commands/migration/migration.prompt.ts b/apps/cli/src/commands/migration/migration.prompt.ts deleted file mode 100644 index bd74d62990..0000000000 --- a/apps/cli/src/commands/migration/migration.prompt.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { Effect, Option } from "effect"; - -import { parseYesNo } from "../../command-internal/prompt-yes-no.ts"; -import { Output } from "../../shared/output/output.service.ts"; -import { Stdin } from "../../shared/runtime/stdin.service.ts"; - -/** The established console read-line timeouts. */ -const TTY_TIMEOUT_MILLIS = 10 * 60 * 1000; -const NON_TTY_TIMEOUT_MILLIS = 100; - -/** - * The console yes/no prompt shared by fetch/repair/down/squash. Diverges from the - * general `promptYesNo`: it writes the label to stderr and reads one stdin line - * regardless of `--output` format (rather than auto-defaulting in json/stream-json), - * and on a real TTY it reads a raw stdin line with a 10-minute timeout instead of a - * clack confirm UI. `--yes` short-circuits to `true`, echoing `<label> y`. A parsed - * answer wins; an empty line, EOF, or timeout takes the default; any other line declines. - */ -export const migrationConfirm = ( - title: string, - options: { readonly defaultValue: boolean; readonly yes: boolean }, -) => - Effect.gen(function* () { - const output = yield* Output; - const stdin = yield* Stdin; - const choices = options.defaultValue ? "Y/n" : "y/N"; - const label = `${title} [${choices}] `; - if (options.yes) { - yield* output.raw(`${label}y\n`, "stderr"); - return true; - } - - // TTY input echoes via the terminal; a non-TTY run needs a manual stderr echo. - yield* output.raw(label, "stderr"); - const line = yield* stdin.readLine(stdin.isTTY ? TTY_TIMEOUT_MILLIS : NON_TTY_TIMEOUT_MILLIS); - const input = Option.getOrElse(line, () => ""); - if (!stdin.isTTY) yield* output.raw(`${input.trim()}\n`, "stderr"); - // An unrecognised answer is never consent. - return parseYesNo(input) ?? (input.length > 0 ? false : options.defaultValue); - }); 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 3c5c24c600..e24f80861c 100644 --- a/apps/cli/src/commands/migration/repair/repair.integration.test.ts +++ b/apps/cli/src/commands/migration/repair/repair.integration.test.ts @@ -73,7 +73,6 @@ function fakeDatabase(opts: Pick<SetupOpts, "failSql"> = {}) { const queries: Array<{ sql: string; params?: ReadonlyArray<unknown> }> = []; const resolver = Layer.succeed(DbConfigResolver, { - resolve: () => Effect.die("resolve is not used by this test"), resolveConnection: (connectionInput: DbConnectionInput) => Effect.succeed({ conn: { @@ -152,8 +151,6 @@ function setup(workdir: string, opts: SetupOpts = {}) { matchesLinkedRef: Option.isNone(flagValue), }), resolveOptional: () => Effect.succeed(Option.some(VALID_REF)), - loadProjectRef: (flagValue: Option.Option<string>) => - Effect.succeed(Option.getOrElse(flagValue, () => VALID_REF)), promptProjectRef: () => Effect.succeed(VALID_REF), }); 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 2d43959511..0a8178778a 100644 --- a/apps/cli/src/commands/migration/squash/squash.integration.test.ts +++ b/apps/cli/src/commands/migration/squash/squash.integration.test.ts @@ -310,7 +310,6 @@ function fakeServices(workdir: string, opts: SetupOpts = {}) { const resolverCalls: Array<DbConnectionInput> = []; const resolver = Layer.succeed(DbConfigResolver, { - resolve: () => Effect.die("resolve is not used by this test"), resolveConnection: (input: DbConnectionInput) => { resolverCalls.push(input); return Effect.succeed({ @@ -421,8 +420,6 @@ function setup(workdir: string, opts: SetupOpts = {}) { matchesLinkedRef: Option.isNone(flagValue), }), resolveOptional: () => Effect.succeed(Option.some(VALID_REF)), - loadProjectRef: (flagValue: Option.Option<string>) => - Effect.succeed(Option.getOrElse(flagValue, () => VALID_REF)), promptProjectRef: () => Effect.succeed(VALID_REF), }); 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 a38accd133..e1b1d59f6a 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 @@ -65,7 +65,6 @@ const testLayer = (root: string) => mockRuntimeInfo(), Layer.succeed(CliArgs, { args: [] }), Layer.succeed(DbConfigResolver, { - resolve: () => Effect.die("resolve is not used by this test"), resolveConnection: (input: DbConnectionInput) => Effect.succeed({ conn: { @@ -94,7 +93,6 @@ const testLayer = (root: string) => resolveRef: () => Effect.succeed({ ref: "abcdefghijklmnopqrst", source: "file", matchesLinkedRef: true }), resolveOptional: () => Effect.succeed(Option.some("abcdefghijklmnopqrst")), - loadProjectRef: () => Effect.succeed("abcdefghijklmnopqrst"), promptProjectRef: () => Effect.succeed("abcdefghijklmnopqrst"), }), mockTty(), 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 4942645780..970e26119a 100644 --- a/apps/cli/src/commands/migration/up/up.integration.test.ts +++ b/apps/cli/src/commands/migration/up/up.integration.test.ts @@ -76,7 +76,6 @@ function fakeDatabase(opts: SetupOpts = {}) { const resolverCalls: Array<DbConnectionInput> = []; const resolver = Layer.succeed(DbConfigResolver, { - resolve: () => Effect.die("resolve is not used by this test"), resolveConnection: (input: DbConnectionInput) => { resolverCalls.push(input); return Effect.succeed({ @@ -148,8 +147,6 @@ function setup(workdir: string, opts: SetupOpts = {}) { matchesLinkedRef: Option.isNone(flagValue), }), resolveOptional: () => Effect.succeed(Option.some(VALID_REF)), - loadProjectRef: (flagValue: Option.Option<string>) => - Effect.succeed(Option.getOrElse(flagValue, () => VALID_REF)), promptProjectRef: () => Effect.succeed(VALID_REF), }); diff --git a/apps/cli/src/commands/pull/pull.integration.test.ts b/apps/cli/src/commands/pull/pull.integration.test.ts index 3b8df18dac..c6e1b158b7 100644 --- a/apps/cli/src/commands/pull/pull.integration.test.ts +++ b/apps/cli/src/commands/pull/pull.integration.test.ts @@ -390,7 +390,6 @@ function makeDbConfigLayers( }); const resolver = Layer.succeed(DbConfigResolver, { - resolve: () => Effect.die("resolve is not used by pull"), resolveConnection: ({ target }: DbConnectionInput) => Effect.succeed({ conn: { 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 b6ed62f945..f262ede0a5 100644 --- a/apps/cli/src/commands/seed/buckets/buckets.integration.test.ts +++ b/apps/cli/src/commands/seed/buckets/buckets.integration.test.ts @@ -211,16 +211,6 @@ const setupSeedBuckets = Effect.fnUntraced(function* ( // An explicit `--project-ref` flag takes precedence and short-circuits // before `linkedFails`, so a test can prove the flag resolves a ref even // for an "unlinked" workdir. - loadProjectRef: (flagValue: Option.Option<string>) => - Option.isSome(flagValue) && flagValue.value.length > 0 - ? Effect.succeed(flagValue.value) - : opts.linkedFails === true - ? Effect.fail( - new ProjectRefNotLinkedError({ - message: "Cannot find project ref. Have you run supabase link?", - }), - ) - : Effect.succeed(projectRefRef), promptProjectRef: () => Effect.succeed(projectRefRef), }); diff --git a/apps/cli/src/config/project-ref.layer.ts b/apps/cli/src/config/project-ref.layer.ts index 0c1fe2db99..9eb4370d2f 100644 --- a/apps/cli/src/config/project-ref.layer.ts +++ b/apps/cli/src/config/project-ref.layer.ts @@ -158,26 +158,10 @@ export const projectRefLayer = Layer.effect( if (Option.isSome(cliSettings.projectId)) { return cliSettings.projectId; } - // A ref-file read error degrades to "not linked" here, unlike `resolve`/ - // `loadProjectRef`, since `projects list` only uses the value as a display marker. + // A ref-file read error degrades to "not linked" here, unlike `resolve`, + // since `projects list` only uses the value as a display marker. return yield* readRefFile.pipe(Effect.orElseSucceed(() => Option.none<string>())); }), - loadProjectRef: (flagValue) => - Effect.gen(function* () { - if (Option.isSome(flagValue) && flagValue.value.length > 0) { - return yield* assertValid(flagValue.value); - } - if (Option.isSome(cliSettings.projectId)) { - return yield* assertValid(cliSettings.projectId.value); - } - const fileValue = yield* readRefFile; - if (Option.isSome(fileValue)) { - return yield* assertValid(fileValue.value); - } - return yield* Effect.fail( - new ProjectRefNotLinkedError({ message: PROJECT_NOT_LINKED_MESSAGE }), - ); - }), promptProjectRef: promptForProjectRef, }); }), 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 426f0170fa..8c0511cc85 100644 --- a/apps/cli/src/config/project-ref.layer.unit.test.ts +++ b/apps/cli/src/config/project-ref.layer.unit.test.ts @@ -276,60 +276,6 @@ describe("projectRefLayer", () => { }); }); - describe("loadProjectRef (non-prompting)", () => { - it.effect("prefers flag, then projectId, then the ref file", () => { - writeRefFile(tempRoot, ANOTHER_REF); - const { layer } = makeLayer({ workdir: tempRoot, projectId: ANOTHER_REF }); - return Effect.gen(function* () { - const { loadProjectRef } = yield* ProjectRefResolver; - expect(yield* loadProjectRef(Option.some(VALID_REF))).toBe(VALID_REF); - }).pipe(Effect.provide(layer)); - }); - - it.effect("reads the ref file when flag and projectId are unset", () => { - writeRefFile(tempRoot, VALID_REF); - const { layer } = makeLayer({ workdir: tempRoot }); - return Effect.gen(function* () { - const { loadProjectRef } = yield* ProjectRefResolver; - expect(yield* loadProjectRef(Option.none())).toBe(VALID_REF); - }).pipe(Effect.provide(layer)); - }); - - it.effect("fails fast with ProjectRefNotLinkedError and never prompts on a TTY", () => { - const { layer, out } = makeLayer({ - workdir: tempRoot, - stdinIsTty: true, - projects: [ - { id: VALID_REF, name: "alpha", organization_slug: "acme", region: "us-east-1" }, - ], - promptSelectResponses: [VALID_REF], - }); - return Effect.gen(function* () { - const { loadProjectRef } = yield* ProjectRefResolver; - const exit = yield* Effect.exit(loadProjectRef(Option.none())); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - const errorJson = JSON.stringify(exit.cause); - expect(errorJson).toContain("ProjectRefNotLinkedError"); - expect(errorJson).toContain("supabase link"); - } - expect(out.promptSelectCalls).toHaveLength(0); - }).pipe(Effect.provide(layer)); - }); - - it.effect("validates the resolved ref format", () => { - const { layer } = makeLayer({ workdir: tempRoot, projectId: "not-a-valid-ref" }); - return Effect.gen(function* () { - const { loadProjectRef } = yield* ProjectRefResolver; - const exit = yield* Effect.exit(loadProjectRef(Option.none())); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain("InvalidProjectRefError"); - } - }).pipe(Effect.provide(layer)); - }); - }); - describe("resolveForLink", () => { it.effect("prefers the --project-ref flag", () => { const { layer } = makeLayer({ workdir: tempRoot, projectId: ANOTHER_REF }); @@ -496,6 +442,19 @@ describe("projectRefLayer", () => { }).pipe(Effect.provide(layer)); }); + it.effect("surfaces a ref-file read failure instead of reporting not-linked", () => { + mkdirSync(join(tempRoot, "supabase", ".temp", "project-ref"), { recursive: true }); + const { layer } = makeLayer({ workdir: tempRoot }); + return Effect.gen(function* () { + const { resolveRef } = yield* ProjectRefResolver; + const exit = yield* Effect.exit(resolveRef(Option.none())); + expect(Exit.isFailure(exit)).toBe(true); + const errorJson = JSON.stringify(exit); + expect(errorJson).toContain("failed to load project ref"); + expect(errorJson).not.toContain("Cannot find project ref"); + }).pipe(Effect.provide(layer)); + }); + it.effect("announces nothing when no ref resolves", () => { const { layer, out } = makeLayer({ workdir: tempRoot }); return Effect.gen(function* () { diff --git a/apps/cli/src/config/project-ref.service.ts b/apps/cli/src/config/project-ref.service.ts index 63bfe97d84..bcc781362e 100644 --- a/apps/cli/src/config/project-ref.service.ts +++ b/apps/cli/src/config/project-ref.service.ts @@ -62,20 +62,6 @@ interface ProjectRefResolverShape { readonly resolveOptional: ( flagValue: Option.Option<string>, ) => Effect.Effect<Option.Option<string>, never, never>; - /** - * Non-prompting resolution chain (flag → `cliSettings.projectId` → ref file) that fails - * hard with `ProjectRefNotLinkedError` when nothing resolves, with ref-format validation. - * Announces no target. - * - * @deprecated Use `resolveRef`, or `resolveTarget` for database and storage commands. - */ - readonly loadProjectRef: ( - flagValue: Option.Option<string>, - ) => Effect.Effect< - string, - ProjectRefNotLinkedError | InvalidProjectRefError | ProjectRefReadError, - never - >; /** * Lists all projects and prompts the user to select one with the given title, * writing "Selected project: <ref>" to stderr (text mode). The `title` lets 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 7656cda800..fe41bc426d 100644 --- a/apps/cli/src/shared/cli/code-structure.unit.test.ts +++ b/apps/cli/src/shared/cli/code-structure.unit.test.ts @@ -264,67 +264,6 @@ const NETWORK_BAN_REMOVAL_OWNERS: ReadonlyArray<RegExp> = [ const TARGET_GUARD_FILE = "shared/cli/code-structure.unit.test.ts"; -/** Files still on the previous target selection and consent APIs; the integration pass empties this list. */ -const TARGET_MIGRATION_PENDING_FILES: ReadonlySet<string> = new Set<string>([ - "command-internal/db-config.layer.ts", - "command-internal/db-config.types.ts", - "command-internal/db-pull-run.ts", - "command-internal/db-push-core.ts", - "command-internal/db-target-flags.ts", - "command-internal/db-target-flags.unit.test.ts", - "command-internal/migration-fetch-run.ts", - "command-internal/seed-remote-consent.ts", - "command-internal/test-db.command-handler.ts", - "command-internal/test-db.handler.ts", - "command-internal/test-db.integration.test.ts", - "commands/db/advisors/advisors.command.ts", - "commands/db/advisors/advisors.handler.ts", - "commands/db/diff/diff.command.ts", - "commands/db/diff/diff.handler.ts", - "commands/db/dump/dump.command.ts", - "commands/db/dump/dump.handler.ts", - "commands/db/lint/lint.command.ts", - "commands/db/lint/lint.handler.ts", - "commands/db/pull/pull.command.ts", - "commands/db/push/push.command.ts", - "commands/db/push/push.handler.ts", - "commands/db/query/query.command.ts", - "commands/db/query/query.handler.ts", - "commands/db/reset/reset.command.ts", - "commands/db/reset/reset.handler.ts", - "commands/db/schema/declarative/generate/generate.command.ts", - "commands/db/schema/declarative/generate/generate.handler.ts", - "commands/db/shared/pooler-fallback.ts", - "commands/gen/types/types.command.ts", - "commands/inspect/db/inspect-db-command.ts", - "commands/inspect/db/inspect-query.ts", - "commands/inspect/report/report.command.ts", - "commands/inspect/report/report.handler.ts", - "commands/migration/down/down.command.ts", - "commands/migration/down/down.handler.ts", - "commands/migration/fetch/fetch.command.ts", - "commands/migration/fetch/fetch.handler.ts", - "commands/migration/fetch/fetch.integration.test.ts", - "commands/migration/list/list.command.ts", - "commands/migration/list/list.handler.ts", - "commands/migration/migration.prompt.ts", - "commands/migration/repair/repair.command.ts", - "commands/migration/repair/repair.handler.ts", - "commands/migration/squash/squash.command.ts", - "commands/migration/squash/squash.handler.ts", - "commands/migration/up/up.command.ts", - "commands/migration/up/up.handler.ts", - "commands/seed/buckets/buckets.flags.ts", - "commands/seed/buckets/buckets.handler.ts", - "commands/seed/seed.flags.ts", - "commands/storage/cp/cp.handler.ts", - "commands/storage/ls/ls.handler.ts", - "commands/storage/mv/mv.handler.ts", - "commands/storage/rm/rm.handler.ts", - "commands/storage/storage.flags.ts", - "telemetry/command-telemetry.ts", -]); - const allSpecifiers = (source: string): Array<string> => Array.from( source.matchAll(/(?:from\s+|import\(\s*)["']([^"']+)["']/g), @@ -508,9 +447,7 @@ layer(BunServices.layer)("code structure", (it) => { Effect.gen(function* () { const files = (yield* walk(srcDir)).filter(isSourceFile); const violations = yield* scanSource(files, (relativePath, source) => - relativePath in TARGET_FLAG_EXEMPT || TARGET_MIGRATION_PENDING_FILES.has(relativePath) - ? [] - : findRawTargetFlags(source), + relativePath in TARGET_FLAG_EXEMPT ? [] : findRawTargetFlags(source), ); expect(violations).toEqual([]); }), @@ -520,9 +457,7 @@ layer(BunServices.layer)("code structure", (it) => { Effect.gen(function* () { const files = (yield* walk(srcDir)).filter(isAnyTypeScript); const violations = yield* scanSource(files, (relativePath, source) => - relativePath === TARGET_GUARD_FILE || TARGET_MIGRATION_PENDING_FILES.has(relativePath) - ? [] - : findLegacyTargetIdentifiers(source), + relativePath === TARGET_GUARD_FILE ? [] : findLegacyTargetIdentifiers(source), ); expect(violations).toEqual([]); }), @@ -543,9 +478,7 @@ layer(BunServices.layer)("code structure", (it) => { it.effect("leaves announcing the target to the Target banner", () => Effect.gen(function* () { const files = (yield* walk(srcDir)).filter(isSourceFile); - const violations = yield* scanSource(files, (relativePath, source) => - TARGET_MIGRATION_PENDING_FILES.has(relativePath) ? [] : findAdHocTargetLines(source), - ); + const violations = yield* scanSource(files, (_, source) => findAdHocTargetLines(source)); expect(violations).toEqual([]); }), ); diff --git a/apps/cli/src/telemetry/command-telemetry.ts b/apps/cli/src/telemetry/command-telemetry.ts index 1db7d382d2..ac1ef7aa69 100644 --- a/apps/cli/src/telemetry/command-telemetry.ts +++ b/apps/cli/src/telemetry/command-telemetry.ts @@ -177,8 +177,7 @@ function extractChangedFlagNames( } // End-of-options sentinel: parsing stops at a bare `--`, so everything after it - // is positional (e.g. `test db -- --linked` makes `--linked` a path arg, not a flag). Mirrors - // `resolveDbTargetFlags`'s `--` handling. + // is positional (e.g. `test db -- --linked` makes `--linked` a path arg, not a flag). if (arg === "--") break; if (arg.startsWith("--")) { diff --git a/apps/cli/tests/helpers/compute.ts b/apps/cli/tests/helpers/compute.ts index 13e17f256e..464e57f15b 100644 --- a/apps/cli/tests/helpers/compute.ts +++ b/apps/cli/tests/helpers/compute.ts @@ -417,8 +417,6 @@ const testProjectRefLayer = (linked: boolean) => { Effect.fail(new ProjectRefNotLinkedError({ message: "Not available in compute tests" })), resolveOptional: () => Effect.succeed(linked ? Option.some(COMPUTE_PROJECT_REF) : Option.none()), - loadProjectRef: () => - Effect.fail(new ProjectRefNotLinkedError({ message: "Not available in compute tests" })), promptProjectRef: () => Effect.fail(new ProjectRefNotLinkedError({ message: "Not available in compute tests" })), }); diff --git a/apps/cli/tests/helpers/notebooks.ts b/apps/cli/tests/helpers/notebooks.ts index 08536954e8..ee4e569353 100644 --- a/apps/cli/tests/helpers/notebooks.ts +++ b/apps/cli/tests/helpers/notebooks.ts @@ -242,7 +242,6 @@ function testProjectRefLayer(linked: boolean) { ), resolveForLink: resolve, resolveOptional: (flag) => Effect.succeed(optional(flag)), - loadProjectRef: resolve, promptProjectRef: () => resolve(Option.none()), }); } diff --git a/apps/cli/tests/helpers/storage.ts b/apps/cli/tests/helpers/storage.ts index 33b608be6b..6b36498c21 100644 --- a/apps/cli/tests/helpers/storage.ts +++ b/apps/cli/tests/helpers/storage.ts @@ -441,12 +441,6 @@ export function setupStorage(workdir: string, opts: SetupStorageOptions) { resolveOptional: () => Effect.succeed(Option.some(projectRefRef)), // An explicit `--project-ref` flag takes precedence and short-circuits before // `linkedFails`, so a test can resolve a ref even for an "unlinked" workdir. - loadProjectRef: (flagValue: Option.Option<string>) => - Option.isSome(flagValue) && flagValue.value.length > 0 - ? Effect.succeed(flagValue.value) - : opts.linkedFails === true - ? Effect.fail(notLinked()) - : Effect.succeed(projectRefRef), promptProjectRef: () => Effect.succeed(projectRefRef), }); From ccd9cd3ab6910f2e2a5e285f0c6297a060e0d18f Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Thu, 15 Oct 2026 15:23:29 +0100 Subject: [PATCH 65/92] refactor(cli): delete the cancel and mutual-exclusion errors consent replaced Also removes the seed consent prompt helpers, withContext, and the linked resolver wiring the declarative sync layers never consumed. --- .../command-permissions/model.ts | 17 --- apps/cli/src/command-internal/db-push-core.ts | 4 +- .../seed-remote-consent.errors.ts | 16 --- .../command-internal/seed-remote-consent.ts | 112 +++--------------- .../src/command-internal/test-db.errors.ts | 11 -- .../src/commands/branches/branches.errors.ts | 9 -- .../src/commands/config/push/push.errors.ts | 11 -- .../commands/db/advisors/advisors.errors.ts | 12 -- apps/cli/src/commands/db/diff/diff.errors.ts | 12 -- apps/cli/src/commands/db/lint/lint.errors.ts | 12 -- apps/cli/src/commands/db/pull/pull.errors.ts | 10 -- apps/cli/src/commands/db/push/push.errors.ts | 21 ---- .../cli/src/commands/db/query/query.errors.ts | 14 --- .../cli/src/commands/db/reset/reset.errors.ts | 25 ---- .../schema/declarative/declarative.errors.ts | 13 -- .../declarative/sync/sync.integration.test.ts | 47 +------- .../db/schema/declarative/sync/sync.layers.ts | 23 +++- .../commands/feedback/delete/delete.errors.ts | 11 -- .../src/commands/inspect/db/inspect-query.ts | 13 -- .../src/commands/projects/projects.errors.ts | 9 -- .../src/commands/secrets/secrets.errors.ts | 23 ---- .../commands/seed/buckets/buckets.errors.ts | 14 --- .../src/commands/storage/storage.errors.ts | 14 --- .../src/shared/cli/hidden-flag.unit.test.ts | 8 +- apps/cli/src/shared/compute/compute.errors.ts | 31 ----- .../cli/src/shared/functions/deploy.errors.ts | 10 -- .../telemetry/__fixtures__/error-tags.txt | 26 ---- 27 files changed, 42 insertions(+), 486 deletions(-) delete mode 100644 apps/cli/src/command-internal/seed-remote-consent.errors.ts diff --git a/apps/cli/src/command-internal/command-permissions/model.ts b/apps/cli/src/command-internal/command-permissions/model.ts index 9d65e3fb61..a76bb2f14d 100644 --- a/apps/cli/src/command-internal/command-permissions/model.ts +++ b/apps/cli/src/command-internal/command-permissions/model.ts @@ -161,23 +161,6 @@ export function withCondition( }; } -/** - * Adds `prerequisite` to every entry's `context`, for a condition that isn't a flag, e.g. a choice - * the user makes at a prompt. - */ -export function withContext( - fragment: PermissionFragment, - prerequisite: string, -): PermissionFragment { - return { - ...fragment, - operations: fragment.operations.map((entry) => ({ - ...entry, - context: entry.context === undefined ? prerequisite : `${prerequisite}, and ${entry.context}`, - })), - }; -} - /** * Gives every entry in `fragment` the `kind` the including command handles it with, when that * differs from the block's own (e.g. a command that carries on after a 403 on a call its diff --git a/apps/cli/src/command-internal/db-push-core.ts b/apps/cli/src/command-internal/db-push-core.ts index a6d8ebe5e5..d00a2d7d26 100644 --- a/apps/cli/src/command-internal/db-push-core.ts +++ b/apps/cli/src/command-internal/db-push-core.ts @@ -1,6 +1,6 @@ import { Effect, FileSystem, Option, Path } from "effect"; -import type { planDbSeed } from "./seed-remote-consent.ts"; +import type { DbSeedPlan } from "./seed-remote-consent.ts"; import { Output } from "../shared/output/output.service.ts"; import { listLocalMigrations } from "./migration-list.ts"; import { @@ -78,7 +78,7 @@ export interface DbPushCoreInput { readonly includeAll: boolean; readonly includeRoles: boolean; readonly includeSeed: boolean; - readonly seed: Effect.Success<ReturnType<typeof planDbSeed>>; + readonly seed: DbSeedPlan; readonly includeVault: boolean; readonly dnsResolver: "native" | "https"; /** Already loaded + validated `config.toml`, e.g. via `checkDbToml`. */ diff --git a/apps/cli/src/command-internal/seed-remote-consent.errors.ts b/apps/cli/src/command-internal/seed-remote-consent.errors.ts deleted file mode 100644 index 424b028b7c..0000000000 --- a/apps/cli/src/command-internal/seed-remote-consent.errors.ts +++ /dev/null @@ -1,16 +0,0 @@ -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 index 59fad5a094..3c71a2ea0b 100644 --- a/apps/cli/src/command-internal/seed-remote-consent.ts +++ b/apps/cli/src/command-internal/seed-remote-consent.ts @@ -3,32 +3,28 @@ 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 { +/** A seed run whose target matched a `[remotes.*]` block that did not turn seeding on itself. */ +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 { +/** The effective `[db.seed]` values a command acts on, plus the remote that did not enable them. */ +interface DbSeedInput { readonly enabled: boolean; readonly sqlPaths: ReadonlyArray<string>; readonly consent: SeedConsentTarget | undefined; } -type SeedConsentCommand = "push" | "reset"; - -const SEED_CONSENT_SUGGESTIONS: Record<SeedConsentCommand, string> = { - 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.", -}; +/** The seed input plus the line a hosted-mutation consent shows for the files a run would seed. */ +export interface DbSeedPlan { + readonly input: DbSeedInput; + /** `undefined` when seeding is off or `files` is empty. */ + readonly detail: (files: ReadonlyArray<string>) => string | undefined; +} const remoteDeclaresSeedEnabled = (resolvedConfig: ResolvedCliConfig, remote: string): boolean => { const block = resolvedConfig.loaded.interpolatedRemotes?.[remote]; @@ -41,11 +37,8 @@ const remoteDeclaresSeedEnabled = (resolvedConfig: ResolvedCliConfig, remote: st 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* ( +/** Reads `[db.seed]` through the resolved config; the caller's single consent carries {@link DbSeedPlan.detail}. */ +export const planDbSeed = Effect.fn("DbSeedPlan.plan")(function* ( resolvedConfig: ResolvedCliConfig, target: { readonly workdir: string; readonly ref: string }, ) { @@ -61,92 +54,15 @@ export const resolveDbSeedInput = Effect.fn("DbSeedInput.resolve")(function* ( remote, enabledBy: describeCliConfigOrigin(enabled.origin, { workdir: target.workdir, path }), }; - return { enabled: enabled.value, sqlPaths: sqlPaths.value, consent } satisfies DbSeedInput; -}); - -/** The seed input plus the line a hosted-mutation consent shows for the files a run would seed. */ -interface DbSeedPlan { - readonly input: DbSeedInput; - /** `undefined` when seeding is off or `files` is empty. */ - readonly detail: (files: ReadonlyArray<string>) => string | undefined; -} - -/** Reads `[db.seed]` like {@link resolveDbSeedInput} but never prompts: the caller's single consent carries {@link DbSeedPlan.detail}. */ -export const planDbSeed = Effect.fn("DbSeedPlan.plan")(function* ( - resolvedConfig: ResolvedCliConfig, - target: { readonly workdir: string; readonly ref: string }, -) { - const input = yield* resolveDbSeedInput(resolvedConfig, target); - const remote = Option.getOrUndefined(resolvedConfig.appliedRemote); + const input: DbSeedInput = { enabled: enabled.value, sqlPaths: sqlPaths.value, consent }; return { input, detail: (files) => { if (!input.enabled || files.length === 0) return undefined; const noun = files.length === 1 ? "file" : "files"; const into = remote === undefined ? "" : ` into [remotes.${remote}]`; - const enabledBy = - input.consent === undefined ? "" : ` (seeding enabled by ${input.consent.enabledBy})`; + const enabledBy = consent === undefined ? "" : ` (seeding enabled by ${consent.enabledBy})`; return `seed ${files.length} ${noun}${into}${enabledBy}`; }, } satisfies DbSeedPlan; }); - -/** 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<string>; - 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/test-db.errors.ts b/apps/cli/src/command-internal/test-db.errors.ts index 62b81c4820..557f2b07d7 100644 --- a/apps/cli/src/command-internal/test-db.errors.ts +++ b/apps/cli/src/command-internal/test-db.errors.ts @@ -41,14 +41,3 @@ export class TestDbNoTestsError extends Data.TaggedError("TestDbNoTestsError")<{ return actionability.invalidInput; } } - -/** More than one of `--db-url` / `--linked` / `--local` was set. */ -export class TestDbMutuallyExclusiveFlagsError extends Data.TaggedError( - "TestDbMutuallyExclusiveFlagsError", -)<{ - readonly message: string; -}> { - get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { - return actionability.provideFlags; - } -} diff --git a/apps/cli/src/commands/branches/branches.errors.ts b/apps/cli/src/commands/branches/branches.errors.ts index 4c2b12e2d7..a149820667 100644 --- a/apps/cli/src/commands/branches/branches.errors.ts +++ b/apps/cli/src/commands/branches/branches.errors.ts @@ -293,15 +293,6 @@ export class BranchesEnvNotSupportedError extends Data.TaggedError("BranchesEnvN } } -/** @public */ -export class BranchesCreateCancelledError extends Data.TaggedError("BranchesCreateCancelledError")<{ - readonly message: string; -}> { - get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { - return actionability.cancelled; - } -} - export class BranchesBranchNameEmptyError extends Data.TaggedError("BranchesBranchNameEmptyError")<{ readonly message: string; }> { diff --git a/apps/cli/src/commands/config/push/push.errors.ts b/apps/cli/src/commands/config/push/push.errors.ts index dddbf504b8..3349ff96e8 100644 --- a/apps/cli/src/commands/config/push/push.errors.ts +++ b/apps/cli/src/commands/config/push/push.errors.ts @@ -120,17 +120,6 @@ export class ConfigPushBranchResolveStatusError extends Data.TaggedError( // transport failure, or an unexpected status) degrades to an uncertain target rather than // aborting the push. There is no error class for this probe — it never fails the command. -// TODO(CLI-2695): delete with its entry in the error-tag snapshot once consent replaced it. -/** @public */ -export class ConfigPushCancelledError extends Data.TaggedError("ConfigPushCancelledError")<{ - readonly message: string; - readonly suggestion?: string; -}> { - get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { - return actionability.cancelled; - } -} - export class ConfigPushListAddonsNetworkError extends Data.TaggedError( "ConfigPushListAddonsNetworkError", )<DecodableNetworkErrorArgs> { diff --git a/apps/cli/src/commands/db/advisors/advisors.errors.ts b/apps/cli/src/commands/db/advisors/advisors.errors.ts index 25fec4cc61..a9598f2721 100644 --- a/apps/cli/src/commands/db/advisors/advisors.errors.ts +++ b/apps/cli/src/commands/db/advisors/advisors.errors.ts @@ -15,18 +15,6 @@ import { * `InvalidProjectRefError`. */ -/** - * Unused: target conflicts raise `TargetSelectorConflictError`. - * @public - */ -export class DbAdvisorsMutuallyExclusiveFlagsError extends Data.TaggedError( - "DbAdvisorsMutuallyExclusiveFlagsError", -)<{ readonly message: string }> { - get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { - return actionability.provideFlags; - } -} - /** * `--linked` PreRunE: no access token; message text and the "Run supabase * login first." suggestion are an established output contract. diff --git a/apps/cli/src/commands/db/diff/diff.errors.ts b/apps/cli/src/commands/db/diff/diff.errors.ts index 0e550fcadb..edec34492b 100644 --- a/apps/cli/src/commands/db/diff/diff.errors.ts +++ b/apps/cli/src/commands/db/diff/diff.errors.ts @@ -6,18 +6,6 @@ import { ErrorActionabilityId, } from "../../../shared/telemetry/error-actionability.ts"; -/** - * Unused: target conflicts raise `TargetSelectorConflictError`. - * @public - */ -export class DbDiffTargetFlagsError extends Data.TaggedError("DbDiffTargetFlagsError")<{ - readonly message: string; -}> { - get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { - return actionability.provideFlags; - } -} - /** * Conflicting diff-engine flags (`use-migra`/`use-pgadmin`/`use-pg-delta`); message text is * an established output contract. `use-pg-schema` is rejected unconditionally before this diff --git a/apps/cli/src/commands/db/lint/lint.errors.ts b/apps/cli/src/commands/db/lint/lint.errors.ts index fa256d8b48..3999955406 100644 --- a/apps/cli/src/commands/db/lint/lint.errors.ts +++ b/apps/cli/src/commands/db/lint/lint.errors.ts @@ -14,18 +14,6 @@ import { * connection layer — not re-wrapped here. */ -/** - * Unused: target conflicts raise `TargetSelectorConflictError`. - * @public - */ -export class DbLintMutuallyExclusiveFlagsError extends Data.TaggedError( - "DbLintMutuallyExclusiveFlagsError", -)<{ readonly message: string }> { - get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { - return actionability.provideFlags; - } -} - /** `failed to begin transaction: <cause>`; message text is an established output contract. */ export class DbLintBeginTxError extends Data.TaggedError("DbLintBeginTxError")<{ readonly message: string; diff --git a/apps/cli/src/commands/db/pull/pull.errors.ts b/apps/cli/src/commands/db/pull/pull.errors.ts index b55cf7ab0e..d774fdfa6a 100644 --- a/apps/cli/src/commands/db/pull/pull.errors.ts +++ b/apps/cli/src/commands/db/pull/pull.errors.ts @@ -6,16 +6,6 @@ import { ErrorActionabilityId, } from "../../../shared/telemetry/error-actionability.ts"; -// TODO(CLI-2695): delete with its entry in the error-tag snapshot; the target selector replaced it. -/** @public */ -export class DbPullTargetFlagsError extends Data.TaggedError("DbPullTargetFlagsError")<{ - readonly message: string; -}> { - get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { - return actionability.provideFlags; - } -} - /** * `--declarative` combined with `--diff-engine`; message text is an established output * contract. diff --git a/apps/cli/src/commands/db/push/push.errors.ts b/apps/cli/src/commands/db/push/push.errors.ts index e9e068a5bf..35b446305d 100644 --- a/apps/cli/src/commands/db/push/push.errors.ts +++ b/apps/cli/src/commands/db/push/push.errors.ts @@ -5,16 +5,6 @@ import { ErrorActionabilityId, } from "../../../shared/telemetry/error-actionability.ts"; -// TODO(CLI-2695): delete with its entry in the error-tag snapshot; the target selector replaced it. -/** @public */ -export class DbPushTargetFlagsError extends Data.TaggedError("DbPushTargetFlagsError")<{ - readonly message: string; -}> { - get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { - return actionability.provideFlags; - } -} - /** * Remote migration versions are missing from the local directory; message * text is an established output contract. The `migration repair` / `db pull` @@ -43,17 +33,6 @@ export class DbPushMissingRemoteError extends Data.TaggedError("DbPushMissingRem } } -// TODO(CLI-2695): delete with its entry in the error-tag snapshot; hosted consent replaced it. -/** @public */ -export class DbPushCancelledError extends Data.TaggedError("DbPushCancelledError")<{ - readonly message: string; - readonly suggestion?: string; -}> { - get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { - return actionability.cancelled; - } -} - /** Locating `supabase/roles.sql` failed; message text (`failed to find custom roles: <cause>`) is an established output contract. */ export class DbPushRolesError extends Data.TaggedError("DbPushRolesError")<{ readonly message: string; diff --git a/apps/cli/src/commands/db/query/query.errors.ts b/apps/cli/src/commands/db/query/query.errors.ts index 5b4ba04c7d..cbc87d4cc2 100644 --- a/apps/cli/src/commands/db/query/query.errors.ts +++ b/apps/cli/src/commands/db/query/query.errors.ts @@ -81,20 +81,6 @@ export class DbQueryExecError extends Data.TaggedError("DbQueryExecError")<{ } } -/** - * Unused: target conflicts raise `TargetSelectorConflictError`. - * @public - */ -export class DbQueryMutuallyExclusiveFlagsError extends Data.TaggedError( - "DbQueryMutuallyExclusiveFlagsError", -)<{ - readonly message: string; -}> { - get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { - return actionability.provideFlags; - } -} - /** * The linked Management API returned a non-201 status; message text * (`"unexpected status <status>: <body>"`) is an established output contract. diff --git a/apps/cli/src/commands/db/reset/reset.errors.ts b/apps/cli/src/commands/db/reset/reset.errors.ts index 79f0fb1d5a..786a896d35 100644 --- a/apps/cli/src/commands/db/reset/reset.errors.ts +++ b/apps/cli/src/commands/db/reset/reset.errors.ts @@ -6,18 +6,6 @@ import { ErrorActionabilityId, } from "../../../shared/telemetry/error-actionability.ts"; -/** - * Unused: target conflicts raise `TargetSelectorConflictError`. - * @public - */ -export class DbResetTargetFlagsError extends Data.TaggedError("DbResetTargetFlagsError")<{ - readonly message: string; -}> { - get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { - return actionability.provideFlags; - } -} - /** * `--version` and `--last` together; message text is an established output * contract. @@ -56,19 +44,6 @@ export class DbResetMigrationFileError extends Data.TaggedError("DbResetMigratio } } -/** - * Unused: a declined hosted confirmation raises `HostedMutationDeclinedError`. - * @public - */ -export class DbResetCancelledError extends Data.TaggedError("DbResetCancelledError")<{ - readonly message: string; - readonly suggestion?: string; -}> { - get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { - return actionability.cancelled; - } -} - /** * A drop / migrate / seed / vault statement failed during the remote reset. * `suggestion` is set only by the `--experimental` schema-files apply branch diff --git a/apps/cli/src/commands/db/schema/declarative/declarative.errors.ts b/apps/cli/src/commands/db/schema/declarative/declarative.errors.ts index 020732b47a..47bed4998d 100644 --- a/apps/cli/src/commands/db/schema/declarative/declarative.errors.ts +++ b/apps/cli/src/commands/db/schema/declarative/declarative.errors.ts @@ -44,19 +44,6 @@ export class DeclarativeMutuallyExclusiveFlagsError extends Data.TaggedError( } } -/** - * Retained for its telemetry tag; no command raises it now that no prompt asks for a URL. - * - * @public - */ -export class DeclarativeInvalidDbUrlError extends Data.TaggedError("DeclarativeInvalidDbUrlError")<{ - readonly message: string; -}> { - get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { - return actionability.provideFlags; - } -} - /** * `db schema declarative generate` ran but produced no declarative files (sync's post-generate * guard); message text is an established output contract. 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 fe2e669d78..6aab8548e2 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 @@ -18,8 +18,6 @@ import { assertPermissionDrift } from "../../../../../../tests/helpers/permissio import { provideEnv, realDbConfigResolver, - realLinkedProjectCache, - recordingProjectHttpClient, serveLinkedDatabase, stubManagementApi, type StubbedRequest, @@ -44,7 +42,6 @@ import { VALID_REF, VALID_TOKEN, mockCommandSettings, - mockLinkedProjectCacheTracked, mockLocalDockerEngineUnavailableLayer, mockCommandPlatformApiService, mockTelemetryStateTracked, @@ -114,7 +111,7 @@ interface SetupOpts { removals?: PgDeltaRemovalSummary; planErrors?: ReadonlyArray<PgDeltaEngineError>; stackBackend?: boolean; - /** Runs the real resolver and linked-project cache; `workdir` must be a real directory. */ + /** Runs the real db-config resolver; `workdir` must be a real directory. */ realLayers?: boolean; } @@ -219,7 +216,6 @@ function setup(workdir: string, opts: SetupOpts = {}) { promptTextResponses: opts.promptTextResponses, }); const telemetry = mockTelemetryStateTracked(); - const cache = mockLinkedProjectCacheTracked(); const localPostgresImageChecks: Array<true> = []; const platformApi = mockCommandPlatformApiService({}); // Backs `resetLocalDatabase`'s real, native container-recreate, reached when the @@ -290,17 +286,6 @@ function setup(workdir: string, opts: SetupOpts = {}) { }, }); const resolver = Layer.succeed(DbConfigResolver, { - resolve: () => - Effect.succeed({ - conn: { - host: "db.remote", - port: 5432, - user: "postgres", - password: "x", - database: "postgres", - }, - isLocal: false, - }), resolveConnection: ({ target }) => Effect.succeed({ conn: { @@ -388,7 +373,6 @@ function setup(workdir: string, opts: SetupOpts = {}) { }).pipe(Effect.provide(BunServices.layer)), }), ); - const projectHttp = recordingProjectHttpClient(VALID_REF); const layer = Layer.mergeAll( out.layer, configValuesLayer({ @@ -399,12 +383,6 @@ function setup(workdir: string, opts: SetupOpts = {}) { }), }), telemetry.layer, - opts.realLayers === true - ? realLinkedProjectCache({ - httpClient: projectHttp.layer, - cliSettings: mockCommandSettings({ workdir }), - }) - : cache.layer, seam, engine, mockLocalDockerEngineUnavailableLayer, @@ -451,9 +429,7 @@ function setup(workdir: string, opts: SetupOpts = {}) { dbExec, dbBatches, dbConnectPorts, - cache, telemetry, - projectHttp, localPostgresImageChecks, declarativeExportCalls, planDebug, @@ -927,22 +903,6 @@ describe("db schema declarative sync integration", () => { }).pipe(Effect.provide(s.layer)); }); - it.effect("bootstrap never fills the linked-project cache, even in a linked workdir", () => { - const s = setup(tmp.current, { - experimental: true, - stdinIsTty: true, - diffSql: "", - projectId: Option.some(VALID_REF), - promptConfirmResponses: [true, false], // [generate a new one? yes][reset? no] - }); - return Effect.gen(function* () { - yield* seedMigration(tmp.current); - yield* dbSchemaDeclarativeSync(flags({ noApply: Option.some(true) })); - expect(s.cache.cached).toBe(false); - expect(s.telemetry.flushCount).toBe(1); - }).pipe(Effect.provide(s.layer)); - }); - it.effect("empty diff prints 'No schema changes found' and writes nothing", () => { const s = setup(tmp.current, { experimental: true, diffSql: "" }); return Effect.gen(function* () { @@ -1561,7 +1521,7 @@ describe("db schema declarative sync integration", () => { }); }); -describe("db schema declarative sync permissions through the real resolver and cache", () => { +describe("db schema declarative sync permissions through the real resolver", () => { const tmp = useTempWorkdir(); const saveLinkedRef = Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; @@ -1569,7 +1529,7 @@ describe("db schema declarative sync permissions through the real resolver and c yield* fs.writeFileString(`${tmp.current}/supabase/.temp/project-ref`, VALID_REF); }); - it.effect("the bootstrap makes no Management API request and leaves the cache alone", () => { + it.effect("the bootstrap makes no Management API request", () => { const s = setup(tmp.current, { realLayers: true, experimental: true, @@ -1589,7 +1549,6 @@ describe("db schema declarative sync permissions through the real resolver and c expect(Exit.isSuccess(exit)).toBe(true); expect(s.declarativeExportCalls).toHaveLength(1); expect(requests).toEqual([]); - expect(s.projectHttp.calls).toEqual([]); assertPermissionDrift({ command: "db schema declarative sync", activeFlags: { "no-apply": true }, diff --git a/apps/cli/src/commands/db/schema/declarative/sync/sync.layers.ts b/apps/cli/src/commands/db/schema/declarative/sync/sync.layers.ts index 45f0c52d6c..3a8324ec42 100644 --- a/apps/cli/src/commands/db/schema/declarative/sync/sync.layers.ts +++ b/apps/cli/src/commands/db/schema/declarative/sync/sync.layers.ts @@ -1,23 +1,38 @@ import { Layer } from "effect"; +import { commandCredentialsLayer } from "../../../../../auth/command-credentials.layer.ts"; +import { commandPlatformApiFactoryLayer } from "../../../../../auth/command-platform-api-factory.layer.ts"; +import { commandSettingsLayer } from "../../../../../config/command-settings.layer.ts"; import { commandRuntimeLayer } from "../../../../../shared/runtime/command-runtime.layer.ts"; import { stdinLayer } from "../../../../../shared/runtime/stdin.layer.ts"; +import { debugLoggerLayer } from "../../../../../command-internal/debug-logger.layer.ts"; import { identityStitchLayer } from "../../../../../command-internal/identity-stitch.ts"; -import { linkedDbResolverRuntimeLayer } from "../../../../../command-internal/management-api-runtime.layer.ts"; import { telemetryStateLayer } from "../../../../../telemetry/telemetry-state.layer.ts"; import { pgDeltaCommandRuntimeLayer, pgDeltaDbConfigRuntimeLayer, } from "../../../../../command-internal/pgdelta-engine-runtime.layer.ts"; +const cliSettings = commandSettingsLayer.pipe(Layer.provide(debugLoggerLayer)); +const credentials = commandCredentialsLayer.pipe( + Layer.provide(cliSettings), + Layer.provide(debugLoggerLayer), +); + +// The local-reset bucket seeding statically requires the factory, which resolves its token lazily. +const platformApiFactory = commandPlatformApiFactoryLayer.pipe( + Layer.provide(credentials), + Layer.provide(cliSettings), + Layer.provide(debugLoggerLayer), + Layer.provide(identityStitchLayer), +); + export const dbSchemaDeclarativeSyncRuntimeLayer = Layer.mergeAll( pgDeltaDbConfigRuntimeLayer, pgDeltaCommandRuntimeLayer, identityStitchLayer, telemetryStateLayer, - linkedDbResolverRuntimeLayer(["db", "schema", "declarative", "sync"]).pipe( - Layer.provide(identityStitchLayer), - ), + platformApiFactory, commandRuntimeLayer(["db", "schema", "declarative", "sync"]), stdinLayer, ); diff --git a/apps/cli/src/commands/feedback/delete/delete.errors.ts b/apps/cli/src/commands/feedback/delete/delete.errors.ts index 5345fe20f8..348b44d293 100644 --- a/apps/cli/src/commands/feedback/delete/delete.errors.ts +++ b/apps/cli/src/commands/feedback/delete/delete.errors.ts @@ -15,8 +15,6 @@ export const FEEDBACK_NOT_FOUND_MESSAGE = "from the linked project directory (or pass --project-ref <ref>) and log in as the " + "account that submitted it."; -export const FEEDBACK_DELETE_CANCELLED_MESSAGE = "Deletion cancelled."; - /** The token argument is not a UUID (checked client-side before any request). */ export class FeedbackInvalidTokenError extends Data.TaggedError("FeedbackInvalidTokenError")<{ readonly message: string; @@ -39,12 +37,3 @@ export class FeedbackNotFoundError extends Data.TaggedError("FeedbackNotFoundErr return actionability.invalidInput; } } - -/** The user declined the confirmation prompt. */ -export class FeedbackDeleteCancelledError extends Data.TaggedError("FeedbackDeleteCancelledError")<{ - readonly message: string; -}> { - get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { - return actionability.cancelled; - } -} diff --git a/apps/cli/src/commands/inspect/db/inspect-query.ts b/apps/cli/src/commands/inspect/db/inspect-query.ts index 7789591353..e72d179f0b 100644 --- a/apps/cli/src/commands/inspect/db/inspect-query.ts +++ b/apps/cli/src/commands/inspect/db/inspect-query.ts @@ -41,19 +41,6 @@ export interface InspectQuerySpec { readonly project: (row: Record<string, unknown>, cfg: ResolvedDbConfig) => ReadonlyArray<string>; } -/** - * Retained for its telemetry tag; the shared target selector now rejects conflicting target flags. - * - * @public - */ -export class InspectMutuallyExclusiveFlagsError extends Data.TaggedError( - "InspectMutuallyExclusiveFlagsError", -)<{ readonly message: string }> { - get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { - return actionability.provideFlags; - } -} - /** * The backtick-wrapped `` `…` `` text cell. An empty code span isn't a valid glamour token, so * empty/null values render as the two literal backticks too, keeping the same column width as diff --git a/apps/cli/src/commands/projects/projects.errors.ts b/apps/cli/src/commands/projects/projects.errors.ts index 9f103a70ee..3a21d19b38 100644 --- a/apps/cli/src/commands/projects/projects.errors.ts +++ b/apps/cli/src/commands/projects/projects.errors.ts @@ -173,12 +173,3 @@ export class ProjectsDeleteRefRequiredError extends Data.TaggedError( return actionability.provideFlags; } } - -/** User declined the delete confirmation prompt. */ -export class ProjectsDeleteCancelledError extends Data.TaggedError("ProjectsDeleteCancelledError")<{ - readonly message: string; -}> { - get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { - return actionability.cancelled; - } -} diff --git a/apps/cli/src/commands/secrets/secrets.errors.ts b/apps/cli/src/commands/secrets/secrets.errors.ts index 9f932d319f..5fc97456c5 100644 --- a/apps/cli/src/commands/secrets/secrets.errors.ts +++ b/apps/cli/src/commands/secrets/secrets.errors.ts @@ -133,19 +133,6 @@ export class SecretsUnsetInputError extends Data.TaggedError("SecretsUnsetInputE } } -// TODO(CLI-2695): delete with its entry in the error-tag snapshot once consent replaced it. -/** @public */ -export class SecretsUnsetConfirmationRequiredError extends Data.TaggedError( - "SecretsUnsetConfirmationRequiredError", -)<{ - readonly message: string; - readonly suggestion: string; -}> { - get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { - return actionability.provideFlags; - } -} - export class SecretsEnvNotSupportedError extends Data.TaggedError("SecretsEnvNotSupportedError")<{ readonly message: string; }> { @@ -153,13 +140,3 @@ export class SecretsEnvNotSupportedError extends Data.TaggedError("SecretsEnvNot return actionability.invalidInput; } } - -// TODO(CLI-2695): delete with its entry in the error-tag snapshot once consent replaced it. -/** @public */ -export class SecretsUnsetCancelledError extends Data.TaggedError("SecretsUnsetCancelledError")<{ - readonly message: string; -}> { - get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { - return actionability.cancelled; - } -} diff --git a/apps/cli/src/commands/seed/buckets/buckets.errors.ts b/apps/cli/src/commands/seed/buckets/buckets.errors.ts index 64d8b0b7c6..37c69350e0 100644 --- a/apps/cli/src/commands/seed/buckets/buckets.errors.ts +++ b/apps/cli/src/commands/seed/buckets/buckets.errors.ts @@ -24,20 +24,6 @@ export class SeedConfigLoadError extends Data.TaggedError("SeedConfigLoadError") } } -/** - * Unused: target conflicts raise `TargetSelectorConflictError`. - * @public - */ -export class SeedMutuallyExclusiveFlagsError extends Data.TaggedError( - "SeedMutuallyExclusiveFlagsError", -)<{ - readonly message: string; -}> { - get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { - return actionability.provideFlags; - } -} - /** * The resolved `--workdir`/`SUPABASE_WORKDIR` doesn't exist or isn't a * directory. Only reachable when explicitly set — beats the `--project-ref` diff --git a/apps/cli/src/commands/storage/storage.errors.ts b/apps/cli/src/commands/storage/storage.errors.ts index b8dd415118..60551dfd93 100644 --- a/apps/cli/src/commands/storage/storage.errors.ts +++ b/apps/cli/src/commands/storage/storage.errors.ts @@ -172,20 +172,6 @@ export class StorageFileError extends Data.TaggedError("StorageFileError")<{ } } -/** - * Unused: target conflicts raise `TargetSelectorConflictError`. - * @public - */ -export class StorageMutuallyExclusiveFlagsError extends Data.TaggedError( - "StorageMutuallyExclusiveFlagsError", -)<{ - readonly message: string; -}> { - get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { - return actionability.provideFlags; - } -} - /** * The resolved `--workdir`/`SUPABASE_WORKDIR` doesn't exist or isn't a * directory (`validateWorkdirIsDirectory`). Only reachable when the diff --git a/apps/cli/src/shared/cli/hidden-flag.unit.test.ts b/apps/cli/src/shared/cli/hidden-flag.unit.test.ts index 57c08ee1c0..b5b1b8e144 100644 --- a/apps/cli/src/shared/cli/hidden-flag.unit.test.ts +++ b/apps/cli/src/shared/cli/hidden-flag.unit.test.ts @@ -20,7 +20,7 @@ import { startCommand } from "../../commands/start/start.command.ts"; import { stopCommand } from "../../commands/stop/stop.command.ts"; import { GLOBAL_FLAGS } from "../../command-internal/global-flags.ts"; import { RemovedSurfaceError } from "../../command-internal/removed-command.ts"; -import { TestDbMutuallyExclusiveFlagsError } from "../../command-internal/test-db.errors.ts"; +import { TargetSelectorConflictError } from "../../command-internal/target/target.errors.ts"; import { mockAnalytics, mockOutput, @@ -320,8 +320,8 @@ describe("hidden subcommands", () => { it.effect("still executes the native `db test` hidden alias by exact name (CLI-1962)", () => Effect.gen(function* () { - // `--local --linked` fail inside the native handler's mutual-exclusivity check before any - // DB/docker IO, so that typed failure (not success) is what proves dispatch reached it. + // `--local --linked` fail in the target selector before any DB/docker IO, so that typed + // failure (not success) is what proves dispatch reached the handler. const fs = yield* FileSystem.FileSystem; const home = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-hidden-flag-" }); const run = (args: ReadonlyArray<string>) => @@ -335,7 +335,7 @@ describe("hidden subcommands", () => { ); expect(Exit.isFailure(dbTestExit)).toBe(true); if (!Exit.isFailure(dbTestExit)) return; - expect(Cause.squash(dbTestExit.cause)).toBeInstanceOf(TestDbMutuallyExclusiveFlagsError); + expect(Cause.squash(dbTestExit.cause)).toBeInstanceOf(TargetSelectorConflictError); const unknownExit = yield* run(["db", "not-a-real-command"]); expect(yield* encodeJson(unknownExit)).toContain("UnknownSubcommand"); diff --git a/apps/cli/src/shared/compute/compute.errors.ts b/apps/cli/src/shared/compute/compute.errors.ts index 702d34ecaf..80d2bf0a9a 100644 --- a/apps/cli/src/shared/compute/compute.errors.ts +++ b/apps/cli/src/shared/compute/compute.errors.ts @@ -279,37 +279,6 @@ export class ComputeApiUnexpectedStatusError extends Data.TaggedError( } } -/** The user answered the `delete` confirmation with something other than the name. */ -export class ComputeDeleteNotConfirmedError extends Data.TaggedError( - "ComputeDeleteNotConfirmedError", -)<{ - readonly detail: string; - readonly suggestion: string; -}> { - get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { - return actionability.cancelled; - } -} - -/** - * `delete` could not ask for confirmation and was not told to skip it. - * - * There is nowhere to read a typed answer from without an interactive terminal, - * and the alternative to refusing is deleting on the strength of the command - * line alone — so a redirected stdout or a CI runner has to pass `--yes` - * (or `SUPABASE_YES`) to say that out loud. - */ -export class ComputeDeleteConfirmationRequiredError extends Data.TaggedError( - "ComputeDeleteConfirmationRequiredError", -)<{ - readonly detail: string; - readonly suggestion: string; -}> { - get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { - return actionability.provideFlags; - } -} - /** * The logs query itself failed. The analytics endpoint can answer HTTP 200 with a populated * `error` field, so this isn't reachable from a status code alone; it also covers the server's diff --git a/apps/cli/src/shared/functions/deploy.errors.ts b/apps/cli/src/shared/functions/deploy.errors.ts index e43e07ed37..09e94dd380 100644 --- a/apps/cli/src/shared/functions/deploy.errors.ts +++ b/apps/cli/src/shared/functions/deploy.errors.ts @@ -33,16 +33,6 @@ export class NoFunctionsToDeployError extends Data.TaggedError("NoFunctionsToDep } } -// TODO(CLI-2695): delete with its entry in the error-tag snapshot once consent replaced it. -/** @public */ -export class FunctionDeployCancelledError extends Data.TaggedError("FunctionDeployCancelledError")<{ - readonly message: string; -}> { - get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { - return actionability.cancelled; - } -} - export class FunctionImportNotDirectoryError extends Data.TaggedError( "FunctionImportNotDirectoryError", )<{ diff --git a/apps/cli/src/shared/telemetry/__fixtures__/error-tags.txt b/apps/cli/src/shared/telemetry/__fixtures__/error-tags.txt index ccb7dde638..5e4160c238 100644 --- a/apps/cli/src/shared/telemetry/__fixtures__/error-tags.txt +++ b/apps/cli/src/shared/telemetry/__fixtures__/error-tags.txt @@ -15,7 +15,6 @@ BranchesApiKeysNetworkError BranchesApiKeysUnexpectedStatusError BranchesBranchNameEmptyError BranchesBranchingDisabledError -BranchesCreateCancelledError BranchesCreateNetworkError BranchesCreateUnexpectedStatusError BranchesDeleteNetworkError @@ -54,8 +53,6 @@ ComputeArchiveCompressionError ComputeBuildFailedError ComputeBuildTimeoutError ComputeConfigWriteUnsafeError -ComputeDeleteConfirmationRequiredError -ComputeDeleteNotConfirmedError ComputeDirectoryExistsError ComputeEnvNotSupportedError ComputeFollowNotSupportedError @@ -109,7 +106,6 @@ ConfigPushBranchNotLinkedError ConfigPushBranchNotReadyError ConfigPushBranchResolveNetworkError ConfigPushBranchResolveStatusError -ConfigPushCancelledError ConfigPushConfigEmptyError ConfigPushConfigReadNetworkError ConfigPushConfigReadStatusError @@ -139,7 +135,6 @@ CredentialDeleteError DbAdvisorsBeginTxError DbAdvisorsFailOnError DbAdvisorsInvalidTokenError -DbAdvisorsMutuallyExclusiveFlagsError DbAdvisorsNotLoggedInError DbAdvisorsPerformanceNetworkError DbAdvisorsPerformanceStatusError @@ -164,7 +159,6 @@ DbDiffDbNotRunningError DbDiffEngineConflictError DbDiffExplicitFlagsError DbDiffPgAdminError -DbDiffTargetFlagsError DbDiffUnknownTargetError DbDiffWriteError DbDumpMutuallyExclusiveFlagsError @@ -177,43 +171,34 @@ DbLintEnableCheckError DbLintFailOnError DbLintListSchemasError DbLintMalformedJsonError -DbLintMutuallyExclusiveFlagsError DbLintQueryError -DbPasswordFlagsError DbPullDumpError DbPullEngineConflictError DbPullInSyncError DbPullMigrationConflictError -DbPullTargetFlagsError DbPullWriteError DbPushApplyError -DbPushCancelledError DbPushMissingLocalError DbPushMissingRemoteError DbPushRolesError -DbPushTargetFlagsError DbQueryExecError DbQueryLoginRequiredError -DbQueryMutuallyExclusiveFlagsError DbQueryNoSqlError DbQueryNoStdinSqlError DbQueryReadFileError DbQueryReadOnlyStatementError DbQueryUnexpectedStatusError DbResetApplyError -DbResetCancelledError DbResetInvalidVersionError DbResetLastFlagError DbResetMigrationFileError DbResetSeedFlagsError -DbResetTargetFlagsError DbResetVersionFlagsError DbSetupError DbStartFromBackupUnsupportedError DeclarativeApplyError DeclarativeCompatibilityError DeclarativeDiffError -DeclarativeInvalidDbUrlError DeclarativeMutuallyExclusiveFlagsError DeclarativeNoFilesGeneratedError DeclarativeNonInteractiveError @@ -254,13 +239,11 @@ ExperimentalStackStatusError ExperimentalStackStopError ExperimentalStackTargetError FeedbackBackendError -FeedbackDeleteCancelledError FeedbackEmptyMessageError FeedbackInvalidTokenError FeedbackMessageTooLongError FeedbackNotFoundError FileWatcherError -FunctionDeployCancelledError FunctionDownloadNotFoundError FunctionImportNotDirectoryError FunctionNotFoundError @@ -311,7 +294,6 @@ ImagePrepullError InitConfigExistsError InitParseSettingsError InspectCsvqError -InspectMutuallyExclusiveFlagsError InspectReportMkdirError InspectReportWriteError InvalidAccessTokenError @@ -368,7 +350,6 @@ MigrationSquashBaselineError MigrationSquashDumpError MigrationSquashMissingVersionError MigrationSquashWriteError -MigrationTargetFlagsError MigrationVaultError MigrationsReadError MissingComputeExposureError @@ -437,7 +418,6 @@ ProjectsCreateMissingArgError ProjectsCreateNameEmptyError ProjectsCreateNetworkError ProjectsCreateUnexpectedStatusError -ProjectsDeleteCancelledError ProjectsDeleteNetworkError ProjectsDeleteNotFoundError ProjectsDeleteRefRequiredError @@ -471,15 +451,11 @@ SecretsNoArgumentsError SecretsSetInputError SecretsSetNetworkError SecretsSetUnexpectedStatusError -SecretsUnsetCancelledError -SecretsUnsetConfirmationRequiredError SecretsUnsetInputError SecretsUnsetNetworkError SecretsUnsetUnexpectedStatusError SeedConfigLoadError -SeedConsentRequiredError SeedMissingProjectConfigError -SeedMutuallyExclusiveFlagsError SeedWorkdirError ServeLocalDbInspectError ServeLocalDbNotRunningError @@ -574,7 +550,6 @@ StorageMissingBucketError StorageMissingFlagError StorageMissingPathError StorageMissingProjectConfigError -StorageMutuallyExclusiveFlagsError StorageObjectNotFoundError StorageRmConfirmationRequiredError StorageUnsupportedMoveError @@ -588,7 +563,6 @@ TargetPasswordConflictError TargetProjectRefConflictError TargetSelectorConflictError TestDbEnablePgtapError -TestDbMutuallyExclusiveFlagsError TestDbNoTestsError TestDbRunError TestNewFileExistsError From c67e90060fa048f0670ed527604423f8e309b158 Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Thu, 15 Oct 2026 15:32:34 +0100 Subject: [PATCH 66/92] refactor(cli): drop redundant linked-target work and duplicate target echoes --- apps/cli/src/command-internal/seed-buckets.ts | 12 +- .../src/commands/config/push/SIDE_EFFECTS.md | 16 +- .../config/push/push.branch-target.ts | 49 ++---- .../src/commands/config/push/push.format.ts | 27 +--- .../src/commands/config/push/push.handler.ts | 2 - .../config/push/push.integration.test.ts | 147 ++++-------------- .../commands/config/push/push.unit.test.ts | 96 +----------- .../src/commands/db/query/query.command.ts | 4 +- .../src/commands/db/query/query.handler.ts | 70 ++++----- .../db/query/query.integration.test.ts | 35 +---- .../src/commands/migration/up/SIDE_EFFECTS.md | 3 +- .../src/commands/migration/up/up.handler.ts | 2 +- .../migration/up/up.integration.test.ts | 10 ++ .../seed/buckets/buckets.integration.test.ts | 3 + apps/cli/src/docs/docs-spec.tables.ts | 44 +++--- apps/cli/tests/helpers/live.ts | 1 + 16 files changed, 140 insertions(+), 381 deletions(-) diff --git a/apps/cli/src/command-internal/seed-buckets.ts b/apps/cli/src/command-internal/seed-buckets.ts index 2c3beee63e..df6d8b7b6b 100644 --- a/apps/cli/src/command-internal/seed-buckets.ts +++ b/apps/cli/src/command-internal/seed-buckets.ts @@ -275,10 +275,12 @@ export const seedBucketsRun = Effect.fnUntraced(function* < const summary = emptySummary(); + const existingBuckets = yield* gateway.listBuckets; + // A hosted run never prompts per item: one consent covers every overwrite and prune. const confirmHostedWrites = projectRef === "" ? undefined : opts.confirmHostedWrites; if (confirmHostedWrites !== undefined) { - const details = yield* planHostedWrites(gateway, config, bucketPropsByName); + const details = yield* planHostedWrites(gateway, config, bucketPropsByName, existingBuckets); if (details.length > 0) yield* confirmHostedWrites(details); } const approved = confirmHostedWrites !== undefined || yes; @@ -290,6 +292,7 @@ export const seedBucketsRun = Effect.fnUntraced(function* < interactive, skipPrompts, gateway, + existingBuckets, bucketPropsByName, summary, ); @@ -372,6 +375,8 @@ const computeBucketProps = ( }), }); +type ExistingBucket = { readonly id: string; readonly name: string }; + // `propsByName` is precomputed and size-validated before this runs. const upsertBuckets = Effect.fnUntraced(function* ( output: typeof Output.Service, @@ -379,10 +384,10 @@ const upsertBuckets = Effect.fnUntraced(function* ( interactive: boolean, promptless: boolean, gateway: StorageGateway, + existing: ReadonlyArray<ExistingBucket>, propsByName: ReadonlyMap<string, UpsertBucketProps>, summary: SeedSummary, ) { - const existing = yield* gateway.listBuckets; const byName = new Map(existing.map((b) => [b.name, b.id])); for (const [name, props] of propsByName) { @@ -520,9 +525,10 @@ const planHostedWrites = Effect.fnUntraced(function* ( gateway: StorageGateway, config: CliConfig, propsByName: ReadonlyMap<string, UpsertBucketProps>, + existingBuckets: ReadonlyArray<ExistingBucket>, ) { const details: Array<string> = []; - const existing = new Set((yield* gateway.listBuckets).map((bucket) => bucket.name)); + const existing = new Set(existingBuckets.map((bucket) => bucket.name)); const configured = [...propsByName.keys()]; const create = configured.filter((name) => !existing.has(name)); const overwrite = configured.filter((name) => existing.has(name)); diff --git a/apps/cli/src/commands/config/push/SIDE_EFFECTS.md b/apps/cli/src/commands/config/push/SIDE_EFFECTS.md index f4eca2a38f..211ef02cdf 100644 --- a/apps/cli/src/commands/config/push/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/config/push/SIDE_EFFECTS.md @@ -160,17 +160,9 @@ All diagnostics on **stderr**, no stdout. The `Target:` banner prints first (`Target: project <name> (<ref>) · production`, `Target: project <name> (<parent ref>) · branch (<ref>)` for a branch, or `Target: project <ref> · identity unverified` when the live probe could not tell). When a `[remotes.<name>]` block matches the target ref, -`Loading config override: [remotes.<name>]` follows. Then the target-echo block — for a -plain project, `Pushing config to project: <name> (<ref>)` (degrades to the bare `Pushing -config to project: <ref>` when no name could be resolved); for a branch, `Pushing config -to branch: <name> (<ref>)` followed, only when known, by a second line ` Parent project: -<name> (<ref>)` (either half degrades to a bare ref on its own when its name isn't known; -the parent line is omitted entirely when no parent could be determined at all); or, when -the target-detection probe couldn't tell at all (a timeout, a transport failure, or an -unexpected status), `Pushing config to: <ref> (could not determine whether this is a -branch or the main project)`. No target shape is gated separately: a branch is confirmed -through the one consent question below, which names the branch and its parent project, -whether it was resolved implicitly or named with `--project-ref`. +`Loading config override: [remotes.<name>]` follows. No target shape is gated separately: a +branch is confirmed through the one consent question below, which names the branch and its +parent project, whether it was resolved implicitly or named with `--project-ref`. Then `Comparison scope: <present> (not returned: <missing>)` — printed EVERY run, not just when a block is missing (family consistency with `config diff`/`config pull`). When a `SUPABASE_*` variable supplied any value about to be @@ -357,7 +349,7 @@ may itself contain a `.`. ## Notes - **`--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 assert an identity the probe could not verify) 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. Every target shape goes through the same single consent question before the first write, so a branch resolved implicitly and one named with `--project-ref` are treated alike, and the question names the branch and its parent project. The target-echo line always prints regardless of which shape it is. +- **Every invocation detects whether the resolved ref is the linked project, one of its branches, or genuinely undeterminable** (CLI-2168) before doing anything else. 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 assert an identity the probe could not verify) 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. Every target shape goes through the same single consent question before the first write, so a branch resolved implicitly and one named with `--project-ref` are treated alike, and the question names the branch and its parent project. - **Resolution runs BEFORE the config load**, not after: a `[remotes.<name>]` 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 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 <name-or-uuid>`; 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). - 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. 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 66d837e74f..5c84c42797 100644 --- a/apps/cli/src/commands/config/push/push.branch-target.ts +++ b/apps/cli/src/commands/config/push/push.branch-target.ts @@ -13,24 +13,21 @@ import { readProjectRefFile, tempPaths } from "../../../shared/config/temp-paths import { Output } from "../../../shared/output/output.service.ts"; /** - * What `ref` actually is, resolved for `config push`'s target echo: + * What `ref` actually is, resolved for `config push`'s payload: * - * - `"project"` — `ref` is the linked project itself; `name` is present when the live probe - * found it. + * - `"project"` — `ref` is the linked project itself. * - `"branch"` — `ref` is confirmed to be a preview branch (an explicit `--project-ref - * <name-or-uuid>`, or a live 404); `parentRef`/`parentName`/`branch` are each present only - * when they could be determined. + * <name-or-uuid>`, or a live 404); `parentRef`/`branch` are each present only when they could + * be determined. * - `"unknown"` — the live probe couldn't tell (a timeout, a transport failure, or an - * unexpected status). Asserted as neither a branch nor a project — the target-echo line says - * plainly that it doesn't know. + * unexpected status). Asserted as neither a branch nor a project. */ export type ConfigPushTarget = - | { readonly kind: "project"; readonly ref: string; readonly name?: string } + | { readonly kind: "project"; readonly ref: string } | { readonly kind: "branch"; readonly ref: string; readonly parentRef?: string; - readonly parentName?: string; readonly branch?: string; } | { readonly kind: "unknown"; readonly ref: string }; @@ -67,16 +64,16 @@ function isNotFound(cause: unknown): boolean { } type ProbeOutcome = Data.TaggedEnum<{ - Project: { readonly name: string | undefined }; + Project: {}; Branch: {}; Unknown: {}; }>; const ProbeOutcome = Data.taggedEnum<ProbeOutcome>(); /** - * Resolves what `ref` actually is, so `config push` can tell the user whether they're pushing to - * the linked project or a branch. Never fails: this - * probe is diagnostic-only and must never abort a push that would otherwise succeed. + * Resolves what `ref` actually is, so `config push` can tell a branch from the linked project. + * Never fails: this probe is diagnostic-only and must never abort a push that would otherwise + * succeed. * * A known branch name/UUID skips the live probe. Otherwise `GET /v1/projects/{ref}` (bounded at * {@link BRANCH_LOOKUP_TIMEOUT}) returns a project on 200, `"unknown"` on any other failure @@ -102,20 +99,9 @@ export function resolveConfigPushTarget( Effect.orElseSucceed(() => Option.none<CachedLinkedProject>()), ); - // A name target: both fields already known, so just enrich the parent's name from cache - // when it happens to match. if (opts.knownBranch?.kind === "name") { const { branchName, parentRef } = opts.knownBranch; - const cached = yield* readCachedParent; - const parentName = - Option.isSome(cached) && cached.value.ref === parentRef ? cached.value.name : undefined; - return { - kind: "branch", - ref, - parentRef, - ...(parentName === undefined ? {} : { parentName }), - branch: branchName, - }; + return { kind: "branch", ref, parentRef, branch: branchName }; } // A uuid known-branch means ref is certainly a branch, so skip the live probe. Otherwise @@ -129,7 +115,7 @@ export function resolveConfigPushTarget( const probe: ProbeOutcome = yield* Effect.timeoutOrElse( api.v1.getProject({ ref }).pipe( - Effect.map((project) => ProbeOutcome.Project({ name: normalizeApiName(project.name) })), + Effect.map(() => ProbeOutcome.Project()), Effect.catch((cause) => Effect.succeed(isNotFound(cause) ? ProbeOutcome.Branch() : ProbeOutcome.Unknown()), ), @@ -144,11 +130,7 @@ export function resolveConfigPushTarget( yield* (ProbeOutcome.$is("Unknown")(probe) ? probing?.fail() : probing?.clear) ?? Effect.void; const probed = ProbeOutcome.$match(probe, { - Project: ({ name }): ConfigPushTarget => ({ - kind: "project", - ref, - ...(name === undefined ? {} : { name }), - }), + Project: (): ConfigPushTarget => ({ kind: "project", ref }), Unknown: (): ConfigPushTarget => ({ kind: "unknown", ref }), // A Branch probe (404) falls into the shared recovery below, same as an // incomplete uuid knownBranch. @@ -186,15 +168,10 @@ export function resolveConfigPushTarget( return { kind: "branch", ref }; } - const parentName = - Option.isSome(cached) && cached.value.ref === candidateParentRef - ? cached.value.name - : undefined; return { kind: "branch", ref, parentRef: candidateParentRef, - ...(parentName === undefined ? {} : { parentName }), ...(branchName === undefined ? {} : { branch: branchName }), }; }); diff --git a/apps/cli/src/commands/config/push/push.format.ts b/apps/cli/src/commands/config/push/push.format.ts index c192b4ccfb..595e21fe76 100644 --- a/apps/cli/src/commands/config/push/push.format.ts +++ b/apps/cli/src/commands/config/push/push.format.ts @@ -1,6 +1,6 @@ import type { ConfigChange } from "@supabase/config"; -import { formatNamedRef, sanitizeInlineName } from "../../../command-internal/http-errors.ts"; +import { sanitizeInlineName } from "../../../command-internal/http-errors.ts"; import { configEnvOriginList, configEnvOriginPayload, @@ -432,31 +432,6 @@ export function pushPayload(input: PushPayloadInput): Record<string, unknown> { }; } -// Every interpolated ref/name goes through `formatNamedRef`, so an API-provided branch/project -// name can't inject ANSI/OSC/newline controls into the terminal. - -/** - * The target-echo block, printed to stderr before any further network call. The no-name - * degradation shape (a project whose name could not be resolved) stays exactly - * `Pushing config to project: <ref>`, pinned by existing tests. - */ -export function configPushTargetLines(target: ConfigPushTarget): string { - if (target.kind === "project") { - return `Pushing config to project: ${formatNamedRef(target.name, target.ref)}\n`; - } - if (target.kind === "unknown") { - return `Pushing config to: ${sanitizeInlineName(target.ref)} (could not determine whether this is a branch or the main project)\n`; - } - - const lines: Array<string> = [ - `Pushing config to branch: ${formatNamedRef(target.branch, target.ref)}`, - ]; - if (target.parentRef !== undefined) { - lines.push(` Parent project: ${formatNamedRef(target.parentName, target.parentRef)}`); - } - return `${lines.join("\n")}\n`; -} - const CONSENT_SERVICE_NAME: Readonly<Record<PushResource | "experimental.webhooks", string>> = { ...RESOURCE_DISPLAY_NAME, "experimental.webhooks": "Webhooks", diff --git a/apps/cli/src/commands/config/push/push.handler.ts b/apps/cli/src/commands/config/push/push.handler.ts index 8c5d4265a6..76d58ce986 100644 --- a/apps/cli/src/commands/config/push/push.handler.ts +++ b/apps/cli/src/commands/config/push/push.handler.ts @@ -80,7 +80,6 @@ import { } from "./push.errors.ts"; import { configPushPayloadFields, - configPushTargetLines, pushConsentAction, pushCostLine, pushEnvSourcedLine, @@ -318,7 +317,6 @@ export const configPush = Effect.fn("config.push")(function* (flags: ConfigPushF Effect.withSpan("config.push.probeTarget"), ); yield* Effect.annotateCurrentSpan("config.target_kind", target.kind); - yield* output.raw(configPushTargetLines(target), "stderr"); // 6. Cost matrix (prices the paid add-ons a push would enable). const cost = yield* getCostMatrix(ref); 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 98b23b70be..7d7a336e2e 100644 --- a/apps/cli/src/commands/config/push/push.integration.test.ts +++ b/apps/cli/src/commands/config/push/push.integration.test.ts @@ -179,9 +179,7 @@ const BRANCH_UUID = "11111111-1111-4111-8111-111111111111"; /** * Schema-valid `V1GetProjectOutput` fixture for the live target-detection probe. The default - * `project` route response has an empty `name`, which `normalizeApiName` folds to `undefined`, so - * the target-echo line degrades to the bare `Pushing config to project: <ref>\n` text most - * scenarios pin. Tests below override `name` to exercise the named-project path. + * `project` route response has an empty `name`. */ const PUSH_TEST_PROJECT = { id: REF, @@ -453,7 +451,7 @@ describe("config push integration", () => { if (Exit.isFailure(exit)) { expect(Cause.pretty(exit.cause)).toContain("ConfigPushApiUpdateStatusError"); } - expect(out.stderrText).toContain(`Pushing config to project: ${REF}`); + expect(out.stderrText).toContain(`Target: project ${REF}`); expect(out.stderrText).toContain("Updating API service with config:"); }).pipe(Effect.provide(layer)); }); @@ -538,7 +536,7 @@ schemas = ["public", "remote_schema"] yield* configPush({ projectRef: Option.none() }); expect(out.stderrText).toContain("Loading config override: [remotes.staging]"); expect(out.stderrText.indexOf("Loading config override: [remotes.staging]")).toBeLessThan( - out.stderrText.indexOf("Pushing config to project:"), + out.stderrText.indexOf("Comparison scope:"), ); const update = api.requests.find((r) => r.method === "PATCH" && r.url.includes("/postgrest")); expect(update).toBeDefined(); @@ -2955,7 +2953,7 @@ const BRANCH_PUSH_TOML = `project_id = "test"\n[api]\nmax_rows = 2000\n`; * The realistic "already ran `supabase link <branch>`" state: `.temp/project-ref` holds the * branch's own ref, `.temp/linked-project.json` caches the parent (name "My App"), the live probe * 404s, and the parent's branch list confirms it — so the target resolves to `{ kind: "branch", - * ref: BRANCH_REF, parentRef: PARENT_REF, parentName: "My App", branch: "feat-x" }`. + * ref: BRANCH_REF, parentRef: PARENT_REF, branch: "feat-x" }`. */ function setupLinkedBranchPush( opts: { @@ -2981,49 +2979,15 @@ function setupLinkedBranchPush( } describe("config push branch/project target detection (CLI-2168)", () => { - it.effect("a plain project push echoes the named project target", () => { - const { layer, out, api } = setup({ - toml: BRANCH_PUSH_TOML, - yes: true, - project: { status: 200, body: { ...PUSH_TEST_PROJECT, name: "Test Project" } }, - }); - return Effect.gen(function* () { - yield* configPush({ projectRef: Option.none() }); - expect(out.stderrText).toContain(`Pushing config to project: Test Project (${REF})`); - expect(out.stderrText).not.toContain("Pushing config to branch"); - expect(api.requests.some((r) => r.method === "PATCH" && r.url.includes("/postgrest"))).toBe( - true, - ); - }).pipe(Effect.provide(layer)); - }); - it.effect( - "an empty project name from the live probe degrades to the bare-ref target-echo line", - () => { - // `normalizeApiName` folds an empty `name` into `undefined` before it reaches the target - // object — the same degradation every other scenario in this file relies on via - // `PUSH_TEST_PROJECT`'s default empty name; this test pins it explicitly. - const { layer, out } = setup({ - toml: BRANCH_PUSH_TOML, - yes: true, - project: { status: 200, body: { ...PUSH_TEST_PROJECT, name: "" } }, - }); - return Effect.gen(function* () { - yield* configPush({ projectRef: Option.none() }); - expect(out.stderrText).toContain(`Pushing config to project: ${REF}\n`); - expect(out.stderrText).not.toContain("project: ("); - }).pipe(Effect.provide(layer)); - }, - ); - - it.effect( - "the realistic 'link <branch>' then flag-less push flow shows both target lines and proceeds", + "the realistic 'link <branch>' then flag-less push flow announces the branch and proceeds", () => { const { layer, out, api } = setupLinkedBranchPush({ yes: true }); return Effect.gen(function* () { yield* configPush({ projectRef: Option.none() }); - expect(out.stderrText).toContain(`Pushing config to branch: feat-x (${BRANCH_REF})`); - expect(out.stderrText).toContain(` Parent project: My App (${PARENT_REF})`); + expect(out.stderrText).toContain( + `Target: project My App (${PARENT_REF}) · branch (${BRANCH_REF})`, + ); expect( api.requests.some((r) => r.url.includes(`/v1/projects/${PARENT_REF}/branches`)), ).toBe(true); @@ -3050,8 +3014,7 @@ describe("config push branch/project target detection (CLI-2168)", () => { }); return Effect.gen(function* () { yield* configPush({ projectRef: Option.none() }); - expect(out.stderrText).toContain(`Pushing config to branch: ${BRANCH_REF}`); - expect(out.stderrText).toContain(` Parent project: ${PARENT_REF}`); + expect(out.stderrText).toContain(`Target: project ${PARENT_REF} · branch (${BRANCH_REF})`); expect(out.stderrText).not.toContain("My App"); expect(api.requests.some((r) => r.method === "PATCH" && r.url.includes("/postgrest"))).toBe( true, @@ -3060,24 +3023,20 @@ describe("config push branch/project target detection (CLI-2168)", () => { }, ); - it.effect( - "a live 404 with nothing cached degrades to a bare branch line with zero branch lookups", - () => { - const { layer, out, api } = setup({ - toml: BRANCH_PUSH_TOML, - yes: true, - projectId: Option.some(PROBE_REF), - project: { status: 404, body: {} }, - v2: { status: 200, body: v2Response({ ref: PROBE_REF }) }, - }); - return Effect.gen(function* () { - yield* configPush({ projectRef: Option.none() }); - expect(out.stderrText).toContain(`Pushing config to branch: ${PROBE_REF}\n`); - expect(out.stderrText).not.toContain("Parent project:"); - expect(api.requests.some((r) => r.url.includes("/branches"))).toBe(false); - }).pipe(Effect.provide(layer)); - }, - ); + it.effect("a live 404 with nothing cached makes no branch lookups", () => { + const { layer, out, api } = setup({ + toml: BRANCH_PUSH_TOML, + yes: true, + projectId: Option.some(PROBE_REF), + project: { status: 404, body: {} }, + v2: { status: 200, body: v2Response({ ref: PROBE_REF }) }, + }); + return Effect.gen(function* () { + yield* configPush({ projectRef: Option.none() }); + expect(out.stderrText).not.toContain("· branch ("); + expect(api.requests.some((r) => r.url.includes("/branches"))).toBe(false); + }).pipe(Effect.provide(layer)); + }); it.effect("declining a branch push fails before any write and still flushes telemetry", () => { const { layer, out, api, telemetry, linkedProjectCache } = setupLinkedBranchPush({ @@ -3138,8 +3097,7 @@ describe("config push branch/project target detection (CLI-2168)", () => { }); return Effect.gen(function* () { yield* configPush({ projectRef: Option.none() }); - expect(out.stderrText).toContain(`Pushing config to branch: ${PROBE_REF}`); - expect(out.stderrText).not.toContain("Parent project:"); + expect(out.stderrText).not.toContain(PARENT_REF); expect( api.requests.some((r) => r.url.includes(`/v1/projects/${PARENT_REF}/branches`)), ).toBe(true); @@ -3158,8 +3116,7 @@ describe("config push branch/project target detection (CLI-2168)", () => { }); return Effect.gen(function* () { yield* configPush({ projectRef: Option.none() }); - expect(out.stderrText).toContain(`Pushing config to branch: ${PROBE_REF}`); - expect(out.stderrText).not.toContain("Parent project:"); + expect(out.stderrText).not.toContain("· branch ("); expect(api.requests.some((r) => r.url.includes("/branches"))).toBe(false); }).pipe(Effect.provide(layer)); }); @@ -3175,8 +3132,7 @@ describe("config push branch/project target detection (CLI-2168)", () => { }); return Effect.gen(function* () { yield* configPush({ projectRef: Option.none() }); - expect(out.stderrText).toContain(`Pushing config to branch: ${PROBE_REF}`); - expect(out.stderrText).not.toContain("Parent project:"); + expect(out.stderrText).not.toContain("not-a-real-ref"); expect(api.requests.some((r) => r.url.includes("/branches"))).toBe(false); }).pipe(Effect.provide(layer)); }); @@ -3195,9 +3151,7 @@ describe("config push branch/project target detection (CLI-2168)", () => { }); return Effect.gen(function* () { yield* configPush({ projectRef: Option.none() }); - expect(out.stderrText).toContain( - `Pushing config to: ${PROBE_REF} (could not determine whether this is a branch or the main project)`, - ); + expect(out.stderrText).toContain(`Target: project ${PROBE_REF} · identity unverified`); expect(api.requests.some((r) => r.url.includes("/branches"))).toBe(false); expect(api.requests.some((r) => r.method === "PATCH" && r.url.includes("/postgrest"))).toBe( true, @@ -3232,8 +3186,7 @@ describe("config push branch/project target detection (CLI-2168)", () => { expect( api.requests.some((r) => r.url.includes(`/v1/projects/${PARENT_REF}/branches`)), ).toBe(true); - expect(out.stderrText).toContain(`Pushing config to branch: ${PROBE_REF}`); - expect(out.stderrText).not.toContain("Parent project:"); + expect(out.stderrText).not.toContain("· branch ("); }).pipe(Effect.provide(layer)); }, ); @@ -3250,9 +3203,7 @@ describe("config push branch/project target detection (CLI-2168)", () => { }); return Effect.gen(function* () { yield* configPush({ projectRef: Option.none() }); - expect(out.stderrText).toContain( - `Pushing config to: ${PROBE_REF} (could not determine whether this is a branch or the main project)`, - ); + expect(out.stderrText).toContain(`Target: project ${PROBE_REF} · identity unverified`); expect(api.requests.some((r) => r.method === "PATCH" && r.url.includes("/postgrest"))).toBe( true, ); @@ -3301,8 +3252,6 @@ describe("config push --project-ref branch name/UUID resolution (CLI-2289)", () }); return Effect.gen(function* () { yield* configPush({ projectRef: Option.some("staging") }); - expect(out.stderrText).toContain(`Pushing config to branch: staging (${BRANCH_REF})`); - expect(out.stderrText).toContain(` Parent project: ${REF}`); expect( api.requests.some( (r) => r.method === "GET" && new URL(r.url).pathname === `/v1/projects/${BRANCH_REF}`, @@ -3325,41 +3274,6 @@ describe("config push --project-ref branch name/UUID resolution (CLI-2289)", () }).pipe(Effect.provide(layer)); }); - it.effect( - "--project-ref <branch-name> enriches the parent name from a matching linked-project cache", - () => { - const { layer, out } = setup({ - seed: writeLinkedProjectCacheFile({ ref: REF, name: "Test Project" }), - toml: BRANCH_PUSH_TOML, - yes: true, - branchByName: { status: 200, body: BRANCH_BY_NAME }, - v2: { status: 200, body: v2Response({ ref: BRANCH_REF }) }, - }); - return Effect.gen(function* () { - yield* configPush({ projectRef: Option.some("staging") }); - expect(out.stderrText).toContain(` Parent project: Test Project (${REF})`); - }).pipe(Effect.provide(layer)); - }, - ); - - it.effect( - "--project-ref <branch-name> ignores a linked-project cache belonging to a different parent", - () => { - const { layer, out } = setup({ - seed: writeLinkedProjectCacheFile({ ref: OTHER_PARENT_REF, name: "Someone Else" }), - toml: BRANCH_PUSH_TOML, - yes: true, - branchByName: { status: 200, body: BRANCH_BY_NAME }, - v2: { status: 200, body: v2Response({ ref: BRANCH_REF }) }, - }); - return Effect.gen(function* () { - yield* configPush({ projectRef: Option.some("staging") }); - expect(out.stderrText).toContain(` Parent project: ${REF}`); - expect(out.stderrText).not.toContain("Someone Else"); - }).pipe(Effect.provide(layer)); - }, - ); - it.effect( "--project-ref <branch-name> resolution works without a spinner in json output mode", () => { @@ -3395,7 +3309,6 @@ describe("config push --project-ref branch name/UUID resolution (CLI-2289)", () return Effect.gen(function* () { const exit = yield* configPush({ projectRef: Option.some(BRANCH_UUID) }).pipe(Effect.exit); expect(Exit.isSuccess(exit)).toBe(true); - expect(out.stderrText).toContain(`Pushing config to branch: ${UUID_TARGET_REF}`); expect(out.stderrText).toContain(`Target: branch ${UUID_TARGET_REF}\n`); expect(out.stderrText).not.toContain(BRANCH_UUID); const branchRequests = api.requests.filter((r) => r.url.includes("/branches")); @@ -3420,7 +3333,7 @@ describe("config push --project-ref branch name/UUID resolution (CLI-2289)", () return Effect.gen(function* () { const error = yield* configPush({ projectRef: Option.some(BRANCH_UUID) }).pipe(Effect.flip); expect(error).toBeInstanceOf(HostedMutationConsentRequiredError); - expect(out.stderrText).toContain(`Pushing config to branch: ${UUID_TARGET_REF}`); + expect(out.stderrText).toContain(`Target: branch ${UUID_TARGET_REF}`); expect(api.requests.some((r) => r.method === "PATCH")).toBe(false); }).pipe(Effect.provide(layer)); }, diff --git a/apps/cli/src/commands/config/push/push.unit.test.ts b/apps/cli/src/commands/config/push/push.unit.test.ts index a0d66826c1..884ae572e4 100644 --- a/apps/cli/src/commands/config/push/push.unit.test.ts +++ b/apps/cli/src/commands/config/push/push.unit.test.ts @@ -1,104 +1,10 @@ import { describe, expect, test } from "vitest"; -import type { ConfigPushTarget } from "./push.branch-target.ts"; -import { - configPushPayloadFields, - configPushTargetLines, - pushConsentAction, - pushCostLine, -} from "./push.format.ts"; +import { configPushPayloadFields, pushConsentAction, pushCostLine } from "./push.format.ts"; const REF = "abcdefghijklmnopqrst"; const PARENT_REF = "pppppppppppppppppppp"; -describe("configPushTargetLines", () => { - test("a plain project with no known name stays byte-identical to the pre-CLI-2168 line", () => { - expect(configPushTargetLines({ kind: "project", ref: REF })).toBe( - `Pushing config to project: ${REF}\n`, - ); - }); - - test("a plain project with a known name shows the name alongside the ref", () => { - expect(configPushTargetLines({ kind: "project", ref: REF, name: "Test Project" })).toBe( - `Pushing config to project: Test Project (${REF})\n`, - ); - }); - - test("a named branch with a named parent shows both lines", () => { - const target: ConfigPushTarget = { - kind: "branch", - ref: REF, - branch: "feat-x", - parentRef: PARENT_REF, - parentName: "My App", - }; - expect(configPushTargetLines(target)).toBe( - `Pushing config to branch: feat-x (${REF})\n Parent project: My App (${PARENT_REF})\n`, - ); - }); - - test("a named branch with a known but unnamed parent omits the parent's name", () => { - const target: ConfigPushTarget = { - kind: "branch", - ref: REF, - branch: "feat-x", - parentRef: PARENT_REF, - }; - expect(configPushTargetLines(target)).toBe( - `Pushing config to branch: feat-x (${REF})\n Parent project: ${PARENT_REF}\n`, - ); - }); - - test("a named branch with no known parent at all omits the parent line entirely", () => { - const target: ConfigPushTarget = { kind: "branch", ref: REF, branch: "feat-x" }; - expect(configPushTargetLines(target)).toBe(`Pushing config to branch: feat-x (${REF})\n`); - }); - - test("an unnamed branch with a named parent shows a bare branch ref and the parent's name", () => { - const target: ConfigPushTarget = { - kind: "branch", - ref: REF, - parentRef: PARENT_REF, - parentName: "My App", - }; - expect(configPushTargetLines(target)).toBe( - `Pushing config to branch: ${REF}\n Parent project: My App (${PARENT_REF})\n`, - ); - }); - - test("a bare branch with nothing else known renders a single line", () => { - expect(configPushTargetLines({ kind: "branch", ref: REF })).toBe( - `Pushing config to branch: ${REF}\n`, - ); - }); - - test("control characters and ANSI escapes in a project name are sanitized", () => { - // Routes through `formatNamedRef`/`sanitizeInlineName` (not - // re-tested here) — this asserts the formatter actually calls it, not - // that the sanitizer itself works. - const rendered = configPushTargetLines({ - kind: "project", - ref: REF, - name: "evil\x1b[31mred", - }); - expect(rendered).toBe(`Pushing config to project: evil[31mred (${REF})\n`); - expect(rendered).not.toContain("\x1b"); - }); - - test("a newline in a branch/parent name is collapsed instead of forging extra output lines", () => { - const target: ConfigPushTarget = { - kind: "branch", - ref: REF, - branch: "evil\nbranch", - parentRef: PARENT_REF, - parentName: "evil\nparent", - }; - expect(configPushTargetLines(target)).toBe( - `Pushing config to branch: evil branch (${REF})\n Parent project: evil parent (${PARENT_REF})\n`, - ); - }); -}); - describe("pushConsentAction", () => { test("names a single service", () => { expect(pushConsentAction(["auth"])).toBe("push config changes to Auth"); diff --git a/apps/cli/src/commands/db/query/query.command.ts b/apps/cli/src/commands/db/query/query.command.ts index 4c549fa579..3c2fea749e 100644 --- a/apps/cli/src/commands/db/query/query.command.ts +++ b/apps/cli/src/commands/db/query/query.command.ts @@ -7,7 +7,6 @@ import { withCondition, withDbTarget, } from "../../../command-internal/command-permissions/model.ts"; -import { dbConfig } from "../../../command-internal/db-config.layer.ts"; import { linkedProjectCache } from "../../../telemetry/linked-project-cache.layer.ts"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; import { withCommandTelemetry } from "../../../telemetry/command-telemetry.ts"; @@ -56,9 +55,8 @@ export const dbQueryCommand = Command.make("query", config).pipe( operations: [ { operationId: "v1-run-a-query", kind: "required", when: [{ dbTarget: ["linked"] }] }, ], - noApiEffectFlags: ["file", "read-only"], + noApiEffectFlags: ["file", "project-ref", "read-only"], }, - dbConfig, withCondition(linkedProjectCache, { dbTarget: ["linked"] }), ), { default: "local" }, diff --git a/apps/cli/src/commands/db/query/query.handler.ts b/apps/cli/src/commands/db/query/query.handler.ts index b4a2fe7e87..57614b4739 100644 --- a/apps/cli/src/commands/db/query/query.handler.ts +++ b/apps/cli/src/commands/db/query/query.handler.ts @@ -18,6 +18,7 @@ import { LinkedProjectCache } from "../../../telemetry/linked-project-cache.serv import { TelemetryState } from "../../../telemetry/telemetry-state.service.ts"; import { TelemetryOutputFormat } from "../../../telemetry/telemetry-output-format.service.ts"; import { DbConfigResolver } from "../../../command-internal/db-config.service.ts"; +import type { ResolvedDbConnection } from "../../../command-internal/db-config.types.ts"; import { DbConnection, type DbSession, @@ -93,6 +94,10 @@ const inReadOnlyTransaction = <A, E, R>(session: DbSession, body: Effect.Effect< () => session.queryRaw("ROLLBACK").pipe(Effect.ignore), ); +type QueryRoute = + | { readonly kind: "linked"; readonly ref: string; readonly token: Redacted.Redacted<string> } + | { readonly kind: "direct"; readonly connection: ResolvedDbConnection }; + export const dbQuery = Effect.fn("db.query")(function* (flags: DbQueryFlags) { const output = yield* Output; const telemetryState = yield* TelemetryState; @@ -270,31 +275,31 @@ export const dbQuery = Effect.fn("db.query")(function* (flags: DbQueryFlags) { // Recorded now so the cache finalizer still fires if a later step fails. if (target.kind === "linked") yield* Ref.set(linkedRefForCache, target.ref); - // Resolved before the token check and before `--file`/stdin are read, so a bad `--db-url`, an - // invalid merged config or a connection failure surfaces ahead of a login or missing-file - // error. A linked query goes through the Management API, so that resolved connection only - // serves those early failures and is otherwise discarded. - const connection = yield* resolver.resolveConnection({ - target, - dnsResolver, - password: Option.none(), - networkBanRemoval: yield* hostedMutationPreapproval, - }); - - let linkedAuth: { readonly token: Redacted.Redacted<string>; readonly ref: string } | undefined; - if (target.kind === "linked") { - const credentials = yield* CommandCredentials; - // Routed through `credentials.getAccessToken`, which validates the `sbp_...` shape, rather - // than trusting env presence alone and surfacing a confusing status error later. - const tokenOpt = yield* credentials.getAccessToken; - if (Option.isNone(tokenOpt)) { - return yield* new DbQueryLoginRequiredError({ - message: MISSING_TOKEN_MESSAGE, - suggestion: "Run supabase login first.", - }); + // Resolved before `--file`/stdin are read, so a bad `--db-url`, an invalid merged config, a + // connection failure or a missing login surfaces ahead of a missing-file error. A linked query + // goes through the Management API and never opens a database connection. + const route: QueryRoute = yield* Effect.gen(function* () { + if (target.kind === "linked") { + const credentials = yield* CommandCredentials; + // Routed through `credentials.getAccessToken`, which validates the `sbp_...` shape, rather + // than trusting env presence alone and surfacing a confusing status error later. + const tokenOpt = yield* credentials.getAccessToken; + if (Option.isNone(tokenOpt)) { + return yield* new DbQueryLoginRequiredError({ + message: MISSING_TOKEN_MESSAGE, + suggestion: "Run supabase login first.", + }); + } + return { kind: "linked", token: tokenOpt.value, ref: target.ref } satisfies QueryRoute; } - linkedAuth = { token: tokenOpt.value, ref: target.ref }; - } + const connection = yield* resolver.resolveConnection({ + target, + dnsResolver, + password: Option.none(), + networkBanRemoval: yield* hostedMutationPreapproval, + }); + return { kind: "direct", connection } satisfies QueryRoute; + }); // 1. Resolve SQL: --file > positional arg > piped stdin. const sql = yield* Effect.gen(function* () { @@ -367,24 +372,19 @@ export const dbQuery = Effect.fn("db.query")(function* (flags: DbQueryFlags) { // 3. A hosted statement may write, so it needs consent unless `--read-only` confines it. if (!flags.readOnly) { - yield* requireTargetMutationConsent(connection, { + yield* requireTargetMutationConsent(route.kind === "direct" ? route.connection : target, { action: "Run a SQL statement", details: [`sql: ${sqlPreview(sql)}`], }); } // 4. Linked queries go through the Management API; local/--db-url connect directly. - if (linkedAuth !== undefined) { - return yield* runLinked( - sql, - format, - agentMode, - linkedAuth.ref, - linkedAuth.token, - flags.readOnly, - ).pipe(Effect.withSpan("db.query.runLinked")); + if (route.kind === "linked") { + return yield* runLinked(sql, format, agentMode, route.ref, route.token, flags.readOnly).pipe( + Effect.withSpan("db.query.runLinked"), + ); } - return yield* runLocal(connection, sql, format, agentMode, flags.readOnly).pipe( + return yield* runLocal(route.connection, sql, format, agentMode, flags.readOnly).pipe( Effect.withSpan("db.query.runLocal"), ); }).pipe( 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 54040c1b26..57d65afa5b 100644 --- a/apps/cli/src/commands/db/query/query.integration.test.ts +++ b/apps/cli/src/commands/db/query/query.integration.test.ts @@ -869,7 +869,7 @@ describe("db query integration", () => { }).pipe(Effect.provide(layer)); }); - it.live("mints a login role and queries through the real DB config resolver and cache", () => { + it.live("queries a linked project through the real resolver without minting a login role", () => { const { layer, httpClient } = setup({ linkedStatus: 201, linkedBody: '[{"name":"alice","id":1}]', @@ -898,20 +898,11 @@ describe("db query integration", () => { projectRef: Option.some(REF), }), ); - expect(resolverRequests.map(({ method, path }) => `${method} ${path}`)).toEqual([ - `GET /v1/projects/${REF}/config/database/pooler`, - `POST /v1/projects/${REF}/cli/login-role`, - ]); + expect(resolverRequests).toEqual([]); assertPermissionDrift({ command: "db query", activeFlags: { linked: true, "project-ref": REF }, - requests: [ - ...resolverRequests.map(({ method, path }) => ({ - method, - url: `https://api.supabase.com${path}`, - })), - ...httpClient.calls, - ], + requests: httpClient.calls, exact: true, }); }).pipe( @@ -1030,19 +1021,15 @@ describe("db query integration", () => { }).pipe(Effect.provide(layer)); }); - it.effect("resolves the linked DB config before the API call", () => { + it.effect("never resolves a database connection for a linked query", () => { const { layer, out, cache } = setup({ resolveFails: true, linkedStatus: 201, linkedBody: '[{"id":1}]', }); return Effect.gen(function* () { - const exit = yield* dbQuery(flags({ sql: Option.some("select 1"), linked: true })).pipe( - Effect.exit, - ); - expect(Exit.isFailure(exit)).toBe(true); - expect(failMessage(exit)).toContain("failed to parse connection string"); - expect(out.stdoutText).toBe(""); + yield* dbQuery(flags({ sql: Option.some("select 1"), linked: true })); + expect(out.stdoutText).toContain("│ id │"); expect(cache.cached).toBe(true); }).pipe(Effect.provide(layer)); }); @@ -1173,16 +1160,6 @@ describe("db query integration", () => { }).pipe(Effect.provide(layer)); }); - it.effect("surfaces a linked config/connection failure before the missing-token error", () => { - const { layer } = setup({ accessToken: Option.none(), resolveFails: true }); - return Effect.gen(function* () { - const exit = yield* dbQuery(flags({ sql: Option.some("select 1"), linked: true })).pipe( - Effect.exit, - ); - expect(failMessage(exit)).toContain("failed to parse connection string"); - expect(failMessage(exit)).not.toContain("Access token not provided"); - }).pipe(Effect.provide(layer)); - }); describe("--read-only", () => { it.effect("brackets the statement in a read-only transaction on a local target", () => { const { layer, out, dbConnection } = setup({ result: SELECT_RESULT }); diff --git a/apps/cli/src/commands/migration/up/SIDE_EFFECTS.md b/apps/cli/src/commands/migration/up/SIDE_EFFECTS.md index cc375d4a5e..514c78f370 100644 --- a/apps/cli/src/commands/migration/up/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/migration/up/SIDE_EFFECTS.md @@ -46,7 +46,8 @@ ### `--output-format text` Prints `Target: …` to stderr, then `Applying migration <file>...` per pending migration, then -`Local database is up to date.` to stdout. Connects, lists remote + local +`Local database is up to date.` (`Remote database is up to date.` for a non-local target) to +stdout. Connects, lists remote + local migrations, computes the pending set, upserts `[db.vault]` secrets, and applies each pending migration transactionally. Does **not** seed. diff --git a/apps/cli/src/commands/migration/up/up.handler.ts b/apps/cli/src/commands/migration/up/up.handler.ts index 18a72e6ced..618959066f 100644 --- a/apps/cli/src/commands/migration/up/up.handler.ts +++ b/apps/cli/src/commands/migration/up/up.handler.ts @@ -129,7 +129,7 @@ const runUp = Effect.fnUntraced(function* (flags: MigrationUpFlags) { } if (output.format === "text") { - yield* output.raw("Local database is up to date.\n"); + yield* output.raw(`${cfg.isLocal ? "Local" : "Remote"} database is up to date.\n`); } else { yield* output.success("Migrations applied", { applied: pending }); } 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 970e26119a..f060c68c64 100644 --- a/apps/cli/src/commands/migration/up/up.integration.test.ts +++ b/apps/cli/src/commands/migration/up/up.integration.test.ts @@ -366,6 +366,16 @@ describe("migration up", () => { }, ); + it.effect("reports a remote database as up to date for a linked target", () => { + const { layer, out } = setup(tmp.current, { remote: ["20240101000000"] }); + return Effect.gen(function* () { + yield* seed(tmp.current, "20240101000000_a.sql"); + yield* migrationUp(flags({ linked: true })); + expect(stripAnsi(out.stdoutText)).toContain("Remote database is up to date."); + expect(stripAnsi(out.stdoutText)).not.toContain("Local database is up to date."); + }).pipe(Effect.provide(layer)); + }); + it.effect("emits a structured result in json", () => { const { layer, out } = setup(tmp.current, { format: "json", remote: [] }); return Effect.gen(function* () { 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 f262ede0a5..b6cc6b631f 100644 --- a/apps/cli/src/commands/seed/buckets/buckets.integration.test.ts +++ b/apps/cli/src/commands/seed/buckets/buckets.integration.test.ts @@ -2571,6 +2571,9 @@ describe("seed buckets", () => { "Seed storage buckets?", ]); expect(requests.some((r) => r.method === "PUT")).toBe(true); + expect( + requests.filter((r) => r.method === "GET" && r.url.endsWith("/storage/v1/bucket")), + ).toHaveLength(1); }).pipe(seedScenario), ); diff --git a/apps/cli/src/docs/docs-spec.tables.ts b/apps/cli/src/docs/docs-spec.tables.ts index 1c60dac12b..07e36bb03f 100644 --- a/apps/cli/src/docs/docs-spec.tables.ts +++ b/apps/cli/src/docs/docs-spec.tables.ts @@ -129,7 +129,7 @@ export const DOCS_DEFAULT_OVERRIDES: Readonly<Record<string, string>> = { "supabase-db-advisors local": "true", "supabase-db-advisors type": "all", "supabase-db-diff local": "true", - "supabase-db-dump linked": "true", + "supabase-db-dump local": "true", "supabase-db-lint fail-on": "none", "supabase-db-lint level": "warning", "supabase-db-lint local": "true", @@ -138,6 +138,7 @@ export const DOCS_DEFAULT_OVERRIDES: Readonly<Record<string, string>> = { "supabase-db-push linked": "true", "supabase-db-query local": "true", "supabase-db-reset local": "true", + "supabase-db-schema-declarative-generate local": "true", "supabase-db-schema-declarative-sync file": "declarative_sync", "supabase-functions-deploy jobs": "1", "supabase-functions-new auth": "apikey", @@ -146,38 +147,39 @@ export const DOCS_DEFAULT_OVERRIDES: Readonly<Record<string, string>> = { "supabase-gen-bearer-jwt valid-for": "30m0s", "supabase-gen-signing-key algorithm": "ES256", "supabase-gen-types lang": "typescript", + "supabase-gen-types local": "true", "supabase-gen-types query-timeout": "15s", - "supabase-inspect-db-bloat linked": "true", - "supabase-inspect-db-blocking linked": "true", - "supabase-inspect-db-calls linked": "true", - "supabase-inspect-db-db-stats linked": "true", - "supabase-inspect-db-index-stats linked": "true", - "supabase-inspect-db-locks linked": "true", - "supabase-inspect-db-long-running-queries linked": "true", - "supabase-inspect-db-outliers linked": "true", - "supabase-inspect-db-replication-slots linked": "true", - "supabase-inspect-db-role-stats linked": "true", - "supabase-inspect-db-table-stats linked": "true", - "supabase-inspect-db-traffic-profile linked": "true", - "supabase-inspect-db-vacuum-stats linked": "true", - "supabase-inspect-report linked": "true", + "supabase-inspect-db-bloat local": "true", + "supabase-inspect-db-blocking local": "true", + "supabase-inspect-db-calls local": "true", + "supabase-inspect-db-db-stats local": "true", + "supabase-inspect-db-index-stats local": "true", + "supabase-inspect-db-locks local": "true", + "supabase-inspect-db-long-running-queries local": "true", + "supabase-inspect-db-outliers local": "true", + "supabase-inspect-db-replication-slots local": "true", + "supabase-inspect-db-role-stats local": "true", + "supabase-inspect-db-table-stats local": "true", + "supabase-inspect-db-traffic-profile local": "true", + "supabase-inspect-db-vacuum-stats local": "true", + "supabase-inspect-report local": "true", "supabase-inspect-report output-dir": ".", "supabase-login name": "built-in token name generator", "supabase-migration-down last": "1", "supabase-migration-down local": "true", "supabase-migration-fetch linked": "true", - "supabase-migration-list linked": "true", - "supabase-migration-repair linked": "true", + "supabase-migration-list local": "true", + "supabase-migration-repair local": "true", "supabase-migration-squash local": "true", "supabase-migration-up local": "true", "supabase-seed-buckets local": "true", "supabase-storage-cp cache-control": "max-age=3600", "supabase-storage-cp content-type": "auto-detect", "supabase-storage-cp jobs": "1", - "supabase-storage-cp linked": "true", - "supabase-storage-ls linked": "true", - "supabase-storage-mv linked": "true", - "supabase-storage-rm linked": "true", + "supabase-storage-cp local": "true", + "supabase-storage-ls local": "true", + "supabase-storage-mv local": "true", + "supabase-storage-rm local": "true", "supabase-test-db local": "true", "supabase-test-new template": "pgtap", }; diff --git a/apps/cli/tests/helpers/live.ts b/apps/cli/tests/helpers/live.ts index 3a29b8c569..25757f42b7 100644 --- a/apps/cli/tests/helpers/live.ts +++ b/apps/cli/tests/helpers/live.ts @@ -219,6 +219,7 @@ export async function removeLiveMigration( "reverted", "--db-url", project.dbUrl, + "--yes", ]); requireCliSuccess(reverted, `migration repair cleanup for ${version}`); } From 6f3f581800f3ee5e1a929f4c68e437cecd1e0b1b Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Thu, 15 Oct 2026 15:37:24 +0100 Subject: [PATCH 67/92] fix(cli): name the config file when target resolution cannot parse it --- .../target/resolve-target.integration.test.ts | 11 +++++++++++ .../command-internal/target/resolve-target.ts | 17 +++++++++++++---- 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/apps/cli/src/command-internal/target/resolve-target.integration.test.ts b/apps/cli/src/command-internal/target/resolve-target.integration.test.ts index 93055c8a89..b33963d93d 100644 --- a/apps/cli/src/command-internal/target/resolve-target.integration.test.ts +++ b/apps/cli/src/command-internal/target/resolve-target.integration.test.ts @@ -233,6 +233,17 @@ describe("resolveTarget (selectable)", () => { expect(JSON.stringify(result.exit)).toContain("TargetSelectorConflictError"); }), ); + + it.effect("names the file when the workspace config cannot be parsed", () => + Effect.gen(function* () { + const result = yield* run("selectable", none, { toml: "[db\nport = " }); + const failure = Exit.isFailure(result.exit) ? Cause.squash(result.exit.cause) : undefined; + expect(failure).toMatchObject({ + _tag: "CliConfigLoadError", + message: expect.stringMatching(/^failed to parse supabase[/\\]config\.toml: ./), + }); + }), + ); }); describe("resolveTarget (sync)", () => { diff --git a/apps/cli/src/command-internal/target/resolve-target.ts b/apps/cli/src/command-internal/target/resolve-target.ts index 42d23abd61..088d975b36 100644 --- a/apps/cli/src/command-internal/target/resolve-target.ts +++ b/apps/cli/src/command-internal/target/resolve-target.ts @@ -4,6 +4,8 @@ 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 { ProjectRefResolver, type ProjectRefSource } from "../../config/project-ref.service.ts"; +import { CliConfigLoadError } from "../../shared/config/cli-config-load.errors.ts"; +import { relativeConfigPath } from "../workdir-project.ts"; import { parseTargetSelector, type TargetSelectorInput } from "./target-selector.ts"; import { TargetPolicy } from "./target-policy.ts"; @@ -68,10 +70,17 @@ export const resolveTarget = Effect.fn("Target.resolve")(function* (input: Targe return yield* finish(yield* linked(Option.none()), "default"); } const { workdir } = yield* CommandSettings; - const resolvedConfig = yield* (yield* CliConfigValues).load({ - workdir, - projectRef: Option.none(), - }); + const resolvedConfig = yield* (yield* CliConfigValues) + .load({ workdir, projectRef: Option.none() }) + .pipe( + Effect.catchTag("CliConfigParseError", (error) => + Effect.fail( + new CliConfigLoadError({ + message: `failed to parse ${relativeConfigPath(workdir, error.path)}: ${String(error.cause)}`, + }), + ), + ), + ); const workspaceTarget = yield* resolvedConfig.get(CliConfigKeys.target); const selectedBy: TargetSelection = workspaceTarget.origin.tier === "shell" || workspaceTarget.origin.tier === "projectEnv" From 23cdf3acebe2106fa9a3949cf743a29c3afb3ad5 Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Thu, 15 Oct 2026 15:37:25 +0100 Subject: [PATCH 68/92] fix(cli): ask bootstrap's hosted consent once --- apps/cli/src/commands/bootstrap/bootstrap.handler.ts | 1 + .../commands/bootstrap/bootstrap.integration.test.ts | 12 ++++++++++++ 2 files changed, 13 insertions(+) diff --git a/apps/cli/src/commands/bootstrap/bootstrap.handler.ts b/apps/cli/src/commands/bootstrap/bootstrap.handler.ts index ca1bffb807..7402df29d7 100644 --- a/apps/cli/src/commands/bootstrap/bootstrap.handler.ts +++ b/apps/cli/src/commands/bootstrap/bootstrap.handler.ts @@ -207,6 +207,7 @@ export const bootstrap = Effect.fn("bootstrap")(function* ( postgresEngine: undefined, templateUrl: starter.url.length > 0 ? starter.url : undefined, emitStructuredResult: false, + consent, }); const projectRef = created.ref; yield* Ref.set(createdRef, projectRef.length > 0 ? projectRef : undefined); diff --git a/apps/cli/src/commands/bootstrap/bootstrap.integration.test.ts b/apps/cli/src/commands/bootstrap/bootstrap.integration.test.ts index 41720e23ea..5d8444dc5a 100644 --- a/apps/cli/src/commands/bootstrap/bootstrap.integration.test.ts +++ b/apps/cli/src/commands/bootstrap/bootstrap.integration.test.ts @@ -436,6 +436,18 @@ describe("bootstrap integration", () => { }).pipe(Effect.provide(BunServices.layer)), ); + it.live("asks for hosted-mutation consent once for the whole bootstrap", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const s = setup(path, { promptConfirmResponses: [true] }); + yield* bootstrap(flags({ template: Option.some("scratch") }), FAST_BACKOFF).pipe( + Effect.provide(s.layer), + ); + expect(s.out.promptConfirmCalls).toHaveLength(1); + expect(s.createBodies).toHaveLength(1); + }).pipe(Effect.provide(BunServices.layer)), + ); + it.live("prompts for a workdir when none is configured", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; From fecec51ffbdcf1ed2f48b99d678a0583530de216 Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Thu, 15 Oct 2026 15:37:25 +0100 Subject: [PATCH 69/92] refactor(cli): build pull's linked target in project-target --- .../src/command-internal/project-target.ts | 28 ++++++++++++++++++- apps/cli/src/commands/pull/pull.handler.ts | 14 ++-------- 2 files changed, 29 insertions(+), 13 deletions(-) diff --git a/apps/cli/src/command-internal/project-target.ts b/apps/cli/src/command-internal/project-target.ts index 22bc54a6c9..f107fe2935 100644 --- a/apps/cli/src/command-internal/project-target.ts +++ b/apps/cli/src/command-internal/project-target.ts @@ -1,7 +1,9 @@ import type { SupabaseApiError } from "@supabase/api/effect"; -import { Data, Effect, Option } from "effect"; +import { Data, Effect, FileSystem, Option, Path } from "effect"; +import { CommandSettings } from "../config/command-settings.service.ts"; import { ProjectRefResolver } from "../config/project-ref.service.ts"; +import { readProjectRefFile } from "../shared/config/temp-paths.ts"; import { parentNotLinkedMessage, parentRefInvalidMessage, @@ -14,6 +16,7 @@ import { Output } from "../shared/output/output.service.ts"; import { resolveBranchProjectRef } from "./branch-ref.resolver.ts"; import { sanitizeInlineName } from "./http-errors.ts"; import { BRANCH_PROJECT_REF_PATTERN, BRANCH_UUID_PATTERN } from "./ref-patterns.ts"; +import type { ResolvedTarget } from "./target/resolve-target.ts"; import { renderBranchTarget } from "./target/target-identity.ts"; import { actionability, @@ -224,3 +227,26 @@ export const resolveConfigTarget = Effect.fn("ProjectTarget.resolve")(function* yield* Effect.annotateCurrentSpan("project.ref", ref); return { ref, branch }; }); + +/** + * The linked {@link ResolvedTarget} for a ref {@link resolveConfigTarget} already announced, so + * connection resolution can use it without a second banner. Only `matchesLinkedRef` feeds + * connection resolution; `source` is informational. + */ +export const linkedTargetForConfigRef = Effect.fn("ProjectTarget.linkedTarget")(function* ( + requested: Option.Option<string>, + ref: string, +) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const { workdir } = yield* CommandSettings; + const linkedRef = yield* readProjectRefFile(fs, path, workdir).pipe( + Effect.orElseSucceed(() => Option.none<string>()), + ); + return { + kind: "linked", + ref, + source: Option.isSome(requested) ? "flag" : "file", + matchesLinkedRef: Option.contains(linkedRef, ref), + } satisfies ResolvedTarget; +}); diff --git a/apps/cli/src/commands/pull/pull.handler.ts b/apps/cli/src/commands/pull/pull.handler.ts index 40991c601b..f8e2785547 100644 --- a/apps/cli/src/commands/pull/pull.handler.ts +++ b/apps/cli/src/commands/pull/pull.handler.ts @@ -9,15 +9,14 @@ import { validateWorkdirIsDirectory } from "../../command-internal/workdir-valid import { MachineErrorContext } from "../../shared/output/machine-error-context.service.ts"; import { resolveYes, OutputFlag } from "../../command-internal/global-flags.ts"; import { requireHostedMutationConsent } from "../../command-internal/hosted-consent/hosted-consent.ts"; -import type { ResolvedTarget } from "../../command-internal/target/resolve-target.ts"; import { CommandTarget, type TargetJson } from "../../shared/output/command-target.service.ts"; -import { readProjectRefFile } from "../../shared/config/temp-paths.ts"; import { Output } from "../../shared/output/output.service.ts"; import { Tty } from "../../shared/runtime/tty.service.ts"; import { LinkedProjectCache } from "../../telemetry/linked-project-cache.service.ts"; import { TelemetryState } from "../../telemetry/telemetry-state.service.ts"; import { configTargetErrorsFor, + linkedTargetForConfigRef, resolveConfigTarget, } from "../../command-internal/project-target.ts"; import { unexpectedStatusMessage } from "../../command-internal/http-errors.ts"; @@ -235,16 +234,7 @@ export const pull = Effect.fn("pull")(function* (flags: PullFlags) { return { ref: target.ref, branch: target.branch, runPlan: plan, source: pullSource }; }).pipe(Effect.withSpan("pull.resolveTarget")); - // `resolveConfigTarget` already announced the target; wrapping its ref here avoids a second banner. - const linkedRef = yield* readProjectRefFile(fs, path, cliSettings.workdir).pipe( - Effect.orElseSucceed(() => Option.none<string>()), - ); - const linkedTarget: ResolvedTarget = { - kind: "linked", - ref, - source: "flag", - matchesLinkedRef: Option.contains(linkedRef, ref), - }; + const linkedTarget = yield* linkedTargetForConfigRef(requested, ref); const targetIdentity: TargetJson = Option.getOrElse(yield* commandTarget.json, () => ({})); // Retry hints below use the planned remote destination's label, not the raw `remoteLabel` From 0beacee8ff1cbb966f155af17439d6b59070a9de Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Thu, 15 Oct 2026 15:37:26 +0100 Subject: [PATCH 70/92] docs(cli): refresh target wording and stale comments --- apps/cli/AGENTS.md | 3 +++ apps/cli/docs/supabase/migration/repair.md | 4 ++++ apps/cli/src/commands/db/advisors/advisors.layers.ts | 4 ++-- apps/cli/src/commands/db/diff/diff.integration.test.ts | 2 +- apps/cli/src/commands/db/dump/dump.layers.ts | 4 ++-- apps/cli/src/commands/db/pull/pull.integration.test.ts | 2 +- apps/cli/src/commands/db/reset/SIDE_EFFECTS.md | 6 +++--- apps/cli/src/commands/db/reset/reset.integration.test.ts | 2 +- apps/cli/src/commands/feedback/feedback-project-ref.ts | 2 +- .../src/commands/seed/buckets/buckets.integration.test.ts | 4 ++-- 10 files changed, 20 insertions(+), 13 deletions(-) diff --git a/apps/cli/AGENTS.md b/apps/cli/AGENTS.md index 3433750f01..fee986ff27 100644 --- a/apps/cli/AGENTS.md +++ b/apps/cli/AGENTS.md @@ -245,6 +245,9 @@ without one. Declare the target flags only through `targetFlags` in - Every leaf with consent has a `describeHostedConsent("<command path>", …)` test from `tests/helpers/hosted-consent.ts`. +See [ADR 0033](../../docs/adr/0033-target-selection-and-hosted-mutation-consent.md) for the +target-default and consent rules. + ## Tests Follow the root testing philosophy and use the package scripts in `package.json`. Cover meaningful diff --git a/apps/cli/docs/supabase/migration/repair.md b/apps/cli/docs/supabase/migration/repair.md index 80fcd53b7f..305ebfc15d 100644 --- a/apps/cli/docs/supabase/migration/repair.md +++ b/apps/cli/docs/supabase/migration/repair.md @@ -45,6 +45,10 @@ Now you can run `db pull` again to dump the remote schema as a local migration f ```bash $ supabase db pull +Target: project My Project (abcdefghijklmnopqrst) · production +Write 1 migration file to supabase/migrations. +Record them as applied in the remote migration history. +Update the remote migration history table on project My Project (abcdefghijklmnopqrst) · production? [y/N] Schema written to supabase/migrations/20240414044403_remote_schema.sql Finished supabase db pull. diff --git a/apps/cli/src/commands/db/advisors/advisors.layers.ts b/apps/cli/src/commands/db/advisors/advisors.layers.ts index 30b0239646..2d5d237dc0 100644 --- a/apps/cli/src/commands/db/advisors/advisors.layers.ts +++ b/apps/cli/src/commands/db/advisors/advisors.layers.ts @@ -26,8 +26,8 @@ import { telemetryStateLayer } from "../../../telemetry/telemetry-state.layer.ts * * Instead the project-ref resolver is given the lazy `commandPlatformApiFactoryLayer`, whose * `make` is only forced by an interactive project-ref prompt. The linked path resolves the ref - * via the non-prompting `loadProjectRef`, which never forces the factory; the local path never - * resolves a project ref at all, so no token is resolved there either. + * through target resolution, which never forces the factory; the local path never resolves a + * project ref at all, so no token is resolved there either. * * `commandSettingsLayer` is provided to each consumer that needs it, since `Layer.provide` does * not share to merge siblings; layers are memoised by reference so the config / credentials / 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 77722e5ed0..02860d77d4 100644 --- a/apps/cli/src/commands/db/diff/diff.integration.test.ts +++ b/apps/cli/src/commands/db/diff/diff.integration.test.ts @@ -145,7 +145,7 @@ interface SetupOpts { // `CommandSettings.projectId`; defaults to `Option.some("test")`. Pass // `Option.none()` to exercise the config.toml/workdir-basename fallback. readonly projectId?: Option.Option<string>; - // Simulates an unlinked workdir: `loadProjectRef` fails with + // Simulates an unlinked workdir: the project-ref resolver fails with // `ProjectRefNotLinkedError` absent an explicit `--project-ref` flag. readonly linkedFails?: boolean; // Per-differ-run `--json-diff` stdout, indexed by run order; falls back to `""` diff --git a/apps/cli/src/commands/db/dump/dump.layers.ts b/apps/cli/src/commands/db/dump/dump.layers.ts index 85229a4aa9..d2004b4711 100644 --- a/apps/cli/src/commands/db/dump/dump.layers.ts +++ b/apps/cli/src/commands/db/dump/dump.layers.ts @@ -41,8 +41,8 @@ const platformApiFactory = commandPlatformApiFactoryLayer.pipe( Layer.provide(identityStitchLayer), ); -// Exposed so the handler can pre-validate `--project-ref` via `loadProjectRef` -// before the linked-project-cache finalizer ever sees it. +// Exposed so target resolution can validate `--project-ref` before the linked-project-cache +// finalizer ever sees it. const projectRef = projectRefLayer.pipe( Layer.provide(platformApiFactory), Layer.provide(cliSettings), 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 eea3edc8ba..2f9b3c0a3b 100644 --- a/apps/cli/src/commands/db/pull/pull.integration.test.ts +++ b/apps/cli/src/commands/db/pull/pull.integration.test.ts @@ -184,7 +184,7 @@ interface SetupOpts { // `CommandSettings.projectId`; defaults to `Option.some("test")`. Pass // `Option.none()` to exercise the config.toml/workdir-basename fallback. readonly projectId?: Option.Option<string>; - // Simulates an unlinked workdir: `loadProjectRef` fails with + // Simulates an unlinked workdir: the project-ref resolver fails with // `ProjectRefNotLinkedError` absent an explicit `--project-ref` flag. readonly linkedFails?: boolean; // Swaps in the stateful Docker model (real `stop`/`cp`/`start`), required by the diff --git a/apps/cli/src/commands/db/reset/SIDE_EFFECTS.md b/apps/cli/src/commands/db/reset/SIDE_EFFECTS.md index 4a989a2671..0548ac8c31 100644 --- a/apps/cli/src/commands/db/reset/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/db/reset/SIDE_EFFECTS.md @@ -29,9 +29,9 @@ The whole local-reset composition is hoisted into `command-internal/db-bootstrap 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 — see those -commands' own `SIDE_EFFECTS.md`. +command). `db schema declarative`'s optional local reset and `db schema 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 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 765905bf73..7f8cdb5685 100644 --- a/apps/cli/src/commands/db/reset/reset.integration.test.ts +++ b/apps/cli/src/commands/db/reset/reset.integration.test.ts @@ -927,7 +927,7 @@ function setup( replicationSlotCounts?: ReadonlyArray<number>; replicationSlotQueryFails?: boolean; failStatement?: { readonly sql: string; readonly code?: string; readonly message: string }; - // Simulates an unlinked workdir: `loadProjectRef` fails with `ProjectRefNotLinkedError` + // Simulates an unlinked workdir: the project-ref resolver fails with `ProjectRefNotLinkedError` // absent an explicit `--project-ref` flag, instead of falling back to `opts.ref`. linkedFails?: boolean; stackBackend?: boolean; diff --git a/apps/cli/src/commands/feedback/feedback-project-ref.ts b/apps/cli/src/commands/feedback/feedback-project-ref.ts index 02ef3dcdea..2ee822146d 100644 --- a/apps/cli/src/commands/feedback/feedback-project-ref.ts +++ b/apps/cli/src/commands/feedback/feedback-project-ref.ts @@ -11,7 +11,7 @@ import { readProjectRefFile } from "../../shared/config/temp-paths.ts"; // - The caller-supplied override (`--project-ref` and/or `SUPABASE_PROJECT_ID`, // captured by `CommandSettings`) is validated against `PROJECT_REF_PATTERN` // and fails with the shared `InvalidProjectRefError` when malformed — the -// same hard check every `resolve`/`loadProjectRef` caller applies to a value +// same hard check every project-ref caller applies to a value // the user typed, so a typo is reported instead of silently falling through // to a different project's context. // - `<workdir>/supabase/.temp/project-ref`, the file `supabase link` writes, is 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 b6cc6b631f..12cfc0e7ad 100644 --- a/apps/cli/src/commands/seed/buckets/buckets.integration.test.ts +++ b/apps/cli/src/commands/seed/buckets/buckets.integration.test.ts @@ -96,7 +96,7 @@ const setupSeedBuckets = Effect.fnUntraced(function* ( readonly pipedAnswers?: ReadonlyArray<string>; readonly args?: ReadonlyArray<string>; readonly yes?: boolean; - /** Project ref returned by loadProjectRef for --linked tests. */ + /** Project ref returned by the project-ref resolver for --linked tests. */ readonly projectRef?: string; /** API keys response for Management API mock. */ readonly apiKeys?: ReadonlyArray<{ @@ -105,7 +105,7 @@ const setupSeedBuckets = Effect.fnUntraced(function* ( type?: string | null; secret_jwt_template?: Record<string, unknown> | null; }>; - /** When true, loadProjectRef fails with ProjectRefNotLinkedError. */ + /** When true, the project-ref resolver fails with ProjectRefNotLinkedError. */ readonly linkedFails?: boolean; /** When set, the Management API `getProjectApiKeys` call fails with this error. */ readonly apiKeysFail?: HttpClientError.HttpClientError; From 1388dc4005e76765a65a9134468604cc8be14674 Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Thu, 15 Oct 2026 15:43:12 +0100 Subject: [PATCH 71/92] test(cli): expect the readable config load error from db reset --- apps/cli/src/commands/db/reset/reset.integration.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 7f8cdb5685..20e1637d69 100644 --- a/apps/cli/src/commands/db/reset/reset.integration.test.ts +++ b/apps/cli/src/commands/db/reset/reset.integration.test.ts @@ -1868,7 +1868,8 @@ describe("db reset", () => { expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { expect(Option.getOrUndefined(Cause.findErrorOption(exit.cause))).toMatchObject({ - _tag: "CliConfigParseError", + _tag: "CliConfigLoadError", + message: expect.stringContaining("failed to parse supabase"), }); } expect(child.spawned.some((s) => s.args[0] === "container" && s.args[1] === "rm")).toBe( From ca8bd80b09437d122277fb9d70dda23efad01a6d Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Thu, 15 Oct 2026 15:45:04 +0100 Subject: [PATCH 72/92] chore(cli): remove imports and fixtures the cleanup left unused --- apps/cli/src/commands/config/push/push.integration.test.ts | 3 +-- apps/cli/src/commands/inspect/db/inspect-query.ts | 7 +------ 2 files changed, 2 insertions(+), 8 deletions(-) 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 7d7a336e2e..61ad9c7276 100644 --- a/apps/cli/src/commands/config/push/push.integration.test.ts +++ b/apps/cli/src/commands/config/push/push.integration.test.ts @@ -172,7 +172,6 @@ function authWriteResponseFixture(): Record<string, unknown> { // a branch, its parent, and an unrelated project simultaneously. const BRANCH_REF = "cccccccccccccccccccc"; const PARENT_REF = "pppppppppppppppppppp"; -const OTHER_PARENT_REF = "qqqqqqqqqqqqqqqqqqqq"; const PROBE_REF = "zzzzzzzzzzzzzzzzzzzz"; const UUID_TARGET_REF = "rrrrrrrrrrrrrrrrrrrr"; const BRANCH_UUID = "11111111-1111-4111-8111-111111111111"; @@ -3244,7 +3243,7 @@ describe("config push --project-ref branch name/UUID resolution (CLI-2289)", () it.effect( "--project-ref <branch-name> resolves via the already-known parent, no extra live probe", () => { - const { layer, out, api } = setup({ + const { layer, api } = setup({ toml: BRANCH_PUSH_TOML, yes: true, branchByName: { status: 200, body: BRANCH_BY_NAME }, diff --git a/apps/cli/src/commands/inspect/db/inspect-query.ts b/apps/cli/src/commands/inspect/db/inspect-query.ts index e72d179f0b..31f0a71eed 100644 --- a/apps/cli/src/commands/inspect/db/inspect-query.ts +++ b/apps/cli/src/commands/inspect/db/inspect-query.ts @@ -1,12 +1,7 @@ -import { Data, Effect, Option } from "effect"; +import { Effect, Option } from "effect"; import { DnsResolverFlag } from "../../../command-internal/global-flags.ts"; import { Output } from "../../../shared/output/output.service.ts"; -import { - actionability, - type CliErrorActionabilityDeclaration, - ErrorActionabilityId, -} from "../../../shared/telemetry/error-actionability.ts"; import { renderGlamourTable } from "../../../output/glamour-table.ts"; import { DbConfigResolver } from "../../../command-internal/db-config.service.ts"; import type { ResolvedDbConfig } from "../../../command-internal/db-config.types.ts"; From 582530420906d5518a08036693e33e5e9e13437c Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Thu, 15 Oct 2026 16:21:56 +0100 Subject: [PATCH 73/92] test(cli): name the consent-free operations that next now maps --- .../src/command-internal/target/target-policy.unit.test.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/apps/cli/src/command-internal/target/target-policy.unit.test.ts b/apps/cli/src/command-internal/target/target-policy.unit.test.ts index 3d072e79d4..de0d88e0cf 100644 --- a/apps/cli/src/command-internal/target/target-policy.unit.test.ts +++ b/apps/cli/src/command-internal/target/target-policy.unit.test.ts @@ -29,6 +29,12 @@ const CONSENT_FREE_OPERATIONS: ReadonlyMap<string, string> = new Map([ ], ["v1-delete-network-bans", "removes the caller's own ban only when the token permits it"], ["v1-list-all-network-bans", "a POST that only reads the ban list"], + ["v1-verify-dns-config", "re-runs verification and changes no configuration"], + ["v1-check-vanity-subdomain-availability", "a POST that only reads availability"], + [ + "v2-create-notebook", + "notebooks pull creates remote notebooks only after an interactive reconcile choice", + ], ]); const MUTATING_VERBS: ReadonlySet<string> = new Set([ From d5b14c4411d7d1a7e3b393bbb97cbc739658a115 Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Thu, 15 Oct 2026 16:21:57 +0100 Subject: [PATCH 74/92] test(cli): align merged integration tests with the target banner and defaults --- .../config/push/push.integration.test.ts | 4 +-- .../domains/delete/delete.integration.test.ts | 4 ++- .../deploy/deploy.integration.test.ts | 2 +- .../gen/types/types.integration.test.ts | 9 +++--- .../storage/ls/ls.integration.test.ts | 30 ++----------------- .../storage/rm/rm.integration.test.ts | 2 +- 6 files changed, 13 insertions(+), 38 deletions(-) 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 536845a837..ce990e45d3 100644 --- a/apps/cli/src/commands/config/push/push.integration.test.ts +++ b/apps/cli/src/commands/config/push/push.integration.test.ts @@ -1917,7 +1917,7 @@ allowed_cidrs_v6 = ["::1/128"] `PATCH ${API}/v1/projects/${REF}/postgrest`, `GET ${API}/v1/projects/${REF}`, ]); - expect(out.stderrText).toContain(`Pushing config to project: ${REF}`); + expect(out.stderrText).toContain(`Target: project ${REF} · identity unverified`); expect(yield* decodeJsonText(yield* fs.readFileString(cachePath))).toMatchObject({ ref: REF, }); @@ -1940,7 +1940,7 @@ allowed_cidrs_v6 = ["::1/128"] `GET ${API}/v2/projects/${REF}/config`, `PATCH ${API}/v1/projects/${REF}/postgrest`, ]); - expect(out.stderrText).toContain("could not determine whether this is a branch"); + expect(out.stderrText).toContain(`Target: project ${REF} · identity unverified`); assertPermissionDrift({ command: "config push", requests: api.requests, exact: true }); }).pipe(Effect.provide(layer)); }); diff --git a/apps/cli/src/commands/domains/delete/delete.integration.test.ts b/apps/cli/src/commands/domains/delete/delete.integration.test.ts index 25569a25c5..97c397bfdc 100644 --- a/apps/cli/src/commands/domains/delete/delete.integration.test.ts +++ b/apps/cli/src/commands/domains/delete/delete.integration.test.ts @@ -183,7 +183,9 @@ describe("domains delete integration", () => { const cachePath = `${tempRoot.current}/supabase/.temp/linked-project.json`; expect(yield* fs.exists(cachePath)).toBe(false); yield* domainsDelete(baseFlags); - expect(out.stderrText).toBe("Deleted custom hostname config successfully.\n"); + expect(out.stderrText).toBe( + unverifiedTargetBanner() + confirmed + "Deleted custom hostname config successfully.\n", + ); expect(api.requests.map(({ method, url }) => `${method} ${url}`)).toEqual([ "GET https://api.supabase.com/v1/projects", `DELETE https://api.supabase.com/v1/projects/${VALID_REF}/custom-hostname`, 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 0284c0a084..a26b00715d 100644 --- a/apps/cli/src/commands/functions/deploy/deploy.integration.test.ts +++ b/apps/cli/src/commands/functions/deploy/deploy.integration.test.ts @@ -1593,8 +1593,8 @@ describe("functions deploy", () => { yield* functionsDeploy({ ...baseFlags, noVerifyJwt: true, prune: true }); expect(requestLines(api.requests)).toEqual([ - `POST https://api.supabase.com/v1/projects/${VALID_REF}/functions/deploy`, `GET https://api.supabase.com/v1/projects/${VALID_REF}/functions`, + `POST https://api.supabase.com/v1/projects/${VALID_REF}/functions/deploy`, `DELETE https://api.supabase.com/v1/projects/${VALID_REF}/functions/remote-only`, ]); assertPermissionDrift({ 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 9c58c25bb0..450d74705c 100644 --- a/apps/cli/src/commands/gen/types/types.integration.test.ts +++ b/apps/cli/src/commands/gen/types/types.integration.test.ts @@ -3485,7 +3485,7 @@ describe("gen types permissions through the real layers", () => { ); it.effect( - "the implicit linked fallback generates from the saved ref when no target flag is given", + "targets the local database without management requests when no target flag is given", () => Effect.gen(function* () { const ctx = yield* setupRecorded({ @@ -3494,12 +3494,11 @@ describe("gen types permissions through the real layers", () => { }); yield* ctx.run(genTypes(defaultFlags())); - expect(methodPaths(ctx.api.requests)).toEqual([ - `GET /v1/projects/${VALID_REF}/types/typescript`, - ]); + expect(ctx.generator.calls).toHaveLength(1); + expect(ctx.recordedRequests()).toEqual([]); assertPermissionDrift({ command: "gen types", - activeFlags: { ...DEFAULT_SWITCHES, linked: true, lang: "typescript" }, + activeFlags: { ...DEFAULT_SWITCHES, lang: "typescript" }, requests: ctx.recordedRequests(), exact: true, }); 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 f3d1aa469b..40f898099b 100644 --- a/apps/cli/src/commands/storage/ls/ls.integration.test.ts +++ b/apps/cli/src/commands/storage/ls/ls.integration.test.ts @@ -492,7 +492,7 @@ project_id = "env(STAGING_REF)" expect(yield* fs.exists(`${tmp.current}/${CACHE_FILE}`)).toBe(true); assertPermissionDrift({ command: "storage ls", - activeFlags: { local: false }, + activeFlags: { linked: true }, requests: managementRequests, }); }).pipe(Effect.provide(BunServices.layer)), @@ -515,7 +515,7 @@ project_id = "env(STAGING_REF)" ]); assertPermissionDrift({ command: "storage ls", - activeFlags: { local: false }, + activeFlags: { linked: true }, requests: managementRequests, }); }), @@ -560,32 +560,6 @@ project_id = "env(STAGING_REF)" }).pipe(Effect.provide(BunServices.layer)), ), ); - - it.effect("--linked=false still targets the linked project", () => - withEnvVar( - "SUPABASE_AUTH_SERVICE_ROLE_KEY", - undefined, - Effect.gen(function* () { - const { layer, out, requests, managementRequests } = setupStorage(tmp.current, { - recordManagementApi: true, - routes: [{ method: "GET", match: BUCKET, body: [{ name: "remote", id: "remote" }] }], - }); - yield* storageLs({ ...lsFlags({ local: false }), linked: false }).pipe( - Effect.provide(layer), - ); - expect(out.stdoutText).toBe("remote/\n"); - expect(requests[0]?.url.startsWith(`https://${VALID_REF}.supabase.co`)).toBe(true); - expect( - managementRequests.map(({ method, urlWithParams }) => `${method} ${urlWithParams}`), - ).toEqual([REVEALED_API_KEYS, PROJECT_LOOKUP]); - assertPermissionDrift({ - command: "storage ls", - activeFlags: { linked: false, local: false }, - requests: managementRequests, - }); - }), - ), - ); }); describe("stack backend", () => { diff --git a/apps/cli/src/commands/storage/rm/rm.integration.test.ts b/apps/cli/src/commands/storage/rm/rm.integration.test.ts index fd0157c588..e028a4455a 100644 --- a/apps/cli/src/commands/storage/rm/rm.integration.test.ts +++ b/apps/cli/src/commands/storage/rm/rm.integration.test.ts @@ -912,7 +912,7 @@ describe("storage rm", () => { expect(yield* fs.exists(`${tmp.current}/${CACHE_FILE}`)).toBe(true); assertPermissionDrift({ command: "storage rm", - activeFlags: { local: false }, + activeFlags: { linked: true }, requests: managementRequests, }); }).pipe(Effect.provide(layer)); From 11bdda24b530d129336498671a355ab50701c0ed Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Thu, 15 Oct 2026 16:22:51 +0100 Subject: [PATCH 75/92] fix(cli): drop imports left unused by the next merge --- .../cli/src/commands/backups/restore/restore.integration.test.ts | 1 - apps/cli/src/commands/branches/create/create.command.ts | 1 - apps/cli/src/commands/secrets/unset/unset.command.ts | 1 - apps/cli/src/commands/storage/cp/cp.command.ts | 1 - apps/cli/src/commands/storage/ls/ls.command.ts | 1 - apps/cli/src/commands/storage/mv/mv.command.ts | 1 - apps/cli/src/commands/storage/rm/rm.command.ts | 1 - 7 files changed, 7 deletions(-) diff --git a/apps/cli/src/commands/backups/restore/restore.integration.test.ts b/apps/cli/src/commands/backups/restore/restore.integration.test.ts index 48b54edb0f..4bfb948269 100644 --- a/apps/cli/src/commands/backups/restore/restore.integration.test.ts +++ b/apps/cli/src/commands/backups/restore/restore.integration.test.ts @@ -1,7 +1,6 @@ import type { V1ListAllProjectsOutput } from "@supabase/api/effect"; import { describe, expect, it } from "@effect/vitest"; import { Cause, Effect, Exit, Layer, Option, FileSystem, Schema } from "effect"; -import * as HttpClientResponse from "effect/http/HttpClientResponse"; import { mockOutput, mockTty } from "../../../../tests/helpers/mocks.ts"; import { diff --git a/apps/cli/src/commands/branches/create/create.command.ts b/apps/cli/src/commands/branches/create/create.command.ts index 155c590eab..8f222e7cb9 100644 --- a/apps/cli/src/commands/branches/create/create.command.ts +++ b/apps/cli/src/commands/branches/create/create.command.ts @@ -1,7 +1,6 @@ import { Argument, Command, Flag } from "effect/cli"; import type * as CliCommand from "effect/cli/Command"; -import { Layer } from "effect"; import { withPermissions } from "../../../command-internal/command-permissions/command-permissions.annotation.ts"; import { compose } from "../../../command-internal/command-permissions/model.ts"; import { projectRefPicker } from "../../../config/project-ref.layer.ts"; diff --git a/apps/cli/src/commands/secrets/unset/unset.command.ts b/apps/cli/src/commands/secrets/unset/unset.command.ts index 3c2aea8937..90aa321274 100644 --- a/apps/cli/src/commands/secrets/unset/unset.command.ts +++ b/apps/cli/src/commands/secrets/unset/unset.command.ts @@ -1,7 +1,6 @@ import type * as CliCommand from "effect/cli/Command"; import { Argument, Command, Flag } from "effect/cli"; -import { Layer } from "effect"; import { withPermissions } from "../../../command-internal/command-permissions/command-permissions.annotation.ts"; import { compose } from "../../../command-internal/command-permissions/model.ts"; import { projectRefPicker } from "../../../config/project-ref.layer.ts"; diff --git a/apps/cli/src/commands/storage/cp/cp.command.ts b/apps/cli/src/commands/storage/cp/cp.command.ts index 07b9d81441..6478dbb486 100644 --- a/apps/cli/src/commands/storage/cp/cp.command.ts +++ b/apps/cli/src/commands/storage/cp/cp.command.ts @@ -4,7 +4,6 @@ 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 { withPermissions } from "../../../command-internal/command-permissions/command-permissions.annotation.ts"; import { compose } from "../../../command-internal/command-permissions/model.ts"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; diff --git a/apps/cli/src/commands/storage/ls/ls.command.ts b/apps/cli/src/commands/storage/ls/ls.command.ts index 62bef30787..c25955db34 100644 --- a/apps/cli/src/commands/storage/ls/ls.command.ts +++ b/apps/cli/src/commands/storage/ls/ls.command.ts @@ -4,7 +4,6 @@ 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 { withPermissions } from "../../../command-internal/command-permissions/command-permissions.annotation.ts"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; import { withCommandTelemetry } from "../../../telemetry/command-telemetry.ts"; diff --git a/apps/cli/src/commands/storage/mv/mv.command.ts b/apps/cli/src/commands/storage/mv/mv.command.ts index 4b55c08338..4a9b662d13 100644 --- a/apps/cli/src/commands/storage/mv/mv.command.ts +++ b/apps/cli/src/commands/storage/mv/mv.command.ts @@ -4,7 +4,6 @@ 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 { withPermissions } from "../../../command-internal/command-permissions/command-permissions.annotation.ts"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; import { withCommandTelemetry } from "../../../telemetry/command-telemetry.ts"; diff --git a/apps/cli/src/commands/storage/rm/rm.command.ts b/apps/cli/src/commands/storage/rm/rm.command.ts index dfe72ee914..f9c5bdb9b3 100644 --- a/apps/cli/src/commands/storage/rm/rm.command.ts +++ b/apps/cli/src/commands/storage/rm/rm.command.ts @@ -3,7 +3,6 @@ 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 { withPermissions } from "../../../command-internal/command-permissions/command-permissions.annotation.ts"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; import { withCommandTelemetry } from "../../../telemetry/command-telemetry.ts"; From 1b5e1873de1d5e2811dbfe8453190de942d48201 Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Thu, 15 Oct 2026 16:45:42 +0100 Subject: [PATCH 76/92] fix(cli): accept gen types --linked with --project-ref and announce the local target first --- .../src/commands/gen/types/SIDE_EFFECTS.md | 24 ++++--- .../src/commands/gen/types/types.handler.ts | 10 ++- .../gen/types/types.integration.test.ts | 68 ++++++++++++++++++- 3 files changed, 89 insertions(+), 13 deletions(-) diff --git a/apps/cli/src/commands/gen/types/SIDE_EFFECTS.md b/apps/cli/src/commands/gen/types/SIDE_EFFECTS.md index 1eba903973..4e1e5ed248 100644 --- a/apps/cli/src/commands/gen/types/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/gen/types/SIDE_EFFECTS.md @@ -31,9 +31,9 @@ inspecting `supabase_db_*`. `--project-id` is a deprecated alias of `--project-r ## Files Written -| Path | Format | When | -| ---------------------------------------------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------- | -| `<workdir>/supabase/.temp/linked-project.json` | JSON | project-ref paths (`--linked`, `--project-id`, the implicit linked fallback), via `LinkedProjectCache.cache`, when the file is absent | +| Path | Format | When | +| ---------------------------------------------- | ------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `<workdir>/supabase/.temp/linked-project.json` | JSON | linked targets (`--linked`, `--project-ref`, `--project-id`, or a workspace `target` of `remote`), via `LinkedProjectCache.cache`, when the file is absent | `--local` and `--db-url` write no files. @@ -127,9 +127,11 @@ way `--db-url` does. Prints generated TypeScript (or other language) type definitions to stdout. Diagnostics go to stderr: one `Target: …` banner naming the resolved target (the project ref and whether its identity is verified, the local stack host and port, or the custom -Postgres host) and `Connecting to …` for database introspection. In-process TypeScript also -prints a stderr note that the output is unformatted. `--project-id` additionally prints -`--project-id is deprecated; use --project-ref instead.` on stderr. +Postgres host) and `Connecting to …` for database introspection. On the legacy Docker Compose +stack the banner prints before the running check, so a stopped stack still shows the chosen +target. In-process TypeScript also prints a stderr note that the output is unformatted. +`--project-id` additionally prints `--project-id is deprecated; use --project-ref instead.` on +stderr. ### `--output-format json` @@ -146,12 +148,14 @@ Not applicable. - The target comes from `resolveTarget`: a selector flag wins, then the workspace `target` (`SUPABASE_TARGET`, then `config.toml`), then local. With no selector the command generates from the local database; it neither prompts for a target nor fails. `--project-ref` implies - `--linked`; combining it with `--local` or `--db-url` is an error, as is combining two of + `--linked`; `--linked --project-ref <ref>` together is accepted and targets that ref, while + combining `--project-ref` with `--local` or `--db-url` is an error, as is combining two of `--local`, `--linked` and `--db-url`. A `false` switch (`--linked=false`) counts as absent. - `--project-id` is a deprecated alias of `--project-ref`; passing both is an error. -- `--linked`/`--project-ref` are mutually exclusive with `postgrest-v9-compat`, `query-timeout`, - and each language flag (`swift-access-control`), enforced with the exact error text and sorted - group order `linked/project-ref/<flag>`. +- `--linked`/`--project-ref` (one member of the group, so the pair itself is allowed) are mutually + exclusive with `postgrest-v9-compat`, `query-timeout`, and each language flag + (`swift-access-control`), enforced with the exact error text and sorted group order + `linked/project-ref/<flag>`. - With `--local`, a missing `supabase/config.toml` uses the embedded config defaults plus 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 diff --git a/apps/cli/src/commands/gen/types/types.handler.ts b/apps/cli/src/commands/gen/types/types.handler.ts index f1f8315c47..5f19ad89fe 100644 --- a/apps/cli/src/commands/gen/types/types.handler.ts +++ b/apps/cli/src/commands/gen/types/types.handler.ts @@ -115,6 +115,9 @@ function isPoolerHost(host: string, poolerHost: string): boolean { const GEN_TYPES_COMMAND_PATH = ["gen", "types"] as const; +// `--project-ref` refines `--linked`, so the pair counts as one member of a mutex group. +const REMOTE_TARGET_FLAGS: ReadonlySet<string> = new Set(["linked", "project-ref"]); + // Validation reports only the first violated group, in this listed order. const GEN_TYPES_MUTEX_GROUPS: ReadonlyArray<ReadonlyArray<string>> = [ ["linked", "project-ref", "postgrest-v9-compat"], @@ -619,7 +622,10 @@ export const genTypes = Effect.fn("gen.types")(function* (flags: GenTypesFlags) }; for (const group of GEN_TYPES_MUTEX_GROUPS) { const set = group.filter((flagName) => changedMutexFlags[flagName]); - if (set.length > 1) { + const members = new Set( + set.map((flagName) => (REMOTE_TARGET_FLAGS.has(flagName) ? "linked" : flagName)), + ); + if (members.size > 1) { return yield* new GenTypesFlagUsageError({ message: mutuallyExclusiveFlagsMessage(group, set), }); @@ -666,11 +672,11 @@ export const genTypes = Effect.fn("gen.types")(function* (flags: GenTypesFlags) return; } - yield* assertLocalDbRunning(projectId); const host = yield* getHostname( yield* configValues.load({ workdir: cliSettings.workdir, projectRef: Option.none() }), ); yield* announceTarget(renderLocalTarget(host, config.port)); + yield* assertLocalDbRunning(projectId); yield* runGenerate({ conn: { host, 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 450d74705c..dffa880096 100644 --- a/apps/cli/src/commands/gen/types/types.integration.test.ts +++ b/apps/cli/src/commands/gen/types/types.integration.test.ts @@ -1283,6 +1283,71 @@ describe("gen types", () => { }).pipe(Effect.provide(BunServices.layer)), ); + it.effect("accepts --linked together with --project-ref", () => + Effect.gen(function* () { + const { layer, api, out } = yield* setup({ + args: ["gen", "types", "--linked", "--project-ref", VALID_REF], + projectTypes: "export type Database = {};", + }); + yield* genTypes(defaultFlags({ linked: true, projectRef: Option.some(VALID_REF) })).pipe( + Effect.provide(layer), + ); + + expect(out.stdoutText).toBe("export type Database = {};"); + expect(api.requests).toEqual([ + { + method: "generateTypescriptTypes", + input: { ref: VALID_REF, included_schemas: "public" }, + }, + ]); + }).pipe(Effect.provide(BunServices.layer)), + ); + + it.effect("rejects --query-timeout with --linked and --project-ref (mutex group)", () => + Effect.gen(function* () { + const { layer, api } = yield* setup({ + args: ["gen", "types", "--linked", "--project-ref", VALID_REF, "--query-timeout", "20s"], + }); + const exit = yield* genTypes( + defaultFlags({ + linked: true, + projectRef: Option.some(VALID_REF), + queryTimeout: "20s", + }), + ).pipe(Effect.provide(layer), Effect.exit); + + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(String(exit.cause)).toContain( + "if any flags in the group [linked project-ref query-timeout] are set none of the others can be; [linked project-ref query-timeout] were all set", + ); + } + expect(api.requests).toHaveLength(0); + }).pipe(Effect.provide(BunServices.layer)), + ); + + it.effect("rejects --linked with --project-ref when --local is also set", () => + Effect.gen(function* () { + const { layer, api } = yield* setup({ + args: ["gen", "types", "--local", "--linked", "--project-ref", VALID_REF], + }); + const exit = yield* genTypes( + defaultFlags({ local: true, linked: true, projectRef: Option.some(VALID_REF) }), + ).pipe(Effect.provide(layer), Effect.exit); + + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect( + Option.exists( + Cause.findErrorOption(exit.cause), + Predicate.isTagged("TargetSelectorConflictError"), + ), + ).toBe(true); + } + expect(api.requests).toHaveLength(0); + }).pipe(Effect.provide(BunServices.layer)), + ); + it.effect("rejects --query-timeout with --project-ref (mutex group)", () => Effect.gen(function* () { const { layer } = yield* setup({ @@ -2716,7 +2781,7 @@ describe("gen types", () => { "port = 54321", ].join("\n"), ); - const { layer } = yield* setup({ + const { layer, out } = yield* setup({ workdir, childExitCode: 1, childStderr: ["Error: No such container: supabase_db_demo"], @@ -2727,6 +2792,7 @@ describe("gen types", () => { ); expect(Exit.isFailure(exit)).toBe(true); + expect(out.stderrText).toContain("Target: local stack ("); if (Exit.isFailure(exit)) { expect(String(exit.cause)).toContain("supabase start is not running."); expect( From 8131c15b0d6b4df0105fb98de482d4e62cdb24d2 Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Thu, 15 Oct 2026 16:48:13 +0100 Subject: [PATCH 77/92] fix(cli): emit the functions deploy json envelope after pruning --- .../commands/functions/deploy/SIDE_EFFECTS.md | 3 +- .../deploy/deploy.integration.test.ts | 89 ++++++++++++++++++- apps/cli/src/shared/functions/deploy.ts | 50 +++++------ 3 files changed, 113 insertions(+), 29 deletions(-) diff --git a/apps/cli/src/commands/functions/deploy/SIDE_EFFECTS.md b/apps/cli/src/commands/functions/deploy/SIDE_EFFECTS.md index bd2c21874a..99dd908a03 100644 --- a/apps/cli/src/commands/functions/deploy/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/functions/deploy/SIDE_EFFECTS.md @@ -85,7 +85,8 @@ Prints progress and success messages as Functions are deployed, bundled, uploade ### `--output-format json` -Emits a structured success payload with the project ref, deployed function slugs, and dashboard URL. +Emits one structured success payload with the project ref, deployed function slugs, and dashboard +URL, after `--prune` has finished deleting. A failing prune emits only the error envelope. ### `--output-format stream-json` 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 a26b00715d..d681d77004 100644 --- a/apps/cli/src/commands/functions/deploy/deploy.integration.test.ts +++ b/apps/cli/src/commands/functions/deploy/deploy.integration.test.ts @@ -1456,13 +1456,15 @@ describe("functions deploy", () => { readonly yes: boolean; readonly interactive?: boolean; readonly confirmResponses?: ReadonlyArray<boolean>; + readonly format?: "text" | "json"; + readonly handler?: ApiHandler; }) => { const out = mockOutput({ - format: "text", + format: opts.format ?? "text", interactive: opts.interactive, promptConfirmResponses: opts.confirmResponses, }); - const api = mockCommandPlatformApi({ handler: pruneApiHandler }); + const api = mockCommandPlatformApi({ handler: opts.handler ?? pruneApiHandler }); const layer = Layer.mergeAll( cliConfigValuesTestLayer, buildTestRuntime({ @@ -1530,6 +1532,89 @@ describe("functions deploy", () => { }).pipe(Effect.provide(layer), Effect.ensuring(removeTempRoot)); }); + it.effect("emits one json success envelope once the prune has finished", () => { + let messagesWhenDeleting: number | undefined; + const { out, layer } = setupPrune({ + yes: true, + format: "json", + handler: (request, recorded) => { + if (request.method === "DELETE") messagesWhenDeleting = out.messages.length; + return pruneApiHandler(request, recorded); + }, + }); + + return Effect.gen(function* () { + yield* writeCliConfig(tempRoot.current); + yield* writeLocalFunction(tempRoot.current, "hello-world"); + + yield* functionsDeploy({ ...baseFlags, prune: true }).pipe(withJsonErrorHandling); + + expect(messagesWhenDeleting).toBe(0); + expect(out.messages).toEqual([ + { + type: "success", + message: "Deployed Functions.", + data: { + project_ref: VALID_REF, + functions: ["hello-world"], + dashboard_url: `https://supabase.com/dashboard/project/${VALID_REF}/functions`, + }, + }, + ]); + }).pipe(Effect.provide(layer), Effect.ensuring(removeTempRoot)); + }); + + it.effect("emits only the failure envelope when the prune fails after a deploy", () => { + const { out, api, layer } = setupPrune({ + yes: true, + format: "json", + handler: (request, recorded) => + request.method === "DELETE" + ? Effect.succeed(jsonResponse(request, 500, { message: "boom" })) + : pruneApiHandler(request, recorded), + }); + + return Effect.gen(function* () { + yield* writeCliConfig(tempRoot.current); + yield* writeLocalFunction(tempRoot.current, "hello-world"); + + yield* functionsDeploy({ ...baseFlags, prune: true }).pipe(withJsonErrorHandling); + + expect(api.requests.some((request) => request.method === "POST")).toBe(true); + expect(out.messages.map((message) => message.type)).toEqual(["fail"]); + expect(out.failures[0]?.message).toContain("unexpected delete function status 500"); + }).pipe(Effect.provide(layer), Effect.ensuring(removeTempRoot)); + }); + + it.effect("emits the up-to-date json envelope after pruning when nothing is enabled", () => { + let messagesWhenDeleting: number | undefined; + const { out, layer } = setupPrune({ + yes: true, + format: "json", + handler: (request, recorded) => { + if (request.method === "DELETE") messagesWhenDeleting = out.messages.length; + return pruneApiHandler(request, recorded); + }, + }); + + return Effect.gen(function* () { + yield* writeCliConfig( + tempRoot.current, + ['project_id = "test-project"', '[functions."hello-world"]', "enabled = false", ""].join( + "\n", + ), + ); + yield* writeLocalFunction(tempRoot.current, "hello-world"); + + yield* functionsDeploy({ ...baseFlags, prune: true }).pipe(withJsonErrorHandling); + + expect(messagesWhenDeleting).toBe(0); + expect(out.messages.map((message) => [message.type, message.message])).toEqual([ + ["success", "All Functions are up to date."], + ]); + }).pipe(Effect.provide(layer), Effect.ensuring(removeTempRoot)); + }); + it.effect("deploys and deletes nothing when the combined question is declined", () => { const { api, layer } = setupPrune({ yes: false, diff --git a/apps/cli/src/shared/functions/deploy.ts b/apps/cli/src/shared/functions/deploy.ts index d897c1b3cf..9b8f8c95d3 100644 --- a/apps/cli/src/shared/functions/deploy.ts +++ b/apps/cli/src/shared/functions/deploy.ts @@ -2630,23 +2630,18 @@ export const deployFunctions = Effect.fn("functions.deploy")(function* < dependencies.api, jobs, ).pipe( - Effect.as(true), + Effect.as(Option.none<string>()), Effect.catchIf( (error): error is NoFunctionsToDeployError => error instanceof NoFunctionsToDeployError, (error) => - (output.format === "text" - ? output.raw(`${error.message}\n`, "stderr") - : output.success(error.message, { - project_ref: projectRef, - functions: uniqueSlugs, - dashboard_url: dashboardUrl, - }) - ).pipe(Effect.as(false)), + (output.format === "text" ? output.raw(`${error.message}\n`, "stderr") : Effect.void).pipe( + Effect.as(Option.some(error.message)), + ), ), ); const styleWarning = dependencies.styleWarning ?? ((text: string) => text); - const deployed = useLocalBundler + const nothingToDeployMessage = useLocalBundler ? yield* Effect.gen(function* () { if (!(yield* isDockerRunning())) { yield* output.raw(`${styleWarning("WARNING:")} Docker is not running\n`, "stderr"); @@ -2675,28 +2670,31 @@ export const deployFunctions = Effect.fn("functions.deploy")(function* < styleEmphasis, projectEnvValues: context.projectEnvValues, }); - return true; + return Option.none<string>(); }) : yield* deployWithApi; - if (deployed) { - if (output.format === "text") { - // Joins the raw `slugs` list, not the deduped set, so `functions deploy foo foo` prints - // "foo, foo". - yield* output.raw( - `Deployed Functions on project ${styleIdentifier(projectRef)}: ${slugs.join(", ")}\n`, - ); - yield* output.raw(`You can inspect your deployment in the Dashboard: ${dashboardUrl}\n`); - } else { - yield* output.success("Deployed Functions.", { - project_ref: projectRef, - functions: uniqueSlugs, - dashboard_url: dashboardUrl, - }); - } + if (output.format === "text" && Option.isNone(nothingToDeployMessage)) { + // Joins the raw `slugs` list, not the deduped set, so `functions deploy foo foo` prints + // "foo, foo". + yield* output.raw( + `Deployed Functions on project ${styleIdentifier(projectRef)}: ${slugs.join(", ")}\n`, + ); + yield* output.raw(`You can inspect your deployment in the Dashboard: ${dashboardUrl}\n`); } if (flags.prune) { yield* pruneFunctions(projectRef, toPrune, dependencies.api); } + + if (output.format !== "text") { + yield* output.success( + Option.getOrElse(nothingToDeployMessage, () => "Deployed Functions."), + { + project_ref: projectRef, + functions: uniqueSlugs, + dashboard_url: dashboardUrl, + }, + ); + } }); From 73772c0c9007761e63721e4f57d0f262c327ff22 Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Thu, 15 Oct 2026 16:48:53 +0100 Subject: [PATCH 78/92] fix(cli): gate network-ban removal on a --yes approval and report bans after the retries run out --- .../src/command-internal/db-config.errors.ts | 2 +- .../db-config.integration.test.ts | 74 +++++++++++--- .../src/command-internal/db-config.layer.ts | 99 +++++++++++-------- .../src/command-internal/db-config.types.ts | 2 +- apps/cli/src/command-internal/db-pull-run.ts | 13 +-- .../src/command-internal/pooler-fallback.ts | 27 ++++- .../pooler-fallback.unit.test.ts | 43 ++++++++ .../commands/bootstrap/bootstrap.handler.ts | 6 +- apps/cli/src/commands/db/dump/dump.handler.ts | 7 +- .../src/commands/db/shared/pooler-fallback.ts | 19 ++-- .../src/commands/inspect/db/SIDE_EFFECTS.md | 14 +-- .../commands/inspect/report/SIDE_EFFECTS.md | 3 +- 12 files changed, 219 insertions(+), 90 deletions(-) diff --git a/apps/cli/src/command-internal/db-config.errors.ts b/apps/cli/src/command-internal/db-config.errors.ts index b30c63bfee..86ba1be874 100644 --- a/apps/cli/src/command-internal/db-config.errors.ts +++ b/apps/cli/src/command-internal/db-config.errors.ts @@ -144,7 +144,7 @@ export class DbConfigConnectTempRoleError extends Data.TaggedError("DbConfigConn } } -/** The temporary login role cannot connect because the requester is network-banned and removing the ban was not approved. */ +/** The temporary login role could not connect while network bans were listed and removing them was not approved with `--yes`. */ export class LinkedDbNetworkBannedError extends Data.TaggedError("LinkedDbNetworkBannedError")<{ readonly message: string; readonly suggestion?: string; 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 cd041f5d02..225d8ccf17 100644 --- a/apps/cli/src/command-internal/db-config.integration.test.ts +++ b/apps/cli/src/command-internal/db-config.integration.test.ts @@ -67,6 +67,7 @@ import { identityStitchLayer } from "./identity-stitch.ts"; import { DbConfigResolver } from "./db-config.service.ts"; import type { DbConnectionInput } from "./db-config.types.ts"; import { hostedMutationPreapproval } from "./hosted-consent/hosted-consent.ts"; +import type { HostedMutationConsent } from "./hosted-consent/hosted-consent.token.ts"; import type { ResolvedTarget } from "./target/resolve-target.ts"; import { DbConnectError } from "./db-connection.errors.ts"; import { DbConnection, type DbSession, type PgConnInput } from "./db-connection.service.ts"; @@ -1959,7 +1960,11 @@ describe("dbConfigResolver.resolveConnection", () => { }); return { attempts, layer }; }; - const forkResolve = (input: DbConnectionInput, connection: Layer.Layer<DbConnection>) => + const forkResolve = ( + input: DbConnectionInput, + connection: Layer.Layer<DbConnection>, + output?: ReturnType<typeof mockOutput>, + ) => Effect.gen(function* () { const retries = yield* Queue.unbounded<string>(); const debugLogger = Layer.succeed(DebugLogger, { @@ -1973,43 +1978,58 @@ describe("dbConfigResolver.resolveConnection", () => { dbConnection: connection, debugLogger, configEnv: { SUPABASE_ACCESS_TOKEN: VALID_TOKEN }, + output, }).pipe(Effect.forkChild({ startImmediately: true })); const advance = (retryMessages: number) => Effect.gen(function* () { for (let seen = 1; seen <= retryMessages; seen++) { yield* Queue.take(retries).pipe(Effect.raceFirst(Fiber.join(fiber))); - yield* TestClock.adjust("10 seconds"); + yield* TestClock.adjust("1 minute"); } }); return { fiber, advance }; }); - it.effect("fails with LinkedDbNetworkBannedError and removes nothing without approval", () => { + it.effect("reports the listed bans once the retries run out and removes nothing", () => { const requests: Array<{ readonly method: string; readonly path: string }> = []; - const connection = failingConnect(3); + const connection = failingConnect(Number.POSITIVE_INFINITY); return Effect.gen(function* () { - yield* bannedRequests(requests, ["203.0.113.7"]); + yield* bannedRequests(requests, ["203.0.113.7", "198.51.100.2"]); const { fiber, advance } = yield* forkResolve( inputFor(linkedTarget(ref, { source: "flag", matchesLinkedRef: false })), connection.layer, ); - yield* advance(2); + yield* advance(8); const exit = yield* Fiber.await(fiber); expect(failureText(exit)).toContain("LinkedDbNetworkBannedError"); const error = Exit.isFailure(exit) ? Cause.findErrorOption(exit.cause) : Option.none(); expect(error).toEqual( Option.some( expect.objectContaining({ - message: expect.stringContaining("203.0.113.7"), - suggestion: expect.stringContaining("supabase network-bans remove"), + message: expect.stringContaining("203.0.113.7, 198.51.100.2"), + suggestion: + "Re-run with --yes to remove them, or run supabase network-bans remove --db-unban-ip 203.0.113.7,198.51.100.2 --experimental --yes.", }), ), ); - expect(connection.attempts.count).toBe(3); - expect(requests).toContainEqual({ - method: "POST", - path: `/v1/projects/${ref}/network-bans/retrieve`, - }); + expect(connection.attempts.count).toBe(9); + expect(requests.some((request) => request.method === "DELETE")).toBe(false); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)); + }); + + it.effect("connects without removing bans that are not blocking the caller", () => { + const requests: Array<{ readonly method: string; readonly path: string }> = []; + const connection = failingConnect(3); + return Effect.gen(function* () { + yield* bannedRequests(requests, ["203.0.113.7"]); + const { fiber, advance } = yield* forkResolve( + inputFor(linkedTarget(ref, { source: "flag", matchesLinkedRef: false })), + connection.layer, + ); + yield* advance(3); + const r = yield* Fiber.join(fiber); + expect(r.conn).toMatchObject({ user: `cli_login_role.${ref}` }); + expect(connection.attempts.count).toBe(4); expect(requests.some((request) => request.method === "DELETE")).toBe(false); }).pipe(Effect.scoped, Effect.provide(BunServices.layer)); }); @@ -2031,9 +2051,10 @@ describe("dbConfigResolver.resolveConnection", () => { }).pipe(Effect.scoped, Effect.provide(BunServices.layer)); }); - it.effect("removes the bans once the caller holds an approval", () => { + it.effect("removes the bans and says so once the caller holds a --yes approval", () => { const requests: Array<{ readonly method: string; readonly path: string }> = []; const connection = failingConnect(3); + const output = mockOutput(); return Effect.gen(function* () { yield* bannedRequests(requests, ["203.0.113.7"]); const { fiber, advance } = yield* forkResolve( @@ -2041,6 +2062,7 @@ describe("dbConfigResolver.resolveConnection", () => { networkBanRemoval: yield* approval, }), connection.layer, + output, ); yield* advance(3); const r = yield* Fiber.join(fiber); @@ -2050,8 +2072,32 @@ describe("dbConfigResolver.resolveConnection", () => { method: "DELETE", path: `/v1/projects/${ref}/network-bans`, }); + expect(output.stderrText).toContain("Removing network bans (203.0.113.7)...\n"); assertDbConfigDrift(requests, { linked: true, "project-ref": ref }); }).pipe(Effect.scoped, Effect.provide(BunServices.layer)); }); + + it.effect("does not remove bans for an approval obtained by prompting", () => { + const requests: Array<{ readonly method: string; readonly path: string }> = []; + const connection = failingConnect(Number.POSITIVE_INFINITY); + // The brand is type-only; only the consent module mints tokens in production. + const promptApproval = Option.some({ + action: "Push", + via: "prompt", + } as HostedMutationConsent); + return Effect.gen(function* () { + yield* bannedRequests(requests, ["203.0.113.7"]); + const { fiber, advance } = yield* forkResolve( + inputFor(linkedTarget(ref, { source: "flag", matchesLinkedRef: false }), { + networkBanRemoval: promptApproval, + }), + connection.layer, + ); + yield* advance(8); + const exit = yield* Fiber.await(fiber); + expect(failureText(exit)).toContain("LinkedDbNetworkBannedError"); + expect(requests.some((request) => request.method === "DELETE")).toBe(false); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)); + }); }); }); diff --git a/apps/cli/src/command-internal/db-config.layer.ts b/apps/cli/src/command-internal/db-config.layer.ts index b10dd5a053..15b32e7acd 100644 --- a/apps/cli/src/command-internal/db-config.layer.ts +++ b/apps/cli/src/command-internal/db-config.layer.ts @@ -149,6 +149,10 @@ export const dbConfigPoolerFallback = block( })), ); +/** Only a `--yes`-sourced approval may clear bans; a prompt answer never does, since removal clears every listed address. */ +const canRemoveNetworkBans = (input: DbConnectionInput): boolean => + Option.exists(input.networkBanRemoval, (approval) => approval.via === "yes"); + const DIRECT_PORT = 5432; const TCP_PROBE_TIMEOUT = Duration.seconds(5); const MAX_RETRIES = 8; @@ -233,34 +237,42 @@ const initLoginRole = Effect.fn("DbConfig.initLoginRole")(function* ( return { ...conn, user: role.role, password: role.password }; }); -const listAndUnban = Effect.fnUntraced(function* (ref: string, removeBans: boolean) { +const listBans = Effect.fnUntraced(function* (ref: string) { const api = yield* (yield* CommandPlatformApiFactory).make; const bans = yield* api.v1.listAllNetworkBans({ ref }).pipe(Effect.catch(listBansErrorMapper)); - const addrs = bans.banned_ipv4_addresses; - if (addrs.length === 0) return; - if (!removeBans) { - return yield* new Errors.LinkedDbNetworkBannedError({ - message: `Network bans (${addrs.join(", ")}) may be blocking the connection, and removing them needs approval.`, - suggestion: - "Re-run with --yes to remove the bans, or run supabase network-bans remove --db-unban-ip <ip> --experimental --yes first.", - }); - } + return bans.banned_ipv4_addresses; +}); + +const removeBans = Effect.fnUntraced(function* (ref: string, addrs: ReadonlyArray<string>) { + const output = yield* Output; + const api = yield* (yield* CommandPlatformApiFactory).make; + yield* output.raw(`Removing network bans (${addrs.join(", ")})...\n`, "stderr"); yield* api.v1 .deleteNetworkBans({ ref, ipv4_addresses: [...addrs], requester_ip: false }) .pipe(Effect.catch(unbanErrorMapper)); }); -// Verify-connect with backoff while the pooler refreshes the temp password. On attempt ≥ 3, -// clear any network ban on the requester. +const networkBannedError = (addrs: ReadonlyArray<string>) => + new Errors.LinkedDbNetworkBannedError({ + message: `Network bans (${addrs.join(", ")}) may be blocking the connection. Removing them clears every listed address, so it needs --yes.`, + suggestion: `Re-run with --yes to remove them, or run supabase network-bans remove --db-unban-ip ${addrs.join(",")} --experimental --yes.`, + }); + +// Verify-connect with backoff while the pooler refreshes the temp password. From attempt 3 on, look +// for network bans on the project: with approval they are removed, without it the last listing is +// reported if the retries run out. const waitForTempRole = Effect.fn("DbConfig.waitForTempRole")(function* ( ref: string, conn: PgConnInput, dnsResolver: "native" | "https", - removeBans: boolean, + removeNetworkBans: boolean, ) { const dbConn = yield* DbConnection; const debug = yield* DebugLogger; - const attempt = (n: number): Effect.Effect<void, DbConfigError, CommandPlatformApiFactory> => + const attempt = ( + n: number, + listedBans: ReadonlyArray<string>, + ): Effect.Effect<void, DbConfigError, CommandPlatformApiFactory | Output> => // The temp-role probe always targets the remote Supavisor pooler, so it connects with TLS // and honors `--dns-resolver`. Effect.scoped(dbConn.connect(conn, { isLocal: false, dnsResolver }).pipe(Effect.asVoid)).pipe( @@ -270,38 +282,43 @@ const waitForTempRole = Effect.fn("DbConfig.waitForTempRole")(function* ( // up only after attempt 9 (`n > MAX_RETRIES`), not at attempt 8. if (n > MAX_RETRIES) { return Effect.fail( - new Errors.DbConfigConnectTempRoleError({ - message: `failed to connect as temp role: ${cause.message}`, - suggestion: SUGGEST_ENV_VAR, - }), + listedBans.length > 0 + ? networkBannedError(listedBans) + : new Errors.DbConfigConnectTempRoleError({ + message: `failed to connect as temp role: ${cause.message}`, + suggestion: SUGGEST_ENV_VAR, + }), ); } - // From the 3rd failure onward, clear any network ban on the requester. Uses a - // deterministic backoff curve rather than jittered, since jitter only matters under - // concurrent pooler refreshes. + // Deterministic backoff rather than jittered, since jitter only matters under concurrent + // pooler refreshes. const delayMs = Math.min( Duration.toMillis(BACKOFF_INITIAL) * 1.5 ** (n - 1), Duration.toMillis(BACKOFF_MAX), ); return Effect.gen(function* () { - // A transient ban-list/unban failure must not propagate out of the retry loop; log - // it to --debug, then discard. An unapproved ban removal is the one failure that ends it. - if (n >= 3) { - yield* listAndUnban(ref, removeBans).pipe( - Effect.catch((banError) => - banError._tag === "LinkedDbNetworkBannedError" - ? Effect.fail(banError) - : debug.debug(banError.message), - ), - ); - } + // A transient ban-list/unban failure must not propagate out of the retry loop; log it + // to --debug, then discard. + const bans = + n < 3 + ? listedBans + : yield* listBans(ref).pipe( + Effect.flatMap((addrs) => + addrs.length > 0 && removeNetworkBans + ? removeBans(ref, addrs).pipe(Effect.as([])) + : Effect.succeed(addrs), + ), + Effect.catch((banError) => + debug.debug(banError.message).pipe(Effect.as(listedBans)), + ), + ); yield* debug.debug(`Retry (${n}/${MAX_RETRIES}): ${cause.message}`); yield* Effect.sleep(Duration.millis(delayMs)); - return yield* attempt(n + 1); + return yield* attempt(n + 1, bans); }); }), ); - return yield* attempt(1); + return yield* attempt(1, []); }); /** @@ -413,7 +430,7 @@ const resolvePoolerConn = Effect.fn("DbConfig.resolvePoolerConn")(function* ( // `ref` from the Management API instead. ignoreSavedUrl = false, resolveVaultSecrets = true, - removeNetworkBans = true, + removeNetworkBans: boolean, ) { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -485,6 +502,8 @@ export const resolveLinkedConn = Effect.fn("DbConfig.resolveLinkedConn")(functio dnsResolver: "native" | "https", explicitPassword: Option.Option<string>, options: { + /** Whether the temp-role retry may clear listed network bans; only a `--yes`-sourced approval sets it. */ + readonly removeNetworkBans: boolean; readonly adHocProjectRef?: boolean; readonly resolveVaultSecrets?: boolean; /** @@ -497,16 +516,14 @@ export const resolveLinkedConn = Effect.fn("DbConfig.resolveLinkedConn")(functio readonly fetchPoolerFromApi?: boolean; /** Whether the calling command binds `--password`, so the withheld-password notice can name it. */ readonly hasPasswordFlag?: boolean; - /** When false, a network ban that blocks the temp role fails with `LinkedDbNetworkBannedError` instead of being removed. */ - readonly removeNetworkBans?: boolean; - } = {}, + }, ) { const { adHocProjectRef = false, resolveVaultSecrets = true, fetchPoolerFromApi = false, hasPasswordFlag = false, - removeNetworkBans = true, + removeNetworkBans, } = options; const dbPassword = yield* resolveLinkedPassword(ref, workdir, explicitPassword, hasPasswordFlag); const host = `db.${ref}.${projectHost}`; @@ -795,7 +812,7 @@ export const dbConfigResolverLayer = Layer.effect( adHocProjectRef: !target.matchesLinkedRef, fetchPoolerFromApi: target.source === "flag", hasPasswordFlag: input.hasPasswordFlag ?? false, - removeNetworkBans: Option.isSome(input.networkBanRemoval), + removeNetworkBans: canRemoveNetworkBans(input), }), ); return { conn, isLocal: false, ref: Option.some(target.ref), target }; @@ -841,7 +858,7 @@ export const dbConfigResolverLayer = Layer.effect( true, !target.matchesLinkedRef, input.resolveVaultSecrets ?? true, - Option.isSome(input.networkBanRemoval), + canRemoveNetworkBans(input), ); }).pipe( Effect.provide( diff --git a/apps/cli/src/command-internal/db-config.types.ts b/apps/cli/src/command-internal/db-config.types.ts index a63e8dcae9..b4a3d0c2ad 100644 --- a/apps/cli/src/command-internal/db-config.types.ts +++ b/apps/cli/src/command-internal/db-config.types.ts @@ -27,7 +27,7 @@ export interface DbConnectionInput { readonly resolveVaultSecrets?: boolean; /** Set when the command binds `--password`, so the withheld-password notice can name it. */ readonly hasPasswordFlag?: boolean; - /** `Some` lets the temp-role retry clear a network ban; `None` fails with `LinkedDbNetworkBannedError` instead. */ + /** A `--yes`-sourced `Some` lets the temp-role retry clear network bans; otherwise a ban surfaces as `LinkedDbNetworkBannedError`. */ readonly networkBanRemoval: Option.Option<HostedMutationConsent>; } diff --git a/apps/cli/src/command-internal/db-pull-run.ts b/apps/cli/src/command-internal/db-pull-run.ts index 7b1c163623..5d668f714f 100644 --- a/apps/cli/src/command-internal/db-pull-run.ts +++ b/apps/cli/src/command-internal/db-pull-run.ts @@ -43,6 +43,7 @@ import { import { emitPoolerFallbackWarning, isDirectLinkedHost, + orNoPoolerFallback, runWithPoolerFallback, } from "../commands/db/shared/pooler-fallback.ts"; import { dumpSchemaScript } from "./pg-dump.scripts.ts"; @@ -227,11 +228,9 @@ export const runDbPull = Effect.fn("db.pull.run")(function* ( }) && isIPv6ConnectivityError(error.message) ) { - // A pooler resolution failure is treated as "no fallback" (re-fail the - // original diff error), not surfaced as its own error. - const pooler = yield* resolver - .resolvePoolerFallback(connectionInput) - .pipe(Effect.orElseSucceed(() => Option.none())); + const pooler = yield* orNoPoolerFallback( + resolver.resolvePoolerFallback(connectionInput), + ); if (Option.isSome(pooler)) { yield* emitPoolerFallbackWarning(resolved.conn.host); return yield* attempt({ @@ -443,9 +442,7 @@ export const runDbPull = Effect.fn("db.pull.run")(function* ( isLocal: resolved.isLocal, projectHost: cliSettings.projectHost, resolvePooler: () => - resolver - .resolvePoolerFallback(connectionInput) - .pipe(Effect.orElseSucceed(() => Option.none())), + orNoPoolerFallback(resolver.resolvePoolerFallback(connectionInput)), runWithConn: runSchemaDump, reprintOnRetry: Effect.void, }); diff --git a/apps/cli/src/command-internal/pooler-fallback.ts b/apps/cli/src/command-internal/pooler-fallback.ts index 7ecdc66e31..ecc9a0b4d4 100644 --- a/apps/cli/src/command-internal/pooler-fallback.ts +++ b/apps/cli/src/command-internal/pooler-fallback.ts @@ -1,4 +1,4 @@ -import { Effect, Option } from "effect"; +import { Effect, Option, Predicate } from "effect"; import { Output } from "../shared/output/output.service.ts"; import { yellow } from "./colors.ts"; @@ -8,6 +8,21 @@ export function isDirectDbHost(host: string, projectHost: string): boolean { return host.startsWith("db.") && host.endsWith(`.${projectHost}`); } +/** + * Treats a failed pooler resolution as "no fallback" so the caller keeps its original error, except + * an unapproved network-ban removal, whose "re-run with --yes" guidance would otherwise be lost. + */ +export const orNoPoolerFallback = <E, R>( + resolve: Effect.Effect<Option.Option<PgConnInput>, E, R>, +) => + resolve.pipe( + Effect.catch((error) => + Predicate.isTagged(error, "LinkedDbNetworkBannedError") + ? Effect.fail(error) + : Effect.succeed(Option.none<PgConnInput>()), + ), + ); + export interface PoolerFallbackOptions<A, E, R, R2, RF, EF> { readonly run: Effect.Effect<A, E, R>; readonly retry: (pooler: PgConnInput) => Effect.Effect<A, E, R2>; @@ -32,10 +47,12 @@ export const emitPoolerFallbackWarning = (host: string): Effect.Effect<void, nev export function runWithPoolerFallback<A, E, R, R2, RF, EF>( options: PoolerFallbackOptions<A, E, R, R2, RF, EF>, -): Effect.Effect<A, E, R | R2 | RF | Output> { - const resolveFallback = options.resolveFallback.pipe( - Effect.orElseSucceed(() => Option.none<PgConnInput>()), - ); +): Effect.Effect< + A, + E | (EF & { readonly _tag: "LinkedDbNetworkBannedError" }), + R | R2 | RF | Output +> { + const resolveFallback = orNoPoolerFallback(options.resolveFallback); const retryOrReturn = (result: A) => Effect.gen(function* () { diff --git a/apps/cli/src/command-internal/pooler-fallback.unit.test.ts b/apps/cli/src/command-internal/pooler-fallback.unit.test.ts index b32a0f8444..08e43655ca 100644 --- a/apps/cli/src/command-internal/pooler-fallback.unit.test.ts +++ b/apps/cli/src/command-internal/pooler-fallback.unit.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "@effect/vitest"; import { Effect, Exit, Layer, Option } from "effect"; import { Output } from "../shared/output/output.service.ts"; +import { DbConfigPoolerLoginError, LinkedDbNetworkBannedError } from "./db-config.errors.ts"; import type { PgConnInput } from "./db-connection.service.ts"; import { runWithPoolerFallback } from "./pooler-fallback.ts"; @@ -175,4 +176,46 @@ describe("runWithPoolerFallback", () => { expect(out.stderrText).toContain("Retrying via the IPv4 connection pooler."); }).pipe(Effect.provide(out.layer)); }); + + it.live( + "surfaces an unapproved network-ban removal instead of keeping the original result", + () => { + const out = captureOutput(); + const banned = new LinkedDbNetworkBannedError({ + message: "Network bans (203.0.113.7) may be blocking the connection.", + suggestion: "Re-run with --yes to remove them.", + }); + + return Effect.gen(function* () { + const exit = yield* runWithPoolerFallback({ + run: Effect.succeed({ exitCode: 1, stderr: "network is unreachable" }), + retry: () => Effect.succeed({ exitCode: 0, stderr: "" }), + directHost: "db.abcdefghijklmnopqrst.supabase.co", + eligible: true, + resolveFallback: Effect.fail(banned), + classifyResult: (result: AttemptResult) => result.exitCode !== 0, + }).pipe(Effect.exit); + + expect(exit).toEqual(Exit.fail(banned)); + expect(out.stderrText).toBe(""); + }).pipe(Effect.provide(out.layer)); + }, + ); + + it.live("keeps the original result when the pooler cannot be resolved for another reason", () => { + const out = captureOutput(); + + return Effect.gen(function* () { + const result = yield* runWithPoolerFallback({ + run: Effect.succeed({ exitCode: 1, stderr: "network is unreachable" }), + retry: () => Effect.succeed({ exitCode: 0, stderr: "" }), + directHost: "db.abcdefghijklmnopqrst.supabase.co", + eligible: true, + resolveFallback: Effect.fail(new DbConfigPoolerLoginError({ message: "tenant mismatch" })), + classifyResult: (result: AttemptResult) => result.exitCode !== 0, + }); + + expect(result).toEqual({ exitCode: 1, stderr: "network is unreachable" }); + }).pipe(Effect.provide(out.layer)); + }); }); diff --git a/apps/cli/src/commands/bootstrap/bootstrap.handler.ts b/apps/cli/src/commands/bootstrap/bootstrap.handler.ts index 7402df29d7..bdf182a191 100644 --- a/apps/cli/src/commands/bootstrap/bootstrap.handler.ts +++ b/apps/cli/src/commands/bootstrap/bootstrap.handler.ts @@ -25,7 +25,10 @@ import type { ConnectSuggestionContext } from "../../command-internal/connect-er import { resolveLinkedConn } from "../../command-internal/db-config.layer.ts"; import { checkDbToml } from "../../command-internal/db-config.toml-read.ts"; import { dbPushCore } from "../../command-internal/db-push-core.ts"; -import { requireHostedMutationConsent } from "../../command-internal/hosted-consent/hosted-consent.ts"; +import { + hostedMutationPreapproval, + requireHostedMutationConsent, +} from "../../command-internal/hosted-consent/hosted-consent.ts"; import { planDbSeed } 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"; @@ -321,6 +324,7 @@ export const bootstrap = Effect.fn("bootstrap")(function* ( cliSettings.poolerHost, dnsResolver, Option.some(created.dbPassword), + { removeNetworkBans: Option.isSome(yield* hostedMutationPreapproval) }, ).pipe( Effect.catchTag("DbConfigIpv6Error", (error) => output.raw(`${error.message}\n`, "stderr").pipe(Effect.as(dbConfig)), diff --git a/apps/cli/src/commands/db/dump/dump.handler.ts b/apps/cli/src/commands/db/dump/dump.handler.ts index 34990b0f3f..d54154fbca 100644 --- a/apps/cli/src/commands/db/dump/dump.handler.ts +++ b/apps/cli/src/commands/db/dump/dump.handler.ts @@ -50,7 +50,7 @@ import { } from "../../../command-internal/stack-local-database.ts"; import { resolveBundledPostgresRuntime } from "../../../command-internal/bundled-postgres-client.ts"; import { supabaseEnvStringWithProjectFallback } from "../../../command-internal/supabase-env.ts"; -import { runWithPoolerFallback } from "../shared/pooler-fallback.ts"; +import { orNoPoolerFallback, runWithPoolerFallback } from "../shared/pooler-fallback.ts"; import { dumpDataScript, dumpRoleScript, @@ -401,10 +401,7 @@ export const dbDump = Effect.fn("db.dump")(function* (flags: DbDumpFlags) { host: conn.host, isLocal, projectHost: cliSettings.projectHost, - resolvePooler: () => - resolver - .resolvePoolerFallback(connectionInput) - .pipe(Effect.orElseSucceed(() => Option.none())), + resolvePooler: () => orNoPoolerFallback(resolver.resolvePoolerFallback(connectionInput)), runWithConn: (c) => runContainer(mode.buildEnv(c, opt)), reprintOnRetry: output.raw(`Dumping ${mode.verb} from ${db} database...\n`, "stderr"), }), diff --git a/apps/cli/src/commands/db/shared/pooler-fallback.ts b/apps/cli/src/commands/db/shared/pooler-fallback.ts index 7975454ce7..6af747caca 100644 --- a/apps/cli/src/commands/db/shared/pooler-fallback.ts +++ b/apps/cli/src/commands/db/shared/pooler-fallback.ts @@ -9,7 +9,10 @@ import { runWithPoolerFallback as runWithSharedPoolerFallback, } from "../../../command-internal/pooler-fallback.ts"; -export { emitPoolerFallbackWarning } from "../../../command-internal/pooler-fallback.ts"; +export { + emitPoolerFallbackWarning, + orNoPoolerFallback, +} from "../../../command-internal/pooler-fallback.ts"; /** The exit/stderr pair a dump attempt surfaces for pooler-fallback classification. */ interface PoolerFallbackResult { @@ -41,7 +44,11 @@ export const isDirectLinkedHost = (params: { * warning and retries once via `runWithConn`. Otherwise returns the original `result` * unchanged, so the caller's failure classification always reads the correct stderr. */ -export const runWithPoolerFallback = Effect.fn("PoolerFallback.run")(function* <E, RRun>(params: { +export const runWithPoolerFallback = Effect.fn("PoolerFallback.run")(function* < + E, + RRun, + EP, +>(params: { /** The first attempt's result; returned unchanged when no fallback fires. */ readonly result: PoolerFallbackResult; readonly connType: ResolvedTarget["kind"]; @@ -51,11 +58,11 @@ export const runWithPoolerFallback = Effect.fn("PoolerFallback.run")(function* < /** `cliSettings.projectHost` — the direct-DB-host suffix (`supabase.co`/`.red`). */ readonly projectHost: string; /** - * Resolves the IPv4 pooler connection, already error-neutralized to `None` (a resolution - * failure means "no fallback"). A thunk, since resolving the pooler creates a temp role — - * it only runs once the eligibility gate passes, never on the happy path. + * Resolves the IPv4 pooler connection; a failure means "no fallback" except an unapproved + * network-ban removal, which propagates. A thunk, since resolving the pooler creates a temp + * role — it only runs once the eligibility gate passes, never on the happy path. */ - readonly resolvePooler: () => Effect.Effect<Option.Option<PgConnInput>>; + readonly resolvePooler: () => Effect.Effect<Option.Option<PgConnInput>, EP>; /** Re-runs the dump against a connection (`db dump`/`db pull` each adapt their runner). */ readonly runWithConn: (conn: PgConnInput) => Effect.Effect<PoolerFallbackResult, E, RRun>; /** `db dump` re-prints "Dumping ..." on retry; `db pull` passes `Effect.void`. */ diff --git a/apps/cli/src/commands/inspect/db/SIDE_EFFECTS.md b/apps/cli/src/commands/inspect/db/SIDE_EFFECTS.md index 181d38b375..144e53fe5d 100644 --- a/apps/cli/src/commands/inspect/db/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/inspect/db/SIDE_EFFECTS.md @@ -31,13 +31,13 @@ no new config reads. ## API Routes (MAY fire on a linked target, inside the resolver) -| Method | Path | Auth | When | -| -------- | ------------------------------------------- | ------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `GET` | `/v1/projects` | Bearer token | linked target with no ref from `--project-ref`, `SUPABASE_PROJECT_ID` or `.temp/project-ref`, interactive terminal only (project picker) | -| `POST` | `/v1/projects/{ref}/cli/login-role` | Bearer token | linked target with no database password, to create a temp login role | -| `GET` | `/v1/projects/{ref}/config/database/pooler` | Bearer token | linked target from `--project-ref`, direct host unreachable, no usable saved pooler URL | -| `POST` | `/v1/projects/{ref}/network-bans/retrieve` | Bearer token | linked target, from the 3rd failed temp-role connect (list bans) | -| `DELETE` | `/v1/projects/{ref}/network-bans` | Bearer token | linked target, only when bans are listed and `--yes` or a shell `SUPABASE_YES` is set (clear a self-ban); otherwise the command fails with `LinkedDbNetworkBannedError` | +| Method | Path | Auth | When | +| -------- | ------------------------------------------- | ------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `GET` | `/v1/projects` | Bearer token | linked target with no ref from `--project-ref`, `SUPABASE_PROJECT_ID` or `.temp/project-ref`, interactive terminal only (project picker) | +| `POST` | `/v1/projects/{ref}/cli/login-role` | Bearer token | linked target with no database password, to create a temp login role | +| `GET` | `/v1/projects/{ref}/config/database/pooler` | Bearer token | linked target from `--project-ref`, direct host unreachable, no usable saved pooler URL | +| `POST` | `/v1/projects/{ref}/network-bans/retrieve` | Bearer token | linked target, from the 3rd failed temp-role connect (list bans) | +| `DELETE` | `/v1/projects/{ref}/network-bans` | Bearer token | linked target, only when bans are listed and `--yes` or a shell `SUPABASE_YES` is set (clear every listed address, announced on stderr as `Removing network bans (...)`); otherwise the connect keeps retrying and fails with `LinkedDbNetworkBannedError` once the retries run out while bans are still listed | ## Environment Variables diff --git a/apps/cli/src/commands/inspect/report/SIDE_EFFECTS.md b/apps/cli/src/commands/inspect/report/SIDE_EFFECTS.md index 337be721c6..864cbdab96 100644 --- a/apps/cli/src/commands/inspect/report/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/inspect/report/SIDE_EFFECTS.md @@ -62,7 +62,8 @@ wasn't refreshed this run. Queries run directly against Postgres (server-side `COPY (<query>) TO STDOUT WITH CSV HEADER`). The Management API is used lazily only on a linked target, to resolve the connection (via `DbConfigResolver`); a network ban is cleared there only under -`--yes` or a shell `SUPABASE_YES`, otherwise the command fails with `LinkedDbNetworkBannedError`. +`--yes` or a shell `SUPABASE_YES`, otherwise the command fails with `LinkedDbNetworkBannedError` once the +connect retries run out while bans are still listed. ## Environment Variables From e6b0c64d30fe441c91797a720177c4d87458fc46 Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Thu, 15 Oct 2026 16:58:04 +0100 Subject: [PATCH 79/92] fix(cli): consume a closing */ before the next block-comment delimiter can match A nested comment closed by */ directly followed by * re-used the slash as the start of /*, so the splitter stayed inside a comment PostgreSQL had already closed. /*/ also no longer closes the comment it opens. Adds an unterminated-input flag for callers that must refuse such input. --- apps/cli/src/command-internal/sql-split.ts | 69 ++++++++++++----- .../command-internal/sql-split.unit.test.ts | 77 ++++++++++++++++++- 2 files changed, 125 insertions(+), 21 deletions(-) diff --git a/apps/cli/src/command-internal/sql-split.ts b/apps/cli/src/command-internal/sql-split.ts index f1423b9bf4..fa2cc45463 100644 --- a/apps/cli/src/command-internal/sql-split.ts +++ b/apps/cli/src/command-internal/sql-split.ts @@ -10,6 +10,8 @@ interface State { /** Returns the next state, or `null` to emit a token (statement boundary). */ next(char: string, data: string): State | null; + /** `true` while inside a block comment, quoted string, quoted identifier or dollar quote. */ + readonly open?: boolean; } const BEGIN_ATOMIC = "ATOMIC"; @@ -50,6 +52,11 @@ function isBeginAtomic(data: string): boolean { return endsWithKeyword(data.slice(0, end), "BEGIN"); } +// A character that ends a delimiter can't start the next one: `/*/` opens a comment without +// closing it, and `*/*` closes one before a lone `*`. +const blockDelimiterDelta = (window: string): -1 | 0 | 1 => + window === "/*" ? 1 : window === "*/" ? -1 : 0; + // Index of the first character past leading blanks and comments, or `text.length` when only // trivia (or an unterminated comment) remains. function skipLeadingTrivia(text: string, isBlank: (char: string) => boolean): number { @@ -62,13 +69,13 @@ function skipLeadingTrivia(text: string, isBlank: (char: string) => boolean): nu if (newline === -1) return text.length; i += newline + 3; } else if (text.startsWith("/*", i)) { - // Match `BlockState`'s sliding-window scan so both agree on overlapping delimiters. let depth = 1; + let afterDelimiter = true; i += 2; while (i < text.length && depth > 0) { - const window = text.slice(i - 1, i + 1); - if (window === "/*") depth += 1; - else if (window === "*/") depth -= 1; + const delta: number = afterDelimiter ? 0 : blockDelimiterDelta(text.slice(i - 1, i + 1)); + afterDelimiter = delta !== 0; + depth += delta; i += 1; } if (depth > 0) return text.length; @@ -146,18 +153,16 @@ class LineCommentState implements State { class BlockState implements State { private depth = 0; + private afterDelimiter = false; + get open(): boolean { + return this.depth > 0; + } next(char: string, data: string): State | null { - const window = data.slice(-2); - if (window === "/*") { - this.depth += 1; - return this; - } - if (this.depth === 0) return new ReadyState().next(char, data); - if (window === "*/") { - this.depth -= 1; - if (this.depth === 0) return new ReadyState(); - } - return this; + const delta = this.afterDelimiter ? 0 : blockDelimiterDelta(data.slice(-2)); + this.afterDelimiter = delta !== 0; + if (this.depth === 0 && delta === 0) return new ReadyState().next(char, data); + this.depth += delta; + return this.depth === 0 ? new ReadyState() : this; } } @@ -168,6 +173,9 @@ class QuoteState implements State { private readonly delimiter: string, private readonly backslashEscapes: boolean, ) {} + get open(): boolean { + return !this.escape; + } next(char: string, data: string): State | null { if (this.escape) { // Preserve a doubled quote ('' or ""). @@ -221,6 +229,7 @@ class QuoteContinueState implements State { } class DollarState implements State { + readonly open = true; constructor(private readonly delimiter: string) {} next(_char: string, data: string): State | null { if (data.slice(-this.delimiter.length) === this.delimiter) return new ReadyState(); @@ -245,6 +254,9 @@ class EscapeState implements State { class ParenState implements State { constructor(private prev: State) {} + get open(): boolean { + return this.prev.open === true; + } next(char: string, data: string): State | null { const curr = this.prev.next(char, data); if (curr === null) { @@ -267,6 +279,9 @@ class AtomicState implements State { ) { this.statementStart = start; } + get open(): boolean { + return this.prev.open === true; + } next(char: string, data: string): State | null { const pendingEnd = this.pendingEnd; this.pendingEnd = false; @@ -306,8 +321,14 @@ interface RawToken { readonly terminated: boolean; } +interface RawSplit { + readonly tokens: RawToken[]; + /** The input ends inside a block comment, quoted string, quoted identifier or dollar quote. */ + readonly unterminated: boolean; +} + /** The FSM traversal shared by every `splitSql*` entry point below. */ -function splitRaw(sql: string): RawToken[] { +function splitRaw(sql: string): RawSplit { let state: State = new ReadyState(); const tokens: RawToken[] = []; // Slice each token from `sql` instead of growing it with `+=`: states read the token's tail every @@ -329,7 +350,7 @@ function splitRaw(sql: string): RawToken[] { } // Trailing non-terminated statement at EOF. if (end > start) tokens.push({ text: sql.slice(start), terminated: false }); - return tokens; + return { tokens, unterminated: state.open === true }; } /** @@ -341,7 +362,7 @@ export function splitSql( ...transform: ReadonlyArray<(s: string) => string> ): string[] { const statements: string[] = []; - for (const { text: raw } of splitRaw(sql)) { + for (const { text: raw } of splitRaw(sql).tokens) { let token = raw; for (const apply of transform) token = apply(token); if (token.length > 0) statements.push(token); @@ -357,6 +378,16 @@ export function splitAndTrim(sql: string): string[] { return splitSql(sql, trimStatement); } +/** `splitAndTrim`, plus whether `sql` ends inside a block comment, quote or dollar quote. */ +export function splitAndTrimChecked(sql: string): { + readonly statements: string[]; + readonly unterminated: boolean; +} { + const { tokens, unterminated } = splitRaw(sql); + const statements = tokens.map(({ text }) => trimStatement(text)).filter((s) => s.length > 0); + return { statements, unterminated }; +} + /** One statement, paired with both its raw and trimmed forms. */ export interface SplitSqlToken { /** The exact text `splitSql(sql)` (no transforms) would emit for this statement. */ @@ -381,7 +412,7 @@ export interface SplitSqlToken { * empty, so callers counting only non-empty trimmed statements still see every raw token. */ export function splitSqlTokens(sql: string): ReadonlyArray<SplitSqlToken> { - return splitRaw(sql).map(({ text: raw, terminated }) => ({ + return splitRaw(sql).tokens.map(({ text: raw, terminated }) => ({ raw, trimmed: trimStatement(raw), terminated, diff --git a/apps/cli/src/command-internal/sql-split.unit.test.ts b/apps/cli/src/command-internal/sql-split.unit.test.ts index 4739dff325..a3437b2b2a 100644 --- a/apps/cli/src/command-internal/sql-split.unit.test.ts +++ b/apps/cli/src/command-internal/sql-split.unit.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest"; import { findDropStatements, splitAndTrim, + splitAndTrimChecked, splitSql, splitSqlTokens, trimLeadingSqlComments, @@ -148,6 +149,35 @@ describe("splitAndTrim", () => { ]); }); + it.each([ + ["a closing */ directly followed by *", "/* /* */* x */ COMMIT; DELETE FROM t; SELECT 1"], + ["a closing */ directly followed by /*", "/* /* *//* */ */ COMMIT; DELETE FROM t; SELECT 1"], + ["a doubled star before the closing slash", "/* /* **/ */ COMMIT; DELETE FROM t; SELECT 1"], + ])("closes a nested block comment with %s", (_name, sql) => { + const statements = splitAndTrim(sql); + expect(statements).toHaveLength(3); + expect(statements.slice(1)).toEqual(["DELETE FROM t", "SELECT 1"]); + expect(statements[0]!.endsWith("COMMIT")).toBe(true); + }); + + it("does not let /*/ close the comment it opens", () => { + expect(splitAndTrim("SELECT 1 /*/ ; */ ; SELECT 2")).toEqual(["SELECT 1 /*/ ; */", "SELECT 2"]); + }); + + it("reads /*/ inside a comment as a nested opener only", () => { + expect(splitAndTrim("/* a /*/ b */ ; */ SELECT 1; SELECT 2")).toEqual([ + "/* a /*/ b */ ; */ SELECT 1", + "SELECT 2", + ]); + }); + + it("closes a comment written as /**/ and /***/", () => { + expect(splitAndTrim("SELECT /**/ 1; SELECT /***/ 2")).toEqual([ + "SELECT /**/ 1", + "SELECT /***/ 2", + ]); + }); + it("does not split inside a BEGIN ATOMIC body", () => { const sql = "CREATE FUNCTION f() RETURNS int LANGUAGE sql BEGIN ATOMIC SELECT 1; SELECT 2; END; SELECT 3;"; @@ -253,9 +283,9 @@ describe("splitAndTrim", () => { ]); }); - it("does not close a BEGIN ATOMIC body at an END after an overlapping block comment", () => { + it("does not close a BEGIN ATOMIC body at an END after a block comment nesting /*/", () => { const body = - "CREATE FUNCTION f() RETURNS int LANGUAGE sql BEGIN ATOMIC SELECT 1; /* a /*/ b */ SELECT 2 END; SELECT 3; END"; + "CREATE FUNCTION f() RETURNS int LANGUAGE sql BEGIN ATOMIC SELECT 1; /* a /*/ b */ */ SELECT 2 END; SELECT 3; END"; expect(splitAndTrim(`${body}; SELECT 4;`)).toEqual([body, "SELECT 4"]); }); @@ -268,6 +298,44 @@ describe("splitAndTrim", () => { }); }); +describe("splitAndTrimChecked", () => { + it("returns the statements splitAndTrim returns", () => { + const sql = "SELECT 1; /* a */ SELECT 'b;';\nSELECT $$c; d$$"; + expect(splitAndTrimChecked(sql).statements).toEqual(splitAndTrim(sql)); + }); + + it.each([ + ["an open block comment", "SELECT 1 /* a"], + ["an open nested block comment", "SELECT 1 /* a /* b */"], + ["an open string", "SELECT 'a"], + ["a string ending in a doubled quote", "SELECT 'a''"], + ["an open quoted identifier", 'SELECT "a'], + ["an open escape string", String.raw`SELECT E'a\'`], + ["an open dollar quote", "SELECT $$a"], + ["an open tagged dollar quote", "SELECT $t$a$$"], + ["an open string inside parentheses", "SELECT ('a"], + ["an open comment after a statement", "SELECT 1; /*"], + ])("flags %s as unterminated", (_name, sql) => { + expect(splitAndTrimChecked(sql).unterminated).toBe(true); + }); + + it.each([ + ["a plain statement", "SELECT 1"], + ["a trailing line comment", "SELECT 1 -- a"], + ["a closed block comment", "SELECT 1 /* a */"], + ["a closed nested block comment", "SELECT 1 /* a /* b */ */"], + ["a closed string", "SELECT 'a'"], + ["a string with a doubled quote", "SELECT 'a''b'"], + ["a closed quoted identifier", 'SELECT "a"'], + ["a closed escape string", String.raw`SELECT E'a\''`], + ["a closed dollar quote", "SELECT $$a;b$$"], + ["a closed string inside parentheses", "SELECT ('a')"], + ["blank input", " \n"], + ])("does not flag %s", (_name, sql) => { + expect(splitAndTrimChecked(sql).unterminated).toBe(false); + }); +}); + describe("splitSql", () => { it("preserves raw statements (no transforms) including the trailing ;-less token", () => { expect(splitSql("SELECT 1; SELECT 2")).toEqual(["SELECT 1;", " SELECT 2"]); @@ -295,6 +363,9 @@ describe("trimLeadingSqlComments", () => { ["a carriage-return line comment", "-- a\rSELECT 1"], ["a block comment", "/* a */SELECT 1"], ["a nested block comment", "/* a /* b */ c */ SELECT 1"], + ["a nested comment closed by */ then *", "/* /* */* x */ SELECT 1"], + ["a nested comment closed by */ then /*", "/* /* *//* */ */ SELECT 1"], + ["a comment nesting /*/", "/* a /*/ b */ */ SELECT 1"], ["stacked comments and blanks", "\uFEFF-- a\n /* b */ \n-- c\nSELECT 1"], ])("strips %s", (_name, sql) => { expect(trimLeadingSqlComments(sql)).toBe("SELECT 1"); @@ -307,6 +378,8 @@ describe("trimLeadingSqlComments", () => { it.each([ ["a line comment without a newline", "-- a"], ["an unterminated block comment", "/* a SELECT 1"], + ["a comment opened by /*/ and closed later", "/*/ SELECT 1 */"], + ["a nested comment closed only once", "/* /* */* x SELECT 1"], ["only whitespace", " \n"], ])("returns empty for %s", (_name, sql) => { expect(trimLeadingSqlComments(sql)).toBe(""); From 16acb92dc3c475a62d9c1d8c134d8a0403e9aaca Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Thu, 15 Oct 2026 16:58:05 +0100 Subject: [PATCH 80/92] fix(cli): restrict db query --read-only to read statements and cap lock waits --read-only now accepts only statements that start with SELECT, WITH, VALUES, TABLE, SHOW or EXPLAIN, refuses input that ends inside a comment, quote or dollar quote or contains BEGIN ATOMIC, and sets a 5s lock_timeout inside the read-only transaction on direct connections. Docs and SIDE_EFFECTS state what the flag does and does not guarantee. --- apps/cli/docs/supabase/db/query.md | 6 +- .../cli/src/commands/db/query/SIDE_EFFECTS.md | 43 +++-- .../cli/src/commands/db/query/query.errors.ts | 2 +- .../src/commands/db/query/query.handler.ts | 17 +- .../db/query/query.integration.test.ts | 57 +++++- .../src/commands/db/query/query.read-only.ts | 136 ++++++++------ .../db/query/query.read-only.unit.test.ts | 172 +++++++++++++----- 7 files changed, 303 insertions(+), 130 deletions(-) diff --git a/apps/cli/docs/supabase/db/query.md b/apps/cli/docs/supabase/db/query.md index 0759a2964b..07b7ad904a 100644 --- a/apps/cli/docs/supabase/db/query.md +++ b/apps/cli/docs/supabase/db/query.md @@ -6,7 +6,11 @@ With no target flag the command runs against the workspace target, which is the The command cannot tell a read from a write, so a hosted database (the linked project or a remote `--db-url`) asks for one confirmation (default No) before the statement is sent. Piped answers are never read, so a script, a JSON run or an agent must pass `--yes` or set `SUPABASE_YES` in the shell; a `SUPABASE_YES` in `supabase/.env` does not count. The local database never asks. -Pass `--read-only` to run a statement that must not change anything. It skips the confirmation on a hosted database and makes the statement unable to write: a linked project runs it as a read-only query through the Management API, and the local database or a `--db-url` database runs it in a read-only transaction that is always rolled back. `--read-only` rejects, before the statement is sent or any question is asked, any statement that begins or ends a transaction (`BEGIN`, `START TRANSACTION`, `COMMIT`, `END`, `ABORT`, `ROLLBACK`, `PREPARE TRANSACTION`) or changes read-only mode (`SET TRANSACTION`, `SET SESSION CHARACTERISTICS`, `SET` or `RESET` of `transaction_read_only` or `default_transaction_read_only`, `RESET ALL`). Drop `--read-only` to run one of these. +Pass `--read-only` to run a statement that must not change anything. It skips the confirmation on a hosted database. Before the statement is sent or any question is asked, every statement must start with `SELECT`, `WITH`, `VALUES`, `TABLE`, `SHOW` or `EXPLAIN` (after whitespace and comments). Any other statement (`COMMIT`, `SET`, `CALL`, `DO`, `COPY`, `LOCK` and so on), input that ends inside a comment, quote or dollar quote, and input containing `BEGIN ATOMIC` is rejected. Drop `--read-only` to run one of these. + +The local database or a `--db-url` database then runs the statement in a read-only transaction that is always rolled back, with a 5 second `lock_timeout` so the read cannot wait on heavy locks; Postgres rejects writes inside it. A linked project runs it through the Management API's `read_only` mode, and the API enforces it. + +`--read-only` does not stop a read statement from calling a function that has side effects, such as `pg_terminate_backend` or `dblink_exec`; those still run. When used by an AI coding agent (auto-detected or via --agent=yes), the default output format is JSON with an untrusted data warning envelope. When used by a diff --git a/apps/cli/src/commands/db/query/SIDE_EFFECTS.md b/apps/cli/src/commands/db/query/SIDE_EFFECTS.md index 689b9641ca..e712b6aeb7 100644 --- a/apps/cli/src/commands/db/query/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/db/query/SIDE_EFFECTS.md @@ -11,7 +11,8 @@ in `config.toml`, default `local`; see Any statement sent to a hosted target (linked project or remote `--db-url`) asks for one confirmation first, because the command cannot tell a read from a write. `--read-only` skips that -confirmation and makes the statement unable to write (see Notes). The local stack never prompts. +confirmation and accepts only read statements in a read-only transaction (see Notes). The local +stack never prompts. ## Files Read @@ -48,14 +49,14 @@ confirmation and makes the statement unable to write (see Notes). The local stac ## Exit Codes -| Code | Condition | -| ---- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `0` | success | -| `1` | more than one of `--db-url`/`--linked`/`--local` (`TargetSelectorConflictError`); no SQL provided; empty stdin; unreadable `--file`; linked target without login; query exec failure; non-201 linked status | -| `1` | `--project-ref` with `--local` or `--db-url` (`TargetProjectRefConflictError`) | -| `1` | hosted target without `--read-only` in a JSON, piped, agent or non-TTY run without `--yes` or a shell `SUPABASE_YES` (`HostedMutationConsentRequiredError`); nothing is sent | -| `1` | `--read-only` with transaction control or a read-only setting change (`DbQueryReadOnlyStatementError`); nothing is sent | -| `1` | hosted target confirmation declined (`HostedMutationDeclinedError`, `context canceled`); nothing is sent | +| Code | Condition | +| ---- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `0` | success | +| `1` | more than one of `--db-url`/`--linked`/`--local` (`TargetSelectorConflictError`); no SQL provided; empty stdin; unreadable `--file`; linked target without login; query exec failure; non-201 linked status | +| `1` | `--project-ref` with `--local` or `--db-url` (`TargetProjectRefConflictError`) | +| `1` | hosted target without `--read-only` in a JSON, piped, agent or non-TTY run without `--yes` or a shell `SUPABASE_YES` (`HostedMutationConsentRequiredError`); nothing is sent | +| `1` | `--read-only` with a statement that does not start with `SELECT`, `WITH`, `VALUES`, `TABLE`, `SHOW` or `EXPLAIN`, or with input that ends inside a comment, quote or dollar quote (`DbQueryReadOnlyStatementError`); nothing is sent | +| `1` | hosted target confirmation declined (`HostedMutationDeclinedError`, `context canceled`); nothing is sent | ## Output @@ -107,16 +108,20 @@ from the environment. Agent mode defaults the format to JSON (table for humans). (`Confirmed by --yes: …`); a `SUPABASE_YES` in `supabase/.env` and a piped answer do not. Stdin is never read for the answer. Reading a missing SQL source (no SQL, empty stdin, unreadable `--file`) fails before the question. -- **`--read-only`.** Skips the hosted confirmation entirely and prints no question. A linked - target sends `"read_only": true` in the Management API request, so the server rejects writes. - The local stack and a `--db-url` database run the statement inside `BEGIN READ ONLY` … `ROLLBACK`, - so Postgres rejects writes. On every target, before the statement is sent or any question is asked, the - SQL is split into statements and any whose leading keywords (after whitespace, a BOM and comments) - are `BEGIN`, `START TRANSACTION`, `COMMIT`, `END`, `ABORT`, `ROLLBACK`, `PREPARE TRANSACTION`, - `SET TRANSACTION`, `SET SESSION CHARACTERISTICS`, `RESET ALL`, or a `SET`/`SET SESSION`/`SET LOCAL`/ - `RESET` of `transaction_read_only` or `default_transaction_read_only` fails with - `DbQueryReadOnlyStatementError` naming only that keyword. Keywords inside strings, dollar quotes - and comments, and reads such as `SELECT set_config(...)`, pass. +- **`--read-only`.** Skips the hosted confirmation entirely and prints no question. On every + target, before the statement is sent or any question is asked, the SQL is split into statements + and each must start (after whitespace, a BOM and comments) with `SELECT`, `WITH`, `VALUES`, + `TABLE`, `SHOW` or `EXPLAIN`. Any other statement (`COMMIT`, `SET`, `CALL`, `DO`, `COPY`, `LOCK`, + …), input that ends inside a comment, quote or dollar quote, and input containing + `BEGIN ATOMIC` (the splitter reads such a body as one statement) fails with + `DbQueryReadOnlyStatementError`, naming only the leading keyword or a fixed label. Keywords + inside strings, dollar quotes and comments, and reads such as `SELECT set_config(...)`, pass the + keyword check. A linked target sends `"read_only": true` in the Management API request, and the + API enforces it. The local stack and a `--db-url` database run the statement inside + `BEGIN READ ONLY`, `SET LOCAL lock_timeout = '5s'`, the statement, then `ROLLBACK`, so Postgres + rejects writes and the read cannot queue behind heavy locks (there is no `statement_timeout`). + What it does not cover: a function with side effects called from a read statement, such as + `pg_terminate_backend` or `dblink_exec`, still runs. - **Linked-project cache.** On the `--linked` path, after the query runs — whether it succeeds or fails — the handler issues `GET /v1/projects/{ref}` and writes `supabase/.temp/linked-project.json`. The diff --git a/apps/cli/src/commands/db/query/query.errors.ts b/apps/cli/src/commands/db/query/query.errors.ts index cbc87d4cc2..5114e620cb 100644 --- a/apps/cli/src/commands/db/query/query.errors.ts +++ b/apps/cli/src/commands/db/query/query.errors.ts @@ -50,7 +50,7 @@ export class DbQueryLoginRequiredError extends Data.TaggedError("DbQueryLoginReq } } -/** `--read-only` was combined with SQL that ends the transaction or changes its read-only mode. */ +/** `--read-only` was combined with SQL that is not a plain read statement. */ export class DbQueryReadOnlyStatementError extends Data.TaggedError( "DbQueryReadOnlyStatementError", )<{ diff --git a/apps/cli/src/commands/db/query/query.handler.ts b/apps/cli/src/commands/db/query/query.handler.ts index 8214379912..862e4f9c4d 100644 --- a/apps/cli/src/commands/db/query/query.handler.ts +++ b/apps/cli/src/commands/db/query/query.handler.ts @@ -31,7 +31,7 @@ import { Stdin } from "../../../shared/runtime/stdin.service.ts"; import { AiTool } from "../../../shared/telemetry/ai-tool.service.ts"; import type { DbQueryFlags } from "./query.command.ts"; import { RLS_CHECK_SQL, buildRlsAdvisory } from "./query.advisory.ts"; -import { findReadOnlyViolation } from "./query.read-only.ts"; +import { findReadOnlyViolation, READ_ONLY_STATEMENT_KEYWORDS } from "./query.read-only.ts"; import { DbQueryExecError, DbQueryLoginRequiredError, @@ -80,12 +80,17 @@ const isRowObject = (element: unknown): element is { readonly [key: string]: unk const toExecError = (cause: { readonly message: string }) => new DbQueryExecError({ message: cause.message }); +const READ_ONLY_LOCK_TIMEOUT = "5s"; + // `queryRaw` runs every statement on one dedicated connection, so BEGIN/ROLLBACK issued through -// it bracket the user's statement. +// it bracket the user's statement. The lock timeout keeps a read from queuing behind heavy locks. const inReadOnlyTransaction = <A, E, R>(session: DbSession, body: Effect.Effect<A, E, R>) => Effect.acquireUseRelease( session.queryRaw("BEGIN READ ONLY").pipe(Effect.mapError(toExecError)), - () => body, + () => + session + .queryRaw(`SET LOCAL lock_timeout = '${READ_ONLY_LOCK_TIMEOUT}'`) + .pipe(Effect.mapError(toExecError), Effect.andThen(body)), () => session.queryRaw("ROLLBACK").pipe(Effect.ignore), ); @@ -354,13 +359,13 @@ export const dbQuery = Effect.fn("db.query")(function* (flags: DbQueryFlags) { "query.read_only": flags.readOnly, }); - // `--read-only` skips consent only because the statement cannot write, so SQL that ends the - // transaction or flips its read-only mode is refused before anything connects or prompts. + // `--read-only` skips consent only because the statement cannot write, so anything but a read + // statement is refused before anything connects or prompts. if (flags.readOnly) { const violation = findReadOnlyViolation(sql); if (violation !== undefined) { return yield* new DbQueryReadOnlyStatementError({ - message: `--read-only does not allow transaction control or read-only setting changes (found: ${violation})`, + message: `--read-only only allows ${READ_ONLY_STATEMENT_KEYWORDS.join(", ")} statements (found: ${violation})`, suggestion: "Drop --read-only to run this statement; a hosted database then asks you to confirm the write.", }); 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 e8af8ea7a6..bdb383c6a7 100644 --- a/apps/cli/src/commands/db/query/query.integration.test.ts +++ b/apps/cli/src/commands/db/query/query.integration.test.ts @@ -118,7 +118,8 @@ function mockResolver(isLocal: boolean | undefined, resolveFails = false) { }); } -const WRITE_STATEMENT = /^\s*(insert|update|delete|create|drop|alter)\b/iu; +const WRITE_STATEMENT = /\b(insert|update|delete|create|drop|alter)\b/iu; +const READ_ONLY_LOCK_TIMEOUT_SQL = "SET LOCAL lock_timeout = '5s'"; function mockDbConnection(opts: { result?: QueryResult; @@ -1139,21 +1140,28 @@ describe("db query integration", () => { const { layer, out, dbConnection } = setup({ result: SELECT_RESULT }); return Effect.gen(function* () { yield* dbQuery(flags({ sql: Option.some("select 1"), local: true, readOnly: true })); - expect(dbConnection.statements).toEqual(["BEGIN READ ONLY", "select 1", "ROLLBACK"]); + expect(dbConnection.statements).toEqual([ + "BEGIN READ ONLY", + READ_ONLY_LOCK_TIMEOUT_SQL, + "select 1", + "ROLLBACK", + ]); expect(out.stdoutText).toContain("alice"); }).pipe(Effect.provide(layer)); }); - it.effect("fails a write and still rolls the transaction back", () => { + it.effect("fails a write inside a read statement and still rolls the transaction back", () => { + const sql = "with d as (delete from t returning *) select * from d"; const { layer, dbConnection } = setup(); return Effect.gen(function* () { const exit = yield* dbQuery( - flags({ sql: Option.some("insert into t values (1)"), local: true, readOnly: true }), + flags({ sql: Option.some(sql), local: true, readOnly: true }), ).pipe(Effect.exit); expect(failMessage(exit)).toContain("read-only transaction"); expect(dbConnection.statements).toEqual([ "BEGIN READ ONLY", - "insert into t values (1)", + READ_ONLY_LOCK_TIMEOUT_SQL, + sql, "ROLLBACK", ]); }).pipe(Effect.provide(layer)); @@ -1181,7 +1189,12 @@ describe("db query integration", () => { readOnly: true, }), ); - expect(dbConnection.statements).toEqual(["BEGIN READ ONLY", "select 1", "ROLLBACK"]); + expect(dbConnection.statements).toEqual([ + "BEGIN READ ONLY", + READ_ONLY_LOCK_TIMEOUT_SQL, + "select 1", + "ROLLBACK", + ]); }).pipe(Effect.provide(layer)); }); @@ -1223,7 +1236,7 @@ describe("db query integration", () => { DbQueryReadOnlyStatementError, ); expect(failMessage(exit)).toBe( - "--read-only does not allow transaction control or read-only setting changes (found: COMMIT)", + "--read-only only allows SELECT, WITH, VALUES, TABLE, SHOW, EXPLAIN statements (found: COMMIT)", ); expect(rejected.dbConnection.connects).toBe(0); expect(rejected.dbConnection.statements).toEqual([]); @@ -1240,6 +1253,36 @@ describe("db query integration", () => { ); } + const refusedStatements = [ + ["a statement that is not a read", "CLUSTER users", "CLUSTER"], + ["a role switch", "SET ROLE postgres", "SET"], + [ + "a nested comment that hides a commit", + "/* /* */* x */ COMMIT; DELETE FROM users; /* */", + "COMMIT", + ], + [ + "an unterminated comment", + "select 1 /* COMMIT", + "an unterminated comment, quote or dollar quote", + ], + ] as const; + for (const [name, sql, found] of refusedStatements) { + it.effect(`refuses ${name} on a local target without connecting`, () => { + const { layer, dbConnection } = setup({ result: SELECT_RESULT }); + return Effect.gen(function* () { + const exit = yield* dbQuery( + flags({ sql: Option.some(sql), local: true, readOnly: true }), + ).pipe(Effect.exit); + expect(Exit.isFailure(exit) && Cause.squash(exit.cause)).toBeInstanceOf( + DbQueryReadOnlyStatementError, + ); + expect(failMessage(exit)).toContain(`(found: ${found})`); + expect(dbConnection.connects).toBe(0); + }).pipe(Effect.provide(layer)); + }); + } + it.effect("omits read_only from the Management API request by default", () => { const { layer, platformApi } = setup({ linkedStatus: 201, linkedBody: '[{"id":1}]' }); return Effect.gen(function* () { diff --git a/apps/cli/src/commands/db/query/query.read-only.ts b/apps/cli/src/commands/db/query/query.read-only.ts index 979dd935c1..f56663fa5b 100644 --- a/apps/cli/src/commands/db/query/query.read-only.ts +++ b/apps/cli/src/commands/db/query/query.read-only.ts @@ -1,67 +1,101 @@ -import { splitAndTrim, trimLeadingSqlComments } from "../../../command-internal/sql-split.ts"; +import { + splitAndTrimChecked, + trimLeadingSqlComments, +} from "../../../command-internal/sql-split.ts"; -const TRANSACTION_CONTROL = new Set(["BEGIN", "COMMIT", "END", "ABORT", "ROLLBACK"]); -const READ_ONLY_SETTINGS = new Set(["default_transaction_read_only", "transaction_read_only"]); +export const READ_ONLY_STATEMENT_KEYWORDS = [ + "SELECT", + "WITH", + "VALUES", + "TABLE", + "SHOW", + "EXPLAIN", +] as const; -// A quoted identifier (optionally `U&"…"`) or an unquoted one; PostgreSQL treats every code point -// at or above 0x80 as an identifier character. -const IDENTIFIER = - /^(?:[uU]&)?"(?:[^"]|"")*"|^[A-Za-z_\u{80}-\u{10FFFF}][A-Za-z0-9_$\u{80}-\u{10FFFF}]*/u; +const READ_STATEMENTS: ReadonlySet<string> = new Set(READ_ONLY_STATEMENT_KEYWORDS); -interface Word { - readonly text: string; - readonly rest: string; -} +// Reported violations use these fixed labels instead of echoing the statement. +const REFUSED_STATEMENTS: ReadonlySet<string> = new Set([ + "ABORT", + "ALTER", + "ANALYZE", + "BEGIN", + "CALL", + "CHECKPOINT", + "CLOSE", + "CLUSTER", + "COMMENT", + "COMMIT", + "COPY", + "CREATE", + "DEALLOCATE", + "DECLARE", + "DELETE", + "DISCARD", + "DO", + "DROP", + "END", + "EXECUTE", + "FETCH", + "GRANT", + "IMPORT", + "INSERT", + "LISTEN", + "LOAD", + "LOCK", + "MERGE", + "MOVE", + "NOTIFY", + "PREPARE", + "REASSIGN", + "REFRESH", + "REINDEX", + "RELEASE", + "RESET", + "REVOKE", + "ROLLBACK", + "SAVEPOINT", + "SECURITY", + "SET", + "START", + "TRUNCATE", + "UNLISTEN", + "UPDATE", + "VACUUM", +]); -const nextWord = (sql: string): Word | undefined => { - const trimmed = trimLeadingSqlComments(sql); - const text = IDENTIFIER.exec(trimmed)?.[0]; - return text === undefined ? undefined : { text, rest: trimmed.slice(text.length) }; -}; +const UNRECOGNISED_STATEMENT = "an unrecognised statement"; +const UNTERMINATED_INPUT = "an unterminated comment, quote or dollar quote"; -// `U&"…"` names can't be decoded cheaply, so every one counts as a possible read-only setting. -const readOnlySettingLabel = (word: Word): string | undefined => { - if (/^u&/iu.test(word.text)) return 'U&"..."'; - const name = word.text.replace(/^"|"$/gu, "").toLowerCase(); - return READ_ONLY_SETTINGS.has(name) ? name : undefined; -}; +// PostgreSQL treats every code point at or above 0x80 as an identifier character. +const LEADING_WORD = /^[A-Za-z0-9_$\u{80}-\u{10FFFF}]+/u; -const statementViolation = (statement: string): string | undefined => { - const first = nextWord(statement); - if (first === undefined) return undefined; - const keyword = first.text.toUpperCase(); - if (TRANSACTION_CONTROL.has(keyword)) return keyword; - if (keyword === "START" || keyword === "PREPARE") { - return nextWord(first.rest)?.text.toUpperCase() === "TRANSACTION" - ? `${keyword} TRANSACTION` - : undefined; - } - if (keyword !== "SET" && keyword !== "RESET") return undefined; +// The splitter reads a `BEGIN ATOMIC` body as one statement, so `WITH begin AS (SELECT 1) SELECT * +// FROM begin atomic; COMMIT; DELETE FROM t; END` hides its second and third statements from it. +const BEGIN_ATOMIC = /begin\s+atomic/iu; - let subject = nextWord(first.rest); - const scope = subject?.text.toUpperCase(); - if (subject !== undefined && keyword === "SET" && (scope === "SESSION" || scope === "LOCAL")) { - subject = nextWord(subject.rest); - if (scope === "SESSION" && subject?.text.toUpperCase() === "CHARACTERISTICS") { - return "SET SESSION CHARACTERISTICS"; - } - } - if (subject === undefined) return undefined; - const subjectKeyword = subject.text.toUpperCase(); - if (keyword === "SET" && subjectKeyword === "TRANSACTION") return "SET TRANSACTION"; - if (keyword === "RESET" && subjectKeyword === "ALL") return "RESET ALL"; - const setting = readOnlySettingLabel(subject); - return setting === undefined ? undefined : `${keyword} ${setting}`; +const statementViolation = (statement: string): string | undefined => { + const text = trimLeadingSqlComments(statement); + if (text === "") return undefined; + const word = LEADING_WORD.exec(text)?.[0]; + // ASCII only: `ſelect` upper-cases to `SELECT` in JS but is a plain identifier to PostgreSQL. + const keyword = word !== undefined && /^[A-Za-z]+$/u.test(word) ? word.toUpperCase() : undefined; + if (keyword === undefined) return UNRECOGNISED_STATEMENT; + if (READ_STATEMENTS.has(keyword)) return undefined; + return REFUSED_STATEMENTS.has(keyword) ? keyword : UNRECOGNISED_STATEMENT; }; /** - * The first statement `--read-only` refuses, named by a fixed label (never the SQL): transaction - * control, or a change to the transaction's or session's read-only characteristics. + * The first reason `--read-only` refuses `sql`, as a fixed label (never the SQL): a statement + * that does not start with one of {@link READ_ONLY_STATEMENT_KEYWORDS}, or input the splitter + * cannot read to the end. */ export const findReadOnlyViolation = (sql: string): string | undefined => { - for (const statement of splitAndTrim(sql)) { + const { statements, unterminated } = splitAndTrimChecked(sql); + for (const statement of statements) { const violation = statementViolation(statement); if (violation !== undefined) return violation; } - return undefined; + if (BEGIN_ATOMIC.test(sql)) return "BEGIN ATOMIC"; + return unterminated ? UNTERMINATED_INPUT : undefined; }; diff --git a/apps/cli/src/commands/db/query/query.read-only.unit.test.ts b/apps/cli/src/commands/db/query/query.read-only.unit.test.ts index e7dcdcd540..e41a6d8d4f 100644 --- a/apps/cli/src/commands/db/query/query.read-only.unit.test.ts +++ b/apps/cli/src/commands/db/query/query.read-only.unit.test.ts @@ -2,42 +2,86 @@ import { describe, expect, it } from "vitest"; import { findReadOnlyViolation } from "./query.read-only.ts"; +const UNRECOGNISED = "an unrecognised statement"; +const UNTERMINATED = "an unterminated comment, quote or dollar quote"; + describe("findReadOnlyViolation", () => { it.each([ ["BEGIN", "BEGIN"], ["begin read write", "BEGIN"], - ["START TRANSACTION ISOLATION LEVEL SERIALIZABLE", "START TRANSACTION"], - ["start\n\ttransaction", "START TRANSACTION"], + ["START TRANSACTION ISOLATION LEVEL SERIALIZABLE", "START"], ["COMMIT", "COMMIT"], ["commit and chain", "COMMIT"], ["COMMIT PREPARED 'tx'", "COMMIT"], ["END", "END"], ["ABORT", "ABORT"], - ["ROLLBACK", "ROLLBACK"], ["ROLLBACK TO SAVEPOINT s", "ROLLBACK"], - ["ROLLBACK PREPARED 'tx'", "ROLLBACK"], - ["PREPARE TRANSACTION 'tx'", "PREPARE TRANSACTION"], - ])("rejects transaction control: %s", (sql, found) => { - expect(findReadOnlyViolation(sql)).toBe(found); + ["PREPARE TRANSACTION 'tx'", "PREPARE"], + ["SAVEPOINT s", "SAVEPOINT"], + ["RELEASE SAVEPOINT s", "RELEASE"], + ])("rejects transaction control: %s", (sql, label) => { + expect(findReadOnlyViolation(sql)).toBe(label); + }); + + it.each([ + "SET TRANSACTION READ WRITE", + "SET SESSION CHARACTERISTICS AS TRANSACTION READ WRITE", + "SET default_transaction_read_only = off", + "SET LOCAL transaction_read_only TO off", + 'SET U&"transaction\\005fread\\005fonly" = off', + "SET ROLE postgres", + "SET SESSION AUTHORIZATION postgres", + "SET search_path TO public", + "SET LOCAL statement_timeout = '5s'", + "SET LOCAL lock_timeout = 0", + ])("rejects SET: %s", (sql) => { + expect(findReadOnlyViolation(sql)).toBe("SET"); + }); + + it.each([ + ["RESET ALL", "RESET"], + ["RESET transaction_read_only", "RESET"], + ["CLUSTER t", "CLUSTER"], + ["REINDEX TABLE t", "REINDEX"], + ["LOCK TABLE t IN ACCESS EXCLUSIVE MODE", "LOCK"], + ["ANALYZE t", "ANALYZE"], + ["VACUUM t", "VACUUM"], + ["NOTIFY chan", "NOTIFY"], + ["LISTEN chan", "LISTEN"], + ["COPY t TO STDOUT", "COPY"], + ["CALL p()", "CALL"], + ["DO $$ BEGIN PERFORM 1; END $$", "DO"], + ["INSERT INTO t VALUES (1)", "INSERT"], + ["update t set a = 1", "UPDATE"], + ["DELETE FROM t", "DELETE"], + ["MERGE INTO t USING s ON true WHEN MATCHED THEN DELETE", "MERGE"], + ["TRUNCATE t", "TRUNCATE"], + ["CREATE TABLE t ()", "CREATE"], + ["ALTER TABLE t ADD COLUMN c int", "ALTER"], + ["DROP TABLE t", "DROP"], + ["GRANT SELECT ON t TO r", "GRANT"], + ["PREPARE q AS SELECT 1", "PREPARE"], + ["EXECUTE q", "EXECUTE"], + ["DECLARE c CURSOR FOR SELECT 1", "DECLARE"], + ["FETCH c", "FETCH"], + ["DISCARD ALL", "DISCARD"], + ["CHECKPOINT", "CHECKPOINT"], + ])("rejects a statement that is not a read: %s", (sql, label) => { + expect(findReadOnlyViolation(sql)).toBe(label); }); it.each([ - ["SET TRANSACTION READ WRITE", "SET TRANSACTION"], - ["set transaction isolation level serializable", "SET TRANSACTION"], - ["SET SESSION CHARACTERISTICS AS TRANSACTION READ WRITE", "SET SESSION CHARACTERISTICS"], - ["SET default_transaction_read_only = off", "SET default_transaction_read_only"], - ["SET SESSION default_transaction_read_only TO off", "SET default_transaction_read_only"], - ["SET LOCAL default_transaction_read_only = off", "SET default_transaction_read_only"], - ["SET transaction_read_only = off", "SET transaction_read_only"], - ["SET LOCAL TRANSACTION_READ_ONLY TO DEFAULT", "SET transaction_read_only"], - ['SET "Transaction_Read_Only" = off', "SET transaction_read_only"], - ['SET U&"transaction\\005fread\\005fonly" = off', 'SET U&"..."'], - ["RESET default_transaction_read_only", "RESET default_transaction_read_only"], - ["RESET transaction_read_only", "RESET transaction_read_only"], - ["RESET ALL", "RESET ALL"], - ["reset\nall", "RESET ALL"], - ])("rejects read-only characteristic changes: %s", (sql, found) => { - expect(findReadOnlyViolation(sql)).toBe(found); + ["an unknown word", "FROBNICATE t"], + ["a parenthesised query", "(SELECT 1) UNION (SELECT 2)"], + ["a quoted keyword", '"select" 1'], + ["a keyword with a trailing identifier character", "SELECTx 1"], + ["a keyword with a trailing digit", "SELECT1"], + ["a non-ASCII lookalike of a keyword", "ſelect 1"], + ["a string", "'SELECT 1'"], + ["a number", "1"], + ["a dollar quote", "$$SELECT 1$$"], + ])("labels %s without echoing it", (_name, sql) => { + expect(findReadOnlyViolation(sql)).toBe(UNRECOGNISED); }); it.each([ @@ -49,10 +93,18 @@ describe("findReadOnlyViolation", () => { ["byte order mark", "COMMIT"], ["byte order mark before a comment", "-- note\nCOMMIT"], ["form feed", "\fCOMMIT"], - ["comment between keywords", "SET /* rw */ TRANSACTION READ WRITE"], - ["comment before the setting", "SET -- rw\n transaction_read_only = off"], + ["nested comment closed by */ then *", "/* /* */* x */ COMMIT; DELETE FROM t; /* */"], + ["comment opened by /*/", "/*/ x */ COMMIT"], ])("sees through a %s", (_name, sql) => { - expect(findReadOnlyViolation(sql)).toBeDefined(); + expect(findReadOnlyViolation(sql)).toBe("COMMIT"); + }); + + it("refuses a read-only setting change hidden after a nested comment", () => { + expect( + findReadOnlyViolation( + "/* /* */* x */ SET default_transaction_read_only = off; DELETE FROM t", + ), + ).toBe("SET"); }); it("finds a rejected statement after other statements", () => { @@ -60,10 +112,54 @@ describe("findReadOnlyViolation", () => { "COMMIT", ); expect(findReadOnlyViolation("SELECT 1;\n-- c\nset transaction read write;\nSELECT 2")).toBe( - "SET TRANSACTION", + "SET", ); }); + it.each([ + "SELECT 1 /* COMMIT", + "/* COMMIT", + "SELECT 'COMMIT", + "SELECT 1; SELECT 'a", + 'SELECT "a', + "SELECT $$ COMMIT; DELETE FROM t", + "SELECT $t$ COMMIT $$", + "SELECT 1 /* a /* b */", + String.raw`SELECT E'a\'`, + ])("rejects input that ends inside a comment or quote: %s", (sql) => { + expect(findReadOnlyViolation(sql)).toBe(UNTERMINATED); + }); + + it.each([ + "WITH begin AS (SELECT 1) SELECT * FROM begin atomic; COMMIT; DELETE FROM users; END", + "WITH begin AS (SELECT 1) SELECT * FROM begin\n ATOMIC; SET transaction_read_only = off; DELETE FROM users; END", + "SELECT 'begin atomic'", + ])("rejects input containing BEGIN ATOMIC: %s", (sql) => { + expect(findReadOnlyViolation(sql)).toBe("BEGIN ATOMIC"); + }); + + it.each([ + ["SELECT", "SELECT 1"], + ["a lowercase SELECT", "select 1"], + ["WITH", "WITH t AS (SELECT 1) SELECT * FROM t"], + [ + "WITH RECURSIVE", + "with recursive t(n) as (select 1 union all select n + 1 from t) select n from t", + ], + ["VALUES", "VALUES (1), (2)"], + ["TABLE", "TABLE users"], + ["SHOW", "SHOW search_path"], + ["SHOW ALL", "show all"], + ["EXPLAIN", "EXPLAIN SELECT 1"], + ["EXPLAIN ANALYZE", "EXPLAIN (ANALYZE, BUFFERS) SELECT 1"], + ["a keyword glued to a punctuation mark", "SELECT*FROM t"], + ["a keyword after a line comment", "-- note\nSELECT 1"], + ["a keyword after a nested block comment", "/* a /* b */ c */ SELECT 1"], + ["a keyword after a byte order mark", "SELECT 1"], + ])("lets through %s", (_name, sql) => { + expect(findReadOnlyViolation(sql)).toBeUndefined(); + }); + it.each([ ["a keyword inside a string", "SELECT 'COMMIT; SET TRANSACTION READ WRITE'"], ["a keyword inside an escape string", String.raw`SELECT E'it\'s; COMMIT; --'`], @@ -71,29 +167,15 @@ describe("findReadOnlyViolation", () => { ["a keyword inside a tagged dollar quote", "SELECT $q$; BEGIN; COMMIT; $q$"], ["a keyword inside a line comment", "SELECT 1 -- COMMIT\n; SELECT 2"], ["a keyword inside a block comment", "SELECT 1 /* ROLLBACK; */ ; SELECT 2"], - ["a statement that is only a comment", "-- COMMIT"], - ["an unterminated block comment", "/* COMMIT"], ["a keyword as a quoted identifier", 'SELECT "commit", "begin" FROM t'], ["a keyword as a column", "SELECT begin, commit, rollback FROM t"], - ["an identifier that starts with a keyword", "COMMITTED_ROWS"], - ["an identifier that starts with BEGIN", "BEGINNERS"], - ["a function body", "CREATE FUNCTION f() RETURNS void LANGUAGE sql BEGIN ATOMIC SELECT 1; END"], - [ - "a plpgsql body", - "DO $$ BEGIN PERFORM set_config('transaction_read_only', 'off', true); END $$", - ], ["set_config", "SELECT set_config('default_transaction_read_only', 'off', false)"], ["current_setting", "SELECT current_setting('transaction_read_only')"], - ["an unrelated SET", "SET search_path TO public"], - ["an unrelated SET LOCAL", "SET LOCAL statement_timeout = '5s'"], - ["SET SESSION AUTHORIZATION", "SET SESSION AUTHORIZATION postgres"], - ["an unrelated RESET", "RESET search_path"], - ["a similarly named setting", "SET transaction_read_only_hint = 1"], - ["a custom setting", "SET my.transaction_read_only = 1"], - ["a SAVEPOINT", "SAVEPOINT s; RELEASE SAVEPOINT s"], - ["a PREPARE of a query", "PREPARE q AS SELECT 1"], - ["START as an identifier", "START_DATE"], ["a plain multi-statement read", "SELECT 1; SELECT 2;\nWITH t AS (SELECT 1) SELECT * FROM t"], + ["a trailing line comment", "SELECT 1 -- COMMIT"], + ["a trailing closed block comment", "SELECT 1 /* COMMIT */"], + ["a statement that is only a comment", "-- COMMIT"], + ["a statement that is only a closed block comment", "/* COMMIT */"], ["an empty statement", ";;"], ["blank SQL", " \n"], ])("lets through %s", (_name, sql) => { From fda5591c3b119747ce122e0a13e14174c6feb94c Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Thu, 15 Oct 2026 16:58:52 +0100 Subject: [PATCH 81/92] fix(cli): suggest the resolved target's flag when repairing migration history --- apps/cli/src/command-internal/db-pull-run.ts | 3 +- apps/cli/src/command-internal/db-push-core.ts | 14 ++--- .../src/command-internal/migration-history.ts | 19 +++--- .../migration-history.unit.test.ts | 62 ++++++++++++------- .../commands/bootstrap/bootstrap.handler.ts | 2 +- apps/cli/src/commands/db/push/push.handler.ts | 3 +- .../commands/db/push/push.integration.test.ts | 29 +++++++-- .../src/commands/migration/up/up.handler.ts | 3 +- 8 files changed, 84 insertions(+), 51 deletions(-) diff --git a/apps/cli/src/command-internal/db-pull-run.ts b/apps/cli/src/command-internal/db-pull-run.ts index 5d668f714f..d19327a994 100644 --- a/apps/cli/src/command-internal/db-pull-run.ts +++ b/apps/cli/src/command-internal/db-pull-run.ts @@ -16,6 +16,7 @@ import { } from "./hosted-consent/hosted-consent.ts"; import type { HostedMutationConsent } from "./hosted-consent/hosted-consent.token.ts"; import type { ResolvedTarget } from "./target/resolve-target.ts"; +import { flagsForTarget } from "./target/target-flags.ts"; import { resolveDbImage } from "./db-image.ts"; import { DbConnection, type PgConnInput } from "./db-connection.service.ts"; import { loadProjectEnvValues, readDbToml, resolveDeclarativeDir } from "./db-config.toml-read.ts"; @@ -326,7 +327,7 @@ export const runDbPull = Effect.fn("db.pull.run")(function* ( path, path.join(cliSettings.workdir, "supabase", "migrations"), ); - const sync = reconcileMigrations(remote, local, connType === "local"); + const sync = reconcileMigrations(remote, local, flagsForTarget(target)); if (sync.kind === "conflict") { return yield* new DbPullMigrationConflictError({ message: diff --git a/apps/cli/src/command-internal/db-push-core.ts b/apps/cli/src/command-internal/db-push-core.ts index d00a2d7d26..a279533d37 100644 --- a/apps/cli/src/command-internal/db-push-core.ts +++ b/apps/cli/src/command-internal/db-push-core.ts @@ -62,14 +62,8 @@ export interface DbPushCoreInput { readonly projectRef: string; readonly conn: PgConnInput; readonly isLocal: boolean; - /** - * Whether `--local` (not `--db-url`/`--linked`) was the explicit target selector, distinct - * from `isLocal` (whether the resolved connection happens to point at a local address, e.g. a - * `--db-url` pointing at `127.0.0.1`). Only feeds the "missing local migrations" repair - * suggestion's `--local` flag; `bootstrap` never selects `--local`, so it is always `false` - * there. - */ - readonly repairSuggestsLocalFlag: boolean; + /** Flags that point the "missing local migrations" repair suggestion at the pushed target. */ + readonly repairTargetFlags: string; /** * Gates the "DRY RUN: …" heads-up line, the "Would push/seed/create …" plan, and the JSON * `dryRun` field. @@ -103,7 +97,7 @@ export const dbPushCore = Effect.fn("DbPush.run")(function* (input: DbPushCoreIn projectRef, conn, isLocal, - repairSuggestsLocalFlag, + repairTargetFlags, dryRun, includeAll, includeRoles, @@ -144,7 +138,7 @@ export const dbPushCore = Effect.fn("DbPush.run")(function* (input: DbPushCoreIn if (result.kind === "missing-local") { return yield* new DbPushMissingLocalError({ message: ERR_MISSING_LOCAL, - suggestion: suggestRevertHistory(result.versions, repairSuggestsLocalFlag), + suggestion: suggestRevertHistory(result.versions, repairTargetFlags), }); } if (result.kind === "missing-remote") { diff --git a/apps/cli/src/command-internal/migration-history.ts b/apps/cli/src/command-internal/migration-history.ts index 3ff20c7f21..8b4d55c1ce 100644 --- a/apps/cli/src/command-internal/migration-history.ts +++ b/apps/cli/src/command-internal/migration-history.ts @@ -171,7 +171,7 @@ export type MigrationSync = export function reconcileMigrations( remote: ReadonlyArray<string>, local: ReadonlyArray<string>, - isLocal = false, + targetFlags: string, ): MigrationSync { // `MIGRATION_VERSION_MAX` pins the exhausted side of the walk; `parseMigrationVersion` is // shared with `migration list` so both surfaces skip the same edge-case versions. @@ -216,7 +216,7 @@ export function reconcileMigrations( if (extraRemote.length + extraLocal.length > 0) { return { kind: "conflict", - suggestion: suggestMigrationRepair(extraRemote, extraLocal, isLocal), + suggestion: suggestMigrationRepair(extraRemote, extraLocal, targetFlags), }; } if (local.length === 0) { @@ -225,31 +225,30 @@ export function reconcileMigrations( return { kind: "in-sync" }; } +/** `targetFlags` are the flags that select the resolved target (see `flagsForTarget`). */ export function suggestMigrationRepair( extraRemote: ReadonlyArray<string>, extraLocal: ReadonlyArray<string>, - isLocal = false, + targetFlags: string, ): string { - const localFlag = isLocal ? " --local" : ""; let result = "\nMake sure your local git repo is up-to-date. If the error persists, try repairing the migration history table:\n"; for (const version of extraRemote) { - result += `${bold(`supabase migration repair${localFlag} --status reverted ${version}`)}\n`; + result += `${bold(`supabase migration repair ${targetFlags} --status reverted ${version}`)}\n`; } for (const version of extraLocal) { - result += `${bold(`supabase migration repair${localFlag} --status applied ${version}`)}\n`; + result += `${bold(`supabase migration repair ${targetFlags} --status applied ${version}`)}\n`; } return result; } /** Each generated line ends with a trailing newline, including the last one. */ -export function suggestRevertHistory(versions: ReadonlyArray<string>, isLocal = false): string { - const localFlag = isLocal ? " --local" : ""; +export function suggestRevertHistory(versions: ReadonlyArray<string>, targetFlags: string): string { return ( "\nMake sure your local git repo is up-to-date. If the error persists, try repairing the migration history table:\n" + - `${bold(`supabase migration repair${localFlag} --status reverted ${versions.join(" ")}`)}\n` + + `${bold(`supabase migration repair ${targetFlags} --status reverted ${versions.join(" ")}`)}\n` + "\nAnd update local migrations to match remote database:\n" + - `${bold(`supabase db pull${localFlag}`)}\n` + `${bold(`supabase db pull ${targetFlags}`)}\n` ); } diff --git a/apps/cli/src/command-internal/migration-history.unit.test.ts b/apps/cli/src/command-internal/migration-history.unit.test.ts index 0073012b0f..81935bf7d2 100644 --- a/apps/cli/src/command-internal/migration-history.unit.test.ts +++ b/apps/cli/src/command-internal/migration-history.unit.test.ts @@ -29,59 +29,71 @@ const failingSession = (error: DbExecError): DbSession => ({ describe("reconcileMigrations", () => { it("reports in-sync when remote and local match", () => { - expect(reconcileMigrations(["20240101000000"], ["20240101000000"])).toEqual({ + expect(reconcileMigrations(["20240101000000"], ["20240101000000"], "--linked")).toEqual({ kind: "in-sync", }); }); it("reports missing only when both histories are empty", () => { - expect(reconcileMigrations([], [])).toEqual({ kind: "missing" }); - expect(reconcileMigrations(["20240101000000"], []).kind).toBe("conflict"); + expect(reconcileMigrations([], [], "--linked")).toEqual({ kind: "missing" }); + expect(reconcileMigrations(["20240101000000"], [], "--linked").kind).toBe("conflict"); }); it("reports a conflict with an extra remote migration", () => { - const result = reconcileMigrations(["20240101000000"], ["20240102000000"]); + const result = reconcileMigrations(["20240101000000"], ["20240102000000"], "--linked"); expect(result.kind).toBe("conflict"); if (result.kind === "conflict") { expect(stripAnsi(result.suggestion)).toContain( - "supabase migration repair --status reverted 20240101000000", + "supabase migration repair --linked --status reverted 20240101000000", ); expect(stripAnsi(result.suggestion)).toContain( - "supabase migration repair --status applied 20240102000000", + "supabase migration repair --linked --status applied 20240102000000", ); } }); it("reports a conflict with an extra local migration", () => { - const result = reconcileMigrations([], ["20240102000000"]); + const result = reconcileMigrations([], ["20240102000000"], "--linked"); expect(result.kind).toBe("conflict"); }); it("is in sync when an 8-digit and a 14-digit version share a prefix (#6036)", () => { expect( - reconcileMigrations(["20260420", "20260420010000"], ["20260420010000", "20260420"]), + reconcileMigrations( + ["20260420", "20260420010000"], + ["20260420010000", "20260420"], + "--linked", + ), ).toEqual({ kind: "in-sync" }); }); it("skips versions that do not parse as integers", () => { - expect(reconcileMigrations(["bogus", "20240101000000"], ["20240101000000"])).toEqual({ + expect( + reconcileMigrations(["bogus", "20240101000000"], ["20240101000000"], "--linked"), + ).toEqual({ kind: "in-sync", }); }); it("skips empty / whitespace versions (not Number() coercion)", () => { - expect(reconcileMigrations(["", "20240101000000"], [" ", "20240101000000"])).toEqual({ + expect( + reconcileMigrations(["", "20240101000000"], [" ", "20240101000000"], "--linked"), + ).toEqual({ kind: "in-sync", }); }); it("treats a version within the int64 range as a real conflict", () => { - expect(reconcileMigrations(["9999999999999999"], []).kind).toBe("conflict"); + expect(reconcileMigrations(["9999999999999999"], [], "--linked").kind).toBe("conflict"); }); it("skips a version beyond the int64 range instead of hanging the scan", () => { expect( - reconcileMigrations(["20240101000000", "9999999999999999999"], ["20240101000000"]), + reconcileMigrations( + ["20240101000000", "9999999999999999999"], + ["20240101000000"], + "--linked", + ), ).toEqual({ kind: "in-sync" }); }); }); @@ -174,21 +186,29 @@ describe("findPendingMigrations", () => { }); describe("suggestMigrationRepair", () => { - it("lists reverted (remote) then applied (local) repair commands", () => { - const out = stripAnsi(suggestMigrationRepair(["111"], ["222"])); + it("lists reverted (remote) then applied (local) repair commands for the target", () => { + const out = stripAnsi(suggestMigrationRepair(["111"], ["222"], "--linked")); expect(out).toContain("try repairing the migration history table:"); - expect(out).toContain("supabase migration repair --status reverted 111"); - expect(out).toContain("supabase migration repair --status applied 222"); + expect(out).toContain("supabase migration repair --linked --status reverted 111"); + expect(out).toContain("supabase migration repair --linked --status applied 222"); + }); + + it("carries a local or db-url target into every repair command", () => { + expect(stripAnsi(suggestMigrationRepair(["111"], [], "--local"))).toContain( + "supabase migration repair --local --status reverted 111", + ); + expect(stripAnsi(suggestMigrationRepair([], ["222"], "--db-url <redacted>"))).toContain( + "supabase migration repair --db-url <redacted> --status applied 222", + ); }); }); describe("suggestRevertHistory", () => { it("builds the revert-history suggestion with a trailing newline per line", () => { - expect(suggestRevertHistory(["0002", "0003"])).toContain( - "supabase migration repair --status reverted 0002 0003", - ); - expect(suggestRevertHistory(["0002"])).toMatch(/\n$/u); - expect(suggestRevertHistory(["0002"])).toContain("supabase db pull"); + const out = stripAnsi(suggestRevertHistory(["0002", "0003"], "--linked")); + expect(out).toContain("supabase migration repair --linked --status reverted 0002 0003"); + expect(out).toContain("supabase db pull --linked"); + expect(out).toMatch(/\n$/u); }); }); diff --git a/apps/cli/src/commands/bootstrap/bootstrap.handler.ts b/apps/cli/src/commands/bootstrap/bootstrap.handler.ts index bdf182a191..eaf09968df 100644 --- a/apps/cli/src/commands/bootstrap/bootstrap.handler.ts +++ b/apps/cli/src/commands/bootstrap/bootstrap.handler.ts @@ -344,7 +344,7 @@ export const bootstrap = Effect.fn("bootstrap")(function* ( projectRef, conn, isLocal: false, - repairSuggestsLocalFlag: false, + repairTargetFlags: "--linked", dryRun: false, includeAll: false, includeRoles: true, diff --git a/apps/cli/src/commands/db/push/push.handler.ts b/apps/cli/src/commands/db/push/push.handler.ts index 262da84f68..f1d639d8d7 100644 --- a/apps/cli/src/commands/db/push/push.handler.ts +++ b/apps/cli/src/commands/db/push/push.handler.ts @@ -10,6 +10,7 @@ import { dbPushCore } from "../../../command-internal/db-push-core.ts"; import { hostedMutationPreapproval } from "../../../command-internal/hosted-consent/hosted-consent.ts"; import { planDbSeed } from "../../../command-internal/seed-remote-consent.ts"; import { resolveTarget } from "../../../command-internal/target/resolve-target.ts"; +import { flagsForTarget } from "../../../command-internal/target/target-flags.ts"; import { LinkedProjectCache } from "../../../telemetry/linked-project-cache.service.ts"; import { TelemetryState } from "../../../telemetry/telemetry-state.service.ts"; import type { DbPushFlags } from "./push.command.ts"; @@ -86,7 +87,7 @@ export const dbPush = Effect.fn("db.push")(function* (flags: DbPushFlags) { projectRef, conn: cfg.conn, isLocal: cfg.isLocal, - repairSuggestsLocalFlag: target.kind === "local", + repairTargetFlags: flagsForTarget(target), dryRun: flags.dryRun, includeAll: flags.includeAll, includeRoles: flags.includeRoles, 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 6b106c8144..136331724d 100644 --- a/apps/cli/src/commands/db/push/push.integration.test.ts +++ b/apps/cli/src/commands/db/push/push.integration.test.ts @@ -495,8 +495,8 @@ describe("db push", () => { }); }); - it.effect("keeps repair suggestions targetless for an explicit local database URL", () => { - const dbUrl = "postgresql://postgres:postgres@127.0.0.1:54322/postgres"; + it.effect("points repair suggestions at an explicit database URL without echoing it", () => { + const dbUrl = "postgresql://postgres:s3cret@127.0.0.1:54322/postgres"; const { layer } = setup(tmp.current, { toml: 'project_id = "test"\n', remoteMigrations: ["20240101000000"], @@ -507,10 +507,27 @@ describe("db push", () => { dbUrl: Option.some(dbUrl), local: false, }).pipe(Effect.provide(layer), Effect.exit); - expect(failSuggestion(exit)).toContain("migration repair --status reverted"); - expect(failSuggestion(exit)).not.toContain("migration repair --local"); - if (Exit.isFailure(exit)) - expect(Cause.pretty(exit.cause)).not.toContain("migration repair --local"); + expect(failSuggestion(exit)).toContain( + "migration repair --db-url <redacted> --status reverted", + ); + expect(failSuggestion(exit)).toContain("supabase db pull --db-url <redacted>"); + expect(failSuggestion(exit)).not.toContain("s3cret"); + }); + }); + + it.effect("points repair suggestions at the linked project", () => { + const { layer } = setup(tmp.current, { + toml: 'project_id = "test"\n', + remoteMigrations: ["20240101000000"], + }); + return Effect.gen(function* () { + const exit = yield* dbPush({ ...DEFAULT_FLAGS, local: false, linked: true }).pipe( + Effect.provide(layer), + Effect.exit, + ); + expect(failSuggestion(exit)).toContain("migration repair --linked --status reverted"); + expect(failSuggestion(exit)).toContain("supabase db pull --linked"); + expect(failSuggestion(exit)).not.toContain("--local"); }); }); diff --git a/apps/cli/src/commands/migration/up/up.handler.ts b/apps/cli/src/commands/migration/up/up.handler.ts index 618959066f..7922c583d8 100644 --- a/apps/cli/src/commands/migration/up/up.handler.ts +++ b/apps/cli/src/commands/migration/up/up.handler.ts @@ -23,6 +23,7 @@ import { suggestRevertHistory, } from "../../../command-internal/migration-history.ts"; import { resolveTarget } from "../../../command-internal/target/resolve-target.ts"; +import { flagsForTarget } from "../../../command-internal/target/target-flags.ts"; import { upsertVaultSecrets } from "../../../command-internal/vault.ts"; import { LinkedProjectCache } from "../../../telemetry/linked-project-cache.service.ts"; import { TelemetryState } from "../../../telemetry/telemetry-state.service.ts"; @@ -74,7 +75,7 @@ const runUp = Effect.fnUntraced(function* (flags: MigrationUpFlags) { Effect.fail( new MigrationMissingLocalError({ message: "Remote migration versions not found in local migrations directory.", - suggestion: suggestRevertHistory(result.versions, target.kind === "local"), + suggestion: suggestRevertHistory(result.versions, flagsForTarget(target)), }), ), "missing-remote": (result) => { From cca834c6a47f4daf63b96cc9a15e1e04a7580748 Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Thu, 15 Oct 2026 16:58:54 +0100 Subject: [PATCH 82/92] fix(cli): name the migration list history column after the target --- apps/cli/docs/supabase/migration/list.md | 4 ++-- apps/cli/src/cli/complete.e2e.test.ts | 2 +- apps/cli/src/commands/migration/list/SIDE_EFFECTS.md | 4 ++-- apps/cli/src/commands/migration/list/list.command.ts | 6 ++++-- apps/cli/src/commands/migration/list/list.handler.ts | 12 +++++++++--- .../commands/migration/list/list.integration.test.ts | 6 +++++- 6 files changed, 23 insertions(+), 11 deletions(-) diff --git a/apps/cli/docs/supabase/migration/list.md b/apps/cli/docs/supabase/migration/list.md index a3cc54a303..1562b35864 100644 --- a/apps/cli/docs/supabase/migration/list.md +++ b/apps/cli/docs/supabase/migration/list.md @@ -1,11 +1,11 @@ ## supabase-migration-list -Lists migration history in both local and remote databases. +Lists the migration files in `supabase/migrations` next to the migration history of the target database. By default, it lists the migration history of your local database. Pass `--linked` (or `--project-ref`) to list the linked project instead, or set `target = "remote"` in your project config (or `SUPABASE_TARGET=remote`) to make the linked project the default. Linking requires running `supabase link`. For self-hosted databases, you can pass in the connection parameters using `--db-url` flag. > Note that URL strings must be escaped according to [RFC 3986](https://www.rfc-editor.org/rfc/rfc3986). -Local migrations are stored in `supabase/migrations` directory while remote migrations are tracked in `supabase_migrations.schema_migrations` table. Only the timestamps are compared to identify any differences. +Local migrations are stored in `supabase/migrations` directory while the target database tracks applied migrations in the `supabase_migrations.schema_migrations` table. The second column is headed `Remote` for a linked project or `--db-url` database, and `Local DB` for the local database. Only the timestamps are compared to identify any differences. In case of discrepancies between the local and remote migration history, you can resolve them using the `migration repair` command. diff --git a/apps/cli/src/cli/complete.e2e.test.ts b/apps/cli/src/cli/complete.e2e.test.ts index 0d2caddd89..1a94f0f82e 100644 --- a/apps/cli/src/cli/complete.e2e.test.ts +++ b/apps/cli/src/cli/complete.e2e.test.ts @@ -13,7 +13,7 @@ describe("supabase __complete", () => { const { exitCode, stdout } = await runSupabase(["__complete", "migration", "li"], {}); expect(exitCode).toBe(0); const lines = stdout.trim().split("\n"); - expect(lines[0]).toBe("list\tList local and remote migrations"); + expect(lines[0]).toBe("list\tList local files and the target database's migration history"); expect(lines.at(-1)).toBe(":4"); }, ); diff --git a/apps/cli/src/commands/migration/list/SIDE_EFFECTS.md b/apps/cli/src/commands/migration/list/SIDE_EFFECTS.md index 330aa5ae0a..b460207bc8 100644 --- a/apps/cli/src/commands/migration/list/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/migration/list/SIDE_EFFECTS.md @@ -43,9 +43,9 @@ ### `--output-format text` -Prints `Target: …` to stderr first, then an ASCII table `|Local|Remote|Time (UTC)|` to stdout (cells are backtick-wrapped inline code). Queries `SELECT version FROM +Prints `Target: …` to stderr first, then an ASCII table `|Local|Remote|Time (UTC)|` to stdout (cells are backtick-wrapped inline code). The database column is headed `Remote` for a linked or `--db-url` target and `Local DB` for the local database. Queries `SELECT version FROM supabase_migrations.schema_migrations ORDER BY version` (a missing table → empty -Remote column). +database column). ### `--output-format json` diff --git a/apps/cli/src/commands/migration/list/list.command.ts b/apps/cli/src/commands/migration/list/list.command.ts index b8e5388283..06a169b4f9 100644 --- a/apps/cli/src/commands/migration/list/list.command.ts +++ b/apps/cli/src/commands/migration/list/list.command.ts @@ -32,8 +32,10 @@ const config = { export type MigrationListFlags = CliCommand.Command.Config.Infer<typeof config>; export const migrationListCommand = Command.make("list", config).pipe( - Command.withDescription("List local and remote migrations."), - Command.withShortDescription("List local and remote migrations"), + Command.withDescription( + "List local migration files and the migration history of the target database.", + ), + Command.withShortDescription("List local files and the target database's migration history"), withPermissions( withDbTarget( compose( diff --git a/apps/cli/src/commands/migration/list/list.handler.ts b/apps/cli/src/commands/migration/list/list.handler.ts index bcb02e6587..5b2f218d1e 100644 --- a/apps/cli/src/commands/migration/list/list.handler.ts +++ b/apps/cli/src/commands/migration/list/list.handler.ts @@ -11,13 +11,17 @@ import { listRemoteMigrations, loadLocalVersions, } from "../../../command-internal/migration-history.ts"; -import { resolveTarget } from "../../../command-internal/target/resolve-target.ts"; +import { + resolveTarget, + type ResolvedTarget, +} from "../../../command-internal/target/resolve-target.ts"; import { LinkedProjectCache } from "../../../telemetry/linked-project-cache.service.ts"; import { TelemetryState } from "../../../telemetry/telemetry-state.service.ts"; import type { MigrationListFlags } from "./list.command.ts"; import { makeMigrationListRows, migrationListTableCells } from "./list.format.ts"; -const LIST_HEADERS = ["Local", "Remote", "Time (UTC)"] as const; +const listHeaders = (target: ResolvedTarget) => + ["Local", target.kind === "local" ? "Local DB" : "Remote", "Time (UTC)"] as const; const runList = Effect.fnUntraced(function* (flags: MigrationListFlags) { const output = yield* Output; @@ -61,7 +65,9 @@ const runList = Effect.fnUntraced(function* (flags: MigrationListFlags) { "migration.count": rows.length, }); if (output.format === "text") { - yield* output.raw(renderGlamourTable([...LIST_HEADERS], migrationListTableCells(rows))); + yield* output.raw( + renderGlamourTable([...listHeaders(target)], migrationListTableCells(rows)), + ); } else { yield* output.success("Migrations listed", { migrations: rows }); } 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 b7e322f2bb..882c211a18 100644 --- a/apps/cli/src/commands/migration/list/list.integration.test.ts +++ b/apps/cli/src/commands/migration/list/list.integration.test.ts @@ -194,7 +194,8 @@ describe("migration list", () => { yield* seedMigrations(tmp.current, ["20240101000000_a.sql", "20240103000000_c.sql"]); yield* migrationList(flags()); const stdout = stripAnsi(ctx.out.stdoutText); - expect(stdout).toContain("Local"); + expect(stdout).toContain("Local DB"); + expect(stdout).not.toContain("Remote"); expect(stdout).toContain("Time (UTC)"); expect(stdout).toContain("`20240101000000`"); expect(stdout).toContain("`20240102000000`"); @@ -214,6 +215,9 @@ describe("migration list", () => { yield* migrationList(flags()); expect(ctx.resolverCalls[0]?.target).toMatchObject({ kind: "linked", ref: VALID_REF }); expect(ctx.cache.cachedRef).toBe(VALID_REF); + const stdout = stripAnsi(ctx.out.stdoutText); + expect(stdout).toContain("Remote"); + expect(stdout).not.toContain("Local DB"); }).pipe(Effect.provide(ctx.layer)); }, ); From c1c460baa712062aae118294a6fa66abfdf2ef40 Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Thu, 15 Oct 2026 16:58:56 +0100 Subject: [PATCH 83/92] docs(cli): refresh db dump, inspect, repair and target help for the local default --- apps/cli/docs/supabase/db/dump.md | 4 +- apps/cli/docs/supabase/migration/repair.md | 19 +++++--- apps/cli/docs/templates/examples.yaml | 45 ++++++++++++------- .../command-internal/target/target-flags.ts | 18 +++++++- .../target/target-flags.unit.test.ts | 39 ++++++++++++++-- apps/cli/src/commands/db/dump/dump.command.ts | 4 +- .../cli/src/commands/inspect/db/db.command.ts | 4 +- .../commands/inspect/report/report.command.ts | 4 +- 8 files changed, 105 insertions(+), 32 deletions(-) diff --git a/apps/cli/docs/supabase/db/dump.md b/apps/cli/docs/supabase/db/dump.md index 7cdd3b6629..71a04af1a2 100644 --- a/apps/cli/docs/supabase/db/dump.md +++ b/apps/cli/docs/supabase/db/dump.md @@ -1,8 +1,8 @@ ## supabase-db-dump -Dumps contents from a remote database. +Dumps contents from the target database. -Dumps the workspace target by default, which is the local database unless `SUPABASE_TARGET` or `target` in `config.toml` says otherwise. Pass `--linked` to dump the linked project, which requires your local project to be linked by running `supabase link`. For self-hosted databases, you can pass in the connection parameters using `--db-url` flag. The command prints a `Target:` line on stderr naming the database it resolved, and never asks for confirmation. +Dumps the local database by default, unless `SUPABASE_TARGET` or `target` in `config.toml` selects the linked project. Pass `--linked` to dump the linked project, which requires your local project to be linked by running `supabase link`. For self-hosted databases, you can pass in the connection parameters using `--db-url` flag. The command prints a `Target:` line on stderr naming the database it resolved, and never asks for confirmation. Runs `pg_dump` in a container with additional flags to exclude Supabase managed schemas. The ignored schemas include auth, storage, and those created by extensions. diff --git a/apps/cli/docs/supabase/migration/repair.md b/apps/cli/docs/supabase/migration/repair.md index 305ebfc15d..0b00d8649b 100644 --- a/apps/cli/docs/supabase/migration/repair.md +++ b/apps/cli/docs/supabase/migration/repair.md @@ -9,7 +9,8 @@ If your local and remote migration history goes out of sync, you can repair the For example, your migration history may look like the table below, with missing entries in either local or remote. ```bash -$ supabase migration list +$ supabase migration list --linked +Target: project My Project (abcdefghijklmnopqrst) · production LOCAL │ REMOTE │ TIME (UTC) ─────────────────┼────────────────┼────────────────────── │ 20230103054303 │ 2023-01-03 05:43:03 @@ -21,7 +22,8 @@ To reset your migration history to a clean state, first delete your local migrat ```bash $ rm supabase/migrations/20230103054315_remote_commit.sql -$ supabase migration list +$ supabase migration list --linked +Target: project My Project (abcdefghijklmnopqrst) · production LOCAL │ REMOTE │ TIME (UTC) ─────────────────┼────────────────┼────────────────────── │ 20230103054303 │ 2023-01-03 05:43:03 @@ -32,11 +34,14 @@ Then mark the remote migration `20230103054303` as reverted. ```bash $ supabase migration repair 20230103054303 --status reverted --linked Target: project My Project (abcdefghijklmnopqrst) · production -Mark 1 migration as reverted on project My Project (abcdefghijklmnopqrst) · production? [y/N] + • 20230103054303 +◇ Mark 1 migration as reverted on project My Project (abcdefghijklmnopqrst) · production? +│ Yes Repaired migration history: [20230103054303] => reverted Finished supabase migration repair. -$ supabase migration list +$ supabase migration list --linked +Target: project My Project (abcdefghijklmnopqrst) · production LOCAL │ REMOTE │ TIME (UTC) ─────────────────┼────────────────┼────────────────────── ``` @@ -48,11 +53,13 @@ $ supabase db pull Target: project My Project (abcdefghijklmnopqrst) · production Write 1 migration file to supabase/migrations. Record them as applied in the remote migration history. -Update the remote migration history table on project My Project (abcdefghijklmnopqrst) · production? [y/N] +◇ Update the remote migration history table on project My Project (abcdefghijklmnopqrst) · production? +│ Yes Schema written to supabase/migrations/20240414044403_remote_schema.sql Finished supabase db pull. -$ supabase migration list +$ supabase migration list --linked +Target: project My Project (abcdefghijklmnopqrst) · production LOCAL │ REMOTE │ TIME (UTC) ─────────────────┼────────────────┼────────────────────── 20240414044403 │ 20240414044403 │ 2024-04-14 04:44:03 diff --git a/apps/cli/docs/templates/examples.yaml b/apps/cli/docs/templates/examples.yaml index 63e7f6a995..df7d53c366 100644 --- a/apps/cli/docs/templates/examples.yaml +++ b/apps/cli/docs/templates/examples.yaml @@ -111,7 +111,7 @@ supabase-status: supabase-migration-list: - id: basic-usage name: Basic usage - code: supabase migration list + code: supabase migration list --linked response: |2 LOCAL │ REMOTE │ TIME (UTC) ─────────────────┼────────────────┼────────────────────── @@ -140,20 +140,24 @@ supabase-migration-new: supabase-migration-repair: - id: basic-usage name: Mark a migration as reverted - code: supabase migration repair 20230103054303 --status reverted + code: supabase migration repair 20230103054303 --status reverted --linked --yes response: | - Repaired migration history: 20230103054303 => reverted + Target: project My Project (abcdefghijklmnopqrst) · production + Confirmed by --yes: Mark 1 migration as reverted on project My Project (abcdefghijklmnopqrst) · production + Repaired migration history: [20230103054303] => reverted - id: mark-applied name: Mark a migration as applied - code: supabase migration repair 20230222032233 --status applied + code: supabase migration repair 20230222032233 --status applied --linked --yes response: | - Repaired migration history: 20230222032233 => applied + Target: project My Project (abcdefghijklmnopqrst) · production + Confirmed by --yes: Mark 1 migration as applied on project My Project (abcdefghijklmnopqrst) · production + Repaired migration history: [20230222032233] => applied supabase-db-diff: - id: basic-usage name: Basic usage code: supabase db diff -f my_table response: | - Connecting to local database... + Target: local stack (127.0.0.1:54322) Creating shadow database... Applying migration 20230425064254_remote_commit.sql... Diffing schemas: auth,extensions,public,storage @@ -164,7 +168,7 @@ supabase-db-diff: name: Against linked project code: supabase db diff -f my_table --linked response: | - Connecting to local database... + Target: project My Project (abcdefghijklmnopqrst) · production Creating shadow database... Diffing schemas: auth,extensions,public,storage Finished supabase db diff on branch main. @@ -175,7 +179,7 @@ supabase-db-diff: name: For a specific schema code: supabase db diff -f my_table --schema auth response: | - Connecting to local database... + Target: local stack (127.0.0.1:54322) Creating shadow database... Diffing schemas: auth Finished supabase db diff on branch main. @@ -186,18 +190,28 @@ supabase-db-dump: name: Basic usage code: supabase db dump -f supabase/schema.sql response: | + Target: local stack (127.0.0.1:54322) + Dumping schemas from local database... + Dumped schema to supabase/schema.sql. + - id: linked-project + name: Dump the linked project + code: supabase db dump -f supabase/schema.sql --linked + response: | + Target: project My Project (abcdefghijklmnopqrst) · production Dumping schemas from remote database... Dumped schema to supabase/schema.sql. - id: role-only name: Role only - code: supabase db dump -f supabase/roles.sql --role-only + code: supabase db dump -f supabase/roles.sql --role-only --linked response: | + Target: project My Project (abcdefghijklmnopqrst) · production Dumping roles from remote database... Dumped schema to supabase/roles.sql. - id: data-only name: Data only - code: supabase db dump -f supabase/seed.sql --data-only + code: supabase db dump -f supabase/seed.sql --data-only --linked response: | + Target: project My Project (abcdefghijklmnopqrst) · production Dumping data from remote database... Dumped schema to supabase/seed.sql. supabase-db-lint: @@ -230,16 +244,17 @@ supabase-db-pull: name: Basic usage code: supabase db pull response: | - Connecting to remote database... + Target: project My Project (abcdefghijklmnopqrst) · production + ◇ Update the remote migration history table on project My Project (abcdefghijklmnopqrst) · production? + │ Yes Schema written to supabase/migrations/20240414044403_remote_schema.sql - Update remote migration history table? [Y/n] Repaired migration history: [20240414044403] => applied Finished supabase db pull. - id: local-studio name: Local studio code: supabase db pull --local response: | - Connecting to local database... + Target: local stack (127.0.0.1:54322) Creating shadow database... Seeding globals from roles.sql... Applying migration 20240414044403_remote_schema.sql... @@ -250,7 +265,7 @@ supabase-db-pull: name: Custom schemas code: supabase db pull --schema public,extensions response: | - Connecting to remote database... + Target: project My Project (abcdefghijklmnopqrst) · production Creating shadow database... Seeding globals from roles.sql... Applying migration 20240414044403_remote_schema.sql... @@ -300,7 +315,7 @@ supabase-db-schema-declarative-sync: Enter a name for this migration (press Enter to keep 'declarative_sync'): add_updated_at Created new migration at supabase/migrations/20260317194051_add_updated_at.sql Apply this migration to local database? [Y/n] - Connecting to local database... + Target: local stack (127.0.0.1:54322) Applying migration 20260317194051_add_updated_at.sql... Migration applied successfully. - id: generate-first diff --git a/apps/cli/src/command-internal/target/target-flags.ts b/apps/cli/src/command-internal/target/target-flags.ts index e4a2a02913..c66a0c1ec9 100644 --- a/apps/cli/src/command-internal/target/target-flags.ts +++ b/apps/cli/src/command-internal/target/target-flags.ts @@ -1,6 +1,8 @@ import type { Option } from "effect"; import { Flag, GlobalFlag } from "effect/cli"; +import type { ResolvedTarget } from "./resolve-target.ts"; + type TargetFlagGroup = "sync" | "selectable"; type TargetSubject = "database" | "Storage API"; @@ -14,7 +16,7 @@ const localFlag = ({ group, subject }: TargetFlagOptions) => Flag.withDescription( group === "sync" ? `Target the local ${subject}.` - : `Target the local ${subject}. Default unless the workspace target is "remote".`, + : `Target the local ${subject} (default unless target = "remote" is set in config.toml or SUPABASE_TARGET).`, ), Flag.withDefault(false), ); @@ -24,7 +26,7 @@ const linkedFlag = ({ group }: TargetFlagOptions) => Flag.withDescription( group === "sync" ? "Target the linked project (default)." - : 'Target the linked project. Default when the workspace target is "remote".', + : 'Target the linked project (default when target = "remote" is set in config.toml or SUPABASE_TARGET).', ), Flag.withDefault(false), ); @@ -70,6 +72,18 @@ export function targetFlags( return options.dbUrl ? { ...base, dbUrl: dbUrlFlag() } : base; } +/** The flags that point a suggested follow-up command at `target`; a `--db-url` value is never echoed. */ +export const flagsForTarget = (target: ResolvedTarget): string => { + switch (target.kind) { + case "local": + return "--local"; + case "db-url": + return "--db-url <redacted>"; + case "linked": + return target.matchesLinkedRef ? "--linked" : `--linked --project-ref ${target.ref}`; + } +}; + /** * `seed` accepts the selectors before or after its subcommand, which needs scoped globals; they * share their definitions with {@link targetFlags}. diff --git a/apps/cli/src/command-internal/target/target-flags.unit.test.ts b/apps/cli/src/command-internal/target/target-flags.unit.test.ts index 02aa9f6300..7919ec64dd 100644 --- a/apps/cli/src/command-internal/target/target-flags.unit.test.ts +++ b/apps/cli/src/command-internal/target/target-flags.unit.test.ts @@ -1,11 +1,16 @@ import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; -import { Effect, Layer, Option } from "effect"; +import { Effect, Layer, Option, Redacted } from "effect"; import { CliOutput, Command, type Flag } from "effect/cli"; import type * as CliCommand from "effect/cli/Command"; import { unwrapParam } from "../param-introspection.ts"; -import { deprecatedProjectIdFlag, seedTargetGlobalFlags, targetFlags } from "./target-flags.ts"; +import { + deprecatedProjectIdFlag, + seedTargetGlobalFlags, + flagsForTarget, + targetFlags, +} from "./target-flags.ts"; const silentFormatter: CliOutput.Formatter = { formatCliError: () => "", @@ -80,7 +85,7 @@ describe("targetFlags", () => { const sync = targetFlags({ group: "sync", subject: "database", dbUrl: true }); expect(describeFlag(sync.linked).description).toBe("Target the linked project (default)."); expect(describeFlag(withDbUrl.linked).description).toBe( - 'Target the linked project. Default when the workspace target is "remote".', + 'Target the linked project (default when target = "remote" is set in config.toml or SUPABASE_TARGET).', ); }); @@ -113,3 +118,31 @@ describe("seedTargetGlobalFlags", () => { expect(describeFlag(seedTargetGlobalFlags.local.flag).name).toBe("local"); }); }); + +describe("flagsForTarget", () => { + it("names the flags that re-select each kind of target without echoing a db-url", () => { + expect(flagsForTarget({ kind: "local" })).toBe("--local"); + expect(flagsForTarget({ kind: "db-url", url: Redacted.make("postgresql://u:pw@h/db") })).toBe( + "--db-url <redacted>", + ); + expect( + flagsForTarget({ + kind: "linked", + ref: "abcdefghijklmnopqrst", + source: "file", + matchesLinkedRef: true, + }), + ).toBe("--linked"); + }); + + it("keeps an explicit ref that is not the linked project", () => { + expect( + flagsForTarget({ + kind: "linked", + ref: "abcdefghijklmnopqrst", + source: "flag", + matchesLinkedRef: false, + }), + ).toBe("--linked --project-ref abcdefghijklmnopqrst"); + }); +}); diff --git a/apps/cli/src/commands/db/dump/dump.command.ts b/apps/cli/src/commands/db/dump/dump.command.ts index ab4afc4dc5..c33de1641d 100644 --- a/apps/cli/src/commands/db/dump/dump.command.ts +++ b/apps/cli/src/commands/db/dump/dump.command.ts @@ -99,8 +99,8 @@ const config = { export type DbDumpFlags = CliCommand.Command.Config.Infer<typeof config>; export const dbDumpCommand = Command.make("dump", config).pipe( - Command.withDescription("Dumps data or schemas from the remote database."), - Command.withShortDescription("Dumps data or schemas from the remote database"), + Command.withDescription("Dumps data or schemas from the target database."), + Command.withShortDescription("Dumps data or schemas from the target database"), withPermissions( withDbTarget( compose( diff --git a/apps/cli/src/commands/inspect/db/db.command.ts b/apps/cli/src/commands/inspect/db/db.command.ts index 3dd805c79b..d4121f58e7 100644 --- a/apps/cli/src/commands/inspect/db/db.command.ts +++ b/apps/cli/src/commands/inspect/db/db.command.ts @@ -26,7 +26,9 @@ import { inspectDbUnusedIndexesCommand } from "./unused-indexes/unused-indexes.c import { inspectDbVacuumStatsCommand } from "./vacuum-stats/vacuum-stats.command.ts"; export const inspectDbCommand = Command.make("db").pipe( - Command.withDescription("Tools to inspect your Supabase database."), + Command.withDescription( + "Tools to inspect your Supabase database. They inspect the local database by default; pass --linked for the linked project or --db-url for another database.", + ), Command.withShortDescription("Inspect database"), Command.withSubcommands([ inspectDbDbStatsCommand, diff --git a/apps/cli/src/commands/inspect/report/report.command.ts b/apps/cli/src/commands/inspect/report/report.command.ts index e5100c1229..4928d3e426 100644 --- a/apps/cli/src/commands/inspect/report/report.command.ts +++ b/apps/cli/src/commands/inspect/report/report.command.ts @@ -26,7 +26,9 @@ const config = { export type InspectReportFlags = CliCommand.Command.Config.Infer<typeof config>; export const inspectReportCommand = Command.make("report", config).pipe( - Command.withDescription("Generate a CSV output for all inspect commands."), + Command.withDescription( + "Generate a CSV output for all inspect commands. It reports on the local database by default; pass --linked for the linked project or --db-url for another database.", + ), Command.withShortDescription("Generate a CSV output for all inspect commands"), withPermissions( withDbTarget(compose({ operations: [], noApiEffectFlags: ["output-dir"] }, dbConfig), { From 4e296c10e073eca94128be94c8b3606e51a614fb Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Thu, 15 Oct 2026 16:59:04 +0100 Subject: [PATCH 84/92] feat(cli): point local connection failures at --linked on a linked checkout --- .../src/command-internal/connect-errors.ts | 16 +++++++++- .../connect-errors.unit.test.ts | 7 ++++ .../db-config.integration.test.ts | 25 +++++++++++++++ .../src/command-internal/db-config.layer.ts | 15 +++++++-- .../db-connection.sql-pg.layer.ts | 16 ++++++++-- .../db-connection.sql-pg.unit.test.ts | 13 ++++++++ .../db-password-rejection.integration.test.ts | 32 +++++++++++++++++-- .../target/target-password.ts | 4 ++- .../declarative/declarative.smart-target.ts | 11 +++++-- apps/cli/src/shared/config/temp-paths.ts | 11 +++++++ 10 files changed, 138 insertions(+), 12 deletions(-) diff --git a/apps/cli/src/command-internal/connect-errors.ts b/apps/cli/src/command-internal/connect-errors.ts index fa0c8c05e3..0d63a45af2 100644 --- a/apps/cli/src/command-internal/connect-errors.ts +++ b/apps/cli/src/command-internal/connect-errors.ts @@ -63,6 +63,16 @@ export const SUGGEST_ENV_VAR = */ export const SUGGEST_LOCAL_STACK = "Make sure Docker is running, then run: supabase start"; +/** Appended to the local-stack hint when the checkout is linked to a hosted project. */ +export const SUGGEST_LINKED_PROJECT = "To use the linked project instead, pass --linked."; + +/** The local-stack hint, plus the `--linked` pointer when the checkout is linked. */ +export function localStackSuggestion(linkedCheckout: boolean | undefined): string { + return linkedCheckout === true + ? `${SUGGEST_LOCAL_STACK}\n${SUGGEST_LINKED_PROJECT}` + : SUGGEST_LOCAL_STACK; +} + /** * Remote-only "Network Restrictions" hint, shown for a connection refused or blocked by IP * allow-listing. Shared by both the always-remote `Address not in tenant allow_list` branch @@ -78,6 +88,8 @@ export interface ConnectSuggestionContext { readonly dashboardUrl: string; /** Active profile name. */ readonly profileName: string; + /** Whether the checkout is linked to a hosted project, so a local failure can point at `--linked`. */ + readonly linkedCheckout?: boolean; } /** @@ -350,7 +362,9 @@ export function connectSuggestion( } // Don't send the user to the dashboard's Network Restrictions page for a --local connection. if (text.includes("ECONNREFUSED") || text.includes("connection refused")) { - return ctx.isLocal ? SUGGEST_LOCAL_STACK : suggestNetworkRestrictions(ctx.dashboardUrl); + return ctx.isLocal + ? localStackSuggestion(ctx.linkedCheckout) + : suggestNetworkRestrictions(ctx.dashboardUrl); } if ( text.includes("SCRAM exchange: Wrong password") || diff --git a/apps/cli/src/command-internal/connect-errors.unit.test.ts b/apps/cli/src/command-internal/connect-errors.unit.test.ts index 4255124580..831d9aa3c9 100644 --- a/apps/cli/src/command-internal/connect-errors.unit.test.ts +++ b/apps/cli/src/command-internal/connect-errors.unit.test.ts @@ -274,6 +274,13 @@ describe("connectSuggestion", () => { expect(connectSuggestion(err, { ...ctx, isLocal: true })).toBe(SUGGEST_LOCAL_STACK); }); + it("points a refused local connection at --linked when the checkout is linked", () => { + const err = sqlError(systemError("connect ECONNREFUSED 127.0.0.1:54322", "ECONNREFUSED")); + expect(connectSuggestion(err, { ...ctx, isLocal: true, linkedCheckout: true })).toBe( + `${SUGGEST_LOCAL_STACK}\nTo use the linked project instead, pass --linked.`, + ); + }); + it("maps an AggregateError of refused dials to the network-restrictions hint", () => { const err = sqlError( Object.assign(new AggregateError([], "all attempts failed"), { 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 225d8ccf17..6b65d452e0 100644 --- a/apps/cli/src/command-internal/db-config.integration.test.ts +++ b/apps/cli/src/command-internal/db-config.integration.test.ts @@ -301,6 +301,31 @@ describe("dbConfigResolver (local + db-url)", () => { ), ); + it.effect( + "local mode on a linked checkout carries the --linked pointer in its connect hint", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const dir = yield* withWorkdir(); + yield* fs.makeDirectory(path.join(dir, "supabase", ".temp"), { recursive: true }); + yield* fs.writeFileString( + path.join(dir, "supabase", ".temp", "project-ref"), + "abcdefghijklmnopqrst", + ); + const r = yield* resolveConnection(dir, inputFor(localTarget)); + expect(r.conn.suggestionContext).toMatchObject({ linkedCheckout: true }); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), + ); + + it.effect("local mode on an unlinked checkout adds no --linked pointer", () => + Effect.gen(function* () { + const dir = yield* withWorkdir(); + const r = yield* resolveConnection(dir, inputFor(localTarget)); + expect(r.conn.suggestionContext?.linkedCheckout).toBeUndefined(); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), + ); + it.effect("local mode: falls back to default port/password without a config.toml", () => Effect.gen(function* () { const dir = yield* withWorkdir(); diff --git a/apps/cli/src/command-internal/db-config.layer.ts b/apps/cli/src/command-internal/db-config.layer.ts index 15b32e7acd..fb76d33369 100644 --- a/apps/cli/src/command-internal/db-config.layer.ts +++ b/apps/cli/src/command-internal/db-config.layer.ts @@ -47,6 +47,7 @@ import { StackApi, stackApiLayer } from "./stack-api.ts"; import { stackLocalDatabaseConn } from "./stack-local-database.ts"; import { block, type OperationEntry } from "./command-permissions/model.ts"; import { renderDbUrlTarget, renderLocalTarget } from "./target/target-identity.ts"; +import { isCheckoutLinked } from "../shared/config/temp-paths.ts"; import { rejectTargetPassword } from "./target/target-password.ts"; const linkedEntries: ReadonlyArray<Omit<OperationEntry, "source">> = [ @@ -799,8 +800,10 @@ export const dbConfigResolverLayer = Layer.effect( const resolveConnection = (input: DbConnectionInput) => Effect.gen(function* () { const { target } = input; + const linkedCheckout = + target.kind === "local" && (yield* isCheckoutLinked(fs, path, cliSettings.workdir)); if (target.kind !== "linked") { - yield* rejectTargetPassword(target.kind, input.password); + yield* rejectTargetPassword(target.kind, input.password, linkedCheckout); } const resolveVaultSecrets = input.resolveVaultSecrets ?? true; if (target.kind === "linked") { @@ -829,7 +832,13 @@ export const dbConfigResolverLayer = Layer.effect( } const resolved = yield* resolveLocalStack(localHost, resolveVaultSecrets); yield* commandTarget.announce(renderLocalTarget(resolved.conn.host, resolved.conn.port)); - return { ...resolved, target }; + return { + ...resolved, + conn: linkedCheckout + ? { ...resolved.conn, suggestionContext: { ...suggestionContext, linkedCheckout } } + : resolved.conn, + target, + }; }); // When a linked dump's pg_dump container fails with an IPv6 connectivity error (the direct @@ -874,7 +883,7 @@ export const dbConfigResolverLayer = Layer.effect( // so each connecting command inherits the hint without per-call-site wiring. const withSuggestion = (conn: PgConnInput): PgConnInput => ({ ...conn, - suggestionContext, + suggestionContext: conn.suggestionContext ?? suggestionContext, }); return DbConfigResolver.of({ resolveConnection: (input) => diff --git a/apps/cli/src/command-internal/db-connection.sql-pg.layer.ts b/apps/cli/src/command-internal/db-connection.sql-pg.layer.ts index a905cb17a4..71cf5e1cb7 100644 --- a/apps/cli/src/command-internal/db-connection.sql-pg.layer.ts +++ b/apps/cli/src/command-internal/db-connection.sql-pg.layer.ts @@ -8,12 +8,12 @@ import { ConnectionError, SqlError } from "effect/sql/SqlError"; import * as Pg from "pg"; import { to as pgCopyTo } from "pg-copy-streams"; import { - SUGGEST_LOCAL_STACK, connectFailureMessage, connectSuggestion, isDialFailure, isIPv6ConnectivityErrorCause, isSqlState, + localStackSuggestion, } from "./connect-errors.ts"; import { DbConnectError, DbCopyError, DbExecError } from "./db-connection.errors.ts"; import { makePoolSqlClient } from "./db-connection.pool-client.ts"; @@ -199,13 +199,14 @@ export function batchFailureError( error: Error, batch: { readonly completed: number; readonly outcome: BatchOutcome } | undefined, isLocal: boolean, + linkedCheckout?: boolean, ): DbExecError | DbConnectError { if (batch === undefined || batch.outcome === "unsent") { return new DbConnectError({ message: `${BATCH_CONNECTION_LOST}: ${error.message}`, // The checkout failure a tick earlier carries this same hint, so losing the // connection mid-batch must not silently drop it. - ...(isLocal ? { suggestion: SUGGEST_LOCAL_STACK } : {}), + ...(isLocal ? { suggestion: localStackSuggestion(linkedCheckout) } : {}), }); } const mapped = toExecError(error); @@ -960,7 +961,16 @@ const connect = ( resume(Effect.void); return; } - resume(Effect.fail(batchFailureError(error, batchQuery, options.isLocal))); + resume( + Effect.fail( + batchFailureError( + error, + batchQuery, + options.isLocal, + cfg.suggestionContext?.linkedCheckout, + ), + ), + ); }; batchQuery = new PgBatchQuery(statements, finish); try { diff --git a/apps/cli/src/command-internal/db-connection.sql-pg.unit.test.ts b/apps/cli/src/command-internal/db-connection.sql-pg.unit.test.ts index 71d3983532..ab5c5e3be5 100644 --- a/apps/cli/src/command-internal/db-connection.sql-pg.unit.test.ts +++ b/apps/cli/src/command-internal/db-connection.sql-pg.unit.test.ts @@ -791,6 +791,19 @@ describe("batchFailureError", () => { expect("suggestion" in remote).toBe(false); }); + it("adds the --linked pointer to the local-stack hint on a linked checkout", () => { + const error = batchFailureError( + new Error("socket died"), + { completed: 0, outcome: "unsent" }, + true, + true, + ); + + expect(error).toMatchObject({ + suggestion: `${SUGGEST_LOCAL_STACK}\nTo use the linked project instead, pass --linked.`, + }); + }); + it("keeps a partially written batch on the statement path, blaming statement 0", () => { // A poisoned batch is corked, so `completed` stays 0 and statement 0 is blamed. const error = batchFailureError( 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 index b7ce56150b..ac18f4d10e 100644 --- a/apps/cli/src/command-internal/db-password-rejection.integration.test.ts +++ b/apps/cli/src/command-internal/db-password-rejection.integration.test.ts @@ -1,6 +1,6 @@ import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; -import { Cause, Effect, Exit, FileSystem, Layer } from "effect"; +import { Cause, Effect, Exit, FileSystem, Layer, Path } from "effect"; import { CliOutput, Command } from "effect/cli"; import { rootCommandForFeatures } from "../cli/root.ts"; @@ -54,10 +54,22 @@ const selectorArgs = (selector: "db-url" | "local" | "none") => ? ["--local"] : []; -const runCommand = (entry: WalkedCommand, selector: "db-url" | "local" | "none") => +const runCommand = ( + entry: WalkedCommand, + selector: "db-url" | "local" | "none", + options: { readonly linked?: boolean } = {}, +) => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; const home = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-password-rejection-" }); + if (options.linked === true) { + yield* fs.makeDirectory(path.join(home, "supabase", ".temp"), { recursive: true }); + yield* fs.writeFileString( + path.join(home, "supabase", ".temp", "project-ref"), + "abcdefghijklmnopqrst", + ); + } const args = [ ...entry.path, ...requiredFlagArgs(entry), @@ -129,4 +141,20 @@ describe("--password with a direct database target", () => { }); }), ); + + it.effect("points a local --password rejection at --linked when the checkout is linked", () => + Effect.gen(function* () { + const entry = passwordCommands.find( + (candidate) => candidate.path.join(" ") === "migration list", + ); + expect(entry).toBeDefined(); + const failure = yield* runCommand(entry!, "local", { linked: true }); + + expect(failure).toMatchObject({ + _tag: "TargetPasswordConflictError", + message: + "--password only applies to a linked project. The local database uses [db].password from supabase/config.toml. To use the linked project instead, pass --linked.", + }); + }), + ); }); diff --git a/apps/cli/src/command-internal/target/target-password.ts b/apps/cli/src/command-internal/target/target-password.ts index a0b701fa78..1b181ec622 100644 --- a/apps/cli/src/command-internal/target/target-password.ts +++ b/apps/cli/src/command-internal/target/target-password.ts @@ -1,11 +1,13 @@ import { Effect, Option } from "effect"; +import { SUGGEST_LINKED_PROJECT } from "../connect-errors.ts"; import { TargetPasswordConflictError } from "./target.errors.ts"; /** Fails when a non-empty `--password` accompanies a target that is not a linked project. */ export const rejectTargetPassword = ( target: "local" | "db-url", password: Option.Option<string>, + linkedCheckout = false, ): Effect.Effect<void, TargetPasswordConflictError> => Option.isNone(Option.filter(password, (value) => value.length > 0)) ? Effect.void @@ -15,6 +17,6 @@ export const rejectTargetPassword = ( 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" - : "--password only applies to a linked project. The local database uses [db].password from supabase/config.toml.", + : `--password only applies to a linked project. The local database uses [db].password from supabase/config.toml.${linkedCheckout ? ` ${SUGGEST_LINKED_PROJECT}` : ""}`, }), ); 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 a0db23aa5b..67a82c646f 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 @@ -1,5 +1,7 @@ -import { Effect, Option } from "effect"; +import { Effect, FileSystem, Option, Path } from "effect"; +import { CommandSettings } from "../../../../config/command-settings.service.ts"; +import { isCheckoutLinked } from "../../../../shared/config/temp-paths.ts"; import { DnsResolverFlag } from "../../../../command-internal/global-flags.ts"; import { resetLocalDatabase } from "../../../../command-internal/db-bootstrap/reset-local-database.ts"; import { DbConfigResolver } from "../../../../command-internal/db-config.service.ts"; @@ -53,7 +55,12 @@ export const prepareLocalTargetEndpoint = Effect.fn("DeclarativeSchema.localTarg E, R, >(shouldReset: Effect.Effect<boolean, E, R>, password: Option.Option<string> = Option.none()) { - yield* rejectTargetPassword("local", password); + const { workdir } = yield* CommandSettings; + yield* rejectTargetPassword( + "local", + password, + yield* isCheckoutLinked(yield* FileSystem.FileSystem, yield* Path.Path, workdir), + ); const seam = yield* DeclarativeSeam; yield* seam.ensureLocalPostgresImageCurrent; yield* seam.ensureLocalDatabaseStarted; diff --git a/apps/cli/src/shared/config/temp-paths.ts b/apps/cli/src/shared/config/temp-paths.ts index b635b57d1b..ca436ea791 100644 --- a/apps/cli/src/shared/config/temp-paths.ts +++ b/apps/cli/src/shared/config/temp-paths.ts @@ -79,3 +79,14 @@ export const readProjectRefFile = ( const trimmed = content.trim(); return trimmed.length === 0 ? Option.none<string>() : Option.some(trimmed); }); + +/** Whether `<workdir>/supabase/.temp/project-ref` names a linked project; an unreadable file counts as unlinked. */ +export const isCheckoutLinked = ( + fs: FileSystem.FileSystem, + path: Path.Path, + workdir: string, +): Effect.Effect<boolean> => + readProjectRefFile(fs, path, workdir).pipe( + Effect.map(Option.isSome), + Effect.orElseSucceed(() => false), + ); From 78a2d3811bfbd5ad5f4c37c0beaf2094887fa7c9 Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Thu, 15 Oct 2026 16:59:17 +0100 Subject: [PATCH 85/92] fix(cli): let db query --read-only start a statement with a parenthesised query --- apps/cli/docs/supabase/db/query.md | 2 +- apps/cli/src/commands/db/query/SIDE_EFFECTS.md | 18 +++++++++--------- .../src/commands/db/query/query.read-only.ts | 2 ++ .../db/query/query.read-only.unit.test.ts | 7 ++++++- 4 files changed, 18 insertions(+), 11 deletions(-) diff --git a/apps/cli/docs/supabase/db/query.md b/apps/cli/docs/supabase/db/query.md index 07b7ad904a..a008b5e254 100644 --- a/apps/cli/docs/supabase/db/query.md +++ b/apps/cli/docs/supabase/db/query.md @@ -6,7 +6,7 @@ With no target flag the command runs against the workspace target, which is the The command cannot tell a read from a write, so a hosted database (the linked project or a remote `--db-url`) asks for one confirmation (default No) before the statement is sent. Piped answers are never read, so a script, a JSON run or an agent must pass `--yes` or set `SUPABASE_YES` in the shell; a `SUPABASE_YES` in `supabase/.env` does not count. The local database never asks. -Pass `--read-only` to run a statement that must not change anything. It skips the confirmation on a hosted database. Before the statement is sent or any question is asked, every statement must start with `SELECT`, `WITH`, `VALUES`, `TABLE`, `SHOW` or `EXPLAIN` (after whitespace and comments). Any other statement (`COMMIT`, `SET`, `CALL`, `DO`, `COPY`, `LOCK` and so on), input that ends inside a comment, quote or dollar quote, and input containing `BEGIN ATOMIC` is rejected. Drop `--read-only` to run one of these. +Pass `--read-only` to run a statement that must not change anything. It skips the confirmation on a hosted database. Before the statement is sent or any question is asked, every statement must start with `SELECT`, `WITH`, `VALUES`, `TABLE`, `SHOW`, `EXPLAIN` or a parenthesised query (after whitespace and comments). Any other statement (`COMMIT`, `SET`, `CALL`, `DO`, `COPY`, `LOCK` and so on), input that ends inside a comment, quote or dollar quote, and input containing `BEGIN ATOMIC` is rejected. Drop `--read-only` to run one of these. The local database or a `--db-url` database then runs the statement in a read-only transaction that is always rolled back, with a 5 second `lock_timeout` so the read cannot wait on heavy locks; Postgres rejects writes inside it. A linked project runs it through the Management API's `read_only` mode, and the API enforces it. diff --git a/apps/cli/src/commands/db/query/SIDE_EFFECTS.md b/apps/cli/src/commands/db/query/SIDE_EFFECTS.md index e712b6aeb7..49992f1562 100644 --- a/apps/cli/src/commands/db/query/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/db/query/SIDE_EFFECTS.md @@ -49,14 +49,14 @@ stack never prompts. ## Exit Codes -| Code | Condition | -| ---- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `0` | success | -| `1` | more than one of `--db-url`/`--linked`/`--local` (`TargetSelectorConflictError`); no SQL provided; empty stdin; unreadable `--file`; linked target without login; query exec failure; non-201 linked status | -| `1` | `--project-ref` with `--local` or `--db-url` (`TargetProjectRefConflictError`) | -| `1` | hosted target without `--read-only` in a JSON, piped, agent or non-TTY run without `--yes` or a shell `SUPABASE_YES` (`HostedMutationConsentRequiredError`); nothing is sent | -| `1` | `--read-only` with a statement that does not start with `SELECT`, `WITH`, `VALUES`, `TABLE`, `SHOW` or `EXPLAIN`, or with input that ends inside a comment, quote or dollar quote (`DbQueryReadOnlyStatementError`); nothing is sent | -| `1` | hosted target confirmation declined (`HostedMutationDeclinedError`, `context canceled`); nothing is sent | +| Code | Condition | +| ---- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `0` | success | +| `1` | more than one of `--db-url`/`--linked`/`--local` (`TargetSelectorConflictError`); no SQL provided; empty stdin; unreadable `--file`; linked target without login; query exec failure; non-201 linked status | +| `1` | `--project-ref` with `--local` or `--db-url` (`TargetProjectRefConflictError`) | +| `1` | hosted target without `--read-only` in a JSON, piped, agent or non-TTY run without `--yes` or a shell `SUPABASE_YES` (`HostedMutationConsentRequiredError`); nothing is sent | +| `1` | `--read-only` with a statement that does not start with `SELECT`, `WITH`, `VALUES`, `TABLE`, `SHOW`, `EXPLAIN` or `(`, or with input that ends inside a comment, quote or dollar quote (`DbQueryReadOnlyStatementError`); nothing is sent | +| `1` | hosted target confirmation declined (`HostedMutationDeclinedError`, `context canceled`); nothing is sent | ## Output @@ -111,7 +111,7 @@ from the environment. Agent mode defaults the format to JSON (table for humans). - **`--read-only`.** Skips the hosted confirmation entirely and prints no question. On every target, before the statement is sent or any question is asked, the SQL is split into statements and each must start (after whitespace, a BOM and comments) with `SELECT`, `WITH`, `VALUES`, - `TABLE`, `SHOW` or `EXPLAIN`. Any other statement (`COMMIT`, `SET`, `CALL`, `DO`, `COPY`, `LOCK`, + `TABLE`, `SHOW`, `EXPLAIN` or `(` (a parenthesised query). Any other statement (`COMMIT`, `SET`, `CALL`, `DO`, `COPY`, `LOCK`, …), input that ends inside a comment, quote or dollar quote, and input containing `BEGIN ATOMIC` (the splitter reads such a body as one statement) fails with `DbQueryReadOnlyStatementError`, naming only the leading keyword or a fixed label. Keywords diff --git a/apps/cli/src/commands/db/query/query.read-only.ts b/apps/cli/src/commands/db/query/query.read-only.ts index f56663fa5b..4e487066e9 100644 --- a/apps/cli/src/commands/db/query/query.read-only.ts +++ b/apps/cli/src/commands/db/query/query.read-only.ts @@ -77,6 +77,8 @@ const BEGIN_ATOMIC = /begin\s+atomic/iu; const statementViolation = (statement: string): string | undefined => { const text = trimLeadingSqlComments(statement); if (text === "") return undefined; + // Only a parenthesised SELECT or VALUES can start with `(`. + if (text.startsWith("(")) return undefined; const word = LEADING_WORD.exec(text)?.[0]; // ASCII only: `ſelect` upper-cases to `SELECT` in JS but is a plain identifier to PostgreSQL. const keyword = word !== undefined && /^[A-Za-z]+$/u.test(word) ? word.toUpperCase() : undefined; diff --git a/apps/cli/src/commands/db/query/query.read-only.unit.test.ts b/apps/cli/src/commands/db/query/query.read-only.unit.test.ts index e41a6d8d4f..351beb4b86 100644 --- a/apps/cli/src/commands/db/query/query.read-only.unit.test.ts +++ b/apps/cli/src/commands/db/query/query.read-only.unit.test.ts @@ -72,7 +72,6 @@ describe("findReadOnlyViolation", () => { it.each([ ["an unknown word", "FROBNICATE t"], - ["a parenthesised query", "(SELECT 1) UNION (SELECT 2)"], ["a quoted keyword", '"select" 1'], ["a keyword with a trailing identifier character", "SELECTx 1"], ["a keyword with a trailing digit", "SELECT1"], @@ -99,6 +98,10 @@ describe("findReadOnlyViolation", () => { expect(findReadOnlyViolation(sql)).toBe("COMMIT"); }); + it("still checks every statement after a parenthesised query", () => { + expect(findReadOnlyViolation("(SELECT 1) UNION (SELECT 2); COMMIT")).toBe("COMMIT"); + }); + it("refuses a read-only setting change hidden after a nested comment", () => { expect( findReadOnlyViolation( @@ -152,6 +155,8 @@ describe("findReadOnlyViolation", () => { ["SHOW ALL", "show all"], ["EXPLAIN", "EXPLAIN SELECT 1"], ["EXPLAIN ANALYZE", "EXPLAIN (ANALYZE, BUFFERS) SELECT 1"], + ["a parenthesised query", "(SELECT 1) UNION (SELECT 2)"], + ["a parenthesised query after a comment", "-- note\n/* a */ (VALUES (1))"], ["a keyword glued to a punctuation mark", "SELECT*FROM t"], ["a keyword after a line comment", "-- note\nSELECT 1"], ["a keyword after a nested block comment", "/* a /* b */ c */ SELECT 1"], From 97977dbf0a497dc66ecd6f0300356591b5776d2a Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Thu, 15 Oct 2026 17:00:05 +0100 Subject: [PATCH 86/92] fix(cli): suggest a valid value for empty target flags and date the --project-id removal --- apps/cli/src/command-internal/target/target-flags.ts | 2 +- apps/cli/src/command-internal/target/target-selector.ts | 8 +++++++- .../command-internal/target/target-selector.unit.test.ts | 3 +++ apps/cli/src/command-internal/target/target.errors.ts | 1 + .../command-internal/target/target.errors.unit.test.ts | 2 +- apps/cli/src/commands/gen/types/SIDE_EFFECTS.md | 2 +- apps/cli/src/commands/gen/types/types.handler.ts | 3 ++- apps/cli/src/commands/gen/types/types.integration.test.ts | 4 +++- 8 files changed, 19 insertions(+), 6 deletions(-) diff --git a/apps/cli/src/command-internal/target/target-flags.ts b/apps/cli/src/command-internal/target/target-flags.ts index c66a0c1ec9..f016ea8dea 100644 --- a/apps/cli/src/command-internal/target/target-flags.ts +++ b/apps/cli/src/command-internal/target/target-flags.ts @@ -99,7 +99,7 @@ export const seedTargetGlobalFlags = { /** Hidden alias of `--project-ref`, accepted only by `gen types`. */ export const deprecatedProjectIdFlag = Flag.String("project-id").pipe( - Flag.withDescription("Deprecated: use --project-ref."), + Flag.withDescription("Deprecated, removed in v4: use --project-ref."), Flag.optional, Flag.withHidden, ); diff --git a/apps/cli/src/command-internal/target/target-selector.ts b/apps/cli/src/command-internal/target/target-selector.ts index 466032d666..558e799bc2 100644 --- a/apps/cli/src/command-internal/target/target-selector.ts +++ b/apps/cli/src/command-internal/target/target-selector.ts @@ -34,7 +34,12 @@ export const parseTargetSelector = ( if (dbUrl === "") { return Result.fail( - new TargetEmptyValueError({ flag: "--db-url", message: "--db-url cannot be empty." }), + new TargetEmptyValueError({ + flag: "--db-url", + message: "--db-url cannot be empty.", + suggestion: + "Pass a connection string, for example --db-url postgresql://postgres:postgres@127.0.0.1:54322/postgres", + }), ); } if (projectRef === "") { @@ -42,6 +47,7 @@ export const parseTargetSelector = ( new TargetEmptyValueError({ flag: "--project-ref", message: "--project-ref cannot be empty.", + suggestion: "Pass a project ref, for example --project-ref abcdefghijklmnopqrst", }), ); } diff --git a/apps/cli/src/command-internal/target/target-selector.unit.test.ts b/apps/cli/src/command-internal/target/target-selector.unit.test.ts index 9c391daecd..8feffab3bb 100644 --- a/apps/cli/src/command-internal/target/target-selector.unit.test.ts +++ b/apps/cli/src/command-internal/target/target-selector.unit.test.ts @@ -135,6 +135,8 @@ describe("parseTargetSelector", () => { _tag: "TargetEmptyValueError", flag: "--db-url", message: "--db-url cannot be empty.", + suggestion: + "Pass a connection string, for example --db-url postgresql://postgres:postgres@127.0.0.1:54322/postgres", }); }); @@ -143,6 +145,7 @@ describe("parseTargetSelector", () => { _tag: "TargetEmptyValueError", flag: "--project-ref", message: "--project-ref cannot be empty.", + suggestion: "Pass a project ref, for example --project-ref abcdefghijklmnopqrst", }); }); diff --git a/apps/cli/src/command-internal/target/target.errors.ts b/apps/cli/src/command-internal/target/target.errors.ts index 360e4f3d0b..a48146643a 100644 --- a/apps/cli/src/command-internal/target/target.errors.ts +++ b/apps/cli/src/command-internal/target/target.errors.ts @@ -32,6 +32,7 @@ export class TargetProjectRefConflictError extends Data.TaggedError( export class TargetEmptyValueError extends Data.TaggedError("TargetEmptyValueError")<{ readonly flag: "--db-url" | "--project-ref"; readonly message: string; + readonly suggestion: string; }> { get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { return actionability.provideFlags; diff --git a/apps/cli/src/command-internal/target/target.errors.unit.test.ts b/apps/cli/src/command-internal/target/target.errors.unit.test.ts index 29260506c2..365b66fed2 100644 --- a/apps/cli/src/command-internal/target/target.errors.unit.test.ts +++ b/apps/cli/src/command-internal/target/target.errors.unit.test.ts @@ -12,7 +12,7 @@ describe("target errors", () => { it.each([ new TargetSelectorConflictError({ flags: ["--local", "--linked"], message: "m" }), new TargetProjectRefConflictError({ conflictsWith: "--local", message: "m" }), - new TargetEmptyValueError({ flag: "--db-url", message: "m" }), + new TargetEmptyValueError({ flag: "--db-url", message: "m", suggestion: "s" }), new TargetPasswordConflictError({ target: "local", message: "m" }), ])("classifies $_tag as invalid input the user can fix", (error) => { expect(classifyCliErrorActionability(error)).toMatchObject({ diff --git a/apps/cli/src/commands/gen/types/SIDE_EFFECTS.md b/apps/cli/src/commands/gen/types/SIDE_EFFECTS.md index 1eba903973..38f4d2358f 100644 --- a/apps/cli/src/commands/gen/types/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/gen/types/SIDE_EFFECTS.md @@ -129,7 +129,7 @@ Diagnostics go to stderr: one `Target: …` banner naming the resolved target (t ref and whether its identity is verified, the local stack host and port, or the custom Postgres host) and `Connecting to …` for database introspection. In-process TypeScript also prints a stderr note that the output is unformatted. `--project-id` additionally prints -`--project-id is deprecated; use --project-ref instead.` on stderr. +`--project-id is deprecated and will be removed in v4; use --project-ref instead.` on stderr. ### `--output-format json` diff --git a/apps/cli/src/commands/gen/types/types.handler.ts b/apps/cli/src/commands/gen/types/types.handler.ts index f1f8315c47..4dd32556a4 100644 --- a/apps/cli/src/commands/gen/types/types.handler.ts +++ b/apps/cli/src/commands/gen/types/types.handler.ts @@ -122,7 +122,8 @@ const GEN_TYPES_MUTEX_GROUPS: ReadonlyArray<ReadonlyArray<string>> = [ ...GEN_TYPES_LANGUAGE_FLAG_NAMES.map((name) => ["linked", "project-ref", name]), ]; -const PROJECT_ID_DEPRECATION_LINE = "--project-id is deprecated; use --project-ref instead."; +const PROJECT_ID_DEPRECATION_LINE = + "--project-id is deprecated and will be removed in v4; use --project-ref instead."; const POSTGREST_V9_COMPAT_DEPRECATION_LINE = "Flag --postgrest-v9-compat has been deprecated, PostgREST 9 reached end of life; the flag still disables one-to-one relationship detection."; 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 450d74705c..539794a2b7 100644 --- a/apps/cli/src/commands/gen/types/types.integration.test.ts +++ b/apps/cli/src/commands/gen/types/types.integration.test.ts @@ -1019,7 +1019,9 @@ describe("gen types", () => { Effect.provide(layer), ); - expect(out.stderrText).toContain("--project-id is deprecated; use --project-ref instead."); + expect(out.stderrText).toContain( + "--project-id is deprecated and will be removed in v4; use --project-ref instead.", + ); expect(out.stderrText).toContain(unverifiedTargetBanner()); expect(api.requests).toContainEqual({ method: "generateTypescriptTypes", From 6d04b90ac6438204b9bbdedda897bfb9e420f900 Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Thu, 15 Oct 2026 17:05:28 +0100 Subject: [PATCH 87/92] fix(cli): tighten hosted-mutation consent sources, messages and exemptions - Hosted changes accept only `--yes` or a shell-exported SUPABASE_YES; a value that a cwd `.env`, `.env.local` or `.env.$NODE_ENV[.local]` also defines counts as file-sourced. - The confirmation echo names the real source (`--yes` or `SUPABASE_YES`). - The consent-required error names the action, target and reason, with suggestions for `--yes`, `--read-only` (db query), ignored dotenv files and agent runs. - Action strings are imperative sentence-case phrases at every call site. - `canPrompt` refuses when an agent is detected; `notebooks pull` reconcile uses it. - Global `--yes` help describes the hosted rule. - A sync or selectable leaf with `consent: "none"` must declare `consentExemptReason` (type and tree guard); notebooks pull's create operation is exempt for that leaf only. - ADR 0033 matches the code: groups, approval sources, agent rule, exemptions. --- apps/cli/AGENTS.md | 4 +- apps/cli/src/cli/root.ts | 1 + apps/cli/src/command-internal/global-flags.ts | 37 ++- .../hosted-consent-coverage.unit.test.ts | 17 + .../hosted-consent/hosted-consent.prompt.ts | 47 +++ .../hosted-consent/hosted-consent.ts | 77 +++-- .../hosted-consent.unit.test.ts | 301 +++++++++++++++--- .../hosted-consent/hosted-consent.yes.ts | 100 ++++++ .../command-internal/project-create-core.ts | 2 +- apps/cli/src/command-internal/supabase-env.ts | 2 +- .../command-internal/target/target-policy.ts | 28 +- .../target/target-policy.unit.test.ts | 55 +++- .../src/commands/config/push/SIDE_EFFECTS.md | 4 +- .../src/commands/config/push/push.format.ts | 4 +- .../config/push/push.integration.test.ts | 10 +- .../commands/db/advisors/advisors.command.ts | 6 +- apps/cli/src/commands/db/diff/diff.command.ts | 6 +- apps/cli/src/commands/db/dump/dump.command.ts | 6 +- apps/cli/src/commands/db/lint/lint.command.ts | 6 +- .../src/commands/db/query/query.handler.ts | 1 + .../db/query/query.integration.test.ts | 1 - .../declarative/generate/generate.command.ts | 6 +- .../compute/delete/delete.handler.ts | 2 +- .../compute/delete/delete.integration.test.ts | 8 +- .../experimental/compute/push/push.handler.ts | 2 +- .../commands/feedback/delete/SIDE_EFFECTS.md | 2 +- .../feedback/delete/delete.handler.ts | 2 +- .../delete/delete.integration.test.ts | 2 +- .../commands/functions/delete/SIDE_EFFECTS.md | 4 +- .../commands/functions/deploy/SIDE_EFFECTS.md | 2 +- .../deploy/deploy.integration.test.ts | 4 +- .../src/commands/gen/types/types.command.ts | 2 +- .../commands/inspect/db/inspect-db-command.ts | 6 +- .../commands/inspect/report/report.command.ts | 6 +- .../commands/migration/fetch/fetch.command.ts | 6 +- .../commands/migration/list/list.command.ts | 2 +- .../commands/notebooks/notebooks.shared.ts | 7 +- .../commands/notebooks/pull/pull.command.ts | 6 +- .../commands/notebooks/pull/pull.handler.ts | 8 +- .../commands/notebooks/push/push.handler.ts | 8 +- .../commands/orgs/create/create.handler.ts | 2 +- .../commands/projects/delete/SIDE_EFFECTS.md | 4 +- .../projects/delete/delete.handler.ts | 2 +- .../delete/delete.integration.test.ts | 2 +- .../src/commands/secrets/set/SIDE_EFFECTS.md | 2 +- .../src/commands/secrets/set/set.handler.ts | 2 +- .../secrets/set/set.integration.test.ts | 2 +- .../commands/secrets/unset/SIDE_EFFECTS.md | 2 +- .../commands/secrets/unset/unset.handler.ts | 2 +- .../secrets/unset/unset.integration.test.ts | 8 +- .../cli/src/commands/storage/ls/ls.command.ts | 2 +- apps/cli/src/shared/functions/delete.ts | 2 +- apps/cli/src/shared/functions/deploy.ts | 8 +- apps/cli/tests/helpers/hosted-consent.ts | 48 ++- ...t-selection-and-hosted-mutation-consent.md | 57 ++-- 55 files changed, 749 insertions(+), 196 deletions(-) create mode 100644 apps/cli/src/command-internal/hosted-consent/hosted-consent.prompt.ts create mode 100644 apps/cli/src/command-internal/hosted-consent/hosted-consent.yes.ts diff --git a/apps/cli/AGENTS.md b/apps/cli/AGENTS.md index fee986ff27..73567581ab 100644 --- a/apps/cli/AGENTS.md +++ b/apps/cli/AGENTS.md @@ -241,7 +241,9 @@ without one. Declare the target flags only through `targetFlags` in target may be local) after the plan and before the first persistent effect, and pass the returned `HostedMutationConsent` to anything that needs proof of it. - Never read stdin for consent. JSON, piped, and agent runs need `--yes` or a shell - `SUPABASE_YES`; `supabase/.env` does not count. + `SUPABASE_YES`; `supabase/.env` and a cwd `.env*` file do not count. Write the action as an + imperative sentence-case phrase with no trailing period (`Delete project <ref>`). +- A `sync` or `selectable` leaf with `consent: "none"` declares a `consentExemptReason`. - Every leaf with consent has a `describeHostedConsent("<command path>", …)` test from `tests/helpers/hosted-consent.ts`. diff --git a/apps/cli/src/cli/root.ts b/apps/cli/src/cli/root.ts index 434b6ff3cd..7acadaf020 100644 --- a/apps/cli/src/cli/root.ts +++ b/apps/cli/src/cli/root.ts @@ -232,6 +232,7 @@ export const rootCommandForFeatures = (options: RootCommandFeatures = {}) => : stackBackendLayer(options.stackBackend), commandTargetLayer.pipe(Layer.provideMerge(outputLayer)), machineErrorContextLayer, + Layer.succeed(AiTool, aiTool), ); }), ), diff --git a/apps/cli/src/command-internal/global-flags.ts b/apps/cli/src/command-internal/global-flags.ts index 3751d1ae37..45b1681713 100644 --- a/apps/cli/src/command-internal/global-flags.ts +++ b/apps/cli/src/command-internal/global-flags.ts @@ -6,7 +6,12 @@ import { VALUE_CONSUMING_LONG_FLAGS, VALUE_CONSUMING_SHORT_FLAGS, } from "../shared/cli/value-consuming-flags.ts"; -import { supabaseEnvBool, supabaseEnvBoolWithProjectFallback } from "./supabase-env.ts"; +import { envValue } from "../shared/config/env-option.ts"; +import { + parseEnvBool, + supabaseEnvBool, + supabaseEnvBoolWithProjectFallback, +} from "./supabase-env.ts"; // The CLI's global-flag registry is tree-wide, so `-o/--output` can't be redeclared per command to // vary its allowed values; this models it as the union of every command's accepted values, and @@ -90,7 +95,9 @@ export const NetworkIdFlag = GlobalFlag.Setting("network-id")({ export const YesFlag = GlobalFlag.Setting("yes")({ flag: Flag.Boolean("yes").pipe( - Flag.withDescription("answer yes to all prompts"), + Flag.withDescription( + "answer yes to all prompts; hosted changes only accept this flag or SUPABASE_YES exported in your shell, never supabase/.env or another .env file", + ), Flag.withDefault(false), ), }); @@ -220,20 +227,30 @@ const yesFlagExplicitlyFalse = (args: ReadonlyArray<string>): boolean => (arg) => arg.startsWith("--yes=") && FALSE_VALUES.has(arg.slice("--yes=".length)), ); -/** - * `--yes` resolved with an env fallback: when the flag isn't passed, `SUPABASE_YES` is honored. An - * explicit `--yes` (including `--yes=false`) wins over the env. Prefer this over reading - * {@link YesFlag} directly anywhere a command auto-confirms a prompt. - */ -export const resolveYes = Effect.gen(function* () { +/** `raw` is the `SUPABASE_YES` value the process sees, which may come from a dotenv file Bun autoloaded. */ +export type YesSource = { readonly via: "flag" } | { readonly via: "env"; readonly raw: string }; + +/** The origin of a granted `--yes`, or `None`; an explicit `--yes=false` wins over the env. */ +export const resolveYesSource = Effect.gen(function* () { const flag = yield* YesFlag; const cliArgs = yield* CliArgs; if (yesFlagExplicitlyFalse(cliArgs.args)) { - return false; + return Option.none<YesSource>(); } - return flag || (yield* supabaseEnvBool("SUPABASE_YES")); + if (flag) return Option.some<YesSource>({ via: "flag" }); + const raw = yield* envValue("SUPABASE_YES"); + return raw !== undefined && parseEnvBool(raw) + ? Option.some<YesSource>({ via: "env", raw }) + : Option.none<YesSource>(); }); +/** + * `--yes` resolved with an env fallback: when the flag isn't passed, `SUPABASE_YES` is honored. An + * explicit `--yes` (including `--yes=false`) wins over the env. Prefer this over reading + * {@link YesFlag} directly anywhere a command auto-confirms a prompt. + */ +export const resolveYes = Effect.map(resolveYesSource, Option.isSome); + /** * `--yes` resolved with the project `.env` consulted too, for commands that load the nested * project env before prompting (`migration down`, `migration repair --all`). Shell env diff --git a/apps/cli/src/command-internal/hosted-consent/hosted-consent-coverage.unit.test.ts b/apps/cli/src/command-internal/hosted-consent/hosted-consent-coverage.unit.test.ts index 01b294ea19..c00ec4d0b9 100644 --- a/apps/cli/src/command-internal/hosted-consent/hosted-consent-coverage.unit.test.ts +++ b/apps/cli/src/command-internal/hosted-consent/hosted-consent-coverage.unit.test.ts @@ -36,6 +36,19 @@ const testFiles = readdirSync(srcDir, { recursive: true, encoding: "utf8" }) const callsByLeaf = new Map<string, Array<string>>(); const nonLiteralCallSites: Array<string> = []; +const lowercaseActions: Array<string> = []; + +const sourceFiles = readdirSync(srcDir, { recursive: true, encoding: "utf8" }) + .filter((file) => file.endsWith(".ts") && !file.includes(".test.")) + .map((file) => file.split(sep).join("/")); + +const LOWERCASE_ACTION = /\baction:\s*[`"'][a-z]/; +for (const file of sourceFiles) { + const source = readFileSync(join(srcDir, file), "utf8"); + if (source.includes("hosted-consent.ts") && LOWERCASE_ACTION.test(source)) { + lowercaseActions.push(file); + } +} for (const file of testFiles) { const source = readFileSync(join(srcDir, file), "utf8"); @@ -73,4 +86,8 @@ describe("hosted consent coverage", () => { it("names the leaf as a string literal at every call site so the scan can see it", () => { expect(nonLiteralCallSites).toEqual([]); }); + + it("starts every consent action literal with a capital letter", () => { + expect(lowercaseActions).toEqual([]); + }); }); diff --git a/apps/cli/src/command-internal/hosted-consent/hosted-consent.prompt.ts b/apps/cli/src/command-internal/hosted-consent/hosted-consent.prompt.ts new file mode 100644 index 0000000000..91cbaf3097 --- /dev/null +++ b/apps/cli/src/command-internal/hosted-consent/hosted-consent.prompt.ts @@ -0,0 +1,47 @@ +import { Effect, Option } from "effect"; + +import { Output } from "../../shared/output/output.service.ts"; +import { isMachineResourceFormat } from "../../shared/output/resource-format.ts"; +import { AiTool } from "../../shared/telemetry/ai-tool.service.ts"; +import { Tty } from "../../shared/runtime/tty.service.ts"; +import { resolveAgentMode } from "../agent-mode.ts"; +import { AgentFlag, OutputFlag } from "../global-flags.ts"; + +export interface PromptBlocker { + readonly reason: string; + readonly agent: boolean; +} + +const agentDetected = Effect.gen(function* () { + const flag = Option.getOrElse(yield* Effect.serviceOption(AgentFlag), () => "auto" as const); + const tool = yield* Effect.serviceOption(AiTool); + return resolveAgentMode( + flag, + Option.flatMap(tool, (detected) => detected.name), + ); +}); + +/** + * Why this run cannot ask a question, or `None` when it can: text output on an interactive + * stdout with a TTY stdin, no machine `-o` format, and no agent driving it. + */ +export const promptBlocker = Effect.gen(function* () { + const output = yield* Output; + const tty = yield* Tty; + const outputFlag = yield* OutputFlag; + if (yield* agentDetected) { + return Option.some<PromptBlocker>({ reason: "it is running in agent mode", agent: true }); + } + const blocked = (reason: string) => Option.some<PromptBlocker>({ reason, agent: false }); + if (output.format !== "text") return blocked(`its output format is ${output.format}`); + if (isMachineResourceFormat(outputFlag)) { + return blocked( + `it writes machine-readable -o ${Option.getOrElse(outputFlag, () => "")} output`, + ); + } + if (!output.interactive) return blocked("stdout is not a terminal"); + if (!tty.stdinIsTty) return blocked("stdin is not a terminal"); + return Option.none<PromptBlocker>(); +}); + +export const canPrompt = Effect.map(promptBlocker, Option.isNone); diff --git a/apps/cli/src/command-internal/hosted-consent/hosted-consent.ts b/apps/cli/src/command-internal/hosted-consent/hosted-consent.ts index df51ee90d7..ffc62f0863 100644 --- a/apps/cli/src/command-internal/hosted-consent/hosted-consent.ts +++ b/apps/cli/src/command-internal/hosted-consent/hosted-consent.ts @@ -3,46 +3,65 @@ import { Effect, Option } from "effect"; import { CommandTarget } from "../../shared/output/command-target.service.ts"; import { CONTEXT_CANCELED_MESSAGE } from "../../shared/output/errors.ts"; import { Output } from "../../shared/output/output.service.ts"; -import { isMachineResourceFormat } from "../../shared/output/resource-format.ts"; -import { Tty } from "../../shared/runtime/tty.service.ts"; import type { ResolvedDbConfig } from "../db-config.types.ts"; -import { OutputFlag, resolveYes } from "../global-flags.ts"; import type { ResolvedTarget } from "../target/resolve-target.ts"; import { TargetPolicy } from "../target/target-policy.ts"; import { HostedMutationConsentRequiredError, HostedMutationDeclinedError, } from "./hosted-consent.errors.ts"; +import { promptBlocker } from "./hosted-consent.prompt.ts"; +import type { PromptBlocker } from "./hosted-consent.prompt.ts"; import type { HostedMutationConsent } from "./hosted-consent.token.ts"; +import { ignoredYesFiles, resolveHostedYes } from "./hosted-consent.yes.ts"; export interface HostedMutationRequest { - /** What is about to change, phrased to follow "Confirmed by --yes:". */ + /** An imperative, sentence-case phrase with no trailing period, such as "Apply 3 migrations". */ readonly action: string; /** Plan lines printed before the question. */ readonly details?: ReadonlyArray<string>; /** Asks for `expected` to be typed instead of a yes/no answer. */ readonly confirm?: { readonly kind: "typed"; readonly expected: string }; + /** The command has a `--read-only` mode that needs no confirmation. */ + readonly readOnlyAlternative?: boolean; } // The brand is type-only, so minting a token needs this one assertion. const grant = (action: string, via: HostedMutationConsent["via"]): HostedMutationConsent => ({ action, via }) as HostedMutationConsent; -const announcedTargetSuffix = Effect.serviceOption(CommandTarget).pipe( +const announcedTarget = Effect.serviceOption(CommandTarget).pipe( Effect.flatMap( Option.match({ - onNone: () => Effect.succeed(""), - onSome: (cell) => - Effect.map(cell.text, Option.match({ onNone: () => "", onSome: (text) => ` on ${text}` })), + onNone: () => Effect.succeed(Option.none<string>()), + onSome: (cell) => cell.text, }), ), ); -const consentRequired = (request: HostedMutationRequest, onTarget: string) => - new HostedMutationConsentRequiredError({ - message: `${request.action}${onTarget} needs confirmation, and this run can't prompt. Nothing was changed.`, - suggestion: `Re-run with --yes to confirm ${request.action}, or run it in an interactive terminal.`, +const consentRequired = Effect.fnUntraced(function* ( + request: HostedMutationRequest, + target: Option.Option<string>, + blocker: PromptBlocker, +) { + const ignored = yield* ignoredYesFiles; + const suggestions = [ + blocker.agent + ? "This is an agent run: ask the user before re-running with --yes." + : "Re-run with --yes to confirm.", + ...(request.readOnlyAlternative === true + ? ["For a read-only statement, pass --read-only instead."] + : []), + ...(ignored.length > 0 + ? [`SUPABASE_YES in ${ignored.join(", ")} does not confirm hosted changes; pass --yes.`] + : []), + ]; + const where = Option.match(target, { onNone: () => "", onSome: (text) => ` (target: ${text})` }); + return yield* new HostedMutationConsentRequiredError({ + message: `Confirmation required: ${request.action}${where}. This run can't ask because ${blocker.reason}. Nothing was changed.`, + suggestion: suggestions.join(" "), }); +}); const ask = Effect.fnUntraced(function* ( request: HostedMutationRequest, @@ -62,9 +81,9 @@ const ask = Effect.fnUntraced(function* ( }); /** - * Grants consent for a hosted mutation or fails. `--yes` or a shell `SUPABASE_YES` (never the - * project `.env`) grants it; otherwise only an interactive text-mode terminal is asked. Stdin is - * never read, so a piped answer does not count. + * Grants consent for a hosted mutation or fails. `--yes` or a shell `SUPABASE_YES` (never a dotenv + * file) grants it; otherwise only an interactive text-mode terminal with no agent driving it is + * asked. Stdin is never read, so a piped answer does not count. */ export const requireHostedMutationConsent = Effect.fn("HostedConsent.require")(function* ( request: HostedMutationRequest, @@ -78,25 +97,23 @@ export const requireHostedMutationConsent = Effect.fn("HostedConsent.require")(f ); } const output = yield* Output; - const tty = yield* Tty; - const outputFlag = yield* OutputFlag; - const onTarget = yield* announcedTargetSuffix; + const target = yield* announcedTarget; + const onTarget = Option.match(target, { onNone: () => "", onSome: (text) => ` on ${text}` }); - if (yield* resolveYes) { - yield* output.raw(`Confirmed by --yes: ${request.action}${onTarget}\n`, "stderr"); + const approvedBy = yield* resolveHostedYes; + if (Option.isSome(approvedBy)) { + yield* output.raw(`Confirmed by ${approvedBy.value}: ${request.action}${onTarget}\n`, "stderr"); yield* Effect.annotateCurrentSpan("consent.via", "yes"); return grant(request.action, "yes"); } - const canPrompt = - output.format === "text" && - output.interactive && - tty.stdinIsTty && - !isMachineResourceFormat(outputFlag); - if (!canPrompt) return yield* consentRequired(request, onTarget); + const blocker = yield* promptBlocker; + if (Option.isSome(blocker)) return yield* consentRequired(request, target, blocker.value); const confirmed = yield* ask(request, policy.consent === "yes", onTarget).pipe( - Effect.catchTag("NonInteractiveError", () => consentRequired(request, onTarget)), + Effect.catchTag("NonInteractiveError", () => + consentRequired(request, target, { reason: "no prompt is available", agent: false }), + ), ); if (!confirmed) { return yield* new HostedMutationDeclinedError({ message: CONTEXT_CANCELED_MESSAGE }); @@ -116,6 +133,8 @@ export const requireTargetMutationConsent = Effect.fn("HostedConsent.requireForT }); /** `Some` only under `--yes` or a shell `SUPABASE_YES`; never prompts, so a read can hand it to a connection that may clear a network ban. */ -export const hostedMutationPreapproval = Effect.map(resolveYes, (yes) => - yes ? Option.some(grant("remove network bans", "yes")) : Option.none<HostedMutationConsent>(), +export const hostedMutationPreapproval = Effect.map(resolveHostedYes, (approvedBy) => + Option.isSome(approvedBy) + ? Option.some(grant("Remove network bans", "yes")) + : Option.none<HostedMutationConsent>(), ); diff --git a/apps/cli/src/command-internal/hosted-consent/hosted-consent.unit.test.ts b/apps/cli/src/command-internal/hosted-consent/hosted-consent.unit.test.ts index 6582b2c8c6..59429730ec 100644 --- a/apps/cli/src/command-internal/hosted-consent/hosted-consent.unit.test.ts +++ b/apps/cli/src/command-internal/hosted-consent/hosted-consent.unit.test.ts @@ -2,16 +2,20 @@ import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; import { Cause, Effect, Exit, FileSystem, Layer, Option, Path, Redacted } from "effect"; -import { withConfigEnv, withEmptyConfigEnv } from "../../../tests/helpers/command-mocks.ts"; -import { mockOutput, mockTty } from "../../../tests/helpers/mocks.ts"; +import { + mockCommandSettings, + withConfigEnv, + withEmptyConfigEnv, +} from "../../../tests/helpers/command-mocks.ts"; +import { mockOutput, mockRuntimeInfo, mockTty } from "../../../tests/helpers/mocks.ts"; import { CliArgs } from "../../shared/cli/cli-args.service.ts"; import { commandTargetLayer } from "../../shared/output/command-target.layer.ts"; import { announceTarget } from "../../shared/output/command-target.service.ts"; import type { OutputFormat } from "../../shared/output/types.ts"; +import { AiTool } from "../../shared/telemetry/ai-tool.service.ts"; import { classifyCliErrorActionability } from "../../shared/telemetry/error-actionability.ts"; -import { loadProjectEnvValues } from "../db-config.toml-read.ts"; import type { ResolvedDbConfig } from "../db-config.types.ts"; -import { OutputFlag, YesFlag } from "../global-flags.ts"; +import { AgentFlag, OutputFlag, YesFlag } from "../global-flags.ts"; import type { ResolvedTarget } from "../target/resolve-target.ts"; import { TargetPolicy } from "../target/target-policy.ts"; import type { TargetPolicyShape } from "../target/target-policy.ts"; @@ -40,11 +44,17 @@ interface Scenario { readonly textResponses?: ReadonlyArray<string>; readonly promptFails?: boolean; readonly shellYes?: string; + readonly env?: Readonly<Record<string, string>>; readonly announced?: string; + readonly agent?: "auto" | "yes" | "no"; + readonly aiTool?: string; + readonly cwd?: string; + readonly workdir?: string; + readonly withoutFilesystem?: boolean; } const REQUEST: HostedMutationRequest = { - action: "apply 3 migrations", + action: "Apply 3 migrations", details: [" 20240101_a.sql", " 20240102_b.sql"], }; @@ -72,11 +82,24 @@ const harness = (scenario: Scenario) => { scenario.outputFlag === undefined ? Option.none() : Option.some(scenario.outputFlag), ), Layer.succeed(TargetPolicy, scenario.policy ?? { group: "hosted", consent: "no" }), + scenario.agent === undefined ? Layer.empty : Layer.succeed(AgentFlag, scenario.agent), + scenario.aiTool === undefined + ? Layer.empty + : Layer.succeed(AiTool, { name: Option.some(scenario.aiTool) }), + scenario.withoutFilesystem === true + ? Layer.empty + : Layer.merge(BunServices.layer, mockRuntimeInfo({ cwd: scenario.cwd ?? "/test/project" })), + scenario.workdir === undefined + ? Layer.empty + : mockCommandSettings({ workdir: scenario.workdir }), ); + const shellEnv = { + NODE_ENV: "", + ...(scenario.shellYes === undefined ? {} : { SUPABASE_YES: scenario.shellYes }), + ...scenario.env, + }; const withShellEnv = <A, E, R>(body: Effect.Effect<A, E, R>) => - scenario.shellYes === undefined - ? withEmptyConfigEnv(body) - : withConfigEnv({ SUPABASE_YES: scenario.shellYes }, body); + withEmptyConfigEnv(withConfigEnv(shellEnv, body)); const prepare = <A, E, R>(body: Effect.Effect<A, E, R>) => withShellEnv( (scenario.announced === undefined @@ -122,7 +145,7 @@ describe("requireHostedMutationConsent", () => { }); yield* run.exit; expect(run.out.promptConfirmCalls).toEqual([ - { message: "apply 3 migrations?", opts: { defaultValue: expected } }, + { message: "Apply 3 migrations?", opts: { defaultValue: expected } }, ]); }), ); @@ -147,10 +170,10 @@ describe("requireHostedMutationConsent", () => { announced: "project abcdefghijklmnopqrst", }); const token = successOf(yield* run.exit); - expect(token).toMatchObject({ action: "apply 3 migrations", via: "yes" }); + expect(token).toMatchObject({ action: "Apply 3 migrations", via: "yes" }); expect(run.out.promptConfirmCalls).toEqual([]); expect(run.out.stderrText).toContain( - "Confirmed by --yes: apply 3 migrations on project abcdefghijklmnopqrst\n", + "Confirmed by --yes: Apply 3 migrations on project abcdefghijklmnopqrst\n", ); }), ); @@ -159,7 +182,7 @@ describe("requireHostedMutationConsent", () => { Effect.gen(function* () { const run = consent({ yes: true }); yield* run.exit; - expect(run.out.stderrText).toBe("Confirmed by --yes: apply 3 migrations\n"); + expect(run.out.stderrText).toBe("Confirmed by --yes: Apply 3 migrations\n"); }), ); @@ -184,39 +207,134 @@ describe("requireHostedMutationConsent", () => { }); describe("SUPABASE_YES", () => { - it.effect("grants when set in the shell environment", () => + const withDotenvDir = <A, E, R>( + files: Readonly<Record<string, string>>, + body: (dir: string) => Effect.Effect<A, E, R>, + ) => Effect.gen(function* () { - const run = consent({ shellYes: "1", format: "json" }); - const token = successOf(yield* run.exit); - expect(token.via).toBe("yes"); - expect(run.out.stderrText).toContain("Confirmed by --yes: apply 3 migrations"); - }), + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const dir = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-consent-" }); + for (const [name, contents] of Object.entries(files)) { + yield* fs.makeDirectory(path.dirname(path.join(dir, name)), { recursive: true }); + yield* fs.writeFileString(path.join(dir, name), contents); + } + return yield* body(dir); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)); + + it.effect( + "grants when set in the shell environment and echoes SUPABASE_YES as the source", + () => + Effect.gen(function* () { + const run = consent({ shellYes: "1", format: "json" }); + const token = successOf(yield* run.exit); + expect(token.via).toBe("yes"); + expect(run.out.stderrText).toContain("Confirmed by SUPABASE_YES: Apply 3 migrations"); + }), ); it.effect("does not grant when it is only in the project .env", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const workdir = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-consent-" }); - yield* fs.makeDirectory(path.join(workdir, "supabase"), { recursive: true }); - yield* fs.writeFileString(path.join(workdir, "supabase", ".env"), "SUPABASE_YES=1\n"); - expect(yield* loadProjectEnvValues(fs, path, workdir)).toMatchObject({ SUPABASE_YES: "1" }); + withDotenvDir({ "supabase/.env": "SUPABASE_YES=1\n" }, (dir) => + Effect.gen(function* () { + const exit = yield* consent({ cwd: dir, workdir: dir }).exit; + expect(failureOf(exit)).toBeInstanceOf(HostedMutationConsentRequiredError); + }), + ), + ); + + it.effect.each([".env", ".env.local", ".env.development", ".env.development.local"])( + "does not grant a shell value that matches %s in the working directory", + (name) => + withDotenvDir({ [name]: "SUPABASE_YES=1\n" }, (cwd) => + Effect.gen(function* () { + const exit = yield* consent({ shellYes: "1", cwd }).exit; + expect(failureOf(exit)).toBeInstanceOf(HostedMutationConsentRequiredError); + }), + ), + ); + + it.effect("checks the dotenv files NODE_ENV selects", () => + withDotenvDir({ ".env.production": "SUPABASE_YES=1\n" }, (cwd) => + Effect.gen(function* () { + const production = yield* consent({ + shellYes: "1", + cwd, + env: { NODE_ENV: "production" }, + }).exit; + expect(failureOf(production)).toBeInstanceOf(HostedMutationConsentRequiredError); + const development = yield* consent({ shellYes: "1", cwd }).exit; + expect(successOf(development).via).toBe("yes"); + }), + ), + ); - const exit = yield* consent({}).exit; + it.effect("grants a shell value that differs from the working directory's file", () => + withDotenvDir({ ".env": "SUPABASE_YES=true\n" }, (cwd) => + Effect.gen(function* () { + const exit = yield* consent({ shellYes: "1", cwd }).exit; + expect(successOf(exit).via).toBe("yes"); + }), + ), + ); + + it.effect("grants when the working directory's dotenv files do not mention it", () => + withDotenvDir({ ".env": "OTHER=1\n" }, (cwd) => + Effect.gen(function* () { + const exit = yield* consent({ shellYes: "1", cwd }).exit; + expect(successOf(exit).via).toBe("yes"); + }), + ), + ); + + it.effect("does not grant when a dotenv file that does not parse mentions it", () => + withDotenvDir({ ".env": "SUPABASE_YES=1\n!!!\n" }, (cwd) => + Effect.gen(function* () { + const exit = yield* consent({ shellYes: "1", cwd }).exit; + expect(failureOf(exit)).toBeInstanceOf(HostedMutationConsentRequiredError); + }), + ), + ); + + it.effect("does not grant when the filesystem cannot be checked", () => + Effect.gen(function* () { + const exit = yield* consent({ shellYes: "1", withoutFilesystem: true }).exit; expect(failureOf(exit)).toBeInstanceOf(HostedMutationConsentRequiredError); - }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), + }), + ); + + it.effect("still grants --yes when a working-directory .env sets the variable", () => + withDotenvDir({ ".env": "SUPABASE_YES=1\n" }, (cwd) => + Effect.gen(function* () { + const run = consent({ yes: true, cwd }); + expect(successOf(yield* run.exit).via).toBe("yes"); + expect(run.out.stderrText).toContain("Confirmed by --yes: Apply 3 migrations"); + }), + ), ); }); describe("when the run cannot prompt", () => { it.effect.each([ - { name: "json output", scenario: { format: "json", stdinIsTty: true } }, - { name: "stream-json output", scenario: { format: "stream-json", stdinIsTty: true } }, + { + name: "json output", + scenario: { format: "json", stdinIsTty: true }, + reason: "its output format is json", + }, + { + name: "stream-json output", + scenario: { format: "stream-json", stdinIsTty: true }, + reason: "its output format is stream-json", + }, { name: "-o yaml in a terminal", scenario: { ...interactiveTerminal, outputFlag: "yaml" }, + reason: "it writes machine-readable -o yaml output", + }, + { + name: "-o json in a terminal", + scenario: { ...interactiveTerminal, outputFlag: "json" }, + reason: "it writes machine-readable -o json output", }, - { name: "-o json in a terminal", scenario: { ...interactiveTerminal, outputFlag: "json" } }, { name: "piped stdin carrying an affirmative answer", scenario: { @@ -225,33 +343,126 @@ describe("requireHostedMutationConsent", () => { stdinIsTty: false, confirmResponses: [true], }, + reason: "stdin is not a terminal", }, { name: "a non-interactive stdout", scenario: { format: "text", interactive: false, stdinIsTty: true }, + reason: "stdout is not a terminal", }, - ] as const satisfies ReadonlyArray<{ name: string; scenario: Scenario }>)( + ] as const satisfies ReadonlyArray<{ name: string; scenario: Scenario; reason: string }>)( "fails with ConsentRequiredError for $name and never asks", - ({ scenario }) => + ({ scenario, reason }) => Effect.gen(function* () { const run = consent({ ...scenario, announced: "project abcdefghijklmnopqrst" }); const error = failureOf(yield* run.exit); expect(error).toBeInstanceOf(HostedMutationConsentRequiredError); expect(error).toMatchObject({ - message: - "apply 3 migrations on project abcdefghijklmnopqrst needs confirmation, and this run can't prompt. Nothing was changed.", - suggestion: - "Re-run with --yes to confirm apply 3 migrations, or run it in an interactive terminal.", + message: `Confirmation required: Apply 3 migrations (target: project abcdefghijklmnopqrst). This run can't ask because ${reason}. Nothing was changed.`, + suggestion: "Re-run with --yes to confirm.", }); expect(run.out.promptConfirmCalls).toEqual([]); expect(run.out.promptTextCalls).toEqual([]); }), ); + it.effect("leaves the target out of the message when nothing was announced", () => + Effect.gen(function* () { + const error = failureOf(yield* consent({ format: "json" }).exit); + expect(error).toMatchObject({ + message: + "Confirmation required: Apply 3 migrations. This run can't ask because its output format is json. Nothing was changed.", + }); + }), + ); + it.effect("fails with ConsentRequiredError when the prompt turns out to be unavailable", () => Effect.gen(function* () { - const exit = yield* consent({ ...interactiveTerminal, promptFails: true }).exit; - expect(failureOf(exit)).toBeInstanceOf(HostedMutationConsentRequiredError); + const error = failureOf(yield* consent({ ...interactiveTerminal, promptFails: true }).exit); + expect(error).toBeInstanceOf(HostedMutationConsentRequiredError); + expect(error).toMatchObject({ + message: expect.stringContaining("because no prompt is available"), + }); + }), + ); + + it.effect("suggests --read-only when the command has a read-only mode", () => + Effect.gen(function* () { + const error = failureOf( + yield* consent({ format: "json" }, { ...REQUEST, readOnlyAlternative: true }).exit, + ); + expect(error).toMatchObject({ + suggestion: + "Re-run with --yes to confirm. For a read-only statement, pass --read-only instead.", + }); + }), + ); + + it.effect("says a project .env SUPABASE_YES does not confirm hosted changes", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const dir = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-consent-" }); + yield* fs.makeDirectory(path.join(dir, "supabase"), { recursive: true }); + yield* fs.writeFileString(path.join(dir, "supabase", ".env"), "SUPABASE_YES=1\n"); + const error = failureOf(yield* consent({ format: "json", cwd: dir, workdir: dir }).exit); + expect(error).toMatchObject({ + suggestion: + "Re-run with --yes to confirm. SUPABASE_YES in supabase/.env does not confirm hosted changes; pass --yes.", + }); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), + ); + + it.effect("says a working-directory .env SUPABASE_YES does not confirm hosted changes", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const dir = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-consent-" }); + yield* fs.writeFileString(path.join(dir, ".env"), "SUPABASE_YES=1\n"); + const error = failureOf(yield* consent({ format: "json", shellYes: "1", cwd: dir }).exit); + expect(error).toMatchObject({ + suggestion: + "Re-run with --yes to confirm. SUPABASE_YES in .env does not confirm hosted changes; pass --yes.", + }); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), + ); + }); + + describe("agents", () => { + it.effect.each([ + { name: "--agent yes", scenario: { agent: "yes" } }, + { name: "a detected agent", scenario: { agent: "auto", aiTool: "claude_code" } }, + ] as const satisfies ReadonlyArray<{ name: string; scenario: Scenario }>)( + "refuses to prompt for $name in an interactive terminal", + ({ scenario }) => + Effect.gen(function* () { + const run = consent({ ...interactiveTerminal, ...scenario, confirmResponses: [true] }); + const error = failureOf(yield* run.exit); + expect(error).toBeInstanceOf(HostedMutationConsentRequiredError); + expect(error).toMatchObject({ + message: expect.stringContaining("because it is running in agent mode"), + suggestion: "This is an agent run: ask the user before re-running with --yes.", + }); + expect(run.out.promptConfirmCalls).toEqual([]); + }), + ); + + it.effect("prompts when --agent no overrides a detected agent", () => + Effect.gen(function* () { + const run = consent({ + ...interactiveTerminal, + agent: "no", + aiTool: "claude_code", + confirmResponses: [true], + }); + expect(successOf(yield* run.exit).via).toBe("prompt"); + }), + ); + + it.effect("grants --yes to an agent", () => + Effect.gen(function* () { + const run = consent({ ...interactiveTerminal, yes: true, agent: "yes" }); + expect(successOf(yield* run.exit).via).toBe("yes"); }), ); }); @@ -265,12 +476,12 @@ describe("requireHostedMutationConsent", () => { announced: "project abcdefghijklmnopqrst", }); const token = successOf(yield* run.exit); - expect(token).toMatchObject({ action: "apply 3 migrations", via: "prompt" }); + expect(token).toMatchObject({ action: "Apply 3 migrations", via: "prompt" }); expect(run.out.stderrText).toBe( "Target: project abcdefghijklmnopqrst\n 20240101_a.sql\n 20240102_b.sql\n", ); expect(run.out.promptConfirmCalls.map((call) => call.message)).toEqual([ - "apply 3 migrations on project abcdefghijklmnopqrst?", + "Apply 3 migrations on project abcdefghijklmnopqrst?", ]); }), ); @@ -362,6 +573,16 @@ describe("hostedMutationPreapproval", () => { ); }), ); + + it.effect("is None for a shell value that a working-directory .env also sets", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const cwd = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-consent-" }); + yield* fs.writeFileString(path.join(cwd, ".env.local"), "SUPABASE_YES=1\n"); + expect(Option.isNone(yield* preapproval({ shellYes: "1", cwd }))).toBe(true); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), + ); }); describe("requireTargetMutationConsent", () => { diff --git a/apps/cli/src/command-internal/hosted-consent/hosted-consent.yes.ts b/apps/cli/src/command-internal/hosted-consent/hosted-consent.yes.ts new file mode 100644 index 0000000000..5999b2972d --- /dev/null +++ b/apps/cli/src/command-internal/hosted-consent/hosted-consent.yes.ts @@ -0,0 +1,100 @@ +import { Effect, FileSystem, Option, Path } from "effect"; + +import { CommandSettings } from "../../config/command-settings.service.ts"; +import { candidateDotenvFilenames } from "../../shared/config/cli-config-env.ts"; +import { parseDotEnv } from "../../shared/config/dotenv.ts"; +import { envValue } from "../../shared/config/env-option.ts"; +import { RuntimeInfo } from "../../shared/runtime/runtime-info.service.ts"; +import { resolveYesSource } from "../global-flags.ts"; +import { parseEnvBool } from "../supabase-env.ts"; + +export type HostedYesSource = "--yes" | "SUPABASE_YES"; + +const YES_VARIABLE = "SUPABASE_YES"; + +interface DotenvDefinition { + readonly file: string; + /** `None` when the file does not parse but mentions the variable, which counts as any value. */ + readonly value: Option.Option<string>; +} + +interface Platform { + readonly fs: FileSystem.FileSystem; + readonly path: Path.Path; + readonly cwd: string; +} + +// Without a filesystem the origin of `SUPABASE_YES` cannot be checked, so callers treat it as unverified. +const platform = Effect.gen(function* () { + const fs = yield* Effect.serviceOption(FileSystem.FileSystem); + const path = yield* Effect.serviceOption(Path.Path); + const runtime = yield* Effect.serviceOption(RuntimeInfo); + return Option.all({ fs, path, runtime }).pipe( + Option.map(({ fs, path, runtime }): Platform => ({ fs, path, cwd: runtime.cwd })), + ); +}); + +const envNameFrom = (variable: string) => + Effect.map(envValue(variable), (value) => + value === undefined || value === "" ? "development" : value, + ); + +const definitionsIn = Effect.fnUntraced(function* (host: Platform, dir: string, env: string) { + const found: Array<DotenvDefinition> = []; + for (const name of candidateDotenvFilenames(env)) { + const file = host.path.join(dir, name); + const content = yield* host.fs.readFileString(file).pipe(Effect.option); + if (Option.isNone(content)) continue; + const parsed = yield* Effect.try(() => parseDotEnv(content.value)).pipe(Effect.option); + if (Option.isNone(parsed)) { + if (content.value.includes(YES_VARIABLE)) found.push({ file, value: Option.none() }); + } else if (Object.hasOwn(parsed.value, YES_VARIABLE)) { + found.push({ file, value: Option.some(parsed.value[YES_VARIABLE]!) }); + } + } + return found; +}); + +/** The files Bun loads into `process.env` from the working directory before any CLI code runs. */ +const autoloadedDefinitions = (host: Platform) => + Effect.flatMap(envNameFrom("NODE_ENV"), (env) => definitionsIn(host, host.cwd, env)); + +/** + * Confirms a hosted change only for `--yes` or a `SUPABASE_YES` the shell exported. Bun copies + * working-directory dotenv files into the environment, and a shell variable overrides them, so a + * value that matches one of those files is treated as the file's and not as consent. + */ +export const resolveHostedYes = Effect.gen(function* () { + const source = yield* resolveYesSource; + if (Option.isNone(source)) return Option.none<HostedYesSource>(); + if (source.value.via === "flag") return Option.some<HostedYesSource>("--yes"); + const { raw } = source.value; + const host = yield* platform; + if (Option.isNone(host)) return Option.none<HostedYesSource>(); + const fromFile = (yield* autoloadedDefinitions(host.value)).some( + ({ value }) => Option.isNone(value) || value.value === raw, + ); + return fromFile ? Option.none<HostedYesSource>() : Option.some<HostedYesSource>("SUPABASE_YES"); +}); + +/** Dotenv files, relative to the working directory, whose `SUPABASE_YES` does not confirm a hosted change. */ +export const ignoredYesFiles = Effect.gen(function* () { + const host = yield* platform; + if (Option.isNone(host)) return []; + const { path, cwd } = host.value; + const workdir = Option.match(yield* Effect.serviceOption(CommandSettings), { + onNone: () => cwd, + onSome: (settings) => settings.workdir, + }); + const projectEnv = yield* envNameFrom("SUPABASE_ENV"); + + const definitions = [ + ...(yield* autoloadedDefinitions(host.value)), + ...(yield* definitionsIn(host.value, path.join(workdir, "supabase"), projectEnv)), + ...(yield* definitionsIn(host.value, workdir, projectEnv)), + ]; + const files = definitions + .filter(({ value }) => Option.isNone(value) || parseEnvBool(value.value)) + .map(({ file }) => path.relative(cwd, file)); + return [...new Set(files)]; +}); diff --git a/apps/cli/src/command-internal/project-create-core.ts b/apps/cli/src/command-internal/project-create-core.ts index 165ef55133..29108d2809 100644 --- a/apps/cli/src/command-internal/project-create-core.ts +++ b/apps/cli/src/command-internal/project-create-core.ts @@ -131,7 +131,7 @@ export const projectCreateCore = Effect.fn("ProjectCreate.run")(function* ( if (input.consent === undefined) { yield* requireHostedMutationConsent({ - action: `create project "${name}" in organization ${orgId}`, + action: `Create project "${name}" in organization ${orgId}`, }); } diff --git a/apps/cli/src/command-internal/supabase-env.ts b/apps/cli/src/command-internal/supabase-env.ts index b4016143b6..477c35de5d 100644 --- a/apps/cli/src/command-internal/supabase-env.ts +++ b/apps/cli/src/command-internal/supabase-env.ts @@ -13,7 +13,7 @@ import { envValue } from "../shared/config/env-option.ts"; const TRUE_VALUES = new Set(["1", "t", "T", "TRUE", "true", "True"]); /** Truthiness for an already-resolved env value (see module doc). */ -function parseEnvBool(raw: string | undefined): boolean { +export function parseEnvBool(raw: string | undefined): boolean { return raw !== undefined && TRUE_VALUES.has(raw); } diff --git a/apps/cli/src/command-internal/target/target-policy.ts b/apps/cli/src/command-internal/target/target-policy.ts index 744a22d524..93cd1ad1fc 100644 --- a/apps/cli/src/command-internal/target/target-policy.ts +++ b/apps/cli/src/command-internal/target/target-policy.ts @@ -3,20 +3,32 @@ import { Command } from "effect/cli"; export type TargetGroup = "local-only" | "account" | "hosted" | "sync" | "selectable" | "removed"; -interface TargetPolicyBase { +export interface TargetPolicyShape { readonly group: TargetGroup; + readonly consent: "none" | "no" | "yes"; + readonly consentExemptReason?: string | undefined; } -/** `consentExemptReason` is only meaningful on a leaf that never asks for hosted-mutation consent. */ -export type TargetPolicyShape = - | (TargetPolicyBase & { +/** + * What a leaf declares. `consentExemptReason` explains why a leaf never asks for hosted-mutation + * consent; a `sync` or `selectable` leaf must give one because it can reach a hosted database. + */ +export type TargetPolicyDeclaration = + | { + readonly group: "sync" | "selectable"; + readonly consent: "none"; + readonly consentExemptReason: string; + } + | { + readonly group: Exclude<TargetGroup, "sync" | "selectable">; readonly consent: "none"; readonly consentExemptReason?: string; - }) - | (TargetPolicyBase & { + } + | { + readonly group: TargetGroup; readonly consent: "no" | "yes"; readonly consentExemptReason?: never; - }); + }; /** The command's target group and hosted-mutation consent default, provided to its handler. */ export class TargetPolicy extends Context.Service<TargetPolicy, TargetPolicyShape>()( @@ -34,7 +46,7 @@ export class TargetPolicyAnnotation extends Context.Service< * other `Command.provide` layers so every one of them can read `TargetPolicy`. */ export const withTargetPolicy = - (policy: TargetPolicyShape) => + (policy: TargetPolicyDeclaration) => <const Name extends string, Input, ContextInput, E, R>( self: Command.Command<Name, Input, ContextInput, E, R>, ) => diff --git a/apps/cli/src/command-internal/target/target-policy.unit.test.ts b/apps/cli/src/command-internal/target/target-policy.unit.test.ts index de0d88e0cf..63062014ad 100644 --- a/apps/cli/src/command-internal/target/target-policy.unit.test.ts +++ b/apps/cli/src/command-internal/target/target-policy.unit.test.ts @@ -31,12 +31,20 @@ const CONSENT_FREE_OPERATIONS: ReadonlyMap<string, string> = new Map([ ["v1-list-all-network-bans", "a POST that only reads the ban list"], ["v1-verify-dns-config", "re-runs verification and changes no configuration"], ["v1-check-vanity-subdomain-availability", "a POST that only reads availability"], +]); + +/** Non-GET operations one leaf may call without consent; the leaf must also declare `consentExemptReason`. */ +const LEAF_CONSENT_FREE_OPERATIONS: ReadonlyMap<string, ReadonlyMap<string, string>> = new Map([ [ - "v2-create-notebook", - "notebooks pull creates remote notebooks only after an interactive reconcile choice", + "notebooks pull", + new Map([["v2-create-notebook", "creates remote notebooks only after an interactive choice"]]), ], ]); +const isConsentFree = (path: string, operationId: string) => + CONSENT_FREE_OPERATIONS.has(operationId) || + LEAF_CONSENT_FREE_OPERATIONS.get(path)?.has(operationId) === true; + const MUTATING_VERBS: ReadonlySet<string> = new Set([ "create", "delete", @@ -144,14 +152,37 @@ describe.each(leavesByVariant)( return permissions.operations .filter( ({ operationId }) => - OPERATIONS.get(operationId)?.method !== "GET" && - !CONSENT_FREE_OPERATIONS.has(operationId), + OPERATIONS.get(operationId)?.method !== "GET" && !isConsentFree(path, operationId), ) .map(({ operationId }) => `${path}: ${operationId}`); }); expect([...new Set(offenders)]).toEqual([]); }); + it("requires an exemption reason on a sync or selectable leaf that never asks for consent", () => { + const offenders = withPolicy + .filter(({ policy }) => policy.group === "sync" || policy.group === "selectable") + .filter( + ({ policy }) => + policy.consent === "none" && (policy.consentExemptReason ?? "").trim() === "", + ) + .map(({ path }) => path); + expect(offenders).toEqual([]); + }); + + it("gives every leaf-scoped consent-free operation a leaf that declares its reason", () => { + const reasonByPath = new Map(withPolicy.map(({ path, policy }) => [path, policy])); + const offenders = [...LEAF_CONSENT_FREE_OPERATIONS.keys()].filter((path) => { + const policy = reasonByPath.get(path); + return ( + policy === undefined || + policy.consent !== "none" || + (policy.consentExemptReason ?? "").trim() === "" + ); + }); + expect(offenders).toEqual([]); + }); + it("requires consent or an exemption reason on a leaf named for a mutating verb", () => { const offenders = withPolicy .filter(({ path }) => MUTATING_VERBS.has(lastWord(path))) @@ -167,9 +198,10 @@ describe.each(leavesByVariant)( describe("target policy allowlists", () => { it("name only operations in the API spec", () => { - const unknown = [...CONSENT_FREE_OPERATIONS.keys()].filter( - (operationId) => !OPERATIONS.has(operationId), - ); + const unknown = [ + ...CONSENT_FREE_OPERATIONS.keys(), + ...[...LEAF_CONSENT_FREE_OPERATIONS.values()].flatMap((operations) => [...operations.keys()]), + ].filter((operationId) => !OPERATIONS.has(operationId)); expect(unknown).toEqual([]); }); }); @@ -192,6 +224,15 @@ describe("withTargetPolicy", () => { expect(readTargetPolicy(leaf)).toEqual(Option.some({ group: "hosted", consent: "no" })); }); + it("requires a reason from a sync or selectable declaration that never asks for consent", () => { + // @ts-expect-error a leaf that can reach a hosted database must say why it never asks + withTargetPolicy({ group: "sync", consent: "none" }); + // @ts-expect-error a leaf that can reach a hosted database must say why it never asks + withTargetPolicy({ group: "selectable", consent: "none" }); + withTargetPolicy({ group: "selectable", consent: "none", consentExemptReason: "read-only" }); + withTargetPolicy({ group: "hosted", consent: "none" }); + }); + it("reads no policy from an unannotated command", () => { expect(readTargetPolicy(Command.make("leaf"))).toEqual(Option.none()); }); diff --git a/apps/cli/src/commands/config/push/SIDE_EFFECTS.md b/apps/cli/src/commands/config/push/SIDE_EFFECTS.md index 605e1a69a7..1d7abe45b3 100644 --- a/apps/cli/src/commands/config/push/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/config/push/SIDE_EFFECTS.md @@ -213,10 +213,10 @@ full plan. When at least one resource (or the webhooks enable) will be written, a price line `Enabling <name> will cost you <price>.` prints for each paid add-on the push would -switch on, then one question is asked: `push config changes to <API, DB and Auth…> on +switch on, then one question is asked: `Push config changes to <API, DB and Auth…> on <target>?` — the services are the ones that will be written, in push order — with a default answer of **no**. `--yes` or a `SUPABASE_YES` set in the shell skips the question -and echoes `Confirmed by --yes: push config changes to … on <target>` to stderr; a +and echoes `Confirmed by --yes: Push config changes to … on <target>` to stderr; a `SUPABASE_YES` in a project `.env*` file does not count. Piped answers are never read, and a run that cannot prompt (stdin not a TTY, redirected stdout, or machine output) fails without `--yes`. A push with nothing to write never asks. Declining fails the command diff --git a/apps/cli/src/commands/config/push/push.format.ts b/apps/cli/src/commands/config/push/push.format.ts index 595e21fe76..0a4b5a2881 100644 --- a/apps/cli/src/commands/config/push/push.format.ts +++ b/apps/cli/src/commands/config/push/push.format.ts @@ -437,7 +437,7 @@ const CONSENT_SERVICE_NAME: Readonly<Record<PushResource | "experimental.webhook "experimental.webhooks": "Webhooks", }; -/** The consent action for a push that will write `services`, phrased to follow "Confirmed by --yes:". */ +/** The consent action for a push that will write `services`. */ export function pushConsentAction( services: ReadonlyArray<PushResource | "experimental.webhooks">, ): string { @@ -445,7 +445,7 @@ export function pushConsentAction( const last = names.at(-1); const list = names.length > 1 && last !== undefined ? `${names.slice(0, -1).join(", ")} and ${last}` : last; - return `push config changes to ${list}`; + return `Push config changes to ${list}`; } /** The price line shown before consent for a paid addon the push would switch on. */ 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 ce990e45d3..064840506b 100644 --- a/apps/cli/src/commands/config/push/push.integration.test.ts +++ b/apps/cli/src/commands/config/push/push.integration.test.ts @@ -884,7 +884,7 @@ max_rows = 1000 return Effect.gen(function* () { yield* configPush({ projectRef: Option.none() }); expect(out.promptConfirmCalls).toHaveLength(1); - expect(out.promptConfirmCalls[0]?.message).toContain("push config changes to API and Auth"); + expect(out.promptConfirmCalls[0]?.message).toContain("Push config changes to API and Auth"); expect(out.promptConfirmCalls[0]?.opts?.defaultValue).toBe(false); expect(api.requests.filter((r) => r.method === "PATCH")).toHaveLength(2); }).pipe(Effect.provide(layer)); @@ -912,7 +912,7 @@ max_rows = 1000 return Effect.gen(function* () { yield* configPush({ projectRef: Option.none() }); expect(out.stderrText).toContain( - `Confirmed by --yes: push config changes to API on project ${REF}`, + `Confirmed by --yes: Push config changes to API on project ${REF}`, ); expect(api.requests.some((r) => r.method === "PATCH" && r.url.includes("/postgrest"))).toBe( true, @@ -1676,7 +1676,7 @@ secret = "my-plaintext-secret" expect(out.stderrText).toMatch( /\n\nauth\.captcha\.secret \[secret\]\n {2}local: {2}\(set\)\n {2}remote: \(not set\)\n\n/, ); - expect(out.stderrText).toContain("Confirmed by --yes: push config changes to Auth on "); + expect(out.stderrText).toContain("Confirmed by --yes: Push config changes to Auth on "); }).pipe(Effect.provide(layer)); }, ); @@ -1988,7 +1988,7 @@ allowed_cidrs_v6 = ["::1/128"] const error = yield* configPush({ projectRef: Option.none() }).pipe(Effect.flip); expect(error).toBeInstanceOf(HostedMutationDeclinedError); expect(out.stderrText).toContain("Enabling webhooks for project:"); - expect(out.promptConfirmCalls[0]?.message).toContain("push config changes to Webhooks"); + expect(out.promptConfirmCalls[0]?.message).toContain("Push config changes to Webhooks"); expect(methodsOf(apiMock)).not.toContain("enableDatabaseWebhook"); }).pipe(Effect.provide(layer)); }); @@ -3146,7 +3146,7 @@ describe("config push branch/project target detection (CLI-2168)", () => { return Effect.gen(function* () { yield* configPush({ projectRef: Option.none() }); expect(out.stderrText).toContain( - `Confirmed by --yes: push config changes to API on project My App (${PARENT_REF}) · branch (${BRANCH_REF})`, + `Confirmed by --yes: Push config changes to API on project My App (${PARENT_REF}) · branch (${BRANCH_REF})`, ); expect(api.requests.some((r) => r.method === "PATCH" && r.url.includes("/postgrest"))).toBe( true, diff --git a/apps/cli/src/commands/db/advisors/advisors.command.ts b/apps/cli/src/commands/db/advisors/advisors.command.ts index 6e8a364977..f26f69606d 100644 --- a/apps/cli/src/commands/db/advisors/advisors.command.ts +++ b/apps/cli/src/commands/db/advisors/advisors.command.ts @@ -95,5 +95,9 @@ export const dbAdvisorsCommand = Command.make("advisors", config).pipe( Command.provide(dbAdvisorsRuntimeLayer), Command.provide(cliConfigValuesLayer), withCliConfigFlags(config), - withTargetPolicy({ group: "selectable", consent: "none" }), + withTargetPolicy({ + group: "selectable", + consent: "none", + consentExemptReason: "its transaction is always rolled back", + }), ); diff --git a/apps/cli/src/commands/db/diff/diff.command.ts b/apps/cli/src/commands/db/diff/diff.command.ts index e6ec8face0..cc4f545dfd 100644 --- a/apps/cli/src/commands/db/diff/diff.command.ts +++ b/apps/cli/src/commands/db/diff/diff.command.ts @@ -147,5 +147,9 @@ export const dbDiffCommand = Command.make("diff", config).pipe( Command.provide(dbDiffRuntimeLayer), Command.provide(cliConfigValuesLayer), withCliConfigFlags(config), - withTargetPolicy({ group: "selectable", consent: "none" }), + withTargetPolicy({ + group: "selectable", + consent: "none", + consentExemptReason: "reads the target and writes only local migration files", + }), ); diff --git a/apps/cli/src/commands/db/dump/dump.command.ts b/apps/cli/src/commands/db/dump/dump.command.ts index ab4afc4dc5..6f05a913b9 100644 --- a/apps/cli/src/commands/db/dump/dump.command.ts +++ b/apps/cli/src/commands/db/dump/dump.command.ts @@ -153,5 +153,9 @@ export const dbDumpCommand = Command.make("dump", config).pipe( Command.provide(dbDumpRuntimeLayer), Command.provide(cliConfigValuesLayer), withCliConfigFlags(config), - withTargetPolicy({ group: "selectable", consent: "none" }), + withTargetPolicy({ + group: "selectable", + consent: "none", + consentExemptReason: "reads the target and writes only a local dump", + }), ); diff --git a/apps/cli/src/commands/db/lint/lint.command.ts b/apps/cli/src/commands/db/lint/lint.command.ts index a90a9c97e3..982eaf59c5 100644 --- a/apps/cli/src/commands/db/lint/lint.command.ts +++ b/apps/cli/src/commands/db/lint/lint.command.ts @@ -77,5 +77,9 @@ export const dbLintCommand = Command.make("lint", config).pipe( Command.provide(dbLintRuntimeLayer), Command.provide(cliConfigValuesLayer), withCliConfigFlags(config), - withTargetPolicy({ group: "selectable", consent: "none" }), + withTargetPolicy({ + group: "selectable", + consent: "none", + consentExemptReason: "its transaction is always rolled back", + }), ); diff --git a/apps/cli/src/commands/db/query/query.handler.ts b/apps/cli/src/commands/db/query/query.handler.ts index 8214379912..37b41e0c55 100644 --- a/apps/cli/src/commands/db/query/query.handler.ts +++ b/apps/cli/src/commands/db/query/query.handler.ts @@ -371,6 +371,7 @@ export const dbQuery = Effect.fn("db.query")(function* (flags: DbQueryFlags) { if (!flags.readOnly) { yield* requireTargetMutationConsent(route.kind === "direct" ? route.connection : target, { action: "Run a SQL statement", + readOnlyAlternative: true, details: [`sql: ${sqlPreview(sql)}`], }); } 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 e8af8ea7a6..73604352b1 100644 --- a/apps/cli/src/commands/db/query/query.integration.test.ts +++ b/apps/cli/src/commands/db/query/query.integration.test.ts @@ -1292,7 +1292,6 @@ describeHostedConsent("db query", { mockTelemetryOutputFormat().layer, Layer.succeed(Random, { randomHex: () => Effect.succeed(BOUNDARY) }), Layer.succeed(AiTool, { name: Option.none() }), - Layer.succeed(AgentFlag, "no"), Layer.succeed(DnsResolverFlag, "native"), ), ), 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 fc1edc7134..a69d04a4f0 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 @@ -143,5 +143,9 @@ export const dbSchemaDeclarativeGenerateCommand = Command.make("generate", confi Command.provide(dbSchemaDeclarativeGenerateRuntimeLayer), Command.provide(cliConfigValuesLayer), withCliConfigFlags(config), - withTargetPolicy({ group: "selectable", consent: "none" }), + withTargetPolicy({ + group: "selectable", + consent: "none", + consentExemptReason: "reads the target and writes only local declarative files", + }), ); diff --git a/apps/cli/src/commands/experimental/compute/delete/delete.handler.ts b/apps/cli/src/commands/experimental/compute/delete/delete.handler.ts index 40cdccf2e3..c68862ce5a 100644 --- a/apps/cli/src/commands/experimental/compute/delete/delete.handler.ts +++ b/apps/cli/src/commands/experimental/compute/delete/delete.handler.ts @@ -117,7 +117,7 @@ export const computeDelete = Effect.fn("compute.delete")(function* (flags: Compu ? `${declared} declared instance${declared === 1 ? "" : "s"} will be terminated.` : undefined; yield* requireHostedMutationConsent({ - action: `permanently delete compute "${name}"`, + action: `Permanently delete compute "${name}"`, ...(terminating === undefined ? {} : { details: [terminating] }), confirm: { kind: "typed", expected: name }, }); diff --git a/apps/cli/src/commands/experimental/compute/delete/delete.integration.test.ts b/apps/cli/src/commands/experimental/compute/delete/delete.integration.test.ts index f1ac37cccf..24e80dca34 100644 --- a/apps/cli/src/commands/experimental/compute/delete/delete.integration.test.ts +++ b/apps/cli/src/commands/experimental/compute/delete/delete.integration.test.ts @@ -68,7 +68,7 @@ describe("compute delete", () => { }); expect(http.routeKeys).toEqual([getRoute, deleteRoute]); - expect(out.stderrText).toContain('permanently delete compute "api"'); + expect(out.stderrText).toContain('Permanently delete compute "api"'); assertPermissionDrift({ command: "compute delete", variant: { computeEnabled: true }, @@ -206,7 +206,7 @@ describe("compute delete", () => { return yield* Effect.gen(function* () { yield* computeDelete({ name: "api", projectRef: Option.none() }); - expect(out.stderrText).toContain('permanently delete compute "api"'); + expect(out.stderrText).toContain('Permanently delete compute "api"'); // No count is quoted: the read that would have supplied one was refused. expect(out.stderrText).not.toContain("will be terminated"); expect(http.routeKeys).toEqual([getRoute, deleteRoute]); @@ -274,7 +274,7 @@ describe("compute delete", () => { expect(http.routeKeys).toEqual([getRoute, deleteRoute]); expect(out.promptTextCalls).toEqual([]); - expect(out.stderrText).toContain('Confirmed by --yes: permanently delete compute "api"'); + expect(out.stderrText).toContain('Confirmed by --yes: Permanently delete compute "api"'); }).pipe(Effect.provide(layer)); }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), ); @@ -642,7 +642,7 @@ describe("compute delete", () => { return yield* Effect.gen(function* () { yield* computeDelete({ name: "api", projectRef: Option.none() }); - expect(out.stderrText).toContain('permanently delete compute "api"'); + expect(out.stderrText).toContain('Permanently delete compute "api"'); expect(out.stderrText).not.toContain("will be terminated"); }).pipe(Effect.provide(layer)); }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), diff --git a/apps/cli/src/commands/experimental/compute/push/push.handler.ts b/apps/cli/src/commands/experimental/compute/push/push.handler.ts index ea727c3ab0..8204f9ce56 100644 --- a/apps/cli/src/commands/experimental/compute/push/push.handler.ts +++ b/apps/cli/src/commands/experimental/compute/push/push.handler.ts @@ -595,7 +595,7 @@ export const computePush = Effect.fn("compute.push")(function* ( yield* rejectComputeEnvOutput(); yield* requireHostedMutationConsent({ - action: `deploy compute ${names.join(", ")}`, + action: `Deploy compute ${names.join(", ")}`, }); const machineOutput = yield* computeMachineOutputRequested(); diff --git a/apps/cli/src/commands/feedback/delete/SIDE_EFFECTS.md b/apps/cli/src/commands/feedback/delete/SIDE_EFFECTS.md index f87199bd05..9003a4d648 100644 --- a/apps/cli/src/commands/feedback/delete/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/feedback/delete/SIDE_EFFECTS.md @@ -88,7 +88,7 @@ The confirmation prompt runs before the row's existence is known (the CLI never reads the row), so a wrong token is answered with the not-found error after the prompt. The delete runs behind a "Deleting feedback..." spinner. `--yes` / a shell `SUPABASE_YES` skips the confirmation prompt and prints -`Confirmed by --yes: permanently delete this feedback` on stderr; a `SUPABASE_YES` in a +`Confirmed by --yes: Permanently delete this feedback` on stderr; a `SUPABASE_YES` in a project `.env` does not count, and stdin is never read for the answer. `-o json` requires `--yes` like the machine output formats do: it never prompts, even on a TTY, so stdout stays payload-only, and it emits `{ "deleted": true }`. diff --git a/apps/cli/src/commands/feedback/delete/delete.handler.ts b/apps/cli/src/commands/feedback/delete/delete.handler.ts index 3502577b3f..b3107d24ca 100644 --- a/apps/cli/src/commands/feedback/delete/delete.handler.ts +++ b/apps/cli/src/commands/feedback/delete/delete.handler.ts @@ -70,7 +70,7 @@ export const feedbackDelete = Effect.fn("feedback.delete")(function* (args: Feed // The row is never read, so consent is asked before its existence is known; a wrong token // is reported as "not found" after the DELETE matches zero rows. - yield* requireHostedMutationConsent({ action: "permanently delete this feedback" }); + yield* requireHostedMutationConsent({ action: "Permanently delete this feedback" }); const deleting = yield* output.task("Deleting feedback..."); const { deleted } = yield* client.delete(token, rowContext).pipe(settleFeedbackTask(deleting)); diff --git a/apps/cli/src/commands/feedback/delete/delete.integration.test.ts b/apps/cli/src/commands/feedback/delete/delete.integration.test.ts index d79ea95331..e031060c74 100644 --- a/apps/cli/src/commands/feedback/delete/delete.integration.test.ts +++ b/apps/cli/src/commands/feedback/delete/delete.integration.test.ts @@ -169,7 +169,7 @@ describe("feedback delete", () => { // The CLI never reads the row: nothing is shown ahead of the prompt. expect(out.messages).not.toContainEqual(expect.objectContaining({ type: "info" })); expect(out.promptConfirmCalls).toEqual([ - { message: "permanently delete this feedback?", opts: { defaultValue: false } }, + { message: "Permanently delete this feedback?", opts: { defaultValue: false } }, ]); expect(client.deleteCalls).toEqual([{ token: TOKEN, projectRef: undefined }]); expect(out.messages).toContainEqual( diff --git a/apps/cli/src/commands/functions/delete/SIDE_EFFECTS.md b/apps/cli/src/commands/functions/delete/SIDE_EFFECTS.md index 27df8fa286..36be3bc84c 100644 --- a/apps/cli/src/commands/functions/delete/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/functions/delete/SIDE_EFFECTS.md @@ -58,7 +58,7 @@ ### `--output-format text` -Prints a success message after the function is deleted. Stderr carries the `Target:` banner, then either the confirmation question (`delete Function <slug> on <target>?`, default No) on an interactive terminal or `Confirmed by --yes: delete Function <slug> on <target>` under `--yes`. +Prints a success message after the function is deleted. Stderr carries the `Target:` banner, then either the confirmation question (`Delete Function <slug> on <target>?`, default No) on an interactive terminal or `Confirmed by --yes: Delete Function <slug> on <target>` under `--yes`. ### `--output-format json` @@ -73,7 +73,7 @@ Prints the same structured success result, including `target`, and needs `--yes` - Requires exactly one argument: the function slug/name. - Does NOT remove the function from the local filesystem. - Requires a linked project (`--project-ref` or linked project config). -- Asks one question before the DELETE request: `delete Function <slug>` on the announced target, default No. The slug is validated and the project resolved first, so a usage error never prompts. +- Asks one question before the DELETE request: `Delete Function <slug>` on the announced target, default No. The slug is validated and the project resolved first, so a usage error never prompts. - `--yes` or a `SUPABASE_YES` set in the shell skips the question. A `SUPABASE_YES` in a project `.env*` file does not count, and piped stdin answers are never read. - Runs natively in TypeScript through the Management API. - Telemetry state is flushed on success and failure. The linked-project cache is filled post-run only under the conditions listed in Files Written. diff --git a/apps/cli/src/commands/functions/deploy/SIDE_EFFECTS.md b/apps/cli/src/commands/functions/deploy/SIDE_EFFECTS.md index bd2c21874a..9319a0b3e7 100644 --- a/apps/cli/src/commands/functions/deploy/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/functions/deploy/SIDE_EFFECTS.md @@ -116,7 +116,7 @@ The `--output`/`-o` flag does not change deploy output. - `--use-api`, `--use-docker`, and `--legacy-bundle` are mutually exclusive deploy modes. - Prints the `Target:` banner (project name, ref and, for a branch, the parent project) before anything else. -- **Consent:** one question, `deploy N Function(s)` on the target, asked after planning and +- **Consent:** one question, `Deploy N Function(s)` on the target, asked after planning and before the first bundle, upload or delete. The default answer is No. `--prune` adds `delete N Function(s) with --prune` to the same question, so a deploy with pruning never asks twice. The question lists the Function slugs on stderr. 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 a26b00715d..b6a78502b9 100644 --- a/apps/cli/src/commands/functions/deploy/deploy.integration.test.ts +++ b/apps/cli/src/commands/functions/deploy/deploy.integration.test.ts @@ -1497,7 +1497,7 @@ describe("functions deploy", () => { yield* functionsDeploy({ ...baseFlags, prune: true }); expect(stripControlSequences(out.stderrText)).toContain( - "Confirmed by --yes: deploy 1 Function and delete 1 Function with --prune on project", + "Confirmed by --yes: Deploy 1 Function and delete 1 Function with --prune on project", ); expect(out.promptConfirmCalls).toHaveLength(0); expect(api.requests.some((request) => request.method === "DELETE")).toBe(true); @@ -1520,7 +1520,7 @@ describe("functions deploy", () => { expect(out.promptConfirmCalls).toHaveLength(1); expect(out.promptConfirmCalls[0]?.message).toContain( - "deploy 1 Function and delete 1 Function with --prune", + "Deploy 1 Function and delete 1 Function with --prune", ); expect(out.promptConfirmCalls[0]?.opts?.defaultValue).toBe(false); expect(stripControlSequences(out.stderrText)).toContain( diff --git a/apps/cli/src/commands/gen/types/types.command.ts b/apps/cli/src/commands/gen/types/types.command.ts index 689c0cdf30..6fe53abd5a 100644 --- a/apps/cli/src/commands/gen/types/types.command.ts +++ b/apps/cli/src/commands/gen/types/types.command.ts @@ -194,5 +194,5 @@ export const genTypesCommand = Command.make("types", commandConfig).pipe( Command.provide(genTypesRuntimeLayer), Command.provide(cliConfigValuesLayer), withCliConfigFlags(commandConfig), - withTargetPolicy({ group: "selectable", consent: "none" }), + withTargetPolicy({ group: "selectable", consent: "none", consentExemptReason: "read-only" }), ); diff --git a/apps/cli/src/commands/inspect/db/inspect-db-command.ts b/apps/cli/src/commands/inspect/db/inspect-db-command.ts index 08d89af88d..b22a50ba2d 100644 --- a/apps/cli/src/commands/inspect/db/inspect-db-command.ts +++ b/apps/cli/src/commands/inspect/db/inspect-db-command.ts @@ -21,7 +21,11 @@ export const INSPECT_DB_FLAGS = targetFlags({ /** The Management API calls every `inspect db` subcommand makes, all through the database-config resolver. */ export const INSPECT_DB_PERMISSIONS = withDbTarget(dbConfig, { default: "local" }); /** Target policy shared by every `inspect db` command. */ -export const withInspectDbTargetPolicy = withTargetPolicy({ group: "selectable", consent: "none" }); +export const withInspectDbTargetPolicy = withTargetPolicy({ + group: "selectable", + consent: "none", + consentExemptReason: "read-only", +}); /** * Wraps an `inspect db` handler with the standard command-level pipeline: telemetry diff --git a/apps/cli/src/commands/inspect/report/report.command.ts b/apps/cli/src/commands/inspect/report/report.command.ts index e5100c1229..a61a9ba76a 100644 --- a/apps/cli/src/commands/inspect/report/report.command.ts +++ b/apps/cli/src/commands/inspect/report/report.command.ts @@ -51,5 +51,9 @@ export const inspectReportCommand = Command.make("report", config).pipe( Command.provide(Layer.merge(inspectBaseLayer, commandRuntimeLayer(["inspect", "report"]))), Command.provide(cliConfigValuesLayer), withCliConfigFlags(config), - withTargetPolicy({ group: "selectable", consent: "none" }), + withTargetPolicy({ + group: "selectable", + consent: "none", + consentExemptReason: "reads the target and writes only local report files", + }), ); diff --git a/apps/cli/src/commands/migration/fetch/fetch.command.ts b/apps/cli/src/commands/migration/fetch/fetch.command.ts index 29c6d31c15..dab8d09942 100644 --- a/apps/cli/src/commands/migration/fetch/fetch.command.ts +++ b/apps/cli/src/commands/migration/fetch/fetch.command.ts @@ -52,5 +52,9 @@ export const migrationFetchCommand = Command.make("fetch", config).pipe( Command.provide(migrationDbRuntimeLayer(["migration", "fetch"])), Command.provide(cliConfigValuesLayer), withCliConfigFlags(config), - withTargetPolicy({ group: "sync", consent: "none" }), + withTargetPolicy({ + group: "sync", + consent: "none", + consentExemptReason: "reads the remote migration history and writes only local migration files", + }), ); diff --git a/apps/cli/src/commands/migration/list/list.command.ts b/apps/cli/src/commands/migration/list/list.command.ts index b8e5388283..80dbd419f7 100644 --- a/apps/cli/src/commands/migration/list/list.command.ts +++ b/apps/cli/src/commands/migration/list/list.command.ts @@ -64,5 +64,5 @@ export const migrationListCommand = Command.make("list", config).pipe( Command.provide(migrationDbRuntimeLayer(["migration", "list"])), Command.provide(cliConfigValuesLayer), withCliConfigFlags(config), - withTargetPolicy({ group: "selectable", consent: "none" }), + withTargetPolicy({ group: "selectable", consent: "none", consentExemptReason: "read-only" }), ); diff --git a/apps/cli/src/commands/notebooks/notebooks.shared.ts b/apps/cli/src/commands/notebooks/notebooks.shared.ts index 49228c549f..cb47b9c512 100644 --- a/apps/cli/src/commands/notebooks/notebooks.shared.ts +++ b/apps/cli/src/commands/notebooks/notebooks.shared.ts @@ -2,6 +2,7 @@ import type { ApiClient } from "@supabase/api/effect"; import { V2GetNotebookInput, V2UpdateNotebookInput } from "@supabase/api/effect"; import { Effect, Exit, FileSystem, Option, Path, Schema, Stream } from "effect"; import { block } from "../../command-internal/command-permissions/model.ts"; +import { canPrompt } from "../../command-internal/hosted-consent/hosted-consent.prompt.ts"; import { Output } from "../../shared/output/output.service.ts"; import { notebooksMachineOutputRequested } from "./notebooks.output.ts"; import { sanitizeInlineName, mapHttpError } from "../../command-internal/http-errors.ts"; @@ -568,7 +569,7 @@ export type NotebooksReconcileChoice = "keep" | "delete" | "copy"; * says which, so the command asks instead of picking. * * `keep` is the answer whenever there is nobody to ask — a non-TTY, a machine - * output format, or a cancelled prompt. The other answers mutate local or remote + * output format, an agent run, or a cancelled prompt. The other answers mutate local or remote * state, so the unattended run reports the divergence and leaves both sides alone rather * than resolving it in a direction nobody chose. */ @@ -577,15 +578,13 @@ export const promptNotebooksReconcile = Effect.fnUntraced(function* (options: { readonly names: ReadonlyArray<string>; readonly deleteLabel: string; readonly copyLabel: string; - /** `-o json|yaml|toml|env` — stdout belongs to the payload, so do not prompt. */ - readonly machineOutput: boolean; }) { const output = yield* Output; const listing = `${options.summary}\n${options.names.map((name) => ` • ${sanitizeInlineName(name)}`).join("\n")}\n`; yield* output.raw(listing, "stderr"); - if (output.format !== "text" || !output.interactive || options.machineOutput) { + if (!(yield* canPrompt)) { yield* output.raw("Left alone — rerun interactively to resolve.\n", "stderr"); return "keep"; } diff --git a/apps/cli/src/commands/notebooks/pull/pull.command.ts b/apps/cli/src/commands/notebooks/pull/pull.command.ts index 57cd56151f..7bcdecf9c7 100644 --- a/apps/cli/src/commands/notebooks/pull/pull.command.ts +++ b/apps/cli/src/commands/notebooks/pull/pull.command.ts @@ -72,5 +72,9 @@ export const notebooksPullCommand = Command.make("pull", config).pipe( ), Command.withHandler(notebooksPullHandler), Command.provide(managementApiRuntimeLayer(["notebooks", "pull"])), - withTargetPolicy({ group: "hosted", consent: "none" }), + withTargetPolicy({ + group: "hosted", + consent: "none", + consentExemptReason: "creates project notebooks only after an interactive reconcile choice", + }), ); diff --git a/apps/cli/src/commands/notebooks/pull/pull.handler.ts b/apps/cli/src/commands/notebooks/pull/pull.handler.ts index 16f1319dc7..c9058ecb8c 100644 --- a/apps/cli/src/commands/notebooks/pull/pull.handler.ts +++ b/apps/cli/src/commands/notebooks/pull/pull.handler.ts @@ -5,11 +5,7 @@ import { CommandSettings } from "../../../config/command-settings.service.ts"; 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 { - emitNotebooksMachineOutput, - notebooksMachineOutputRequested, - rejectNotebooksEnvOutput, -} from "../notebooks.output.ts"; +import { emitNotebooksMachineOutput, rejectNotebooksEnvOutput } from "../notebooks.output.ts"; import { isNotebookNameWritable, downloadNotebook, @@ -59,7 +55,6 @@ export const notebooksPull = Effect.fn("notebooks.pull")(function* (flags: Noteb // Refused before the project is resolved: `-o env` cannot hold the payload, // and failing at emit time would mean failing after the files are written. yield* rejectNotebooksEnvOutput(); - const machineOutput = yield* notebooksMachineOutputRequested(); const ref = yield* resolver.resolve(flags.projectRef); @@ -112,7 +107,6 @@ export const notebooksPull = Effect.fn("notebooks.pull")(function* (flags: Noteb names: localOnly, copyLabel: "Create them in the project", deleteLabel: `Delete them from ${dir}`, - machineOutput, }); if (choice === "delete") { diff --git a/apps/cli/src/commands/notebooks/push/push.handler.ts b/apps/cli/src/commands/notebooks/push/push.handler.ts index 2d29b4177d..35170a7845 100644 --- a/apps/cli/src/commands/notebooks/push/push.handler.ts +++ b/apps/cli/src/commands/notebooks/push/push.handler.ts @@ -8,11 +8,7 @@ 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 { NotebookNotFoundError } from "../notebooks.errors.ts"; -import { - emitNotebooksMachineOutput, - notebooksMachineOutputRequested, - rejectNotebooksEnvOutput, -} from "../notebooks.output.ts"; +import { emitNotebooksMachineOutput, rejectNotebooksEnvOutput } from "../notebooks.output.ts"; import { deleteRemoteNotebook, downloadNotebook, @@ -58,7 +54,6 @@ export const notebooksPush = Effect.fn("notebooks.push")(function* (flags: Noteb // Refused before the project is resolved: failing at emit time would mean // failing after the project has already changed. yield* rejectNotebooksEnvOutput(); - const machineOutput = yield* notebooksMachineOutputRequested(); const ref = yield* resolver.resolve(flags.projectRef); @@ -99,7 +94,6 @@ export const notebooksPush = Effect.fn("notebooks.push")(function* (flags: Noteb names: remoteOnly.map((notebook) => notebook.name), copyLabel: `Write them into ${dir}`, deleteLabel: "Delete them from the project", - machineOutput, }); } // Resolve and validate the selected plan before the first upload. diff --git a/apps/cli/src/commands/orgs/create/create.handler.ts b/apps/cli/src/commands/orgs/create/create.handler.ts index 0cf9c466a0..4b6d88662a 100644 --- a/apps/cli/src/commands/orgs/create/create.handler.ts +++ b/apps/cli/src/commands/orgs/create/create.handler.ts @@ -33,7 +33,7 @@ export const orgsCreate = Effect.fn("orgs.create")(function* (flags: OrgsCreateF const telemetryState = yield* TelemetryState; yield* Effect.gen(function* () { - yield* requireHostedMutationConsent({ action: `create organization "${flags.name}"` }); + yield* requireHostedMutationConsent({ action: `Create organization "${flags.name}"` }); // Spinner only runs in text mode, since it would corrupt machine-readable stdout. It // gates on output.format rather than outputFlagFormat because --output pretty keeps the format diff --git a/apps/cli/src/commands/projects/delete/SIDE_EFFECTS.md b/apps/cli/src/commands/projects/delete/SIDE_EFFECTS.md index 2a832581d8..1d61410421 100644 --- a/apps/cli/src/commands/projects/delete/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/projects/delete/SIDE_EFFECTS.md @@ -62,9 +62,9 @@ ### `--output-format text` -Prints `This action is irreversible.` and asks `delete project <ref>?` (default +Prints `This action is irreversible.` and asks `Delete project <ref>?` (default **No**), then on success prints `Deleted project: <name>` to stdout. `--yes` or a shell -`SUPABASE_YES` answers the question and prints `Confirmed by --yes: delete project <ref>` +`SUPABASE_YES` answers the question and prints `Confirmed by --yes: Delete project <ref>` on stderr; a `SUPABASE_YES` in a project `.env` does not count. ### `--output-format json` diff --git a/apps/cli/src/commands/projects/delete/delete.handler.ts b/apps/cli/src/commands/projects/delete/delete.handler.ts index b7728038cf..02c8ee14b2 100644 --- a/apps/cli/src/commands/projects/delete/delete.handler.ts +++ b/apps/cli/src/commands/projects/delete/delete.handler.ts @@ -61,7 +61,7 @@ export const projectsDelete = Effect.fn("projects.delete")(function* (flags: Pro } yield* requireHostedMutationConsent({ - action: `delete project ${ref}`, + action: `Delete project ${ref}`, details: ["This action is irreversible."], }); diff --git a/apps/cli/src/commands/projects/delete/delete.integration.test.ts b/apps/cli/src/commands/projects/delete/delete.integration.test.ts index 3545ba4a70..6347330593 100644 --- a/apps/cli/src/commands/projects/delete/delete.integration.test.ts +++ b/apps/cli/src/commands/projects/delete/delete.integration.test.ts @@ -121,7 +121,7 @@ describe("projects delete integration", () => { const { layer, out, api } = setup({ yes: true }); return Effect.gen(function* () { yield* projectsDelete({ ref: Option.some(VALID_REF) }); - expect(out.stderrText).toContain(`Confirmed by --yes: delete project ${VALID_REF}`); + expect(out.stderrText).toContain(`Confirmed by --yes: Delete project ${VALID_REF}`); expect(hasMethod(api, "DELETE")).toBe(true); }).pipe(Effect.provide(layer)); }); diff --git a/apps/cli/src/commands/secrets/set/SIDE_EFFECTS.md b/apps/cli/src/commands/secrets/set/SIDE_EFFECTS.md index a10bce631c..f882577be8 100644 --- a/apps/cli/src/commands/secrets/set/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/secrets/set/SIDE_EFFECTS.md @@ -70,7 +70,7 @@ ### `--output pretty` (default) / `--output-format text` -Stdout: `Finished supabase secrets set.\n`. Stderr: the `Target:` banner, one `Env name cannot start with SUPABASE_, skipping: <name>` line per filtered entry, then either the confirmation (`Secrets to set:` with the bulleted names, then `set N secret(s) on <target>?`, default No) on an interactive terminal or `Confirmed by --yes: set N secret(s) on <target>` under `--yes`. Secret values are never printed. +Stdout: `Finished supabase secrets set.\n`. Stderr: the `Target:` banner, one `Env name cannot start with SUPABASE_, skipping: <name>` line per filtered entry, then either the confirmation (`Secrets to set:` with the bulleted names, then `Set N secret(s) on <target>?`, default No) on an interactive terminal or `Confirmed by --yes: Set N secret(s) on <target>` under `--yes`. Secret values are never printed. The `--output {json,yaml,toml,env}` flags all collapse to the same text-mode `Finished` message. diff --git a/apps/cli/src/commands/secrets/set/set.handler.ts b/apps/cli/src/commands/secrets/set/set.handler.ts index e4fd1ec8e7..560eddad17 100644 --- a/apps/cli/src/commands/secrets/set/set.handler.ts +++ b/apps/cli/src/commands/secrets/set/set.handler.ts @@ -302,7 +302,7 @@ export const secretsSet = Effect.fn("secrets.set")(function* (flags: SecretsSetF ); yield* requireHostedMutationConsent({ - action: `set ${body.length} ${body.length === 1 ? "secret" : "secrets"}`, + action: `Set ${body.length} ${body.length === 1 ? "secret" : "secrets"}`, details: ["Secrets to set:", ...body.map(({ name }) => ` • ${sanitizeInlineName(name)}`)], }); 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 eebbb4b201..36179f76ef 100644 --- a/apps/cli/src/commands/secrets/set/set.integration.test.ts +++ b/apps/cli/src/commands/secrets/set/set.integration.test.ts @@ -154,7 +154,7 @@ describe("secrets set integration", () => { secrets: ["FOO=hunter2", "BAR=swordfish"], }); expect(out.promptConfirmCalls[0]?.message).toBe( - `set 2 secrets on project ${VALID_REF} · identity unverified?`, + `Set 2 secrets on project ${VALID_REF} · identity unverified?`, ); expect(out.promptConfirmCalls[0]?.opts?.defaultValue).toBe(false); expect(out.stderrText).toContain("Secrets to set:\n • FOO\n • BAR\n"); diff --git a/apps/cli/src/commands/secrets/unset/SIDE_EFFECTS.md b/apps/cli/src/commands/secrets/unset/SIDE_EFFECTS.md index 1e50073d80..1db2f16f16 100644 --- a/apps/cli/src/commands/secrets/unset/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/secrets/unset/SIDE_EFFECTS.md @@ -67,7 +67,7 @@ ### `--output pretty` (default) / `--output-format text` -Stdout: `Finished supabase secrets unset.\n`. Stderr: the `Target:` banner, then either the confirmation (`Secrets to unset:` with the bulleted names, then `unset N secret(s) on <target>?`, default No) on an interactive terminal or `Confirmed by --yes: unset N secret(s) on <target>` under `--yes`. +Stdout: `Finished supabase secrets unset.\n`. Stderr: the `Target:` banner, then either the confirmation (`Secrets to unset:` with the bulleted names, then `Unset N secret(s) on <target>?`, default No) on an interactive terminal or `Confirmed by --yes: Unset N secret(s) on <target>` under `--yes`. `--dry-run` stdout, with no `Finished` line and no prompt: diff --git a/apps/cli/src/commands/secrets/unset/unset.handler.ts b/apps/cli/src/commands/secrets/unset/unset.handler.ts index 7f0257cd1b..d7a270eb3a 100644 --- a/apps/cli/src/commands/secrets/unset/unset.handler.ts +++ b/apps/cli/src/commands/secrets/unset/unset.handler.ts @@ -105,7 +105,7 @@ export const secretsUnset = Effect.fn("secrets.unset")(function* (flags: Secrets } yield* requireHostedMutationConsent({ - action: `unset ${names.length} ${names.length === 1 ? "secret" : "secrets"}`, + action: `Unset ${names.length} ${names.length === 1 ? "secret" : "secrets"}`, details: ["Secrets to unset:", ...names.map((name) => ` • ${sanitizeInlineName(name)}`)], }); diff --git a/apps/cli/src/commands/secrets/unset/unset.integration.test.ts b/apps/cli/src/commands/secrets/unset/unset.integration.test.ts index b50391a97c..e711cf6fb1 100644 --- a/apps/cli/src/commands/secrets/unset/unset.integration.test.ts +++ b/apps/cli/src/commands/secrets/unset/unset.integration.test.ts @@ -147,7 +147,7 @@ describe("secrets unset integration", () => { expect(methods(api)).toEqual(["DELETE"]); expect(api.requests[0]?.body).toEqual(["FOO", "BAR"]); expect(out.stderrText).toBe( - `${unverifiedTargetBanner()}Confirmed by --yes: unset 2 secrets on ${ON_TARGET}\n`, + `${unverifiedTargetBanner()}Confirmed by --yes: Unset 2 secrets on ${ON_TARGET}\n`, ); expect(out.stdoutText).toBe("Finished supabase secrets unset.\n"); assertPermissionDrift({ @@ -167,7 +167,7 @@ describe("secrets unset integration", () => { Effect.gen(function* () { yield* unset({ names: ["FOO"] }); expect(out.stderrText).toBe( - `${unverifiedTargetBanner()}Confirmed by --yes: unset 1 secret on ${ON_TARGET}\n`, + `${unverifiedTargetBanner()}Confirmed by SUPABASE_YES: Unset 1 secret on ${ON_TARGET}\n`, ); expect(methods(api)).toEqual(["DELETE"]); }).pipe(Effect.provide(layer)), @@ -179,7 +179,7 @@ describe("secrets unset integration", () => { return Effect.gen(function* () { yield* unset({ names: ["FOO"] }); expect(out.promptConfirmCalls).toEqual([ - { message: `unset 1 secret on ${ON_TARGET}?`, opts: { defaultValue: false } }, + { message: `Unset 1 secret on ${ON_TARGET}?`, opts: { defaultValue: false } }, ]); expect(out.stderrText).toContain("Secrets to unset:\n • FOO\n"); expect(methods(api)).toEqual(["DELETE"]); @@ -280,7 +280,7 @@ describe("secrets unset integration", () => { exact: true, }); expect(out.stderrText).toBe( - `${unverifiedTargetBanner()}Confirmed by --yes: unset 2 secrets on ${ON_TARGET}\n`, + `${unverifiedTargetBanner()}Confirmed by --yes: Unset 2 secrets on ${ON_TARGET}\n`, ); }).pipe(Effect.provide(layer)); }); diff --git a/apps/cli/src/commands/storage/ls/ls.command.ts b/apps/cli/src/commands/storage/ls/ls.command.ts index c25955db34..bea0ea7305 100644 --- a/apps/cli/src/commands/storage/ls/ls.command.ts +++ b/apps/cli/src/commands/storage/ls/ls.command.ts @@ -56,5 +56,5 @@ export const storageLsCommand = Command.make("ls", config).pipe( Command.provide(storageGatewayRuntimeLayer(["storage", "ls"])), Command.provide(cliConfigValuesLayer), withCliConfigFlags(config), - withTargetPolicy({ group: "selectable", consent: "none" }), + withTargetPolicy({ group: "selectable", consent: "none", consentExemptReason: "read-only" }), ); diff --git a/apps/cli/src/shared/functions/delete.ts b/apps/cli/src/shared/functions/delete.ts index ca076a64e4..b6c34c7c7c 100644 --- a/apps/cli/src/shared/functions/delete.ts +++ b/apps/cli/src/shared/functions/delete.ts @@ -53,7 +53,7 @@ export const deleteFunction = Effect.fn("functions.delete")(function* < yield* validateSlug(flags.slug); const projectRef = yield* dependencies.resolveProjectRef(flags.projectRef); - yield* requireHostedMutationConsent({ action: `delete Function ${flags.slug}` }); + yield* requireHostedMutationConsent({ action: `Delete Function ${flags.slug}` }); const response = yield* dependencies.api .executeRaw(operationDefinitions.v1DeleteAFunction, { diff --git a/apps/cli/src/shared/functions/deploy.ts b/apps/cli/src/shared/functions/deploy.ts index d897c1b3cf..c169713ac1 100644 --- a/apps/cli/src/shared/functions/deploy.ts +++ b/apps/cli/src/shared/functions/deploy.ts @@ -2484,8 +2484,12 @@ const functionCount = (count: number) => `${count} ${count === 1 ? "Function" : const requireDeployConsent = (toDeploy: ReadonlyArray<string>, toPrune: ReadonlyArray<string>) => requireHostedMutationConsent({ action: [ - ...(toDeploy.length > 0 ? [`deploy ${functionCount(toDeploy.length)}`] : []), - ...(toPrune.length > 0 ? [`delete ${functionCount(toPrune.length)} with --prune`] : []), + ...(toDeploy.length > 0 ? [`Deploy ${functionCount(toDeploy.length)}`] : []), + ...(toPrune.length > 0 + ? [ + `${toDeploy.length > 0 ? "delete" : "Delete"} ${functionCount(toPrune.length)} with --prune`, + ] + : []), ].join(" and "), details: [ ...(toDeploy.length > 0 diff --git a/apps/cli/tests/helpers/hosted-consent.ts b/apps/cli/tests/helpers/hosted-consent.ts index 9e9346dd4a..33321a7095 100644 --- a/apps/cli/tests/helpers/hosted-consent.ts +++ b/apps/cli/tests/helpers/hosted-consent.ts @@ -7,7 +7,7 @@ import { HostedMutationConsentRequiredError, HostedMutationDeclinedError, } from "../../src/command-internal/hosted-consent/hosted-consent.errors.ts"; -import { YesFlag } from "../../src/command-internal/global-flags.ts"; +import { AgentFlag, YesFlag } from "../../src/command-internal/global-flags.ts"; import { TargetPolicy, readTargetPolicy } from "../../src/command-internal/target/target-policy.ts"; import type { TargetPolicyShape } from "../../src/command-internal/target/target-policy.ts"; import { commandTreeFor } from "./command-flags.ts"; @@ -24,8 +24,18 @@ import { mockOutput, mockStdin, mockTty } from "./mocks.ts"; type RuntimeLayer = ReturnType<typeof buildTestRuntime>; -const consentLayer = (runtime: RuntimeLayer, yes: boolean, policy: TargetPolicyShape) => - Layer.mergeAll(runtime, Layer.succeed(YesFlag, yes), Layer.succeed(TargetPolicy, policy)); +const consentLayer = ( + runtime: RuntimeLayer, + yes: boolean, + policy: TargetPolicyShape, + agent: "auto" | "yes", +) => + Layer.mergeAll( + runtime, + Layer.succeed(YesFlag, yes), + Layer.succeed(AgentFlag, agent), + Layer.succeed(TargetPolicy, policy), + ); /** Everything a leaf's handler can require while `describeHostedConsent` runs it. */ type HostedConsentServices = Layer.Success<ReturnType<typeof consentLayer>>; @@ -58,6 +68,7 @@ interface CaseOptions { readonly interactive?: boolean; readonly yes?: boolean; readonly shellYes?: boolean; + readonly agent?: boolean; readonly projectEnvYes?: boolean; readonly stdinInput?: string; readonly confirmResponses?: ReadonlyArray<boolean>; @@ -104,8 +115,8 @@ const nonGetRequests = (api: HostedConsentEnv["api"]): number => /** * Runs the hosted-consent matrix against the leaf at `path`: no consent without `--yes` in - * JSON or piped text, a TTY decline, `--yes`, shell `SUPABASE_YES`, and a project `.env` - * that must not count as consent. + * JSON or piped text, a TTY decline, no prompt for an agent, `--yes`, shell `SUPABASE_YES`, and + * a project `.env` that must not count as consent. */ export function describeHostedConsent(path: string, scenario: HostedConsentScenario): void { const policy = policyFor(path, scenario); @@ -134,7 +145,12 @@ export function describeHostedConsent(path: string, scenario: HostedConsentScena outputFlag: options.json ? Option.some("json") : Option.none(), cliArgs: options.yes === true ? ["--yes"] : [], }); - const layer = consentLayer(runtime, options.yes ?? false, policy); + const layer = consentLayer( + runtime, + options.yes ?? false, + policy, + options.agent ? "yes" : "auto", + ); if (scenario.setup !== undefined) { yield* scenario.setup(workdir).pipe(Effect.provide(BunServices.layer)); @@ -187,6 +203,21 @@ export function describeHostedConsent(path: string, scenario: HostedConsentScena }), ); + it.effect("refuses to prompt an agent in an interactive terminal and changes nothing", () => + Effect.gen(function* () { + const { exit, out, mutations } = yield* runCase({ + json: false, + interactive: true, + agent: true, + confirmResponses: [true], + }); + expect(failureOf(exit)).toBeInstanceOf(HostedMutationConsentRequiredError); + expect(out.promptConfirmCalls).toEqual([]); + expect(out.promptTextCalls).toEqual([]); + expect(mutations).toBe(0); + }), + ); + it.effect( policy.group === "account" ? "runs under --yes and echoes the confirmation" @@ -205,11 +236,12 @@ export function describeHostedConsent(path: string, scenario: HostedConsentScena }), ); - it.effect("runs under a shell SUPABASE_YES", () => + it.effect("runs under a shell SUPABASE_YES and echoes it as the source", () => Effect.gen(function* () { - const { exit, mutations } = yield* runCase({ json: true, shellYes: true }); + const { exit, out, mutations } = yield* runCase({ json: true, shellYes: true }); expectSuccess(exit); expect(mutations).toBeGreaterThan(0); + expect(out.stderrText).toContain("Confirmed by SUPABASE_YES: "); }), ); diff --git a/docs/adr/0033-target-selection-and-hosted-mutation-consent.md b/docs/adr/0033-target-selection-and-hosted-mutation-consent.md index aab16868f8..843302b8c5 100644 --- a/docs/adr/0033-target-selection-and-hosted-mutation-consent.md +++ b/docs/adr/0033-target-selection-and-hosted-mutation-consent.md @@ -34,10 +34,11 @@ truth for the default target and for the consent default. - `local-only`: `start`, `stop`, `status`, `stack *`, `init`, `unlink`, `functions serve`, `migration new`, `gen signing-key`, `db schema declarative sync`. They act on the local stack or the checkout and have no target flags. -- `account`: `login`, `logout`, `whoami`, `orgs *`, `projects *`, `bootstrap`, `feedback`. They act on - the account or an organisation and have no default project. -- `hosted`: `functions deploy`, `secrets *`, `config *`, `branches *`, `link` and the other platform - resource commands. They act on the linked project and have no `--local`, `--linked` or `--db-url`. +- `account`: `login`, `logout`, `whoami`, `orgs *`, `projects` (except `projects api-keys`), + `bootstrap`, `feedback`. They act on the account or an organisation and have no default project. +- `hosted`: `functions deploy`, `secrets *`, `config *`, `branches *`, `projects api-keys`, `link` and + the other platform resource commands. They act on the linked project and have no `--local`, + `--linked` or `--db-url`. - `sync`: `db push`, `db pull`, `migration fetch`. They default to the linked project; `--local` and `--db-url` select another database. - `selectable`: `db reset`, `db query`, `db lint`, `db advisors`, `db dump`, `db diff`, `test db`, @@ -133,13 +134,19 @@ hosted. `projects create`, `orgs create`, `branches create`, `domains create` and `bootstrap`. Everything that replaces or deletes live state, and all arbitrary SQL, migration, seed and storage writes, default to No. -- **Approval.** Only `--yes` or a `SUPABASE_YES` in the shell environment counts. A `SUPABASE_YES` in - the project `supabase/.env` does not, because the checkout must not pre-approve its collaborators' - and agents' production writes. `--yes=false` wins over the environment. +- **Approval.** Only `--yes` or a `SUPABASE_YES` in the shell environment counts, and the + confirmation line names which one. A `SUPABASE_YES` in the project `supabase/.env` does not, + because the checkout must not pre-approve its collaborators' and agents' production writes. The + compiled binary also loads `.env`, `.env.local` and `.env.$NODE_ENV[.local]` from the working + directory into the environment, so a `SUPABASE_YES` that any of those files also defines with the + same value is treated as the file's; a shell export of that value then needs `--yes` as well. + `--yes=false` wins over the environment. - **No stdin.** The CLI never reads stdin for consent. A piped `y` does nothing. - **Interactive only.** A prompt appears only for text output on an interactive stdout with a TTY - stdin. JSON, stream-json, `-o json|yaml|toml|env`, detected agents and non-TTY runs fail with - `HostedMutationConsentRequiredError` unless `--yes` is set. + stdin. JSON, stream-json, `-o json|yaml|toml|env`, agents (`--agent`, or an AI tool detected from + the environment) and non-TTY runs fail with `HostedMutationConsentRequiredError` unless `--yes` is + set. The error names the action and target, says why the run can't ask, and tells an agent to ask + the user before passing `--yes`. - **Decline.** A declined prompt fails with `HostedMutationDeclinedError` ("context canceled"). It replaces the per-command cancelled errors. `compute delete` keeps its typed-name confirmation through the same service. @@ -156,12 +163,20 @@ hosted. - **Reads never prompt.** `db query --read-only` is a read: it sends `read_only: true` to the Management API for a linked target, and runs the statement in a read-only transaction for a local or `--db-url` target. It skips consent. -- **Exemptions.** These leaves declare `consent: "none"` with a reason: `login` (mints the caller's - own token), `feedback add` (sending the message is the command's purpose), `domains reverify` - (re-runs verification and changes no configuration), `telemetry disable` (a local preference) and - the removed commands. Three operations are exempt in the tree test: the temporary login role, - network-ban removal during connection setup (token-gated as above) and the network-ban list, a - POST that only reads. +- **Exemptions.** A leaf that never asks declares `consent: "none"`. A `sync` or `selectable` leaf + must also give a `consentExemptReason`, enforced by the type of `withTargetPolicy` and by the tree + test, because it can reach a hosted database: they are reads or write only local files (`db lint`, + `db advisors`, `db dump`, `db diff`, `db schema declarative generate`, `gen types`, `storage ls`, + `inspect *`, `migration fetch`, `migration list`). Other leaves with a reason are `login` (mints + the caller's own token), `feedback add` (sending the message is the command's purpose), + `domains reverify` (re-runs verification and changes no configuration), `telemetry disable` (a + local preference), `notebooks pull` and the removed commands. +- **Consent-free operations.** The tree test lets a `consent: "none"` leaf call five non-GET + operations: the temporary login role, network-ban removal during connection setup (token-gated as + above), the network-ban list and the vanity-subdomain availability check, which are POSTs that + only read, and DNS verification, which re-runs a check. A sixth, creating a notebook, is allowed + for `notebooks pull` alone: it only creates, and asks through the same prompt check as consent + before it does. - **Local prompts.** An explicitly requested local reset does not prompt: `migration down` and `migration repair --all` lose their local prompts. Incidental resets, `stack destroy`, checkout overwrites and `storage rm` keep theirs. Local prompts still honour a project `.env` @@ -218,17 +233,19 @@ to No makes the Enter key safe. - a `sync` or `selectable` leaf lacks `--local`, `--linked` or `--project-ref`; - a declared database-target default disagrees with the group; - a leaf with `consent: "none"` calls a non-GET operation outside the reviewed consent-free map; - - a leaf named for a mutating verb has `consent: "none"` and no exemption reason. + - a leaf named for a mutating verb has `consent: "none"` and no exemption reason; + - a `sync` or `selectable` leaf has `consent: "none"` and no exemption reason; + - a leaf-scoped consent-free operation belongs to a leaf without an exemption reason. - `code-structure.unit.test.ts` fails when: - `--local`, `--linked`, `--db-url` or `--project-id` is declared with a raw `Flag.*` outside the shared target flags; - the old per-command resolvers and defaults return; - network-ban removal is called outside connection setup and `network-bans remove`; - a handler prints its own `Connecting to local|remote database` line. -- Every leaf with `consent !== "none"` has a `describeHostedConsent` test. It runs six cases: - JSON without `--yes`, a piped `y`, a declined prompt, `--yes`, a shell `SUPABASE_YES`, and - `SUPABASE_YES` only in `supabase/.env`. A coverage test requires the set of consent leaves to - equal the set of leaves under test. +- Every leaf with `consent !== "none"` has a `describeHostedConsent` test. It runs seven cases: + JSON without `--yes`, a piped `y`, a declined prompt, an agent in an interactive terminal, + `--yes`, a shell `SUPABASE_YES`, and `SUPABASE_YES` only in `supabase/.env`. A coverage test + requires the set of consent leaves to equal the set of leaves under test. ## Alternatives Considered From d7cb937fdd6c1e5585e5a065459e3d9ccb5cbe28 Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Thu, 15 Oct 2026 17:07:51 +0100 Subject: [PATCH 88/92] refactor(cli): evidence-based target identity, typed JSON target and default-local hint Show "branch" only on positive evidence, name a ref that is not the linked project, append a --linked hint to the default-local banner, add the port to the db-url banner, encode every JSON target through a CommandTargetJson schema, share withAnnouncedTarget, and build the linked ResolvedTarget in resolveTarget. config push reads its JSON target environment from the live probe and prints its plan lines in plan wording. --- .../db-config.integration.test.ts | 2 +- .../hosted-consent.unit.test.ts | 13 ++- .../command-internal/parent-project-ref.ts | 5 +- .../src/command-internal/project-target.ts | 48 ++++---- .../command-internal/target/resolve-target.ts | 62 ++++++---- .../target/target-identity.ts | 52 +++++---- .../target/target-identity.unit.test.ts | 106 ++++++++++++------ .../src/command-internal/workdir-project.ts | 8 ++ .../restore/restore.integration.test.ts | 2 +- .../branches/pause/pause.integration.test.ts | 2 +- .../unpause/unpause.integration.test.ts | 2 +- apps/cli/src/commands/config/config.target.ts | 14 --- .../src/commands/config/diff/diff.handler.ts | 4 +- .../src/commands/config/pull/pull.handler.ts | 4 +- .../src/commands/config/push/push.format.ts | 36 ++++-- .../config/push/push.format.unit.test.ts | 30 ++--- .../src/commands/config/push/push.handler.ts | 16 ++- .../config/push/push.integration.test.ts | 78 +++++++++---- .../src/commands/db/advisors/SIDE_EFFECTS.md | 2 +- .../db/advisors/advisors.integration.test.ts | 1 + apps/cli/src/commands/db/diff/SIDE_EFFECTS.md | 2 +- apps/cli/src/commands/db/dump/SIDE_EFFECTS.md | 2 +- apps/cli/src/commands/db/lint/SIDE_EFFECTS.md | 2 +- .../commands/db/lint/lint.integration.test.ts | 1 + .../cli/src/commands/db/push/push.e2e.test.ts | 2 +- .../cli/src/commands/db/query/SIDE_EFFECTS.md | 2 +- .../cli/src/commands/db/reset/SIDE_EFFECTS.md | 2 +- .../domains/create/create.integration.test.ts | 2 +- .../domains/delete/delete.integration.test.ts | 6 +- .../update-root-key.integration.test.ts | 2 +- .../inspect/db/inspect-db.e2e.test.ts | 4 +- .../inspect/report/report.e2e.test.ts | 2 +- .../postgres-config.integration.test.ts | 2 +- apps/cli/src/commands/pull/pull.aggregate.ts | 3 - .../commands/pull/pull.aggregate.unit.test.ts | 4 - apps/cli/src/commands/pull/pull.format.ts | 1 - .../commands/pull/pull.format.unit.test.ts | 11 +- apps/cli/src/commands/pull/pull.handler.ts | 39 ++++--- apps/cli/src/commands/pull/pull.types.ts | 3 - .../secrets/set/set.integration.test.ts | 2 +- .../secrets/unset/unset.integration.test.ts | 2 +- apps/cli/src/commands/test/db/SIDE_EFFECTS.md | 2 +- .../vanity-subdomains.integration.test.ts | 2 +- apps/cli/src/config/project-ref.layer.ts | 52 ++++++--- .../src/config/project-ref.layer.unit.test.ts | 43 +++++-- .../src/shared/output/command-target.layer.ts | 32 ++++-- .../shared/output/command-target.service.ts | 53 +++++++-- .../shared/output/output.layer.unit.test.ts | 57 +++++++++- apps/cli/tests/helpers/command-mocks.ts | 2 +- 49 files changed, 545 insertions(+), 281 deletions(-) delete mode 100644 apps/cli/src/commands/config/config.target.ts 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 cd041f5d02..44419bb8c3 100644 --- a/apps/cli/src/command-internal/db-config.integration.test.ts +++ b/apps/cli/src/command-internal/db-config.integration.test.ts @@ -1814,7 +1814,7 @@ describe("dbConfigResolver.resolveConnection", () => { const r = yield* resolveConnection(dir, inputFor({ kind: "db-url", url }), { output }); expect(r.conn).toMatchObject({ host: "db.example.com", port: 6543, user: "alice" }); expect(r.isLocal).toBe(false); - expect(output.stderrText).toBe("Target: custom Postgres (db.example.com)\n"); + expect(output.stderrText).toBe("Target: custom Postgres (db.example.com:6543)\n"); }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), ); diff --git a/apps/cli/src/command-internal/hosted-consent/hosted-consent.unit.test.ts b/apps/cli/src/command-internal/hosted-consent/hosted-consent.unit.test.ts index 6582b2c8c6..0d8ac29695 100644 --- a/apps/cli/src/command-internal/hosted-consent/hosted-consent.unit.test.ts +++ b/apps/cli/src/command-internal/hosted-consent/hosted-consent.unit.test.ts @@ -6,7 +6,10 @@ import { withConfigEnv, withEmptyConfigEnv } from "../../../tests/helpers/comman import { mockOutput, mockTty } from "../../../tests/helpers/mocks.ts"; import { CliArgs } from "../../shared/cli/cli-args.service.ts"; import { commandTargetLayer } from "../../shared/output/command-target.layer.ts"; -import { announceTarget } from "../../shared/output/command-target.service.ts"; +import { + announceTarget, + type CommandTargetJson, +} from "../../shared/output/command-target.service.ts"; import type { OutputFormat } from "../../shared/output/types.ts"; import { classifyCliErrorActionability } from "../../shared/telemetry/error-actionability.ts"; import { loadProjectEnvValues } from "../db-config.toml-read.ts"; @@ -48,6 +51,12 @@ const REQUEST: HostedMutationRequest = { details: [" 20240101_a.sql", " 20240102_b.sql"], }; +const UNVERIFIED_JSON: CommandTargetJson = { + kind: "linked", + project_ref: "abcdefghijklmnopqrst", + environment: "unverified", +}; + const interactiveTerminal: Scenario = { format: "text", interactive: true, stdinIsTty: true }; const harness = (scenario: Scenario) => { @@ -81,7 +90,7 @@ const harness = (scenario: Scenario) => { withShellEnv( (scenario.announced === undefined ? body - : Effect.flatMap(announceTarget({ text: scenario.announced, json: {} }), () => body) + : Effect.flatMap(announceTarget({ text: scenario.announced, json: UNVERIFIED_JSON }), () => body) ).pipe(Effect.provide(layer)), ); return { out, prepare }; diff --git a/apps/cli/src/command-internal/parent-project-ref.ts b/apps/cli/src/command-internal/parent-project-ref.ts index 893744234a..d576845933 100644 --- a/apps/cli/src/command-internal/parent-project-ref.ts +++ b/apps/cli/src/command-internal/parent-project-ref.ts @@ -159,10 +159,7 @@ export const resolveParentScopedProjectRef = Effect.fn("ParentProjectRef.resolve yield* Path.Path, cliSettings.workdir, ); - const cachedParent = Option.exists(cache, (project) => project.ref === parent.ref); - yield* announceTarget( - describeLinkedTarget({ ref: parent.ref, source: cachedParent ? "file" : "env", cache }), - ); + yield* announceTarget(describeLinkedTarget({ ref: parent.ref, cache })); return parent.ref; } diff --git a/apps/cli/src/command-internal/project-target.ts b/apps/cli/src/command-internal/project-target.ts index 05cb89f225..ee22f5da37 100644 --- a/apps/cli/src/command-internal/project-target.ts +++ b/apps/cli/src/command-internal/project-target.ts @@ -18,7 +18,7 @@ import { branchRef, resolveBranchProjectRef } from "./branch-ref.resolver.ts"; import { compose, withCondition } from "./command-permissions/model.ts"; import { sanitizeInlineName } from "./http-errors.ts"; import { BRANCH_PROJECT_REF_PATTERN, BRANCH_UUID_PATTERN } from "./ref-patterns.ts"; -import type { ResolvedTarget } from "./target/resolve-target.ts"; +import { type LinkedTarget, linkedTarget } from "./target/resolve-target.ts"; import { renderBranchTarget } from "./target/target-identity.ts"; import { actionability, @@ -36,6 +36,11 @@ export interface ConfigTarget { readonly branch: string | undefined; } +/** A {@link ConfigTarget} with the same ref as the linked target connection resolution takes. */ +export interface ResolvedConfigTarget extends ConfigTarget { + readonly linked: LinkedTarget; +} + /** * Builds the four target-resolution failures {@link resolveConfigTarget} can raise. * A single type parameter suffices because {@link configTargetErrorsFor} already unions @@ -182,6 +187,7 @@ export const resolveConfigTarget = Effect.fn("ProjectTarget.resolve")(function* let ref: string; let branch: string | undefined; + let linked: LinkedTarget; if (Option.isSome(requested) && !BRANCH_PROJECT_REF_PATTERN.test(requested.value)) { const target = requested.value; branch = target; @@ -227,34 +233,24 @@ export const resolveConfigTarget = Effect.fn("ProjectTarget.resolve")(function* parentRef: parentProjectRef, }), ); + const { workdir } = yield* CommandSettings; + const linkedRef = yield* readProjectRefFile( + yield* FileSystem.FileSystem, + yield* Path.Path, + workdir, + ).pipe(Effect.orElseSucceed(() => Option.none<string>())); + linked = linkedTarget({ + ref, + source: "flag", + matchesLinkedRef: Option.contains(linkedRef, ref), + }); } else { yield* Effect.annotateCurrentSpan("project_target.kind", "project"); - ref = yield* resolver.resolve(requested); + const resolved = yield* resolver.resolveRef(requested); + ref = resolved.ref; + linked = linkedTarget(resolved); } yield* Effect.annotateCurrentSpan("project.ref", ref); - return { ref, branch }; -}); - -/** - * The linked {@link ResolvedTarget} for a ref {@link resolveConfigTarget} already announced, so - * connection resolution can use it without a second banner. Only `matchesLinkedRef` feeds - * connection resolution; `source` is informational. - */ -export const linkedTargetForConfigRef = Effect.fn("ProjectTarget.linkedTarget")(function* ( - requested: Option.Option<string>, - ref: string, -) { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const { workdir } = yield* CommandSettings; - const linkedRef = yield* readProjectRefFile(fs, path, workdir).pipe( - Effect.orElseSucceed(() => Option.none<string>()), - ); - return { - kind: "linked", - ref, - source: Option.isSome(requested) ? "flag" : "file", - matchesLinkedRef: Option.contains(linkedRef, ref), - } satisfies ResolvedTarget; + return { ref, branch, linked } satisfies ResolvedConfigTarget; }); diff --git a/apps/cli/src/command-internal/target/resolve-target.ts b/apps/cli/src/command-internal/target/resolve-target.ts index 088d975b36..139736c45c 100644 --- a/apps/cli/src/command-internal/target/resolve-target.ts +++ b/apps/cli/src/command-internal/target/resolve-target.ts @@ -1,11 +1,20 @@ -import { Effect, Option, Redacted, Result } from "effect"; +import { Effect, FileSystem, Option, Path, Redacted, Result } from "effect"; 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 { ProjectRefResolver, type ProjectRefSource } from "../../config/project-ref.service.ts"; +import { + PROJECT_REF_PATTERN, + ProjectRefResolver, + type ProjectRefSource, + type ResolvedProjectRef, +} from "../../config/project-ref.service.ts"; import { CliConfigLoadError } from "../../shared/config/cli-config-load.errors.ts"; -import { relativeConfigPath } from "../workdir-project.ts"; +import { readProjectRefFile } from "../../shared/config/temp-paths.ts"; +import { CommandTarget } from "../../shared/output/command-target.service.ts"; +import { readCachedLinkedProject } from "../parent-project-ref.ts"; +import { describeConfigParseError } from "../workdir-project.ts"; +import { linkedAlternativeHint } from "./target-identity.ts"; import { parseTargetSelector, type TargetSelectorInput } from "./target-selector.ts"; import { TargetPolicy } from "./target-policy.ts"; @@ -19,8 +28,18 @@ export type ResolvedTarget = } | { readonly kind: "db-url"; readonly url: Redacted.Redacted<string> }; +export type LinkedTarget = Extract<ResolvedTarget, { readonly kind: "linked" }>; + type TargetSelection = "flag" | "env" | "config" | "default"; +/** The linked target for a ref the ref chain has already resolved and announced. */ +export const linkedTarget = (resolved: ResolvedProjectRef): LinkedTarget => ({ + kind: "linked", + ref: resolved.ref, + source: resolved.source, + matchesLinkedRef: resolved.matchesLinkedRef, +}); + /** * Resolves the target a `sync` or `selectable` command acts on: a selector flag wins, then * `sync` means the linked project and `selectable` follows the workspace `target`. Linked goes @@ -47,13 +66,21 @@ export const resolveTarget = Effect.fn("Target.resolve")(function* (input: Targe return target; }); const linked = Effect.fnUntraced(function* (flag: Option.Option<string>) { - const resolved = yield* projectRef.resolveRef(flag); - return { - kind: "linked", - ref: resolved.ref, - source: resolved.source, - matchesLinkedRef: resolved.matchesLinkedRef, - } satisfies ResolvedTarget; + return linkedTarget(yield* projectRef.resolveRef(flag)); + }); + const hintLinkedAlternative = Effect.fnUntraced(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const { workdir } = yield* CommandSettings; + const linkedRef = yield* readProjectRefFile(fs, path, workdir).pipe( + Effect.map(Option.filter((ref) => PROJECT_REF_PATTERN.test(ref))), + Effect.orElseSucceed(() => Option.none<string>()), + ); + if (Option.isNone(linkedRef)) return; + const cell = yield* Effect.serviceOption(CommandTarget); + if (Option.isNone(cell)) return; + const cache = yield* readCachedLinkedProject(fs, path, workdir); + yield* cell.value.hintLocal(linkedAlternativeHint({ ref: linkedRef.value, cache })); }); switch (selection.explicit) { @@ -74,11 +101,7 @@ export const resolveTarget = Effect.fn("Target.resolve")(function* (input: Targe .load({ workdir, projectRef: Option.none() }) .pipe( Effect.catchTag("CliConfigParseError", (error) => - Effect.fail( - new CliConfigLoadError({ - message: `failed to parse ${relativeConfigPath(workdir, error.path)}: ${String(error.cause)}`, - }), - ), + Effect.fail(new CliConfigLoadError({ message: describeConfigParseError(workdir, error) })), ), ); const workspaceTarget = yield* resolvedConfig.get(CliConfigKeys.target); @@ -88,8 +111,9 @@ export const resolveTarget = Effect.fn("Target.resolve")(function* (input: Targe : workspaceTarget.origin.tier === "config" ? "config" : "default"; - return yield* finish( - workspaceTarget.value === "remote" ? yield* linked(Option.none()) : { kind: "local" }, - selectedBy, - ); + if (workspaceTarget.value === "remote") { + return yield* finish(yield* linked(Option.none()), selectedBy); + } + yield* hintLinkedAlternative(); + return yield* finish({ kind: "local" }, selectedBy); }); diff --git a/apps/cli/src/command-internal/target/target-identity.ts b/apps/cli/src/command-internal/target/target-identity.ts index 2c3171569e..71527493a5 100644 --- a/apps/cli/src/command-internal/target/target-identity.ts +++ b/apps/cli/src/command-internal/target/target-identity.ts @@ -1,6 +1,5 @@ import { Option } from "effect"; -import type { ProjectRefSource } from "../../config/project-ref.service.ts"; import type { RenderedTarget } from "../../shared/output/command-target.service.ts"; import { sanitizeInlineName } from "../http-errors.ts"; import type { CachedLinkedProject } from "../parent-project-ref.ts"; @@ -13,9 +12,9 @@ export const renderLocalTarget = (host: string, port: number): RenderedTarget => json: { kind: "local", host, port }, }); -/** Names the host only; the connection string's credentials never reach the banner. */ +/** Names the host and port only; the connection string's credentials never reach the banner. */ export const renderDbUrlTarget = (host: string, port: number): RenderedTarget => ({ - text: `custom Postgres (${sanitizeInlineName(host)})`, + text: `custom Postgres (${hostWithPort(sanitizeInlineName(host), port)})`, json: { kind: "db-url", host, port }, }); @@ -25,17 +24,21 @@ const named = (name: string | undefined, ref: string): string => { }; /** - * A linked project's environment from the `linked-project.json` cache alone, with no API call. - * A cache naming the ref itself is a production project. A cache naming another project while the - * ref came from `supabase/.temp/project-ref` is that project's branch, because `link <branch>` - * stores the parent in the cache. Any other ref is unverified. + * A linked project's identity from the `linked-project.json` cache alone, with no API call. Only a + * cache naming the resolved ref itself is evidence (a production project); a project that is + * merely linked, or one the cache cannot vouch for, is shown as its ref and reported `unverified`, + * because the cache can be stale or missing and never proves a branch. A ref that differs from + * the linked project's own says so. */ export const describeLinkedTarget = (input: { readonly ref: string; - readonly source: ProjectRefSource; readonly cache: Option.Option<CachedLinkedProject>; + /** The name the project picker showed for `ref`. */ + readonly name?: string; + /** The ref in `supabase/.temp/project-ref`, when the caller compares against the linked project. */ + readonly linkedRef?: Option.Option<string>; }): RenderedTarget => { - const { ref, source } = input; + const { ref } = input; const cached = Option.getOrUndefined(input.cache); if (cached?.ref === ref) { return { @@ -48,23 +51,28 @@ export const describeLinkedTarget = (input: { }, }; } - if (cached !== undefined && source === "file") { - return { - text: `project ${named(cached.name, cached.ref)} · branch (${ref})`, - json: { - kind: "linked", - project_ref: ref, - environment: "branch", - parent_project_ref: cached.ref, - }, - }; - } + const differsFromLinked = Option.exists(input.linkedRef ?? Option.none(), (linked) => linked !== ref); return { - text: `project ${ref} · identity unverified`, - json: { kind: "linked", project_ref: ref, environment: "unverified" }, + text: `project ${named(input.name, ref)}${differsFromLinked ? " (not the linked project)" : ""}`, + json: { + kind: "linked", + project_ref: ref, + ...(input.name === undefined ? {} : { project_name: input.name }), + environment: "unverified", + }, }; }; +/** The suffix a default-local banner carries when the checkout is linked, naming the project `--linked` would use. */ +export const linkedAlternativeHint = (input: { + readonly ref: string; + readonly cache: Option.Option<CachedLinkedProject>; +}): string => { + const cached = Option.getOrUndefined(input.cache); + const name = cached?.ref === input.ref ? cached.name : undefined; + return `default; pass --linked for ${named(name, input.ref)}`; +}; + /** A branch resolved to its own project ref; `branch` is absent for a UUID lookup and `parentRef` for anything but a name lookup. */ export const renderBranchTarget = (input: { readonly branch: Option.Option<string>; diff --git a/apps/cli/src/command-internal/target/target-identity.unit.test.ts b/apps/cli/src/command-internal/target/target-identity.unit.test.ts index de13ecae27..d5c1599bd7 100644 --- a/apps/cli/src/command-internal/target/target-identity.unit.test.ts +++ b/apps/cli/src/command-internal/target/target-identity.unit.test.ts @@ -3,6 +3,7 @@ import { Option } from "effect"; import { describeLinkedTarget, + linkedAlternativeHint, renderBranchTarget, renderDbUrlTarget, renderLocalTarget, @@ -27,7 +28,7 @@ describe("renderLocalTarget", () => { describe("renderDbUrlTarget", () => { it("names the host without credentials", () => { expect(renderDbUrlTarget("db.example.com", 5432)).toEqual({ - text: "custom Postgres (db.example.com)", + text: "custom Postgres (db.example.com:5432)", json: { kind: "db-url", host: "db.example.com", port: 5432 }, }); }); @@ -37,7 +38,6 @@ describe("describeLinkedTarget", () => { it("is production when the cache names the resolved ref", () => { const target = describeLinkedTarget({ ref: REF, - source: "flag", cache: Option.some({ ref: REF, name: "alpha" }), }); expect(target).toEqual({ @@ -47,56 +47,80 @@ describe("describeLinkedTarget", () => { }); it("omits the name when the cache has none", () => { - const target = describeLinkedTarget({ - ref: REF, - source: "file", - cache: Option.some({ ref: REF }), - }); + const target = describeLinkedTarget({ ref: REF, cache: Option.some({ ref: REF }) }); expect(target.text).toBe(`project ${REF} · production`); expect(target.json).toEqual({ kind: "linked", project_ref: REF, environment: "production" }); }); - it("is a branch when the ref file names a different project than the cached parent", () => { + it("never claims a branch for a ref the cache does not name", () => { const target = describeLinkedTarget({ ref: OTHER_REF, - source: "file", cache: Option.some({ ref: REF, name: "alpha" }), }); expect(target).toEqual({ - text: `project alpha (${REF}) · branch (${OTHER_REF})`, + text: `project ${OTHER_REF}`, + json: { kind: "linked", project_ref: OTHER_REF, environment: "unverified" }, + }); + }); + + it("is just the ref without a cache", () => { + const target = describeLinkedTarget({ ref: REF, cache: Option.none() }); + expect(target.text).toBe(`project ${REF}`); + }); + + it("says a ref that differs from the linked project is not the linked project", () => { + const target = describeLinkedTarget({ + ref: OTHER_REF, + cache: Option.some({ ref: REF, name: "alpha" }), + linkedRef: Option.some(REF), + }); + expect(target.text).toBe(`project ${OTHER_REF} (not the linked project)`); + expect(target.json).toEqual({ + kind: "linked", + project_ref: OTHER_REF, + environment: "unverified", + }); + }); + + it("does not flag the linked ref itself", () => { + const target = describeLinkedTarget({ + ref: REF, + cache: Option.none(), + linkedRef: Option.some(REF), + }); + expect(target.text).toBe(`project ${REF}`); + }); + + it("does not flag a ref when nothing is linked", () => { + const target = describeLinkedTarget({ + ref: REF, + cache: Option.none(), + linkedRef: Option.none(), + }); + expect(target.text).toBe(`project ${REF}`); + }); + + it("shows the project name the picker gave", () => { + const target = describeLinkedTarget({ + ref: OTHER_REF, + cache: Option.none(), + name: "beta", + linkedRef: Option.some(REF), + }); + expect(target).toEqual({ + text: `project beta (${OTHER_REF}) (not the linked project)`, json: { kind: "linked", project_ref: OTHER_REF, - environment: "branch", - parent_project_ref: REF, + project_name: "beta", + environment: "unverified", }, }); }); - it.each(["flag", "env", "picker"] as const)( - "is unverified for a %s ref the cache does not name", - (source) => { - const target = describeLinkedTarget({ - ref: OTHER_REF, - source, - cache: Option.some({ ref: REF, name: "alpha" }), - }); - expect(target).toEqual({ - text: `project ${OTHER_REF} · identity unverified`, - json: { kind: "linked", project_ref: OTHER_REF, environment: "unverified" }, - }); - }, - ); - - it("is unverified without a cache", () => { - const target = describeLinkedTarget({ ref: REF, source: "file", cache: Option.none() }); - expect(target.text).toBe(`project ${REF} · identity unverified`); - }); - it("strips control characters and newlines from a cached project name", () => { const target = describeLinkedTarget({ ref: REF, - source: "file", cache: Option.some({ ref: REF, name: "alpha\nTarget: forged\u001b[31m" }), }); expect(target.text).not.toContain("\n"); @@ -104,6 +128,22 @@ describe("describeLinkedTarget", () => { }); }); +describe("linkedAlternativeHint", () => { + it("names the cached project when the cache matches the linked ref", () => { + expect( + linkedAlternativeHint({ ref: REF, cache: Option.some({ ref: REF, name: "alpha" }) }), + ).toBe(`default; pass --linked for alpha (${REF})`); + }); + + it("falls back to the ref when the cache is absent or names another project", () => { + const expected = `default; pass --linked for ${REF}`; + expect(linkedAlternativeHint({ ref: REF, cache: Option.none() })).toBe(expected); + expect( + linkedAlternativeHint({ ref: REF, cache: Option.some({ ref: OTHER_REF, name: "beta" }) }), + ).toBe(expected); + }); +}); + describe("renderBranchTarget", () => { it("names the parent, branch and resolved ref for a name lookup", () => { const target = renderBranchTarget({ diff --git a/apps/cli/src/command-internal/workdir-project.ts b/apps/cli/src/command-internal/workdir-project.ts index d301665c97..c32b16b2ba 100644 --- a/apps/cli/src/command-internal/workdir-project.ts +++ b/apps/cli/src/command-internal/workdir-project.ts @@ -13,6 +13,14 @@ export function relativeConfigPath(workdir: string, path: string): string { return path.startsWith(workdir) ? path.slice(workdir.length).replace(/^[/\\]/, "") : path; } +/** The one message every loader reports for a config file that fails to parse. */ +export function describeConfigParseError( + workdir: string, + error: { readonly path: string; readonly cause: unknown }, +): string { + return `failed to parse ${relativeConfigPath(workdir, error.path)}: ${String(error.cause)}`; +} + /** * Shared "no project here" message for JSON-capable config loads (`config diff/push/pull`, * `gen types`, `storage ls|mv|rm|cp`, `seed buckets`). diff --git a/apps/cli/src/commands/backups/restore/restore.integration.test.ts b/apps/cli/src/commands/backups/restore/restore.integration.test.ts index 4bfb948269..cb758bcde8 100644 --- a/apps/cli/src/commands/backups/restore/restore.integration.test.ts +++ b/apps/cli/src/commands/backups/restore/restore.integration.test.ts @@ -54,7 +54,7 @@ const buildRuntime = (opts: Parameters<typeof buildTestRuntime>[0]) => Layer.succeed(TargetPolicy, { group: "hosted", consent: "no" }), ); -const confirmed = `Confirmed by --yes: Start a PITR restore without a recovery time target on project ${VALID_REF} · identity unverified\n`; +const confirmed = `Confirmed by --yes: Start a PITR restore without a recovery time target on project ${VALID_REF}\n`; function setup(opts: SetupOpts = {}) { const out = mockOutput({ format: opts.format ?? "text" }); diff --git a/apps/cli/src/commands/branches/pause/pause.integration.test.ts b/apps/cli/src/commands/branches/pause/pause.integration.test.ts index e89cf389b4..224f2b659e 100644 --- a/apps/cli/src/commands/branches/pause/pause.integration.test.ts +++ b/apps/cli/src/commands/branches/pause/pause.integration.test.ts @@ -81,7 +81,7 @@ const buildRuntime = (opts: Parameters<typeof buildTestRuntime>[0]) => Layer.succeed(TargetPolicy, { group: "hosted", consent: "no" }), ); -const confirmed = `Confirmed by --yes: Pause preview branch ${BRANCH_REF} on project ${VALID_REF} · identity unverified\n`; +const confirmed = `Confirmed by --yes: Pause preview branch ${BRANCH_REF} on project ${VALID_REF}\n`; const sequence = (requests: ReadonlyArray<{ method: string; url: string }>) => requests.map(({ method, url }) => `${method} ${url}`); diff --git a/apps/cli/src/commands/branches/unpause/unpause.integration.test.ts b/apps/cli/src/commands/branches/unpause/unpause.integration.test.ts index aa6c631da6..bdab2bcd95 100644 --- a/apps/cli/src/commands/branches/unpause/unpause.integration.test.ts +++ b/apps/cli/src/commands/branches/unpause/unpause.integration.test.ts @@ -68,7 +68,7 @@ const buildRuntime = (opts: Parameters<typeof buildTestRuntime>[0]) => Layer.succeed(TargetPolicy, { group: "hosted", consent: "no" }), ); -const confirmed = `Confirmed by --yes: Unpause preview branch ${BRANCH_REF} on project ${VALID_REF} · identity unverified\n`; +const confirmed = `Confirmed by --yes: Unpause preview branch ${BRANCH_REF} on project ${VALID_REF}\n`; const sequence = (requests: ReadonlyArray<{ method: string; url: string }>) => requests.map(({ method, url }) => `${method} ${url}`); diff --git a/apps/cli/src/commands/config/config.target.ts b/apps/cli/src/commands/config/config.target.ts deleted file mode 100644 index 128c51ccba..0000000000 --- a/apps/cli/src/commands/config/config.target.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { Effect, Option } from "effect"; - -import { CommandTarget } from "../../shared/output/command-target.service.ts"; -import { configIsRecord } from "./config.paths.ts"; - -/** Puts the announced target's identity fields (`kind`, `project_name`, `environment`, …) in front of the payload's own `target` fields, which win on a clash. */ -export const configPayloadWithTargetIdentity = Effect.fnUntraced(function* ( - payload: Record<string, unknown>, -) { - const announced = yield* CommandTarget.use((cell) => cell.json); - if (Option.isNone(announced)) return payload; - const own = configIsRecord(payload["target"]) ? payload["target"] : {}; - return { ...payload, target: { ...announced.value, ...own } }; -}); diff --git a/apps/cli/src/commands/config/diff/diff.handler.ts b/apps/cli/src/commands/config/diff/diff.handler.ts index adb6236428..a28131b9ff 100644 --- a/apps/cli/src/commands/config/diff/diff.handler.ts +++ b/apps/cli/src/commands/config/diff/diff.handler.ts @@ -32,7 +32,7 @@ import { } from "../config.load.ts"; import { configApiScope, configEnvOriginLookup, configScopeLine } from "../config.format.ts"; import { configProjectConfigTry } from "../config.project-config.ts"; -import { configPayloadWithTargetIdentity } from "../config.target.ts"; +import { withAnnouncedTarget } from "../../../shared/output/command-target.service.ts"; import { configReadStatusMessage } from "../config.read-status.ts"; import { configDiffComparisonLine, @@ -203,7 +203,7 @@ export const configDiff = Effect.fn("config.diff")(function* (flags: ConfigDiffF if (output.format !== "text") { yield* output.success( configDiffSummaryMessage(changeSet, scope), - yield* configPayloadWithTargetIdentity( + yield* withAnnouncedTarget( configDiffPayload(changeSet, scope, context, originFor), ), ); diff --git a/apps/cli/src/commands/config/pull/pull.handler.ts b/apps/cli/src/commands/config/pull/pull.handler.ts index 8d3ff53191..10584fcf53 100644 --- a/apps/cli/src/commands/config/pull/pull.handler.ts +++ b/apps/cli/src/commands/config/pull/pull.handler.ts @@ -29,7 +29,7 @@ import { type ConfigPullOutcome, type ConfigPullSource, } from "../../../command-internal/config-pull-run.ts"; -import { configPayloadWithTargetIdentity } from "../config.target.ts"; +import { withAnnouncedTarget } from "../../../shared/output/command-target.service.ts"; import { configPullCreatedBlockLabel, configPullSummaryMessage } from "./pull.format.ts"; import type { ConfigPullPlan } from "./pull.plan.ts"; import { @@ -112,7 +112,7 @@ export const runConfigPull = Effect.fn("config.pull.run")(function* (input: Conf // keeps them. const emitOutcome = (planForOutput: ConfigPullPlan, outcome: ConfigPullOutcome) => output.format !== "text" - ? configPayloadWithTargetIdentity( + ? withAnnouncedTarget( configPullPayload(changeSet, scope, planForOutput, context, outcome), ).pipe( Effect.flatMap((payload) => diff --git a/apps/cli/src/commands/config/push/push.format.ts b/apps/cli/src/commands/config/push/push.format.ts index 595e21fe76..b4ce967966 100644 --- a/apps/cli/src/commands/config/push/push.format.ts +++ b/apps/cli/src/commands/config/push/push.format.ts @@ -58,12 +58,12 @@ export interface PushEnvSourced { } const PUSH_UPDATING_PREFIX: Readonly<Record<PushResource, string>> = { - api: "Updating API service with config:", - "db.settings": "Updating DB service with config:", - "db.network_restrictions": "Updating network restrictions with config:", - "db.ssl_enforcement": "Updating SSL enforcement with config:", - auth: "Updating Auth service with config:", - storage: "Updating Storage service with config:", + api: "Would update API service with:", + "db.settings": "Would update DB service with:", + "db.network_restrictions": "Would update network restrictions with:", + "db.ssl_enforcement": "Would update SSL enforcement with:", + auth: "Would update Auth service with:", + storage: "Would update Storage service with:", }; const RESOURCE_DISPLAY_NAME: Readonly<Record<PushResource, string>> = { @@ -174,9 +174,9 @@ export interface PushUpdatingLineInput { } /** - * The `Updating <resource> service with config:` block for a resource with at least one pushable - * difference. `changes` renders through the same per-property format `config diff` uses; the - * combined block always ends on a blank line. + * The `Would update <resource> service with:` plan block for a resource with at least one pushable + * difference, printed before the consent question. `changes` renders through the same per-property + * format `config diff` uses; the combined block always ends on a blank line. */ export function pushUpdatingLine(input: PushUpdatingLineInput): string { return ( @@ -453,6 +453,24 @@ export function pushCostLine(item: { readonly name: string; readonly price: stri return `Enabling ${item.name} will cost you ${item.price}.\n`; } +/** The `target` fields only the live probe can settle, overriding the announced identity. */ +export function configPushTargetFields(target: ConfigPushTarget): { + readonly environment?: "production" | "branch"; + readonly parent_project_ref?: string; +} { + switch (target.kind) { + case "project": + return { environment: "production" }; + case "branch": + return { + environment: "branch", + ...(target.parentRef === undefined ? {} : { parent_project_ref: target.parentRef }), + }; + case "unknown": + return {}; + } +} + /** Additive machine-payload fields describing the resolved target. `is_branch` is omitted, not * `false`, when the target couldn't be determined — an absent key is the correct "unknown" * signal. */ 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 e9cc47e4d8..13267351e5 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 @@ -68,12 +68,12 @@ const NO_EXTRAS_OR_FORCED = { extras: [], forced: [], secretsEncoded: [] }; describe("pushUpdatingLine", () => { const RESOURCE_PREFIXES: ReadonlyArray<readonly [PushResource, string]> = [ - ["api", "Updating API service with config:"], - ["db.settings", "Updating DB service with config:"], - ["db.network_restrictions", "Updating network restrictions with config:"], - ["db.ssl_enforcement", "Updating SSL enforcement with config:"], - ["auth", "Updating Auth service with config:"], - ["storage", "Updating Storage service with config:"], + ["api", "Would update API service with:"], + ["db.settings", "Would update DB service with:"], + ["db.network_restrictions", "Would update network restrictions with:"], + ["db.ssl_enforcement", "Would update SSL enforcement with:"], + ["auth", "Would update Auth service with:"], + ["storage", "Would update Storage service with:"], ]; test.each(RESOURCE_PREFIXES)("uses the established prefix for %s", (resource, prefix) => { @@ -97,7 +97,7 @@ describe("pushUpdatingLine", () => { secretsEncoded: [SENT_SECRET.path], }), ).toBe( - "Updating Auth service with config:\n" + + "Would update Auth service with:\n" + "api.max_rows [update]\n" + " local: 2000\n" + " remote: 1000\n" + @@ -119,7 +119,7 @@ describe("pushUpdatingLine", () => { secretsEncoded: [SENT_SECRET_ABSENT_REMOTE.path], }), ).toBe( - "Updating Auth service with config:\n" + + "Would update Auth service with:\n" + "auth.captcha.secret [secret]\n" + " local: (set)\n" + " remote: (not set)\n" + @@ -136,7 +136,7 @@ describe("pushUpdatingLine", () => { ...NO_EXTRAS_OR_FORCED, }), ).toBe( - "Updating Auth service with config:\n" + + "Would update Auth service with:\n" + "auth.sms.twilio.auth_token [secret]\n" + " local: (not set — empty or unresolved env reference; will not be pushed)\n" + " remote: (not set)\n" + @@ -153,7 +153,7 @@ describe("pushUpdatingLine", () => { ...NO_EXTRAS_OR_FORCED, }), ).toBe( - "Updating Auth service with config:\n" + + "Would update Auth service with:\n" + "auth.sms.twilio.auth_token [secret]\n" + " local: (not set — empty or unresolved env reference; will not be pushed)\n" + " remote: (set)\n" + @@ -182,7 +182,7 @@ describe("pushUpdatingLine", () => { secretsEncoded: [], }), ).toBe( - "Updating Auth service with config:\n" + + "Would update Auth service with:\n" + "api.max_rows [update]\n" + " local: 2000\n" + " remote: 1000\n" + @@ -202,7 +202,7 @@ describe("pushUpdatingLine", () => { secrets: [UNCHANGED_SECRET, GATED_SECRET], ...NO_EXTRAS_OR_FORCED, }), - ).toBe("Updating Auth service with config:\n"); + ).toBe("Would update Auth service with:\n"); }); test("renders a [content] block for a template/notification body with no registry row", () => { @@ -216,7 +216,7 @@ describe("pushUpdatingLine", () => { forced: [], }), ).toBe( - "Updating Auth service with config:\n" + + "Would update Auth service with:\n" + "auth.email.template.invite.content [content]\n" + " local: (file content from content_path)\n" + " remote: (differs)\n" + @@ -235,7 +235,7 @@ describe("pushUpdatingLine", () => { forced: [{ path: ["db", "network_restrictions", "allowed_cidrs_v6"], value: [] }], }), ).toBe( - "Updating network restrictions with config:\n" + + "Would update network restrictions with:\n" + "db.network_restrictions.allowed_cidrs_v6 [group-write]\n" + " local: [] (schema default — not declared in config.toml)\n" + " remote: (not returned)\n" + @@ -254,7 +254,7 @@ describe("pushUpdatingLine", () => { }); expect(rendered.endsWith("\n\n")).toBe(true); expect(rendered).toBe( - "Updating Auth service with config:\n" + + "Would update Auth service with:\n" + "api.max_rows [update]\n" + " local: 2000\n" + " remote: 1000\n" + diff --git a/apps/cli/src/commands/config/push/push.handler.ts b/apps/cli/src/commands/config/push/push.handler.ts index 76d58ce986..030f041775 100644 --- a/apps/cli/src/commands/config/push/push.handler.ts +++ b/apps/cli/src/commands/config/push/push.handler.ts @@ -10,6 +10,7 @@ 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"; import { requireHostedMutationConsent } from "../../../command-internal/hosted-consent/hosted-consent.ts"; +import { withAnnouncedTarget } from "../../../shared/output/command-target.service.ts"; import { Output } from "../../../shared/output/output.service.ts"; import { assertDecryptableSecrets } from "../../../command-internal/db-config.toml-read.ts"; import { resolveLinkedParentRef } from "../../../command-internal/parent-project-ref.ts"; @@ -80,6 +81,7 @@ import { } from "./push.errors.ts"; import { configPushPayloadFields, + configPushTargetFields, pushConsentAction, pushCostLine, pushEnvSourcedLine, @@ -585,7 +587,7 @@ export const configPush = Effect.fn("config.push")(function* (flags: ConfigPushF } } if (webhooksEnabled) { - yield* output.raw(`Enabling webhooks for project: ${sanitizeInlineName(ref)}\n`, "stderr"); + yield* output.raw(`Would enable webhooks for project: ${sanitizeInlineName(ref)}\n`, "stderr"); } // 11. One consent covers every write; nothing is sent before it. @@ -725,10 +727,14 @@ export const configPush = Effect.fn("config.push")(function* (flags: ConfigPushF originFor, ), }; - yield* output.success(pushSummaryMessage(payloadInput), { - ...pushPayload(payloadInput), - ...configPushPayloadFields(target), - }); + yield* output.success( + pushSummaryMessage(payloadInput), + yield* withAnnouncedTarget({ + ...pushPayload(payloadInput), + ...configPushPayloadFields(target), + target: configPushTargetFields(target), + }), + ); } }).pipe( Effect.ensuring( 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 ce990e45d3..2aa6c9222e 100644 --- a/apps/cli/src/commands/config/push/push.integration.test.ts +++ b/apps/cli/src/commands/config/push/push.integration.test.ts @@ -480,7 +480,7 @@ describe("config push integration", () => { expect(Cause.pretty(exit.cause)).toContain("ConfigPushApiUpdateStatusError"); } expect(out.stderrText).toContain(`Target: project ${REF}`); - expect(out.stderrText).toContain("Updating API service with config:"); + expect(out.stderrText).toContain("Would update API service with:"); }).pipe(Effect.provide(layer)); }); @@ -1882,7 +1882,7 @@ allowed_cidrs_v6 = ["::1/128"] }); return Effect.gen(function* () { yield* configPush({ projectRef: Option.none() }); - expect(out.stderrText).toContain("Enabling webhooks for project:"); + expect(out.stderrText).toContain("Would enable webhooks for project:"); expect( api.requests.some( (r) => @@ -1917,7 +1917,7 @@ allowed_cidrs_v6 = ["::1/128"] `PATCH ${API}/v1/projects/${REF}/postgrest`, `GET ${API}/v1/projects/${REF}`, ]); - expect(out.stderrText).toContain(`Target: project ${REF} · identity unverified`); + expect(out.stderrText).toContain(`Target: project ${REF}`); expect(yield* decodeJsonText(yield* fs.readFileString(cachePath))).toMatchObject({ ref: REF, }); @@ -1940,7 +1940,7 @@ allowed_cidrs_v6 = ["::1/128"] `GET ${API}/v2/projects/${REF}/config`, `PATCH ${API}/v1/projects/${REF}/postgrest`, ]); - expect(out.stderrText).toContain(`Target: project ${REF} · identity unverified`); + expect(out.stderrText).toContain(`Target: project ${REF}`); assertPermissionDrift({ command: "config push", requests: api.requests, exact: true }); }).pipe(Effect.provide(layer)); }); @@ -1987,7 +1987,7 @@ allowed_cidrs_v6 = ["::1/128"] return Effect.gen(function* () { const error = yield* configPush({ projectRef: Option.none() }).pipe(Effect.flip); expect(error).toBeInstanceOf(HostedMutationDeclinedError); - expect(out.stderrText).toContain("Enabling webhooks for project:"); + expect(out.stderrText).toContain("Would enable webhooks for project:"); expect(out.promptConfirmCalls[0]?.message).toContain("push config changes to Webhooks"); expect(methodsOf(apiMock)).not.toContain("enableDatabaseWebhook"); }).pipe(Effect.provide(layer)); @@ -2680,7 +2680,7 @@ secret = "super-secret" expect(out.stderrText).toContain( "Comparison scope: api, database, pooler, realtime, storage (not returned: auth)", ); - expect(out.stderrText).not.toContain("Updating Auth service with config:"); + expect(out.stderrText).not.toContain("Would update Auth service with:"); expect(out.promptConfirmCalls).toHaveLength(0); expect(methodsOf(apiMock)).not.toContain("updateAuthServiceConfig"); const success = out.messages.find((m) => m.type === "success"); @@ -2857,7 +2857,7 @@ content_path = "./templates-content-only/invite.html" yield* writeWorkdirFile(["templates-content-only", "invite.html"], "<h1>Invite</h1>"); yield* configPush({ projectRef: Option.none() }); expect(out.stderrText).toContain( - "Updating Auth service with config:\nauth.email.template.invite.content [content]\n local: (file content from content_path)\n remote: (differs)\n\n", + "Would update Auth service with:\nauth.email.template.invite.content [content]\n local: (file content from content_path)\n remote: (differs)\n\n", ); const update = apiMock.requests.find((r) => r.method === "updateAuthServiceConfig"); expect(update).toBeDefined(); @@ -3031,8 +3031,8 @@ const BRANCH_PUSH_TOML = `project_id = "test"\n[api]\nmax_rows = 2000\n`; /** * The realistic "already ran `supabase link <branch>`" state: `.temp/project-ref` holds the * branch's own ref, `.temp/linked-project.json` caches the parent (name "My App"), the live probe - * 404s, and the parent's branch list confirms it — so the target resolves to `{ kind: "branch", - * ref: BRANCH_REF, parentRef: PARENT_REF, branch: "feat-x" }`. + * 404s, and the parent's branch list confirms it — so the probed target is `{ kind: "branch", + * ref: BRANCH_REF, parentRef: PARENT_REF, branch: "feat-x" }` while the banner stays `project <ref>`. */ function setupLinkedBranchPush( opts: { @@ -3059,14 +3059,13 @@ function setupLinkedBranchPush( describe("config push branch/project target detection (CLI-2168)", () => { it.effect( - "the realistic 'link <branch>' then flag-less push flow announces the branch and proceeds", + "the realistic 'link <branch>' then flag-less push flow proceeds without inferring a branch from the cache", () => { const { layer, out, api } = setupLinkedBranchPush({ yes: true }); return Effect.gen(function* () { yield* configPush({ projectRef: Option.none() }); - expect(out.stderrText).toContain( - `Target: project My App (${PARENT_REF}) · branch (${BRANCH_REF})`, - ); + expect(out.stderrText).toContain(`Target: project ${BRANCH_REF}\n`); + expect(out.stderrText).not.toContain("· branch"); expect( api.requests.some((r) => r.url.includes(`/v1/projects/${PARENT_REF}/branches`)), ).toBe(true); @@ -3080,7 +3079,7 @@ describe("config push branch/project target detection (CLI-2168)", () => { ); it.effect( - "a branch push with no branch-list match still trusts a just-linked parent (no cached name)", + "a branch push with no branch-list match still reports a just-linked parent in the payload", () => { const { layer, out, api } = setup({ seed: Effect.all([ @@ -3089,13 +3088,20 @@ describe("config push branch/project target detection (CLI-2168)", () => { ]), toml: BRANCH_PUSH_TOML, yes: true, + format: "json", projectId: Option.none(), project: { status: 404, body: {} }, v2: { status: 200, body: v2Response({ ref: BRANCH_REF }) }, }); return Effect.gen(function* () { yield* configPush({ projectRef: Option.none() }); - expect(out.stderrText).toContain(`Target: project ${PARENT_REF} · branch (${BRANCH_REF})`); + const data = yield* decodeJsonRecord(out.messages.find((m) => m.type === "success")?.data); + expect(data["target"]).toEqual({ + kind: "linked", + project_ref: BRANCH_REF, + environment: "branch", + parent_project_ref: PARENT_REF, + }); expect(out.stderrText).not.toContain("My App"); expect(api.requests.some((r) => r.method === "PATCH" && r.url.includes("/postgrest"))).toBe( true, @@ -3123,7 +3129,7 @@ describe("config push branch/project target detection (CLI-2168)", () => { }).pipe(Effect.provide(layer)); }); - it.effect("declining a branch push fails before any write and still flushes telemetry", () => { + it.effect("declining a branch-linked push fails before any write and still flushes telemetry", () => { const { layer, out, api, telemetry, linkedProjectCache } = setupLinkedBranchPush({ confirm: [false], }); @@ -3131,7 +3137,7 @@ describe("config push branch/project target detection (CLI-2168)", () => { const error = yield* configPush({ projectRef: Option.none() }).pipe(Effect.flip); expect(error).toBeInstanceOf(HostedMutationDeclinedError); expect(out.promptConfirmCalls[0]?.message).toContain( - `on project My App (${PARENT_REF}) · branch (${BRANCH_REF})?`, + `on project ${BRANCH_REF}?`, ); expect(api.requests.some((r) => ["PATCH", "PUT", "POST"].includes(r.method))).toBe(false); expect(telemetry.flushed).toBe(true); @@ -3140,13 +3146,13 @@ describe("config push branch/project target detection (CLI-2168)", () => { }); it.effect( - "--yes confirms a branch push and echoes the confirmation with the branch target", + "--yes confirms a branch-linked push and echoes the confirmation with the project ref", () => { const { layer, out, api } = setupLinkedBranchPush({ yes: true }); return Effect.gen(function* () { yield* configPush({ projectRef: Option.none() }); expect(out.stderrText).toContain( - `Confirmed by --yes: push config changes to API on project My App (${PARENT_REF}) · branch (${BRANCH_REF})`, + `Confirmed by --yes: push config changes to API on project ${BRANCH_REF}`, ); expect(api.requests.some((r) => r.method === "PATCH" && r.url.includes("/postgrest"))).toBe( true, @@ -3165,6 +3171,12 @@ describe("config push branch/project target detection (CLI-2168)", () => { expect(data["is_branch"]).toBe(true); expect(data["branch"]).toBe("feat-x"); expect(data["parent_project_ref"]).toBe(PARENT_REF); + expect(data["target"]).toEqual({ + kind: "linked", + project_ref: BRANCH_REF, + environment: "branch", + parent_project_ref: PARENT_REF, + }); expect(Array.isArray(data["services"])).toBe(true); }).pipe(Effect.provide(layer)); }); @@ -3236,7 +3248,7 @@ describe("config push branch/project target detection (CLI-2168)", () => { }); return Effect.gen(function* () { yield* configPush({ projectRef: Option.none() }); - expect(out.stderrText).toContain(`Target: project ${PROBE_REF} · identity unverified`); + expect(out.stderrText).toContain(`Target: project ${PROBE_REF}`); expect(api.requests.some((r) => r.url.includes("/branches"))).toBe(false); expect(api.requests.some((r) => r.method === "PATCH" && r.url.includes("/postgrest"))).toBe( true, @@ -3290,7 +3302,7 @@ describe("config push branch/project target detection (CLI-2168)", () => { }); return Effect.gen(function* () { yield* configPush({ projectRef: Option.none() }); - expect(out.stderrText).toContain(`Target: project ${PROBE_REF} · identity unverified`); + expect(out.stderrText).toContain(`Target: project ${PROBE_REF}`); expect(api.requests.some((r) => r.method === "PATCH" && r.url.includes("/postgrest"))).toBe( true, ); @@ -3317,10 +3329,34 @@ describe("config push branch/project target detection (CLI-2168)", () => { const data = yield* decodeJsonRecord(success?.data); expect(data["project_ref"]).toBe(PROBE_REF); expect("is_branch" in data).toBe(false); + expect(data["target"]).toEqual({ + kind: "linked", + project_ref: PROBE_REF, + environment: "unverified", + }); }).pipe(Effect.provide(layer)); }, ); + it.effect("a live probe that finds the project reports it as production in the target", () => { + const { layer, out } = setup({ + toml: BRANCH_PUSH_TOML, + yes: true, + format: "json", + projectId: Option.some(PROBE_REF), + v2: { status: 200, body: v2Response({ ref: PROBE_REF }) }, + }); + return Effect.gen(function* () { + yield* configPush({ projectRef: Option.none() }); + const data = yield* decodeJsonRecord(out.messages.find((m) => m.type === "success")?.data); + expect(data["target"]).toEqual({ + kind: "linked", + project_ref: PROBE_REF, + environment: "production", + }); + }).pipe(Effect.provide(layer)); + }); + // The tests above cover "unknown" for a genuine probe error; the timeout-specific sub-case // remains untested. A `TestClock`-driven proof wasn't added: `configPush` does real, unmocked // filesystem I/O before it reaches the probe's `Effect.timeoutOrElse`, and that I/O settles on diff --git a/apps/cli/src/commands/db/advisors/SIDE_EFFECTS.md b/apps/cli/src/commands/db/advisors/SIDE_EFFECTS.md index 944eceb341..9544b09db8 100644 --- a/apps/cli/src/commands/db/advisors/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/db/advisors/SIDE_EFFECTS.md @@ -74,7 +74,7 @@ honored (see `command-settings.layer.unit.test.ts`). ### `--output-format text` Diagnostics on **stderr**: the `Target: …` line (linked project `Target: <name> (<ref>)`, -`Target: local stack (<host>:<port>)`, or `Target: custom Postgres (<host>)`) and +`Target: local stack (<host>:<port>)`, or `Target: custom Postgres (<host>:<port>)`) and `No issues found` (when no lints). The result is a pretty-printed 2-space JSON array on **stdout** (struct-order keys, `metadata` omitted when absent, trailing newline). diff --git a/apps/cli/src/commands/db/advisors/advisors.integration.test.ts b/apps/cli/src/commands/db/advisors/advisors.integration.test.ts index 010afd1815..ae26fcc014 100644 --- a/apps/cli/src/commands/db/advisors/advisors.integration.test.ts +++ b/apps/cli/src/commands/db/advisors/advisors.integration.test.ts @@ -314,6 +314,7 @@ function setup(opts: SetupOpts = {}) { Layer.succeed(YesFlag, false), Layer.succeed(DnsResolverFlag, "native"), Layer.succeed(CliArgs, { args: [] }), + BunServices.layer, ); return { layer, diff --git a/apps/cli/src/commands/db/diff/SIDE_EFFECTS.md b/apps/cli/src/commands/db/diff/SIDE_EFFECTS.md index 8de535fa12..5d80282bdb 100644 --- a/apps/cli/src/commands/db/diff/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/db/diff/SIDE_EFFECTS.md @@ -156,7 +156,7 @@ migra/pg-delta-engine-specific). ### `--output-format text` Progress to stderr (the `Target: …` line — linked project `Target: <name> (<ref>)`, -`Target: local stack (<host>:<port>)` or `Target: custom Postgres (<host>)`, printed once per +`Target: local stack (<host>:<port>)` or `Target: custom Postgres (<host>:<port>)`, printed once per distinct target — then `Creating shadow database...`, `Diffing schemas[: <list>]`, `Finished supabase db diff on branch <branch>.`, drop-statement warning, and the `--file` write warning). A configured `[db.migrations].schema_paths` also warns diff --git a/apps/cli/src/commands/db/dump/SIDE_EFFECTS.md b/apps/cli/src/commands/db/dump/SIDE_EFFECTS.md index 62bfb797ad..63ef4feffd 100644 --- a/apps/cli/src/commands/db/dump/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/db/dump/SIDE_EFFECTS.md @@ -66,7 +66,7 @@ a custom database. It only reads, so it never asks for confirmation, on any targ SQL goes to **stdout** (or `--file`) in **all** `--output-format` modes — there is no machine envelope for `db dump` (same rationale as `test db`). Diagnostics go to **stderr**: the `Target: …` line (linked project `Target: <name> (<ref>)`, -`Target: local stack (<host>:<port>)`, or `Target: custom Postgres (<host>)`), +`Target: local stack (<host>:<port>)`, or `Target: custom Postgres (<host>:<port>)`), `Dumping {schemas|data| roles} from {local|remote} database...`, the `--dry-run` notice, and the `Dumped schema to <abs>.` confirmation when `--file` is used. `--dry-run` prints the env-expanded script to stdout without diff --git a/apps/cli/src/commands/db/lint/SIDE_EFFECTS.md b/apps/cli/src/commands/db/lint/SIDE_EFFECTS.md index 91bbaaf2bd..20f7679053 100644 --- a/apps/cli/src/commands/db/lint/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/db/lint/SIDE_EFFECTS.md @@ -76,7 +76,7 @@ container stacks can enable it. Compose uses the Compose Postgres image. ### `--output-format text` Diagnostics on **stderr**: the `Target: …` line (linked project `Target: <name> (<ref>)`, -`Target: local stack (<host>:<port>)`, or `Target: custom Postgres (<host>)`), +`Target: local stack (<host>:<port>)`, or `Target: custom Postgres (<host>:<port>)`), `Linting schema: <s>`, and `\nNo schema errors found` (when no issues). The result is a pretty-printed 2-space JSON array on **stdout** (struct-order keys, `omitempty` fields dropped, trailing newline). A filtered-empty result diff --git a/apps/cli/src/commands/db/lint/lint.integration.test.ts b/apps/cli/src/commands/db/lint/lint.integration.test.ts index c83656513f..48734b9f04 100644 --- a/apps/cli/src/commands/db/lint/lint.integration.test.ts +++ b/apps/cli/src/commands/db/lint/lint.integration.test.ts @@ -254,6 +254,7 @@ function setup(opts: SetupOpts = {}) { Layer.succeed(YesFlag, false), Layer.succeed(CliArgs, { args: [] }), Layer.succeed(DnsResolverFlag, "native"), + BunServices.layer, ); return { layer, out, resolver, connection, telemetry, processControl, projectRef, cache, http }; } diff --git a/apps/cli/src/commands/db/push/push.e2e.test.ts b/apps/cli/src/commands/db/push/push.e2e.test.ts index a50119039c..a3a52a7bdd 100644 --- a/apps/cli/src/commands/db/push/push.e2e.test.ts +++ b/apps/cli/src/commands/db/push/push.e2e.test.ts @@ -48,7 +48,7 @@ describe("supabase db push --skip-vault", () => { { cwd: projectDir }, ); expect(exitCode).toBe(1); - expect(stderr).toContain("Target: custom Postgres (127.0.0.1)"); + expect(stderr).toContain("Target: custom Postgres (127.0.0.1:1)"); expect(stderr).toContain("failed to connect"); expect(stderr).not.toContain("failed to parse config:"); }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), diff --git a/apps/cli/src/commands/db/query/SIDE_EFFECTS.md b/apps/cli/src/commands/db/query/SIDE_EFFECTS.md index 689b9641ca..245330d1a5 100644 --- a/apps/cli/src/commands/db/query/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/db/query/SIDE_EFFECTS.md @@ -62,7 +62,7 @@ confirmation and makes the statement unable to write (see Notes). The local stac The query payload goes to **stdout** in every `--output-format` mode (there is no `--output-format` for `db query`; there is no machine envelope around the payload). Diagnostics go to **stderr**: the `Target: …` line (linked project -`Target: <name> (<ref>)`, `Target: local stack (<host>:<port>)`, `Target: custom Postgres (<host>)`) +`Target: <name> (<ref>)`, `Target: local stack (<host>:<port>)`, `Target: custom Postgres (<host>:<port>)`) and the confirmation question. DDL/DML with no result columns prints the command tag. - **table** (default for humans): `olekukonko/tablewriter` v1 box layout, NULL for nil. diff --git a/apps/cli/src/commands/db/reset/SIDE_EFFECTS.md b/apps/cli/src/commands/db/reset/SIDE_EFFECTS.md index 0548ac8c31..347d973c55 100644 --- a/apps/cli/src/commands/db/reset/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/db/reset/SIDE_EFFECTS.md @@ -221,7 +221,7 @@ surfaced as `1`. Every target prints a `Target: …` line to **stderr** before the first database change: the linked project (`Target: <name> (<ref>)`), `Target: local stack (<host>:<port>)`, or -`Target: custom Postgres (<host>)`. There is no `Connecting to … database…` line. +`Target: custom Postgres (<host>:<port>)`. There is no `Connecting to … database…` line. The remote path then asks for confirmation (see Notes), prints `Resetting remote database…` to **stderr**, then either the schema-files branch's apply (no per-file progress, CLI-1958) or the diff --git a/apps/cli/src/commands/domains/create/create.integration.test.ts b/apps/cli/src/commands/domains/create/create.integration.test.ts index dcc88b69a2..b4ae502a5d 100644 --- a/apps/cli/src/commands/domains/create/create.integration.test.ts +++ b/apps/cli/src/commands/domains/create/create.integration.test.ts @@ -72,7 +72,7 @@ const buildRuntime = (opts: Parameters<typeof buildTestRuntime>[0]) => const decodeJson = Schema.decodeEffect(Schema.fromJsonString(Schema.Unknown)); -const confirmed = `Confirmed by --yes: Create custom hostname ${CUSTOM_HOSTNAME} on project ${VALID_REF} · identity unverified\n`; +const confirmed = `Confirmed by --yes: Create custom hostname ${CUSTOM_HOSTNAME} on project ${VALID_REF}\n`; function apiOptions(opts: SetupOpts): MockCommandPlatformApiOpts { const cname = opts.cname ?? "ok"; diff --git a/apps/cli/src/commands/domains/delete/delete.integration.test.ts b/apps/cli/src/commands/domains/delete/delete.integration.test.ts index 97c397bfdc..0300689908 100644 --- a/apps/cli/src/commands/domains/delete/delete.integration.test.ts +++ b/apps/cli/src/commands/domains/delete/delete.integration.test.ts @@ -41,7 +41,7 @@ const buildRuntime = (opts: Parameters<typeof buildTestRuntime>[0]) => Layer.succeed(TargetPolicy, { group: "hosted", consent: "no" }), ); -const confirmed = `Confirmed by --yes: Delete the custom hostname config on project ${VALID_REF} · identity unverified\n`; +const confirmed = `Confirmed by --yes: Delete the custom hostname config on project ${VALID_REF}\n`; const decodeJson = Schema.decodeEffect(Schema.fromJsonString(Schema.Unknown)); function setup(opts: SetupOpts = {}) { @@ -184,7 +184,9 @@ describe("domains delete integration", () => { expect(yield* fs.exists(cachePath)).toBe(false); yield* domainsDelete(baseFlags); expect(out.stderrText).toBe( - unverifiedTargetBanner() + confirmed + "Deleted custom hostname config successfully.\n", + `Target: project alpha (${VALID_REF})\n` + + `Confirmed by --yes: Delete the custom hostname config on project alpha (${VALID_REF})\n` + + "Deleted custom hostname config successfully.\n", ); expect(api.requests.map(({ method, url }) => `${method} ${url}`)).toEqual([ "GET https://api.supabase.com/v1/projects", diff --git a/apps/cli/src/commands/encryption/update-root-key/update-root-key.integration.test.ts b/apps/cli/src/commands/encryption/update-root-key/update-root-key.integration.test.ts index f8eb8a4009..8cc5296e9c 100644 --- a/apps/cli/src/commands/encryption/update-root-key/update-root-key.integration.test.ts +++ b/apps/cli/src/commands/encryption/update-root-key/update-root-key.integration.test.ts @@ -183,7 +183,7 @@ describe("encryption update-root-key integration", () => { expect(out.stdoutText).toBe(""); expect(out.stderrText).toBe( unverifiedTargetBanner() + - `Confirmed by --yes: ${ACTION} on project ${VALID_REF} · identity unverified\n`, + `Confirmed by --yes: ${ACTION} on project ${VALID_REF}\n`, ); }).pipe(Effect.provide(layer)); }); diff --git a/apps/cli/src/commands/inspect/db/inspect-db.e2e.test.ts b/apps/cli/src/commands/inspect/db/inspect-db.e2e.test.ts index 60e6728ff2..d98bb6bd45 100644 --- a/apps/cli/src/commands/inspect/db/inspect-db.e2e.test.ts +++ b/apps/cli/src/commands/inspect/db/inspect-db.e2e.test.ts @@ -26,7 +26,7 @@ describe("supabase inspect db", () => { { home: home.dir, env: { HOME: home.dir } }, ); expect(exitCode).toBe(1); - expect(stderr).toContain("Target: custom Postgres (127.0.0.1)"); + expect(stderr).toContain("Target: custom Postgres (127.0.0.1:1)"); expect(stderr).toMatch(/failed to connect to postgres|connection refused|ECONNREFUSED/i); }), ), @@ -45,7 +45,7 @@ describe("supabase inspect db", () => { expect(exitCode).toBe(1); expect(stderr).toContain('Command "cache-hit" is deprecated, use "db-stats" instead.'); const deprecationIndex = stderr.indexOf('Command "cache-hit" is deprecated'); - const bannerIndex = stderr.indexOf("Target: custom Postgres (127.0.0.1)"); + const bannerIndex = stderr.indexOf("Target: custom Postgres (127.0.0.1:1)"); expect(deprecationIndex).toBeGreaterThanOrEqual(0); expect(bannerIndex).toBeGreaterThan(deprecationIndex); }), diff --git a/apps/cli/src/commands/inspect/report/report.e2e.test.ts b/apps/cli/src/commands/inspect/report/report.e2e.test.ts index 16cbef67e0..17a4fb6489 100644 --- a/apps/cli/src/commands/inspect/report/report.e2e.test.ts +++ b/apps/cli/src/commands/inspect/report/report.e2e.test.ts @@ -28,7 +28,7 @@ describe("supabase inspect report", () => { { home: home.dir, env: { HOME: home.dir } }, ); expect(exitCode).toBe(1); - expect(stderr).toContain("Target: custom Postgres (127.0.0.1)"); + expect(stderr).toContain("Target: custom Postgres (127.0.0.1:1)"); expect(stderr).toMatch(/failed to connect to postgres|connection refused|ECONNREFUSED/i); const dated = (yield* fs.readDirectory(outputDir)).filter((name) => /^\d{4}-\d{2}-\d{2}$/.test(name), diff --git a/apps/cli/src/commands/postgres-config/postgres-config.integration.test.ts b/apps/cli/src/commands/postgres-config/postgres-config.integration.test.ts index 3294594c87..e088fcf9c9 100644 --- a/apps/cli/src/commands/postgres-config/postgres-config.integration.test.ts +++ b/apps/cli/src/commands/postgres-config/postgres-config.integration.test.ts @@ -51,7 +51,7 @@ const decodeJson = Schema.decodeEffect(Schema.fromJsonString(Schema.Unknown)); const tempRoot = useTempWorkdir("supabase-postgres-config-int-"); const confirmedBy = (action: string) => - `Confirmed by --yes: ${action} on project ${VALID_REF} · identity unverified\n`; + `Confirmed by --yes: ${action} on project ${VALID_REF}\n`; const UPDATE_CONFIRMED = confirmedBy("Update the Postgres config overrides"); const DELETE_CONFIRMED = confirmedBy("Delete Postgres config overrides"); diff --git a/apps/cli/src/commands/pull/pull.aggregate.ts b/apps/cli/src/commands/pull/pull.aggregate.ts index 6a46d70652..8007e7da6d 100644 --- a/apps/cli/src/commands/pull/pull.aggregate.ts +++ b/apps/cli/src/commands/pull/pull.aggregate.ts @@ -1,7 +1,6 @@ import type { DownloadFunctionsResult } from "../../shared/functions/download.ts"; import type { MigrationFetchOutcome } from "../../command-internal/migration-fetch-run.ts"; import type { DbPullOutcome } from "../../command-internal/db-pull-run.ts"; -import type { TargetJson } from "../../shared/output/command-target.service.ts"; import type { PullAggregate, PullStepFailure, @@ -339,7 +338,6 @@ export function pullCounts(results: ReadonlyArray<PullStepResult>): PullCounts { export function pullAggregate(input: { readonly ref: string; readonly branch: string | undefined; - readonly targetIdentity: TargetJson; readonly dryRun: boolean; readonly confirmed: boolean; readonly dirtyPaths: ReadonlyArray<string>; @@ -348,7 +346,6 @@ export function pullAggregate(input: { return { ref: input.ref, branch: input.branch, - targetIdentity: input.targetIdentity, dryRun: input.dryRun, confirmed: input.confirmed, dirtyPaths: input.dirtyPaths, diff --git a/apps/cli/src/commands/pull/pull.aggregate.unit.test.ts b/apps/cli/src/commands/pull/pull.aggregate.unit.test.ts index ebb6a5acf3..3d36f11713 100644 --- a/apps/cli/src/commands/pull/pull.aggregate.unit.test.ts +++ b/apps/cli/src/commands/pull/pull.aggregate.unit.test.ts @@ -538,7 +538,6 @@ describe("pullAggregate", () => { pullAggregate({ ref: "abcdefghijklmnopqrst", branch: "staging", - targetIdentity: { kind: "linked", environment: "branch" }, dryRun: true, confirmed: false, dirtyPaths: ["supabase/config.toml"], @@ -547,7 +546,6 @@ describe("pullAggregate", () => { ).toEqual({ ref: "abcdefghijklmnopqrst", branch: "staging", - targetIdentity: { kind: "linked", environment: "branch" }, dryRun: true, confirmed: false, dirtyPaths: ["supabase/config.toml"], @@ -560,7 +558,6 @@ describe("pullAggregate", () => { pullAggregate({ ref: "abcdefghijklmnopqrst", branch: undefined, - targetIdentity: {}, dryRun: false, confirmed: true, dirtyPaths: [], @@ -586,7 +583,6 @@ describe("pullAggregate", () => { const aggregate = pullAggregate({ ref: "abcdefghijklmnopqrst", branch: undefined, - targetIdentity: {}, dryRun: false, confirmed: true, dirtyPaths: [], diff --git a/apps/cli/src/commands/pull/pull.format.ts b/apps/cli/src/commands/pull/pull.format.ts index 004a03a4a5..dd9102dbdb 100644 --- a/apps/cli/src/commands/pull/pull.format.ts +++ b/apps/cli/src/commands/pull/pull.format.ts @@ -34,7 +34,6 @@ export function pullPayload(aggregate: PullAggregate): Record<string, unknown> { return { schema_version: PULL_PAYLOAD_VERSION, target: { - ...aggregate.targetIdentity, project_ref: aggregate.ref, ...(aggregate.branch === undefined ? {} : { branch: aggregate.branch }), }, diff --git a/apps/cli/src/commands/pull/pull.format.unit.test.ts b/apps/cli/src/commands/pull/pull.format.unit.test.ts index fa41e6a2ae..122ace90d6 100644 --- a/apps/cli/src/commands/pull/pull.format.unit.test.ts +++ b/apps/cli/src/commands/pull/pull.format.unit.test.ts @@ -53,16 +53,10 @@ describe("pullPayload", () => { }); }); - it("merges the announced target's identity fields into target, keeping project_ref and branch", () => { + it("names the branch it was given next to the project ref", () => { const aggregate: PullAggregate = { ref: PROJECT_REF, branch: "staging", - targetIdentity: { - kind: "linked", - project_ref: "ignored-in-favour-of-ref", - environment: "branch", - parent_project_ref: "bbbbbbbbbbbbbbbbbbbb", - }, dryRun: true, confirmed: false, dirtyPaths: [], @@ -70,10 +64,7 @@ describe("pullPayload", () => { }; expect(pullPayload(aggregate)["target"]).toEqual({ - kind: "linked", project_ref: PROJECT_REF, - environment: "branch", - parent_project_ref: "bbbbbbbbbbbbbbbbbbbb", branch: "staging", }); }); diff --git a/apps/cli/src/commands/pull/pull.handler.ts b/apps/cli/src/commands/pull/pull.handler.ts index f8e2785547..6f84daa0cb 100644 --- a/apps/cli/src/commands/pull/pull.handler.ts +++ b/apps/cli/src/commands/pull/pull.handler.ts @@ -9,14 +9,13 @@ import { validateWorkdirIsDirectory } from "../../command-internal/workdir-valid import { MachineErrorContext } from "../../shared/output/machine-error-context.service.ts"; import { resolveYes, OutputFlag } from "../../command-internal/global-flags.ts"; import { requireHostedMutationConsent } from "../../command-internal/hosted-consent/hosted-consent.ts"; -import { CommandTarget, type TargetJson } from "../../shared/output/command-target.service.ts"; +import { withAnnouncedTarget } from "../../shared/output/command-target.service.ts"; import { Output } from "../../shared/output/output.service.ts"; import { Tty } from "../../shared/runtime/tty.service.ts"; import { LinkedProjectCache } from "../../telemetry/linked-project-cache.service.ts"; import { TelemetryState } from "../../telemetry/telemetry-state.service.ts"; import { configTargetErrorsFor, - linkedTargetForConfigRef, resolveConfigTarget, } from "../../command-internal/project-target.ts"; import { unexpectedStatusMessage } from "../../command-internal/http-errors.ts"; @@ -101,10 +100,18 @@ const configPullPayloadFor = (runPlan: ConfigPullRunPlan, outcome: ConfigPullOut * mixed-failure disposition is inlined separately instead, since machine mode there feeds the * payload into `MachineErrorContext` ahead of a re-failed cause. */ -const pullEmit = (output: typeof Output.Service, aggregate: PullAggregate) => - output.format === "text" - ? output.raw(renderPullSummary(aggregate)) - : output.success(pullSummaryMessage(aggregate), pullPayload(aggregate)); +const pullEmit = Effect.fnUntraced(function* ( + output: typeof Output.Service, + aggregate: PullAggregate, +) { + if (output.format === "text") { + return yield* output.raw(renderPullSummary(aggregate)); + } + return yield* output.success( + pullSummaryMessage(aggregate), + yield* withAnnouncedTarget(pullPayload(aggregate)), + ); +}); /** * Runs `effect`, capturing a typed failure into a failed `Exit` instead of letting it @@ -194,7 +201,6 @@ export const pull = Effect.fn("pull")(function* (flags: PullFlags) { const linkedProjectCache = yield* LinkedProjectCache; const telemetryState = yield* TelemetryState; const machineErrorContext = yield* MachineErrorContext; - const commandTarget = yield* CommandTarget; const requested = Option.filter(flags.projectRef, (value) => value.length > 0); const remoteLabel = Option.getOrUndefined( @@ -219,7 +225,7 @@ export const pull = Effect.fn("pull")(function* (flags: PullFlags) { // Resolves the pull target exactly once; every sub-step below targets `ref` directly // instead of re-resolving (ADR 0024). - const { ref, branch, runPlan, source } = yield* Effect.gen(function* () { + const { ref, branch, linked, runPlan, source } = yield* Effect.gen(function* () { const pullSource = yield* openConfigPullSource(); const target = yield* resolveConfigTarget(requested, pullTargetErrors, mapBranchResolveError); yield* Ref.set(resolvedRef, Option.some(target.ref)); @@ -231,12 +237,15 @@ export const pull = Effect.fn("pull")(function* (flags: PullFlags) { remoteLabel, source: pullSource, }); - return { ref: target.ref, branch: target.branch, runPlan: plan, source: pullSource }; + return { + ref: target.ref, + branch: target.branch, + linked: target.linked, + runPlan: plan, + source: pullSource, + }; }).pipe(Effect.withSpan("pull.resolveTarget")); - const linkedTarget = yield* linkedTargetForConfigRef(requested, ref); - const targetIdentity: TargetJson = Option.getOrElse(yield* commandTarget.json, () => ({})); - // Retry hints below use the planned remote destination's label, not the raw `remoteLabel` // flag: for a branch-derived implicit target, that's the `[remotes.<branch>]` block the // plan actually targets, which may differ from an unset `--remote-label`. @@ -355,7 +364,6 @@ export const pull = Effect.fn("pull")(function* (flags: PullFlags) { const aggregate = pullAggregate({ ref, branch, - targetIdentity, dryRun: true, confirmed: false, dirtyPaths, @@ -387,7 +395,7 @@ export const pull = Effect.fn("pull")(function* (flags: PullFlags) { const stepContext: PullStepContext = { ref, assumeYes: true, - target: linkedTarget, + target: linked, preapproved, }; const { results, firstFailureCause } = yield* Effect.gen(function* () { @@ -483,7 +491,6 @@ export const pull = Effect.fn("pull")(function* (flags: PullFlags) { const aggregate = pullAggregate({ ref, branch, - targetIdentity, dryRun: false, confirmed: true, dirtyPaths, @@ -497,7 +504,7 @@ export const pull = Effect.fn("pull")(function* (flags: PullFlags) { if (output.format === "text") { yield* output.raw(renderPullSummary(aggregate)); } else { - yield* machineErrorContext.set(pullPayload(aggregate)); + yield* machineErrorContext.set(yield* withAnnouncedTarget(pullPayload(aggregate))); } return yield* Effect.failCause(firstFailureCause); }).pipe( diff --git a/apps/cli/src/commands/pull/pull.types.ts b/apps/cli/src/commands/pull/pull.types.ts index 598145a9e6..4509aa6dae 100644 --- a/apps/cli/src/commands/pull/pull.types.ts +++ b/apps/cli/src/commands/pull/pull.types.ts @@ -2,7 +2,6 @@ import type { Option } from "effect"; import type { HostedMutationConsent } from "../../command-internal/hosted-consent/hosted-consent.token.ts"; import type { ResolvedTarget } from "../../command-internal/target/resolve-target.ts"; -import type { TargetJson } from "../../shared/output/command-target.service.ts"; export const PULL_STEP_ORDER = ["config", "migration_history", "db", "functions"] as const; @@ -42,8 +41,6 @@ export interface PullStepContext { export interface PullAggregate { readonly ref: string; readonly branch: string | undefined; - /** The announced target's machine form (`kind`, identity fields), merged into the payload's `target`. */ - readonly targetIdentity?: TargetJson; readonly dryRun: boolean; readonly confirmed: boolean; /** Workdir-relative paths that had uncommitted or untracked changes at the start of this 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 eebbb4b201..b93248f600 100644 --- a/apps/cli/src/commands/secrets/set/set.integration.test.ts +++ b/apps/cli/src/commands/secrets/set/set.integration.test.ts @@ -154,7 +154,7 @@ describe("secrets set integration", () => { secrets: ["FOO=hunter2", "BAR=swordfish"], }); expect(out.promptConfirmCalls[0]?.message).toBe( - `set 2 secrets on project ${VALID_REF} · identity unverified?`, + `set 2 secrets on project ${VALID_REF}?`, ); expect(out.promptConfirmCalls[0]?.opts?.defaultValue).toBe(false); expect(out.stderrText).toContain("Secrets to set:\n • FOO\n • BAR\n"); diff --git a/apps/cli/src/commands/secrets/unset/unset.integration.test.ts b/apps/cli/src/commands/secrets/unset/unset.integration.test.ts index b50391a97c..acf0bd943e 100644 --- a/apps/cli/src/commands/secrets/unset/unset.integration.test.ts +++ b/apps/cli/src/commands/secrets/unset/unset.integration.test.ts @@ -60,7 +60,7 @@ const MIXED_SECRETS: SecretsList = [ { name: "BAR", value: "d3" }, ]; -const ON_TARGET = `project ${VALID_REF} · identity unverified`; +const ON_TARGET = `project ${VALID_REF}`; function setup(opts: SetupOpts = {}) { const out = mockOutput({ diff --git a/apps/cli/src/commands/test/db/SIDE_EFFECTS.md b/apps/cli/src/commands/test/db/SIDE_EFFECTS.md index 3ffcfaf8c1..1972f3742f 100644 --- a/apps/cli/src/commands/test/db/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/test/db/SIDE_EFFECTS.md @@ -103,7 +103,7 @@ subprocess's stdout is forwarded chunk-by-chunk, byte-exact and unframed) — ### `--output-format text` TAP streams to stdout. The `Target: …` line (linked project `Target: <name> (<ref>)`, -`Target: local stack (<host>:<port>)`, or `Target: custom Postgres (<host>)`) and the hosted +`Target: local stack (<host>:<port>)`, or `Target: custom Postgres (<host>:<port>)`) and the hosted confirmation question are written to **stderr**, never to stdout — no spinner is used, so stdout carries only the raw TAP bytes. diff --git a/apps/cli/src/commands/vanity-subdomains/vanity-subdomains.integration.test.ts b/apps/cli/src/commands/vanity-subdomains/vanity-subdomains.integration.test.ts index a3377c24e4..755e44946d 100644 --- a/apps/cli/src/commands/vanity-subdomains/vanity-subdomains.integration.test.ts +++ b/apps/cli/src/commands/vanity-subdomains/vanity-subdomains.integration.test.ts @@ -46,7 +46,7 @@ const buildRuntime = (opts: Parameters<typeof buildTestRuntime>[0]) => Layer.succeed(TargetPolicy, { group: "hosted", consent: "no" }), ); -const confirmedDelete = `Confirmed by --yes: Delete the vanity subdomain on project ${VALID_REF} · identity unverified\n`; +const confirmedDelete = `Confirmed by --yes: Delete the vanity subdomain on project ${VALID_REF}\n`; const API_URL = "https://api.supabase.com"; const decodeJson = Schema.decodeEffect(Schema.fromJsonString(Schema.Unknown)); diff --git a/apps/cli/src/config/project-ref.layer.ts b/apps/cli/src/config/project-ref.layer.ts index 6bd76049d4..f397f6869e 100644 --- a/apps/cli/src/config/project-ref.layer.ts +++ b/apps/cli/src/config/project-ref.layer.ts @@ -54,7 +54,7 @@ export const projectRefLayer = Layer.effect( const readRefFile = readProjectRefFile(fs, path, cliSettings.workdir); - const promptForProjectRef = Effect.fnUntraced(function* (title: string) { + const pickProject = Effect.fnUntraced(function* (title: string) { const api = yield* platformApi.make.pipe( Effect.mapError( (cause) => @@ -75,6 +75,7 @@ export const projectRefLayer = Layer.effect( }), ), ); + const names = new Map(projects.map((project) => [project.id, project.name] as const)); const options = projects.map((project) => ({ value: project.id, label: project.id, @@ -90,16 +91,26 @@ export const projectRefLayer = Layer.effect( ); // In text mode `output.info` writes to stderr; in json/stream-json modes it's a no-op. yield* output.info(`Selected project: ${chosen}`); - return chosen; + return { ref: chosen, name: names.get(chosen) }; }); - const announceLinked = Effect.fnUntraced(function* (ref: string, source: ProjectRefSource) { + const promptForProjectRef = (title: string) => + pickProject(title).pipe(Effect.map((picked) => picked.ref)); + + const announceLinked = Effect.fnUntraced(function* ( + ref: string, + identity: { readonly name?: string; readonly linkedRef?: Option.Option<string> } = {}, + ) { const cache = yield* readCachedLinkedProject(fs, path, cliSettings.workdir); - yield* commandTarget.announce(describeLinkedTarget({ ref, source, cache })); + yield* commandTarget.announce(describeLinkedTarget({ ref, cache, ...identity })); }); const resolveRef = Effect.fnUntraced(function* (flagValue: Option.Option<string>) { - let resolved: { readonly ref: string; readonly source: ProjectRefSource }; + let resolved: { + readonly ref: string; + readonly source: ProjectRefSource; + readonly name?: string; + }; let linkedRef = Option.none<string>(); if (Option.isSome(flagValue) && flagValue.value.length > 0) { resolved = { ref: yield* assertValid(flagValue.value), source: "flag" }; @@ -110,8 +121,12 @@ export const projectRefLayer = Layer.effect( if (Option.isSome(linkedRef)) { resolved = { ref: yield* assertValid(linkedRef.value), source: "file" }; } else if (tty.stdinIsTty && output.interactive) { - const chosen = yield* promptForProjectRef("Select a project:"); - resolved = { ref: yield* assertValid(chosen), source: "picker" }; + const picked = yield* pickProject("Select a project:"); + resolved = { + ref: yield* assertValid(picked.ref), + source: "picker", + ...(picked.name === undefined ? {} : { name: picked.name }), + }; } else { return yield* new ProjectRefNotLinkedError({ message: PROJECT_NOT_LINKED_MESSAGE }); } @@ -119,9 +134,13 @@ export const projectRefLayer = Layer.effect( if (resolved.source === "flag" || resolved.source === "env") { linkedRef = yield* readRefFile.pipe(Effect.orElseSucceed(() => Option.none<string>())); } - yield* announceLinked(resolved.ref, resolved.source); + yield* announceLinked(resolved.ref, { + linkedRef, + ...(resolved.name === undefined ? {} : { name: resolved.name }), + }); return { - ...resolved, + ref: resolved.ref, + source: resolved.source, matchesLinkedRef: Option.isSome(linkedRef) && linkedRef.value === resolved.ref, } satisfies ResolvedProjectRef; }); @@ -131,15 +150,18 @@ export const projectRefLayer = Layer.effect( resolve: (flagValue) => resolveRef(flagValue).pipe(Effect.map((resolved) => resolved.ref)), resolveForLink: (flagValue) => Effect.gen(function* () { - let resolved: { readonly ref: string; readonly source: ProjectRefSource }; + let resolved: { readonly ref: string; readonly name?: string }; if (Option.isSome(flagValue) && flagValue.value.length > 0) { - resolved = { ref: yield* assertValid(flagValue.value), source: "flag" }; + resolved = { ref: yield* assertValid(flagValue.value) }; } else if (Option.isSome(cliSettings.projectId)) { - resolved = { ref: yield* assertValid(cliSettings.projectId.value), source: "env" }; + resolved = { ref: yield* assertValid(cliSettings.projectId.value) }; } else if (tty.stdinIsTty && output.interactive) { // `resolveForLink` skips the ref-file fallback that `resolveRef` uses. - const chosen = yield* promptForProjectRef("Select a project:"); - resolved = { ref: yield* assertValid(chosen), source: "picker" }; + const picked = yield* pickProject("Select a project:"); + resolved = { + ref: yield* assertValid(picked.ref), + ...(picked.name === undefined ? {} : { name: picked.name }), + }; } else { return yield* Effect.fail( new ProjectRefRequiredError({ @@ -147,7 +169,7 @@ export const projectRefLayer = Layer.effect( }), ); } - yield* announceLinked(resolved.ref, resolved.source); + yield* announceLinked(resolved.ref, resolved.name === undefined ? {} : { name: resolved.name }); return resolved.ref; }), resolveOptional: (flagValue) => 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 8c0511cc85..bfe9ecb529 100644 --- a/apps/cli/src/config/project-ref.layer.unit.test.ts +++ b/apps/cli/src/config/project-ref.layer.unit.test.ts @@ -355,7 +355,7 @@ describe("projectRefLayer", () => { const { resolveRef } = yield* ProjectRefResolver; const resolved = yield* resolveRef(Option.some(VALID_REF)); expect(resolved).toEqual({ ref: VALID_REF, source: "flag", matchesLinkedRef: false }); - expect(out.stderrText).toBe(banner(`project ${VALID_REF} · identity unverified`)); + expect(out.stderrText).toBe(banner(`project ${VALID_REF}`)); }).pipe(Effect.provide(layer)); }); @@ -380,20 +380,18 @@ describe("projectRefLayer", () => { }).pipe(Effect.provide(layer)); }); - it.effect("treats a saved ref that differs from the cached project as its branch", () => { + it.effect("never calls a saved ref a branch just because the cache names another project", () => { writeRefFile(tempRoot, ANOTHER_REF); writeLinkedCache(tempRoot, { ref: VALID_REF, name: "alpha" }); const { layer, out } = makeLayer({ workdir: tempRoot }); return Effect.gen(function* () { const { resolveRef } = yield* ProjectRefResolver; yield* resolveRef(Option.none()); - expect(out.stderrText).toBe( - banner(`project alpha (${VALID_REF}) · branch (${ANOTHER_REF})`), - ); + expect(out.stderrText).toBe(banner(`project ${ANOTHER_REF}`)); }).pipe(Effect.provide(layer)); }); - it.effect("leaves a flag ref outside the linked project unverified", () => { + it.effect("says a flag ref outside the linked project is not the linked project", () => { writeRefFile(tempRoot, VALID_REF); writeLinkedCache(tempRoot, { ref: VALID_REF, name: "alpha" }); const { layer, out } = makeLayer({ workdir: tempRoot }); @@ -401,7 +399,17 @@ describe("projectRefLayer", () => { const { resolveRef } = yield* ProjectRefResolver; const resolved = yield* resolveRef(Option.some(ANOTHER_REF)); expect(resolved.matchesLinkedRef).toBe(false); - expect(out.stderrText).toBe(banner(`project ${ANOTHER_REF} · identity unverified`)); + expect(out.stderrText).toBe(banner(`project ${ANOTHER_REF} (not the linked project)`)); + }).pipe(Effect.provide(layer)); + }); + + it.effect("says an env ref outside the linked project is not the linked project", () => { + writeRefFile(tempRoot, VALID_REF); + const { layer, out } = makeLayer({ workdir: tempRoot, projectId: ANOTHER_REF }); + return Effect.gen(function* () { + const { resolveRef } = yield* ProjectRefResolver; + yield* resolveRef(Option.none()); + expect(out.stderrText).toBe(banner(`project ${ANOTHER_REF} (not the linked project)`)); }).pipe(Effect.provide(layer)); }); @@ -432,13 +440,30 @@ describe("projectRefLayer", () => { }).pipe(Effect.provide(layer)); }); + it.effect("names the project the picker chose in the banner", () => { + const projects = [ + { id: ANOTHER_REF, name: "beta", organization_slug: "acme", region: "eu-west-1" }, + ]; + const { layer, out } = makeLayer({ + workdir: tempRoot, + stdinIsTty: true, + projects, + promptSelectResponses: [ANOTHER_REF], + }); + return Effect.gen(function* () { + const { resolveRef } = yield* ProjectRefResolver; + yield* resolveRef(Option.none()); + expect(out.stderrText).toBe(banner(`project beta (${ANOTHER_REF})`)); + }).pipe(Effect.provide(layer)); + }); + it.effect("announces once when the same command resolves twice", () => { const { layer, out } = makeLayer({ workdir: tempRoot }); return Effect.gen(function* () { const { resolve } = yield* ProjectRefResolver; yield* resolve(Option.some(VALID_REF)); yield* resolve(Option.some(VALID_REF)); - expect(out.stderrText).toBe(banner(`project ${VALID_REF} · identity unverified`)); + expect(out.stderrText).toBe(banner(`project ${VALID_REF}`)); }).pipe(Effect.provide(layer)); }); @@ -470,7 +495,7 @@ describe("projectRefLayer", () => { return Effect.gen(function* () { const { resolveForLink } = yield* ProjectRefResolver; yield* resolveForLink(Option.some(VALID_REF)); - expect(out.stderrText).toBe(banner(`project ${VALID_REF} · identity unverified`)); + expect(out.stderrText).toBe(banner(`project ${VALID_REF}`)); }).pipe(Effect.provide(layer)); }); }); diff --git a/apps/cli/src/shared/output/command-target.layer.ts b/apps/cli/src/shared/output/command-target.layer.ts index 7805643b59..8cc2d9a155 100644 --- a/apps/cli/src/shared/output/command-target.layer.ts +++ b/apps/cli/src/shared/output/command-target.layer.ts @@ -1,35 +1,46 @@ -import { Effect, Layer, Option, Ref } from "effect"; +import { Effect, Layer, Option, Ref, Schema } from "effect"; -import { CommandTarget, type RenderedTarget, type TargetJson } from "./command-target.service.ts"; +import { + CommandTarget, + CommandTargetJson, + type RenderedTarget, +} from "./command-target.service.ts"; import { MachineErrorContext } from "./machine-error-context.service.ts"; import { Output } from "./output.service.ts"; interface AnnouncedTargets { readonly texts: ReadonlyArray<string>; - readonly first: Option.Option<TargetJson>; + readonly first: Option.Option<CommandTargetJson>; } +const encodeTargetJson = Schema.encodeEffect(CommandTargetJson); + export const commandTargetLayer = Layer.effect( CommandTarget, Effect.gen(function* () { const output = yield* Output; const state = yield* Ref.make<AnnouncedTargets>({ texts: [], first: Option.none() }); + const localHint = yield* Ref.make(Option.none<string>()); return CommandTarget.of({ announce: (rendered: RenderedTarget) => Effect.gen(function* () { + const hint = yield* Ref.get(localHint); + const text = + rendered.json.kind === "local" && Option.isSome(hint) + ? `${rendered.text} · ${hint.value}` + : rendered.text; + const json = yield* encodeTargetJson(rendered.json).pipe(Effect.orDie); const outcome = yield* Ref.modify( state, (current): readonly ["repeat" | "first" | "additional", AnnouncedTargets] => - current.texts.includes(rendered.text) + current.texts.includes(text) ? ["repeat", current] : [ Option.isNone(current.first) ? "first" : "additional", { - texts: [...current.texts, rendered.text], - first: Option.isNone(current.first) - ? Option.some(rendered.json) - : current.first, + texts: [...current.texts, text], + first: Option.isNone(current.first) ? Option.some(json) : current.first, }, ], ); @@ -40,12 +51,13 @@ export const commandTargetLayer = Layer.effect( if (Option.isSome(context)) { const recorded = yield* context.value.get; if (recorded.target === undefined) { - yield* context.value.set({ target: rendered.json }); + yield* context.value.set({ target: json }); } } } - yield* output.raw(`Target: ${rendered.text}\n`, "stderr"); + yield* output.raw(`Target: ${text}\n`, "stderr"); }), + hintLocal: (hint: string) => Ref.set(localHint, Option.some(hint)), json: Ref.get(state).pipe(Effect.map((current) => current.first)), text: Ref.get(state).pipe(Effect.map((current) => Option.fromNullishOr(current.texts[0]))), }); diff --git a/apps/cli/src/shared/output/command-target.service.ts b/apps/cli/src/shared/output/command-target.service.ts index d2baeb11b2..66a5a80ab5 100644 --- a/apps/cli/src/shared/output/command-target.service.ts +++ b/apps/cli/src/shared/output/command-target.service.ts @@ -1,23 +1,49 @@ -import type { Effect, Option } from "effect"; -import { Context } from "effect"; +import { Context, Effect, Option, Schema } from "effect"; -/** The machine-readable form of a target, merged into JSON results and error envelopes. */ -export type TargetJson = Readonly<Record<string, unknown>>; +const LocalTargetJson = Schema.Struct({ + kind: Schema.Literal("local"), + host: Schema.String, + port: Schema.Number, +}); + +const DbUrlTargetJson = Schema.Struct({ + kind: Schema.Literal("db-url"), + host: Schema.String, + port: Schema.Number, +}); + +const LinkedTargetJson = Schema.Struct({ + kind: Schema.Literal("linked"), + project_ref: Schema.String, + project_name: Schema.optionalKey(Schema.String), + environment: Schema.Literals(["production", "branch", "unverified"]), + parent_project_ref: Schema.optionalKey(Schema.String), +}); + +/** The machine-readable form of a target, merged into JSON results and error envelopes as `target`. See `apps/cli/docs/command-target.md`. */ +export const CommandTargetJson = Schema.Union([ + LocalTargetJson, + DbUrlTargetJson, + LinkedTargetJson, +]).pipe(Schema.toTaggedUnion("kind")); +export type CommandTargetJson = typeof CommandTargetJson.Type; /** A target as announced to the user (`text`) and to machine consumers (`json`). */ export interface RenderedTarget { readonly text: string; - readonly json: TargetJson; + readonly json: CommandTargetJson; } interface CommandTargetShape { /** * Prints `Target: <text>` to stderr in every output format, once per distinct `text`. The first - * announced target also becomes the run's {@link TargetJson}. + * announced target also becomes the run's {@link CommandTargetJson}. */ readonly announce: (rendered: RenderedTarget) => Effect.Effect<void>; + /** Appends `hint` to the banner of a local target announced later in this run. */ + readonly hintLocal: (hint: string) => Effect.Effect<void>; /** The first announced target's JSON form, or `None` before any announcement. */ - readonly json: Effect.Effect<Option.Option<TargetJson>>; + readonly json: Effect.Effect<Option.Option<CommandTargetJson>>; /** The first announced target's text, or `None` before any announcement. */ readonly text: Effect.Effect<Option.Option<string>>; } @@ -29,3 +55,16 @@ export class CommandTarget extends Context.Service<CommandTarget, CommandTargetS export const announceTarget = (rendered: RenderedTarget) => CommandTarget.use((cell) => cell.announce(rendered)); + +const isRecord = (value: unknown): value is Record<string, unknown> => + typeof value === "object" && value !== null && !Array.isArray(value); + +/** Adds the announced target's identity fields in front of the payload's own `target` fields, which win on a clash. */ +export const withAnnouncedTarget = Effect.fnUntraced(function* ( + payload: Readonly<Record<string, unknown>>, +) { + const announced = yield* CommandTarget.use((cell) => cell.json); + if (Option.isNone(announced)) return payload; + const own = isRecord(payload["target"]) ? payload["target"] : {}; + return { ...payload, target: { ...announced.value, ...own } }; +}); diff --git a/apps/cli/src/shared/output/output.layer.unit.test.ts b/apps/cli/src/shared/output/output.layer.unit.test.ts index b73503850e..0cda08e196 100644 --- a/apps/cli/src/shared/output/output.layer.unit.test.ts +++ b/apps/cli/src/shared/output/output.layer.unit.test.ts @@ -16,7 +16,12 @@ import { import { TestClock } from "effect/testing"; import { CONTEXT_CANCELED_MESSAGE, NonInteractiveError } from "./errors.ts"; import { mockTty } from "../../../tests/helpers/mocks.ts"; -import { announceTarget, CommandTarget } from "./command-target.service.ts"; +import { + announceTarget, + CommandTarget, + type RenderedTarget, + withAnnouncedTarget, +} from "./command-target.service.ts"; import { commandTargetLayer } from "./command-target.layer.ts"; import { machineErrorContextLayer } from "./machine-error-context.layer.ts"; import { MachineErrorContext } from "./machine-error-context.service.ts"; @@ -1342,12 +1347,12 @@ describe("Output", () => { }); describe("command target", () => { - const localTarget = { + const localTarget: RenderedTarget = { text: "local stack (127.0.0.1:54322)", json: { kind: "local", host: "127.0.0.1", port: 54322 }, }; - const linkedTarget = { - text: "project abcdefghijklmnopqrst · identity unverified", + const linkedTarget: RenderedTarget = { + text: "project abcdefghijklmnopqrst", json: { kind: "linked", project_ref: "abcdefghijklmnopqrst", @@ -1498,7 +1503,7 @@ describe("Output", () => { yield* announceTarget(localTarget); yield* out.result({ diff: "" }); expect(mock.stderr).toEqual([ - "Target: project abcdefghijklmnopqrst · identity unverified\n", + "Target: project abcdefghijklmnopqrst\n", "Target: local stack (127.0.0.1:54322)\n", ]); const parsed = yield* decodeJson(mock.stdout[0]!); @@ -1517,6 +1522,48 @@ describe("Output", () => { }).pipe(Effect.provide(layerWith(jsonOutputLayer, mock))); }); + it.effect("a local banner carries the hint set before it was announced", () => { + const mock = mockStdio(); + return Effect.gen(function* () { + const cell = yield* CommandTarget; + yield* cell.hintLocal("default; pass --linked for alpha (abcdefghijklmnopqrst)"); + yield* announceTarget(localTarget); + expect(mock.stderr).toEqual([ + "Target: local stack (127.0.0.1:54322) · default; pass --linked for alpha (abcdefghijklmnopqrst)\n", + ]); + expect(yield* cell.json).toEqual(Option.some(localTarget.json)); + }).pipe(Effect.provide(layerWith(jsonOutputLayer, mock))); + }); + + it.effect("the hint only applies to a local banner", () => { + const mock = mockStdio(); + return Effect.gen(function* () { + const cell = yield* CommandTarget; + yield* cell.hintLocal("default; pass --linked for abcdefghijklmnopqrst"); + yield* announceTarget(linkedTarget); + expect(mock.stderr).toEqual(["Target: project abcdefghijklmnopqrst\n"]); + }).pipe(Effect.provide(layerWith(jsonOutputLayer, mock))); + }); + + it.effect("withAnnouncedTarget puts the announced target in front of the payload's own", () => { + const mock = mockStdio(); + return Effect.gen(function* () { + const announced = yield* withAnnouncedTarget({ rows: 1 }); + expect(announced).toEqual({ rows: 1 }); + yield* announceTarget(linkedTarget); + expect(yield* withAnnouncedTarget({ rows: 1 })).toEqual({ + rows: 1, + target: linkedTarget.json, + }); + expect( + yield* withAnnouncedTarget({ rows: 1, target: { environment: "branch", extra: true } }), + ).toEqual({ + rows: 1, + target: { ...linkedTarget.json, environment: "branch", extra: true }, + }); + }).pipe(Effect.provide(layerWith(jsonOutputLayer, mock))); + }); + it.effect("an error context target set before the announcement is kept", () => { const mock = mockStdio(); return Effect.gen(function* () { diff --git a/apps/cli/tests/helpers/command-mocks.ts b/apps/cli/tests/helpers/command-mocks.ts index 84ea5bb5e3..17aac70c77 100644 --- a/apps/cli/tests/helpers/command-mocks.ts +++ b/apps/cli/tests/helpers/command-mocks.ts @@ -85,7 +85,7 @@ export const VALID_REF = "abcdefghijklmnopqrst"; /** The stderr banner a command prints for a linked project with no `linked-project.json` cache. */ export const unverifiedTargetBanner = (ref: string = VALID_REF): string => - `Target: project ${ref} · identity unverified\n`; + `Target: project ${ref}\n`; export const VALID_TOKEN = "sbp_" + "a".repeat(40); export const DEFAULT_API_URL = "https://api.supabase.com"; From b1e37dc2b6037d67444e99cca671b8def1d290c8 Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Thu, 15 Oct 2026 17:12:14 +0100 Subject: [PATCH 89/92] refactor(cli): one config parse-error message, target docs and tests Every loader reports a malformed config file through describeConfigParseError, resolveTarget hints at --linked only for a default-local run on a linked checkout, resolveConfigTarget has integration coverage for its linked target, and the command target banner and JSON shape live in one shared doc that the SIDE_EFFECTS files link. --- apps/cli/docs/command-target.md | 35 ++++++ .../project-target.integration.test.ts | 111 ++++++++++++++++++ apps/cli/src/command-internal/seed-buckets.ts | 3 +- .../target/resolve-target.integration.test.ts | 68 +++++++++++ .../command-internal/target/resolve-target.ts | 2 +- .../workdir-project.unit.test.ts | 19 ++- apps/cli/src/commands/config/config.load.ts | 3 +- .../src/commands/config/diff/SIDE_EFFECTS.md | 2 +- .../src/commands/config/pull/SIDE_EFFECTS.md | 2 +- .../src/commands/config/push/SIDE_EFFECTS.md | 21 ++-- apps/cli/src/commands/domains/SIDE_EFFECTS.md | 2 +- .../src/commands/encryption/SIDE_EFFECTS.md | 2 +- .../commands/gen/gen.signing-keys-config.ts | 3 +- .../src/commands/gen/types/types.handler.ts | 6 +- apps/cli/src/commands/link/SIDE_EFFECTS.md | 5 +- .../commands/network-bans/get/SIDE_EFFECTS.md | 2 +- .../network-bans/remove/SIDE_EFFECTS.md | 2 +- .../network-restrictions/get/SIDE_EFFECTS.md | 2 +- .../update/SIDE_EFFECTS.md | 2 +- .../commands/notebooks/pull/SIDE_EFFECTS.md | 2 +- .../commands/notebooks/push/SIDE_EFFECTS.md | 2 +- .../postgres-config/delete/SIDE_EFFECTS.md | 2 +- .../postgres-config/get/SIDE_EFFECTS.md | 2 +- .../postgres-config/update/SIDE_EFFECTS.md | 2 +- apps/cli/src/commands/pull/SIDE_EFFECTS.md | 8 +- .../src/commands/secrets/list/SIDE_EFFECTS.md | 2 +- .../src/commands/secrets/set/SIDE_EFFECTS.md | 2 +- .../commands/secrets/unset/SIDE_EFFECTS.md | 2 +- .../snippets/download/SIDE_EFFECTS.md | 2 +- .../commands/snippets/list/SIDE_EFFECTS.md | 2 +- .../ssl-enforcement/get/SIDE_EFFECTS.md | 2 +- .../ssl-enforcement/update/SIDE_EFFECTS.md | 2 +- apps/cli/src/commands/sso/add/SIDE_EFFECTS.md | 2 +- .../cli/src/commands/sso/info/SIDE_EFFECTS.md | 2 +- .../cli/src/commands/sso/list/SIDE_EFFECTS.md | 2 +- .../src/commands/sso/remove/SIDE_EFFECTS.md | 2 +- .../cli/src/commands/sso/show/SIDE_EFFECTS.md | 2 +- .../src/commands/sso/update/SIDE_EFFECTS.md | 2 +- .../cli/src/commands/storage/storage.frame.ts | 7 +- .../command-target.service.unit.test.ts | 39 ++++++ 40 files changed, 328 insertions(+), 54 deletions(-) create mode 100644 apps/cli/docs/command-target.md create mode 100644 apps/cli/src/command-internal/project-target.integration.test.ts create mode 100644 apps/cli/src/shared/output/command-target.service.unit.test.ts diff --git a/apps/cli/docs/command-target.md b/apps/cli/docs/command-target.md new file mode 100644 index 0000000000..cf0e395b4a --- /dev/null +++ b/apps/cli/docs/command-target.md @@ -0,0 +1,35 @@ +# Command target + +Every command that acts on a database or hosted project announces the target it resolved, once, before it acts. The same identity goes to machine consumers as the JSON `target` object. + +## Banner + +`Target: <text>` on stderr in every output format, printed once per distinct text. + +| Target | Text | +| ------------------------------------- | ----------------------------------------------------------------------------------- | +| Local stack | `local stack (<host>:<port>)` | +| Local stack, nothing selected, linked | `local stack (<host>:<port>) · default; pass --linked for <name> (<ref>)` | +| `--db-url` | `custom Postgres (<host>:<port>)`; the connection string's credentials never appear | +| Linked project the cache names | `project <name> (<ref>) · production` | +| Linked project nothing vouches for | `project <ref>`, or `project <name> (<ref>)` when the project picker named it | +| Ref other than the linked project | `project <ref> (not the linked project)` | +| Branch found by name or UUID | `project <parent ref> · branch <name> (<ref>)`, or `branch <ref>` for a UUID | + +The `default; pass --linked` hint appears only when the checkout is linked and neither a selector flag nor a workspace `target` chose local. "Branch" is claimed only on positive evidence: a branch lookup by name or UUID, or a live probe. A cached link never proves one. + +## JSON `target` + +`json` and `stream-json` results carry `target`, tagged by `kind`: + +| `kind` | Fields | +| -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `local` | `host`, `port` | +| `db-url` | `host`, `port` | +| `linked` | `project_ref`, `environment` (`production`, `branch` or `unverified`), optional `project_name`, optional `parent_project_ref` (a branch's parent project) | + +`environment` is `production` when the linked-project cache names the resolved ref, `branch` on positive evidence, and `unverified` otherwise. A failed identity check is `unverified`, never `branch`. + +The first announced target becomes the run's `target`. It is merged into object results and into the machine error envelope. Array results are written unchanged. A command that owns extra `target` fields (for example `branch`, `local_scope`, or `config push`'s probed `environment`) adds them in front of the announced identity's, and its own values win on a clash. + +The schema is `CommandTargetJson` in `src/shared/output/command-target.service.ts`; every target is encoded through it before it reaches output. diff --git a/apps/cli/src/command-internal/project-target.integration.test.ts b/apps/cli/src/command-internal/project-target.integration.test.ts new file mode 100644 index 0000000000..2ebd4e2de3 --- /dev/null +++ b/apps/cli/src/command-internal/project-target.integration.test.ts @@ -0,0 +1,111 @@ +import { BunServices } from "@effect/platform-bun"; +import { describe, expect, it } from "@effect/vitest"; +import { Effect, FileSystem, Layer, Option, Path } from "effect"; + +import { + mockCommandPlatformApiService, + mockCommandSettings, +} from "../../tests/helpers/command-mocks.ts"; +import { mockOutput } from "../../tests/helpers/mocks.ts"; +import { ProjectRefResolver } from "../config/project-ref.service.ts"; +import type { ProjectRefSource } from "../config/project-ref.service.ts"; +import { commandTargetLayer } from "../shared/output/command-target.layer.ts"; +import { + configTargetErrorsFor, + mintConfigTargetErrors, + resolveConfigTarget, +} from "./project-target.ts"; + +const PARENT_REF = "parentparentparentpa"; +const BRANCH_REF = "branchbranchbranchbr"; +const BRANCH_UUID = "11111111-2222-4333-8444-555555555555"; + +const minted = mintConfigTargetErrors("ProjectTargetTest"); +const errors = configTargetErrorsFor({ + notLinked: minted.BranchNotLinkedError, + parentRefInvalid: minted.ParentRefInvalidError, + branchNotFound: minted.BranchNotFoundError, + branchNotReady: minted.BranchNotReadyError, +}); + +const resolverFor = (source: ProjectRefSource, matchesLinkedRef: boolean) => + Layer.succeed(ProjectRefResolver, { + resolveRef: (flagValue) => + Effect.succeed({ + ref: Option.getOrElse(flagValue, () => PARENT_REF), + source, + matchesLinkedRef, + }), + resolve: () => Effect.die("resolve is not used by resolveConfigTarget"), + resolveForLink: () => Effect.die("resolveForLink is not used by resolveConfigTarget"), + resolveOptional: () => Effect.die("resolveOptional is not used by resolveConfigTarget"), + promptProjectRef: () => Effect.die("promptProjectRef is not used by resolveConfigTarget"), + }); + +const run = ( + requested: Option.Option<string>, + options: { readonly savedRef?: string; readonly source?: ProjectRefSource } = {}, +) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const dir = yield* fs.makeTempDirectoryScoped({ prefix: "project-target-" }); + if (options.savedRef !== undefined) { + yield* fs.makeDirectory(path.join(dir, "supabase", ".temp"), { recursive: true }); + yield* fs.writeFileString( + path.join(dir, "supabase", ".temp", "project-ref"), + options.savedRef, + ); + } + const out = mockOutput(); + const api = mockCommandPlatformApiService({ + v1: { getABranchConfig: () => Effect.succeed({ ref: BRANCH_REF }) }, + }); + return yield* resolveConfigTarget(requested, errors, () => Effect.die("unexpected")).pipe( + Effect.provide( + Layer.mergeAll( + out.layer, + commandTargetLayer.pipe(Layer.provide(out.layer)), + resolverFor(options.source ?? "flag", options.savedRef === PARENT_REF), + api.layer, + mockCommandSettings({ workdir: dir, projectId: Option.none() }), + ), + ), + ); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)); + +describe("resolveConfigTarget linked target", () => { + it.effect("carries the source and linked-ref match the ref chain resolved", () => + Effect.gen(function* () { + const flag = yield* run(Option.some(PARENT_REF), { savedRef: PARENT_REF }); + expect(flag.linked).toEqual({ + kind: "linked", + ref: PARENT_REF, + source: "flag", + matchesLinkedRef: true, + }); + const file = yield* run(Option.none(), { source: "file", savedRef: PARENT_REF }); + expect(file.linked).toMatchObject({ source: "file", matchesLinkedRef: true }); + }), + ); + + it.effect("marks a branch resolved from a UUID as a flag source", () => + Effect.gen(function* () { + const target = yield* run(Option.some(BRANCH_UUID)); + expect(target).toMatchObject({ ref: BRANCH_REF, branch: BRANCH_UUID }); + expect(target.linked).toEqual({ + kind: "linked", + ref: BRANCH_REF, + source: "flag", + matchesLinkedRef: false, + }); + }), + ); + + it.effect("matches the linked ref when the branch's own ref is the saved one", () => + Effect.gen(function* () { + const target = yield* run(Option.some(BRANCH_UUID), { savedRef: BRANCH_REF }); + expect(target.linked.matchesLinkedRef).toBe(true); + }), + ); +}); diff --git a/apps/cli/src/command-internal/seed-buckets.ts b/apps/cli/src/command-internal/seed-buckets.ts index df6d8b7b6b..8e0ced02b7 100644 --- a/apps/cli/src/command-internal/seed-buckets.ts +++ b/apps/cli/src/command-internal/seed-buckets.ts @@ -32,6 +32,7 @@ import { isVectorBucketsFeatureNotEnabled, } from "../commands/seed/buckets/buckets.classify.ts"; import { SeedConfigLoadError } from "../commands/seed/buckets/buckets.errors.ts"; +import { describeConfigParseError } from "./workdir-project.ts"; import { bucketObjectKey } from "../commands/seed/buckets/buckets.upload.ts"; // Re-exported so `stack start` can report a project-context config-load failure with the @@ -189,7 +190,7 @@ export const seedBucketsRun = Effect.fnUntraced(function* < "CliConfigParseError", (cause) => new SeedConfigLoadError({ - message: `failed to parse supabase/config.toml: ${String(cause.cause)}`, + message: describeConfigParseError(workdir, cause), }), ), Effect.mapError((cause) => diff --git a/apps/cli/src/command-internal/target/resolve-target.integration.test.ts b/apps/cli/src/command-internal/target/resolve-target.integration.test.ts index b33963d93d..52a5d36d42 100644 --- a/apps/cli/src/command-internal/target/resolve-target.integration.test.ts +++ b/apps/cli/src/command-internal/target/resolve-target.integration.test.ts @@ -4,10 +4,14 @@ import { Cause, Effect, Exit, FileSystem, Layer, Option, Path, Redacted, Tracer import { configValuesLayer } from "../../../tests/helpers/config-values-layer.ts"; import { VALID_REF, mockCommandSettings } from "../../../tests/helpers/command-mocks.ts"; +import { mockOutput } from "../../../tests/helpers/mocks.ts"; import { ProjectRefNotLinkedError } from "../../config/project-ref.errors.ts"; import { ProjectRefResolver } from "../../config/project-ref.service.ts"; import type { ProjectRefSource } from "../../config/project-ref.service.ts"; +import { commandTargetLayer } from "../../shared/output/command-target.layer.ts"; +import { announceTarget } from "../../shared/output/command-target.service.ts"; import { resolveTarget } from "./resolve-target.ts"; +import { renderLocalTarget } from "./target-identity.ts"; import { TargetPolicy } from "./target-policy.ts"; import type { TargetGroup } from "./target-policy.ts"; import type { TargetSelectorInput } from "./target-selector.ts"; @@ -54,6 +58,8 @@ const run = ( readonly env?: Readonly<Record<string, string>>; readonly linked?: boolean; readonly projectId?: Option.Option<string>; + readonly savedRef?: string; + readonly cache?: { readonly ref: string; readonly name?: string }; } = {}, ) => Effect.gen(function* () { @@ -67,7 +73,20 @@ const run = ( if (options.dotenv !== undefined) { yield* fs.writeFileString(path.join(dir, "supabase", ".env"), options.dotenv); } + if (options.savedRef !== undefined || options.cache !== undefined) { + yield* fs.makeDirectory(path.join(dir, "supabase", ".temp"), { recursive: true }); + } + if (options.savedRef !== undefined) { + yield* fs.writeFileString(path.join(dir, "supabase", ".temp", "project-ref"), options.savedRef); + } + if (options.cache !== undefined) { + yield* fs.writeFileString( + path.join(dir, "supabase", ".temp", "linked-project.json"), + JSON.stringify(options.cache), + ); + } const resolver = projectRefResolver({ linked: options.linked ?? true }); + const out = mockOutput(); const spans: Array<Tracer.NativeSpan> = []; const tracer = Tracer.make({ span(spanOptions) { @@ -77,9 +96,11 @@ const run = ( }, }); const exit = yield* resolveTarget(input).pipe( + Effect.tap(() => announceTarget(renderLocalTarget("127.0.0.1", 54322))), Effect.provide( Layer.mergeAll( Layer.succeed(TargetPolicy, { group, consent: "none" }), + commandTargetLayer.pipe(Layer.provide(out.layer)), resolver.layer, mockCommandSettings({ workdir: dir, @@ -95,6 +116,7 @@ const run = ( const attributes = spans.find((span) => span.name === "Target.resolve")?.attributes; return { exit, + stderr: out.stderrText, refFlags: resolver.flags, kind: attributes?.get("target.kind"), selection: attributes?.get("target.selection"), @@ -234,6 +256,52 @@ describe("resolveTarget (selectable)", () => { }), ); + it.effect("hints at --linked on a default-local banner when the checkout is linked", () => + Effect.gen(function* () { + const result = yield* run("selectable", none, { + savedRef: FILE_REF, + cache: { ref: FILE_REF, name: "alpha" }, + }); + expect(succeeded(result.exit)).toEqual({ kind: "local" }); + expect(result.stderr).toBe( + `Target: local stack (127.0.0.1:54322) · default; pass --linked for alpha (${FILE_REF})\n`, + ); + }), + ); + + it.effect("hints with the bare ref when the cache does not name the linked project", () => + Effect.gen(function* () { + const result = yield* run("selectable", none, { savedRef: FILE_REF }); + expect(result.stderr).toBe( + `Target: local stack (127.0.0.1:54322) · default; pass --linked for ${FILE_REF}\n`, + ); + }), + ); + + it.effect("adds no hint when the checkout is not linked", () => + Effect.gen(function* () { + const result = yield* run("selectable", none, { cache: { ref: FILE_REF, name: "alpha" } }); + expect(result.stderr).toBe("Target: local stack (127.0.0.1:54322)\n"); + }), + ); + + it.effect("adds no hint when a selector chose local", () => + Effect.gen(function* () { + const result = yield* run("selectable", { ...none, local: true }, { savedRef: FILE_REF }); + expect(result.stderr).toBe("Target: local stack (127.0.0.1:54322)\n"); + }), + ); + + it.effect("adds no hint when the workspace config chose local", () => + Effect.gen(function* () { + const result = yield* run("selectable", none, { + toml: 'target = "local"\n', + savedRef: FILE_REF, + }); + expect(result.stderr).toBe("Target: local stack (127.0.0.1:54322)\n"); + }), + ); + it.effect("names the file when the workspace config cannot be parsed", () => Effect.gen(function* () { const result = yield* run("selectable", none, { toml: "[db\nport = " }); diff --git a/apps/cli/src/command-internal/target/resolve-target.ts b/apps/cli/src/command-internal/target/resolve-target.ts index 139736c45c..c5017d7bb2 100644 --- a/apps/cli/src/command-internal/target/resolve-target.ts +++ b/apps/cli/src/command-internal/target/resolve-target.ts @@ -114,6 +114,6 @@ export const resolveTarget = Effect.fn("Target.resolve")(function* (input: Targe if (workspaceTarget.value === "remote") { return yield* finish(yield* linked(Option.none()), selectedBy); } - yield* hintLinkedAlternative(); + if (selectedBy === "default") yield* hintLinkedAlternative(); return yield* finish({ kind: "local" }, selectedBy); }); diff --git a/apps/cli/src/command-internal/workdir-project.unit.test.ts b/apps/cli/src/command-internal/workdir-project.unit.test.ts index e9beef0fd0..2aa3b94b7f 100644 --- a/apps/cli/src/command-internal/workdir-project.unit.test.ts +++ b/apps/cli/src/command-internal/workdir-project.unit.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { missingProjectConfigMessage } from "./workdir-project.ts"; +import { describeConfigParseError, missingProjectConfigMessage } from "./workdir-project.ts"; describe("missingProjectConfigMessage", () => { it("points at `supabase init` for a defaulted workdir", () => { @@ -29,3 +29,20 @@ describe("missingProjectConfigMessage", () => { expect(message).not.toContain(""); }); }); + +describe("describeConfigParseError", () => { + it("names the failed file relative to the workdir", () => { + expect( + describeConfigParseError("/repo", { + path: "/repo/supabase/config.toml", + cause: new Error("unexpected end of input"), + }), + ).toBe("failed to parse supabase/config.toml: Error: unexpected end of input"); + }); + + it("keeps a path outside the workdir as given", () => { + expect( + describeConfigParseError("/repo", { path: "/elsewhere/config.json", cause: "bad json" }), + ).toBe("failed to parse /elsewhere/config.json: bad json"); + }); +}); diff --git a/apps/cli/src/commands/config/config.load.ts b/apps/cli/src/commands/config/config.load.ts index 9272f60121..31deade030 100644 --- a/apps/cli/src/commands/config/config.load.ts +++ b/apps/cli/src/commands/config/config.load.ts @@ -7,6 +7,7 @@ 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 { + describeConfigParseError, missingProjectConfigMessageEffect, relativeConfigPath, } from "../../command-internal/workdir-project.ts"; @@ -23,7 +24,7 @@ const describeLoadFailure = ( cause: { readonly message: string }, ): string => cause instanceof CliConfigParseError - ? `failed to parse ${relativeConfigPath(cliSettings.workdir, cause.path)}: ${String(cause.cause)}` + ? describeConfigParseError(cliSettings.workdir, cause) : cause.message; /** diff --git a/apps/cli/src/commands/config/diff/SIDE_EFFECTS.md b/apps/cli/src/commands/config/diff/SIDE_EFFECTS.md index 39b24cf5d7..bec9e9bb91 100644 --- a/apps/cli/src/commands/config/diff/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/config/diff/SIDE_EFFECTS.md @@ -114,7 +114,7 @@ masked/unmanaged caveats too, so echoing it never claims "in sync" on a partial response (e.g. a scoped token returning `auth: {}`) or while masked values may have drifted. The payload contains `schema_version` (integer version of THIS payload contract, currently `1`), `config_schema` (the file's -`$schema` URL), `target` (`kind: "linked"`, `project_ref`, `project_name` and `environment` (`production`, `branch` or `unverified`) when known, `parent_project_ref` for a branch, optional `branch`, and `local_scope`), +`$schema` URL), `target` (the [command target](../../../../docs/command-target.md) plus optional `branch` and `local_scope`), `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 diff --git a/apps/cli/src/commands/config/pull/SIDE_EFFECTS.md b/apps/cli/src/commands/config/pull/SIDE_EFFECTS.md index eff1f77248..8a8eda2e7f 100644 --- a/apps/cli/src/commands/config/pull/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/config/pull/SIDE_EFFECTS.md @@ -148,7 +148,7 @@ apply.`, `[remotes.<label>] would be created (dry run); no config differences to `output.success(message, payload)`. `schema_version` is this command's OWN payload version (`CONFIG_PULL_PAYLOAD_VERSION`, currently `1`) — independent of `config diff`'s. The payload -contains `config_schema`, `config_path`, `format`, `target` (`kind: "linked"`, `project_ref`, `project_name` and `environment` (`production`, `branch` or `unverified`) when known, `parent_project_ref` for a branch, optional `branch`, and +contains `config_schema`, `config_path`, `format`, `target` (the [command target](../../../../docs/command-target.md) plus optional `branch` and `local_scope` — the block the LOCAL comparison operand came from, `"base"` when pulling into a brand-new `[remotes.*]` block since there is nothing to overlay yet), `destination` (`scope`, optional `label`, `created` — WHERE this run writes, independent of `target.local_scope`), diff --git a/apps/cli/src/commands/config/push/SIDE_EFFECTS.md b/apps/cli/src/commands/config/push/SIDE_EFFECTS.md index 605e1a69a7..24c8ebf8bb 100644 --- a/apps/cli/src/commands/config/push/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/config/push/SIDE_EFFECTS.md @@ -157,12 +157,12 @@ cannot be resolved from either the read or the file — reported in their own ### `--output-format text` All diagnostics on **stderr**, no stdout. The `Target:` banner prints first -(`Target: project <name> (<ref>) · production`, `Target: project <name> (<parent ref>) · -branch (<ref>)` for a branch, or `Target: project <ref> · identity unverified` when the -live probe could not tell). When a `[remotes.<name>]` block matches the target ref, +(`Target: project <name> (<ref>) · production`, `Target: project <parent ref> · branch <name> +(<ref>)` for a branch named with `--project-ref`, or `Target: project <ref>` when nothing vouches +for the project; see [Command target](../../../../docs/command-target.md)). When a `[remotes.<name>]` block matches the target ref, `Loading config override: [remotes.<name>]` follows. No target shape is gated separately: a -branch is confirmed through the one consent question below, which names the branch and its -parent project, whether it was resolved implicitly or named with `--project-ref`. +branch is confirmed through the one consent question below, which names the target the banner +announced. Then `Comparison scope: <present> (not returned: <missing>)` — printed EVERY run, not just when a block is missing (family consistency with `config diff`/`config pull`). When a `SUPABASE_*` variable supplied any value about to be @@ -180,7 +180,7 @@ ends `(from <VAR>)` or `(from <VAR> in <file>)`. Then, per resource, one of: - the diff block: ``` -Updating <X> service with config: +Would update <X> service with: <path> [update] local: <value> remote: <value> @@ -207,7 +207,7 @@ consent question); `[content]` is a mailer template/notification body with no co own; `[group-write]` is an undeclared companion the endpoint required alongside a declared change, sent at its schema default because the read didn't report the project's current value for it. Secret values never appear in output. Every block ends -on a blank line. Experimental prints `Enabling webhooks for project: <ref>`. Every +on a blank line. Experimental prints `Would enable webhooks for project: <ref>`. Every diff is printed before anything is written, so the consent question always follows the full plan. @@ -248,9 +248,10 @@ something to write needs `--yes` or a shell `SUPABASE_YES`; without them the com (exit `1`) with this command's standard machine error envelope (`{_tag: "Error", error: {...}}` in `json` mode, a `{type: "error", ...}` NDJSON event in `stream-json` mode) and no success payload. A structured summary is emitted on stdout via `output.success(message, -data)`, and carries a `target` object (`kind: "linked"`, `project_ref`, and when known -`project_name`, `environment` — `production`, `branch` or `unverified` — and -`parent_project_ref`) next to the push summary. +data)`, and carries a [`target`](../../../../docs/command-target.md) object next to the push +summary. Its `environment` and `parent_project_ref` come from the live target probe (`production` +for a project, `branch` for a branch); when the probe could not tell, the announced identity's +`unverified` stands. `env_sourced` is additive and present only when a `SUPABASE_*` variable supplied a pushed value: `[{path: [...], origin: {source: "shell" | "project_env", env_variable, file?}}]`. diff --git a/apps/cli/src/commands/domains/SIDE_EFFECTS.md b/apps/cli/src/commands/domains/SIDE_EFFECTS.md index 426e1b02f4..4e6c793f95 100644 --- a/apps/cli/src/commands/domains/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/domains/SIDE_EFFECTS.md @@ -107,7 +107,7 @@ suppressed on stderr. `delete` ignores `-o`. - In a structured `-o` mode the human status is suppressed on stderr, keeping stdout clean. - The CNAME pre-check's "failed to locate" error text embeds a readable, 1024-byte-capped JSON dump of the DNS answers, rather than an uncapped decimal byte-value array — see `domains.cname.ts`'s comment at the failure site for detail. -- Prints `Target: <project>` once on stderr in every output format, for example `Target: project <name> (<ref>) · production`. `--output-format json|stream-json` object results and error envelopes also carry a `target` object; `--output {json,yaml,toml,env}` payloads are unchanged. +- Prints `Target: <project>` once on stderr in every output format, for example `Target: project <name> (<ref>) · production`. `--output-format json|stream-json` object results and error envelopes also carry a `target` object (see [Command target](../../../docs/command-target.md)); `--output {json,yaml,toml,env}` payloads are unchanged. - **Consent (`create`).** Asks once, after the CNAME pre-check passes and before the POST, with default Yes: `Create custom hostname <hostname>`. - **Consent (`activate`).** Asks once before the POST, with default No: `Activate the custom hostname`. The question notes that third-party auth providers stop working on the Supabase-provisioned subdomain. - **Consent (`delete`).** Asks once before the DELETE, with default No: `Delete the custom hostname config`. diff --git a/apps/cli/src/commands/encryption/SIDE_EFFECTS.md b/apps/cli/src/commands/encryption/SIDE_EFFECTS.md index 932d8e8757..2f0c76200c 100644 --- a/apps/cli/src/commands/encryption/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/encryption/SIDE_EFFECTS.md @@ -88,6 +88,6 @@ One `result` event carrying `{root_key}` (both subcommands). - The TTY prompt is a clack masked prompt labelled `Enter a new root key: ` (clack adds its own framing). Piped (non-TTY) mode does not print the prompt at all — it reads stdin directly. -- After the project ref resolves, one `Target: project <ref> · <identity>` line is printed on stderr in every output format. +- After the project ref resolves, one `Target: …` banner naming the resolved project is printed on stderr in every output format; see [Command target](../../../docs/command-target.md). - `update-root-key` asks `Replace the project's root encryption key on <target>?` (default No) before the first write. `--yes` or a shell `SUPABASE_YES` confirms without asking; a piped `y` on stdin is never read. Without `--yes`, a non-interactive, JSON, stream-json or `-o` run fails with `HostedMutationConsentRequiredError`, and declining fails with `HostedMutationDeclinedError`; neither changes anything. - The new key is read (masked TTY prompt or piped stdin) before the confirmation, which never reads stdin. 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 b2a8e8fb1f..9c614a5531 100644 --- a/apps/cli/src/commands/gen/gen.signing-keys-config.ts +++ b/apps/cli/src/commands/gen/gen.signing-keys-config.ts @@ -3,6 +3,7 @@ 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 { describeConfigParseError } from "../../command-internal/workdir-project.ts"; import { assertDecodableJwkAlgorithm } from "../../command-internal/local-jwt.ts"; import { jsonKindName } from "../../command-internal/html-safe-json.ts"; @@ -322,7 +323,7 @@ export const resolveSigningKeysConfigPaths = Effect.fnUntraced(function* <E>( Effect.mapError((cause) => onConfigParseError( cause._tag === "CliConfigParseError" - ? `failed to parse ${cause.path}: ${String(cause.cause)}` + ? describeConfigParseError(cwd, cause) : describeConfigLoadFailure(cause), ), ), diff --git a/apps/cli/src/commands/gen/types/types.handler.ts b/apps/cli/src/commands/gen/types/types.handler.ts index f1f8315c47..f5cdf0c7c4 100644 --- a/apps/cli/src/commands/gen/types/types.handler.ts +++ b/apps/cli/src/commands/gen/types/types.handler.ts @@ -35,8 +35,8 @@ import type { DbConnectError } from "../../../command-internal/db-connection.err import type { PgConnInput } from "../../../command-internal/db-connection.service.ts"; import { tempPaths } from "../../../shared/config/temp-paths.ts"; import { + describeConfigParseError, missingProjectConfigMessageEffect, - relativeConfigPath, } from "../../../command-internal/workdir-project.ts"; import { validateWorkdirIsDirectory } from "../../../command-internal/workdir-validation.ts"; import { LinkedProjectCache } from "../../../telemetry/linked-project-cache.service.ts"; @@ -253,8 +253,6 @@ export const genTypes = Effect.fn("gen.types")(function* (flags: GenTypesFlags) const lang = flags.lang; const languageOptions = languageOptionValues(flags); - const toRelativeConfigPath = (path: string) => relativeConfigPath(cliSettings.workdir, path); - // `projectRef` is passed only for the linked path, so a matching `[remotes.*]` overlay is // merged in the same load; omitted for the local and `--db-url` paths. const loadApiSchemas = (projectRef?: string) => @@ -271,7 +269,7 @@ export const genTypes = Effect.fn("gen.types")(function* (flags: GenTypesFlags) Effect.catchTags({ CliConfigParseError: (cause) => new GenTypesParseConfigError({ - message: `failed to parse ${toRelativeConfigPath(cause.path)}: ${String(cause.cause)}`, + message: describeConfigParseError(cliSettings.workdir, cause), }), DuplicateRemoteProjectIdError: (cause) => new GenTypesParseConfigError({ message: cause.message }), diff --git a/apps/cli/src/commands/link/SIDE_EFFECTS.md b/apps/cli/src/commands/link/SIDE_EFFECTS.md index 5b84e0cb9e..2bfdda8eed 100644 --- a/apps/cli/src/commands/link/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/link/SIDE_EFFECTS.md @@ -129,8 +129,9 @@ Tenant service gateway (`https://<ref>.<projectHost>`, `apikey: <service-key>` + ### `--output-format text` - stderr: the warning `link ignores --password; remove it from your scripts.` when `--password` is passed (printed before the access token is resolved); `Selected project: <ref>` (prompt path); `WARNING: Project status is <status> instead of Active Healthy. Some operations might fail.`; the dashboard unpause suggestion on a paused project. -- stderr: the target banner `Target: project <ref> · identity unverified`, printed once the ref - resolves and before the project is looked up. `link` only records a link, so it asks for no +- stderr: the target banner `Target: project <ref>` (`Target: project <name> (<ref>)` when the + picker named it), printed once the ref resolves and before the project is looked up; see + [Command target](../../../docs/command-target.md). `link` only records a link, so it asks for no confirmation and the banner stays as is once the project verifies. - stderr: `Resolved branch "<name>" of project <parentRef> to project ref <branchRef>.` — via `output.raw(..., "stderr")` (NOT `output.info`, which clack renders on stdout with `│`/`◇` diff --git a/apps/cli/src/commands/network-bans/get/SIDE_EFFECTS.md b/apps/cli/src/commands/network-bans/get/SIDE_EFFECTS.md index a3875e5e35..42f759bb95 100644 --- a/apps/cli/src/commands/network-bans/get/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/network-bans/get/SIDE_EFFECTS.md @@ -87,4 +87,4 @@ One `result` event whose `data` is the full response object. `SUPABASE_EXPERIMENTAL`), checked before the login check. A closed gate exits 1 before project-ref resolution, the API call, the `linked-project.json` write, the `telemetry.json` write, and the `cli_command_executed` event. -- After the project ref resolves, one `Target: project <ref> · <identity>` line is printed on stderr in every output format. +- After the project ref resolves, one `Target: …` banner naming the resolved project is printed on stderr in every output format; see [Command target](../../../../docs/command-target.md). diff --git a/apps/cli/src/commands/network-bans/remove/SIDE_EFFECTS.md b/apps/cli/src/commands/network-bans/remove/SIDE_EFFECTS.md index fd1d8373d2..5ddc2ad89e 100644 --- a/apps/cli/src/commands/network-bans/remove/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/network-bans/remove/SIDE_EFFECTS.md @@ -90,6 +90,6 @@ One `result` event on success when the `--output` flag is unset. `SUPABASE_EXPERIMENTAL`), checked before the login check. A closed gate exits 1 before project-ref resolution, the API call, the `linked-project.json` write, the `telemetry.json` write, and the `cli_command_executed` event. -- After the project ref resolves, one `Target: project <ref> · <identity>` line is printed on stderr in every output format. +- After the project ref resolves, one `Target: …` banner naming the resolved project is printed on stderr in every output format; see [Command target](../../../../docs/command-target.md). - Asks `Remove network bans on <target>?` (default No) before the first write, after printing the IPs to unban (or the caller's own IP when `--db-unban-ip` is omitted). `--yes` or a shell `SUPABASE_YES` confirms without asking; a piped `y` on stdin is never read. Without `--yes`, a non-interactive, JSON, stream-json or `-o` run fails with `HostedMutationConsentRequiredError`, and declining fails with `HostedMutationDeclinedError`; neither changes anything. - The `LinkedDbNetworkBannedError` raised by commands that connect to a linked database points at this command; it needs `--experimental` and the confirmation above like any other run. diff --git a/apps/cli/src/commands/network-restrictions/get/SIDE_EFFECTS.md b/apps/cli/src/commands/network-restrictions/get/SIDE_EFFECTS.md index 038451335e..9b2f63e0c8 100644 --- a/apps/cli/src/commands/network-restrictions/get/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/network-restrictions/get/SIDE_EFFECTS.md @@ -98,4 +98,4 @@ One `result` event whose `data` is the full response object. - `.temp/linked-project.json` is written after ref resolution (once the `--experimental` gate is open), even when the API call fails, but only on a cache miss with a token when `GET /v1/projects/{ref}` succeeds and `.temp/project-ref` does not name another ref. A closed gate writes nothing. - `telemetry.json` is written on every invocation past the `--experimental` gate, including failures. A closed gate writes nothing. -- After the project ref resolves, one `Target: project <ref> · <identity>` line is printed on stderr in every output format. +- After the project ref resolves, one `Target: …` banner naming the resolved project is printed on stderr in every output format; see [Command target](../../../../docs/command-target.md). diff --git a/apps/cli/src/commands/network-restrictions/update/SIDE_EFFECTS.md b/apps/cli/src/commands/network-restrictions/update/SIDE_EFFECTS.md index e5444b0501..463377b76d 100644 --- a/apps/cli/src/commands/network-restrictions/update/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/network-restrictions/update/SIDE_EFFECTS.md @@ -124,5 +124,5 @@ One `result` event whose `data` is the full response object. writes nothing. A malformed `--db-allow-cidr` CSV value (e.g. an unterminated quote) also writes nothing and fires no telemetry, even with `--experimental` set — it fails during flag parsing, before the gate and the handler. -- After the project ref resolves, one `Target: project <ref> · <identity>` line is printed on stderr in every output format. +- After the project ref resolves, one `Target: …` banner naming the resolved project is printed on stderr in every output format; see [Command target](../../../../docs/command-target.md). - Asks `Replace the network restrictions on <target>?` (`Append to the network restrictions on <target>?` with `--append`; default No) before the first write, after printing the IPv4 and IPv6 CIDRs. `--yes` or a shell `SUPABASE_YES` confirms without asking; a piped `y` on stdin is never read. Without `--yes`, a non-interactive, JSON, stream-json or `-o` run fails with `HostedMutationConsentRequiredError`, and declining fails with `HostedMutationDeclinedError`; neither changes anything. diff --git a/apps/cli/src/commands/notebooks/pull/SIDE_EFFECTS.md b/apps/cli/src/commands/notebooks/pull/SIDE_EFFECTS.md index acc90b5a6a..0f369bb1fd 100644 --- a/apps/cli/src/commands/notebooks/pull/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/notebooks/pull/SIDE_EFFECTS.md @@ -126,4 +126,4 @@ Network operations show progress in text mode; machine output remains payload-on `-o table` and `-o csv` are rejected by command instrumentation before the handler runs. `-o env` is rejected by the handler before resolving the project. -After the project ref resolves, one `Target: project <ref> · <identity>` line is printed on stderr in every output format. +After the project ref resolves, one `Target: …` banner naming the resolved project is printed on stderr in every output format; see [Command target](../../../../docs/command-target.md). diff --git a/apps/cli/src/commands/notebooks/push/SIDE_EFFECTS.md b/apps/cli/src/commands/notebooks/push/SIDE_EFFECTS.md index 94f162d66c..6bdf5583a4 100644 --- a/apps/cli/src/commands/notebooks/push/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/notebooks/push/SIDE_EFFECTS.md @@ -57,7 +57,7 @@ entry are canonicalized and refused when they resolve outside the project root. ## Confirmation -- After the project ref resolves, one `Target: project <ref> · <identity>` line is printed on stderr in every output format. +- After the project ref resolves, one `Target: …` banner naming the resolved project is printed on stderr in every output format; see [Command target](../../../../docs/command-target.md). - Asks `Push <n> notebook(s) on <target>?` (`Push <n> notebook(s) and delete <m> from the project on <target>?` for the delete answer; default No) before the first write, after printing one `Create`, `Update` or `Delete` line per notebook. `--yes` or a shell `SUPABASE_YES` confirms without asking; a piped `y` on stdin is never read. Without `--yes`, a non-interactive, JSON, stream-json or `-o` run fails with `HostedMutationConsentRequiredError`, and declining fails with `HostedMutationDeclinedError`; neither changes anything. - No confirmation is asked when nothing would be uploaded or deleted (an empty notebooks directory with the `keep` or write-locally answer). The reconciliation question above is separate: it chooses what to do with project-only notebooks, and the confirmation covers the resulting uploads and deletes together. diff --git a/apps/cli/src/commands/postgres-config/delete/SIDE_EFFECTS.md b/apps/cli/src/commands/postgres-config/delete/SIDE_EFFECTS.md index e11c5c5f87..57d5d248b2 100644 --- a/apps/cli/src/commands/postgres-config/delete/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/postgres-config/delete/SIDE_EFFECTS.md @@ -113,6 +113,6 @@ One `result` event on success. (or `SUPABASE_EXPERIMENTAL`), checked before the login check. A closed gate exits 1 before project-ref resolution, the API calls, the `linked-project.json` write, the `telemetry.json` write, and the `cli_command_executed` event. -- After the project ref resolves, one `Target: project <ref> · <identity>` line is printed on stderr in every output format. +- After the project ref resolves, one `Target: …` banner naming the resolved project is printed on stderr in every output format; see [Command target](../../../../docs/command-target.md). - Asks `Delete Postgres config overrides on <target>?` (default No) before the first write, after printing the keys to delete and whether the restart is skipped. `--yes` or a shell `SUPABASE_YES` confirms without asking; a piped `y` on stdin is never read. Without `--yes`, a non-interactive, JSON, stream-json or `-o` run fails with `HostedMutationConsentRequiredError`, and declining fails with `HostedMutationDeclinedError`; neither changes anything. - The current overrides are fetched (a read) before the confirmation so the plan is known; the `PUT` happens only after it. diff --git a/apps/cli/src/commands/postgres-config/get/SIDE_EFFECTS.md b/apps/cli/src/commands/postgres-config/get/SIDE_EFFECTS.md index 932857d2a7..170bc0e3cb 100644 --- a/apps/cli/src/commands/postgres-config/get/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/postgres-config/get/SIDE_EFFECTS.md @@ -99,4 +99,4 @@ One `result` event on success. (or `SUPABASE_EXPERIMENTAL`), checked before the login check. A closed gate exits 1 before project-ref resolution, the API call, the `linked-project.json` write, the `telemetry.json` write, and the `cli_command_executed` event. -- After the project ref resolves, one `Target: project <ref> · <identity>` line is printed on stderr in every output format. +- After the project ref resolves, one `Target: …` banner naming the resolved project is printed on stderr in every output format; see [Command target](../../../../docs/command-target.md). diff --git a/apps/cli/src/commands/postgres-config/update/SIDE_EFFECTS.md b/apps/cli/src/commands/postgres-config/update/SIDE_EFFECTS.md index 54bbf5a3c3..cd991a69e7 100644 --- a/apps/cli/src/commands/postgres-config/update/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/postgres-config/update/SIDE_EFFECTS.md @@ -116,6 +116,6 @@ One `result` event on success. (or `SUPABASE_EXPERIMENTAL`), checked before the login check. A closed gate exits 1 before project-ref resolution, the API calls, the `linked-project.json` write, the `telemetry.json` write, and the `cli_command_executed` event. -- After the project ref resolves, one `Target: project <ref> · <identity>` line is printed on stderr in every output format. +- After the project ref resolves, one `Target: …` banner naming the resolved project is printed on stderr in every output format; see [Command target](../../../../docs/command-target.md). - Asks `Update the Postgres config overrides on <target>?` (default No) before the first write, after printing the overrides to set, whether all existing ones are replaced, and whether the restart is skipped. `--yes` or a shell `SUPABASE_YES` confirms without asking; a piped `y` on stdin is never read. Without `--yes`, a non-interactive, JSON, stream-json or `-o` run fails with `HostedMutationConsentRequiredError`, and declining fails with `HostedMutationDeclinedError`; neither changes anything. - The current overrides are fetched (a read) before the confirmation so the plan is known; the `PUT` happens only after it. diff --git a/apps/cli/src/commands/pull/SIDE_EFFECTS.md b/apps/cli/src/commands/pull/SIDE_EFFECTS.md index 7669da6882..ab8b45fb1d 100644 --- a/apps/cli/src/commands/pull/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/pull/SIDE_EFFECTS.md @@ -19,7 +19,8 @@ it actually pulls a migration-mode schema change, and that step requires Docker initial-pull `pg_dump` container). See "Consent", "Database writes" and "Docker" below. The target is always a linked project or branch, announced once as `Target: …` on stderr (the -banner comes from the target resolution, not from the sub-steps). +banner comes from the target resolution, not from the sub-steps); see +[Command target](../../../docs/command-target.md). ## Files Read @@ -238,10 +239,7 @@ Shape (`pull.format.ts`): { "schema_version": 1, "target": { - "kind": "linked", - "project_ref": "...", - "project_name": "...", // from the linked-project cache, when it names this project - "environment": "production", // or "branch" (with "parent_project_ref") or "unverified" + // the command target (docs/command-target.md), plus: "branch": "...", // only when --project-ref named a branch }, "dry_run": false, diff --git a/apps/cli/src/commands/secrets/list/SIDE_EFFECTS.md b/apps/cli/src/commands/secrets/list/SIDE_EFFECTS.md index b950faae69..84585607f1 100644 --- a/apps/cli/src/commands/secrets/list/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/secrets/list/SIDE_EFFECTS.md @@ -78,7 +78,7 @@ Fails immediately with `SecretsEnvNotSupportedError("--output env flag is not su ### `--output-format json` -Single JSON object emitted via `Output.success` with `{secrets: [...]}` as the `data` field, plus a `target` object (`kind: "linked"`, `project_ref`, and when known `project_name`, `environment`, `parent_project_ref`). +Single JSON object emitted via `Output.success` with `{secrets: [...]}` as the `data` field, plus a [`target`](../../../../docs/command-target.md) object. ### `--output-format stream-json` diff --git a/apps/cli/src/commands/secrets/set/SIDE_EFFECTS.md b/apps/cli/src/commands/secrets/set/SIDE_EFFECTS.md index a10bce631c..b05f062fcd 100644 --- a/apps/cli/src/commands/secrets/set/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/secrets/set/SIDE_EFFECTS.md @@ -76,7 +76,7 @@ The `--output {json,yaml,toml,env}` flags all collapse to the same text-mode `Fi ### `--output-format json` -Single JSON object emitted via `Output.success` with `{project_ref, count}` as the `data` field, plus a `target` object (`kind: "linked"`, `project_ref`, and when known `project_name`, `environment`, `parent_project_ref`). Needs `--yes` or a shell `SUPABASE_YES`, because machine output never prompts. +Single JSON object emitted via `Output.success` with `{project_ref, count}` as the `data` field, plus a [`target`](../../../../docs/command-target.md) object. Needs `--yes` or a shell `SUPABASE_YES`, because machine output never prompts. ### `--output-format stream-json` diff --git a/apps/cli/src/commands/secrets/unset/SIDE_EFFECTS.md b/apps/cli/src/commands/secrets/unset/SIDE_EFFECTS.md index 1e50073d80..b89e63ab6a 100644 --- a/apps/cli/src/commands/secrets/unset/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/secrets/unset/SIDE_EFFECTS.md @@ -85,7 +85,7 @@ Single JSON object emitted via `Output.success` with `{project_ref, count, names With `--dry-run`, the message is `Dry run: no secrets were unset.` and `data` is `{project_ref, count, names, dry_run: true}`. -Every `data` object also carries a `target` object (`kind: "linked"`, `project_ref`, and when known `project_name`, `environment`, `parent_project_ref`). A real unset needs `--yes` or a shell `SUPABASE_YES`, because machine output never prompts. +Every `data` object also carries a [`target`](../../../../docs/command-target.md) object. A real unset needs `--yes` or a shell `SUPABASE_YES`, because machine output never prompts. When `--all` resolves to no secrets, the message is `You have not set any function secrets, nothing to do.` and `data` is `{project_ref, count: 0, names: [], dry_run}`. diff --git a/apps/cli/src/commands/snippets/download/SIDE_EFFECTS.md b/apps/cli/src/commands/snippets/download/SIDE_EFFECTS.md index 2a9b6753dd..37a1ab833e 100644 --- a/apps/cli/src/commands/snippets/download/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/snippets/download/SIDE_EFFECTS.md @@ -86,4 +86,4 @@ NDJSON `success` event with the same full payload as `--output-format json`. - The `--output` flag has **no effect** on this command — `--output` values (`pretty|json|yaml|toml|env`) do not change text-mode rendering. Only the TS-extension `--output-format json|stream-json` produces a structured payload. - UUID validation runs **after** project-ref resolution but **before** the API call. Error messages: `invalid snippet ID: invalid UUID length: N` for malformed lengths, `invalid snippet ID: invalid UUID format` for length-36 inputs with wrong dash positions or hex chars. - The linked-project cache fires after project-ref resolves; the telemetry state always flushes. Both run on success and on every error path — including invalid-UUID early-exit — via the two `Effect.ensuring` blocks in the handler. -- After the project ref resolves, one `Target: project <ref> · <identity>` line is printed on stderr in every output format. +- After the project ref resolves, one `Target: …` banner naming the resolved project is printed on stderr in every output format; see [Command target](../../../../docs/command-target.md). diff --git a/apps/cli/src/commands/snippets/list/SIDE_EFFECTS.md b/apps/cli/src/commands/snippets/list/SIDE_EFFECTS.md index 1557a84b4d..cdf10198af 100644 --- a/apps/cli/src/commands/snippets/list/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/snippets/list/SIDE_EFFECTS.md @@ -98,4 +98,4 @@ NDJSON `success` event with the full response as `data`. - When both `--output` and `--output-format` are set, `--output` wins (matches the precedence used elsewhere in ports). - `--output env` is rejected **after** project-ref resolution but **before** the API call. - The linked-project cache fires after project-ref resolves; the telemetry state always flushes. Both run on success and on every error path via the two `Effect.ensuring` blocks in the handler. -- After the project ref resolves, one `Target: project <ref> · <identity>` line is printed on stderr in every output format. +- After the project ref resolves, one `Target: …` banner naming the resolved project is printed on stderr in every output format; see [Command target](../../../../docs/command-target.md). diff --git a/apps/cli/src/commands/ssl-enforcement/get/SIDE_EFFECTS.md b/apps/cli/src/commands/ssl-enforcement/get/SIDE_EFFECTS.md index e9efb20aac..3468f334f7 100644 --- a/apps/cli/src/commands/ssl-enforcement/get/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/ssl-enforcement/get/SIDE_EFFECTS.md @@ -105,4 +105,4 @@ One `result` event: (or `SUPABASE_EXPERIMENTAL`), checked before the login check. A closed gate exits 1 before project-ref resolution, the API call, the `linked-project.json` write, the `telemetry.json` write, and the `cli_command_executed` event. -- After the project ref resolves, one `Target: project <ref> · <identity>` line is printed on stderr in every output format. +- After the project ref resolves, one `Target: …` banner naming the resolved project is printed on stderr in every output format; see [Command target](../../../../docs/command-target.md). diff --git a/apps/cli/src/commands/ssl-enforcement/update/SIDE_EFFECTS.md b/apps/cli/src/commands/ssl-enforcement/update/SIDE_EFFECTS.md index 021742a51e..e111a5b8e9 100644 --- a/apps/cli/src/commands/ssl-enforcement/update/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/ssl-enforcement/update/SIDE_EFFECTS.md @@ -107,5 +107,5 @@ One `result` event: A closed gate exits 1 before the enable/disable mutex check, project-ref resolution, the API call, the `linked-project.json` write, the `telemetry.json` write, and the `cli_command_executed` event. -- After the project ref resolves, one `Target: project <ref> · <identity>` line is printed on stderr in every output format. +- After the project ref resolves, one `Target: …` banner naming the resolved project is printed on stderr in every output format; see [Command target](../../../../docs/command-target.md). - Asks `Enable SSL enforcement for database connections on <target>?` (`Disable SSL enforcement for database connections on <target>?` with `--disable-db-ssl-enforcement`; default No) before the first write. `--yes` or a shell `SUPABASE_YES` confirms without asking; a piped `y` on stdin is never read. Without `--yes`, a non-interactive, JSON, stream-json or `-o` run fails with `HostedMutationConsentRequiredError`, and declining fails with `HostedMutationDeclinedError`; neither changes anything. diff --git a/apps/cli/src/commands/sso/add/SIDE_EFFECTS.md b/apps/cli/src/commands/sso/add/SIDE_EFFECTS.md index 136db81bba..535cdfc919 100644 --- a/apps/cli/src/commands/sso/add/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/sso/add/SIDE_EFFECTS.md @@ -103,5 +103,5 @@ Single `success` event with the parsed response as data. - Metadata URL validation error message: `only HTTPS Metadata URLs are supported Use --skip-url-validation to suppress this error` (no trailing period; differs from `sso update`'s variant). - The `## Attribute Mapping` / `## SAML 2.0 Metadata XML` sections are emitted as plain markdown (heading + fence). The headings carry no extra styling; the XML body inside the fence is formatted by `formatSsoMetadataXml`. - **Missing `--type` parser error**: the error message itself is `Error: required flag(s) "type" not set` verbatim (mapped in `shared/output/normalize-error.ts`). Effect CLI's parser however dumps the full help block to stdout _before_ the error. The error string is unchanged; the surrounding help dump is an Effect CLI behavior that would require forking the CLI parser to suppress. -- After the project ref resolves, one `Target: project <ref> · <identity>` line is printed on stderr in every output format. +- After the project ref resolves, one `Target: …` banner naming the resolved project is printed on stderr in every output format; see [Command target](../../../../docs/command-target.md). - Asks `Add an SSO provider on <target>?` (default No) before the first write, after printing the type, domains and metadata source (file or URL; the URL itself is not printed). `--yes` or a shell `SUPABASE_YES` confirms without asking; a piped `y` on stdin is never read. Without `--yes`, a non-interactive, JSON, stream-json or `-o` run fails with `HostedMutationConsentRequiredError`, and declining fails with `HostedMutationDeclinedError`; neither changes anything. diff --git a/apps/cli/src/commands/sso/info/SIDE_EFFECTS.md b/apps/cli/src/commands/sso/info/SIDE_EFFECTS.md index 9e080efced..814a1b37fe 100644 --- a/apps/cli/src/commands/sso/info/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/sso/info/SIDE_EFFECTS.md @@ -77,4 +77,4 @@ Single `success` event with the payload as data. - All three URLs are deterministic functions of the project ref. - The `Single sign-on URL (ACS URL)` label is emitted without a trailing space, so the flat ASCII renderer does not double it up against the cell padding. -- After the project ref resolves, one `Target: project <ref> · <identity>` line is printed on stderr in every output format. +- After the project ref resolves, one `Target: …` banner naming the resolved project is printed on stderr in every output format; see [Command target](../../../../docs/command-target.md). diff --git a/apps/cli/src/commands/sso/list/SIDE_EFFECTS.md b/apps/cli/src/commands/sso/list/SIDE_EFFECTS.md index fedaf77867..248c308c56 100644 --- a/apps/cli/src/commands/sso/list/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/sso/list/SIDE_EFFECTS.md @@ -72,4 +72,4 @@ Single `success` event with `{providers: items}` as data. - The `SAML 2.0 EntityID` header label is rendered as plain text, without inline-code backticks. - Upgrade-gate side calls only fire on 4xx. -- After the project ref resolves, one `Target: project <ref> · <identity>` line is printed on stderr in every output format. +- After the project ref resolves, one `Target: …` banner naming the resolved project is printed on stderr in every output format; see [Command target](../../../../docs/command-target.md). diff --git a/apps/cli/src/commands/sso/remove/SIDE_EFFECTS.md b/apps/cli/src/commands/sso/remove/SIDE_EFFECTS.md index 8bb5efab66..3fc2638925 100644 --- a/apps/cli/src/commands/sso/remove/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/sso/remove/SIDE_EFFECTS.md @@ -76,5 +76,5 @@ Single `success` event with the removed provider's details as data. - Removing a provider will prevent existing SSO users from logging in. - The `<provider-id>` argument must be a valid UUID; UUIDs are accepted case-insensitively. -- After the project ref resolves, one `Target: project <ref> · <identity>` line is printed on stderr in every output format. +- After the project ref resolves, one `Target: …` banner naming the resolved project is printed on stderr in every output format; see [Command target](../../../../docs/command-target.md). - Asks `Remove SSO provider <id> on <target>?` (default No) before the first write. `--yes` or a shell `SUPABASE_YES` confirms without asking; a piped `y` on stdin is never read. Without `--yes`, a non-interactive, JSON, stream-json or `-o` run fails with `HostedMutationConsentRequiredError`, and declining fails with `HostedMutationDeclinedError`; neither changes anything. diff --git a/apps/cli/src/commands/sso/show/SIDE_EFFECTS.md b/apps/cli/src/commands/sso/show/SIDE_EFFECTS.md index 605c725006..586a4e1146 100644 --- a/apps/cli/src/commands/sso/show/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/sso/show/SIDE_EFFECTS.md @@ -79,4 +79,4 @@ Single `success` event with the response as data. - The `<provider-id>` argument must be a valid UUID; invalid input produces `identity provider ID "<input>" is not a UUID`. UUIDs are accepted case-insensitively. - `--metadata` short-circuits any `--output` selection. - The `## SAML 2.0 Metadata XML` fenced block is pretty-printed via `formatSsoMetadataXml` (`sso.format.ts`), matching a 2-space-indented XML pretty-printer's output byte-for-byte. -- After the project ref resolves, one `Target: project <ref> · <identity>` line is printed on stderr in every output format. +- After the project ref resolves, one `Target: …` banner naming the resolved project is printed on stderr in every output format; see [Command target](../../../../docs/command-target.md). diff --git a/apps/cli/src/commands/sso/update/SIDE_EFFECTS.md b/apps/cli/src/commands/sso/update/SIDE_EFFECTS.md index 008b8b2388..31ceef6559 100644 --- a/apps/cli/src/commands/sso/update/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/sso/update/SIDE_EFFECTS.md @@ -109,6 +109,6 @@ Single `success` event with the parsed response as data. - Metadata URL validation error message: `only HTTPS Metadata URLs are supported Use --skip-url-validation to suppress this error.` (single trailing period; differs from `sso add`'s variant which omits the trailing period). - The `## Attribute Mapping` / `## SAML 2.0 Metadata XML` sections are emitted as plain markdown (heading + fence). The headings carry no extra styling; the XML body inside the fence is formatted by `formatSsoMetadataXml`. - **PUT failure message reuses the GET error string**: a non-2xx PUT response produces `unexpected error fetching identity provider: <body>` — note "fetching" not "updating". -- After the project ref resolves, one `Target: project <ref> · <identity>` line is printed on stderr in every output format. +- After the project ref resolves, one `Target: …` banner naming the resolved project is printed on stderr in every output format; see [Command target](../../../../docs/command-target.md). - Asks `Update SSO provider <id> on <target>?` (default No) before the first write, after printing the changed metadata, attribute mapping, name ID format and the resulting domain set. `--yes` or a shell `SUPABASE_YES` confirms without asking; a piped `y` on stdin is never read. Without `--yes`, a non-interactive, JSON, stream-json or `-o` run fails with `HostedMutationConsentRequiredError`, and declining fails with `HostedMutationDeclinedError`; neither changes anything. - The provider is fetched (a read) before the confirmation so the resulting domain set is known; the `PUT` happens only after it. diff --git a/apps/cli/src/commands/storage/storage.frame.ts b/apps/cli/src/commands/storage/storage.frame.ts index 4c442fa99c..45057d7ece 100644 --- a/apps/cli/src/commands/storage/storage.frame.ts +++ b/apps/cli/src/commands/storage/storage.frame.ts @@ -22,7 +22,10 @@ import { parseStorageUrl, } from "../../command-internal/storage-url.ts"; import { StorageConfigError } from "../../command-internal/storage-credentials.errors.ts"; -import { missingProjectConfigMessageEffect } from "../../command-internal/workdir-project.ts"; +import { + describeConfigParseError, + missingProjectConfigMessageEffect, +} from "../../command-internal/workdir-project.ts"; import { validateWorkdirIsDirectory } from "../../command-internal/workdir-validation.ts"; import { StorageInvalidUrlError, @@ -82,7 +85,7 @@ export const loadStorageConfig = Effect.fn("Storage.loadConfig")(function* ( "CliConfigParseError", (cause) => new StorageConfigError({ - message: `failed to parse supabase/config.toml: ${String(cause.cause)}`, + message: describeConfigParseError(cliSettings.workdir, cause), }), ), Effect.mapError((cause) => diff --git a/apps/cli/src/shared/output/command-target.service.unit.test.ts b/apps/cli/src/shared/output/command-target.service.unit.test.ts new file mode 100644 index 0000000000..bfe9c39c66 --- /dev/null +++ b/apps/cli/src/shared/output/command-target.service.unit.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from "@effect/vitest"; +import { Schema } from "effect"; + +import { CommandTargetJson } from "./command-target.service.ts"; + +const encode = Schema.encodeUnknownSync(CommandTargetJson); + +describe("CommandTargetJson", () => { + it("encodes each target kind", () => { + const local = { kind: "local", host: "127.0.0.1", port: 54322 }; + const dbUrl = { kind: "db-url", host: "db.example.com", port: 5432 }; + const linked = { + kind: "linked", + project_ref: "abcdefghijklmnopqrst", + project_name: "alpha", + environment: "branch", + parent_project_ref: "qrstuvwxyzabcdefghij", + }; + expect([encode(local), encode(dbUrl), encode(linked)]).toEqual([local, dbUrl, linked]); + }); + + it("leaves the optional linked fields out when absent", () => { + const linked = { kind: "linked", project_ref: "abcdefghijklmnopqrst", environment: "unverified" }; + expect(encode(linked)).toEqual(linked); + }); + + it("drops fields the schema does not declare", () => { + expect( + encode({ kind: "db-url", host: "db.example.com", port: 5432, password: "secret" }), + ).toEqual({ kind: "db-url", host: "db.example.com", port: 5432 }); + }); + + it("rejects an unknown kind or environment", () => { + expect(() => encode({ kind: "remote", host: "h", port: 1 })).toThrow(); + expect(() => + encode({ kind: "linked", project_ref: "abcdefghijklmnopqrst", environment: "staging" }), + ).toThrow(); + }); +}); From ed4b40e9320c02bdb79489ed831874060fad7fba Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Thu, 15 Oct 2026 17:13:50 +0100 Subject: [PATCH 90/92] chore(cli): format target changes and satisfy knip and effect lint --- .../hosted-consent.unit.test.ts | 5 ++- .../src/command-internal/project-target.ts | 2 +- .../target/resolve-target.integration.test.ts | 5 ++- .../target/target-identity.ts | 5 ++- .../src/commands/config/diff/diff.handler.ts | 4 +-- .../src/commands/config/push/push.handler.ts | 5 ++- .../config/push/push.integration.test.ts | 31 ++++++++++--------- .../update-root-key.integration.test.ts | 3 +- .../postgres-config.integration.test.ts | 3 +- .../secrets/set/set.integration.test.ts | 4 +-- apps/cli/src/config/project-ref.layer.ts | 5 ++- .../src/config/project-ref.layer.unit.test.ts | 23 ++++++++------ .../src/shared/output/command-target.layer.ts | 6 +--- .../shared/output/command-target.service.ts | 4 +-- .../command-target.service.unit.test.ts | 6 +++- 15 files changed, 62 insertions(+), 49 deletions(-) diff --git a/apps/cli/src/command-internal/hosted-consent/hosted-consent.unit.test.ts b/apps/cli/src/command-internal/hosted-consent/hosted-consent.unit.test.ts index 0d8ac29695..d6fa94ffff 100644 --- a/apps/cli/src/command-internal/hosted-consent/hosted-consent.unit.test.ts +++ b/apps/cli/src/command-internal/hosted-consent/hosted-consent.unit.test.ts @@ -90,7 +90,10 @@ const harness = (scenario: Scenario) => { withShellEnv( (scenario.announced === undefined ? body - : Effect.flatMap(announceTarget({ text: scenario.announced, json: UNVERIFIED_JSON }), () => body) + : Effect.flatMap( + announceTarget({ text: scenario.announced, json: UNVERIFIED_JSON }), + () => body, + ) ).pipe(Effect.provide(layer)), ); return { out, prepare }; diff --git a/apps/cli/src/command-internal/project-target.ts b/apps/cli/src/command-internal/project-target.ts index ee22f5da37..2152727bb9 100644 --- a/apps/cli/src/command-internal/project-target.ts +++ b/apps/cli/src/command-internal/project-target.ts @@ -37,7 +37,7 @@ export interface ConfigTarget { } /** A {@link ConfigTarget} with the same ref as the linked target connection resolution takes. */ -export interface ResolvedConfigTarget extends ConfigTarget { +interface ResolvedConfigTarget extends ConfigTarget { readonly linked: LinkedTarget; } diff --git a/apps/cli/src/command-internal/target/resolve-target.integration.test.ts b/apps/cli/src/command-internal/target/resolve-target.integration.test.ts index 52a5d36d42..1b7c0889b9 100644 --- a/apps/cli/src/command-internal/target/resolve-target.integration.test.ts +++ b/apps/cli/src/command-internal/target/resolve-target.integration.test.ts @@ -77,7 +77,10 @@ const run = ( yield* fs.makeDirectory(path.join(dir, "supabase", ".temp"), { recursive: true }); } if (options.savedRef !== undefined) { - yield* fs.writeFileString(path.join(dir, "supabase", ".temp", "project-ref"), options.savedRef); + yield* fs.writeFileString( + path.join(dir, "supabase", ".temp", "project-ref"), + options.savedRef, + ); } if (options.cache !== undefined) { yield* fs.writeFileString( diff --git a/apps/cli/src/command-internal/target/target-identity.ts b/apps/cli/src/command-internal/target/target-identity.ts index 71527493a5..7767c2d8db 100644 --- a/apps/cli/src/command-internal/target/target-identity.ts +++ b/apps/cli/src/command-internal/target/target-identity.ts @@ -51,7 +51,10 @@ export const describeLinkedTarget = (input: { }, }; } - const differsFromLinked = Option.exists(input.linkedRef ?? Option.none(), (linked) => linked !== ref); + const differsFromLinked = Option.exists( + input.linkedRef ?? Option.none(), + (linked) => linked !== ref, + ); return { text: `project ${named(input.name, ref)}${differsFromLinked ? " (not the linked project)" : ""}`, json: { diff --git a/apps/cli/src/commands/config/diff/diff.handler.ts b/apps/cli/src/commands/config/diff/diff.handler.ts index a28131b9ff..f649b2837f 100644 --- a/apps/cli/src/commands/config/diff/diff.handler.ts +++ b/apps/cli/src/commands/config/diff/diff.handler.ts @@ -203,9 +203,7 @@ export const configDiff = Effect.fn("config.diff")(function* (flags: ConfigDiffF if (output.format !== "text") { yield* output.success( configDiffSummaryMessage(changeSet, scope), - yield* withAnnouncedTarget( - configDiffPayload(changeSet, scope, context, originFor), - ), + yield* withAnnouncedTarget(configDiffPayload(changeSet, scope, context, originFor)), ); } else { yield* output.raw(renderConfigDiffText(changeSet, scope, originFor)); diff --git a/apps/cli/src/commands/config/push/push.handler.ts b/apps/cli/src/commands/config/push/push.handler.ts index 030f041775..c170347c84 100644 --- a/apps/cli/src/commands/config/push/push.handler.ts +++ b/apps/cli/src/commands/config/push/push.handler.ts @@ -587,7 +587,10 @@ export const configPush = Effect.fn("config.push")(function* (flags: ConfigPushF } } if (webhooksEnabled) { - yield* output.raw(`Would enable webhooks for project: ${sanitizeInlineName(ref)}\n`, "stderr"); + yield* output.raw( + `Would enable webhooks for project: ${sanitizeInlineName(ref)}\n`, + "stderr", + ); } // 11. One consent covers every write; nothing is sent before it. 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 2aa6c9222e..b5af5c02af 100644 --- a/apps/cli/src/commands/config/push/push.integration.test.ts +++ b/apps/cli/src/commands/config/push/push.integration.test.ts @@ -3129,21 +3129,22 @@ describe("config push branch/project target detection (CLI-2168)", () => { }).pipe(Effect.provide(layer)); }); - it.effect("declining a branch-linked push fails before any write and still flushes telemetry", () => { - const { layer, out, api, telemetry, linkedProjectCache } = setupLinkedBranchPush({ - confirm: [false], - }); - return Effect.gen(function* () { - const error = yield* configPush({ projectRef: Option.none() }).pipe(Effect.flip); - expect(error).toBeInstanceOf(HostedMutationDeclinedError); - expect(out.promptConfirmCalls[0]?.message).toContain( - `on project ${BRANCH_REF}?`, - ); - expect(api.requests.some((r) => ["PATCH", "PUT", "POST"].includes(r.method))).toBe(false); - expect(telemetry.flushed).toBe(true); - expect(linkedProjectCache.cached).toBe(true); - }).pipe(Effect.provide(layer)); - }); + it.effect( + "declining a branch-linked push fails before any write and still flushes telemetry", + () => { + const { layer, out, api, telemetry, linkedProjectCache } = setupLinkedBranchPush({ + confirm: [false], + }); + return Effect.gen(function* () { + const error = yield* configPush({ projectRef: Option.none() }).pipe(Effect.flip); + expect(error).toBeInstanceOf(HostedMutationDeclinedError); + expect(out.promptConfirmCalls[0]?.message).toContain(`on project ${BRANCH_REF}?`); + expect(api.requests.some((r) => ["PATCH", "PUT", "POST"].includes(r.method))).toBe(false); + expect(telemetry.flushed).toBe(true); + expect(linkedProjectCache.cached).toBe(true); + }).pipe(Effect.provide(layer)); + }, + ); it.effect( "--yes confirms a branch-linked push and echoes the confirmation with the project ref", diff --git a/apps/cli/src/commands/encryption/update-root-key/update-root-key.integration.test.ts b/apps/cli/src/commands/encryption/update-root-key/update-root-key.integration.test.ts index 8cc5296e9c..14b056ced6 100644 --- a/apps/cli/src/commands/encryption/update-root-key/update-root-key.integration.test.ts +++ b/apps/cli/src/commands/encryption/update-root-key/update-root-key.integration.test.ts @@ -182,8 +182,7 @@ describe("encryption update-root-key integration", () => { expect(success?.data).toEqual({ root_key: "new-key" }); expect(out.stdoutText).toBe(""); expect(out.stderrText).toBe( - unverifiedTargetBanner() + - `Confirmed by --yes: ${ACTION} on project ${VALID_REF}\n`, + unverifiedTargetBanner() + `Confirmed by --yes: ${ACTION} on project ${VALID_REF}\n`, ); }).pipe(Effect.provide(layer)); }); diff --git a/apps/cli/src/commands/postgres-config/postgres-config.integration.test.ts b/apps/cli/src/commands/postgres-config/postgres-config.integration.test.ts index e088fcf9c9..ac5526f6a7 100644 --- a/apps/cli/src/commands/postgres-config/postgres-config.integration.test.ts +++ b/apps/cli/src/commands/postgres-config/postgres-config.integration.test.ts @@ -50,8 +50,7 @@ const decodeJson = Schema.decodeEffect(Schema.fromJsonString(Schema.Unknown)); const tempRoot = useTempWorkdir("supabase-postgres-config-int-"); -const confirmedBy = (action: string) => - `Confirmed by --yes: ${action} on project ${VALID_REF}\n`; +const confirmedBy = (action: string) => `Confirmed by --yes: ${action} on project ${VALID_REF}\n`; const UPDATE_CONFIRMED = confirmedBy("Update the Postgres config overrides"); const DELETE_CONFIRMED = confirmedBy("Delete Postgres config overrides"); 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 b93248f600..51d3c5ffbb 100644 --- a/apps/cli/src/commands/secrets/set/set.integration.test.ts +++ b/apps/cli/src/commands/secrets/set/set.integration.test.ts @@ -153,9 +153,7 @@ describe("secrets set integration", () => { envFile: Option.none(), secrets: ["FOO=hunter2", "BAR=swordfish"], }); - expect(out.promptConfirmCalls[0]?.message).toBe( - `set 2 secrets on project ${VALID_REF}?`, - ); + expect(out.promptConfirmCalls[0]?.message).toBe(`set 2 secrets on project ${VALID_REF}?`); expect(out.promptConfirmCalls[0]?.opts?.defaultValue).toBe(false); expect(out.stderrText).toContain("Secrets to set:\n • FOO\n • BAR\n"); expect(out.stderrText).not.toMatch(/hunter2|swordfish/u); diff --git a/apps/cli/src/config/project-ref.layer.ts b/apps/cli/src/config/project-ref.layer.ts index f397f6869e..aafe591c83 100644 --- a/apps/cli/src/config/project-ref.layer.ts +++ b/apps/cli/src/config/project-ref.layer.ts @@ -169,7 +169,10 @@ export const projectRefLayer = Layer.effect( }), ); } - yield* announceLinked(resolved.ref, resolved.name === undefined ? {} : { name: resolved.name }); + yield* announceLinked( + resolved.ref, + resolved.name === undefined ? {} : { name: resolved.name }, + ); return resolved.ref; }), resolveOptional: (flagValue) => 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 bfe9ecb529..76a59e3895 100644 --- a/apps/cli/src/config/project-ref.layer.unit.test.ts +++ b/apps/cli/src/config/project-ref.layer.unit.test.ts @@ -380,16 +380,19 @@ describe("projectRefLayer", () => { }).pipe(Effect.provide(layer)); }); - it.effect("never calls a saved ref a branch just because the cache names another project", () => { - writeRefFile(tempRoot, ANOTHER_REF); - writeLinkedCache(tempRoot, { ref: VALID_REF, name: "alpha" }); - const { layer, out } = makeLayer({ workdir: tempRoot }); - return Effect.gen(function* () { - const { resolveRef } = yield* ProjectRefResolver; - yield* resolveRef(Option.none()); - expect(out.stderrText).toBe(banner(`project ${ANOTHER_REF}`)); - }).pipe(Effect.provide(layer)); - }); + it.effect( + "never calls a saved ref a branch just because the cache names another project", + () => { + writeRefFile(tempRoot, ANOTHER_REF); + writeLinkedCache(tempRoot, { ref: VALID_REF, name: "alpha" }); + const { layer, out } = makeLayer({ workdir: tempRoot }); + return Effect.gen(function* () { + const { resolveRef } = yield* ProjectRefResolver; + yield* resolveRef(Option.none()); + expect(out.stderrText).toBe(banner(`project ${ANOTHER_REF}`)); + }).pipe(Effect.provide(layer)); + }, + ); it.effect("says a flag ref outside the linked project is not the linked project", () => { writeRefFile(tempRoot, VALID_REF); diff --git a/apps/cli/src/shared/output/command-target.layer.ts b/apps/cli/src/shared/output/command-target.layer.ts index 8cc2d9a155..3189e62374 100644 --- a/apps/cli/src/shared/output/command-target.layer.ts +++ b/apps/cli/src/shared/output/command-target.layer.ts @@ -1,10 +1,6 @@ import { Effect, Layer, Option, Ref, Schema } from "effect"; -import { - CommandTarget, - CommandTargetJson, - type RenderedTarget, -} from "./command-target.service.ts"; +import { CommandTarget, CommandTargetJson, type RenderedTarget } from "./command-target.service.ts"; import { MachineErrorContext } from "./machine-error-context.service.ts"; import { Output } from "./output.service.ts"; diff --git a/apps/cli/src/shared/output/command-target.service.ts b/apps/cli/src/shared/output/command-target.service.ts index 66a5a80ab5..b479cc7f29 100644 --- a/apps/cli/src/shared/output/command-target.service.ts +++ b/apps/cli/src/shared/output/command-target.service.ts @@ -3,13 +3,13 @@ import { Context, Effect, Option, Schema } from "effect"; const LocalTargetJson = Schema.Struct({ kind: Schema.Literal("local"), host: Schema.String, - port: Schema.Number, + port: Schema.Finite, }); const DbUrlTargetJson = Schema.Struct({ kind: Schema.Literal("db-url"), host: Schema.String, - port: Schema.Number, + port: Schema.Finite, }); const LinkedTargetJson = Schema.Struct({ diff --git a/apps/cli/src/shared/output/command-target.service.unit.test.ts b/apps/cli/src/shared/output/command-target.service.unit.test.ts index bfe9c39c66..d1e34eef7e 100644 --- a/apps/cli/src/shared/output/command-target.service.unit.test.ts +++ b/apps/cli/src/shared/output/command-target.service.unit.test.ts @@ -20,7 +20,11 @@ describe("CommandTargetJson", () => { }); it("leaves the optional linked fields out when absent", () => { - const linked = { kind: "linked", project_ref: "abcdefghijklmnopqrst", environment: "unverified" }; + const linked = { + kind: "linked", + project_ref: "abcdefghijklmnopqrst", + environment: "unverified", + }; expect(encode(linked)).toEqual(linked); }); From d682e59e430cc74e09a2c2bac865e01c323774d1 Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Thu, 15 Oct 2026 17:15:02 +0100 Subject: [PATCH 91/92] docs(cli): align ADR 0033 banner examples with the identity rules --- ...t-selection-and-hosted-mutation-consent.md | 22 ++++++++++--------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/docs/adr/0033-target-selection-and-hosted-mutation-consent.md b/docs/adr/0033-target-selection-and-hosted-mutation-consent.md index 843302b8c5..634fe19c23 100644 --- a/docs/adr/0033-target-selection-and-hosted-mutation-consent.md +++ b/docs/adr/0033-target-selection-and-hosted-mutation-consent.md @@ -97,17 +97,19 @@ credentials. ```text Target: local stack (<host>:<port>) -Target: custom Postgres (<host>) +Target: local stack (<host>:<port>) · default; pass --linked for <name> (<ref>) +Target: custom Postgres (<host>:<port>) Target: project <name> (<ref>) · production -Target: project <parent name> (<parent ref>) · branch (<ref>) -Target: project <ref> · identity unverified +Target: project <parent ref> · branch <name> (<ref>) +Target: project <ref> +Target: project <ref> (not the linked project) ``` -Project identity comes from `supabase/.temp/linked-project.json` alone. A ref that is its own parent -in the cache is production. A ref from the ref file whose cache names another parent is a branch of -that parent. Any other ref, for example one from `--project-ref`, is unverified. A branch the -`config` family selects by name prints `Target: project <parent ref> · branch <name> (<ref>)` -instead. +Project identity comes from `supabase/.temp/linked-project.json` with no API call. A cache that +names the resolved ref itself is a production project. The cache can be stale or missing, so it +never proves a branch: "branch" is shown only on positive evidence (a branch lookup by name or +UUID, or a live probe), and anything else is the bare ref, reported `unverified` in JSON. The full +table and the JSON shape live in [the command target doc](../../apps/cli/docs/command-target.md). JSON results carry the same information as `target`: @@ -214,8 +216,8 @@ to No makes the Enter key safe. - A hosted `db query` needs consent unless it is `--read-only`. - `db reset`'s JSON `target` changes from a string to the `target` object. - Declining the `db pull` or `pull` history update loses the "write the file only" path. -- Every command that resolves a target adds a stderr line. A ref that is not in the cache shows - `identity unverified`. +- Every command that resolves a target adds a stderr line. A ref the cache does not vouch for + shows as the bare ref, and JSON reports it `unverified`. - The cancelled-error tags are consolidated and new tags are added, so telemetry keyed on the old tags needs updating. From 8a9815f59ce26f1cbacd13e47e9cdeb1eb617ef6 Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Thu, 15 Oct 2026 17:21:07 +0100 Subject: [PATCH 92/92] test(cli): expect sentence-case config push consent actions --- apps/cli/src/commands/config/push/push.unit.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/cli/src/commands/config/push/push.unit.test.ts b/apps/cli/src/commands/config/push/push.unit.test.ts index 884ae572e4..47f3e1c677 100644 --- a/apps/cli/src/commands/config/push/push.unit.test.ts +++ b/apps/cli/src/commands/config/push/push.unit.test.ts @@ -7,16 +7,16 @@ const PARENT_REF = "pppppppppppppppppppp"; describe("pushConsentAction", () => { test("names a single service", () => { - expect(pushConsentAction(["auth"])).toBe("push config changes to Auth"); + expect(pushConsentAction(["auth"])).toBe("Push config changes to Auth"); }); test("joins two services with and", () => { - expect(pushConsentAction(["api", "storage"])).toBe("push config changes to API and Storage"); + expect(pushConsentAction(["api", "storage"])).toBe("Push config changes to API and Storage"); }); test("joins three or more with commas and a final and, including webhooks", () => { expect(pushConsentAction(["api", "db.settings", "experimental.webhooks"])).toBe( - "push config changes to API, DB and Webhooks", + "Push config changes to API, DB and Webhooks", ); }); });