From ad0c0fd41ff84024007e82d92d20aa212d7b62bf Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Thu, 15 Oct 2026 12:21:31 +0100 Subject: [PATCH 001/291] 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 002/291] 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 003/291] 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 004/291] 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 005/291] 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 006/291] 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 007/291] 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 008/291] 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 009/291] 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 010/291] 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 011/291] 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 012/291] 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 013/291] 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 014/291] 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 015/291] 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 016/291] 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 017/291] 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 018/291] 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 019/291] 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 020/291] 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 021/291] 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 022/291] 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 023/291] 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 024/291] 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 025/291] 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 026/291] 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 027/291] 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 028/291] 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 029/291] 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 030/291] 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 031/291] 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 032/291] 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 033/291] 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 034/291] 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 035/291] 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 036/291] 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 037/291] 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 038/291] 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 039/291] 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 040/291] 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 041/291] 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 042/291] 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 043/291] 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 044/291] 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 045/291] 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 046/291] 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 047/291] 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 048/291] 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 049/291] 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 050/291] 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 051/291] 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 052/291] 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 053/291] 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 054/291] 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 055/291] 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 056/291] 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 057/291] 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 058/291] 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 059/291] 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 060/291] 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 061/291] 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 062/291] 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 063/291] 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 064/291] 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 065/291] 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 066/291] 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 067/291] 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 068/291] 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 069/291] 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 070/291] 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 071/291] 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 072/291] 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 073/291] 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 074/291] 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 075/291] 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 076/291] 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 077/291] 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 078/291] 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 079/291] 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 080/291] 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 081/291] 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 082/291] 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 083/291] 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 084/291] 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 085/291] 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 086/291] 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 087/291] 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 088/291] 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 089/291] 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 090/291] 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 091/291] 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 092/291] 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", ); }); }); From a4c38a231801f98e2b513cd625a16a768a090a1f Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Mon, 19 Oct 2026 10:04:16 +0100 Subject: [PATCH 093/291] feat(cli): label each command's target in help Group help tags every subcommand row as local or remote, local only or remote only, and a command's own help gets a TARGET section before USAGE. The --remote flag row shows the value it accepts. JSON help carries the same information as a target field. --- apps/cli/src/shared/cli/command-docs.ts | 3 +- apps/cli/src/shared/output/json-formatter.ts | 24 ++- apps/cli/src/shared/output/target-help.ts | 152 +++++++++++++++ .../shared/output/target-help.unit.test.ts | 180 ++++++++++++++++++ apps/cli/src/shared/output/text-formatter.ts | 13 ++ apps/cli/tests/helpers/remote-flag-fixture.ts | 60 ++++++ 6 files changed, 429 insertions(+), 3 deletions(-) create mode 100644 apps/cli/src/shared/output/target-help.ts create mode 100644 apps/cli/src/shared/output/target-help.unit.test.ts create mode 100644 apps/cli/tests/helpers/remote-flag-fixture.ts diff --git a/apps/cli/src/shared/cli/command-docs.ts b/apps/cli/src/shared/cli/command-docs.ts index 622fb23e02..bb30a58880 100644 --- a/apps/cli/src/shared/cli/command-docs.ts +++ b/apps/cli/src/shared/cli/command-docs.ts @@ -1,4 +1,5 @@ import type { Command, HelpDoc } from "effect/cli"; +import { withRemoteFlagUsage } from "../output/target-help.ts"; import { helpDocFor } from "./subcommand-flag-suggestions.ts"; // Get HelpDoc from a command (uses internal buildHelpDoc) @@ -8,7 +9,7 @@ export function getHelpDoc( ): HelpDoc.HelpDoc { const helpDoc = helpDocFor(command, commandPath); if (helpDoc === undefined) throw new Error(`Command ${command.name} has no help doc`); - return helpDoc; + return withRemoteFlagUsage(helpDoc); } // Navigate to a subcommand by path segments diff --git a/apps/cli/src/shared/output/json-formatter.ts b/apps/cli/src/shared/output/json-formatter.ts index ce60c19ed8..0261fd7f1b 100644 --- a/apps/cli/src/shared/output/json-formatter.ts +++ b/apps/cli/src/shared/output/json-formatter.ts @@ -1,12 +1,32 @@ import type { CliOutput, HelpDoc } from "effect/cli"; import type { CliErrorSuggestionContext } from "../cli/subcommand-flag-suggestions.ts"; import { cliErrorCode, formatCliErrorsForDisplay } from "../cli/subcommand-flag-suggestions.ts"; +import { subcommandScopes, targetHelpFor, withRemoteFlagUsage } from "./target-help.ts"; export function jsonCliOutputFormatter(context?: CliErrorSuggestionContext): CliOutput.Formatter { return { formatHelpDoc: (doc: HelpDoc.HelpDoc) => { - const { annotations: _annotations, ...serializable } = doc; - return JSON.stringify({ _tag: "Help", doc: serializable }); + const scopes = subcommandScopes(doc, context?.rootCommand); + const target = targetHelpFor(doc); + const { annotations: _annotations, ...serializable } = withRemoteFlagUsage(doc); + return JSON.stringify({ + _tag: "Help", + doc: { + ...serializable, + ...(target === undefined ? {} : { target }), + ...(serializable.subcommands === undefined + ? {} + : { + subcommands: serializable.subcommands.map((group) => ({ + ...group, + commands: group.commands.map((sub) => { + const scope = scopes.get(sub.name); + return scope === undefined ? sub : { ...sub, target: { scope } }; + }), + })), + }), + }, + }); }, formatCliError: (error) => JSON.stringify({ diff --git a/apps/cli/src/shared/output/target-help.ts b/apps/cli/src/shared/output/target-help.ts new file mode 100644 index 0000000000..cb9ac80c47 --- /dev/null +++ b/apps/cli/src/shared/output/target-help.ts @@ -0,0 +1,152 @@ +import { Array as Arr, Context, Option } from "effect"; +import type { Command, HelpDoc } from "effect/cli"; +import { TargetPolicyAnnotation } from "../../command-internal/target/target-policy.ts"; + +type TargetKind = "selectable" | "hosted" | "local-only"; + +interface TargetCopy { + readonly scope: string; + readonly summary: string; + /** The value `--remote` accepts on this kind of command; `undefined` when it takes none. */ + readonly remoteValue: string | undefined; +} + +const TARGET_COPY: Readonly<Record<TargetKind, TargetCopy>> = { + selectable: { + scope: "local or remote", + summary: + "Local by default. Pass --remote for the linked project, or --remote=<ref|branch|url>.", + remoteValue: "<ref|branch|url>", + }, + hosted: { + scope: "remote only", + summary: "Remote only. Uses the linked project; pass --remote=<ref|branch> for another.", + remoteValue: "<ref|branch>", + }, + "local-only": { + scope: "local only", + summary: "Local only. Runs against the local stack.", + remoteValue: undefined, + }, +}; + +const KIND_BY_GROUP: Readonly<Record<string, TargetKind | undefined>> = { + selectable: "selectable", + sync: "selectable", + hosted: "hosted", + "local-only": "local-only", +}; + +const REMOTE_FLAG = "remote"; + +/** The flag's display name once its value hint is known: `remote[=<ref|branch>]`. */ +export const remoteFlagName = (hint: string): string => `${REMOTE_FLAG}[=${hint}]`; + +export interface TargetHelp { + readonly scope: string; + readonly summary: string; +} + +const kindOf = (annotations: Context.Context<never>): TargetKind | undefined => + Context.getOption(annotations, TargetPolicyAnnotation).pipe( + Option.flatMapNullishOr((policy) => KIND_BY_GROUP[policy.group]), + Option.getOrUndefined, + ); + +const declaresRemoteFlag = (doc: HelpDoc.HelpDoc): boolean => + [...doc.flags, ...(doc.globalFlags ?? [])].some((flag) => flag.name === REMOTE_FLAG); + +/** What a leaf's help says about its target; `undefined` for account, removed and group commands. */ +export function targetHelpFor(doc: HelpDoc.HelpDoc): TargetHelp | undefined { + const kind = kindOf(doc.annotations); + if (kind === undefined) return undefined; + const { scope, summary } = TARGET_COPY[kind]; + return { + scope, + summary: declaresRemoteFlag(doc) ? summary : summary.slice(0, summary.indexOf(".") + 1), + }; +} + +/** The value hint `--remote` takes on a command, e.g. `<ref|branch>`; `undefined` where it takes none. */ +export function remoteValueHint(annotations: Context.Context<never>): string | undefined { + const kind = kindOf(annotations); + return kind === undefined ? undefined : TARGET_COPY[kind].remoteValue; +} + +/** The `--remote` flag rows show the value `remote` takes on this command: `--remote[=<ref|branch>]`. */ +export function withRemoteFlagUsage(doc: HelpDoc.HelpDoc): HelpDoc.HelpDoc { + const hint = remoteValueHint(doc.annotations); + if (hint === undefined || !declaresRemoteFlag(doc)) return doc; + const rename = (flag: HelpDoc.FlagDoc): HelpDoc.FlagDoc => + flag.name === REMOTE_FLAG ? { ...flag, name: remoteFlagName(hint) } : flag; + return { + ...doc, + flags: doc.flags.map(rename), + ...(doc.globalFlags === undefined ? {} : { globalFlags: doc.globalFlags.map(rename) }), + }; +} + +function findCommand( + root: Command.Command.Any, + path: ReadonlyArray<string>, +): Command.Command.Any | undefined { + let current = root; + for (const segment of path) { + const next = current.subcommands + .flatMap((group) => group.commands) + .find((command) => command.name === segment || command.alias === segment); + if (next === undefined) return undefined; + current = next; + } + return current; +} + +/** The command a help doc describes: its usage line starts with the command path, root first. */ +function commandForDoc( + root: Command.Command.Any, + doc: HelpDoc.HelpDoc, +): Command.Command.Any | undefined { + const path: Array<string> = []; + for (const token of doc.usage.split(" ")) { + if (token.startsWith("<") || token.startsWith("[")) break; + path.push(token); + } + return findCommand(root, path.slice(1)); +} + +/** Scope label per subcommand row of a group's help, keyed by subcommand name. */ +export function subcommandScopes( + doc: HelpDoc.HelpDoc, + root: Command.Command.Any | undefined, +): ReadonlyMap<string, string> { + const scopes = new Map<string, string>(); + if (root === undefined || doc.subcommands === undefined) return scopes; + const parent = commandForDoc(root, doc); + if (parent === undefined) return scopes; + for (const child of parent.subcommands.flatMap((group) => group.commands)) { + const kind = kindOf(child.annotations); + if (kind !== undefined) scopes.set(child.name, TARGET_COPY[kind].scope); + } + return scopes; +} + +/** The doc with each labelled subcommand row suffixed ` [local or remote]`. */ +export function labelSubcommands( + doc: HelpDoc.HelpDoc, + root: Command.Command.Any | undefined, +): HelpDoc.HelpDoc { + const scopes = subcommandScopes(doc, root); + if (doc.subcommands === undefined || scopes.size === 0) return doc; + return { + ...doc, + subcommands: doc.subcommands.map((group) => ({ + ...group, + commands: Arr.map(group.commands, (sub) => { + const scope = scopes.get(sub.name); + return scope === undefined + ? sub + : { ...sub, shortDescription: `${sub.shortDescription ?? sub.description} [${scope}]` }; + }), + })), + }; +} diff --git a/apps/cli/src/shared/output/target-help.unit.test.ts b/apps/cli/src/shared/output/target-help.unit.test.ts new file mode 100644 index 0000000000..a1b26b9f79 --- /dev/null +++ b/apps/cli/src/shared/output/target-help.unit.test.ts @@ -0,0 +1,180 @@ +import type { Command } from "effect/cli"; +import { describe, expect, it } from "vitest"; +import { + fixtureGroup, + fixtureLeaf, + fixtureRoot, +} from "../../../tests/helpers/remote-flag-fixture.ts"; +import { rootCommand } from "../../cli/root.ts"; +import { helpDocFor } from "../cli/subcommand-flag-suggestions.ts"; +import { jsonCliOutputFormatter } from "./json-formatter.ts"; +import { textCliOutputFormatter } from "./text-formatter.ts"; + +const helpFor = (root: Command.Command.Any, path: ReadonlyArray<string>) => { + let command = root; + for (const segment of path) { + const next = command.subcommands + .flatMap((group) => group.commands) + .find((candidate) => candidate.name === segment); + if (next === undefined) throw new Error(`no command ${segment}`); + command = next; + } + const doc = helpDocFor(command, ["supabase", ...path]); + if (doc === undefined) throw new Error("no help doc"); + return doc; +}; + +const textHelp = (root: Command.Command.Any, path: ReadonlyArray<string>) => + textCliOutputFormatter({ rootCommand: root, args: [] }).formatHelpDoc(helpFor(root, path)); + +const jsonHelp = (root: Command.Command.Any, path: ReadonlyArray<string>) => { + const parsed: { doc: Record<string, any> } = JSON.parse( + jsonCliOutputFormatter({ rootCommand: root, args: [] }).formatHelpDoc(helpFor(root, path)), + ); + return parsed.doc; +}; + +const tree = fixtureRoot( + fixtureGroup( + "db", + fixtureLeaf("push", "selectable"), + fixtureLeaf("start", "local-only"), + fixtureLeaf("secrets", "hosted"), + fixtureLeaf("login", "account"), + fixtureLeaf("legacy", "removed"), + fixtureGroup("schema", fixtureLeaf("generate", "selectable")), + ), +); + +describe("help target labels", () => { + it("labels each subcommand row from its target group", () => { + const text = textHelp(tree, ["db"]); + + expect(text).toMatch(/push\s+The push command\.\s+\[local or remote\]/); + expect(text).toMatch(/start\s+The start command\.\s+\[local only\]/); + expect(text).toMatch(/secrets\s+The secrets command\.\s+\[remote only\]/); + }); + + it.each(["login", "legacy", "schema"])("leaves %s unlabelled", (name) => { + const row = textHelp(tree, ["db"]) + .split("\n") + .find((line) => line.trimStart().startsWith(`${name} `)); + + expect(row).toBeDefined(); + expect(row).not.toContain("["); + }); + + it("labels the rows of the real command tree", () => { + const text = textCliOutputFormatter({ rootCommand, args: [] }).formatHelpDoc( + helpFor(rootCommand, ["functions"]), + ); + + expect(text).toMatch(/deploy\s.*\[remote only\]/); + expect(text).toMatch(/serve\s.*\[local only\]/); + }); + + it("leaves rows unlabelled without a root command to look children up in", () => { + const text = textCliOutputFormatter().formatHelpDoc(helpFor(tree, ["db"])); + + expect(text).not.toContain("[local or remote]"); + }); +}); + +describe("help TARGET section", () => { + it("states the selectable default before USAGE", () => { + const text = textHelp(tree, ["db", "push"]); + + expect(text).toContain( + "TARGET\n Local by default. Pass --remote for the linked project, or --remote=<ref|branch|url>.\n\nUSAGE\n", + ); + expect(text.indexOf("DESCRIPTION")).toBeLessThan(text.indexOf("TARGET")); + }); + + it("states that a hosted command uses the linked project", () => { + expect(textHelp(tree, ["db", "secrets"])).toContain( + "TARGET\n Remote only. Uses the linked project; pass --remote=<ref|branch> for another.\n\nUSAGE\n", + ); + }); + + it("states that a local-only command runs against the local stack", () => { + expect(textHelp(tree, ["db", "start"])).toContain( + "TARGET\n Local only. Runs against the local stack.\n\nUSAGE\n", + ); + }); + + it("drops the --remote advice from a hosted command that has no such flag", () => { + const root = fixtureRoot(fixtureLeaf("link", "hosted", { withRemote: false })); + + expect(textHelp(root, ["link"])).toContain("TARGET\n Remote only.\n\nUSAGE\n"); + }); + + it.each(["login", "legacy"])("adds no TARGET section to the %s command", (name) => { + expect(textHelp(tree, ["db", name])).not.toContain("TARGET"); + }); + + it("adds no TARGET section to a group", () => { + expect(textHelp(tree, ["db"])).not.toContain("TARGET"); + }); + + it("starts with the section when the command has no description", () => { + const root = fixtureRoot(fixtureLeaf("push", "selectable", { description: "" })); + + expect(textHelp(root, ["push"]).startsWith("TARGET\n")).toBe(true); + }); +}); + +describe("help --remote flag row", () => { + it("shows the full value hint on a selectable command", () => { + expect(textHelp(tree, ["db", "push"])).toMatch( + /--remote\[=<ref\|branch\|url>\]\s+Target a remote project\./, + ); + }); + + it("omits the url from the hint on a hosted command", () => { + const text = textHelp(tree, ["db", "secrets"]); + + expect(text).toMatch(/--remote\[=<ref\|branch>\]\s+Target a remote project\./); + expect(text).not.toContain("url"); + }); + + it("leaves other flags as the default formatter renders them", () => { + expect(textHelp(tree, ["db", "push"])).toMatch(/--other\s+Another flag\./); + }); +}); + +describe("JSON help", () => { + it("reports the target scope and summary of a leaf", () => { + expect(jsonHelp(tree, ["db", "push"]).target).toEqual({ + scope: "local or remote", + summary: + "Local by default. Pass --remote for the linked project, or --remote=<ref|branch|url>.", + }); + }); + + it("reports the target scope of each subcommand row", () => { + const [group] = jsonHelp(tree, ["db"]).subcommands; + const scopes = Object.fromEntries( + group.commands.map((sub: { name: string; target?: { scope: string } }) => [ + sub.name, + sub.target?.scope, + ]), + ); + + expect(scopes).toMatchObject({ + push: "local or remote", + start: "local only", + secrets: "remote only", + login: undefined, + }); + }); + + it("names the --remote flag with its value hint", () => { + const flags: Array<{ name: string }> = jsonHelp(tree, ["db", "push"]).flags; + + expect(flags.map((flag) => flag.name)).toContain("remote[=<ref|branch|url>]"); + }); + + it("omits target from an account command", () => { + expect(jsonHelp(tree, ["db", "login"])).not.toHaveProperty("target"); + }); +}); diff --git a/apps/cli/src/shared/output/text-formatter.ts b/apps/cli/src/shared/output/text-formatter.ts index dc314545af..fe8ba2492c 100644 --- a/apps/cli/src/shared/output/text-formatter.ts +++ b/apps/cli/src/shared/output/text-formatter.ts @@ -4,12 +4,18 @@ import type { FormattedCliError, } from "../cli/subcommand-flag-suggestions.ts"; import { formatCliErrorsForDisplay } from "../cli/subcommand-flag-suggestions.ts"; +import { labelSubcommands, targetHelpFor, withRemoteFlagUsage } from "./target-help.ts"; function stripSingleErrorHeader(text: string): string { const prefix = "\nERROR\n "; return text.startsWith(prefix) ? text.slice(prefix.length) : text.trimStart(); } +function insertBeforeUsage(text: string, section: string): string { + const at = `\n${text}`.indexOf("\nUSAGE\n"); + return at === -1 ? text : `${text.slice(0, at)}${section}\n\n${text.slice(at)}`; +} + export function textCliOutputFormatter(context?: CliErrorSuggestionContext): CliOutput.Formatter { const base = CliOutput.defaultFormatter({ colors: false }); const formatErrorMessage = (error: FormattedCliError) => @@ -17,6 +23,13 @@ export function textCliOutputFormatter(context?: CliErrorSuggestionContext): Cli return { ...base, + formatHelpDoc: (doc) => { + const text = base.formatHelpDoc( + withRemoteFlagUsage(labelSubcommands(doc, context?.rootCommand)), + ); + const target = targetHelpFor(doc); + return target === undefined ? text : insertBeforeUsage(text, `TARGET\n ${target.summary}`); + }, formatErrors: (errors) => { const formatted = formatCliErrorsForDisplay(errors, context); if (!formatted.changed) return base.formatErrors(errors); diff --git a/apps/cli/tests/helpers/remote-flag-fixture.ts b/apps/cli/tests/helpers/remote-flag-fixture.ts new file mode 100644 index 0000000000..1347ced87a --- /dev/null +++ b/apps/cli/tests/helpers/remote-flag-fixture.ts @@ -0,0 +1,60 @@ +import { Effect } from "effect"; +import type { Command } from "effect/cli"; +import { Argument, Command as CommandApi, Flag, Param, Primitive } from "effect/cli"; +import { withTargetPolicy } from "../../src/command-internal/target/target-policy.ts"; +import type { TargetPolicyDeclaration } from "../../src/command-internal/target/target-policy.ts"; + +/** A bare-capable, string-valued flag shaped like `--remote`: the Boolean tag keeps the parser from taking the next token as its value. */ +export const remoteFlag = Param.makeSingle({ + kind: Param.flagKind, + name: "remote", + primitiveType: Object.assign(Object.create(Primitive.Boolean), { + parse: (value: string) => Effect.succeed(value), + }), +}).pipe(Flag.withDescription("Target a remote project."), Flag.optional); + +const exempt = "fixture"; + +const POLICIES = { + selectable: { group: "selectable", consent: "none", consentExemptReason: exempt }, + hosted: { group: "hosted", consent: "none" }, + "local-only": { group: "local-only", consent: "none" }, + account: { group: "account", consent: "none" }, + removed: { group: "removed", consent: "none" }, +} as const satisfies Record<string, TargetPolicyDeclaration>; + +export type FixtureGroup = keyof typeof POLICIES; + +export interface FixtureLeafOptions { + readonly withRemote?: boolean; + readonly positional?: boolean; + readonly description?: string; +} + +/** A leaf declaring `group`'s target policy, with a `--remote` flag unless `withRemote` is false. */ +export const fixtureLeaf = ( + name: string, + group: FixtureGroup, + options: FixtureLeafOptions = {}, +) => { + const config = { + other: Flag.Boolean("other").pipe(Flag.withDescription("Another flag.")), + ...(options.withRemote === false ? {} : { remote: remoteFlag }), + ...(options.positional === true + ? { target: Argument.String("target").pipe(Argument.optional) } + : {}), + }; + return CommandApi.make(name, config, () => Effect.void).pipe( + CommandApi.withDescription(options.description ?? `The ${name} command.`), + withTargetPolicy(POLICIES[group]), + ); +}; + +export const fixtureRoot = (...children: ReadonlyArray<Command.Command.Any>): Command.Command.Any => + CommandApi.make("supabase").pipe(CommandApi.withSubcommands([...children])); + +export const fixtureGroup = (name: string, ...children: ReadonlyArray<Command.Command.Any>) => + CommandApi.make(name).pipe( + CommandApi.withDescription(`The ${name} group.`), + CommandApi.withSubcommands([...children]), + ); From 996734bf63518a3fca9c670e35c0e5fa5b6144ce Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Mon, 19 Oct 2026 10:05:57 +0100 Subject: [PATCH 094/291] feat(cli): complete --remote as a value-optional flag --remote=<value> takes any text and offers no file candidates. The token after a bare --remote is its value on a command with no positional or no free positional slot, and a positional otherwise. --- apps/cli/src/cli/complete.remote.unit.test.ts | 84 +++++++++++++++++++ apps/cli/src/cli/complete.ts | 28 +++++++ apps/cli/tests/helpers/remote-flag-fixture.ts | 14 +++- 3 files changed, 122 insertions(+), 4 deletions(-) create mode 100644 apps/cli/src/cli/complete.remote.unit.test.ts diff --git a/apps/cli/src/cli/complete.remote.unit.test.ts b/apps/cli/src/cli/complete.remote.unit.test.ts new file mode 100644 index 0000000000..ca4441a53b --- /dev/null +++ b/apps/cli/src/cli/complete.remote.unit.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from "vitest"; +import { fixtureGroup, fixtureLeaf, fixtureRoot } from "../../tests/helpers/remote-flag-fixture.ts"; +import { CompletionDirective, respondToComplete } from "./complete.ts"; + +const tree = fixtureRoot( + fixtureGroup( + "db", + fixtureLeaf("push", "selectable"), + fixtureLeaf("pull", "selectable", { positionals: ["optional"] }), + fixtureLeaf("query", "selectable", { positionals: ["required"] }), + fixtureLeaf("cp", "selectable", { positionals: ["required", "variadic"] }), + fixtureLeaf("deploy", "hosted"), + ), +); + +const complete = (...words: ReadonlyArray<string>) => + respondToComplete(tree, ["__complete", ...words]); + +const names = (...words: ReadonlyArray<string>) => + complete(...words)?.candidates.map((candidate) => candidate.name); + +const noValueCandidates = { candidates: [], directive: CompletionDirective.NoFileComp }; + +describe("--remote flag-name completion", () => { + it("offers --remote as a bare switch, never --remote=", () => { + expect(names("db", "push", "--rem")).toEqual(["--remote"]); + }); + + it("offers --remote after positionals", () => { + expect(names("db", "pull", "add_profiles", "--rem")).toEqual(["--remote"]); + }); + + it("stops offering --remote once it is given", () => { + expect(names("db", "push", "--remote", "--rem")).toEqual([]); + expect(names("db", "push", "--remote=feature-x", "--rem")).toEqual([]); + }); +}); + +describe("--remote= value completion", () => { + it.each(["--remote=", "--remote=feature-x", "--remote=postgresql://u:p@host:5432/db"])( + "takes any text after the equals sign and offers no files (%s)", + (token) => { + expect(complete("db", "push", token)).toEqual(noValueCandidates); + }, + ); + + it("keeps completing flags after a branch name given with an equals sign", () => { + expect(names("db", "push", "--remote=feature-x", "--o")).toEqual(["--other"]); + }); +}); + +describe("the token after a bare --remote", () => { + it("is the value on a command with no positional", () => { + expect(complete("db", "push", "--remote", "")).toEqual(noValueCandidates); + expect(complete("db", "deploy", "--remote", "feat")).toEqual(noValueCandidates); + }); + + it("is the positional while the command has a free positional slot", () => { + expect(complete("db", "pull", "--remote", "")).toEqual({ + candidates: [], + directive: CompletionDirective.Default, + }); + expect(complete("db", "query", "--remote", "sel")).toEqual({ + candidates: [], + directive: CompletionDirective.Default, + }); + }); + + it("is the value once the positionals are filled", () => { + expect(complete("db", "pull", "add_profiles", "--remote", "")).toEqual(noValueCandidates); + expect(complete("db", "query", "select 1", "--remote", "")).toEqual(noValueCandidates); + }); + + it("is always a positional when the command takes a variadic one", () => { + expect(complete("db", "cp", "a", "b", "--remote", "")).toEqual({ + candidates: [], + directive: CompletionDirective.Default, + }); + }); + + it("does not stop flag completion from running after the bare switch", () => { + expect(names("db", "pull", "--remote", "--o")).toEqual(["--other"]); + }); +}); diff --git a/apps/cli/src/cli/complete.ts b/apps/cli/src/cli/complete.ts index 4b647b832c..fa66fd3b11 100644 --- a/apps/cli/src/cli/complete.ts +++ b/apps/cli/src/cli/complete.ts @@ -9,6 +9,7 @@ import { RESOURCE_OUTPUT_FORMATS, } from "../command-internal/output-formats.ts"; import { unwrapParam } from "../command-internal/param-introspection.ts"; +import { commandInternals } from "../docs/docs-introspection.ts"; import { isValidBase0Int64, parseUintBase0 } from "../shared/config/parse-uint.ts"; import { parseStringSliceFlag } from "../command-internal/string-slice-flag.ts"; import { withAnalyticsContext } from "../shared/telemetry/analytics-context.ts"; @@ -654,6 +655,26 @@ function outputFlagChoiceKeys(matchedPath: ReadonlyArray<string>): ReadonlyArray : RESOURCE_OUTPUT_FORMATS; } +/** + * `--remote` is tagged as a boolean so a bare `--remote` parses, yet `--remote=<value>` takes any + * text; the runtime rejects `false` and the empty value with a clear error of its own. + */ +const REMOTE_FLAG = "remote"; + +/** + * Whether the token after a bare `--remote` is read as its value rather than a positional, + * mirroring the argv rewrite: always for a command with no positional, and for a token past the + * last positional slot. A variadic positional never runs out of slots. + */ +function remoteTakesNextToken( + finalCommand: Command.Command.Any, + leftoverArgs: ReadonlyArray<string>, +): boolean { + const positionals = commandInternals(finalCommand).config.arguments; + if (positionals.some((param) => unwrapParam(param)?.isVariadic === true)) return false; + return leftoverArgs.length >= positionals.length; +} + /** * Validates a flag's value; an invalid value returns zero candidates with the Default * directive, exactly like an unresolved flag name. The command-dependent overrides @@ -666,6 +687,7 @@ function isValidFlagValue( flag: FlagDescriptor, value: string, ): boolean { + if (flag.name === REMOTE_FLAG) return true; const key = `${matchedPath.join(" ")}:${flag.name}`; if (COMPLETION_UINT_FLAGS.has(key)) { return "value" in parseUintBase0(value); @@ -801,6 +823,9 @@ function flagValueCompletion( matchedPath: ReadonlyArray<string>, flagName: string | undefined, ): CompletionResult { + if (flagName === REMOTE_FLAG) { + return { candidates: [], directive: CompletionDirective.NoFileComp }; + } const key = flagName === undefined ? undefined : `${matchedPath.join(" ")}:${flagName}`; const extensions = key === undefined ? undefined : COMPLETION_FLAG_FILE_EXTENSIONS.get(key); if (extensions !== undefined) { @@ -933,6 +958,9 @@ export function classifyCompletion(input: ClassifyCompletionInput): CompletionRe if (!resolved.isBoolean) { return flagValueCompletion(matchedPath, resolved.name); } + if (resolved.name === REMOTE_FLAG && remoteTakesNextToken(finalCommand, leftoverArgs)) { + return flagValueCompletion(matchedPath, resolved.name); + } // A resolved boolean precedingToken falls through to Case 3, since it never // consumed a value. } diff --git a/apps/cli/tests/helpers/remote-flag-fixture.ts b/apps/cli/tests/helpers/remote-flag-fixture.ts index 1347ced87a..81220ef694 100644 --- a/apps/cli/tests/helpers/remote-flag-fixture.ts +++ b/apps/cli/tests/helpers/remote-flag-fixture.ts @@ -13,6 +13,12 @@ export const remoteFlag = Param.makeSingle({ }), }).pipe(Flag.withDescription("Target a remote project."), Flag.optional); +const positional = (kind: "required" | "optional" | "variadic", index: number) => { + const argument = Argument.String(`arg${index}`); + if (kind === "optional") return argument.pipe(Argument.optional); + return kind === "variadic" ? argument.pipe(Argument.variadic()) : argument; +}; + const exempt = "fixture"; const POLICIES = { @@ -27,7 +33,7 @@ export type FixtureGroup = keyof typeof POLICIES; export interface FixtureLeafOptions { readonly withRemote?: boolean; - readonly positional?: boolean; + readonly positionals?: ReadonlyArray<"required" | "optional" | "variadic">; readonly description?: string; } @@ -40,9 +46,9 @@ export const fixtureLeaf = ( const config = { other: Flag.Boolean("other").pipe(Flag.withDescription("Another flag.")), ...(options.withRemote === false ? {} : { remote: remoteFlag }), - ...(options.positional === true - ? { target: Argument.String("target").pipe(Argument.optional) } - : {}), + ...Object.fromEntries( + (options.positionals ?? []).map((kind, index) => [`arg${index}`, positional(kind, index)]), + ), }; return CommandApi.make(name, config, () => Effect.void).pipe( CommandApi.withDescription(options.description ?? `The ${name} command.`), From 247b07cdf31f7828d8bfd06028e10e1c13d16ae5 Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Mon, 19 Oct 2026 10:07:53 +0100 Subject: [PATCH 095/291] feat(cli): show --remote with its value hint in the docs spec --- apps/cli/src/docs/docs-spec.tables.ts | 36 ------------------------ apps/cli/src/docs/docs-spec.ts | 22 +++++++++------ apps/cli/src/docs/docs-spec.unit.test.ts | 15 ++++++++++ 3 files changed, 29 insertions(+), 44 deletions(-) diff --git a/apps/cli/src/docs/docs-spec.tables.ts b/apps/cli/src/docs/docs-spec.tables.ts index 07e36bb03f..ad7cb6af2b 100644 --- a/apps/cli/src/docs/docs-spec.tables.ts +++ b/apps/cli/src/docs/docs-spec.tables.ts @@ -126,19 +126,10 @@ export const DOCS_DEFAULT_OVERRIDES: Readonly<Record<string, string>> = { "supabase profile": "supabase", "supabase-db-advisors fail-on": "none", "supabase-db-advisors level": "warn", - "supabase-db-advisors local": "true", "supabase-db-advisors type": "all", - "supabase-db-diff local": "true", - "supabase-db-dump local": "true", "supabase-db-lint fail-on": "none", "supabase-db-lint level": "warning", - "supabase-db-lint local": "true", "supabase-db-pull diff-engine": "pg-delta", - "supabase-db-pull linked": "true", - "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", @@ -147,40 +138,13 @@ 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 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 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 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/src/docs/docs-spec.ts b/apps/cli/src/docs/docs-spec.ts index 94376b9297..6d5d4312f3 100644 --- a/apps/cli/src/docs/docs-spec.ts +++ b/apps/cli/src/docs/docs-spec.ts @@ -9,6 +9,7 @@ import { userGlobalFlagParams, } from "./docs-introspection.ts"; import { unwrapParam } from "../command-internal/param-introspection.ts"; +import { remoteFlagName, remoteValueHint } from "../shared/output/target-help.ts"; import { DOCS_ARG_OVERRIDES, DOCS_CHOICE_OVERRIDES, @@ -179,9 +180,10 @@ function docsNormalizeText(text: string): string { } /** Doc for a visible flag; `undefined` for hidden flags; throws on an unrecognizable param. */ -function docsFlagDoc( +export function docsFlagDoc( commandPath: ReadonlyArray<string>, param: Param.AnyFlag, + remoteHint?: string, ): DocsFlag | undefined { const unwrapped = unwrapParam(param); if (unwrapped === undefined) { @@ -207,9 +209,11 @@ function docsFlagDoc( const varname = enumType ?? docsPrimitiveVarname(single, isVariadic); const shorthand = single.aliases.find((alias) => alias.length === 1); - const name = `${shorthand === undefined ? "" : `-${shorthand}, `}--${single.name}${ - varname === undefined ? "" : ` <${varname}>` - }`; + const longName = + single.name === "remote" && remoteHint !== undefined + ? remoteFlagName(remoteHint) + : `${single.name}${varname === undefined ? "" : ` <${varname}>`}`; + const name = `${shorthand === undefined ? "" : `-${shorthand}, `}--${longName}`; const required = DOCS_REQUIRED.has(overrideKey); @@ -309,10 +313,11 @@ export function buildDocsSpec(input: DocsSpecInput): DocsSpec { const flagDocsFor = ( commandPath: ReadonlyArray<string>, params: ReadonlyArray<Param.AnyFlag>, + remoteHint?: string, ): ReadonlyArray<DocsFlag> => { const docId = commandPath.join("-"); return params - .map((param) => docsFlagDoc(commandPath, param)) + .map((param) => docsFlagDoc(commandPath, param, remoteHint)) .filter((flag) => flag !== undefined) .filter((flag) => { const key = `${docId} ${flag.id}`; @@ -345,7 +350,8 @@ export function buildDocsSpec(input: DocsSpecInput): DocsSpec { const flags: Array<DocsFlag> = []; if (isLeaf) { - const declared = flagDocsFor(commandPath, internals.config.flags); + const remoteHint = remoteValueHint(command.annotations); + const declared = flagDocsFor(commandPath, internals.config.flags, remoteHint); const declaredIds = new Set(declared.map((flag) => flag.id)); const extraFlags = DOCS_EXTRA_FLAGS[docId]; if (extraFlags !== undefined) { @@ -369,7 +375,7 @@ export function buildDocsSpec(input: DocsSpecInput): DocsSpec { flags.push(experimentalOptionalFlag); } const ownIds = new Set(own.map((flag) => flag.id)); - const inherited = flagDocsFor(commandPath, inheritedParams).filter( + const inherited = flagDocsFor(commandPath, inheritedParams, remoteHint).filter( (flag) => !ownIds.has(flag.id) && !rootFlagIds.has(flag.id), ); inherited.forEach((flag) => seenFlagKeys.add(`${docId} ${flag.id}`)); @@ -423,7 +429,7 @@ export function buildDocsSpec(input: DocsSpecInput): DocsSpec { flags, }); - // A non-root command's own global flags (e.g. `seed`'s `--linked`/`--local`) behave like + // A non-root command's own global flags (e.g. `seed`'s `--remote`) behave like // persistent flags: they're surfaced on every leaf beneath it, alongside shared flags. const childInherited = [ ...inheritedParams, diff --git a/apps/cli/src/docs/docs-spec.unit.test.ts b/apps/cli/src/docs/docs-spec.unit.test.ts index 655aaa5cc2..0b8202ef28 100644 --- a/apps/cli/src/docs/docs-spec.unit.test.ts +++ b/apps/cli/src/docs/docs-spec.unit.test.ts @@ -5,11 +5,13 @@ import { Command, Flag } from "effect/cli"; import { describe, expect, it } from "vitest"; import { ExperimentalFlag } from "../command-internal/global-flags.ts"; +import { remoteFlag } from "../../tests/helpers/remote-flag-fixture.ts"; import { rootCommand } from "../cli/root.ts"; import { readDocsContent } from "./docs-spec.content.ts"; import { buildDocsSpec, + docsFlagDoc, docsOverlayPath, docsStripOverlayHeading, stringifyDocsSpec, @@ -76,6 +78,19 @@ describe("docsStripOverlayHeading", () => { }); }); +describe("docsFlagDoc", () => { + it("renders --remote with the value hint of the command's target group", () => { + const doc = docsFlagDoc(["supabase", "db", "push"], remoteFlag, "<ref|branch|url>"); + + expect(doc?.name).toBe("--remote[=<ref|branch|url>]"); + expect(doc?.id).toBe("remote"); + }); + + it("renders --remote as a bare switch where the command takes no value hint", () => { + expect(docsFlagDoc(["supabase", "db", "push"], remoteFlag)?.name).toBe("--remote"); + }); +}); + describe("buildDocsSpec", () => { it("never publishes an unlisted command subtree", () => { // Walks the tree rather than matching a `supabase-experimental*` prefix, so it also From af8edf60cfa1b3b0cad4dff049ee2008010e6327 Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Mon, 19 Oct 2026 10:07:54 +0100 Subject: [PATCH 096/291] style(cli): format the test db integration test imports --- .../src/command-internal/test-db.integration.test.ts | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) 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 080506f521..4074054c6f 100644 --- a/apps/cli/src/command-internal/test-db.integration.test.ts +++ b/apps/cli/src/command-internal/test-db.integration.test.ts @@ -1,6 +1,16 @@ import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; -import { Cause, ConfigProvider, Effect, Exit, FileSystem, Layer, Option, Path, Schema } from "effect"; +import { + Cause, + ConfigProvider, + Effect, + Exit, + FileSystem, + Layer, + Option, + Path, + Schema, +} from "effect"; import { vi } from "vitest"; vi.mock("node:net", (importOriginal) => From 8583677783e4edb538141b8f6d32ef69873a72cf Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Mon, 19 Oct 2026 10:18:00 +0100 Subject: [PATCH 097/291] chore(cli): trim the docs-spec tables header comment --- apps/cli/src/docs/docs-spec.tables.ts | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/apps/cli/src/docs/docs-spec.tables.ts b/apps/cli/src/docs/docs-spec.tables.ts index ad7cb6af2b..eb9c89bfd5 100644 --- a/apps/cli/src/docs/docs-spec.tables.ts +++ b/apps/cli/src/docs/docs-spec.tables.ts @@ -2,11 +2,9 @@ import type { DocsFlag } from "./docs-spec.ts"; import { genTypesLanguageFlagDefaults } from "../commands/gen/types/types.languages.ts"; /** - * Static data for the docs spec generator — information the Effect command tree cannot - * express structurally: docs-site section tags, `--experimental`-required leaves, deprecated - * commands to exclude from the reference (though still shown in `--help`), and flag defaults - * that `Flag.withDefault` only captures in a closure. `buildDocsSpec` validates every key here - * against the walked tree and fails the build on stale entries. + * Static data the Effect command tree cannot express structurally: docs-site section tags, + * `--experimental`-required leaves, deprecated commands, and flag defaults that + * `Flag.withDefault` only captures in a closure. `buildDocsSpec` fails the build on stale keys. */ export interface DocsInfoTag { From 70a0a39c8f81705d2b843501793da479ad588ab8 Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Mon, 19 Oct 2026 10:18:02 +0100 Subject: [PATCH 098/291] docs(cli): describe database commands without assuming a remote target Reword descriptions that say remote or linked now that commands run locally by default, and put --remote last in command examples. --- apps/cli/src/command-internal/test-db.command-handler.ts | 4 ++-- apps/cli/src/commands/backups/list/list.command.ts | 2 +- apps/cli/src/commands/config/diff/diff.command.ts | 2 +- apps/cli/src/commands/config/pull/pull.command.ts | 2 +- apps/cli/src/commands/config/push/push.command.ts | 4 ++-- apps/cli/src/commands/db/diff/diff.command.ts | 4 ++-- apps/cli/src/commands/db/dump/dump.command.ts | 6 +++--- apps/cli/src/commands/db/lint/lint.command.ts | 4 ++-- apps/cli/src/commands/db/pull/pull.command.ts | 6 +++--- apps/cli/src/commands/db/push/push.command.ts | 8 ++++---- apps/cli/src/commands/db/reset/reset.command.ts | 4 ++-- .../db/schema/declarative/generate/generate.command.ts | 4 ++-- .../cli/src/commands/domains/activate/activate.command.ts | 2 +- apps/cli/src/commands/domains/create/create.command.ts | 2 +- apps/cli/src/commands/domains/delete/delete.command.ts | 2 +- apps/cli/src/commands/domains/get/get.command.ts | 2 +- .../cli/src/commands/domains/reverify/reverify.command.ts | 2 +- apps/cli/src/commands/functions/delete/delete.command.ts | 2 +- apps/cli/src/commands/functions/deploy/deploy.command.ts | 2 +- .../src/commands/functions/download/download.command.ts | 2 +- apps/cli/src/commands/functions/list/list.command.ts | 2 +- apps/cli/src/commands/gen/types/types.command.ts | 8 ++++---- apps/cli/src/commands/inspect/db/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 +- apps/cli/src/commands/migration/squash/squash.command.ts | 2 +- apps/cli/src/commands/migration/up/up.command.ts | 6 +++--- .../src/commands/projects/api-keys/api-keys.command.ts | 2 +- apps/cli/src/commands/pull/pull.command.ts | 2 +- apps/cli/src/commands/secrets/list/list.command.ts | 2 +- apps/cli/src/commands/snippets/list/list.command.ts | 2 +- apps/cli/src/commands/sso/add/add.command.ts | 2 +- apps/cli/src/commands/sso/info/info.command.ts | 2 +- apps/cli/src/commands/sso/list/list.command.ts | 2 +- apps/cli/src/commands/sso/remove/remove.command.ts | 2 +- apps/cli/src/commands/sso/show/show.command.ts | 2 +- apps/cli/src/commands/sso/update/update.command.ts | 2 +- 38 files changed, 56 insertions(+), 56 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 4001bf6afa..d16b21a8a1 100644 --- a/apps/cli/src/command-internal/test-db.command-handler.ts +++ b/apps/cli/src/command-internal/test-db.command-handler.ts @@ -11,8 +11,8 @@ 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. */ -export const TEST_DB_DESCRIPTION = "Tests local database with pgTAP."; -export const TEST_DB_SHORT = "Tests local database with pgTAP"; +export const TEST_DB_DESCRIPTION = "Tests a database with pgTAP."; +export const TEST_DB_SHORT = "Tests a database with pgTAP"; /** * `test db`'s entire output is the streamed pg_prove TAP on stdout. A run diff --git a/apps/cli/src/commands/backups/list/list.command.ts b/apps/cli/src/commands/backups/list/list.command.ts index 067bc7f067..48df7b585c 100644 --- a/apps/cli/src/commands/backups/list/list.command.ts +++ b/apps/cli/src/commands/backups/list/list.command.ts @@ -29,7 +29,7 @@ export const backupsListCommand = Command.make("list", config).pipe( description: "List all physical backups", }, { - command: "supabase backups list --project-ref <ref>", + command: "supabase backups list --remote=<ref>", description: "List backups for a specific project", }, ]), diff --git a/apps/cli/src/commands/config/diff/diff.command.ts b/apps/cli/src/commands/config/diff/diff.command.ts index 44b99d5737..286b52b471 100644 --- a/apps/cli/src/commands/config/diff/diff.command.ts +++ b/apps/cli/src/commands/config/diff/diff.command.ts @@ -65,7 +65,7 @@ export const configDiffCommand = Command.make("diff", config).pipe( description: "Diff against the linked project", }, { - command: "supabase config diff --project-ref staging --exit-code", + command: "supabase config diff --exit-code --remote=staging", description: "Diff against the 'staging' branch, exiting 2 on drift", }, ]), diff --git a/apps/cli/src/commands/config/pull/pull.command.ts b/apps/cli/src/commands/config/pull/pull.command.ts index 3e12e53ec9..02de06cb42 100644 --- a/apps/cli/src/commands/config/pull/pull.command.ts +++ b/apps/cli/src/commands/config/pull/pull.command.ts @@ -75,7 +75,7 @@ export const configPullCommand = Command.make("pull", config).pipe( description: "Pull from the linked project into the config root", }, { - command: "supabase config pull --project-ref staging", + command: "supabase config pull --remote=staging", description: "Pull from the 'staging' branch into [remotes.staging]", }, { diff --git a/apps/cli/src/commands/config/push/push.command.ts b/apps/cli/src/commands/config/push/push.command.ts index 92b9dadf25..77f83aba1d 100644 --- a/apps/cli/src/commands/config/push/push.command.ts +++ b/apps/cli/src/commands/config/push/push.command.ts @@ -57,11 +57,11 @@ export const configPushCommand = Command.make("push", config).pipe( description: "Push local config to the linked project", }, { - command: "supabase config push --project-ref abcdefghijklmnopqrst", + command: "supabase config push --remote=abcdefghijklmnopqrst", description: "Push local config to a specific project", }, { - command: "supabase config push --project-ref staging", + command: "supabase config push --remote=staging", description: "Push local config to the 'staging' branch", }, ]), diff --git a/apps/cli/src/commands/db/diff/diff.command.ts b/apps/cli/src/commands/db/diff/diff.command.ts index cc4f545dfd..2b77babbff 100644 --- a/apps/cli/src/commands/db/diff/diff.command.ts +++ b/apps/cli/src/commands/db/diff/diff.command.ts @@ -92,9 +92,9 @@ const explicitLinkedSide = (flag: "from" | "to") => export const dbDiffCommand = Command.make("diff", config).pipe( Command.withDescription( - "Compares a shadow built from supabase/migrations with a live database (--local by default, --linked, or --db-url). Declarative files under supabase/schemas are not part of this baseline. Output is printed by default; in normal mode, -f names and saves the complete diff as a migration and does not filter objects. Explicit --from/--to output is flattened review SQL, not a portable apply script.", + "Compares a shadow built from supabase/migrations with a live database (the local database by default, or the one --remote selects). Declarative files under supabase/schemas are not part of this baseline. Output is printed by default; in normal mode, -f names and saves the complete diff as a migration and does not filter objects. Explicit --from/--to output is flattened review SQL, not a portable apply script.", ), - Command.withShortDescription("Diffs the local database for schema changes"), + Command.withShortDescription("Diffs a database for schema changes"), withPermissions( withDbTarget( compose( diff --git a/apps/cli/src/commands/db/dump/dump.command.ts b/apps/cli/src/commands/db/dump/dump.command.ts index 81d76db57d..1ce359cd44 100644 --- a/apps/cli/src/commands/db/dump/dump.command.ts +++ b/apps/cli/src/commands/db/dump/dump.command.ts @@ -82,7 +82,7 @@ const config = { name: "password", alias: "p", description: - "Database password for the linked project (--linked/--project-ref). Not used with --local or --db-url.", + "Database password for the project selected with --remote. Not used for the local database or a database URL.", }), schema: Flag.String("schema").pipe( Flag.withAlias("s"), @@ -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 target database."), - Command.withShortDescription("Dumps data or schemas from the target database"), + Command.withDescription("Dumps data or schemas from a database."), + Command.withShortDescription("Dumps data or schemas from a database"), withPermissions( withDbTarget( compose( diff --git a/apps/cli/src/commands/db/lint/lint.command.ts b/apps/cli/src/commands/db/lint/lint.command.ts index 982eaf59c5..2d7580473e 100644 --- a/apps/cli/src/commands/db/lint/lint.command.ts +++ b/apps/cli/src/commands/db/lint/lint.command.ts @@ -42,8 +42,8 @@ const config = { export type DbLintFlags = CliCommand.Command.Config.Infer<typeof config>; export const dbLintCommand = Command.make("lint", config).pipe( - Command.withDescription("Checks local database for typing error."), - Command.withShortDescription("Checks local database for typing error"), + Command.withDescription("Checks a database for typing error."), + Command.withShortDescription("Checks a database for typing error"), withPermissions( withDbTarget( compose( diff --git a/apps/cli/src/commands/db/pull/pull.command.ts b/apps/cli/src/commands/db/pull/pull.command.ts index e120f049ac..7b0c9616a9 100644 --- a/apps/cli/src/commands/db/pull/pull.command.ts +++ b/apps/cli/src/commands/db/pull/pull.command.ts @@ -68,7 +68,7 @@ const config = { name: "password", alias: "p", description: - "Database password for the linked project (--linked/--project-ref). Not used with --local or --db-url.", + "Database password for the project selected with --remote. Not used for the local database or a database URL.", }), } as const; @@ -105,9 +105,9 @@ const telemetryOptions = (flags: DbPullFlags) => ({ export const dbPullCommand = Command.make("pull", config).pipe( Command.withDescription( - "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.", + "Migration mode compares supabase/migrations with the selected live database (the local database by default, or the one --remote selects), 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"), + Command.withShortDescription("Pull schema from a database"), withPermissions( withDbTarget( compose( diff --git a/apps/cli/src/commands/db/push/push.command.ts b/apps/cli/src/commands/db/push/push.command.ts index 15d19760b3..43da731dc8 100644 --- a/apps/cli/src/commands/db/push/push.command.ts +++ b/apps/cli/src/commands/db/push/push.command.ts @@ -21,7 +21,7 @@ import { withTargetPolicy } from "../../../command-internal/target/target-policy const config = { includeAll: Flag.Boolean("include-all").pipe( - Flag.withDescription("Include all migrations not found on remote history table."), + Flag.withDescription("Include all migrations not found in the migration history table."), Flag.withDefault(false), ), includeRoles: Flag.Boolean("include-roles").pipe( @@ -47,7 +47,7 @@ const config = { name: "password", alias: "p", description: - "Database password for the linked project (--linked/--project-ref). Not used with --local or --db-url.", + "Database password for the project selected with --remote. Not used for the local database or a database URL.", }), } as const; @@ -55,9 +55,9 @@ export type DbPushFlags = CliCommand.Command.Config.Infer<typeof config>; export const dbPushCommand = Command.make("push", config).pipe( Command.withDescription( - "Push new migrations to the remote database. Vault secrets from config.toml are updated before migrations unless --skip-vault is set.", + "Push new migrations to a database. Vault secrets from config.toml are updated before migrations unless --skip-vault is set.", ), - Command.withShortDescription("Push new migrations to the remote database"), + Command.withShortDescription("Push new migrations to a database"), withPermissions( withDbTarget( compose( diff --git a/apps/cli/src/commands/db/reset/reset.command.ts b/apps/cli/src/commands/db/reset/reset.command.ts index e37fb1a059..c9fe668db2 100644 --- a/apps/cli/src/commands/db/reset/reset.command.ts +++ b/apps/cli/src/commands/db/reset/reset.command.ts @@ -45,8 +45,8 @@ const config = { export type DbResetFlags = CliCommand.Command.Config.Infer<typeof config>; export const dbResetCommand = Command.make("reset", config).pipe( - Command.withDescription("Resets the local database to current migrations."), - Command.withShortDescription("Resets the local database to current migrations"), + Command.withDescription("Resets a database to current migrations."), + Command.withShortDescription("Resets a database to current migrations"), withPermissions( withDbTarget( compose( 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 a69d04a4f0..389937219c 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 @@ -58,7 +58,7 @@ const config = { name: "password", alias: "p", description: - "Database password for the linked project (--linked/--project-ref). Not used with --local or --db-url.", + "Database password for the project selected with --remote. Not used for the local database or a database URL.", }), } as const; @@ -71,7 +71,7 @@ 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. Generates from the local database unless --linked, --project-ref, or --db-url is passed or the workspace target is remote.", + "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 --remote selects another.", ), Command.withShortDescription("Generate declarative schema from a database"), withPermissions( diff --git a/apps/cli/src/commands/domains/activate/activate.command.ts b/apps/cli/src/commands/domains/activate/activate.command.ts index 9bdc6bb2e1..dfd82e9508 100644 --- a/apps/cli/src/commands/domains/activate/activate.command.ts +++ b/apps/cli/src/commands/domains/activate/activate.command.ts @@ -39,7 +39,7 @@ export const domainsActivateCommand = Command.make("activate", config).pipe( Command.withShortDescription("Activate the custom hostname for a project"), Command.withExamples([ { - command: "supabase domains activate --project-ref abcdefghijklmnopqrst", + command: "supabase domains activate --remote=abcdefghijklmnopqrst", description: "Activate the custom hostname for a project", }, ]), diff --git a/apps/cli/src/commands/domains/create/create.command.ts b/apps/cli/src/commands/domains/create/create.command.ts index 32ada004db..9afb9635b0 100644 --- a/apps/cli/src/commands/domains/create/create.command.ts +++ b/apps/cli/src/commands/domains/create/create.command.ts @@ -43,7 +43,7 @@ export const domainsCreateCommand = Command.make("create", config).pipe( Command.withExamples([ { command: - "supabase domains create --custom-hostname example.com --project-ref abcdefghijklmnopqrst", + "supabase domains create --custom-hostname example.com --remote=abcdefghijklmnopqrst", description: "Create a custom hostname for a project", }, ]), diff --git a/apps/cli/src/commands/domains/delete/delete.command.ts b/apps/cli/src/commands/domains/delete/delete.command.ts index a0223b5d40..e51a00f77a 100644 --- a/apps/cli/src/commands/domains/delete/delete.command.ts +++ b/apps/cli/src/commands/domains/delete/delete.command.ts @@ -37,7 +37,7 @@ export const domainsDeleteCommand = Command.make("delete", config).pipe( Command.withShortDescription("Delete the custom hostname config"), Command.withExamples([ { - command: "supabase domains delete --project-ref abcdefghijklmnopqrst", + command: "supabase domains delete --remote=abcdefghijklmnopqrst", description: "Delete the custom hostname config for a project", }, ]), diff --git a/apps/cli/src/commands/domains/get/get.command.ts b/apps/cli/src/commands/domains/get/get.command.ts index 0201ad0303..82d3a13f79 100644 --- a/apps/cli/src/commands/domains/get/get.command.ts +++ b/apps/cli/src/commands/domains/get/get.command.ts @@ -39,7 +39,7 @@ export const domainsGetCommand = Command.make("get", config).pipe( Command.withShortDescription("Get the current custom hostname config"), Command.withExamples([ { - command: "supabase domains get --project-ref abcdefghijklmnopqrst", + command: "supabase domains get --remote=abcdefghijklmnopqrst", description: "Get the custom hostname config for a project", }, ]), diff --git a/apps/cli/src/commands/domains/reverify/reverify.command.ts b/apps/cli/src/commands/domains/reverify/reverify.command.ts index 060383ddf1..a7cae54ea3 100644 --- a/apps/cli/src/commands/domains/reverify/reverify.command.ts +++ b/apps/cli/src/commands/domains/reverify/reverify.command.ts @@ -37,7 +37,7 @@ export const domainsReverifyCommand = Command.make("reverify", config).pipe( Command.withShortDescription("Re-verify the custom hostname config"), Command.withExamples([ { - command: "supabase domains reverify --project-ref abcdefghijklmnopqrst", + command: "supabase domains reverify --remote=abcdefghijklmnopqrst", description: "Re-verify the custom hostname for a project", }, ]), diff --git a/apps/cli/src/commands/functions/delete/delete.command.ts b/apps/cli/src/commands/functions/delete/delete.command.ts index 54b9d4a751..030e5981e4 100644 --- a/apps/cli/src/commands/functions/delete/delete.command.ts +++ b/apps/cli/src/commands/functions/delete/delete.command.ts @@ -42,7 +42,7 @@ export const functionsDeleteCommand = Command.make("delete", config).pipe( description: "Delete a deployed function from the linked project", }, { - command: "supabase functions delete hello-world --project-ref abcdefghijklmnopqrst", + command: "supabase functions delete hello-world --remote=abcdefghijklmnopqrst", description: "Delete a deployed function from a specific project", }, ]), diff --git a/apps/cli/src/commands/functions/deploy/deploy.command.ts b/apps/cli/src/commands/functions/deploy/deploy.command.ts index 04be45da63..33c67dbf11 100644 --- a/apps/cli/src/commands/functions/deploy/deploy.command.ts +++ b/apps/cli/src/commands/functions/deploy/deploy.command.ts @@ -75,7 +75,7 @@ export const functionsDeployCommand = Command.make("deploy", config).pipe( description: "Deploy a single function to the linked project", }, { - command: "supabase functions deploy --project-ref abcdefghijklmnopqrst", + command: "supabase functions deploy --remote=abcdefghijklmnopqrst", description: "Deploy all local functions to a specific project", }, ]), diff --git a/apps/cli/src/commands/functions/download/download.command.ts b/apps/cli/src/commands/functions/download/download.command.ts index 4428ec069c..914bfb64f4 100644 --- a/apps/cli/src/commands/functions/download/download.command.ts +++ b/apps/cli/src/commands/functions/download/download.command.ts @@ -84,7 +84,7 @@ export const functionsDownloadCommand = Command.make("download", config).pipe( description: "Download a single function from the linked project", }, { - command: "supabase functions download --project-ref abcdefghijklmnopqrst", + command: "supabase functions download --remote=abcdefghijklmnopqrst", description: "Download all functions from a specific project", }, ]), diff --git a/apps/cli/src/commands/functions/list/list.command.ts b/apps/cli/src/commands/functions/list/list.command.ts index 7fdf8812ba..e7baa562e6 100644 --- a/apps/cli/src/commands/functions/list/list.command.ts +++ b/apps/cli/src/commands/functions/list/list.command.ts @@ -29,7 +29,7 @@ export const functionsListCommand = Command.make("list", config).pipe( description: "List all deployed functions in the linked project", }, { - command: "supabase functions list --project-ref abcdefghijklmnopqrst", + command: "supabase functions list --remote=abcdefghijklmnopqrst", description: "List all deployed functions in a specific project", }, ]), diff --git a/apps/cli/src/commands/gen/types/types.command.ts b/apps/cli/src/commands/gen/types/types.command.ts index 6fe53abd5a..e4a16f9301 100644 --- a/apps/cli/src/commands/gen/types/types.command.ts +++ b/apps/cli/src/commands/gen/types/types.command.ts @@ -116,19 +116,19 @@ export const genTypesCommand = Command.make("types", commandConfig).pipe( Command.withShortDescription("Generate types from Postgres schema"), Command.withExamples([ { - command: "supabase gen types --local", + command: "supabase gen types", description: "Generate types from the local dev database", }, { - command: "supabase gen types --linked --lang=go", + command: "supabase gen types --lang=go --remote", description: "Generate Go types from the linked project", }, { - command: "supabase gen types --project-ref abc-def-123 --schema public --schema private", + command: "supabase gen types --schema public --schema private --remote=abcdefghijklmnopqrst", description: "Generate types from a project ref with specific schemas", }, { - command: "supabase gen types --db-url 'postgresql://...' --schema public --schema auth", + command: "supabase gen types --schema public --schema auth --remote='postgresql://...'", description: "Generate types from a database URL", }, ]), diff --git a/apps/cli/src/commands/inspect/db/db.command.ts b/apps/cli/src/commands/inspect/db/db.command.ts index d4121f58e7..396e32f831 100644 --- a/apps/cli/src/commands/inspect/db/db.command.ts +++ b/apps/cli/src/commands/inspect/db/db.command.ts @@ -27,7 +27,7 @@ import { inspectDbVacuumStatsCommand } from "./vacuum-stats/vacuum-stats.command export const inspectDbCommand = Command.make("db").pipe( 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.", + "Tools to inspect your Supabase database. They inspect the local database by default; pass --remote for the linked project, or --remote=<ref|branch|url> for another database.", ), Command.withShortDescription("Inspect database"), Command.withSubcommands([ diff --git a/apps/cli/src/commands/inspect/report/report.command.ts b/apps/cli/src/commands/inspect/report/report.command.ts index 1af049c2eb..e3e49af1d5 100644 --- a/apps/cli/src/commands/inspect/report/report.command.ts +++ b/apps/cli/src/commands/inspect/report/report.command.ts @@ -27,7 +27,7 @@ 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. It reports on the local database by default; pass --linked for the linked project or --db-url for another database.", + "Generate a CSV output for all inspect commands. It reports on the local database by default; pass --remote for the linked project, or --remote=<ref|branch|url> for another database.", ), Command.withShortDescription("Generate a CSV output for all inspect commands"), withPermissions( diff --git a/apps/cli/src/commands/migration/list/list.command.ts b/apps/cli/src/commands/migration/list/list.command.ts index 857ecfe2e5..76cebfb5ca 100644 --- a/apps/cli/src/commands/migration/list/list.command.ts +++ b/apps/cli/src/commands/migration/list/list.command.ts @@ -25,7 +25,7 @@ const config = { name: "password", alias: "p", description: - "Database password for the linked project (--linked/--project-ref). Not used with --local or --db-url.", + "Database password for the project selected with --remote. Not used for the local database or a database URL.", }), } as const; diff --git a/apps/cli/src/commands/migration/repair/repair.command.ts b/apps/cli/src/commands/migration/repair/repair.command.ts index 3244ba5000..444a10ab58 100644 --- a/apps/cli/src/commands/migration/repair/repair.command.ts +++ b/apps/cli/src/commands/migration/repair/repair.command.ts @@ -31,7 +31,7 @@ const config = { name: "password", alias: "p", description: - "Database password for the linked project (--linked/--project-ref). Not used with --local or --db-url.", + "Database password for the project selected with --remote. Not used for the local database or a database URL.", }), } as const; diff --git a/apps/cli/src/commands/migration/squash/squash.command.ts b/apps/cli/src/commands/migration/squash/squash.command.ts index 4f76c720d9..775410a7c2 100644 --- a/apps/cli/src/commands/migration/squash/squash.command.ts +++ b/apps/cli/src/commands/migration/squash/squash.command.ts @@ -29,7 +29,7 @@ const config = { name: "password", alias: "p", description: - "Database password for the linked project (--linked/--project-ref). Not used with --local or --db-url.", + "Database password for the project selected with --remote. Not used for the local database or a database URL.", }), } as const; diff --git a/apps/cli/src/commands/migration/up/up.command.ts b/apps/cli/src/commands/migration/up/up.command.ts index e38491ae79..606b8e4a4c 100644 --- a/apps/cli/src/commands/migration/up/up.command.ts +++ b/apps/cli/src/commands/migration/up/up.command.ts @@ -20,7 +20,7 @@ import { withTargetPolicy } from "../../../command-internal/target/target-policy const config = { includeAll: Flag.Boolean("include-all").pipe( - Flag.withDescription("Include all migrations not found on remote history table."), + Flag.withDescription("Include all migrations not found in the migration history table."), Flag.withDefault(false), ), ...targetFlags({ group: "selectable", subject: "database", dbUrl: true }), @@ -29,8 +29,8 @@ const config = { export type MigrationUpFlags = CliCommand.Command.Config.Infer<typeof config>; export const migrationUpCommand = Command.make("up", config).pipe( - Command.withDescription("Apply pending migrations to local database."), - Command.withShortDescription("Apply pending migrations to local database"), + Command.withDescription("Apply pending migrations to a database."), + Command.withShortDescription("Apply pending migrations to a database"), withPermissions( withDbTarget( compose( 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 38c23e5789..b7ed4d809e 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 @@ -28,7 +28,7 @@ export const projectsApiKeysCommand = Command.make("api-keys", config).pipe( Command.withShortDescription("List API keys"), Command.withExamples([ { - command: "supabase projects api-keys --project-ref abcdefghijklmnopqrst", + command: "supabase projects api-keys --remote=abcdefghijklmnopqrst", description: "List all API keys for a project", }, { diff --git a/apps/cli/src/commands/pull/pull.command.ts b/apps/cli/src/commands/pull/pull.command.ts index 38d2942dba..31fbbba757 100644 --- a/apps/cli/src/commands/pull/pull.command.ts +++ b/apps/cli/src/commands/pull/pull.command.ts @@ -91,7 +91,7 @@ export const pullCommand = Command.make("pull", config).pipe( description: "Pull config, schema, and functions from the linked project", }, { - command: "supabase pull --project-ref staging", + command: "supabase pull --remote=staging", description: "Pull from the 'staging' branch of the linked project", }, { diff --git a/apps/cli/src/commands/secrets/list/list.command.ts b/apps/cli/src/commands/secrets/list/list.command.ts index 0b33e261ec..72d07b0208 100644 --- a/apps/cli/src/commands/secrets/list/list.command.ts +++ b/apps/cli/src/commands/secrets/list/list.command.ts @@ -29,7 +29,7 @@ export const secretsListCommand = Command.make("list", config).pipe( description: "List all secrets for the linked project", }, { - command: "supabase secrets list --project-ref abcdefghijklmnopqrst", + command: "supabase secrets list --remote=abcdefghijklmnopqrst", description: "List secrets for a specific project", }, ]), diff --git a/apps/cli/src/commands/snippets/list/list.command.ts b/apps/cli/src/commands/snippets/list/list.command.ts index 9de66e6973..6c6b34e953 100644 --- a/apps/cli/src/commands/snippets/list/list.command.ts +++ b/apps/cli/src/commands/snippets/list/list.command.ts @@ -28,7 +28,7 @@ export const snippetsListCommand = Command.make("list", config).pipe( description: "List all SQL snippets", }, { - command: "supabase snippets list --project-ref <ref>", + command: "supabase snippets list --remote=<ref>", description: "List snippets for a specific project", }, ]), diff --git a/apps/cli/src/commands/sso/add/add.command.ts b/apps/cli/src/commands/sso/add/add.command.ts index cc35fa74e0..a6f662c467 100644 --- a/apps/cli/src/commands/sso/add/add.command.ts +++ b/apps/cli/src/commands/sso/add/add.command.ts @@ -72,7 +72,7 @@ export const ssoAddCommand = Command.make("add", config).pipe( Command.withExamples([ { command: - "supabase sso add --type saml --project-ref mwjylndxudmiehsxhmmz --metadata-url 'https://...' --domains example.com", + "supabase sso add --type saml --metadata-url 'https://...' --domains example.com --remote=mwjylndxudmiehsxhmmz", description: "Add a new SAML SSO provider", }, ]), diff --git a/apps/cli/src/commands/sso/info/info.command.ts b/apps/cli/src/commands/sso/info/info.command.ts index b80c62bf21..66f585994d 100644 --- a/apps/cli/src/commands/sso/info/info.command.ts +++ b/apps/cli/src/commands/sso/info/info.command.ts @@ -26,7 +26,7 @@ export const ssoInfoCommand = Command.make("info", config).pipe( Command.withShortDescription("Returns the SAML SSO settings required for the identity provider"), Command.withExamples([ { - command: "supabase sso info --project-ref mwjylndxudmiehsxhmmz", + command: "supabase sso info --remote=mwjylndxudmiehsxhmmz", description: "Get SSO info for a project", }, ]), diff --git a/apps/cli/src/commands/sso/list/list.command.ts b/apps/cli/src/commands/sso/list/list.command.ts index 75190c02b1..9546e927c7 100644 --- a/apps/cli/src/commands/sso/list/list.command.ts +++ b/apps/cli/src/commands/sso/list/list.command.ts @@ -25,7 +25,7 @@ export const ssoListCommand = Command.make("list", config).pipe( Command.withShortDescription("List all SSO identity providers"), Command.withExamples([ { - command: "supabase sso list --project-ref mwjylndxudmiehsxhmmz", + command: "supabase sso list --remote=mwjylndxudmiehsxhmmz", description: "List all SSO providers for a project", }, ]), diff --git a/apps/cli/src/commands/sso/remove/remove.command.ts b/apps/cli/src/commands/sso/remove/remove.command.ts index bf12c250d5..358e41f22c 100644 --- a/apps/cli/src/commands/sso/remove/remove.command.ts +++ b/apps/cli/src/commands/sso/remove/remove.command.ts @@ -31,7 +31,7 @@ export const ssoRemoveCommand = Command.make("remove", config).pipe( Command.withExamples([ { command: - "supabase sso remove b5ae62f9-ef1d-4f11-a02b-731c8bbb11e8 --project-ref mwjylndxudmiehsxhmmz", + "supabase sso remove b5ae62f9-ef1d-4f11-a02b-731c8bbb11e8 --remote=mwjylndxudmiehsxhmmz", description: "Remove an SSO provider by ID", }, ]), diff --git a/apps/cli/src/commands/sso/show/show.command.ts b/apps/cli/src/commands/sso/show/show.command.ts index 7b21dd6e83..b782268257 100644 --- a/apps/cli/src/commands/sso/show/show.command.ts +++ b/apps/cli/src/commands/sso/show/show.command.ts @@ -34,7 +34,7 @@ export const ssoShowCommand = Command.make("show", config).pipe( Command.withExamples([ { command: - "supabase sso show b5ae62f9-ef1d-4f11-a02b-731c8bbb11e8 --project-ref mwjylndxudmiehsxhmmz", + "supabase sso show b5ae62f9-ef1d-4f11-a02b-731c8bbb11e8 --remote=mwjylndxudmiehsxhmmz", description: "Show SSO provider details", }, ]), diff --git a/apps/cli/src/commands/sso/update/update.command.ts b/apps/cli/src/commands/sso/update/update.command.ts index bbed3ae18b..2365e582a6 100644 --- a/apps/cli/src/commands/sso/update/update.command.ts +++ b/apps/cli/src/commands/sso/update/update.command.ts @@ -85,7 +85,7 @@ export const ssoUpdateCommand = Command.make("update", config).pipe( Command.withExamples([ { command: - "supabase sso update b5ae62f9-ef1d-4f11-a02b-731c8bbb11e8 --project-ref mwjylndxudmiehsxhmmz --add-domains example.com", + "supabase sso update b5ae62f9-ef1d-4f11-a02b-731c8bbb11e8 --add-domains example.com --remote=mwjylndxudmiehsxhmmz", description: "Update an SSO provider's domains", }, ]), From b314ab45a91d4382e6208439635e42330ce5b621 Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Mon, 19 Oct 2026 10:18:03 +0100 Subject: [PATCH 099/291] docs(cli): document --remote in the command reference Examples and overlays use --remote last, mention the deprecated selector flags once per page, and drop the target config key and SUPABASE_TARGET. --- README.md | 4 +- apps/cli/docs/analytics.md | 4 +- apps/cli/docs/command-target.md | 8 ++- apps/cli/docs/stack-commands.md | 35 ++++++----- apps/cli/docs/supabase/config/diff.md | 2 +- apps/cli/docs/supabase/config/pull.md | 4 +- apps/cli/docs/supabase/config/push.md | 2 +- apps/cli/docs/supabase/db/advisors.md | 4 +- apps/cli/docs/supabase/db/diff.md | 8 ++- apps/cli/docs/supabase/db/dump.md | 6 +- apps/cli/docs/supabase/db/lint.md | 6 +- apps/cli/docs/supabase/db/pull.md | 18 +++--- apps/cli/docs/supabase/db/push.md | 8 ++- apps/cli/docs/supabase/db/query.md | 10 ++-- apps/cli/docs/supabase/db/reset.md | 10 ++-- .../db/schema-declarative-generate.md | 4 +- .../supabase/db/schema-declarative-sync.md | 2 +- apps/cli/docs/supabase/gen.md | 4 +- apps/cli/docs/supabase/migration/list.md | 6 +- apps/cli/docs/supabase/migration/repair.md | 16 ++--- apps/cli/docs/supabase/migration/squash.md | 4 +- apps/cli/docs/supabase/pull.md | 6 +- apps/cli/docs/supabase/test/db.md | 8 ++- apps/cli/docs/templates/examples.yaml | 58 +++++++++---------- 24 files changed, 133 insertions(+), 104 deletions(-) diff --git a/README.md b/README.md index c5ea9ae1bd..6f73c589c3 100644 --- a/README.md +++ b/README.md @@ -101,8 +101,8 @@ supabase functions deploy hello-world Generate TypeScript types from your local database or linked project: ```sh -supabase gen types --local -supabase gen types --linked +supabase gen types +supabase gen types --remote ``` ## Reference diff --git a/apps/cli/docs/analytics.md b/apps/cli/docs/analytics.md index 6f994749b4..3d8bbdd5fb 100644 --- a/apps/cli/docs/analytics.md +++ b/apps/cli/docs/analytics.md @@ -96,11 +96,11 @@ Local-runtime commands also carry properties that split error rates by runtime: - `stack_backend` (`legacy` or `stack`) on commands routed by `experimental.stack`; see [backend selection](stack-commands.md) - `stack_runtime` (`docker`, `podman`, or `native`): the runtime of the stack a stack-backend - command looked up, which for `--db-url` may be the project stack even when the URL targets + command looked up, which for `--remote=<url>` may be the project stack even when the URL targets another database - `orioledb` (boolean) once the command resolves local database config, `true` when the project selects an OrioleDB image (`db.orioledb_version` on a 15/17 project). It describes local config - only; for linked or `--db-url` targets it says nothing about the remote database + only; for `--remote` targets it says nothing about the remote database Flag capture is intentionally conservative: `flags` records only flags changed on the command line, and redacts each value unless it is boolean, a choice flag, or listed in the command's diff --git a/apps/cli/docs/command-target.md b/apps/cli/docs/command-target.md index cf0e395b4a..a6d14d9ec5 100644 --- a/apps/cli/docs/command-target.md +++ b/apps/cli/docs/command-target.md @@ -2,6 +2,8 @@ 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. +Database commands run against the local stack unless `--remote` is passed: bare `--remote` is the linked project, and `--remote=<ref|branch|url>` is another project, a branch of the linked project, or a database URL. Hosted-only commands, which have no local side, use the linked project by default and take `--remote=<ref|branch>` for another. `--linked`, `--project-ref`, `--db-url` and `--local` still work in v3 but are deprecated and removed in v4; use `--remote`. + ## Banner `Target: <text>` on stderr in every output format, printed once per distinct text. @@ -9,14 +11,14 @@ Every command that acts on a database or hosted project announces the target it | 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 | +| Local stack, nothing selected, linked | `local stack (<host>:<port>) · default; pass --remote for <name> (<ref>)` | +| `--remote=<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. +The `default; pass --remote` hint appears only when the checkout is linked and no `--remote` was passed. "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` diff --git a/apps/cli/docs/stack-commands.md b/apps/cli/docs/stack-commands.md index 48b065dc6e..0fec78edc5 100644 --- a/apps/cli/docs/stack-commands.md +++ b/apps/cli/docs/stack-commands.md @@ -190,12 +190,12 @@ project config and omits the Docker-era default ports so the stack is not pinned Without the environment variable, `init` still writes the established template with those ports and without the stack flag. Blank `supabase bootstrap` uses the same scaffold. -When the flag is on, `--local` targets of the `db`, `migration`, `test db`, `gen types`, and +When the flag is on, local targets of the `db`, `migration`, `test db`, `gen types`, and `inspect` families use the project stack and provision a throwaway shadow database owned by the stack runtime. The shadow runs the project's resolved database version, the OrioleDB build when `db.orioledb_version` is set, and its baseline cache is keyed by that version. Top-level -`supabase pull` uses the same stack shadow as `db pull`. Linked and `--db-url` targets stay on the Management API for engine selection. -A `--db-url` that matches `config.toml` host and port is still rewritten like a published +`supabase pull` uses the same stack shadow as `db pull`. Targets selected with `--remote` stay on the Management API for engine selection. +A `--remote=<url>` database URL that matches `config.toml` host and port is still rewritten like a published stack target for dump's tool container. Compose names (`supabase_db_*`, `supabase_network_*`, `db:5432`) are not used. The stack backend requires the in-process pg-delta engine; `--use-migra`, `--use-pgadmin`, `--use-pg-schema`, and `db pull --diff-engine migra` are @@ -222,34 +222,33 @@ and restores default JWT verification on the same Functions instance. Per-functi `--output-format stream-json`, readiness emits a result containing the instance ID and URL, followed by live log events. -`storage ls`/`cp`/`mv`/`rm` and `seed buckets` (including bucket seeding inside `db reset ---local`) also consult `experimental.stack`, with the same `SUPABASE_EXPERIMENTAL_STACK` +`storage ls`/`cp`/`mv`/`rm` and `seed buckets` (including bucket seeding inside `db reset`) also consult `experimental.stack`, with the same `SUPABASE_EXPERIMENTAL_STACK` env-precedence rule as `start`/`stop`/`status`. See -[Storage and bucket seeding](#storage-and-bucket-seeding) below. Explicit `--linked`/ -`--project-ref` remote targeting for these commands is unaffected by the flag either way. +[Storage and bucket seeding](#storage-and-bucket-seeding) below. Explicit `--remote` +targeting for these commands is unaffected by the flag either way. `db start` brings up a postgres-only project stack on first create. An existing stack resumes its primary database without changing other services (webhooks setup only, creating a missing `supabase_functions` schema; no second overlay or migrate-and-seed). `supabase start` while that postgres-only stack is running stops it and starts the full configured stack, keeping data. `--from-backup` is not supported on the stack path. -`db reset --local` and declarative resets rebuild the existing database while retaining stack +A local `db reset` and declarative resets rebuild the existing database while retaining stack identity, ports, and composition. See [`db/reset/SIDE_EFFECTS.md`](../src/commands/db/reset/SIDE_EFFECTS.md). -`gen types --local` and `inspect db … --local` resolve the project stack through the same -`--local` database target as `db dump`. They do not start a stack. +A local `gen types` and `inspect db …` resolve the project stack through the same +local database target as `db dump`. They do not start a stack. -On the stack backend, `db dump --local`, `migration squash`, and `test db --local` run +On the stack backend, a local `db dump`, `migration squash`, and a local `test db` run catalog tools attached to their owning stack. Tools use runtime-facing database addresses; `test db` preserves the user, password, and database selected by the CLI resolver. -External `--linked` and `--db-url` tools use CLI-owned adapters without creating a stack. +External `--remote` tools use CLI-owned adapters without creating a stack. Supported native platforms run the bundled executable with `artifact/bin` on PATH. Other platforms use a one-shot container from the catalog Postgres image. Neither path falls back to host-installed PostgreSQL tools. Missing tools fail closed; container execution requires Docker. -`db lint --local` talks to the running stack over SQL. Catalog Postgres includes +A local `db lint` talks to the running stack over SQL. Catalog Postgres includes `plpgsql_check`, so native and container stacks can `CREATE EXTENSION` inside the lint transaction (always rolled back). It does not launch a client binary. @@ -336,12 +335,12 @@ to be destroyed does not stop the rest, though the command still fails. The `experimental.stack` flag (with the usual `SUPABASE_EXPERIMENTAL_STACK=1|0` env precedence) routes several command families at once: the top-level `start`/`stop`/`status` -aliases, the `stack` namespace itself, `db`/`migration`/`test`'s `--local` targets (above), and +aliases, the `stack` namespace itself, `db`/`migration`/`test`'s local targets (above), and now `storage ls`/`cp`/`mv`/`rm` and `seed buckets` (including the bucket seeding step inside `db -reset --local`). An explicit `--linked`/`--project-ref` remote target for `storage`/`seed` is +reset`). An explicit `--remote` target for `storage`/`seed` is unaffected by the flag either way. -Under the stack backend, `--local` Storage operations resolve their endpoint and credential from +Under the stack backend, local Storage operations resolve their endpoint and credential from the selected composition rather than `[api]`/`[api.tls]`: the endpoint is the Storage member's observed HTTP binding plus `/storage/v1/...`, and the service-role JWT is generated from the primary database member's observed JWT secret. The stack is located by the project root @@ -369,7 +368,7 @@ status`/`supabase stack restart`, or `supabase start` when the stack has never b Missing Storage HTTP endpoints fail with `StackStorageCapabilityError`, as do other Storage capability failures. A stack-gateway 502/503 encountered while Storage activates is reported as `StackStorageCapabilityError` guiding the user to `supabase stack logs` then `supabase stack -restart`, not as a raw status body — but only for a `--local` target; a `--linked` failure passes +restart`, not as a raw status body — but only for a local target; a `--remote` failure passes through unchanged. All of these failures exit `1` for `storage`/`seed buckets`, and no HTTP request is sent when Storage is disabled or the stack is not running. @@ -392,6 +391,6 @@ a missing capability/endpoint, a gateway activation failure, or an invalid bucke `start` command; startup does not silently skip the configured seed. See [`stack/start/SIDE_EFFECTS.md`](../src/commands/experimental/stack/start/SIDE_EFFECTS.md). -**`db reset --local` and declarative reset** reapply the catalog, migrations, and seeds before +**A local `db reset` and declarative reset** reapply the catalog, migrations, and seeds before resuming the saved composition. Bucket-seeding failures warn after the database reset succeeds. See [`db/reset/SIDE_EFFECTS.md`](../src/commands/db/reset/SIDE_EFFECTS.md). diff --git a/apps/cli/docs/supabase/config/diff.md b/apps/cli/docs/supabase/config/diff.md index 503d6849dd..6f299df3ef 100644 --- a/apps/cli/docs/supabase/config/diff.md +++ b/apps/cli/docs/supabase/config/diff.md @@ -2,7 +2,7 @@ Shows the configuration differences between the local `supabase/config.toml` and the effective configuration of a remote project or branch. Read-only: it never modifies the local file or any remote configuration. -Pass `--project-ref` to compare against 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. When the target ref matches a `[remotes.*]` block's `project_id`, that block's merged config is the local side of the comparison. +Pass `--remote=<ref|branch>` to compare against 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. `--project-ref` still works in v3 but is deprecated and removed in v4; use `--remote`. When the target ref matches a `[remotes.*]` block's `project_id`, that block's merged config is the local side of the comparison. Only platform-managed properties are compared. Local-stack-only sections — `[studio]`, `[analytics]`, `[functions]`, `[edge_runtime]`, port numbers, image version pins, `[db.migrations]`, and similar — have no hosted counterpart and are never reported, whether or not your file declares them. diff --git a/apps/cli/docs/supabase/config/pull.md b/apps/cli/docs/supabase/config/pull.md index 7cfcfdcd72..1874e6aa6c 100644 --- a/apps/cli/docs/supabase/config/pull.md +++ b/apps/cli/docs/supabase/config/pull.md @@ -2,7 +2,7 @@ Writes the effective configuration of a remote project or branch into the local `supabase/config.toml`/`config.json` — the write side of `supabase config diff`. Every write is a surgical, format-preserving edit: comments, key ordering, and quoting elsewhere in the file are left untouched, and only the values that actually change are rewritten. -Pass `--project-ref` to pull from 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. +Pass `--remote=<ref|branch>` to pull from 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. `--project-ref` still works in v3 but is deprecated and removed in v4; use `--remote`. Where a pulled value lands depends on whether the target is already tracked by a `[remotes.*]` block, not on how you named it: if any block's `project_id` already matches the resolved ref, that block is reused — whatever its own label — and every value lands there. Otherwise, if the target was named as a branch, a new `[remotes.<branch-name>]` block is created (a branch resolved by UUID falls back to the project ref itself as its label). Only when neither applies — a bare `--project-ref` naming a project directly, or the linked project with no branch involved — do values land at the config root. `--remote-label` overrides the block config pull would otherwise reuse or create; naming a block that already tracks a different project, or naming nothing while a different block already tracks this exact ref, is an error rather than a silent overwrite. A `[remotes.*]` block whose `project_id` is an `env(...)` reference that merely _resolves_ to the target ref is never reused or rewritten — that is a hard error naming the variable, since a value written there would never actually take effect on the next load. Creating a new block always writes its `project_id`, even when every value it would otherwise carry already matches the local defaults — otherwise the block could never be reused on a later pull. @@ -28,7 +28,7 @@ enabled = true max_rows = 1000 ``` -`supabase config pull --project-ref staging`, where `staging` is a branch of the linked project and no existing `[remotes.*]` block already tracks it, creates a new block at the end of the file — `max_rows` lands under its own `[remotes.staging.api]` sub-table, the same way `[api]` itself is a sub-table of the document root, not a key directly under `[remotes.staging]`: +`supabase config pull --remote=staging`, where `staging` is a branch of the linked project and no existing `[remotes.*]` block already tracks it, creates a new block at the end of the file — `max_rows` lands under its own `[remotes.staging.api]` sub-table, the same way `[api]` itself is a sub-table of the document root, not a key directly under `[remotes.staging]`: ```toml project_id = "abcdefghijklmnopqrst" diff --git a/apps/cli/docs/supabase/config/push.md b/apps/cli/docs/supabase/config/push.md index 8c94c34399..19225b033b 100644 --- a/apps/cli/docs/supabase/config/push.md +++ b/apps/cli/docs/supabase/config/push.md @@ -4,7 +4,7 @@ Updates the configurations of a linked Supabase project with the local `supabase This command allows you to manage project configuration as code by defining settings locally and then pushing them to your remote project. -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. +Pass `--remote=<ref|branch>` 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. `--project-ref` still works in v3 but is deprecated and removed in v4; use `--remote`. 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. diff --git a/apps/cli/docs/supabase/db/advisors.md b/apps/cli/docs/supabase/db/advisors.md index aa9efb02f9..1c2e295433 100644 --- a/apps/cli/docs/supabase/db/advisors.md +++ b/apps/cli/docs/supabase/db/advisors.md @@ -2,4 +2,6 @@ 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. +Inspects the local database by default. Pass `--remote` for the linked project, or `--remote=<ref|branch|url>` for another project, a branch of the linked project, or a database URL. The command prints a `Target:` line on stderr naming the database it resolved, and never asks for confirmation. + +`--linked`, `--project-ref`, `--db-url` and `--local` still work in v3 but are deprecated and removed in v4; use `--remote`. diff --git a/apps/cli/docs/supabase/db/diff.md b/apps/cli/docs/supabase/db/diff.md index 3090bfc92d..86ff32ba0d 100644 --- a/apps/cli/docs/supabase/db/diff.md +++ b/apps/cli/docs/supabase/db/diff.md @@ -1,12 +1,14 @@ ## supabase-db-diff -Diffs schema changes made to the local or remote database. +Diffs schema changes made to a 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. 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. +Diffs the local database by default, which requires the local development stack to be running. Pass `--remote` to diff the linked project, or `--remote=<ref|branch|url>` to diff another project, a branch of the linked project, or a database URL such as a self-hosted database. The command prints a `Target:` line on stderr naming the database it resolved, and never asks for confirmation. + +`--linked`, `--project-ref`, `--db-url` and `--local` still work in v3 but are deprecated and removed in v4; use `--remote`. 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. -Explicit `--from`/`--to` mode always uses pg-delta. In this mode, `-f` is ignored and stdout (or `--output`) is a flattened representation for review, not a portable apply script. Do not apply it directly with plain `psql -f`: transactional units can contain `SET LOCAL` preambles that only take effect inside a transaction, while plans that mix transactional and non-transactional units cannot safely be wrapped in one transaction. To create an applicable migration, use normal target mode with `supabase db diff -f <name>`, then apply it through `supabase db reset` locally or `supabase db push` against the linked project. These paths preserve the plan's per-unit transaction semantics. +Explicit `--from`/`--to` mode always uses pg-delta. In this mode, `-f` is ignored and stdout (or `--output`) is a flattened representation for review, not a portable apply script. Do not apply it directly with plain `psql -f`: transactional units can contain `SET LOCAL` preambles that only take effect inside a transaction, while plans that mix transactional and non-transactional units cannot safely be wrapped in one transaction. To create an applicable migration, use normal target mode with `supabase db diff -f <name>`, then apply it through `supabase db reset` locally or `supabase db push --remote` against the linked project. These paths preserve the plan's per-unit transaction semantics. By default, all schemas in the target database are diffed. Use the `--schema public,extensions` flag to restrict diffing to a subset of schemas. diff --git a/apps/cli/docs/supabase/db/dump.md b/apps/cli/docs/supabase/db/dump.md index 71a04af1a2..d3b7e5de3a 100644 --- a/apps/cli/docs/supabase/db/dump.md +++ b/apps/cli/docs/supabase/db/dump.md @@ -1,8 +1,10 @@ ## supabase-db-dump -Dumps contents from the target database. +Dumps contents from a database. -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. +Dumps the local database by default. Pass `--remote` to dump the linked project, which requires your local project to be linked by running `supabase link`. Pass `--remote=<ref|branch|url>` to dump another project, a branch of the linked project, or a database URL such as a self-hosted database. The command prints a `Target:` line on stderr naming the database it resolved, and never asks for confirmation. + +`--linked`, `--project-ref`, `--db-url` and `--local` still work in v3 but are deprecated and removed in v4; use `--remote`. 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 16201908b2..8bf079513a 100644 --- a/apps/cli/docs/supabase/db/lint.md +++ b/apps/cli/docs/supabase/db/lint.md @@ -1,8 +1,10 @@ ## supabase-db-lint -Lints local database for schema errors. +Lints a 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. 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. +Lints the local database by default, which requires the local development stack to be running. Pass `--remote` to lint the linked project, or `--remote=<ref|branch|url>` to lint another project, a branch of the linked project, or a database URL such as a self-hosted database. The command prints a `Target:` line on stderr naming the database it resolved, and never asks for confirmation. + +`--linked`, `--project-ref`, `--db-url` and `--local` still work in v3 but are deprecated and removed in v4; use `--remote`. 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/pull.md b/apps/cli/docs/supabase/db/pull.md index bbad95ea48..1e8ab966f5 100644 --- a/apps/cli/docs/supabase/db/pull.md +++ b/apps/cli/docs/supabase/db/pull.md @@ -1,14 +1,16 @@ # supabase-db-pull -Pulls schema changes from a remote database. A new migration file will be created under `supabase/migrations` directory. +Pulls schema changes from a database. A new migration file will be created under `supabase/migrations` directory. -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. +Pulls from the local database by default. Pass `--remote` to pull from the linked project, which requires your local project to be linked by running `supabase link`. Pass `--remote=<ref|branch|url>` to pull from another project, a branch of the linked project, or a database URL such as a self-hosted database. In a linked checkout, a run without `--remote` prints a hint naming the linked project. 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. +`--linked`, `--project-ref`, `--db-url` and `--local` still work in v3 but are deprecated and removed in v4; use `--remote`. -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. +> Note this command requires Docker Desktop (or a running Docker daemon), as it starts a local Postgres container to diff the schema you pull. -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`. +After the migration file is written, a new row is inserted into the migration history table to reflect the current state of the 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 schemas you have created. Otherwise, this command will only diff schema changes against the target database, similar to running `db diff --remote`. Pass `--declarative` to switch to the declarative pg-delta export workflow instead of writing a migration file. The tree lives in `supabase/schemas` by default. Change the location with `experimental.pgdelta.declarative_schema_path`. @@ -25,14 +27,14 @@ pg-delta is the default diff engine: the migration-file `db pull` workflow uses Under the pg-delta engine, the pull captures your own customizations in Supabase-managed schemas automatically. Examples are triggers on managed tables like `auth.users` and RLS policies on `storage.objects` and `storage.buckets`. Under the legacy migra engine, the initial pull's `pg_dump` skips managed schemas. The diff pass that follows appends your trigger and RLS policy changes there to the same migration. If an older CLI reports that `auth` and `storage` were excluded, capture existing customizations there with `--schema auth,storage`. -When pulling from a remote database with `--db-url`, prefer a direct connection (`db.<project-ref>.supabase.co:5432`) over the connection pooler so pg-delta can introspect the full catalog reliably. +When pulling with `--remote=<url>`, prefer a direct connection (`db.<project-ref>.supabase.co:5432`) over the connection pooler so pg-delta can introspect the full catalog reliably. ## Debugging empty pg-delta pulls -If `db pull` reports `No schema changes found` but you expect schema output, set `PGDELTA_DEBUG=1` before running the command. Unlike `--debug`, this keeps SSL enabled for remote Supabase connections. +If `db pull` reports `No schema changes found` but you expect schema output, set `PGDELTA_DEBUG=1` before running the command. Unlike `--debug`, this keeps SSL enabled for hosted Supabase connections. ```sh -PGDELTA_DEBUG=1 supabase db pull --db-url "$DATABASE_URL" +PGDELTA_DEBUG=1 supabase db pull --remote="$DATABASE_URL" ``` When pg-delta returns zero statements, the CLI writes a debug bundle under `supabase/.temp/pgdelta/v2/debug/<timestamp>-diff/` containing the source/desired catalog snapshots, the plan, and coverage diagnostics. Catalog files are not written during normal `db pull` runs. diff --git a/apps/cli/docs/supabase/db/push.md b/apps/cli/docs/supabase/db/push.md index 19526da48e..d4362f8164 100644 --- a/apps/cli/docs/supabase/db/push.md +++ b/apps/cli/docs/supabase/db/push.md @@ -1,10 +1,12 @@ ## supabase-db-push -Pushes all local migrations to a remote database. +Pushes all local migrations to a database. -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. +Pushes to the local database by default. Pass `--remote` to push to the linked project, which requires your local project to be linked by running `supabase link`. Pass `--remote=<ref|branch|url>` to push to another project, a branch of the linked project, or a database URL such as a self-hosted database. In a linked checkout, a run without `--remote` prints a hint naming the linked project. The command prints a `Target:` line on stderr naming the database it resolved. -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. +`--linked`, `--project-ref`, `--db-url` and `--local` still work in v3 but are deprecated and removed in v4; use `--remote`. + +Before changing a hosted 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/docs/supabase/db/query.md b/apps/cli/docs/supabase/db/query.md index a008b5e254..0005e69b66 100644 --- a/apps/cli/docs/supabase/db/query.md +++ b/apps/cli/docs/supabase/db/query.md @@ -1,14 +1,16 @@ ## supabase-db-query -Execute a SQL query against the local or linked database. +Execute a SQL query against a 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. +Runs against the local database by default. Pass `--remote` for the linked project, or `--remote=<ref|branch|url>` for another project, a branch of the linked project, or a database URL. 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. +`--linked`, `--project-ref`, `--db-url` and `--local` still work in v3 but are deprecated and removed in v4; use `--remote`. + +The command cannot tell a read from a write, so a hosted database (the linked project or a remote database 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`, `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. +The local database or a database URL 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. diff --git a/apps/cli/docs/supabase/db/reset.md b/apps/cli/docs/supabase/db/reset.md index 58033d5c98..c29c432478 100644 --- a/apps/cli/docs/supabase/db/reset.md +++ b/apps/cli/docs/supabase/db/reset.md @@ -1,6 +1,6 @@ ## supabase-db-reset -Resets the local database to a clean state. +Resets a database to a clean state. Requires the local development stack to be started by running `supabase start`. @@ -8,10 +8,12 @@ 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. +Resets the local database by default. Pass `--remote` to reset the linked project, or `--remote=<ref|branch|url>` to reset another project, a branch of the linked project, or a database URL. 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. +`--linked`, `--project-ref`, `--db-url` and `--local` still work in v3 but are deprecated and removed in v4; use `--remote`. + +When running db reset with `--remote`, 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. +If you combine `--sql-paths` with `--remote`, 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/db/schema-declarative-generate.md b/apps/cli/docs/supabase/db/schema-declarative-generate.md index 4438444124..94c099a4cb 100644 --- a/apps/cli/docs/supabase/db/schema-declarative-generate.md +++ b/apps/cli/docs/supabase/db/schema-declarative-generate.md @@ -4,10 +4,12 @@ Generate declarative schema files from a database. 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 database is the local one unless you pass `--remote` for the linked project, or `--remote=<ref|branch|url>` for another project, a branch of the linked project, or a database URL. 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. Emitted SQL uses the same default format as `db pull` (uppercase keywords, indent 2, width 180, column-aligned). Override with `[experimental.pgdelta] format_options`, or set `format_options = "null"` for raw statements. Enabled by default. When `[experimental.pgdelta] enabled = false` is set in config, pass the `--experimental` flag to run it. + +`--linked`, `--project-ref`, `--db-url` and `--local` still work in v3 but are deprecated and removed in v4; use `--remote`. diff --git a/apps/cli/docs/supabase/db/schema-declarative-sync.md b/apps/cli/docs/supabase/db/schema-declarative-sync.md index ff65de4c31..dc1865fd84 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, 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. +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 --remote` or `generate --remote=<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/docs/supabase/gen.md b/apps/cli/docs/supabase/gen.md index aff58b4e60..0b1ab2ae90 100644 --- a/apps/cli/docs/supabase/gen.md +++ b/apps/cli/docs/supabase/gen.md @@ -2,8 +2,10 @@ Automatically generates type definitions based on your Postgres database schema. -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. +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 `--remote` for the linked project, or `--remote=<ref|branch|url>` for another project, a branch of the linked project, or a database URL. 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. The types respect relationships, constraints, and custom types defined in your database schema. + +`--linked`, `--project-ref`, `--db-url` and `--local` still work in v3 but are deprecated and removed in v4; use `--remote`. `--project-id` is also deprecated; use `--remote=<ref>`. diff --git a/apps/cli/docs/supabase/migration/list.md b/apps/cli/docs/supabase/migration/list.md index 1562b35864..39c8de538a 100644 --- a/apps/cli/docs/supabase/migration/list.md +++ b/apps/cli/docs/supabase/migration/list.md @@ -2,10 +2,12 @@ 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. +By default, it lists the migration history of your local database. Pass `--remote` to list the linked project instead, which requires running `supabase link`. Pass `--remote=<ref|branch|url>` to list another project, a branch of the linked project, or a database URL such as a self-hosted database. + +`--linked`, `--project-ref`, `--db-url` and `--local` still work in v3 but are deprecated and removed in v4; use `--remote`. > 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 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. +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 project or database URL passed with `--remote`, 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/docs/supabase/migration/repair.md b/apps/cli/docs/supabase/migration/repair.md index 0b00d8649b..95fa0296e2 100644 --- a/apps/cli/docs/supabase/migration/repair.md +++ b/apps/cli/docs/supabase/migration/repair.md @@ -2,14 +2,16 @@ Repairs the migration history table. -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. +By default, it repairs your local database. Pass `--remote` to repair the linked project, which requires running `supabase link`. Pass `--remote=<ref|branch|url>` to repair another project, a branch of the linked project, or 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. + +`--linked`, `--project-ref`, `--db-url` and `--local` still work in v3 but are deprecated and removed in v4; use `--remote`. 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. For example, your migration history may look like the table below, with missing entries in either local or remote. ```bash -$ supabase migration list --linked +$ supabase migration list --remote Target: project My Project (abcdefghijklmnopqrst) · production LOCAL │ REMOTE │ TIME (UTC) ─────────────────┼────────────────┼────────────────────── @@ -22,7 +24,7 @@ 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 --linked +$ supabase migration list --remote Target: project My Project (abcdefghijklmnopqrst) · production LOCAL │ REMOTE │ TIME (UTC) ─────────────────┼────────────────┼────────────────────── @@ -32,7 +34,7 @@ Target: project My Project (abcdefghijklmnopqrst) · production Then mark the remote migration `20230103054303` as reverted. ```bash -$ supabase migration repair 20230103054303 --status reverted --linked +$ supabase migration repair 20230103054303 --status reverted --remote Target: project My Project (abcdefghijklmnopqrst) · production • 20230103054303 ◇ Mark 1 migration as reverted on project My Project (abcdefghijklmnopqrst) · production? @@ -40,7 +42,7 @@ Target: project My Project (abcdefghijklmnopqrst) · production Repaired migration history: [20230103054303] => reverted Finished supabase migration repair. -$ supabase migration list --linked +$ supabase migration list --remote Target: project My Project (abcdefghijklmnopqrst) · production LOCAL │ REMOTE │ TIME (UTC) ─────────────────┼────────────────┼────────────────────── @@ -49,7 +51,7 @@ Target: project My Project (abcdefghijklmnopqrst) · production Now you can run `db pull` again to dump the remote schema as a local migration file. ```bash -$ supabase db pull +$ supabase db pull --remote Target: project My Project (abcdefghijklmnopqrst) · production Write 1 migration file to supabase/migrations. Record them as applied in the remote migration history. @@ -58,7 +60,7 @@ Record them as applied in the remote migration history. Schema written to supabase/migrations/20240414044403_remote_schema.sql Finished supabase db pull. -$ supabase migration list --linked +$ supabase migration list --remote Target: project My Project (abcdefghijklmnopqrst) · production LOCAL │ REMOTE │ TIME (UTC) ─────────────────┼────────────────┼────────────────────── diff --git a/apps/cli/docs/supabase/migration/squash.md b/apps/cli/docs/supabase/migration/squash.md index ff0643b986..542038fa5e 100644 --- a/apps/cli/docs/supabase/migration/squash.md +++ b/apps/cli/docs/supabase/migration/squash.md @@ -10,4 +10,6 @@ By default, the latest `<timestamp>_<name>.sql` file will be updated to contain 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. +Squashing rewrites your local migration files. When the target is a hosted database (`--remote` or `--remote=<ref|branch|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 --remote` for the remote history. + +`--linked`, `--project-ref`, `--db-url` and `--local` still work in v3 but are deprecated and removed in v4; use `--remote`. diff --git a/apps/cli/docs/supabase/pull.md b/apps/cli/docs/supabase/pull.md index edaedd474c..c49a4814d3 100644 --- a/apps/cli/docs/supabase/pull.md +++ b/apps/cli/docs/supabase/pull.md @@ -2,7 +2,7 @@ Refreshes local project state from a linked Supabase project or branch in one step, instead of running `config pull`, `db pull`, `migration fetch`, and `functions download` individually. It runs four steps, always in this order: pull config into `supabase/config.toml`, optionally fetch the remote migration history table into `supabase/migrations`, pull the database schema into `supabase/migrations` (also updating that database's own migration history table), and download every Edge Function's source into `supabase/functions`. The order is fixed rather than parallel: the database step depends on `db.major_version` in the config file the first step may have just changed, and on the local migration history the second step may have just populated. -Pass `--project-ref` to target 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. `--remote-label` overrides the `[remotes.*]` block the config step would otherwise reuse or create for that target, identically to `config pull`'s own flag of the same name (see `supabase config pull`'s docs for the reuse/creation rules it follows). +Pass `--remote=<ref|branch>` to target 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. `--project-ref` still works in v3 but is deprecated and removed in v4; use `--remote`. `--remote-label` overrides the `[remotes.*]` block the config step would otherwise reuse or create for that target, identically to `config pull`'s own flag of the same name (see `supabase config pull`'s docs for the reuse/creation rules it follows). 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. @@ -27,7 +27,7 @@ supabase pull Pulls config, database schema, and functions from the linked project, fetching migration history first if `supabase/migrations` is empty. ```sh -supabase pull --project-ref staging +supabase pull --remote=staging ``` Pulls from the `staging` branch of the linked project. @@ -44,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. `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. +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 `--remote` 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/docs/supabase/test/db.md b/apps/cli/docs/supabase/test/db.md index c79b87ac57..dd8eb06c08 100644 --- a/apps/cli/docs/supabase/test/db.md +++ b/apps/cli/docs/supabase/test/db.md @@ -1,12 +1,14 @@ # supabase-test-db -Executes pgTAP tests against the local database. +Executes pgTAP tests against a 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. +Runs against the local database by default. Pass `--remote` for the linked project, or `--remote=<ref|branch|url>` for another project, a branch of the linked project, or a database URL. 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. +`--linked`, `--project-ref`, `--db-url` and `--local` still work in v3 but are deprecated and removed in v4; use `--remote`. + +Running the tests enables the pgTAP extension and drops it again afterwards, so a hosted database (the linked project or a remote database 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. diff --git a/apps/cli/docs/templates/examples.yaml b/apps/cli/docs/templates/examples.yaml index df7d53c366..e8fa528a8c 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 --linked + code: supabase migration list --remote response: |2 LOCAL │ REMOTE │ TIME (UTC) ─────────────────┼────────────────┼────────────────────── @@ -120,7 +120,7 @@ supabase-migration-list: 20230222032233 │ │ 2023-02-22 03:22:33 - id: with-db-url name: Connect to self-hosted database - code: supabase migration list --db-url 'postgres://postgres[:percent_encoded_password]@127.0.0.1[:port]/postgres' + code: supabase migration list --remote='postgres://postgres[:percent_encoded_password]@127.0.0.1[:port]/postgres' response: |2 LOCAL │ REMOTE │ TIME (UTC) ─────────────────┼────────────────┼────────────────────── @@ -140,14 +140,14 @@ supabase-migration-new: supabase-migration-repair: - id: basic-usage name: Mark a migration as reverted - code: supabase migration repair 20230103054303 --status reverted --linked --yes + code: supabase migration repair 20230103054303 --status reverted --yes --remote response: | 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 --linked --yes + code: supabase migration repair 20230222032233 --status applied --yes --remote response: | Target: project My Project (abcdefghijklmnopqrst) · production Confirmed by --yes: Mark 1 migration as applied on project My Project (abcdefghijklmnopqrst) · production @@ -166,7 +166,7 @@ supabase-db-diff: No schema changes found - id: linked-project name: Against linked project - code: supabase db diff -f my_table --linked + code: supabase db diff -f my_table --remote response: | Target: project My Project (abcdefghijklmnopqrst) · production Creating shadow database... @@ -195,21 +195,21 @@ supabase-db-dump: Dumped schema to supabase/schema.sql. - id: linked-project name: Dump the linked project - code: supabase db dump -f supabase/schema.sql --linked + code: supabase db dump -f supabase/schema.sql --remote 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 --linked + code: supabase db dump -f supabase/roles.sql --role-only --remote 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 --linked + code: supabase db dump -f supabase/seed.sql --data-only --remote response: | Target: project My Project (abcdefghijklmnopqrst) · production Dumping data from remote database... @@ -242,7 +242,7 @@ supabase-db-lint: supabase-db-pull: - id: basic-usage name: Basic usage - code: supabase db pull + code: supabase db pull --remote response: | Target: project My Project (abcdefghijklmnopqrst) · production ◇ Update the remote migration history table on project My Project (abcdefghijklmnopqrst) · production? @@ -252,7 +252,7 @@ supabase-db-pull: Finished supabase db pull. - id: local-studio name: Local studio - code: supabase db pull --local + code: supabase db pull response: | Target: local stack (127.0.0.1:54322) Creating shadow database... @@ -263,7 +263,7 @@ supabase-db-pull: The remote database is already in sync with your local migrations — nothing to pull. - id: custom-schemas name: Custom schemas - code: supabase db pull --schema public,extensions + code: supabase db pull --schema public,extensions --remote response: | Target: project My Project (abcdefghijklmnopqrst) · production Creating shadow database... @@ -275,18 +275,18 @@ supabase-db-pull: supabase-db-push: - id: basic-usage name: Basic usage - code: supabase db push + code: supabase db push --remote response: | Linked project is up to date. - id: self-hosted name: Self hosted - code: supabase db push --db-url "postgres://user:pass@127.0.0.1:5432/postgres" + code: supabase db push --remote="postgres://user:pass@127.0.0.1:5432/postgres" response: | Pushing migration 20230410135622_create_employees_table.sql... Finished supabase db push. - id: dry-run name: Dry run - code: supabase db push --dry-run + code: supabase db push --dry-run --remote response: | DRY RUN: migrations will *not* be pushed to the database. Would push migration 20230410135622_create_employees_table.sql... @@ -345,15 +345,15 @@ supabase-sso-show: name: Show information code: |- supabase sso show 6df4d73f-bf21-405f-a084-b11adf19fea5 \ - --project-ref abcdefghijklmnopqrst + --remote=abcdefghijklmnopqrst response: |- Information about the identity provider in pretty output. - id: metadata-output name: Get raw SAML 2.0 Metadata XML code: |- supabase sso show 6df4d73f-bf21-405f-a084-b11adf19fea5 \ - --project-ref abcdefghijklmnopqrst \ - --metadata + --metadata \ + --remote=abcdefghijklmnopqrst response: |- Raw SAML 2.0 XML assigned to this identity provider. This is the version used in the authentication project, and if using a SAML 2.0 @@ -364,24 +364,24 @@ supabase-sso-update: name: Replace domains code: |- supabase sso update 6df4d73f-bf21-405f-a084-b11adf19fea5 \ - --project-ref abcdefghijklmnopqrst \ - --domains new-company.com,new-company.net + --domains new-company.com,new-company.net \ + --remote=abcdefghijklmnopqrst response: |- Information about the updated provider. - id: add-domains name: Add an additional domain code: |- supabase sso update 6df4d73f-bf21-405f-a084-b11adf19fea5 \ - --project-ref abcdefghijklmnopqrst \ - --add-domains company.net + --add-domains company.net \ + --remote=abcdefghijklmnopqrst response: |- Information about the updated provider. - id: remove-domains name: Remove a domain code: |- supabase sso update 6df4d73f-bf21-405f-a084-b11adf19fea5 \ - --project-ref abcdefghijklmnopqrst \ - --remove-domains company.org + --remove-domains company.org \ + --remote=abcdefghijklmnopqrst response: |- Information about the updated provider. supabase-sso-remove: @@ -389,7 +389,7 @@ supabase-sso-remove: name: Remove a provider code: |- supabase sso remove 6df4d73f-bf21-405f-a084-b11adf19fea5 \ - --project-ref abcdefghijklmnopqrst + --remote=abcdefghijklmnopqrst response: |- Information about the removed identity provider. It's a good idea to save this in case you need it later on. @@ -398,10 +398,10 @@ supabase-sso-add: name: Add with Metadata URL code: |- supabase sso add \ - --project-ref abcdefgijklmnopqrst \ --type saml \ --metadata-url 'https://...' \ - --domains company.com + --domains company.com \ + --remote=abcdefghijklmnopqrst response: |- Information about the added identity provider. You can use company.com as the domain name on the frontend side to initiate a SSO @@ -410,10 +410,10 @@ supabase-sso-add: name: Add with Metadata File code: |- supabase sso add \ - --project-ref abcdefgijklmnopqrst \ --type saml \ --metadata-file /path/to/metadata/file.xml \ - --domains company.com + --domains company.com \ + --remote=abcdefghijklmnopqrst response: |- Information about the added identity provider. You can use company.com as the domain name on the frontend side to initiate a SSO @@ -421,5 +421,5 @@ supabase-sso-add: supabase-sso-info: - id: basic-usage name: Show project information - code: supabase sso info --project-ref abcdefghijklmnopqrst + code: supabase sso info --remote=abcdefghijklmnopqrst response: Information about your project's SAML 2.0 configuration. From 4ba88db5539da6db7497f626e5707e8995568278 Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Mon, 19 Oct 2026 10:18:04 +0100 Subject: [PATCH 100/291] docs(cli): describe the --remote design in ADR 0033 --- docs/adr/0032-config-value-precedence.md | 9 +- ...t-selection-and-hosted-mutation-consent.md | 171 ++++++++++++------ 2 files changed, 114 insertions(+), 66 deletions(-) diff --git a/docs/adr/0032-config-value-precedence.md b/docs/adr/0032-config-value-precedence.md index d3c99b031d..e40e2dab8f 100644 --- a/docs/adr/0032-config-value-precedence.md +++ b/docs/adr/0032-config-value-precedence.md @@ -57,7 +57,7 @@ variables, `.env` files, `[remotes.*]` blocks and `config.toml` combine. They di `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`. + `db reset` against a remote target 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 @@ -72,9 +72,6 @@ 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. @@ -184,8 +181,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. +- [ADR 0033](0033-target-selection-and-hosted-mutation-consent.md): target selection with `--remote`, + 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 index 634fe19c23..bc1b76ba0d 100644 --- a/docs/adr/0033-target-selection-and-hosted-mutation-consent.md +++ b/docs/adr/0033-target-selection-and-hosted-mutation-consent.md @@ -15,6 +15,8 @@ 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` selected a project on one command and errored on the next, `--db-url` was missing + from some, and database commands took no branch names. - 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 @@ -37,58 +39,80 @@ truth for the default target and for the consent default. - `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`, - `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. + the other platform resource commands. They have no local side, so they act on the linked project + by default and take `--remote=<ref|branch>` for another project. +- `selectable`: `db push`, `db pull`, `migration fetch`, `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 run locally unless `--remote` is passed. This group + absorbs the former `sync` group (`db push`, `db pull`, `migration fetch`), which defaulted to the + linked project. - `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. +`db push`, `db pull`, `migration fetch`, `db dump`, `migration list`, `migration repair`, +`inspect`, `gen types` and `storage ls|cp|mv|rm` move from the linked project to local. +`db schema declarative generate` stops asking which target to use. + +### One flag: `--remote` + +`--remote` selects the target on every command that can act on a hosted project. It is bare or +takes a value, and there is no workspace-level switch: the `target` config key and +`SUPABASE_TARGET` do not exist, so the intent is on the command line and nowhere else. + +| You pass | Target | +| ------------------- | ------------------------------------------------------------------------ | +| nothing | The local stack on `selectable` commands; the linked project on `hosted` | +| `--remote` | The linked project, production or a branch | +| `--remote=<ref>` | That project: 20 lowercase letters, never looked up as a branch | +| `--remote=<url>` | That database: a `postgres://` or `postgresql://` URL | +| `--remote=<branch>` | A branch of the linked project, by name or UUID | + +The value rule is the one `supabase link` uses, through its resolver; a value that matches no branch +fails with link's error, which lists the branches. A URL applies only where the remote side is +Postgres: `storage`, `seed buckets` and `hosted` commands need a project and reject one. `hosted` +commands accept `--remote=<ref|branch>`, and bare `--remote` changes nothing for them. The linked +project comes from `SUPABASE_PROJECT_ID`, then the link file. `SUPABASE_PROJECT_ID` alone never +makes a `selectable` command remote; it is only a ref source once `--remote` is passed. An +unresolvable remote target fails. The CLI never falls back between local and remote. + +`--remote=<value>` always parses. `--remote <value>` is rewritten to `--remote=<value>` before +parsing when the command takes no positional, when the token is a ref or URL, or when the command +already has more bare tokens than positionals. The one ambiguous shape, a bare `--remote` directly +followed by a token the command took as a positional, is checked against the linked project's +branches before anything runs: a match fails with a hint to use `--remote=<branch>`, and no match +runs the command. + +### Deprecated aliases + +`--linked`, `--project-ref`, `--db-url` and `gen types --project-id` stay through v3 as hidden +aliases. Each goes through the same resolver as the `--remote` form it maps to, prints a warning +that names that form, and says v4 removes it. `--local` is still accepted and does nothing, because +local is already the default; it warns the same way. v4 removes all of them, and `db diff`'s +`linked` value for `--from` and `--to`, which `remote` replaces. ### Selector rules -One parser (`parseTargetSelector`) backs every `sync` and `selectable` command: +One parser (`parseTargetSelector`) backs every `selectable` command and maps the deprecated aliases +onto `--remote`: -- `--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. +- `--remote` and the aliases are mutually exclusive. They are rejected together, never resolved by + precedence. +- `--no-remote` and `--remote=false` fail with "omit `--remote` to target the local database". + `--remote yes`, `--remote 1`, `--remote true` and `--remote on` mean bare `--remote`. +- An empty `--remote=` value is rejected. +- `--password` is rejected unless the target is a project, meaning bare `--remote`, a ref or a + branch. ### One project-ref chain -`ProjectRefResolver.resolveRef` is the only chain: `--project-ref`, then `SUPABASE_PROJECT_ID`, then +`ProjectRefResolver.resolveRef` is the only chain: `--remote=<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 @@ -97,7 +121,7 @@ credentials. ```text Target: local stack (<host>:<port>) -Target: local stack (<host>:<port>) · default; pass --linked for <name> (<ref>) +Target: local stack (<host>:<port>) · default; pass --remote for <name> (<ref>) Target: custom Postgres (<host>:<port>) Target: project <name> (<ref>) · production Target: project <parent ref> · branch <name> (<ref>) @@ -105,6 +129,10 @@ Target: project <ref> Target: project <ref> (not the linked project) ``` +On `hosted` commands the banner is the notice that the command is talking to a remote project, and +it names the linked project they default to. A bare `db push`, `db pull` or `migration fetch` in a +linked checkout runs locally and prints the `default; pass --remote` hint. + 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 @@ -122,13 +150,25 @@ JSON results carry the same information as `target`: 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 <url>`) prints both and reports the +command that announces two targets (`db diff --from remote --to <url>`) prints both and reports the first in JSON. +### Help and docs + +Help states each command's target from the same policy declaration, so labels cannot drift from +behaviour. A group's help labels each subcommand `[local or remote]` (`selectable`), `[local only]` +or `[remote only]` (`hosted`); account commands carry no label. A command's own help has a `TARGET` +section above `USAGE`, and its `remote` flag row reads `--remote[=<ref|branch|url>]` on `selectable` +commands and `--remote[=<ref|branch>]` on `hosted` ones. JSON help carries the same information as +`target`. Docs, examples, help and the deprecation warnings put `--remote` last, in the `=` form +when it has a value (`db pull add_profiles --remote`, `db push --remote=<ref>`), so it cannot be +read as anything else, and shell completion never offers a space-separated value for `--remote` on +a command that has a positional. + ### 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 +Every persistent mutation of a hosted resource in the `account`, `hosted` and `selectable` groups +needs consent. This includes writes through a non-local `--remote=<url>`: a loopback host on the local or shadow database ports, or the local stack's endpoint, counts as local; anything else is hosted. @@ -164,8 +204,8 @@ hosted. `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.** A leaf that never asks declares `consent: "none"`. A `sync` or `selectable` leaf + or database URL target. It skips consent. +- **Exemptions.** A leaf that never asks declares `consent: "none"`. A `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`, @@ -186,9 +226,11 @@ hosted. ## 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"`. +The default now follows what a command can target, not who wrote the handler. A checkout reaches a +hosted database through a `selectable` command only when the invocation says `--remote`. The intent +is visible in the command line, so there is no per-checkout switch to forget or to inherit, and one +flag means the same thing on every command. Keeping the old flags as aliases through v3 gives +scripts, CI and agents the whole v3 line to move. 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 @@ -207,9 +249,12 @@ to No makes the Enter key safe. ### 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. +- Breaking changes in v3: the flipped defaults, including bare `db push`, `db pull` and + `migration fetch`, which most people run meaning the linked project, and the new `--yes` + requirement for unattended hosted writes. A bare run in a linked checkout prints a hint, and v2 + warns ahead of time. +- Anyone who followed the v2 warning and added `--linked` gets a second warning in v3 asking for + `--remote`. v4 removes the aliases. - `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. @@ -232,15 +277,15 @@ to No makes the Enter key safe. - `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 `selectable` leaf lacks `--remote`; - 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 `sync` or `selectable` leaf has `consent: "none"` and no exemption reason; + - a `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; + - `--remote`, `--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. @@ -254,21 +299,27 @@ to No makes the Enter key safe. 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 +2. **Default every `selectable` command to linked**: it reaches a hosted database from an + unconfigured checkout. +3. **A workspace `target = "local" | "remote"` key**: it covers remote-first workflows, but the same + command line then reaches different places in different checkouts, and every example needs a + caveat. `--remote` states the intent where it applies. +4. **Keep `--linked`, `--db-url` and `--project-ref` as the flags**: three flags behave differently + per command and none takes a branch name. `--remote` replaces them with one rule. +5. **Remove the old flags in v3**: it breaks working scripts in the same release that flips the + defaults. They warn through v3 and go in v4. +6. **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 +7. **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 +8. **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. +- [ADR 0032](0032-config-value-precedence.md): config value precedence. This ADR supersedes its + seed consent section. ## See Also From 1e0fa2bb7f3c246c85e5dcf760d97e1178f2f01e Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Mon, 19 Oct 2026 11:14:00 +0100 Subject: [PATCH 101/291] refactor(config): remove the workspace target key and SUPABASE_TARGET --- .../matched-remote-with-project-id-env.json | 3 +- .../matched-remote-with-shell-env.json | 3 +- .../matched-remote.json | 3 +- .../local-container-inputs/no-overrides.json | 3 +- .../project-dotenv.json | 3 +- .../local-container-inputs/shell-env.json | 3 +- .../stack-config/no-overrides.json | 3 +- .../stack-config/project-dotenv.json | 3 +- .../stack-config/remote-block-ignored.json | 3 +- ...ock-merged-via-context-with-shell-env.json | 3 +- .../remote-block-merged-via-context.json | 3 +- .../stack-config/shell-beats-dotenv.json | 3 +- .../stack-config/shell-env.json | 3 +- apps/docs/public/cli/config.schema.json | 6 -- packages/config/docs/cli-config-loading.md | 16 ---- packages/config/src/base.ts | 12 --- packages/config/src/io.ts | 27 ------ packages/config/src/io.unit.test.ts | 92 ------------------- 18 files changed, 13 insertions(+), 179 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 40f0945c4a..a49452a620 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,8 +525,7 @@ "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 653407f9f2..9c45da7b15 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,8 +525,7 @@ "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 9c44bf7c2f..a0698bac71 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,8 +525,7 @@ "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 696752da26..c35d4d806b 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,8 +525,7 @@ "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 274198ecb9..713f623162 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,8 +532,7 @@ "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 50eccadf1d..a6ab8caf6f 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,8 +525,7 @@ "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 4da32d2256..14e5c5dc26 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,7 +707,6 @@ "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 15ec88281a..034299bb78 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,7 +676,6 @@ "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 4354055636..ca7de557e0 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,7 +1121,6 @@ "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 91b6e9789b..405b3322ba 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,7 +668,6 @@ "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 7c5f677b02..788f1a6061 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,7 +692,6 @@ "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 1c691d0814..b2fdf27b39 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,7 +674,6 @@ "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 60435ac8ca..7e20a8e533 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,7 +668,6 @@ "api_url": "http://127.0.0.1", "enabled": false, "port": 55323 - }, - "target": "local" + } } } diff --git a/apps/docs/public/cli/config.schema.json b/apps/docs/public/cli/config.schema.json index 46ec72a46a..b0132becc6 100644 --- a/apps/docs/public/cli/config.schema.json +++ b/apps/docs/public/cli/config.schema.json @@ -2515,12 +2515,6 @@ }, "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/docs/cli-config-loading.md b/packages/config/docs/cli-config-loading.md index 568fbc3340..5c1159054a 100644 --- a/packages/config/docs/cli-config-loading.md +++ b/packages/config/docs/cli-config-loading.md @@ -375,22 +375,6 @@ 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: diff --git a/packages/config/src/base.ts b/packages/config/src/base.ts index 82e51c1d29..a13a079fd8 100644 --- a/packages/config/src/base.ts +++ b/packages/config/src/base.ts @@ -8,7 +8,6 @@ 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"; @@ -76,19 +75,8 @@ 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 65c6ac2748..a601a34410 100644 --- a/packages/config/src/io.ts +++ b/packages/config/src/io.ts @@ -143,31 +143,6 @@ 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}$/; @@ -371,7 +346,6 @@ 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, @@ -414,7 +388,6 @@ 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 4950c55d69..010d90fa69 100644 --- a/packages/config/src/io.unit.test.ts +++ b/packages/config/src/io.unit.test.ts @@ -238,18 +238,6 @@ 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({ @@ -1638,86 +1626,6 @@ 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<unknown> { - 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 4a94f1f661af4ef25a7146536f9dc08415276133 Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Mon, 19 Oct 2026 11:14:01 +0100 Subject: [PATCH 102/291] feat(cli): select remote targets with a single --remote flag --- .../command-permissions/model.ts | 8 +- .../src/command-internal/connect-errors.ts | 11 +- apps/cli/src/command-internal/db-pull-run.ts | 2 +- .../src/command-internal/migration-history.ts | 13 +- .../target/branch-resolution.errors.ts | 78 ++++++++ .../target/branch-resolution.ts | 176 +++++++++++++++++ .../target/remote-resolution.ts | 93 +++++++++ .../command-internal/target/remote-value.ts | 42 +++++ .../target/resolve-project-target.ts | 55 ++++++ .../command-internal/target/resolve-target.ts | 110 ++++++----- .../target/target-deprecation.ts | 132 +++++++++++++ .../command-internal/target/target-flags.ts | 119 ++++++++---- .../target/target-identity.ts | 4 +- .../target/target-password.ts | 2 +- .../command-internal/target/target-policy.ts | 8 +- .../target/target-selector.ts | 177 ++++++++++++++---- .../command-internal/target/target.errors.ts | 31 ++- .../test-db.command-handler.ts | 6 +- .../src/command-internal/test-db.handler.ts | 1 + .../commands/bootstrap/bootstrap.handler.ts | 2 +- .../commands/db/advisors/advisors.command.ts | 3 +- .../commands/db/advisors/advisors.handler.ts | 1 + apps/cli/src/commands/db/diff/diff.command.ts | 15 +- apps/cli/src/commands/db/diff/diff.errors.ts | 12 -- .../cli/src/commands/db/diff/diff.explicit.ts | 41 ++-- apps/cli/src/commands/db/diff/diff.handler.ts | 128 ++++++++----- apps/cli/src/commands/db/dump/dump.command.ts | 3 +- apps/cli/src/commands/db/dump/dump.handler.ts | 1 + apps/cli/src/commands/db/lint/lint.command.ts | 3 +- apps/cli/src/commands/db/lint/lint.handler.ts | 1 + apps/cli/src/commands/db/pull/pull.command.ts | 7 +- apps/cli/src/commands/db/push/push.command.ts | 7 +- apps/cli/src/commands/db/push/push.handler.ts | 1 + .../src/commands/db/query/query.command.ts | 3 +- .../src/commands/db/query/query.handler.ts | 1 + .../src/commands/db/reset/reset.command.ts | 3 +- .../src/commands/db/reset/reset.handler.ts | 1 + .../declarative/generate/generate.command.ts | 3 +- .../commands/functions/deploy/SIDE_EFFECTS.md | 17 +- .../functions/deploy/deploy.command.ts | 21 ++- .../functions/deploy/deploy.handler.ts | 11 +- .../src/commands/gen/types/types.command.ts | 6 +- .../src/commands/gen/types/types.handler.ts | 35 ++-- .../commands/inspect/db/inspect-db-command.ts | 7 +- .../src/commands/inspect/db/inspect-query.ts | 2 + .../table-index-sizes.command.ts | 2 +- .../total-index-size.command.ts | 2 +- .../total-table-sizes.command.ts | 2 +- .../commands/inspect/report/report.command.ts | 3 +- apps/cli/src/commands/link/link.errors.ts | 71 ------- apps/cli/src/commands/link/link.handler.ts | 142 ++------------ .../commands/migration/down/down.command.ts | 3 +- .../commands/migration/fetch/fetch.command.ts | 7 +- .../commands/migration/list/list.command.ts | 3 +- .../migration/repair/repair.command.ts | 4 +- .../migration/repair/repair.handler.ts | 2 + .../migration/squash/squash.command.ts | 3 +- .../src/commands/migration/up/up.command.ts | 3 +- apps/cli/src/commands/pull/pull.aggregate.ts | 4 +- .../commands/seed/buckets/buckets.command.ts | 8 +- .../commands/seed/buckets/buckets.handler.ts | 4 +- apps/cli/src/commands/seed/seed.command.ts | 10 +- .../cli/src/commands/storage/cp/cp.command.ts | 1 + .../cli/src/commands/storage/ls/ls.command.ts | 1 + .../cli/src/commands/storage/mv/mv.command.ts | 1 + .../cli/src/commands/storage/rm/rm.command.ts | 2 + .../cli/src/commands/storage/rm/rm.handler.ts | 2 + .../cli/src/commands/storage/storage.flags.ts | 6 +- .../cli/src/commands/storage/storage.frame.ts | 4 +- .../src/shared/cli/invalid-value-message.ts | 18 +- apps/cli/src/shared/cli/remote-argv.ts | 109 +++++++++++ apps/cli/src/shared/cli/run.ts | 27 ++- apps/cli/src/shared/functions/deploy.ts | 7 +- .../telemetry/__fixtures__/error-tags.txt | 3 +- apps/cli/tests/helpers/permission-registry.ts | 23 ++- 75 files changed, 1350 insertions(+), 530 deletions(-) create mode 100644 apps/cli/src/command-internal/target/branch-resolution.errors.ts create mode 100644 apps/cli/src/command-internal/target/branch-resolution.ts create mode 100644 apps/cli/src/command-internal/target/remote-resolution.ts create mode 100644 apps/cli/src/command-internal/target/remote-value.ts create mode 100644 apps/cli/src/command-internal/target/resolve-project-target.ts create mode 100644 apps/cli/src/command-internal/target/target-deprecation.ts create mode 100644 apps/cli/src/shared/cli/remote-argv.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..2dadf7200c 100644 --- a/apps/cli/src/command-internal/command-permissions/model.ts +++ b/apps/cli/src/command-internal/command-permissions/model.ts @@ -50,15 +50,15 @@ interface ValueCondition extends FlagConditionBase { type FlagCondition = PresenceCondition | SwitchCondition | ValueCondition; /** - * The target a command talks to: `--local`, `--db-url`, or the linked project. `seed buckets` uses - * it for its Storage target, which `--linked`/`--local` select the same way. + * The target a command talks to: the local stack, a database URL, or a hosted project. `seed + * buckets` uses it for its Storage target, which `--remote`/`--local` select the same way. */ export type DbTarget = "local" | "db-url" | "linked"; /** * The run's database target must be one of `dbTarget` for an entry to apply. The target is - * `--db-url` if given, else `--local`, else `--linked`, else the default set with - * {@link withDbTarget}. As with {@link FlagCondition}, `source` marks a building block's condition. + * `--db-url` (or `--remote=<url>`) if given, else `--local`, else `--linked`, `--project-ref` or + * any other `--remote`, else the default set with {@link withDbTarget}. As with {@link FlagCondition}, `source` marks a building block's condition. */ interface DbTargetCondition { readonly dbTarget: ReadonlyArray<DbTarget>; diff --git a/apps/cli/src/command-internal/connect-errors.ts b/apps/cli/src/command-internal/connect-errors.ts index 0d63a45af2..73646823c9 100644 --- a/apps/cli/src/command-internal/connect-errors.ts +++ b/apps/cli/src/command-internal/connect-errors.ts @@ -12,13 +12,12 @@ import { aqua } from "./colors.ts"; /** * Command-agnostic hint shown when a direct connection fails because the host is IPv6-only, - * pointing users at the IPv4 transaction pooler via `--db-url`. Reproduces the established - * message text exactly, including the aqua-coloured `--db-url`. + * pointing users at the IPv4 transaction pooler via `--remote=<db-url>`. */ export function ipv6Suggestion(): string { return ( "Your network does not support IPv6, which is required for direct connections to the database.\n" + - `Retry with your project's IPv4 transaction pooler connection string via ${aqua("--db-url")}.\n` + + `Retry with your project's IPv4 transaction pooler connection string via ${aqua("--remote=<db-url>")}.\n` + "You can copy it from the dashboard under Connect > Transaction pooler." ); } @@ -64,9 +63,9 @@ 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."; +export const SUGGEST_LINKED_PROJECT = "To use the linked project instead, pass --remote."; -/** The local-stack hint, plus the `--linked` pointer when the checkout is linked. */ +/** The local-stack hint, plus the `--remote` pointer when the checkout is linked. */ export function localStackSuggestion(linkedCheckout: boolean | undefined): string { return linkedCheckout === true ? `${SUGGEST_LOCAL_STACK}\n${SUGGEST_LINKED_PROJECT}` @@ -88,7 +87,7 @@ 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`. */ + /** Whether the checkout is linked to a hosted project, so a local failure can point at `--remote`. */ readonly linkedCheckout?: boolean; } diff --git a/apps/cli/src/command-internal/db-pull-run.ts b/apps/cli/src/command-internal/db-pull-run.ts index d19327a994..a8dbb4ed9b 100644 --- a/apps/cli/src/command-internal/db-pull-run.ts +++ b/apps/cli/src/command-internal/db-pull-run.ts @@ -76,7 +76,7 @@ import { updateMigrationHistory } from "../commands/db/pull/pull.sync.ts"; */ export type DbPullRunFlags = Omit< DbPullCommandFlags, - "usePgDelta" | "dbUrl" | "linked" | "local" | "projectRef" + "usePgDelta" | "remote" | "dbUrl" | "linked" | "local" | "projectRef" >; /** diff --git a/apps/cli/src/command-internal/migration-history.ts b/apps/cli/src/command-internal/migration-history.ts index 8b4d55c1ce..6d9ec9bdd1 100644 --- a/apps/cli/src/command-internal/migration-history.ts +++ b/apps/cli/src/command-internal/migration-history.ts @@ -225,7 +225,10 @@ export function reconcileMigrations( return { kind: "in-sync" }; } -/** `targetFlags` are the flags that select the resolved target (see `flagsForTarget`). */ +const withTargetFlags = (command: string, targetFlags: string): string => + targetFlags === "" ? command : `${command} ${targetFlags}`; + +/** `targetFlags` are the flags that select the resolved target (see `flagsForTarget`), appended last. */ export function suggestMigrationRepair( extraRemote: ReadonlyArray<string>, extraLocal: ReadonlyArray<string>, @@ -234,10 +237,10 @@ export function suggestMigrationRepair( 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 ${targetFlags} --status reverted ${version}`)}\n`; + result += `${bold(withTargetFlags(`supabase migration repair --status reverted ${version}`, targetFlags))}\n`; } for (const version of extraLocal) { - result += `${bold(`supabase migration repair ${targetFlags} --status applied ${version}`)}\n`; + result += `${bold(withTargetFlags(`supabase migration repair --status applied ${version}`, targetFlags))}\n`; } return result; } @@ -246,9 +249,9 @@ export function suggestMigrationRepair( 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 ${targetFlags} --status reverted ${versions.join(" ")}`)}\n` + + `${bold(withTargetFlags(`supabase migration repair --status reverted ${versions.join(" ")}`, targetFlags))}\n` + "\nAnd update local migrations to match remote database:\n" + - `${bold(`supabase db pull ${targetFlags}`)}\n` + `${bold(withTargetFlags("supabase db pull", targetFlags))}\n` ); } diff --git a/apps/cli/src/command-internal/target/branch-resolution.errors.ts b/apps/cli/src/command-internal/target/branch-resolution.errors.ts new file mode 100644 index 0000000000..fcc5c85a91 --- /dev/null +++ b/apps/cli/src/command-internal/target/branch-resolution.errors.ts @@ -0,0 +1,78 @@ +import { Data } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, + statusCodeActionability, +} from "../../shared/telemetry/error-actionability.ts"; + +/** + * A non-ref-shaped value was given (treated as a branch name) but no linked parent project + * could be resolved to search for that branch — none of `SUPABASE_PROJECT_ID`, + * `supabase/.temp/linked-project.json`, or `supabase/.temp/project-ref` yielded a candidate. + */ +export class LinkBranchNotLinkedError extends Data.TaggedError("LinkBranchNotLinkedError")<{ + readonly message: string; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.projectNotLinked; + } +} + +/** + * A parent-project candidate exists (`SUPABASE_PROJECT_ID`, `supabase/.temp/linked-project.json`, + * or `supabase/.temp/project-ref`) but none of them is ref-shaped — corrupt or stale linked state. + */ +export class LinkParentRefInvalidError extends Data.TaggedError("LinkParentRefInvalidError")<{ + readonly message: string; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.relinkProject; + } +} + +/** No branch with the given name/UUID exists on the resolved parent project. */ +export class LinkBranchNotFoundError extends Data.TaggedError("LinkBranchNotFoundError")<{ + readonly message: string; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.invalidInput; + } +} + +/** + * The resolved branch has no `project_ref` yet (e.g. `status: CREATING_PROJECT`). Guards + * against silently falling through to an unrelated ref elsewhere in the resolver chain. + */ +export class LinkBranchNotReadyError extends Data.TaggedError("LinkBranchNotReadyError")<{ + readonly branch: string; + readonly status: string; + readonly message: string; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return { ...actionability.apiStatus, fingerprint_suffix: "branch_not_ready" }; + } +} + +/** Transport (or response-decode) failure while listing branches for a branch-name lookup. */ +export class LinkBranchListNetworkError extends Data.TaggedError("LinkBranchListNetworkError")<{ + readonly message: string; + readonly decode?: boolean; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return this.decode === true + ? { ...actionability.apiStatus, fingerprint_suffix: "api_response" } + : actionability.externalNetwork; + } +} + +/** `GET /v1/projects/{ref}/branches` returned a non-200 status during a branch-name lookup. */ +export class LinkBranchListStatusError extends Data.TaggedError("LinkBranchListStatusError")<{ + readonly status: number; + readonly body: string; + readonly message: string; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return statusCodeActionability(this.status, { notFoundIsInvalidInput: true }); + } +} diff --git a/apps/cli/src/command-internal/target/branch-resolution.ts b/apps/cli/src/command-internal/target/branch-resolution.ts new file mode 100644 index 0000000000..3a2981ece1 --- /dev/null +++ b/apps/cli/src/command-internal/target/branch-resolution.ts @@ -0,0 +1,176 @@ +import type { V1ListAllBranchesOutput } from "@supabase/api/effect"; +import { Effect, Option } from "effect"; + +import { CommandPlatformApiFactory } from "../../auth/command-platform-api-factory.service.ts"; +import { CommandPlatformApi } from "../../auth/command-platform-api.service.ts"; +import { PROJECT_REF_PATTERN } from "../../config/project-ref.service.ts"; +import { Output } from "../../shared/output/output.service.ts"; +import { block } from "../command-permissions/model.ts"; +import { mapHttpError, sanitizeInlineName } from "../http-errors.ts"; +import { + parentNotLinkedMessage, + parentRefInvalidMessage, + parentRefTypoHint, + resolveLinkedParentRef, +} from "../parent-project-ref.ts"; +import { + LinkBranchListNetworkError, + LinkBranchListStatusError, + LinkBranchNotFoundError, + LinkBranchNotLinkedError, + LinkBranchNotReadyError, + LinkParentRefInvalidError, +} from "./branch-resolution.errors.ts"; + +type Branches = typeof V1ListAllBranchesOutput.Type; +export type ParentBranch = Branches[number]; + +const MAX_LISTED_BRANCHES = 20; + +const BRANCH_LOOKUP_CONTEXT = + "the value is neither a 20-letter project ref nor a database URL (a branch name or UUID), or a bare --remote is followed by a word that could name a branch"; + +/** The branch listing every branch-name lookup makes against the linked project. */ +export const branchResolution = block("branch-resolution", [ + { + operationId: "v1-list-all-branches", + kind: "required", + when: [{ flag: "remote", present: true }], + context: BRANCH_LOOKUP_CONTEXT, + }, + { + operationId: "v1-list-all-branches", + kind: "required", + when: [{ flag: "project-ref", present: true }], + context: BRANCH_LOOKUP_CONTEXT, + }, +]); + +/** A branch resolved to its own project ref, with the parent project it was found under. */ +export interface BranchResolution { + readonly projectRef: string; + readonly branchName: string; + readonly parentRef: string; +} + +function branchNotFoundMessage(value: string, parentRef: string, branches: Branches): string { + if (branches.length === 0) { + return `Branch "${value}" not found: project ${parentRef} has no branches.${parentRefTypoHint(value)}`; + } + + const sortedNames = branches.map((branch) => branch.name).toSorted(); + const shown = sortedNames.slice(0, MAX_LISTED_BRANCHES); + const remaining = sortedNames.length - shown.length; + // Branch names are API-provided; sanitize them the same way response bodies are before + // embedding them in an error message. + const shownSanitized = sanitizeInlineName(shown.join(", ")); + const namesList = + remaining > 0 + ? `${shownSanitized}, … (${remaining} more — run supabase branches list)` + : shownSanitized; + + const trimmedLower = value.trim().toLowerCase(); + const nearMiss = branches.find((branch) => branch.name.toLowerCase() === trimmedLower); + // Sanitized like the list above — an API-provided name must not be able to inject + // ANSI/OSC/newline controls into the terminal. + const didYouMean = + nearMiss !== undefined ? ` Did you mean "${sanitizeInlineName(nearMiss.name)}"?` : ""; + + return ( + `Branch "${value}" not found for project ${parentRef}. Available branches: ${namesList}` + + `${didYouMean}${parentRefTypoHint(value)}` + ); +} + +/** UUID matching is case-insensitive; name matching is exact, with the not-found hint covering near misses. */ +export const findBranch = (branches: Branches, value: string): ParentBranch | undefined => + branches.find( + (branch) => branch.name === value || branch.id.toLowerCase() === value.toLowerCase(), + ); + +/** The linked parent project's ref, searched for a branch named `value`. */ +const resolveBranchParentRef = Effect.fnUntraced(function* (value: string) { + const parent = yield* resolveLinkedParentRef(); + if (parent.kind === "absent") { + return yield* new LinkBranchNotLinkedError({ message: parentNotLinkedMessage(value) }); + } + if (parent.kind === "invalid") { + return yield* new LinkParentRefInvalidError({ message: parentRefInvalidMessage(value) }); + } + return parent.ref; +}); + +/** The eager client when the command built one, else the lazy factory's, so a bare run never resolves a token. */ +const acquireApi = Effect.fnUntraced(function* () { + const direct = yield* Effect.serviceOption(CommandPlatformApi); + if (Option.isSome(direct)) return direct.value; + const factory = yield* Effect.serviceOption(CommandPlatformApiFactory); + if (Option.isNone(factory)) { + return yield* Effect.die( + new Error("a branch lookup needs CommandPlatformApi or CommandPlatformApiFactory"), + ); + } + return yield* factory.value.make; +}); + +/** `GET /v1/projects/{ref}/branches`, shared by every branch-name lookup. */ +export const listParentBranches = Effect.fnUntraced(function* (parentRef: string, label: string) { + const output = yield* Output; + const api = yield* acquireApi(); + + const mapBranchListError = mapHttpError({ + networkError: LinkBranchListNetworkError, + statusError: LinkBranchListStatusError, + networkMessage: (cause) => `failed to list branches: ${cause}`, + statusMessage: (status, body) => + status === 404 + ? `Cannot list branches for project ${parentRef} (HTTP 404). If ${parentRef} is itself a preview branch, link its parent project first: supabase link --project-ref <parent-ref>` + : `unexpected list branches status ${status}: ${body}`, + }); + + const task = output.format === "text" ? yield* output.task(label) : undefined; + const branches: Branches = yield* api.v1.listAllBranches({ ref: parentRef }).pipe( + Effect.tapError(() => task?.fail() ?? Effect.void), + Effect.catch(mapBranchListError), + ); + yield* task?.clear ?? Effect.void; + return branches; +}); + +/** + * Resolves a non-ref-shaped value to the branch's project ref by looking it up (by name or UUID) + * against the linked parent project's branches. A ref-shaped value (20 lowercase letters) is + * always a ref and never looked up as a branch name. + * + * Uses the list endpoint rather than a single-branch lookup so the full list can power the + * available-branches error. + */ +export const resolveLinkBranchRef = Effect.fn("Target.resolveBranch")(function* (value: string) { + const parentRef = yield* resolveBranchParentRef(value); + const branches = yield* listParentBranches(parentRef, "Resolving branch..."); + yield* Effect.annotateCurrentSpan({ + "project.parent_ref": parentRef, + "branch.count": branches.length, + }); + + const found = findBranch(branches, value); + if (found === undefined) { + return yield* new LinkBranchNotFoundError({ + message: branchNotFoundMessage(value, parentRef, branches), + }); + } + + if (!PROJECT_REF_PATTERN.test(found.project_ref)) { + return yield* new LinkBranchNotReadyError({ + branch: found.name, + status: found.status, + message: `Branch "${sanitizeInlineName(found.name)}" has no project ref yet (status: ${found.status}). Wait for it to finish provisioning, then retry.`, + }); + } + + return { + projectRef: found.project_ref, + branchName: found.name, + parentRef, + } satisfies BranchResolution; +}); diff --git a/apps/cli/src/command-internal/target/remote-resolution.ts b/apps/cli/src/command-internal/target/remote-resolution.ts new file mode 100644 index 0000000000..959c4d2c26 --- /dev/null +++ b/apps/cli/src/command-internal/target/remote-resolution.ts @@ -0,0 +1,93 @@ +import { Effect, Option } from "effect"; + +import { CommandSettings } from "../../config/command-settings.service.ts"; +import { CliArgs } from "../../shared/cli/cli-args.service.ts"; +import { CommandTarget } from "../../shared/output/command-target.service.ts"; +import { Output } from "../../shared/output/output.service.ts"; +import { resolveLinkedParentRef } from "../parent-project-ref.ts"; +import { + findBranch, + listParentBranches, + resolveLinkBranchRef, + type BranchResolution, +} from "./branch-resolution.ts"; +import { couldNameBranch, type RemoteValue } from "./remote-value.ts"; +import { deprecationMessages, remoteFlagText } from "./target-deprecation.ts"; +import { renderBranchTarget } from "./target-identity.ts"; +import type { TargetDeprecation } from "./target-selector.ts"; +import { TargetRemoteAmbiguousError } from "./target.errors.ts"; + +/** + * The word a bare `--remote` is directly followed by when it could name a branch. A bare + * `--remote` ahead of an operand reaches the command as a flag plus a positional, so the word may + * be meant as `--remote=<word>`. + */ +const bareRemoteOperand = (argv: ReadonlyArray<string>): string | undefined => { + const end = argv.indexOf("--"); + const flags = end === -1 ? argv : argv.slice(0, end); + const index = flags.indexOf("--remote"); + const next = index === -1 ? undefined : flags[index + 1]; + if (next === undefined || next.startsWith("-")) return undefined; + return couldNameBranch(next) ? next : undefined; +}; + +/** Prints one warning per deprecated selector flag the run used, rebuilt from the real command line when it is known. */ +export const warnTargetDeprecations = Effect.fnUntraced(function* ( + deprecations: ReadonlyArray<TargetDeprecation>, +) { + if (deprecations.length === 0) return; + const output = yield* Output; + const { projectHost } = yield* CommandSettings; + const argv = Option.getOrUndefined( + Option.map(yield* Effect.serviceOption(CliArgs), (cli) => cli.args), + ); + for (const message of deprecationMessages(deprecations, { argv, projectHost })) { + yield* output.warn(message); + } +}); + +/** + * Fails a bare `--remote` whose next word is, or may be, a branch of the linked project, before + * the command prompts, connects or writes. + */ +export const rejectAmbiguousRemote = Effect.fn("Target.checkRemoteOperand")(function* ( + remote: Option.Option<RemoteValue>, +) { + if (!Option.exists(remote, (value) => value._tag === "Linked")) return; + const cliArgs = yield* Effect.serviceOption(CliArgs); + const operand = Option.isSome(cliArgs) ? bareRemoteOperand(cliArgs.value.args) : undefined; + if (operand === undefined) return; + const parent = yield* resolveLinkedParentRef(); + if (parent.kind !== "resolved") return; + + const hint = `Did you mean ${remoteFlagText({ _tag: "Branch", name: operand })}?`; + const branches = yield* listParentBranches(parent.ref, "Checking branches...").pipe( + Effect.option, + ); + if (Option.isNone(branches)) { + return yield* new TargetRemoteAmbiguousError({ + message: `Could not list the branches of project ${parent.ref} to tell whether ${operand} names one. ${hint}`, + }); + } + if (findBranch(branches.value, operand) !== undefined) { + return yield* new TargetRemoteAmbiguousError({ + message: `${operand} is a branch of project ${parent.ref}. ${hint}`, + }); + } +}); + +/** Resolves a branch name or UUID to its project ref and announces it as the run's target. */ +export const resolveRemoteBranch = Effect.fnUntraced(function* (name: string) { + const found: BranchResolution = yield* resolveLinkBranchRef(name); + const cell = yield* Effect.serviceOption(CommandTarget); + if (Option.isSome(cell)) { + yield* cell.value.announce( + renderBranchTarget({ + branch: Option.some(found.branchName), + ref: found.projectRef, + parentRef: Option.some(found.parentRef), + }), + ); + } + return found; +}); diff --git a/apps/cli/src/command-internal/target/remote-value.ts b/apps/cli/src/command-internal/target/remote-value.ts new file mode 100644 index 0000000000..6c0096b6cc --- /dev/null +++ b/apps/cli/src/command-internal/target/remote-value.ts @@ -0,0 +1,42 @@ +import { BRANCH_PROJECT_REF_PATTERN } from "../ref-patterns.ts"; + +/** What `--remote` (or a `db diff --from/--to` value) names. */ +export type RemoteValue = + | { readonly _tag: "Linked" } + | { readonly _tag: "Ref"; readonly ref: string } + | { readonly _tag: "DbUrl"; readonly url: string } + | { readonly _tag: "Branch"; readonly name: string }; + +export type NamedRemoteValue = Exclude<RemoteValue, { readonly _tag: "Linked" }>; + +/** Starts every failure for an off or empty `--remote` value, which the CLI prints without its usual `Invalid value` framing. */ +export const LEAVE_OUT_REMOTE_PREFIX = "Leave out --remote "; + +const LINKED_LITERAL = /^(true|yes|on|1)$/i; +const OFF_LITERAL = /^(false|no|off|0)$/i; +const DB_URL_PATTERN = /^postgres(ql)?:\/\//; + +/** 20 lowercase letters is a project ref, a postgres URL is a database, anything else names a branch. */ +export const classifyNamedRemote = (raw: string): NamedRemoteValue => { + if (BRANCH_PROJECT_REF_PATTERN.test(raw)) return { _tag: "Ref", ref: raw }; + if (DB_URL_PATTERN.test(raw)) return { _tag: "DbUrl", url: raw }; + return { _tag: "Branch", name: raw }; +}; + +const NOT_A_BRANCH_NAME: ReadonlyArray<RegExp> = [ + /\s/, + /^[a-z][a-z0-9+.-]*:\/\//i, + /^\d+$/, + /^[./~]/, +]; + +/** Whether a word reads as a branch name rather than a path, URL, number or sentence. */ +export const couldNameBranch = (word: string): boolean => + word !== "" && !NOT_A_BRANCH_NAME.some((pattern) => pattern.test(word)); + +/** Classifies a `--remote` value; `undefined` for an empty value or an off switch (`false|no|off|0`). */ +export const classifyRemoteValue = (raw: string): RemoteValue | undefined => { + if (LINKED_LITERAL.test(raw)) return { _tag: "Linked" }; + if (raw === "" || OFF_LITERAL.test(raw)) return undefined; + return classifyNamedRemote(raw); +}; diff --git a/apps/cli/src/command-internal/target/resolve-project-target.ts b/apps/cli/src/command-internal/target/resolve-project-target.ts new file mode 100644 index 0000000000..cef3e76cce --- /dev/null +++ b/apps/cli/src/command-internal/target/resolve-project-target.ts @@ -0,0 +1,55 @@ +import { Effect, Option, Result } from "effect"; + +import { + CommandRuntime, + getCommandRuntimeCommand, +} from "../../shared/runtime/command-runtime.service.ts"; +import { resolveRemoteTarget } from "./resolve-target.ts"; +import { rejectAmbiguousRemote, warnTargetDeprecations } from "./remote-resolution.ts"; +import type { RemoteValue } from "./remote-value.ts"; +import { parseTargetSelector } from "./target-selector.ts"; + +export interface ProjectTargetInput { + readonly remote: Option.Option<RemoteValue>; + readonly projectRef: Option.Option<string>; +} + +/** + * Resolves the project a hosted-only command acts on: `--remote=<ref|branch>` names one, and a + * bare `--remote` or no selector falls through to the project-ref chain (`SUPABASE_PROJECT_ID`, + * the link file, the picker). + */ +export const resolveProjectTarget = Effect.fn("Target.resolveProject")(function* ( + input: ProjectTargetInput, +) { + const runtime = yield* Effect.serviceOption(CommandRuntime); + const { selection, deprecations } = yield* Result.match( + parseTargetSelector( + { ...input, local: false, linked: false }, + Option.isSome(runtime) + ? { commandName: `\`supabase ${getCommandRuntimeCommand(runtime.value)}\`` } + : {}, + ), + { onFailure: Effect.fail, onSuccess: Effect.succeed }, + ); + yield* warnTargetDeprecations(deprecations); + + switch (selection.explicit) { + case "branch": + return (yield* resolveRemoteTarget({ _tag: "Branch", name: selection.name })).ref; + case "linked": { + if (Option.isNone(selection.ref)) yield* rejectAmbiguousRemote(input.remote); + const remote: RemoteValue = Option.isSome(selection.ref) + ? { _tag: "Ref", ref: selection.ref.value } + : { _tag: "Linked" }; + return (yield* resolveRemoteTarget(remote)).ref; + } + case undefined: + return (yield* resolveRemoteTarget({ _tag: "Linked" })).ref; + case "local": + case "db-url": + return yield* Effect.die( + new Error(`a project-only selector cannot select "${selection.explicit}"`), + ); + } +}); diff --git a/apps/cli/src/command-internal/target/resolve-target.ts b/apps/cli/src/command-internal/target/resolve-target.ts index c5017d7bb2..0f8c237a2d 100644 --- a/apps/cli/src/command-internal/target/resolve-target.ts +++ b/apps/cli/src/command-internal/target/resolve-target.ts @@ -1,7 +1,5 @@ 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 { PROJECT_REF_PATTERN, @@ -9,11 +7,19 @@ import { type ProjectRefSource, type ResolvedProjectRef, } from "../../config/project-ref.service.ts"; -import { CliConfigLoadError } from "../../shared/config/cli-config-load.errors.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 { + CommandRuntime, + getCommandRuntimeCommand, +} from "../../shared/runtime/command-runtime.service.ts"; +import { + rejectAmbiguousRemote, + resolveRemoteBranch, + warnTargetDeprecations, +} from "./remote-resolution.ts"; +import type { RemoteValue } from "./remote-value.ts"; import { linkedAlternativeHint } from "./target-identity.ts"; import { parseTargetSelector, type TargetSelectorInput } from "./target-selector.ts"; import { TargetPolicy } from "./target-policy.ts"; @@ -30,7 +36,7 @@ export type ResolvedTarget = export type LinkedTarget = Extract<ResolvedTarget, { readonly kind: "linked" }>; -type TargetSelection = "flag" | "env" | "config" | "default"; +type SelectedBy = "flag" | "default"; /** The linked target for a ref the ref chain has already resolved and announced. */ export const linkedTarget = (resolved: ResolvedProjectRef): LinkedTarget => ({ @@ -40,34 +46,60 @@ export const linkedTarget = (resolved: ResolvedProjectRef): LinkedTarget => ({ matchesLinkedRef: resolved.matchesLinkedRef, }); +/** The linked target for one hosted `--remote` value; announces it, as a branch when the value names one. */ +export const resolveRemoteTarget = Effect.fn("Target.resolveRemote")(function* ( + remote: Exclude<RemoteValue, { readonly _tag: "DbUrl" }>, +) { + if (remote._tag === "Branch") { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const { workdir } = yield* CommandSettings; + const found = yield* resolveRemoteBranch(remote.name); + const linkedRef = yield* readProjectRefFile(fs, path, workdir).pipe( + Effect.orElseSucceed(() => Option.none<string>()), + ); + return linkedTarget({ + ref: found.projectRef, + source: "flag", + matchesLinkedRef: Option.isSome(linkedRef) && linkedRef.value === found.projectRef, + }); + } + const projectRef = yield* ProjectRefResolver; + return linkedTarget( + yield* projectRef.resolveRef(remote._tag === "Ref" ? Option.some(remote.ref) : Option.none()), + ); +}); + /** - * 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. + * Resolves the target a `selectable` command acts on: `--remote` (or a deprecated alias) picks a + * linked project, a branch or a database URL, and no selector means the local stack. */ export const resolveTarget = Effect.fn("Target.resolve")(function* (input: TargetSelectorInput) { const policy = yield* TargetPolicy; - if (policy.group !== "sync" && policy.group !== "selectable") { + if (policy.group !== "selectable") { return yield* Effect.die( - new Error(`resolveTarget needs a sync or selectable command, not "${policy.group}"`), + new Error(`resolveTarget needs a selectable command, not "${policy.group}"`), ); } - const selection = yield* Result.match(parseTargetSelector(input), { - onFailure: Effect.fail, - onSuccess: Effect.succeed, - }); - const projectRef = yield* ProjectRefResolver; + const runtime = yield* Effect.serviceOption(CommandRuntime); + const { selection, deprecations } = yield* Result.match( + parseTargetSelector( + input, + Option.isSome(runtime) + ? { commandName: `\`supabase ${getCommandRuntimeCommand(runtime.value)}\`` } + : {}, + ), + { onFailure: Effect.fail, onSuccess: Effect.succeed }, + ); + yield* warnTargetDeprecations(deprecations); - const finish = Effect.fnUntraced(function* (target: ResolvedTarget, selectedBy: TargetSelection) { + const finish = Effect.fnUntraced(function* (target: ResolvedTarget, selectedBy: SelectedBy) { yield* Effect.annotateCurrentSpan({ "target.kind": target.kind, "target.selection": selectedBy, }); return target; }); - const linked = Effect.fnUntraced(function* (flag: Option.Option<string>) { - return linkedTarget(yield* projectRef.resolveRef(flag)); - }); const hintLinkedAlternative = Effect.fnUntraced(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -88,32 +120,20 @@ export const resolveTarget = Effect.fn("Target.resolve")(function* (input: Targe 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 "linked": { + if (Option.isNone(selection.ref)) yield* rejectAmbiguousRemote(input.remote); + const remote: RemoteValue = Option.isSome(selection.ref) + ? { _tag: "Ref", ref: selection.ref.value } + : { _tag: "Linked" }; + return yield* finish(yield* resolveRemoteTarget(remote), "flag"); + } + case "branch": + return yield* finish( + yield* resolveRemoteTarget({ _tag: "Branch", name: selection.name }), + "flag", + ); case undefined: + yield* hintLinkedAlternative(); + return yield* finish({ kind: "local" }, "default"); } - - 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() }) - .pipe( - Effect.catchTag("CliConfigParseError", (error) => - Effect.fail(new CliConfigLoadError({ message: describeConfigParseError(workdir, error) })), - ), - ); - 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"; - if (workspaceTarget.value === "remote") { - return yield* finish(yield* linked(Option.none()), selectedBy); - } - if (selectedBy === "default") yield* hintLinkedAlternative(); - return yield* finish({ kind: "local" }, selectedBy); }); diff --git a/apps/cli/src/command-internal/target/target-deprecation.ts b/apps/cli/src/command-internal/target/target-deprecation.ts new file mode 100644 index 0000000000..5673e3a984 --- /dev/null +++ b/apps/cli/src/command-internal/target/target-deprecation.ts @@ -0,0 +1,132 @@ +import { BRANCH_PROJECT_REF_PATTERN } from "../ref-patterns.ts"; +import type { RemoteValue } from "./remote-value.ts"; +import type { RemoteAliasFlag, TargetDeprecation } from "./target-selector.ts"; + +const ALIAS_VALUE_FLAGS: ReadonlySet<string> = new Set([ + "--db-url", + "--project-ref", + "--project-id", +]); +const ALIAS_SWITCH_FLAGS: ReadonlySet<string> = new Set(["--linked", "--local"]); +const SECRET_VALUE_FLAGS: ReadonlySet<string> = new Set(["--password", "-p", "--db-password"]); +const BOOLEAN_LITERALS: ReadonlySet<string> = new Set([ + "true", + "false", + "yes", + "no", + "on", + "off", + "1", + "0", +]); +const DB_URL_PATTERN = /^postgres(ql)?:\/\//; +const SAFE_TOKEN = /^[\w@%+=:,./-]+$/; + +const shellQuote = (token: string): string => + SAFE_TOKEN.test(token) ? token : `'${token.replaceAll("'", `'\\''`)}'`; + +/** The `--remote` spelling for `value`; a database URL is never echoed. */ +export const remoteFlagText = (value: RemoteValue): string => { + switch (value._tag) { + case "Linked": + return "--remote"; + case "Ref": + return `--remote=${value.ref}`; + case "Branch": + return `--remote=${shellQuote(value.name)}`; + case "DbUrl": + return "--remote=<db-url>"; + } +}; + +const aliasNameOf = (token: string): string | undefined => { + const name = token.replace(/=.*$/, "").replace(/^--no-/, "--"); + return ALIAS_VALUE_FLAGS.has(name) || ALIAS_SWITCH_FLAGS.has(name) ? name : undefined; +}; + +/** + * `argv` with the deprecated selector tokens (and any `--remote` that `remote` replaces) removed, + * database URLs and passwords redacted, and `remote` last so it cannot be read as the value of a + * positional (ahead of a literal `--`). + */ +export const rebuildCommand = (argv: ReadonlyArray<string>, remote: string | undefined): string => { + const kept: Array<string> = []; + let tail: ReadonlyArray<string> = []; + for (let index = 0; index < argv.length; index += 1) { + const token = argv[index]; + if (token === undefined) continue; + if (token === "--") { + tail = argv.slice(index).map(shellQuote); + break; + } + if (remote !== undefined && (token === "--remote" || token.startsWith("--remote="))) continue; + const alias = aliasNameOf(token); + if (alias !== undefined) { + const next = argv[index + 1]; + const consumesNext = + !token.includes("=") && + ((ALIAS_VALUE_FLAGS.has(alias) && next !== undefined) || + (ALIAS_SWITCH_FLAGS.has(alias) && next !== undefined && BOOLEAN_LITERALS.has(next))); + if (consumesNext) index += 1; + continue; + } + if (SECRET_VALUE_FLAGS.has(token) && argv[index + 1] !== undefined) { + kept.push(token, "<redacted>"); + index += 1; + continue; + } + const secretFlag = [...SECRET_VALUE_FLAGS].find((flag) => token.startsWith(`${flag}=`)); + if (secretFlag !== undefined) { + kept.push(`${secretFlag}=<redacted>`); + continue; + } + kept.push(DB_URL_PATTERN.test(token) ? "<db-url>" : shellQuote(token)); + } + return ["supabase", ...kept, ...(remote === undefined ? [] : [remote]), ...tail].join(" "); +}; + +/** The project ref a Supabase connection string already names: `db.<ref>.<host>` or a `postgres.<ref>` pooler user. */ +export const refFromDbUrl = (url: string, projectHost: string): string | undefined => { + try { + const parsed = new URL(url); + const host = parsed.hostname; + if (host.startsWith("db.") && host.endsWith(`.${projectHost}`)) { + const ref = host.slice("db.".length, host.length - projectHost.length - 1); + if (BRANCH_PROJECT_REF_PATTERN.test(ref)) return ref; + } + const poolerRef = /^postgres\.([a-z]{20})$/.exec(decodeURIComponent(parsed.username))?.[1]; + return poolerRef; + } catch { + return undefined; + } +}; + +const joinFlags = (flags: ReadonlyArray<RemoteAliasFlag>): string => + flags.length <= 2 + ? flags.join(" and ") + : `${flags.slice(0, -1).join(", ")} and ${flags[flags.length - 1]}`; + +/** + * The warning for each deprecated selector the run used. `argv` is the real command line, so the + * replacement shows the command the user ran; a database URL is never echoed. + */ +export const deprecationMessages = ( + deprecations: ReadonlyArray<TargetDeprecation>, + context: { readonly argv: ReadonlyArray<string> | undefined; readonly projectHost: string }, +): ReadonlyArray<string> => + deprecations.map((deprecation) => { + const { argv } = context; + if (deprecation.kind === "local") { + const example = argv === undefined ? "" : `: ${rebuildCommand(argv, undefined)}`; + return `--local isn't needed (local is the default) and will be removed in v4${example}`; + } + const remote = remoteFlagText(deprecation.remote); + const example = argv === undefined ? "" : `: ${rebuildCommand(argv, remote)}`; + const ref = + deprecation.remote._tag === "DbUrl" + ? refFromDbUrl(deprecation.remote.url, context.projectHost) + : undefined; + const alternative = ref === undefined ? "" : `, or --remote=${ref} with an access token set`; + const verb = deprecation.flags.length > 1 ? "are" : "is"; + return `${joinFlags(deprecation.flags)} ${verb} deprecated and will be removed in v4. Use --remote instead${example}${alternative}`; + }); diff --git a/apps/cli/src/command-internal/target/target-flags.ts b/apps/cli/src/command-internal/target/target-flags.ts index f016ea8dea..196b3f900a 100644 --- a/apps/cli/src/command-internal/target/target-flags.ts +++ b/apps/cli/src/command-internal/target/target-flags.ts @@ -1,51 +1,85 @@ -import type { Option } from "effect"; -import { Flag, GlobalFlag } from "effect/cli"; +import { Effect, Option } from "effect"; +import { Flag, GlobalFlag, Param, Primitive } from "effect/cli"; +import { classifyRemoteValue, LEAVE_OUT_REMOTE_PREFIX, type RemoteValue } from "./remote-value.ts"; import type { ResolvedTarget } from "./resolve-target.ts"; -type TargetFlagGroup = "sync" | "selectable"; type TargetSubject = "database" | "Storage API"; interface TargetFlagOptions { - readonly group: TargetFlagGroup; readonly subject: TargetSubject; } -const localFlag = ({ group, subject }: TargetFlagOptions) => +type RemoteFlagVariant = + | { readonly kind: "selectable"; readonly subject: TargetSubject; readonly dbUrl: boolean } + | { readonly kind: "hosted" }; + +const remoteDescription = (variant: RemoteFlagVariant): string => { + if (variant.kind === "hosted") { + return "Target another project: --remote=<ref|branch>. Defaults to the linked project."; + } + return variant.dbUrl + ? "Target a remote database: the linked project, or --remote=<ref|branch|url>." + : `Target a remote ${variant.subject}: the linked project, or --remote=<ref|branch>.`; +}; + +const leaveOutMessage = (variant: RemoteFlagVariant): string => + variant.kind === "hosted" + ? `${LEAVE_OUT_REMOTE_PREFIX}to use the linked project.` + : `${LEAVE_OUT_REMOTE_PREFIX}to target the local ${variant.subject}.`; + +/** + * Keeps the `Boolean` primitive tag so the parser accepts a bare `--remote` and never takes the + * next token as its value; only `--remote=<value>` (or a boolean literal) reaches `parse`. + */ +const remotePrimitive = (variant: RemoteFlagVariant): Primitive.Primitive<RemoteValue> => + Object.assign(Object.create(Primitive.Boolean), { + parse: (raw: string) => { + const value = classifyRemoteValue(raw); + return value === undefined ? Effect.fail(leaveOutMessage(variant)) : Effect.succeed(value); + }, + }); + +/** `--remote`: the linked project when bare, else the project, branch or database named by its value. */ +const remoteFlag = (variant: RemoteFlagVariant) => + Param.makeSingle({ + kind: Param.flagKind, + name: "remote", + primitiveType: remotePrimitive(variant), + }).pipe(Flag.withDescription(remoteDescription(variant)), Flag.optional); + +const localFlag = () => Flag.Boolean("local").pipe( Flag.withDescription( - group === "sync" - ? `Target the local ${subject}.` - : `Target the local ${subject} (default unless target = "remote" is set in config.toml or SUPABASE_TARGET).`, + "Deprecated, removed in v4: local is the default, so --local is not needed.", ), Flag.withDefault(false), + Flag.withHidden, ); -const linkedFlag = ({ group }: TargetFlagOptions) => +const linkedFlag = () => Flag.Boolean("linked").pipe( - Flag.withDescription( - group === "sync" - ? "Target the linked project (default)." - : 'Target the linked project (default when target = "remote" is set in config.toml or SUPABASE_TARGET).', - ), + Flag.withDescription("Deprecated, removed in v4: use --remote."), Flag.withDefault(false), + Flag.withHidden, ); const dbUrlFlag = () => Flag.String("db-url").pipe( - Flag.withDescription( - "Target the database specified by the connection string (must be percent-encoded).", - ), + Flag.withDescription("Deprecated, removed in v4: use --remote=<db-url>."), Flag.optional, + Flag.withHidden, ); const projectRefFlag = () => Flag.String("project-ref").pipe( - Flag.withDescription("Project ref of the Supabase project. Implies --linked."), + Flag.withDescription("Deprecated, removed in v4: use --remote=<ref>."), Flag.optional, + Flag.withHidden, ); export type TargetFlagsWithDbUrl = { + readonly remote: Flag.Flag<Option.Option<RemoteValue>>; readonly local: Flag.Flag<boolean>; readonly linked: Flag.Flag<boolean>; readonly dbUrl: Flag.Flag<Option.Option<string>>; @@ -54,7 +88,7 @@ export type TargetFlagsWithDbUrl = { export type TargetFlagsWithoutDbUrl = Omit<TargetFlagsWithDbUrl, "dbUrl">; -/** The only declaration of `--local`, `--linked`, `--db-url` and `--project-ref` for sync and selectable commands. */ +/** The only declaration of `--remote` and its deprecated `--local`, `--linked`, `--db-url` and `--project-ref` aliases for selectable commands. */ export function targetFlags( options: TargetFlagOptions & { readonly dbUrl: true }, ): TargetFlagsWithDbUrl; @@ -65,22 +99,44 @@ export function targetFlags( options: TargetFlagOptions & { readonly dbUrl: boolean }, ): TargetFlagsWithDbUrl | TargetFlagsWithoutDbUrl { const base = { - local: localFlag(options), - linked: linkedFlag(options), + remote: remoteFlag({ kind: "selectable", subject: options.subject, dbUrl: options.dbUrl }), + local: localFlag(), + linked: linkedFlag(), projectRef: projectRefFlag(), }; 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. */ +/** The flags of a hosted-only command: `--remote=<ref|branch>` and the deprecated `--project-ref` alias. */ +export const projectTargetFlags = () => ({ + remote: remoteFlag({ kind: "hosted" }), + projectRef: projectRefFlag(), +}); + +/** The `--remote` value as telemetry records it: a project ref verbatim, a bare `--remote` as `true`, a branch name or database URL redacted. */ +export const remoteTelemetryFlag = ( + remote: Option.Option<RemoteValue>, +): Option.Option<string | boolean> => + Option.map(remote, (value) => { + switch (value._tag) { + case "Linked": + return true; + case "Ref": + return value.ref; + default: + return "<redacted>"; + } + }); + +/** The flags that point a suggested follow-up command at `target`; a database URL is never echoed. */ export const flagsForTarget = (target: ResolvedTarget): string => { switch (target.kind) { case "local": - return "--local"; + return ""; case "db-url": - return "--db-url <redacted>"; + return "--remote=<db-url>"; case "linked": - return target.matchesLinkedRef ? "--linked" : `--linked --project-ref ${target.ref}`; + return target.matchesLinkedRef ? "--remote" : `--remote=${target.ref}`; } }; @@ -89,17 +145,16 @@ export const flagsForTarget = (target: ResolvedTarget): string => { * 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" }), + remote: GlobalFlag.Setting("remote")({ + flag: remoteFlag({ kind: "selectable", subject: "Storage API", dbUrl: false }), }), + linked: GlobalFlag.Setting("linked")({ flag: linkedFlag() }), + local: GlobalFlag.Setting("local")({ flag: localFlag() }), }; -/** Hidden alias of `--project-ref`, accepted only by `gen types`. */ +/** Hidden alias of `--remote=<ref>`, accepted only by `gen types`. */ export const deprecatedProjectIdFlag = Flag.String("project-id").pipe( - Flag.withDescription("Deprecated, removed in v4: use --project-ref."), + Flag.withDescription("Deprecated, removed in v4: use --remote=<ref>."), Flag.optional, Flag.withHidden, ); diff --git a/apps/cli/src/command-internal/target/target-identity.ts b/apps/cli/src/command-internal/target/target-identity.ts index 7767c2d8db..498a9ca600 100644 --- a/apps/cli/src/command-internal/target/target-identity.ts +++ b/apps/cli/src/command-internal/target/target-identity.ts @@ -66,14 +66,14 @@ export const describeLinkedTarget = (input: { }; }; -/** The suffix a default-local banner carries when the checkout is linked, naming the project `--linked` would use. */ +/** The suffix a default-local banner carries when the checkout is linked, naming the project `--remote` 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)}`; + return `default; pass --remote 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. */ diff --git a/apps/cli/src/command-internal/target/target-password.ts b/apps/cli/src/command-internal/target/target-password.ts index 1b181ec622..7cd076ce5c 100644 --- a/apps/cli/src/command-internal/target/target-password.ts +++ b/apps/cli/src/command-internal/target/target-password.ts @@ -16,7 +16,7 @@ export const rejectTargetPassword = ( 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 can't be used with --remote=<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.${linkedCheckout ? ` ${SUGGEST_LINKED_PROJECT}` : ""}`, }), ); diff --git a/apps/cli/src/command-internal/target/target-policy.ts b/apps/cli/src/command-internal/target/target-policy.ts index 93cd1ad1fc..30365b3ee6 100644 --- a/apps/cli/src/command-internal/target/target-policy.ts +++ b/apps/cli/src/command-internal/target/target-policy.ts @@ -1,7 +1,7 @@ import { Context, Layer, Option } from "effect"; import { Command } from "effect/cli"; -export type TargetGroup = "local-only" | "account" | "hosted" | "sync" | "selectable" | "removed"; +export type TargetGroup = "local-only" | "account" | "hosted" | "selectable" | "removed"; export interface TargetPolicyShape { readonly group: TargetGroup; @@ -11,16 +11,16 @@ export interface TargetPolicyShape { /** * 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. + * consent; a `selectable` leaf must give one because it can reach a hosted database. */ export type TargetPolicyDeclaration = | { - readonly group: "sync" | "selectable"; + readonly group: "selectable"; readonly consent: "none"; readonly consentExemptReason: string; } | { - readonly group: Exclude<TargetGroup, "sync" | "selectable">; + readonly group: Exclude<TargetGroup, "selectable">; readonly consent: "none"; readonly consentExemptReason?: string; } diff --git a/apps/cli/src/command-internal/target/target-selector.ts b/apps/cli/src/command-internal/target/target-selector.ts index 558e799bc2..119d11283b 100644 --- a/apps/cli/src/command-internal/target/target-selector.ts +++ b/apps/cli/src/command-internal/target/target-selector.ts @@ -1,85 +1,188 @@ import { Option, Result } from "effect"; +import { classifyNamedRemote, type RemoteValue } from "./remote-value.ts"; import { TargetEmptyValueError, + TargetNeedsProjectError, TargetProjectRefConflictError, TargetSelectorConflictError, type TargetSelectorError, } from "./target.errors.ts"; export interface TargetSelectorInput { + readonly remote: Option.Option<RemoteValue>; readonly local: boolean; readonly linked: boolean; - readonly dbUrl: Option.Option<string>; + /** Absent for a command with no database connection, which rejects a database URL. */ + readonly dbUrl?: Option.Option<string>; readonly projectRef: Option.Option<string>; + /** `gen types` only: the deprecated alias of `--project-ref`. */ + readonly projectId?: Option.Option<string>; } -export type TargetSelection = +type TargetSelection = | { readonly explicit: "local" } | { readonly explicit: "db-url"; readonly url: string } - | { readonly explicit: "linked"; readonly projectRef: Option.Option<string> } + | { readonly explicit: "linked"; readonly ref: Option.Option<string> } + | { readonly explicit: "branch"; readonly name: string } | { readonly explicit: undefined }; +export type RemoteAliasFlag = "--linked" | "--project-ref" | "--project-id" | "--db-url"; + +/** A deprecated selector flag the run used, with the `--remote` form it maps to. */ +export type TargetDeprecation = + | { + readonly kind: "remote-alias"; + readonly flags: ReadonlyArray<RemoteAliasFlag>; + readonly remote: RemoteValue; + } + | { readonly kind: "local" }; + +export interface ParsedTargetSelector { + readonly selection: TargetSelection; + readonly deprecations: ReadonlyArray<TargetDeprecation>; +} + +export interface TargetSelectorOptions { + /** Names the command in the error for a database URL it cannot use. */ + readonly commandName?: string; +} + const joinFlags = (flags: ReadonlyArray<string>): 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. */ +const selectionOf = (remote: RemoteValue): TargetSelection => { + switch (remote._tag) { + case "Linked": + return { explicit: "linked", ref: Option.none() }; + case "Ref": + return { explicit: "linked", ref: Option.some(remote.ref) }; + case "DbUrl": + return { explicit: "db-url", url: remote.url }; + case "Branch": + return { explicit: "branch", name: remote.name }; + } +}; + +const emptyValueError = (flag: "--db-url" | "--project-ref" | "--project-id") => + new TargetEmptyValueError({ + flag, + message: `${flag} cannot be empty.`, + suggestion: + flag === "--db-url" + ? "Pass a connection string, for example --remote=postgresql://postgres:postgres@127.0.0.1:54322/postgres" + : "Pass a project ref, for example --remote=abcdefghijklmnopqrst", + }); + +/** + * A `false` switch counts as absent, so `--linked=false` and `--no-linked` select nothing. + * `--linked`, `--project-ref`, `--project-id` and `--db-url` resolve exactly like the `--remote` + * form they map to and are reported as deprecations; `--local` selects the default. + */ export const parseTargetSelector = ( input: TargetSelectorInput, -): Result.Result<TargetSelection, TargetSelectorError> => { - const dbUrl = Option.getOrUndefined(input.dbUrl); + options: TargetSelectorOptions = {}, +): Result.Result<ParsedTargetSelector, TargetSelectorError> => { + const remote = Option.getOrUndefined(input.remote); + const dbUrl = Option.getOrUndefined(input.dbUrl ?? Option.none()); const projectRef = Option.getOrUndefined(input.projectRef); + const projectId = Option.getOrUndefined(input.projectId ?? Option.none()); - if (dbUrl === "") { - return Result.fail( - 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 === "") { - return Result.fail( - new TargetEmptyValueError({ - flag: "--project-ref", - message: "--project-ref cannot be empty.", - suggestion: "Pass a project ref, for example --project-ref abcdefghijklmnopqrst", - }), - ); - } + if (dbUrl === "") return Result.fail(emptyValueError("--db-url")); + if (projectRef === "") return Result.fail(emptyValueError("--project-ref")); + if (projectId === "") return Result.fail(emptyValueError("--project-id")); const selected = [ - ...(input.local ? ["--local"] : []), + ...(remote === undefined ? [] : ["--remote"]), ...(input.linked ? ["--linked"] : []), ...(dbUrl === undefined ? [] : ["--db-url"]), + ...(input.local ? ["--local"] : []), ]; 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)}.`, + message: `Only one of --remote, --linked, --db-url and --local can be set; got ${joinFlags(selected)}.`, }), ); } - if (projectRef !== undefined && (input.local || dbUrl !== undefined)) { - const conflictsWith = input.local ? "--local" : "--db-url"; + if (projectRef !== undefined && projectId !== undefined) { return Result.fail( - new TargetProjectRefConflictError({ - conflictsWith, - message: `--project-ref selects a linked project and cannot be combined with ${conflictsWith}.`, + new TargetSelectorConflictError({ + flags: ["--project-id", "--project-ref"], + message: "--project-id is a deprecated alias of --project-ref; pass only one of them.", }), ); } + const aliasFlag = projectRef === undefined ? "--project-id" : "--project-ref"; + const aliasValue = projectRef ?? projectId; + if (aliasValue !== undefined) { + const conflictsWith = input.local + ? "--local" + : dbUrl !== undefined + ? "--db-url" + : remote !== undefined && remote._tag !== "Linked" + ? "--remote" + : undefined; + if (conflictsWith !== undefined) { + return Result.fail( + new TargetProjectRefConflictError({ + conflictsWith, + message: + conflictsWith === "--remote" + ? `${aliasFlag} cannot be combined with --remote=<value>; use --remote=<ref> alone.` + : `${aliasFlag} selects a remote project and cannot be combined with ${conflictsWith}.`, + }), + ); + } + } + + const outcome = ((): { + readonly selection: TargetSelection; + readonly deprecations: ReadonlyArray<TargetDeprecation>; + } => { + if (input.local) return { selection: { explicit: "local" }, deprecations: [{ kind: "local" }] }; + if (dbUrl !== undefined) { + return { + selection: { explicit: "db-url", url: dbUrl }, + deprecations: [ + { kind: "remote-alias", flags: ["--db-url"], remote: { _tag: "DbUrl", url: dbUrl } }, + ], + }; + } + if (aliasValue !== undefined) { + const value = classifyNamedRemote(aliasValue); + return { + selection: selectionOf(value), + deprecations: [ + { + kind: "remote-alias", + flags: input.linked ? ["--linked", aliasFlag] : [aliasFlag], + remote: value, + }, + ], + }; + } + if (input.linked) { + const value: RemoteValue = { _tag: "Linked" }; + return { + selection: selectionOf(value), + deprecations: [{ kind: "remote-alias", flags: ["--linked"], remote: value }], + }; + } + if (remote !== undefined) return { selection: selectionOf(remote), deprecations: [] }; + return { selection: { explicit: undefined }, deprecations: [] }; + })(); - 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 }); + if (input.dbUrl === undefined && outcome.selection.explicit === "db-url") { + return Result.fail( + new TargetNeedsProjectError({ + message: `${options.commandName ?? "This command"} needs a project, not a database URL.`, + }), + ); } - return Result.succeed({ explicit: undefined }); + return Result.succeed(outcome); }; diff --git a/apps/cli/src/command-internal/target/target.errors.ts b/apps/cli/src/command-internal/target/target.errors.ts index a48146643a..628a6767ca 100644 --- a/apps/cli/src/command-internal/target/target.errors.ts +++ b/apps/cli/src/command-internal/target/target.errors.ts @@ -6,7 +6,7 @@ import { ErrorActionabilityId, } from "../../shared/telemetry/error-actionability.ts"; -/** More than one of `--local`, `--linked` and `--db-url` was set. */ +/** More than one of `--remote`, `--linked`, `--db-url` and `--local` was set. */ export class TargetSelectorConflictError extends Data.TaggedError("TargetSelectorConflictError")<{ readonly flags: ReadonlyArray<string>; readonly message: string; @@ -16,11 +16,11 @@ export class TargetSelectorConflictError extends Data.TaggedError("TargetSelecto } } -/** `--project-ref` selects a linked project, so it cannot be combined with `--local` or `--db-url`. */ +/** `--project-ref` names a project, so it cannot be combined with `--remote=<value>`, `--local` or `--db-url`. */ export class TargetProjectRefConflictError extends Data.TaggedError( "TargetProjectRefConflictError", )<{ - readonly conflictsWith: "--local" | "--db-url"; + readonly conflictsWith: "--remote" | "--local" | "--db-url"; readonly message: string; }> { get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { @@ -28,9 +28,9 @@ export class TargetProjectRefConflictError extends Data.TaggedError( } } -/** `--db-url` or `--project-ref` was passed an empty value. */ +/** `--db-url`, `--project-ref` or `--project-id` was passed an empty value. */ export class TargetEmptyValueError extends Data.TaggedError("TargetEmptyValueError")<{ - readonly flag: "--db-url" | "--project-ref"; + readonly flag: "--db-url" | "--project-ref" | "--project-id"; readonly message: string; readonly suggestion: string; }> { @@ -39,6 +39,24 @@ export class TargetEmptyValueError extends Data.TaggedError("TargetEmptyValueErr } } +/** A command that acts on a project was given a database URL. */ +export class TargetNeedsProjectError extends Data.TaggedError("TargetNeedsProjectError")<{ + readonly message: string; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} + +/** A bare `--remote` is followed by a word that names a branch (or whose branches could not be listed), so the word is probably the branch. */ +export class TargetRemoteAmbiguousError extends Data.TaggedError("TargetRemoteAmbiguousError")<{ + 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"; @@ -52,4 +70,5 @@ export class TargetPasswordConflictError extends Data.TaggedError("TargetPasswor export type TargetSelectorError = | TargetSelectorConflictError | TargetProjectRefConflictError - | TargetEmptyValueError; + | TargetEmptyValueError + | TargetNeedsProjectError; 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 4001bf6afa..6385843f33 100644 --- a/apps/cli/src/command-internal/test-db.command-handler.ts +++ b/apps/cli/src/command-internal/test-db.command-handler.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 type { RemoteValue } from "./target/remote-value.ts"; import { targetFlags } from "./target/target-flags.ts"; import { testDb } from "./test-db.handler.ts"; @@ -43,11 +44,12 @@ export const testDbConfig = { Argument.withDescription("Paths to test files or directories."), Argument.variadic(), ), - ...targetFlags({ group: "selectable", subject: "database", dbUrl: true }), + ...targetFlags({ subject: "database", dbUrl: true }), } as const; export interface TestDbFlags { readonly paths: ReadonlyArray<string>; + readonly remote: Option.Option<RemoteValue>; readonly dbUrl: Option.Option<string>; readonly linked: boolean; readonly local: boolean; @@ -62,6 +64,7 @@ export interface TestDbFlags { export function runTestDbCommand(flags: CliCommand.Command.Config.Infer<typeof testDbConfig>) { return testDb({ paths: flags.paths, + remote: flags.remote, dbUrl: flags.dbUrl, linked: flags.linked, local: flags.local, @@ -69,6 +72,7 @@ export function runTestDbCommand(flags: CliCommand.Command.Config.Infer<typeof t }).pipe( withCommandTelemetry({ flags: { + remote: flags.remote, "db-url": flags.dbUrl, linked: flags.linked, local: flags.local, diff --git a/apps/cli/src/command-internal/test-db.handler.ts b/apps/cli/src/command-internal/test-db.handler.ts index a4d96b8763..2a67ff910a 100644 --- a/apps/cli/src/command-internal/test-db.handler.ts +++ b/apps/cli/src/command-internal/test-db.handler.ts @@ -117,6 +117,7 @@ export const testDb = Effect.fn("test.db")(function* (flags: TestDbFlags) { yield* Effect.gen(function* () { const target = yield* resolveTarget({ + remote: flags.remote, local: flags.local, linked: flags.linked, dbUrl: flags.dbUrl, diff --git a/apps/cli/src/commands/bootstrap/bootstrap.handler.ts b/apps/cli/src/commands/bootstrap/bootstrap.handler.ts index eaf09968df..a3493c997f 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, - repairTargetFlags: "--linked", + repairTargetFlags: "--remote", dryRun: false, includeAll: false, includeRoles: true, diff --git a/apps/cli/src/commands/db/advisors/advisors.command.ts b/apps/cli/src/commands/db/advisors/advisors.command.ts index f26f69606d..c6ee162f7e 100644 --- a/apps/cli/src/commands/db/advisors/advisors.command.ts +++ b/apps/cli/src/commands/db/advisors/advisors.command.ts @@ -18,7 +18,7 @@ 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 }), + ...targetFlags({ 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, @@ -76,6 +76,7 @@ export const dbAdvisorsCommand = Command.make("advisors", config).pipe( dbAdvisors(flags).pipe( withCommandTelemetry({ flags: { + remote: flags.remote, "db-url": flags.dbUrl, linked: flags.linked, local: flags.local, diff --git a/apps/cli/src/commands/db/advisors/advisors.handler.ts b/apps/cli/src/commands/db/advisors/advisors.handler.ts index 510d3b815e..7a501d937c 100644 --- a/apps/cli/src/commands/db/advisors/advisors.handler.ts +++ b/apps/cli/src/commands/db/advisors/advisors.handler.ts @@ -203,6 +203,7 @@ const runAdvisors = Effect.fnUntraced(function* ( dnsResolver: "native" | "https", ) { const target = yield* resolveTarget({ + remote: flags.remote, local: flags.local, linked: flags.linked, dbUrl: flags.dbUrl, diff --git a/apps/cli/src/commands/db/diff/diff.command.ts b/apps/cli/src/commands/db/diff/diff.command.ts index cc4f545dfd..11efdb6b9c 100644 --- a/apps/cli/src/commands/db/diff/diff.command.ts +++ b/apps/cli/src/commands/db/diff/diff.command.ts @@ -49,11 +49,15 @@ const config = { Flag.withDefault(false), ), from: Flag.String("from").pipe( - Flag.withDescription("Diff from local, linked, migrations, or a Postgres URL."), + Flag.withDescription( + "Diff from local, migrations, remote, a project ref, a branch, or a Postgres URL.", + ), Flag.optional, ), to: Flag.String("to").pipe( - Flag.withDescription("Diff to local, linked, migrations, or a Postgres URL."), + Flag.withDescription( + "Diff to local, migrations, remote, a project ref, a branch, or a Postgres URL.", + ), Flag.optional, ), output: Flag.String("output").pipe( @@ -63,7 +67,7 @@ const config = { ), Flag.optional, ), - ...targetFlags({ group: "selectable", subject: "database", dbUrl: true }), + ...targetFlags({ subject: "database", dbUrl: true }), file: Flag.String("file").pipe( Flag.withAlias("f"), Flag.withDescription( @@ -86,9 +90,9 @@ const config = { export type DbDiffFlags = CliCommand.Command.Config.Infer<typeof config>; -// An explicit `--from`/`--to linked` side resolves the linked project without `--linked`. +// An explicit `--from`/`--to` of `remote` or the deprecated `linked` resolves the linked project without `--remote`. A project ref, branch or URL side is not modelled: `oneOf` only matches fixed values. const explicitLinkedSide = (flag: "from" | "to") => - withCondition(compose(dbConfigLinked, linkedProjectCache), { flag, oneOf: ["linked"] }); + withCondition(compose(dbConfigLinked, linkedProjectCache), { flag, oneOf: ["linked", "remote"] }); export const dbDiffCommand = Command.make("diff", config).pipe( Command.withDescription( @@ -131,6 +135,7 @@ export const dbDiffCommand = Command.make("diff", config).pipe( from: flags.from, to: flags.to, output: flags.output, + remote: flags.remote, "db-url": flags.dbUrl, linked: flags.linked, local: flags.local, diff --git a/apps/cli/src/commands/db/diff/diff.errors.ts b/apps/cli/src/commands/db/diff/diff.errors.ts index edec34492b..507a861337 100644 --- a/apps/cli/src/commands/db/diff/diff.errors.ts +++ b/apps/cli/src/commands/db/diff/diff.errors.ts @@ -31,18 +31,6 @@ export class DbDiffExplicitFlagsError extends Data.TaggedError("DbDiffExplicitFl } } -/** - * An explicit `--from`/`--to` ref was neither `local`/`linked`/`migrations` nor a - * postgres URL; message text is an established output contract. - */ -export class DbDiffUnknownTargetError extends Data.TaggedError("DbDiffUnknownTargetError")<{ - readonly message: string; -}> { - get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { - return actionability.provideFlags; - } -} - /** * Writing the diff output failed — a `--file` migration, or an explicit-mode * `--output` file. diff --git a/apps/cli/src/commands/db/diff/diff.explicit.ts b/apps/cli/src/commands/db/diff/diff.explicit.ts index 876be4f039..4b318eac3a 100644 --- a/apps/cli/src/commands/db/diff/diff.explicit.ts +++ b/apps/cli/src/commands/db/diff/diff.explicit.ts @@ -1,20 +1,31 @@ -import { isPostgresURL } from "../../../command-internal/pgdelta.ts"; +import { + classifyNamedRemote, + type NamedRemoteValue, +} from "../../../command-internal/target/remote-value.ts"; -/** The kinds an explicit `--from`/`--to` ref resolves to. */ -export type ExplicitRefKind = "local" | "linked" | "migrations" | "url" | "unknown"; +/** What an explicit `--from`/`--to` value names. */ +export type ExplicitRef = + | { readonly _tag: "Local" } + | { readonly _tag: "Migrations" } + | { readonly _tag: "Remote"; readonly viaDeprecatedLinked: boolean } + | NamedRemoteValue; /** - * Classifies an explicit `--from`/`--to` ref: `local`/`linked`/`migrations` are - * the named targets; anything else must be a `postgres://` / `postgresql://` - * URL, otherwise it is unknown. + * `local`, `migrations` and `remote` (the linked project) are keywords; `linked` is the + * deprecated spelling of `remote`. Anything else is classified like a `--remote` value, so a + * branch that shares a keyword's name is reached by its ref or UUID. */ -export function classifyExplicitRef(ref: string): ExplicitRefKind { - if (ref === "local" || ref === "linked" || ref === "migrations") return ref; - if (isPostgresURL(ref)) return "url"; - return "unknown"; -} - -/** Unknown-target error message; text is an established output contract. */ -export function unknownTargetMessage(ref: string): string { - return `unknown target ${JSON.stringify(ref)}: must be one of 'local', 'linked', 'migrations', or a postgres:// URL`; +export function classifyExplicitRef(ref: string): ExplicitRef { + switch (ref) { + case "local": + return { _tag: "Local" }; + case "migrations": + return { _tag: "Migrations" }; + case "remote": + return { _tag: "Remote", viaDeprecatedLinked: false }; + case "linked": + return { _tag: "Remote", viaDeprecatedLinked: true }; + default: + return classifyNamedRemote(ref); + } } diff --git a/apps/cli/src/commands/db/diff/diff.handler.ts b/apps/cli/src/commands/db/diff/diff.handler.ts index da8a40c260..3bcf14abf2 100644 --- a/apps/cli/src/commands/db/diff/diff.handler.ts +++ b/apps/cli/src/commands/db/diff/diff.handler.ts @@ -22,9 +22,11 @@ import { import { DbConfigResolver } from "../../../command-internal/db-config.service.ts"; import { hostedMutationPreapproval } from "../../../command-internal/hosted-consent/hosted-consent.ts"; import { + resolveRemoteTarget, resolveTarget, - type ResolvedTarget, + type LinkedTarget, } from "../../../command-internal/target/resolve-target.ts"; +import type { RemoteValue } from "../../../command-internal/target/remote-value.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"; @@ -77,12 +79,11 @@ import { writePgDeltaMigrations } from "../shared/pgdelta-migrations.write.ts"; import { type PgDeltaContext, isPgDeltaDebugEnabled } from "../../../command-internal/pgdelta.ts"; import { prepareShadowSource } from "../shared/shadow-source.ts"; import type { DbDiffFlags } from "./diff.command.ts"; -import { classifyExplicitRef, unknownTargetMessage } from "./diff.explicit.ts"; +import { classifyExplicitRef } from "./diff.explicit.ts"; import { DbDiffDbNotRunningError, DbDiffEngineConflictError, DbDiffExplicitFlagsError, - DbDiffUnknownTargetError, DbDiffWriteError, } from "./diff.errors.ts"; import { diffSchemaPgAdmin } from "./pgadmin-diff.ts"; @@ -178,20 +179,23 @@ 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 linked project resolves once, so `--from linked --to linked` announces it once. - let linkedTarget: ResolvedTarget | undefined; - // Each linked resolution mints a temporary login role that invalidates the previous one, - // so every `linked` ref shares a single connection. - let linkedEndpoint: PgDeltaDatabaseEndpoint | undefined; - // A target flag validates the selection and, for the linked project, merges + // The project `remote` names resolves once, so `--from remote --to remote` announces it once. + let linkedTarget: LinkedTarget | undefined; + const namedTargets = new Map<string, LinkedTarget>(); + // Each hosted resolution mints a temporary login role that invalidates the previous one for + // that project, so every ref to a project shares a single connection. + const hostedEndpoints = new Map<string, PgDeltaDatabaseEndpoint>(); + // A target flag validates the selection and, for a hosted project, merges // `[remotes.<ref>]` so the explicit `local`/`migrations` refs below see that override. if ( flags.local || flags.linked || + Option.isSome(flags.remote) || Option.isSome(flags.dbUrl) || Option.isSome(flags.projectRef) ) { const preflight = yield* resolveTarget({ + remote: flags.remote, local: flags.local, linked: flags.linked, dbUrl: flags.dbUrl, @@ -204,19 +208,62 @@ export const dbDiff = Effect.fn("db.diff")(function* (flags: DbDiffFlags) { } } // Read config once, after the preflight: the `[remotes.<ref>]`-merged config - // when a changed `--linked` resolved a ref (so base config isn't validated + // when a target flag resolved a ref (so base config isn't validated // before the merge), else the base config. let cfg = mergedLinkedRef !== undefined ? yield* readDbToml(fs, path, cliSettings.workdir, mergedLinkedRef) : yield* readDbToml(fs, path, cliSettings.workdir); - // Each ref resolves in order; the `linked` branch re-merges the matching - // `[remotes.<ref>]` block so a later `local` ref read and the trailing - // `pgDeltaFormatOptions()` see the override. Thread the merged config through. - const resolveRef = (ref: string) => + const hostedTarget = Effect.fnUntraced(function* ( + remote: Exclude<RemoteValue, { readonly _tag: "DbUrl" }>, + ) { + if (remote._tag === "Linked") { + linkedTarget ??= yield* resolveRemoteTarget(remote); + return linkedTarget; + } + const key = remote._tag === "Ref" ? remote.ref : `branch:${remote.name}`; + const known = namedTargets.get(key); + if (known !== undefined) return known; + const resolvedTarget = yield* resolveRemoteTarget(remote); + namedTargets.set(key, resolvedTarget); + return resolvedTarget; + }); + const hostedEndpoint = Effect.fnUntraced(function* ( + remote: Exclude<RemoteValue, { readonly _tag: "DbUrl" }>, + ) { + const target = yield* hostedTarget(remote); + const existing = hostedEndpoints.get(target.ref); + if (existing !== undefined) return existing; + const resolved = yield* resolver.resolveConnection({ + target, + dnsResolver, + password: Option.none(), + networkBanRemoval: yield* hostedMutationPreapproval, + }); + const ref2 = Option.getOrUndefined(resolved.ref ?? Option.none()); + if (ref2 !== undefined) { + yield* Ref.set(linkedRefForCache, Option.some(ref2)); + if (mergedLinkedRef === undefined) { + mergedLinkedRef = ref2; + cfg = yield* readDbToml(fs, path, cliSettings.workdir, ref2); + } + } + const endpoint = { + kind: "database", + ref: toPostgresURL(resolved.conn), + connection: resolved.conn, + connectOptions: { isLocal: resolved.isLocal, dnsResolver }, + } satisfies PgDeltaDatabaseEndpoint; + hostedEndpoints.set(target.ref, endpoint); + return endpoint; + }); + // Each ref resolves in order; the first hosted project re-merges its `[remotes.<ref>]` block + // so a later `local` ref read and the trailing `pgDeltaFormatOptions()` see the override. + const resolveRef = (ref: string, flag: "--from" | "--to") => Effect.gen(function* () { - switch (classifyExplicitRef(ref)) { - case "local": { + const explicit = classifyExplicitRef(ref); + switch (explicit._tag) { + case "Local": { const backend = yield* currentStackBackend; if (backend.kind !== "stack") { const hostConfig = yield* configValues.load({ @@ -262,35 +309,17 @@ export const dbDiff = Effect.fn("db.diff")(function* (flags: DbDiffFlags) { connectOptions: { isLocal: true, dnsResolver }, } satisfies PgDeltaDatabaseEndpoint; } - case "linked": { - if (linkedEndpoint !== undefined) return linkedEndpoint; - linkedTarget ??= yield* resolveTarget({ - local: false, - linked: true, - dbUrl: Option.none(), - projectRef: flags.projectRef, - }); - const resolved = yield* resolver.resolveConnection({ - target: linkedTarget, - dnsResolver, - password: Option.none(), - networkBanRemoval: yield* hostedMutationPreapproval, - }); - const ref2 = Option.getOrUndefined(resolved.ref ?? Option.none()); - if (ref2 !== undefined) { - yield* Ref.set(linkedRefForCache, Option.some(ref2)); - mergedLinkedRef = ref2; - cfg = yield* readDbToml(fs, path, cliSettings.workdir, ref2); + case "Remote": + if (explicit.viaDeprecatedLinked) { + yield* output.warn( + `${flag} linked is deprecated and will be removed in v4. Use ${flag} remote instead.`, + ); } - linkedEndpoint = { - kind: "database", - ref: toPostgresURL(resolved.conn), - connection: resolved.conn, - connectOptions: { isLocal: resolved.isLocal, dnsResolver }, - }; - return linkedEndpoint; - } - case "migrations": + return yield* hostedEndpoint({ _tag: "Linked" }); + case "Ref": + case "Branch": + return yield* hostedEndpoint(explicit); + case "Migrations": // Preserve resolution order: the migrations shadow/catalog config must reflect // only refs resolved before this endpoint. migrationsToml ??= cfg; @@ -298,20 +327,18 @@ export const dbDiff = Effect.fn("db.diff")(function* (flags: DbDiffFlags) { kind: "migrations", ...(mergedLinkedRef !== undefined ? { projectRef: mergedLinkedRef } : {}), } satisfies PgDeltaEndpoint; - case "url": + case "DbUrl": return { kind: "database", - ref, + ref: explicit.url, // The next engine parses arbitrary explicit URLs itself; they connect as remote // by default, so TLS is used. connectOptions: { isLocal: false, dnsResolver }, } satisfies PgDeltaDatabaseEndpoint; - case "unknown": - return yield* new DbDiffUnknownTargetError({ message: unknownTargetMessage(ref) }); } }); - const source = yield* resolveRef(from); - const desired = yield* resolveRef(to); + const source = yield* resolveRef(from, "--from"); + const desired = yield* resolveRef(to, "--to"); const explicitResolvedConfig = yield* configValues.load({ workdir: cliSettings.workdir, projectRef: Option.fromNullishOr(mergedLinkedRef), @@ -373,6 +400,7 @@ export const dbDiff = Effect.fn("db.diff")(function* (flags: DbDiffFlags) { // Native path: resolve the target, provision a live shadow source, then diff. const diffTarget = yield* resolveTarget({ + remote: flags.remote, local: flags.local, linked: flags.linked, dbUrl: flags.dbUrl, diff --git a/apps/cli/src/commands/db/dump/dump.command.ts b/apps/cli/src/commands/db/dump/dump.command.ts index 81d76db57d..c0ec2d7257 100644 --- a/apps/cli/src/commands/db/dump/dump.command.ts +++ b/apps/cli/src/commands/db/dump/dump.command.ts @@ -77,7 +77,7 @@ const config = { Flag.withDescription("File path to save the dumped contents."), Flag.optional, ), - ...targetFlags({ group: "selectable", subject: "database", dbUrl: true }), + ...targetFlags({ subject: "database", dbUrl: true }), password: CliConfigKeys.linkedDb.password.flag({ name: "password", alias: "p", @@ -135,6 +135,7 @@ export const dbDumpCommand = Command.make("dump", config).pipe( "role-only": flags.roleOnly, "keep-comments": flags.keepComments, file: flags.file, + remote: flags.remote, "db-url": flags.dbUrl, linked: flags.linked, local: flags.local, diff --git a/apps/cli/src/commands/db/dump/dump.handler.ts b/apps/cli/src/commands/db/dump/dump.handler.ts index d54154fbca..946f941947 100644 --- a/apps/cli/src/commands/db/dump/dump.handler.ts +++ b/apps/cli/src/commands/db/dump/dump.handler.ts @@ -135,6 +135,7 @@ export const dbDump = Effect.fn("db.dump")(function* (flags: DbDumpFlags) { // 3. Resolve the target (a selector flag, else the workspace target) and connect to it. const target = yield* resolveTarget({ + remote: flags.remote, local: flags.local, linked: flags.linked, dbUrl: flags.dbUrl, diff --git a/apps/cli/src/commands/db/lint/lint.command.ts b/apps/cli/src/commands/db/lint/lint.command.ts index 982eaf59c5..b5ae2198ee 100644 --- a/apps/cli/src/commands/db/lint/lint.command.ts +++ b/apps/cli/src/commands/db/lint/lint.command.ts @@ -19,7 +19,7 @@ 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 }), + ...targetFlags({ subject: "database", dbUrl: true }), schema: Flag.String("schema").pipe( Flag.withAlias("s"), Flag.withDescription("Comma separated list of schema to include."), @@ -58,6 +58,7 @@ export const dbLintCommand = Command.make("lint", config).pipe( dbLint(flags).pipe( withCommandTelemetry({ flags: { + remote: flags.remote, "db-url": flags.dbUrl, linked: flags.linked, local: flags.local, diff --git a/apps/cli/src/commands/db/lint/lint.handler.ts b/apps/cli/src/commands/db/lint/lint.handler.ts index e98a9acb63..4ca5c41e98 100644 --- a/apps/cli/src/commands/db/lint/lint.handler.ts +++ b/apps/cli/src/commands/db/lint/lint.handler.ts @@ -102,6 +102,7 @@ const runLint = Effect.fnUntraced(function* (flags: DbLintFlags, dnsResolver: "n const processControl = yield* ProcessControl; const target = yield* resolveTarget({ + remote: flags.remote, local: flags.local, linked: flags.linked, dbUrl: flags.dbUrl, diff --git a/apps/cli/src/commands/db/pull/pull.command.ts b/apps/cli/src/commands/db/pull/pull.command.ts index e120f049ac..ebad2fab52 100644 --- a/apps/cli/src/commands/db/pull/pull.command.ts +++ b/apps/cli/src/commands/db/pull/pull.command.ts @@ -63,7 +63,7 @@ const config = { (err) => (err instanceof Error ? err.message : String(err)), ), ), - ...targetFlags({ group: "sync", subject: "database", dbUrl: true }), + ...targetFlags({ subject: "database", dbUrl: true }), password: CliConfigKeys.linkedDb.password.flag({ name: "password", alias: "p", @@ -91,6 +91,7 @@ const telemetryOptions = (flags: DbPullFlags) => ({ "diff-engine": flags.diffEngine, "strict-coverage": flags.strictCoverage, schema: flags.schema, + remote: flags.remote, "db-url": flags.dbUrl, linked: flags.linked, local: flags.local, @@ -125,7 +126,7 @@ export const dbPullCommand = Command.make("pull", config).pipe( dbConfigPoolerFallback, withCondition(linkedProjectCache, { dbTarget: ["linked"] }), ), - { default: "linked" }, + { default: "local" }, ), ), Command.withHandler((flags) => { @@ -160,5 +161,5 @@ export const dbPullCommand = Command.make("pull", config).pipe( }), Command.provide(cliConfigValuesLayer), withCliConfigFlags(config), - withTargetPolicy({ group: "sync", consent: "no" }), + withTargetPolicy({ group: "selectable", 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 15d19760b3..501b80ffd3 100644 --- a/apps/cli/src/commands/db/push/push.command.ts +++ b/apps/cli/src/commands/db/push/push.command.ts @@ -42,7 +42,7 @@ const config = { ), Flag.withDefault(false), ), - ...targetFlags({ group: "sync", subject: "database", dbUrl: true }), + ...targetFlags({ subject: "database", dbUrl: true }), password: CliConfigKeys.linkedDb.password.flag({ name: "password", alias: "p", @@ -74,7 +74,7 @@ export const dbPushCommand = Command.make("push", config).pipe( dbConfig, withCondition(linkedProjectCache, { dbTarget: ["linked"] }), ), - { default: "linked" }, + { default: "local" }, ), ), Command.withHandler((flags) => @@ -86,6 +86,7 @@ export const dbPushCommand = Command.make("push", config).pipe( "include-seed": flags.includeSeed, "skip-vault": flags.skipVault, "dry-run": flags.dryRun, + remote: flags.remote, "db-url": flags.dbUrl, linked: flags.linked, local: flags.local, @@ -102,5 +103,5 @@ export const dbPushCommand = Command.make("push", config).pipe( Command.provide(dbPushRuntimeLayer), Command.provide(cliConfigValuesLayer), withCliConfigFlags(config), - withTargetPolicy({ group: "sync", consent: "no" }), + withTargetPolicy({ group: "selectable", consent: "no" }), ); diff --git a/apps/cli/src/commands/db/push/push.handler.ts b/apps/cli/src/commands/db/push/push.handler.ts index f1d639d8d7..550ee6254a 100644 --- a/apps/cli/src/commands/db/push/push.handler.ts +++ b/apps/cli/src/commands/db/push/push.handler.ts @@ -38,6 +38,7 @@ export const dbPush = Effect.fn("db.push")(function* (flags: DbPushFlags) { const body = Effect.gen(function* () { const target = yield* resolveTarget({ + remote: flags.remote, local: flags.local, linked: flags.linked, dbUrl: flags.dbUrl, diff --git a/apps/cli/src/commands/db/query/query.command.ts b/apps/cli/src/commands/db/query/query.command.ts index 3c2fea749e..ebf4b97615 100644 --- a/apps/cli/src/commands/db/query/query.command.ts +++ b/apps/cli/src/commands/db/query/query.command.ts @@ -29,7 +29,7 @@ const config = { Argument.withDescription("SQL query to execute."), Argument.optional, ), - ...targetFlags({ group: "selectable", subject: "database", dbUrl: true }), + ...targetFlags({ subject: "database", dbUrl: true }), file: Flag.String("file").pipe( Flag.withAlias("f"), Flag.withDescription("Path to a SQL file to execute."), @@ -66,6 +66,7 @@ export const dbQueryCommand = Command.make("query", config).pipe( dbQuery(flags).pipe( withCommandTelemetry({ flags: { + remote: flags.remote, "db-url": flags.dbUrl, linked: flags.linked, local: flags.local, diff --git a/apps/cli/src/commands/db/query/query.handler.ts b/apps/cli/src/commands/db/query/query.handler.ts index 6b0ab41bb6..a3a82148cc 100644 --- a/apps/cli/src/commands/db/query/query.handler.ts +++ b/apps/cli/src/commands/db/query/query.handler.ts @@ -269,6 +269,7 @@ export const dbQuery = Effect.fn("db.query")(function* (flags: DbQueryFlags) { yield* Effect.gen(function* () { const target = yield* resolveTarget({ + remote: flags.remote, local: flags.local, linked: flags.linked, dbUrl: flags.dbUrl, diff --git a/apps/cli/src/commands/db/reset/reset.command.ts b/apps/cli/src/commands/db/reset/reset.command.ts index e37fb1a059..ee3f19b2c4 100644 --- a/apps/cli/src/commands/db/reset/reset.command.ts +++ b/apps/cli/src/commands/db/reset/reset.command.ts @@ -20,7 +20,7 @@ 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 }), + ...targetFlags({ subject: "database", dbUrl: true }), noSeed: CliConfigKeys.db.seed.enabled.flag({ name: "no-seed", description: "Skip running the seed script after reset.", @@ -61,6 +61,7 @@ export const dbResetCommand = Command.make("reset", config).pipe( dbReset(flags).pipe( withCommandTelemetry({ flags: { + remote: flags.remote, "db-url": flags.dbUrl, linked: flags.linked, local: flags.local, diff --git a/apps/cli/src/commands/db/reset/reset.handler.ts b/apps/cli/src/commands/db/reset/reset.handler.ts index a2ba065a00..69e15cf7e8 100644 --- a/apps/cli/src/commands/db/reset/reset.handler.ts +++ b/apps/cli/src/commands/db/reset/reset.handler.ts @@ -154,6 +154,7 @@ export const dbReset = Effect.fn("db.reset")(function* (flags: DbResetFlags) { } const target = yield* resolveTarget({ + remote: flags.remote, local: flags.local, linked: flags.linked, dbUrl: flags.dbUrl, 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 a69d04a4f0..5f9574c240 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 @@ -25,7 +25,7 @@ import { targetFlags } from "../../../../../command-internal/target/target-flags import { withTargetPolicy } from "../../../../../command-internal/target/target-policy.ts"; const config = { - ...targetFlags({ group: "selectable", subject: "database", dbUrl: true }), + ...targetFlags({ subject: "database", dbUrl: true }), overwrite: Flag.Boolean("overwrite").pipe( Flag.withDescription("Overwrite declarative schema files without confirmation."), Flag.withDefault(false), @@ -124,6 +124,7 @@ export const dbSchemaDeclarativeGenerateCommand = Command.make("generate", confi "output-dir": merged.outputDir, reset: merged.reset, schema: merged.schema, + remote: merged.remote, "db-url": merged.dbUrl, linked: merged.linked, local: merged.local, diff --git a/apps/cli/src/commands/functions/deploy/SIDE_EFFECTS.md b/apps/cli/src/commands/functions/deploy/SIDE_EFFECTS.md index 4a0204a380..1708b2d35c 100644 --- a/apps/cli/src/commands/functions/deploy/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/functions/deploy/SIDE_EFFECTS.md @@ -46,7 +46,8 @@ Docker bundling may pull or run the configured edge-runtime image and uses the | `PATCH` | `/v1/projects/{ref}/functions/{slug}` | Bearer token | bundled function body | `{ id, slug, ... }` | | `PUT` | `/v1/projects/{ref}/functions` | Bearer token | bulk update payload | none (only status `200` is checked) | | `DELETE` | `/v1/projects/{ref}/functions/{slug}` | Bearer token | none | ignored on `200/404` | -| `GET` | `/v1/projects` | Bearer token | none | `[{id, name, organization_slug, region}]` — project picker: no project ref from `--project-ref`, `SUPABASE_PROJECT_ID` or `supabase/.temp/project-ref`, with stdin and stdout TTYs and a non-JSON output format | +| `GET` | `/v1/projects` | Bearer token | none | `[{id, name, organization_slug, region}]` — project picker: no project ref from `--remote=<ref>`, `SUPABASE_PROJECT_ID` or `supabase/.temp/project-ref`, with stdin and stdout TTYs and a non-JSON output format | +| `GET` | `/v1/projects/{parent}/branches` | Bearer token | none | `[{id, name, project_ref, is_default}]` — only for `--remote=<branch>` (or the deprecated `--project-ref=<branch>`), and for a bare `--remote` followed by a word that could name a branch; `{parent}` is `SUPABASE_PROJECT_ID`, else the linked project | | `GET` | `/v1/projects/{ref}` | Bearer token | none | `{ref, name, organization_id, organization_slug}` — linked-project cache miss only (best-effort, post-run): written only when `<workdir>/supabase/.temp/linked-project.json` doesn't exist, `.temp/project-ref` doesn't name another ref, an access token exists and the lookup returns 200 | ## Environment Variables @@ -54,7 +55,7 @@ Docker bundling may pull or run the configured edge-runtime image and uses the | Variable | Purpose | Required? | | ------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------- | | `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | -| `SUPABASE_PROJECT_ID` | optional project ref fallback; also read from project dotenv now (previously ambient-shell-only) | no | +| `SUPABASE_PROJECT_ID` | optional project ref fallback and the parent project that `--remote=<branch>` is resolved against; also read from project dotenv now (previously ambient-shell-only) | no | | `SUPABASE_ENV` | selects environment-specific dotenv files (`.env.<env>.local`, `.env.<env>`) | no (defaults to `development`) | | `SUPABASE_INTERNAL_IMAGE_REGISTRY` | selects the Functions bundler image registry; read from the ambient shell **or** project dotenv; unset resolves ECR->GHCR->Docker-Hub candidates in order instead of a single URL | no | | `SUPABASE_USE_SLIM_IMAGES` | resolves the Functions bundler image from the slim `ghcr.io/supabase/cli/edge-runtime` build (`true`/`1` enable); `deno_version = 1` and historical `.temp/edge-runtime-version` pins stay on docker.io; ambient shell only, unlike the neighboring registry override | no | @@ -111,7 +112,17 @@ The `--output`/`-o` flag does not change deploy output. directory, forward slashes). Imports outside the workdir but inside the nearest git root still upload, with `../`-relative names. The nearest git root is the containment boundary for uploaded imports. -- Requires a linked project unless `--project-ref` is provided. +- Requires a linked project unless `--remote=<ref|branch>` is given. A bare `--remote` targets the + linked project. +- `--remote=<value>` is read as a project ref (20 lowercase letters), a branch of the linked + project (by name or UUID), or `true|yes|on|1` for the linked project. A database URL is rejected + (`functions deploy` takes a project, not a database); `false|no|off|0`, `--no-remote` and an + empty value fail with `Leave out --remote to use the linked project.` +- A bare `--remote` followed by a word that is a branch of the linked project fails before any + other work with a `Did you mean --remote=<word>?` hint, since the word may have been meant as the + flag's value. +- The deprecated `--project-ref` is an alias of `--remote=<ref>` and prints a deprecation warning + on stderr that names the replacement; it cannot be combined with `--remote=<value>`. - 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. diff --git a/apps/cli/src/commands/functions/deploy/deploy.command.ts b/apps/cli/src/commands/functions/deploy/deploy.command.ts index 04be45da63..28af22f037 100644 --- a/apps/cli/src/commands/functions/deploy/deploy.command.ts +++ b/apps/cli/src/commands/functions/deploy/deploy.command.ts @@ -1,7 +1,14 @@ +import { Option } from "effect"; import { Argument, 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 } from "../../../command-internal/command-permissions/model.ts"; +import { branchResolution } from "../../../command-internal/target/branch-resolution.ts"; +import { + projectTargetFlags, + remoteTelemetryFlag, +} from "../../../command-internal/target/target-flags.ts"; +import { PROJECT_REF_PATTERN } from "../../../config/project-ref.service.ts"; import { projectRefPicker } from "../../../config/project-ref.layer.ts"; import { linkedProjectCache } from "../../../telemetry/linked-project-cache.layer.ts"; import { withCliConfigFlags } from "../../../config/cli-config-flags.ts"; @@ -18,10 +25,7 @@ const config = { Argument.withDescription("Names of Functions to deploy. Deploys all if omitted."), Argument.variadic(), ), - projectRef: Flag.String("project-ref").pipe( - Flag.withDescription("Project ref of the Supabase project."), - Flag.optional, - ), + ...projectTargetFlags(), noVerifyJwt: Flag.Boolean("no-verify-jwt").pipe( Flag.withDescription("Disable JWT verification for the Function."), Flag.withDefault(false), @@ -148,11 +152,18 @@ export const functionsDeployCommand = Command.make("deploy", config).pipe( }, projectRefPicker, linkedProjectCache, + branchResolution, ), ), Command.withHandler((flags) => functionsDeploy(flags).pipe( - withCommandTelemetry({ flags, safeFlags: FUNCTIONS_PROJECT_REF_SAFE_FLAGS }), + withCommandTelemetry({ + flags: { ...flags, remote: remoteTelemetryFlag(flags.remote) }, + safeFlags: + Option.isSome(flags.projectRef) && PROJECT_REF_PATTERN.test(flags.projectRef.value) + ? [...FUNCTIONS_PROJECT_REF_SAFE_FLAGS, "remote"] + : ["remote"], + }), withJsonErrorHandling, ), ), diff --git a/apps/cli/src/commands/functions/deploy/deploy.handler.ts b/apps/cli/src/commands/functions/deploy/deploy.handler.ts index d79ee3833a..e223bca8d1 100644 --- a/apps/cli/src/commands/functions/deploy/deploy.handler.ts +++ b/apps/cli/src/commands/functions/deploy/deploy.handler.ts @@ -5,7 +5,7 @@ import { aqua, bold, yellow } from "../../../command-internal/colors.ts"; import { functionsLocalConfigLoader } from "../../../command-internal/functions-local-config.ts"; 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 { resolveProjectTarget } from "../../../command-internal/target/resolve-project-target.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"; @@ -17,7 +17,6 @@ export const functionsDeploy = Effect.fn("functions.deploy")(function* ( ) { const api = yield* CommandPlatformApi; const cliSettings = yield* CommandSettings; - const resolver = yield* ProjectRefResolver; const linkedProjectCache = yield* LinkedProjectCache; const telemetryState = yield* TelemetryState; const runtimeInfo = yield* RuntimeInfo; @@ -39,10 +38,10 @@ export const functionsDeploy = Effect.fn("functions.deploy")(function* ( localConfigLoader: functionsLocalConfigLoader, rawArgs, edgeRuntimeVersion, - resolveProjectRef: (projectRef) => - resolver - .resolve(projectRef) - .pipe(Effect.tap((ref) => Ref.set(resolvedProjectRef, Option.some(ref)))), + resolveProjectRef: () => + resolveProjectTarget({ remote: flags.remote, projectRef: flags.projectRef }).pipe( + Effect.tap((ref) => Ref.set(resolvedProjectRef, Option.some(ref))), + ), // Written to stdout, so the TTY color gate must check stdout. styleIdentifier: (text) => aqua(text, process.stdout), // Written to stderr, matching `bold`'s default TTY gate. diff --git a/apps/cli/src/commands/gen/types/types.command.ts b/apps/cli/src/commands/gen/types/types.command.ts index 6fe53abd5a..5aef035d1c 100644 --- a/apps/cli/src/commands/gen/types/types.command.ts +++ b/apps/cli/src/commands/gen/types/types.command.ts @@ -34,13 +34,14 @@ import { cliConfigValuesLayer } from "../../../config/cli-config-values.layer.ts import { withCliConfigFlags } from "../../../config/cli-config-flags.ts"; import { deprecatedProjectIdFlag, + remoteTelemetryFlag, 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 = { - ...targetFlags({ group: "selectable", subject: "database", dbUrl: true }), + ...targetFlags({ subject: "database", dbUrl: true }), projectId: deprecatedProjectIdFlag, lang: Flag.Literals("lang", GEN_TYPES_LANGUAGES).pipe( Flag.withDescription("Output language of the generated types. (default typescript)"), @@ -179,9 +180,10 @@ export const genTypesCommand = Command.make("types", commandConfig).pipe( Command.withHandler((flags) => genTypes(flags).pipe( withCommandTelemetry({ - flags, + flags: { ...flags, remote: remoteTelemetryFlag(flags.remote) }, safeFlags: [ "project-id", + "remote", ...(Option.isSome(flags.projectRef) && PROJECT_REF_PATTERN.test(flags.projectRef.value) ? ["project-ref"] : []), diff --git a/apps/cli/src/commands/gen/types/types.handler.ts b/apps/cli/src/commands/gen/types/types.handler.ts index f2b6617d17..0da7dc017d 100644 --- a/apps/cli/src/commands/gen/types/types.handler.ts +++ b/apps/cli/src/commands/gen/types/types.handler.ts @@ -30,7 +30,6 @@ import { 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"; @@ -115,19 +114,16 @@ 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"]); +// The hosted-project selectors name one project, so they count as one member of a mutex group. +const REMOTE_TARGET_FLAGS: ReadonlySet<string> = new Set(["remote", "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"], - ["linked", "project-ref", "query-timeout"], - ...GEN_TYPES_LANGUAGE_FLAG_NAMES.map((name) => ["linked", "project-ref", name]), + ["remote", "linked", "project-ref", "postgrest-v9-compat"], + ["remote", "linked", "project-ref", "query-timeout"], + ...GEN_TYPES_LANGUAGE_FLAG_NAMES.map((name) => ["remote", "linked", "project-ref", name]), ]; -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."; @@ -569,7 +565,7 @@ export const genTypes = Effect.fn("gen.types")(function* (flags: GenTypesFlags) return yield* new GenTypesNetworkIdUnsupportedError({ message: "gen types now generates types in-process and cannot join a Docker network via " + - "--network-id; use a host-reachable --db-url instead.", + "--network-id; use a host-reachable --remote=<db-url> instead.", }); } @@ -577,25 +573,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); + const remoteIsDatabase = Option.exists(flags.remote, (value) => value._tag === "DbUrl"); // 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. - if (flags.postgrestV9Compat && Option.isNone(flags.dbUrl)) { + if (flags.postgrestV9Compat && Option.isNone(flags.dbUrl) && !remoteIsDatabase) { // Established error text, including the "must used" typo — do not // "fix" the grammar. return yield* new GenTypesFlagUsageError({ - message: "--postgrest-v9-compat must used together with --db-url", + message: "--postgrest-v9-compat must used together with --remote=<db-url>", }); } const positionalLang = findPositionalLanguage(rawArgs); @@ -611,6 +599,7 @@ export const genTypes = Effect.fn("gen.types")(function* (flags: GenTypesFlags) // `--linked=false` counts as absent; every other flag counts once passed, whatever its value. const changedMutexFlags: Record<string, boolean> = { + remote: Option.isSome(flags.remote) && !remoteIsDatabase, linked: flags.linked, "project-ref": Option.isSome(projectRefFlag), "postgrest-v9-compat": occurrences.has("postgrest-v9-compat"), @@ -634,10 +623,12 @@ export const genTypes = Effect.fn("gen.types")(function* (flags: GenTypesFlags) yield* Effect.annotateCurrentSpan("typegen.lang", lang); const target = yield* resolveTarget({ + remote: flags.remote, local: flags.local, linked: flags.linked, dbUrl: flags.dbUrl, - projectRef: projectRefFlag, + projectRef: flags.projectRef, + projectId: flags.projectId, }); if (target.kind === "local") { 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 b22a50ba2d..586ba68b9f 100644 --- a/apps/cli/src/commands/inspect/db/inspect-db-command.ts +++ b/apps/cli/src/commands/inspect/db/inspect-db-command.ts @@ -12,11 +12,7 @@ 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 = targetFlags({ - group: "selectable", - subject: "database", - dbUrl: true, -}); +export const INSPECT_DB_FLAGS = targetFlags({ 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" }); @@ -38,6 +34,7 @@ export function inspectDbCommandHandler<E, R>( handler(flags).pipe( withCommandTelemetry({ flags: { + remote: flags.remote, "db-url": flags.dbUrl, linked: flags.linked, local: flags.local, diff --git a/apps/cli/src/commands/inspect/db/inspect-query.ts b/apps/cli/src/commands/inspect/db/inspect-query.ts index 31f0a71eed..0e3f677c0f 100644 --- a/apps/cli/src/commands/inspect/db/inspect-query.ts +++ b/apps/cli/src/commands/inspect/db/inspect-query.ts @@ -7,11 +7,13 @@ 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 { hostedMutationPreapproval } from "../../../command-internal/hosted-consent/hosted-consent.ts"; +import type { RemoteValue } from "../../../command-internal/target/remote-value.ts"; import { resolveTarget } from "../../../command-internal/target/resolve-target.ts"; import { TelemetryState } from "../../../telemetry/telemetry-state.service.ts"; /** The target selector flags every `inspect db` subcommand inherits; see `targetFlags`. */ export interface InspectConnectionFlags { + readonly remote: Option.Option<RemoteValue>; readonly dbUrl: Option.Option<string>; readonly linked: boolean; readonly local: boolean; 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 22152b47d7..2c72cff15e 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 @@ -17,7 +17,7 @@ export const inspectDbTableIndexSizesCommand = Command.make( // Removed: fails before any Management API call, so no flag changes what it calls. withPermissions({ operations: [], - noApiEffectFlags: ["db-url", "linked", "local", "project-ref"], + noApiEffectFlags: ["db-url", "linked", "local", "project-ref", "remote"], }), Command.withHandler(() => removedCommand("Use `supabase inspect db table-stats` instead.").pipe( 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 bccc10f235..4f4af66553 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 @@ -17,7 +17,7 @@ export const inspectDbTotalIndexSizeCommand = Command.make( // Removed: fails before any Management API call, so no flag changes what it calls. withPermissions({ operations: [], - noApiEffectFlags: ["db-url", "linked", "local", "project-ref"], + noApiEffectFlags: ["db-url", "linked", "local", "project-ref", "remote"], }), Command.withHandler(() => removedCommand("Use `supabase inspect db index-stats` instead.").pipe( 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 f8b3d16e7f..cd5119f83d 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 @@ -17,7 +17,7 @@ export const inspectDbTotalTableSizesCommand = Command.make( // Removed: fails before any Management API call, so no flag changes what it calls. withPermissions({ operations: [], - noApiEffectFlags: ["db-url", "linked", "local", "project-ref"], + noApiEffectFlags: ["db-url", "linked", "local", "project-ref", "remote"], }), Command.withHandler(() => removedCommand("Use `supabase inspect db table-stats` instead.").pipe( diff --git a/apps/cli/src/commands/inspect/report/report.command.ts b/apps/cli/src/commands/inspect/report/report.command.ts index 1af049c2eb..e9338dfabb 100644 --- a/apps/cli/src/commands/inspect/report/report.command.ts +++ b/apps/cli/src/commands/inspect/report/report.command.ts @@ -16,7 +16,7 @@ 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 }), + ...targetFlags({ subject: "database", dbUrl: true }), outputDir: Flag.String("output-dir").pipe( Flag.withDescription("Path to save CSV files in."), Flag.withDefault("."), @@ -39,6 +39,7 @@ export const inspectReportCommand = Command.make("report", config).pipe( inspectReport(flags).pipe( withCommandTelemetry({ flags: { + remote: flags.remote, "db-url": flags.dbUrl, linked: flags.linked, local: flags.local, diff --git a/apps/cli/src/commands/link/link.errors.ts b/apps/cli/src/commands/link/link.errors.ts index 5178ec88c9..4d030708ea 100644 --- a/apps/cli/src/commands/link/link.errors.ts +++ b/apps/cli/src/commands/link/link.errors.ts @@ -107,74 +107,3 @@ export class LinkRefArgConflictError extends Data.TaggedError("LinkRefArgConflic return actionability.invalidInput; } } - -/** - * A non-ref-shaped value was given (treated as a branch name) but no linked parent project - * could be resolved to search for that branch — none of `SUPABASE_PROJECT_ID`, - * `supabase/.temp/linked-project.json`, or `supabase/.temp/project-ref` yielded a candidate. - */ -export class LinkBranchNotLinkedError extends Data.TaggedError("LinkBranchNotLinkedError")<{ - readonly message: string; -}> { - get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { - return actionability.projectNotLinked; - } -} - -/** - * A parent-project candidate exists (`SUPABASE_PROJECT_ID`, `supabase/.temp/linked-project.json`, - * or `supabase/.temp/project-ref`) but none of them is ref-shaped — corrupt or stale linked state. - */ -export class LinkParentRefInvalidError extends Data.TaggedError("LinkParentRefInvalidError")<{ - readonly message: string; -}> { - get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { - return actionability.relinkProject; - } -} - -/** No branch with the given name/UUID exists on the resolved parent project. */ -export class LinkBranchNotFoundError extends Data.TaggedError("LinkBranchNotFoundError")<{ - readonly message: string; -}> { - get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { - return actionability.invalidInput; - } -} - -/** - * The resolved branch has no `project_ref` yet (e.g. `status: CREATING_PROJECT`). Guards - * against silently falling through to an unrelated ref elsewhere in the resolver chain. - */ -export class LinkBranchNotReadyError extends Data.TaggedError("LinkBranchNotReadyError")<{ - readonly branch: string; - readonly status: string; - readonly message: string; -}> { - get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { - return { ...actionability.apiStatus, fingerprint_suffix: "branch_not_ready" }; - } -} - -/** Transport (or response-decode) failure while listing branches for a branch-name lookup. */ -export class LinkBranchListNetworkError extends Data.TaggedError("LinkBranchListNetworkError")<{ - readonly message: string; - readonly decode?: boolean; -}> { - get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { - return this.decode === true - ? { ...actionability.apiStatus, fingerprint_suffix: "api_response" } - : actionability.externalNetwork; - } -} - -/** `GET /v1/projects/{ref}/branches` returned a non-200 status during a branch-name lookup. */ -export class LinkBranchListStatusError extends Data.TaggedError("LinkBranchListStatusError")<{ - readonly status: number; - readonly body: string; - readonly message: string; -}> { - get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { - return statusCodeActionability(this.status, { notFoundIsInvalidInput: true }); - } -} diff --git a/apps/cli/src/commands/link/link.handler.ts b/apps/cli/src/commands/link/link.handler.ts index e4b1ba8953..5ed2fc3596 100644 --- a/apps/cli/src/commands/link/link.handler.ts +++ b/apps/cli/src/commands/link/link.handler.ts @@ -1,4 +1,3 @@ -import type { V1ListAllBranchesOutput } from "@supabase/api/effect"; import { Duration, Effect, FileSystem, Option, Path, Ref, Schema } from "effect"; import type { PlatformError } from "effect/PlatformError"; @@ -21,46 +20,29 @@ import { import { classifyProjectLookupError } from "../../command-internal/branch-target.ts"; import { type CachedLinkedProject, - parentNotLinkedMessage, - parentRefInvalidMessage, - parentRefTypoHint, parseCachedLinkedProject, - resolveLinkedParentRef, } from "../../command-internal/parent-project-ref.ts"; import { dashboardUrl } from "../../command-internal/profile.ts"; import { mapTenantApiKeysError } from "../../command-internal/get-tenant-api-keys.ts"; -import { sanitizeInlineName, mapHttpError } from "../../command-internal/http-errors.ts"; +import { sanitizeInlineName } from "../../command-internal/http-errors.ts"; import { linkServicesCore } from "../../command-internal/link-services-core.ts"; import { extractServiceKeys } from "../../command-internal/tenant-keys.ts"; import { tempPaths } from "../../shared/config/temp-paths.ts"; import { LinkApiKeysNetworkError, LinkAuthTokenError, - LinkBranchListNetworkError, - LinkBranchListStatusError, - LinkBranchNotFoundError, - LinkBranchNotLinkedError, - LinkBranchNotReadyError, LinkMissingKeyError, - LinkParentRefInvalidError, LinkProjectStatusError, LinkProjectStatusNetworkError, LinkRefArgConflictError, ProjectPausedError, } from "./link.errors.ts"; +import { + type BranchResolution, + resolveLinkBranchRef, +} from "../../command-internal/target/branch-resolution.ts"; import type { LinkFlags } from "./link.command.ts"; -type LinkBranches = typeof V1ListAllBranchesOutput.Type; -type LinkBranch = LinkBranches[number]; - -/** Result of resolving a branch name/UUID to its project ref, threaded into the - * machine payload (`branch`, `parent_project_ref`) alongside the plain ref. */ -interface LinkBranchResolution { - readonly projectRef: string; - readonly branchName: string; - readonly parentRef: string; -} - // Classify a `getProject` failure: a 404 means the project is a branch (resolve // to `None`, link continues); any other status surfaces the body; transport // failures surface a network error. @@ -82,8 +64,6 @@ const mapApiKeysError = mapTenantApiKeysError({ // on the generated client's default 60s×5 retries. const LINK_CACHE_CORRELATION_TIMEOUT = Duration.seconds(5); -const LINK_MAX_LISTED_BRANCHES = 20; - const encodeLinkedProjectCache = Schema.encodeEffect( Schema.fromJsonString( Schema.Struct({ @@ -99,107 +79,6 @@ const encodeLinkedParentCache = Schema.encodeEffect( Schema.fromJsonString(Schema.Struct({ ref: Schema.String })), ); -function linkBranchNotFoundMessage( - value: string, - parentRef: string, - branches: LinkBranches, -): string { - if (branches.length === 0) { - return `Branch "${value}" not found: project ${parentRef} has no branches.${parentRefTypoHint(value)}`; - } - - const sortedNames = branches.map((branch) => branch.name).toSorted(); - const shown = sortedNames.slice(0, LINK_MAX_LISTED_BRANCHES); - const remaining = sortedNames.length - shown.length; - // Branch names are API-provided; sanitize them the same way response bodies are before - // embedding them in an error message. - const shownSanitized = sanitizeInlineName(shown.join(", ")); - const namesList = - remaining > 0 - ? `${shownSanitized}, … (${remaining} more — run supabase branches list)` - : shownSanitized; - - const trimmedLower = value.trim().toLowerCase(); - const nearMiss = branches.find((branch) => branch.name.toLowerCase() === trimmedLower); - // Sanitized like the list above — an API-provided name must not be able to inject - // ANSI/OSC/newline controls into the terminal. - const didYouMean = - nearMiss !== undefined ? ` Did you mean "${sanitizeInlineName(nearMiss.name)}"?` : ""; - - return ( - `Branch "${value}" not found for project ${parentRef}. Available branches: ${namesList}` + - `${didYouMean}${parentRefTypoHint(value)}` - ); -} - -/** - * Resolves a non-ref-shaped `[ref-or-branch]`/`--project-ref` value to the branch's project ref - * by looking it up (by name or UUID) against the parent project's branches. A ref-shaped value - * (20 lowercase letters) is always treated as a ref and never looked up as a branch name. - * - * Uses the list endpoint (`GET /v1/projects/{ref}/branches`) rather than a single-branch lookup - * so the full list can power the available-branches error enrichment below. - */ -const resolveLinkBranchRef = Effect.fn("link.resolveBranch")(function* (value: string) { - const output = yield* Output; - const api = yield* CommandPlatformApi; - - const parent = yield* resolveLinkedParentRef(); - if (parent.kind === "absent") { - return yield* new LinkBranchNotLinkedError({ message: parentNotLinkedMessage(value) }); - } - if (parent.kind === "invalid") { - return yield* new LinkParentRefInvalidError({ message: parentRefInvalidMessage(value) }); - } - const parentRef = parent.ref; - - const mapBranchListError = mapHttpError({ - networkError: LinkBranchListNetworkError, - statusError: LinkBranchListStatusError, - networkMessage: (cause) => `failed to list branches: ${cause}`, - statusMessage: (status, body) => - status === 404 - ? `Cannot list branches for project ${parentRef} (HTTP 404). If ${parentRef} is itself a preview branch, link its parent project first: supabase link --project-ref <parent-ref>` - : `unexpected list branches status ${status}: ${body}`, - }); - - const task = output.format === "text" ? yield* output.task("Resolving branch...") : undefined; - const branches: LinkBranches = yield* api.v1.listAllBranches({ ref: parentRef }).pipe( - Effect.tapError(() => task?.fail() ?? Effect.void), - Effect.catch(mapBranchListError), - ); - yield* task?.clear ?? Effect.void; - yield* Effect.annotateCurrentSpan({ - "project.parent_ref": parentRef, - "branch.count": branches.length, - }); - - const found: LinkBranch | undefined = branches.find( - // UUID matching is case-insensitive (canonical ids are lowercase hex, but uppercase input - // is a valid UUID spelling); name matching stays exact, with the did-you-mean hint covering - // near misses. - (branch) => branch.name === value || branch.id.toLowerCase() === value.toLowerCase(), - ); - if (found === undefined) { - return yield* new LinkBranchNotFoundError({ - message: linkBranchNotFoundMessage(value, parentRef, branches), - }); - } - - if (!PROJECT_REF_PATTERN.test(found.project_ref)) { - return yield* new LinkBranchNotReadyError({ - branch: found.name, - status: found.status, - message: `Branch "${sanitizeInlineName(found.name)}" has no project ref yet (status: ${found.status}). Wait for it to finish provisioning, then retry.`, - }); - } - - const line = `Resolved branch "${sanitizeInlineName(found.name)}" of project ${parentRef} to project ref ${found.project_ref}.`; - yield* output.format === "text" ? output.raw(`${line}\n`, "stderr") : output.info(line); - - return { projectRef: found.project_ref, branchName: found.name, parentRef }; -}); - export const link = Effect.fn("link")(function* (flags: LinkFlags) { const output = yield* Output; const api = yield* CommandPlatformApi; @@ -234,12 +113,19 @@ export const link = Effect.fn("link")(function* (flags: LinkFlags) { const requested = Option.isSome(refArg) ? refArg : projectRefFlag; + const resolveBranch = Effect.fnUntraced(function* (value: string) { + const found = yield* resolveLinkBranchRef(value); + const line = `Resolved branch "${sanitizeInlineName(found.branchName)}" of project ${found.parentRef} to project ref ${found.projectRef}.`; + yield* output.format === "text" ? output.raw(`${line}\n`, "stderr") : output.info(line); + return found; + }); + // A ref-shaped value (20 lowercase letters) is always treated as a ref and // never looked up as a branch name (see `resolveLinkBranchRef`). const branchResolution = Option.isSome(requested) && !PROJECT_REF_PATTERN.test(requested.value) - ? Option.some(yield* resolveLinkBranchRef(requested.value)) - : Option.none<LinkBranchResolution>(); + ? Option.some(yield* resolveBranch(requested.value)) + : Option.none<BranchResolution>(); const resolvedRefOrBranch = Option.isSome(branchResolution) ? Option.some(branchResolution.value.projectRef) diff --git a/apps/cli/src/commands/migration/down/down.command.ts b/apps/cli/src/commands/migration/down/down.command.ts index 5d45525df5..272926397d 100644 --- a/apps/cli/src/commands/migration/down/down.command.ts +++ b/apps/cli/src/commands/migration/down/down.command.ts @@ -34,7 +34,7 @@ const config = { (err) => (err instanceof Error ? err.message : String(err)), ), ), - ...targetFlags({ group: "selectable", subject: "database", dbUrl: true }), + ...targetFlags({ subject: "database", dbUrl: true }), } as const; export type MigrationDownFlags = CliCommand.Command.Config.Infer<typeof config>; @@ -57,6 +57,7 @@ export const migrationDownCommand = Command.make("down", config).pipe( withCommandTelemetry({ flags: { last: flags.last, + remote: flags.remote, "db-url": flags.dbUrl, linked: flags.linked, local: flags.local, diff --git a/apps/cli/src/commands/migration/fetch/fetch.command.ts b/apps/cli/src/commands/migration/fetch/fetch.command.ts index dab8d09942..899a0f0519 100644 --- a/apps/cli/src/commands/migration/fetch/fetch.command.ts +++ b/apps/cli/src/commands/migration/fetch/fetch.command.ts @@ -18,7 +18,7 @@ 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 = targetFlags({ group: "sync", subject: "database", dbUrl: true }); +const config = targetFlags({ subject: "database", dbUrl: true }); export type MigrationFetchFlags = CliCommand.Command.Config.Infer<typeof config>; @@ -32,13 +32,14 @@ export const migrationFetchCommand = Command.make("fetch", config).pipe( dbConfig, withCondition(linkedProjectCache, { dbTarget: ["linked"] }), ), - { default: "linked" }, + { default: "local" }, ), ), Command.withHandler((flags) => migrationFetch(flags).pipe( withCommandTelemetry({ flags: { + remote: flags.remote, "db-url": flags.dbUrl, linked: flags.linked, local: flags.local, @@ -53,7 +54,7 @@ export const migrationFetchCommand = Command.make("fetch", config).pipe( Command.provide(cliConfigValuesLayer), withCliConfigFlags(config), withTargetPolicy({ - group: "sync", + group: "selectable", 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 857ecfe2e5..c8c1b116fb 100644 --- a/apps/cli/src/commands/migration/list/list.command.ts +++ b/apps/cli/src/commands/migration/list/list.command.ts @@ -20,7 +20,7 @@ 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 }), + ...targetFlags({ subject: "database", dbUrl: true }), password: CliConfigKeys.linkedDb.password.flag({ name: "password", alias: "p", @@ -50,6 +50,7 @@ export const migrationListCommand = Command.make("list", config).pipe( migrationList(flags).pipe( withCommandTelemetry({ flags: { + remote: flags.remote, "db-url": flags.dbUrl, linked: flags.linked, local: flags.local, diff --git a/apps/cli/src/commands/migration/repair/repair.command.ts b/apps/cli/src/commands/migration/repair/repair.command.ts index 3244ba5000..b1f40c6e33 100644 --- a/apps/cli/src/commands/migration/repair/repair.command.ts +++ b/apps/cli/src/commands/migration/repair/repair.command.ts @@ -26,7 +26,7 @@ const config = { status: Flag.Literals("status", ["applied", "reverted"] as const).pipe( Flag.withDescription("Version status to update."), ), - ...targetFlags({ group: "selectable", subject: "database", dbUrl: true }), + ...targetFlags({ subject: "database", dbUrl: true }), password: CliConfigKeys.linkedDb.password.flag({ name: "password", alias: "p", @@ -52,6 +52,7 @@ export const migrationRepairCommand = Command.make("repair", config).pipe( migrationRepair({ versions: flags.versions.map(String), status: flags.status, + remote: flags.remote, dbUrl: flags.dbUrl, linked: flags.linked, local: flags.local, @@ -61,6 +62,7 @@ export const migrationRepairCommand = Command.make("repair", config).pipe( withCommandTelemetry({ flags: { status: flags.status, + remote: flags.remote, "db-url": flags.dbUrl, linked: flags.linked, local: flags.local, diff --git a/apps/cli/src/commands/migration/repair/repair.handler.ts b/apps/cli/src/commands/migration/repair/repair.handler.ts index 8aa2c69082..eb50bcda82 100644 --- a/apps/cli/src/commands/migration/repair/repair.handler.ts +++ b/apps/cli/src/commands/migration/repair/repair.handler.ts @@ -22,6 +22,7 @@ import { UPSERT_MIGRATION_VERSION, } from "../../../command-internal/migration-history.ts"; import { parseMigrationVersion } from "../../../command-internal/migration-timestamp.format.ts"; +import type { RemoteValue } from "../../../command-internal/target/remote-value.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"; @@ -31,6 +32,7 @@ import { MigrationRepairUpdateError } from "./repair.errors.ts"; export interface MigrationRepairInput { readonly versions: ReadonlyArray<string>; readonly status: "applied" | "reverted"; + readonly remote: Option.Option<RemoteValue>; readonly dbUrl: Option.Option<string>; readonly linked: boolean; readonly local: boolean; diff --git a/apps/cli/src/commands/migration/squash/squash.command.ts b/apps/cli/src/commands/migration/squash/squash.command.ts index 4f76c720d9..ee7df76b3d 100644 --- a/apps/cli/src/commands/migration/squash/squash.command.ts +++ b/apps/cli/src/commands/migration/squash/squash.command.ts @@ -24,7 +24,7 @@ const config = { Flag.withDescription("Squash up to the specified version."), Flag.optional, ), - ...targetFlags({ group: "selectable", subject: "database", dbUrl: true }), + ...targetFlags({ subject: "database", dbUrl: true }), password: CliConfigKeys.linkedDb.password.flag({ name: "password", alias: "p", @@ -53,6 +53,7 @@ export const migrationSquashCommand = Command.make("squash", config).pipe( withCommandTelemetry({ flags: { version: flags.version, + remote: flags.remote, "db-url": flags.dbUrl, linked: flags.linked, local: flags.local, diff --git a/apps/cli/src/commands/migration/up/up.command.ts b/apps/cli/src/commands/migration/up/up.command.ts index e38491ae79..1dd76f82be 100644 --- a/apps/cli/src/commands/migration/up/up.command.ts +++ b/apps/cli/src/commands/migration/up/up.command.ts @@ -23,7 +23,7 @@ const config = { Flag.withDescription("Include all migrations not found on remote history table."), Flag.withDefault(false), ), - ...targetFlags({ group: "selectable", subject: "database", dbUrl: true }), + ...targetFlags({ subject: "database", dbUrl: true }), } as const; export type MigrationUpFlags = CliCommand.Command.Config.Infer<typeof config>; @@ -46,6 +46,7 @@ export const migrationUpCommand = Command.make("up", config).pipe( withCommandTelemetry({ flags: { "include-all": flags.includeAll, + remote: flags.remote, "db-url": flags.dbUrl, linked: flags.linked, local: flags.local, diff --git a/apps/cli/src/commands/pull/pull.aggregate.ts b/apps/cli/src/commands/pull/pull.aggregate.ts index 8007e7da6d..cd1d65a104 100644 --- a/apps/cli/src/commands/pull/pull.aggregate.ts +++ b/apps/cli/src/commands/pull/pull.aggregate.ts @@ -265,10 +265,10 @@ export function pullRetryHint( const remoteLabelFlag = pullRemoteLabelFlag(remoteLabel); const commandByStep: Record<PullStepId, string> = { config: `supabase config pull --project-ref ${ref}${remoteLabelFlag}`, - migration_history: `supabase migration fetch --project-ref ${ref}`, + migration_history: `supabase migration fetch --remote=${ref}`, // `db pull` rejects an ambient `--experimental`/`SUPABASE_EXPERIMENTAL` without `--declarative`; // `--experimental=false` keeps this suggested command from failing on it. - db: `supabase db pull --project-ref ${ref} --experimental=false`, + db: `supabase db pull --experimental=false --remote=${ref}`, functions: `supabase functions download --project-ref ${ref}`, }; return `To retry just this step, run: ${commandByStep[step]}`; diff --git a/apps/cli/src/commands/seed/buckets/buckets.command.ts b/apps/cli/src/commands/seed/buckets/buckets.command.ts index ed9a1aae49..9c0064d9c0 100644 --- a/apps/cli/src/commands/seed/buckets/buckets.command.ts +++ b/apps/cli/src/commands/seed/buckets/buckets.command.ts @@ -20,14 +20,16 @@ import { import { stdinLayer } from "../../../shared/runtime/stdin.layer.ts"; import { storageGatewayRuntimeLayer } from "../../../command-internal/storage-runtime.layer.ts"; import { seedBuckets } from "./buckets.handler.ts"; +import type { RemoteValue } from "../../../command-internal/target/remote-value.ts"; import { withTargetPolicy } from "../../../command-internal/target/target-policy.ts"; const config = { - projectRef: targetFlags({ group: "selectable", subject: "Storage API", dbUrl: false }).projectRef, + projectRef: targetFlags({ subject: "Storage API", dbUrl: false }).projectRef, }; -// `--linked`/`--local` are scoped globals on the `seed` group (`seedTargetGlobalFlags`). +// `--remote`/`--linked`/`--local` are scoped globals on the `seed` group (`seedTargetGlobalFlags`). export type BucketsFlags = { + readonly remote: Option.Option<RemoteValue>; readonly linked: boolean; readonly local: boolean; readonly projectRef: Option.Option<string>; @@ -48,6 +50,7 @@ export const bucketsCommand = Command.make("buckets", config).pipe( Command.withHandler((leafFlags) => Effect.gen(function* () { const flags: BucketsFlags = { + remote: yield* seedTargetGlobalFlags.remote, linked: yield* seedTargetGlobalFlags.linked, local: yield* seedTargetGlobalFlags.local, projectRef: leafFlags.projectRef, @@ -55,6 +58,7 @@ export const bucketsCommand = Command.make("buckets", config).pipe( return yield* seedBuckets(flags).pipe( withCommandTelemetry({ flags: { + remote: flags.remote, linked: flags.linked, local: flags.local, "project-ref": flags.projectRef, diff --git a/apps/cli/src/commands/seed/buckets/buckets.handler.ts b/apps/cli/src/commands/seed/buckets/buckets.handler.ts index 26c755da6a..4503dead01 100644 --- a/apps/cli/src/commands/seed/buckets/buckets.handler.ts +++ b/apps/cli/src/commands/seed/buckets/buckets.handler.ts @@ -1,4 +1,4 @@ -import { Effect, FileSystem, Option, Ref } from "effect"; +import { Effect, FileSystem, Ref } from "effect"; import { CommandSettings } from "../../../config/command-settings.service.ts"; import { requireTargetMutationConsent } from "../../../command-internal/hosted-consent/hosted-consent.ts"; @@ -44,9 +44,9 @@ export const seedBuckets = Effect.fn("seed.buckets")(function* (flags: BucketsFl // Resolved before the config load so the matching `[remotes.<name>]` override // (whose `project_id == ref`) is merged over the base config. const target = yield* resolveTarget({ + remote: flags.remote, local: flags.local, linked: flags.linked, - dbUrl: Option.none(), projectRef: flags.projectRef, }); const projectRef = target.kind === "linked" ? target.ref : ""; diff --git a/apps/cli/src/commands/seed/seed.command.ts b/apps/cli/src/commands/seed/seed.command.ts index 57f171ef56..c22126181e 100644 --- a/apps/cli/src/commands/seed/seed.command.ts +++ b/apps/cli/src/commands/seed/seed.command.ts @@ -7,7 +7,11 @@ 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. Kept after - // `withSubcommands` so it discharges the subcommands' flag requirements. - Command.withGlobalFlags([seedTargetGlobalFlags.linked, seedTargetGlobalFlags.local]), + // Persistent `--remote`/`--linked`/`--local`, accepted before or after the subcommand. Kept + // after `withSubcommands` so it discharges the subcommands' flag requirements. + Command.withGlobalFlags([ + seedTargetGlobalFlags.remote, + seedTargetGlobalFlags.linked, + seedTargetGlobalFlags.local, + ]), ); diff --git a/apps/cli/src/commands/storage/cp/cp.command.ts b/apps/cli/src/commands/storage/cp/cp.command.ts index 6478dbb486..891289f984 100644 --- a/apps/cli/src/commands/storage/cp/cp.command.ts +++ b/apps/cli/src/commands/storage/cp/cp.command.ts @@ -97,6 +97,7 @@ export const storageCpCommand = Command.make("cp", config).pipe( cacheControl: flags.cacheControl, contentType: flags.contentType, jobs: flags.jobs, + remote: flags.remote, linked: flags.linked, local: flags.local, "project-ref": flags.projectRef, diff --git a/apps/cli/src/commands/storage/ls/ls.command.ts b/apps/cli/src/commands/storage/ls/ls.command.ts index bea0ea7305..57ecb4d3a9 100644 --- a/apps/cli/src/commands/storage/ls/ls.command.ts +++ b/apps/cli/src/commands/storage/ls/ls.command.ts @@ -43,6 +43,7 @@ export const storageLsCommand = Command.make("ls", config).pipe( yield* requireExperimental; const telemetryFlags = { recursive: flags.recursive, + remote: flags.remote, linked: flags.linked, local: flags.local, "project-ref": flags.projectRef, diff --git a/apps/cli/src/commands/storage/mv/mv.command.ts b/apps/cli/src/commands/storage/mv/mv.command.ts index 4a9b662d13..ab275bff3e 100644 --- a/apps/cli/src/commands/storage/mv/mv.command.ts +++ b/apps/cli/src/commands/storage/mv/mv.command.ts @@ -41,6 +41,7 @@ export const storageMvCommand = Command.make("mv", config).pipe( yield* requireExperimental; const telemetryFlags = { recursive: flags.recursive, + remote: flags.remote, linked: flags.linked, local: flags.local, "project-ref": flags.projectRef, diff --git a/apps/cli/src/commands/storage/rm/rm.command.ts b/apps/cli/src/commands/storage/rm/rm.command.ts index f9c5bdb9b3..45a10b62a2 100644 --- a/apps/cli/src/commands/storage/rm/rm.command.ts +++ b/apps/cli/src/commands/storage/rm/rm.command.ts @@ -45,6 +45,7 @@ export const storageRmCommand = Command.make("rm", config).pipe( yield* requireExperimental; const telemetryFlags = { recursive: flags.recursive, + remote: flags.remote, linked: flags.linked, local: flags.local, "project-ref": flags.projectRef, @@ -52,6 +53,7 @@ export const storageRmCommand = Command.make("rm", config).pipe( return yield* storageRm({ files: flags.files.map(String), recursive: flags.recursive, + remote: flags.remote, linked: flags.linked, local: flags.local, projectRef: flags.projectRef, diff --git a/apps/cli/src/commands/storage/rm/rm.handler.ts b/apps/cli/src/commands/storage/rm/rm.handler.ts index 2f289bf7d2..7742e0349b 100644 --- a/apps/cli/src/commands/storage/rm/rm.handler.ts +++ b/apps/cli/src/commands/storage/rm/rm.handler.ts @@ -15,6 +15,7 @@ import { type StorageGateway, } from "../../../command-internal/storage-gateway.ts"; import { splitBucketPrefix, storageIsDir } from "../../../command-internal/storage-url.ts"; +import type { RemoteValue } from "../../../command-internal/target/remote-value.ts"; import { assertStorageWorkdir, connectStorageGateway, @@ -33,6 +34,7 @@ import { listStoragePaths } from "../storage.iterate.ts"; export interface StorageRmFlags { readonly files: ReadonlyArray<string>; readonly recursive: boolean; + readonly remote: Option.Option<RemoteValue>; readonly linked: boolean; readonly local: boolean; readonly projectRef: Option.Option<string>; diff --git a/apps/cli/src/commands/storage/storage.flags.ts b/apps/cli/src/commands/storage/storage.flags.ts index 3bbe4ab295..48be955967 100644 --- a/apps/cli/src/commands/storage/storage.flags.ts +++ b/apps/cli/src/commands/storage/storage.flags.ts @@ -7,11 +7,7 @@ import { storageCredentials } from "../../command-internal/storage-credentials.t import { targetFlags } from "../../command-internal/target/target-flags.ts"; import { linkedProjectCache } from "../../telemetry/linked-project-cache.layer.ts"; -export const storageTargetFlags = targetFlags({ - group: "selectable", - subject: "Storage API", - dbUrl: false, -}); +export const storageTargetFlags = targetFlags({ subject: "Storage API", dbUrl: false }); /** The Management API calls every `storage` subcommand makes on the linked project. */ export const STORAGE_PERMISSIONS = withDbTarget( diff --git a/apps/cli/src/commands/storage/storage.frame.ts b/apps/cli/src/commands/storage/storage.frame.ts index 45057d7ece..64910d4b9e 100644 --- a/apps/cli/src/commands/storage/storage.frame.ts +++ b/apps/cli/src/commands/storage/storage.frame.ts @@ -15,6 +15,7 @@ import { makeStorageGateway, type StorageGateway, } from "../../command-internal/storage-gateway.ts"; +import type { RemoteValue } from "../../command-internal/target/remote-value.ts"; import { resolveTarget } from "../../command-internal/target/resolve-target.ts"; import { UrlParseError, @@ -35,6 +36,7 @@ import { } from "./storage.errors.ts"; interface StorageTargetFlags { + readonly remote: Option.Option<RemoteValue>; readonly local: boolean; readonly linked: boolean; readonly projectRef: Option.Option<string>; @@ -43,9 +45,9 @@ interface StorageTargetFlags { /** The resolved target and its project ref, empty for the local stack. */ export const resolveStorageTarget = Effect.fnUntraced(function* (flags: StorageTargetFlags) { const target = yield* resolveTarget({ + remote: flags.remote, local: flags.local, linked: flags.linked, - dbUrl: Option.none(), projectRef: flags.projectRef, }); return { target, projectRef: target.kind === "linked" ? target.ref : "" }; diff --git a/apps/cli/src/shared/cli/invalid-value-message.ts b/apps/cli/src/shared/cli/invalid-value-message.ts index 40bdbb7a25..f3eb048b96 100644 --- a/apps/cli/src/shared/cli/invalid-value-message.ts +++ b/apps/cli/src/shared/cli/invalid-value-message.ts @@ -1,3 +1,5 @@ +import { LEAVE_OUT_REMOTE_PREFIX } from "../../command-internal/target/remote-value.ts"; + // Some of effect's own CLI primitive parsers (`choice`, and the schema-backed `integer`, `float`, // `boolean`, `date`) fail with an `expected` string that already starts with "Expected", which // `CliError.InvalidValue`'s own message getter then prefixes again, rendering @@ -8,8 +10,9 @@ const EXPECTED_PREFIX = "Expected "; // Some flags already fail with their own byte-exact diagnostic (e.g. malformed CSV in a -// string-slice flag), which the CLI's stderr contract must reproduce verbatim. Wrapping it in -// `CliError.InvalidValue`'s own template would double-frame it, so render it as-is instead. +// string-slice flag, or `--remote` given an off value), which the CLI's stderr contract must +// reproduce verbatim. Wrapping it in `CliError.InvalidValue`'s own template would double-frame +// it, so render it as-is instead. const INVALID_ARGUMENT_PREFIX = "invalid argument "; export interface InvalidValueMessageFields { @@ -22,11 +25,16 @@ export interface InvalidValueMessageFields { /** * Rebuilds a `CliError.InvalidValue` message from its own template when `expected` carries the * doubled "Expected" prefix, or passes `expected` through verbatim when it's already a complete - * `invalid argument …` diagnostic. Returns `undefined` when unaffected, so callers fall back to the - * error's own message. + * `invalid argument …` or `Leave out --remote …` diagnostic. Returns `undefined` when unaffected, + * so callers fall back to the error's own message. */ export function formatInvalidValueMessage(error: InvalidValueMessageFields): string | undefined { - if (error.expected.startsWith(INVALID_ARGUMENT_PREFIX)) return error.expected; + if ( + error.expected.startsWith(INVALID_ARGUMENT_PREFIX) || + error.expected.startsWith(LEAVE_OUT_REMOTE_PREFIX) + ) { + return error.expected; + } if (!error.expected.startsWith(EXPECTED_PREFIX)) return undefined; return error.kind === "argument" ? `Invalid value for argument <${error.option}>: "${error.value}". ${error.expected}` diff --git a/apps/cli/src/shared/cli/remote-argv.ts b/apps/cli/src/shared/cli/remote-argv.ts new file mode 100644 index 0000000000..da742e8d2c --- /dev/null +++ b/apps/cli/src/shared/cli/remote-argv.ts @@ -0,0 +1,109 @@ +import type { Command } from "effect/cli"; + +import { BRANCH_PROJECT_REF_PATTERN } from "../../command-internal/ref-patterns.ts"; +import { couldNameBranch } from "../../command-internal/target/remote-value.ts"; +import { commandInternals } from "../../docs/docs-introspection.ts"; +import { BOOLEAN_FLAG_VALUES } from "./agent-output.ts"; +import { helpDocFor } from "./subcommand-flag-suggestions.ts"; + +export interface RemoteArgvLeaf { + /** Command-path segments argv names ahead of the leaf's own operands. */ + readonly pathLength: number; + /** How many operands the leaf takes; `Infinity` when it takes a variable number. */ + readonly operandCapacity: number; + /** Whether a flag token takes the next argv token as its value. */ + readonly consumesNextToken: (token: string) => boolean; +} + +const LINKED_LITERALS: ReadonlySet<string> = new Set(["true", "yes", "on", "1"]); +const DB_URL_PATTERN = /^postgres(ql)?:\/\//; + +/** + * Joins a bare `--remote <word>` into `--remote=<word>` when the word cannot be one of the + * command's operands: the command takes none, the word is a project ref, a database URL or a + * boolean literal, or the command already has more operands than it takes. + */ +export function joinRemoteOperands( + args: ReadonlyArray<string>, + leaf: RemoteArgvLeaf, +): ReadonlyArray<string> { + const bare: Array<number> = []; + const remotes: Array<number> = []; + let end = args.length; + for (let index = 0; index < args.length; index += 1) { + const arg = args[index]; + if (arg === undefined) continue; + if (arg === "--") { + end = index; + break; + } + if (!arg.startsWith("-")) { + bare.push(index); + continue; + } + const next = args[index + 1]; + const isLiteral = next !== undefined && BOOLEAN_FLAG_VALUES.has(next); + if (arg === "--remote") { + if (!isLiteral || LINKED_LITERALS.has(next)) remotes.push(index); + if (isLiteral) index += 1; + } else if (leaf.consumesNextToken(arg) || (isLiteral && !arg.includes("="))) { + index += 1; + } + } + + const operandCount = + bare.length - leaf.pathLength + (end < args.length ? args.length - end - 1 : 0); + const joined = new Set<number>(); + for (const index of remotes) { + const word = args[index + 1]; + if (word === undefined || word.startsWith("-")) continue; + const ordinal = bare.indexOf(index + 1); + if (ordinal !== -1 && ordinal < leaf.pathLength) continue; + if ( + LINKED_LITERALS.has(word) || + leaf.operandCapacity === 0 || + BRANCH_PROJECT_REF_PATTERN.test(word) || + DB_URL_PATTERN.test(word) || + (couldNameBranch(word) && operandCount > leaf.operandCapacity) + ) { + joined.add(index); + } + } + if (joined.size === 0) return args; + return args.flatMap((arg, index) => { + if (joined.has(index)) return [`--remote=${args[index + 1]}`]; + return joined.has(index - 1) ? [] : [arg]; + }); +} + +/** + * The operand capacity of the command `path` resolves to, or `undefined` when it does not accept + * `--remote` (as its own flag, or as a persistent flag of an ancestor such as `seed`). + */ +export function remoteOperandCapacity( + rootCommand: Command.Command.Any, + path: ReadonlyArray<string>, +): number | undefined { + const commands: Array<Command.Command.Any> = [rootCommand]; + for (const segment of path.slice(1)) { + const parent = commands[commands.length - 1]; + const next = parent?.subcommands + .flatMap((group) => group.commands) + .find((command) => command.name === segment || command.alias === segment); + if (next === undefined) return undefined; + commands.push(next); + } + const leaf = commands[commands.length - 1]; + if (leaf === undefined) return undefined; + const doc = helpDocFor(leaf, path); + const acceptsRemote = + doc?.flags.some((flag) => flag.name === "remote") === true || + commands.some((command) => + commandInternals(command).globalFlags.some( + (flag) => flag._tag === "Setting" && flag.id === "remote", + ), + ); + if (!acceptsRemote) return undefined; + const operands = doc?.args ?? []; + return operands.some((operand) => operand.variadic) ? Infinity : operands.length; +} diff --git a/apps/cli/src/shared/cli/run.ts b/apps/cli/src/shared/cli/run.ts index 4d852750cd..b2ce3e2cd2 100644 --- a/apps/cli/src/shared/cli/run.ts +++ b/apps/cli/src/shared/cli/run.ts @@ -59,6 +59,7 @@ import { resolveAgentOutputFormatFromArgs, ROOT_BOOLEAN_FLAGS, } from "./agent-output.ts"; +import { joinRemoteOperands, remoteOperandCapacity } from "./remote-argv.ts"; import { SuccessTrailer, successTrailerLayer } from "./success-trailer.ts"; import type { CliErrorSuggestionContext } from "./subcommand-flag-suggestions.ts"; import { @@ -187,6 +188,29 @@ function valueTakingFlagTokenPredicateForArgv( return (token) => isGlobalValueFlagToken(token) || leafPredicate(token); } +/** `args` with a bare `--remote <word>` joined to `--remote=<word>` wherever the command's operands leave no other reading. */ +export function rewriteRemoteArgv( + rootCommand: Command.Command.Any, + args: ReadonlyArray<string>, +): ReadonlyArray<string> { + if (!args.includes("--remote")) return args; + const path = resolvedCommandPathForArgv(rootCommand, extractCommandPath(args)); + const operandCapacity = remoteOperandCapacity(rootCommand, path); + if (operandCapacity === undefined) return args; + const isValueTakingToken = valueTakingFlagTokenPredicateForArgv(rootCommand, args); + return joinRemoteOperands(args, { + pathLength: path.length - 1, + operandCapacity, + consumesNextToken: (token) => { + const [flag] = token.split("=", 1); + return ( + (!token.includes("=") && flag !== undefined && isValueTakingToken(flag)) || + shortClusterConsumesNextToken(token, isValueTakingToken) + ); + }, + }); +} + /** Whether `token` is an occurrence of the long or exact-short boolean flag `name`, bare or valued (`--help`, `--help=false`). */ function isFlagOccurrence(token: string, name: string): boolean { return token === name || token.startsWith(`${name}=`); @@ -635,10 +659,11 @@ export const runCli = Effect.fnUntraced(function* < options: RunCliOptions<BeforeParseError>, ) { const bootMs = Math.round(performance.now()); - const args = yield* Effect.gen(function* () { + const rawArgs = yield* Effect.gen(function* () { const stdio = yield* Stdio.Stdio; return yield* stdio.args; }).pipe(Effect.provide(BunServices.layer)); + const args = rewriteRemoteArgv(rootCommand, rawArgs); // Same shape `formatterLayerFor` builds below, so `normalizeCause`'s fallback path can reuse // `formatCliErrorsForDisplay` and surface the same subcommand-flag hint the formatters would. diff --git a/apps/cli/src/shared/functions/deploy.ts b/apps/cli/src/shared/functions/deploy.ts index 576ccaee00..bb384dcaba 100644 --- a/apps/cli/src/shared/functions/deploy.ts +++ b/apps/cli/src/shared/functions/deploy.ts @@ -84,7 +84,6 @@ export function shouldChmodBundleOutputDirectory(platform: NodeJS.Platform) { interface FunctionsDeployFlags { readonly functionNames: ReadonlyArray<string>; - readonly projectRef: Option.Option<string>; readonly noVerifyJwt: boolean; readonly useApi: boolean; readonly importMap: Option.Option<string>; @@ -108,9 +107,7 @@ interface DeployFunctionsDependencies<ResolveError, ResolveRequirements> { readonly localConfigLoader: FunctionsLocalConfigLoader; readonly rawArgs: ReadonlyArray<string>; readonly edgeRuntimeVersion: string; - readonly resolveProjectRef: ( - projectRef: Option.Option<string>, - ) => Effect.Effect<string, ResolveError, ResolveRequirements>; + readonly resolveProjectRef: () => Effect.Effect<string, ResolveError, ResolveRequirements>; /** * Optional shell-specific styling hooks. All default to identity (plain text); keeping them * injected keeps this shared module free of CLI-specific rendering. @@ -2510,7 +2507,7 @@ export const deployFunctions = Effect.fn("functions.deploy")(function* < }); } - const projectRef = yield* dependencies.resolveProjectRef(flags.projectRef); + const projectRef = yield* dependencies.resolveProjectRef(); // `@supabase/config` merges the matching `[remotes.*]` block over the base config, so this // already reflects any remote function/edge_runtime overrides, through the same // `Config.Validate`/dotenv/env-override pipeline `start`/`stop`/`status` use (see diff --git a/apps/cli/src/shared/telemetry/__fixtures__/error-tags.txt b/apps/cli/src/shared/telemetry/__fixtures__/error-tags.txt index dd1e35ea89..4e23964bf2 100644 --- a/apps/cli/src/shared/telemetry/__fixtures__/error-tags.txt +++ b/apps/cli/src/shared/telemetry/__fixtures__/error-tags.txt @@ -159,7 +159,6 @@ DbDiffDbNotRunningError DbDiffEngineConflictError DbDiffExplicitFlagsError DbDiffPgAdminError -DbDiffUnknownTargetError DbDiffWriteError DbDumpMutuallyExclusiveFlagsError DbDumpOpenFileError @@ -559,8 +558,10 @@ StorageWorkdirError TarFieldOutOfRangeError TarPathTooLongError TargetEmptyValueError +TargetNeedsProjectError TargetPasswordConflictError TargetProjectRefConflictError +TargetRemoteAmbiguousError TargetSelectorConflictError TestDbEnablePgtapError TestDbNoTestsError diff --git a/apps/cli/tests/helpers/permission-registry.ts b/apps/cli/tests/helpers/permission-registry.ts index 1dd07198cc..c53e9ad8f0 100644 --- a/apps/cli/tests/helpers/permission-registry.ts +++ b/apps/cli/tests/helpers/permission-registry.ts @@ -16,26 +16,29 @@ export type PermissionVariant = Parameters<typeof rootCommandForFeatures>[0]; /** * The flags one run uses, keyed by name without the leading `--`: a switch's effective value * (its default included) where an `is` condition checks it, a string where a `oneOf` condition - * checks the value, otherwise `true`. List only the database-target flags (`db-url`, `local`, - * `linked`) the run was given: the CLI treats one as set whenever it appears on the command line. + * checks the value, otherwise `true`. List only the database-target flags (`remote`, `db-url`, + * `local`, `linked`) the run was given: the CLI treats one as set whenever it appears on the + * command line. `remote` is `true` when bare, else the value it was given. */ export type ActiveFlags = Readonly<Record<string, boolean | string>>; /** The flags that select a database target, in precedence order. */ -export const DB_TARGET_FLAGS = [ - "db-url", - "local", - "linked", -] as const satisfies ReadonlyArray<DbTarget>; +export const DB_TARGET_FLAGS = ["db-url", "local", "linked", "remote"] as const; -/** The target `activeFlags` selects (`db-url`, `local`, `linked`, or `project-ref`, which implies linked), else `fallback`. */ +const DB_URL_PREFIX = /^postgres(ql)?:\/\//; + +/** The target `activeFlags` selects (`db-url`, `local`, `linked`, `remote`, or `project-ref`, which imply linked unless `remote` is a database URL), else `fallback`. */ function dbTargetOf( activeFlags: ActiveFlags, fallback: DbTarget | undefined, ): DbTarget | undefined { - for (const flag of DB_TARGET_FLAGS) { + for (const flag of ["db-url", "local", "linked"] as const satisfies ReadonlyArray<DbTarget>) { if (Object.hasOwn(activeFlags, flag)) return flag; } + if (Object.hasOwn(activeFlags, "remote")) { + const remote = activeFlags["remote"]; + return typeof remote === "string" && DB_URL_PREFIX.test(remote) ? "db-url" : "linked"; + } if (Object.hasOwn(activeFlags, "project-ref")) return "linked"; return fallback; } @@ -87,7 +90,7 @@ export function applicableEntries( const target = dbTargetOf(activeFlags, permissions.dbTargetDefault); if (target === undefined) { throw new Error( - "an entry checks the database target, but activeFlags names none of --db-url, --local, --linked and the declaration sets no default (withDbTarget)", + "an entry checks the database target, but activeFlags names none of --remote, --db-url, --local, --linked and the declaration sets no default (withDbTarget)", ); } return target; From cc12e7f9a09deb5366f0f82294a45f070b357f03 Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Mon, 19 Oct 2026 11:14:01 +0100 Subject: [PATCH 103/291] test(cli): cover --remote selection, resolution and argv rewriting --- .../connect-errors.unit.test.ts | 4 +- .../db-connection.sql-pg.unit.test.ts | 4 +- .../db-password-rejection.integration.test.ts | 63 ++- .../migration-history.unit.test.ts | 44 +- .../branch-resolution.errors.unit.test.ts | 69 +++ ...resolve-project-target.integration.test.ts | 206 +++++++ .../target/resolve-target.integration.test.ts | 533 +++++++++++------- .../target/target-deprecation.unit.test.ts | 162 ++++++ .../target/target-flags.unit.test.ts | 179 ++++-- .../target/target-identity.unit.test.ts | 4 +- .../target/target-policy.unit.test.ts | 15 +- .../target/target-selector.unit.test.ts | 315 ++++++++--- .../target/target.errors.unit.test.ts | 4 + .../test-db.integration.test.ts | 18 +- .../test-db.native.integration.test.ts | 1 + .../db/advisors/advisors.integration.test.ts | 6 +- .../db/diff/diff.explicit.unit.test.ts | 45 +- .../commands/db/diff/diff.integration.test.ts | 1 + .../commands/db/dump/dump.integration.test.ts | 12 +- .../commands/db/lint/lint.integration.test.ts | 6 +- .../commands/db/pull/pull.integration.test.ts | 14 +- .../pull.removed-flag.integration.test.ts | 6 +- .../commands/db/push/push.integration.test.ts | 34 +- .../db/query/query.integration.test.ts | 9 +- .../db/reset/reset.integration.test.ts | 13 +- .../generate/generate.integration.test.ts | 19 +- .../commands/db/test/test.integration.test.ts | 1 + .../deploy/deploy.integration.test.ts | 264 +++++++-- .../gen/types/types.integration.test.ts | 63 +-- .../db/inspect-deprecated.integration.test.ts | 1 + .../db/inspect-query.integration.test.ts | 24 +- .../db/inspect-specs.integration.test.ts | 1 + .../inspect/report/report.integration.test.ts | 1 + .../commands/link/link.errors.unit.test.ts | 64 --- .../migration/down/down.integration.test.ts | 1 + .../migration/fetch/fetch.integration.test.ts | 7 +- .../migration/list/list.integration.test.ts | 36 +- .../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 | 5 +- .../commands/pull/pull.aggregate.unit.test.ts | 8 +- .../commands/pull/pull.integration.test.ts | 8 +- .../seed/buckets/buckets.integration.test.ts | 102 +++- .../storage/cp/cp.integration.test.ts | 3 +- .../storage/ls/ls.integration.test.ts | 3 +- .../storage/mv/mv.integration.test.ts | 3 +- .../storage/rm/rm.integration.test.ts | 37 +- .../src/shared/cli/hidden-flag.unit.test.ts | 2 +- .../src/shared/cli/remote-argv.unit.test.ts | 190 +++++++ .../telemetry/command-telemetry.unit.test.ts | 60 ++ 51 files changed, 1979 insertions(+), 694 deletions(-) create mode 100644 apps/cli/src/command-internal/target/branch-resolution.errors.unit.test.ts create mode 100644 apps/cli/src/command-internal/target/resolve-project-target.integration.test.ts create mode 100644 apps/cli/src/command-internal/target/target-deprecation.unit.test.ts create mode 100644 apps/cli/src/shared/cli/remote-argv.unit.test.ts 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 831d9aa3c9..3b31e9d043 100644 --- a/apps/cli/src/command-internal/connect-errors.unit.test.ts +++ b/apps/cli/src/command-internal/connect-errors.unit.test.ts @@ -274,10 +274,10 @@ 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", () => { + it("points a refused local connection at --remote 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.`, + `${SUGGEST_LOCAL_STACK}\nTo use the linked project instead, pass --remote.`, ); }); 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 ab5c5e3be5..500139e65e 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,7 +791,7 @@ describe("batchFailureError", () => { expect("suggestion" in remote).toBe(false); }); - it("adds the --linked pointer to the local-stack hint on a linked checkout", () => { + it("adds the --remote pointer to the local-stack hint on a linked checkout", () => { const error = batchFailureError( new Error("socket died"), { completed: 0, outcome: "unsent" }, @@ -800,7 +800,7 @@ describe("batchFailureError", () => { ); expect(error).toMatchObject({ - suggestion: `${SUGGEST_LOCAL_STACK}\nTo use the linked project instead, pass --linked.`, + suggestion: `${SUGGEST_LOCAL_STACK}\nTo use the linked project instead, pass --remote.`, }); }); 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 ac18f4d10e..58b853b204 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 @@ -31,8 +31,10 @@ const passwordCommands = walkCommandTree(root).filter( (entry) => hasFlag(entry, "password") && !isTombstone(entry), ); -const withSelector = (selector: "db-url" | "local") => - passwordCommands.filter((entry) => hasFlag(entry, selector)); +type Selector = "db-url" | "remote-url" | "local" | "none"; + +const withSelector = (selector: Exclude<Selector, "none">) => + passwordCommands.filter((entry) => hasFlag(entry, selector === "local" ? "local" : "db-url")); const requiredFlagArgs = (entry: WalkedCommand): ReadonlyArray<string> => entry.flags.flatMap((flag) => { @@ -47,16 +49,24 @@ const requiredFlagArgs = (entry: WalkedCommand): ReadonlyArray<string> => return [`--${flag.name}`, firstChoice ?? "value"]; }); -const selectorArgs = (selector: "db-url" | "local" | "none") => - selector === "db-url" - ? ["--db-url", "postgres://user:secret@127.0.0.1:1/postgres"] - : selector === "local" - ? ["--local"] - : []; +const DIRECT_URL = "postgres://user:secret@127.0.0.1:1/postgres"; + +const selectorArgs = (selector: Selector) => { + switch (selector) { + case "db-url": + return ["--db-url", DIRECT_URL]; + case "remote-url": + return [`--remote=${DIRECT_URL}`]; + case "local": + return ["--local"]; + case "none": + return []; + } +}; const runCommand = ( entry: WalkedCommand, - selector: "db-url" | "local" | "none", + selector: Selector, options: { readonly linked?: boolean } = {}, ) => Effect.gen(function* () { @@ -105,28 +115,31 @@ describe("--password with a direct database target", () => { it("is discovered on commands that accept a direct target", () => { expect(passwordCommands.length).toBeGreaterThan(5); expect(withSelector("db-url").length).toBeGreaterThan(5); + expect(withSelector("remote-url").length).toBeGreaterThan(5); expect(withSelector("local").length).toBeGreaterThan(5); }); - for (const selector of ["db-url", "local"] as const) { + for (const selector of ["db-url", "remote-url", "local"] as const) { for (const entry of withSelector(selector)) { - it.effect(`rejects ${entry.path.join(" ")} --password with --${selector}`, () => - Effect.gen(function* () { - const failure = yield* runCommand(entry, selector); - - expect(failure).toMatchObject({ - _tag: "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 only applies to a linked project. The local database uses [db].password from supabase/config.toml.", - }); - }), + it.effect( + `rejects ${entry.path.join(" ")} --password with ${selector === "remote-url" ? "--remote=<url>" : `--${selector}`}`, + () => + Effect.gen(function* () { + const failure = yield* runCommand(entry, selector); + + expect(failure).toMatchObject({ + _tag: "TargetPasswordConflictError", + message: + selector === "local" + ? "--password only applies to a linked project. The local database uses [db].password from supabase/config.toml." + : "--password can't be used with --remote=<db-url>. Put the password in the connection string: postgres://USER:PASSWORD@HOST:PORT/postgres", + }); + }), ); } } - it.effect("rejects migration squash --password when the workspace target is local", () => + it.effect("rejects migration squash --password when no target is selected", () => Effect.gen(function* () { const entry = passwordCommands.find( (candidate) => candidate.path.join(" ") === "migration squash", @@ -142,7 +155,7 @@ describe("--password with a direct database target", () => { }), ); - it.effect("points a local --password rejection at --linked when the checkout is linked", () => + it.effect("points a local --password rejection at --remote when the checkout is linked", () => Effect.gen(function* () { const entry = passwordCommands.find( (candidate) => candidate.path.join(" ") === "migration list", @@ -153,7 +166,7 @@ describe("--password with a direct database target", () => { 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.", + "--password only applies to a linked project. The local database uses [db].password from supabase/config.toml. To use the linked project instead, pass --remote.", }); }), ); 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 81935bf7d2..95507b05f2 100644 --- a/apps/cli/src/command-internal/migration-history.unit.test.ts +++ b/apps/cli/src/command-internal/migration-history.unit.test.ts @@ -29,31 +29,31 @@ const failingSession = (error: DbExecError): DbSession => ({ describe("reconcileMigrations", () => { it("reports in-sync when remote and local match", () => { - expect(reconcileMigrations(["20240101000000"], ["20240101000000"], "--linked")).toEqual({ + expect(reconcileMigrations(["20240101000000"], ["20240101000000"], "--remote")).toEqual({ kind: "in-sync", }); }); it("reports missing only when both histories are empty", () => { - expect(reconcileMigrations([], [], "--linked")).toEqual({ kind: "missing" }); - expect(reconcileMigrations(["20240101000000"], [], "--linked").kind).toBe("conflict"); + expect(reconcileMigrations([], [], "--remote")).toEqual({ kind: "missing" }); + expect(reconcileMigrations(["20240101000000"], [], "--remote").kind).toBe("conflict"); }); it("reports a conflict with an extra remote migration", () => { - const result = reconcileMigrations(["20240101000000"], ["20240102000000"], "--linked"); + const result = reconcileMigrations(["20240101000000"], ["20240102000000"], "--remote"); expect(result.kind).toBe("conflict"); if (result.kind === "conflict") { expect(stripAnsi(result.suggestion)).toContain( - "supabase migration repair --linked --status reverted 20240101000000", + "supabase migration repair --status reverted 20240101000000 --remote", ); expect(stripAnsi(result.suggestion)).toContain( - "supabase migration repair --linked --status applied 20240102000000", + "supabase migration repair --status applied 20240102000000 --remote", ); } }); it("reports a conflict with an extra local migration", () => { - const result = reconcileMigrations([], ["20240102000000"], "--linked"); + const result = reconcileMigrations([], ["20240102000000"], "--remote"); expect(result.kind).toBe("conflict"); }); @@ -62,14 +62,14 @@ describe("reconcileMigrations", () => { reconcileMigrations( ["20260420", "20260420010000"], ["20260420010000", "20260420"], - "--linked", + "--remote", ), ).toEqual({ kind: "in-sync" }); }); it("skips versions that do not parse as integers", () => { expect( - reconcileMigrations(["bogus", "20240101000000"], ["20240101000000"], "--linked"), + reconcileMigrations(["bogus", "20240101000000"], ["20240101000000"], "--remote"), ).toEqual({ kind: "in-sync", }); @@ -77,14 +77,14 @@ describe("reconcileMigrations", () => { it("skips empty / whitespace versions (not Number() coercion)", () => { expect( - reconcileMigrations(["", "20240101000000"], [" ", "20240101000000"], "--linked"), + reconcileMigrations(["", "20240101000000"], [" ", "20240101000000"], "--remote"), ).toEqual({ kind: "in-sync", }); }); it("treats a version within the int64 range as a real conflict", () => { - expect(reconcileMigrations(["9999999999999999"], [], "--linked").kind).toBe("conflict"); + expect(reconcileMigrations(["9999999999999999"], [], "--remote").kind).toBe("conflict"); }); it("skips a version beyond the int64 range instead of hanging the scan", () => { @@ -92,7 +92,7 @@ describe("reconcileMigrations", () => { reconcileMigrations( ["20240101000000", "9999999999999999999"], ["20240101000000"], - "--linked", + "--remote", ), ).toEqual({ kind: "in-sync" }); }); @@ -187,27 +187,27 @@ describe("findPendingMigrations", () => { describe("suggestMigrationRepair", () => { it("lists reverted (remote) then applied (local) repair commands for the target", () => { - const out = stripAnsi(suggestMigrationRepair(["111"], ["222"], "--linked")); + const out = stripAnsi(suggestMigrationRepair(["111"], ["222"], "--remote")); expect(out).toContain("try repairing the migration history table:"); - expect(out).toContain("supabase migration repair --linked --status reverted 111"); - expect(out).toContain("supabase migration repair --linked --status applied 222"); + expect(out).toContain("supabase migration repair --status reverted 111 --remote"); + expect(out).toContain("supabase migration repair --status applied 222 --remote"); }); - it("carries a local or db-url target into every repair command", () => { + it("puts the target last in every repair command, so --remote cannot swallow a version", () => { expect(stripAnsi(suggestMigrationRepair(["111"], [], "--local"))).toContain( - "supabase migration repair --local --status reverted 111", + "supabase migration repair --status reverted 111 --local", ); - expect(stripAnsi(suggestMigrationRepair([], ["222"], "--db-url <redacted>"))).toContain( - "supabase migration repair --db-url <redacted> --status applied 222", + expect(stripAnsi(suggestMigrationRepair([], ["222"], "--remote=<db-url>"))).toContain( + "supabase migration repair --status applied 222 --remote=<db-url>", ); }); }); describe("suggestRevertHistory", () => { it("builds the revert-history suggestion with a trailing newline per line", () => { - 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"); + const out = stripAnsi(suggestRevertHistory(["0002", "0003"], "--remote")); + expect(out).toContain("supabase migration repair --status reverted 0002 0003 --remote"); + expect(out).toContain("supabase db pull --remote"); expect(out).toMatch(/\n$/u); }); }); diff --git a/apps/cli/src/command-internal/target/branch-resolution.errors.unit.test.ts b/apps/cli/src/command-internal/target/branch-resolution.errors.unit.test.ts new file mode 100644 index 0000000000..672e86eabf --- /dev/null +++ b/apps/cli/src/command-internal/target/branch-resolution.errors.unit.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from "vitest"; + +import { classifyCliErrorActionability } from "../../shared/telemetry/error-actionability.ts"; +import { + LinkBranchListNetworkError, + LinkBranchListStatusError, + LinkBranchNotReadyError, + LinkParentRefInvalidError, +} from "./branch-resolution.errors.ts"; + +describe("branch resolution actionability", () => { + it("classifies a branch-list 404 as invalid input, same policy as the api-keys 404", () => { + const result = classifyCliErrorActionability( + new LinkBranchListStatusError({ status: 404, body: "ignored", message: "ignored" }), + ); + expect(result.error_kind).toBe("user_actionable"); + expect(result.error_category).toBe("invalid_input"); + expect(result.error_fingerprint).toBe("tag:LinkBranchListStatusError:not_found"); + }); + + it("keeps a non-404 branch-list status on the API-status policy", () => { + const result = classifyCliErrorActionability( + new LinkBranchListStatusError({ status: 500, body: "ignored", message: "ignored" }), + ); + expect(result.error_kind).toBe("external_service"); + expect(result.error_category).toBe("api_status"); + expect(result.error_fingerprint).toBe("tag:LinkBranchListStatusError:api_status"); + }); + + it("classifies a branch-list body-decode failure as an API response problem", () => { + const result = classifyCliErrorActionability( + new LinkBranchListNetworkError({ message: "boom", decode: true }), + ); + expect(result.error_kind).toBe("external_service"); + expect(result.error_category).toBe("api_status"); + expect(result.error_fingerprint).toBe("tag:LinkBranchListNetworkError:api_response"); + }); + + it("classifies a branch-list transport failure as network", () => { + const result = classifyCliErrorActionability( + new LinkBranchListNetworkError({ message: "boom" }), + ); + expect(result.error_kind).toBe("external_service"); + expect(result.error_category).toBe("network"); + }); + + it("classifies an invalid linked parent ref as a relink-project remediation", () => { + const result = classifyCliErrorActionability( + new LinkParentRefInvalidError({ message: "ignored" }), + ); + expect(result.error_kind).toBe("user_actionable"); + expect(result.error_category).toBe("invalid_config"); + expect(result.suggestion_type).toBe("link_project"); + expect(result.suggested_command).toBe("supabase link"); + }); + + it("classifies a not-yet-provisioned branch as an API-status problem with its own fingerprint", () => { + const result = classifyCliErrorActionability( + new LinkBranchNotReadyError({ + branch: "feature-branch", + status: "CREATING_PROJECT", + message: "ignored", + }), + ); + expect(result.error_kind).toBe("external_service"); + expect(result.error_category).toBe("api_status"); + expect(result.error_fingerprint).toBe("tag:LinkBranchNotReadyError:branch_not_ready"); + }); +}); diff --git a/apps/cli/src/command-internal/target/resolve-project-target.integration.test.ts b/apps/cli/src/command-internal/target/resolve-project-target.integration.test.ts new file mode 100644 index 0000000000..307d1fe923 --- /dev/null +++ b/apps/cli/src/command-internal/target/resolve-project-target.integration.test.ts @@ -0,0 +1,206 @@ +import { BunServices } from "@effect/platform-bun"; +import { describe, expect, it } from "@effect/vitest"; +import { Cause, Effect, Exit, FileSystem, Layer, Option, Path } from "effect"; + +import { + jsonResponse, + mockCommandPlatformApi, + 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 { 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 type { RemoteValue } from "./remote-value.ts"; +import { resolveProjectTarget } from "./resolve-project-target.ts"; + +const FILE_REF = "filefilefilefilefile"; +const FLAG_REF = "flagflagflagflagflag"; +const BRANCH_REF = "branchprojectrefabcd"; + +const branchRow = (name: string) => ({ + id: "11111111-2222-4333-8444-555555555555", + name, + project_ref: BRANCH_REF, + parent_project_ref: FILE_REF, + is_default: false, + persistent: false, + status: "MIGRATIONS_PASSED", + created_at: "2026-05-27T01:02:03Z", + updated_at: "2026-05-27T01:02:04Z", + with_data: true, +}); + +const run = ( + input: { readonly remote?: RemoteValue; readonly projectRef?: string }, + options: { + readonly linked?: boolean; + readonly savedRef?: string; + readonly args?: ReadonlyArray<string>; + readonly branches?: ReadonlyArray<ReturnType<typeof branchRow>>; + } = {}, +) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const dir = yield* fs.makeTempDirectoryScoped({ prefix: "resolve-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 refFlags: Array<Option.Option<string>> = []; + const resolver = Layer.succeed(ProjectRefResolver, { + resolveRef: (flagValue) => + options.linked === false + ? Effect.fail(new ProjectRefNotLinkedError({ message: "Cannot find project ref." })) + : Effect.sync(() => { + refFlags.push(flagValue); + return { + ref: Option.getOrElse(flagValue, () => FILE_REF), + source: Option.isSome(flagValue) ? ("flag" as const) : ("file" as const), + matchesLinkedRef: Option.isNone(flagValue), + }; + }), + resolve: () => Effect.die("resolve is not used by resolveProjectTarget"), + resolveForLink: () => Effect.die("resolveForLink is not used by resolveProjectTarget"), + resolveOptional: () => Effect.die("resolveOptional is not used by resolveProjectTarget"), + promptProjectRef: () => Effect.die("promptProjectRef is not used by resolveProjectTarget"), + }); + const out = mockOutput(); + const api = mockCommandPlatformApi({ + handler: (request) => Effect.succeed(jsonResponse(request, 200, options.branches ?? [])), + }); + const exit = yield* resolveProjectTarget({ + remote: Option.fromNullishOr(input.remote), + projectRef: Option.fromNullishOr(input.projectRef), + }).pipe( + Effect.provide( + Layer.mergeAll( + commandTargetLayer.pipe(Layer.provide(out.layer)), + out.layer, + resolver, + api.layer, + mockCommandSettings({ workdir: dir, projectId: Option.none() }), + commandRuntimeLayer(["functions", "deploy"]).pipe(Layer.provide(BunServices.layer)), + options.args === undefined ? Layer.empty : Layer.succeed(CliArgs, { args: options.args }), + ), + ), + Effect.exit, + ); + return { + exit, + stderr: out.stderrText, + warnings: out.messages.filter((m) => m.type === "warn").map((m) => m.message), + refFlags, + branchRequests: api.requests.length, + }; + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)); + +const refOf = (exit: Exit.Exit<string, unknown>) => { + if (!Exit.isSuccess(exit)) throw new Error(`expected success, got ${Cause.pretty(exit.cause)}`); + return exit.value; +}; + +const failureOf = (exit: Exit.Exit<unknown, unknown>) => + Exit.isFailure(exit) ? Cause.squash(exit.cause) : undefined; + +describe("resolveProjectTarget", () => { + it.effect.each([ + { name: "no selector", input: {} }, + { name: "a bare --remote", input: { remote: { _tag: "Linked" } as const } }, + ])("falls through to the project-ref chain for $name", ({ input }) => + Effect.gen(function* () { + const result = yield* run(input); + expect(refOf(result.exit)).toBe(FILE_REF); + expect(result.refFlags).toEqual([Option.none()]); + expect(result.warnings).toEqual([]); + }), + ); + + it.effect("uses the project --remote=<ref> names, with no deprecation warning", () => + Effect.gen(function* () { + const result = yield* run({ remote: { _tag: "Ref", ref: FLAG_REF } }); + expect(refOf(result.exit)).toBe(FLAG_REF); + expect(result.refFlags).toEqual([Option.some(FLAG_REF)]); + expect(result.warnings).toEqual([]); + }), + ); + + it.effect("keeps --project-ref working and warns with the --remote form of the command", () => + Effect.gen(function* () { + const result = yield* run( + { projectRef: FLAG_REF }, + { args: ["functions", "deploy", "--project-ref", FLAG_REF, "hello"] }, + ); + expect(refOf(result.exit)).toBe(FLAG_REF); + expect(result.warnings).toEqual([ + `--project-ref is deprecated and will be removed in v4. Use --remote instead: supabase functions deploy hello --remote=${FLAG_REF}`, + ]); + }), + ); + + it.effect("looks a branch name up under the linked project and announces the branch", () => + Effect.gen(function* () { + const result = yield* run( + { remote: { _tag: "Branch", name: "feature-x" } }, + { savedRef: FILE_REF, branches: [branchRow("feature-x")] }, + ); + expect(refOf(result.exit)).toBe(BRANCH_REF); + expect(result.branchRequests).toBe(1); + expect(result.refFlags).toEqual([]); + expect(result.stderr).toContain(`project ${FILE_REF} · branch feature-x (${BRANCH_REF})`); + }), + ); + + it.effect("treats a --project-ref that is not a ref as a branch name", () => + Effect.gen(function* () { + const result = yield* run( + { projectRef: "feature-x" }, + { savedRef: FILE_REF, branches: [branchRow("feature-x")] }, + ); + expect(refOf(result.exit)).toBe(BRANCH_REF); + }), + ); + + it.effect("rejects a database URL, naming the command", () => + Effect.gen(function* () { + const result = yield* run({ + remote: { _tag: "DbUrl", url: "postgresql://postgres:pw@db.example.com:5432/postgres" }, + }); + expect(failureOf(result.exit)).toMatchObject({ + _tag: "TargetNeedsProjectError", + message: "`supabase functions deploy` needs a project, not a database URL.", + }); + expect(result.refFlags).toEqual([]); + }), + ); + + it.effect("stops a bare --remote followed by a word that is a branch of the linked project", () => + Effect.gen(function* () { + const result = yield* run( + { remote: { _tag: "Linked" } }, + { + savedRef: FILE_REF, + args: ["functions", "deploy", "--remote", "feature-x"], + branches: [branchRow("feature-x")], + }, + ); + expect(failureOf(result.exit)).toMatchObject({ + _tag: "TargetRemoteAmbiguousError", + message: `feature-x is a branch of project ${FILE_REF}. Did you mean --remote=feature-x?`, + }); + }), + ); + + it.effect("carries the not-linked failure through instead of guessing a project", () => + Effect.gen(function* () { + const result = yield* run({}, { linked: false }); + expect(failureOf(result.exit)).toMatchObject({ _tag: "ProjectRefNotLinkedError" }); + }), + ); +}); 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 1b7c0889b9..7f1e4a10d3 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 @@ -2,14 +2,20 @@ 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 { + VALID_REF, + jsonResponse, + mockCommandPlatformApi, + 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 { 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 { RemoteValue } from "./remote-value.ts"; import { resolveTarget } from "./resolve-target.ts"; import { renderLocalTarget } from "./target-identity.ts"; import { TargetPolicy } from "./target-policy.ts"; @@ -17,14 +23,35 @@ import type { TargetGroup } from "./target-policy.ts"; import type { TargetSelectorInput } from "./target-selector.ts"; const none: TargetSelectorInput = { + remote: Option.none(), local: false, linked: false, dbUrl: Option.none(), projectRef: Option.none(), }; +const remote = (value: RemoteValue): Partial<TargetSelectorInput> => ({ + remote: Option.some(value), +}); + const FILE_REF = "filefilefilefilefile"; const FLAG_REF = "flagflagflagflagflag"; +const BRANCH_REF = "branchprojectrefabcd"; +const SECRET_URL = + "postgresql://postgres:hunter2@db.abcdefghijklmnopqrst.supabase.co:5432/postgres"; + +const branchRow = (name: string, projectRef = BRANCH_REF) => ({ + id: "11111111-2222-4333-8444-555555555555", + name, + project_ref: projectRef, + parent_project_ref: FILE_REF, + is_default: false, + persistent: false, + status: "MIGRATIONS_PASSED", + created_at: "2026-05-27T01:02:03Z", + updated_at: "2026-05-27T01:02:04Z", + with_data: true, +}); const projectRefResolver = (options: { readonly linked: boolean }) => { const flags: Array<Option.Option<string>> = []; @@ -53,29 +80,19 @@ const run = ( group: TargetGroup, input: TargetSelectorInput, options: { - readonly toml?: string; - readonly dotenv?: string; - 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 }; + readonly args?: ReadonlyArray<string>; + readonly branches?: ReadonlyArray<ReturnType<typeof branchRow>> | "unavailable"; } = {}, ) => 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); - } - if (options.savedRef !== undefined || options.cache !== undefined) { - yield* fs.makeDirectory(path.join(dir, "supabase", ".temp"), { recursive: true }); - } + yield* fs.makeDirectory(path.join(dir, "supabase", ".temp"), { recursive: true }); if (options.savedRef !== undefined) { yield* fs.writeFileString( path.join(dir, "supabase", ".temp", "project-ref"), @@ -90,6 +107,14 @@ const run = ( } const resolver = projectRefResolver({ linked: options.linked ?? true }); const out = mockOutput(); + const api = mockCommandPlatformApi({ + handler: (request) => + Effect.succeed( + options.branches === "unavailable" + ? jsonResponse(request, 500, { message: "boom" }) + : jsonResponse(request, 200, options.branches ?? []), + ), + }); const spans: Array<Tracer.NativeSpan> = []; const tracer = Tracer.make({ span(spanOptions) { @@ -104,12 +129,14 @@ const run = ( Layer.mergeAll( Layer.succeed(TargetPolicy, { group, consent: "none" }), commandTargetLayer.pipe(Layer.provide(out.layer)), + out.layer, resolver.layer, + api.layer, mockCommandSettings({ workdir: dir, projectId: options.projectId ?? Option.none(), }), - configValuesLayer({ env: options.env }), + options.args === undefined ? Layer.empty : Layer.succeed(CliArgs, { args: options.args }), ), ), Effect.withTracer(tracer), @@ -120,7 +147,11 @@ const run = ( return { exit, stderr: out.stderrText, + warnings: out.messages.filter((m) => m.type === "warn").map((m) => m.message), refFlags: resolver.flags, + apiRequests: api.requests.map( + (request) => `${request.method} ${new URL(request.url).pathname}`, + ), kind: attributes?.get("target.kind"), selection: attributes?.get("target.selection"), }; @@ -131,56 +162,20 @@ const succeeded = <A>(exit: Exit.Exit<A, unknown>): A => { return exit.value; }; -describe("resolveTarget (selectable)", () => { +const failureOf = (exit: Exit.Exit<unknown, unknown>) => + Exit.isFailure(exit) ? Cause.squash(exit.cause) : undefined; + +describe("resolveTarget", () => { 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.warnings).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) }); @@ -189,161 +184,317 @@ describe("resolveTarget (selectable)", () => { }), ); - it.effect( - "fails with not-linked instead of falling back to local when remote cannot resolve", - () => + describe("--remote", () => { + it.effect("targets the linked project when bare", () => Effect.gen(function* () { - const result = yield* run("selectable", none, { - toml: 'target = "remote"\n', - linked: false, + const result = yield* run("selectable", { ...none, ...remote({ _tag: "Linked" }) }); + expect(succeeded(result.exit)).toEqual({ + kind: "linked", + ref: FILE_REF, + source: "file", + matchesLinkedRef: true, }); - expect(Exit.isFailure(result.exit)).toBe(true); - expect(JSON.stringify(result.exit)).toContain("ProjectRefNotLinkedError"); + expect(result.refFlags).toEqual([Option.none()]); + expect(result.warnings).toEqual([]); + expect([result.kind, result.selection]).toEqual(["linked", "flag"]); }), - ); + ); - 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("passes a project ref to the ref chain and marks it a flag source", () => + Effect.gen(function* () { + const result = yield* run("selectable", { + ...none, + ...remote({ _tag: "Ref", ref: 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("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("keeps a database URL redacted and never resolves a project", () => + Effect.gen(function* () { + const result = yield* run("selectable", { + ...none, + ...remote({ _tag: "DbUrl", url: SECRET_URL }), + }); + const target = succeeded(result.exit); + expect(target.kind).toBe("db-url"); + if (target.kind === "db-url") expect(Redacted.value(target.url)).toBe(SECRET_URL); + expect(JSON.stringify(target)).not.toContain("hunter2"); + expect(result.refFlags).toEqual([]); + expect(result.apiRequests).toEqual([]); + }), + ); - 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("resolves a branch name under the linked project and announces it", () => + Effect.gen(function* () { + const result = yield* run( + "selectable", + { ...none, ...remote({ _tag: "Branch", name: "feature-x" }) }, + { savedRef: FILE_REF, branches: [branchRow("feature-x"), branchRow("other", FLAG_REF)] }, + ); + expect(succeeded(result.exit)).toEqual({ + kind: "linked", + ref: BRANCH_REF, + source: "flag", + matchesLinkedRef: false, + }); + expect(result.apiRequests).toEqual([`GET /v1/projects/${FILE_REF}/branches`]); + expect(result.refFlags).toEqual([]); + expect(result.stderr).toContain(`project ${FILE_REF} · branch feature-x (${BRANCH_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("fails with the available branches when none has that name", () => + Effect.gen(function* () { + const result = yield* run( + "selectable", + { ...none, ...remote({ _tag: "Branch", name: "feature-y" }) }, + { savedRef: FILE_REF, branches: [branchRow("feature-x")] }, + ); + expect(failureOf(result.exit)).toMatchObject({ + _tag: "LinkBranchNotFoundError", + message: expect.stringContaining("Available branches: feature-x"), + }); + }), + ); - 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"); - }), - ); + it.effect("asks to link the parent project when no project is linked", () => + Effect.gen(function* () { + const result = yield* run("selectable", { + ...none, + ...remote({ _tag: "Branch", name: "feature-x" }), + }); + expect(failureOf(result.exit)).toMatchObject({ _tag: "LinkBranchNotLinkedError" }); + expect(result.apiRequests).toEqual([]); + }), + ); - 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("fails with not-linked instead of falling back to local when it cannot resolve", () => + Effect.gen(function* () { + const result = yield* run( + "selectable", + { ...none, ...remote({ _tag: "Linked" }) }, + { linked: false }, + ); + expect(failureOf(result.exit)).toMatchObject({ _tag: "ProjectRefNotLinkedError" }); + }), + ); + }); - 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`, - ); - }), - ); + describe("a bare --remote followed by a word", () => { + const args = ["db", "push", "--remote", "feature-x"]; + const bare = { ...none, ...remote({ _tag: "Linked" }) }; - 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("fails when the word is a branch of the linked project", () => + Effect.gen(function* () { + const result = yield* run("selectable", bare, { + args, + savedRef: FILE_REF, + branches: [branchRow("feature-x")], + }); + expect(failureOf(result.exit)).toMatchObject({ + _tag: "TargetRemoteAmbiguousError", + message: `feature-x is a branch of project ${FILE_REF}. Did you mean --remote=feature-x?`, + }); + expect(result.refFlags).toEqual([]); + }), + ); - 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("resolves the linked project when the word names no branch", () => + Effect.gen(function* () { + const result = yield* run("selectable", bare, { + args, + savedRef: FILE_REF, + branches: [branchRow("other")], + }); + expect(succeeded(result.exit)).toMatchObject({ kind: "linked", ref: FILE_REF }); + }), + ); - 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("fails when the branches cannot be listed to tell", () => + Effect.gen(function* () { + const result = yield* run("selectable", bare, { + args, + savedRef: FILE_REF, + branches: "unavailable", + }); + expect(failureOf(result.exit)).toMatchObject({ + _tag: "TargetRemoteAmbiguousError", + message: `Could not list the branches of project ${FILE_REF} to tell whether feature-x names one. Did you mean --remote=feature-x?`, + }); + }), + ); + + it.effect.each([ + { name: "a path", args: ["storage", "ls", "--remote", "./photos"] }, + { name: "a URL", args: ["storage", "ls", "--remote", "ss:///bucket"] }, + { name: "a flag", args: ["db", "push", "--remote", "--dry-run"] }, + { name: "nothing", args: ["db", "push", "--remote"] }, + ])("does not look up branches when the next token is $name", ({ args: tokens }) => + Effect.gen(function* () { + const result = yield* run("selectable", bare, { + args: tokens, + savedRef: FILE_REF, + branches: [branchRow("feature-x")], + }); + expect(succeeded(result.exit)).toMatchObject({ kind: "linked" }); + expect(result.apiRequests).toEqual([]); + }), + ); + + it.effect("does not look up branches when no project is linked", () => + Effect.gen(function* () { + const result = yield* run("selectable", bare, { args, branches: [branchRow("feature-x")] }); + expect(succeeded(result.exit)).toMatchObject({ kind: "linked" }); + expect(result.apiRequests).toEqual([]); + }), + ); + }); + + describe("deprecated selectors", () => { + it.effect("warns that --linked is replaced by --remote, with the command as run", () => + Effect.gen(function* () { + const result = yield* run( + "selectable", + { ...none, linked: true }, + { args: ["db", "push", "--dry-run", "--linked"] }, + ); + expect(succeeded(result.exit)).toMatchObject({ kind: "linked", ref: FILE_REF }); + expect(result.warnings).toEqual([ + "--linked is deprecated and will be removed in v4. Use --remote instead: supabase db push --dry-run --remote", + ]); + }), + ); + + it.effect("folds --linked and --project-ref into one warning that ends in --remote=<ref>", () => + Effect.gen(function* () { + const result = yield* run( + "selectable", + { ...none, linked: true, projectRef: Option.some(FLAG_REF) }, + { args: ["db", "push", "--linked", "--project-ref", FLAG_REF, "--dry-run"] }, + ); + expect(succeeded(result.exit)).toMatchObject({ kind: "linked", ref: FLAG_REF }); + expect(result.warnings).toEqual([ + `--linked and --project-ref are deprecated and will be removed in v4. Use --remote instead: supabase db push --dry-run --remote=${FLAG_REF}`, + ]); + }), + ); + + it.effect("never echoes the --db-url connection string or --password", () => + Effect.gen(function* () { + const result = yield* run( + "selectable", + { ...none, dbUrl: Option.some(SECRET_URL) }, + { args: ["db", "push", "--db-url", SECRET_URL, "--password", "hunter2"] }, + ); + expect(succeeded(result.exit)).toMatchObject({ kind: "db-url" }); + expect(result.warnings).toEqual([ + "--db-url is deprecated and will be removed in v4. Use --remote instead: supabase db push --password <redacted> --remote=<db-url>, or --remote=abcdefghijklmnopqrst with an access token set", + ]); + expect(result.warnings.join("\n")).not.toContain("hunter2"); + }), + ); + + it.effect("says --local is not needed", () => + Effect.gen(function* () { + const result = yield* run( + "selectable", + { ...none, local: true }, + { args: ["db", "push", "--local"] }, + ); + expect(succeeded(result.exit)).toEqual({ kind: "local" }); + expect(result.warnings).toEqual([ + "--local isn't needed (local is the default) and will be removed in v4: supabase db push", + ]); + expect(result.refFlags).toEqual([]); + }), + ); + + it.effect("warns without a command line when the real argv is unknown", () => + Effect.gen(function* () { + const result = yield* run("selectable", { ...none, linked: true }); + expect(result.warnings).toEqual([ + "--linked is deprecated and will be removed in v4. Use --remote instead", + ]); + }), + ); - it.effect("names the file when the workspace config cannot be parsed", () => + it.effect("treats --project-ref as --remote=<ref> and keeps the 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("does not warn for --remote itself", () => + Effect.gen(function* () { + const result = yield* run("selectable", { ...none, ...remote({ _tag: "Linked" }) }); + expect(result.warnings).toEqual([]); + }), + ); + }); + + it.effect("rejects conflicting selectors", () => 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: ./), + const result = yield* run("selectable", { + ...none, + ...remote({ _tag: "Linked" }), + linked: true, }); + expect(failureOf(result.exit)).toMatchObject({ _tag: "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"]); - }), - ); + describe("the default-local banner", () => { + it.effect("hints at --remote 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 --remote for alpha (${FILE_REF})\n`, + ); + }), + ); - 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("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 --remote for ${FILE_REF}\n`, + ); + }), + ); - 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"); - }), - ); -}); + 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 --local 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"); + }), + ); + }); -describe("resolveTarget (other groups)", () => { it.effect.each(["local-only", "account", "hosted", "removed"] satisfies Array<TargetGroup>)( "dies for a %s command", (group) => @@ -352,7 +503,7 @@ describe("resolveTarget (other groups)", () => { 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", + "resolveTarget needs a selectable command", ); } }), diff --git a/apps/cli/src/command-internal/target/target-deprecation.unit.test.ts b/apps/cli/src/command-internal/target/target-deprecation.unit.test.ts new file mode 100644 index 0000000000..72024384d3 --- /dev/null +++ b/apps/cli/src/command-internal/target/target-deprecation.unit.test.ts @@ -0,0 +1,162 @@ +import { describe, expect, it } from "vitest"; + +import { + deprecationMessages, + rebuildCommand, + refFromDbUrl, + remoteFlagText, +} from "./target-deprecation.ts"; +import type { TargetDeprecation } from "./target-selector.ts"; + +const REF = "abcdefghijklmnopqrst"; +const HOST = "supabase.co"; + +describe("remoteFlagText", () => { + it.each([ + { remote: { _tag: "Linked" }, expected: "--remote" }, + { remote: { _tag: "Ref", ref: REF }, expected: `--remote=${REF}` }, + { remote: { _tag: "Branch", name: "feature-x" }, expected: "--remote=feature-x" }, + { remote: { _tag: "Branch", name: "my branch" }, expected: "--remote='my branch'" }, + { remote: { _tag: "DbUrl", url: "postgresql://u:pw@h/db" }, expected: "--remote=<db-url>" }, + ] as const)("spells $remote as $expected", ({ remote, expected }) => { + expect(remoteFlagText(remote)).toBe(expected); + }); +}); + +describe("rebuildCommand", () => { + it.each([ + { + name: "drops a deprecated switch and puts --remote last", + argv: ["db", "push", "--linked", "--dry-run"], + remote: "--remote", + expected: "supabase db push --dry-run --remote", + }, + { + name: "drops a deprecated value flag together with its value", + argv: ["db", "dump", "--project-ref", REF, "-f", "out.sql"], + remote: `--remote=${REF}`, + expected: `supabase db dump -f out.sql --remote=${REF}`, + }, + { + name: "drops an inline deprecated value", + argv: ["db", "dump", `--project-ref=${REF}`], + remote: `--remote=${REF}`, + expected: `supabase db dump --remote=${REF}`, + }, + { + name: "drops a boolean literal that follows a deprecated switch", + argv: ["db", "push", "--linked", "true"], + remote: "--remote", + expected: "supabase db push --remote", + }, + { + name: "replaces a --remote the alias already maps to", + argv: ["db", "push", "--remote", "--project-ref", REF], + remote: `--remote=${REF}`, + expected: `supabase db push --remote=${REF}`, + }, + { + name: "keeps --remote ahead of a literal --", + argv: ["functions", "deploy", "--linked", "--", "-weird"], + remote: "--remote", + expected: "supabase functions deploy --remote -- -weird", + }, + { + name: "redacts a database URL and a password", + argv: ["db", "push", "--db-url", "postgresql://u:pw@h/db", "--password", "hunter2"], + remote: "--remote=<db-url>", + expected: "supabase db push --password <redacted> --remote=<db-url>", + }, + { + name: "redacts an inline password and a stray URL operand", + argv: ["db", "push", "--password=hunter2", "postgresql://u:pw@h/db", "--local"], + remote: undefined, + expected: "supabase db push --password=<redacted> <db-url>", + }, + { + name: "quotes a token the shell would split", + argv: ["storage", "ls", "ss:///my bucket", "--linked"], + remote: "--remote", + expected: "supabase storage ls 'ss:///my bucket' --remote", + }, + ])("$name", ({ argv, remote, expected }) => { + expect(rebuildCommand(argv, remote)).toBe(expected); + }); +}); + +describe("refFromDbUrl", () => { + it.each([ + { url: `postgresql://postgres:pw@db.${REF}.supabase.co:5432/postgres`, expected: REF }, + { + url: `postgresql://postgres.${REF}:pw@aws-0-eu-west-1.pooler.supabase.com:6543/postgres`, + expected: REF, + }, + { url: "postgresql://postgres:pw@db.example.com:5432/postgres", expected: undefined }, + { url: "postgresql://postgres:pw@127.0.0.1:54322/postgres", expected: undefined }, + { url: "not a url", expected: undefined }, + ])("reads $expected from $url", ({ url, expected }) => { + expect(refFromDbUrl(url, HOST)).toBe(expected); + }); +}); + +describe("deprecationMessages", () => { + const messages = ( + deprecations: ReadonlyArray<TargetDeprecation>, + argv: ReadonlyArray<string> | undefined, + ) => deprecationMessages(deprecations, { argv, projectHost: HOST }); + + it("names the flag, the replacement and v4, with the command as the user ran it", () => { + expect( + messages( + [{ kind: "remote-alias", flags: ["--linked"], remote: { _tag: "Linked" } }], + ["db", "push", "--linked"], + ), + ).toEqual([ + "--linked is deprecated and will be removed in v4. Use --remote instead: supabase db push --remote", + ]); + }); + + it("joins every alias the run used into one warning", () => { + expect( + messages( + [ + { + kind: "remote-alias", + flags: ["--linked", "--project-ref"], + remote: { _tag: "Ref", ref: REF }, + }, + ], + ["db", "push", "--linked", "--project-ref", REF], + ), + ).toEqual([ + `--linked and --project-ref are deprecated and will be removed in v4. Use --remote instead: supabase db push --remote=${REF}`, + ]); + }); + + it("offers the project ref a Supabase database URL already names, without echoing the URL", () => { + const url = `postgresql://postgres:hunter2@db.${REF}.supabase.co:5432/postgres`; + const [message] = messages( + [{ kind: "remote-alias", flags: ["--db-url"], remote: { _tag: "DbUrl", url } }], + ["db", "push", "--db-url", url], + ); + expect(message).toBe( + `--db-url is deprecated and will be removed in v4. Use --remote instead: supabase db push --remote=<db-url>, or --remote=${REF} with an access token set`, + ); + expect(message).not.toContain("hunter2"); + }); + + it("omits the command line when the argv is unknown", () => { + expect( + messages( + [{ kind: "remote-alias", flags: ["--project-id"], remote: { _tag: "Ref", ref: REF } }], + undefined, + ), + ).toEqual(["--project-id is deprecated and will be removed in v4. Use --remote instead"]); + }); + + it("says --local is not needed and drops it from the example", () => { + expect(messages([{ kind: "local" }], ["db", "push", "--local"])).toEqual([ + "--local isn't needed (local is the default) and will be removed in v4: supabase db push", + ]); + }); +}); 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 7919ec64dd..82a814d97b 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,14 +1,17 @@ import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; import { Effect, Layer, Option, Redacted } from "effect"; -import { CliOutput, Command, type Flag } from "effect/cli"; +import { CliError, CliOutput, Command, type Flag } from "effect/cli"; import type * as CliCommand from "effect/cli/Command"; +import { formatInvalidValueMessage } from "../../shared/cli/invalid-value-message.ts"; import { unwrapParam } from "../param-introspection.ts"; import { deprecatedProjectIdFlag, - seedTargetGlobalFlags, flagsForTarget, + projectTargetFlags, + remoteTelemetryFlag, + seedTargetGlobalFlags, targetFlags, } from "./target-flags.ts"; @@ -34,20 +37,43 @@ const parseWith = <Config extends CliCommand.Command.Config>( ); }; +const rejectedMessage = <Config extends CliCommand.Command.Config>( + config: Config, + args: ReadonlyArray<string>, +) => + parseWith(config, args).pipe( + Effect.flip, + Effect.map((error) => { + if (!CliError.isCliError(error) || error._tag !== "ShowHelp") { + throw new Error(`expected a parse failure, got ${String(error)}`); + } + const [inner] = error.errors; + if (inner?._tag !== "InvalidValue") { + throw new Error(`expected an invalid value, got ${inner?._tag}`); + } + return formatInvalidValueMessage(inner) ?? inner.message; + }), + ); + const describeFlag = (flag: Flag.Flag<unknown>) => { const single = unwrapParam(flag)?.single; return { name: single?.name, + hidden: single?.hidden, description: Option.getOrUndefined(single?.description ?? Option.none()), }; }; +const REF = "abcdefghijklmnopqrst"; +const URL = "postgresql://postgres:postgres@127.0.0.1:54322/postgres"; + describe("targetFlags", () => { - const withDbUrl = targetFlags({ group: "selectable", subject: "database", dbUrl: true }); + const withDbUrl = targetFlags({ subject: "database", dbUrl: true }); it.effect("defaults every selector to absent", () => Effect.gen(function* () { expect(yield* parseWith(withDbUrl, [])).toEqual({ + remote: Option.none(), local: false, linked: false, dbUrl: Option.none(), @@ -56,7 +82,22 @@ describe("targetFlags", () => { }), ); - it.effect("parses each selector", () => + it.effect.each([ + { args: ["--remote"], expected: { _tag: "Linked" } }, + { args: ["--remote", "yes"], expected: { _tag: "Linked" } }, + { args: ["--remote=on"], expected: { _tag: "Linked" } }, + { args: ["--remote=1"], expected: { _tag: "Linked" } }, + { args: [`--remote=${REF}`], expected: { _tag: "Ref", ref: REF } }, + { args: [`--remote=${URL}`], expected: { _tag: "DbUrl", url: URL } }, + { args: ["--remote=feature-x"], expected: { _tag: "Branch", name: "feature-x" } }, + { args: ["--remote=Feature_X"], expected: { _tag: "Branch", name: "Feature_X" } }, + ])("parses $args as $expected", ({ args, expected }) => + Effect.gen(function* () { + expect(yield* parseWith(withDbUrl, args)).toMatchObject({ remote: Option.some(expected) }); + }), + ); + + it.effect("keeps the deprecated aliases parseable", () => Effect.gen(function* () { expect(yield* parseWith(withDbUrl, ["--local"])).toMatchObject({ local: true }); expect(yield* parseWith(withDbUrl, ["--linked"])).toMatchObject({ linked: true }); @@ -66,32 +107,98 @@ describe("targetFlags", () => { dbUrl: Option.some("postgresql://h/db"), projectRef: Option.some("abc"), }); + expect(yield* parseWith(withDbUrl, ["--linked=false"])).toMatchObject({ linked: false }); }), ); - it.effect("accepts an explicit false switch", () => + it.effect.each([ + { args: ["--remote=false"], value: "false" }, + { args: ["--remote=0"], value: "0" }, + { args: ["--remote", "off"], value: "off" }, + { args: ["--no-remote"], value: "false" }, + { args: ["--remote="], value: "" }, + ])("rejects $args by pointing at the local default", ({ args }) => Effect.gen(function* () { - expect(yield* parseWith(withDbUrl, ["--linked=false"])).toMatchObject({ linked: false }); + expect(yield* rejectedMessage(withDbUrl, args)).toBe( + "Leave out --remote to target the local database.", + ); + }), + ); + + it.effect("names the Storage API in its local-default message", () => + Effect.gen(function* () { + const storage = targetFlags({ subject: "Storage API", dbUrl: false }); + expect(yield* rejectedMessage(storage, ["--no-remote"])).toBe( + "Leave out --remote to target the local Storage API.", + ); }), ); 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"]); + const storage = targetFlags({ subject: "Storage API", dbUrl: false }); + expect(Object.keys(storage).sort()).toEqual(["linked", "local", "projectRef", "remote"]); + expect(Object.keys(withDbUrl).sort()).toEqual([ + "dbUrl", + "linked", + "local", + "projectRef", + "remote", + ]); }); - 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 target = "remote" is set in config.toml or SUPABASE_TARGET).', + it("shows --remote and hides every deprecated alias", () => { + expect(describeFlag(withDbUrl.remote).hidden).toBeFalsy(); + for (const alias of [ + withDbUrl.local, + withDbUrl.linked, + withDbUrl.dbUrl, + withDbUrl.projectRef, + ]) { + expect(describeFlag(alias).hidden).toBe(true); + } + }); + + it("describes --remote by what the command can target", () => { + expect(describeFlag(withDbUrl.remote).description).toBe( + "Target a remote database: the linked project, or --remote=<ref|branch|url>.", ); + expect( + describeFlag(targetFlags({ subject: "Storage API", dbUrl: false }).remote).description, + ).toBe("Target a remote Storage API: the linked project, or --remote=<ref|branch>."); }); +}); + +describe("projectTargetFlags", () => { + const hosted = projectTargetFlags(); + + it.effect("parses --remote and the deprecated --project-ref", () => + Effect.gen(function* () { + expect(yield* parseWith(hosted, [])).toEqual({ + remote: Option.none(), + projectRef: Option.none(), + }); + expect(yield* parseWith(hosted, [`--remote=${REF}`])).toMatchObject({ + remote: Option.some({ _tag: "Ref", ref: REF }), + }); + expect(yield* parseWith(hosted, ["--project-ref", REF])).toMatchObject({ + projectRef: Option.some(REF), + }); + }), + ); - 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."); + it.effect("rejects an off value by pointing at the linked project", () => + Effect.gen(function* () { + expect(yield* rejectedMessage(hosted, ["--no-remote"])).toBe( + "Leave out --remote to use the linked project.", + ); + }), + ); + + it("describes --remote as another project and hides --project-ref", () => { + expect(describeFlag(hosted.remote).description).toBe( + "Target another project: --remote=<ref|branch>. Defaults to the linked project.", + ); + expect(describeFlag(hosted.projectRef).hidden).toBe(true); }); }); @@ -114,35 +221,41 @@ describe("deprecatedProjectIdFlag", () => { describe("seedTargetGlobalFlags", () => { it("shares the selector flag names with targetFlags", () => { + expect(describeFlag(seedTargetGlobalFlags.remote.flag).name).toBe("remote"); expect(describeFlag(seedTargetGlobalFlags.linked.flag).name).toBe("linked"); expect(describeFlag(seedTargetGlobalFlags.local.flag).name).toBe("local"); }); }); +describe("remoteTelemetryFlag", () => { + it.each([ + { remote: { _tag: "Linked" }, expected: true }, + { remote: { _tag: "Ref", ref: REF }, expected: REF }, + { remote: { _tag: "Branch", name: "customer-acme" }, expected: "<redacted>" }, + { remote: { _tag: "DbUrl", url: URL }, expected: "<redacted>" }, + ] as const)("records $remote as $expected", ({ remote, expected }) => { + expect(remoteTelemetryFlag(Option.some(remote))).toEqual(Option.some(expected)); + }); + + it("records nothing when --remote is absent", () => { + expect(remoteTelemetryFlag(Option.none())).toEqual(Option.none()); + }); +}); + 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: "local" })).toBe(""); expect(flagsForTarget({ kind: "db-url", url: Redacted.make("postgresql://u:pw@h/db") })).toBe( - "--db-url <redacted>", + "--remote=<db-url>", ); expect( - flagsForTarget({ - kind: "linked", - ref: "abcdefghijklmnopqrst", - source: "file", - matchesLinkedRef: true, - }), - ).toBe("--linked"); + flagsForTarget({ kind: "linked", ref: REF, source: "file", matchesLinkedRef: true }), + ).toBe("--remote"); }); 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"); + flagsForTarget({ kind: "linked", ref: REF, source: "flag", matchesLinkedRef: false }), + ).toBe(`--remote=${REF}`); }); }); 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 d5c1599bd7..41899d8654 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 @@ -132,11 +132,11 @@ 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})`); + ).toBe(`default; pass --remote 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}`; + const expected = `default; pass --remote for ${REF}`; expect(linkedAlternativeHint({ ref: REF, cache: Option.none() })).toBe(expected); expect( linkedAlternativeHint({ ref: REF, cache: Option.some({ ref: OTHER_REF, name: "beta" }) }), 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 63062014ad..b5ebeebb63 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 @@ -115,11 +115,11 @@ describe.each(leavesByVariant)( expect(offenders).toEqual([]); }); - it("gives sync and selectable leaves local, linked and project-ref flags", () => { + it("gives selectable leaves remote, local, linked and project-ref flags", () => { const offenders = withPolicy - .filter(({ policy }) => policy.group === "sync" || policy.group === "selectable") + .filter(({ policy }) => policy.group === "selectable") .flatMap(({ path, node }) => - (["local", "linked", "project-ref"] as const) + (["remote", "local", "linked", "project-ref"] as const) .filter((flag) => !node.flags.has(flag)) .map((flag) => `${path} lacks --${flag}`), ); @@ -128,7 +128,6 @@ describe.each(leavesByVariant)( it("matches a declared database-target default to the group", () => { const expected: Partial<Record<TargetGroup, "linked" | "local">> = { - sync: "linked", selectable: "local", }; const offenders = withPolicy.flatMap(({ path, node, policy }) => { @@ -159,9 +158,9 @@ describe.each(leavesByVariant)( expect([...new Set(offenders)]).toEqual([]); }); - it("requires an exemption reason on a sync or selectable leaf that never asks for consent", () => { + it("requires an exemption reason on a selectable leaf that never asks for consent", () => { const offenders = withPolicy - .filter(({ policy }) => policy.group === "sync" || policy.group === "selectable") + .filter(({ policy }) => policy.group === "selectable") .filter( ({ policy }) => policy.consent === "none" && (policy.consentExemptReason ?? "").trim() === "", @@ -224,9 +223,7 @@ 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" }); + it("requires a reason from a 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: "selectable", consent: "none" }); withTargetPolicy({ group: "selectable", consent: "none", consentExemptReason: "read-only" }); 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 8feffab3bb..59eabad1f5 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 @@ -1,151 +1,246 @@ import { Option, Result } from "effect"; import { describe, expect, it } from "vitest"; -import { parseTargetSelector, type TargetSelectorInput } from "./target-selector.ts"; +import type { RemoteValue } from "./remote-value.ts"; +import { + parseTargetSelector, + type TargetSelectorInput, + type TargetSelectorOptions, +} from "./target-selector.ts"; + +const REF = "abcdefghijklmnopqrst"; +const URL = "postgresql://u:p@db.example.com:5432/postgres"; const none: TargetSelectorInput = { + remote: Option.none(), local: false, linked: false, dbUrl: Option.none(), projectRef: Option.none(), }; -const parse = (overrides: Partial<TargetSelectorInput>) => - parseTargetSelector({ ...none, ...overrides }); +const remote = (value: RemoteValue) => ({ remote: Option.some(value) }); + +const parse = (overrides: Partial<TargetSelectorInput>, options?: TargetSelectorOptions) => + parseTargetSelector({ ...none, ...overrides }, options); -const failure = (overrides: Partial<TargetSelectorInput>) => { +const success = (overrides: Partial<TargetSelectorInput>) => { const result = parse(overrides); + if (Result.isFailure(result)) + throw new Error(`expected the selector to be accepted: ${result.failure.message}`); + return result.success; +}; + +const failure = (overrides: Partial<TargetSelectorInput>, options?: TargetSelectorOptions) => { + const result = parse(overrides, options); 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("reports no explicit target and no deprecation when nothing is set", () => { + expect(success({})).toEqual({ selection: { explicit: undefined }, deprecations: [] }); }); - 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)); + describe("--remote", () => { + it.each([ + ["a bare --remote", { _tag: "Linked" }, { explicit: "linked", ref: Option.none() }], + ["a project ref", { _tag: "Ref", ref: REF }, { explicit: "linked", ref: Option.some(REF) }], + ["a database URL", { _tag: "DbUrl", url: URL }, { explicit: "db-url", url: URL }], + [ + "a branch name", + { _tag: "Branch", name: "feature-x" }, + { explicit: "branch", name: "feature-x" }, + ], + ] as const)( + "selects the target named by %s without a deprecation", + (_name, value, selection) => { + expect(success(remote(value))).toEqual({ selection, deprecations: [] }); + }, + ); }); - 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 })); - }); + describe("deprecated aliases", () => { + it("treats --local as the default and reports it", () => { + expect(success({ local: true })).toEqual({ + selection: { explicit: "local" }, + deprecations: [{ kind: "local" }], + }); + }); - 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" }), - ); - }); + it("maps --linked to a bare --remote", () => { + expect(success({ linked: true })).toEqual({ + selection: { explicit: "linked", ref: Option.none() }, + deprecations: [{ kind: "remote-alias", flags: ["--linked"], remote: { _tag: "Linked" } }], + }); + }); - 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("maps --db-url to --remote=<url>", () => { + expect(success({ dbUrl: Option.some(URL) })).toEqual({ + selection: { explicit: "db-url", url: URL }, + deprecations: [ + { kind: "remote-alias", flags: ["--db-url"], remote: { _tag: "DbUrl", url: URL } }, + ], + }); }); - it("agrees with an explicit --linked", () => { - expect(parse({ linked: true, projectRef: Option.some("abcdefghijklmnopqrst") })).toEqual( - Result.succeed({ explicit: "linked", projectRef: Option.some("abcdefghijklmnopqrst") }), - ); + it.each(["--project-ref", "--project-id"] as const)( + "maps %s with a ref to --remote=<ref>", + (flag) => { + const overrides = + flag === "--project-ref" + ? { projectRef: Option.some(REF) } + : { projectId: Option.some(REF) }; + expect(success(overrides)).toEqual({ + selection: { explicit: "linked", ref: Option.some(REF) }, + deprecations: [ + { kind: "remote-alias", flags: [flag], remote: { _tag: "Ref", ref: REF } }, + ], + }); + }, + ); + + it("maps --project-ref with a branch name to --remote=<branch>", () => { + expect(success({ projectRef: Option.some("feature-x") })).toEqual({ + selection: { explicit: "branch", name: "feature-x" }, + deprecations: [ + { + kind: "remote-alias", + flags: ["--project-ref"], + remote: { _tag: "Branch", name: "feature-x" }, + }, + ], + }); }); - 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("folds --linked and --project-ref into one deprecation", () => { + expect(success({ linked: true, projectRef: Option.some(REF) })).toEqual({ + selection: { explicit: "linked", ref: Option.some(REF) }, + deprecations: [ + { + kind: "remote-alias", + flags: ["--linked", "--project-ref"], + remote: { _tag: "Ref", ref: REF }, + }, + ], }); }); - it("is rejected with --db-url", () => { + it("lets a bare --remote agree with --project-ref", () => { expect( - failure({ - dbUrl: Option.some("postgresql://h/db"), - projectRef: Option.some("abcdefghijklmnopqrst"), - }), + success({ ...remote({ _tag: "Linked" }), projectRef: Option.some(REF) }), ).toMatchObject({ - _tag: "TargetProjectRefConflictError", - conflictsWith: "--db-url", + selection: { explicit: "linked", ref: Option.some(REF) }, }); }); - it("is not rejected with --local=false", () => { - expect(parse({ local: false, projectRef: Option.some("abcdefghijklmnopqrst") })).toEqual( - Result.succeed({ explicit: "linked", projectRef: Option.some("abcdefghijklmnopqrst") }), - ); + it("treats false switches as absent", () => { + expect(success({ local: false, linked: false })).toEqual({ + selection: { explicit: undefined }, + deprecations: [], + }); + expect(success({ linked: false, dbUrl: Option.some(URL) })).toMatchObject({ + selection: { explicit: "db-url", url: URL }, + }); }); }); - describe("mutual exclusion", () => { - it("rejects --local with --linked and names both flags", () => { - expect(failure({ local: true, linked: true })).toMatchObject({ + describe("conflicts", () => { + it.each([ + ["--linked", { linked: true }, ["--remote", "--linked"]], + ["--db-url", { dbUrl: Option.some(URL) }, ["--remote", "--db-url"]], + ["--local", { local: true }, ["--remote", "--local"]], + ] as const)("rejects --remote with %s and names both flags", (_flag, overrides, flags) => { + expect(failure({ ...remote({ _tag: "Linked" }), ...overrides })).toMatchObject({ _tag: "TargetSelectorConflictError", - flags: ["--local", "--linked"], - message: "Only one of --local, --linked and --db-url can be set; got --local and --linked.", + flags, }); }); - it("rejects --local with --db-url", () => { - expect(failure({ local: true, dbUrl: Option.some("postgresql://h/db") })).toMatchObject({ + it("lists every selector that was set, in a fixed order", () => { + expect( + failure({ + ...remote({ _tag: "Linked" }), + local: true, + linked: true, + dbUrl: Option.some(URL), + }), + ).toMatchObject({ _tag: "TargetSelectorConflictError", - flags: ["--local", "--db-url"], + flags: ["--remote", "--linked", "--db-url", "--local"], + message: + "Only one of --remote, --linked, --db-url and --local can be set; got --remote, --linked, --db-url and --local.", }); }); - it("rejects --linked with --db-url", () => { - expect(failure({ linked: true, dbUrl: Option.some("postgresql://h/db") })).toMatchObject({ - _tag: "TargetSelectorConflictError", - flags: ["--linked", "--db-url"], + it("rejects --local with --linked", () => { + expect(failure({ local: true, linked: true })).toMatchObject({ + flags: ["--linked", "--local"], + message: + "Only one of --remote, --linked, --db-url and --local can be set; got --linked and --local.", }); }); - it("lists all three flags when every selector is set", () => { - expect( - failure({ local: true, linked: true, dbUrl: Option.some("postgresql://h/db") }), - ).toMatchObject({ + it.each([ + [ + "--local", + { local: true }, + "--project-ref selects a remote project and cannot be combined with --local.", + ], + [ + "--db-url", + { dbUrl: Option.some(URL) }, + "--project-ref selects a remote project and cannot be combined with --db-url.", + ], + [ + "--remote=<ref>", + remote({ _tag: "Ref", ref: REF }), + "--project-ref cannot be combined with --remote=<value>; use --remote=<ref> alone.", + ], + ] as const)("rejects --project-ref with %s", (_flag, overrides, message) => { + expect(failure({ ...overrides, projectRef: Option.some(REF) })).toMatchObject({ + _tag: "TargetProjectRefConflictError", + message, + }); + }); + + it("rejects --project-id with --project-ref", () => { + expect(failure({ projectRef: Option.some(REF), projectId: Option.some(REF) })).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.", + flags: ["--project-id", "--project-ref"], }); }); it("reports the selector conflict before the --project-ref conflict", () => { - expect( - failure({ local: true, linked: true, projectRef: Option.some("abcdefghijklmnopqrst") }), - ).toMatchObject({ _tag: "TargetSelectorConflictError" }); + expect(failure({ local: true, linked: true, projectRef: Option.some(REF) })).toMatchObject({ + _tag: "TargetSelectorConflictError", + }); }); }); describe("empty values", () => { - it("rejects an empty --db-url", () => { - expect(failure({ dbUrl: Option.some("") })).toMatchObject({ + it.each([ + [ + "--db-url", + { dbUrl: Option.some("") }, + "Pass a connection string, for example --remote=postgresql://postgres:postgres@127.0.0.1:54322/postgres", + ], + [ + "--project-ref", + { projectRef: Option.some("") }, + "Pass a project ref, for example --remote=abcdefghijklmnopqrst", + ], + [ + "--project-id", + { projectId: Option.some("") }, + "Pass a project ref, for example --remote=abcdefghijklmnopqrst", + ], + ] as const)("rejects an empty %s", (flag, overrides, suggestion) => { + expect(failure(overrides)).toMatchObject({ _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", - }); - }); - - 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.", - suggestion: "Pass a project ref, for example --project-ref abcdefghijklmnopqrst", + flag, + message: `${flag} cannot be empty.`, + suggestion, }); }); @@ -156,4 +251,48 @@ describe("parseTargetSelector", () => { }); }); }); + + describe("a command with no database connection", () => { + const hosted = (overrides: Partial<TargetSelectorInput>, options?: TargetSelectorOptions) => + parseTargetSelector( + { + remote: Option.none(), + local: false, + linked: false, + projectRef: Option.none(), + ...overrides, + }, + options, + ); + + it("accepts a project ref and a branch", () => { + expect(hosted(remote({ _tag: "Ref", ref: REF }))).toEqual( + Result.succeed({ + selection: { explicit: "linked", ref: Option.some(REF) }, + deprecations: [], + }), + ); + expect(hosted(remote({ _tag: "Branch", name: "feature-x" }))).toMatchObject({ + success: { selection: { explicit: "branch", name: "feature-x" } }, + }); + }); + + it("rejects a database URL and names the command", () => { + const result = hosted(remote({ _tag: "DbUrl", url: URL }), { + commandName: "`supabase functions deploy`", + }); + expect(result).toMatchObject({ + failure: { + _tag: "TargetNeedsProjectError", + message: "`supabase functions deploy` needs a project, not a database URL.", + }, + }); + }); + + it("falls back to a generic name for the command", () => { + expect(hosted(remote({ _tag: "DbUrl", url: URL }))).toMatchObject({ + failure: { message: "This command needs a project, not a database URL." }, + }); + }); + }); }); 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 365b66fed2..df1717c5db 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 @@ -3,8 +3,10 @@ import { describe, expect, it } from "vitest"; import { classifyCliErrorActionability } from "../../shared/telemetry/error-actionability.ts"; import { TargetEmptyValueError, + TargetNeedsProjectError, TargetPasswordConflictError, TargetProjectRefConflictError, + TargetRemoteAmbiguousError, TargetSelectorConflictError, } from "./target.errors.ts"; @@ -13,6 +15,8 @@ describe("target errors", () => { new TargetSelectorConflictError({ flags: ["--local", "--linked"], message: "m" }), new TargetProjectRefConflictError({ conflictsWith: "--local", message: "m" }), new TargetEmptyValueError({ flag: "--db-url", message: "m", suggestion: "s" }), + new TargetNeedsProjectError({ message: "m" }), + new TargetRemoteAmbiguousError({ message: "m" }), 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/command-internal/test-db.integration.test.ts b/apps/cli/src/command-internal/test-db.integration.test.ts index 080506f521..e0053311ac 100644 --- a/apps/cli/src/command-internal/test-db.integration.test.ts +++ b/apps/cli/src/command-internal/test-db.integration.test.ts @@ -1,6 +1,16 @@ import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; -import { Cause, ConfigProvider, Effect, Exit, FileSystem, Layer, Option, Path, Schema } from "effect"; +import { + Cause, + ConfigProvider, + Effect, + Exit, + FileSystem, + Layer, + Option, + Path, + Schema, +} from "effect"; import { vi } from "vitest"; vi.mock("node:net", (importOriginal) => @@ -15,7 +25,6 @@ import { VALID_REF, VALID_TOKEN, mockCommandSettings, - withConfigEnv, mockTelemetryStateTracked, useTempWorkdir, } from "../../tests/helpers/command-mocks.ts"; @@ -339,6 +348,7 @@ function setup(opts: SetupOpts = {}) { const flags = (over: Partial<Parameters<typeof testDb>[0]> = {}) => ({ paths: over.paths ?? [], + remote: over.remote ?? Option.none(), dbUrl: over.dbUrl ?? Option.none<string>(), linked: over.linked ?? false, local: over.local ?? false, @@ -714,10 +724,10 @@ describe("test db integration", () => { }).pipe(Effect.provide(layer)); }); - it.live("follows a remote workspace target when no selector flag is given", () => { + it.live("targets the linked project for a bare --remote", () => { const { layer, resolver } = setup({ conn: REMOTE_CONN, isLocal: false }); return Effect.gen(function* () { - yield* withConfigEnv({ SUPABASE_TARGET: "remote" }, testDb(flags())); + yield* testDb(flags({ remote: Option.some({ _tag: "Linked" }) })); expect(resolver.inputs[0]?.target.kind).toBe("linked"); }).pipe(Effect.provide(layer)); }); 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 6e40ee634d..9fd63af950 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 @@ -47,6 +47,7 @@ const databaseCreation = { const flags = (path: string) => ({ paths: [path], + remote: Option.none(), dbUrl: Option.none<string>(), linked: false, local: true, 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 ae26fcc014..a0942a4a53 100644 --- a/apps/cli/src/commands/db/advisors/advisors.integration.test.ts +++ b/apps/cli/src/commands/db/advisors/advisors.integration.test.ts @@ -22,7 +22,6 @@ import { mockTelemetryStateTracked, sequentialExecBatch, useTempWorkdir, - withConfigEnv, } from "../../../../tests/helpers/command-mocks.ts"; import { assertPermissionDrift } from "../../../../tests/helpers/permission-drift.ts"; import { @@ -331,6 +330,7 @@ function setup(opts: SetupOpts = {}) { const flags = (over: Partial<DbAdvisorsFlags> = {}): DbAdvisorsFlags => ({ dbUrl: over.dbUrl ?? Option.none<string>(), + remote: over.remote ?? Option.none(), linked: over.linked ?? false, local: over.local ?? false, projectRef: over.projectRef ?? Option.none<string>(), @@ -478,10 +478,10 @@ describe("db advisors — local", () => { }).pipe(Effect.provide(layer)); }); - it.effect("follows a remote workspace target when no selector flag is given", () => { + it.effect("targets the linked project for a bare --remote", () => { const { layer, resolver, cache } = setup({ securityLints: [], performanceLints: [] }); return Effect.gen(function* () { - yield* withConfigEnv({ SUPABASE_TARGET: "remote" }, dbAdvisors(flags())); + yield* dbAdvisors(flags({ remote: Option.some({ _tag: "Linked" } as const) })); expect(resolver.resolveInputs.map((input) => input.target.kind)).toEqual(["linked"]); expect(cache.cached).toBe(true); }).pipe(Effect.provide(layer)); diff --git a/apps/cli/src/commands/db/diff/diff.explicit.unit.test.ts b/apps/cli/src/commands/db/diff/diff.explicit.unit.test.ts index 445d67fcb3..980e173345 100644 --- a/apps/cli/src/commands/db/diff/diff.explicit.unit.test.ts +++ b/apps/cli/src/commands/db/diff/diff.explicit.unit.test.ts @@ -1,30 +1,35 @@ import { describe, expect, it } from "vitest"; -import { classifyExplicitRef, unknownTargetMessage } from "./diff.explicit.ts"; +import { classifyExplicitRef } from "./diff.explicit.ts"; -describe("classifyExplicitRef", () => { - it("recognises the named targets", () => { - expect(classifyExplicitRef("local")).toBe("local"); - expect(classifyExplicitRef("linked")).toBe("linked"); - expect(classifyExplicitRef("migrations")).toBe("migrations"); - }); +const REF = "abcdefghijklmnopqrst"; +const UUID = "11111111-2222-4333-8444-555555555555"; - it("recognises postgres URLs", () => { - expect(classifyExplicitRef("postgres://u:p@h:5432/db")).toBe("url"); - expect(classifyExplicitRef("postgresql://u@h/db")).toBe("url"); +describe("classifyExplicitRef", () => { + it.each([ + { value: "local", expected: { _tag: "Local" } }, + { value: "migrations", expected: { _tag: "Migrations" } }, + { value: "remote", expected: { _tag: "Remote", viaDeprecatedLinked: false } }, + { value: "linked", expected: { _tag: "Remote", viaDeprecatedLinked: true } }, + ])("reads the keyword $value", ({ value, expected }) => { + expect(classifyExplicitRef(value)).toEqual(expected); }); - it("rejects anything else as unknown", () => { - expect(classifyExplicitRef("remote")).toBe("unknown"); - expect(classifyExplicitRef("https://h/db")).toBe("unknown"); - expect(classifyExplicitRef("")).toBe("unknown"); + it.each([ + { value: REF, expected: { _tag: "Ref", ref: REF } }, + { + value: "postgres://u:p@h:5432/db", + expected: { _tag: "DbUrl", url: "postgres://u:p@h:5432/db" }, + }, + { value: "postgresql://u@h/db", expected: { _tag: "DbUrl", url: "postgresql://u@h/db" } }, + { value: "feature-x", expected: { _tag: "Branch", name: "feature-x" } }, + { value: UUID, expected: { _tag: "Branch", name: UUID } }, + ])("classifies $value like a --remote value", ({ value, expected }) => { + expect(classifyExplicitRef(value)).toEqual(expected); }); -}); -describe("unknownTargetMessage", () => { - it("emits the quoted error byte-for-byte", () => { - expect(unknownTargetMessage("remote")).toBe( - "unknown target \"remote\": must be one of 'local', 'linked', 'migrations', or a postgres:// URL", - ); + it("reaches a branch named like a keyword through its UUID, not the keyword", () => { + expect(classifyExplicitRef("Remote")).toEqual({ _tag: "Branch", name: "Remote" }); + expect(classifyExplicitRef(UUID)).toEqual({ _tag: "Branch", name: UUID }); }); }); 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 fc946b4aa5..bf6f60cbb9 100644 --- a/apps/cli/src/commands/db/diff/diff.integration.test.ts +++ b/apps/cli/src/commands/db/diff/diff.integration.test.ts @@ -562,6 +562,7 @@ const flags = (over: Partial<DbDiffFlags> = {}): DbDiffFlags => ({ to: over.to ?? Option.none(), output: over.output ?? Option.none(), dbUrl: over.dbUrl ?? Option.none(), + remote: over.remote ?? Option.none(), linked: over.linked ?? false, local: over.local ?? false, projectRef: over.projectRef ?? 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 2d8ec82112..74beaaa209 100644 --- a/apps/cli/src/commands/db/dump/dump.integration.test.ts +++ b/apps/cli/src/commands/db/dump/dump.integration.test.ts @@ -50,7 +50,6 @@ import { mockLinkedProjectCacheTracked, mockTelemetryStateTracked, useTempWorkdir, - withConfigEnv, withEnvVar, } from "../../../../tests/helpers/command-mocks.ts"; import { cliConfigValuesTestLayer } from "../../../../tests/helpers/config-values-layer.ts"; @@ -539,6 +538,7 @@ const flags = (over: Partial<DbDumpFlags> = {}): DbDumpFlags => ({ keepComments: over.keepComments ?? Option.none(), file: over.file ?? Option.none(), dbUrl: over.dbUrl ?? Option.none(), + remote: over.remote ?? Option.none(), linked: over.linked ?? false, local: over.local ?? false, projectRef: over.projectRef ?? Option.none(), @@ -846,10 +846,10 @@ describe("db dump integration", () => { }).pipe(Effect.provide(layer)); }); - it.effect("follows a remote workspace target when no selector flag is given", () => { + it.effect("targets the linked project for a bare --remote", () => { const { layer, resolver } = setup({ conn: REMOTE_CONN, isLocal: false }); return Effect.gen(function* () { - yield* withConfigEnv({ SUPABASE_TARGET: "remote" }, dbDump(flags({ dryRun: true }))); + yield* dbDump(flags({ dryRun: true, remote: Option.some({ _tag: "Linked" }) })); expect(resolver.calls[0]?.target.kind).toBe("linked"); }).pipe(Effect.provide(layer)); }); @@ -1019,17 +1019,15 @@ describe("db dump integration", () => { }); it.effect( - "rejects a malformed --project-ref on the linked path before resolving or caching", + "reads a --project-ref that is not a ref as a branch name and fails before resolving or caching", () => { - // 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: true, projectRef: Option.some("BADREF") })).pipe( Effect.exit, ); expect(Exit.isFailure(exit)).toBe(true); - expect(failMessage(exit)).toBe(INVALID_PROJECT_REF_MESSAGE); + expect(failMessage(exit)).toContain('Cannot resolve "BADREF"'); expect(resolver.calls).toEqual([]); expect(cache.cached).toBe(false); }).pipe(Effect.provide(layer)); 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 48734b9f04..9e6e6c7cf7 100644 --- a/apps/cli/src/commands/db/lint/lint.integration.test.ts +++ b/apps/cli/src/commands/db/lint/lint.integration.test.ts @@ -20,7 +20,6 @@ import { mockTelemetryStateTracked, sequentialExecBatch, useTempWorkdir, - withConfigEnv, } from "../../../../tests/helpers/command-mocks.ts"; import { assertPermissionDrift } from "../../../../tests/helpers/permission-drift.ts"; import { @@ -261,6 +260,7 @@ function setup(opts: SetupOpts = {}) { const flags = (over: Partial<DbLintFlags> = {}): DbLintFlags => ({ dbUrl: over.dbUrl ?? Option.none<string>(), + remote: over.remote ?? Option.none(), linked: over.linked ?? false, local: over.local ?? false, projectRef: over.projectRef ?? Option.none<string>(), @@ -689,10 +689,10 @@ describe("db lint", () => { }).pipe(Effect.provide(layer)); }); - it.effect("follows a remote workspace target when no selector flag is given", () => { + it.effect("targets the linked project for a bare --remote", () => { const { layer, resolver, cache } = setup({ isLocal: false, checkRows: { public: [] } }); return Effect.gen(function* () { - yield* withConfigEnv({ SUPABASE_TARGET: "remote" }, dbLint(flags({ schema: ["public"] }))); + yield* dbLint(flags({ schema: ["public"], remote: Option.some({ _tag: "Linked" }) })); expect(resolver.resolveInput?.target.kind).toBe("linked"); expect(cache.cached).toBe(true); }).pipe(Effect.provide(layer)); 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 2f9b3c0a3b..8ec3e848e0 100644 --- a/apps/cli/src/commands/db/pull/pull.integration.test.ts +++ b/apps/cli/src/commands/db/pull/pull.integration.test.ts @@ -543,7 +543,7 @@ function setup(workdir: string, opts: SetupOpts = {}) { ), Layer.succeed(YesFlag, opts.yes ?? false), Layer.succeed(OutputFlag, Option.none()), - Layer.succeed(TargetPolicy, { group: "sync", consent: "no" }), + Layer.succeed(TargetPolicy, { group: "selectable", consent: "no" }), Layer.succeed(CliArgs, { args: opts.args ?? [] }), ); return { @@ -578,6 +578,11 @@ const flags = (over: Partial<DbPullFlags> = {}): DbPullFlags => ({ strictCoverage: over.strictCoverage ?? false, schema: over.schema ?? [], dbUrl: over.dbUrl ?? Option.none(), + remote: + over.remote ?? + (over.local === true || over.linked === true || Option.isSome(over.dbUrl ?? Option.none()) + ? Option.none() + : Option.some({ _tag: "Linked" as const })), linked: over.linked ?? false, local: over.local ?? false, projectRef: over.projectRef ?? Option.none(), @@ -1843,7 +1848,7 @@ describe("db pull", () => { }).pipe(Effect.provide(s.layer)); }); - it.effect("db pull --local keeps migration repair suggestions local", () => { + it.effect("db pull --local suggests repair commands without a target flag", () => { const s = setup(tmp.current, { migrations: ["20240102000000"], remoteVersions: ["20240101000000"], @@ -1855,10 +1860,10 @@ describe("db pull", () => { ? Option.getOrUndefined(Cause.findErrorOption(exit.cause)) : undefined; expect(failure).toMatchObject({ - suggestion: expect.stringContaining("migration repair --local --status reverted"), + suggestion: expect.stringContaining("migration repair --status reverted 20240101000000\n"), }); expect(failure).toMatchObject({ - suggestion: expect.stringContaining("migration repair --local --status applied"), + suggestion: expect.stringContaining("migration repair --status applied 20240102000000\n"), }); }).pipe(Effect.provide(s.layer)); }); @@ -2314,6 +2319,7 @@ describe("db pull permissions through the real resolver and cache", () => { expect(asLines(requests)).toEqual([loginRoleRequest, poolerRequest, loginRoleRequest]); assertPermissionDrift({ command: "db pull", + activeFlags: { remote: true }, requests: [...asRecordedRequests(requests), ...s.projectHttp.calls], exact: true, }); 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 00262b17b7..f78992309d 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 @@ -177,7 +177,7 @@ describe("db pull removed surfaces through command dispatch", () => { it.live("does not reject a plain db pull", () => Effect.gen(function* () { - const { exit } = yield* runDbPull([]); + const { exit } = yield* runDbPull(["--remote"]); expectNotLinked(exit); }), @@ -185,7 +185,7 @@ describe("db pull removed surfaces through command dispatch", () => { it.live("does not reject --experimental=false over SUPABASE_EXPERIMENTAL=true", () => Effect.gen(function* () { - const { exit } = yield* runDbPull(["--experimental=false"], { + const { exit } = yield* runDbPull(["--remote", "--experimental=false"], { env: { SUPABASE_EXPERIMENTAL: "true" }, }); @@ -195,7 +195,7 @@ describe("db pull removed surfaces through command dispatch", () => { it.live("does not reject --experimental --declarative", () => Effect.gen(function* () { - const { exit } = yield* runDbPull(["--experimental", "--declarative"]); + const { exit } = yield* runDbPull(["--remote", "--experimental", "--declarative"]); expectNotLinked(exit); }), 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 136331724d..6aaa17d212 100644 --- a/apps/cli/src/commands/db/push/push.integration.test.ts +++ b/apps/cli/src/commands/db/push/push.integration.test.ts @@ -81,6 +81,7 @@ const DEFAULT_FLAGS: DbPushFlags = { skipVault: false, dryRun: false, dbUrl: Option.none(), + remote: Option.none(), linked: false, local: true, projectRef: Option.none(), @@ -290,7 +291,7 @@ function setup( 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(TargetPolicy, { group: "selectable", consent: "no" }), Layer.succeed(DnsResolverFlag, "native"), projectRefLayer, telemetry.layer, @@ -488,8 +489,10 @@ describe("db push", () => { expect(Cause.pretty(exit.cause)).toContain( "Remote migration versions not found in local migrations directory.", ); - expect(failSuggestion(exit)).toContain("migration repair --local --status reverted"); - expect(failSuggestion(exit)).toContain("supabase db pull --local"); + expect(failSuggestion(exit)).toContain( + "migration repair --status reverted 20240101000000\n", + ); + expect(failSuggestion(exit)).toContain("supabase db pull\n"); } expect(out).toBeDefined(); }); @@ -508,9 +511,9 @@ describe("db push", () => { local: false, }).pipe(Effect.provide(layer), Effect.exit); expect(failSuggestion(exit)).toContain( - "migration repair --db-url <redacted> --status reverted", + "migration repair --status reverted 20240101000000 --remote=<db-url>", ); - expect(failSuggestion(exit)).toContain("supabase db pull --db-url <redacted>"); + expect(failSuggestion(exit)).toContain("supabase db pull --remote=<db-url>"); expect(failSuggestion(exit)).not.toContain("s3cret"); }); }); @@ -525,8 +528,10 @@ describe("db push", () => { 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)).toContain( + "migration repair --status reverted 20240101000000 --remote", + ); + expect(failSuggestion(exit)).toContain("supabase db pull --remote"); expect(failSuggestion(exit)).not.toContain("--local"); }); }); @@ -1072,13 +1077,17 @@ describe("db push", () => { }, ); - it.effect("defaults to the linked target when no target flag is set", () => { + it.effect("targets the linked project for a bare --remote", () => { const { layer, out, resolver } = setup(tmp.current, { toml: 'project_id = "test"\n', projectRef: VALID_REF, }); return Effect.gen(function* () { - yield* dbPush({ ...DEFAULT_FLAGS, local: false }).pipe(Effect.provide(layer)); + yield* dbPush({ + ...DEFAULT_FLAGS, + local: false, + remote: Option.some({ _tag: "Linked" } as const), + }).pipe(Effect.provide(layer)); expect(resolver.calls[0]).toMatchObject({ target: { kind: "linked", ref: VALID_REF, source: "file", matchesLinkedRef: true }, hasPasswordFlag: true, @@ -1088,7 +1097,6 @@ describe("db push", () => { }); it.effect("mints a login role and fills the cache through the real resolver and cache", () => { - // No target flag: push defaults to the linked project. const { layer, projectHttp } = setup(tmp.current, { toml: 'project_id = "test"\n', realLayers: true, @@ -1484,7 +1492,11 @@ describeHostedConsent("db push", { invoke: ({ out }) => { const conn = mockConnection({}); return { - run: dbPush({ ...DEFAULT_FLAGS, local: false }).pipe( + run: dbPush({ + ...DEFAULT_FLAGS, + local: false, + remote: Option.some({ _tag: "Linked" } as const), + }).pipe( Effect.provide( Layer.mergeAll( conn.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 a00f5be518..a9c3081cf9 100644 --- a/apps/cli/src/commands/db/query/query.integration.test.ts +++ b/apps/cli/src/commands/db/query/query.integration.test.ts @@ -25,7 +25,6 @@ vi.mock("node:net", (importOriginal) => import { VALID_TOKEN, - withConfigEnv, mockCommandPlatformApi, mockCommandSettings, mockLinkedProjectCacheTracked, @@ -385,6 +384,7 @@ function setup(opts: SetupOpts = {}) { const flags = (over: Partial<DbQueryFlags> = {}): DbQueryFlags => ({ sql: over.sql ?? Option.none(), dbUrl: over.dbUrl ?? Option.none(), + remote: over.remote ?? Option.none(), linked: over.linked ?? false, local: over.local ?? false, projectRef: over.projectRef ?? Option.none(), @@ -971,15 +971,14 @@ describe("db query integration", () => { }).pipe(Effect.provide(layer)); }); - it.effect("follows a remote workspace target when no selector flag is given", () => { + it.effect("targets the linked project for a bare --remote", () => { const { layer, out, cache } = setup({ linkedStatus: 201, linkedBody: '[{"name":"alice","id":1}]', }); return Effect.gen(function* () { - yield* withConfigEnv( - { SUPABASE_TARGET: "remote" }, - dbQuery(flags({ sql: Option.some("select 1"), linked: false })), + yield* dbQuery( + flags({ sql: Option.some("select 1"), remote: Option.some({ _tag: "Linked" }) }), ); expect(out.stdoutText).toContain("│ name │ id │"); expect(cache.cached).toBe(true); 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 20e1637d69..678978820f 100644 --- a/apps/cli/src/commands/db/reset/reset.integration.test.ts +++ b/apps/cli/src/commands/db/reset/reset.integration.test.ts @@ -140,6 +140,7 @@ const CONN: PgConnInput = { const DEFAULT_FLAGS: DbResetFlags = { dbUrl: Option.none(), + remote: Option.none(), linked: false, local: false, projectRef: Option.none(), @@ -1868,8 +1869,8 @@ describe("db reset", () => { expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { expect(Option.getOrUndefined(Cause.findErrorOption(exit.cause))).toMatchObject({ - _tag: "CliConfigLoadError", - message: expect.stringContaining("failed to parse supabase"), + _tag: "DbConfigLoadError", + message: expect.stringContaining("failed to load config"), }); } expect(child.spawned.some((s) => s.args[0] === "container" && s.args[1] === "rm")).toBe( @@ -3012,7 +3013,6 @@ describe("db reset", () => { it.effect( "applies configured schema files instead of replaying migrations on an experimental remote reset", () => { - // `--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: { @@ -3021,13 +3021,14 @@ describe("db reset", () => { "supabase/seed.sql": "insert into t values (1);", }, experimental: true, - args: ["db", "reset", "--linked=false"], + args: ["db", "reset", "--remote"], confirm: [true], - env: { SUPABASE_TARGET: "remote" }, ref: VALID_REF, }); return Effect.gen(function* () { - yield* dbReset({ ...DEFAULT_FLAGS, linked: false }).pipe(Effect.provide(layer)); + yield* dbReset({ ...DEFAULT_FLAGS, remote: Option.some({ _tag: "Linked" }) }).pipe( + Effect.provide(layer), + ); expect(conn.execs.some((s) => s.includes("create table schema_users"))).toBe(true); expect(conn.execs.some((s) => s.includes("create table migrated_table"))).toBe(false); expect(out.stderrText).not.toContain("Applying migration"); 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 19dd4caecd..0a129a0cfe 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 @@ -447,6 +447,7 @@ const flags = ( reset: over.reset ?? false, schema: over.schema ?? [], dbUrl: over.dbUrl ?? Option.none(), + remote: over.remote ?? Option.none(), linked: over.linked ?? false, local: over.local ?? false, projectRef: over.projectRef ?? Option.none(), @@ -498,7 +499,8 @@ describe("db schema declarative generate integration", () => { ); expect(error).toMatchObject({ _tag: "TargetSelectorConflictError", - message: "Only one of --local, --linked and --db-url can be set; got --local and --linked.", + message: + "Only one of --remote, --linked, --db-url and --local can be set; got --linked and --local.", }); }).pipe(Effect.provide(layer)); }); @@ -530,7 +532,7 @@ describe("db schema declarative generate integration", () => { expect(error).toMatchObject({ _tag: "TargetSelectorConflictError", message: - "Only one of --local, --linked and --db-url can be set; got --local and --linked.", + "Only one of --remote, --linked, --db-url and --local can be set; got --linked and --local.", }); }).pipe(Effect.provide(layer)); }, @@ -574,7 +576,7 @@ describe("db schema declarative generate integration", () => { expect(error).toMatchObject({ _tag: "TargetSelectorConflictError", message: - "Only one of --local, --linked and --db-url can be set; got --local and --linked.", + "Only one of --remote, --linked, --db-url and --local can be set; got --linked and --local.", }); }).pipe(Effect.provide(layer)), ); @@ -652,17 +654,10 @@ describe("db schema declarative generate integration", () => { }).pipe(Effect.provide(s.layer)); }); - it.effect("selects the linked project when the workspace target is remote", () => { + it.effect("selects the linked project for a bare --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()); + yield* dbSchemaDeclarativeGenerate(flags({ remote: Option.some({ _tag: "Linked" }) })); expect(s.resolverCalls.map((call) => call.target)).toEqual([ expect.objectContaining({ kind: "linked", ref: VALID_REF }), ]); 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 5904f74138..08af568701 100644 --- a/apps/cli/src/commands/db/test/test.integration.test.ts +++ b/apps/cli/src/commands/db/test/test.integration.test.ts @@ -263,6 +263,7 @@ function setup(opts: SetupOpts = {}) { const flags = (over: Partial<TestDbFlags> = {}): TestDbFlags => ({ paths: [], + remote: Option.none(), dbUrl: Option.none(), linked: false, local: false, 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 85fb6daf00..3ecdcff3ad 100644 --- a/apps/cli/src/commands/functions/deploy/deploy.integration.test.ts +++ b/apps/cli/src/commands/functions/deploy/deploy.integration.test.ts @@ -45,6 +45,7 @@ import { realLinkedProjectCache, } from "../../../../tests/helpers/permission-real-layers.ts"; import { Output } from "../../../shared/output/output.service.ts"; +import { commandRuntimeLayer } from "../../../shared/runtime/command-runtime.layer.ts"; import { mockChildProcessSpawner } from "../../../../tests/helpers/child-process-spawner.ts"; import { describeHostedConsent } from "../../../../tests/helpers/hosted-consent.ts"; import { @@ -88,6 +89,7 @@ const requestLines = (requests: ReadonlyArray<{ readonly method: string; readonl const baseFlags: FunctionsDeployFlags = { functionNames: ["hello-world"], + remote: Option.none(), projectRef: Option.none(), noVerifyJwt: false, useApi: true, @@ -444,68 +446,218 @@ describe("functions deploy", () => { }).pipe(Effect.provide(layer), Effect.ensuring(removeTempRoot)); }); - it.effect("uses an explicit project ref when provided", () => { - const out = mockOutput({ format: "text" }); - const api = mockCommandPlatformApi({ - handler: (request) => { - if (request.method === "GET") { - return Effect.succeed(jsonResponse(request, 200, [])); - } - 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", + describe("project selection", () => { + const OTHER_REF = "qrstuvwxyzabcdefghij"; + const BRANCH_REF = "branchprojectrefabcd"; + const branchRow = (name: string) => ({ + id: "11111111-2222-4333-8444-555555555555", + name, + project_ref: BRANCH_REF, + parent_project_ref: VALID_REF, + is_default: false, + persistent: false, + status: "MIGRATIONS_PASSED", + created_at: "2026-05-27T01:02:03Z", + updated_at: "2026-05-27T01:02:04Z", + with_data: true, + }); + + const selectionHarness = (args: ReadonlyArray<string>) => { + const out = mockOutput({ format: "text" }); + const api = mockCommandPlatformApi({ + handler: (request) => + Effect.succeed( + request.url.endsWith("/branches") + ? jsonResponse(request, 200, [branchRow("feature-x")]) + : request.method === "GET" + ? jsonResponse(request, 200, []) + : jsonResponse(request, 201, deployedFunction("hello-world")), + ), + }); + const layer = Layer.mergeAll( + cliConfigValuesTestLayer, + buildTestRuntime({ + out, + api, + cliSettings: mockCommandSettings({ workdir: tempRoot.current, projectId: Option.none() }), + runtimeInfo: mockRuntimeInfo({ cwd: tempRoot.current }), + cliArgs: args, + }), + commandRuntimeLayer(["functions", "deploy"]).pipe(Layer.provide(BunServices.layer)), + approvedHostedMutation, + Stdio.layerTest({ args: Effect.succeed(args) }), + ); + const deployedTo = () => + api.requests + .filter( + (request) => request.method === "POST" && request.url.endsWith("/functions/deploy"), + ) + .map((request) => request.url); + const warnings = () => + out.messages.filter((message) => message.type === "warn").map((message) => message.message); + return { api, layer, deployedTo, warnings }; + }; + + const linkParent = 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); + }).pipe(Effect.provide(BunServices.layer)); + + it.effect("deploys to the project --remote=<ref> names, with no deprecation warning", () => { + const { layer, deployedTo, warnings } = selectionHarness([ + "functions", + "deploy", + "hello-world", + `--remote=${OTHER_REF}`, + ]); + return Effect.gen(function* () { + yield* writeCliConfig(tempRoot.current); + yield* writeLocalFunction(tempRoot.current, "hello-world"); + + yield* functionsDeploy({ + ...baseFlags, + remote: Option.some({ _tag: "Ref", ref: OTHER_REF }), + }); + + expect(deployedTo()).toEqual([ + `https://api.supabase.com/v1/projects/${OTHER_REF}/functions/deploy`, + ]); + expect(warnings()).toEqual([]); + }).pipe(Effect.provide(layer), Effect.ensuring(removeTempRoot)); + }); + + it.effect("keeps --project-ref working and points at --remote=<ref>", () => { + const { layer, deployedTo, warnings } = selectionHarness([ + "functions", + "deploy", + "hello-world", + "--project-ref", + OTHER_REF, + ]); + return Effect.gen(function* () { + yield* writeCliConfig(tempRoot.current); + yield* writeLocalFunction(tempRoot.current, "hello-world"); + + yield* functionsDeploy({ ...baseFlags, projectRef: Option.some(OTHER_REF) }); + + expect(deployedTo()).toEqual([ + `https://api.supabase.com/v1/projects/${OTHER_REF}/functions/deploy`, + ]); + expect(warnings()).toEqual([ + `--project-ref is deprecated and will be removed in v4. Use --remote instead: supabase functions deploy hello-world --remote=${OTHER_REF}`, + ]); + }).pipe(Effect.provide(layer), Effect.ensuring(removeTempRoot)); + }); + + it.effect("rejects a database URL before any request", () => { + const { api, layer } = selectionHarness([ + "functions", + "deploy", + "hello-world", + "--remote=postgresql://postgres:pw@db.example.com:5432/postgres", + ]); + return Effect.gen(function* () { + yield* writeCliConfig(tempRoot.current); + yield* writeLocalFunction(tempRoot.current, "hello-world"); + + const error = yield* functionsDeploy({ + ...baseFlags, + remote: Option.some({ + _tag: "DbUrl", + url: "postgresql://postgres:pw@db.example.com:5432/postgres", }), + }).pipe(Effect.flip); + + expect(error).toMatchObject({ + _tag: "TargetNeedsProjectError", + message: "`supabase functions deploy` needs a project, not a database URL.", + }); + expect(api.requests).toEqual([]); + }).pipe(Effect.provide(layer), Effect.ensuring(removeTempRoot)); + }); + + it.effect("deploys to the project of the branch --remote=<branch> names", () => { + const { api, layer, deployedTo } = selectionHarness([ + "functions", + "deploy", + "hello-world", + "--remote=feature-x", + ]); + return Effect.gen(function* () { + yield* writeCliConfig(tempRoot.current); + yield* writeLocalFunction(tempRoot.current, "hello-world"); + yield* linkParent; + + yield* functionsDeploy({ + ...baseFlags, + remote: Option.some({ _tag: "Branch", name: "feature-x" }), + }); + + expect(requestLines(api.requests)[0]).toBe( + `GET https://api.supabase.com/v1/projects/${VALID_REF}/branches`, ); - }, + expect(deployedTo()).toEqual([ + `https://api.supabase.com/v1/projects/${BRANCH_REF}/functions/deploy`, + ]); + assertPermissionDrift({ + command: "functions deploy", + activeFlags: { ...apiPathFlags, remote: true }, + requests: api.requests, + }); + }).pipe(Effect.provide(layer), Effect.ensuring(removeTempRoot)); }); - const layer = Layer.mergeAll( - cliConfigValuesTestLayer, - buildTestRuntime({ - out, - api, - cliSettings: mockCommandSettings({ - workdir: tempRoot.current, - projectId: Option.none(), - }), - runtimeInfo: mockRuntimeInfo({ cwd: tempRoot.current }), - }), - approvedHostedMutation, - Stdio.layerTest({ - args: Effect.succeed([ - "functions", - "deploy", - "hello-world", - "--use-api", - "--project-ref", - "qrstuvwxyzabcdefghij", - ]), - }), - ); - return Effect.gen(function* () { - yield* writeCliConfig(tempRoot.current); - yield* writeLocalFunction(tempRoot.current, "hello-world"); + it.effect("fails with the branches that exist when no branch has that name", () => { + const { layer, deployedTo } = selectionHarness([ + "functions", + "deploy", + "hello-world", + "--remote=feature-y", + ]); + return Effect.gen(function* () { + yield* writeCliConfig(tempRoot.current); + yield* writeLocalFunction(tempRoot.current, "hello-world"); + yield* linkParent; - yield* functionsDeploy({ - ...baseFlags, - projectRef: Option.some("qrstuvwxyzabcdefghij"), - }); + const error = yield* functionsDeploy({ + ...baseFlags, + remote: Option.some({ _tag: "Branch", name: "feature-y" }), + }).pipe(Effect.flip); - const deployRequest = api.requests.find( - (request) => request.method === "POST" && request.url.endsWith("/functions/deploy"), - ); - expect(deployRequest?.url).toContain("/projects/qrstuvwxyzabcdefghij/functions/deploy"); - }).pipe(Effect.provide(layer), Effect.ensuring(removeTempRoot)); + expect(error).toMatchObject({ + _tag: "LinkBranchNotFoundError", + message: expect.stringContaining("Available branches: feature-x"), + }); + expect(deployedTo()).toEqual([]); + }).pipe(Effect.provide(layer), Effect.ensuring(removeTempRoot)); + }); + + it.effect("stops a bare --remote that swallowed a function name that is also a branch", () => { + const { layer, deployedTo } = selectionHarness([ + "functions", + "deploy", + "--remote", + "feature-x", + ]); + return Effect.gen(function* () { + yield* writeCliConfig(tempRoot.current); + yield* writeLocalFunction(tempRoot.current, "feature-x"); + yield* linkParent; + + const error = yield* functionsDeploy({ + ...baseFlags, + functionNames: ["feature-x"], + remote: Option.some({ _tag: "Linked" }), + }).pipe(Effect.flip); + + expect(error).toMatchObject({ + _tag: "TargetRemoteAmbiguousError", + message: `feature-x is a branch of project ${VALID_REF}. Did you mean --remote=feature-x?`, + }); + expect(deployedTo()).toEqual([]); + }).pipe(Effect.provide(layer), Effect.ensuring(removeTempRoot)); + }); }); it.effect("resolves --import-map relative to the caller cwd", () => { 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 b330dda68e..222c149073 100644 --- a/apps/cli/src/commands/gen/types/types.integration.test.ts +++ b/apps/cli/src/commands/gen/types/types.integration.test.ts @@ -146,6 +146,7 @@ class ApiCallFailure extends Data.TaggedError("ApiCallFailure")<{ function defaultFlags(overrides: Partial<GenTypesFlags> = {}): GenTypesFlags { return { + remote: Option.none(), local: false, linked: false, dbUrl: Option.none(), @@ -527,6 +528,7 @@ const setup = Effect.fnUntraced(function* ( }), telemetry: telemetry.layer, linkedProjectCache: linkedProjectCache.layer, + cliArgs: opts.args ?? ["gen", "types"], }); const layer = Layer.mergeAll( @@ -969,19 +971,15 @@ describe("gen types", () => { }); describe("target selection", () => { - it.effect("follows a workspace target of remote to the linked project", () => + it.effect("targets the linked project for a bare --remote", () => 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)); + yield* genTypes(defaultFlags({ remote: Option.some({ _tag: "Linked" } as const) })).pipe( + Effect.provide(layer), + ); expect(out.stderrText).toContain(unverifiedTargetBanner()); expect(api.requests).toContainEqual({ @@ -991,25 +989,7 @@ describe("gen types", () => { }).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", () => + it.effect("treats --project-id as a deprecated alias of --remote=<ref>", () => Effect.gen(function* () { const { layer, out, api } = yield* setup({ args: ["gen", "types", "--project-id", VALID_REF], @@ -1019,9 +999,10 @@ describe("gen types", () => { Effect.provide(layer), ); - expect(out.stderrText).toContain( - "--project-id is deprecated and will be removed in v4; use --project-ref instead.", - ); + expect(out.messages).toContainEqual({ + type: "warn", + message: `--project-id is deprecated and will be removed in v4. Use --remote instead: supabase gen types --remote=${VALID_REF}`, + }); expect(out.stderrText).toContain(unverifiedTargetBanner()); expect(api.requests).toContainEqual({ method: "generateTypescriptTypes", @@ -1156,7 +1137,7 @@ describe("gen types", () => { expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { expect(String(exit.cause)).toContain( - "Only one of --local, --linked and --db-url can be set; got --local and --linked.", + "Only one of --remote, --linked, --db-url and --local can be set; got --linked and --local.", ); expect( Option.exists( @@ -1203,7 +1184,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-ref swift-access-control] are set none of the others can be; [linked swift-access-control] were all set", + "if any flags in the group [remote 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)), @@ -1234,7 +1215,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-ref swift-access-control] are set none of the others can be; [project-ref swift-access-control] were all set", + "if any flags in the group [remote 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)), @@ -1253,7 +1234,7 @@ describe("gen types", () => { if (Exit.isFailure(exit)) { // Established guard, including its "must used" typo — do not "fix" the grammar. expect(String(exit.cause)).toContain( - "--postgrest-v9-compat must used together with --db-url", + "--postgrest-v9-compat must used together with --remote=<db-url>", ); expect( Option.exists( @@ -1278,7 +1259,7 @@ describe("gen types", () => { expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { expect(String(exit.cause)).toContain( - "--postgrest-v9-compat must used together with --db-url", + "--postgrest-v9-compat must used together with --remote=<db-url>", ); } expect(telemetry.flushed).toBe(true); @@ -1321,7 +1302,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-ref query-timeout] are set none of the others can be; [linked project-ref query-timeout] were all set", + "if any flags in the group [remote 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); @@ -1362,7 +1343,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-ref query-timeout] are set none of the others can be; [project-ref query-timeout] were all set", + "if any flags in the group [remote 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)), @@ -1382,7 +1363,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-ref query-timeout] are set none of the others can be; [linked query-timeout] were all set", + "if any flags in the group [remote linked project-ref query-timeout] are set none of the others can be; [linked query-timeout] were all set", ); } }).pipe(Effect.provide(BunServices.layer)), @@ -1436,7 +1417,7 @@ describe("gen types", () => { expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { expect(String(exit.cause)).toContain( - "--postgrest-v9-compat must used together with --db-url", + "--postgrest-v9-compat must used together with --remote=<db-url>", ); } }).pipe(Effect.provide(BunServices.layer)), @@ -1490,7 +1471,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-ref postgrest-v9-compat] are set none of the others can be; [postgrest-v9-compat project-ref] were all set", + "if any flags in the group [remote 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)), @@ -3622,7 +3603,7 @@ describe("gen types permissions through the real layers", () => { expect(Exit.isSuccess(exit)).toBe(scenario.generates); if (Exit.isFailure(exit)) { expect(String(exit.cause)).toContain( - "--postgrest-v9-compat must used together with --db-url", + "--postgrest-v9-compat must used together with --remote=<db-url>", ); } expect(ctx.generator.calls).toHaveLength(scenario.generates ? 1 : 0); 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 088f62c597..d7cc170f6e 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 @@ -83,6 +83,7 @@ function setup() { const flags: InspectConnectionFlags = { dbUrl: Option.none<string>(), + remote: Option.none(), linked: false, local: true, projectRef: Option.none<string>(), 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 e4ff059e77..3acec1bf64 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,16 +1,6 @@ import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; -import { - Cause, - ConfigProvider, - Effect, - Exit, - FileSystem, - Layer, - Option, - Path, - Redacted, -} from "effect"; +import { Cause, ConfigProvider, Effect, Exit, Layer, Option, Redacted } from "effect"; import { vi } from "vitest"; vi.mock("node:net", (importOriginal) => @@ -244,6 +234,7 @@ function setup(opts: SetupOpts = {}) { const flags = (over: Partial<InspectConnectionFlags> = {}): InspectConnectionFlags => ({ dbUrl: over.dbUrl ?? Option.none<string>(), + remote: over.remote ?? Option.none(), linked: over.linked ?? false, local: over.local ?? false, projectRef: over.projectRef ?? Option.none<string>(), @@ -357,21 +348,14 @@ describe("inspect db query runner", () => { }).pipe(Effect.provide(layer)); }); - it.effect("follows a workspace target of remote to the linked project", () => { + it.effect("a bare --remote routes to the linked project and announces it", () => { 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()); + yield* inspectDbDbStats(flags({ remote: Option.some({ _tag: "Linked" } as const) })); expect(resolver.connectionInput?.target).toMatchObject({ kind: "linked", ref: VALID_REF }); expect(out.stderrText).toContain(unverifiedTargetBanner()); }).pipe(Effect.provide(layer)); 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 639f7b936f..6d0226984d 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 @@ -87,6 +87,7 @@ function setup(rows: ReadonlyArray<Record<string, unknown>>) { } const localFlags = { + remote: Option.none(), dbUrl: Option.none<string>(), linked: false, local: true, 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 c890c192f8..ee6d36ce19 100644 --- a/apps/cli/src/commands/inspect/report/report.integration.test.ts +++ b/apps/cli/src/commands/inspect/report/report.integration.test.ts @@ -231,6 +231,7 @@ function setupReport(opts: SetupOpts = {}) { const flags = (over: Partial<InspectReportFlags> = {}): InspectReportFlags => ({ dbUrl: over.dbUrl ?? Option.none<string>(), + remote: over.remote ?? Option.none(), linked: over.linked ?? false, local: over.local ?? false, projectRef: over.projectRef ?? Option.none<string>(), diff --git a/apps/cli/src/commands/link/link.errors.unit.test.ts b/apps/cli/src/commands/link/link.errors.unit.test.ts index ccbedb7b02..d45cb8cafe 100644 --- a/apps/cli/src/commands/link/link.errors.unit.test.ts +++ b/apps/cli/src/commands/link/link.errors.unit.test.ts @@ -2,11 +2,7 @@ import { describe, expect, it } from "vitest"; import { classifyCliErrorActionability } from "../../shared/telemetry/error-actionability.ts"; import { LinkAuthTokenError, - LinkBranchListNetworkError, - LinkBranchListStatusError, - LinkBranchNotReadyError, LinkMissingKeyError, - LinkParentRefInvalidError, LinkProjectStatusError, LinkProjectStatusNetworkError, } from "./link.errors.ts"; @@ -57,63 +53,3 @@ describe("link response actionability", () => { expect(result.error_fingerprint).toBe("tag:LinkMissingKeyError:api_response"); }); }); - -describe("branch-name resolution actionability (CLI-2167)", () => { - it("classifies a branch-list 404 as invalid input, same policy as the api-keys 404", () => { - const result = classifyCliErrorActionability( - new LinkBranchListStatusError({ status: 404, body: "ignored", message: "ignored" }), - ); - expect(result.error_kind).toBe("user_actionable"); - expect(result.error_category).toBe("invalid_input"); - expect(result.error_fingerprint).toBe("tag:LinkBranchListStatusError:not_found"); - }); - - it("keeps a non-404 branch-list status on the API-status policy", () => { - const result = classifyCliErrorActionability( - new LinkBranchListStatusError({ status: 500, body: "ignored", message: "ignored" }), - ); - expect(result.error_kind).toBe("external_service"); - expect(result.error_category).toBe("api_status"); - expect(result.error_fingerprint).toBe("tag:LinkBranchListStatusError:api_status"); - }); - - it("classifies a branch-list body-decode failure as an API response problem", () => { - const result = classifyCliErrorActionability( - new LinkBranchListNetworkError({ message: "boom", decode: true }), - ); - expect(result.error_kind).toBe("external_service"); - expect(result.error_category).toBe("api_status"); - expect(result.error_fingerprint).toBe("tag:LinkBranchListNetworkError:api_response"); - }); - - it("classifies a branch-list transport failure as network", () => { - const result = classifyCliErrorActionability( - new LinkBranchListNetworkError({ message: "boom" }), - ); - expect(result.error_kind).toBe("external_service"); - expect(result.error_category).toBe("network"); - }); - - it("classifies an invalid linked parent ref as a relink-project remediation", () => { - const result = classifyCliErrorActionability( - new LinkParentRefInvalidError({ message: "ignored" }), - ); - expect(result.error_kind).toBe("user_actionable"); - expect(result.error_category).toBe("invalid_config"); - expect(result.suggestion_type).toBe("link_project"); - expect(result.suggested_command).toBe("supabase link"); - }); - - it("classifies a not-yet-provisioned branch as an API-status problem with its own fingerprint", () => { - const result = classifyCliErrorActionability( - new LinkBranchNotReadyError({ - branch: "feature-branch", - status: "CREATING_PROJECT", - message: "ignored", - }), - ); - expect(result.error_kind).toBe("external_service"); - expect(result.error_category).toBe("api_status"); - expect(result.error_fingerprint).toBe("tag:LinkBranchNotReadyError:branch_not_ready"); - }); -}); 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 19ba2a95a9..b17ec29cbb 100644 --- a/apps/cli/src/commands/migration/down/down.integration.test.ts +++ b/apps/cli/src/commands/migration/down/down.integration.test.ts @@ -202,6 +202,7 @@ function setup(workdir: string, opts: SetupOpts = {}) { const flags = (over: Partial<MigrationDownFlags> = {}): MigrationDownFlags => ({ last: over.last ?? 1, dbUrl: over.dbUrl ?? Option.none(), + remote: over.remote ?? Option.none(), linked: over.linked ?? false, local: over.local ?? false, projectRef: over.projectRef ?? 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 adb7547d60..c56ccdb482 100644 --- a/apps/cli/src/commands/migration/fetch/fetch.integration.test.ts +++ b/apps/cli/src/commands/migration/fetch/fetch.integration.test.ts @@ -167,7 +167,7 @@ function setup(workdir: string, opts: SetupOpts = {}) { Layer.succeed(YesFlag, opts.yes ?? false), Layer.succeed(CliArgs, { args: ["migration", "fetch"] }), Layer.succeed(OutputFlag, Option.none()), - Layer.succeed(TargetPolicy, { group: "sync", consent: "none" }), + Layer.succeed(TargetPolicy, { group: "selectable", consent: "none" }), mockTty({ stdinIsTty: opts.isTTY ?? true }), mockStdin( opts.isTTY ?? true, @@ -181,6 +181,7 @@ function setup(workdir: string, opts: SetupOpts = {}) { const flags = (over: Partial<MigrationFetchFlags> = {}): MigrationFetchFlags => ({ dbUrl: over.dbUrl ?? Option.none(), + remote: over.remote ?? Option.none(), linked: over.linked ?? false, local: over.local ?? false, projectRef: over.projectRef ?? Option.none(), @@ -460,10 +461,10 @@ describe("migration fetch", () => { }).pipe(Effect.provide(layer)); }); - it.effect("targets the linked project by default and caches its ref", () => { + it.effect("targets the linked project for a bare --remote and caches its ref", () => { const { layer, resolveCalls, cache } = setup(tmp.current, { rows: [] }); return Effect.gen(function* () { - yield* migrationFetch(flags()); + yield* migrationFetch(flags({ remote: Option.some({ _tag: "Linked" }) })); expect(resolveCalls).toHaveLength(1); expect(resolveCalls[0]?.target).toMatchObject({ kind: "linked", ref: VALID_REF }); expect(Option.isNone(resolveCalls[0]?.networkBanRemoval ?? Option.none())).toBe(true); 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 882c211a18..c2c8778f07 100644 --- a/apps/cli/src/commands/migration/list/list.integration.test.ts +++ b/apps/cli/src/commands/migration/list/list.integration.test.ts @@ -162,6 +162,7 @@ function setup(workdir: string, opts: SetupOpts = {}) { const flags = (over: Partial<MigrationListFlags> = {}): MigrationListFlags => ({ dbUrl: over.dbUrl ?? Option.none(), + remote: over.remote ?? Option.none(), linked: over.linked ?? false, local: over.local ?? false, projectRef: over.projectRef ?? Option.none(), @@ -176,13 +177,6 @@ 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", () => { @@ -205,22 +199,18 @@ 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); - const stdout = stripAnsi(ctx.out.stdoutText); - expect(stdout).toContain("Remote"); - expect(stdout).not.toContain("Local DB"); - }).pipe(Effect.provide(ctx.layer)); - }, - ); + it.effect("lists the linked project and fills the cache for a bare --remote", () => { + const ctx = setup(tmp.current, { remote: ["20240101000000"] }); + return Effect.gen(function* () { + yield* seedMigrations(tmp.current, ["20240101000000_a.sql"]); + yield* migrationList(flags({ remote: Option.some({ _tag: "Linked" }) })); + 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)); + }); it.effect("shows an empty Remote column when the history table is absent (42P01)", () => { const { layer, out } = setup(tmp.current, { 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 e24f80861c..04c273f1e7 100644 --- a/apps/cli/src/commands/migration/repair/repair.integration.test.ts +++ b/apps/cli/src/commands/migration/repair/repair.integration.test.ts @@ -190,6 +190,7 @@ const input = (over: Partial<MigrationRepairInput> = {}): MigrationRepairInput = versions: over.versions ?? [], status: over.status ?? "applied", dbUrl: over.dbUrl ?? Option.none(), + remote: over.remote ?? Option.none(), linked: over.linked ?? false, local: over.local ?? false, projectRef: over.projectRef ?? 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 0a8178778a..306ac042de 100644 --- a/apps/cli/src/commands/migration/squash/squash.integration.test.ts +++ b/apps/cli/src/commands/migration/squash/squash.integration.test.ts @@ -447,6 +447,7 @@ function setup(workdir: string, opts: SetupOpts = {}) { const flags = (over: Partial<MigrationSquashFlags> = {}): MigrationSquashFlags => ({ version: over.version ?? Option.none(), dbUrl: over.dbUrl ?? Option.none(), + remote: over.remote ?? Option.none(), linked: over.linked ?? false, local: over.local ?? false, password: over.password ?? 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 e1b1d59f6a..539cf73330 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 @@ -40,6 +40,7 @@ const liveStackApi = stackApiLayer.pipe(Layer.provide(BunServices.layer)); const flags: MigrationSquashFlags = { version: Option.none(), dbUrl: Option.none(), + remote: Option.none(), linked: false, local: true, password: Option.none(), 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 f060c68c64..b1beaa0546 100644 --- a/apps/cli/src/commands/migration/up/up.integration.test.ts +++ b/apps/cli/src/commands/migration/up/up.integration.test.ts @@ -185,6 +185,7 @@ function setup(workdir: string, opts: SetupOpts = {}) { const flags = (over: Partial<MigrationUpFlags> = {}): MigrationUpFlags => ({ includeAll: over.includeAll ?? false, dbUrl: over.dbUrl ?? Option.none(), + remote: over.remote ?? Option.none(), linked: over.linked ?? false, local: over.local ?? false, projectRef: over.projectRef ?? Option.none(), @@ -241,10 +242,10 @@ describe("migration up", () => { const error = yield* migrationUp(flags()).pipe(Effect.flip); expect(error).toBeInstanceOf(MigrationMissingLocalError); expect(error).toMatchObject({ - suggestion: expect.stringContaining("migration repair --local --status reverted"), + suggestion: expect.stringContaining("migration repair --status reverted 20240199000000\n"), }); expect(error).toMatchObject({ - suggestion: expect.stringContaining("supabase db pull --local"), + suggestion: expect.stringContaining("supabase db pull\n"), }); }).pipe(Effect.provide(layer)); }); 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 3d36f11713..8c79bfc95f 100644 --- a/apps/cli/src/commands/pull/pull.aggregate.unit.test.ts +++ b/apps/cli/src/commands/pull/pull.aggregate.unit.test.ts @@ -438,15 +438,15 @@ describe("pullRetryHint", () => { ); }); - it("names 'supabase migration fetch --project-ref <ref>' for the migration_history step", () => { + it("names 'supabase migration fetch --remote=<ref>' for the migration_history step", () => { expect(pullRetryHint("migration_history", ref, undefined)).toBe( - "To retry just this step, run: supabase migration fetch --project-ref abcdefghijklmnopqrst", + "To retry just this step, run: supabase migration fetch --remote=abcdefghijklmnopqrst", ); }); - it("names 'supabase db pull --project-ref <ref> --experimental=false' so the db step avoids the structured-dump rejection", () => { + it("names 'supabase db pull --experimental=false --remote=<ref>' so the db step avoids the structured-dump rejection", () => { expect(pullRetryHint("db", ref, undefined)).toBe( - "To retry just this step, run: supabase db pull --project-ref abcdefghijklmnopqrst --experimental=false", + "To retry just this step, run: supabase db pull --experimental=false --remote=abcdefghijklmnopqrst", ); }); diff --git a/apps/cli/src/commands/pull/pull.integration.test.ts b/apps/cli/src/commands/pull/pull.integration.test.ts index 980a944467..9774976ac9 100644 --- a/apps/cli/src/commands/pull/pull.integration.test.ts +++ b/apps/cli/src/commands/pull/pull.integration.test.ts @@ -1887,10 +1887,10 @@ describe("pull integration", () => { expect(stepLine(out.stdoutText, "functions")).toContain("unchanged"); expect(out.stdoutText).toContain( - `To retry just this step, run: supabase migration fetch --project-ref ${VALID_REF}`, + `To retry just this step, run: supabase migration fetch --remote=${VALID_REF}`, ); expect(out.stdoutText).toContain( - `To retry just this step, run: supabase db pull --project-ref ${VALID_REF} --experimental=false`, + `To retry just this step, run: supabase db pull --experimental=false --remote=${VALID_REF}`, ); }).pipe(Effect.provide(layer)); }, @@ -1961,7 +1961,7 @@ describe("pull integration", () => { expect(stepLine(out.stdoutText, "functions")).toContain("unchanged"); expect(out.stdoutText).toContain( - `To retry just this step, run: supabase db pull --project-ref ${VALID_REF} --experimental=false`, + `To retry just this step, run: supabase db pull --experimental=false --remote=${VALID_REF}`, ); }).pipe(Effect.provide(layer)); }, @@ -1998,7 +1998,7 @@ describe("pull integration", () => { `Alternatively, rerun \`supabase pull --with-migration-history --project-ref ${VALID_REF}\``, ); const retryHintIndex = suggestion.indexOf( - `To retry just this step, run: supabase db pull --project-ref ${VALID_REF}`, + `To retry just this step, run: supabase db pull --experimental=false --remote=${VALID_REF}`, ); expect(migrationHistoryHintIndex).toBeGreaterThan(-1); expect(retryHintIndex).toBeGreaterThan(migrationHistoryHintIndex); 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 f82aeaaabb..337f24cec0 100644 --- a/apps/cli/src/commands/seed/buckets/buckets.integration.test.ts +++ b/apps/cli/src/commands/seed/buckets/buckets.integration.test.ts @@ -78,7 +78,12 @@ interface MockRoute { readonly transportDescription?: string; } -const DEFAULT_FLAGS: BucketsFlags = { linked: false, local: true, projectRef: Option.none() }; +const DEFAULT_FLAGS: BucketsFlags = { + remote: Option.none(), + linked: false, + local: true, + projectRef: Option.none(), +}; function failureErrors<A, E>(exit: Exit.Exit<A, E>): ReadonlyArray<E> { if (!Exit.isFailure(exit)) return []; @@ -831,7 +836,12 @@ describe("seed buckets", () => { // A linked run never short-circuits on empty config: even with no config // file, the remote client is built, the service-role key fetched, and // buckets listed — failures surface instead of a silent success. - const flags: BucketsFlags = { linked: true, local: false, projectRef: Option.none() }; + const flags: BucketsFlags = { + remote: Option.none(), + linked: true, + local: false, + projectRef: Option.none(), + }; const { layer, requests } = yield* setupSeedBuckets(tmp.current, { projectRef: VALID_REF, apiKeys: [ @@ -1164,6 +1174,7 @@ describe("seed buckets", () => { routes: [{ method: "GET", match: "/storage/v1/bucket", transport: true }], }); const exit = yield* seedBuckets({ + remote: Option.none(), linked: true, local: false, projectRef: Option.none(), @@ -2124,7 +2135,12 @@ describe("seed buckets", () => { it.effect("--linked seeds the remote storage project", () => Effect.gen(function* () { - const flags: BucketsFlags = { linked: true, local: false, projectRef: Option.none() }; + const flags: BucketsFlags = { + remote: Option.none(), + linked: true, + local: false, + projectRef: Option.none(), + }; const { layer, out, requests } = yield* setupSeedBuckets(tmp.current, { toml: "[storage.buckets.test]\npublic = true\n", projectRef: VALID_REF, @@ -2163,9 +2179,12 @@ describe("seed buckets", () => { ], }); const fs = yield* FileSystem.FileSystem; - yield* seedBuckets({ linked: true, local: false, projectRef: Option.none() }).pipe( - Effect.provide(layer), - ); + yield* seedBuckets({ + remote: Option.none(), + linked: true, + local: false, + projectRef: Option.none(), + }).pipe(Effect.provide(layer)); expect( managementRequests.map(({ method, urlWithParams }) => `${method} ${urlWithParams}`), ).toEqual([ @@ -2199,9 +2218,12 @@ describe("seed buckets", () => { { method: "POST", match: "/storage/v1/bucket", body: { name: "test" } }, ], }); - yield* seedBuckets({ linked: true, local: false, projectRef: Option.none() }).pipe( - Effect.provide(layer), - ); + yield* seedBuckets({ + remote: Option.none(), + linked: true, + local: false, + projectRef: Option.none(), + }).pipe(Effect.provide(layer)); expect(managementRequests.map(({ method, url }) => `${method} ${url}`)).toEqual([ `GET https://api.supabase.com/v1/projects/${VALID_REF}`, ]); @@ -2255,6 +2277,7 @@ describe("seed buckets", () => { ], }); const exit = yield* seedBuckets({ + remote: Option.none(), linked: true, local: false, projectRef: Option.some(FLAG_REF), @@ -2279,6 +2302,7 @@ describe("seed buckets", () => { toml: "[storage.buckets.test]\npublic = true\n", }); const exit = yield* seedBuckets({ + remote: Option.none(), linked: false, local: true, projectRef: Option.some(FLAG_REF), @@ -2286,7 +2310,7 @@ describe("seed buckets", () => { expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { expect(Cause.pretty(exit.cause)).toContain( - "--project-ref selects a linked project and cannot be combined with --local.", + "--project-ref selects a remote project and cannot be combined with --local.", ); } expect(requests).toEqual([]); @@ -2305,6 +2329,7 @@ describe("seed buckets", () => { ], }); const exit = yield* seedBuckets({ + remote: Option.none(), linked: false, local: false, projectRef: Option.none(), @@ -2328,6 +2353,7 @@ describe("seed buckets", () => { routes: [{ method: "GET", match: "/storage/v1/bucket", body: [] }], }); const exit = yield* seedBuckets({ + remote: Option.none(), linked: true, local: false, projectRef: Option.none(), @@ -2353,6 +2379,7 @@ describe("seed buckets", () => { routes: [{ method: "GET", match: "/storage/v1/bucket", body: [] }], }); const exit = yield* seedBuckets({ + remote: Option.none(), linked: true, local: false, projectRef: Option.none(), @@ -2390,10 +2417,12 @@ describe("seed buckets", () => { { method: "POST", match: "/storage/v1/bucket", body: { name: "test" } }, ], }); - yield* seedBuckets({ linked: true, local: false, projectRef: Option.none() }).pipe( - Effect.provide(linked.layer), - Effect.exit, - ); + yield* seedBuckets({ + remote: Option.none(), + linked: true, + local: false, + projectRef: Option.none(), + }).pipe(Effect.provide(linked.layer), Effect.exit); expect(linked.linkedCache.cached).toBe(true); expect(linked.linkedCache.cachedRef).toBe(VALID_REF); @@ -2408,7 +2437,12 @@ describe("seed buckets", () => { "SUPABASE_AUTH_SERVICE_ROLE_KEY", "env-service-role-key", Effect.gen(function* () { - const flags: BucketsFlags = { linked: true, local: false, projectRef: Option.none() }; + const flags: BucketsFlags = { + remote: Option.none(), + linked: true, + local: false, + projectRef: Option.none(), + }; const { layer, requests } = yield* setupSeedBuckets(tmp.current, { toml: "[storage.buckets.test]\npublic = true\n", projectRef: VALID_REF, @@ -2432,7 +2466,12 @@ describe("seed buckets", () => { "SUPABASE_AUTH_SERVICE_ROLE_KEY", "", Effect.gen(function* () { - const flags: BucketsFlags = { linked: true, local: false, projectRef: Option.none() }; + const flags: BucketsFlags = { + remote: Option.none(), + linked: true, + local: false, + projectRef: Option.none(), + }; const { layer, requests } = yield* setupSeedBuckets(tmp.current, { toml: "[storage.buckets.test]\npublic = true\n", projectRef: VALID_REF, @@ -2453,7 +2492,12 @@ describe("seed buckets", () => { it.effect("upserts analytics buckets when analytics.enabled and --linked", () => Effect.gen(function* () { - const flags: BucketsFlags = { linked: true, local: false, projectRef: Option.none() }; + const flags: BucketsFlags = { + remote: Option.none(), + linked: true, + local: false, + projectRef: Option.none(), + }; const { layer, out, requests } = yield* setupSeedBuckets(tmp.current, { toml: [ "[storage.analytics]", @@ -2504,7 +2548,12 @@ describe("seed buckets", () => { 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 flags: BucketsFlags = { + remote: Option.none(), + linked: true, + local: false, + projectRef: Option.none(), + }; const { layer, out, requests } = yield* setupSeedBuckets(tmp.current, { toml: [ "[storage.analytics]", @@ -2559,6 +2608,7 @@ describe("seed buckets", () => { ], }); const exit = yield* seedBuckets({ + remote: Option.none(), linked: true, local: false, projectRef: Option.none(), @@ -2585,6 +2635,7 @@ describe("seed buckets", () => { routes: [{ method: "GET", match: "/storage/v1/bucket", body: [] }], }); const exit = yield* seedBuckets({ + remote: Option.none(), linked: true, local: false, projectRef: Option.none(), @@ -2612,7 +2663,12 @@ describe("seed buckets", () => { it.effect("--linked fails when the project is not linked", () => Effect.gen(function* () { - const flags: BucketsFlags = { linked: true, local: false, projectRef: Option.none() }; + const flags: BucketsFlags = { + remote: Option.none(), + linked: true, + local: false, + projectRef: Option.none(), + }; const { layer } = yield* setupSeedBuckets(tmp.current, { toml: "[storage.buckets.test]\npublic = true\n", linkedFails: true, @@ -2708,7 +2764,12 @@ describe("seed buckets", () => { // bucket; `mergeRemoteConfig` merges subtrees recursively rather than // replacing [storage.buckets] wholesale, so both buckets are seeded. const remoteRef = VALID_REF; - const flags: BucketsFlags = { linked: true, local: false, projectRef: Option.none() }; + const flags: BucketsFlags = { + remote: Option.none(), + linked: true, + local: false, + projectRef: Option.none(), + }; const { layer, out, requests } = yield* setupSeedBuckets(tmp.current, { toml: [ 'project_id = "test"', @@ -3189,6 +3250,7 @@ describeHostedConsent("seed buckets", { ]); return { run: seedBuckets({ + remote: Option.none(), linked: false, local: false, projectRef: Option.some(VALID_REF), 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 1f7b2e942a..6d11f6dcb9 100644 --- a/apps/cli/src/commands/storage/cp/cp.integration.test.ts +++ b/apps/cli/src/commands/storage/cp/cp.integration.test.ts @@ -39,6 +39,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), + remote: Option.none(), linked: opts.local === false, local: opts.local ?? true, projectRef: Option.none(), @@ -606,7 +607,7 @@ describe("storage cp", () => { expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { expect(Cause.pretty(exit.cause)).toContain( - "--project-ref selects a linked project and cannot be combined with --local.", + "--project-ref selects a remote project and cannot be combined with --local.", ); } expect(requests).toHaveLength(0); 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 40f898099b..b70bb7c696 100644 --- a/apps/cli/src/commands/storage/ls/ls.integration.test.ts +++ b/apps/cli/src/commands/storage/ls/ls.integration.test.ts @@ -41,6 +41,7 @@ function lsFlags( return { path: opts.path === undefined ? Option.none() : Option.some(opts.path), recursive: opts.recursive ?? false, + remote: Option.none(), linked: opts.local === false, local: opts.local ?? true, projectRef: Option.none(), @@ -303,7 +304,7 @@ project_id = "env(STAGING_REF)" expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { expect(Cause.pretty(exit.cause)).toContain( - "--project-ref selects a linked project and cannot be combined with --local.", + "--project-ref selects a remote project and cannot be combined with --local.", ); } expect(requests).toHaveLength(0); 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 442cdd2f88..3938db320a 100644 --- a/apps/cli/src/commands/storage/mv/mv.integration.test.ts +++ b/apps/cli/src/commands/storage/mv/mv.integration.test.ts @@ -31,6 +31,7 @@ function mvFlags(opts: { src: opts.src, dst: opts.dst, recursive: opts.recursive ?? false, + remote: Option.none(), linked: opts.local === false, local: opts.local ?? true, projectRef: Option.none(), @@ -327,7 +328,7 @@ describe("storage mv", () => { expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { expect(Cause.pretty(exit.cause)).toContain( - "--project-ref selects a linked project and cannot be combined with --local.", + "--project-ref selects a remote project and cannot be combined with --local.", ); } expect(requests).toHaveLength(0); 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 e028a4455a..d87d91917f 100644 --- a/apps/cli/src/commands/storage/rm/rm.integration.test.ts +++ b/apps/cli/src/commands/storage/rm/rm.integration.test.ts @@ -75,6 +75,7 @@ describe("storage rm", () => { const exit = yield* storageRm({ files: ["ss:///private/abstract.pdf", "ss:///private/docs/readme.md"], recursive: false, + remote: Option.none(), linked: false, local: true, projectRef: Option.none(), @@ -98,6 +99,7 @@ describe("storage rm", () => { const exit = yield* storageRm({ files: ["ss:///private/a.pdf"], recursive: false, + remote: Option.none(), linked: false, local: true, projectRef: Option.none(), @@ -122,6 +124,7 @@ describe("storage rm", () => { const exit = yield* storageRm({ files: ["ss:///private/a.pdf"], recursive: false, + remote: Option.none(), linked: false, local: true, projectRef: Option.none(), @@ -145,6 +148,7 @@ describe("storage rm", () => { const exit = yield* storageRm({ files: ["ss:///private/a.pdf"], recursive: false, + remote: Option.none(), linked: false, local: true, projectRef: Option.none(), @@ -166,6 +170,7 @@ describe("storage rm", () => { const exit = yield* storageRm({ files: ["ss:///private/a.pdf"], recursive: false, + remote: Option.none(), linked: true, local: false, projectRef: Option.none(), @@ -195,6 +200,7 @@ describe("storage rm", () => { const exit = yield* storageRm({ files: ["ss:///private/a.pdf"], recursive: false, + remote: Option.none(), linked: false, local: true, projectRef: Option.none(), @@ -216,6 +222,7 @@ describe("storage rm", () => { const exit = yield* storageRm({ files: ["ss:///private/a.pdf"], recursive: false, + remote: Option.none(), linked: false, local: true, projectRef: Option.none(), @@ -238,6 +245,7 @@ describe("storage rm", () => { const exit = yield* storageRm({ files: ["ss:///private/a.pdf"], recursive: false, + remote: Option.none(), linked: false, local: true, projectRef: Option.none(), @@ -267,6 +275,7 @@ describe("storage rm", () => { const exit = yield* storageRm({ files: ["ss:///private/a.pdf"], recursive: false, + remote: Option.none(), linked: false, local: true, projectRef: Option.none(), @@ -301,6 +310,7 @@ describe("storage rm", () => { const exit = yield* storageRm({ files: [], recursive: true, + remote: Option.none(), linked: false, local: true, projectRef: Option.none(), @@ -330,6 +340,7 @@ describe("storage rm", () => { const exit = yield* storageRm({ files: [], recursive: false, + remote: Option.none(), linked: false, local: true, projectRef: Option.none(), @@ -370,6 +381,7 @@ describe("storage rm", () => { const exit = yield* storageRm({ files, recursive: false, + remote: Option.none(), linked: false, local: true, projectRef: Option.none(), @@ -393,6 +405,7 @@ describe("storage rm", () => { const exit = yield* storageRm({ files: ["ss:///"], recursive: false, + remote: Option.none(), linked: false, local: true, projectRef: Option.none(), @@ -414,6 +427,7 @@ describe("storage rm", () => { const exit = yield* storageRm({ files: ["ss:///private/"], recursive: false, + remote: Option.none(), linked: false, local: true, projectRef: Option.none(), @@ -437,6 +451,7 @@ describe("storage rm", () => { const exit = yield* storageRm({ files: [], recursive: false, + remote: Option.none(), linked: false, local: true, projectRef: Option.none(), @@ -466,6 +481,7 @@ describe("storage rm", () => { const exit = yield* storageRm({ files: [], recursive: true, + remote: Option.none(), linked: false, local: true, projectRef: Option.none(), @@ -501,6 +517,7 @@ describe("storage rm", () => { const exit = yield* storageRm({ files: ["ss:///test"], recursive: true, + remote: Option.none(), linked: false, local: true, projectRef: Option.none(), @@ -559,6 +576,7 @@ describe("storage rm", () => { const exit = yield* storageRm({ files: ["ss:///private/dir"], recursive: true, + remote: Option.none(), linked: false, local: true, projectRef: Option.none(), @@ -591,6 +609,7 @@ describe("storage rm", () => { const exit = yield* storageRm({ files: ["ss:///test"], recursive: true, + remote: Option.none(), linked: false, local: true, projectRef: Option.none(), @@ -615,6 +634,7 @@ describe("storage rm", () => { const exit = yield* storageRm({ files: ["ss:///private/dir"], recursive: true, + remote: Option.none(), linked: false, local: true, projectRef: Option.none(), @@ -638,6 +658,7 @@ describe("storage rm", () => { const exit = yield* storageRm({ files: ["ss:///private/a.pdf"], recursive: false, + remote: Option.none(), linked: false, local: true, projectRef: Option.none(), @@ -669,6 +690,7 @@ describe("storage rm", () => { const exit = yield* storageRm({ files: ["ss:///private/a.pdf"], recursive: false, + remote: Option.none(), linked: false, local: true, projectRef: Option.none(), @@ -691,6 +713,7 @@ describe("storage rm", () => { const exit = yield* storageRm({ files: [], recursive: true, + remote: Option.none(), linked: false, local: true, projectRef: Option.none(), @@ -713,6 +736,7 @@ describe("storage rm", () => { const exit = yield* storageRm({ files: ["ss:///private/a.pdf"], recursive: false, + remote: Option.none(), linked: true, local: false, projectRef: Option.none(), @@ -736,6 +760,7 @@ describe("storage rm", () => { const exit = yield* storageRm({ files: ["ss:///private/a.pdf"], recursive: false, + remote: Option.none(), linked: true, local: false, projectRef: Option.some(FLAG_REF), @@ -759,6 +784,7 @@ describe("storage rm", () => { const exit = yield* storageRm({ files: ["ss:///private/a.pdf"], recursive: false, + remote: Option.none(), linked: false, local: true, projectRef: Option.some(FLAG_REF), @@ -766,7 +792,7 @@ describe("storage rm", () => { expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { expect(Cause.pretty(exit.cause)).toContain( - "--project-ref selects a linked project and cannot be combined with --local.", + "--project-ref selects a remote project and cannot be combined with --local.", ); } expect(requests).toHaveLength(0); @@ -796,6 +822,7 @@ describe("storage rm", () => { const exit = yield* storageRm({ files: ["ss:///private/a.pdf"], recursive: false, + remote: Option.none(), linked: false, local: true, projectRef: Option.none(), @@ -821,6 +848,7 @@ describe("storage rm", () => { const exit = yield* storageRm({ files: ["ss:///private/a.pdf"], recursive: false, + remote: Option.none(), linked: false, local: true, projectRef: Option.none(), @@ -845,6 +873,7 @@ describe("storage rm", () => { const exit = yield* storageRm({ files: ["ss:///private/a.pdf"], recursive: false, + remote: Option.none(), linked: false, local: true, projectRef: Option.none(), @@ -871,6 +900,7 @@ describe("storage rm", () => { const exit = yield* storageRm({ files: ["ss:///private/a.pdf"], recursive: false, + remote: Option.none(), linked: false, local: true, projectRef: Option.none(), @@ -898,6 +928,7 @@ describe("storage rm", () => { yield* storageRm({ files: ["ss:///private/a.pdf"], recursive: false, + remote: Option.none(), linked: true, local: false, projectRef: Option.none(), @@ -936,6 +967,7 @@ describe("stack backend", () => { const exit = yield* storageRm({ files: ["ss:///private/a.pdf"], recursive: false, + remote: Option.none(), linked: false, local: true, projectRef: Option.none(), @@ -965,6 +997,7 @@ describe("stack backend", () => { const exit = yield* storageRm({ files: ["ss:///private/a.pdf"], recursive: false, + remote: Option.none(), linked: false, local: true, projectRef: Option.none(), @@ -988,6 +1021,7 @@ describe("stack backend", () => { const exit = yield* storageRm({ files: ["ss:///private/a.pdf"], recursive: false, + remote: Option.none(), linked: false, local: true, projectRef: Option.none(), @@ -1021,6 +1055,7 @@ describeHostedConsent("storage rm", { run: storageRm({ files: ["ss:///private/a.pdf"], recursive: false, + remote: Option.none(), linked: false, local: false, projectRef: Option.some(VALID_REF), 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 b5b1b8e144..8dd9c9a16f 100644 --- a/apps/cli/src/shared/cli/hidden-flag.unit.test.ts +++ b/apps/cli/src/shared/cli/hidden-flag.unit.test.ts @@ -122,7 +122,7 @@ describe("native hidden flags", () => { ]); expect(buildHelpDoc(functionsDeployCommand).flags.map((flag) => flag.name)).toEqual([ - "project-ref", + "remote", "no-verify-jwt", "use-api", "import-map", diff --git a/apps/cli/src/shared/cli/remote-argv.unit.test.ts b/apps/cli/src/shared/cli/remote-argv.unit.test.ts new file mode 100644 index 0000000000..482af85b44 --- /dev/null +++ b/apps/cli/src/shared/cli/remote-argv.unit.test.ts @@ -0,0 +1,190 @@ +import { describe, expect, it } from "vitest"; + +import { rootCommandForFeatures } from "../../cli/root.ts"; +import { joinRemoteOperands, type RemoteArgvLeaf } from "./remote-argv.ts"; +import { rewriteRemoteArgv } from "./run.ts"; + +const REF = "abcdefghijklmnopqrst"; +const URL = "postgresql://postgres:secret@db.example.com:5432/postgres"; + +const noOperands: RemoteArgvLeaf = { + pathLength: 2, + operandCapacity: 0, + consumesNextToken: (token) => token === "--password" || token === "-p", +}; +const oneOperand: RemoteArgvLeaf = { ...noOperands, operandCapacity: 1 }; +const variadic: RemoteArgvLeaf = { ...noOperands, operandCapacity: Infinity }; + +describe("joinRemoteOperands", () => { + const cases: ReadonlyArray<{ + readonly name: string; + readonly leaf: RemoteArgvLeaf; + readonly args: ReadonlyArray<string>; + readonly expected: ReadonlyArray<string>; + }> = [ + { + name: "joins a branch name for a command with no operands", + leaf: noOperands, + args: ["db", "push", "--remote", "feature-x"], + expected: ["db", "push", `--remote=feature-x`], + }, + { + name: "joins a project ref", + leaf: oneOperand, + args: ["storage", "ls", "--remote", REF], + expected: ["storage", "ls", `--remote=${REF}`], + }, + { + name: "joins a database URL", + leaf: variadic, + args: ["functions", "deploy", "--remote", URL], + expected: ["functions", "deploy", `--remote=${URL}`], + }, + { + name: "joins each linked boolean literal", + leaf: variadic, + args: ["functions", "deploy", "--remote", "yes", "--remote", "on", "--remote", "1"], + expected: ["functions", "deploy", "--remote=yes", "--remote=on", "--remote=1"], + }, + { + name: "leaves an off literal for the parser to reject", + leaf: noOperands, + args: ["db", "push", "--remote", "false"], + expected: ["db", "push", "--remote", "false"], + }, + { + name: "keeps a branch-shaped word as the operand when the command takes one", + leaf: oneOperand, + args: ["storage", "ls", "--remote", "feature-x"], + expected: ["storage", "ls", "--remote", "feature-x"], + }, + { + name: "joins a branch-shaped word once the command has more operands than it takes", + leaf: oneOperand, + args: ["storage", "ls", "ss:///bucket", "--remote", "feature-x"], + expected: ["storage", "ls", "ss:///bucket", "--remote=feature-x"], + }, + { + name: "never joins for a command with a variable number of operands", + leaf: variadic, + args: ["functions", "deploy", "--remote", "feature-x", "hello"], + expected: ["functions", "deploy", "--remote", "feature-x", "hello"], + }, + { + name: "does not join a flag", + leaf: noOperands, + args: ["db", "push", "--remote", "--dry-run"], + expected: ["db", "push", "--remote", "--dry-run"], + }, + { + name: "does not join a word after --", + leaf: noOperands, + args: ["db", "push", "--remote", "--", "feature-x"], + expected: ["db", "push", "--remote", "--", "feature-x"], + }, + { + name: "does not join the next command-path segment", + leaf: noOperands, + args: ["db", "--remote", "push"], + expected: ["db", "--remote", "push"], + }, + { + name: "does not read another flag's value as a --remote", + leaf: noOperands, + args: ["db", "push", "--password", "--remote", "feature-x"], + expected: ["db", "push", "--password", "--remote", "feature-x"], + }, + { + name: "skips the value of another flag when counting operands", + leaf: oneOperand, + args: ["storage", "ls", "-p", "secret", "--remote", "feature-x"], + expected: ["storage", "ls", "-p", "secret", "--remote", "feature-x"], + }, + { + name: "keeps an inline value untouched", + leaf: noOperands, + args: ["db", "push", "--remote=feature-x", "extra"], + expected: ["db", "push", "--remote=feature-x", "extra"], + }, + { + name: "joins an empty word so the parser reports it", + leaf: noOperands, + args: ["db", "push", "--remote", ""], + expected: ["db", "push", "--remote="], + }, + { + name: "joins a word that comes before other flags", + leaf: noOperands, + args: ["db", "push", "--remote", "feature-x", "--dry-run"], + expected: ["db", "push", "--remote=feature-x", "--dry-run"], + }, + ]; + + it.each(cases)("$name", ({ leaf, args, expected }) => { + expect(joinRemoteOperands(args, leaf)).toEqual(expected); + }); +}); + +describe("rewriteRemoteArgv", () => { + const root = rootCommandForFeatures(); + const cases: ReadonlyArray<{ + readonly name: string; + readonly args: ReadonlyArray<string>; + readonly expected: ReadonlyArray<string>; + }> = [ + { + name: "joins a branch for db push", + args: ["db", "push", "--remote", "feature-x"], + expected: ["db", "push", "--remote=feature-x"], + }, + { + name: "joins a branch after global flags and their values", + args: ["--profile", "staging", "db", "push", "--remote", "feature-x", "--dry-run"], + expected: ["--profile", "staging", "db", "push", "--remote=feature-x", "--dry-run"], + }, + { + name: "joins a ref for storage ls, which takes one operand", + args: ["storage", "ls", "--remote", REF, "ss:///bucket"], + expected: ["storage", "ls", `--remote=${REF}`, "ss:///bucket"], + }, + { + name: "leaves a bare --remote before storage ls's own operand", + args: ["storage", "ls", "--remote", "ss:///bucket"], + expected: ["storage", "ls", "--remote", "ss:///bucket"], + }, + { + name: "joins a branch for functions deploy only when it is a ref or URL", + args: ["functions", "deploy", "--remote", REF, "hello"], + expected: ["functions", "deploy", `--remote=${REF}`, "hello"], + }, + { + name: "joins for seed's persistent --remote after the subcommand", + args: ["seed", "buckets", "--remote", "feature-x"], + expected: ["seed", "buckets", "--remote=feature-x"], + }, + { + name: "joins for seed's persistent --remote before the subcommand", + args: ["seed", "--remote", "feature-x", "buckets"], + expected: ["seed", "--remote=feature-x", "buckets"], + }, + { + name: "never swallows the subcommand name after a bare --remote", + args: ["seed", "--remote", "buckets"], + expected: ["seed", "--remote", "buckets"], + }, + { + name: "leaves commands without a remote flag alone", + args: ["link", "--remote", "feature-x"], + expected: ["link", "--remote", "feature-x"], + }, + { + name: "leaves argv without --remote alone", + args: ["db", "push", "--dry-run"], + expected: ["db", "push", "--dry-run"], + }, + ]; + + it.each(cases)("$name", ({ args, expected }) => { + expect(rewriteRemoteArgv(root, args)).toEqual(expected); + }); +}); diff --git a/apps/cli/src/telemetry/command-telemetry.unit.test.ts b/apps/cli/src/telemetry/command-telemetry.unit.test.ts index 7d1d367cf0..27ec6eada3 100644 --- a/apps/cli/src/telemetry/command-telemetry.unit.test.ts +++ b/apps/cli/src/telemetry/command-telemetry.unit.test.ts @@ -28,6 +28,8 @@ import { ConfigDiffLoadConfigError } from "../commands/config/diff/diff.errors.t import { DbDumpRunError } from "../commands/db/dump/dump.errors.ts"; import { IdentityStitch } from "../command-internal/identity-stitch.ts"; import { RemovedSurfaceError } from "../command-internal/removed-command.ts"; +import type { RemoteValue } from "../command-internal/target/remote-value.ts"; +import { remoteTelemetryFlag } from "../command-internal/target/target-flags.ts"; import { withCommandTelemetry } from "./command-telemetry.ts"; import { recordCommandTelemetry } from "./command-telemetry-attributes.ts"; import { stackBackendLayer } from "../command-internal/stack-backend.ts"; @@ -573,6 +575,64 @@ describe("withCommandTelemetry", () => { ); }); + describe("--remote", () => { + const REF = "abcdefghijklmnopqrst"; + const DB_URL = "postgresql://postgres:secret@db.example.com:5432/postgres"; + + const recordedFlags = ( + remote: Option.Option<RemoteValue>, + passRemoteTelemetryFlag: boolean, + ) => { + const analytics = mockContextualAnalytics(); + return Effect.void.pipe( + withCommandTelemetry({ + flags: { remote: passRemoteTelemetryFlag ? remoteTelemetryFlag(remote) : remote }, + safeFlags: passRemoteTelemetryFlag ? ["remote"] : [], + }), + Effect.provide(analytics.layer), + Effect.provide(mockProcessControl().layer), + Effect.provide(mockOutput({ format: "text" }).layer), + Effect.provide( + Stdio.layerTest({ args: Effect.succeed(["functions", "deploy", "--remote"]) }), + ), + Effect.provide( + commandRuntimeLayer(["functions", "deploy"]).pipe(Layer.provide(BunCrypto.layer)), + ), + Effect.map(() => analytics.captured[0]?.properties.flags), + ); + }; + + it.live("redacts the value when a command passes its raw flags", () => + Effect.gen(function* () { + const flags = yield* recordedFlags( + Option.some({ _tag: "Branch", name: "customer-acme" }), + false, + ); + expect(flags).toEqual({ remote: "<redacted>" }); + }), + ); + + it.live.each([ + { name: "a bare --remote as true", remote: { _tag: "Linked" }, expected: true }, + { name: "a project ref verbatim", remote: { _tag: "Ref", ref: REF }, expected: REF }, + { + name: "a branch name as redacted", + remote: { _tag: "Branch", name: "customer-acme" }, + expected: "<redacted>", + }, + { + name: "a database URL as redacted", + remote: { _tag: "DbUrl", url: DB_URL }, + expected: "<redacted>", + }, + ] as const)("records $name through remoteTelemetryFlag", ({ remote, expected }) => + Effect.gen(function* () { + const flags = yield* recordedFlags(Option.some(remote), true); + expect(flags).toEqual({ remote: expected }); + }), + ); + }); + it.live("passes Flag.Literals values through verbatim", () => { const analytics = mockContextualAnalytics(); const config = { From 4d2aeb7035f8bbc6096cd6b8b6de7712cd65d1a7 Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Mon, 19 Oct 2026 11:18:35 +0100 Subject: [PATCH 104/291] refactor(cli): report --remote in command telemetry centrally --- .../command-internal/target/remote-value.ts | 9 ++ .../command-internal/target/target-flags.ts | 15 ---- .../target/target-flags.unit.test.ts | 16 ---- .../functions/deploy/deploy.command.ts | 11 +-- .../src/commands/gen/types/types.command.ts | 4 +- apps/cli/src/telemetry/command-telemetry.ts | 23 ++++- .../telemetry/command-telemetry.unit.test.ts | 83 ++++++++++++------- 7 files changed, 91 insertions(+), 70 deletions(-) diff --git a/apps/cli/src/command-internal/target/remote-value.ts b/apps/cli/src/command-internal/target/remote-value.ts index 6c0096b6cc..84fad7e6da 100644 --- a/apps/cli/src/command-internal/target/remote-value.ts +++ b/apps/cli/src/command-internal/target/remote-value.ts @@ -1,3 +1,5 @@ +import { Predicate } from "effect"; + import { BRANCH_PROJECT_REF_PATTERN } from "../ref-patterns.ts"; /** What `--remote` (or a `db diff --from/--to` value) names. */ @@ -7,6 +9,13 @@ export type RemoteValue = | { readonly _tag: "DbUrl"; readonly url: string } | { readonly _tag: "Branch"; readonly name: string }; +/** Whether a value is a `RemoteValue`, told apart by its `_tag`. */ +export const isRemoteValue = (value: unknown): value is RemoteValue => + Predicate.isTagged(value, "Linked") || + Predicate.isTagged(value, "Ref") || + Predicate.isTagged(value, "DbUrl") || + Predicate.isTagged(value, "Branch"); + export type NamedRemoteValue = Exclude<RemoteValue, { readonly _tag: "Linked" }>; /** Starts every failure for an off or empty `--remote` value, which the CLI prints without its usual `Invalid value` framing. */ diff --git a/apps/cli/src/command-internal/target/target-flags.ts b/apps/cli/src/command-internal/target/target-flags.ts index 196b3f900a..90256ba382 100644 --- a/apps/cli/src/command-internal/target/target-flags.ts +++ b/apps/cli/src/command-internal/target/target-flags.ts @@ -113,21 +113,6 @@ export const projectTargetFlags = () => ({ projectRef: projectRefFlag(), }); -/** The `--remote` value as telemetry records it: a project ref verbatim, a bare `--remote` as `true`, a branch name or database URL redacted. */ -export const remoteTelemetryFlag = ( - remote: Option.Option<RemoteValue>, -): Option.Option<string | boolean> => - Option.map(remote, (value) => { - switch (value._tag) { - case "Linked": - return true; - case "Ref": - return value.ref; - default: - return "<redacted>"; - } - }); - /** The flags that point a suggested follow-up command at `target`; a database URL is never echoed. */ export const flagsForTarget = (target: ResolvedTarget): string => { switch (target.kind) { 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 82a814d97b..af28d9e9af 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 @@ -10,7 +10,6 @@ import { deprecatedProjectIdFlag, flagsForTarget, projectTargetFlags, - remoteTelemetryFlag, seedTargetGlobalFlags, targetFlags, } from "./target-flags.ts"; @@ -227,21 +226,6 @@ describe("seedTargetGlobalFlags", () => { }); }); -describe("remoteTelemetryFlag", () => { - it.each([ - { remote: { _tag: "Linked" }, expected: true }, - { remote: { _tag: "Ref", ref: REF }, expected: REF }, - { remote: { _tag: "Branch", name: "customer-acme" }, expected: "<redacted>" }, - { remote: { _tag: "DbUrl", url: URL }, expected: "<redacted>" }, - ] as const)("records $remote as $expected", ({ remote, expected }) => { - expect(remoteTelemetryFlag(Option.some(remote))).toEqual(Option.some(expected)); - }); - - it("records nothing when --remote is absent", () => { - expect(remoteTelemetryFlag(Option.none())).toEqual(Option.none()); - }); -}); - describe("flagsForTarget", () => { it("names the flags that re-select each kind of target without echoing a db-url", () => { expect(flagsForTarget({ kind: "local" })).toBe(""); diff --git a/apps/cli/src/commands/functions/deploy/deploy.command.ts b/apps/cli/src/commands/functions/deploy/deploy.command.ts index 3c9719861c..64f0a27241 100644 --- a/apps/cli/src/commands/functions/deploy/deploy.command.ts +++ b/apps/cli/src/commands/functions/deploy/deploy.command.ts @@ -4,10 +4,7 @@ import type * as CliCommand from "effect/cli/Command"; import { withPermissions } from "../../../command-internal/command-permissions/command-permissions.annotation.ts"; import { compose } from "../../../command-internal/command-permissions/model.ts"; import { branchResolution } from "../../../command-internal/target/branch-resolution.ts"; -import { - projectTargetFlags, - remoteTelemetryFlag, -} from "../../../command-internal/target/target-flags.ts"; +import { projectTargetFlags } from "../../../command-internal/target/target-flags.ts"; import { PROJECT_REF_PATTERN } from "../../../config/project-ref.service.ts"; import { projectRefPicker } from "../../../config/project-ref.layer.ts"; import { linkedProjectCache } from "../../../telemetry/linked-project-cache.layer.ts"; @@ -158,11 +155,11 @@ export const functionsDeployCommand = Command.make("deploy", config).pipe( Command.withHandler((flags) => functionsDeploy(flags).pipe( withCommandTelemetry({ - flags: { ...flags, remote: remoteTelemetryFlag(flags.remote) }, + flags, safeFlags: Option.isSome(flags.projectRef) && PROJECT_REF_PATTERN.test(flags.projectRef.value) - ? [...FUNCTIONS_PROJECT_REF_SAFE_FLAGS, "remote"] - : ["remote"], + ? FUNCTIONS_PROJECT_REF_SAFE_FLAGS + : [], }), withJsonErrorHandling, ), diff --git a/apps/cli/src/commands/gen/types/types.command.ts b/apps/cli/src/commands/gen/types/types.command.ts index 88187b201e..b3ae3e7408 100644 --- a/apps/cli/src/commands/gen/types/types.command.ts +++ b/apps/cli/src/commands/gen/types/types.command.ts @@ -34,7 +34,6 @@ import { cliConfigValuesLayer } from "../../../config/cli-config-values.layer.ts import { withCliConfigFlags } from "../../../config/cli-config-flags.ts"; import { deprecatedProjectIdFlag, - remoteTelemetryFlag, targetFlags, } from "../../../command-internal/target/target-flags.ts"; import { withTargetPolicy } from "../../../command-internal/target/target-policy.ts"; @@ -180,10 +179,9 @@ export const genTypesCommand = Command.make("types", commandConfig).pipe( Command.withHandler((flags) => genTypes(flags).pipe( withCommandTelemetry({ - flags: { ...flags, remote: remoteTelemetryFlag(flags.remote) }, + flags, safeFlags: [ "project-id", - "remote", ...(Option.isSome(flags.projectRef) && PROJECT_REF_PATTERN.test(flags.projectRef.value) ? ["project-ref"] : []), diff --git a/apps/cli/src/telemetry/command-telemetry.ts b/apps/cli/src/telemetry/command-telemetry.ts index ac1ef7aa69..bebfce1358 100644 --- a/apps/cli/src/telemetry/command-telemetry.ts +++ b/apps/cli/src/telemetry/command-telemetry.ts @@ -48,6 +48,7 @@ import { } from "../shared/cli/value-consuming-flags.ts"; import { unwrapToSingleParam } from "../command-internal/param-introspection.ts"; import { StackBackendContext } from "../command-internal/stack-backend.ts"; +import { isRemoteValue, type RemoteValue } from "../command-internal/target/remote-value.ts"; import { CommandTelemetryAttributes, type CommandTelemetryAttributeValues, @@ -119,6 +120,20 @@ const REDACTED_VALUE = "<redacted>"; const MACHINE_OUTPUT_FLAG_FORMATS = new Set(["env", "json", "toml", "yaml", "csv"]); const OUTPUT_FLAG_FORMATS = new Set([...MACHINE_OUTPUT_FLAG_FORMATS, "pretty"]); +const REMOTE_FLAG_NAME = "remote"; + +// A project ref is telemetry-safe; a branch name or database URL is user data. +function remoteTelemetryValue(remote: RemoteValue): string | boolean { + switch (remote._tag) { + case "Linked": + return true; + case "Ref": + return remote.ref; + default: + return REDACTED_VALUE; + } +} + function toCliFlagName(key: string): string { return key.replace(/[A-Z]/g, (char) => `-${char.toLowerCase()}`); } @@ -318,7 +333,12 @@ function buildFlagsMap<Flags extends Record<string, unknown>>(options: { // only fall back to the live global-flag value when the handler never declared this name. const isFromHandler = handlerFlagsByCliName.has(cliName); const rawValue = isFromHandler ? handlerFlagsByCliName.get(cliName) : globalFlags[cliName]; - const value = normalizeFlagValue(rawValue); + const normalized = normalizeFlagValue(rawValue); + const remote = + isFromHandler && cliName === REMOTE_FLAG_NAME && isRemoteValue(normalized) + ? normalized + : undefined; + const value = remote === undefined ? normalized : remoteTelemetryValue(remote); // `safeFlagSet`/`choiceFlagNames` vouch for a value only when it actually came from this // command's own `flags` record — a value resolved from the global-flag fallback instead @@ -326,6 +346,7 @@ function buildFlagsMap<Flags extends Record<string, unknown>>(options: { // a different command's per-flag safe/choice annotation just because the CLI name matches. // Boolean values are always safe, regardless of source. const isSafe = + remote !== undefined || typeof value === "boolean" || (isFromHandler ? safeFlagSet.has(cliName) || choiceFlagNames.has(cliName) diff --git a/apps/cli/src/telemetry/command-telemetry.unit.test.ts b/apps/cli/src/telemetry/command-telemetry.unit.test.ts index 27ec6eada3..f9d103dbfe 100644 --- a/apps/cli/src/telemetry/command-telemetry.unit.test.ts +++ b/apps/cli/src/telemetry/command-telemetry.unit.test.ts @@ -29,7 +29,6 @@ import { DbDumpRunError } from "../commands/db/dump/dump.errors.ts"; import { IdentityStitch } from "../command-internal/identity-stitch.ts"; import { RemovedSurfaceError } from "../command-internal/removed-command.ts"; import type { RemoteValue } from "../command-internal/target/remote-value.ts"; -import { remoteTelemetryFlag } from "../command-internal/target/target-flags.ts"; import { withCommandTelemetry } from "./command-telemetry.ts"; import { recordCommandTelemetry } from "./command-telemetry-attributes.ts"; import { stackBackendLayer } from "../command-internal/stack-backend.ts"; @@ -579,56 +578,84 @@ describe("withCommandTelemetry", () => { const REF = "abcdefghijklmnopqrst"; const DB_URL = "postgresql://postgres:secret@db.example.com:5432/postgres"; - const recordedFlags = ( - remote: Option.Option<RemoteValue>, - passRemoteTelemetryFlag: boolean, - ) => { + const recordedFlags = (remote: unknown, arg: string, safeFlags?: ReadonlyArray<string>) => { const analytics = mockContextualAnalytics(); return Effect.void.pipe( - withCommandTelemetry({ - flags: { remote: passRemoteTelemetryFlag ? remoteTelemetryFlag(remote) : remote }, - safeFlags: passRemoteTelemetryFlag ? ["remote"] : [], - }), + withCommandTelemetry({ flags: { remote }, safeFlags }), Effect.provide(analytics.layer), Effect.provide(mockProcessControl().layer), Effect.provide(mockOutput({ format: "text" }).layer), - Effect.provide( - Stdio.layerTest({ args: Effect.succeed(["functions", "deploy", "--remote"]) }), - ), + Effect.provide(Stdio.layerTest({ args: Effect.succeed(["functions", "deploy", arg]) })), Effect.provide( commandRuntimeLayer(["functions", "deploy"]).pipe(Layer.provide(BunCrypto.layer)), ), - Effect.map(() => analytics.captured[0]?.properties.flags), + Effect.map(() => analytics.captured[0]), ); }; - it.live("redacts the value when a command passes its raw flags", () => - Effect.gen(function* () { - const flags = yield* recordedFlags( - Option.some({ _tag: "Branch", name: "customer-acme" }), - false, - ); - expect(flags).toEqual({ remote: "<redacted>" }); - }), - ); - it.live.each([ - { name: "a bare --remote as true", remote: { _tag: "Linked" }, expected: true }, - { name: "a project ref verbatim", remote: { _tag: "Ref", ref: REF }, expected: REF }, + { + name: "a bare --remote as true", + arg: "--remote", + remote: { _tag: "Linked" }, + expected: true, + }, + { + name: "a project ref verbatim", + arg: `--remote=${REF}`, + remote: { _tag: "Ref", ref: REF }, + expected: REF, + }, { name: "a branch name as redacted", + arg: "--remote=customer-acme", remote: { _tag: "Branch", name: "customer-acme" }, expected: "<redacted>", }, { name: "a database URL as redacted", + arg: `--remote=${DB_URL}`, remote: { _tag: "DbUrl", url: DB_URL }, expected: "<redacted>", }, - ] as const)("records $name through remoteTelemetryFlag", ({ remote, expected }) => + ] as const)("records $name when the command passes no safeFlags", ({ arg, remote, expected }) => + Effect.gen(function* () { + const event = yield* recordedFlags(Option.some(remote), arg); + expect(event?.properties.flags).toEqual({ remote: expected }); + expect(JSON.stringify(event)).not.toContain(DB_URL); + expect(JSON.stringify(event)).not.toContain("customer-acme"); + }), + ); + + it.live("keeps the command's own safeFlags alongside --remote", () => + Effect.gen(function* () { + const analytics = mockContextualAnalytics(); + const event = yield* Effect.void.pipe( + withCommandTelemetry({ + flags: { remote: Option.some<RemoteValue>({ _tag: "Linked" }), projectRef: REF }, + safeFlags: ["project-ref"], + }), + Effect.provide(analytics.layer), + Effect.provide(mockProcessControl().layer), + Effect.provide(mockOutput({ format: "text" }).layer), + Effect.provide( + Stdio.layerTest({ + args: Effect.succeed(["functions", "deploy", "--remote", "--project-ref", REF]), + }), + ), + Effect.provide( + commandRuntimeLayer(["functions", "deploy"]).pipe(Layer.provide(BunCrypto.layer)), + ), + Effect.map(() => analytics.captured[0]), + ); + expect(event?.properties.flags).toEqual({ remote: true, "project-ref": REF }); + }), + ); + + it.live("redacts a --remote flag that does not carry a RemoteValue", () => Effect.gen(function* () { - const flags = yield* recordedFlags(Option.some(remote), true); - expect(flags).toEqual({ remote: expected }); + const event = yield* recordedFlags("customer-acme", "--remote=customer-acme"); + expect(event?.properties.flags).toEqual({ remote: "<redacted>" }); }), ); }); From f38d47813b8b0471c160a92638281c8582d6fc65 Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Mon, 19 Oct 2026 11:18:37 +0100 Subject: [PATCH 105/291] test(cli): complete --remote instead of the hidden --linked and --local aliases --- apps/cli/src/cli/complete.unit.test.ts | 36 ++++++++++++-------------- 1 file changed, 16 insertions(+), 20 deletions(-) diff --git a/apps/cli/src/cli/complete.unit.test.ts b/apps/cli/src/cli/complete.unit.test.ts index 610bec2da5..53ae5990cb 100644 --- a/apps/cli/src/cli/complete.unit.test.ts +++ b/apps/cli/src/cli/complete.unit.test.ts @@ -92,15 +92,11 @@ describe("respondToComplete", () => { }); it("offers a non-root command's own declared global flags from a nested subcommand (Command.withGlobalFlags)", () => { - const atGroup = respondToComplete(rootCommand, ["__complete", "seed", "--l"]); - expect(atGroup?.candidates.map((c) => c.name)).toEqual( - expect.arrayContaining(["--linked", "--local"]), - ); + const atGroup = respondToComplete(rootCommand, ["__complete", "seed", "--rem"]); + expect(atGroup?.candidates.map((c) => c.name)).toEqual(expect.arrayContaining(["--remote"])); - const atLeaf = respondToComplete(rootCommand, ["__complete", "seed", "buckets", "--l"]); - expect(atLeaf?.candidates.map((c) => c.name)).toEqual( - expect.arrayContaining(["--linked", "--local"]), - ); + const atLeaf = respondToComplete(rootCommand, ["__complete", "seed", "buckets", "--rem"]); + expect(atLeaf?.candidates.map((c) => c.name)).toEqual(expect.arrayContaining(["--remote"])); }); it("does not duplicate a flag name that exists both globally and as a command's own local flag", () => { @@ -178,9 +174,9 @@ describe("respondToComplete", () => { "squash", "--version", "20240101000000", - "--l", + "--rem", ]); - expect(result?.candidates.map((c) => c.name)).toContain("--linked"); + expect(result?.candidates.map((c) => c.name)).toContain("--remote"); }); it("does not short-circuit on --help positioned after a genuine `--` terminator (it is positional, not a flag)", () => { @@ -388,7 +384,7 @@ describe("respondToComplete", () => { "--p", ]); expect(result?.candidates.map((c) => c.name)).toEqual( - expect.arrayContaining(["--profile", "--project-ref", "--prune"]), + expect.arrayContaining(["--profile", "--prune"]), ); expect(result?.directive).toBe(CompletionDirective.NoFileComp); }); @@ -527,7 +523,7 @@ describe("respondToComplete", () => { ]); expect(result?.directive).toBe(CompletionDirective.NoFileComp); expect(result?.candidates.map((c) => c.name)).toEqual( - expect.arrayContaining(["--debug", "--dns-resolver", "--db-url"]), + expect.arrayContaining(["--debug", "--dns-resolver"]), ); }); }); @@ -557,9 +553,9 @@ describe("respondToComplete", () => { "query", "--output", "table", - "--li", + "--rem", ]); - expect(result?.candidates.map((c) => c.name)).toContain("--linked"); + expect(result?.candidates.map((c) => c.name)).toContain("--remote"); }); it("accepts the resource-command values (env/pretty/toml/yaml) outside db query", () => { @@ -632,7 +628,7 @@ describe("respondToComplete", () => { "types", "--query-timeout", "bogus", - "--l", + "--rem", ]); expect(queryTimeout).toEqual({ candidates: [], @@ -645,9 +641,9 @@ describe("respondToComplete", () => { "types", "--query-timeout", "5s", - "--l", + "--rem", ]); - expect(queryTimeoutValid?.candidates.map((c) => c.name)).toContain("--local"); + expect(queryTimeoutValid?.candidates.map((c) => c.name)).toContain("--remote"); const validFor = respondToComplete(rootCommand, [ "__complete", @@ -681,7 +677,7 @@ describe("respondToComplete", () => { "types", "--query-timeout", "2562048h", - "--l", + "--rem", ]); expect(overflow).toEqual({ candidates: [], directive: CompletionDirective.Default }); @@ -691,9 +687,9 @@ describe("respondToComplete", () => { "types", "--query-timeout", "2562047h47m16.854775807s", - "--l", + "--rem", ]); - expect(max?.candidates.map((c) => c.name)).toContain("--local"); + expect(max?.candidates.map((c) => c.name)).toContain("--remote"); }); it("rejects a malformed value for the time flag (gen bearer-jwt --exp, RFC3339 only)", () => { From 7a8b977a0d16a8ad818791f293db648ee8053184 Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Mon, 19 Oct 2026 11:29:23 +0100 Subject: [PATCH 106/291] refactor(cli): resolve --remote=<branch> in migration, inspect, storage, seed and gen types Declare the branch lookup in each command's permission map, give the inspect runtime the lazy platform API factory the lookup needs, reserve --remote as a gen types flag name and redact --project-id unless it is a project ref. --- apps/cli/src/commands/gen/types/types.command.ts | 14 +++++++++++++- .../src/commands/inspect/db/inspect-db-command.ts | 7 +++++-- apps/cli/src/commands/inspect/inspect.layers.ts | 7 ++++--- .../src/commands/inspect/report/report.command.ts | 8 +++++--- .../src/commands/migration/down/down.command.ts | 2 ++ .../src/commands/migration/fetch/fetch.command.ts | 2 ++ .../src/commands/migration/list/list.command.ts | 2 ++ .../commands/migration/repair/repair.command.ts | 2 ++ .../commands/migration/squash/squash.command.ts | 2 ++ apps/cli/src/commands/migration/up/up.command.ts | 2 ++ .../src/commands/seed/buckets/buckets.command.ts | 4 +++- apps/cli/src/commands/storage/storage.flags.ts | 4 +++- 12 files changed, 45 insertions(+), 11 deletions(-) diff --git a/apps/cli/src/commands/gen/types/types.command.ts b/apps/cli/src/commands/gen/types/types.command.ts index b3ae3e7408..b0d587cd0a 100644 --- a/apps/cli/src/commands/gen/types/types.command.ts +++ b/apps/cli/src/commands/gen/types/types.command.ts @@ -14,6 +14,7 @@ import { dbConfigPoolerFallbackLinked, } from "../../../command-internal/db-config.layer.ts"; import { projectRefPicker } from "../../../config/project-ref.layer.ts"; +import { branchResolution } from "../../../command-internal/target/branch-resolution.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"; @@ -69,6 +70,7 @@ const config = { /** Every flag name `gen types` already answers to: its own, the globals, and Effect's built-ins. */ const GEN_TYPES_RESERVED_FLAG_NAMES: ReadonlyArray<string> = [ + "remote", "local", "linked", "db-url", @@ -164,6 +166,13 @@ export const genTypesCommand = Command.make("types", commandConfig).pipe( context: "the ref names a preview branch and generating against its direct host fails with an IPv6 connectivity error", }, + { + operationId: "v1-list-all-branches", + kind: "required", + when: [{ flag: "project-id", present: true }], + context: + "--project-id names a branch (not a 20-letter project ref) of the linked project", + }, ], noApiEffectFlags: ["schema", "query-timeout", ...GEN_TYPES_LANGUAGE_FLAG_NAMES], }, @@ -171,6 +180,7 @@ export const genTypesCommand = Command.make("types", commandConfig).pipe( withCondition(withContext(dbConfigPoolerFallbackLinked, notBranch), nonTypescript), withCondition(projectRefPicker, { flag: "project-id", present: false }), linkedProjectCache, + branchResolution, ), ), { default: "local" }, @@ -181,7 +191,9 @@ export const genTypesCommand = Command.make("types", commandConfig).pipe( withCommandTelemetry({ flags, safeFlags: [ - "project-id", + ...(Option.isSome(flags.projectId) && PROJECT_REF_PATTERN.test(flags.projectId.value) + ? ["project-id"] + : []), ...(Option.isSome(flags.projectRef) && PROJECT_REF_PATTERN.test(flags.projectRef.value) ? ["project-ref"] : []), 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 586ba68b9f..8bb7a2f8bb 100644 --- a/apps/cli/src/commands/inspect/db/inspect-db-command.ts +++ b/apps/cli/src/commands/inspect/db/inspect-db-command.ts @@ -1,7 +1,8 @@ import { Effect } from "effect"; -import { withDbTarget } from "../../../command-internal/command-permissions/model.ts"; +import { compose, withDbTarget } from "../../../command-internal/command-permissions/model.ts"; import { dbConfig } from "../../../command-internal/db-config.layer.ts"; +import { branchResolution } from "../../../command-internal/target/branch-resolution.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"; @@ -15,7 +16,9 @@ import type { InspectConnectionFlags } from "./inspect-query.ts"; export const INSPECT_DB_FLAGS = targetFlags({ 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" }); +export const INSPECT_DB_PERMISSIONS = withDbTarget(compose(dbConfig, branchResolution), { + default: "local", +}); /** Target policy shared by every `inspect db` command. */ export const withInspectDbTargetPolicy = withTargetPolicy({ group: "selectable", diff --git a/apps/cli/src/commands/inspect/inspect.layers.ts b/apps/cli/src/commands/inspect/inspect.layers.ts index b7f649a0d1..136a23cb82 100644 --- a/apps/cli/src/commands/inspect/inspect.layers.ts +++ b/apps/cli/src/commands/inspect/inspect.layers.ts @@ -42,12 +42,13 @@ const dbConfig = dbConfigLayer.pipe( * The services every `inspect` leaf shares, minus the command-runtime identity: the DB-config * 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 reaches it lazily through the - * platform-API factory and the resolver. + * The Management API client is not merged here — it resolves an access token eagerly, which would + * break the auth-free local and `--remote=<url>` paths. The linked path and branch-name lookups + * reach it lazily through the platform-API factory. */ export const inspectBaseLayer = Layer.mergeAll( dbConfig, + platformApiFactory, projectRef, dbConnectionLayer, cliSettings, diff --git a/apps/cli/src/commands/inspect/report/report.command.ts b/apps/cli/src/commands/inspect/report/report.command.ts index 16ad05dba9..1d4cce12df 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 { branchResolution } from "../../../command-internal/target/branch-resolution.ts"; import { targetFlags } from "../../../command-internal/target/target-flags.ts"; import { withTargetPolicy } from "../../../command-internal/target/target-policy.ts"; @@ -31,9 +32,10 @@ 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: "local", - }), + withDbTarget( + compose({ operations: [], noApiEffectFlags: ["output-dir"] }, dbConfig, branchResolution), + { default: "local" }, + ), ), Command.withHandler((flags) => inspectReport(flags).pipe( diff --git a/apps/cli/src/commands/migration/down/down.command.ts b/apps/cli/src/commands/migration/down/down.command.ts index 272926397d..0150c1652e 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 { branchResolution } from "../../../command-internal/target/branch-resolution.ts"; import { targetFlags } from "../../../command-internal/target/target-flags.ts"; import { withTargetPolicy } from "../../../command-internal/target/target-policy.ts"; @@ -48,6 +49,7 @@ export const migrationDownCommand = Command.make("down", config).pipe( { operations: [], noApiEffectFlags: ["last"] }, dbConfig, withCondition(linkedProjectCache, { dbTarget: ["linked"] }), + branchResolution, ), { default: "local" }, ), diff --git a/apps/cli/src/commands/migration/fetch/fetch.command.ts b/apps/cli/src/commands/migration/fetch/fetch.command.ts index 899a0f0519..8ec3060426 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 { branchResolution } from "../../../command-internal/target/branch-resolution.ts"; import { targetFlags } from "../../../command-internal/target/target-flags.ts"; import { withTargetPolicy } from "../../../command-internal/target/target-policy.ts"; @@ -31,6 +32,7 @@ export const migrationFetchCommand = Command.make("fetch", config).pipe( { operations: [], noApiEffectFlags: [] }, dbConfig, withCondition(linkedProjectCache, { dbTarget: ["linked"] }), + branchResolution, ), { default: "local" }, ), diff --git a/apps/cli/src/commands/migration/list/list.command.ts b/apps/cli/src/commands/migration/list/list.command.ts index 535b2a96ce..044bc44c6a 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 { branchResolution } from "../../../command-internal/target/branch-resolution.ts"; import { targetFlags } from "../../../command-internal/target/target-flags.ts"; import { withTargetPolicy } from "../../../command-internal/target/target-policy.ts"; @@ -42,6 +43,7 @@ export const migrationListCommand = Command.make("list", config).pipe( { operations: [], noApiEffectFlags: [] }, dbConfig, withCondition(linkedProjectCache, { dbTarget: ["linked"] }), + branchResolution, ), { default: "local" }, ), diff --git a/apps/cli/src/commands/migration/repair/repair.command.ts b/apps/cli/src/commands/migration/repair/repair.command.ts index 1781a44b94..6eaf64e4d5 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 { branchResolution } from "../../../command-internal/target/branch-resolution.ts"; import { targetFlags } from "../../../command-internal/target/target-flags.ts"; import { withTargetPolicy } from "../../../command-internal/target/target-policy.ts"; @@ -44,6 +45,7 @@ export const migrationRepairCommand = Command.make("repair", config).pipe( { operations: [], noApiEffectFlags: ["status"] }, dbConfig, withCondition(linkedProjectCache, { dbTarget: ["linked"] }), + branchResolution, ), { default: "local" }, ), diff --git a/apps/cli/src/commands/migration/squash/squash.command.ts b/apps/cli/src/commands/migration/squash/squash.command.ts index 5b7e621bc6..17de2bcca5 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 { branchResolution } from "../../../command-internal/target/branch-resolution.ts"; import { targetFlags } from "../../../command-internal/target/target-flags.ts"; import { withTargetPolicy } from "../../../command-internal/target/target-policy.ts"; @@ -44,6 +45,7 @@ export const migrationSquashCommand = Command.make("squash", config).pipe( { operations: [], noApiEffectFlags: ["version"] }, dbConfig, withCondition(linkedProjectCache, { dbTarget: ["linked"] }), + branchResolution, ), { default: "local" }, ), diff --git a/apps/cli/src/commands/migration/up/up.command.ts b/apps/cli/src/commands/migration/up/up.command.ts index d16046662c..7276ce771e 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 { branchResolution } from "../../../command-internal/target/branch-resolution.ts"; import { targetFlags } from "../../../command-internal/target/target-flags.ts"; import { withTargetPolicy } from "../../../command-internal/target/target-policy.ts"; @@ -37,6 +38,7 @@ export const migrationUpCommand = Command.make("up", config).pipe( { operations: [], noApiEffectFlags: ["include-all"] }, dbConfig, withCondition(linkedProjectCache, { dbTarget: ["linked"] }), + branchResolution, ), { default: "local" }, ), diff --git a/apps/cli/src/commands/seed/buckets/buckets.command.ts b/apps/cli/src/commands/seed/buckets/buckets.command.ts index 9c0064d9c0..b3e148be4b 100644 --- a/apps/cli/src/commands/seed/buckets/buckets.command.ts +++ b/apps/cli/src/commands/seed/buckets/buckets.command.ts @@ -13,6 +13,7 @@ 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 { branchResolution } from "../../../command-internal/target/branch-resolution.ts"; import { seedTargetGlobalFlags, targetFlags, @@ -41,8 +42,9 @@ export const bucketsCommand = Command.make("buckets", config).pipe( withPermissions( withDbTarget( compose( - { operations: [], noApiEffectFlags: ["project-ref"] }, + { operations: [], noApiEffectFlags: [] }, withCondition(compose(storageCredentials, linkedProjectCache), { dbTarget: ["linked"] }), + branchResolution, ), { default: "local" }, ), diff --git a/apps/cli/src/commands/storage/storage.flags.ts b/apps/cli/src/commands/storage/storage.flags.ts index 48be955967..6321acfb5b 100644 --- a/apps/cli/src/commands/storage/storage.flags.ts +++ b/apps/cli/src/commands/storage/storage.flags.ts @@ -4,6 +4,7 @@ import { withDbTarget, } from "../../command-internal/command-permissions/model.ts"; import { storageCredentials } from "../../command-internal/storage-credentials.ts"; +import { branchResolution } from "../../command-internal/target/branch-resolution.ts"; import { targetFlags } from "../../command-internal/target/target-flags.ts"; import { linkedProjectCache } from "../../telemetry/linked-project-cache.layer.ts"; @@ -12,8 +13,9 @@ export const storageTargetFlags = targetFlags({ subject: "Storage API", dbUrl: f /** The Management API calls every `storage` subcommand makes on the linked project. */ export const STORAGE_PERMISSIONS = withDbTarget( compose( - { operations: [], noApiEffectFlags: ["project-ref", "recursive"] }, + { operations: [], noApiEffectFlags: ["recursive"] }, withCondition(compose(storageCredentials, linkedProjectCache), { dbTarget: ["linked"] }), + branchResolution, ), { default: "local" }, ); From 0f5a03982ba804973f49a8ec0877ed4967a736ed Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Mon, 19 Oct 2026 11:37:57 +0100 Subject: [PATCH 107/291] fix(cli): declare the pooler config read for --remote=<ref> in the database permission map --- apps/cli/src/command-internal/db-config.layer.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/apps/cli/src/command-internal/db-config.layer.ts b/apps/cli/src/command-internal/db-config.layer.ts index fb76d33369..7998441be9 100644 --- a/apps/cli/src/command-internal/db-config.layer.ts +++ b/apps/cli/src/command-internal/db-config.layer.ts @@ -65,6 +65,13 @@ 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: "remote" }], + context: + "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", From ba020148f27a26cb9bbd1ecd34cdf83a5b862519 Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Mon, 19 Oct 2026 11:38:03 +0100 Subject: [PATCH 108/291] test(cli): cover migration commands through --remote with a warning check per alias --- .../migration/down/down.integration.test.ts | 226 ++++++++++++-- .../migration/fetch/fetch.integration.test.ts | 215 ++++++++++--- .../migration/list/list.integration.test.ts | 215 ++++++++++--- .../repair/repair.integration.test.ts | 290 ++++++++++++++---- .../squash/squash.integration.test.ts | 227 +++++++++++--- .../migration/up/up.integration.test.ts | 182 +++++++++-- 6 files changed, 1095 insertions(+), 260 deletions(-) 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 b17ec29cbb..20e8fcc5fe 100644 --- a/apps/cli/src/commands/migration/down/down.integration.test.ts +++ b/apps/cli/src/commands/migration/down/down.integration.test.ts @@ -15,6 +15,8 @@ import { stripAnsi } from "../../../../tests/helpers/ansi.ts"; import { VALID_REF, VALID_TOKEN, + jsonResponse, + mockCommandPlatformApi, mockCommandSettings, mockLinkedProjectCacheTracked, mockTelemetryStateTracked, @@ -59,6 +61,22 @@ import { migrationDown } from "./down.handler.ts"; import type { MigrationDownFlags } from "./down.command.ts"; const LIST_SQL = "SELECT version FROM supabase_migrations.schema_migrations ORDER BY version"; +const FLAG_REF = "flagflagflagflagflag"; +const BRANCH_REF = "branchprojectrefabcd"; +const DB_URL = "postgresql://postgres:postgres@127.0.0.1:54322/postgres"; + +const branchRow = (name: string) => ({ + id: "11111111-2222-4333-8444-555555555555", + name, + project_ref: BRANCH_REF, + parent_project_ref: VALID_REF, + is_default: false, + persistent: false, + status: "MIGRATIONS_PASSED", + created_at: "2026-05-27T01:02:03Z", + updated_at: "2026-05-27T01:02:04Z", + with_data: true, +}); interface SetupOpts { /** Runs the real `DbConfigResolver` and linked-project cache against `workdir`. */ @@ -72,6 +90,7 @@ interface SetupOpts { readonly failDrop?: boolean; readonly failSeed?: boolean; readonly seedTable?: ReadonlyArray<{ path: string; hash: string }>; + readonly args?: ReadonlyArray<string>; } const SELECT_SEED = "SELECT path, hash FROM supabase_migrations.seed_files"; @@ -80,9 +99,11 @@ 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, { - resolveConnection: (input: DbConnectionInput) => - Effect.succeed({ + resolveConnection: (input: DbConnectionInput) => { + resolverCalls.push(input); + return Effect.succeed({ conn: { host: "127.0.0.1", port: 54322, @@ -93,7 +114,8 @@ function fakeDatabase(opts: SetupOpts = {}) { isLocal: input.target.kind === "local", ref: input.target.kind === "linked" ? Option.some(input.target.ref) : Option.none(), target: input.target, - } satisfies ResolvedDbConnection), + } satisfies ResolvedDbConnection); + }, resolvePoolerFallback: () => Effect.succeed(Option.none()), }); @@ -129,7 +151,7 @@ function fakeDatabase(opts: SetupOpts = {}) { }, }); - return { resolver, connection, execs, queries }; + return { resolver, connection, execs, queries, resolverCalls }; } const droppedSchemas = (execs: ReadonlyArray<string>) => @@ -144,7 +166,7 @@ function setup(workdir: string, opts: SetupOpts = {}) { }); const telemetry = mockTelemetryStateTracked(); const cache = mockLinkedProjectCacheTracked(); - const { resolver, connection, execs, queries } = fakeDatabase(opts); + const { resolver, connection, execs, queries, resolverCalls } = 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. @@ -168,6 +190,9 @@ function setup(workdir: string, opts: SetupOpts = {}) { }); const http = recordingProjectHttpClient(VALID_REF); + const branchApi = mockCommandPlatformApi({ + handler: (request) => Effect.succeed(jsonResponse(request, 200, [branchRow("feature-x")])), + }); const layer = Layer.mergeAll( cliConfigValuesTestLayer, Layer.succeed(TargetPolicy, { group: "selectable", consent: "no" }), @@ -192,11 +217,28 @@ function setup(workdir: string, opts: SetupOpts = {}) { Layer.succeed(DnsResolverFlag, "native"), Layer.succeed(YesFlag, opts.yes ?? false), Layer.succeed(OutputFlag, Option.none()), - Layer.succeed(CliArgs, { args: [] }), + Layer.succeed(CliArgs, { args: opts.args ?? [] }), mockTty({ stdinIsTty: interactive, stdoutIsTty: interactive }), + branchApi.layer, BunServices.layer, ); - return { layer, http, out, telemetry, execs, queries, cache }; + return { + layer, + http, + out, + telemetry, + execs, + queries, + cache, + resolverCalls, + branchRequests: branchApi.requests, + // A missing seed.sql also warns on a database reset; only selector warnings matter here. + warnings: () => + out.messages + .filter((message) => message.type === "warn") + .map((message) => message.message) + .filter((message) => !message.startsWith("no files matched pattern")), + }; } const flags = (over: Partial<MigrationDownFlags> = {}): MigrationDownFlags => ({ @@ -241,7 +283,9 @@ 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, linked: true })).pipe(Effect.flip); + const error = yield* migrationDown( + flags({ last: 0, remote: Option.some({ _tag: "Linked" }) }), + ).pipe(Effect.flip); expect(error).toBeInstanceOf(ProjectRefNotLinkedError); }).pipe(Effect.provide(layer)); }); @@ -280,7 +324,7 @@ describe("migration down", () => { }); return Effect.gen(function* () { yield* seed(tmp.current, "20240101000000_a.sql"); - yield* migrationDown(flags({ last: 1, linked: true })); + yield* migrationDown(flags({ last: 1, remote: Option.some({ _tag: "Linked" }) })); const stderr = stripAnsi(out.stderrText); expect(stderr).toContain("20240102000000"); expect(stderr).toContain("you will lose all data in this database"); @@ -290,47 +334,161 @@ describe("migration down", () => { }).pipe(Effect.provide(layer)); }); - it.effect("reverts on the project given via --project-ref, which implies --linked", () => { + it.effect("reverts on the project given via --remote=<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, { + const ctx = setup(tmp.current, { yes: true, remote: ["20240101000000", "20240102000000"], }); return Effect.gen(function* () { yield* seed(tmp.current, "20240101000000_a.sql"); - 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)); + yield* migrationDown(flags({ last: 1, remote: Option.some({ _tag: "Ref", ref: FLAG_REF }) })); + expect(ctx.cache.cached).toBe(true); + expect(ctx.cache.cachedRef).toBe(FLAG_REF); + expect(ctx.cache.cachedRef).not.toBe(VALID_REF); + expect(ctx.warnings()).toEqual([]); + }).pipe(Effect.provide(ctx.layer)); }); - it.effect("rejects --project-ref combined with --local before touching the database", () => { - const FLAG_REF = "flagflagflagflagflag"; - const { layer, execs, queries, cache } = setup(tmp.current, { + it.effect("reverts on the project of the branch --remote=<branch> names", () => { + const ctx = setup(tmp.current, { + yes: true, + remote: ["20240101000000", "20240102000000"], + }); + return Effect.gen(function* () { + yield* seed(tmp.current, "20240101000000_a.sql"); + yield* migrationDown( + flags({ last: 1, remote: Option.some({ _tag: "Branch", name: "feature-x" }) }), + ); + expect(ctx.branchRequests.map(({ method, url }) => `${method} ${url}`)).toEqual([ + `GET https://api.supabase.com/v1/projects/${VALID_REF}/branches`, + ]); + expect(ctx.resolverCalls[0]?.target).toMatchObject({ kind: "linked", ref: BRANCH_REF }); + expect(ctx.cache.cachedRef).toBe(BRANCH_REF); + expect(ctx.warnings()).toEqual([]); + }).pipe(Effect.provide(ctx.layer)); + }); + + it.effect("fails with the branches that exist when --remote=<branch> matches none", () => { + const ctx = setup(tmp.current, { + yes: true, remote: ["20240101000000", "20240102000000"], }); return Effect.gen(function* () { const error = yield* migrationDown( - flags({ last: 1, local: true, projectRef: Option.some(FLAG_REF) }), + flags({ last: 1, remote: Option.some({ _tag: "Branch", name: "feature-y" }) }), ).pipe(Effect.flip); - expect(error).toBeInstanceOf(TargetProjectRefConflictError); - expect(execs).toEqual([]); - expect(queries).toEqual([]); - expect(cache.cached).toBe(false); - }).pipe(Effect.provide(layer)); + expect(error).toMatchObject({ + _tag: "LinkBranchNotFoundError", + message: expect.stringContaining("Available branches: feature-x"), + }); + expect(ctx.resolverCalls).toEqual([]); + expect(ctx.execs).toEqual([]); + }).pipe(Effect.provide(ctx.layer)); }); - it.effect("rejects --db-url combined with --local", () => { - const { layer } = setup(tmp.current); + it.effect("reverts the database --remote=<url> names and never touches the linked cache", () => { + const ctx = setup(tmp.current, { + yes: true, + remote: ["20240101000000", "20240102000000"], + }); + return Effect.gen(function* () { + yield* seed(tmp.current, "20240101000000_a.sql"); + yield* migrationDown(flags({ last: 1, remote: Option.some({ _tag: "DbUrl", url: DB_URL }) })); + expect(ctx.resolverCalls[0]?.target).toMatchObject({ kind: "db-url" }); + expect(droppedSchemas(ctx.execs)).toBe(1); + expect(ctx.cache.cached).toBe(false); + expect(ctx.warnings()).toEqual([]); + }).pipe(Effect.provide(ctx.layer)); + }); + + it.effect("rejects --remote combined with --local before touching the database", () => { + const ctx = setup(tmp.current, { + remote: ["20240101000000", "20240102000000"], + }); return Effect.gen(function* () { const error = yield* migrationDown( - flags({ dbUrl: Option.some("postgresql://x"), local: true }), + flags({ last: 1, remote: Option.some({ _tag: "Linked" }), local: true }), ).pipe(Effect.flip); expect(error).toBeInstanceOf(TargetSelectorConflictError); - }).pipe(Effect.provide(layer)); + expect(ctx.execs).toEqual([]); + expect(ctx.queries).toEqual([]); + expect(ctx.cache.cached).toBe(false); + }).pipe(Effect.provide(ctx.layer)); + }); + + describe("deprecated selectors", () => { + const cases = [ + { + alias: "--linked", + args: ["migration", "down", "--linked"], + flags: { linked: true }, + target: { kind: "linked", ref: VALID_REF }, + warning: + "--linked is deprecated and will be removed in v4. Use --remote instead: supabase migration down --remote", + }, + { + alias: "--project-ref", + args: ["migration", "down", "--project-ref", FLAG_REF], + flags: { projectRef: Option.some(FLAG_REF) }, + target: { kind: "linked", ref: FLAG_REF }, + warning: `--project-ref is deprecated and will be removed in v4. Use --remote instead: supabase migration down --remote=${FLAG_REF}`, + }, + { + alias: "--db-url", + args: ["migration", "down", "--db-url", DB_URL], + flags: { dbUrl: Option.some(DB_URL) }, + target: { kind: "db-url" }, + warning: + "--db-url is deprecated and will be removed in v4. Use --remote instead: supabase migration down --remote=<db-url>", + }, + { + alias: "--local", + args: ["migration", "down", "--local"], + flags: { local: true }, + target: { kind: "local" }, + warning: + "--local isn't needed (local is the default) and will be removed in v4: supabase migration down", + }, + ] as const satisfies ReadonlyArray<{ + readonly alias: string; + readonly args: ReadonlyArray<string>; + readonly flags: Partial<MigrationDownFlags>; + readonly target: Record<string, string>; + readonly warning: string; + }>; + + for (const testCase of cases) { + it.effect(`${testCase.alias} still selects its target and warns`, () => { + const ctx = setup(tmp.current, { + yes: true, + remote: ["20240101000000", "20240102000000"], + args: testCase.args, + }); + return Effect.gen(function* () { + yield* seed(tmp.current, "20240101000000_a.sql"); + yield* migrationDown(flags({ last: 1, ...testCase.flags })); + expect(ctx.resolverCalls[0]?.target).toMatchObject(testCase.target); + expect(ctx.warnings()).toEqual([testCase.warning]); + }).pipe(Effect.provide(ctx.layer)); + }); + } + + it.effect("rejects --project-ref combined with --local before touching the database", () => { + const { layer, execs, queries, cache } = setup(tmp.current, { + remote: ["20240101000000", "20240102000000"], + }); + return Effect.gen(function* () { + 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 on the local database", () => { @@ -449,7 +607,9 @@ 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, projectRef: Option.some(VALID_REF) })); + yield* migrationDown( + flags({ last: 1, remote: Option.some({ _tag: "Ref", ref: 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`, @@ -461,7 +621,7 @@ describe("migration down", () => { expect(yield* fs.exists(`${tmp.current}/supabase/.temp/linked-project.json`)).toBe(true); assertPermissionDrift({ command: "migration down", - activeFlags: { linked: true, "project-ref": VALID_REF }, + activeFlags: { remote: VALID_REF }, requests: [...asRecordedRequests(requests), ...ctx.http.calls], exact: true, }); @@ -514,7 +674,7 @@ describeHostedConsent("migration down", { invoke: () => { const db = fakeDatabase({ remote: ["20240101000000", "20240102000000"] }); return { - run: migrationDown(flags({ last: 1, linked: true })).pipe( + run: migrationDown(flags({ last: 1, remote: Option.some({ _tag: "Linked" }) })).pipe( Effect.provide( Layer.mergeAll( db.resolver, 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 c56ccdb482..666926ddc7 100644 --- a/apps/cli/src/commands/migration/fetch/fetch.integration.test.ts +++ b/apps/cli/src/commands/migration/fetch/fetch.integration.test.ts @@ -13,6 +13,8 @@ vi.mock("node:net", (importOriginal) => import { VALID_REF, VALID_TOKEN, + jsonResponse, + mockCommandPlatformApi, mockCommandSettings, mockLinkedProjectCacheTracked, mockTelemetryStateTracked, @@ -55,6 +57,22 @@ import type { MigrationFetchFlags } from "./fetch.command.ts"; const SELECT_SQL = "SELECT version, coalesce(name, '') as name, statements FROM supabase_migrations.schema_migrations"; +const FLAG_REF = "flagflagflagflagflag"; +const BRANCH_REF = "branchprojectrefabcd"; +const DB_URL = "postgresql://u:p@db.example.com/postgres"; + +const branchRow = (name: string) => ({ + id: "11111111-2222-4333-8444-555555555555", + name, + project_ref: BRANCH_REF, + parent_project_ref: VALID_REF, + is_default: false, + persistent: false, + status: "MIGRATIONS_PASSED", + created_at: "2026-05-27T01:02:03Z", + updated_at: "2026-05-27T01:02:04Z", + with_data: true, +}); interface MigrationRow { readonly version: string; @@ -73,6 +91,7 @@ interface SetupOpts { readonly rows?: ReadonlyArray<MigrationRow>; readonly resolveFails?: boolean; readonly connectFails?: boolean; + readonly args?: ReadonlyArray<string>; } function setup(workdir: string, opts: SetupOpts = {}) { @@ -143,6 +162,9 @@ function setup(workdir: string, opts: SetupOpts = {}) { }); const http = recordingProjectHttpClient(VALID_REF); + const branchApi = mockCommandPlatformApi({ + handler: (request) => Effect.succeed(jsonResponse(request, 200, [branchRow("feature-x")])), + }); const layer = Layer.mergeAll( out.layer, configValuesLayer({ output: out.layer }), @@ -165,7 +187,8 @@ function setup(workdir: string, opts: SetupOpts = {}) { mockCommandSettings({ workdir }), Layer.succeed(DnsResolverFlag, "native"), Layer.succeed(YesFlag, opts.yes ?? false), - Layer.succeed(CliArgs, { args: ["migration", "fetch"] }), + Layer.succeed(CliArgs, { args: opts.args ?? ["migration", "fetch"] }), + branchApi.layer, Layer.succeed(OutputFlag, Option.none()), Layer.succeed(TargetPolicy, { group: "selectable", consent: "none" }), mockTty({ stdinIsTty: opts.isTTY ?? true }), @@ -176,7 +199,18 @@ function setup(workdir: string, opts: SetupOpts = {}) { ), BunServices.layer, ); - return { layer, http, out, telemetry, cache, resolveCalls }; + const warnings = () => + out.messages.filter((message) => message.type === "warn").map((message) => message.message); + return { + layer, + http, + out, + telemetry, + cache, + resolveCalls, + warnings, + branchRequests: branchApi.requests, + }; } const flags = (over: Partial<MigrationFetchFlags> = {}): MigrationFetchFlags => ({ @@ -480,69 +514,155 @@ describe("migration fetch", () => { }).pipe(Effect.provide(layer)); }); - it.effect("fetches from the local database with --local without caching a project", () => { - const { layer, resolveCalls, cache } = setup(tmp.current, { + it.effect("fetches from the local database by default without caching a project", () => { + const { layer, resolveCalls, cache, warnings } = setup(tmp.current, { rows: [{ version: "20240101000000", name: "init", statements: ["create table a"] }], }); return Effect.gen(function* () { - yield* migrationFetch(flags({ local: true })); + yield* migrationFetch(flags()); expect(resolveCalls[0]?.target).toEqual({ kind: "local" }); expect(cache.cached).toBe(false); + expect(warnings()).toEqual([]); 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: [] }); + it.effect("fetches from the database --remote=<url> names", () => { + const { layer, resolveCalls, cache, warnings } = setup(tmp.current, { rows: [] }); return Effect.gen(function* () { - yield* migrationFetch( - flags({ dbUrl: Option.some("postgresql://u:p@db.example.com/postgres") }), - ); + yield* migrationFetch(flags({ remote: Option.some({ _tag: "DbUrl", url: DB_URL }) })); expect(resolveCalls[0]?.target.kind).toBe("db-url"); expect(cache.cached).toBe(false); + expect(warnings()).toEqual([]); }).pipe(Effect.provide(layer)); }); - it.effect("rejects --db-url combined with --linked before reading the project .env", () => { + it.effect("rejects --remote combined with --local 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 }), + flags({ remote: Option.some({ _tag: "Linked" }), local: 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", + "fetches from the project --remote=<ref> names, overriding the default linked ref", () => { - const FLAG_REF = "flagflagflagflagflag"; - const { layer, cache } = setup(tmp.current, { rows: [] }); + const { layer, cache, warnings } = setup(tmp.current, { rows: [] }); return Effect.gen(function* () { - yield* migrationFetch(flags({ projectRef: Option.some(FLAG_REF) })); + yield* migrationFetch(flags({ remote: Option.some({ _tag: "Ref", ref: FLAG_REF }) })); expect(cache.cached).toBe(true); expect(cache.cachedRef).toBe(FLAG_REF); expect(cache.cachedRef).not.toBe(VALID_REF); + expect(warnings()).toEqual([]); }).pipe(Effect.provide(layer)); }, ); - it.effect("rejects --project-ref combined with --local", () => { - const FLAG_REF = "flagflagflagflagflag"; - const { layer, out, cache } = setup(tmp.current); + it.effect("fetches from the project of the branch --remote=<branch> names", () => { + const { layer, resolveCalls, cache, branchRequests, warnings } = setup(tmp.current, { + rows: [], + }); + return Effect.gen(function* () { + yield* migrationFetch(flags({ remote: Option.some({ _tag: "Branch", name: "feature-x" }) })); + expect(branchRequests.map(({ method, url }) => `${method} ${url}`)).toEqual([ + `GET https://api.supabase.com/v1/projects/${VALID_REF}/branches`, + ]); + expect(resolveCalls[0]?.target).toMatchObject({ kind: "linked", ref: BRANCH_REF }); + expect(cache.cachedRef).toBe(BRANCH_REF); + expect(warnings()).toEqual([]); + }).pipe(Effect.provide(layer)); + }); + + it.effect("fails with the branches that exist when --remote=<branch> matches none", () => { + const { layer, resolveCalls, out } = setup(tmp.current, { rows: [] }); return Effect.gen(function* () { const error = yield* migrationFetch( - flags({ local: true, projectRef: Option.some(FLAG_REF) }), + flags({ remote: Option.some({ _tag: "Branch", name: "feature-y" }) }), ).pipe(Effect.flip); - expect(error).toBeInstanceOf(TargetProjectRefConflictError); + expect(error).toMatchObject({ + _tag: "LinkBranchNotFoundError", + message: expect.stringContaining("Available branches: feature-x"), + }); + expect(resolveCalls).toEqual([]); expect(yield* migrationsDirExists(tmp.current)).toBe(false); expect(out.promptConfirmCalls.length).toBe(0); - expect(cache.cached).toBe(false); }).pipe(Effect.provide(layer)); }); + describe("deprecated selectors", () => { + const cases = [ + { + alias: "--linked", + args: ["migration", "fetch", "--linked"], + flags: { linked: true }, + target: { kind: "linked", ref: VALID_REF }, + warning: + "--linked is deprecated and will be removed in v4. Use --remote instead: supabase migration fetch --remote", + }, + { + alias: "--project-ref", + args: ["migration", "fetch", "--project-ref", FLAG_REF], + flags: { projectRef: Option.some(FLAG_REF) }, + target: { kind: "linked", ref: FLAG_REF }, + warning: `--project-ref is deprecated and will be removed in v4. Use --remote instead: supabase migration fetch --remote=${FLAG_REF}`, + }, + { + alias: "--db-url", + args: ["migration", "fetch", "--db-url", DB_URL], + flags: { dbUrl: Option.some(DB_URL) }, + target: { kind: "db-url" }, + warning: + "--db-url is deprecated and will be removed in v4. Use --remote instead: supabase migration fetch --remote=<db-url>", + }, + { + alias: "--local", + args: ["migration", "fetch", "--local"], + flags: { local: true }, + target: { kind: "local" }, + warning: + "--local isn't needed (local is the default) and will be removed in v4: supabase migration fetch", + }, + ] as const satisfies ReadonlyArray<{ + readonly alias: string; + readonly args: ReadonlyArray<string>; + readonly flags: Partial<MigrationFetchFlags>; + readonly target: Record<string, string>; + readonly warning: string; + }>; + + for (const testCase of cases) { + it.effect(`${testCase.alias} still selects its target and warns`, () => { + const { layer, resolveCalls, warnings } = setup(tmp.current, { + rows: [], + args: testCase.args, + }); + return Effect.gen(function* () { + yield* migrationFetch(flags(testCase.flags)); + expect(resolveCalls[0]?.target).toMatchObject(testCase.target); + expect(warnings()).toEqual([testCase.warning]); + }).pipe(Effect.provide(layer)); + }); + } + + it.effect("rejects --project-ref combined with --local", () => { + const { layer, out, cache } = setup(tmp.current); + return Effect.gen(function* () { + const error = yield* migrationFetch( + flags({ local: true, projectRef: Option.some(FLAG_REF) }), + ).pipe(Effect.flip); + expect(error).toBeInstanceOf(TargetProjectRefConflictError); + expect(yield* migrationsDirExists(tmp.current)).toBe(false); + expect(out.promptConfirmCalls.length).toBe(0); + expect(cache.cached).toBe(false); + }).pipe(Effect.provide(layer)); + }); + }); + it.effect("mints a login role and fills the cache through the real resolver and cache", () => { const ctx = setup(tmp.current, { realLayers: true, @@ -552,7 +672,7 @@ describe("migration fetch", () => { return Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; yield* stubManagementApi(requests, serveLinkedDatabase(VALID_REF)); - yield* migrationFetch(flags({ projectRef: Option.some(VALID_REF) })); + yield* migrationFetch(flags({ remote: Option.some({ _tag: "Ref", ref: VALID_REF }) })); expect(yield* listMigrations(tmp.current)).toEqual(["20240101000000_init.sql"]); expect(requests.map(({ method, path }) => `${method} ${path}`)).toEqual([ `GET /v1/projects/${VALID_REF}/config/database/pooler`, @@ -564,7 +684,7 @@ describe("migration fetch", () => { expect(yield* fs.exists(`${tmp.current}/supabase/.temp/linked-project.json`)).toBe(true); assertPermissionDrift({ command: "migration fetch", - activeFlags: { "project-ref": VALID_REF }, + activeFlags: { remote: VALID_REF }, requests: [...asRecordedRequests(requests), ...ctx.http.calls], exact: true, }); @@ -575,27 +695,30 @@ describe("migration fetch", () => { ); }); - it.effect("makes no Management API request for --local through the real resolver", () => { - const ctx = setup(tmp.current, { - realLayers: true, - rows: [], - }); - const requests: Array<StubbedRequest> = []; - return Effect.gen(function* () { - yield* stubManagementApi(requests, () => undefined); - yield* migrationFetch(flags({ local: true })); - expect(requests).toEqual([]); - expect(ctx.http.calls).toEqual([]); - assertPermissionDrift({ - command: "migration fetch", - activeFlags: { local: true }, - requests: [], - exact: true, + it.effect( + "makes no Management API request for the default local target through the real resolver", + () => { + const ctx = setup(tmp.current, { + realLayers: true, + rows: [], }); - }).pipe( - Effect.scoped, - Effect.provide(ctx.layer), - provideEnv({ SUPABASE_ACCESS_TOKEN: VALID_TOKEN }), - ); - }); + const requests: Array<StubbedRequest> = []; + return Effect.gen(function* () { + yield* stubManagementApi(requests, () => undefined); + yield* migrationFetch(flags()); + expect(requests).toEqual([]); + expect(ctx.http.calls).toEqual([]); + assertPermissionDrift({ + command: "migration fetch", + activeFlags: {}, + requests: [], + exact: true, + }); + }).pipe( + Effect.scoped, + Effect.provide(ctx.layer), + provideEnv({ SUPABASE_ACCESS_TOKEN: VALID_TOKEN }), + ); + }, + ); }); 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 c2c8778f07..cfd78a5499 100644 --- a/apps/cli/src/commands/migration/list/list.integration.test.ts +++ b/apps/cli/src/commands/migration/list/list.integration.test.ts @@ -14,6 +14,8 @@ import { stripAnsi } from "../../../../tests/helpers/ansi.ts"; import { VALID_REF, VALID_TOKEN, + jsonResponse, + mockCommandPlatformApi, mockCommandSettings, mockLinkedProjectCacheTracked, mockTelemetryStateTracked, @@ -54,6 +56,22 @@ import { migrationList } from "./list.handler.ts"; import type { MigrationListFlags } from "./list.command.ts"; const LIST_SQL = "SELECT version FROM supabase_migrations.schema_migrations ORDER BY version"; +const FLAG_REF = "flagflagflagflagflag"; +const BRANCH_REF = "branchprojectrefabcd"; +const DB_URL = "postgresql://postgres:postgres@127.0.0.1:54322/postgres"; + +const branchRow = (name: string) => ({ + id: "11111111-2222-4333-8444-555555555555", + name, + project_ref: BRANCH_REF, + parent_project_ref: VALID_REF, + is_default: false, + persistent: false, + status: "MIGRATIONS_PASSED", + created_at: "2026-05-27T01:02:03Z", + updated_at: "2026-05-27T01:02:04Z", + with_data: true, +}); interface SetupOpts { readonly format?: OutputFormat; @@ -62,6 +80,7 @@ interface SetupOpts { readonly remoteError?: DbExecError; /** Runs the real `DbConfigResolver` and linked-project cache against `workdir`. */ readonly realLayers?: boolean; + readonly args?: ReadonlyArray<string>; } function setup(workdir: string, opts: SetupOpts = {}) { @@ -124,6 +143,9 @@ function setup(workdir: string, opts: SetupOpts = {}) { }); const http = recordingProjectHttpClient(VALID_REF); + const branchApi = mockCommandPlatformApi({ + handler: (request) => Effect.succeed(jsonResponse(request, 200, [branchRow("feature-x")])), + }); const layer = Layer.mergeAll( cliConfigValuesTestLayer, Layer.succeed(TargetPolicy, { group: "selectable", consent: "none" }), @@ -147,7 +169,8 @@ function setup(workdir: string, opts: SetupOpts = {}) { projectRef, mockCommandSettings({ workdir }), Layer.succeed(DnsResolverFlag, "native"), - Layer.succeed(CliArgs, { args: [] }), + Layer.succeed(CliArgs, { args: opts.args ?? [] }), + branchApi.layer, BunServices.layer, ); return { @@ -157,6 +180,9 @@ function setup(workdir: string, opts: SetupOpts = {}) { cache, http, resolverCalls, + branchRequests: branchApi.requests, + warnings: () => + out.messages.filter((message) => message.type === "warn").map((message) => message.message), }; } @@ -243,51 +269,133 @@ describe("migration list", () => { }).pipe(Effect.provide(layer)); }); - it.effect("lists the project given via --project-ref, which implies --linked", () => { + it.effect("lists the project given via --remote=<ref> and fills the cache for that 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 ctx = setup(tmp.current, { remote: ["20240101000000"] }); return Effect.gen(function* () { yield* seedMigrations(tmp.current, ["20240101000000_a.sql"]); - yield* migrationList(flags({ projectRef: Option.some(FLAG_REF) })); + yield* migrationList(flags({ remote: Option.some({ _tag: "Ref", ref: FLAG_REF }) })); expect(ctx.cache.cachedRef).toBe(FLAG_REF); expect(ctx.cache.cachedRef).not.toBe(VALID_REF); + expect(ctx.warnings()).toEqual([]); }).pipe(Effect.provide(ctx.layer)); }); - it.effect("rejects --project-ref combined with --local before connecting", () => { - const FLAG_REF = "flagflagflagflagflag"; - const ctx = setup(tmp.current, { remote: [] }); + it.effect("lists the database --remote=<url> names and never touches the linked cache", () => { + const ctx = setup(tmp.current, { remote: ["20240101000000"] }); return Effect.gen(function* () { yield* seedMigrations(tmp.current, ["20240101000000_a.sql"]); - const error = yield* migrationList( - flags({ local: true, projectRef: Option.some(FLAG_REF) }), - ).pipe(Effect.flip); - expect(error).toBeInstanceOf(TargetProjectRefConflictError); - expect(ctx.resolverCalls).toEqual([]); + yield* migrationList(flags({ remote: Option.some({ _tag: "DbUrl", url: DB_URL }) })); + expect(ctx.resolverCalls[0]?.target).toMatchObject({ kind: "db-url" }); expect(ctx.cache.cachedRef).toBeUndefined(); + expect(ctx.warnings()).toEqual([]); }).pipe(Effect.provide(ctx.layer)); }); - it.effect("targets the local database with --local and skips the linked cache", () => { - const ctx = setup(tmp.current, { remote: [] }); + it.effect("lists the project of the branch --remote=<branch> names", () => { + const ctx = setup(tmp.current, { remote: ["20240101000000"] }); return Effect.gen(function* () { yield* seedMigrations(tmp.current, ["20240101000000_a.sql"]); - yield* migrationList(flags({ local: true })); - expect(ctx.resolverCalls[0]?.target).toEqual({ kind: "local" }); - expect(ctx.cache.cachedRef).toBeUndefined(); + yield* migrationList(flags({ remote: Option.some({ _tag: "Branch", name: "feature-x" }) })); + expect(ctx.branchRequests.map(({ method, url }) => `${method} ${url}`)).toEqual([ + `GET https://api.supabase.com/v1/projects/${VALID_REF}/branches`, + ]); + expect(ctx.resolverCalls[0]?.target).toMatchObject({ kind: "linked", ref: BRANCH_REF }); + expect(ctx.cache.cachedRef).toBe(BRANCH_REF); + expect(ctx.warnings()).toEqual([]); }).pipe(Effect.provide(ctx.layer)); }); - it.effect("rejects --db-url combined with --linked", () => { - const { layer } = setup(tmp.current); + it.effect("fails with the branches that exist when --remote=<branch> matches none", () => { + const ctx = setup(tmp.current, { remote: [] }); return Effect.gen(function* () { const error = yield* migrationList( - flags({ dbUrl: Option.some("postgresql://x"), linked: true }), + flags({ remote: Option.some({ _tag: "Branch", name: "feature-y" }) }), + ).pipe(Effect.flip); + expect(error).toMatchObject({ + _tag: "LinkBranchNotFoundError", + message: expect.stringContaining("Available branches: feature-x"), + }); + expect(ctx.resolverCalls).toEqual([]); + }).pipe(Effect.provide(ctx.layer)); + }); + + it.effect("rejects --remote combined with --local before connecting", () => { + const ctx = setup(tmp.current, { remote: [] }); + return Effect.gen(function* () { + const error = yield* migrationList( + flags({ remote: Option.some({ _tag: "Linked" }), local: true }), ).pipe(Effect.flip); expect(error).toBeInstanceOf(TargetSelectorConflictError); - }).pipe(Effect.provide(layer)); + expect(ctx.resolverCalls).toEqual([]); + expect(ctx.cache.cachedRef).toBeUndefined(); + }).pipe(Effect.provide(ctx.layer)); + }); + + describe("deprecated selectors", () => { + const cases = [ + { + alias: "--linked", + args: ["migration", "list", "--linked"], + flags: { linked: true }, + target: { kind: "linked", ref: VALID_REF }, + warning: + "--linked is deprecated and will be removed in v4. Use --remote instead: supabase migration list --remote", + }, + { + alias: "--project-ref", + args: ["migration", "list", "--project-ref", FLAG_REF], + flags: { projectRef: Option.some(FLAG_REF) }, + target: { kind: "linked", ref: FLAG_REF }, + warning: `--project-ref is deprecated and will be removed in v4. Use --remote instead: supabase migration list --remote=${FLAG_REF}`, + }, + { + alias: "--db-url", + args: ["migration", "list", "--db-url", DB_URL], + flags: { dbUrl: Option.some(DB_URL) }, + target: { kind: "db-url" }, + warning: + "--db-url is deprecated and will be removed in v4. Use --remote instead: supabase migration list --remote=<db-url>", + }, + { + alias: "--local", + args: ["migration", "list", "--local"], + flags: { local: true }, + target: { kind: "local" }, + warning: + "--local isn't needed (local is the default) and will be removed in v4: supabase migration list", + }, + ] as const satisfies ReadonlyArray<{ + readonly alias: string; + readonly args: ReadonlyArray<string>; + readonly flags: Partial<MigrationListFlags>; + readonly target: Record<string, string>; + readonly warning: string; + }>; + + for (const testCase of cases) { + it.effect(`${testCase.alias} still selects its target and warns`, () => { + const ctx = setup(tmp.current, { remote: [], args: testCase.args }); + return Effect.gen(function* () { + yield* migrationList(flags(testCase.flags)); + expect(ctx.resolverCalls[0]?.target).toMatchObject(testCase.target); + expect(ctx.warnings()).toEqual([testCase.warning]); + }).pipe(Effect.provide(ctx.layer)); + }); + } + + it.effect("rejects --project-ref combined with --local before connecting", () => { + const ctx = setup(tmp.current, { remote: [] }); + return Effect.gen(function* () { + const error = yield* migrationList( + flags({ local: true, projectRef: Option.some(FLAG_REF) }), + ).pipe(Effect.flip); + expect(error).toBeInstanceOf(TargetProjectRefConflictError); + expect(ctx.resolverCalls).toEqual([]); + expect(ctx.cache.cachedRef).toBeUndefined(); + }).pipe(Effect.provide(ctx.layer)); + }); }); it.effect("emits structured migrations in json", () => { @@ -335,7 +443,7 @@ describe("migration list", () => { const fs = yield* FileSystem.FileSystem; yield* stubManagementApi(requests, serveLinkedDatabase(VALID_REF)); yield* seedMigrations(tmp.current, ["20240101000000_a.sql"]); - yield* migrationList(flags({ projectRef: Option.some(VALID_REF) })); + yield* migrationList(flags({ remote: Option.some({ _tag: "Ref", ref: 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`, @@ -346,7 +454,7 @@ describe("migration list", () => { expect(yield* fs.exists(`${tmp.current}/supabase/.temp/linked-project.json`)).toBe(true); assertPermissionDrift({ command: "migration list", - activeFlags: { "project-ref": VALID_REF }, + activeFlags: { remote: VALID_REF }, requests: [...asRecordedRequests(requests), ...ctx.http.calls], exact: true, }); @@ -357,35 +465,41 @@ describe("migration list", () => { ); }); - it.effect("makes no Management API request for --local through the real resolver", () => { - const ctx = setup(tmp.current, { - isLocal: true, - realLayers: true, - }); - const requests: Array<StubbedRequest> = []; - return Effect.gen(function* () { - yield* stubManagementApi(requests, () => undefined); - yield* migrationList(flags({ local: true })); - expect(requests).toEqual([]); - expect(ctx.http.calls).toEqual([]); - assertPermissionDrift({ - command: "migration list", - activeFlags: { local: true }, - requests: [], - exact: true, + it.effect( + "makes no Management API request for the default local target through the real resolver", + () => { + const ctx = setup(tmp.current, { + isLocal: true, + realLayers: true, }); - }).pipe( - Effect.scoped, - Effect.provide(ctx.layer), - provideEnv({ SUPABASE_ACCESS_TOKEN: VALID_TOKEN }), - ); - }); + const requests: Array<StubbedRequest> = []; + return Effect.gen(function* () { + yield* stubManagementApi(requests, () => undefined); + yield* migrationList(flags()); + expect(requests).toEqual([]); + expect(ctx.http.calls).toEqual([]); + assertPermissionDrift({ + command: "migration list", + activeFlags: {}, + requests: [], + exact: true, + }); + }).pipe( + Effect.scoped, + Effect.provide(ctx.layer), + provideEnv({ SUPABASE_ACCESS_TOKEN: VALID_TOKEN }), + ); + }, + ); - it.effect("rejects --password combined with --db-url through the real resolver", () => { + it.effect("rejects --password combined with --remote=<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") }), + flags({ + remote: Option.some({ _tag: "DbUrl", url: "postgresql://x" }), + password: Option.some("pw"), + }), ).pipe(Effect.flip); expect(error).toBeInstanceOf(TargetPasswordConflictError); }).pipe(Effect.scoped, Effect.provide(ctx.layer)); @@ -399,14 +513,17 @@ describe("migration list", () => { return Effect.gen(function* () { yield* stubManagementApi(requests, serveLinkedDatabase(VALID_REF)); yield* migrationList( - flags({ password: Option.some("pw"), projectRef: Option.some(VALID_REF) }), + flags({ + password: Option.some("pw"), + remote: Option.some({ _tag: "Ref", ref: VALID_REF }), + }), ); expect(requests.map(({ method, path }) => `${method} ${path}`)).toEqual([ `GET /v1/projects/${VALID_REF}/config/database/pooler`, ]); assertPermissionDrift({ command: "migration list", - activeFlags: { password: true, "project-ref": VALID_REF }, + activeFlags: { password: true, remote: VALID_REF }, requests: [...asRecordedRequests(requests), ...ctx.http.calls], exact: true, }); 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 04c273f1e7..c0db85c14a 100644 --- a/apps/cli/src/commands/migration/repair/repair.integration.test.ts +++ b/apps/cli/src/commands/migration/repair/repair.integration.test.ts @@ -14,6 +14,8 @@ import { stripAnsi } from "../../../../tests/helpers/ansi.ts"; import { VALID_REF, VALID_TOKEN, + jsonResponse, + mockCommandPlatformApi, mockCommandSettings, mockLinkedProjectCacheTracked, mockTelemetryStateTracked, @@ -57,6 +59,23 @@ import { MigrationFileNotFoundError, MigrationInvalidVersionError } from "../mig import { MigrationRepairUpdateError } from "./repair.errors.ts"; import { migrationRepair, type MigrationRepairInput } from "./repair.handler.ts"; +const FLAG_REF = "flagflagflagflagflag"; +const BRANCH_REF = "branchprojectrefabcd"; +const DB_URL = "postgresql://postgres:postgres@127.0.0.1:54322/postgres"; + +const branchRow = (name: string) => ({ + id: "11111111-2222-4333-8444-555555555555", + name, + project_ref: BRANCH_REF, + parent_project_ref: VALID_REF, + is_default: false, + persistent: false, + status: "MIGRATIONS_PASSED", + created_at: "2026-05-27T01:02:03Z", + updated_at: "2026-05-27T01:02:04Z", + with_data: true, +}); + interface SetupOpts { /** Runs the real `DbConfigResolver` and linked-project cache against `workdir`. */ readonly realLayers?: boolean; @@ -66,15 +85,18 @@ interface SetupOpts { readonly confirm?: boolean; readonly failSql?: string; readonly failResolve?: boolean; + readonly args?: ReadonlyArray<string>; } function fakeDatabase(opts: Pick<SetupOpts, "failSql"> = {}) { const execs: Array<string> = []; const queries: Array<{ sql: string; params?: ReadonlyArray<unknown> }> = []; + const resolverCalls: Array<DbConnectionInput> = []; const resolver = Layer.succeed(DbConfigResolver, { - resolveConnection: (connectionInput: DbConnectionInput) => - Effect.succeed({ + resolveConnection: (connectionInput: DbConnectionInput) => { + resolverCalls.push(connectionInput); + return Effect.succeed({ conn: { host: "127.0.0.1", port: 54322, @@ -88,7 +110,8 @@ function fakeDatabase(opts: Pick<SetupOpts, "failSql"> = {}) { ? Option.some(connectionInput.target.ref) : Option.none(), target: connectionInput.target, - } satisfies ResolvedDbConnection), + } satisfies ResolvedDbConnection); + }, resolvePoolerFallback: () => Effect.succeed(Option.none()), }); @@ -119,7 +142,7 @@ function fakeDatabase(opts: Pick<SetupOpts, "failSql"> = {}) { }, }); - return { resolver, connection, execs, queries }; + return { resolver, connection, execs, queries, resolverCalls }; } function setup(workdir: string, opts: SetupOpts = {}) { @@ -131,7 +154,7 @@ function setup(workdir: string, opts: SetupOpts = {}) { }); const telemetry = mockTelemetryStateTracked(); const cache = mockLinkedProjectCacheTracked(); - const { resolver, connection, execs, queries } = fakeDatabase(opts); + const { resolver, connection, execs, queries, resolverCalls } = 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. @@ -155,6 +178,9 @@ function setup(workdir: string, opts: SetupOpts = {}) { }); const http = recordingProjectHttpClient(VALID_REF); + const branchApi = mockCommandPlatformApi({ + handler: (request) => Effect.succeed(jsonResponse(request, 200, [branchRow("feature-x")])), + }); const layer = Layer.mergeAll( cliConfigValuesTestLayer, Layer.succeed(TargetPolicy, { group: "selectable", consent: "no" }), @@ -179,11 +205,24 @@ function setup(workdir: string, opts: SetupOpts = {}) { Layer.succeed(DnsResolverFlag, "native"), Layer.succeed(YesFlag, opts.yes ?? false), Layer.succeed(OutputFlag, Option.none()), - Layer.succeed(CliArgs, { args: [] }), + Layer.succeed(CliArgs, { args: opts.args ?? [] }), mockTty({ stdinIsTty: interactive, stdoutIsTty: interactive }), + branchApi.layer, BunServices.layer, ); - return { layer, http, out, telemetry, execs, queries, cache }; + return { + layer, + http, + out, + telemetry, + execs, + queries, + cache, + resolverCalls, + branchRequests: branchApi.requests, + warnings: () => + out.messages.filter((message) => message.type === "warn").map((message) => message.message), + }; } const input = (over: Partial<MigrationRepairInput> = {}): MigrationRepairInput => ({ @@ -224,7 +263,11 @@ 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", linked: true }), + input({ + versions: ["not-a-number"], + status: "applied", + remote: Option.some({ _tag: "Linked" }), + }), ).pipe(Effect.flip); expect(error).toBeInstanceOf(ProjectRefNotLinkedError); }).pipe(Effect.provide(layer)); @@ -235,7 +278,11 @@ describe("migration repair", () => { return Effect.gen(function* () { yield* seedMigration(tmp.current, "20240101000000_init.sql", "create table a;\n"); const error = yield* migrationRepair( - input({ versions: ["20240101000000"], status: "applied", linked: true }), + input({ + versions: ["20240101000000"], + status: "applied", + remote: Option.some({ _tag: "Linked" }), + }), ).pipe(Effect.flip); expect(error).toBeInstanceOf(HostedMutationDeclinedError); expect(cache.cachedRef).toBe(VALID_REF); @@ -312,7 +359,7 @@ describe("migration repair", () => { return Effect.gen(function* () { yield* seedMigration(tmp.current, "20240101000000_init.sql", "create table a;\n"); const error = yield* migrationRepair( - input({ versions: [], status: "applied", linked: true }), + input({ versions: [], status: "applied", remote: Option.some({ _tag: "Linked" }) }), ).pipe(Effect.flip); expect(error).toBeInstanceOf(HostedMutationDeclinedError); expect(execs).toEqual([]); @@ -323,7 +370,7 @@ describe("migration repair", () => { const { layer, out } = setup(tmp.current, { failResolve: true }); return Effect.gen(function* () { const error = yield* migrationRepair( - input({ versions: [], status: "applied", linked: true }), + input({ versions: [], status: "applied", remote: Option.some({ _tag: "Linked" }) }), ).pipe(Effect.flip); expect(error).toBeInstanceOf(ProjectRefNotLinkedError); expect(out.promptConfirmCalls.length).toBe(0); @@ -383,58 +430,172 @@ describe("migration repair", () => { }).pipe(Effect.provide(layer)); }); - it.effect("rejects --db-url combined with --linked", () => { - const { layer } = setup(tmp.current); + it.effect("rejects --remote combined with --local before touching the database", () => { + const { layer, execs, queries, cache } = setup(tmp.current); return Effect.gen(function* () { const error = yield* migrationRepair( input({ versions: ["20240101000000"], status: "applied", - linked: true, - dbUrl: Option.some("postgresql://x"), + remote: Option.some({ _tag: "Linked" }), + local: true, }), ).pipe(Effect.flip); expect(error).toBeInstanceOf(TargetSelectorConflictError); + expect(execs).toEqual([]); + expect(queries).toEqual([]); + expect(cache.cached).toBe(false); }).pipe(Effect.provide(layer)); }); - it.effect("repairs the project given via --project-ref, which implies --linked", () => { + it.effect("repairs the project given via --remote=<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, { yes: true }); + const ctx = setup(tmp.current, { yes: true }); 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), + remote: Option.some({ _tag: "Ref", ref: FLAG_REF }), }), ); - expect(cache.cached).toBe(true); - expect(cache.cachedRef).toBe(FLAG_REF); - expect(cache.cachedRef).not.toBe(VALID_REF); - }).pipe(Effect.provide(layer)); + expect(ctx.cache.cached).toBe(true); + expect(ctx.cache.cachedRef).toBe(FLAG_REF); + expect(ctx.cache.cachedRef).not.toBe(VALID_REF); + expect(ctx.warnings()).toEqual([]); + }).pipe(Effect.provide(ctx.layer)); }); - it.effect("rejects --project-ref combined with --local before touching the database", () => { - const FLAG_REF = "flagflagflagflagflag"; - const { layer, execs, queries, cache } = setup(tmp.current); + it.effect("repairs the project of the branch --remote=<branch> names", () => { + const ctx = setup(tmp.current, { yes: true }); + return Effect.gen(function* () { + yield* seedMigration(tmp.current, "20240101000000_init.sql", "create table a;\n"); + yield* migrationRepair( + input({ + versions: ["20240101000000"], + status: "applied", + remote: Option.some({ _tag: "Branch", name: "feature-x" }), + }), + ); + expect(ctx.branchRequests.map(({ method, url }) => `${method} ${url}`)).toEqual([ + `GET https://api.supabase.com/v1/projects/${VALID_REF}/branches`, + ]); + expect(ctx.resolverCalls[0]?.target).toMatchObject({ kind: "linked", ref: BRANCH_REF }); + expect(ctx.cache.cachedRef).toBe(BRANCH_REF); + expect(ctx.warnings()).toEqual([]); + }).pipe(Effect.provide(ctx.layer)); + }); + + it.effect("fails with the branches that exist when --remote=<branch> matches none", () => { + const ctx = setup(tmp.current, { yes: 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", - local: true, - projectRef: Option.some(FLAG_REF), + remote: Option.some({ _tag: "Branch", name: "feature-y" }), }), ).pipe(Effect.flip); - expect(error).toBeInstanceOf(TargetProjectRefConflictError); - expect(execs).toEqual([]); - expect(queries).toEqual([]); - expect(cache.cached).toBe(false); - }).pipe(Effect.provide(layer)); + expect(error).toMatchObject({ + _tag: "LinkBranchNotFoundError", + message: expect.stringContaining("Available branches: feature-x"), + }); + expect(ctx.resolverCalls).toEqual([]); + expect(ctx.execs).toEqual([]); + }).pipe(Effect.provide(ctx.layer)); + }); + + it.effect("repairs the database --remote=<url> names and never touches the linked cache", () => { + const ctx = setup(tmp.current, { yes: true }); + return Effect.gen(function* () { + yield* seedMigration(tmp.current, "20240101000000_init.sql", "create table a;\n"); + yield* migrationRepair( + input({ + versions: ["20240101000000"], + status: "applied", + remote: Option.some({ _tag: "DbUrl", url: DB_URL }), + }), + ); + expect(ctx.resolverCalls[0]?.target).toMatchObject({ kind: "db-url" }); + expect(ctx.execs).toContain("COMMIT"); + expect(ctx.cache.cached).toBe(false); + expect(ctx.warnings()).toEqual([]); + }).pipe(Effect.provide(ctx.layer)); + }); + + describe("deprecated selectors", () => { + const base = ["migration", "repair", "--status", "applied", "20240101000000"]; + const cases = [ + { + alias: "--linked", + args: [...base, "--linked"], + flags: { linked: true }, + target: { kind: "linked", ref: VALID_REF }, + warning: `--linked is deprecated and will be removed in v4. Use --remote instead: supabase ${base.join(" ")} --remote`, + }, + { + alias: "--project-ref", + args: [...base, "--project-ref", FLAG_REF], + flags: { projectRef: Option.some(FLAG_REF) }, + target: { kind: "linked", ref: FLAG_REF }, + warning: `--project-ref is deprecated and will be removed in v4. Use --remote instead: supabase ${base.join(" ")} --remote=${FLAG_REF}`, + }, + { + alias: "--db-url", + args: [...base, "--db-url", DB_URL], + flags: { dbUrl: Option.some(DB_URL) }, + target: { kind: "db-url" }, + warning: `--db-url is deprecated and will be removed in v4. Use --remote instead: supabase ${base.join(" ")} --remote=<db-url>`, + }, + { + alias: "--local", + args: [...base, "--local"], + flags: { local: true }, + target: { kind: "local" }, + warning: `--local isn't needed (local is the default) and will be removed in v4: supabase ${base.join(" ")}`, + }, + ] as const satisfies ReadonlyArray<{ + readonly alias: string; + readonly args: ReadonlyArray<string>; + readonly flags: Partial<MigrationRepairInput>; + readonly target: Record<string, string>; + readonly warning: string; + }>; + + for (const testCase of cases) { + it.effect(`${testCase.alias} still selects its target and warns`, () => { + const ctx = setup(tmp.current, { yes: true, args: testCase.args }); + return Effect.gen(function* () { + yield* seedMigration(tmp.current, "20240101000000_init.sql", "create table a;\n"); + yield* migrationRepair( + input({ versions: ["20240101000000"], status: "applied", ...testCase.flags }), + ); + expect(ctx.resolverCalls[0]?.target).toMatchObject(testCase.target); + expect(ctx.warnings()).toEqual([testCase.warning]); + }).pipe(Effect.provide(ctx.layer)); + }); + } + + it.effect("rejects --project-ref combined with --local before touching the database", () => { + const { layer, execs, queries, cache } = setup(tmp.current); + return Effect.gen(function* () { + const error = yield* migrationRepair( + input({ + versions: ["20240101000000"], + status: "applied", + 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 mode", () => { @@ -462,7 +623,7 @@ describe("migration repair", () => { input({ versions: ["20240101000000"], status: "applied", - projectRef: Option.some(VALID_REF), + remote: Option.some({ _tag: "Ref", ref: VALID_REF }), }), ); expect(requests.map(({ method, path }) => `${method} ${path}`)).toEqual([ @@ -475,7 +636,7 @@ describe("migration repair", () => { expect(yield* fs.exists(`${tmp.current}/supabase/.temp/linked-project.json`)).toBe(true); assertPermissionDrift({ command: "migration repair", - activeFlags: { "project-ref": VALID_REF }, + activeFlags: { remote: VALID_REF }, requests: [...asRecordedRequests(requests), ...ctx.http.calls], exact: true, }); @@ -486,31 +647,32 @@ describe("migration repair", () => { ); }); - it.effect("makes no Management API request for --local through the real resolver", () => { - 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", local: true }), + it.effect( + "makes no Management API request for the default local target through the real resolver", + () => { + 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" })); + expect(requests).toEqual([]); + expect(ctx.http.calls).toEqual([]); + assertPermissionDrift({ + command: "migration repair", + activeFlags: {}, + requests: [], + exact: true, + }); + }).pipe( + Effect.scoped, + Effect.provide(ctx.layer), + provideEnv({ SUPABASE_ACCESS_TOKEN: VALID_TOKEN }), ); - expect(requests).toEqual([]); - expect(ctx.http.calls).toEqual([]); - assertPermissionDrift({ - command: "migration repair", - activeFlags: { local: true }, - requests: [], - exact: true, - }); - }).pipe( - Effect.scoped, - Effect.provide(ctx.layer), - provideEnv({ SUPABASE_ACCESS_TOKEN: VALID_TOKEN }), - ); - }); + }, + ); - it.effect("rejects --password combined with --db-url through the real resolver", () => { + it.effect("rejects --password combined with --remote=<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"); @@ -518,7 +680,7 @@ describe("migration repair", () => { input({ versions: ["20240101000000"], status: "applied", - dbUrl: Option.some("postgresql://x"), + remote: Option.some({ _tag: "DbUrl", url: "postgresql://x" }), password: Option.some("pw"), }), ).pipe(Effect.flip); @@ -538,7 +700,7 @@ describe("migration repair", () => { versions: ["20240101000000"], status: "applied", password: Option.some("pw"), - projectRef: Option.some(VALID_REF), + remote: Option.some({ _tag: "Ref", ref: VALID_REF }), }), ); expect(requests.map(({ method, path }) => `${method} ${path}`)).toEqual([ @@ -546,7 +708,7 @@ describe("migration repair", () => { ]); assertPermissionDrift({ command: "migration repair", - activeFlags: { password: true, "project-ref": VALID_REF }, + activeFlags: { password: true, remote: VALID_REF }, requests: [...asRecordedRequests(requests), ...ctx.http.calls], exact: true, }); @@ -564,7 +726,11 @@ describeHostedConsent("migration repair", { const db = fakeDatabase(); return { run: migrationRepair( - input({ versions: ["20240101000000"], status: "applied", linked: true }), + input({ + versions: ["20240101000000"], + status: "applied", + remote: Option.some({ _tag: "Linked" }), + }), ).pipe( Effect.provide( Layer.mergeAll( 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 306ac042de..940a3fae8f 100644 --- a/apps/cli/src/commands/migration/squash/squash.integration.test.ts +++ b/apps/cli/src/commands/migration/squash/squash.integration.test.ts @@ -20,6 +20,8 @@ import { FAKE_SHADOW_CONTAINER_ID, VALID_REF, VALID_TOKEN, + jsonResponse, + mockCommandPlatformApi, mockCommandSettings, mockLinkedProjectCacheTracked, mockShadowContainerCliSpawner, @@ -232,6 +234,23 @@ const alwaysReadyHttpClientLayer = Layer.succeed( ), ); +const FLAG_REF = "flagflagflagflagflag"; +const BRANCH_REF = "branchprojectrefabcd"; +const DB_URL = "postgresql://postgres:postgres@127.0.0.1:54322/postgres"; + +const branchRow = (name: string) => ({ + id: "11111111-2222-4333-8444-555555555555", + name, + project_ref: BRANCH_REF, + parent_project_ref: VALID_REF, + is_default: false, + persistent: false, + status: "MIGRATIONS_PASSED", + created_at: "2026-05-27T01:02:03Z", + updated_at: "2026-05-27T01:02:04Z", + with_data: true, +}); + interface SetupOpts { /** Runs the real `DbConfigResolver` and linked-project cache against `workdir`. */ readonly realLayers?: boolean; @@ -253,6 +272,7 @@ interface SetupOpts { readonly fullDumpSql?: string; readonly failDumpKind?: "before" | "after" | "full"; readonly fsFaults?: FsFaultOpts; + readonly args?: ReadonlyArray<string>; } function fakeServices(workdir: string, opts: SetupOpts = {}) { @@ -423,8 +443,12 @@ function setup(workdir: string, opts: SetupOpts = {}) { promptProjectRef: () => Effect.succeed(VALID_REF), }); + const branchApi = mockCommandPlatformApi({ + handler: (request) => Effect.succeed(jsonResponse(request, 200, [branchRow("feature-x")])), + }); const layer = Layer.mergeAll( services, + branchApi.layer, out.layer, telemetry.layer, opts.realLayers === true @@ -437,11 +461,21 @@ function setup(workdir: string, opts: SetupOpts = {}) { Layer.succeed(YesFlag, opts.yes ?? false), Layer.succeed(OutputFlag, Option.none()), Layer.succeed(TargetPolicy, { group: "selectable", consent: "no" }), - Layer.succeed(CliArgs, { args: [] }), + Layer.succeed(CliArgs, { args: opts.args ?? [] }), mockTty({ stdinIsTty: interactive, stdoutIsTty: interactive }), ); - return { layer, http, out, telemetry, cache, ...fake }; + return { + layer, + http, + out, + telemetry, + cache, + branchRequests: branchApi.requests, + warnings: () => + out.messages.filter((message) => message.type === "warn").map((message) => message.message), + ...fake, + }; } const flags = (over: Partial<MigrationSquashFlags> = {}): MigrationSquashFlags => ({ @@ -507,22 +541,11 @@ const tmp = useTempWorkdir(); describe("migration squash", () => { describe("flag surface & ordering", () => { - it.effect("rejects --linked combined with --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(TargetSelectorConflictError); - expect(s.resolverCalls).toEqual([]); - }).pipe(Effect.provide(s.layer)); - }); - - it.effect("rejects --db-url combined with --linked", () => { + it.effect("rejects --remote combined with --local", () => { const s = setup(tmp.current); return Effect.gen(function* () { const error = yield* migrationSquash( - flags({ dbUrl: Option.some("postgresql://x"), linked: true }), + flags({ remote: Option.some({ _tag: "Linked" }), local: true }), ).pipe(Effect.flip); expect(error).toBeInstanceOf(TargetSelectorConflictError); expect(s.resolverCalls).toEqual([]); @@ -556,12 +579,15 @@ describe("migration squash", () => { }).pipe(Effect.provide(s.layer)); }); - it.effect("rejects --password combined with --db-url through the real resolver", () => { + it.effect("rejects --password combined with --remote=<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") }), + flags({ + remote: Option.some({ _tag: "DbUrl", url: "postgresql://x" }), + password: Option.some("y"), + }), ).pipe(Effect.flip); expect(error).toBeInstanceOf(TargetPasswordConflictError); expect(s.shadowSpawned).toEqual([]); @@ -609,7 +635,7 @@ describe("migration squash", () => { const s = setup(tmp.current, { failResolve: true }); return Effect.gen(function* () { const error = yield* migrationSquash( - flags({ linked: true, version: Option.some("not-a-number") }), + flags({ remote: Option.some({ _tag: "Linked" }), version: Option.some("not-a-number") }), ).pipe(Effect.flip); expect(error).toBeInstanceOf(ProjectRefNotLinkedError); }).pipe(Effect.provide(s.layer)); @@ -1146,22 +1172,28 @@ describe("migration squash", () => { }, ); - it.effect("a --db-url pointing at the local stack also takes the local-suggestion path", () => { - 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)); - }); + it.effect( + "a --remote=<url> pointing at the local stack also takes the local-suggestion path", + () => { + const s = setup(tmp.current, { interactive: true, isLocal: true }); + return Effect.gen(function* () { + yield* seedMigration(tmp.current, "0_init.sql"); + yield* migrationSquash( + flags({ remote: Option.some({ _tag: "DbUrl", url: "postgresql://local" }) }), + ); + expect(stdout(s.out)).toContain("Finished supabase migration squash."); + expect(s.out.promptConfirmCalls).toEqual([]); + }).pipe(Effect.provide(s.layer)); + }, + ); }); describe("hosted target", () => { function setupRemote(opts: SetupOpts = {}) { return setup(tmp.current, { yes: true, ...opts }); } - const hosted = (over: Partial<MigrationSquashFlags> = {}) => flags({ linked: true, ...over }); + const hosted = (over: Partial<MigrationSquashFlags> = {}) => + flags({ remote: Option.some({ _tag: "Linked" }), ...over }); it.effect("baselines the remote history table once the consent is given", () => { const s = setupRemote(); @@ -1326,7 +1358,7 @@ describe("migration squash", () => { }, ); - it.effect("--linked caches the linked project ref even when the squash fails", () => { + it.effect("--remote caches the linked project ref even when the squash fails", () => { const s = setup(tmp.current); return Effect.gen(function* () { const error = yield* migrationSquash(hosted({ version: Option.some("bad") })).pipe( @@ -1338,27 +1370,129 @@ describe("migration squash", () => { }).pipe(Effect.provide(s.layer)); }); + it.effect("--remote=<ref> overrides the workdir's own ref for resolution and caching", () => { + // VALID_REF is what the workdir would resolve to without the flag. + const s = setup(tmp.current); + return Effect.gen(function* () { + const error = yield* migrationSquash( + flags({ + remote: Option.some({ _tag: "Ref", ref: 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); + expect(s.warnings()).toEqual([]); + }).pipe(Effect.provide(s.layer)); + }); + it.effect( - "--project-ref implies --linked and overrides the workdir's own ref for resolution and caching", + "--remote=<branch> resolves the branch's project ref for resolution and caching", () => { - // VALID_REF is what the workdir would resolve to without the flag. - const FLAG_REF = "flagflagflagflagflag"; const s = setup(tmp.current); return Effect.gen(function* () { const error = yield* migrationSquash( - flags({ projectRef: Option.some(FLAG_REF), version: Option.some("bad") }), + flags({ + remote: Option.some({ _tag: "Branch", name: "feature-x" }), + 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); + expect(s.branchRequests.map(({ method, url }) => `${method} ${url}`)).toEqual([ + `GET https://api.supabase.com/v1/projects/${VALID_REF}/branches`, + ]); + expect(s.resolverCalls[0]?.target).toMatchObject({ kind: "linked", ref: BRANCH_REF }); + expect(s.cache.cachedRef).toBe(BRANCH_REF); + expect(s.warnings()).toEqual([]); }).pipe(Effect.provide(s.layer)); }, ); + it.effect("--remote=<branch> fails with the branches that exist when none matches", () => { + const s = setup(tmp.current); + return Effect.gen(function* () { + const error = yield* migrationSquash( + flags({ remote: Option.some({ _tag: "Branch", name: "feature-y" }) }), + ).pipe(Effect.flip); + expect(error).toMatchObject({ + _tag: "LinkBranchNotFoundError", + message: expect.stringContaining("Available branches: feature-x"), + }); + expect(s.resolverCalls).toEqual([]); + expect(s.shadowSpawned).toEqual([]); + }).pipe(Effect.provide(s.layer)); + }); + + it.effect("--remote=<url> squashes against that database and skips the linked cache", () => { + const s = setupRemote(); + return Effect.gen(function* () { + yield* seedMigration(tmp.current, "0_init.sql"); + yield* migrationSquash(flags({ remote: Option.some({ _tag: "DbUrl", url: DB_URL }) })); + expect(s.resolverCalls[0]?.target).toMatchObject({ kind: "db-url" }); + expect(s.cache.cached).toBe(false); + expect(s.warnings()).toEqual([]); + }).pipe(Effect.provide(s.layer)); + }); + + describe("deprecated selectors", () => { + const cases = [ + { + alias: "--linked", + args: ["migration", "squash", "--linked"], + flags: { linked: true }, + target: { kind: "linked", ref: VALID_REF }, + warning: + "--linked is deprecated and will be removed in v4. Use --remote instead: supabase migration squash --remote", + }, + { + alias: "--project-ref", + args: ["migration", "squash", "--project-ref", FLAG_REF], + flags: { projectRef: Option.some(FLAG_REF) }, + target: { kind: "linked", ref: FLAG_REF }, + warning: `--project-ref is deprecated and will be removed in v4. Use --remote instead: supabase migration squash --remote=${FLAG_REF}`, + }, + { + alias: "--db-url", + args: ["migration", "squash", "--db-url", DB_URL], + flags: { dbUrl: Option.some(DB_URL) }, + target: { kind: "db-url" }, + warning: + "--db-url is deprecated and will be removed in v4. Use --remote instead: supabase migration squash --remote=<db-url>", + }, + { + alias: "--local", + args: ["migration", "squash", "--local"], + flags: { local: true }, + target: { kind: "local" }, + warning: + "--local isn't needed (local is the default) and will be removed in v4: supabase migration squash", + }, + ] as const satisfies ReadonlyArray<{ + readonly alias: string; + readonly args: ReadonlyArray<string>; + readonly flags: Partial<MigrationSquashFlags>; + readonly target: Record<string, string>; + readonly warning: string; + }>; + + for (const testCase of cases) { + it.effect(`${testCase.alias} still selects its target and warns`, () => { + const s = setupRemote({ args: testCase.args }); + return Effect.gen(function* () { + yield* seedMigration(tmp.current, "0_init.sql"); + yield* migrationSquash(flags(testCase.flags)); + expect(s.resolverCalls[0]?.target).toMatchObject(testCase.target); + expect(s.warnings()).toEqual([testCase.warning]); + }).pipe(Effect.provide(s.layer)); + }); + } + }); + it.effect( - "--linked reads [remotes.<ref>] and prints the config-override line before the consent", + "--remote reads [remotes.<ref>] and prints the config-override line before the consent", () => { const s = setupRemote(); return Effect.gen(function* () { @@ -1385,7 +1519,7 @@ describe("migration squash", () => { const fs = yield* FileSystem.FileSystem; yield* stubManagementApi(requests, serveLinkedDatabase(VALID_REF)); yield* seedMigration(tmp.current, "0_init.sql"); - yield* migrationSquash(flags({ projectRef: Option.some(VALID_REF) })); + yield* migrationSquash(flags({ remote: Option.some({ _tag: "Ref", ref: 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`, @@ -1397,7 +1531,7 @@ describe("migration squash", () => { expect(yield* fs.exists(`${tmp.current}/supabase/.temp/linked-project.json`)).toBe(true); assertPermissionDrift({ command: "migration squash", - activeFlags: { linked: true, "project-ref": VALID_REF }, + activeFlags: { remote: VALID_REF }, requests: [...asRecordedRequests(requests), ...s.http.calls], exact: true, }); @@ -1415,14 +1549,17 @@ describe("migration squash", () => { yield* stubManagementApi(requests, serveLinkedDatabase(VALID_REF)); yield* seedMigration(tmp.current, "0_init.sql"); yield* migrationSquash( - flags({ password: Option.some("pw"), projectRef: Option.some(VALID_REF) }), + flags({ + password: Option.some("pw"), + remote: Option.some({ _tag: "Ref", ref: VALID_REF }), + }), ); expect(requests.map(({ method, path }) => `${method} ${path}`)).toEqual([ `GET /v1/projects/${VALID_REF}/config/database/pooler`, ]); assertPermissionDrift({ command: "migration squash", - activeFlags: { linked: true, password: true, "project-ref": VALID_REF }, + activeFlags: { password: true, remote: VALID_REF }, requests: [...asRecordedRequests(requests), ...s.http.calls], exact: true, }); @@ -1523,7 +1660,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({ linked: true })); + yield* migrationSquash(flags({ remote: Option.some({ _tag: "Linked" }) })); 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 @@ -1556,7 +1693,9 @@ describeHostedConsent("migration squash", { entry.sql.includes("DELETE FROM supabase_migrations"), ).length; return { - run: migrationSquash(flags({ linked: true })).pipe(Effect.provide(fake.services)), + run: migrationSquash(flags({ remote: Option.some({ _tag: "Linked" }) })).pipe( + Effect.provide(fake.services), + ), mutations: () => fake.shadowSpawned.length + historyWrites(), }; }, 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 b1beaa0546..90073cdf3d 100644 --- a/apps/cli/src/commands/migration/up/up.integration.test.ts +++ b/apps/cli/src/commands/migration/up/up.integration.test.ts @@ -14,6 +14,8 @@ import { stripAnsi } from "../../../../tests/helpers/ansi.ts"; import { VALID_REF, VALID_TOKEN, + jsonResponse, + mockCommandPlatformApi, mockCommandSettings, mockLinkedProjectCacheTracked, mockTelemetryStateTracked, @@ -58,6 +60,22 @@ import type { MigrationUpFlags } from "./up.command.ts"; const LIST_SQL = "SELECT version FROM supabase_migrations.schema_migrations ORDER BY version"; const READ_VAULT = "SELECT id, name FROM vault.secrets WHERE name = ANY($1)"; +const FLAG_REF = "flagflagflagflagflag"; +const BRANCH_REF = "branchprojectrefabcd"; +const DB_URL = "postgresql://postgres:postgres@127.0.0.1:54322/postgres"; + +const branchRow = (name: string) => ({ + id: "11111111-2222-4333-8444-555555555555", + name, + project_ref: BRANCH_REF, + parent_project_ref: VALID_REF, + is_default: false, + persistent: false, + status: "MIGRATIONS_PASSED", + created_at: "2026-05-27T01:02:03Z", + updated_at: "2026-05-27T01:02:04Z", + with_data: true, +}); interface SetupOpts { /** Runs the real `DbConfigResolver` and linked-project cache against `workdir`. */ @@ -68,6 +86,7 @@ interface SetupOpts { readonly failApply?: boolean; readonly failVault?: boolean; readonly existingVault?: ReadonlyArray<{ id: string; name: string }>; + readonly args?: ReadonlyArray<string>; } function fakeDatabase(opts: SetupOpts = {}) { @@ -151,6 +170,9 @@ function setup(workdir: string, opts: SetupOpts = {}) { }); const http = recordingProjectHttpClient(VALID_REF); + const branchApi = mockCommandPlatformApi({ + handler: (request) => Effect.succeed(jsonResponse(request, 200, [branchRow("feature-x")])), + }); const layer = Layer.mergeAll( cliConfigValuesTestLayer, Layer.succeed(TargetPolicy, { group: "selectable", consent: "no" }), @@ -176,10 +198,23 @@ function setup(workdir: string, opts: SetupOpts = {}) { projectRef, mockCommandSettings({ workdir }), Layer.succeed(DnsResolverFlag, "native"), - Layer.succeed(CliArgs, { args: [] }), + Layer.succeed(CliArgs, { args: opts.args ?? [] }), + branchApi.layer, BunServices.layer, ); - return { layer, http, out, telemetry, execs, queries, cache, resolverCalls }; + return { + layer, + http, + out, + telemetry, + execs, + queries, + cache, + resolverCalls, + branchRequests: branchApi.requests, + warnings: () => + out.messages.filter((message) => message.type === "warn").map((message) => message.message), + }; } const flags = (over: Partial<MigrationUpFlags> = {}): MigrationUpFlags => ({ @@ -313,40 +348,135 @@ describe("migration up", () => { }).pipe(Effect.provide(layer)); }); - it.effect("rejects --db-url combined with --linked", () => { + it.effect("rejects --remote combined with --local", () => { const { layer } = setup(tmp.current); return Effect.gen(function* () { const error = yield* migrationUp( - flags({ dbUrl: Option.some("postgresql://x"), linked: true }), + flags({ remote: Option.some({ _tag: "Linked" }), local: true }), ).pipe(Effect.flip); expect(error).toBeInstanceOf(TargetSelectorConflictError); }).pipe(Effect.provide(layer)); }); - it.effect("applies on the project given via --project-ref, which implies --linked", () => { + it.effect("applies on the project given via --remote=<ref>", () => { // 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: [] }); + const ctx = 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)); + yield* migrationUp(flags({ remote: Option.some({ _tag: "Ref", ref: FLAG_REF }) })); + expect(ctx.resolverCalls[0]?.target).toMatchObject({ kind: "linked", ref: FLAG_REF }); + expect(ctx.cache.cachedRef).toBe(FLAG_REF); + expect(ctx.cache.cachedRef).not.toBe(VALID_REF); + expect(ctx.warnings()).toEqual([]); + }).pipe(Effect.provide(ctx.layer)); }); - 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: [] }); + it.effect("applies on the project of the branch --remote=<branch> names", () => { + const ctx = setup(tmp.current, { yes: true, remote: [] }); + return Effect.gen(function* () { + yield* migrationUp(flags({ remote: Option.some({ _tag: "Branch", name: "feature-x" }) })); + expect(ctx.branchRequests.map(({ method, url }) => `${method} ${url}`)).toEqual([ + `GET https://api.supabase.com/v1/projects/${VALID_REF}/branches`, + ]); + expect(ctx.resolverCalls[0]?.target).toMatchObject({ kind: "linked", ref: BRANCH_REF }); + expect(ctx.cache.cachedRef).toBe(BRANCH_REF); + expect(ctx.warnings()).toEqual([]); + }).pipe(Effect.provide(ctx.layer)); + }); + + it.effect("fails with the branches that exist when --remote=<branch> matches none", () => { + const ctx = setup(tmp.current, { yes: true, remote: [] }); return Effect.gen(function* () { const error = yield* migrationUp( - flags({ local: true, projectRef: Option.some(FLAG_REF) }), + flags({ remote: Option.some({ _tag: "Branch", name: "feature-y" }) }), ).pipe(Effect.flip); - expect(error).toBeInstanceOf(TargetProjectRefConflictError); - expect(execs).toEqual([]); - expect(queries).toEqual([]); - expect(cache.cached).toBe(false); - }).pipe(Effect.provide(layer)); + expect(error).toMatchObject({ + _tag: "LinkBranchNotFoundError", + message: expect.stringContaining("Available branches: feature-x"), + }); + expect(ctx.resolverCalls).toEqual([]); + expect(ctx.execs).toEqual([]); + }).pipe(Effect.provide(ctx.layer)); + }); + + it.effect( + "applies on the database --remote=<url> names and never touches the linked cache", + () => { + const ctx = setup(tmp.current, { yes: true, remote: [] }); + return Effect.gen(function* () { + yield* seed(tmp.current, "20240101000000_a.sql"); + yield* migrationUp(flags({ remote: Option.some({ _tag: "DbUrl", url: DB_URL }) })); + expect(ctx.resolverCalls[0]?.target).toMatchObject({ kind: "db-url" }); + expect(insertedVersions(ctx.queries)).toEqual(["20240101000000"]); + expect(ctx.cache.cached).toBe(false); + expect(ctx.warnings()).toEqual([]); + }).pipe(Effect.provide(ctx.layer)); + }, + ); + + describe("deprecated selectors", () => { + const cases = [ + { + alias: "--linked", + args: ["migration", "up", "--linked"], + flags: { linked: true }, + target: { kind: "linked", ref: VALID_REF }, + warning: + "--linked is deprecated and will be removed in v4. Use --remote instead: supabase migration up --remote", + }, + { + alias: "--project-ref", + args: ["migration", "up", "--project-ref", FLAG_REF], + flags: { projectRef: Option.some(FLAG_REF) }, + target: { kind: "linked", ref: FLAG_REF }, + warning: `--project-ref is deprecated and will be removed in v4. Use --remote instead: supabase migration up --remote=${FLAG_REF}`, + }, + { + alias: "--db-url", + args: ["migration", "up", "--db-url", DB_URL], + flags: { dbUrl: Option.some(DB_URL) }, + target: { kind: "db-url" }, + warning: + "--db-url is deprecated and will be removed in v4. Use --remote instead: supabase migration up --remote=<db-url>", + }, + { + alias: "--local", + args: ["migration", "up", "--local"], + flags: { local: true }, + target: { kind: "local" }, + warning: + "--local isn't needed (local is the default) and will be removed in v4: supabase migration up", + }, + ] as const satisfies ReadonlyArray<{ + readonly alias: string; + readonly args: ReadonlyArray<string>; + readonly flags: Partial<MigrationUpFlags>; + readonly target: Record<string, string>; + readonly warning: string; + }>; + + for (const testCase of cases) { + it.effect(`${testCase.alias} still selects its target and warns`, () => { + const ctx = setup(tmp.current, { yes: true, remote: [], args: testCase.args }); + return Effect.gen(function* () { + yield* migrationUp(flags(testCase.flags)); + expect(ctx.resolverCalls[0]?.target).toMatchObject(testCase.target); + expect(ctx.warnings()).toEqual([testCase.warning]); + }).pipe(Effect.provide(ctx.layer)); + }); + } + + it.effect("rejects --project-ref combined with --local before touching the database", () => { + const { layer, execs, queries, cache } = setup(tmp.current, { remote: [] }); + return Effect.gen(function* () { + 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( @@ -358,7 +488,7 @@ describe("migration up", () => { }); return Effect.gen(function* () { yield* seed(tmp.current, "20240101000000_a.sql"); - yield* migrationUp(flags({ linked: true })); + yield* migrationUp(flags({ remote: Option.some({ _tag: "Linked" }) })); expect(insertedVersions(queries)).toEqual([]); expect(out.messages).toContainEqual( expect.objectContaining({ type: "success", message: "Migrations applied" }), @@ -371,7 +501,7 @@ describe("migration up", () => { const { layer, out } = setup(tmp.current, { remote: ["20240101000000"] }); return Effect.gen(function* () { yield* seed(tmp.current, "20240101000000_a.sql"); - yield* migrationUp(flags({ linked: true })); + yield* migrationUp(flags({ remote: Option.some({ _tag: "Linked" }) })); 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)); @@ -404,7 +534,7 @@ describe("migration up", () => { const fs = yield* FileSystem.FileSystem; yield* stubManagementApi(requests, serveLinkedDatabase(VALID_REF)); yield* seed(tmp.current, "20240101000000_a.sql"); - yield* migrationUp(flags({ linked: true, projectRef: Option.some(VALID_REF) })); + yield* migrationUp(flags({ remote: Option.some({ _tag: "Ref", ref: VALID_REF }) })); expect(insertedVersions(ctx.queries)).toEqual(["20240101000000"]); expect(requests.map(({ method, path }) => `${method} ${path}`)).toEqual([ `GET /v1/projects/${VALID_REF}/config/database/pooler`, @@ -416,7 +546,7 @@ describe("migration up", () => { expect(yield* fs.exists(`${tmp.current}/supabase/.temp/linked-project.json`)).toBe(true); assertPermissionDrift({ command: "migration up", - activeFlags: { linked: true, "project-ref": VALID_REF }, + activeFlags: { remote: VALID_REF }, requests: [...asRecordedRequests(requests), ...ctx.http.calls], exact: true, }); @@ -466,7 +596,7 @@ describeHostedConsent("migration up", { invoke: () => { const db = fakeDatabase({ remote: [] }); return { - run: migrationUp(flags({ linked: true })).pipe( + run: migrationUp(flags({ remote: Option.some({ _tag: "Linked" }) })).pipe( Effect.provide( Layer.mergeAll( db.resolver, From b68ad77443ce7ffad5cb2fcd47e41b92a8437389 Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Mon, 19 Oct 2026 11:40:16 +0100 Subject: [PATCH 109/291] test(cli): cover inspect db and report through --remote with a warning check per alias --- .../db/inspect-deprecated.integration.test.ts | 2 +- .../db/inspect-query.integration.test.ts | 207 ++++++++++++++---- .../db/inspect-specs.integration.test.ts | 2 +- .../inspect/report/report.integration.test.ts | 201 ++++++++++++++--- 4 files changed, 331 insertions(+), 81 deletions(-) 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 d7cc170f6e..752ac9d847 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 @@ -85,7 +85,7 @@ const flags: InspectConnectionFlags = { dbUrl: Option.none<string>(), remote: Option.none(), linked: false, - local: true, + local: false, projectRef: Option.none<string>(), }; 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 3acec1bf64..d05e337e57 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 @@ -16,6 +16,7 @@ import { VALID_REF, VALID_TOKEN, buildTestRuntime, + jsonResponse, mockCommandPlatformApi, mockCommandSettings, mockTelemetryStateTracked, @@ -55,6 +56,22 @@ import { inspectDbRoleStats } from "./role-stats/role-stats.handler.ts"; import { roleStatsSpec } from "./role-stats/role-stats.query.ts"; import type { InspectConnectionFlags } from "./inspect-query.ts"; +const FLAG_REF = "flagflagflagflagflag"; +const BRANCH_REF = "branchprojectrefabcd"; + +const branchRow = (name: string) => ({ + id: "11111111-2222-4333-8444-555555555555", + name, + project_ref: BRANCH_REF, + parent_project_ref: VALID_REF, + is_default: false, + persistent: false, + status: "MIGRATIONS_PASSED", + created_at: "2026-05-27T01:02:03Z", + updated_at: "2026-05-27T01:02:04Z", + with_data: true, +}); + const LOCAL_CONN: PgConnInput = { host: "127.0.0.1", port: 54322, @@ -198,9 +215,12 @@ function setup(opts: SetupOpts = {}) { }); const telemetry = mockTelemetryStateTracked(); const workdir = opts.realLayers?.workdir ?? opts.workdir ?? "/tmp/inspect-db-test"; + const api = mockCommandPlatformApi({ + handler: (request) => Effect.succeed(jsonResponse(request, 200, [branchRow("feature-x")])), + }); const runtime = buildTestRuntime({ out, - api: mockCommandPlatformApi(), + api, cliSettings: mockCommandSettings({ workdir, projectId: opts.projectId === undefined ? Option.none() : Option.some(opts.projectId), @@ -229,7 +249,16 @@ function setup(opts: SetupOpts = {}) { ), configValuesLayer(), ); - return { layer, out, resolver, connection, telemetry }; + return { + layer, + out, + resolver, + connection, + telemetry, + branchRequests: api.requests, + warnings: () => + out.messages.filter((message) => message.type === "warn").map((message) => message.message), + }; } const flags = (over: Partial<InspectConnectionFlags> = {}): InspectConnectionFlags => ({ @@ -319,7 +348,9 @@ describe("inspect db query runner", () => { rows: [DB_STATS_ROW], }); return Effect.gen(function* () { - yield* inspectDbDbStats(flags({ dbUrl: Option.some("postgres://x") })); + yield* inspectDbDbStats( + flags({ remote: Option.some({ _tag: "DbUrl", url: "postgres://x" } as const) }), + ); const target = resolver.connectionInput?.target; expect(target?.kind).toBe("db-url"); expect(target?.kind === "db-url" ? Redacted.value(target.url) : undefined).toBe( @@ -330,15 +361,6 @@ describe("inspect db query runner", () => { }).pipe(Effect.provide(layer)); }); - 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.connectionInput?.target).toEqual({ kind: "local" }); - expect(out.stderrText).not.toContain("Connecting to"); - }).pipe(Effect.provide(layer)); - }); - 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* () { @@ -361,57 +383,144 @@ describe("inspect db query runner", () => { }).pipe(Effect.provide(layer)); }); - it.effect("--linked routes to the linked project and announces it", () => { - const { layer, resolver, out } = setup({ rows: [DB_STATS_ROW], projectId: VALID_REF }); + it.effect("--remote=<ref> targets that project and announces it", () => { + const { layer, resolver, out, warnings } = setup({ rows: [DB_STATS_ROW] }); return Effect.gen(function* () { - yield* inspectDbDbStats(flags({ linked: true })); - expect(resolver.connectionInput?.target).toMatchObject({ kind: "linked", ref: VALID_REF }); - expect(out.stderrText).toContain(unverifiedTargetBanner()); + yield* inspectDbDbStats( + flags({ remote: Option.some({ _tag: "Ref", ref: FLAG_REF } as const) }), + ); + expect(resolver.connectionInput?.target).toMatchObject({ + kind: "linked", + ref: FLAG_REF, + source: "flag", + }); + expect(out.stderrText).toContain(unverifiedTargetBanner(FLAG_REF)); + expect(warnings()).toEqual([]); }).pipe(Effect.provide(layer)); }); - it.effect("--project-ref implies the linked project", () => { - const FLAG_REF = "flagflagflagflagflag"; - const { layer, resolver, out } = setup({ rows: [DB_STATS_ROW] }); + it.effect("--remote=<branch> targets the project of the branch it names", () => { + const { layer, resolver, branchRequests, warnings } = setup({ + rows: [DB_STATS_ROW], + projectId: VALID_REF, + }); return Effect.gen(function* () { - yield* inspectDbDbStats(flags({ projectRef: Option.some(FLAG_REF) })); + yield* inspectDbDbStats( + flags({ remote: Option.some({ _tag: "Branch", name: "feature-x" } as const) }), + ); + expect(branchRequests.map(({ method, url }) => `${method} ${url}`)).toEqual([ + `GET https://api.supabase.com/v1/projects/${VALID_REF}/branches`, + ]); expect(resolver.connectionInput?.target).toMatchObject({ kind: "linked", - ref: FLAG_REF, - source: "flag", + ref: BRANCH_REF, }); - expect(out.stderrText).toContain(unverifiedTargetBanner(FLAG_REF)); + expect(warnings()).toEqual([]); }).pipe(Effect.provide(layer)); }); - it.effect("rejects conflicting target flags", () => { - const { layer, resolver } = setup(); + it.effect("--remote=<branch> fails with the branches that exist when none matches", () => { + const { layer, resolver } = setup({ rows: [DB_STATS_ROW], projectId: VALID_REF }); return Effect.gen(function* () { - const error = yield* inspectDbDbStats(flags({ linked: true, local: true })).pipe(Effect.flip); - expect(error).toBeInstanceOf(TargetSelectorConflictError); + const error = yield* inspectDbDbStats( + flags({ remote: Option.some({ _tag: "Branch", name: "feature-y" } as const) }), + ).pipe(Effect.flip); + expect(error).toMatchObject({ + _tag: "LinkBranchNotFoundError", + message: expect.stringContaining("Available branches: feature-x"), + }); expect(resolver.connectionInput).toBeUndefined(); }).pipe(Effect.provide(layer)); }); - it.effect("rejects --project-ref combined with --local", () => { + it.effect("rejects --remote combined with --local", () => { const { layer, resolver } = setup(); return Effect.gen(function* () { const error = yield* inspectDbDbStats( - flags({ local: true, projectRef: Option.some("flagflagflagflagflag") }), + flags({ remote: Option.some({ _tag: "Linked" } as const), local: true }), ).pipe(Effect.flip); - expect(error).toBeInstanceOf(TargetProjectRefConflictError); + expect(error).toBeInstanceOf(TargetSelectorConflictError); expect(resolver.connectionInput).toBeUndefined(); }).pipe(Effect.provide(layer)); }); + describe("deprecated selectors", () => { + const DEPRECATED_DB_URL = "postgres://x"; + const cases = [ + { + alias: "--linked", + cliArgs: ["inspect", "db", "db-stats", "--linked"], + flags: { linked: true }, + target: { kind: "linked", ref: VALID_REF }, + warning: + "--linked is deprecated and will be removed in v4. Use --remote instead: supabase inspect db db-stats --remote", + }, + { + alias: "--project-ref", + cliArgs: ["inspect", "db", "db-stats", "--project-ref", FLAG_REF], + flags: { projectRef: Option.some(FLAG_REF) }, + target: { kind: "linked", ref: FLAG_REF, source: "flag" }, + warning: `--project-ref is deprecated and will be removed in v4. Use --remote instead: supabase inspect db db-stats --remote=${FLAG_REF}`, + }, + { + alias: "--db-url", + cliArgs: ["inspect", "db", "db-stats", "--db-url", DEPRECATED_DB_URL], + flags: { dbUrl: Option.some(DEPRECATED_DB_URL) }, + target: { kind: "db-url" }, + warning: + "--db-url is deprecated and will be removed in v4. Use --remote instead: supabase inspect db db-stats --remote=<db-url>", + }, + { + alias: "--local", + cliArgs: ["inspect", "db", "db-stats", "--local"], + flags: { local: true }, + target: { kind: "local" }, + warning: + "--local isn't needed (local is the default) and will be removed in v4: supabase inspect db db-stats", + }, + ] as const satisfies ReadonlyArray<{ + readonly alias: string; + readonly cliArgs: ReadonlyArray<string>; + readonly flags: Partial<InspectConnectionFlags>; + readonly target: Record<string, string>; + readonly warning: string; + }>; + + for (const testCase of cases) { + it.effect(`${testCase.alias} still selects its target and warns`, () => { + const { layer, resolver, warnings } = setup({ + rows: [DB_STATS_ROW], + projectId: VALID_REF, + cliArgs: testCase.cliArgs, + }); + return Effect.gen(function* () { + yield* inspectDbDbStats(flags(testCase.flags)); + expect(resolver.connectionInput?.target).toMatchObject(testCase.target); + expect(warnings()).toEqual([testCase.warning]); + }).pipe(Effect.provide(layer)); + }); + } + + it.effect("rejects --project-ref combined with --local", () => { + const { layer, resolver } = setup(); + return Effect.gen(function* () { + const error = yield* inspectDbDbStats( + flags({ local: true, projectRef: Option.some(FLAG_REF) }), + ).pipe(Effect.flip); + expect(error).toBeInstanceOf(TargetProjectRefConflictError); + expect(resolver.connectionInput).toBeUndefined(); + }).pipe(Effect.provide(layer)); + }); + }); + 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* () { - yield* inspectDbDbStats(flags({ local: true })).pipe(Effect.provide(withoutYes.layer)); - yield* inspectDbDbStats(flags({ local: true })).pipe(Effect.provide(withYes.layer)); + yield* inspectDbDbStats(flags()).pipe(Effect.provide(withoutYes.layer)); + yield* inspectDbDbStats(flags()).pipe(Effect.provide(withYes.layer)); expect(banRemoval(withoutYes.resolver.connectionInput)).toBe(false); expect(banRemoval(withYes.resolver.connectionInput)).toBe(true); }); @@ -481,7 +590,9 @@ describe("inspect db query runner", () => { dnsResolver: "https", }); return Effect.gen(function* () { - yield* inspectDbDbStats(flags({ dbUrl: Option.some("postgres://x") })); + yield* inspectDbDbStats( + flags({ remote: Option.some({ _tag: "DbUrl", url: "postgres://x" } as const) }), + ); expect(connection.connectCalls[0]?.dnsResolver).toBe("https"); }).pipe(Effect.provide(layer)); }); @@ -528,13 +639,15 @@ describe("inspect db through the real database-config resolver", () => { () => { const { layer, out, connection } = setup({ rows: [DB_STATS_ROW], - cliArgs: ["inspect", "db", "db-stats", "--project-ref", VALID_REF], + cliArgs: ["inspect", "db", "db-stats", `--remote=${VALID_REF}`], realLayers: { workdir: tempRoot.current }, }); const requests: Array<StubbedRequest> = []; return Effect.gen(function* () { yield* stubManagementApi(requests, serveLinkedDatabase(VALID_REF)); - yield* inspectDbDbStats(flags({ projectRef: Option.some(VALID_REF) })); + yield* inspectDbDbStats( + flags({ remote: Option.some({ _tag: "Ref", ref: VALID_REF } as const) }), + ); expect(asLines(requests)).toEqual([poolerRequest, loginRoleRequest]); expect(connection.connectCalls[0]?.cfg.host).toBe("aws-0-us-east-1.pooler.supabase.com"); expect(out.stdoutText).toContain("8192 kB"); @@ -543,7 +656,7 @@ describe("inspect db through the real database-config resolver", () => { for (const command of RUNNER_INSPECT_DB_LEAVES) { assertPermissionDrift({ command, - activeFlags: { "project-ref": VALID_REF }, + activeFlags: { remote: VALID_REF }, requests: asRecordedRequests(requests), exact: true, }); @@ -551,7 +664,7 @@ describe("inspect db through the real database-config resolver", () => { for (const command of REMOVED_INSPECT_DB_LEAVES) { assertPermissionDrift({ command, - activeFlags: { "project-ref": VALID_REF }, + activeFlags: { remote: VALID_REF }, requests: [], exact: true, }); @@ -609,21 +722,21 @@ describe("inspect db through the real database-config resolver", () => { ); }); - it.effect("makes no Management API request for --local", () => { + it.effect("makes no Management API request for the default local target", () => { const { layer, connection } = setup({ rows: [DB_STATS_ROW], - cliArgs: ["inspect", "db", "db-stats", "--local"], + cliArgs: ["inspect", "db", "db-stats"], realLayers: { workdir: tempRoot.current }, }); const requests: Array<StubbedRequest> = []; return Effect.gen(function* () { yield* stubManagementApi(requests, () => undefined); - yield* inspectDbDbStats(flags({ local: true })); + yield* inspectDbDbStats(flags()); expect(connection.connectCalls).toHaveLength(1); expect(requests).toEqual([]); assertPermissionDrift({ command: "inspect db db-stats", - activeFlags: { local: true }, + activeFlags: {}, requests: [], exact: true, }); @@ -634,22 +747,24 @@ describe("inspect db through the real database-config resolver", () => { ); }); - it.effect("makes no Management API request for --db-url", () => { + it.effect("makes no Management API request for --remote=<url>", () => { const DB_URL = "postgresql://postgres:postgres@127.0.0.1:5432/postgres"; const { layer, connection } = setup({ rows: [DB_STATS_ROW], - cliArgs: ["inspect", "db", "db-stats", "--db-url", DB_URL], + cliArgs: ["inspect", "db", "db-stats", `--remote=${DB_URL}`], realLayers: { workdir: tempRoot.current }, }); const requests: Array<StubbedRequest> = []; return Effect.gen(function* () { yield* stubManagementApi(requests, () => undefined); - yield* inspectDbDbStats(flags({ dbUrl: Option.some(DB_URL) })); + yield* inspectDbDbStats( + flags({ remote: Option.some({ _tag: "DbUrl", url: DB_URL } as const) }), + ); expect(connection.connectCalls).toHaveLength(1); expect(requests).toEqual([]); assertPermissionDrift({ command: "inspect db db-stats", - activeFlags: { "db-url": true }, + activeFlags: { remote: DB_URL }, requests: [], exact: 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 6d0226984d..6f01ebd85f 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 @@ -90,7 +90,7 @@ const localFlags = { remote: Option.none(), dbUrl: Option.none<string>(), linked: false, - local: true, + local: false, projectRef: Option.none<string>(), }; 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 ee6d36ce19..14b5890331 100644 --- a/apps/cli/src/commands/inspect/report/report.integration.test.ts +++ b/apps/cli/src/commands/inspect/report/report.integration.test.ts @@ -27,6 +27,7 @@ import { VALID_REF, VALID_TOKEN, buildTestRuntime, + jsonResponse, mockCommandPlatformApi, mockCommandSettings, mockTelemetryStateTracked, @@ -61,6 +62,22 @@ import type { InspectReportFlags } from "./report.command.ts"; import { inspectReport } from "./report.handler.ts"; import { REPORT_QUERIES, reportIgnoreSchemas, wrapReportQuery } from "./report.queries.ts"; +const FLAG_REF = "flagflagflagflagflag"; +const BRANCH_REF = "branchprojectrefabcd"; + +const branchRow = (name: string) => ({ + id: "11111111-2222-4333-8444-555555555555", + name, + project_ref: BRANCH_REF, + parent_project_ref: VALID_REF, + is_default: false, + persistent: false, + status: "MIGRATIONS_PASSED", + created_at: "2026-05-27T01:02:03Z", + updated_at: "2026-05-27T01:02:04Z", + with_data: true, +}); + const LOCAL_CONN: PgConnInput = { host: "127.0.0.1", port: 54322, @@ -197,10 +214,13 @@ function setupReport(opts: SetupOpts = {}) { ? tempDirLayer("supabase-report-cwd-", (cwd) => mockRuntimeInfo({ cwd })) : mockRuntimeInfo({ cwd: opts.cwd }) ).pipe(Layer.orDie); + const api = mockCommandPlatformApi({ + handler: (request) => Effect.succeed(jsonResponse(request, 200, [branchRow("feature-x")])), + }); const layer = Layer.mergeAll( buildTestRuntime({ out, - api: mockCommandPlatformApi(), + api, cliSettings, tty: mockTty({ stdoutIsTty: opts.stdoutIsTty ?? false }), runtimeInfo, @@ -226,7 +246,16 @@ function setupReport(opts: SetupOpts = {}) { ), ), ); - return { layer, out, resolver, connection, telemetry }; + return { + layer, + out, + resolver, + connection, + telemetry, + branchRequests: api.requests, + warnings: () => + out.messages.filter((message) => message.type === "warn").map((message) => message.message), + }; } const flags = (over: Partial<InspectReportFlags> = {}): InspectReportFlags => ({ @@ -294,20 +323,16 @@ describe("inspect report", () => { }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(() => process.umask(prevUmask)))); }); - it.effect("inspects the local database with --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.connectionInput?.target).toEqual({ kind: "local" }); - }).pipe(Effect.provide(layer)); - }); - - it.effect("inspects a custom database with --db-url", () => { + it.effect("inspects a custom database with --remote=<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") })); + yield* inspectReport( + flags({ + outputDir: base, + remote: Option.some({ _tag: "DbUrl", url: "postgres://x" } as const), + }), + ); const target = resolver.connectionInput?.target; expect(target?.kind === "db-url" ? Redacted.value(target.url) : undefined).toBe( "postgres://x", @@ -325,53 +350,161 @@ describe("inspect report", () => { }).pipe(Effect.provide(layer)); }); - it.effect("rejects more than one of --db-url/--linked/--local", () => { + it.effect("rejects --remote combined with --local", () => { const { layer, resolver } = setupReport(); return Effect.gen(function* () { - const error = yield* inspectReport(flags({ linked: true, local: true })).pipe(Effect.flip); + const error = yield* inspectReport( + flags({ remote: Option.some({ _tag: "Linked" } as const), local: true }), + ).pipe(Effect.flip); expect(error).toBeInstanceOf(TargetSelectorConflictError); expect(resolver.connectionInput).toBeUndefined(); }).pipe(Effect.provide(layer)); }); - it.effect("--linked routes to the linked project and announces it", () => { - const { layer, resolver, out } = setupReport({ + it.effect("a bare --remote routes to the linked project and announces it", () => { + const { layer, resolver, out, warnings } = setupReport({ csvs: DEFAULT_RULE_CSVS, projectId: VALID_REF, }); return Effect.gen(function* () { const base = yield* tempDir("supabase-report-out-"); - yield* inspectReport(flags({ outputDir: base, linked: true })); + yield* inspectReport( + flags({ outputDir: base, remote: Option.some({ _tag: "Linked" } as const) }), + ); expect(resolver.connectionInput?.target).toMatchObject({ kind: "linked", ref: VALID_REF }); expect(out.stderrText).toContain(unverifiedTargetBanner()); + expect(warnings()).toEqual([]); }).pipe(Effect.provide(layer)); }); - it.effect("--project-ref implies the linked project", () => { - const FLAG_REF = "flagflagflagflagflag"; - const { layer, resolver } = setupReport({ csvs: DEFAULT_RULE_CSVS }); + it.effect("--remote=<ref> targets that project", () => { + const { layer, resolver, warnings } = 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) })); + yield* inspectReport( + flags({ outputDir: base, remote: Option.some({ _tag: "Ref", ref: FLAG_REF } as const) }), + ); expect(resolver.connectionInput?.target).toMatchObject({ kind: "linked", ref: FLAG_REF, source: "flag", }); + expect(warnings()).toEqual([]); + }).pipe(Effect.provide(layer)); + }); + + it.effect("--remote=<branch> targets the project of the branch it names", () => { + const { layer, resolver, branchRequests, warnings } = setupReport({ + csvs: DEFAULT_RULE_CSVS, + projectId: VALID_REF, + }); + return Effect.gen(function* () { + const base = yield* tempDir("supabase-report-out-"); + yield* inspectReport( + flags({ + outputDir: base, + remote: Option.some({ _tag: "Branch", name: "feature-x" } as const), + }), + ); + expect(branchRequests.map(({ method, url }) => `${method} ${url}`)).toEqual([ + `GET https://api.supabase.com/v1/projects/${VALID_REF}/branches`, + ]); + expect(resolver.connectionInput?.target).toMatchObject({ + kind: "linked", + ref: BRANCH_REF, + }); + expect(warnings()).toEqual([]); }).pipe(Effect.provide(layer)); }); - it.effect("rejects --project-ref combined with --local", () => { - const { layer, resolver } = setupReport({ csvs: DEFAULT_RULE_CSVS }); + it.effect("--remote=<branch> fails with the branches that exist when none matches", () => { + const { layer, resolver } = setupReport({ csvs: DEFAULT_RULE_CSVS, projectId: VALID_REF }); return Effect.gen(function* () { + const base = yield* tempDir("supabase-report-out-"); const error = yield* inspectReport( - flags({ local: true, projectRef: Option.some("flagflagflagflagflag") }), + flags({ + outputDir: base, + remote: Option.some({ _tag: "Branch", name: "feature-y" } as const), + }), ).pipe(Effect.flip); - expect(error).toBeInstanceOf(TargetProjectRefConflictError); + expect(error).toMatchObject({ + _tag: "LinkBranchNotFoundError", + message: expect.stringContaining("Available branches: feature-x"), + }); expect(resolver.connectionInput).toBeUndefined(); }).pipe(Effect.provide(layer)); }); + describe("deprecated selectors", () => { + const DEPRECATED_DB_URL = "postgres://x"; + const cases = [ + { + alias: "--linked", + cliArgs: ["inspect", "report", "--linked"], + flags: { linked: true }, + target: { kind: "linked", ref: VALID_REF }, + warning: + "--linked is deprecated and will be removed in v4. Use --remote instead: supabase inspect report --remote", + }, + { + alias: "--project-ref", + cliArgs: ["inspect", "report", "--project-ref", FLAG_REF], + flags: { projectRef: Option.some(FLAG_REF) }, + target: { kind: "linked", ref: FLAG_REF, source: "flag" }, + warning: `--project-ref is deprecated and will be removed in v4. Use --remote instead: supabase inspect report --remote=${FLAG_REF}`, + }, + { + alias: "--db-url", + cliArgs: ["inspect", "report", "--db-url", DEPRECATED_DB_URL], + flags: { dbUrl: Option.some(DEPRECATED_DB_URL) }, + target: { kind: "db-url" }, + warning: + "--db-url is deprecated and will be removed in v4. Use --remote instead: supabase inspect report --remote=<db-url>", + }, + { + alias: "--local", + cliArgs: ["inspect", "report", "--local"], + flags: { local: true }, + target: { kind: "local" }, + warning: + "--local isn't needed (local is the default) and will be removed in v4: supabase inspect report", + }, + ] as const satisfies ReadonlyArray<{ + readonly alias: string; + readonly cliArgs: ReadonlyArray<string>; + readonly flags: Partial<InspectReportFlags>; + readonly target: Record<string, string>; + readonly warning: string; + }>; + + for (const testCase of cases) { + it.effect(`${testCase.alias} still selects its target and warns`, () => { + const { layer, resolver, warnings } = setupReport({ + csvs: DEFAULT_RULE_CSVS, + projectId: VALID_REF, + cliArgs: testCase.cliArgs, + }); + return Effect.gen(function* () { + const base = yield* tempDir("supabase-report-out-"); + yield* inspectReport(flags({ outputDir: base, ...testCase.flags })); + expect(resolver.connectionInput?.target).toMatchObject(testCase.target); + expect(warnings()).toEqual([testCase.warning]); + }).pipe(Effect.provide(layer)); + }); + } + + it.effect("rejects --project-ref combined with --local", () => { + const { layer, resolver } = setupReport({ csvs: DEFAULT_RULE_CSVS }); + return Effect.gen(function* () { + const error = yield* inspectReport( + flags({ local: true, projectRef: Option.some(FLAG_REF) }), + ).pipe(Effect.flip); + expect(error).toBeInstanceOf(TargetProjectRefConflictError); + expect(resolver.connectionInput).toBeUndefined(); + }).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* () { @@ -698,7 +831,7 @@ describe("inspect report through the real database-config resolver", () => { const { layer, connection } = setupReport({ csvs: DEFAULT_RULE_CSVS, isLocal: false, - cliArgs: ["inspect", "report", "--project-ref", VALID_REF], + cliArgs: ["inspect", "report", `--remote=${VALID_REF}`], workdir: tempRoot.current, realLayers: true, }); @@ -706,7 +839,9 @@ describe("inspect report through the real database-config resolver", () => { return Effect.gen(function* () { const base = yield* tempDir("supabase-report-out-"); yield* stubManagementApi(requests, serveLinkedDatabase(VALID_REF)); - yield* inspectReport(flags({ outputDir: base, projectRef: Option.some(VALID_REF) })); + yield* inspectReport( + flags({ outputDir: base, remote: Option.some({ _tag: "Ref", ref: VALID_REF } as const) }), + ); expect(requests.map(({ method, path }) => `${method} ${path}`)).toEqual([ `GET /v1/projects/${VALID_REF}/config/database/pooler`, `POST /v1/projects/${VALID_REF}/cli/login-role`, @@ -716,17 +851,17 @@ describe("inspect report through the real database-config resolver", () => { expect(connection.copiedSql).toHaveLength(14); assertPermissionDrift({ command: "inspect report", - activeFlags: { "project-ref": VALID_REF, "output-dir": true }, + activeFlags: { remote: VALID_REF, "output-dir": true }, requests: asRecordedRequests(requests), exact: true, }); }).pipe(Effect.scoped, Effect.provide(layer)); }); - it.effect("makes no Management API request for --local", () => { + it.effect("makes no Management API request for the default local target", () => { const { layer, connection } = setupReport({ csvs: DEFAULT_RULE_CSVS, - cliArgs: ["inspect", "report", "--local"], + cliArgs: ["inspect", "report"], workdir: tempRoot.current, realLayers: true, }); @@ -734,14 +869,14 @@ describe("inspect report through the real database-config resolver", () => { return Effect.gen(function* () { const base = yield* tempDir("supabase-report-out-"); yield* stubManagementApi(requests, () => undefined); - yield* inspectReport(flags({ outputDir: base, local: true })); + yield* inspectReport(flags({ outputDir: base })); const { files } = yield* dateFolderContents(base); expect(files).toHaveLength(14); expect(connection.copiedSql).toHaveLength(14); expect(requests).toEqual([]); assertPermissionDrift({ command: "inspect report", - activeFlags: { local: true, "output-dir": true }, + activeFlags: { "output-dir": true }, requests: [], exact: true, }); From f8afa76fb73755e5dca3ea289d7fa5b46f3bd266 Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Mon, 19 Oct 2026 11:40:22 +0100 Subject: [PATCH 110/291] test(cli-e2e): target projects with --remote and confirm hosted mutations with --yes Hosted commands take --remote=<ref> last, hosted mutations pass --yes, and hosted db query reads pass --read-only. A new target-selection suite keeps one test per deprecated alias family and covers --remote=<ref>, --remote=<branch> and a rejected --remote=<url>. --- apps/cli-e2e/AGENTS.md | 4 +- .../interactions.json | 0 .../interactions.json | 0 .../interactions.json | 0 .../interactions.json | 0 .../interactions.json | 0 .../interactions.json | 0 .../interactions.json | 0 .../interactions.json | 3 +- .../interactions.json | 3 +- .../interactions.json | 0 .../interactions.json | 0 .../interactions.json | 69 ++++ .../interactions.json | 0 .../interactions.json | 27 ++ .../interactions.json | 67 ++++ .../interactions.json | 0 .../interactions.json | 1 + .../interactions.json | 69 ++++ .../interactions.json | 1 + .../interactions.json | 106 ++++++ .../interactions.json | 1 + .../interactions.json | 69 ++++ .../interactions.json | 1 + .../advanced-platform-features.e2e.test.ts | 321 +++++++++--------- apps/cli-e2e/src/tests/branches.e2e.test.ts | 168 ++++++--- .../cli-e2e/src/tests/config-push.e2e.test.ts | 20 +- .../cli-e2e/src/tests/config-toml.e2e.test.ts | 2 +- .../src/tests/database-core.e2e.test.ts | 84 +++-- apps/cli-e2e/src/tests/domains.e2e.test.ts | 59 ++-- apps/cli-e2e/src/tests/functions.e2e.test.ts | 66 ++-- apps/cli-e2e/src/tests/gen.e2e.test.ts | 23 +- apps/cli-e2e/src/tests/inspect.e2e.test.ts | 18 +- apps/cli-e2e/src/tests/link-project.ts | 17 + apps/cli-e2e/src/tests/migrations.e2e.test.ts | 30 +- .../src/tests/network-security.e2e.test.ts | 159 ++++----- apps/cli-e2e/src/tests/orgs.e2e.test.ts | 16 +- apps/cli-e2e/src/tests/projects.e2e.test.ts | 19 +- apps/cli-e2e/src/tests/secrets.e2e.test.ts | 66 ++-- apps/cli-e2e/src/tests/sso.e2e.test.ts | 126 ++++--- apps/cli-e2e/src/tests/storage.e2e.test.ts | 74 ++-- .../src/tests/target-selection.e2e.test.ts | 147 ++++++++ 42 files changed, 1214 insertions(+), 622 deletions(-) rename apps/cli-e2e/fixtures/scenarios/{db-dump-exits-non-zero-on-connection-refused-with-local => db-dump-exits-non-zero-on-connection-refused-without-remote}/interactions.json (100%) rename apps/cli-e2e/fixtures/scenarios/{db-lint-exits-non-zero-on-connection-refused-with-local => db-lint-exits-non-zero-on-connection-refused-without-remote}/interactions.json (100%) rename apps/cli-e2e/fixtures/scenarios/{db-pull-exits-non-zero-on-connection-refused-with-local => db-pull-exits-non-zero-on-connection-refused-without-remote}/interactions.json (100%) rename apps/cli-e2e/fixtures/scenarios/{db-push-local-exits-non-zero-on-connection-refused-with-local => db-push-local-exits-non-zero-on-connection-refused-without-remote}/interactions.json (100%) rename apps/cli-e2e/fixtures/scenarios/{db-push-linked-exits-non-zero-on-connection-refused-with-linked => db-push-remote-exits-non-zero-on-connection-refused-with-remote}/interactions.json (100%) rename apps/cli-e2e/fixtures/scenarios/{db-query-linked-exits-non-zero-on-401 => db-query-remote-exits-non-zero-on-401}/interactions.json (100%) rename apps/cli-e2e/fixtures/scenarios/{db-query-linked-exits-non-zero-on-403 => db-query-remote-exits-non-zero-on-403}/interactions.json (100%) rename apps/cli-e2e/fixtures/scenarios/{db-query-linked-returns-json-with-output-json => db-query-remote-returns-json-with-output-json}/interactions.json (97%) rename apps/cli-e2e/fixtures/scenarios/{db-query-linked-returns-select-1-result-in-table-format => db-query-remote-returns-select-1-result-in-table-format}/interactions.json (97%) rename apps/cli-e2e/fixtures/scenarios/{db-reset-exits-non-zero-on-connection-refused-with-local => db-reset-exits-non-zero-on-connection-refused-without-remote}/interactions.json (100%) rename apps/cli-e2e/fixtures/scenarios/{gen-types-exits-non-zero-with-no-data-source-specified => deprecated-target-aliases-warns-that-db-url-is-deprecated-and-still-targets-that-database}/interactions.json (100%) create mode 100644 apps/cli-e2e/fixtures/scenarios/deprecated-target-aliases-warns-that-linked-is-deprecated-and-still-targets-the-linked-project/interactions.json rename apps/cli-e2e/fixtures/scenarios/{inspect-flags-rejects-db-url-with-local => deprecated-target-aliases-warns-that-local-is-not-needed-and-still-targets-local}/interactions.json (100%) create mode 100644 apps/cli-e2e/fixtures/scenarios/deprecated-target-aliases-warns-that-project-id-is-deprecated-on-gen-types-and-still-targets-that-project/interactions.json create mode 100644 apps/cli-e2e/fixtures/scenarios/deprecated-target-aliases-warns-that-project-ref-is-deprecated-on-a-hosted-command-and-still-targets-that-project/interactions.json rename apps/cli-e2e/fixtures/scenarios/{test-db-exits-non-zero-on-connection-refused-with-local => gen-types-exits-non-zero-when-the-local-stack-is-not-running}/interactions.json (100%) create mode 100644 apps/cli-e2e/fixtures/scenarios/inspect-flags-rejects-remote-with-local/interactions.json create mode 100644 apps/cli-e2e/fixtures/scenarios/remote-target-selection-reads-a-space-separated-remote-ref-as-remote-ref/interactions.json create mode 100644 apps/cli-e2e/fixtures/scenarios/remote-target-selection-rejects-remote-url-on-a-hosted-command/interactions.json create mode 100644 apps/cli-e2e/fixtures/scenarios/remote-target-selection-resolves-remote-branch-on-a-hosted-command-through-the-linked-project-s-branches/interactions.json create mode 100644 apps/cli-e2e/fixtures/scenarios/remote-target-selection-runs-a-bare-db-push-locally-in-a-linked-checkout-and-hints-at-remote/interactions.json create mode 100644 apps/cli-e2e/fixtures/scenarios/remote-target-selection-targets-the-project-named-by-remote-ref/interactions.json create mode 100644 apps/cli-e2e/fixtures/scenarios/test-db-exits-non-zero-on-connection-refused-without-remote/interactions.json create mode 100644 apps/cli-e2e/src/tests/link-project.ts create mode 100644 apps/cli-e2e/src/tests/target-selection.e2e.test.ts diff --git a/apps/cli-e2e/AGENTS.md b/apps/cli-e2e/AGENTS.md index 88cb932497..7cd57a5f91 100644 --- a/apps/cli-e2e/AGENTS.md +++ b/apps/cli-e2e/AGENTS.md @@ -64,7 +64,7 @@ the running tests exercise. The implications: import { testBehaviour } from "./test-context.ts"; testBehaviour("does the thing", async ({ run, projectRef, orgId, apiUrl, workspace }) => { - const result = await run(["command", "--project-ref", projectRef]); + const result = await run(["command", `--remote=${projectRef}`]); expect(result.exitCode).toBe(0); expect(result.stdout).toContain("Expected output"); }); @@ -91,7 +91,7 @@ testBehaviour("exits non-zero on 401", async ({ run, apiUrl }) => { headers: { "Content-Type": "application/json" }, body: JSON.stringify({ status: 401, body: { message: "Invalid token" } }), }); - const result = await run(["command", "--project-ref", PROJECT_REF]); + const result = await run(["command", `--remote=${PROJECT_REF}`]); expect(result.exitCode).not.toBe(0); }); ``` diff --git a/apps/cli-e2e/fixtures/scenarios/db-dump-exits-non-zero-on-connection-refused-with-local/interactions.json b/apps/cli-e2e/fixtures/scenarios/db-dump-exits-non-zero-on-connection-refused-without-remote/interactions.json similarity index 100% rename from apps/cli-e2e/fixtures/scenarios/db-dump-exits-non-zero-on-connection-refused-with-local/interactions.json rename to apps/cli-e2e/fixtures/scenarios/db-dump-exits-non-zero-on-connection-refused-without-remote/interactions.json diff --git a/apps/cli-e2e/fixtures/scenarios/db-lint-exits-non-zero-on-connection-refused-with-local/interactions.json b/apps/cli-e2e/fixtures/scenarios/db-lint-exits-non-zero-on-connection-refused-without-remote/interactions.json similarity index 100% rename from apps/cli-e2e/fixtures/scenarios/db-lint-exits-non-zero-on-connection-refused-with-local/interactions.json rename to apps/cli-e2e/fixtures/scenarios/db-lint-exits-non-zero-on-connection-refused-without-remote/interactions.json diff --git a/apps/cli-e2e/fixtures/scenarios/db-pull-exits-non-zero-on-connection-refused-with-local/interactions.json b/apps/cli-e2e/fixtures/scenarios/db-pull-exits-non-zero-on-connection-refused-without-remote/interactions.json similarity index 100% rename from apps/cli-e2e/fixtures/scenarios/db-pull-exits-non-zero-on-connection-refused-with-local/interactions.json rename to apps/cli-e2e/fixtures/scenarios/db-pull-exits-non-zero-on-connection-refused-without-remote/interactions.json diff --git a/apps/cli-e2e/fixtures/scenarios/db-push-local-exits-non-zero-on-connection-refused-with-local/interactions.json b/apps/cli-e2e/fixtures/scenarios/db-push-local-exits-non-zero-on-connection-refused-without-remote/interactions.json similarity index 100% rename from apps/cli-e2e/fixtures/scenarios/db-push-local-exits-non-zero-on-connection-refused-with-local/interactions.json rename to apps/cli-e2e/fixtures/scenarios/db-push-local-exits-non-zero-on-connection-refused-without-remote/interactions.json diff --git a/apps/cli-e2e/fixtures/scenarios/db-push-linked-exits-non-zero-on-connection-refused-with-linked/interactions.json b/apps/cli-e2e/fixtures/scenarios/db-push-remote-exits-non-zero-on-connection-refused-with-remote/interactions.json similarity index 100% rename from apps/cli-e2e/fixtures/scenarios/db-push-linked-exits-non-zero-on-connection-refused-with-linked/interactions.json rename to apps/cli-e2e/fixtures/scenarios/db-push-remote-exits-non-zero-on-connection-refused-with-remote/interactions.json diff --git a/apps/cli-e2e/fixtures/scenarios/db-query-linked-exits-non-zero-on-401/interactions.json b/apps/cli-e2e/fixtures/scenarios/db-query-remote-exits-non-zero-on-401/interactions.json similarity index 100% rename from apps/cli-e2e/fixtures/scenarios/db-query-linked-exits-non-zero-on-401/interactions.json rename to apps/cli-e2e/fixtures/scenarios/db-query-remote-exits-non-zero-on-401/interactions.json diff --git a/apps/cli-e2e/fixtures/scenarios/db-query-linked-exits-non-zero-on-403/interactions.json b/apps/cli-e2e/fixtures/scenarios/db-query-remote-exits-non-zero-on-403/interactions.json similarity index 100% rename from apps/cli-e2e/fixtures/scenarios/db-query-linked-exits-non-zero-on-403/interactions.json rename to apps/cli-e2e/fixtures/scenarios/db-query-remote-exits-non-zero-on-403/interactions.json diff --git a/apps/cli-e2e/fixtures/scenarios/db-query-linked-returns-json-with-output-json/interactions.json b/apps/cli-e2e/fixtures/scenarios/db-query-remote-returns-json-with-output-json/interactions.json similarity index 97% rename from apps/cli-e2e/fixtures/scenarios/db-query-linked-returns-json-with-output-json/interactions.json rename to apps/cli-e2e/fixtures/scenarios/db-query-remote-returns-json-with-output-json/interactions.json index 4ce1bb15f0..820418ae8a 100644 --- a/apps/cli-e2e/fixtures/scenarios/db-query-linked-returns-json-with-output-json/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/db-query-remote-returns-json-with-output-json/interactions.json @@ -13,7 +13,8 @@ "user-agent": "SupabaseCLI/" }, "body": { - "query": "SELECT 1" + "query": "SELECT 1", + "read_only": true } }, "response": { diff --git a/apps/cli-e2e/fixtures/scenarios/db-query-linked-returns-select-1-result-in-table-format/interactions.json b/apps/cli-e2e/fixtures/scenarios/db-query-remote-returns-select-1-result-in-table-format/interactions.json similarity index 97% rename from apps/cli-e2e/fixtures/scenarios/db-query-linked-returns-select-1-result-in-table-format/interactions.json rename to apps/cli-e2e/fixtures/scenarios/db-query-remote-returns-select-1-result-in-table-format/interactions.json index 533edf3271..37938ca646 100644 --- a/apps/cli-e2e/fixtures/scenarios/db-query-linked-returns-select-1-result-in-table-format/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/db-query-remote-returns-select-1-result-in-table-format/interactions.json @@ -13,7 +13,8 @@ "user-agent": "SupabaseCLI/" }, "body": { - "query": "SELECT 1" + "query": "SELECT 1", + "read_only": true } }, "response": { diff --git a/apps/cli-e2e/fixtures/scenarios/db-reset-exits-non-zero-on-connection-refused-with-local/interactions.json b/apps/cli-e2e/fixtures/scenarios/db-reset-exits-non-zero-on-connection-refused-without-remote/interactions.json similarity index 100% rename from apps/cli-e2e/fixtures/scenarios/db-reset-exits-non-zero-on-connection-refused-with-local/interactions.json rename to apps/cli-e2e/fixtures/scenarios/db-reset-exits-non-zero-on-connection-refused-without-remote/interactions.json diff --git a/apps/cli-e2e/fixtures/scenarios/gen-types-exits-non-zero-with-no-data-source-specified/interactions.json b/apps/cli-e2e/fixtures/scenarios/deprecated-target-aliases-warns-that-db-url-is-deprecated-and-still-targets-that-database/interactions.json similarity index 100% rename from apps/cli-e2e/fixtures/scenarios/gen-types-exits-non-zero-with-no-data-source-specified/interactions.json rename to apps/cli-e2e/fixtures/scenarios/deprecated-target-aliases-warns-that-db-url-is-deprecated-and-still-targets-that-database/interactions.json diff --git a/apps/cli-e2e/fixtures/scenarios/deprecated-target-aliases-warns-that-linked-is-deprecated-and-still-targets-the-linked-project/interactions.json b/apps/cli-e2e/fixtures/scenarios/deprecated-target-aliases-warns-that-linked-is-deprecated-and-still-targets-the-linked-project/interactions.json new file mode 100644 index 0000000000..0790d147ea --- /dev/null +++ b/apps/cli-e2e/fixtures/scenarios/deprecated-target-aliases-warns-that-linked-is-deprecated-and-still-targets-the-linked-project/interactions.json @@ -0,0 +1,69 @@ +[ + { + "request": { + "method": "GET", + "path": "/v1/projects/replayfixtureprojref/advisors/security", + "query": {}, + "headers": { + "accept-encoding": "gzip", + "authorization": "Bearer __ACCESS_TOKEN__", + "host": "localhost:__PORT__", + "user-agent": "SupabaseCLI/" + }, + "body": null + }, + "response": { + "status": 200, + "headers": { + "content-type": "application/json; charset=utf-8", + "x-gotrue-id": "__UUID__", + "x-ratelimit-limit": "120", + "x-ratelimit-remaining": "119", + "x-ratelimit-reset": "60" + }, + "body": { + "lints": [] + } + } + }, + { + "request": { + "method": "GET", + "path": "/v1/projects/replayfixtureprojref", + "query": {}, + "headers": { + "accept-encoding": "gzip", + "authorization": "Bearer __ACCESS_TOKEN__", + "host": "localhost:__PORT__", + "user-agent": "SupabaseCLI/" + }, + "body": null + }, + "response": { + "status": 200, + "headers": { + "content-type": "application/json; charset=utf-8", + "x-gotrue-id": "__UUID__", + "x-ratelimit-limit": "120", + "x-ratelimit-remaining": "116", + "x-ratelimit-reset": "54" + }, + "body": { + "id": "replayfixtureprojref", + "ref": "replayfixtureprojref", + "organization_id": "replayfixtureprojref", + "organization_slug": "replayfixtureprojref", + "name": "cli-e2e-test", + "region": "us-east-1", + "status": "ACTIVE_HEALTHY", + "database": { + "host": "db.replayfixtureprojref.supabase.red", + "version": "17.6.1.111", + "postgres_engine": "17", + "release_channel": "ga" + }, + "created_at": "2000-01-01T00:00:00Z" + } + } + } +] diff --git a/apps/cli-e2e/fixtures/scenarios/inspect-flags-rejects-db-url-with-local/interactions.json b/apps/cli-e2e/fixtures/scenarios/deprecated-target-aliases-warns-that-local-is-not-needed-and-still-targets-local/interactions.json similarity index 100% rename from apps/cli-e2e/fixtures/scenarios/inspect-flags-rejects-db-url-with-local/interactions.json rename to apps/cli-e2e/fixtures/scenarios/deprecated-target-aliases-warns-that-local-is-not-needed-and-still-targets-local/interactions.json diff --git a/apps/cli-e2e/fixtures/scenarios/deprecated-target-aliases-warns-that-project-id-is-deprecated-on-gen-types-and-still-targets-that-project/interactions.json b/apps/cli-e2e/fixtures/scenarios/deprecated-target-aliases-warns-that-project-id-is-deprecated-on-gen-types-and-still-targets-that-project/interactions.json new file mode 100644 index 0000000000..513d2118ed --- /dev/null +++ b/apps/cli-e2e/fixtures/scenarios/deprecated-target-aliases-warns-that-project-id-is-deprecated-on-gen-types-and-still-targets-that-project/interactions.json @@ -0,0 +1,27 @@ +[ + { + "request": { + "method": "GET", + "path": "/v1/projects/replayfixtureprojref/types/typescript", + "query": { + "included_schemas": "public" + }, + "headers": { + "accept-encoding": "gzip", + "authorization": "Bearer __ACCESS_TOKEN__", + "host": "localhost:__PORT__", + "user-agent": "SupabaseCLI/<VERSION>" + }, + "body": null + }, + "response": { + "status": 200, + "headers": { + "content-type": "application/json; charset=utf-8" + }, + "body": { + "types": "export type Json =\n | string\n | number\n | boolean\n | null\n | { [key: string]: Json | undefined }\n | Json[]\n\nexport type Database = {\n public: {\n Tables: {\n [_ in never]: never\n }\n Views: {\n [_ in never]: never\n }\n Functions: {\n [_ in never]: never\n }\n Enums: {\n [_ in never]: never\n }\n CompositeTypes: {\n [_ in never]: never\n }\n }\n}\n" + } + } + } +] diff --git a/apps/cli-e2e/fixtures/scenarios/deprecated-target-aliases-warns-that-project-ref-is-deprecated-on-a-hosted-command-and-still-targets-that-project/interactions.json b/apps/cli-e2e/fixtures/scenarios/deprecated-target-aliases-warns-that-project-ref-is-deprecated-on-a-hosted-command-and-still-targets-that-project/interactions.json new file mode 100644 index 0000000000..43e0e51f84 --- /dev/null +++ b/apps/cli-e2e/fixtures/scenarios/deprecated-target-aliases-warns-that-project-ref-is-deprecated-on-a-hosted-command-and-still-targets-that-project/interactions.json @@ -0,0 +1,67 @@ +[ + { + "request": { + "method": "GET", + "path": "/v1/projects/replayfixtureprojref/secrets", + "query": {}, + "headers": { + "accept-encoding": "gzip", + "authorization": "Bearer __ACCESS_TOKEN__", + "host": "localhost:__PORT__", + "user-agent": "SupabaseCLI/" + }, + "body": null + }, + "response": { + "status": 200, + "headers": { + "content-type": "application/json; charset=utf-8", + "x-gotrue-id": "__UUID__", + "x-ratelimit-limit": "120", + "x-ratelimit-remaining": "119", + "x-ratelimit-reset": "60" + }, + "body": [] + } + }, + { + "request": { + "method": "GET", + "path": "/v1/projects/replayfixtureprojref", + "query": {}, + "headers": { + "accept-encoding": "gzip", + "authorization": "Bearer __ACCESS_TOKEN__", + "host": "localhost:__PORT__", + "user-agent": "SupabaseCLI/" + }, + "body": null + }, + "response": { + "status": 200, + "headers": { + "content-type": "application/json; charset=utf-8", + "x-gotrue-id": "__UUID__", + "x-ratelimit-limit": "120", + "x-ratelimit-remaining": "119", + "x-ratelimit-reset": "60" + }, + "body": { + "id": "replayfixtureprojref", + "ref": "replayfixtureprojref", + "organization_id": "replayfixtureprojref", + "organization_slug": "replayfixtureprojref", + "name": "cli-e2e-test", + "region": "us-east-1", + "status": "ACTIVE_HEALTHY", + "database": { + "host": "db.replayfixtureprojref.supabase.red", + "version": "17.6.1.111", + "postgres_engine": "17", + "release_channel": "ga" + }, + "created_at": "2000-01-01T00:00:00Z" + } + } + } +] diff --git a/apps/cli-e2e/fixtures/scenarios/test-db-exits-non-zero-on-connection-refused-with-local/interactions.json b/apps/cli-e2e/fixtures/scenarios/gen-types-exits-non-zero-when-the-local-stack-is-not-running/interactions.json similarity index 100% rename from apps/cli-e2e/fixtures/scenarios/test-db-exits-non-zero-on-connection-refused-with-local/interactions.json rename to apps/cli-e2e/fixtures/scenarios/gen-types-exits-non-zero-when-the-local-stack-is-not-running/interactions.json diff --git a/apps/cli-e2e/fixtures/scenarios/inspect-flags-rejects-remote-with-local/interactions.json b/apps/cli-e2e/fixtures/scenarios/inspect-flags-rejects-remote-with-local/interactions.json new file mode 100644 index 0000000000..fe51488c70 --- /dev/null +++ b/apps/cli-e2e/fixtures/scenarios/inspect-flags-rejects-remote-with-local/interactions.json @@ -0,0 +1 @@ +[] diff --git a/apps/cli-e2e/fixtures/scenarios/remote-target-selection-reads-a-space-separated-remote-ref-as-remote-ref/interactions.json b/apps/cli-e2e/fixtures/scenarios/remote-target-selection-reads-a-space-separated-remote-ref-as-remote-ref/interactions.json new file mode 100644 index 0000000000..0790d147ea --- /dev/null +++ b/apps/cli-e2e/fixtures/scenarios/remote-target-selection-reads-a-space-separated-remote-ref-as-remote-ref/interactions.json @@ -0,0 +1,69 @@ +[ + { + "request": { + "method": "GET", + "path": "/v1/projects/replayfixtureprojref/advisors/security", + "query": {}, + "headers": { + "accept-encoding": "gzip", + "authorization": "Bearer __ACCESS_TOKEN__", + "host": "localhost:__PORT__", + "user-agent": "SupabaseCLI/" + }, + "body": null + }, + "response": { + "status": 200, + "headers": { + "content-type": "application/json; charset=utf-8", + "x-gotrue-id": "__UUID__", + "x-ratelimit-limit": "120", + "x-ratelimit-remaining": "119", + "x-ratelimit-reset": "60" + }, + "body": { + "lints": [] + } + } + }, + { + "request": { + "method": "GET", + "path": "/v1/projects/replayfixtureprojref", + "query": {}, + "headers": { + "accept-encoding": "gzip", + "authorization": "Bearer __ACCESS_TOKEN__", + "host": "localhost:__PORT__", + "user-agent": "SupabaseCLI/" + }, + "body": null + }, + "response": { + "status": 200, + "headers": { + "content-type": "application/json; charset=utf-8", + "x-gotrue-id": "__UUID__", + "x-ratelimit-limit": "120", + "x-ratelimit-remaining": "116", + "x-ratelimit-reset": "54" + }, + "body": { + "id": "replayfixtureprojref", + "ref": "replayfixtureprojref", + "organization_id": "replayfixtureprojref", + "organization_slug": "replayfixtureprojref", + "name": "cli-e2e-test", + "region": "us-east-1", + "status": "ACTIVE_HEALTHY", + "database": { + "host": "db.replayfixtureprojref.supabase.red", + "version": "17.6.1.111", + "postgres_engine": "17", + "release_channel": "ga" + }, + "created_at": "2000-01-01T00:00:00Z" + } + } + } +] diff --git a/apps/cli-e2e/fixtures/scenarios/remote-target-selection-rejects-remote-url-on-a-hosted-command/interactions.json b/apps/cli-e2e/fixtures/scenarios/remote-target-selection-rejects-remote-url-on-a-hosted-command/interactions.json new file mode 100644 index 0000000000..fe51488c70 --- /dev/null +++ b/apps/cli-e2e/fixtures/scenarios/remote-target-selection-rejects-remote-url-on-a-hosted-command/interactions.json @@ -0,0 +1 @@ +[] diff --git a/apps/cli-e2e/fixtures/scenarios/remote-target-selection-resolves-remote-branch-on-a-hosted-command-through-the-linked-project-s-branches/interactions.json b/apps/cli-e2e/fixtures/scenarios/remote-target-selection-resolves-remote-branch-on-a-hosted-command-through-the-linked-project-s-branches/interactions.json new file mode 100644 index 0000000000..c5e702b158 --- /dev/null +++ b/apps/cli-e2e/fixtures/scenarios/remote-target-selection-resolves-remote-branch-on-a-hosted-command-through-the-linked-project-s-branches/interactions.json @@ -0,0 +1,106 @@ +[ + { + "request": { + "method": "GET", + "path": "/v1/projects/replayfixtureprojref/branches", + "query": {}, + "headers": { + "accept-encoding": "gzip", + "authorization": "Bearer __ACCESS_TOKEN__", + "host": "localhost:__PORT__", + "user-agent": "SupabaseCLI/" + }, + "body": null + }, + "response": { + "status": 200, + "headers": { + "content-type": "application/json; charset=utf-8", + "x-gotrue-id": "__UUID__", + "x-ratelimit-limit": "120", + "x-ratelimit-remaining": "119", + "x-ratelimit-reset": "60" + }, + "body": [ + { + "id": "00000000-0000-0000-0000-000000000000", + "name": "my-branch", + "project_ref": "bbbbbbbbbbbbbbbbbbbb", + "parent_project_ref": "replayfixtureprojref", + "is_default": false, + "git_branch": "main", + "persistent": false, + "status": "MIGRATIONS_PASSED", + "created_at": "2000-01-01T00:00:00Z", + "updated_at": "2000-01-01T00:00:00Z", + "with_data": false + } + ] + } + }, + { + "request": { + "method": "GET", + "path": "/v1/projects/replayfixtureprojref/secrets", + "query": {}, + "headers": { + "accept-encoding": "gzip", + "authorization": "Bearer __ACCESS_TOKEN__", + "host": "localhost:__PORT__", + "user-agent": "SupabaseCLI/" + }, + "body": null + }, + "response": { + "status": 200, + "headers": { + "content-type": "application/json; charset=utf-8", + "x-gotrue-id": "__UUID__", + "x-ratelimit-limit": "120", + "x-ratelimit-remaining": "119", + "x-ratelimit-reset": "60" + }, + "body": [] + } + }, + { + "request": { + "method": "GET", + "path": "/v1/projects/replayfixtureprojref", + "query": {}, + "headers": { + "accept-encoding": "gzip", + "authorization": "Bearer __ACCESS_TOKEN__", + "host": "localhost:__PORT__", + "user-agent": "SupabaseCLI/" + }, + "body": null + }, + "response": { + "status": 200, + "headers": { + "content-type": "application/json; charset=utf-8", + "x-gotrue-id": "__UUID__", + "x-ratelimit-limit": "120", + "x-ratelimit-remaining": "119", + "x-ratelimit-reset": "60" + }, + "body": { + "id": "replayfixtureprojref", + "ref": "replayfixtureprojref", + "organization_id": "replayfixtureprojref", + "organization_slug": "replayfixtureprojref", + "name": "cli-e2e-test", + "region": "us-east-1", + "status": "ACTIVE_HEALTHY", + "database": { + "host": "db.replayfixtureprojref.supabase.red", + "version": "17.6.1.111", + "postgres_engine": "17", + "release_channel": "ga" + }, + "created_at": "2000-01-01T00:00:00Z" + } + } + } +] diff --git a/apps/cli-e2e/fixtures/scenarios/remote-target-selection-runs-a-bare-db-push-locally-in-a-linked-checkout-and-hints-at-remote/interactions.json b/apps/cli-e2e/fixtures/scenarios/remote-target-selection-runs-a-bare-db-push-locally-in-a-linked-checkout-and-hints-at-remote/interactions.json new file mode 100644 index 0000000000..fe51488c70 --- /dev/null +++ b/apps/cli-e2e/fixtures/scenarios/remote-target-selection-runs-a-bare-db-push-locally-in-a-linked-checkout-and-hints-at-remote/interactions.json @@ -0,0 +1 @@ +[] diff --git a/apps/cli-e2e/fixtures/scenarios/remote-target-selection-targets-the-project-named-by-remote-ref/interactions.json b/apps/cli-e2e/fixtures/scenarios/remote-target-selection-targets-the-project-named-by-remote-ref/interactions.json new file mode 100644 index 0000000000..0790d147ea --- /dev/null +++ b/apps/cli-e2e/fixtures/scenarios/remote-target-selection-targets-the-project-named-by-remote-ref/interactions.json @@ -0,0 +1,69 @@ +[ + { + "request": { + "method": "GET", + "path": "/v1/projects/replayfixtureprojref/advisors/security", + "query": {}, + "headers": { + "accept-encoding": "gzip", + "authorization": "Bearer __ACCESS_TOKEN__", + "host": "localhost:__PORT__", + "user-agent": "SupabaseCLI/" + }, + "body": null + }, + "response": { + "status": 200, + "headers": { + "content-type": "application/json; charset=utf-8", + "x-gotrue-id": "__UUID__", + "x-ratelimit-limit": "120", + "x-ratelimit-remaining": "119", + "x-ratelimit-reset": "60" + }, + "body": { + "lints": [] + } + } + }, + { + "request": { + "method": "GET", + "path": "/v1/projects/replayfixtureprojref", + "query": {}, + "headers": { + "accept-encoding": "gzip", + "authorization": "Bearer __ACCESS_TOKEN__", + "host": "localhost:__PORT__", + "user-agent": "SupabaseCLI/" + }, + "body": null + }, + "response": { + "status": 200, + "headers": { + "content-type": "application/json; charset=utf-8", + "x-gotrue-id": "__UUID__", + "x-ratelimit-limit": "120", + "x-ratelimit-remaining": "116", + "x-ratelimit-reset": "54" + }, + "body": { + "id": "replayfixtureprojref", + "ref": "replayfixtureprojref", + "organization_id": "replayfixtureprojref", + "organization_slug": "replayfixtureprojref", + "name": "cli-e2e-test", + "region": "us-east-1", + "status": "ACTIVE_HEALTHY", + "database": { + "host": "db.replayfixtureprojref.supabase.red", + "version": "17.6.1.111", + "postgres_engine": "17", + "release_channel": "ga" + }, + "created_at": "2000-01-01T00:00:00Z" + } + } + } +] diff --git a/apps/cli-e2e/fixtures/scenarios/test-db-exits-non-zero-on-connection-refused-without-remote/interactions.json b/apps/cli-e2e/fixtures/scenarios/test-db-exits-non-zero-on-connection-refused-without-remote/interactions.json new file mode 100644 index 0000000000..fe51488c70 --- /dev/null +++ b/apps/cli-e2e/fixtures/scenarios/test-db-exits-non-zero-on-connection-refused-without-remote/interactions.json @@ -0,0 +1 @@ +[] diff --git a/apps/cli-e2e/src/tests/advanced-platform-features.e2e.test.ts b/apps/cli-e2e/src/tests/advanced-platform-features.e2e.test.ts index 052284a2f0..b3a9e76493 100644 --- a/apps/cli-e2e/src/tests/advanced-platform-features.e2e.test.ts +++ b/apps/cli-e2e/src/tests/advanced-platform-features.e2e.test.ts @@ -9,8 +9,7 @@ describe("postgres-config", () => { "postgres-config", "get", "--experimental", - "--project-ref", - projectRef, + `--remote=${projectRef}`, ]); expect(result.exitCode).toBe(0); expect(result.stdout).not.toBe(""); @@ -25,8 +24,7 @@ describe("postgres-config", () => { "--experimental", "--output", "json", - "--project-ref", - projectRef, + `--remote=${projectRef}`, ]); expect(result.exitCode).toBe(0); const parsed = JSON.parse(result.stdout); @@ -41,8 +39,7 @@ describe("postgres-config", () => { "get", "--experimental", "--debug", - "--project-ref", - projectRef, + `--remote=${projectRef}`, ]); expect(result.exitCode).toBe(0); expect(result.stderr).toMatch(/HTTP.*GET:/); @@ -58,8 +55,7 @@ describe("postgres-config", () => { "postgres-config", "get", "--experimental", - "--project-ref", - PROJECT_REF, + `--remote=${PROJECT_REF}`, ]); expect(result.exitCode).not.toBe(0); expect(result.stderr).toContain("Invalid token"); @@ -75,8 +71,7 @@ describe("postgres-config", () => { "postgres-config", "get", "--experimental", - "--project-ref", - PROJECT_REF, + `--remote=${PROJECT_REF}`, ]); expect(result.exitCode).not.toBe(0); expect(result.stderr).toContain("Forbidden"); @@ -92,8 +87,7 @@ describe("postgres-config", () => { "postgres-config", "get", "--experimental", - "--project-ref", - PROJECT_REF, + `--remote=${PROJECT_REF}`, ]); expect(result.exitCode).not.toBe(0); expect(result.stderr).toContain("Project not found"); @@ -109,8 +103,7 @@ describe("postgres-config", () => { "postgres-config", "get", "--experimental", - "--project-ref", - PROJECT_REF, + `--remote=${PROJECT_REF}`, ]); expect(result.exitCode).not.toBe(0); expect(result.stderr).toContain("Too Many Requests"); @@ -126,8 +119,7 @@ describe("postgres-config", () => { "postgres-config", "get", "--experimental", - "--project-ref", - PROJECT_REF, + `--remote=${PROJECT_REF}`, ]); expect(result.exitCode).not.toBe(0); expect(result.stderr).toContain("Internal Server Error"); @@ -142,8 +134,8 @@ describe("postgres-config", () => { "--experimental", "--config", "max_connections=200", - "--project-ref", - projectRef, + "--yes", + `--remote=${projectRef}`, ]); expect(result.exitCode).toBe(0); }); @@ -157,8 +149,8 @@ describe("postgres-config", () => { "max_connections=200", "--config", "shared_buffers=256MB", - "--project-ref", - projectRef, + "--yes", + `--remote=${projectRef}`, ]); expect(result.exitCode).toBe(0); }); @@ -173,8 +165,8 @@ describe("postgres-config", () => { "--config", "max_connections=200", "--replace-existing-overrides", - "--project-ref", - projectRef, + "--yes", + `--remote=${projectRef}`, ]); expect(result.exitCode).toBe(0); }, @@ -190,8 +182,8 @@ describe("postgres-config", () => { "--config", "max_connections=200", "--no-restart", - "--project-ref", - projectRef, + "--yes", + `--remote=${projectRef}`, ]); expect(result.exitCode).toBe(0); }, @@ -209,8 +201,8 @@ describe("postgres-config", () => { "--experimental", "--config", "max_connections=200", - "--project-ref", - PROJECT_REF, + "--yes", + `--remote=${PROJECT_REF}`, ]); expect(result.exitCode).not.toBe(0); expect(result.stderr).toContain("Invalid token"); @@ -228,8 +220,8 @@ describe("postgres-config", () => { "--experimental", "--config", "max_connections=200", - "--project-ref", - PROJECT_REF, + "--yes", + `--remote=${PROJECT_REF}`, ]); expect(result.exitCode).not.toBe(0); expect(result.stderr).toContain("Project not found"); @@ -250,8 +242,8 @@ describe("postgres-config", () => { "--experimental", "--config", "invalid_key=value", - "--project-ref", - PROJECT_REF, + "--yes", + `--remote=${PROJECT_REF}`, ]); expect(result.exitCode).not.toBe(0); expect(result.stderr).toContain("unrecognized config key"); @@ -269,8 +261,8 @@ describe("postgres-config", () => { "--experimental", "--config", "max_connections=200", - "--project-ref", - PROJECT_REF, + "--yes", + `--remote=${PROJECT_REF}`, ]); expect(result.exitCode).not.toBe(0); expect(result.stderr).toContain("Too Many Requests"); @@ -288,8 +280,8 @@ describe("postgres-config", () => { "--experimental", "--config", "max_connections=200", - "--project-ref", - PROJECT_REF, + "--yes", + `--remote=${PROJECT_REF}`, ]); expect(result.exitCode).not.toBe(0); expect(result.stderr).toContain("Internal Server Error"); @@ -304,8 +296,8 @@ describe("postgres-config", () => { "--experimental", "--config", "max_connections", - "--project-ref", - projectRef, + "--yes", + `--remote=${projectRef}`, ]); expect(result.exitCode).toBe(0); }); @@ -319,8 +311,8 @@ describe("postgres-config", () => { "max_connections", "--config", "shared_buffers", - "--project-ref", - projectRef, + "--yes", + `--remote=${projectRef}`, ]); expect(result.exitCode).toBe(0); }); @@ -335,8 +327,8 @@ describe("postgres-config", () => { "--config", "max_connections", "--no-restart", - "--project-ref", - projectRef, + "--yes", + `--remote=${projectRef}`, ]); expect(result.exitCode).toBe(0); }, @@ -354,8 +346,8 @@ describe("postgres-config", () => { "--experimental", "--config", "max_connections", - "--project-ref", - PROJECT_REF, + "--yes", + `--remote=${PROJECT_REF}`, ]); expect(result.exitCode).not.toBe(0); expect(result.stderr).toContain("Invalid token"); @@ -373,8 +365,8 @@ describe("postgres-config", () => { "--experimental", "--config", "max_connections", - "--project-ref", - PROJECT_REF, + "--yes", + `--remote=${PROJECT_REF}`, ]); expect(result.exitCode).not.toBe(0); expect(result.stderr).toContain("Project not found"); @@ -392,8 +384,8 @@ describe("postgres-config", () => { "--experimental", "--config", "max_connections", - "--project-ref", - PROJECT_REF, + "--yes", + `--remote=${PROJECT_REF}`, ]); expect(result.exitCode).not.toBe(0); expect(result.stderr).toContain("Too Many Requests"); @@ -411,8 +403,8 @@ describe("postgres-config", () => { "--experimental", "--config", "max_connections", - "--project-ref", - PROJECT_REF, + "--yes", + `--remote=${PROJECT_REF}`, ]); expect(result.exitCode).not.toBe(0); expect(result.stderr).toContain("Internal Server Error"); @@ -427,8 +419,7 @@ describe("vanity-subdomains", () => { "vanity-subdomains", "get", "--experimental", - "--project-ref", - projectRef, + `--remote=${projectRef}`, ]); expect(result.exitCode).toBe(0); expect(result.stdout).not.toBe(""); @@ -443,8 +434,7 @@ describe("vanity-subdomains", () => { "--experimental", "--output", "json", - "--project-ref", - projectRef, + `--remote=${projectRef}`, ]); expect(result.exitCode).toBe(0); const parsed = JSON.parse(result.stdout); @@ -458,8 +448,7 @@ describe("vanity-subdomains", () => { "get", "--experimental", "--debug", - "--project-ref", - projectRef, + `--remote=${projectRef}`, ]); expect(result.exitCode).toBe(0); expect(result.stderr).toMatch(/HTTP.*GET:/); @@ -475,8 +464,7 @@ describe("vanity-subdomains", () => { "vanity-subdomains", "get", "--experimental", - "--project-ref", - PROJECT_REF, + `--remote=${PROJECT_REF}`, ]); expect(result.exitCode).not.toBe(0); expect(result.stderr).toContain("Invalid token"); @@ -492,8 +480,7 @@ describe("vanity-subdomains", () => { "vanity-subdomains", "get", "--experimental", - "--project-ref", - PROJECT_REF, + `--remote=${PROJECT_REF}`, ]); expect(result.exitCode).not.toBe(0); expect(result.stderr).toContain("Forbidden"); @@ -509,8 +496,7 @@ describe("vanity-subdomains", () => { "vanity-subdomains", "get", "--experimental", - "--project-ref", - PROJECT_REF, + `--remote=${PROJECT_REF}`, ]); expect(result.exitCode).not.toBe(0); expect(result.stderr).toContain("Project not found"); @@ -526,8 +512,7 @@ describe("vanity-subdomains", () => { "vanity-subdomains", "get", "--experimental", - "--project-ref", - PROJECT_REF, + `--remote=${PROJECT_REF}`, ]); expect(result.exitCode).not.toBe(0); expect(result.stderr).toContain("Too Many Requests"); @@ -543,8 +528,7 @@ describe("vanity-subdomains", () => { "vanity-subdomains", "get", "--experimental", - "--project-ref", - PROJECT_REF, + `--remote=${PROJECT_REF}`, ]); expect(result.exitCode).not.toBe(0); expect(result.stderr).toContain("Internal Server Error"); @@ -559,8 +543,7 @@ describe("vanity-subdomains", () => { "--experimental", "--desired-subdomain", "myapp", - "--project-ref", - projectRef, + `--remote=${projectRef}`, ]); expect(result.exitCode).toBe(0); expect(result.stdout).not.toBe(""); @@ -577,8 +560,7 @@ describe("vanity-subdomains", () => { "myapp", "--output", "json", - "--project-ref", - projectRef, + `--remote=${projectRef}`, ]); expect(result.exitCode).toBe(0); const parsed = JSON.parse(result.stdout); @@ -591,8 +573,7 @@ describe("vanity-subdomains", () => { "vanity-subdomains", "check-availability", "--experimental", - "--project-ref", - PROJECT_REF, + `--remote=${PROJECT_REF}`, ]); expect(result.exitCode).not.toBe(0); }); @@ -612,8 +593,7 @@ describe("vanity-subdomains", () => { "--experimental", "--desired-subdomain", "taken", - "--project-ref", - PROJECT_REF, + `--remote=${PROJECT_REF}`, ]); expect(result.exitCode).not.toBe(0); expect(result.stderr).toContain("already taken"); @@ -631,8 +611,7 @@ describe("vanity-subdomains", () => { "--experimental", "--desired-subdomain", "myapp", - "--project-ref", - PROJECT_REF, + `--remote=${PROJECT_REF}`, ]); expect(result.exitCode).not.toBe(0); expect(result.stderr).toContain("Invalid token"); @@ -650,8 +629,7 @@ describe("vanity-subdomains", () => { "--experimental", "--desired-subdomain", "myapp", - "--project-ref", - PROJECT_REF, + `--remote=${PROJECT_REF}`, ]); expect(result.exitCode).not.toBe(0); expect(result.stderr).toContain("Project not found"); @@ -669,8 +647,7 @@ describe("vanity-subdomains", () => { "--experimental", "--desired-subdomain", "myapp", - "--project-ref", - PROJECT_REF, + `--remote=${PROJECT_REF}`, ]); expect(result.exitCode).not.toBe(0); expect(result.stderr).toContain("Too Many Requests"); @@ -685,8 +662,8 @@ describe("vanity-subdomains", () => { "--experimental", "--desired-subdomain", "myapp", - "--project-ref", - projectRef, + "--yes", + `--remote=${projectRef}`, ]); expect(result.exitCode).toBe(0); expect(result.stdout).toContain("Activated vanity subdomain"); @@ -707,8 +684,8 @@ describe("vanity-subdomains", () => { "--experimental", "--desired-subdomain", "taken", - "--project-ref", - PROJECT_REF, + "--yes", + `--remote=${PROJECT_REF}`, ]); expect(result.exitCode).not.toBe(0); expect(result.stderr).toContain("already taken"); @@ -726,8 +703,8 @@ describe("vanity-subdomains", () => { "--experimental", "--desired-subdomain", "myapp", - "--project-ref", - PROJECT_REF, + "--yes", + `--remote=${PROJECT_REF}`, ]); expect(result.exitCode).not.toBe(0); expect(result.stderr).toContain("Invalid token"); @@ -745,8 +722,8 @@ describe("vanity-subdomains", () => { "--experimental", "--desired-subdomain", "myapp", - "--project-ref", - PROJECT_REF, + "--yes", + `--remote=${PROJECT_REF}`, ]); expect(result.exitCode).not.toBe(0); expect(result.stderr).toContain("Project not found"); @@ -764,8 +741,8 @@ describe("vanity-subdomains", () => { "--experimental", "--desired-subdomain", "myapp", - "--project-ref", - PROJECT_REF, + "--yes", + `--remote=${PROJECT_REF}`, ]); expect(result.exitCode).not.toBe(0); expect(result.stderr).toContain("Too Many Requests"); @@ -783,8 +760,8 @@ describe("vanity-subdomains", () => { "--experimental", "--desired-subdomain", "myapp", - "--project-ref", - PROJECT_REF, + "--yes", + `--remote=${PROJECT_REF}`, ]); expect(result.exitCode).not.toBe(0); expect(result.stderr).toContain("Internal Server Error"); @@ -797,8 +774,8 @@ describe("vanity-subdomains", () => { "vanity-subdomains", "delete", "--experimental", - "--project-ref", - projectRef, + "--yes", + `--remote=${projectRef}`, ]); expect(result.exitCode).toBe(0); expect(result.stderr).toContain("Deleted vanity subdomain"); @@ -814,8 +791,8 @@ describe("vanity-subdomains", () => { "vanity-subdomains", "delete", "--experimental", - "--project-ref", - PROJECT_REF, + "--yes", + `--remote=${PROJECT_REF}`, ]); expect(result.exitCode).not.toBe(0); expect(result.stderr).toContain("Invalid token"); @@ -831,8 +808,8 @@ describe("vanity-subdomains", () => { "vanity-subdomains", "delete", "--experimental", - "--project-ref", - PROJECT_REF, + "--yes", + `--remote=${PROJECT_REF}`, ]); expect(result.exitCode).not.toBe(0); expect(result.stderr).toContain("Project not found"); @@ -848,8 +825,8 @@ describe("vanity-subdomains", () => { "vanity-subdomains", "delete", "--experimental", - "--project-ref", - PROJECT_REF, + "--yes", + `--remote=${PROJECT_REF}`, ]); expect(result.exitCode).not.toBe(0); expect(result.stderr).toContain("Too Many Requests"); @@ -865,8 +842,8 @@ describe("vanity-subdomains", () => { "vanity-subdomains", "delete", "--experimental", - "--project-ref", - PROJECT_REF, + "--yes", + `--remote=${PROJECT_REF}`, ]); expect(result.exitCode).not.toBe(0); expect(result.stderr).toContain("Internal Server Error"); @@ -877,7 +854,7 @@ describe("vanity-subdomains", () => { describe("encryption", () => { describe("encryption:get-root-key", () => { testBehaviour("renders root encryption key", async ({ run, projectRef }) => { - const result = await run(["encryption", "get-root-key", "--project-ref", projectRef]); + const result = await run(["encryption", "get-root-key", `--remote=${projectRef}`]); expect(result.exitCode).toBe(0); expect(result.stdout).not.toBe(""); }); @@ -888,20 +865,13 @@ describe("encryption", () => { "get-root-key", "--output", "json", - "--project-ref", - projectRef, + `--remote=${projectRef}`, ]); expect(result.exitCode).toBe(0); }); testBehaviour("--debug shows HTTP trace", async ({ run, projectRef }) => { - const result = await run([ - "encryption", - "get-root-key", - "--debug", - "--project-ref", - projectRef, - ]); + const result = await run(["encryption", "get-root-key", "--debug", `--remote=${projectRef}`]); expect(result.exitCode).toBe(0); expect(result.stderr).toMatch(/HTTP.*GET:/); }); @@ -912,7 +882,7 @@ describe("encryption", () => { headers: { "Content-Type": "application/json" }, body: JSON.stringify({ status: 401, body: { message: "Invalid token" } }), }); - const result = await run(["encryption", "get-root-key", "--project-ref", PROJECT_REF]); + const result = await run(["encryption", "get-root-key", `--remote=${PROJECT_REF}`]); expect(result.exitCode).not.toBe(0); expect(result.stderr).toContain("Invalid token"); }); @@ -923,7 +893,7 @@ describe("encryption", () => { headers: { "Content-Type": "application/json" }, body: JSON.stringify({ status: 403, body: { message: "Forbidden" } }), }); - const result = await run(["encryption", "get-root-key", "--project-ref", PROJECT_REF]); + const result = await run(["encryption", "get-root-key", `--remote=${PROJECT_REF}`]); expect(result.exitCode).not.toBe(0); expect(result.stderr).toContain("Forbidden"); }); @@ -934,7 +904,7 @@ describe("encryption", () => { headers: { "Content-Type": "application/json" }, body: JSON.stringify({ status: 404, body: { message: "Project not found" } }), }); - const result = await run(["encryption", "get-root-key", "--project-ref", PROJECT_REF]); + const result = await run(["encryption", "get-root-key", `--remote=${PROJECT_REF}`]); expect(result.exitCode).not.toBe(0); expect(result.stderr).toContain("Project not found"); }); @@ -945,7 +915,7 @@ describe("encryption", () => { headers: { "Content-Type": "application/json" }, body: JSON.stringify({ status: 429, body: { message: "Too Many Requests" } }), }); - const result = await run(["encryption", "get-root-key", "--project-ref", PROJECT_REF]); + const result = await run(["encryption", "get-root-key", `--remote=${PROJECT_REF}`]); expect(result.exitCode).not.toBe(0); expect(result.stderr).toContain("Too Many Requests"); }); @@ -956,7 +926,7 @@ describe("encryption", () => { headers: { "Content-Type": "application/json" }, body: JSON.stringify({ status: 500, body: { message: "Internal Server Error" } }), }); - const result = await run(["encryption", "get-root-key", "--project-ref", PROJECT_REF]); + const result = await run(["encryption", "get-root-key", `--remote=${PROJECT_REF}`]); expect(result.exitCode).not.toBe(0); expect(result.stderr).toContain("Internal Server Error"); }); @@ -964,7 +934,12 @@ describe("encryption", () => { describe("encryption:update-root-key", () => { testBehaviour.skipIf(isRecording)("rotates the vault root key", async ({ run, projectRef }) => { - const result = await run(["encryption", "update-root-key", "--project-ref", projectRef]); + const result = await run([ + "encryption", + "update-root-key", + "--yes", + `--remote=${projectRef}`, + ]); expect(result.exitCode).toBe(0); }); @@ -974,7 +949,12 @@ describe("encryption", () => { headers: { "Content-Type": "application/json" }, body: JSON.stringify({ status: 401, body: { message: "Invalid token" } }), }); - const result = await run(["encryption", "update-root-key", "--project-ref", PROJECT_REF]); + const result = await run([ + "encryption", + "update-root-key", + "--yes", + `--remote=${PROJECT_REF}`, + ]); expect(result.exitCode).not.toBe(0); expect(result.stderr).toContain("Invalid token"); }); @@ -985,7 +965,12 @@ describe("encryption", () => { headers: { "Content-Type": "application/json" }, body: JSON.stringify({ status: 403, body: { message: "Forbidden" } }), }); - const result = await run(["encryption", "update-root-key", "--project-ref", PROJECT_REF]); + const result = await run([ + "encryption", + "update-root-key", + "--yes", + `--remote=${PROJECT_REF}`, + ]); expect(result.exitCode).not.toBe(0); expect(result.stderr).toContain("Forbidden"); }); @@ -996,7 +981,12 @@ describe("encryption", () => { headers: { "Content-Type": "application/json" }, body: JSON.stringify({ status: 404, body: { message: "Project not found" } }), }); - const result = await run(["encryption", "update-root-key", "--project-ref", PROJECT_REF]); + const result = await run([ + "encryption", + "update-root-key", + "--yes", + `--remote=${PROJECT_REF}`, + ]); expect(result.exitCode).not.toBe(0); expect(result.stderr).toContain("Project not found"); }); @@ -1007,7 +997,12 @@ describe("encryption", () => { headers: { "Content-Type": "application/json" }, body: JSON.stringify({ status: 429, body: { message: "Too Many Requests" } }), }); - const result = await run(["encryption", "update-root-key", "--project-ref", PROJECT_REF]); + const result = await run([ + "encryption", + "update-root-key", + "--yes", + `--remote=${PROJECT_REF}`, + ]); expect(result.exitCode).not.toBe(0); expect(result.stderr).toContain("Too Many Requests"); }); @@ -1018,7 +1013,12 @@ describe("encryption", () => { headers: { "Content-Type": "application/json" }, body: JSON.stringify({ status: 500, body: { message: "Internal Server Error" } }), }); - const result = await run(["encryption", "update-root-key", "--project-ref", PROJECT_REF]); + const result = await run([ + "encryption", + "update-root-key", + "--yes", + `--remote=${PROJECT_REF}`, + ]); expect(result.exitCode).not.toBe(0); expect(result.stderr).toContain("Internal Server Error"); }); @@ -1028,7 +1028,7 @@ describe("encryption", () => { describe("backups", () => { describe("backups:list", () => { testBehaviour("renders backup table with REGION column", async ({ run, projectRef }) => { - const result = await run(["backups", "list", "--project-ref", projectRef]); + const result = await run(["backups", "list", `--remote=${projectRef}`]); expect(result.exitCode).toBe(0); expect(result.stdout).toContain("REGION"); }); @@ -1036,14 +1036,7 @@ describe("backups", () => { testBehaviour( "returns backup response as JSON with --output json", async ({ run, projectRef }) => { - const result = await run([ - "backups", - "list", - "--output", - "json", - "--project-ref", - projectRef, - ]); + const result = await run(["backups", "list", "--output", "json", `--remote=${projectRef}`]); expect(result.exitCode).toBe(0); const parsed = JSON.parse(result.stdout); expect(parsed).toHaveProperty("region"); @@ -1051,7 +1044,7 @@ describe("backups", () => { ); testBehaviour("--debug shows HTTP trace", async ({ run, projectRef }) => { - const result = await run(["backups", "list", "--debug", "--project-ref", projectRef]); + const result = await run(["backups", "list", "--debug", `--remote=${projectRef}`]); expect(result.exitCode).toBe(0); expect(result.stderr).toMatch(/HTTP.*GET:/); }); @@ -1062,7 +1055,7 @@ describe("backups", () => { headers: { "Content-Type": "application/json" }, body: JSON.stringify({ status: 401, body: { message: "Invalid token" } }), }); - const result = await run(["backups", "list", "--project-ref", PROJECT_REF]); + const result = await run(["backups", "list", `--remote=${PROJECT_REF}`]); expect(result.exitCode).not.toBe(0); expect(result.stderr).toContain("Invalid token"); }); @@ -1073,7 +1066,7 @@ describe("backups", () => { headers: { "Content-Type": "application/json" }, body: JSON.stringify({ status: 403, body: { message: "Forbidden" } }), }); - const result = await run(["backups", "list", "--project-ref", PROJECT_REF]); + const result = await run(["backups", "list", `--remote=${PROJECT_REF}`]); expect(result.exitCode).not.toBe(0); expect(result.stderr).toContain("Forbidden"); }); @@ -1084,7 +1077,7 @@ describe("backups", () => { headers: { "Content-Type": "application/json" }, body: JSON.stringify({ status: 404, body: { message: "Project not found" } }), }); - const result = await run(["backups", "list", "--project-ref", PROJECT_REF]); + const result = await run(["backups", "list", `--remote=${PROJECT_REF}`]); expect(result.exitCode).not.toBe(0); expect(result.stderr).toContain("Project not found"); }); @@ -1095,7 +1088,7 @@ describe("backups", () => { headers: { "Content-Type": "application/json" }, body: JSON.stringify({ status: 429, body: { message: "Too Many Requests" } }), }); - const result = await run(["backups", "list", "--project-ref", PROJECT_REF]); + const result = await run(["backups", "list", `--remote=${PROJECT_REF}`]); expect(result.exitCode).not.toBe(0); expect(result.stderr).toContain("Too Many Requests"); }); @@ -1106,7 +1099,7 @@ describe("backups", () => { headers: { "Content-Type": "application/json" }, body: JSON.stringify({ status: 500, body: { message: "Internal Server Error" } }), }); - const result = await run(["backups", "list", "--project-ref", PROJECT_REF]); + const result = await run(["backups", "list", `--remote=${PROJECT_REF}`]); expect(result.exitCode).not.toBe(0); expect(result.stderr).toContain("Internal Server Error"); }); @@ -1121,8 +1114,8 @@ describe("backups", () => { "restore", "-t", String(BACKUP_TIMESTAMP), - "--project-ref", - projectRef, + "--yes", + `--remote=${projectRef}`, ]); expect(result.exitCode).toBe(0); }, @@ -1137,7 +1130,14 @@ describe("backups", () => { body: { message: "recovery time target is out of range" }, }), }); - const result = await run(["backups", "restore", "-t", "0", "--project-ref", PROJECT_REF]); + const result = await run([ + "backups", + "restore", + "-t", + "0", + "--yes", + `--remote=${PROJECT_REF}`, + ]); expect(result.exitCode).not.toBe(0); expect(result.stderr).toContain("out of range"); }); @@ -1153,8 +1153,8 @@ describe("backups", () => { "restore", "-t", String(BACKUP_TIMESTAMP), - "--project-ref", - PROJECT_REF, + "--yes", + `--remote=${PROJECT_REF}`, ]); expect(result.exitCode).not.toBe(0); expect(result.stderr).toContain("Invalid token"); @@ -1171,8 +1171,8 @@ describe("backups", () => { "restore", "-t", String(BACKUP_TIMESTAMP), - "--project-ref", - PROJECT_REF, + "--yes", + `--remote=${PROJECT_REF}`, ]); expect(result.exitCode).not.toBe(0); expect(result.stderr).toContain("Project not found"); @@ -1189,8 +1189,8 @@ describe("backups", () => { "restore", "-t", String(BACKUP_TIMESTAMP), - "--project-ref", - PROJECT_REF, + "--yes", + `--remote=${PROJECT_REF}`, ]); expect(result.exitCode).not.toBe(0); expect(result.stderr).toContain("Too Many Requests"); @@ -1207,8 +1207,8 @@ describe("backups", () => { "restore", "-t", String(BACKUP_TIMESTAMP), - "--project-ref", - PROJECT_REF, + "--yes", + `--remote=${PROJECT_REF}`, ]); expect(result.exitCode).not.toBe(0); expect(result.stderr).toContain("Internal Server Error"); @@ -1219,7 +1219,7 @@ describe("backups", () => { describe("snippets", () => { describe("snippets:list", () => { testBehaviour("renders snippet table with ID and NAME columns", async ({ run, projectRef }) => { - const result = await run(["snippets", "list", "--project-ref", projectRef]); + const result = await run(["snippets", "list", `--remote=${projectRef}`]); expect(result.exitCode).toBe(0); expect(result.stdout).toContain("ID"); expect(result.stdout).toContain("NAME"); @@ -1233,8 +1233,7 @@ describe("snippets", () => { "list", "--output", "json", - "--project-ref", - projectRef, + `--remote=${projectRef}`, ]); expect(result.exitCode).toBe(0); const parsed = JSON.parse(result.stdout); @@ -1243,7 +1242,7 @@ describe("snippets", () => { ); testBehaviour("--debug shows HTTP trace", async ({ run, projectRef }) => { - const result = await run(["snippets", "list", "--debug", "--project-ref", projectRef]); + const result = await run(["snippets", "list", "--debug", `--remote=${projectRef}`]); expect(result.exitCode).toBe(0); expect(result.stderr).toMatch(/HTTP.*GET:/); }); @@ -1254,7 +1253,7 @@ describe("snippets", () => { headers: { "Content-Type": "application/json" }, body: JSON.stringify({ status: 401, body: { message: "Invalid token" } }), }); - const result = await run(["snippets", "list", "--project-ref", PROJECT_REF]); + const result = await run(["snippets", "list", `--remote=${PROJECT_REF}`]); expect(result.exitCode).not.toBe(0); expect(result.stderr).toContain("Invalid token"); }); @@ -1265,7 +1264,7 @@ describe("snippets", () => { headers: { "Content-Type": "application/json" }, body: JSON.stringify({ status: 403, body: { message: "Forbidden" } }), }); - const result = await run(["snippets", "list", "--project-ref", PROJECT_REF]); + const result = await run(["snippets", "list", `--remote=${PROJECT_REF}`]); expect(result.exitCode).not.toBe(0); expect(result.stderr).toContain("Forbidden"); }); @@ -1276,7 +1275,7 @@ describe("snippets", () => { headers: { "Content-Type": "application/json" }, body: JSON.stringify({ status: 404, body: { message: "Project not found" } }), }); - const result = await run(["snippets", "list", "--project-ref", PROJECT_REF]); + const result = await run(["snippets", "list", `--remote=${PROJECT_REF}`]); expect(result.exitCode).not.toBe(0); expect(result.stderr).toContain("Project not found"); }); @@ -1287,7 +1286,7 @@ describe("snippets", () => { headers: { "Content-Type": "application/json" }, body: JSON.stringify({ status: 429, body: { message: "Too Many Requests" } }), }); - const result = await run(["snippets", "list", "--project-ref", PROJECT_REF]); + const result = await run(["snippets", "list", `--remote=${PROJECT_REF}`]); expect(result.exitCode).not.toBe(0); expect(result.stderr).toContain("Too Many Requests"); }); @@ -1298,7 +1297,7 @@ describe("snippets", () => { headers: { "Content-Type": "application/json" }, body: JSON.stringify({ status: 500, body: { message: "Internal Server Error" } }), }); - const result = await run(["snippets", "list", "--project-ref", PROJECT_REF]); + const result = await run(["snippets", "list", `--remote=${PROJECT_REF}`]); expect(result.exitCode).not.toBe(0); expect(result.stderr).toContain("Internal Server Error"); }); @@ -1308,7 +1307,7 @@ describe("snippets", () => { testBehaviour.skipIf(isRecording)( "prints SQL content to stdout", async ({ run, projectRef }) => { - const result = await run(["snippets", "download", SNIPPET_ID, "--project-ref", projectRef]); + const result = await run(["snippets", "download", SNIPPET_ID, `--remote=${projectRef}`]); expect(result.exitCode).toBe(0); expect(result.stdout).not.toBe(""); }, @@ -1320,7 +1319,7 @@ describe("snippets", () => { headers: { "Content-Type": "application/json" }, body: JSON.stringify({ status: 404, body: { message: "Snippet not found" } }), }); - const result = await run(["snippets", "download", SNIPPET_ID, "--project-ref", PROJECT_REF]); + const result = await run(["snippets", "download", SNIPPET_ID, `--remote=${PROJECT_REF}`]); expect(result.exitCode).not.toBe(0); expect(result.stderr).toContain("not found"); }); @@ -1331,7 +1330,7 @@ describe("snippets", () => { headers: { "Content-Type": "application/json" }, body: JSON.stringify({ status: 401, body: { message: "Invalid token" } }), }); - const result = await run(["snippets", "download", SNIPPET_ID, "--project-ref", PROJECT_REF]); + const result = await run(["snippets", "download", SNIPPET_ID, `--remote=${PROJECT_REF}`]); expect(result.exitCode).not.toBe(0); expect(result.stderr).toContain("Invalid token"); }); @@ -1342,7 +1341,7 @@ describe("snippets", () => { headers: { "Content-Type": "application/json" }, body: JSON.stringify({ status: 429, body: { message: "Too Many Requests" } }), }); - const result = await run(["snippets", "download", SNIPPET_ID, "--project-ref", PROJECT_REF]); + const result = await run(["snippets", "download", SNIPPET_ID, `--remote=${PROJECT_REF}`]); expect(result.exitCode).not.toBe(0); expect(result.stderr).toContain("Too Many Requests"); }); @@ -1353,7 +1352,7 @@ describe("snippets", () => { headers: { "Content-Type": "application/json" }, body: JSON.stringify({ status: 500, body: { message: "Internal Server Error" } }), }); - const result = await run(["snippets", "download", SNIPPET_ID, "--project-ref", PROJECT_REF]); + const result = await run(["snippets", "download", SNIPPET_ID, `--remote=${PROJECT_REF}`]); expect(result.exitCode).not.toBe(0); expect(result.stderr).toContain("Internal Server Error"); }); diff --git a/apps/cli-e2e/src/tests/branches.e2e.test.ts b/apps/cli-e2e/src/tests/branches.e2e.test.ts index a956c1b577..f979199dba 100644 --- a/apps/cli-e2e/src/tests/branches.e2e.test.ts +++ b/apps/cli-e2e/src/tests/branches.e2e.test.ts @@ -7,7 +7,7 @@ const BRANCH_NAME = "my-branch"; describe("branches", () => { describe("branches:list", () => { testBehaviour("renders fixture data in output", async ({ run, projectRef }) => { - const result = await run(["branches", "list", "--project-ref", projectRef]); + const result = await run(["branches", "list", `--remote=${projectRef}`]); expect(result.exitCode).toBe(0); expect(result.stdout).toContain("NAME"); expect(result.stdout).toContain("STATUS"); @@ -21,8 +21,7 @@ describe("branches", () => { "list", "--output", "json", - "--project-ref", - projectRef, + `--remote=${projectRef}`, ]); expect(result.exitCode).toBe(0); const parsed = JSON.parse(result.stdout) as unknown[]; @@ -31,7 +30,7 @@ describe("branches", () => { ); testBehaviour("includes debug output with --debug", async ({ run, projectRef }) => { - const result = await run(["branches", "list", "--debug", "--project-ref", projectRef]); + const result = await run(["branches", "list", "--debug", `--remote=${projectRef}`]); expect(result.exitCode).toBe(0); expect(result.stderr).toMatch(/HTTP.*GET:/); }); @@ -42,7 +41,7 @@ describe("branches", () => { headers: { "Content-Type": "application/json" }, body: JSON.stringify({ status: 401, body: { message: "Invalid token" } }), }); - const result = await run(["branches", "list", "--project-ref", PROJECT_REF]); + const result = await run(["branches", "list", `--remote=${PROJECT_REF}`]); expect(result.exitCode).not.toBe(0); expect(result.stderr).toContain("Invalid token"); }); @@ -53,7 +52,7 @@ describe("branches", () => { headers: { "Content-Type": "application/json" }, body: JSON.stringify({ status: 403, body: { message: "Forbidden" } }), }); - const result = await run(["branches", "list", "--project-ref", PROJECT_REF]); + const result = await run(["branches", "list", `--remote=${PROJECT_REF}`]); expect(result.exitCode).not.toBe(0); expect(result.stderr).toContain("Forbidden"); }); @@ -64,7 +63,7 @@ describe("branches", () => { headers: { "Content-Type": "application/json" }, body: JSON.stringify({ status: 429, body: { message: "Too Many Requests" } }), }); - const result = await run(["branches", "list", "--project-ref", PROJECT_REF]); + const result = await run(["branches", "list", `--remote=${PROJECT_REF}`]); expect(result.exitCode).not.toBe(0); expect(result.stderr).toContain("Too Many Requests"); }); @@ -75,7 +74,7 @@ describe("branches", () => { headers: { "Content-Type": "application/json" }, body: JSON.stringify({ status: 500, body: { message: "Internal Server Error" } }), }); - const result = await run(["branches", "list", "--project-ref", PROJECT_REF]); + const result = await run(["branches", "list", `--remote=${PROJECT_REF}`]); expect(result.exitCode).not.toBe(0); expect(result.stderr).toContain("Internal Server Error"); }); @@ -83,7 +82,13 @@ describe("branches", () => { describe("branches:create", () => { testBehaviour.skipIf(isRecording)("creates ephemeral branch", async ({ run, projectRef }) => { - const result = await run(["branches", "create", BRANCH_NAME, "--project-ref", projectRef]); + const result = await run([ + "branches", + "create", + BRANCH_NAME, + "--yes", + `--remote=${projectRef}`, + ]); expect(result.exitCode).toBe(0); expect(result.stdout).toContain("Created preview branch:"); }); @@ -94,8 +99,8 @@ describe("branches", () => { "create", BRANCH_NAME, "--persistent", - "--project-ref", - projectRef, + "--yes", + `--remote=${projectRef}`, ]); expect(result.exitCode).toBe(0); expect(result.stdout).toContain("Created preview branch:"); @@ -107,8 +112,8 @@ describe("branches", () => { "create", BRANCH_NAME, "--with-data", - "--project-ref", - projectRef, + "--yes", + `--remote=${projectRef}`, ]); expect(result.exitCode).toBe(0); expect(result.stdout).toContain("Created preview branch:"); @@ -120,7 +125,13 @@ describe("branches", () => { headers: { "Content-Type": "application/json" }, body: JSON.stringify({ status: 409, body: { message: "Branch name already in use" } }), }); - const result = await run(["branches", "create", BRANCH_NAME, "--project-ref", PROJECT_REF]); + const result = await run([ + "branches", + "create", + BRANCH_NAME, + "--yes", + `--remote=${PROJECT_REF}`, + ]); expect(result.exitCode).not.toBe(0); expect(result.stderr).toContain("Branch name already in use"); }); @@ -134,7 +145,13 @@ describe("branches", () => { body: { message: "Preview branching is not enabled" }, }), }); - const result = await run(["branches", "create", BRANCH_NAME, "--project-ref", PROJECT_REF]); + const result = await run([ + "branches", + "create", + BRANCH_NAME, + "--yes", + `--remote=${PROJECT_REF}`, + ]); expect(result.exitCode).not.toBe(0); expect(result.stderr).toContain("Preview branching is not enabled"); }); @@ -151,8 +168,8 @@ describe("branches", () => { BRANCH_NAME, "--region", "invalid-region", - "--project-ref", - PROJECT_REF, + "--yes", + `--remote=${PROJECT_REF}`, ]); expect(result.exitCode).not.toBe(0); expect(result.stderr).toContain("--region"); @@ -164,7 +181,13 @@ describe("branches", () => { headers: { "Content-Type": "application/json" }, body: JSON.stringify({ status: 401, body: { message: "Invalid token" } }), }); - const result = await run(["branches", "create", BRANCH_NAME, "--project-ref", PROJECT_REF]); + const result = await run([ + "branches", + "create", + BRANCH_NAME, + "--yes", + `--remote=${PROJECT_REF}`, + ]); expect(result.exitCode).not.toBe(0); expect(result.stderr).toContain("Invalid token"); }); @@ -175,7 +198,13 @@ describe("branches", () => { headers: { "Content-Type": "application/json" }, body: JSON.stringify({ status: 403, body: { message: "Forbidden" } }), }); - const result = await run(["branches", "create", BRANCH_NAME, "--project-ref", PROJECT_REF]); + const result = await run([ + "branches", + "create", + BRANCH_NAME, + "--yes", + `--remote=${PROJECT_REF}`, + ]); expect(result.exitCode).not.toBe(0); expect(result.stderr).toContain("Forbidden"); }); @@ -186,7 +215,13 @@ describe("branches", () => { headers: { "Content-Type": "application/json" }, body: JSON.stringify({ status: 429, body: { message: "Too Many Requests" } }), }); - const result = await run(["branches", "create", BRANCH_NAME, "--project-ref", PROJECT_REF]); + const result = await run([ + "branches", + "create", + BRANCH_NAME, + "--yes", + `--remote=${PROJECT_REF}`, + ]); expect(result.exitCode).not.toBe(0); expect(result.stderr).toContain("Too Many Requests"); }); @@ -196,7 +231,7 @@ describe("branches", () => { testBehaviour.skipIf(isRecording)( "returns single branch details", async ({ run, projectRef }) => { - const result = await run(["branches", "get", BRANCH_NAME, "--project-ref", projectRef]); + const result = await run(["branches", "get", BRANCH_NAME, `--remote=${projectRef}`]); expect(result.exitCode).toBe(0); expect(result.stdout).toContain(BRANCH_NAME); }, @@ -211,8 +246,7 @@ describe("branches", () => { BRANCH_NAME, "--output", "json", - "--project-ref", - projectRef, + `--remote=${projectRef}`, ]); expect(result.exitCode).toBe(0); const parsed = JSON.parse(result.stdout) as Record<string, unknown>; @@ -226,7 +260,7 @@ describe("branches", () => { headers: { "Content-Type": "application/json" }, body: JSON.stringify({ status: 404, body: { message: "Branch not found" } }), }); - const result = await run(["branches", "get", "nonexistent", "--project-ref", PROJECT_REF]); + const result = await run(["branches", "get", "nonexistent", `--remote=${PROJECT_REF}`]); expect(result.exitCode).not.toBe(0); expect(result.stderr).toContain("Branch not found"); }); @@ -237,7 +271,7 @@ describe("branches", () => { headers: { "Content-Type": "application/json" }, body: JSON.stringify({ status: 401, body: { message: "Invalid token" } }), }); - const result = await run(["branches", "get", BRANCH_NAME, "--project-ref", PROJECT_REF]); + const result = await run(["branches", "get", BRANCH_NAME, `--remote=${PROJECT_REF}`]); expect(result.exitCode).not.toBe(0); expect(result.stderr).toContain("Invalid token"); }); @@ -251,8 +285,8 @@ describe("branches", () => { BRANCH_NAME, "--name", "renamed-branch", - "--project-ref", - projectRef, + "--yes", + `--remote=${projectRef}`, ]); expect(result.exitCode).toBe(0); expect(result.stderr).toContain("Updated preview branch:"); @@ -267,8 +301,8 @@ describe("branches", () => { BRANCH_NAME, "--git-branch", "feature/new", - "--project-ref", - projectRef, + "--yes", + `--remote=${projectRef}`, ]); expect(result.exitCode).toBe(0); expect(result.stderr).toContain("Updated preview branch:"); @@ -287,8 +321,8 @@ describe("branches", () => { "nonexistent", "--name", "new-name", - "--project-ref", - PROJECT_REF, + "--yes", + `--remote=${PROJECT_REF}`, ]); expect(result.exitCode).not.toBe(0); expect(result.stderr).toContain("Branch not found"); @@ -306,8 +340,8 @@ describe("branches", () => { BRANCH_NAME, "--name", "new-name", - "--project-ref", - PROJECT_REF, + "--yes", + `--remote=${PROJECT_REF}`, ]); expect(result.exitCode).not.toBe(0); expect(result.stderr).toContain("Invalid token"); @@ -316,7 +350,13 @@ describe("branches", () => { describe("branches:pause", () => { testBehaviour.skipIf(isRecording)("pauses branch successfully", async ({ run, projectRef }) => { - const result = await run(["branches", "pause", BRANCH_NAME, "--project-ref", projectRef]); + const result = await run([ + "branches", + "pause", + BRANCH_NAME, + "--yes", + `--remote=${projectRef}`, + ]); expect(result.exitCode).toBe(0); }); @@ -326,7 +366,13 @@ describe("branches", () => { headers: { "Content-Type": "application/json" }, body: JSON.stringify({ status: 404, body: { message: "Branch not found" } }), }); - const result = await run(["branches", "pause", "nonexistent", "--project-ref", PROJECT_REF]); + const result = await run([ + "branches", + "pause", + "nonexistent", + "--yes", + `--remote=${PROJECT_REF}`, + ]); expect(result.exitCode).not.toBe(0); expect(result.stderr).toContain("Branch not found"); }); @@ -337,7 +383,13 @@ describe("branches", () => { headers: { "Content-Type": "application/json" }, body: JSON.stringify({ status: 401, body: { message: "Invalid token" } }), }); - const result = await run(["branches", "pause", BRANCH_NAME, "--project-ref", PROJECT_REF]); + const result = await run([ + "branches", + "pause", + BRANCH_NAME, + "--yes", + `--remote=${PROJECT_REF}`, + ]); expect(result.exitCode).not.toBe(0); expect(result.stderr).toContain("Invalid token"); }); @@ -347,7 +399,13 @@ describe("branches", () => { testBehaviour.skipIf(isRecording)( "unpauses branch successfully", async ({ run, projectRef }) => { - const result = await run(["branches", "unpause", BRANCH_NAME, "--project-ref", projectRef]); + const result = await run([ + "branches", + "unpause", + BRANCH_NAME, + "--yes", + `--remote=${projectRef}`, + ]); expect(result.exitCode).toBe(0); }, ); @@ -358,7 +416,13 @@ describe("branches", () => { headers: { "Content-Type": "application/json" }, body: JSON.stringify({ status: 401, body: { message: "Invalid token" } }), }); - const result = await run(["branches", "unpause", BRANCH_NAME, "--project-ref", PROJECT_REF]); + const result = await run([ + "branches", + "unpause", + BRANCH_NAME, + "--yes", + `--remote=${PROJECT_REF}`, + ]); expect(result.exitCode).not.toBe(0); expect(result.stderr).toContain("Invalid token"); }); @@ -368,7 +432,13 @@ describe("branches", () => { testBehaviour.skipIf(isRecording)( "deletes branch successfully", async ({ run, projectRef }) => { - const result = await run(["branches", "delete", BRANCH_NAME, "--project-ref", projectRef]); + const result = await run([ + "branches", + "delete", + BRANCH_NAME, + "--yes", + `--remote=${projectRef}`, + ]); expect(result.exitCode).toBe(0); expect(result.stderr).toContain("Deleted preview branch:"); }, @@ -380,7 +450,13 @@ describe("branches", () => { headers: { "Content-Type": "application/json" }, body: JSON.stringify({ status: 404, body: { message: "Branch not found" } }), }); - const result = await run(["branches", "delete", "nonexistent", "--project-ref", PROJECT_REF]); + const result = await run([ + "branches", + "delete", + "nonexistent", + "--yes", + `--remote=${PROJECT_REF}`, + ]); expect(result.exitCode).not.toBe(0); expect(result.stderr).toContain("Branch not found"); }); @@ -391,7 +467,13 @@ describe("branches", () => { headers: { "Content-Type": "application/json" }, body: JSON.stringify({ status: 401, body: { message: "Invalid token" } }), }); - const result = await run(["branches", "delete", BRANCH_NAME, "--project-ref", PROJECT_REF]); + const result = await run([ + "branches", + "delete", + BRANCH_NAME, + "--yes", + `--remote=${PROJECT_REF}`, + ]); expect(result.exitCode).not.toBe(0); expect(result.stderr).toContain("Invalid token"); }); @@ -399,7 +481,7 @@ describe("branches", () => { describe("branches:disable", () => { testBehaviour.skipIf(isRecording)("disables preview branching", async ({ run, projectRef }) => { - const result = await run(["branches", "disable", "--project-ref", projectRef]); + const result = await run(["branches", "disable", "--yes", `--remote=${projectRef}`]); expect(result.exitCode).toBe(0); expect(result.stdout).toContain("Disabled preview branching for project:"); }); @@ -410,7 +492,7 @@ describe("branches", () => { headers: { "Content-Type": "application/json" }, body: JSON.stringify({ status: 401, body: { message: "Invalid token" } }), }); - const result = await run(["branches", "disable", "--project-ref", PROJECT_REF]); + const result = await run(["branches", "disable", "--yes", `--remote=${PROJECT_REF}`]); expect(result.exitCode).not.toBe(0); expect(result.stderr).toContain("Invalid token"); }); diff --git a/apps/cli-e2e/src/tests/config-push.e2e.test.ts b/apps/cli-e2e/src/tests/config-push.e2e.test.ts index 617c6158cc..3be38add6e 100644 --- a/apps/cli-e2e/src/tests/config-push.e2e.test.ts +++ b/apps/cli-e2e/src/tests/config-push.e2e.test.ts @@ -59,13 +59,13 @@ enabled = true describe("config push", () => { testBehaviour("reconciles every section against the remote", async ({ run, workspace }) => { writeConfigToml(workspace.path); - const result = await run(["config", "push", "--yes", "--project-ref", PROJECT_REF]); + const result = await run(["config", "push", "--yes", `--remote=${PROJECT_REF}`]); expect(result.exitCode).toBe(0); }); testBehaviour("emits HTTP trace with --debug", async ({ run, workspace }) => { writeConfigToml(workspace.path); - const result = await run(["config", "push", "--yes", "--project-ref", PROJECT_REF, "--debug"]); + const result = await run(["config", "push", "--yes", "--debug", `--remote=${PROJECT_REF}`]); expect(result.exitCode).toBe(0); expect(result.stderr).toMatch(/HTTP.*GET:/); }); @@ -81,7 +81,7 @@ describe("config push", () => { }); writeConfigToml(workspace.path); - const result = await run(["config", "push", "--yes", "--project-ref", PROJECT_REF]); + const result = await run(["config", "push", "--yes", `--remote=${PROJECT_REF}`]); expect(result.exitCode).not.toBe(0); expect(result.stderr).toContain("401"); }); @@ -101,7 +101,7 @@ describe("config push", () => { }); writeConfigToml(workspace.path); - const result = await run(["config", "push", "--yes", "--project-ref", PROJECT_REF]); + const result = await run(["config", "push", "--yes", `--remote=${PROJECT_REF}`]); expect(result.exitCode).not.toBe(0); expect(result.stderr).toContain("403"); }, @@ -118,7 +118,7 @@ describe("config push", () => { }); writeConfigToml(workspace.path); - const result = await run(["config", "push", "--yes", "--project-ref", PROJECT_REF]); + const result = await run(["config", "push", "--yes", `--remote=${PROJECT_REF}`]); expect(result.exitCode).not.toBe(0); expect(result.stderr).toContain("404"); }); @@ -131,7 +131,7 @@ describe("config push", () => { }); writeConfigToml(workspace.path); - const result = await run(["config", "push", "--yes", "--project-ref", PROJECT_REF]); + const result = await run(["config", "push", "--yes", `--remote=${PROJECT_REF}`]); expect(result.exitCode).not.toBe(0); expect(result.stderr).toContain("409"); }); @@ -149,7 +149,7 @@ describe("config push", () => { }); writeConfigToml(workspace.path); - const result = await run(["config", "push", "--yes", "--project-ref", PROJECT_REF]); + const result = await run(["config", "push", "--yes", `--remote=${PROJECT_REF}`]); expect(result.exitCode).not.toBe(0); expect(result.stderr).toContain("422"); expect(result.stderr).toContain("max_rows"); @@ -166,7 +166,7 @@ describe("config push", () => { }); writeConfigToml(workspace.path); - const result = await run(["config", "push", "--yes", "--project-ref", PROJECT_REF]); + const result = await run(["config", "push", "--yes", `--remote=${PROJECT_REF}`]); expect(result.exitCode).not.toBe(0); expect(result.stderr).toContain("429"); }); @@ -182,7 +182,7 @@ describe("config push", () => { }); writeConfigToml(workspace.path); - const result = await run(["config", "push", "--yes", "--project-ref", PROJECT_REF]); + const result = await run(["config", "push", "--yes", `--remote=${PROJECT_REF}`]); expect(result.exitCode).not.toBe(0); expect(result.stderr).toContain("500"); }); @@ -195,7 +195,7 @@ describe("config push", () => { }); writeConfigToml(workspace.path); - const result = await run(["config", "push", "--yes", "--project-ref", PROJECT_REF]); + const result = await run(["config", "push", "--yes", `--remote=${PROJECT_REF}`]); expect(result.exitCode).not.toBe(0); expect(result.stderr).toContain("502"); }); diff --git a/apps/cli-e2e/src/tests/config-toml.e2e.test.ts b/apps/cli-e2e/src/tests/config-toml.e2e.test.ts index e21e572773..5275c838b8 100644 --- a/apps/cli-e2e/src/tests/config-toml.e2e.test.ts +++ b/apps/cli-e2e/src/tests/config-toml.e2e.test.ts @@ -46,7 +46,7 @@ describe("env-in-config-toml", () => { body: JSON.stringify({ status: 401, body: { message: "Invalid token" } }), }); - const result = await run(["secrets", "list", "--project-ref", PROJECT_REF], { + const result = await run(["secrets", "list", `--remote=${PROJECT_REF}`], { env: ENV_PORTS, }); diff --git a/apps/cli-e2e/src/tests/database-core.e2e.test.ts b/apps/cli-e2e/src/tests/database-core.e2e.test.ts index 0adcb9b5ad..1a4f575c59 100644 --- a/apps/cli-e2e/src/tests/database-core.e2e.test.ts +++ b/apps/cli-e2e/src/tests/database-core.e2e.test.ts @@ -1,23 +1,9 @@ import { mkdirSync, readdirSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { describe, expect } from "vitest"; +import { linkProject } from "./link-project.ts"; import { testBehaviour } from "./test-context.ts"; -/** Writes .supabase/.temp/project-ref and a stub pooler-url so --linked - * commands resolve a connection without a live postgres TCP probe (nothing - * listens on db.{ref}.localhost:5432 in the test harness); combined with - * SUPABASE_DB_PASSWORD (set in harness.ts), the command reaches its handler - * and makes the Management API call under test. */ -function linkProject(dir: string, ref: string): void { - const tempDir = join(dir, "supabase", ".temp"); - mkdirSync(tempDir, { recursive: true }); - writeFileSync(join(tempDir, "project-ref"), ref); - writeFileSync( - join(tempDir, "pooler-url"), - `postgresql://postgres.${ref}:[YOUR-PASSWORD]@aws-0-eu-central-1.pooler.supabase.com:6543/postgres`, - ); -} - const TEST_MIGRATION_SQL = "CREATE TABLE IF NOT EXISTS e2e_test_table (id bigint generated always as identity primary key);"; @@ -32,7 +18,7 @@ describe("db advisors", () => { describe("db advisors:security", () => { testBehaviour("returns security advisors", async ({ run, projectRef, workspace }) => { linkProject(workspace.path, projectRef); - const result = await run(["db", "advisors", "--linked", "--type", "security"]); + const result = await run(["db", "advisors", "--type", "security", "--remote"]); expect(result.exitCode).toBe(0); expect(result.stdout.trim()).toBe(""); expect(result.stderr).toContain("No issues found"); @@ -45,11 +31,11 @@ describe("db advisors", () => { const result = await run([ "db", "advisors", - "--linked", "--type", "security", "--fail-on", "error", + "--remote", ]); expect(result.exitCode).toBe(0); }, @@ -62,7 +48,7 @@ describe("db advisors", () => { headers: { "Content-Type": "application/json" }, body: JSON.stringify({ status: 401, body: { message: "Invalid token" } }), }); - const result = await run(["db", "advisors", "--linked", "--type", "security"]); + const result = await run(["db", "advisors", "--type", "security", "--remote"]); expect(result.exitCode).not.toBe(0); expect(result.stderr).toContain("Invalid token"); }); @@ -74,7 +60,7 @@ describe("db advisors", () => { headers: { "Content-Type": "application/json" }, body: JSON.stringify({ status: 403, body: { message: "Forbidden" } }), }); - const result = await run(["db", "advisors", "--linked", "--type", "security"]); + const result = await run(["db", "advisors", "--type", "security", "--remote"]); expect(result.exitCode).not.toBe(0); expect(result.stderr).toContain("Forbidden"); }); @@ -86,7 +72,7 @@ describe("db advisors", () => { headers: { "Content-Type": "application/json" }, body: JSON.stringify({ status: 429, body: { message: "Too Many Requests" } }), }); - const result = await run(["db", "advisors", "--linked", "--type", "security"]); + const result = await run(["db", "advisors", "--type", "security", "--remote"]); expect(result.exitCode).not.toBe(0); expect(result.stderr).toContain("Too Many Requests"); }); @@ -98,7 +84,7 @@ describe("db advisors", () => { headers: { "Content-Type": "application/json" }, body: JSON.stringify({ status: 500, body: { message: "Internal Server Error" } }), }); - const result = await run(["db", "advisors", "--linked", "--type", "security"]); + const result = await run(["db", "advisors", "--type", "security", "--remote"]); expect(result.exitCode).not.toBe(0); expect(result.stderr).toContain("Internal Server Error"); }); @@ -107,7 +93,7 @@ describe("db advisors", () => { describe("db advisors:performance", () => { testBehaviour("returns performance advisors", async ({ run, projectRef, workspace }) => { linkProject(workspace.path, projectRef); - const result = await run(["db", "advisors", "--linked", "--type", "performance"]); + const result = await run(["db", "advisors", "--type", "performance", "--remote"]); expect(result.exitCode).toBe(0); if (result.stdout.trim()) { const parsed = JSON.parse(result.stdout) as unknown[]; @@ -121,19 +107,19 @@ describe("db advisors", () => { describe("db advisors:all", () => { testBehaviour("returns advisors with --type all", async ({ run, projectRef, workspace }) => { linkProject(workspace.path, projectRef); - const result = await run(["db", "advisors", "--linked", "--type", "all"]); + const result = await run(["db", "advisors", "--type", "all", "--remote"]); expect(result.exitCode).toBe(0); }); }); }); describe("db query", () => { - describe("db query:linked", () => { + describe("db query:remote", () => { testBehaviour( "returns SELECT 1 result in table format", async ({ run, projectRef, workspace }) => { linkProject(workspace.path, projectRef); - const result = await run(["db", "query", "--linked", "SELECT 1"]); + const result = await run(["db", "query", "SELECT 1", "--read-only", "--remote"]); expect(result.exitCode).toBe(0); expect(result.stdout).toContain("1"); }, @@ -141,7 +127,15 @@ describe("db query", () => { testBehaviour("returns JSON with --output json", async ({ run, projectRef, workspace }) => { linkProject(workspace.path, projectRef); - const result = await run(["db", "query", "--linked", "--output", "json", "SELECT 1"]); + const result = await run([ + "db", + "query", + "--output", + "json", + "SELECT 1", + "--read-only", + "--remote", + ]); expect(result.exitCode).toBe(0); const parsed = JSON.parse(result.stdout) as unknown; // In agent mode (CLAUDECODE env set) the output is wrapped in {warning, boundary, rows}. @@ -158,7 +152,7 @@ describe("db query", () => { headers: { "Content-Type": "application/json" }, body: JSON.stringify({ status: 401, body: { message: "Invalid token" } }), }); - const result = await run(["db", "query", "--linked", "SELECT 1"]); + const result = await run(["db", "query", "SELECT 1", "--read-only", "--remote"]); expect(result.exitCode).not.toBe(0); expect(result.stderr).toContain("Invalid token"); }); @@ -170,7 +164,7 @@ describe("db query", () => { headers: { "Content-Type": "application/json" }, body: JSON.stringify({ status: 403, body: { message: "Forbidden" } }), }); - const result = await run(["db", "query", "--linked", "SELECT 1"]); + const result = await run(["db", "query", "SELECT 1", "--read-only", "--remote"]); expect(result.exitCode).not.toBe(0); expect(result.stderr).toContain("Forbidden"); }); @@ -184,7 +178,7 @@ describe("db push", () => { async ({ run, projectRef, workspace }) => { linkProject(workspace.path, projectRef); seedMigration(workspace.path); - const result = await run(["db", "push", "--dry-run"]); + const result = await run(["db", "push", "--dry-run", "--remote"]); expect(result.exitCode).not.toBe(0); expect(result.stderr).toContain("connect"); }, @@ -192,19 +186,19 @@ describe("db push", () => { }); describe("db push:local", () => { - testBehaviour("exits non-zero on connection refused with --local", async ({ run }) => { - const result = await run(["db", "push", "--local"]); + testBehaviour("exits non-zero on connection refused without --remote", async ({ run }) => { + const result = await run(["db", "push"]); expect(result.exitCode).not.toBe(0); expect(result.stderr).toContain("connect"); }); }); - describe("db push:linked", () => { + describe("db push:remote", () => { testBehaviour( - "exits non-zero on connection refused with --linked", + "exits non-zero on connection refused with --remote", async ({ run, projectRef, workspace }) => { linkProject(workspace.path, projectRef); - const result = await run(["db", "push"]); + const result = await run(["db", "push", "--remote"]); expect(result.exitCode).not.toBe(0); expect(result.stderr).toContain("connect"); }, @@ -213,38 +207,38 @@ describe("db push", () => { }); describe("db pull", () => { - testBehaviour("exits non-zero on connection refused with --local", async ({ run }) => { - const result = await run(["db", "pull", "--local"]); + testBehaviour("exits non-zero on connection refused without --remote", async ({ run }) => { + const result = await run(["db", "pull"]); expect(result.exitCode).not.toBe(0); expect(result.stderr).toContain("connect"); }); }); describe("db lint", () => { - testBehaviour("exits non-zero on connection refused with --local", async ({ run }) => { - const result = await run(["db", "lint", "--local"]); + testBehaviour("exits non-zero on connection refused without --remote", async ({ run }) => { + const result = await run(["db", "lint"]); expect(result.exitCode).not.toBe(0); expect(result.stderr).toContain("connect"); }); }); describe("db dump", () => { - testBehaviour("exits non-zero on connection refused with --local", async ({ run }) => { - const result = await run(["db", "dump", "--local"]); + testBehaviour("exits non-zero on connection refused without --remote", async ({ run }) => { + const result = await run(["db", "dump"]); expect(result.exitCode).not.toBe(0); expect(result.stderr).toContain("connect"); }); testBehaviour("exits non-zero when --role-only and --data-only are both set", async ({ run }) => { - const result = await run(["db", "dump", "--local", "--role-only", "--data-only"]); + const result = await run(["db", "dump", "--role-only", "--data-only"]); expect(result.exitCode).not.toBe(0); expect(result.stderr.toLowerCase()).toMatch(/role.only|data.only|mutually exclusive/); }); }); describe("db reset", () => { - testBehaviour("exits non-zero on connection refused with --local", async ({ run }) => { - const result = await run(["db", "reset", "--local"]); + testBehaviour("exits non-zero on connection refused without --remote", async ({ run }) => { + const result = await run(["db", "reset"]); expect(result.exitCode).not.toBe(0); expect(result.stderr).toMatch(/connect|not running/i); }); @@ -268,8 +262,8 @@ describe("test new", () => { }); describe("test db", () => { - testBehaviour("exits non-zero on connection refused with --local", async ({ run }) => { - const result = await run(["test", "db", "--local"]); + testBehaviour("exits non-zero on connection refused without --remote", async ({ run }) => { + const result = await run(["test", "db"]); expect(result.exitCode).not.toBe(0); expect(result.stderr).toContain("connect"); }); diff --git a/apps/cli-e2e/src/tests/domains.e2e.test.ts b/apps/cli-e2e/src/tests/domains.e2e.test.ts index 72cb5f37bc..e2244691e6 100644 --- a/apps/cli-e2e/src/tests/domains.e2e.test.ts +++ b/apps/cli-e2e/src/tests/domains.e2e.test.ts @@ -19,21 +19,21 @@ describe("domains", () => { }), }); - const result = await run(["domains", "get", "--project-ref", PROJECT_REF]); + const result = await run(["domains", "get", `--remote=${PROJECT_REF}`]); expect(result.exitCode).not.toBe(0); expect(result.stderr).toContain("400"); }); testBehaviour.skipIf(isRecording)("no custom domain", async ({ run }) => { - const result = await run(["domains", "get", "--project-ref", PROJECT_REF]); + const result = await run(["domains", "get", `--remote=${PROJECT_REF}`]); expect(result.exitCode).not.toBe(0); expect(result.stderr).toContain("400"); }); testBehaviour.skipIf(isRecording)("pending verification", async ({ run }) => { - const result = await run(["domains", "get", "--project-ref", PROJECT_REF]); + const result = await run(["domains", "get", `--remote=${PROJECT_REF}`]); expect(result.exitCode).toBe(0); expect(result.stderr).toContain( @@ -42,7 +42,7 @@ describe("domains", () => { }); testBehaviour.skipIf(isRecording)("verification completed", async ({ run }) => { - const result = await run(["domains", "get", "--project-ref", PROJECT_REF]); + const result = await run(["domains", "get", `--remote=${PROJECT_REF}`]); expect(result.exitCode).toBe(0); expect(result.stderr).toContain( @@ -51,14 +51,7 @@ describe("domains", () => { }); testBehaviour.skipIf(isRecording)("domain activated", async ({ run }) => { - const result = await run([ - "domains", - "get", - "--project-ref", - PROJECT_REF, - "--output", - "json", - ]); + const result = await run(["domains", "get", "--output", "json", `--remote=${PROJECT_REF}`]); expect(result.exitCode).toBe(0); @@ -98,7 +91,7 @@ describe("domains", () => { }), }); - const result = await run(["domains", "get", "--project-ref", PROJECT_REF]); + const result = await run(["domains", "get", `--remote=${PROJECT_REF}`]); expect(result.exitCode).not.toBe(0); expect(result.stderr).toContain("403"); }); @@ -113,7 +106,7 @@ describe("domains", () => { }), }); - const result = await run(["domains", "get", "--project-ref", PROJECT_REF]); + const result = await run(["domains", "get", `--remote=${PROJECT_REF}`]); expect(result.exitCode).not.toBe(0); expect(result.stderr).toContain("401"); }); @@ -128,7 +121,7 @@ describe("domains", () => { }), }); - const result = await run(["domains", "get", "--project-ref", PROJECT_REF]); + const result = await run(["domains", "get", `--remote=${PROJECT_REF}`]); expect(result.exitCode).not.toBe(0); expect(result.stderr).toContain("429"); }); @@ -143,7 +136,7 @@ describe("domains", () => { }), }); - const result = await run(["domains", "get", "--project-ref", PROJECT_REF]); + const result = await run(["domains", "get", `--remote=${PROJECT_REF}`]); expect(result.exitCode).not.toBe(0); expect(result.stderr).toContain("500"); @@ -159,7 +152,7 @@ describe("domains", () => { }), }); - const result = await run(["domains", "get", "--project-ref", PROJECT_REF]); + const result = await run(["domains", "get", `--remote=${PROJECT_REF}`]); expect(result.exitCode).not.toBe(0); expect(result.stderr).toContain("502"); @@ -168,21 +161,21 @@ describe("domains", () => { describe("domains:reverify", () => { testBehaviour("custom domain disabled", async ({ run }) => { - const result = await run(["domains", "reverify", "--project-ref", PROJECT_REF]); + const result = await run(["domains", "reverify", `--remote=${PROJECT_REF}`]); expect(result.exitCode).not.toBe(0); expect(result.stderr).toContain("400"); }); testBehaviour.skipIf(isRecording)("no custom domain", async ({ run }) => { - const result = await run(["domains", "reverify", "--project-ref", PROJECT_REF]); + const result = await run(["domains", "reverify", `--remote=${PROJECT_REF}`]); expect(result.exitCode).not.toBe(0); expect(result.stderr).toContain("400"); }); testBehaviour.skipIf(isRecording)("pending verification", async ({ run }) => { - const result = await run(["domains", "reverify", "--project-ref", PROJECT_REF]); + const result = await run(["domains", "reverify", `--remote=${PROJECT_REF}`]); expect(result.exitCode).toBe(0); expect(result.stderr).toContain( @@ -191,7 +184,7 @@ describe("domains", () => { }); testBehaviour.skipIf(isRecording)("verification completed", async ({ run }) => { - const result = await run(["domains", "reverify", "--project-ref", PROJECT_REF]); + const result = await run(["domains", "reverify", `--remote=${PROJECT_REF}`]); expect(result.exitCode).not.toBe(0); expect(result.stderr).toContain("400"); @@ -199,28 +192,34 @@ describe("domains", () => { }); describe("domains:activate", () => { testBehaviour("custom domain disabled", async ({ run }) => { - const result = await run(["domains", "activate", "--project-ref", PROJECT_REF]); + const result = await run(["domains", "activate", "--yes", `--remote=${PROJECT_REF}`]); expect(result.exitCode).not.toBe(0); expect(result.stderr).toContain("400"); }); testBehaviour.skipIf(isRecording)("no custom domain", async ({ run }) => { - const result = await run(["domains", "activate", "--project-ref", PROJECT_REF]); + const result = await run(["domains", "activate", "--yes", `--remote=${PROJECT_REF}`]); expect(result.exitCode).not.toBe(0); expect(result.stderr).toContain("400"); }); testBehaviour.skipIf(isRecording)("pending verification", async ({ run }) => { - const result = await run(["domains", "activate", "--project-ref", PROJECT_REF]); + const result = await run(["domains", "activate", "--yes", `--remote=${PROJECT_REF}`]); expect(result.exitCode).not.toBe(0); expect(result.stderr).toContain("400"); }); testBehaviour.skipIf(isRecording)("pending verification in debug mode", async ({ run }) => { - const result = await run(["domains", "activate", "--project-ref", PROJECT_REF, "--debug"]); + const result = await run([ + "domains", + "activate", + "--debug", + "--yes", + `--remote=${PROJECT_REF}`, + ]); expect(result.exitCode).not.toBe(0); expect(result.stderr).toMatch(/HTTP.*POST:/); @@ -228,7 +227,7 @@ describe("domains", () => { }); testBehaviour.skipIf(isRecording)("verification completed", async ({ run }) => { - const result = await run(["domains", "activate", "--project-ref", PROJECT_REF]); + const result = await run(["domains", "activate", "--yes", `--remote=${PROJECT_REF}`]); expect(result.exitCode).toBe(0); expect(result.stderr).toContain(`completed`); @@ -236,7 +235,7 @@ describe("domains", () => { }); testBehaviour.skipIf(isRecording)("domain activated", async ({ run }) => { - const result = await run(["domains", "activate", "--project-ref", PROJECT_REF]); + const result = await run(["domains", "activate", "--yes", `--remote=${PROJECT_REF}`]); expect(result.exitCode).not.toBe(0); expect(result.stderr).toContain("400"); @@ -245,21 +244,21 @@ describe("domains", () => { describe("domains:delete", () => { testBehaviour("custom domain disabled", async ({ run }) => { - const result = await run(["domains", "delete", "--project-ref", PROJECT_REF]); + const result = await run(["domains", "delete", "--yes", `--remote=${PROJECT_REF}`]); expect(result.exitCode).not.toBe(0); expect(result.stderr).toContain("400"); }); testBehaviour.skipIf(isRecording)("no custom domain", async ({ run }) => { - const result = await run(["domains", "delete", "--project-ref", PROJECT_REF]); + const result = await run(["domains", "delete", "--yes", `--remote=${PROJECT_REF}`]); expect(result.exitCode).not.toBe(0); expect(result.stderr).toContain("400"); }); testBehaviour.skipIf(isRecording)("pending verification", async ({ run }) => { - const result = await run(["domains", "delete", "--project-ref", PROJECT_REF]); + const result = await run(["domains", "delete", "--yes", `--remote=${PROJECT_REF}`]); expect(result.exitCode).toBe(0); }); diff --git a/apps/cli-e2e/src/tests/functions.e2e.test.ts b/apps/cli-e2e/src/tests/functions.e2e.test.ts index d246343641..af2a08db8e 100644 --- a/apps/cli-e2e/src/tests/functions.e2e.test.ts +++ b/apps/cli-e2e/src/tests/functions.e2e.test.ts @@ -7,28 +7,21 @@ const FUNCTION_NAME = "hello-world"; describe("functions", () => { describe("functions:list", () => { testBehaviour("renders fixture data in output", async ({ run, projectRef }) => { - const result = await run(["functions", "list", "--project-ref", projectRef]); + const result = await run(["functions", "list", `--remote=${projectRef}`]); expect(result.exitCode).toBe(0); expect(result.stdout).toContain("NAME"); expect(result.stdout).toContain("STATUS"); }); testBehaviour("returns json output with --output json", async ({ run, projectRef }) => { - const result = await run([ - "functions", - "list", - "--output", - "json", - "--project-ref", - projectRef, - ]); + const result = await run(["functions", "list", "--output", "json", `--remote=${projectRef}`]); expect(result.exitCode).toBe(0); const parsed = JSON.parse(result.stdout) as unknown[]; expect(Array.isArray(parsed)).toBe(true); }); testBehaviour("includes debug output with --debug", async ({ run, projectRef }) => { - const result = await run(["functions", "list", "--debug", "--project-ref", projectRef]); + const result = await run(["functions", "list", "--debug", `--remote=${projectRef}`]); expect(result.exitCode).toBe(0); expect(result.stderr).toMatch(/HTTP.*GET:/); }); @@ -39,7 +32,7 @@ describe("functions", () => { headers: { "Content-Type": "application/json" }, body: JSON.stringify({ status: 401, body: { message: "Invalid token" } }), }); - const result = await run(["functions", "list", "--project-ref", PROJECT_REF]); + const result = await run(["functions", "list", `--remote=${PROJECT_REF}`]); expect(result.exitCode).not.toBe(0); }); @@ -49,7 +42,7 @@ describe("functions", () => { headers: { "Content-Type": "application/json" }, body: JSON.stringify({ status: 403, body: { message: "Forbidden" } }), }); - const result = await run(["functions", "list", "--project-ref", PROJECT_REF]); + const result = await run(["functions", "list", `--remote=${PROJECT_REF}`]); expect(result.exitCode).not.toBe(0); expect(result.stderr).toContain("Forbidden"); }); @@ -60,7 +53,7 @@ describe("functions", () => { headers: { "Content-Type": "application/json" }, body: JSON.stringify({ status: 429, body: { message: "Too Many Requests" } }), }); - const result = await run(["functions", "list", "--project-ref", PROJECT_REF]); + const result = await run(["functions", "list", `--remote=${PROJECT_REF}`]); expect(result.exitCode).not.toBe(0); expect(result.stderr).toContain("Too Many Requests"); }); @@ -71,7 +64,7 @@ describe("functions", () => { headers: { "Content-Type": "application/json" }, body: JSON.stringify({ status: 500, body: { message: "Internal Server Error" } }), }); - const result = await run(["functions", "list", "--project-ref", PROJECT_REF]); + const result = await run(["functions", "list", `--remote=${PROJECT_REF}`]); expect(result.exitCode).not.toBe(0); expect(result.stderr).toContain("Internal Server Error"); }); @@ -93,9 +86,8 @@ describe("functions", () => { "deploy", FUNCTION_NAME, "--use-api", - "--project-ref", - PROJECT_REF, "--yes", + `--remote=${PROJECT_REF}`, ]); expect(result.exitCode).not.toBe(0); expect(result.stderr).toContain("Invalid token"); @@ -113,9 +105,8 @@ describe("functions", () => { "deploy", FUNCTION_NAME, "--use-api", - "--project-ref", - PROJECT_REF, "--yes", + `--remote=${PROJECT_REF}`, ]); expect(result.exitCode).not.toBe(0); expect(result.stderr).toContain("Forbidden"); @@ -136,9 +127,8 @@ describe("functions", () => { "deploy", FUNCTION_NAME, "--use-api", - "--project-ref", - PROJECT_REF, "--yes", + `--remote=${PROJECT_REF}`, ]); expect(result.exitCode).not.toBe(0); expect(result.stderr).toContain("Too Many Requests"); @@ -156,9 +146,8 @@ describe("functions", () => { "deploy", FUNCTION_NAME, "--use-api", - "--project-ref", - PROJECT_REF, "--yes", + `--remote=${PROJECT_REF}`, ]); expect(result.exitCode).not.toBe(0); expect(result.stderr).toContain("Internal Server Error"); @@ -173,9 +162,8 @@ describe("functions", () => { "functions", "delete", FUNCTION_NAME, - "--project-ref", - projectRef, "--yes", + `--remote=${projectRef}`, ]); expect(result.exitCode).toBe(0); expect(result.stdout).toContain("Deleted Function"); @@ -192,9 +180,8 @@ describe("functions", () => { "functions", "delete", FUNCTION_NAME, - "--project-ref", - PROJECT_REF, "--yes", + `--remote=${PROJECT_REF}`, ]); expect(result.exitCode).not.toBe(0); expect(result.stderr).toContain("does not exist"); @@ -210,9 +197,8 @@ describe("functions", () => { "functions", "delete", FUNCTION_NAME, - "--project-ref", - PROJECT_REF, "--yes", + `--remote=${PROJECT_REF}`, ]); expect(result.exitCode).not.toBe(0); expect(result.stderr).toContain("Invalid token"); @@ -228,9 +214,8 @@ describe("functions", () => { "functions", "delete", FUNCTION_NAME, - "--project-ref", - PROJECT_REF, "--yes", + `--remote=${PROJECT_REF}`, ]); expect(result.exitCode).not.toBe(0); expect(result.stderr).toContain("Forbidden"); @@ -246,9 +231,8 @@ describe("functions", () => { "functions", "delete", FUNCTION_NAME, - "--project-ref", - PROJECT_REF, "--yes", + `--remote=${PROJECT_REF}`, ]); expect(result.exitCode).not.toBe(0); expect(result.stderr).toContain("Too Many Requests"); @@ -264,9 +248,8 @@ describe("functions", () => { "functions", "delete", FUNCTION_NAME, - "--project-ref", - PROJECT_REF, "--yes", + `--remote=${PROJECT_REF}`, ]); expect(result.exitCode).not.toBe(0); expect(result.stderr).toContain("Internal Server Error"); @@ -282,8 +265,7 @@ describe("functions", () => { "download", FUNCTION_NAME, "--use-api", - "--project-ref", - projectRef, + `--remote=${projectRef}`, ]); expect(result.exitCode).toBe(0); expect(result.stderr).toContain("Downloaded Function"); @@ -301,8 +283,7 @@ describe("functions", () => { "download", FUNCTION_NAME, "--use-api", - "--project-ref", - PROJECT_REF, + `--remote=${PROJECT_REF}`, ]); expect(result.exitCode).not.toBe(0); expect(result.stderr).toContain("Invalid token"); @@ -319,8 +300,7 @@ describe("functions", () => { "download", FUNCTION_NAME, "--use-api", - "--project-ref", - PROJECT_REF, + `--remote=${PROJECT_REF}`, ]); expect(result.exitCode).not.toBe(0); expect(result.stderr).toContain("Forbidden"); @@ -337,8 +317,7 @@ describe("functions", () => { "download", FUNCTION_NAME, "--use-api", - "--project-ref", - PROJECT_REF, + `--remote=${PROJECT_REF}`, ]); expect(result.exitCode).not.toBe(0); expect(result.stderr).toContain("Function not found"); @@ -355,8 +334,7 @@ describe("functions", () => { "download", FUNCTION_NAME, "--use-api", - "--project-ref", - PROJECT_REF, + `--remote=${PROJECT_REF}`, ]); expect(result.exitCode).not.toBe(0); expect(result.stderr).toContain("Internal Server Error"); diff --git a/apps/cli-e2e/src/tests/gen.e2e.test.ts b/apps/cli-e2e/src/tests/gen.e2e.test.ts index 5362a5bf12..95a24b5eec 100644 --- a/apps/cli-e2e/src/tests/gen.e2e.test.ts +++ b/apps/cli-e2e/src/tests/gen.e2e.test.ts @@ -14,7 +14,7 @@ describe("gen", () => { async ({ run, projectRef }) => { // #5212 — with CLAUDECODE=1, piped/redirected output must not include the // plugin hint (would break `gen types > file.ts` and similar captures). - const result = await run(["gen", "types", "--project-id", projectRef], { + const result = await run(["gen", "types", `--remote=${projectRef}`], { env: { CLAUDECODE: "1" }, }); expect(result.exitCode).toBe(0); @@ -28,7 +28,7 @@ describe("gen", () => { testBehaviour.skipIf(isRecording)( "includes debug output with --debug", async ({ run, projectRef }) => { - const result = await run(["gen", "types", "--debug", "--project-id", projectRef]); + const result = await run(["gen", "types", "--debug", `--remote=${projectRef}`]); expect(result.exitCode).toBe(0); expect(result.stderr).toMatch(/HTTP.*GET:/); }, @@ -40,7 +40,7 @@ describe("gen", () => { headers: { "Content-Type": "application/json" }, body: JSON.stringify({ status: 401, body: { message: "Invalid token" } }), }); - const result = await run(["gen", "types", "--project-id", PROJECT_REF]); + const result = await run(["gen", "types", `--remote=${PROJECT_REF}`]); expect(result.exitCode).not.toBe(0); expect(result.stderr).toContain("Invalid token"); }); @@ -51,7 +51,7 @@ describe("gen", () => { headers: { "Content-Type": "application/json" }, body: JSON.stringify({ status: 403, body: { message: "Forbidden" } }), }); - const result = await run(["gen", "types", "--project-id", PROJECT_REF]); + const result = await run(["gen", "types", `--remote=${PROJECT_REF}`]); expect(result.exitCode).not.toBe(0); expect(result.stderr).toContain("Forbidden"); }); @@ -62,15 +62,20 @@ describe("gen", () => { headers: { "Content-Type": "application/json" }, body: JSON.stringify({ status: 404, body: { message: "Project not found" } }), }); - const result = await run(["gen", "types", "--project-id", PROJECT_REF]); + const result = await run(["gen", "types", `--remote=${PROJECT_REF}`]); expect(result.exitCode).not.toBe(0); expect(result.stderr).toContain("Project not found"); }); - testBehaviour("exits non-zero with no data source specified", async ({ runNoProjectId }) => { - const result = await runNoProjectId(["gen", "types"]); - expect(result.exitCode).not.toBe(0); - }); + testBehaviour( + "exits non-zero when the local stack is not running", + async ({ runNoProjectId }) => { + const result = await runNoProjectId(["gen", "types"]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Target: local stack"); + expect(result.stderr).toContain("supabase start is not running"); + }, + ); }); describe("gen:signing-key", () => { diff --git a/apps/cli-e2e/src/tests/inspect.e2e.test.ts b/apps/cli-e2e/src/tests/inspect.e2e.test.ts index 9f5014dab1..bdb9bd5311 100644 --- a/apps/cli-e2e/src/tests/inspect.e2e.test.ts +++ b/apps/cli-e2e/src/tests/inspect.e2e.test.ts @@ -37,17 +37,19 @@ const SUBCOMMANDS = [ ] as const; describe("inspect:flags", () => { - testBehaviour("rejects --db-url with --local", async ({ run, workspace, pgMockPort }) => { + testBehaviour("rejects --remote with --local", async ({ run, workspace, pgMockPort }) => { setupInspectWorkspace(workspace.path, pgMockPort); const result = await run([ "inspect", "db", "db-stats", - "--db-url", - "postgresql://postgres:postgres@localhost:5432/postgres", "--local", + "--remote=postgresql://postgres:postgres@localhost:5432/postgres", ]); expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain( + "Only one of --remote, --linked, --db-url and --local can be set; got --remote and --local.", + ); }); }); @@ -58,7 +60,7 @@ for (const { name, fixtureKey, assertValue } of SUBCOMMANDS) { async ({ run, workspace, apiUrl, pgMockPort }) => { setupInspectWorkspace(workspace.path, pgMockPort); await setPgFixture(apiUrl, fixtureKey); - const result = await run(["inspect", "db", name, "--local"]); + const result = await run(["inspect", "db", name]); expect(result.exitCode).toBe(0); expect(result.stdout).toContain(assertValue); }, @@ -69,8 +71,7 @@ for (const { name, fixtureKey, assertValue } of SUBCOMMANDS) { "inspect", "db", name, - "--db-url", - "postgresql://postgres:postgres@localhost:1/postgres", + "--remote=postgresql://postgres:postgres@localhost:1/postgres", ]); expect(result.exitCode).not.toBe(0); expect(result.stderr).not.toBe(""); @@ -82,7 +83,7 @@ describe("inspect:report", () => { testBehaviour("saves CSV files on success", async ({ run, workspace, pgMockPort }) => { setupInspectWorkspace(workspace.path, pgMockPort); const outDir = join(workspace.path, "report-out"); - const result = await run(["inspect", "report", "--local", "--output-dir", outDir]); + const result = await run(["inspect", "report", "--output-dir", outDir]); expect(result.exitCode).toBe(0); expect(result.stderr).toContain("Reports saved to"); const subdirs = readdirSync(outDir); @@ -97,8 +98,7 @@ describe("inspect:report", () => { const result = await run([ "inspect", "report", - "--db-url", - "postgresql://postgres:postgres@localhost:1/postgres", + "--remote=postgresql://postgres:postgres@localhost:1/postgres", ]); expect(result.exitCode).not.toBe(0); expect(result.stderr).not.toBe(""); diff --git a/apps/cli-e2e/src/tests/link-project.ts b/apps/cli-e2e/src/tests/link-project.ts new file mode 100644 index 0000000000..79af00b6c6 --- /dev/null +++ b/apps/cli-e2e/src/tests/link-project.ts @@ -0,0 +1,17 @@ +import { mkdirSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; + +/** Writes .supabase/.temp/project-ref and a stub pooler-url so --remote + * commands resolve a connection without a live postgres TCP probe (nothing + * listens on db.{ref}.localhost:5432 in the test harness); combined with + * SUPABASE_DB_PASSWORD (set in harness.ts), the command reaches its handler + * and makes the Management API call under test. */ +export function linkProject(dir: string, ref: string): void { + const tempDir = join(dir, "supabase", ".temp"); + mkdirSync(tempDir, { recursive: true }); + writeFileSync(join(tempDir, "project-ref"), ref); + writeFileSync( + join(tempDir, "pooler-url"), + `postgresql://postgres.${ref}:[YOUR-PASSWORD]@aws-0-eu-central-1.pooler.supabase.com:6543/postgres`, + ); +} diff --git a/apps/cli-e2e/src/tests/migrations.e2e.test.ts b/apps/cli-e2e/src/tests/migrations.e2e.test.ts index 3a0c349e42..3180c38ae3 100644 --- a/apps/cli-e2e/src/tests/migrations.e2e.test.ts +++ b/apps/cli-e2e/src/tests/migrations.e2e.test.ts @@ -1,4 +1,4 @@ -import { readdirSync } from "node:fs"; +import { mkdirSync, readdirSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { describe, expect } from "vitest"; import { testBehaviour } from "./test-context.ts"; @@ -26,7 +26,7 @@ describe("migrations", () => { describe("migration:list", () => { testBehaviour("exits non-zero on connection refused", async ({ run }) => { - const result = await run(["migration", "list", "--local"]); + const result = await run(["migration", "list"]); expect(result.exitCode).not.toBe(0); expect(result.stderr).toContain("failed to connect"); }); @@ -34,7 +34,7 @@ describe("migrations", () => { describe("migration:up", () => { testBehaviour("exits non-zero on connection refused", async ({ run }) => { - const result = await run(["migration", "up", "--local"]); + const result = await run(["migration", "up"]); expect(result.exitCode).not.toBe(0); expect(result.stderr).toContain("failed to connect"); }); @@ -42,13 +42,13 @@ describe("migrations", () => { describe("migration:down", () => { testBehaviour("exits non-zero on connection refused", async ({ run }) => { - const result = await run(["migration", "down", "--local"]); + const result = await run(["migration", "down"]); expect(result.exitCode).not.toBe(0); expect(result.stderr).toContain("failed to connect"); }); testBehaviour("exits non-zero on connection refused with --last 2", async ({ run }) => { - const result = await run(["migration", "down", "--last", "2", "--local"]); + const result = await run(["migration", "down", "--last", "2"]); expect(result.exitCode).not.toBe(0); expect(result.stderr).toContain("failed to connect"); }); @@ -56,22 +56,18 @@ describe("migrations", () => { describe("migration:repair", () => { testBehaviour("exits non-zero when --status flag is missing", async ({ run }) => { - const result = await run(["migration", "repair", "--local", "20230101000000"]); + const result = await run(["migration", "repair", "20230101000000"]); expect(result.exitCode).not.toBe(0); // A missing required flag's error prints once, without a duplicate // usage dump, and spells the flag name without its `--` prefix. expect(result.stderr).toContain('"status" not set'); }); - testBehaviour("exits non-zero on connection refused", async ({ run }) => { - const result = await run([ - "migration", - "repair", - "--status", - "applied", - "--local", - "20230101000000", - ]); + testBehaviour("exits non-zero on connection refused", async ({ run, workspace }) => { + const migrationsDir = join(workspace.path, "supabase", "migrations"); + mkdirSync(migrationsDir, { recursive: true }); + writeFileSync(join(migrationsDir, "20230101000000_x.sql"), "select 1;\n"); + const result = await run(["migration", "repair", "--status", "applied", "20230101000000"]); expect(result.exitCode).not.toBe(0); expect(result.stderr).toContain("failed to connect"); }); @@ -79,7 +75,7 @@ describe("migrations", () => { describe("migration:squash", () => { testBehaviour("exits non-zero on connection refused", async ({ run }) => { - const result = await run(["migration", "squash", "--local"]); + const result = await run(["migration", "squash"]); expect(result.exitCode).not.toBe(0); expect(result.stderr).not.toBe(""); }); @@ -87,7 +83,7 @@ describe("migrations", () => { describe("migration:fetch", () => { testBehaviour("exits non-zero on connection refused", async ({ run }) => { - const result = await run(["migration", "fetch", "--local"]); + const result = await run(["migration", "fetch"]); expect(result.exitCode).not.toBe(0); expect(result.stderr).toContain("failed to connect"); }); diff --git a/apps/cli-e2e/src/tests/network-security.e2e.test.ts b/apps/cli-e2e/src/tests/network-security.e2e.test.ts index 8cff53bcc4..5e19ae3803 100644 --- a/apps/cli-e2e/src/tests/network-security.e2e.test.ts +++ b/apps/cli-e2e/src/tests/network-security.e2e.test.ts @@ -5,13 +5,7 @@ import { testBehaviour } from "./test-context.ts"; describe("network-bans", () => { describe("network-bans:get", () => { testBehaviour("renders fixture data in output", async ({ run, projectRef }) => { - const result = await run([ - "network-bans", - "get", - "--experimental", - "--project-ref", - projectRef, - ]); + const result = await run(["network-bans", "get", "--experimental", `--remote=${projectRef}`]); expect(result.exitCode).toBe(0); }); @@ -22,8 +16,7 @@ describe("network-bans", () => { "--experimental", "--output", "json", - "--project-ref", - projectRef, + `--remote=${projectRef}`, ]); expect(result.exitCode).toBe(0); }); @@ -34,8 +27,7 @@ describe("network-bans", () => { "get", "--experimental", "--debug", - "--project-ref", - projectRef, + `--remote=${projectRef}`, ]); expect(result.exitCode).toBe(0); expect(result.stderr).toMatch(/HTTP.*GET:/); @@ -51,8 +43,7 @@ describe("network-bans", () => { "network-bans", "get", "--experimental", - "--project-ref", - PROJECT_REF, + `--remote=${PROJECT_REF}`, ]); expect(result.exitCode).not.toBe(0); expect(result.stderr).toContain("Invalid token"); @@ -68,8 +59,7 @@ describe("network-bans", () => { "network-bans", "get", "--experimental", - "--project-ref", - PROJECT_REF, + `--remote=${PROJECT_REF}`, ]); expect(result.exitCode).not.toBe(0); expect(result.stderr).toContain("Forbidden"); @@ -85,8 +75,7 @@ describe("network-bans", () => { "network-bans", "get", "--experimental", - "--project-ref", - PROJECT_REF, + `--remote=${PROJECT_REF}`, ]); expect(result.exitCode).not.toBe(0); expect(result.stderr).toContain("Project not found"); @@ -102,8 +91,7 @@ describe("network-bans", () => { "network-bans", "get", "--experimental", - "--project-ref", - PROJECT_REF, + `--remote=${PROJECT_REF}`, ]); expect(result.exitCode).not.toBe(0); expect(result.stderr).toContain("Too Many Requests"); @@ -119,8 +107,7 @@ describe("network-bans", () => { "network-bans", "get", "--experimental", - "--project-ref", - PROJECT_REF, + `--remote=${PROJECT_REF}`, ]); expect(result.exitCode).not.toBe(0); expect(result.stderr).toContain("Internal Server Error"); @@ -135,8 +122,8 @@ describe("network-bans", () => { "--experimental", "--db-unban-ip", "1.2.3.4", - "--project-ref", - projectRef, + "--yes", + `--remote=${projectRef}`, ]); expect(result.exitCode).toBe(0); }); @@ -150,8 +137,8 @@ describe("network-bans", () => { "1.2.3.4", "--db-unban-ip", "5.6.7.8", - "--project-ref", - projectRef, + "--yes", + `--remote=${projectRef}`, ]); expect(result.exitCode).toBe(0); }); @@ -163,8 +150,8 @@ describe("network-bans", () => { "--experimental", "--db-unban-ip", "invalid-ip", - "--project-ref", - PROJECT_REF, + "--yes", + `--remote=${PROJECT_REF}`, ]); expect(result.exitCode).not.toBe(0); expect(result.stderr).toContain("invalid IP address"); @@ -182,8 +169,8 @@ describe("network-bans", () => { "--experimental", "--db-unban-ip", "1.2.3.4", - "--project-ref", - PROJECT_REF, + "--yes", + `--remote=${PROJECT_REF}`, ]); expect(result.exitCode).not.toBe(0); expect(result.stderr).toContain("Invalid token"); @@ -201,8 +188,8 @@ describe("network-bans", () => { "--experimental", "--db-unban-ip", "1.2.3.4", - "--project-ref", - PROJECT_REF, + "--yes", + `--remote=${PROJECT_REF}`, ]); expect(result.exitCode).not.toBe(0); expect(result.stderr).toContain("Project not found"); @@ -217,8 +204,7 @@ describe("network-restrictions", () => { "network-restrictions", "get", "--experimental", - "--project-ref", - projectRef, + `--remote=${projectRef}`, ]); expect(result.exitCode).toBe(0); }); @@ -230,8 +216,7 @@ describe("network-restrictions", () => { "--experimental", "--output", "json", - "--project-ref", - projectRef, + `--remote=${projectRef}`, ]); expect(result.exitCode).toBe(0); }); @@ -246,8 +231,7 @@ describe("network-restrictions", () => { "network-restrictions", "get", "--experimental", - "--project-ref", - PROJECT_REF, + `--remote=${PROJECT_REF}`, ]); expect(result.exitCode).not.toBe(0); expect(result.stderr).toContain("Invalid token"); @@ -263,8 +247,7 @@ describe("network-restrictions", () => { "network-restrictions", "get", "--experimental", - "--project-ref", - PROJECT_REF, + `--remote=${PROJECT_REF}`, ]); expect(result.exitCode).not.toBe(0); expect(result.stderr).toContain("Forbidden"); @@ -280,8 +263,7 @@ describe("network-restrictions", () => { "network-restrictions", "get", "--experimental", - "--project-ref", - PROJECT_REF, + `--remote=${PROJECT_REF}`, ]); expect(result.exitCode).not.toBe(0); expect(result.stderr).toContain("Project not found"); @@ -297,8 +279,7 @@ describe("network-restrictions", () => { "network-restrictions", "get", "--experimental", - "--project-ref", - PROJECT_REF, + `--remote=${PROJECT_REF}`, ]); expect(result.exitCode).not.toBe(0); expect(result.stderr).toContain("Too Many Requests"); @@ -314,8 +295,7 @@ describe("network-restrictions", () => { "network-restrictions", "get", "--experimental", - "--project-ref", - PROJECT_REF, + `--remote=${PROJECT_REF}`, ]); expect(result.exitCode).not.toBe(0); expect(result.stderr).toContain("Internal Server Error"); @@ -330,8 +310,8 @@ describe("network-restrictions", () => { "--experimental", "--db-allow-cidr", "0.0.0.0/0", - "--project-ref", - projectRef, + "--yes", + `--remote=${projectRef}`, ]); expect(result.exitCode).toBe(0); }); @@ -344,8 +324,8 @@ describe("network-restrictions", () => { "--append", "--db-allow-cidr", "8.8.8.0/24", - "--project-ref", - projectRef, + "--yes", + `--remote=${projectRef}`, ]); expect(result.exitCode).toBe(0); }); @@ -358,8 +338,8 @@ describe("network-restrictions", () => { "--bypass-cidr-checks", "--db-allow-cidr", "0.0.0.0/0", - "--project-ref", - projectRef, + "--yes", + `--remote=${projectRef}`, ]); expect(result.exitCode).toBe(0); }); @@ -371,8 +351,8 @@ describe("network-restrictions", () => { "--experimental", "--db-allow-cidr", "not-a-cidr", - "--project-ref", - PROJECT_REF, + "--yes", + `--remote=${PROJECT_REF}`, ]); expect(result.exitCode).not.toBe(0); expect(result.stderr).toContain("failed to parse IP"); @@ -390,8 +370,8 @@ describe("network-restrictions", () => { "--experimental", "--db-allow-cidr", "0.0.0.0/0", - "--project-ref", - PROJECT_REF, + "--yes", + `--remote=${PROJECT_REF}`, ]); expect(result.exitCode).not.toBe(0); expect(result.stderr).toContain("Invalid token"); @@ -409,8 +389,8 @@ describe("network-restrictions", () => { "--experimental", "--db-allow-cidr", "0.0.0.0/0", - "--project-ref", - PROJECT_REF, + "--yes", + `--remote=${PROJECT_REF}`, ]); expect(result.exitCode).not.toBe(0); expect(result.stderr).toContain("Project not found"); @@ -428,8 +408,8 @@ describe("network-restrictions", () => { "--experimental", "--db-allow-cidr", "0.0.0.0/0", - "--project-ref", - PROJECT_REF, + "--yes", + `--remote=${PROJECT_REF}`, ]); expect(result.exitCode).not.toBe(0); expect(result.stderr).toContain("Too Many Requests"); @@ -447,8 +427,8 @@ describe("network-restrictions", () => { "--experimental", "--db-allow-cidr", "0.0.0.0/0", - "--project-ref", - PROJECT_REF, + "--yes", + `--remote=${PROJECT_REF}`, ]); expect(result.exitCode).not.toBe(0); expect(result.stderr).toContain("Internal Server Error"); @@ -463,8 +443,7 @@ describe("ssl-enforcement", () => { "ssl-enforcement", "get", "--experimental", - "--project-ref", - projectRef, + `--remote=${projectRef}`, ]); expect(result.exitCode).toBe(0); }); @@ -476,8 +455,7 @@ describe("ssl-enforcement", () => { "--experimental", "--output", "json", - "--project-ref", - projectRef, + `--remote=${projectRef}`, ]); expect(result.exitCode).toBe(0); }); @@ -492,8 +470,7 @@ describe("ssl-enforcement", () => { "ssl-enforcement", "get", "--experimental", - "--project-ref", - PROJECT_REF, + `--remote=${PROJECT_REF}`, ]); expect(result.exitCode).not.toBe(0); expect(result.stderr).toContain("Invalid token"); @@ -509,8 +486,7 @@ describe("ssl-enforcement", () => { "ssl-enforcement", "get", "--experimental", - "--project-ref", - PROJECT_REF, + `--remote=${PROJECT_REF}`, ]); expect(result.exitCode).not.toBe(0); expect(result.stderr).toContain("Forbidden"); @@ -526,8 +502,7 @@ describe("ssl-enforcement", () => { "ssl-enforcement", "get", "--experimental", - "--project-ref", - PROJECT_REF, + `--remote=${PROJECT_REF}`, ]); expect(result.exitCode).not.toBe(0); expect(result.stderr).toContain("Project not found"); @@ -543,8 +518,7 @@ describe("ssl-enforcement", () => { "ssl-enforcement", "get", "--experimental", - "--project-ref", - PROJECT_REF, + `--remote=${PROJECT_REF}`, ]); expect(result.exitCode).not.toBe(0); expect(result.stderr).toContain("Too Many Requests"); @@ -560,8 +534,7 @@ describe("ssl-enforcement", () => { "ssl-enforcement", "get", "--experimental", - "--project-ref", - PROJECT_REF, + `--remote=${PROJECT_REF}`, ]); expect(result.exitCode).not.toBe(0); expect(result.stderr).toContain("Internal Server Error"); @@ -575,8 +548,8 @@ describe("ssl-enforcement", () => { "update", "--experimental", "--enable-db-ssl-enforcement", - "--project-ref", - projectRef, + "--yes", + `--remote=${projectRef}`, ]); expect(result.exitCode).toBe(0); }); @@ -587,8 +560,8 @@ describe("ssl-enforcement", () => { "update", "--experimental", "--disable-db-ssl-enforcement", - "--project-ref", - projectRef, + "--yes", + `--remote=${projectRef}`, ]); expect(result.exitCode).toBe(0); }); @@ -600,8 +573,8 @@ describe("ssl-enforcement", () => { "--experimental", "--enable-db-ssl-enforcement", "--disable-db-ssl-enforcement", - "--project-ref", - PROJECT_REF, + "--yes", + `--remote=${PROJECT_REF}`, ]); expect(result.exitCode).not.toBe(0); }); @@ -611,8 +584,8 @@ describe("ssl-enforcement", () => { "ssl-enforcement", "update", "--experimental", - "--project-ref", - PROJECT_REF, + "--yes", + `--remote=${PROJECT_REF}`, ]); expect(result.exitCode).not.toBe(0); }); @@ -628,8 +601,8 @@ describe("ssl-enforcement", () => { "update", "--experimental", "--enable-db-ssl-enforcement", - "--project-ref", - PROJECT_REF, + "--yes", + `--remote=${PROJECT_REF}`, ]); expect(result.exitCode).not.toBe(0); expect(result.stderr).toContain("Invalid token"); @@ -646,8 +619,8 @@ describe("ssl-enforcement", () => { "update", "--experimental", "--enable-db-ssl-enforcement", - "--project-ref", - PROJECT_REF, + "--yes", + `--remote=${PROJECT_REF}`, ]); expect(result.exitCode).not.toBe(0); expect(result.stderr).toContain("Forbidden"); @@ -664,8 +637,8 @@ describe("ssl-enforcement", () => { "update", "--experimental", "--enable-db-ssl-enforcement", - "--project-ref", - PROJECT_REF, + "--yes", + `--remote=${PROJECT_REF}`, ]); expect(result.exitCode).not.toBe(0); expect(result.stderr).toContain("Project not found"); @@ -682,8 +655,8 @@ describe("ssl-enforcement", () => { "update", "--experimental", "--enable-db-ssl-enforcement", - "--project-ref", - PROJECT_REF, + "--yes", + `--remote=${PROJECT_REF}`, ]); expect(result.exitCode).not.toBe(0); expect(result.stderr).toContain("Too Many Requests"); @@ -700,8 +673,8 @@ describe("ssl-enforcement", () => { "update", "--experimental", "--enable-db-ssl-enforcement", - "--project-ref", - PROJECT_REF, + "--yes", + `--remote=${PROJECT_REF}`, ]); expect(result.exitCode).not.toBe(0); expect(result.stderr).toContain("Internal Server Error"); diff --git a/apps/cli-e2e/src/tests/orgs.e2e.test.ts b/apps/cli-e2e/src/tests/orgs.e2e.test.ts index 028ab99aed..74964819b3 100644 --- a/apps/cli-e2e/src/tests/orgs.e2e.test.ts +++ b/apps/cli-e2e/src/tests/orgs.e2e.test.ts @@ -74,14 +74,14 @@ describe("orgs", () => { describe("orgs:create", () => { testBehaviour.skipIf(isRecording)("creates organization with name", async ({ run }) => { - const result = await run(["orgs", "create", ORG_NAME]); + const result = await run(["orgs", "create", ORG_NAME, "--yes"]); expect(result.exitCode).toBe(0); expect(result.stdout).toContain("Created organization:"); expect(result.stdout).toMatch(/[a-z]{20}/); }); testBehaviour("exits non-zero without name in non-TTY", async ({ run }) => { - const result = await run(["orgs", "create"]); + const result = await run(["orgs", "create", "--yes"]); expect(result.exitCode).not.toBe(0); }); @@ -94,7 +94,7 @@ describe("orgs", () => { body: { message: "Organization name already in use" }, }), }); - const result = await run(["orgs", "create", ORG_NAME]); + const result = await run(["orgs", "create", ORG_NAME, "--yes"]); expect(result.exitCode).not.toBe(0); expect(result.stderr).toContain("Organization name already in use"); }); @@ -105,7 +105,7 @@ describe("orgs", () => { headers: { "Content-Type": "application/json" }, body: JSON.stringify({ status: 403, body: { message: "Organization limit reached" } }), }); - const result = await run(["orgs", "create", ORG_NAME]); + const result = await run(["orgs", "create", ORG_NAME, "--yes"]); expect(result.exitCode).not.toBe(0); expect(result.stderr).toContain("Organization limit reached"); }); @@ -116,7 +116,7 @@ describe("orgs", () => { headers: { "Content-Type": "application/json" }, body: JSON.stringify({ status: 422, body: { message: "Invalid organization name" } }), }); - const result = await run(["orgs", "create", ORG_NAME]); + const result = await run(["orgs", "create", ORG_NAME, "--yes"]); expect(result.exitCode).not.toBe(0); expect(result.stderr).toContain("Invalid organization name"); }); @@ -127,7 +127,7 @@ describe("orgs", () => { headers: { "Content-Type": "application/json" }, body: JSON.stringify({ status: 401, body: { message: "Invalid token" } }), }); - const result = await run(["orgs", "create", ORG_NAME]); + const result = await run(["orgs", "create", ORG_NAME, "--yes"]); expect(result.exitCode).not.toBe(0); expect(result.stderr).toContain("Invalid token"); }); @@ -138,7 +138,7 @@ describe("orgs", () => { headers: { "Content-Type": "application/json" }, body: JSON.stringify({ status: 429, body: { message: "Too Many Requests" } }), }); - const result = await run(["orgs", "create", ORG_NAME]); + const result = await run(["orgs", "create", ORG_NAME, "--yes"]); expect(result.exitCode).not.toBe(0); expect(result.stderr).toContain("Too Many Requests"); }); @@ -149,7 +149,7 @@ describe("orgs", () => { headers: { "Content-Type": "application/json" }, body: JSON.stringify({ status: 500, body: { message: "Internal Server Error" } }), }); - const result = await run(["orgs", "create", ORG_NAME]); + const result = await run(["orgs", "create", ORG_NAME, "--yes"]); expect(result.exitCode).not.toBe(0); expect(result.stderr).toContain("Internal Server Error"); }); diff --git a/apps/cli-e2e/src/tests/projects.e2e.test.ts b/apps/cli-e2e/src/tests/projects.e2e.test.ts index e34dc96926..3dd0f694ae 100644 --- a/apps/cli-e2e/src/tests/projects.e2e.test.ts +++ b/apps/cli-e2e/src/tests/projects.e2e.test.ts @@ -68,7 +68,7 @@ describe("projects", () => { describe("projects:api-keys", () => { testBehaviour("shows default and anon keys", async ({ run, projectRef }) => { - const result = await run(["projects", "api-keys", "--project-ref", projectRef]); + const result = await run(["projects", "api-keys", `--remote=${projectRef}`]); expect(result.exitCode).toBe(0); expect(result.stdout).toContain("NAME"); expect(result.stdout).toContain("KEY VALUE"); @@ -82,8 +82,7 @@ describe("projects", () => { "api-keys", "--output", "json", - "--project-ref", - projectRef, + `--remote=${projectRef}`, ]); expect(result.exitCode).toBe(0); const parsed = JSON.parse(result.stdout) as unknown[]; @@ -97,7 +96,7 @@ describe("projects", () => { headers: { "Content-Type": "application/json" }, body: JSON.stringify({ status: 401, body: { message: "Invalid token" } }), }); - const result = await run(["projects", "api-keys", "--project-ref", PROJECT_REF]); + const result = await run(["projects", "api-keys", `--remote=${PROJECT_REF}`]); expect(result.exitCode).not.toBe(0); }); @@ -107,7 +106,7 @@ describe("projects", () => { headers: { "Content-Type": "application/json" }, body: JSON.stringify({ status: 403, body: { message: "Forbidden" } }), }); - const result = await run(["projects", "api-keys", "--project-ref", PROJECT_REF]); + const result = await run(["projects", "api-keys", `--remote=${PROJECT_REF}`]); expect(result.exitCode).not.toBe(0); }); @@ -117,7 +116,7 @@ describe("projects", () => { headers: { "Content-Type": "application/json" }, body: JSON.stringify({ status: 404, body: { message: "Project not found" } }), }); - const result = await run(["projects", "api-keys", "--project-ref", PROJECT_REF]); + const result = await run(["projects", "api-keys", `--remote=${PROJECT_REF}`]); expect(result.exitCode).not.toBe(0); }); }); @@ -136,13 +135,14 @@ describe("projects", () => { "us-east-1", "--size", "micro", + "--yes", ]); expect(result.exitCode).toBe(0); expect(result.stdout).toContain("REFERENCE ID"); }); testBehaviour("exits non-zero without required flags in non-TTY", async ({ run }) => { - const result = await run(["projects", "create", "--org-id", "test-org-id"]); + const result = await run(["projects", "create", "--org-id", "test-org-id", "--yes"]); expect(result.exitCode).not.toBe(0); }); @@ -162,6 +162,7 @@ describe("projects", () => { "password123", "--region", "us-east-1", + "--yes", ]); expect(result.exitCode).not.toBe(0); }); @@ -188,6 +189,7 @@ describe("projects", () => { "password123", "--region", "us-east-1", + "--yes", ]); expect(result.exitCode).not.toBe(0); }); @@ -208,6 +210,7 @@ describe("projects", () => { "password123", "--region", "us-east-1", + "--yes", ]); expect(result.exitCode).not.toBe(0); }); @@ -228,6 +231,7 @@ describe("projects", () => { "password123", "--region", "us-east-1", + "--yes", ]); expect(result.exitCode).not.toBe(0); }); @@ -248,6 +252,7 @@ describe("projects", () => { "password123", "--region", "us-east-1", + "--yes", ]); expect(result.exitCode).not.toBe(0); }); diff --git a/apps/cli-e2e/src/tests/secrets.e2e.test.ts b/apps/cli-e2e/src/tests/secrets.e2e.test.ts index 72f87d279c..a3d2924351 100644 --- a/apps/cli-e2e/src/tests/secrets.e2e.test.ts +++ b/apps/cli-e2e/src/tests/secrets.e2e.test.ts @@ -7,7 +7,7 @@ import { testBehaviour } from "./test-context.ts"; describe("secrets", () => { describe("secrets:list", () => { testBehaviour("renders fixture data in output", async ({ run, projectRef }) => { - const result = await run(["secrets", "list", "--project-ref", projectRef]); + const result = await run(["secrets", "list", `--remote=${projectRef}`]); expect(result.exitCode).toBe(0); expect(result.stdout).toContain("NAME"); expect(result.stdout).toContain("DIGEST"); @@ -19,19 +19,12 @@ describe("secrets", () => { headers: { "Content-Type": "application/json" }, body: JSON.stringify({ status: 401, body: { message: "Invalid token" } }), }); - const result = await run(["secrets", "list", "--project-ref", PROJECT_REF]); + const result = await run(["secrets", "list", `--remote=${PROJECT_REF}`]); expect(result.exitCode).not.toBe(0); }); testBehaviour("returns json output with --output json", async ({ run, projectRef }) => { - const result = await run([ - "secrets", - "list", - "--output", - "json", - "--project-ref", - projectRef, - ]); + const result = await run(["secrets", "list", "--output", "json", `--remote=${projectRef}`]); expect(result.exitCode).toBe(0); const parsed = JSON.parse(result.stdout) as unknown[]; expect(Array.isArray(parsed)).toBe(true); @@ -43,7 +36,7 @@ describe("secrets", () => { headers: { "Content-Type": "application/json" }, body: JSON.stringify({ status: 403, body: { message: "Forbidden" } }), }); - const result = await run(["secrets", "list", "--project-ref", PROJECT_REF]); + const result = await run(["secrets", "list", `--remote=${PROJECT_REF}`]); expect(result.exitCode).not.toBe(0); }); @@ -53,7 +46,7 @@ describe("secrets", () => { headers: { "Content-Type": "application/json" }, body: JSON.stringify({ status: 404, body: { message: "Project not found" } }), }); - const result = await run(["secrets", "list", "--project-ref", PROJECT_REF]); + const result = await run(["secrets", "list", `--remote=${PROJECT_REF}`]); expect(result.exitCode).not.toBe(0); }); @@ -63,7 +56,7 @@ describe("secrets", () => { headers: { "Content-Type": "application/json" }, body: JSON.stringify({ status: 429, body: { message: "Too Many Requests" } }), }); - const result = await run(["secrets", "list", "--project-ref", PROJECT_REF]); + const result = await run(["secrets", "list", `--remote=${PROJECT_REF}`]); expect(result.exitCode).not.toBe(0); }); @@ -73,19 +66,19 @@ describe("secrets", () => { headers: { "Content-Type": "application/json" }, body: JSON.stringify({ status: 500, body: { message: "Internal Server Error" } }), }); - const result = await run(["secrets", "list", "--project-ref", PROJECT_REF]); + const result = await run(["secrets", "list", `--remote=${PROJECT_REF}`]); expect(result.exitCode).not.toBe(0); }); testBehaviour("includes debug output with --debug", async ({ run, projectRef }) => { - const result = await run(["secrets", "list", "--debug", "--project-ref", projectRef]); + const result = await run(["secrets", "list", "--debug", `--remote=${projectRef}`]); expect(result.exitCode).toBe(0); }); }); describe("secrets:set", () => { testBehaviour("sets a single secret", async ({ run, projectRef }) => { - const result = await run(["secrets", "set", "FOO=bar", "--project-ref", projectRef, "--yes"]); + const result = await run(["secrets", "set", "FOO=bar", "--yes", `--remote=${projectRef}`]); expect(result.exitCode).toBe(0); expect(result.stdout).toContain("Finished"); }); @@ -96,9 +89,8 @@ describe("secrets", () => { "set", "FOO=bar", "BAZ=qux", - "--project-ref", - projectRef, "--yes", + `--remote=${projectRef}`, ]); expect(result.exitCode).toBe(0); expect(result.stdout).toContain("Finished"); @@ -111,9 +103,8 @@ describe("secrets", () => { "set", "--env-file", ".env.local", - "--project-ref", - projectRef, "--yes", + `--remote=${projectRef}`, ]); expect(result.exitCode).toBe(0); expect(result.stdout).toContain("Finished"); @@ -128,14 +119,7 @@ describe("secrets", () => { body: { message: "Validation failed", errors: [{ message: "Invalid secret name" }] }, }), }); - const result = await run([ - "secrets", - "set", - "FOO=bar", - "--project-ref", - PROJECT_REF, - "--yes", - ]); + const result = await run(["secrets", "set", "FOO=bar", "--yes", `--remote=${PROJECT_REF}`]); expect(result.exitCode).not.toBe(0); }); @@ -145,9 +129,8 @@ describe("secrets", () => { "set", "--env-file", "nonexistent.env", - "--project-ref", - PROJECT_REF, "--yes", + `--remote=${PROJECT_REF}`, ]); expect(result.exitCode).not.toBe(0); }); @@ -158,35 +141,27 @@ 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, - "--yes", - ]); + const result = await run(["secrets", "set", "FOO=bar", "--yes", `--remote=${PROJECT_REF}`]); expect(result.exitCode).not.toBe(0); }); }); describe("secrets:unset", () => { testBehaviour("removes a secret", async ({ run, projectRef }) => { - const result = await run(["secrets", "unset", "FOO", "--project-ref", projectRef, "--yes"]); + const result = await run(["secrets", "unset", "FOO", "--yes", `--remote=${projectRef}`]); expect(result.exitCode).toBe(0); expect(result.stdout).toContain("Finished"); }); testBehaviour("removes multiple secrets", async ({ run, projectRef }) => { - await run(["secrets", "set", "FOO=bar", "BAR=baz", "--project-ref", projectRef, "--yes"]); + await run(["secrets", "set", "FOO=bar", "BAR=baz", "--yes", `--remote=${projectRef}`]); const result = await run([ "secrets", "unset", "FOO", "BAR", - "--project-ref", - projectRef, "--yes", + `--remote=${projectRef}`, ]); expect(result.exitCode).toBe(0); expect(result.stdout).toContain("Finished"); @@ -202,9 +177,8 @@ describe("secrets", () => { "secrets", "unset", "NONEXISTENT", - "--project-ref", - PROJECT_REF, "--yes", + `--remote=${PROJECT_REF}`, ]); expect(result.exitCode).not.toBe(0); }); @@ -215,7 +189,7 @@ describe("secrets", () => { headers: { "Content-Type": "application/json" }, body: JSON.stringify({ status: 401, body: { message: "Invalid token" } }), }); - const result = await run(["secrets", "unset", "FOO", "--project-ref", PROJECT_REF, "--yes"]); + const result = await run(["secrets", "unset", "FOO", "--yes", `--remote=${PROJECT_REF}`]); expect(result.exitCode).not.toBe(0); }); @@ -225,7 +199,7 @@ describe("secrets", () => { headers: { "Content-Type": "application/json" }, body: JSON.stringify({ status: 500, body: { message: "Internal Server Error" } }), }); - const result = await run(["secrets", "unset", "FOO", "--project-ref", PROJECT_REF, "--yes"]); + const result = await run(["secrets", "unset", "FOO", "--yes", `--remote=${PROJECT_REF}`]); expect(result.exitCode).not.toBe(0); }); }); diff --git a/apps/cli-e2e/src/tests/sso.e2e.test.ts b/apps/cli-e2e/src/tests/sso.e2e.test.ts index f717699f92..af6a369fc4 100644 --- a/apps/cli-e2e/src/tests/sso.e2e.test.ts +++ b/apps/cli-e2e/src/tests/sso.e2e.test.ts @@ -14,19 +14,19 @@ const MINIMAL_SAML_XML = `<?xml version="1.0" encoding="UTF-8"?> describe("sso", () => { describe("sso:list", () => { testBehaviour("renders fixture data in output", async ({ run, projectRef }) => { - const result = await run(["sso", "list", "--project-ref", projectRef]); + const result = await run(["sso", "list", `--remote=${projectRef}`]); expect(result.exitCode).toBe(0); expect(result.stdout).toContain("IDENTITY PROVIDER ID"); }); testBehaviour("returns json output with --output json", async ({ run, projectRef }) => { - const result = await run(["sso", "list", "--output", "json", "--project-ref", projectRef]); + const result = await run(["sso", "list", "--output", "json", `--remote=${projectRef}`]); expect(result.exitCode).toBe(0); expect(JSON.parse(result.stdout)).toMatchObject({ providers: [] }); }); testBehaviour("includes debug output with --debug", async ({ run, projectRef }) => { - const result = await run(["sso", "list", "--debug", "--project-ref", projectRef]); + const result = await run(["sso", "list", "--debug", `--remote=${projectRef}`]); expect(result.exitCode).toBe(0); expect(result.stderr).toMatch(/HTTP.*GET:/); }); @@ -37,7 +37,7 @@ describe("sso", () => { headers: { "Content-Type": "application/json" }, body: JSON.stringify({ status: 401, body: { message: "Invalid token" } }), }); - const result = await run(["sso", "list", "--project-ref", PROJECT_REF]); + const result = await run(["sso", "list", `--remote=${PROJECT_REF}`]); expect(result.exitCode).not.toBe(0); expect(result.stderr).toContain("Invalid token"); }); @@ -48,7 +48,7 @@ describe("sso", () => { headers: { "Content-Type": "application/json" }, body: JSON.stringify({ status: 403, body: { message: "Forbidden" } }), }); - const result = await run(["sso", "list", "--project-ref", PROJECT_REF]); + const result = await run(["sso", "list", `--remote=${PROJECT_REF}`]); expect(result.exitCode).not.toBe(0); expect(result.stderr).toContain("Forbidden"); }); @@ -59,7 +59,7 @@ describe("sso", () => { headers: { "Content-Type": "application/json" }, body: JSON.stringify({ status: 404, body: { message: "Project not found" } }), }); - const result = await run(["sso", "list", "--project-ref", PROJECT_REF]); + const result = await run(["sso", "list", `--remote=${PROJECT_REF}`]); expect(result.exitCode).not.toBe(0); expect(result.stderr).toContain("SAML 2.0 support is not enabled"); }); @@ -70,7 +70,7 @@ describe("sso", () => { headers: { "Content-Type": "application/json" }, body: JSON.stringify({ status: 429, body: { message: "Too Many Requests" } }), }); - const result = await run(["sso", "list", "--project-ref", PROJECT_REF]); + const result = await run(["sso", "list", `--remote=${PROJECT_REF}`]); expect(result.exitCode).not.toBe(0); expect(result.stderr).toContain("Too Many Requests"); }); @@ -81,7 +81,7 @@ describe("sso", () => { headers: { "Content-Type": "application/json" }, body: JSON.stringify({ status: 500, body: { message: "Internal Server Error" } }), }); - const result = await run(["sso", "list", "--project-ref", PROJECT_REF]); + const result = await run(["sso", "list", `--remote=${PROJECT_REF}`]); expect(result.exitCode).not.toBe(0); expect(result.stderr).toContain("Internal Server Error"); }); @@ -89,13 +89,13 @@ describe("sso", () => { describe("sso:info", () => { testBehaviour("renders fixture data in output", async ({ run, projectRef }) => { - const result = await run(["sso", "info", "--project-ref", projectRef]); + const result = await run(["sso", "info", `--remote=${projectRef}`]); expect(result.exitCode).toBe(0); expect(result.stdout).toContain("supabase.co/auth/v1/sso/saml/acs"); }); testBehaviour("returns json output with --output json", async ({ run, projectRef }) => { - const result = await run(["sso", "info", "--output", "json", "--project-ref", projectRef]); + const result = await run(["sso", "info", "--output", "json", `--remote=${projectRef}`]); expect(result.exitCode).toBe(0); expect(JSON.parse(result.stdout)).toMatchObject({ acs_url: expect.stringContaining("supabase.co/auth/v1/sso/saml/acs"), @@ -109,7 +109,7 @@ describe("sso", () => { testBehaviour.skipIf(isRecording)( "renders fixture data in output", async ({ run, projectRef }) => { - const result = await run(["sso", "show", PROVIDER_ID, "--project-ref", projectRef]); + const result = await run(["sso", "show", PROVIDER_ID, `--remote=${projectRef}`]); expect(result.exitCode).toBe(0); expect(result.stdout).toContain("example.com"); }, @@ -124,8 +124,7 @@ describe("sso", () => { PROVIDER_ID, "--output", "json", - "--project-ref", - projectRef, + `--remote=${projectRef}`, ]); expect(result.exitCode).toBe(0); expect(result.stdout).toContain("example.com"); @@ -140,8 +139,7 @@ describe("sso", () => { "show", PROVIDER_ID, "--metadata", - "--project-ref", - projectRef, + `--remote=${projectRef}`, ]); expect(result.exitCode).toBe(0); expect(result.stdout).toContain("EntityDescriptor"); @@ -154,7 +152,7 @@ describe("sso", () => { headers: { "Content-Type": "application/json" }, body: JSON.stringify({ status: 401, body: { message: "Invalid token" } }), }); - const result = await run(["sso", "show", PROVIDER_ID, "--project-ref", PROJECT_REF]); + const result = await run(["sso", "show", PROVIDER_ID, `--remote=${PROJECT_REF}`]); expect(result.exitCode).not.toBe(0); expect(result.stderr).toContain("Invalid token"); }); @@ -165,7 +163,7 @@ describe("sso", () => { headers: { "Content-Type": "application/json" }, body: JSON.stringify({ status: 403, body: { message: "Forbidden" } }), }); - const result = await run(["sso", "show", PROVIDER_ID, "--project-ref", PROJECT_REF]); + const result = await run(["sso", "show", PROVIDER_ID, `--remote=${PROJECT_REF}`]); expect(result.exitCode).not.toBe(0); expect(result.stderr).toContain("Forbidden"); }); @@ -176,7 +174,7 @@ describe("sso", () => { headers: { "Content-Type": "application/json" }, body: JSON.stringify({ status: 404, body: { message: "SSO Identity Provider not found" } }), }); - const result = await run(["sso", "show", PROVIDER_ID, "--project-ref", PROJECT_REF]); + const result = await run(["sso", "show", PROVIDER_ID, `--remote=${PROJECT_REF}`]); expect(result.exitCode).not.toBe(0); expect(result.stderr).toContain("could not be found"); }); @@ -187,7 +185,7 @@ describe("sso", () => { headers: { "Content-Type": "application/json" }, body: JSON.stringify({ status: 429, body: { message: "Too Many Requests" } }), }); - const result = await run(["sso", "show", PROVIDER_ID, "--project-ref", PROJECT_REF]); + const result = await run(["sso", "show", PROVIDER_ID, `--remote=${PROJECT_REF}`]); expect(result.exitCode).not.toBe(0); expect(result.stderr).toContain("Too Many Requests"); }); @@ -198,7 +196,7 @@ describe("sso", () => { headers: { "Content-Type": "application/json" }, body: JSON.stringify({ status: 500, body: { message: "Internal Server Error" } }), }); - const result = await run(["sso", "show", PROVIDER_ID, "--project-ref", PROJECT_REF]); + const result = await run(["sso", "show", PROVIDER_ID, `--remote=${PROJECT_REF}`]); expect(result.exitCode).not.toBe(0); expect(result.stderr).toContain("Internal Server Error"); }); @@ -219,8 +217,8 @@ describe("sso", () => { metadataPath, "--domains", "example.com", - "--project-ref", - projectRef, + "--yes", + `--remote=${projectRef}`, ]); expect(result.exitCode).toBe(0); expect(result.stdout).toContain("example.com"); @@ -234,8 +232,8 @@ describe("sso", () => { "--metadata-url", "https://example.com/saml/metadata", "--skip-url-validation", - "--project-ref", - PROJECT_REF, + "--yes", + `--remote=${PROJECT_REF}`, ]); expect(result.exitCode).not.toBe(0); expect(result.stderr).toContain('"type"'); @@ -255,8 +253,8 @@ describe("sso", () => { "https://example.com/saml/metadata", "--metadata-file", metadataPath, - "--project-ref", - PROJECT_REF, + "--yes", + `--remote=${PROJECT_REF}`, ]); expect(result.exitCode).not.toBe(0); expect(result.stderr).toContain("metadata"); @@ -271,8 +269,8 @@ describe("sso", () => { "saml", "--metadata-url", "http://localhost:19999/saml.xml", - "--project-ref", - PROJECT_REF, + "--yes", + `--remote=${PROJECT_REF}`, ]); expect(result.exitCode).not.toBe(0); expect(result.stderr).toContain("HTTPS"); @@ -293,8 +291,8 @@ describe("sso", () => { "saml", "--metadata-file", metadataPath, - "--project-ref", - PROJECT_REF, + "--yes", + `--remote=${PROJECT_REF}`, ]); expect(result.exitCode).not.toBe(0); expect(result.stderr).toContain("Invalid token"); @@ -315,8 +313,8 @@ describe("sso", () => { "saml", "--metadata-file", metadataPath, - "--project-ref", - PROJECT_REF, + "--yes", + `--remote=${PROJECT_REF}`, ]); expect(result.exitCode).not.toBe(0); expect(result.stderr).toContain("Forbidden"); @@ -340,8 +338,8 @@ describe("sso", () => { "saml", "--metadata-file", metadataPath, - "--project-ref", - PROJECT_REF, + "--yes", + `--remote=${PROJECT_REF}`, ]); expect(result.exitCode).not.toBe(0); expect(result.stderr).toContain("Invalid SAML metadata"); @@ -362,8 +360,8 @@ describe("sso", () => { "saml", "--metadata-file", metadataPath, - "--project-ref", - PROJECT_REF, + "--yes", + `--remote=${PROJECT_REF}`, ]); expect(result.exitCode).not.toBe(0); expect(result.stderr).toContain("Too Many Requests"); @@ -384,8 +382,8 @@ describe("sso", () => { "saml", "--metadata-file", metadataPath, - "--project-ref", - PROJECT_REF, + "--yes", + `--remote=${PROJECT_REF}`, ]); expect(result.exitCode).not.toBe(0); expect(result.stderr).toContain("Internal Server Error"); @@ -402,8 +400,8 @@ describe("sso", () => { PROVIDER_ID, "--add-domains", "example.com", - "--project-ref", - projectRef, + "--yes", + `--remote=${projectRef}`, ]); expect(result.exitCode).toBe(0); expect(result.stdout).toContain("example.com"); @@ -419,8 +417,8 @@ describe("sso", () => { PROVIDER_ID, "--domains", "new.com", - "--project-ref", - projectRef, + "--yes", + `--remote=${projectRef}`, ]); expect(result.exitCode).toBe(0); expect(result.stdout).toContain("new.com"); @@ -436,8 +434,8 @@ describe("sso", () => { PROVIDER_ID, "--remove-domains", "example.com", - "--project-ref", - projectRef, + "--yes", + `--remote=${projectRef}`, ]); expect(result.exitCode).toBe(0); expect(result.stdout).toContain("IDENTITY PROVIDER ID"); @@ -455,8 +453,8 @@ describe("sso", () => { PROVIDER_ID, "--metadata-file", metadataPath, - "--project-ref", - projectRef, + "--yes", + `--remote=${projectRef}`, ]); expect(result.exitCode).toBe(0); expect(result.stdout).toContain("EntityDescriptor"); @@ -472,8 +470,8 @@ describe("sso", () => { "a.com", "--add-domains", "b.com", - "--project-ref", - PROJECT_REF, + "--yes", + `--remote=${PROJECT_REF}`, ]); expect(result.exitCode).not.toBe(0); expect(result.stderr).toContain("domains"); @@ -491,8 +489,8 @@ describe("sso", () => { PROVIDER_ID, "--add-domains", "example.com", - "--project-ref", - PROJECT_REF, + "--yes", + `--remote=${PROJECT_REF}`, ]); expect(result.exitCode).not.toBe(0); expect(result.stderr).toContain("Invalid token"); @@ -510,8 +508,8 @@ describe("sso", () => { PROVIDER_ID, "--add-domains", "example.com", - "--project-ref", - PROJECT_REF, + "--yes", + `--remote=${PROJECT_REF}`, ]); expect(result.exitCode).not.toBe(0); expect(result.stderr).toContain("Forbidden"); @@ -529,8 +527,8 @@ describe("sso", () => { PROVIDER_ID, "--add-domains", "example.com", - "--project-ref", - PROJECT_REF, + "--yes", + `--remote=${PROJECT_REF}`, ]); expect(result.exitCode).not.toBe(0); expect(result.stderr).toContain("could not be found"); @@ -548,8 +546,8 @@ describe("sso", () => { PROVIDER_ID, "--add-domains", "example.com", - "--project-ref", - PROJECT_REF, + "--yes", + `--remote=${PROJECT_REF}`, ]); expect(result.exitCode).not.toBe(0); expect(result.stderr).toContain("Too Many Requests"); @@ -567,8 +565,8 @@ describe("sso", () => { PROVIDER_ID, "--add-domains", "example.com", - "--project-ref", - PROJECT_REF, + "--yes", + `--remote=${PROJECT_REF}`, ]); expect(result.exitCode).not.toBe(0); expect(result.stderr).toContain("Internal Server Error"); @@ -577,7 +575,7 @@ describe("sso", () => { describe("sso:remove", () => { testBehaviour.skipIf(isRecording)("removes a provider", async ({ run }) => { - const result = await run(["sso", "remove", PROVIDER_ID, "--project-ref", PROJECT_REF]); + const result = await run(["sso", "remove", PROVIDER_ID, "--yes", `--remote=${PROJECT_REF}`]); expect(result.exitCode).toBe(0); expect(result.stdout).toContain("example.com"); }); @@ -588,7 +586,7 @@ describe("sso", () => { headers: { "Content-Type": "application/json" }, body: JSON.stringify({ status: 401, body: { message: "Invalid token" } }), }); - const result = await run(["sso", "remove", PROVIDER_ID, "--project-ref", PROJECT_REF]); + const result = await run(["sso", "remove", PROVIDER_ID, "--yes", `--remote=${PROJECT_REF}`]); expect(result.exitCode).not.toBe(0); expect(result.stderr).toContain("Invalid token"); }); @@ -599,7 +597,7 @@ describe("sso", () => { headers: { "Content-Type": "application/json" }, body: JSON.stringify({ status: 403, body: { message: "Forbidden" } }), }); - const result = await run(["sso", "remove", PROVIDER_ID, "--project-ref", PROJECT_REF]); + const result = await run(["sso", "remove", PROVIDER_ID, "--yes", `--remote=${PROJECT_REF}`]); expect(result.exitCode).not.toBe(0); expect(result.stderr).toContain("Forbidden"); }); @@ -610,7 +608,7 @@ describe("sso", () => { headers: { "Content-Type": "application/json" }, body: JSON.stringify({ status: 404, body: { message: "SSO Identity Provider not found" } }), }); - const result = await run(["sso", "remove", PROVIDER_ID, "--project-ref", PROJECT_REF]); + const result = await run(["sso", "remove", PROVIDER_ID, "--yes", `--remote=${PROJECT_REF}`]); expect(result.exitCode).not.toBe(0); expect(result.stderr).toContain("could not be found"); }); @@ -621,7 +619,7 @@ describe("sso", () => { headers: { "Content-Type": "application/json" }, body: JSON.stringify({ status: 429, body: { message: "Too Many Requests" } }), }); - const result = await run(["sso", "remove", PROVIDER_ID, "--project-ref", PROJECT_REF]); + const result = await run(["sso", "remove", PROVIDER_ID, "--yes", `--remote=${PROJECT_REF}`]); expect(result.exitCode).not.toBe(0); expect(result.stderr).toContain("Too Many Requests"); }); @@ -632,7 +630,7 @@ describe("sso", () => { headers: { "Content-Type": "application/json" }, body: JSON.stringify({ status: 500, body: { message: "Internal Server Error" } }), }); - const result = await run(["sso", "remove", PROVIDER_ID, "--project-ref", PROJECT_REF]); + const result = await run(["sso", "remove", PROVIDER_ID, "--yes", `--remote=${PROJECT_REF}`]); expect(result.exitCode).not.toBe(0); expect(result.stderr).toContain("Internal Server Error"); }); diff --git a/apps/cli-e2e/src/tests/storage.e2e.test.ts b/apps/cli-e2e/src/tests/storage.e2e.test.ts index b0975ba22d..e7f9a66769 100644 --- a/apps/cli-e2e/src/tests/storage.e2e.test.ts +++ b/apps/cli-e2e/src/tests/storage.e2e.test.ts @@ -4,7 +4,7 @@ import { describe, expect } from "vitest"; import { testBehaviour } from "./test-context.ts"; const BUCKET = "cli-e2e-bucket"; -const LOCAL_FLAGS = ["--experimental", "--local"]; +const EXPERIMENTAL_FLAGS = ["--experimental"]; function setupStorageWorkspace(dir: string, relayUrl: string): void { mkdirSync(join(dir, "supabase"), { recursive: true }); @@ -42,7 +42,7 @@ async function clearOverrides(apiUrl: string): Promise<void> { describe("storage ls", () => { testBehaviour("lists objects in bucket", async ({ workspace, run, apiUrl }) => { setupStorageWorkspace(workspace.path, apiUrl); - const result = await run(["storage", "ls", ...LOCAL_FLAGS, `ss:///${BUCKET}/`]); + const result = await run(["storage", "ls", ...EXPERIMENTAL_FLAGS, `ss:///${BUCKET}/`]); expect(result.exitCode).toBe(0); expect(result.stdout).toContain("hello.txt"); const requests = await getRequestLog(apiUrl); @@ -55,7 +55,7 @@ describe("storage ls", () => { testBehaviour("lists objects recursively", async ({ workspace, run, apiUrl }) => { setupStorageWorkspace(workspace.path, apiUrl); - const result = await run(["storage", "ls", ...LOCAL_FLAGS, "-r", `ss:///${BUCKET}/`]); + const result = await run(["storage", "ls", ...EXPERIMENTAL_FLAGS, "-r", `ss:///${BUCKET}/`]); expect(result.exitCode).toBe(0); const requests = await getRequestLog(apiUrl); expect( @@ -68,7 +68,7 @@ describe("storage ls", () => { testBehaviour("exits 1 on 401", async ({ workspace, run, apiUrl }) => { setupStorageWorkspace(workspace.path, apiUrl); await injectGlobalError(apiUrl, 401, "Invalid token"); - const result = await run(["storage", "ls", ...LOCAL_FLAGS, `ss:///${BUCKET}/`]); + const result = await run(["storage", "ls", ...EXPERIMENTAL_FLAGS, `ss:///${BUCKET}/`]); expect(result.exitCode).toBe(1); expect(result.stderr).toContain("Invalid token"); await clearOverrides(apiUrl); @@ -77,7 +77,7 @@ describe("storage ls", () => { testBehaviour("exits 1 on 403", async ({ workspace, run, apiUrl }) => { setupStorageWorkspace(workspace.path, apiUrl); await injectGlobalError(apiUrl, 403, "Forbidden"); - const result = await run(["storage", "ls", ...LOCAL_FLAGS, `ss:///${BUCKET}/`]); + const result = await run(["storage", "ls", ...EXPERIMENTAL_FLAGS, `ss:///${BUCKET}/`]); expect(result.exitCode).toBe(1); expect(result.stderr.length).toBeGreaterThan(0); await clearOverrides(apiUrl); @@ -86,7 +86,7 @@ describe("storage ls", () => { testBehaviour("exits 1 on 429", async ({ workspace, run, apiUrl }) => { setupStorageWorkspace(workspace.path, apiUrl); await injectGlobalError(apiUrl, 429, "Too Many Requests"); - const result = await run(["storage", "ls", ...LOCAL_FLAGS, `ss:///${BUCKET}/`]); + const result = await run(["storage", "ls", ...EXPERIMENTAL_FLAGS, `ss:///${BUCKET}/`]); expect(result.exitCode).toBe(1); expect(result.stderr.length).toBeGreaterThan(0); await clearOverrides(apiUrl); @@ -95,7 +95,7 @@ describe("storage ls", () => { testBehaviour("exits 1 on 500", async ({ workspace, run, apiUrl }) => { setupStorageWorkspace(workspace.path, apiUrl); await injectGlobalError(apiUrl, 500, "Internal Server Error"); - const result = await run(["storage", "ls", ...LOCAL_FLAGS, `ss:///${BUCKET}/`]); + const result = await run(["storage", "ls", ...EXPERIMENTAL_FLAGS, `ss:///${BUCKET}/`]); expect(result.exitCode).toBe(1); expect(result.stderr.length).toBeGreaterThan(0); await clearOverrides(apiUrl); @@ -108,7 +108,7 @@ describe("storage cp", () => { const result = await run([ "storage", "cp", - ...LOCAL_FLAGS, + ...EXPERIMENTAL_FLAGS, "upload.txt", `ss:///${BUCKET}/upload.txt`, ]); @@ -126,7 +126,7 @@ describe("storage cp", () => { const result = await run([ "storage", "cp", - ...LOCAL_FLAGS, + ...EXPERIMENTAL_FLAGS, "--cache-control", "no-cache", "upload.txt", @@ -146,7 +146,7 @@ describe("storage cp", () => { const result = await run([ "storage", "cp", - ...LOCAL_FLAGS, + ...EXPERIMENTAL_FLAGS, "--content-type", "application/json", "upload.txt", @@ -166,7 +166,7 @@ describe("storage cp", () => { const result = await run([ "storage", "cp", - ...LOCAL_FLAGS, + ...EXPERIMENTAL_FLAGS, "nonexistent.txt", `ss:///${BUCKET}/x.txt`, ]); @@ -179,7 +179,7 @@ describe("storage cp", () => { const result = await run([ "storage", "cp", - ...LOCAL_FLAGS, + ...EXPERIMENTAL_FLAGS, "upload.txt", `ss:///${BUCKET}/upload.txt`, ]); @@ -194,7 +194,7 @@ describe("storage cp", () => { const result = await run([ "storage", "cp", - ...LOCAL_FLAGS, + ...EXPERIMENTAL_FLAGS, "upload.txt", `ss:///${BUCKET}/upload.txt`, ]); @@ -209,7 +209,7 @@ describe("storage cp", () => { const result = await run([ "storage", "cp", - ...LOCAL_FLAGS, + ...EXPERIMENTAL_FLAGS, "upload.txt", `ss:///${BUCKET}/upload.txt`, ]); @@ -224,7 +224,7 @@ describe("storage cp", () => { const result = await run([ "storage", "cp", - ...LOCAL_FLAGS, + ...EXPERIMENTAL_FLAGS, "upload.txt", `ss:///${BUCKET}/upload.txt`, ]); @@ -238,7 +238,7 @@ describe("storage cp", () => { const result = await run([ "storage", "cp", - ...LOCAL_FLAGS, + ...EXPERIMENTAL_FLAGS, `ss:///${BUCKET}/hello.txt`, "hello-download.txt", ]); @@ -250,11 +250,17 @@ describe("storage mv", () => { testBehaviour("moves file within bucket", async ({ workspace, run, apiUrl }) => { setupStorageWorkspace(workspace.path, apiUrl); // Upload source file so the move has something to move in staging. - await run(["storage", "cp", ...LOCAL_FLAGS, "upload.txt", `ss:///${BUCKET}/mv-source.txt`]); + await run([ + "storage", + "cp", + ...EXPERIMENTAL_FLAGS, + "upload.txt", + `ss:///${BUCKET}/mv-source.txt`, + ]); const result = await run([ "storage", "mv", - ...LOCAL_FLAGS, + ...EXPERIMENTAL_FLAGS, `ss:///${BUCKET}/mv-source.txt`, `ss:///${BUCKET}/mv-dest.txt`, ]); @@ -277,7 +283,7 @@ describe("storage mv", () => { const result = await run([ "storage", "mv", - ...LOCAL_FLAGS, + ...EXPERIMENTAL_FLAGS, `ss:///${BUCKET}/a.txt`, `ss:///${BUCKET}/b.txt`, ]); @@ -292,7 +298,7 @@ describe("storage mv", () => { const result = await run([ "storage", "mv", - ...LOCAL_FLAGS, + ...EXPERIMENTAL_FLAGS, `ss:///${BUCKET}/a.txt`, `ss:///${BUCKET}/b.txt`, ]); @@ -307,7 +313,7 @@ describe("storage mv", () => { const result = await run([ "storage", "mv", - ...LOCAL_FLAGS, + ...EXPERIMENTAL_FLAGS, `ss:///${BUCKET}/a.txt`, `ss:///${BUCKET}/b.txt`, ]); @@ -322,7 +328,7 @@ describe("storage mv", () => { const result = await run([ "storage", "mv", - ...LOCAL_FLAGS, + ...EXPERIMENTAL_FLAGS, `ss:///${BUCKET}/a.txt`, `ss:///${BUCKET}/b.txt`, ]); @@ -336,12 +342,18 @@ describe("storage rm", () => { testBehaviour("removes a file from storage", async ({ workspace, run, apiUrl }) => { setupStorageWorkspace(workspace.path, apiUrl); // Upload the file to remove. - await run(["storage", "cp", ...LOCAL_FLAGS, "upload.txt", `ss:///${BUCKET}/rm-target.txt`]); + await run([ + "storage", + "cp", + ...EXPERIMENTAL_FLAGS, + "upload.txt", + `ss:///${BUCKET}/rm-target.txt`, + ]); const result = await run([ "storage", "rm", "--yes", - ...LOCAL_FLAGS, + ...EXPERIMENTAL_FLAGS, `ss:///${BUCKET}/rm-target.txt`, ]); expect(result.exitCode).toBe(0); @@ -356,13 +368,13 @@ describe("storage rm", () => { testBehaviour("removes multiple files", async ({ workspace, run, apiUrl }) => { setupStorageWorkspace(workspace.path, apiUrl); writeFileSync(join(workspace.path, "file2.txt"), "second file"); - await run(["storage", "cp", ...LOCAL_FLAGS, "upload.txt", `ss:///${BUCKET}/rm-a.txt`]); - await run(["storage", "cp", ...LOCAL_FLAGS, "file2.txt", `ss:///${BUCKET}/rm-b.txt`]); + await run(["storage", "cp", ...EXPERIMENTAL_FLAGS, "upload.txt", `ss:///${BUCKET}/rm-a.txt`]); + await run(["storage", "cp", ...EXPERIMENTAL_FLAGS, "file2.txt", `ss:///${BUCKET}/rm-b.txt`]); const result = await run([ "storage", "rm", "--yes", - ...LOCAL_FLAGS, + ...EXPERIMENTAL_FLAGS, `ss:///${BUCKET}/rm-a.txt`, `ss:///${BUCKET}/rm-b.txt`, ]); @@ -382,7 +394,7 @@ describe("storage rm", () => { "storage", "rm", "--yes", - ...LOCAL_FLAGS, + ...EXPERIMENTAL_FLAGS, `ss:///${BUCKET}/file.txt`, ]); expect(result.exitCode).toBe(1); @@ -397,7 +409,7 @@ describe("storage rm", () => { "storage", "rm", "--yes", - ...LOCAL_FLAGS, + ...EXPERIMENTAL_FLAGS, `ss:///${BUCKET}/file.txt`, ]); expect(result.exitCode).toBe(1); @@ -412,7 +424,7 @@ describe("storage rm", () => { "storage", "rm", "--yes", - ...LOCAL_FLAGS, + ...EXPERIMENTAL_FLAGS, `ss:///${BUCKET}/file.txt`, ]); expect(result.exitCode).toBe(1); @@ -427,7 +439,7 @@ describe("storage rm", () => { "storage", "rm", "--yes", - ...LOCAL_FLAGS, + ...EXPERIMENTAL_FLAGS, `ss:///${BUCKET}/file.txt`, ]); expect(result.exitCode).toBe(1); diff --git a/apps/cli-e2e/src/tests/target-selection.e2e.test.ts b/apps/cli-e2e/src/tests/target-selection.e2e.test.ts new file mode 100644 index 0000000000..1f7613c6f9 --- /dev/null +++ b/apps/cli-e2e/src/tests/target-selection.e2e.test.ts @@ -0,0 +1,147 @@ +import { describe, expect } from "vitest"; +import { linkProject } from "./link-project.ts"; +import { testBehaviour } from "./test-context.ts"; + +const BRANCH_PROJECT_REF = "bbbbbbbbbbbbbbbbbbbb"; +const DEAD_DB_URL = "postgresql://postgres:postgres@localhost:1/postgres"; + +interface RequestEntry { + method: string; + pathname: string; +} + +async function getRequestLog(apiUrl: string): Promise<RequestEntry[]> { + const res = await fetch(`${apiUrl}/_ctrl/requests`); + return res.json() as Promise<RequestEntry[]>; +} + +describe("deprecated target aliases", () => { + testBehaviour( + "warns that --linked is deprecated and still targets the linked project", + async ({ run, projectRef, workspace }) => { + linkProject(workspace.path, projectRef); + const result = await run(["db", "advisors", "--type", "security", "--linked"]); + expect(result.exitCode).toBe(0); + expect(`${result.stdout}${result.stderr}`).toContain( + "--linked is deprecated and will be removed in v4. Use --remote instead: supabase db advisors --type security --remote", + ); + expect(result.stderr).toContain("No issues found"); + }, + ); + + testBehaviour("warns that --local is not needed and still targets local", async ({ run }) => { + const result = await run(["db", "push", "--local"]); + expect(result.exitCode).not.toBe(0); + expect(`${result.stdout}${result.stderr}`).toContain( + "--local isn't needed (local is the default) and will be removed in v4: supabase db push", + ); + expect(result.stderr).toContain("Target: local stack"); + expect(result.stderr).toContain("connect"); + }); + + testBehaviour( + "warns that --db-url is deprecated and still targets that database", + async ({ run }) => { + const result = await run(["inspect", "db", "db-stats", "--db-url", DEAD_DB_URL]); + expect(result.exitCode).not.toBe(0); + expect(`${result.stdout}${result.stderr}`).toContain( + "--db-url is deprecated and will be removed in v4. Use --remote instead: supabase inspect db db-stats --remote=<db-url>", + ); + expect(`${result.stdout}${result.stderr}`).not.toContain(DEAD_DB_URL); + expect(result.stderr).toContain("Target: custom Postgres (localhost:1)"); + }, + ); + + testBehaviour( + "warns that --project-ref is deprecated on a hosted command and still targets that project", + async ({ run, projectRef }) => { + const result = await run(["secrets", "list", "--project-ref", projectRef]); + expect(result.exitCode).toBe(0); + expect(`${result.stdout}${result.stderr}`).toContain( + `--project-ref is deprecated and will be removed in v4. Use --remote instead: supabase secrets list --remote=${projectRef}`, + ); + }, + ); + + testBehaviour( + "warns that --project-id is deprecated on gen types and still targets that project", + async ({ run, projectRef }) => { + const result = await run(["gen", "types", "--project-id", projectRef]); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("export type Database"); + expect(`${result.stdout}${result.stderr}`).toContain( + `--project-id is deprecated and will be removed in v4. Use --remote instead: supabase gen types --remote=${projectRef}`, + ); + }, + ); +}); + +describe("remote target selection", () => { + testBehaviour( + "runs a bare db push locally in a linked checkout and hints at --remote", + async ({ run, projectRef, workspace, apiUrl }) => { + linkProject(workspace.path, projectRef); + const result = await run(["db", "push"]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain(`Target: local stack`); + expect(result.stderr).toContain(`default; pass --remote for ${projectRef}`); + expect(result.stderr).not.toContain("Target: project"); + const requests = await getRequestLog(apiUrl); + expect(requests.filter((r) => r.pathname.includes("/database/"))).toEqual([]); + }, + ); + + testBehaviour( + "targets the project named by --remote=<ref>", + async ({ run, projectRef, workspace, apiUrl }) => { + linkProject(workspace.path, projectRef); + const result = await run(["db", "advisors", "--type", "security", `--remote=${projectRef}`]); + expect(result.exitCode).toBe(0); + expect(result.stderr).toContain("No issues found"); + const requests = await getRequestLog(apiUrl); + expect(requests.map((r) => `${r.method} ${r.pathname}`)).toContain( + `GET /v1/projects/${projectRef}/advisors/security`, + ); + }, + ); + + testBehaviour( + "reads a space-separated --remote <ref> as --remote=<ref>", + async ({ run, projectRef, workspace, apiUrl }) => { + linkProject(workspace.path, projectRef); + const result = await run(["db", "advisors", "--type", "security", "--remote", projectRef]); + expect(result.exitCode).toBe(0); + const requests = await getRequestLog(apiUrl); + expect(requests.map((r) => `${r.method} ${r.pathname}`)).toContain( + `GET /v1/projects/${projectRef}/advisors/security`, + ); + }, + ); + + testBehaviour( + "resolves --remote=<branch> on a hosted command through the linked project's branches", + async ({ run, projectRef, workspace, apiUrl }) => { + linkProject(workspace.path, projectRef); + const result = await run(["secrets", "list", "--remote=my-branch"]); + expect(result.exitCode).toBe(0); + expect(result.stderr).toContain( + `Target: project ${projectRef} · branch my-branch (${BRANCH_PROJECT_REF})`, + ); + const requests = await getRequestLog(apiUrl); + expect(requests.map((r) => `${r.method} ${r.pathname}`)).toEqual( + expect.arrayContaining([ + `GET /v1/projects/${projectRef}/branches`, + `GET /v1/projects/${BRANCH_PROJECT_REF}/secrets`, + ]), + ); + }, + ); + + testBehaviour("rejects --remote=<url> on a hosted command", async ({ run, apiUrl }) => { + const result = await run(["secrets", "list", `--remote=${DEAD_DB_URL}`]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("needs a project, not a database URL"); + const requests = await getRequestLog(apiUrl); + expect(requests.filter((r) => r.pathname.endsWith("/secrets"))).toEqual([]); + }); +}); From 2074b29b0de28570661f76241ee29225cf757a88 Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Mon, 19 Oct 2026 11:40:23 +0100 Subject: [PATCH 111/291] test(cli): switch e2e and live suites to --remote target selection Database commands drop the redundant --local and pass --remote=<url> for remote databases; hosted commands pass --remote=<ref>. db reset expects the Confirmation required wording, and failed-command telemetry pins consent_required for a hosted db query without --yes. --- .../db-bootstrap/shadow-cache.e2e.test.ts | 5 ++- .../branches/create/create.live.test.ts | 3 +- .../branches/delete/delete.live.test.ts | 3 +- .../branches/disable/disable.live.test.ts | 6 ++-- .../commands/branches/get/get.live.test.ts | 2 +- .../commands/branches/list/list.live.test.ts | 3 +- .../branches/update/update.live.test.ts | 3 +- .../commands/config/diff/diff.live.test.ts | 3 +- .../src/commands/config/pull/pull.e2e.test.ts | 2 +- .../commands/config/pull/pull.live.test.ts | 3 +- .../src/commands/config/push/push.e2e.test.ts | 4 +-- .../commands/config/push/push.live.test.ts | 5 ++- .../db/advisors/advisors.live.test.ts | 2 +- .../db/diff/diff.declarative.e2e.test.ts | 5 ++- .../src/commands/db/dump/dump.live.test.ts | 2 +- .../src/commands/db/lint/lint.live.test.ts | 2 +- .../src/commands/db/pull/pull.live.test.ts | 7 ++--- .../cli/src/commands/db/push/push.e2e.test.ts | 4 +-- .../src/commands/db/push/push.live.test.ts | 4 +-- .../src/commands/db/query/query.live.test.ts | 6 ++-- .../src/commands/db/reset/reset.e2e.test.ts | 4 +-- .../src/commands/db/reset/reset.live.test.ts | 4 +-- .../commands/db/reset/reset.stack.e2e.test.ts | 2 +- .../schema/declarative/sync/sync.e2e.test.ts | 2 +- .../commands/db/start/start.roles.e2e.test.ts | 31 +++++-------------- .../functions/delete/delete.live.test.ts | 8 ++--- .../functions/deploy/deploy.e2e.test.ts | 9 +++--- .../functions/deploy/deploy.live.test.ts | 5 ++- .../functions/download/download.e2e.test.ts | 2 +- .../functions/download/download.live.test.ts | 8 ++--- .../commands/functions/list/list.live.test.ts | 3 +- .../src/commands/gen/types/types.e2e.test.ts | 9 +++--- .../src/commands/gen/types/types.live.test.ts | 3 +- .../inspect/db/db-stats/db-stats.live.test.ts | 2 +- .../inspect/db/inspect-db.e2e.test.ts | 6 ++-- .../inspect/report/report.e2e.test.ts | 9 +++++- .../migration/fetch/fetch.e2e.test.ts | 2 +- .../migration/fetch/fetch.live.test.ts | 2 +- .../commands/migration/list/list.live.test.ts | 2 +- .../migration/repair/repair.live.test.ts | 9 ++---- .../migration/squash/squash.e2e.test.ts | 7 ++--- .../src/commands/migration/up/up.live.test.ts | 8 ++--- .../projects/api-keys/api-keys.live.test.ts | 3 +- apps/cli/src/commands/pull/pull.live.test.ts | 3 +- .../commands/secrets/list/list.live.test.ts | 6 ++-- .../src/commands/secrets/set/set.live.test.ts | 3 +- .../commands/secrets/unset/unset.live.test.ts | 6 ++-- .../commands/seed/buckets/buckets.e2e.test.ts | 22 +++++++------ .../commands/snippets/snippets.e2e.test.ts | 2 +- apps/cli/src/commands/sso/sso.e2e.test.ts | 14 ++++----- .../src/commands/storage/storage.e2e.test.ts | 22 ++++++------- .../telemetry/failure-metadata.e2e.test.ts | 22 +++++++++++-- 52 files changed, 143 insertions(+), 171 deletions(-) diff --git a/apps/cli/src/command-internal/db-bootstrap/shadow-cache.e2e.test.ts b/apps/cli/src/command-internal/db-bootstrap/shadow-cache.e2e.test.ts index 135cd87894..d086f337c8 100644 --- a/apps/cli/src/command-internal/db-bootstrap/shadow-cache.e2e.test.ts +++ b/apps/cli/src/command-internal/db-bootstrap/shadow-cache.e2e.test.ts @@ -46,7 +46,7 @@ const STACK_PORT_ASSIGNMENT = */ const SHADOW_PORT_CANDIDATE_COUNT = 8; -const DIFF_ARGS = ["db", "diff", "--local", "--use-pg-delta"] as const; +const DIFF_ARGS = ["db", "diff", "--use-pg-delta"] as const; /** `shadow-cache.ts`'s published artifact name — `shadow-baseline-<16 hex key>.tar`. */ const BASELINE_TAR_PATTERN = /^shadow-baseline-[0-9a-f]{16}\.tar$/u; @@ -192,7 +192,7 @@ describe("shadow baseline cache (e2e, local Docker stack)", () => { expect(shadowPortCandidates).toHaveLength(SHADOW_PORT_CANDIDATE_COUNT); // Same drift setup as `db/diff/diff.declarative.e2e.test.ts`: create a fresh function - // directly in the local database so `db diff --local` has real, deterministic SQL to + // directly in the local database so `db diff` has real, deterministic SQL to // produce — the byte-identity of that output across cold/warm runs is the actual // user-visible contract here. Schema files can't supply this drift since the next engine // ignores `schema_paths` when building its migrations baseline. @@ -205,7 +205,6 @@ describe("shadow baseline cache (e2e, local Docker stack)", () => { returns void language sql as $$ select 1; $$;`, - "--local", ], { cwd: projectDir, home: homeDir }, ), 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 57915ab5f9..b00bdf0617 100644 --- a/apps/cli/src/commands/branches/create/create.live.test.ts +++ b/apps/cli/src/commands/branches/create/create.live.test.ts @@ -24,11 +24,10 @@ test("creates a preview branch", ({ cliEffect, project }) => "branches", "create", name, - "--project-ref", - project.ref, "--output-format", "json", "--yes", + `--remote=${project.ref}`, ]); requireLiveSuccess(result, "branches create"); const refBody = yield* Schema.decodeEffect(Schema.fromJsonString(CreatedBranchRef))( diff --git a/apps/cli/src/commands/branches/delete/delete.live.test.ts b/apps/cli/src/commands/branches/delete/delete.live.test.ts index cc495b8e17..1464aae3ac 100644 --- a/apps/cli/src/commands/branches/delete/delete.live.test.ts +++ b/apps/cli/src/commands/branches/delete/delete.live.test.ts @@ -25,9 +25,8 @@ test("deletes a preview branch", ({ cliEffect, project }) => "branches", "delete", name, - "--project-ref", - project.ref, "--yes", + `--remote=${project.ref}`, ]); deletionAcknowledged = removed.exitCode === 0; expect(removed.exitCode, removed.stderr).toBe(0); 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 40aa84b81f..f006b6f34b 100644 --- a/apps/cli/src/commands/branches/disable/disable.live.test.ts +++ b/apps/cli/src/commands/branches/disable/disable.live.test.ts @@ -29,9 +29,8 @@ test("disables preview branching", ({ cliEffect, project }) => "branches", "delete", name, - "--project-ref", - project.ref, "--yes", + `--remote=${project.ref}`, ]); deletionAcknowledged = removed.exitCode === 0; expect(removed.exitCode, removed.stderr).toBe(0); @@ -42,9 +41,8 @@ test("disables preview branching", ({ cliEffect, project }) => const disabled = yield* cliEffect([ "branches", "disable", - "--project-ref", - project.ref, "--yes", + `--remote=${project.ref}`, ]); 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/get/get.live.test.ts b/apps/cli/src/commands/branches/get/get.live.test.ts index fffeede252..8f30f8fa09 100644 --- a/apps/cli/src/commands/branches/get/get.live.test.ts +++ b/apps/cli/src/commands/branches/get/get.live.test.ts @@ -20,7 +20,7 @@ test("gets a preview branch by name", ({ cliEffect, project }) => const ref = yield* createLiveBranchEffect(cliEffect, project, name); branchRef = ref; yield* awaitLiveBranchEffect(cliEffect, project, name); - const result = yield* cliEffect(["branches", "get", name, "--project-ref", project.ref]); + const result = yield* cliEffect(["branches", "get", name, `--remote=${project.ref}`]); expect(result.exitCode, result.stderr).toBe(0); // The pretty table prints the branch password and JWT secret, so failures // must not echo stdout: assert through booleans with a secret-free message. diff --git a/apps/cli/src/commands/branches/list/list.live.test.ts b/apps/cli/src/commands/branches/list/list.live.test.ts index aa5cabd8e0..a6989edab5 100644 --- a/apps/cli/src/commands/branches/list/list.live.test.ts +++ b/apps/cli/src/commands/branches/list/list.live.test.ts @@ -26,8 +26,7 @@ test("lists a preview branch for the project", ({ cliEffect, project }) => "list", "--output", "json", - "--project-ref", - project.ref, + `--remote=${project.ref}`, ]); expect(result.exitCode, result.stderr).toBe(0); const branches = yield* Schema.decodeEffect(Schema.fromJsonString(ListedBranches))( 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 9791111eaa..308468b739 100644 --- a/apps/cli/src/commands/branches/update/update.live.test.ts +++ b/apps/cli/src/commands/branches/update/update.live.test.ts @@ -24,13 +24,12 @@ test("renames a preview branch", ({ cliEffect, project }) => "branches", "update", name, - "--project-ref", - project.ref, "--name", renamed, "--output", "json", "--yes", + `--remote=${project.ref}`, ]); expect(updated.exitCode, updated.stderr).toBe(0); expect(updated.stderr).toContain("Updated preview branch"); diff --git a/apps/cli/src/commands/config/diff/diff.live.test.ts b/apps/cli/src/commands/config/diff/diff.live.test.ts index 7a2308e9e8..79b3d6f8f1 100644 --- a/apps/cli/src/commands/config/diff/diff.live.test.ts +++ b/apps/cli/src/commands/config/diff/diff.live.test.ts @@ -25,10 +25,9 @@ test("diffs a freshly-initialized config against the project", ({ cliEffect, pro const result = yield* cliEffect([ "config", "diff", - "--project-ref", - project.ref, "--output-format", "json", + `--remote=${project.ref}`, ]); requireLiveSuccess(result, "config diff"); expect(result.stderr).toContain(`Comparing against project ${project.ref} using base config`); diff --git a/apps/cli/src/commands/config/pull/pull.e2e.test.ts b/apps/cli/src/commands/config/pull/pull.e2e.test.ts index 6effc6cae6..69ccb72ce9 100644 --- a/apps/cli/src/commands/config/pull/pull.e2e.test.ts +++ b/apps/cli/src/commands/config/pull/pull.e2e.test.ts @@ -84,7 +84,7 @@ describe("config pull CLI surface", () => { ); const { exitCode, stdout, stderr } = yield* runSupabaseEffect( - ["config", "pull", "--project-ref", TEST_REF], + ["config", "pull", `--remote=${TEST_REF}`], { cwd, stdin: "n\n", diff --git a/apps/cli/src/commands/config/pull/pull.live.test.ts b/apps/cli/src/commands/config/pull/pull.live.test.ts index 1b8f2405be..c531b2c375 100644 --- a/apps/cli/src/commands/config/pull/pull.live.test.ts +++ b/apps/cli/src/commands/config/pull/pull.live.test.ts @@ -19,11 +19,10 @@ test("pulls remote config into a fresh project, leaving nothing to write on a se const args = [ "config", "pull", - "--project-ref", - project.ref, "--yes", "--output-format", "json", + `--remote=${project.ref}`, ]; const first = yield* cliEffect(args); requireLiveSuccess(first, "config pull"); 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 386b3fd7e6..17215fc1a1 100644 --- a/apps/cli/src/commands/config/push/push.e2e.test.ts +++ b/apps/cli/src/commands/config/push/push.e2e.test.ts @@ -33,7 +33,7 @@ describe("supabase config push", () => { yield* fs.makeDirectory(path.join(projectDir, "supabase"), { recursive: true }); yield* fs.writeFileString(path.join(projectDir, "supabase", "config.toml"), "malformed"); const { exitCode, stdout, stderr } = yield* runSupabaseEffect( - ["config", "push", "--project-ref", TEST_PROJECT_REF], + ["config", "push", `--remote=${TEST_PROJECT_REF}`], { cwd: projectDir, env: { SUPABASE_ACCESS_TOKEN: TEST_TOKEN } }, ); expect(exitCode).toBe(1); @@ -107,7 +107,7 @@ 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, ...(yes ? ["--yes"] : [])], + ["config", "push", ...(yes ? ["--yes"] : []), `--remote=${TEST_PROJECT_REF}`], { cwd, stdin: "y\n", diff --git a/apps/cli/src/commands/config/push/push.live.test.ts b/apps/cli/src/commands/config/push/push.live.test.ts index 0386ff4c15..520e11a574 100644 --- a/apps/cli/src/commands/config/push/push.live.test.ts +++ b/apps/cli/src/commands/config/push/push.live.test.ts @@ -59,7 +59,7 @@ const jsonText = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown)); function diffMaxRows(cliEffect: LiveCliEffect, ref: string, label: string) { return Effect.gen(function* () { const result = yield* cliEffect( - ["config", "diff", "--project-ref", ref, "--output-format", "json"], + ["config", "diff", "--output-format", "json", `--remote=${ref}`], { exitTimeoutMs: DIFF_EXIT_TIMEOUT_MS }, ).pipe(Effect.mapError(liveFailure)); yield* Effect.try({ try: () => requireLiveSuccess(result, label), catch: liveFailure }); @@ -107,11 +107,10 @@ test( const pushArgs = [ "config", "push", - "--project-ref", - project.ref, "--yes", "--output-format", "json", + `--remote=${project.ref}`, ]; // No diff entry means the remote already reads the probe value. diff --git a/apps/cli/src/commands/db/advisors/advisors.live.test.ts b/apps/cli/src/commands/db/advisors/advisors.live.test.ts index 45774ad04e..95638b49ae 100644 --- a/apps/cli/src/commands/db/advisors/advisors.live.test.ts +++ b/apps/cli/src/commands/db/advisors/advisors.live.test.ts @@ -17,7 +17,7 @@ test("reads advisor findings over the database connection", ({ cliEffect, projec queryLiveDb(project.dbUrl, `create table public.${table} (id int)`), ); - const result = yield* cliEffect(["db", "advisors", "--db-url", project.dbUrl]); + const result = yield* cliEffect(["db", "advisors", `--remote=${project.dbUrl}`]); expect(result.exitCode, result.stderr).toBe(0); expect(result.stdout, result.stderr).not.toBe(""); const findings = yield* Schema.decodeEffect( diff --git a/apps/cli/src/commands/db/diff/diff.declarative.e2e.test.ts b/apps/cli/src/commands/db/diff/diff.declarative.e2e.test.ts index 9fbd74df3d..964b80c998 100644 --- a/apps/cli/src/commands/db/diff/diff.declarative.e2e.test.ts +++ b/apps/cli/src/commands/db/diff/diff.declarative.e2e.test.ts @@ -103,20 +103,19 @@ describe("supabase db diff (e2e, pg-delta declarative privileges)", () => { returns void language sql as $$ select 1; $$;`, - "--local", ], { cwd: projectDir, exitTimeoutMs: CLI_COMMAND_TIMEOUT_MS }, ); requireCliSuccess(createFunction, "db query create-function setup"); const revoke = yield* runSupabaseEffect( - ["db", "query", "revoke execute on function public.probe_fn() from public;", "--local"], + ["db", "query", "revoke execute on function public.probe_fn() from public;"], { cwd: projectDir, exitTimeoutMs: CLI_COMMAND_TIMEOUT_MS }, ); requireCliSuccess(revoke, "db query revoke setup"); const diff = yield* runSupabaseEffect( - ["db", "diff", "--local", "--use-pg-delta", "-f", "revoke_public_execute"], + ["db", "diff", "--use-pg-delta", "-f", "revoke_public_execute"], { cwd: projectDir, exitTimeoutMs: DIFF_COMMAND_TIMEOUT_MS }, ); expect(diff.exitCode, `stdout:\n${diff.stdout}\nstderr:\n${diff.stderr}`).toBe(0); diff --git a/apps/cli/src/commands/db/dump/dump.live.test.ts b/apps/cli/src/commands/db/dump/dump.live.test.ts index 862128c878..dc02a1ec7b 100644 --- a/apps/cli/src/commands/db/dump/dump.live.test.ts +++ b/apps/cli/src/commands/db/dump/dump.live.test.ts @@ -10,7 +10,7 @@ test("dumps the remote schema to a file", ({ cliEffect, project, workspace, sign const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const outFile = path.join(workspace.path, "schema.sql"); - const result = yield* cliEffect(["db", "dump", "--db-url", project.dbUrl, "-f", outFile]); + const result = yield* cliEffect(["db", "dump", "-f", outFile, `--remote=${project.dbUrl}`]); expect(result.exitCode, result.stderr).toBe(0); const dump = new TextDecoder("utf-8", { ignoreBOM: true }).decode( yield* fs.readFile(outFile), diff --git a/apps/cli/src/commands/db/lint/lint.live.test.ts b/apps/cli/src/commands/db/lint/lint.live.test.ts index 5ce5b5da15..86a3154fd3 100644 --- a/apps/cli/src/commands/db/lint/lint.live.test.ts +++ b/apps/cli/src/commands/db/lint/lint.live.test.ts @@ -20,7 +20,7 @@ test("reports schema issues from the remote database", ({ cliEffect, project }) ), ); - const result = yield* cliEffect(["db", "lint", "--db-url", project.dbUrl]); + const result = yield* cliEffect(["db", "lint", `--remote=${project.dbUrl}`]); expect(result.exitCode, result.stderr).toBe(0); expect(result.stdout, result.stderr).not.toBe(""); const results = yield* Schema.decodeEffect( diff --git a/apps/cli/src/commands/db/pull/pull.live.test.ts b/apps/cli/src/commands/db/pull/pull.live.test.ts index 20219bb8dd..e249031c85 100644 --- a/apps/cli/src/commands/db/pull/pull.live.test.ts +++ b/apps/cli/src/commands/db/pull/pull.live.test.ts @@ -29,12 +29,11 @@ test("pulls the remote schema into an initial migration", ({ "db", "query", `create table if not exists ${marker} (id int)`, - "--db-url", - project.dbUrl, + `--remote=${project.dbUrl}`, ]); requireLiveSuccess(seeded, "db query setup for db pull"); - const result = yield* cliEffect(["db", "pull", "--db-url", project.dbUrl, "--yes"]); + const result = yield* cliEffect(["db", "pull", "--yes", `--remote=${project.dbUrl}`]); expect(result.exitCode, result.stderr).toBe(0); expect(result.stderr, result.stderr).toContain("Schema written to"); @@ -51,7 +50,7 @@ test("pulls the remote schema into an initial migration", ({ }); const resetRemote = Effect.gen(function* () { - const reset = yield* cliEffect(["db", "reset", "--db-url", project.dbUrl, "--yes"]); + const reset = yield* cliEffect(["db", "reset", "--yes", `--remote=${project.dbUrl}`]); requireLiveSuccess(reset, "db reset cleanup after db pull"); }); 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 a3a52a7bdd..fd907415ea 100644 --- a/apps/cli/src/commands/db/push/push.e2e.test.ts +++ b/apps/cli/src/commands/db/push/push.e2e.test.ts @@ -28,7 +28,7 @@ describe("supabase db push --skip-vault", () => { Effect.gen(function* () { const projectDir = yield* makeVaultProject; const { exitCode, stderr } = yield* runSupabaseEffect( - ["db", "push", "--db-url", UNREACHABLE_DB_URL], + ["db", "push", `--remote=${UNREACHABLE_DB_URL}`], { cwd: projectDir }, ); expect(exitCode).toBe(1); @@ -44,7 +44,7 @@ describe("supabase db push --skip-vault", () => { Effect.gen(function* () { const projectDir = yield* makeVaultProject; const { exitCode, stderr } = yield* runSupabaseEffect( - ["db", "push", "--db-url", UNREACHABLE_DB_URL, "--skip-vault"], + ["db", "push", "--skip-vault", `--remote=${UNREACHABLE_DB_URL}`], { cwd: projectDir }, ); expect(exitCode).toBe(1); diff --git a/apps/cli/src/commands/db/push/push.live.test.ts b/apps/cli/src/commands/db/push/push.live.test.ts index 6861e1d542..503a9abbd0 100644 --- a/apps/cli/src/commands/db/push/push.live.test.ts +++ b/apps/cli/src/commands/db/push/push.live.test.ts @@ -26,13 +26,13 @@ test("pushes a local migration to the remote database", ({ ); const target = Effect.gen(function* () { - const result = yield* cliEffect(["db", "push", "--db-url", project.dbUrl, "--yes"]); + const result = yield* cliEffect(["db", "push", "--yes", `--remote=${project.dbUrl}`]); expect(result.exitCode, result.stderr).toBe(0); expect(result.stdout).toContain("Finished supabase db push"); }); const reset = Effect.gen(function* () { - const result = yield* cliEffect(["db", "reset", "--db-url", project.dbUrl, "--yes"]); + const result = yield* cliEffect(["db", "reset", "--yes", `--remote=${project.dbUrl}`]); requireLiveSuccess(result, "db reset cleanup after db push"); }); 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 9f8053652b..bcfdee3e4d 100644 --- a/apps/cli/src/commands/db/query/query.live.test.ts +++ b/apps/cli/src/commands/db/query/query.live.test.ts @@ -19,13 +19,12 @@ test("runs SQL against the remote database and returns its rows", ({ "db", "query", `select '${marker}' as marker`, - "--db-url", - project.dbUrl, "--read-only", "-o", "json", "--agent", "no", + `--remote=${project.dbUrl}`, ]); expect(result.exitCode, result.stderr).toBe(0); expect(result.stdout, result.stderr).not.toBe(""); @@ -46,9 +45,8 @@ test("rejects a write against the remote database under --read-only", ({ "db", "query", `create table ${table} (id int)`, - "--db-url", - project.dbUrl, "--read-only", + `--remote=${project.dbUrl}`, ]); expect(result.exitCode).not.toBe(0); expect(result.stderr).toContain("read-only transaction"); 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 46e906e51d..41033773b7 100644 --- a/apps/cli/src/commands/db/reset/reset.e2e.test.ts +++ b/apps/cli/src/commands/db/reset/reset.e2e.test.ts @@ -21,12 +21,12 @@ describe("supabase db reset", () => { "[db]\nport = 54322\n", ); const { exitCode, stderr } = yield* runSupabaseEffect( - ["db", "reset", "--db-url", "postgresql://postgres:postgres@127.0.0.1:9999/postgres"], + ["db", "reset", "--remote=postgresql://postgres:postgres@127.0.0.1:9999/postgres"], { cwd: workdir, stdin: "y\n" }, ); expect(exitCode).toBe(1); const text = stripAnsi(stderr); - expect(text).toContain("needs confirmation"); + expect(text).toContain("Confirmation required"); expect(text).toContain("--yes"); expect(text).not.toContain("Resetting remote database"); }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), diff --git a/apps/cli/src/commands/db/reset/reset.live.test.ts b/apps/cli/src/commands/db/reset/reset.live.test.ts index 651f807141..d4ff4a3a43 100644 --- a/apps/cli/src/commands/db/reset/reset.live.test.ts +++ b/apps/cli/src/commands/db/reset/reset.live.test.ts @@ -26,13 +26,13 @@ test("resets the remote database with local migrations", ({ ); const target = Effect.gen(function* () { - const result = yield* cliEffect(["db", "reset", "--db-url", project.dbUrl, "--yes"]); + const result = yield* cliEffect(["db", "reset", "--yes", `--remote=${project.dbUrl}`]); expect(result.exitCode, result.stderr).toBe(0); expect(`${result.stdout}${result.stderr}`).toContain("Resetting remote database"); }); const resetCleanup = Effect.gen(function* () { - const reset = yield* cliEffect(["db", "reset", "--db-url", project.dbUrl, "--yes"]); + const reset = yield* cliEffect(["db", "reset", "--yes", `--remote=${project.dbUrl}`]); requireLiveSuccess(reset, "db reset cleanup"); }); diff --git a/apps/cli/src/commands/db/reset/reset.stack.e2e.test.ts b/apps/cli/src/commands/db/reset/reset.stack.e2e.test.ts index b06a5eda4b..a58d0112d8 100644 --- a/apps/cli/src/commands/db/reset/reset.stack.e2e.test.ts +++ b/apps/cli/src/commands/db/reset/reset.stack.e2e.test.ts @@ -198,7 +198,7 @@ describe("supabase db reset (stack e2e)", () => { ); yield* query(databaseUrl, "create database reset_story_stale_database;"); - const reset = yield* runSupabaseEffect(["db", "reset", "--local"], { + const reset = yield* runSupabaseEffect(["db", "reset"], { cwd: root, home, env: { SUPABASE_EXPERIMENTAL_STACK: "1" }, diff --git a/apps/cli/src/commands/db/schema/declarative/sync/sync.e2e.test.ts b/apps/cli/src/commands/db/schema/declarative/sync/sync.e2e.test.ts index b54627296f..35877fd578 100644 --- a/apps/cli/src/commands/db/schema/declarative/sync/sync.e2e.test.ts +++ b/apps/cli/src/commands/db/schema/declarative/sync/sync.e2e.test.ts @@ -52,7 +52,7 @@ describe("db schema declarative sync (e2e)", () => { ); const reset = yield* runSupabaseEffect( - ["db", "reset", "--local", "--no-seed"], + ["db", "reset", "--no-seed"], scenarioCommandOptions(projectDir), ); requireCliSuccess(reset, "db reset setup"); diff --git a/apps/cli/src/commands/db/start/start.roles.e2e.test.ts b/apps/cli/src/commands/db/start/start.roles.e2e.test.ts index 11cd8ec67c..26c8d796a5 100644 --- a/apps/cli/src/commands/db/start/start.roles.e2e.test.ts +++ b/apps/cli/src/commands/db/start/start.roles.e2e.test.ts @@ -86,7 +86,7 @@ enabled = false }); expect(started.exitCode, `${started.stdout}\n${started.stderr}`).toBe(0); - const types = yield* runSupabaseEffect(["gen", "types", "--local"], { + const types = yield* runSupabaseEffect(["gen", "types"], { cwd: root, home, exitTimeoutMs: AUX_TIMEOUT_MS, @@ -94,7 +94,7 @@ enabled = false expect(types.exitCode, types.stderr).toBe(0); expect(types.stdout).toContain("role_story"); - const migra = yield* runSupabaseEffect(["db", "diff", "--local", "--use-migra"], { + const migra = yield* runSupabaseEffect(["db", "diff", "--use-migra"], { cwd: root, home, exitTimeoutMs: AUX_TIMEOUT_MS, @@ -107,7 +107,6 @@ enabled = false "db", "query", "select tableowner from pg_tables where schemaname = 'supabase_migrations' and tablename = 'schema_migrations'", - "--local", "--output", "json", ], @@ -117,18 +116,18 @@ enabled = false expect(yield* decodeJson(historyOwner.stdout)).toEqual([{ tableowner: "postgres" }]); const alter = yield* runSupabaseEffect( - ["db", "query", "alter table public.role_story add column note text", "--local"], + ["db", "query", "alter table public.role_story add column note text"], { cwd: root, home, exitTimeoutMs: AUX_TIMEOUT_MS }, ); expect(alter.exitCode, alter.stderr).toBe(0); const insert = yield* runSupabaseEffect( - ["db", "query", "insert into public.role_story values (1, 'kept')", "--local"], + ["db", "query", "insert into public.role_story values (1, 'kept')"], { cwd: root, home, exitTimeoutMs: AUX_TIMEOUT_MS }, ); expect(insert.exitCode, insert.stderr).toBe(0); - const diff = yield* runSupabaseEffect(["db", "diff", "--local", "--use-pg-delta"], { + const diff = yield* runSupabaseEffect(["db", "diff", "--use-pg-delta"], { cwd: root, home, exitTimeoutMs: AUX_TIMEOUT_MS, @@ -138,30 +137,14 @@ enabled = false expect(diff.stdout).not.toMatch(/CREATE TABLE[\s\S]*role_story/i); const localRow = yield* runSupabaseEffect( - [ - "db", - "query", - "select id, note from public.role_story", - "--local", - "--output", - "json", - ], + ["db", "query", "select id, note from public.role_story", "--output", "json"], { cwd: root, home, exitTimeoutMs: AUX_TIMEOUT_MS }, ); expect(localRow.exitCode, localRow.stderr).toBe(0); expect(yield* decodeJson(localRow.stdout)).toEqual([{ id: 1, note: "kept" }]); const generate = yield* runSupabaseEffect( - [ - "db", - "schema", - "declarative", - "generate", - "--local", - "--schema", - "public", - "--overwrite", - ], + ["db", "schema", "declarative", "generate", "--schema", "public", "--overwrite"], { cwd: root, home, exitTimeoutMs: AUX_TIMEOUT_MS }, ); expect(generate.exitCode, generate.stderr).toBe(0); 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 6f8f079f2e..32f74ae7c8 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, "--yes"]); + const deleted = yield* cliEffect(["functions", "delete", slug, "--yes", `--remote=${ref}`]); if ( deleted.exitCode !== 0 && !/not found|does not exist/i.test(`${deleted.stdout}\n${deleted.stderr}`) @@ -45,10 +45,9 @@ test("deletes a deployed function", ({ cliEffect, project, workspace }) => "functions", "deploy", slug, - "--project-ref", - project.ref, "--use-api", "--yes", + `--remote=${project.ref}`, ]); if (deployed.exitCode !== 0) { return yield* new FunctionsDeleteLiveError({ @@ -60,9 +59,8 @@ test("deletes a deployed function", ({ cliEffect, project, workspace }) => "functions", "delete", slug, - "--project-ref", - project.ref, "--yes", + `--remote=${project.ref}`, ]); expect(result.exitCode, result.stderr).toBe(0); expect(result.stdout).toContain("Deleted Function"); diff --git a/apps/cli/src/commands/functions/deploy/deploy.e2e.test.ts b/apps/cli/src/commands/functions/deploy/deploy.e2e.test.ts index de02d55612..faf645e6a0 100644 --- a/apps/cli/src/commands/functions/deploy/deploy.e2e.test.ts +++ b/apps/cli/src/commands/functions/deploy/deploy.e2e.test.ts @@ -29,7 +29,7 @@ describe("supabase functions deploy — argument validation", () => { withTempHome((home) => Effect.gen(function* () { const { exitCode, stderr } = yield* runSupabaseEffect( - ["functions", "deploy", SLUG, "--project-ref", FAKE_REF, ...flags], + ["functions", "deploy", SLUG, ...flags, `--remote=${FAKE_REF}`], { home: home.dir, env: { HOME: home.dir, SUPABASE_ACCESS_TOKEN: FAKE_TOKEN }, @@ -51,7 +51,7 @@ describe("supabase functions deploy — argument validation", () => { withTempHome((home) => Effect.gen(function* () { const { exitCode, stderr } = yield* runSupabaseEffect( - ["functions", "deploy", SLUG, "--project-ref", FAKE_REF, "--use-docker", "--jobs", "2"], + ["functions", "deploy", SLUG, "--use-docker", "--jobs", "2", `--remote=${FAKE_REF}`], { home: home.dir, env: { HOME: home.dir, SUPABASE_ACCESS_TOKEN: FAKE_TOKEN }, @@ -74,11 +74,10 @@ describe("supabase functions deploy — argument validation", () => { "functions", "deploy", SLUG, - "--project-ref", - FAKE_REF, "--use-docker=false", "--jobs", "2", + `--remote=${FAKE_REF}`, ], { home: home.dir, @@ -93,7 +92,7 @@ describe("supabase functions deploy — argument validation", () => { ); it.live( - "fails without a linked project or --project-ref", + "fails without a linked project or --remote=<ref>", () => withTempHome((home) => Effect.gen(function* () { 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 3a47228130..7516d29001 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, "--yes"]); + const deleted = yield* cliEffect(["functions", "delete", slug, "--yes", `--remote=${ref}`]); if ( deleted.exitCode !== 0 && !/not found|does not exist/i.test(`${deleted.stdout}\n${deleted.stderr}`) @@ -56,9 +56,8 @@ describe("functions deploy (live)", () => { const result = yield* cliEffect([ "functions", "deploy", - "--project-ref", - project.ref, "--yes", + `--remote=${project.ref}`, ]); expect(result.exitCode, result.stderr).toBe(0); expect(result.stdout).toMatch(/Deployed Function/i); diff --git a/apps/cli/src/commands/functions/download/download.e2e.test.ts b/apps/cli/src/commands/functions/download/download.e2e.test.ts index 2c7ed31ac0..e8607f765c 100644 --- a/apps/cli/src/commands/functions/download/download.e2e.test.ts +++ b/apps/cli/src/commands/functions/download/download.e2e.test.ts @@ -23,7 +23,7 @@ describe("supabase functions download — argument validation", () => { withTempHome((home) => Effect.gen(function* () { const { exitCode, stderr } = yield* runSupabaseEffect( - ["functions", "download", SLUG, "--project-ref", FAKE_REF, ...flags], + ["functions", "download", SLUG, ...flags, `--remote=${FAKE_REF}`], { home: home.dir, env: { HOME: home.dir, SUPABASE_ACCESS_TOKEN: FAKE_TOKEN }, 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 4041dcd178..cf71e5b2bc 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, "--yes"]); + const deleted = yield* cliEffect(["functions", "delete", slug, "--yes", `--remote=${ref}`]); if ( deleted.exitCode !== 0 && !/not found|does not exist/i.test(`${deleted.stdout}\n${deleted.stderr}`) @@ -54,9 +54,8 @@ describe("functions download (live)", () => { const deployed = yield* cliEffect([ "functions", "deploy", - "--project-ref", - project.ref, "--yes", + `--remote=${project.ref}`, ]); requireLiveSuccess(deployed, "functions deploy setup"); @@ -76,8 +75,7 @@ describe("functions download (live)", () => { "functions", "download", slug, - "--project-ref", - project.ref, + `--remote=${project.ref}`, ]); const downloadOutput = `stdout:\n${downloaded.stdout}\nstderr:\n${downloaded.stderr}`; expect(downloaded.exitCode, downloadOutput).toBe(0); diff --git a/apps/cli/src/commands/functions/list/list.live.test.ts b/apps/cli/src/commands/functions/list/list.live.test.ts index b6f683bc6a..ba95ca9f77 100644 --- a/apps/cli/src/commands/functions/list/list.live.test.ts +++ b/apps/cli/src/commands/functions/list/list.live.test.ts @@ -17,8 +17,7 @@ describe("supabase functions list (live)", () => { const { exitCode, stdout, stderr } = yield* cliEffect([ "functions", "list", - "--project-ref", - project.ref, + `--remote=${project.ref}`, ]); expect(exitCode, stderr).toBe(0); expect(stdout, stderr).toMatch(/ID\s+\|\s+NAME\s+\|\s+SLUG\s+\|\s+STATUS/); 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 07dbb608ec..9c9539a7d4 100644 --- a/apps/cli/src/commands/gen/types/types.e2e.test.ts +++ b/apps/cli/src/commands/gen/types/types.e2e.test.ts @@ -337,7 +337,7 @@ describe("gen types e2e", () => { for (const lang of TYPEGEN_LANGS) { const result = yield* runSupabaseEffect( - ["gen", "types", "--local", "--lang", lang, "--schema", "public"], + ["gen", "types", "--lang", lang, "--schema", "public"], { cwd: project.dir, home: home.dir, @@ -345,7 +345,7 @@ describe("gen types e2e", () => { exitTimeoutMs: TYPEGEN_TIMEOUT_MS, }, ); - expectSucceeded(`supabase gen types --local --lang ${lang}`, result); + expectSucceeded(`supabase gen types --lang ${lang}`, result); expectNoRemoteAuthPath(result); expectLanguageShape(lang, result.stdout); expectLocalSmokeTable(lang, result.stdout); @@ -390,12 +390,11 @@ describe("gen types e2e", () => { [ "gen", "types", - "--project-ref", - remoteProjectRef, "--lang", lang, "--schema", "public", + `--remote=${remoteProjectRef}`, ], { cwd: project.dir, @@ -404,7 +403,7 @@ describe("gen types e2e", () => { exitTimeoutMs: TYPEGEN_TIMEOUT_MS, }, ); - expectSucceeded(`supabase gen types --project-ref <ref> --lang ${lang}`, result); + expectSucceeded(`supabase gen types --remote=<ref> --lang ${lang}`, result); expectLanguageShape(lang, result.stdout); } }), diff --git a/apps/cli/src/commands/gen/types/types.live.test.ts b/apps/cli/src/commands/gen/types/types.live.test.ts index 88e2afd197..2de65fc001 100644 --- a/apps/cli/src/commands/gen/types/types.live.test.ts +++ b/apps/cli/src/commands/gen/types/types.live.test.ts @@ -9,10 +9,9 @@ test("generates TypeScript types from the remote schema", ({ cliEffect, project, const result = yield* cliEffect([ "gen", "types", - "--db-url", - project.dbUrl, "--lang", "typescript", + `--remote=${project.dbUrl}`, ]); expect(result.exitCode, result.stderr).toBe(0); expect(result.stdout).toMatch(/export type (Database|Json)/); diff --git a/apps/cli/src/commands/inspect/db/db-stats/db-stats.live.test.ts b/apps/cli/src/commands/inspect/db/db-stats/db-stats.live.test.ts index 2f6aa36d48..e06bb6504d 100644 --- a/apps/cli/src/commands/inspect/db/db-stats/db-stats.live.test.ts +++ b/apps/cli/src/commands/inspect/db/db-stats/db-stats.live.test.ts @@ -6,7 +6,7 @@ import { test } from "../../../../../tests/helpers/live.ts"; test("reports statistics from the remote database", ({ cliEffect, project, signal }) => Effect.runPromise( Effect.gen(function* () { - const result = yield* cliEffect(["inspect", "db", "db-stats", "--db-url", project.dbUrl]); + const result = yield* cliEffect(["inspect", "db", "db-stats", `--remote=${project.dbUrl}`]); expect(result.exitCode, result.stderr).toBe(0); expect(result.stdout).toContain("Database Size"); }), 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 d98bb6bd45..0071715d9f 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 @@ -5,7 +5,7 @@ import { runSupabaseEffect, withTempHome } from "../../../../tests/helpers/cli.t const E2E_TIMEOUT_MS = 30_000; -// A definitely-closed local port: `--db-url` resolves without a config.toml or running +// A definitely-closed local port: `--remote=<url>` resolves without a config.toml or running // stack, then the native handler dials and fails fast — exercising the real subprocess path // without depending on a live database in CI. const DEAD_DB_URL = "postgres://postgres:postgres@127.0.0.1:1/postgres"; @@ -22,7 +22,7 @@ describe("supabase inspect db", () => { withTempHome((home) => Effect.gen(function* () { const { exitCode, stderr } = yield* runSupabaseEffect( - ["inspect", "db", "locks", TEXT_MODE, TEXT_MODE_VALUE, "--db-url", DEAD_DB_URL], + ["inspect", "db", "locks", TEXT_MODE, TEXT_MODE_VALUE, `--remote=${DEAD_DB_URL}`], { home: home.dir, env: { HOME: home.dir } }, ); expect(exitCode).toBe(1); @@ -39,7 +39,7 @@ describe("supabase inspect db", () => { withTempHome((home) => Effect.gen(function* () { const { exitCode, stderr } = yield* runSupabaseEffect( - ["inspect", "db", "cache-hit", TEXT_MODE, TEXT_MODE_VALUE, "--db-url", DEAD_DB_URL], + ["inspect", "db", "cache-hit", TEXT_MODE, TEXT_MODE_VALUE, `--remote=${DEAD_DB_URL}`], { home: home.dir, env: { HOME: home.dir } }, ); expect(exitCode).toBe(1); 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 17a4fb6489..8d2cf186c0 100644 --- a/apps/cli/src/commands/inspect/report/report.e2e.test.ts +++ b/apps/cli/src/commands/inspect/report/report.e2e.test.ts @@ -24,7 +24,14 @@ describe("supabase inspect report", () => { const path = yield* Path.Path; const outputDir = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-report-e2e-" }); const { exitCode, stderr } = yield* runSupabaseEffect( - ["inspect", "report", ...TEXT_MODE, "--db-url", DEAD_DB_URL, "--output-dir", outputDir], + [ + "inspect", + "report", + ...TEXT_MODE, + "--output-dir", + outputDir, + `--remote=${DEAD_DB_URL}`, + ], { home: home.dir, env: { HOME: home.dir } }, ); expect(exitCode).toBe(1); diff --git a/apps/cli/src/commands/migration/fetch/fetch.e2e.test.ts b/apps/cli/src/commands/migration/fetch/fetch.e2e.test.ts index 91fe8d014f..4735991f4b 100644 --- a/apps/cli/src/commands/migration/fetch/fetch.e2e.test.ts +++ b/apps/cli/src/commands/migration/fetch/fetch.e2e.test.ts @@ -27,7 +27,7 @@ describe("supabase migration fetch", () => { "select 1;\n", ); - const { exitCode, stderr } = yield* runSupabaseEffect(["migration", "fetch", "--local"], { + const { exitCode, stderr } = yield* runSupabaseEffect(["migration", "fetch"], { cwd: workdir, stdin: "n\n", }); diff --git a/apps/cli/src/commands/migration/fetch/fetch.live.test.ts b/apps/cli/src/commands/migration/fetch/fetch.live.test.ts index eb6b112888..1db89ff333 100644 --- a/apps/cli/src/commands/migration/fetch/fetch.live.test.ts +++ b/apps/cli/src/commands/migration/fetch/fetch.live.test.ts @@ -29,7 +29,7 @@ test( Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const targetArgs = ["--db-url", project.dbUrl]; + const targetArgs = [`--remote=${project.dbUrl}`]; const version = liveMigrationVersion(); const migrationFile = `${version}_${NAME}.sql`; const seedDir = yield* fs.makeTempDirectoryScoped({ prefix: "sb-migration-seed-live-" }); 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 858e97ce64..c18452030d 100644 --- a/apps/cli/src/commands/migration/list/list.live.test.ts +++ b/apps/cli/src/commands/migration/list/list.live.test.ts @@ -15,7 +15,7 @@ test("lists a seeded remote migration", ({ cli, cliEffect, project, signal }) => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const targetArgs = ["--db-url", project.dbUrl]; + const targetArgs = [`--remote=${project.dbUrl}`]; const version = liveMigrationVersion(); // Seeded outside the workspace so the version can only reach stdout through the remote column. const seedDir = yield* fs.makeTempDirectoryScoped({ prefix: "sb-migration-list-live-" }); 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 49e3c7ee80..05fb9a20e2 100644 --- a/apps/cli/src/commands/migration/repair/repair.live.test.ts +++ b/apps/cli/src/commands/migration/repair/repair.live.test.ts @@ -40,8 +40,7 @@ test("amends the migration history status on the remote database", ({ "--status", "applied", "--yes", - "--db-url", - project.dbUrl, + `--remote=${project.dbUrl}`, ]); expect(applied.exitCode, applied.stderr).toBe(0); expect(applied.stderr, applied.stdout).toContain("=> applied"); @@ -61,8 +60,7 @@ test("amends the migration history status on the remote database", ({ "--status", "reverted", "--yes", - "--db-url", - project.dbUrl, + `--remote=${project.dbUrl}`, ]); expect(reverted.exitCode, reverted.stderr).toBe(0); expect(reverted.stderr, reverted.stdout).toContain("=> reverted"); @@ -92,8 +90,7 @@ test("amends the migration history status on the remote database", ({ "--status", "reverted", "--yes", - "--db-url", - project.dbUrl, + `--remote=${project.dbUrl}`, ]); requireLiveSuccess(cleanup, "migration repair cleanup"); }), diff --git a/apps/cli/src/commands/migration/squash/squash.e2e.test.ts b/apps/cli/src/commands/migration/squash/squash.e2e.test.ts index 0d4a9dc291..c76a169258 100644 --- a/apps/cli/src/commands/migration/squash/squash.e2e.test.ts +++ b/apps/cli/src/commands/migration/squash/squash.e2e.test.ts @@ -53,10 +53,9 @@ describe("supabase migration squash", () => { "select 1;\n", ); - const { exitCode, stdout, stderr } = yield* runSupabaseEffect( - ["migration", "squash", "--local"], - { cwd: workdir }, - ); + const { exitCode, stdout, stderr } = yield* runSupabaseEffect(["migration", "squash"], { + cwd: workdir, + }); expect(exitCode).toBe(0); expect(stripAnsi(stderr)).toContain( 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 5b43155a98..28fecae4f4 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", "--yes", "--db-url", project.dbUrl]); + const applied = yield* cliEffect(["migration", "up", "--yes", `--remote=${project.dbUrl}`]); expect(applied.exitCode, applied.stderr).toBe(0); expect(applied.stderr, applied.stdout).toContain("Applying migration"); @@ -78,8 +78,7 @@ test("applies a test-written migration to the remote database", ({ "db", "query", `drop table if exists e2e_up_${version}`, - "--db-url", - project.dbUrl, + `--remote=${project.dbUrl}`, ]); requireLiveSuccess(dropped, "db query cleanup after migration up"); }); @@ -92,8 +91,7 @@ test("applies a test-written migration to the remote database", ({ "--status", "reverted", "--yes", - "--db-url", - project.dbUrl, + `--remote=${project.dbUrl}`, ]); requireLiveSuccess(reverted, "migration repair cleanup after migration up"); }); diff --git a/apps/cli/src/commands/projects/api-keys/api-keys.live.test.ts b/apps/cli/src/commands/projects/api-keys/api-keys.live.test.ts index 0a8ca9e9b9..65d666edc1 100644 --- a/apps/cli/src/commands/projects/api-keys/api-keys.live.test.ts +++ b/apps/cli/src/commands/projects/api-keys/api-keys.live.test.ts @@ -10,10 +10,9 @@ test("lists API keys for a project", ({ cliEffect, project, signal }) => const result = yield* cliEffect([ "projects", "api-keys", - "--project-ref", - project.ref, "--output", "json", + `--remote=${project.ref}`, ]); expect(result.exitCode, result.stderr).toBe(0); const rows = yield* Schema.decodeEffect(Schema.fromJsonString(V1GetProjectApiKeysOutput))( diff --git a/apps/cli/src/commands/pull/pull.live.test.ts b/apps/cli/src/commands/pull/pull.live.test.ts index cb80061511..09e358cac8 100644 --- a/apps/cli/src/commands/pull/pull.live.test.ts +++ b/apps/cli/src/commands/pull/pull.live.test.ts @@ -21,11 +21,10 @@ test("pulls config, migration history, db schema, and functions from a fresh pro Effect.gen(function* () { const result = yield* cliEffect([ "pull", - "--project-ref", - project.ref, "--output-format", "json", "--yes", + `--remote=${project.ref}`, ]); yield* Effect.try({ try: () => requireLiveSuccess(result, "pull"), 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 fc65bb98d0..2646e45964 100644 --- a/apps/cli/src/commands/secrets/list/list.live.test.ts +++ b/apps/cli/src/commands/secrets/list/list.live.test.ts @@ -23,9 +23,8 @@ test("lists a secret created on the remote project", ({ cliEffect, project }) => "secrets", "set", `${name}=live-value`, - "--project-ref", - project.ref, "--yes", + `--remote=${project.ref}`, ]); yield* requireSuccess(created, "secrets set setup"); @@ -34,8 +33,7 @@ test("lists a secret created on the remote project", ({ cliEffect, project }) => "list", "--output", "json", - "--project-ref", - project.ref, + `--remote=${project.ref}`, ]); expect(result.exitCode, result.stderr).toBe(0); const secrets = yield* secretsPayload(result.stdout); 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 6a4b372b5d..8b6692c6ed 100644 --- a/apps/cli/src/commands/secrets/set/set.live.test.ts +++ b/apps/cli/src/commands/secrets/set/set.live.test.ts @@ -19,9 +19,8 @@ test("sets a secret on the remote project", ({ cliEffect, project }) => "secrets", "set", `${name}=live-value`, - "--project-ref", - project.ref, "--yes", + `--remote=${project.ref}`, ]); expect(result.exitCode, result.stderr).toBe(0); expect(result.stdout).toContain("Finished"); 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 a657a75e8f..e27a180a71 100644 --- a/apps/cli/src/commands/secrets/unset/unset.live.test.ts +++ b/apps/cli/src/commands/secrets/unset/unset.live.test.ts @@ -19,9 +19,8 @@ test("unsets a secret from the remote project", ({ cliEffect, project }) => "secrets", "set", `${name}=live-value`, - "--project-ref", - project.ref, "--yes", + `--remote=${project.ref}`, ]); yield* requireSuccess(created, "secrets set setup"); @@ -29,9 +28,8 @@ test("unsets a secret from the remote project", ({ cliEffect, project }) => "secrets", "unset", name, - "--project-ref", - project.ref, "--yes", + `--remote=${project.ref}`, ]); expect(result.exitCode, result.stderr).toBe(0); expect(result.stdout).toContain("Finished"); 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 7598e6c4c0..29b26e6122 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 shared target selector. + * - `--remote` combined with a deprecated selector alias is rejected by the shared target selector. * Bucket/object seeding is covered by the integration and unit suites. */ describe("supabase seed buckets", () => { @@ -41,26 +41,26 @@ describe("supabase seed buckets", () => { ); it.live( - "rejects passing both --local and --linked", + "rejects passing both --local and --remote", () => Effect.gen(function* () { const projectDir = yield* makeProjectDir("supabase-seed-buckets-e2e-both-"); const { exitCode, stdout, stderr } = yield* runSupabaseEffect( - ["seed", "buckets", "--local", "--linked"], + ["seed", "buckets", "--local", "--remote"], { cwd: projectDir }, ); expect(exitCode).toBe(1); expect(`${stdout}${stderr}`).toContain( - "Only one of --local, --linked and --db-url can be set; got --local and --linked.", + "Only one of --remote, --linked, --db-url and --local can be set; got --remote and --local.", ); }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), E2E_TIMEOUT_MS, ); - // --linked/--local are accepted before the subcommand token too; these two + // Selector flags are accepted before the subcommand token too; these two // cases exercise the real parser boundary, which the in-process suites bypass. it.live( - "accepts --local before the subcommand", + "accepts the deprecated --local before the subcommand and warns it is not needed", () => Effect.gen(function* () { const projectDir = yield* makeProjectDir("supabase-seed-buckets-e2e-prelocal-"); @@ -69,24 +69,26 @@ describe("supabase seed buckets", () => { { cwd: projectDir }, ); expect(`${stdout}${stderr}`).not.toContain("Unrecognized flag"); + expect(`${stdout}${stderr}`).toContain( + "--local isn't needed (local is the default) and will be removed in v4: supabase seed buckets", + ); expect(exitCode).toBe(0); - expect(stdout.trim()).toBe(""); }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), E2E_TIMEOUT_MS, ); it.live( - "rejects --local --linked before the subcommand", + "rejects --local --remote before the subcommand", () => Effect.gen(function* () { const projectDir = yield* makeProjectDir("supabase-seed-buckets-e2e-preboth-"); const { exitCode, stdout, stderr } = yield* runSupabaseEffect( - ["seed", "--local", "--linked", "buckets"], + ["seed", "--local", "--remote", "buckets"], { cwd: projectDir }, ); expect(exitCode).toBe(1); expect(`${stdout}${stderr}`).toContain( - "Only one of --local, --linked and --db-url can be set; got --local and --linked.", + "Only one of --remote, --linked, --db-url and --local can be set; got --remote and --local.", ); }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), E2E_TIMEOUT_MS, diff --git a/apps/cli/src/commands/snippets/snippets.e2e.test.ts b/apps/cli/src/commands/snippets/snippets.e2e.test.ts index 3d3a6222bb..a50bd09ed7 100644 --- a/apps/cli/src/commands/snippets/snippets.e2e.test.ts +++ b/apps/cli/src/commands/snippets/snippets.e2e.test.ts @@ -15,7 +15,7 @@ describe("supabase snippets", () => { () => Effect.gen(function* () { const { exitCode, stdout, stderr } = yield* runSupabaseEffect( - ["snippets", "download", "not-a-uuid", "--project-ref", TEST_PROJECT_REF], + ["snippets", "download", "not-a-uuid", `--remote=${TEST_PROJECT_REF}`], { env: { SUPABASE_ACCESS_TOKEN: TEST_TOKEN } }, ); expect(exitCode).toBe(1); diff --git a/apps/cli/src/commands/sso/sso.e2e.test.ts b/apps/cli/src/commands/sso/sso.e2e.test.ts index e4f613e968..96baf62def 100644 --- a/apps/cli/src/commands/sso/sso.e2e.test.ts +++ b/apps/cli/src/commands/sso/sso.e2e.test.ts @@ -15,7 +15,7 @@ describe("supabase sso", () => { const fs = yield* FileSystem.FileSystem; const cwd = yield* fs.makeTempDirectoryScoped({ prefix: "sso-e2e-" }); const { exitCode, stdout } = yield* runSupabaseEffect( - ["sso", "info", "--project-ref", TEST_PROJECT_REF, "--output-format", "json"], + ["sso", "info", "--output-format", "json", `--remote=${TEST_PROJECT_REF}`], { cwd, env: { SUPABASE_ACCESS_TOKEN: TEST_TOKEN } }, ); expect(exitCode).toBe(0); @@ -35,7 +35,7 @@ describe("supabase sso", () => { const fs = yield* FileSystem.FileSystem; const cwd = yield* fs.makeTempDirectoryScoped({ prefix: "sso-e2e-" }); const { exitCode, stdout } = yield* runSupabaseEffect( - ["sso", "info", "--project-ref", TEST_PROJECT_REF], + ["sso", "info", `--remote=${TEST_PROJECT_REF}`], { cwd, env: { SUPABASE_ACCESS_TOKEN: TEST_TOKEN } }, ); expect(exitCode).toBe(0); @@ -54,7 +54,7 @@ describe("supabase sso", () => { const fs = yield* FileSystem.FileSystem; const cwd = yield* fs.makeTempDirectoryScoped({ prefix: "sso-e2e-" }); const { exitCode, stdout, stderr } = yield* runSupabaseEffect( - ["sso", "show", "not-a-uuid", "--project-ref", TEST_PROJECT_REF], + ["sso", "show", "not-a-uuid", `--remote=${TEST_PROJECT_REF}`], { cwd, env: { SUPABASE_ACCESS_TOKEN: TEST_TOKEN } }, ); expect(exitCode).toBe(1); @@ -70,7 +70,7 @@ describe("supabase sso", () => { const fs = yield* FileSystem.FileSystem; const cwd = yield* fs.makeTempDirectoryScoped({ prefix: "sso-e2e-" }); const { exitCode, stdout, stderr } = yield* runSupabaseEffect( - ["sso", "remove", "not-a-uuid", "--project-ref", TEST_PROJECT_REF], + ["sso", "remove", "not-a-uuid", "--yes", `--remote=${TEST_PROJECT_REF}`], { cwd, env: { SUPABASE_ACCESS_TOKEN: TEST_TOKEN } }, ); expect(exitCode).toBe(1); @@ -86,7 +86,7 @@ describe("supabase sso", () => { const fs = yield* FileSystem.FileSystem; const cwd = yield* fs.makeTempDirectoryScoped({ prefix: "sso-e2e-" }); const { exitCode, stdout, stderr } = yield* runSupabaseEffect( - ["sso", "update", "not-a-uuid", "--project-ref", TEST_PROJECT_REF], + ["sso", "update", "not-a-uuid", "--yes", `--remote=${TEST_PROJECT_REF}`], { cwd, env: { SUPABASE_ACCESS_TOKEN: TEST_TOKEN } }, ); expect(exitCode).toBe(1); @@ -106,7 +106,7 @@ describe("supabase sso", () => { const fs = yield* FileSystem.FileSystem; const cwd = yield* fs.makeTempDirectoryScoped({ prefix: "sso-e2e-" }); const { exitCode, stdout, stderr } = yield* runSupabaseEffect( - ["sso", "add", "--project-ref", TEST_PROJECT_REF], + ["sso", "add", `--remote=${TEST_PROJECT_REF}`], { cwd }, ); expect(exitCode).toBe(1); @@ -125,7 +125,7 @@ describe("supabase sso", () => { const fs = yield* FileSystem.FileSystem; const cwd = yield* fs.makeTempDirectoryScoped({ prefix: "sso-e2e-" }); const { exitCode, stdout, stderr } = yield* runSupabaseEffect( - ["sso", "add", "--type", "bogus", "--project-ref", TEST_PROJECT_REF], + ["sso", "add", "--type", "bogus", `--remote=${TEST_PROJECT_REF}`], { cwd }, ); expect(exitCode).toBe(1); diff --git a/apps/cli/src/commands/storage/storage.e2e.test.ts b/apps/cli/src/commands/storage/storage.e2e.test.ts index dbf71a1873..1bd0995dbc 100644 --- a/apps/cli/src/commands/storage/storage.e2e.test.ts +++ b/apps/cli/src/commands/storage/storage.e2e.test.ts @@ -23,9 +23,9 @@ const withStorageProject = <A, E, R>( }).pipe(Effect.scoped, Effect.provide(BunServices.layer)); /** - * 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. + * Golden-path e2e for `storage`: the compiled binary and `--remote` 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", () => { it.live( @@ -46,19 +46,19 @@ describe("supabase storage", () => { ); it.live( - "rejects passing both --local and --linked", + "rejects passing both --local and --remote", () => withStorageProject("supabase-storage-e2e-mutex-", (projectDir) => Effect.gen(function* () { // 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"], + ["storage", "ls", "ss:///", "--experimental", "--local", "--remote"], { cwd: projectDir }, ); expect(exitCode).toBe(1); expect(`${stdout}${stderr}`).toContain( - "Only one of --local, --linked and --db-url can be set; got --local and --linked.", + "Only one of --remote, --linked, --db-url and --local can be set; got --remote and --local.", ); }), ), @@ -71,7 +71,7 @@ describe("supabase storage", () => { withStorageProject("supabase-storage-e2e-gate-", (projectDir) => Effect.gen(function* () { const { exitCode, stdout, stderr } = yield* runSupabaseEffect( - ["storage", "ls", "ss:///", "--local"], + ["storage", "ls", "ss:///"], { cwd: projectDir }, ); expect(exitCode).toBe(1); @@ -84,14 +84,12 @@ describe("supabase storage", () => { ); it.live( - "accepts --local after the subcommand token", + "accepts --remote after the subcommand token", () => - withStorageProject("supabase-storage-e2e-local-", (projectDir) => + withStorageProject("supabase-storage-e2e-remote-", (projectDir) => Effect.gen(function* () { - // --linked/--local are per-leaf flags, not global ones — Effect CLI requires - // unique global-flag names tree-wide and `seed` already owns those names. const { stdout, stderr } = yield* runSupabaseEffect( - ["storage", "ls", "ss:///", "--local", "--experimental"], + ["storage", "ls", "ss:///", "--experimental", "--remote"], { cwd: projectDir }, ); const combined = `${stdout}${stderr}`; diff --git a/apps/cli/src/shared/telemetry/failure-metadata.e2e.test.ts b/apps/cli/src/shared/telemetry/failure-metadata.e2e.test.ts index 43018bf949..6add2dd44a 100644 --- a/apps/cli/src/shared/telemetry/failure-metadata.e2e.test.ts +++ b/apps/cli/src/shared/telemetry/failure-metadata.e2e.test.ts @@ -34,9 +34,9 @@ describe("failed command telemetry", () => { args: [ "db", "query", - "--db-url", - "postgres://postgres:postgres@127.0.0.1:1/postgres", "select 1", + "--yes", + "--remote=postgres://postgres:postgres@127.0.0.1:1/postgres", ], command: "db query", accessToken: "", @@ -49,6 +49,24 @@ describe("failed command telemetry", () => { }, rawErrors: ["failed to connect", "127.0.0.1", "select 1"], }, + { + args: [ + "db", + "query", + "select 1", + "--remote=postgres://postgres:postgres@127.0.0.1:1/postgres", + ], + command: "db query", + accessToken: "", + expected: { + error_kind: "user_actionable", + error_category: "consent_required", + error_fingerprint: "tag:HostedMutationConsentRequiredError", + has_suggestion: true, + suggestion_type: "provide_flags", + }, + rawErrors: ["127.0.0.1", "select 1"], + }, ])("emits sanitized metadata from the compiled CLI ($command)", (testCase) => Effect.gen(function* () { const capturedEvents: CapturedEvent[] = []; From 1c9ad8f22477253174a159e1c381d6916798d8bd Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Mon, 19 Oct 2026 11:46:09 +0100 Subject: [PATCH 112/291] test(cli): let setupStorage answer a --remote=<branch> lookup --- apps/cli/tests/helpers/storage.ts | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/apps/cli/tests/helpers/storage.ts b/apps/cli/tests/helpers/storage.ts index 7f0c311272..29791c9f5a 100644 --- a/apps/cli/tests/helpers/storage.ts +++ b/apps/cli/tests/helpers/storage.ts @@ -80,6 +80,8 @@ export interface SetupStorageOptions { type?: string | null; secret_jwt_template?: Record<string, unknown> | null; }>; + /** The linked project's branches, answering a `--remote=<branch>` lookup. */ + readonly branches?: ReadonlyArray<Record<string, unknown>>; readonly linkedFails?: boolean; readonly explicitWorkdir?: boolean; readonly stackBackend?: boolean; @@ -381,6 +383,22 @@ const STORAGE_TEST_API_KEYS = [ }, ]; +export const STORAGE_TEST_BRANCH_REF = "branchprojectrefabcd"; + +/** A linked-project branch row for the `branches` option. */ +export const storageTestBranch = (name: string): Record<string, unknown> => ({ + id: "11111111-2222-4333-8444-555555555555", + name, + project_ref: STORAGE_TEST_BRANCH_REF, + parent_project_ref: VALID_REF, + is_default: false, + persistent: false, + status: "MIGRATIONS_PASSED", + created_at: "2026-05-27T01:02:03Z", + updated_at: "2026-05-27T01:02:04Z", + with_data: true, +}); + /** `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 } }, @@ -451,6 +469,7 @@ export function setupStorage(workdir: string, opts: SetupStorageOptions) { const managementApi = mockCommandPlatformApiService({ v1: { getProjectApiKeys: () => Effect.succeed(opts.apiKeys ?? STORAGE_TEST_API_KEYS), + listAllBranches: () => Effect.succeed(opts.branches ?? []), }, }); @@ -527,5 +546,6 @@ export function setupStorage(workdir: string, opts: SetupStorageOptions) { linkedCache, stackCalls: stackApi.stackCalls, managementRequests: recorded?.requests ?? [], + managementCalls: managementApi.requests, }; } From 179f8e8a80a0738a1081571fd8ad594cd50a4ffc Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Mon, 19 Oct 2026 11:46:11 +0100 Subject: [PATCH 113/291] test(cli): cover storage commands through --remote and reject a database URL --- .../storage/cp/cp.command.integration.test.ts | 6 +- .../storage/cp/cp.integration.test.ts | 179 ++++++++++--- .../storage/ls/ls.integration.test.ts | 189 +++++++++++--- .../storage/mv/mv.integration.test.ts | 169 ++++++++++-- .../storage/rm/rm.integration.test.ts | 243 +++++++++++++----- ...rage.experimental-gate.integration.test.ts | 6 +- 6 files changed, 629 insertions(+), 163 deletions(-) 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 7bb410a154..a7c852b56c 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 @@ -21,7 +21,7 @@ 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 +// `--experimental` gate and the `--remote`/`--local` mutex check in the handler — not just // reachable by calling `storageCp` directly with a handcrafted flags object, which // `cp.integration.test.ts` can't exercise since it calls the handler directly. const testRoot = Command.make("supabase").pipe( @@ -66,7 +66,7 @@ function setup(args: ReadonlyArray<string>) { describe("storage cp --jobs negative rejection (command-tree wiring)", () => { it.effect( - "rejects --jobs=-1 with the exact ParseUint message, ahead of the experimental gate and the --linked/--local mutex conflict", + "rejects --jobs=-1 with the exact ParseUint message, ahead of the experimental gate and the --remote/--local mutex conflict", () => { const args = [ "storage", @@ -74,7 +74,7 @@ describe("storage cp --jobs negative rejection (command-tree wiring)", () => { "ss:///bucket/a", "ss:///bucket/b", "--jobs=-1", - "--linked", + "--remote", "--local", ]; const { layer } = setup(args); 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 6d11f6dcb9..259e0e7e1c 100644 --- a/apps/cli/src/commands/storage/cp/cp.integration.test.ts +++ b/apps/cli/src/commands/storage/cp/cp.integration.test.ts @@ -4,9 +4,11 @@ import { Cause, Effect, Exit, FileSystem, Option, Path, Predicate } from "effect import { describeHostedConsent } from "../../../../tests/helpers/hosted-consent.ts"; import { setupStorage, + STORAGE_TEST_BRANCH_REF, STORAGE_TEST_JWT_SECRET, storageConsentApi, storageConsentServices, + storageTestBranch, } from "../../../../tests/helpers/storage.ts"; import { VALID_REF, useTempWorkdir, withEnvVar } from "../../../../tests/helpers/command-mocks.ts"; import { assertPermissionDrift } from "../../../../tests/helpers/permission-drift.ts"; @@ -16,6 +18,8 @@ import { StorageUnsupportedOperationError } from "../storage.errors.ts"; import { storageCp } from "./cp.handler.ts"; import type { StorageCpFlags } from "./cp.command.ts"; +const FLAG_REF = "flagflagflagflagflag"; +const DB_URL = "postgresql://postgres:postgres@127.0.0.1:54322/postgres"; const BUCKET = "/storage/v1/bucket"; const OBJECT = (p: string) => `/storage/v1/object/${p}`; const LIST = (bucket: string) => `/storage/v1/object/list/${bucket}`; @@ -30,7 +34,7 @@ function cpFlags(opts: { cacheControl?: string; contentType?: string; jobs?: number; - local?: boolean; + remote?: boolean; }): StorageCpFlags { return { src: opts.src, @@ -39,9 +43,9 @@ 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), - remote: Option.none(), - linked: opts.local === false, - local: opts.local ?? true, + remote: opts.remote === true ? Option.some({ _tag: "Linked" } as const) : Option.none(), + linked: false, + local: false, projectRef: Option.none(), }; } @@ -535,7 +539,7 @@ describe("storage cp", () => { cpFlags({ src: path.join(tmp.current, "readme.md"), dst: "ss:///private/readme.md", - local: false, + remote: true, }), ).pipe(Effect.exit); expect(Exit.isSuccess(exit)).toBe(true); @@ -556,7 +560,7 @@ describe("storage cp", () => { 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 }), + cpFlags({ src: "ss:///private/readme.md", dst, remote: true }), ).pipe(Effect.exit); expect(Exit.isSuccess(exit)).toBe(true); expect(yield* fs.readFileString(dst)).toBe("downloaded-bytes"); @@ -564,10 +568,9 @@ describe("storage cp", () => { }).pipe(Effect.provide(layer)); }); - it.effect("uploads to the project given via --project-ref, overriding VALID_REF", () => { + it.effect("uploads to the project given via --remote=<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: {} }], @@ -578,8 +581,7 @@ describe("storage cp", () => { yield* fs.writeFileString(path.join(tmp.current, "readme.md"), "hello world"); const exit = yield* storageCp({ ...cpFlags({ src: path.join(tmp.current, "readme.md"), dst: "ss:///private/readme.md" }), - local: false, - projectRef: Option.some(FLAG_REF), + remote: Option.some({ _tag: "Ref", ref: FLAG_REF } as const), }).pipe(Effect.exit); expect(Exit.isSuccess(exit)).toBe(true); expect(requests.some((r) => r.url.startsWith(`https://${FLAG_REF}.supabase.co`))).toBe(true); @@ -589,32 +591,149 @@ describe("storage cp", () => { }).pipe(Effect.provide(layer)); }); - it.effect("rejects --project-ref combined with --local", () => { - const FLAG_REF = "flagflagflagflagflag"; - const { layer, requests, linkedCache } = setupStorage(tmp.current, { - toml: 'project_id = "test"\n', - local: true, + it.effect("downloads from the project of the branch --remote=<branch> names", () => { + const { layer, requests, linkedCache, managementCalls, out } = setupStorage(tmp.current, { + branches: [storageTestBranch("feature-x")], + 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; - yield* fs.writeFileString(path.join(tmp.current, "readme.md"), "hello world"); const exit = yield* storageCp({ - ...cpFlags({ src: path.join(tmp.current, "readme.md"), dst: "ss:///private/readme.md" }), - local: true, - projectRef: Option.some(FLAG_REF), + ...cpFlags({ src: "ss:///private/readme.md", dst: path.join(tmp.current, "out.md") }), + remote: Option.some({ _tag: "Branch", name: "feature-x" } as const), }).pipe(Effect.exit); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - expect(Cause.pretty(exit.cause)).toContain( - "--project-ref selects a remote project and cannot be combined with --local.", - ); - } + expect(Exit.isSuccess(exit)).toBe(true); + expect(managementCalls.filter(({ method }) => method === "listAllBranches")).toEqual([ + { method: "listAllBranches", input: { ref: VALID_REF } }, + ]); + expect( + requests.some((r) => r.url.startsWith(`https://${STORAGE_TEST_BRANCH_REF}.supabase.co`)), + ).toBe(true); + expect(linkedCache.cachedRef).toBe(STORAGE_TEST_BRANCH_REF); + expect( + out.messages.filter((message) => message.type === "warn").map((message) => message.message), + ).toEqual([]); + }).pipe(Effect.provide(layer)); + }); + + it.effect("fails with the branches that exist when --remote=<branch> matches none", () => { + const { layer, requests } = setupStorage(tmp.current, { + branches: [storageTestBranch("feature-x")], + yes: true, + }); + return Effect.gen(function* () { + const path = yield* Path.Path; + const error = yield* storageCp({ + ...cpFlags({ src: "ss:///private/readme.md", dst: path.join(tmp.current, "out.md") }), + remote: Option.some({ _tag: "Branch", name: "feature-y" } as const), + }).pipe(Effect.flip); + expect(error).toMatchObject({ + _tag: "LinkBranchNotFoundError", + message: expect.stringContaining("Available branches: feature-x"), + }); + expect(requests).toHaveLength(0); + }).pipe(Effect.provide(layer)); + }); + + it.effect("rejects --remote=<url> because storage needs a project, not a database", () => { + const { layer, requests, linkedCache } = setupStorage(tmp.current, { + toml: 'project_id = "test"\n', + yes: true, + }); + return Effect.gen(function* () { + const path = yield* Path.Path; + const error = yield* storageCp({ + ...cpFlags({ src: "ss:///private/readme.md", dst: path.join(tmp.current, "out.md") }), + remote: Option.some({ _tag: "DbUrl", url: DB_URL } as const), + }).pipe(Effect.flip); + expect(error).toMatchObject({ + _tag: "TargetNeedsProjectError", + message: expect.stringContaining("needs a project, not a database URL"), + }); expect(requests).toHaveLength(0); expect(linkedCache.cached).toBe(false); }).pipe(Effect.provide(layer)); }); + describe("deprecated selectors", () => { + const cases = [ + { + alias: "--linked", + cliArgs: ["storage", "cp", "--linked"], + flags: { linked: true }, + host: `https://${VALID_REF}.supabase.co`, + warning: + "--linked is deprecated and will be removed in v4. Use --remote instead: supabase storage cp --remote", + }, + { + alias: "--project-ref", + cliArgs: ["storage", "cp", "--project-ref", FLAG_REF], + flags: { projectRef: Option.some(FLAG_REF) }, + host: `https://${FLAG_REF}.supabase.co`, + warning: `--project-ref is deprecated and will be removed in v4. Use --remote instead: supabase storage cp --remote=${FLAG_REF}`, + }, + { + alias: "--local", + cliArgs: ["storage", "cp", "--local"], + flags: { local: true }, + host: "http://127.0.0.1:65432", + warning: + "--local isn't needed (local is the default) and will be removed in v4: supabase storage cp", + }, + ] as const; + + for (const testCase of cases) { + it.effect(`${testCase.alias} still selects its target and warns`, () => { + const { layer, requests, out } = setupStorage(tmp.current, { + toml: 'project_id = "test"\n[api]\nport = 65432\n', + cliArgs: testCase.cliArgs, + routes: [ + { method: "GET", match: OBJECT("private/readme.md"), rawBody: "downloaded-bytes" }, + ], + }); + return Effect.gen(function* () { + const path = yield* Path.Path; + const exit = yield* storageCp({ + ...cpFlags({ src: "ss:///private/readme.md", dst: path.join(tmp.current, "out.md") }), + ...testCase.flags, + }).pipe(Effect.exit); + expect(Exit.isSuccess(exit)).toBe(true); + expect(requests[0]?.url.startsWith(testCase.host)).toBe(true); + expect( + out.messages + .filter((message) => message.type === "warn") + .map((message) => message.message), + ).toEqual([testCase.warning]); + }).pipe(Effect.provide(layer)); + }); + } + + it.effect("rejects --project-ref combined with --local", () => { + const { layer, requests, linkedCache } = setupStorage(tmp.current, { + toml: 'project_id = "test"\n', + local: true, + }); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* fs.writeFileString(path.join(tmp.current, "readme.md"), "hello world"); + const exit = yield* storageCp({ + ...cpFlags({ src: path.join(tmp.current, "readme.md"), dst: "ss:///private/readme.md" }), + local: true, + projectRef: Option.some(FLAG_REF), + }).pipe(Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(Cause.pretty(exit.cause)).toContain( + "--project-ref selects a remote project and cannot be combined with --local.", + ); + } + expect(requests).toHaveLength(0); + expect(linkedCache.cached).toBe(false); + }).pipe(Effect.provide(layer)); + }); + }); + it.effect("propagates a non-200 from the gateway on upload", () => { const { layer } = setupStorage(tmp.current, { toml: 'project_id = "test"\n', @@ -730,7 +849,7 @@ describe("storage cp", () => { cpFlags({ src: path.join(tmp.current, "readme.md"), dst: "ss:///private/readme.md", - local: false, + remote: true, jobs: 1, }), ); @@ -743,7 +862,7 @@ describe("storage cp", () => { expect(yield* fs.exists(path.join(tmp.current, CACHE_FILE))).toBe(true); assertPermissionDrift({ command: "storage cp", - activeFlags: { linked: true, jobs: "1" }, + activeFlags: { remote: true, jobs: "1" }, requests: managementRequests, }); }).pipe(Effect.provide(layer)); @@ -857,9 +976,7 @@ describeHostedConsent("storage cp", { return { run: storageCp({ ...cpFlags({ src: `${workdir}/readme.md`, dst: "ss:///private/readme.md" }), - local: false, - linked: false, - projectRef: Option.some(VALID_REF), + remote: Option.some({ _tag: "Ref", ref: VALID_REF } as const), }).pipe(Effect.provide(storage.layer)), mutations: storage.writes, }; 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 b70bb7c696..30dba8d0e6 100644 --- a/apps/cli/src/commands/storage/ls/ls.integration.test.ts +++ b/apps/cli/src/commands/storage/ls/ls.integration.test.ts @@ -3,7 +3,12 @@ import { describe, expect, it } from "@effect/vitest"; import { Cause, Effect, Exit, FileSystem, Option, Path, Predicate } from "effect"; import { VALID_REF, useTempWorkdir, withEnvVar } from "../../../../tests/helpers/command-mocks.ts"; -import { setupStorage, STORAGE_TEST_JWT_SECRET } from "../../../../tests/helpers/storage.ts"; +import { + setupStorage, + STORAGE_TEST_BRANCH_REF, + STORAGE_TEST_JWT_SECRET, + storageTestBranch, +} from "../../../../tests/helpers/storage.ts"; import { assertPermissionDrift } from "../../../../tests/helpers/permission-drift.ts"; import { StackStorageCapabilityError, @@ -14,6 +19,8 @@ import { storageLs } from "./ls.handler.ts"; import type { StorageLsFlags } from "./ls.command.ts"; import { generateLocalJwt } from "../../../command-internal/local-jwt.ts"; +const FLAG_REF = "flagflagflagflagflag"; +const DB_URL = "postgresql://postgres:postgres@127.0.0.1:54322/postgres"; const BUCKET = "/storage/v1/bucket"; const LIST = (bucket: string) => `/storage/v1/object/list/${bucket}`; const REVEALED_API_KEYS = `GET https://api.supabase.com/v1/projects/${VALID_REF}/api-keys?reveal=true`; @@ -35,15 +42,15 @@ function failureErrors<A, E>(exit: Exit.Exit<A, E>): ReadonlyArray<E> { } function lsFlags( - opts: { path?: string; recursive?: boolean; local?: boolean } = {}, + opts: { path?: string; recursive?: boolean; remote?: boolean } = {}, ): StorageLsFlags { - // `local` drives routing (default true here — most tests use the local stack). + // Without `remote` the command targets the local stack, as a bare invocation does. return { path: opts.path === undefined ? Option.none() : Option.some(opts.path), recursive: opts.recursive ?? false, - remote: Option.none(), - linked: opts.local === false, - local: opts.local ?? true, + remote: opts.remote === true ? Option.some({ _tag: "Linked" } as const) : Option.none(), + linked: false, + local: false, projectRef: Option.none(), }; } @@ -236,7 +243,7 @@ describe("storage ls", () => { ], }); return Effect.gen(function* () { - const exit = yield* storageLs(lsFlags({ local: false })).pipe( + const exit = yield* storageLs(lsFlags({ remote: true })).pipe( Effect.provide(layer), Effect.exit, ); @@ -260,7 +267,7 @@ project_id = "env(STAGING_REF)" "SUPABASE_REMOTES_STAGING_PROJECT_ID", VALID_REF, Effect.gen(function* () { - const exit = yield* storageLs(lsFlags({ local: false })).pipe( + const exit = yield* storageLs(lsFlags({ remote: true })).pipe( Effect.provide(layer), Effect.exit, ); @@ -270,17 +277,16 @@ project_id = "env(STAGING_REF)" ); }); - it.effect("lists the project given via --project-ref, overriding VALID_REF", () => { + it.effect("lists the project given via --remote=<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, { routes: [{ method: "GET", match: BUCKET, body: [{ name: "remote", id: "remote" }] }], }); return Effect.gen(function* () { const exit = yield* storageLs({ - ...lsFlags({ local: false }), - projectRef: Option.some(FLAG_REF), + ...lsFlags(), + remote: Option.some({ _tag: "Ref", ref: FLAG_REF } as const), }).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isSuccess(exit)).toBe(true); expect(requests.some((r) => r.url.startsWith(`https://${FLAG_REF}.supabase.co`))).toBe(true); @@ -290,28 +296,138 @@ project_id = "env(STAGING_REF)" }); }); - it.effect("rejects --project-ref combined with --local", () => { - const FLAG_REF = "flagflagflagflagflag"; - const { layer, requests, linkedCache } = setupStorage(tmp.current, { - toml: 'project_id = "test"\n', - local: true, + it.effect("lists the project of the branch --remote=<branch> names", () => { + const { layer, requests, linkedCache, managementCalls, out } = setupStorage(tmp.current, { + branches: [storageTestBranch("feature-x")], + routes: [{ method: "GET", match: BUCKET, body: [{ name: "remote", id: "remote" }] }], }); return Effect.gen(function* () { const exit = yield* storageLs({ - ...lsFlags({ local: true }), - projectRef: Option.some(FLAG_REF), + ...lsFlags(), + remote: Option.some({ _tag: "Branch", name: "feature-x" } as const), }).pipe(Effect.provide(layer), Effect.exit); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - expect(Cause.pretty(exit.cause)).toContain( - "--project-ref selects a remote project and cannot be combined with --local.", - ); - } + expect(Exit.isSuccess(exit)).toBe(true); + expect(managementCalls.filter(({ method }) => method === "listAllBranches")).toEqual([ + { method: "listAllBranches", input: { ref: VALID_REF } }, + ]); + expect( + requests.some((r) => r.url.startsWith(`https://${STORAGE_TEST_BRANCH_REF}.supabase.co`)), + ).toBe(true); + expect(linkedCache.cachedRef).toBe(STORAGE_TEST_BRANCH_REF); + expect( + out.messages.filter((message) => message.type === "warn").map((message) => message.message), + ).toEqual([]); + }); + }); + + it.effect("fails with the branches that exist when --remote=<branch> matches none", () => { + const { layer, requests } = setupStorage(tmp.current, { + branches: [storageTestBranch("feature-x")], + }); + return Effect.gen(function* () { + const error = yield* storageLs({ + ...lsFlags(), + remote: Option.some({ _tag: "Branch", name: "feature-y" } as const), + }).pipe(Effect.provide(layer), Effect.flip); + expect(error).toMatchObject({ + _tag: "LinkBranchNotFoundError", + message: expect.stringContaining("Available branches: feature-x"), + }); + expect(requests).toHaveLength(0); + }); + }); + + it.effect("rejects --remote=<url> because storage needs a project, not a database", () => { + const { layer, requests, linkedCache } = setupStorage(tmp.current, { + toml: 'project_id = "test"\n', + }); + return Effect.gen(function* () { + const error = yield* storageLs({ + ...lsFlags(), + remote: Option.some({ _tag: "DbUrl", url: DB_URL } as const), + }).pipe(Effect.provide(layer), Effect.flip); + expect(error).toMatchObject({ + _tag: "TargetNeedsProjectError", + message: expect.stringContaining("needs a project, not a database URL"), + }); expect(requests).toHaveLength(0); expect(linkedCache.cached).toBe(false); }); }); + describe("deprecated selectors", () => { + const cases = [ + { + alias: "--linked", + cliArgs: ["storage", "ls", "--linked"], + flags: { linked: true }, + host: `https://${VALID_REF}.supabase.co`, + warning: + "--linked is deprecated and will be removed in v4. Use --remote instead: supabase storage ls --remote", + }, + { + alias: "--project-ref", + cliArgs: ["storage", "ls", "--project-ref", FLAG_REF], + flags: { projectRef: Option.some(FLAG_REF) }, + host: `https://${FLAG_REF}.supabase.co`, + warning: `--project-ref is deprecated and will be removed in v4. Use --remote instead: supabase storage ls --remote=${FLAG_REF}`, + }, + { + alias: "--local", + cliArgs: ["storage", "ls", "--local"], + flags: { local: true }, + host: "http://127.0.0.1:65432", + warning: + "--local isn't needed (local is the default) and will be removed in v4: supabase storage ls", + }, + ] as const; + + for (const testCase of cases) { + it.effect(`${testCase.alias} still selects its target and warns`, () => { + const { layer, requests, out } = setupStorage(tmp.current, { + toml: 'project_id = "test"\n[api]\nport = 65432\n', + cliArgs: testCase.cliArgs, + routes: [{ method: "GET", match: BUCKET, body: [{ name: "remote", id: "remote" }] }], + }); + return Effect.gen(function* () { + const exit = yield* storageLs({ ...lsFlags(), ...testCase.flags }).pipe( + Effect.provide(layer), + Effect.exit, + ); + expect(Exit.isSuccess(exit)).toBe(true); + expect(requests[0]?.url.startsWith(testCase.host)).toBe(true); + expect( + out.messages + .filter((message) => message.type === "warn") + .map((message) => message.message), + ).toEqual([testCase.warning]); + }); + }); + } + + it.effect("rejects --project-ref combined with --local", () => { + const { layer, requests, linkedCache } = setupStorage(tmp.current, { + toml: 'project_id = "test"\n', + local: true, + }); + return Effect.gen(function* () { + const exit = yield* storageLs({ + ...lsFlags(), + local: true, + projectRef: Option.some(FLAG_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 selects a remote project and cannot be combined with --local.", + ); + } + expect(requests).toHaveLength(0); + expect(linkedCache.cached).toBe(false); + }); + }); + }); + it.effect( "signs --local requests with a SUPABASE_AUTH_SERVICE_ROLE_KEY from supabase/.env", () => { @@ -415,7 +531,7 @@ project_id = "env(STAGING_REF)" explicitWorkdir: true, routes: [{ method: "GET", match: BUCKET, body: [{ name: "remote", id: "remote" }] }], }); - const exit = yield* storageLs(lsFlags({ local: false })).pipe( + const exit = yield* storageLs(lsFlags({ remote: true })).pipe( Effect.provide(layer), Effect.exit, ); @@ -484,7 +600,7 @@ project_id = "env(STAGING_REF)" routes: [{ method: "GET", match: BUCKET, body: [{ name: "remote", id: "remote" }] }], }); const fs = yield* FileSystem.FileSystem; - yield* storageLs(lsFlags({ local: false })).pipe(Effect.provide(layer)); + yield* storageLs(lsFlags({ remote: true })).pipe(Effect.provide(layer)); expect( managementRequests.map(({ method, urlWithParams }) => `${method} ${urlWithParams}`), ).toEqual([REVEALED_API_KEYS, PROJECT_LOOKUP]); @@ -493,7 +609,7 @@ project_id = "env(STAGING_REF)" expect(yield* fs.exists(`${tmp.current}/${CACHE_FILE}`)).toBe(true); assertPermissionDrift({ command: "storage ls", - activeFlags: { linked: true }, + activeFlags: { remote: true }, requests: managementRequests, }); }).pipe(Effect.provide(BunServices.layer)), @@ -509,21 +625,21 @@ project_id = "env(STAGING_REF)" recordManagementApi: true, routes: [{ method: "GET", match: BUCKET, body: [{ name: "remote", id: "remote" }] }], }); - yield* storageLs(lsFlags({ local: false })).pipe(Effect.provide(layer)); + yield* storageLs(lsFlags({ remote: true })).pipe(Effect.provide(layer)); expect(requests[0]?.headers["apikey"]).toBe("env-service-role-key"); expect(managementRequests.map(({ method, url }) => `${method} ${url}`)).toEqual([ PROJECT_LOOKUP, ]); assertPermissionDrift({ command: "storage ls", - activeFlags: { linked: true }, + activeFlags: { remote: true }, requests: managementRequests, }); }), ), ); - it.effect("makes no Management API request for --local", () => + it.effect("makes no Management API request for the default local target", () => withEnvVar( "SUPABASE_AUTH_SERVICE_ROLE_KEY", undefined, @@ -534,7 +650,7 @@ project_id = "env(STAGING_REF)" routes: [{ method: "GET", match: BUCKET, body: [{ name: "test", id: "test" }] }], }); const fs = yield* FileSystem.FileSystem; - yield* storageLs(lsFlags({ local: true })).pipe(Effect.provide(layer)); + yield* storageLs(lsFlags({})).pipe(Effect.provide(layer)); expect(out.stdoutText).toBe("test/\n"); expect(requests).toHaveLength(1); expect(requests[0]?.url.startsWith("http://127.0.0.1:65432")).toBe(true); @@ -840,7 +956,7 @@ describe("stack backend", () => { routes: [{ method: "GET", match: BUCKET, body: [{ name: "remote", id: "remote" }] }], }); return Effect.gen(function* () { - const exit = yield* storageLs(lsFlags({ local: false })).pipe( + const exit = yield* storageLs(lsFlags({ remote: true })).pipe( Effect.provide(layer), Effect.exit, ); @@ -861,7 +977,7 @@ describe("stack backend", () => { routes: [{ method: "GET", match: BUCKET, status: 503, body: { message: "unavailable" } }], }); return Effect.gen(function* () { - const exit = yield* storageLs(lsFlags({ local: false })).pipe( + const exit = yield* storageLs(lsFlags({ remote: true })).pipe( Effect.provide(layer), Effect.exit, ); @@ -996,10 +1112,7 @@ describe("stack backend", () => { 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, - ); + const exit = yield* storageLs(lsFlags()).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/mv/mv.integration.test.ts b/apps/cli/src/commands/storage/mv/mv.integration.test.ts index 3938db320a..da9332f61f 100644 --- a/apps/cli/src/commands/storage/mv/mv.integration.test.ts +++ b/apps/cli/src/commands/storage/mv/mv.integration.test.ts @@ -6,15 +6,19 @@ import { generateLocalJwt } from "../../../command-internal/local-jwt.ts"; import { describeHostedConsent } from "../../../../tests/helpers/hosted-consent.ts"; import { setupStorage, + STORAGE_TEST_BRANCH_REF, STORAGE_TEST_JWT_SECRET, storageConsentApi, storageConsentServices, + storageTestBranch, } from "../../../../tests/helpers/storage.ts"; import { VALID_REF, useTempWorkdir, withEnvVar } from "../../../../tests/helpers/command-mocks.ts"; import { assertPermissionDrift } from "../../../../tests/helpers/permission-drift.ts"; import { storageMv } from "./mv.handler.ts"; import type { StorageMvFlags } from "./mv.command.ts"; +const FLAG_REF = "flagflagflagflagflag"; +const DB_URL = "postgresql://postgres:postgres@127.0.0.1:54322/postgres"; const MOVE = "/storage/v1/object/move"; const LIST = (bucket: string) => `/storage/v1/object/list/${bucket}`; const REVEALED_API_KEYS = `GET https://api.supabase.com/v1/projects/${VALID_REF}/api-keys?reveal=true`; @@ -25,15 +29,15 @@ function mvFlags(opts: { src: string; dst: string; recursive?: boolean; - local?: boolean; + remote?: boolean; }): StorageMvFlags { return { src: opts.src, dst: opts.dst, recursive: opts.recursive ?? false, - remote: Option.none(), - linked: opts.local === false, - local: opts.local ?? true, + remote: opts.remote === true ? Option.some({ _tag: "Linked" } as const) : Option.none(), + linked: false, + local: false, projectRef: Option.none(), }; } @@ -282,7 +286,7 @@ describe("storage mv", () => { }); return Effect.gen(function* () { const exit = yield* storageMv( - mvFlags({ src: "ss:///private/a", dst: "ss:///private/b", local: false }), + mvFlags({ src: "ss:///private/a", dst: "ss:///private/b", remote: true }), ).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isSuccess(exit)).toBe(true); expect(requests.some((r) => r.url.startsWith(`https://${VALID_REF}.supabase.co`))).toBe(true); @@ -292,9 +296,8 @@ describe("storage mv", () => { }); }); - it.effect("moves within the project given via --project-ref, overriding VALID_REF", () => { + it.effect("moves within the project given via --remote=<ref>, overriding VALID_REF", () => { // 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" } }], @@ -302,8 +305,7 @@ describe("storage mv", () => { return Effect.gen(function* () { const exit = yield* storageMv({ ...mvFlags({ src: "ss:///private/a", dst: "ss:///private/b" }), - local: false, - projectRef: Option.some(FLAG_REF), + remote: Option.some({ _tag: "Ref", ref: FLAG_REF } as const), }).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isSuccess(exit)).toBe(true); expect(requests.some((r) => r.url.startsWith(`https://${FLAG_REF}.supabase.co`))).toBe(true); @@ -313,26 +315,139 @@ describe("storage mv", () => { }); }); - it.effect("rejects --project-ref combined with --local", () => { - const FLAG_REF = "flagflagflagflagflag"; + it.effect("moves within the project of the branch --remote=<branch> names", () => { + const { layer, requests, linkedCache, managementCalls, out } = setupStorage(tmp.current, { + branches: [storageTestBranch("feature-x")], + yes: true, + routes: [{ method: "POST", match: MOVE, body: { message: "Successfully moved" } }], + }); + return Effect.gen(function* () { + const exit = yield* storageMv({ + ...mvFlags({ src: "ss:///private/a", dst: "ss:///private/b" }), + remote: Option.some({ _tag: "Branch", name: "feature-x" } as const), + }).pipe(Effect.exit); + expect(Exit.isSuccess(exit)).toBe(true); + expect(managementCalls.filter(({ method }) => method === "listAllBranches")).toEqual([ + { method: "listAllBranches", input: { ref: VALID_REF } }, + ]); + expect( + requests.some((r) => r.url.startsWith(`https://${STORAGE_TEST_BRANCH_REF}.supabase.co`)), + ).toBe(true); + expect(linkedCache.cachedRef).toBe(STORAGE_TEST_BRANCH_REF); + expect( + out.messages.filter((message) => message.type === "warn").map((message) => message.message), + ).toEqual([]); + }).pipe(Effect.provide(layer)); + }); + + it.effect("fails with the branches that exist when --remote=<branch> matches none", () => { + const { layer, requests } = setupStorage(tmp.current, { + branches: [storageTestBranch("feature-x")], + yes: true, + }); + return Effect.gen(function* () { + const error = yield* storageMv({ + ...mvFlags({ src: "ss:///private/a", dst: "ss:///private/b" }), + remote: Option.some({ _tag: "Branch", name: "feature-y" } as const), + }).pipe(Effect.flip); + expect(error).toMatchObject({ + _tag: "LinkBranchNotFoundError", + message: expect.stringContaining("Available branches: feature-x"), + }); + expect(requests).toHaveLength(0); + }).pipe(Effect.provide(layer)); + }); + + it.effect("rejects --remote=<url> because storage needs a project, not a database", () => { const { layer, requests, linkedCache } = setupStorage(tmp.current, { toml: 'project_id = "test"\n', - local: true, + yes: true, }); return Effect.gen(function* () { - const exit = yield* storageMv({ + const error = yield* storageMv({ ...mvFlags({ src: "ss:///private/a", dst: "ss:///private/b" }), - local: true, - projectRef: Option.some(FLAG_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 selects a remote project and cannot be combined with --local.", - ); - } + remote: Option.some({ _tag: "DbUrl", url: DB_URL } as const), + }).pipe(Effect.flip); + expect(error).toMatchObject({ + _tag: "TargetNeedsProjectError", + message: expect.stringContaining("needs a project, not a database URL"), + }); expect(requests).toHaveLength(0); expect(linkedCache.cached).toBe(false); + }).pipe(Effect.provide(layer)); + }); + + describe("deprecated selectors", () => { + const cases = [ + { + alias: "--linked", + cliArgs: ["storage", "mv", "--linked"], + flags: { linked: true }, + host: `https://${VALID_REF}.supabase.co`, + warning: + "--linked is deprecated and will be removed in v4. Use --remote instead: supabase storage mv --remote", + }, + { + alias: "--project-ref", + cliArgs: ["storage", "mv", "--project-ref", FLAG_REF], + flags: { projectRef: Option.some(FLAG_REF) }, + host: `https://${FLAG_REF}.supabase.co`, + warning: `--project-ref is deprecated and will be removed in v4. Use --remote instead: supabase storage mv --remote=${FLAG_REF}`, + }, + { + alias: "--local", + cliArgs: ["storage", "mv", "--local"], + flags: { local: true }, + host: "http://127.0.0.1:65432", + warning: + "--local isn't needed (local is the default) and will be removed in v4: supabase storage mv", + }, + ] as const; + + for (const testCase of cases) { + it.effect(`${testCase.alias} still selects its target and warns`, () => { + const { layer, requests, out } = setupStorage(tmp.current, { + toml: 'project_id = "test"\n[api]\nport = 65432\n', + cliArgs: testCase.cliArgs, + yes: true, + routes: [{ method: "POST", match: MOVE, body: { message: "Successfully moved" } }], + }); + return Effect.gen(function* () { + const exit = yield* storageMv({ + ...mvFlags({ src: "ss:///private/a", dst: "ss:///private/b" }), + ...testCase.flags, + }).pipe(Effect.exit); + expect(Exit.isSuccess(exit)).toBe(true); + expect(requests[0]?.url.startsWith(testCase.host)).toBe(true); + expect( + out.messages + .filter((message) => message.type === "warn") + .map((message) => message.message), + ).toEqual([testCase.warning]); + }).pipe(Effect.provide(layer)); + }); + } + + it.effect("rejects --project-ref combined with --local", () => { + const { layer, requests, linkedCache } = setupStorage(tmp.current, { + toml: 'project_id = "test"\n', + local: true, + }); + return Effect.gen(function* () { + const exit = yield* storageMv({ + ...mvFlags({ src: "ss:///private/a", dst: "ss:///private/b" }), + local: true, + projectRef: Option.some(FLAG_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 selects a remote project and cannot be combined with --local.", + ); + } + expect(requests).toHaveLength(0); + expect(linkedCache.cached).toBe(false); + }); }); }); @@ -411,7 +526,7 @@ describe("storage mv", () => { }); return Effect.gen(function* () { const exit = yield* storageMv( - mvFlags({ src: "ss:///private", dst: "ss:///private/docs", recursive: true, local: false }), + mvFlags({ src: "ss:///private", dst: "ss:///private/docs", recursive: true, remote: true }), ).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isSuccess(exit)).toBe(true); const perObject = requests.find( @@ -434,7 +549,7 @@ describe("storage mv", () => { yield* Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; yield* storageMv( - mvFlags({ src: "ss:///private/a", dst: "ss:///private/b", local: false }), + mvFlags({ src: "ss:///private/a", dst: "ss:///private/b", remote: true }), ); expect( managementRequests.map(({ method, urlWithParams }) => `${method} ${urlWithParams}`), @@ -450,7 +565,7 @@ describe("storage mv", () => { expect(yield* fs.exists(`${tmp.current}/${CACHE_FILE}`)).toBe(true); assertPermissionDrift({ command: "storage mv", - activeFlags: { linked: true }, + activeFlags: { remote: true }, requests: managementRequests, }); }).pipe(Effect.provide(layer)); @@ -521,9 +636,7 @@ describeHostedConsent("storage mv", { return { run: storageMv({ ...mvFlags({ src: "ss:///private/a", dst: "ss:///private/b" }), - local: false, - linked: false, - projectRef: Option.some(VALID_REF), + remote: Option.some({ _tag: "Ref", ref: VALID_REF } as const), }).pipe(Effect.provide(storage.layer)), mutations: storage.writes, }; 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 d87d91917f..a8d2eaa259 100644 --- a/apps/cli/src/commands/storage/rm/rm.integration.test.ts +++ b/apps/cli/src/commands/storage/rm/rm.integration.test.ts @@ -10,9 +10,11 @@ import { StorageRmConfirmationRequiredError } from "../storage.errors.ts"; import { describeHostedConsent } from "../../../../tests/helpers/hosted-consent.ts"; import { setupStorage, + STORAGE_TEST_BRANCH_REF, STORAGE_TEST_JWT_SECRET, storageConsentApi, storageConsentServices, + storageTestBranch, } from "../../../../tests/helpers/storage.ts"; import { VALID_REF, @@ -31,6 +33,16 @@ const writeAncestorConfig = Effect.fnUntraced(function* (root: string, toml: str yield* fs.writeFileString(path.join(dir, "config.toml"), toml); }); +const RM_ARGS = { + files: ["ss:///private/a.pdf"], + recursive: false, + remote: Option.none(), + linked: false, + local: false, + projectRef: Option.none(), +}; +const FLAG_REF = "flagflagflagflagflag"; +const DB_URL = "postgresql://postgres:postgres@127.0.0.1:54322/postgres"; const BUCKET = "/storage/v1/bucket"; const DELETE_OBJECT = (bucket: string) => `/storage/v1/object/${bucket}`; const DELETE_BUCKET = (bucket: string) => `/storage/v1/bucket/${bucket}`; @@ -77,7 +89,7 @@ describe("storage rm", () => { recursive: false, remote: Option.none(), linked: false, - local: true, + local: false, projectRef: Option.none(), }).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isSuccess(exit)).toBe(true); @@ -101,7 +113,7 @@ describe("storage rm", () => { recursive: false, remote: Option.none(), linked: false, - local: true, + local: false, projectRef: Option.none(), }).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isSuccess(exit)).toBe(true); @@ -126,7 +138,7 @@ describe("storage rm", () => { recursive: false, remote: Option.none(), linked: false, - local: true, + local: false, projectRef: Option.none(), }).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isSuccess(exit)).toBe(true); @@ -150,7 +162,7 @@ describe("storage rm", () => { recursive: false, remote: Option.none(), linked: false, - local: true, + local: false, projectRef: Option.none(), }).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isSuccess(exit)).toBe(true); @@ -170,8 +182,8 @@ describe("storage rm", () => { const exit = yield* storageRm({ files: ["ss:///private/a.pdf"], recursive: false, - remote: Option.none(), - linked: true, + remote: Option.some({ _tag: "Linked" } as const), + linked: false, local: false, projectRef: Option.none(), }).pipe(Effect.provide(layer), Effect.exit); @@ -202,7 +214,7 @@ describe("storage rm", () => { recursive: false, remote: Option.none(), linked: false, - local: true, + local: false, projectRef: Option.none(), }).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isSuccess(exit)).toBe(true); @@ -224,7 +236,7 @@ describe("storage rm", () => { recursive: false, remote: Option.none(), linked: false, - local: true, + local: false, projectRef: Option.none(), }).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isSuccess(exit)).toBe(true); @@ -247,7 +259,7 @@ describe("storage rm", () => { recursive: false, remote: Option.none(), linked: false, - local: true, + local: false, projectRef: Option.none(), }).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isSuccess(exit)).toBe(true); @@ -277,7 +289,7 @@ describe("storage rm", () => { recursive: false, remote: Option.none(), linked: false, - local: true, + local: false, projectRef: Option.none(), }).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isFailure(exit)).toBe(true); @@ -312,7 +324,7 @@ describe("storage rm", () => { recursive: true, remote: Option.none(), linked: false, - local: true, + local: false, projectRef: Option.none(), }).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isFailure(exit)).toBe(true); @@ -342,7 +354,7 @@ describe("storage rm", () => { recursive: false, remote: Option.none(), linked: false, - local: true, + local: false, projectRef: Option.none(), }).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isFailure(exit)).toBe(true); @@ -383,7 +395,7 @@ describe("storage rm", () => { recursive: false, remote: Option.none(), linked: false, - local: true, + local: false, projectRef: Option.none(), }).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isSuccess(exit)).toBe(true); @@ -407,7 +419,7 @@ describe("storage rm", () => { recursive: false, remote: Option.none(), linked: false, - local: true, + local: false, projectRef: Option.none(), }).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isFailure(exit)).toBe(true); @@ -429,7 +441,7 @@ describe("storage rm", () => { recursive: false, remote: Option.none(), linked: false, - local: true, + local: false, projectRef: Option.none(), }).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isFailure(exit)).toBe(true); @@ -453,7 +465,7 @@ describe("storage rm", () => { recursive: false, remote: Option.none(), linked: false, - local: true, + local: false, projectRef: Option.none(), }).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isFailure(exit)).toBe(true); @@ -483,7 +495,7 @@ describe("storage rm", () => { recursive: true, remote: Option.none(), linked: false, - local: true, + local: false, projectRef: Option.none(), }).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isSuccess(exit)).toBe(true); @@ -519,7 +531,7 @@ describe("storage rm", () => { recursive: true, remote: Option.none(), linked: false, - local: true, + local: false, projectRef: Option.none(), }).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isSuccess(exit)).toBe(true); @@ -578,7 +590,7 @@ describe("storage rm", () => { recursive: true, remote: Option.none(), linked: false, - local: true, + local: false, projectRef: Option.none(), }).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isSuccess(exit)).toBe(true); @@ -611,7 +623,7 @@ describe("storage rm", () => { recursive: true, remote: Option.none(), linked: false, - local: true, + local: false, projectRef: Option.none(), }).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isSuccess(exit)).toBe(true); @@ -636,7 +648,7 @@ describe("storage rm", () => { recursive: true, remote: Option.none(), linked: false, - local: true, + local: false, projectRef: Option.none(), }).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isFailure(exit)).toBe(true); @@ -660,7 +672,7 @@ describe("storage rm", () => { recursive: false, remote: Option.none(), linked: false, - local: true, + local: false, projectRef: Option.none(), }).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isSuccess(exit)).toBe(true); @@ -692,7 +704,7 @@ describe("storage rm", () => { recursive: false, remote: Option.none(), linked: false, - local: true, + local: false, projectRef: Option.none(), }).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isFailure(exit)).toBe(true); @@ -715,7 +727,7 @@ describe("storage rm", () => { recursive: true, remote: Option.none(), linked: false, - local: true, + local: false, projectRef: Option.none(), }).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isFailure(exit)).toBe(true); @@ -736,8 +748,8 @@ describe("storage rm", () => { const exit = yield* storageRm({ files: ["ss:///private/a.pdf"], recursive: false, - remote: Option.none(), - linked: true, + remote: Option.some({ _tag: "Linked" } as const), + linked: false, local: false, projectRef: Option.none(), }).pipe(Effect.provide(layer), Effect.exit); @@ -749,9 +761,8 @@ describe("storage rm", () => { }); }); - it.effect("deletes from the project given via --project-ref, overriding VALID_REF", () => { + it.effect("deletes from the project given via --remote=<ref>, overriding VALID_REF", () => { // 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: "DELETE", match: DELETE_OBJECT("private"), body: [{ name: "a.pdf" }] }], @@ -760,10 +771,10 @@ describe("storage rm", () => { const exit = yield* storageRm({ files: ["ss:///private/a.pdf"], recursive: false, - remote: Option.none(), - linked: true, + remote: Option.some({ _tag: "Ref", ref: FLAG_REF } as const), + linked: false, local: false, - projectRef: Option.some(FLAG_REF), + projectRef: Option.none(), }).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isSuccess(exit)).toBe(true); expect(requests.some((r) => r.url.startsWith(`https://${FLAG_REF}.supabase.co`))).toBe(true); @@ -773,30 +784,142 @@ describe("storage rm", () => { }); }); - it.effect("rejects --project-ref combined with --local", () => { - const FLAG_REF = "flagflagflagflagflag"; + it.effect("deletes from the project of the branch --remote=<branch> names", () => { + const { layer, requests, linkedCache, managementCalls, out } = setupStorage(tmp.current, { + branches: [storageTestBranch("feature-x")], + yes: true, + routes: [{ method: "DELETE", match: DELETE_OBJECT("private"), body: [{ name: "a.pdf" }] }], + }); + return Effect.gen(function* () { + const exit = yield* storageRm({ + ...RM_ARGS, + remote: Option.some({ _tag: "Branch", name: "feature-x" } as const), + }).pipe(Effect.exit); + expect(Exit.isSuccess(exit)).toBe(true); + expect(managementCalls.filter(({ method }) => method === "listAllBranches")).toEqual([ + { method: "listAllBranches", input: { ref: VALID_REF } }, + ]); + expect( + requests.some((r) => r.url.startsWith(`https://${STORAGE_TEST_BRANCH_REF}.supabase.co`)), + ).toBe(true); + expect(linkedCache.cachedRef).toBe(STORAGE_TEST_BRANCH_REF); + expect( + out.messages.filter((message) => message.type === "warn").map((message) => message.message), + ).toEqual([]); + }).pipe(Effect.provide(layer)); + }); + + it.effect("fails with the branches that exist when --remote=<branch> matches none", () => { + const { layer, requests } = setupStorage(tmp.current, { + branches: [storageTestBranch("feature-x")], + yes: true, + }); + return Effect.gen(function* () { + const error = yield* storageRm({ + ...RM_ARGS, + remote: Option.some({ _tag: "Branch", name: "feature-y" } as const), + }).pipe(Effect.flip); + expect(error).toMatchObject({ + _tag: "LinkBranchNotFoundError", + message: expect.stringContaining("Available branches: feature-x"), + }); + expect(requests).toHaveLength(0); + }).pipe(Effect.provide(layer)); + }); + + it.effect("rejects --remote=<url> because storage needs a project, not a database", () => { const { layer, requests, linkedCache } = setupStorage(tmp.current, { toml: 'project_id = "test"\n', - local: true, yes: true, }); return Effect.gen(function* () { - const exit = yield* storageRm({ - files: ["ss:///private/a.pdf"], - recursive: false, - remote: Option.none(), - linked: false, - local: true, - projectRef: Option.some(FLAG_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 selects a remote project and cannot be combined with --local.", - ); - } + const error = yield* storageRm({ + ...RM_ARGS, + remote: Option.some({ _tag: "DbUrl", url: DB_URL } as const), + }).pipe(Effect.flip); + expect(error).toMatchObject({ + _tag: "TargetNeedsProjectError", + message: expect.stringContaining("needs a project, not a database URL"), + }); expect(requests).toHaveLength(0); expect(linkedCache.cached).toBe(false); + }).pipe(Effect.provide(layer)); + }); + + describe("deprecated selectors", () => { + const cases = [ + { + alias: "--linked", + cliArgs: ["storage", "rm", "--linked"], + flags: { linked: true }, + host: `https://${VALID_REF}.supabase.co`, + warning: + "--linked is deprecated and will be removed in v4. Use --remote instead: supabase storage rm --remote", + }, + { + alias: "--project-ref", + cliArgs: ["storage", "rm", "--project-ref", FLAG_REF], + flags: { projectRef: Option.some(FLAG_REF) }, + host: `https://${FLAG_REF}.supabase.co`, + warning: `--project-ref is deprecated and will be removed in v4. Use --remote instead: supabase storage rm --remote=${FLAG_REF}`, + }, + { + alias: "--local", + cliArgs: ["storage", "rm", "--local"], + flags: { local: true }, + host: "http://127.0.0.1:65432", + warning: + "--local isn't needed (local is the default) and will be removed in v4: supabase storage rm", + }, + ] as const; + + for (const testCase of cases) { + it.effect(`${testCase.alias} still selects its target and warns`, () => { + const { layer, requests, out } = setupStorage(tmp.current, { + toml: 'project_id = "test"\n[api]\nport = 65432\n', + cliArgs: testCase.cliArgs, + yes: true, + routes: [ + { method: "DELETE", match: DELETE_OBJECT("private"), body: [{ name: "a.pdf" }] }, + ], + }); + return Effect.gen(function* () { + const exit = yield* storageRm({ ...RM_ARGS, ...testCase.flags }).pipe(Effect.exit); + expect(Exit.isSuccess(exit)).toBe(true); + expect(requests[0]?.url.startsWith(testCase.host)).toBe(true); + expect( + out.messages + .filter((message) => message.type === "warn") + .map((message) => message.message), + ).toEqual([testCase.warning]); + }).pipe(Effect.provide(layer)); + }); + } + + it.effect("rejects --project-ref combined with --local", () => { + const { layer, requests, linkedCache } = setupStorage(tmp.current, { + toml: 'project_id = "test"\n', + local: true, + yes: true, + }); + return Effect.gen(function* () { + const exit = yield* storageRm({ + files: ["ss:///private/a.pdf"], + recursive: false, + remote: Option.none(), + linked: false, + local: true, + projectRef: Option.some(FLAG_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 selects a remote project and cannot be combined with --local.", + ); + } + expect(requests).toHaveLength(0); + expect(linkedCache.cached).toBe(false); + }); }); }); @@ -824,7 +947,7 @@ describe("storage rm", () => { recursive: false, remote: Option.none(), linked: false, - local: true, + local: false, projectRef: Option.none(), }).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isFailure(exit)).toBe(true); @@ -850,7 +973,7 @@ describe("storage rm", () => { recursive: false, remote: Option.none(), linked: false, - local: true, + local: false, projectRef: Option.none(), }).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isSuccess(exit)).toBe(true); @@ -875,7 +998,7 @@ describe("storage rm", () => { recursive: false, remote: Option.none(), linked: false, - local: true, + local: false, projectRef: Option.none(), }).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isFailure(exit)).toBe(true); @@ -902,7 +1025,7 @@ describe("storage rm", () => { recursive: false, remote: Option.none(), linked: false, - local: true, + local: false, projectRef: Option.none(), }).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isSuccess(exit)).toBe(true); @@ -928,8 +1051,8 @@ describe("storage rm", () => { yield* storageRm({ files: ["ss:///private/a.pdf"], recursive: false, - remote: Option.none(), - linked: true, + remote: Option.some({ _tag: "Linked" } as const), + linked: false, local: false, projectRef: Option.none(), }); @@ -943,7 +1066,7 @@ describe("storage rm", () => { expect(yield* fs.exists(`${tmp.current}/${CACHE_FILE}`)).toBe(true); assertPermissionDrift({ command: "storage rm", - activeFlags: { linked: true }, + activeFlags: { remote: true }, requests: managementRequests, }); }).pipe(Effect.provide(layer)); @@ -969,7 +1092,7 @@ describe("stack backend", () => { recursive: false, remote: Option.none(), linked: false, - local: true, + local: false, projectRef: Option.none(), }).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isSuccess(exit)).toBe(true); @@ -999,7 +1122,7 @@ describe("stack backend", () => { recursive: false, remote: Option.none(), linked: false, - local: true, + local: false, projectRef: Option.none(), }).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isSuccess(exit)).toBe(true); @@ -1023,7 +1146,7 @@ describe("stack backend", () => { recursive: false, remote: Option.none(), linked: false, - local: true, + local: false, projectRef: Option.none(), }).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isFailure(exit)).toBe(true); @@ -1055,10 +1178,10 @@ describeHostedConsent("storage rm", { run: storageRm({ files: ["ss:///private/a.pdf"], recursive: false, - remote: Option.none(), + remote: Option.some({ _tag: "Ref", ref: VALID_REF } as const), linked: false, local: false, - projectRef: Option.some(VALID_REF), + projectRef: Option.none(), }).pipe(Effect.provide(storage.layer)), mutations: storage.writes, }; 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 710b8dbca3..13f17f04f4 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 @@ -58,10 +58,10 @@ describe("storage experimental gate vs mutual-exclusivity ordering", () => { ]; for (const { name, args } of leaves) { - const conflictingArgs = [...args, "--linked", "--local"]; + const conflictingArgs = [...args, "--remote", "--local"]; it.effect( - `${name} --linked --local without --experimental fails with the gate error, not the mutex error`, + `${name} --remote --local without --experimental fails with the gate error, not the mutex error`, () => { const { layer } = setup(conflictingArgs); return Effect.gen(function* () { @@ -79,7 +79,7 @@ describe("storage experimental gate vs mutual-exclusivity ordering", () => { }, ); - it.effect(`${name} --linked --local with --experimental fails with the mutex error`, () => { + it.effect(`${name} --remote --local with --experimental fails with the mutex error`, () => { const withExperimental = [...conflictingArgs, "--experimental"]; const { layer } = setup(withExperimental); return Effect.gen(function* () { From ee4db0abffb36e8bef1bf18212277e2b447d72e4 Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Mon, 19 Oct 2026 11:50:31 +0100 Subject: [PATCH 114/291] test(cli): cover seed buckets through --remote and reject a database URL --- .../seed/buckets/buckets.integration.test.ts | 318 ++++++++++++------ 1 file changed, 218 insertions(+), 100 deletions(-) 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 337f24cec0..6e1eb41256 100644 --- a/apps/cli/src/commands/seed/buckets/buckets.integration.test.ts +++ b/apps/cli/src/commands/seed/buckets/buckets.integration.test.ts @@ -53,9 +53,11 @@ import { import { describeHostedConsent } from "../../../../tests/helpers/hosted-consent.ts"; import { buildStorageStackApi, + STORAGE_TEST_BRANCH_REF, STORAGE_TEST_JWT_SECRET, storageConsentApi, storageConsentServices, + storageTestBranch, type SetupStorageStackApiOptions, } from "../../../../tests/helpers/storage.ts"; import { unusedStackServices } from "../../../../tests/helpers/unused-stack.ts"; @@ -104,7 +106,7 @@ const setupSeedBuckets = Effect.fnUntraced(function* ( readonly pipedAnswers?: ReadonlyArray<string>; readonly args?: ReadonlyArray<string>; readonly yes?: boolean; - /** Project ref returned by the project-ref resolver for --linked tests. */ + /** Project ref returned by the project-ref resolver for --remote tests. */ readonly projectRef?: string; /** API keys response for Management API mock. */ readonly apiKeys?: ReadonlyArray<{ @@ -113,6 +115,8 @@ const setupSeedBuckets = Effect.fnUntraced(function* ( type?: string | null; secret_jwt_template?: Record<string, unknown> | null; }>; + /** The linked project's branches, answering a `--remote=<branch>` lookup. */ + readonly branches?: ReadonlyArray<Record<string, unknown>>; /** When true, the project-ref resolver fails with ProjectRefNotLinkedError. */ readonly linkedFails?: boolean; /** When set, the Management API `getProjectApiKeys` call fails with this error. */ @@ -236,6 +240,7 @@ const setupSeedBuckets = Effect.fnUntraced(function* ( opts.apiKeysFail !== undefined ? Effect.fail(opts.apiKeysFail) : Effect.succeed(opts.apiKeys ?? defaultApiKeys), + listAllBranches: () => Effect.succeed(opts.branches ?? []), }, }); @@ -297,9 +302,13 @@ const setupSeedBuckets = Effect.fnUntraced(function* ( linkedCache, stackCalls: stackApi.stackCalls, managementRequests: recorded?.requests ?? [], + managementCalls: managementApi.requests, }; }); +const FLAG_REF = "flagflagflagflagflag"; +const DB_URL = "postgresql://postgres:postgres@127.0.0.1:54322/postgres"; + const VECTOR_LIST = "/storage/v1/vector/ListVectorBuckets"; const VECTOR_CREATE = "/storage/v1/vector/CreateVectorBucket"; const VECTOR_DELETE = "/storage/v1/vector/DeleteVectorBucket"; @@ -415,10 +424,6 @@ describe("seed buckets", () => { }).pipe(seedScenario), ); - // --local/--linked mutual exclusivity is enforced at the command level, so it - // doesn't reach this handler; see `assertSeedTargetsExclusive` in - // buckets.flags.unit.test.ts for that coverage. - it.effect("tolerates null string fields in 200 responses", () => Effect.gen(function* () { // A JSON `null` for a string field decodes to "" and must not abort — a @@ -831,14 +836,14 @@ describe("seed buckets", () => { }).pipe(seedScenario), ); - it.effect("does not skip a --linked run when the config file is absent", () => + it.effect("does not skip a --remote run when the config file is absent", () => Effect.gen(function* () { - // A linked run never short-circuits on empty config: even with no config + // A remote run never short-circuits on empty config: even with no config // file, the remote client is built, the service-role key fetched, and // buckets listed — failures surface instead of a silent success. const flags: BucketsFlags = { - remote: Option.none(), - linked: true, + remote: Option.some({ _tag: "Linked" } as const), + linked: false, local: false, projectRef: Option.none(), }; @@ -852,7 +857,7 @@ describe("seed buckets", () => { secret_jwt_template: { role: "service_role" }, }, ], - args: ["seed", "buckets", "--linked"], + args: ["seed", "buckets", "--remote"], routes: [{ method: "GET", match: "/storage/v1/bucket", body: [] }], }); const exit = yield* seedBuckets(flags).pipe(Effect.provide(layer), Effect.exit); @@ -1165,17 +1170,17 @@ describe("seed buckets", () => { }).pipe(seedScenario), ); - it.effect("omits the port-conflict hint on a --linked (remote) transport failure", () => + it.effect("omits the port-conflict hint on a --remote transport failure", () => Effect.gen(function* () { const { layer } = yield* setupSeedBuckets(tmp.current, { toml: "[storage.buckets.test]\npublic = true\n", projectRef: VALID_REF, - args: ["seed", "buckets", "--linked"], + args: ["seed", "buckets", "--remote"], routes: [{ method: "GET", match: "/storage/v1/bucket", transport: true }], }); const exit = yield* seedBuckets({ - remote: Option.none(), - linked: true, + remote: Option.some({ _tag: "Linked" } as const), + linked: false, local: false, projectRef: Option.none(), }).pipe(Effect.provide(layer), Effect.exit); @@ -2133,11 +2138,11 @@ describe("seed buckets", () => { }).pipe(seedScenario), ); - it.effect("--linked seeds the remote storage project", () => + it.effect("--remote seeds the remote storage project", () => Effect.gen(function* () { const flags: BucketsFlags = { - remote: Option.none(), - linked: true, + remote: Option.some({ _tag: "Linked" } as const), + linked: false, local: false, projectRef: Option.none(), }; @@ -2152,7 +2157,7 @@ describe("seed buckets", () => { secret_jwt_template: { role: "service_role" }, }, ], - args: ["seed", "buckets", "--linked"], + args: ["seed", "buckets", "--remote"], routes: [ { method: "GET", match: "/storage/v1/bucket", body: [] }, { method: "POST", match: "/storage/v1/bucket", body: { name: "test" } }, @@ -2171,7 +2176,7 @@ describe("seed buckets", () => { const { layer, requests, managementRequests } = yield* setupSeedBuckets(tmp.current, { toml: "[storage.buckets.test]\npublic = true\n", projectRef: VALID_REF, - args: ["seed", "buckets", "--linked"], + args: ["seed", "buckets", "--remote"], recordManagementApi: true, routes: [ { method: "GET", match: "/storage/v1/bucket", body: [] }, @@ -2180,8 +2185,8 @@ describe("seed buckets", () => { }); const fs = yield* FileSystem.FileSystem; yield* seedBuckets({ - remote: Option.none(), - linked: true, + remote: Option.some({ _tag: "Linked" } as const), + linked: false, local: false, projectRef: Option.none(), }).pipe(Effect.provide(layer)); @@ -2195,7 +2200,7 @@ describe("seed buckets", () => { expect(yield* fs.exists(`${tmp.current}/supabase/.temp/linked-project.json`)).toBe(true); assertPermissionDrift({ command: "seed buckets", - activeFlags: { linked: true }, + activeFlags: { remote: true }, requests: managementRequests, exact: true, }); @@ -2211,7 +2216,7 @@ describe("seed buckets", () => { const { layer, managementRequests } = yield* setupSeedBuckets(tmp.current, { toml: "[storage.buckets.test]\npublic = true\n", projectRef: VALID_REF, - args: ["seed", "buckets", "--linked"], + args: ["seed", "buckets", "--remote"], recordManagementApi: true, routes: [ { method: "GET", match: "/storage/v1/bucket", body: [] }, @@ -2219,8 +2224,8 @@ describe("seed buckets", () => { ], }); yield* seedBuckets({ - remote: Option.none(), - linked: true, + remote: Option.some({ _tag: "Linked" } as const), + linked: false, local: false, projectRef: Option.none(), }).pipe(Effect.provide(layer)); @@ -2229,7 +2234,7 @@ describe("seed buckets", () => { ]); assertPermissionDrift({ command: "seed buckets", - activeFlags: { linked: true }, + activeFlags: { remote: true }, requests: managementRequests, }); }), @@ -2260,44 +2265,157 @@ describe("seed buckets", () => { }).pipe(seedScenario), ); - it.effect( - "--project-ref --linked seeds the project given by the flag, overriding VALID_REF", - () => - Effect.gen(function* () { - // `opts.projectRef` (the fake's own fallback) is left at its default - // (VALID_REF) — the flag must win over it and drive the storage - // gateway host. - const FLAG_REF = "flagflagflagflagflag"; - const { layer, out, requests, linkedCache } = yield* setupSeedBuckets(tmp.current, { + it.effect("--remote=<ref> seeds the project given by the flag, overriding VALID_REF", () => + Effect.gen(function* () { + const { layer, out, requests, linkedCache } = yield* setupSeedBuckets(tmp.current, { + toml: "[storage.buckets.test]\npublic = true\n", + args: ["seed", "buckets", `--remote=${FLAG_REF}`], + routes: [ + { method: "GET", match: "/storage/v1/bucket", body: [] }, + { method: "POST", match: "/storage/v1/bucket", body: { name: "test" } }, + ], + }); + const exit = yield* seedBuckets({ + ...DEFAULT_FLAGS, + remote: Option.some({ _tag: "Ref", ref: FLAG_REF } as const), + local: false, + }).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isSuccess(exit)).toBe(true); + expect(out.stderrText).toContain("Creating Storage bucket: test"); + expect(requests.some((r) => r.url.startsWith(`https://${FLAG_REF}.supabase.co`))).toBe(true); + expect(requests.some((r) => r.url.includes(VALID_REF))).toBe(false); + expect(linkedCache.cached).toBe(true); + expect(linkedCache.cachedRef).toBe(FLAG_REF); + }).pipe(seedScenario), + ); + + it.effect("--remote=<branch> seeds the project of the branch it names", () => + Effect.gen(function* () { + const { layer, requests, linkedCache, managementCalls, out } = yield* setupSeedBuckets( + tmp.current, + { toml: "[storage.buckets.test]\npublic = true\n", - args: ["seed", "buckets", "--linked"], + args: ["seed", "buckets", "--remote=feature-x"], + branches: [storageTestBranch("feature-x")], routes: [ { method: "GET", match: "/storage/v1/bucket", body: [] }, { method: "POST", match: "/storage/v1/bucket", body: { name: "test" } }, ], - }); - const exit = yield* seedBuckets({ - remote: Option.none(), - linked: true, - local: false, - projectRef: Option.some(FLAG_REF), - }).pipe(Effect.provide(layer), Effect.exit); - expect(Exit.isSuccess(exit)).toBe(true); - expect(out.stderrText).toContain("Creating Storage bucket: test"); - expect(requests.some((r) => r.url.startsWith(`https://${FLAG_REF}.supabase.co`))).toBe( - true, - ); - expect(requests.some((r) => r.url.includes(VALID_REF))).toBe(false); - expect(linkedCache.cached).toBe(true); - expect(linkedCache.cachedRef).toBe(FLAG_REF); - }).pipe(seedScenario), + }, + ); + const exit = yield* seedBuckets({ + ...DEFAULT_FLAGS, + remote: Option.some({ _tag: "Branch", name: "feature-x" } as const), + local: false, + }).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isSuccess(exit)).toBe(true); + expect(managementCalls.filter(({ method }) => method === "listAllBranches")).toEqual([ + { method: "listAllBranches", input: { ref: VALID_REF } }, + ]); + expect( + requests.some((r) => r.url.startsWith(`https://${STORAGE_TEST_BRANCH_REF}.supabase.co`)), + ).toBe(true); + expect(linkedCache.cachedRef).toBe(STORAGE_TEST_BRANCH_REF); + expect(out.messages.filter((message) => message.type === "warn")).toEqual([]); + }).pipe(seedScenario), ); - it.effect("rejects --project-ref on the default local target", () => + it.effect("fails with the branches that exist when --remote=<branch> matches none", () => + Effect.gen(function* () { + const { layer, requests, linkedCache } = yield* setupSeedBuckets(tmp.current, { + toml: "[storage.buckets.test]\npublic = true\n", + branches: [storageTestBranch("feature-x")], + }); + const error = yield* seedBuckets({ + ...DEFAULT_FLAGS, + remote: Option.some({ _tag: "Branch", name: "feature-y" } as const), + local: false, + }).pipe(Effect.provide(layer), Effect.flip); + expect(error).toMatchObject({ + _tag: "LinkBranchNotFoundError", + message: expect.stringContaining("Available branches: feature-x"), + }); + expect(requests).toEqual([]); + expect(linkedCache.cached).toBe(false); + }).pipe(seedScenario), + ); + + it.effect("rejects --remote=<url> because seeding buckets needs a project, not a database", () => + Effect.gen(function* () { + const { layer, requests, linkedCache } = yield* setupSeedBuckets(tmp.current, { + toml: "[storage.buckets.test]\npublic = true\n", + }); + const error = yield* seedBuckets({ + ...DEFAULT_FLAGS, + remote: Option.some({ _tag: "DbUrl", url: DB_URL } as const), + local: false, + }).pipe(Effect.provide(layer), Effect.flip); + expect(error).toMatchObject({ + _tag: "TargetNeedsProjectError", + message: expect.stringContaining("needs a project, not a database URL"), + }); + expect(requests).toEqual([]); + expect(linkedCache.cached).toBe(false); + }).pipe(seedScenario), + ); + + describe("deprecated selectors", () => { + const cases = [ + { + alias: "--linked", + cliArgs: ["seed", "buckets", "--linked"], + flags: { linked: true, local: false }, + host: `https://${VALID_REF}.supabase.co`, + warning: + "--linked is deprecated and will be removed in v4. Use --remote instead: supabase seed buckets --remote", + }, + { + alias: "--project-ref", + cliArgs: ["seed", "buckets", "--project-ref", FLAG_REF], + flags: { projectRef: Option.some(FLAG_REF), local: false }, + host: `https://${FLAG_REF}.supabase.co`, + warning: `--project-ref is deprecated and will be removed in v4. Use --remote instead: supabase seed buckets --remote=${FLAG_REF}`, + }, + { + alias: "--local", + cliArgs: ["seed", "buckets", "--local"], + flags: { local: true }, + host: "http://", + warning: + "--local isn't needed (local is the default) and will be removed in v4: supabase seed buckets", + }, + ] as const; + + for (const testCase of cases) { + it.effect(`${testCase.alias} still selects its target and warns`, () => + Effect.gen(function* () { + const { layer, out, requests } = yield* setupSeedBuckets(tmp.current, { + toml: "[storage.buckets.test]\npublic = true\n", + args: testCase.cliArgs, + routes: [ + { method: "GET", match: "/storage/v1/bucket", body: [] }, + { method: "POST", match: "/storage/v1/bucket", body: { name: "test" } }, + ], + }); + const exit = yield* seedBuckets({ ...DEFAULT_FLAGS, ...testCase.flags }).pipe( + Effect.provide(layer), + Effect.exit, + ); + expect(Exit.isSuccess(exit)).toBe(true); + expect(requests.length).toBeGreaterThan(0); + expect(requests.every((r) => r.url.startsWith(testCase.host))).toBe(true); + expect( + out.messages + .filter((message) => message.type === "warn") + .map((message) => message.message), + ).toEqual([testCase.warning]); + }).pipe(seedScenario), + ); + } + }); + + it.effect("rejects --project-ref combined with --local", () => Effect.gen(function* () { - // seed buckets defaults to local when no target flag is set — the guard - // must fire from the flag alone, with no explicit --local needed. - const FLAG_REF = "flagflagflagflagflag"; const { layer, requests, linkedCache } = yield* setupSeedBuckets(tmp.current, { toml: "[storage.buckets.test]\npublic = true\n", }); @@ -2343,18 +2461,18 @@ describe("seed buckets", () => { }).pipe(seedScenario), ); - it.effect("--linked fails before any Storage call when the api-keys list is empty", () => + it.effect("--remote fails before any Storage call when the api-keys list is empty", () => Effect.gen(function* () { const { layer, requests } = yield* setupSeedBuckets(tmp.current, { toml: "[storage.buckets.test]\npublic = true\n", projectRef: VALID_REF, apiKeys: [], - args: ["seed", "buckets", "--linked"], + args: ["seed", "buckets", "--remote"], routes: [{ method: "GET", match: "/storage/v1/bucket", body: [] }], }); const exit = yield* seedBuckets({ - remote: Option.none(), - linked: true, + remote: Option.some({ _tag: "Linked" } as const), + linked: false, local: false, projectRef: Option.none(), }).pipe(Effect.provide(layer), Effect.exit); @@ -2366,7 +2484,7 @@ describe("seed buckets", () => { }).pipe(seedScenario), ); - it.effect("--linked surfaces tenant.GetApiKeys auth error on a non-200 api-keys response", () => + it.effect("--remote surfaces tenant.GetApiKeys auth error on a non-200 api-keys response", () => Effect.gen(function* () { // A non-200 api-keys response maps to `StorageAuthTokenError` with // "Authorization failed for the access token and project ref pair", not @@ -2375,12 +2493,12 @@ describe("seed buckets", () => { toml: "[storage.buckets.test]\npublic = true\n", projectRef: VALID_REF, apiKeysFail: statusCodeFailure(401), - args: ["seed", "buckets", "--linked"], + args: ["seed", "buckets", "--remote"], routes: [{ method: "GET", match: "/storage/v1/bucket", body: [] }], }); const exit = yield* seedBuckets({ - remote: Option.none(), - linked: true, + remote: Option.some({ _tag: "Linked" } as const), + linked: false, local: false, projectRef: Option.none(), }).pipe(Effect.provide(layer), Effect.exit); @@ -2397,14 +2515,14 @@ describe("seed buckets", () => { }).pipe(seedScenario), ); - it.effect("caches the linked project on --linked but not on local", () => + it.effect("caches the linked project on --remote but not on local", () => Effect.gen(function* () { - // Gated on a non-empty resolved ref: --linked writes the linked-project + // Gated on a non-empty resolved ref: --remote writes the linked-project // cache and group identify; the local path does not. const linked = yield* setupSeedBuckets(tmp.current, { toml: "[storage.buckets.test]\npublic = true\n", projectRef: VALID_REF, - args: ["seed", "buckets", "--linked"], + args: ["seed", "buckets", "--remote"], routes: [ { method: "GET", match: "/storage/v1/bucket", body: [] }, { method: "POST", match: "/storage/v1/bucket", body: { name: "test" } }, @@ -2418,8 +2536,8 @@ describe("seed buckets", () => { ], }); yield* seedBuckets({ - remote: Option.none(), - linked: true, + remote: Option.some({ _tag: "Linked" } as const), + linked: false, local: false, projectRef: Option.none(), }).pipe(Effect.provide(linked.layer), Effect.exit); @@ -2431,22 +2549,22 @@ describe("seed buckets", () => { }).pipe(seedScenario), ); - it.effect("--linked uses SUPABASE_AUTH_SERVICE_ROLE_KEY env var when set", () => + it.effect("--remote uses SUPABASE_AUTH_SERVICE_ROLE_KEY env var when set", () => seedScenario( withEnvVar( "SUPABASE_AUTH_SERVICE_ROLE_KEY", "env-service-role-key", Effect.gen(function* () { const flags: BucketsFlags = { - remote: Option.none(), - linked: true, + remote: Option.some({ _tag: "Linked" } as const), + linked: false, local: false, projectRef: Option.none(), }; const { layer, requests } = yield* setupSeedBuckets(tmp.current, { toml: "[storage.buckets.test]\npublic = true\n", projectRef: VALID_REF, - args: ["seed", "buckets", "--linked"], + args: ["seed", "buckets", "--remote"], routes: [ { method: "GET", match: "/storage/v1/bucket", body: [] }, { method: "POST", match: "/storage/v1/bucket", body: { name: "test" } }, @@ -2460,22 +2578,22 @@ describe("seed buckets", () => { ), ); - it.effect("--linked falls back to the project's service-role key when the env var is empty", () => + it.effect("--remote falls back to the project's service-role key when the env var is empty", () => seedScenario( withEnvVar( "SUPABASE_AUTH_SERVICE_ROLE_KEY", "", Effect.gen(function* () { const flags: BucketsFlags = { - remote: Option.none(), - linked: true, + remote: Option.some({ _tag: "Linked" } as const), + linked: false, local: false, projectRef: Option.none(), }; const { layer, requests } = yield* setupSeedBuckets(tmp.current, { toml: "[storage.buckets.test]\npublic = true\n", projectRef: VALID_REF, - args: ["seed", "buckets", "--linked"], + args: ["seed", "buckets", "--remote"], routes: [ { method: "GET", match: "/storage/v1/bucket", body: [] }, { method: "POST", match: "/storage/v1/bucket", body: { name: "test" } }, @@ -2490,11 +2608,11 @@ describe("seed buckets", () => { ), ); - it.effect("upserts analytics buckets when analytics.enabled and --linked", () => + it.effect("upserts analytics buckets when analytics.enabled and --remote", () => Effect.gen(function* () { const flags: BucketsFlags = { - remote: Option.none(), - linked: true, + remote: Option.some({ _tag: "Linked" } as const), + linked: false, local: false, projectRef: Option.none(), }; @@ -2507,7 +2625,7 @@ describe("seed buckets", () => { "public = true", ].join("\n"), projectRef: VALID_REF, - args: ["seed", "buckets", "--linked"], + args: ["seed", "buckets", "--remote"], routes: [ { method: "GET", match: "/storage/v1/bucket", body: [] }, { method: "POST", match: "/storage/v1/bucket", body: { name: "test" } }, @@ -2549,8 +2667,8 @@ describe("seed buckets", () => { it.effect("prunes a stale analytics bucket after the single hosted confirmation", () => Effect.gen(function* () { const flags: BucketsFlags = { - remote: Option.none(), - linked: true, + remote: Option.some({ _tag: "Linked" } as const), + linked: false, local: false, projectRef: Option.none(), }; @@ -2563,7 +2681,7 @@ describe("seed buckets", () => { "public = true", ].join("\n"), projectRef: VALID_REF, - args: ["seed", "buckets", "--linked"], + args: ["seed", "buckets", "--remote"], routes: [ { method: "GET", match: "/storage/v1/bucket", body: [] }, { method: "POST", match: "/storage/v1/bucket", body: { name: "test" } }, @@ -2601,15 +2719,15 @@ describe("seed buckets", () => { const { layer, out, requests } = yield* setupSeedBuckets(tmp.current, { toml: "[storage.buckets.test]\npublic = true\n", projectRef: VALID_REF, - args: ["seed", "buckets", "--linked"], + args: ["seed", "buckets", "--remote"], routes: [ { method: "GET", match: "/storage/v1/bucket", body: [{ name: "test", id: "test" }] }, { method: "PUT", match: "/storage/v1/bucket/test", body: {} }, ], }); const exit = yield* seedBuckets({ - remote: Option.none(), - linked: true, + remote: Option.some({ _tag: "Linked" } as const), + linked: false, local: false, projectRef: Option.none(), }).pipe(Effect.provide(layer), Effect.exit); @@ -2630,13 +2748,13 @@ describe("seed buckets", () => { const { layer, requests } = yield* setupSeedBuckets(tmp.current, { toml: "[storage.buckets.test]\npublic = true\n", projectRef: VALID_REF, - args: ["seed", "buckets", "--linked"], + args: ["seed", "buckets", "--remote"], confirm: [false], routes: [{ method: "GET", match: "/storage/v1/bucket", body: [] }], }); const exit = yield* seedBuckets({ - remote: Option.none(), - linked: true, + remote: Option.some({ _tag: "Linked" } as const), + linked: false, local: false, projectRef: Option.none(), }).pipe(Effect.provide(layer), Effect.exit); @@ -2661,18 +2779,18 @@ describe("seed buckets", () => { }).pipe(seedScenario), ); - it.effect("--linked fails when the project is not linked", () => + it.effect("--remote fails when the project is not linked", () => Effect.gen(function* () { const flags: BucketsFlags = { - remote: Option.none(), - linked: true, + remote: Option.some({ _tag: "Linked" } as const), + linked: false, local: false, projectRef: Option.none(), }; const { layer } = yield* setupSeedBuckets(tmp.current, { toml: "[storage.buckets.test]\npublic = true\n", linkedFails: true, - args: ["seed", "buckets", "--linked"], + args: ["seed", "buckets", "--remote"], routes: [], }); const exit = yield* seedBuckets(flags).pipe(Effect.provide(layer), Effect.exit); @@ -2758,15 +2876,15 @@ describe("seed buckets", () => { }).pipe(seedScenario), ); - it.effect("--linked merges [remotes.*] storage config override before seeding", () => + it.effect("--remote merges [remotes.*] storage config override before seeding", () => Effect.gen(function* () { // The remote block overrides `base.public` to false and adds a `remote` // bucket; `mergeRemoteConfig` merges subtrees recursively rather than // replacing [storage.buckets] wholesale, so both buckets are seeded. const remoteRef = VALID_REF; const flags: BucketsFlags = { - remote: Option.none(), - linked: true, + remote: Option.some({ _tag: "Linked" } as const), + linked: false, local: false, projectRef: Option.none(), }; @@ -2783,7 +2901,7 @@ describe("seed buckets", () => { "public = false", ].join("\n"), projectRef: remoteRef, - args: ["seed", "buckets", "--linked"], + args: ["seed", "buckets", "--remote"], routes: [ { method: "GET", match: "/storage/v1/bucket", body: [] }, { method: "POST", match: "/storage/v1/bucket", body: {} }, @@ -3250,10 +3368,10 @@ describeHostedConsent("seed buckets", { ]); return { run: seedBuckets({ - remote: Option.none(), + remote: Option.some({ _tag: "Ref", ref: VALID_REF } as const), linked: false, local: false, - projectRef: Option.some(VALID_REF), + projectRef: Option.none(), }).pipe(Effect.provide(storage.layer)), mutations: storage.writes, }; From 871773a427a1ab9824323cd83e6bbda15d0a5499 Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Mon, 19 Oct 2026 11:51:22 +0100 Subject: [PATCH 115/291] test(cli-e2e): pin the local connection failure in the --local alias test --- apps/cli-e2e/src/tests/target-selection.e2e.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/cli-e2e/src/tests/target-selection.e2e.test.ts b/apps/cli-e2e/src/tests/target-selection.e2e.test.ts index 1f7613c6f9..d1127fefea 100644 --- a/apps/cli-e2e/src/tests/target-selection.e2e.test.ts +++ b/apps/cli-e2e/src/tests/target-selection.e2e.test.ts @@ -36,7 +36,7 @@ describe("deprecated target aliases", () => { "--local isn't needed (local is the default) and will be removed in v4: supabase db push", ); expect(result.stderr).toContain("Target: local stack"); - expect(result.stderr).toContain("connect"); + expect(result.stderr).toContain("failed to connect to postgres"); }); testBehaviour( From 28cdc5d309be0c2c85ec3b4e41438c5197dd7775 Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Mon, 19 Oct 2026 11:54:51 +0100 Subject: [PATCH 116/291] refactor(cli): select the project of hosted G-Z commands with --remote network-bans, network-restrictions, notebooks, postgres-config, projects api-keys, secrets, snippets, ssl-enforcement, sso and vanity-subdomains replace their own --project-ref flag with the shared project target flags and resolve through resolveProjectTarget, so --remote=<ref|branch> works and --project-ref stays as a deprecated alias. gen keys points at projects api-keys --remote=<ref>. A shared projectRefSafeFlags helper keeps branch names passed through --project-ref out of telemetry. --- .../target/project-ref-safe-flags.ts | 7 +++++++ .../cli/src/commands/gen/keys/keys.command.ts | 12 +++++------ .../commands/network-bans/get/get.command.ts | 10 +++++----- .../commands/network-bans/get/get.handler.ts | 5 ++--- .../network-bans/remove/remove.command.ts | 10 +++++----- .../network-bans/remove/remove.handler.ts | 5 ++--- .../network-restrictions/get/get.command.ts | 10 +++++----- .../network-restrictions/get/get.handler.ts | 5 ++--- .../update/update.command.ts | 8 ++++---- .../update/update.handler.ts | 5 ++--- .../commands/notebooks/notebooks.shared.ts | 7 ------- .../commands/notebooks/pull/pull.command.ts | 20 ++++++++----------- .../commands/notebooks/pull/pull.handler.ts | 5 ++--- .../commands/notebooks/push/push.command.ts | 20 ++++++++----------- .../commands/notebooks/push/push.handler.ts | 5 ++--- .../postgres-config/delete/delete.command.ts | 8 ++++---- .../postgres-config/delete/delete.handler.ts | 5 ++--- .../postgres-config/get/get.command.ts | 17 ++++++++++------ .../postgres-config/get/get.handler.ts | 5 ++--- .../postgres-config/update/update.command.ts | 8 ++++---- .../postgres-config/update/update.handler.ts | 5 ++--- .../projects/api-keys/api-keys.command.ts | 11 +++++----- .../projects/api-keys/api-keys.handler.ts | 5 ++--- .../src/commands/secrets/list/list.command.ts | 10 +++++----- .../src/commands/secrets/list/list.handler.ts | 5 ++--- .../src/commands/secrets/set/set.command.ts | 8 ++++---- .../src/commands/secrets/set/set.handler.ts | 5 ++--- .../commands/secrets/unset/unset.command.ts | 8 ++++---- .../commands/secrets/unset/unset.handler.ts | 5 ++--- .../snippets/download/download.command.ts | 10 +++++----- .../snippets/download/download.handler.ts | 5 ++--- .../commands/snippets/list/list.command.ts | 10 +++++----- .../commands/snippets/list/list.handler.ts | 5 ++--- .../ssl-enforcement/get/get.command.ts | 10 +++++----- .../ssl-enforcement/get/get.handler.ts | 5 ++--- .../ssl-enforcement/update/update.command.ts | 8 ++++---- .../ssl-enforcement/update/update.handler.ts | 5 ++--- apps/cli/src/commands/sso/add/add.command.ts | 11 +++++----- apps/cli/src/commands/sso/add/add.handler.ts | 5 ++--- .../cli/src/commands/sso/info/info.command.ts | 16 ++++++++------- .../cli/src/commands/sso/info/info.handler.ts | 5 ++--- .../cli/src/commands/sso/list/list.command.ts | 13 ++++++------ .../cli/src/commands/sso/list/list.handler.ts | 5 ++--- .../src/commands/sso/remove/remove.command.ts | 13 ++++++------ .../src/commands/sso/remove/remove.handler.ts | 5 ++--- .../cli/src/commands/sso/show/show.command.ts | 11 +++++----- .../cli/src/commands/sso/show/show.handler.ts | 5 ++--- .../src/commands/sso/update/update.command.ts | 11 +++++----- .../src/commands/sso/update/update.handler.ts | 5 ++--- .../activate/activate.command.ts | 8 ++++---- .../activate/activate.handler.ts | 5 ++--- .../check-availability.command.ts | 8 ++++---- .../check-availability.handler.ts | 8 +++++--- .../delete/delete.command.ts | 10 +++++----- .../delete/delete.handler.ts | 5 ++--- .../vanity-subdomains/get/get.command.ts | 10 +++++----- .../vanity-subdomains/get/get.handler.ts | 5 ++--- 57 files changed, 220 insertions(+), 241 deletions(-) create mode 100644 apps/cli/src/command-internal/target/project-ref-safe-flags.ts diff --git a/apps/cli/src/command-internal/target/project-ref-safe-flags.ts b/apps/cli/src/command-internal/target/project-ref-safe-flags.ts new file mode 100644 index 0000000000..c49fb759d2 --- /dev/null +++ b/apps/cli/src/command-internal/target/project-ref-safe-flags.ts @@ -0,0 +1,7 @@ +import { Option } from "effect"; + +import { PROJECT_REF_PATTERN } from "../../config/project-ref.service.ts"; + +/** `--project-ref` can also hold a branch name, so only a ref-shaped value is safe to log. */ +export const projectRefSafeFlags = (projectRef: Option.Option<string>): ReadonlyArray<string> => + Option.isSome(projectRef) && PROJECT_REF_PATTERN.test(projectRef.value) ? ["project-ref"] : []; diff --git a/apps/cli/src/commands/gen/keys/keys.command.ts b/apps/cli/src/commands/gen/keys/keys.command.ts index 6d0fdcde7a..ce6593d43b 100644 --- a/apps/cli/src/commands/gen/keys/keys.command.ts +++ b/apps/cli/src/commands/gen/keys/keys.command.ts @@ -1,6 +1,7 @@ import { Command, Flag } from "effect/cli"; import { withPermissions } from "../../../command-internal/command-permissions/command-permissions.annotation.ts"; import { removedCommand } from "../../../command-internal/removed-command.ts"; +import { projectTargetFlags } from "../../../command-internal/target/target-flags.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,10 +9,7 @@ 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( - Flag.withDescription("Project ref of the Supabase project."), - Flag.optional, - ), + ...projectTargetFlags(), overrideName: Flag.String("override-name").pipe( Flag.withDescription("Override specific variable names."), Flag.atLeast(0), @@ -19,16 +17,16 @@ const config = { } as const; export const genKeysCommand = Command.make("keys", config).pipe( - Command.withDescription("Removed: use `supabase projects api-keys --project-ref <ref>` instead."), + Command.withDescription("Removed: use `supabase projects api-keys --remote=<ref>` instead."), Command.withShortDescription("Removed: use `projects api-keys` instead"), // Removed: fails before any Management API call, so no flag changes what it calls. withPermissions({ operations: [], - noApiEffectFlags: ["override-name", "project-ref"], + noApiEffectFlags: ["override-name", "project-ref", "remote"], }), Command.withHandler(() => removedCommand( - "Use `supabase projects api-keys --project-ref <ref>` to read a project's API keys.", + "Use `supabase projects api-keys --remote=<ref>` to read a project's API keys.", ).pipe(withCommandTelemetry(), withJsonErrorHandling), ), Command.provide(commandRuntimeLayer(["gen", "keys"])), 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 98f6d31c3c..278b0ea5a8 100644 --- a/apps/cli/src/commands/network-bans/get/get.command.ts +++ b/apps/cli/src/commands/network-bans/get/get.command.ts @@ -1,9 +1,11 @@ import { Effect } from "effect"; -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"; import { compose } from "../../../command-internal/command-permissions/model.ts"; +import { branchResolution } from "../../../command-internal/target/branch-resolution.ts"; +import { projectTargetFlags } from "../../../command-internal/target/target-flags.ts"; import { projectRefPicker } from "../../../config/project-ref.layer.ts"; import { linkedProjectCache } from "../../../telemetry/linked-project-cache.layer.ts"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; @@ -18,10 +20,7 @@ import { networkBansGet } from "./get.handler.ts"; import { withTargetPolicy } from "../../../command-internal/target/target-policy.ts"; const config = { - projectRef: Flag.String("project-ref").pipe( - Flag.withDescription("Project ref of the Supabase project."), - Flag.optional, - ), + ...projectTargetFlags(), }; export type NetworkBansGetFlags = CliCommand.Command.Config.Infer<typeof config>; @@ -36,6 +35,7 @@ export const networkBansGetCommand = Command.make("get", config).pipe( }, projectRefPicker, linkedProjectCache, + branchResolution, ), ), Command.withHandler((flags) => diff --git a/apps/cli/src/commands/network-bans/get/get.handler.ts b/apps/cli/src/commands/network-bans/get/get.handler.ts index 572c6f6180..84cf311516 100644 --- a/apps/cli/src/commands/network-bans/get/get.handler.ts +++ b/apps/cli/src/commands/network-bans/get/get.handler.ts @@ -1,7 +1,7 @@ import { Effect, Option } from "effect"; import { CommandPlatformApi } from "../../../auth/command-platform-api.service.ts"; -import { ProjectRefResolver } from "../../../config/project-ref.service.ts"; +import { resolveProjectTarget } from "../../../command-internal/target/resolve-project-target.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"; @@ -27,12 +27,11 @@ export const networkBansGet = Effect.fn("network-bans.get")(function* (flags: Ne const output = yield* Output; const outputFlag = yield* OutputFlag; const api = yield* CommandPlatformApi; - const resolver = yield* ProjectRefResolver; const linkedProjectCache = yield* LinkedProjectCache; const telemetryState = yield* TelemetryState; yield* Effect.gen(function* () { - const ref = yield* resolver.resolve(flags.projectRef); + const ref = yield* resolveProjectTarget({ remote: flags.remote, projectRef: flags.projectRef }); yield* Effect.gen(function* () { const fetching = 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 66a8bc4d5e..cd6019a9ac 100644 --- a/apps/cli/src/commands/network-bans/remove/remove.command.ts +++ b/apps/cli/src/commands/network-bans/remove/remove.command.ts @@ -1,9 +1,11 @@ import { Effect } from "effect"; -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"; import { compose } from "../../../command-internal/command-permissions/model.ts"; +import { branchResolution } from "../../../command-internal/target/branch-resolution.ts"; +import { projectTargetFlags } from "../../../command-internal/target/target-flags.ts"; import { projectRefPicker } from "../../../config/project-ref.layer.ts"; import { linkedProjectCache } from "../../../telemetry/linked-project-cache.layer.ts"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; @@ -29,10 +31,7 @@ export const networkBansRemoveDbUnbanIpFlag = stringSliceFlag( ); const config = { - projectRef: Flag.String("project-ref").pipe( - Flag.withDescription("Project ref of the Supabase project."), - Flag.optional, - ), + ...projectTargetFlags(), dbUnbanIp: networkBansRemoveDbUnbanIpFlag, } as const; @@ -49,6 +48,7 @@ export const networkBansRemoveCommand = Command.make("remove", config).pipe( }, projectRefPicker, linkedProjectCache, + branchResolution, ), ), Command.withHandler((flags) => 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 0525c10919..3ce97a0096 100644 --- a/apps/cli/src/commands/network-bans/remove/remove.handler.ts +++ b/apps/cli/src/commands/network-bans/remove/remove.handler.ts @@ -2,7 +2,7 @@ import { isIP } from "node:net"; import { Effect, Option } from "effect"; import { CommandPlatformApi } from "../../../auth/command-platform-api.service.ts"; -import { ProjectRefResolver } from "../../../config/project-ref.service.ts"; +import { resolveProjectTarget } from "../../../command-internal/target/resolve-project-target.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"; @@ -29,13 +29,12 @@ export const networkBansRemove = Effect.fn("network-bans.remove")(function* ( const output = yield* Output; const outputFlag = yield* OutputFlag; const api = yield* CommandPlatformApi; - const resolver = yield* ProjectRefResolver; const linkedProjectCache = yield* LinkedProjectCache; const telemetryState = yield* TelemetryState; yield* Effect.gen(function* () { // Resolves before validating --db-unban-ip, so a bad ref surfaces before a bad IP. - const ref = yield* resolver.resolve(flags.projectRef); + const ref = yield* resolveProjectTarget({ remote: flags.remote, projectRef: flags.projectRef }); yield* Effect.gen(function* () { for (const ip of flags.dbUnbanIp) { 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 294fe79759..1d975c1575 100644 --- a/apps/cli/src/commands/network-restrictions/get/get.command.ts +++ b/apps/cli/src/commands/network-restrictions/get/get.command.ts @@ -1,9 +1,11 @@ import { Effect } from "effect"; -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"; import { compose } from "../../../command-internal/command-permissions/model.ts"; +import { branchResolution } from "../../../command-internal/target/branch-resolution.ts"; +import { projectTargetFlags } from "../../../command-internal/target/target-flags.ts"; import { projectRefPicker } from "../../../config/project-ref.layer.ts"; import { linkedProjectCache } from "../../../telemetry/linked-project-cache.layer.ts"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; @@ -18,10 +20,7 @@ import { networkRestrictionsGet } from "./get.handler.ts"; import { withTargetPolicy } from "../../../command-internal/target/target-policy.ts"; const config = { - projectRef: Flag.String("project-ref").pipe( - Flag.withDescription("Project ref of the Supabase project."), - Flag.optional, - ), + ...projectTargetFlags(), } as const; export type NetworkRestrictionsGetFlags = CliCommand.Command.Config.Infer<typeof config>; @@ -36,6 +35,7 @@ export const networkRestrictionsGetCommand = Command.make("get", config).pipe( }, projectRefPicker, linkedProjectCache, + branchResolution, ), ), Command.withHandler((flags) => diff --git a/apps/cli/src/commands/network-restrictions/get/get.handler.ts b/apps/cli/src/commands/network-restrictions/get/get.handler.ts index 83c4771bd3..6ec7738fe8 100644 --- a/apps/cli/src/commands/network-restrictions/get/get.handler.ts +++ b/apps/cli/src/commands/network-restrictions/get/get.handler.ts @@ -1,7 +1,7 @@ import { Effect, Option } from "effect"; import { CommandPlatformApi } from "../../../auth/command-platform-api.service.ts"; -import { ProjectRefResolver } from "../../../config/project-ref.service.ts"; +import { resolveProjectTarget } from "../../../command-internal/target/resolve-project-target.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"; @@ -35,12 +35,11 @@ export const networkRestrictionsGet = Effect.fn("network-restrictions.get")(func const output = yield* Output; const outputFlag = yield* OutputFlag; const api = yield* CommandPlatformApi; - const resolver = yield* ProjectRefResolver; const linkedProjectCache = yield* LinkedProjectCache; const telemetryState = yield* TelemetryState; yield* Effect.gen(function* () { - const ref = yield* resolver.resolve(flags.projectRef); + const ref = yield* resolveProjectTarget({ remote: flags.remote, projectRef: flags.projectRef }); yield* Effect.gen(function* () { const fetching = 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 5c44f539bc..83b3798881 100644 --- a/apps/cli/src/commands/network-restrictions/update/update.command.ts +++ b/apps/cli/src/commands/network-restrictions/update/update.command.ts @@ -4,6 +4,8 @@ import type * as CliCommand from "effect/cli/Command"; import { withPermissions } from "../../../command-internal/command-permissions/command-permissions.annotation.ts"; import { compose } from "../../../command-internal/command-permissions/model.ts"; +import { branchResolution } from "../../../command-internal/target/branch-resolution.ts"; +import { projectTargetFlags } from "../../../command-internal/target/target-flags.ts"; import { projectRefPicker } from "../../../config/project-ref.layer.ts"; import { linkedProjectCache } from "../../../telemetry/linked-project-cache.layer.ts"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; @@ -29,10 +31,7 @@ export const networkRestrictionsUpdateDbAllowCidrFlag = stringSliceFlag( ); const config = { - projectRef: Flag.String("project-ref").pipe( - Flag.withDescription("Project ref of the Supabase project."), - Flag.optional, - ), + ...projectTargetFlags(), dbAllowCidr: networkRestrictionsUpdateDbAllowCidrFlag, bypassCidrChecks: Flag.Boolean("bypass-cidr-checks").pipe( Flag.withDescription("Bypass some of the CIDR validation checks."), @@ -68,6 +67,7 @@ export const networkRestrictionsUpdateCommand = Command.make("update", config).p }, projectRefPicker, linkedProjectCache, + branchResolution, ), ), Command.withHandler((flags) => 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 495ad09469..c83e51c1d5 100644 --- a/apps/cli/src/commands/network-restrictions/update/update.handler.ts +++ b/apps/cli/src/commands/network-restrictions/update/update.handler.ts @@ -5,7 +5,7 @@ import type { import { Effect, Option, Result } from "effect"; import { CommandPlatformApi } from "../../../auth/command-platform-api.service.ts"; -import { ProjectRefResolver } from "../../../config/project-ref.service.ts"; +import { resolveProjectTarget } from "../../../command-internal/target/resolve-project-target.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"; @@ -44,7 +44,6 @@ export const networkRestrictionsUpdate = Effect.fn("network-restrictions.update" const output = yield* Output; const outputFlag = yield* OutputFlag; const api = yield* CommandPlatformApi; - const resolver = yield* ProjectRefResolver; const linkedProjectCache = yield* LinkedProjectCache; const telemetryState = yield* TelemetryState; @@ -61,7 +60,7 @@ export const networkRestrictionsUpdate = Effect.fn("network-restrictions.update" } const { v4, v6 } = validation.success; - const ref = yield* resolver.resolve(flags.projectRef); + const ref = yield* resolveProjectTarget({ remote: flags.remote, projectRef: flags.projectRef }); yield* Effect.gen(function* () { yield* requireHostedMutationConsent({ diff --git a/apps/cli/src/commands/notebooks/notebooks.shared.ts b/apps/cli/src/commands/notebooks/notebooks.shared.ts index cb47b9c512..fb1a0c523d 100644 --- a/apps/cli/src/commands/notebooks/notebooks.shared.ts +++ b/apps/cli/src/commands/notebooks/notebooks.shared.ts @@ -56,13 +56,6 @@ export const notebooksDir = Effect.fnUntraced(function* (workdir: string) { const notebookFileExtension = ".json"; -/** - * `--project-ref` is a project identifier, not user content, so its value is - * logged verbatim — the same call `functions download` / `functions deploy` make - * for the same flag. - */ -export const notebooksProjectRefSafeFlags = ["project-ref"] as const; - /** Page size for the list walk — the API's maximum, so the walk is one call in practice. */ const NOTEBOOK_PAGE_SIZE = 100; diff --git a/apps/cli/src/commands/notebooks/pull/pull.command.ts b/apps/cli/src/commands/notebooks/pull/pull.command.ts index 7bcdecf9c7..44a9581dd3 100644 --- a/apps/cli/src/commands/notebooks/pull/pull.command.ts +++ b/apps/cli/src/commands/notebooks/pull/pull.command.ts @@ -1,18 +1,16 @@ -import { Argument, Command, Flag } from "effect/cli"; +import { Argument, Command } 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 { branchResolution } from "../../../command-internal/target/branch-resolution.ts"; +import { projectRefSafeFlags } from "../../../command-internal/target/project-ref-safe-flags.ts"; +import { projectTargetFlags } from "../../../command-internal/target/target-flags.ts"; import { projectRefPicker } from "../../../config/project-ref.layer.ts"; import { linkedProjectCache } from "../../../telemetry/linked-project-cache.layer.ts"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; import { managementApiRuntimeLayer } from "../../../command-internal/management-api-runtime.layer.ts"; import { withCommandTelemetry } from "../../../telemetry/command-telemetry.ts"; -import { - notebookCreate, - notebookRead, - notebooksList, - notebooksProjectRefSafeFlags, -} from "../notebooks.shared.ts"; +import { notebookCreate, notebookRead, notebooksList } from "../notebooks.shared.ts"; import { notebooksPull } from "./pull.handler.ts"; import { withTargetPolicy } from "../../../command-internal/target/target-policy.ts"; @@ -23,10 +21,7 @@ const config = { ), Argument.optional, ), - projectRef: Flag.String("project-ref").pipe( - Flag.withDescription("Project ref of the Supabase project."), - Flag.optional, - ), + ...projectTargetFlags(), } as const; export type NotebooksPullFlags = CliCommand.Command.Config.Infer<typeof config>; @@ -35,7 +30,7 @@ export type NotebooksPullFlags = CliCommand.Command.Config.Infer<typeof config>; // uses below, instead of re-asserting the generic instrumentation mechanism. export const notebooksPullHandler = (flags: NotebooksPullFlags) => notebooksPull(flags).pipe( - withCommandTelemetry({ flags, safeFlags: notebooksProjectRefSafeFlags }), + withCommandTelemetry({ flags, safeFlags: projectRefSafeFlags(flags.projectRef) }), withJsonErrorHandling, ); @@ -68,6 +63,7 @@ export const notebooksPullCommand = Command.make("pull", config).pipe( "no notebook id argument, local notebooks the project lacks, stdout a TTY with text output, and the create answer", ), linkedProjectCache, + branchResolution, ), ), Command.withHandler(notebooksPullHandler), diff --git a/apps/cli/src/commands/notebooks/pull/pull.handler.ts b/apps/cli/src/commands/notebooks/pull/pull.handler.ts index c9058ecb8c..8ee3b4da48 100644 --- a/apps/cli/src/commands/notebooks/pull/pull.handler.ts +++ b/apps/cli/src/commands/notebooks/pull/pull.handler.ts @@ -2,7 +2,7 @@ import { Effect, Option } from "effect"; 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"; -import { ProjectRefResolver } from "../../../config/project-ref.service.ts"; +import { resolveProjectTarget } from "../../../command-internal/target/resolve-project-target.ts"; import { LinkedProjectCache } from "../../../telemetry/linked-project-cache.service.ts"; import { TelemetryState } from "../../../telemetry/telemetry-state.service.ts"; import { emitNotebooksMachineOutput, rejectNotebooksEnvOutput } from "../notebooks.output.ts"; @@ -42,7 +42,6 @@ export const notebooksPull = Effect.fn("notebooks.pull")(function* (flags: Noteb const output = yield* Output; const api = yield* CommandPlatformApi; const cliSettings = yield* CommandSettings; - const resolver = yield* ProjectRefResolver; const linkedProjectCache = yield* LinkedProjectCache; const telemetryState = yield* TelemetryState; @@ -56,7 +55,7 @@ export const notebooksPull = Effect.fn("notebooks.pull")(function* (flags: Noteb // and failing at emit time would mean failing after the files are written. yield* rejectNotebooksEnvOutput(); - const ref = yield* resolver.resolve(flags.projectRef); + const ref = yield* resolveProjectTarget({ remote: flags.remote, projectRef: flags.projectRef }); yield* Effect.gen(function* () { const pulled: Array<string> = []; diff --git a/apps/cli/src/commands/notebooks/push/push.command.ts b/apps/cli/src/commands/notebooks/push/push.command.ts index 91a3d65525..45f0ca5b47 100644 --- a/apps/cli/src/commands/notebooks/push/push.command.ts +++ b/apps/cli/src/commands/notebooks/push/push.command.ts @@ -1,18 +1,16 @@ -import { Argument, Command, Flag } from "effect/cli"; +import { Argument, Command } 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 { branchResolution } from "../../../command-internal/target/branch-resolution.ts"; +import { projectRefSafeFlags } from "../../../command-internal/target/project-ref-safe-flags.ts"; +import { projectTargetFlags } from "../../../command-internal/target/target-flags.ts"; import { projectRefPicker } from "../../../config/project-ref.layer.ts"; import { linkedProjectCache } from "../../../telemetry/linked-project-cache.layer.ts"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; import { managementApiRuntimeLayer } from "../../../command-internal/management-api-runtime.layer.ts"; import { withCommandTelemetry } from "../../../telemetry/command-telemetry.ts"; -import { - notebookCreate, - notebookRead, - notebooksList, - notebooksProjectRefSafeFlags, -} from "../notebooks.shared.ts"; +import { notebookCreate, notebookRead, notebooksList } from "../notebooks.shared.ts"; import { notebooksPush } from "./push.handler.ts"; import { withTargetPolicy } from "../../../command-internal/target/target-policy.ts"; @@ -21,10 +19,7 @@ const config = { Argument.withDescription("Name of the notebook to push. Pushes all if omitted."), Argument.optional, ), - projectRef: Flag.String("project-ref").pipe( - Flag.withDescription("Project ref of the Supabase project."), - Flag.optional, - ), + ...projectTargetFlags(), } as const; export type NotebooksPushFlags = CliCommand.Command.Config.Infer<typeof config>; @@ -33,7 +28,7 @@ export type NotebooksPushFlags = CliCommand.Command.Config.Infer<typeof config>; // uses below, instead of re-asserting the generic instrumentation mechanism. export const notebooksPushHandler = (flags: NotebooksPushFlags) => notebooksPush(flags).pipe( - withCommandTelemetry({ flags, safeFlags: notebooksProjectRefSafeFlags }), + withCommandTelemetry({ flags, safeFlags: projectRefSafeFlags(flags.projectRef) }), withJsonErrorHandling, ); @@ -77,6 +72,7 @@ export const notebooksPushCommand = Command.make("push", config).pipe( "no notebook name argument, project notebooks with no local file, stdout a TTY with text output, and the write-locally answer", ), linkedProjectCache, + branchResolution, ), ), Command.withHandler(notebooksPushHandler), diff --git a/apps/cli/src/commands/notebooks/push/push.handler.ts b/apps/cli/src/commands/notebooks/push/push.handler.ts index 35170a7845..b0870af8cd 100644 --- a/apps/cli/src/commands/notebooks/push/push.handler.ts +++ b/apps/cli/src/commands/notebooks/push/push.handler.ts @@ -4,7 +4,7 @@ import { requireHostedMutationConsent } from "../../../command-internal/hosted-c 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"; -import { ProjectRefResolver } from "../../../config/project-ref.service.ts"; +import { resolveProjectTarget } from "../../../command-internal/target/resolve-project-target.ts"; import { LinkedProjectCache } from "../../../telemetry/linked-project-cache.service.ts"; import { TelemetryState } from "../../../telemetry/telemetry-state.service.ts"; import { NotebookNotFoundError } from "../notebooks.errors.ts"; @@ -41,7 +41,6 @@ export const notebooksPush = Effect.fn("notebooks.push")(function* (flags: Noteb const output = yield* Output; const api = yield* CommandPlatformApi; const cliSettings = yield* CommandSettings; - const resolver = yield* ProjectRefResolver; const linkedProjectCache = yield* LinkedProjectCache; const telemetryState = yield* TelemetryState; @@ -55,7 +54,7 @@ export const notebooksPush = Effect.fn("notebooks.push")(function* (flags: Noteb // failing after the project has already changed. yield* rejectNotebooksEnvOutput(); - const ref = yield* resolver.resolve(flags.projectRef); + const ref = yield* resolveProjectTarget({ remote: flags.remote, projectRef: flags.projectRef }); yield* Effect.gen(function* () { const local = yield* listLocalNotebooks(workdir); 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 b3ed4e0b05..5c3fcef0e0 100644 --- a/apps/cli/src/commands/postgres-config/delete/delete.command.ts +++ b/apps/cli/src/commands/postgres-config/delete/delete.command.ts @@ -4,6 +4,8 @@ import type * as CliCommand from "effect/cli/Command"; import { withPermissions } from "../../../command-internal/command-permissions/command-permissions.annotation.ts"; import { compose } from "../../../command-internal/command-permissions/model.ts"; +import { branchResolution } from "../../../command-internal/target/branch-resolution.ts"; +import { projectTargetFlags } from "../../../command-internal/target/target-flags.ts"; import { projectRefPicker } from "../../../config/project-ref.layer.ts"; import { linkedProjectCache } from "../../../telemetry/linked-project-cache.layer.ts"; import { postgresConfigRead, postgresConfigWrite } from "../postgres-config.shared.ts"; @@ -29,10 +31,7 @@ export const postgresConfigDeleteConfigFlag = stringSliceFlag( ); const config = { - projectRef: Flag.String("project-ref").pipe( - Flag.withDescription("Project ref of the Supabase project."), - Flag.optional, - ), + ...projectTargetFlags(), config: postgresConfigDeleteConfigFlag, noRestart: Flag.Boolean("no-restart").pipe( Flag.withDescription("Do not restart the database after deleting config."), @@ -52,6 +51,7 @@ export const postgresConfigDeleteCommand = Command.make("delete", config).pipe( postgresConfigWrite, projectRefPicker, linkedProjectCache, + branchResolution, ), ), Command.withHandler((flags) => 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 5243e712ba..7a3c721179 100644 --- a/apps/cli/src/commands/postgres-config/delete/delete.handler.ts +++ b/apps/cli/src/commands/postgres-config/delete/delete.handler.ts @@ -1,7 +1,7 @@ import { Effect } from "effect"; import { requireHostedMutationConsent } from "../../../command-internal/hosted-consent/hosted-consent.ts"; -import { ProjectRefResolver } from "../../../config/project-ref.service.ts"; +import { resolveProjectTarget } from "../../../command-internal/target/resolve-project-target.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"; @@ -23,12 +23,11 @@ export const postgresConfigDelete = Effect.fn("postgres-config.delete")(function flags: PostgresConfigDeleteFlags, ) { const output = yield* Output; - const resolver = yield* ProjectRefResolver; const linkedProjectCache = yield* LinkedProjectCache; const telemetryState = yield* TelemetryState; yield* Effect.gen(function* () { - const ref = yield* resolver.resolve(flags.projectRef); + const ref = yield* resolveProjectTarget({ remote: flags.remote, projectRef: flags.projectRef }); yield* Effect.gen(function* () { const startTask = Effect.suspend((): Effect.Effect<OutputTask | undefined> => 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 eb8cbcb514..7d8d173679 100644 --- a/apps/cli/src/commands/postgres-config/get/get.command.ts +++ b/apps/cli/src/commands/postgres-config/get/get.command.ts @@ -1,9 +1,11 @@ import { Effect } from "effect"; -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"; import { compose } from "../../../command-internal/command-permissions/model.ts"; +import { branchResolution } from "../../../command-internal/target/branch-resolution.ts"; +import { projectTargetFlags } from "../../../command-internal/target/target-flags.ts"; import { projectRefPicker } from "../../../config/project-ref.layer.ts"; import { linkedProjectCache } from "../../../telemetry/linked-project-cache.layer.ts"; import { postgresConfigRead } from "../postgres-config.shared.ts"; @@ -19,10 +21,7 @@ import { postgresConfigGet } from "./get.handler.ts"; import { withTargetPolicy } from "../../../command-internal/target/target-policy.ts"; const config = { - projectRef: Flag.String("project-ref").pipe( - Flag.withDescription("Project ref of the Supabase project."), - Flag.optional, - ), + ...projectTargetFlags(), } as const; export type PostgresConfigGetFlags = CliCommand.Command.Config.Infer<typeof config>; @@ -31,7 +30,13 @@ export const postgresConfigGetCommand = Command.make("get", config).pipe( Command.withDescription("Get the current Postgres database config overrides."), Command.withShortDescription("Get Postgres database config"), withPermissions( - compose({ operations: [] }, postgresConfigRead, projectRefPicker, linkedProjectCache), + compose( + { operations: [] }, + postgresConfigRead, + projectRefPicker, + linkedProjectCache, + branchResolution, + ), ), Command.withHandler((flags) => Effect.gen(function* () { diff --git a/apps/cli/src/commands/postgres-config/get/get.handler.ts b/apps/cli/src/commands/postgres-config/get/get.handler.ts index db64d5f380..ba9bb3adea 100644 --- a/apps/cli/src/commands/postgres-config/get/get.handler.ts +++ b/apps/cli/src/commands/postgres-config/get/get.handler.ts @@ -1,6 +1,6 @@ import { Effect } from "effect"; -import { ProjectRefResolver } from "../../../config/project-ref.service.ts"; +import { resolveProjectTarget } from "../../../command-internal/target/resolve-project-target.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"; @@ -14,12 +14,11 @@ export const postgresConfigGet = Effect.fn("postgres-config.get")(function* ( flags: PostgresConfigGetFlags, ) { const output = yield* Output; - const resolver = yield* ProjectRefResolver; const linkedProjectCache = yield* LinkedProjectCache; const telemetryState = yield* TelemetryState; yield* Effect.gen(function* () { - const ref = yield* resolver.resolve(flags.projectRef); + const ref = yield* resolveProjectTarget({ remote: flags.remote, projectRef: flags.projectRef }); yield* Effect.gen(function* () { const fetching = 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 5b18743cd7..72671a8af6 100644 --- a/apps/cli/src/commands/postgres-config/update/update.command.ts +++ b/apps/cli/src/commands/postgres-config/update/update.command.ts @@ -4,6 +4,8 @@ import type * as CliCommand from "effect/cli/Command"; import { withPermissions } from "../../../command-internal/command-permissions/command-permissions.annotation.ts"; import { compose, withCondition } from "../../../command-internal/command-permissions/model.ts"; +import { branchResolution } from "../../../command-internal/target/branch-resolution.ts"; +import { projectTargetFlags } from "../../../command-internal/target/target-flags.ts"; import { projectRefPicker } from "../../../config/project-ref.layer.ts"; import { linkedProjectCache } from "../../../telemetry/linked-project-cache.layer.ts"; import { postgresConfigRead, postgresConfigWrite } from "../postgres-config.shared.ts"; @@ -29,10 +31,7 @@ export const postgresConfigUpdateConfigFlag = stringSliceFlag( ); const config = { - projectRef: Flag.String("project-ref").pipe( - Flag.withDescription("Project ref of the Supabase project."), - Flag.optional, - ), + ...projectTargetFlags(), config: postgresConfigUpdateConfigFlag, replaceExistingOverrides: Flag.Boolean("replace-existing-overrides").pipe( Flag.withDescription( @@ -58,6 +57,7 @@ export const postgresConfigUpdateCommand = Command.make("update", config).pipe( postgresConfigWrite, projectRefPicker, linkedProjectCache, + branchResolution, ), ), Command.withHandler((flags) => 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 6005ef4b32..be4846a88e 100644 --- a/apps/cli/src/commands/postgres-config/update/update.handler.ts +++ b/apps/cli/src/commands/postgres-config/update/update.handler.ts @@ -1,7 +1,7 @@ import { Effect } from "effect"; import { requireHostedMutationConsent } from "../../../command-internal/hosted-consent/hosted-consent.ts"; -import { ProjectRefResolver } from "../../../config/project-ref.service.ts"; +import { resolveProjectTarget } from "../../../command-internal/target/resolve-project-target.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"; @@ -26,7 +26,6 @@ export const postgresConfigUpdate = Effect.fn("postgres-config.update")(function flags: PostgresConfigUpdateFlags, ) { const output = yield* Output; - const resolver = yield* ProjectRefResolver; const linkedProjectCache = yield* LinkedProjectCache; const telemetryState = yield* TelemetryState; @@ -40,7 +39,7 @@ export const postgresConfigUpdate = Effect.fn("postgres-config.update")(function nextOverrides[splits[0] ?? ""] = splits[1] ?? ""; } - const ref = yield* resolver.resolve(flags.projectRef); + const ref = yield* resolveProjectTarget({ remote: flags.remote, projectRef: flags.projectRef }); yield* Effect.gen(function* () { const startTask = Effect.suspend((): Effect.Effect<OutputTask | undefined> => 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 b7ed4d809e..dd8a68e46b 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 @@ -2,6 +2,9 @@ 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, withCondition } from "../../../command-internal/command-permissions/model.ts"; +import { branchResolution } from "../../../command-internal/target/branch-resolution.ts"; +import { projectRefSafeFlags } from "../../../command-internal/target/project-ref-safe-flags.ts"; +import { projectTargetFlags } from "../../../command-internal/target/target-flags.ts"; import { projectApiKeys } from "../../../command-internal/get-api-keys.ts"; import { projectRefPicker } from "../../../config/project-ref.layer.ts"; import { linkedProjectCache } from "../../../telemetry/linked-project-cache.layer.ts"; @@ -12,10 +15,7 @@ import { projectsApiKeys } from "./api-keys.handler.ts"; import { withTargetPolicy } from "../../../command-internal/target/target-policy.ts"; const config = { - projectRef: Flag.String("project-ref").pipe( - Flag.withDescription("Project ref of the Supabase project."), - Flag.optional, - ), + ...projectTargetFlags(), reveal: Flag.Boolean("reveal").pipe( Flag.withDescription("Reveal the secret API keys in full (e.g. sb_secret_...)."), Flag.withDefault(false), @@ -51,6 +51,7 @@ export const projectsApiKeysCommand = Command.make("api-keys", config).pipe( withCondition(projectApiKeys, { flag: "reveal", is: false }), projectRefPicker, linkedProjectCache, + branchResolution, ), ), Command.withHandler((flags) => @@ -58,7 +59,7 @@ export const projectsApiKeysCommand = Command.make("api-keys", config).pipe( // `reveal` is intentionally not in `safeFlags`: it is a boolean flag, and // boolean values are always logged verbatim by the instrumentation. Only // string flags that are telemetry-safe belong in `safeFlags`. - withCommandTelemetry({ flags, safeFlags: ["project-ref"] }), + withCommandTelemetry({ flags, safeFlags: projectRefSafeFlags(flags.projectRef) }), withJsonErrorHandling, ), ), diff --git a/apps/cli/src/commands/projects/api-keys/api-keys.handler.ts b/apps/cli/src/commands/projects/api-keys/api-keys.handler.ts index ed22f1c02e..d7a1cf1525 100644 --- a/apps/cli/src/commands/projects/api-keys/api-keys.handler.ts +++ b/apps/cli/src/commands/projects/api-keys/api-keys.handler.ts @@ -1,7 +1,7 @@ import type { V1GetProjectApiKeysOutput } from "@supabase/api/effect"; import { Effect, Option } from "effect"; -import { ProjectRefResolver } from "../../../config/project-ref.service.ts"; +import { resolveProjectTarget } from "../../../command-internal/target/resolve-project-target.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"; @@ -53,12 +53,11 @@ export const projectsApiKeys = Effect.fn("projects.api-keys")(function* ( ) { const output = yield* Output; const outputFlag = yield* OutputFlag; - const resolver = yield* ProjectRefResolver; const linkedProjectCache = yield* LinkedProjectCache; const telemetryState = yield* TelemetryState; // `--project-ref` resolution prompts on a TTY and fails when unlinked. - const ref = yield* resolver.resolve(flags.projectRef); + const ref = yield* resolveProjectTarget({ remote: flags.remote, projectRef: flags.projectRef }); yield* Effect.gen(function* () { const fetching = diff --git a/apps/cli/src/commands/secrets/list/list.command.ts b/apps/cli/src/commands/secrets/list/list.command.ts index 72d07b0208..df8480de70 100644 --- a/apps/cli/src/commands/secrets/list/list.command.ts +++ b/apps/cli/src/commands/secrets/list/list.command.ts @@ -1,8 +1,10 @@ import type * as CliCommand from "effect/cli/Command"; -import { Command, Flag } from "effect/cli"; +import { Command } from "effect/cli"; import { withPermissions } from "../../../command-internal/command-permissions/command-permissions.annotation.ts"; import { compose } from "../../../command-internal/command-permissions/model.ts"; +import { branchResolution } from "../../../command-internal/target/branch-resolution.ts"; +import { projectTargetFlags } from "../../../command-internal/target/target-flags.ts"; import { projectRefPicker } from "../../../config/project-ref.layer.ts"; import { linkedProjectCache } from "../../../telemetry/linked-project-cache.layer.ts"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; @@ -12,10 +14,7 @@ import { secretsList } from "./list.handler.ts"; import { withTargetPolicy } from "../../../command-internal/target/target-policy.ts"; const config = { - projectRef: Flag.String("project-ref").pipe( - Flag.withDescription("Project ref of the Supabase project."), - Flag.optional, - ), + ...projectTargetFlags(), } as const; export type SecretsListFlags = CliCommand.Command.Config.Infer<typeof config>; @@ -38,6 +37,7 @@ export const secretsListCommand = Command.make("list", config).pipe( { operations: [{ operationId: "v1-list-all-secrets", kind: "required" }] }, projectRefPicker, linkedProjectCache, + branchResolution, ), ), Command.withHandler((flags) => diff --git a/apps/cli/src/commands/secrets/list/list.handler.ts b/apps/cli/src/commands/secrets/list/list.handler.ts index 869c1bdd6d..9325b3161c 100644 --- a/apps/cli/src/commands/secrets/list/list.handler.ts +++ b/apps/cli/src/commands/secrets/list/list.handler.ts @@ -2,7 +2,7 @@ import type { V1ListAllSecretsOutput } from "@supabase/api/effect"; import { Effect, Option } from "effect"; import { CommandPlatformApi } from "../../../auth/command-platform-api.service.ts"; -import { ProjectRefResolver } from "../../../config/project-ref.service.ts"; +import { resolveProjectTarget } from "../../../command-internal/target/resolve-project-target.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"; @@ -54,11 +54,10 @@ export const secretsList = Effect.fn("secrets.list")(function* (flags: SecretsLi const output = yield* Output; const outputFlag = yield* OutputFlag; const api = yield* CommandPlatformApi; - const resolver = yield* ProjectRefResolver; const linkedProjectCache = yield* LinkedProjectCache; const telemetryState = yield* TelemetryState; - const ref = yield* resolver.resolve(flags.projectRef); + const ref = yield* resolveProjectTarget({ remote: flags.remote, projectRef: flags.projectRef }); yield* Effect.gen(function* () { const fetching = diff --git a/apps/cli/src/commands/secrets/set/set.command.ts b/apps/cli/src/commands/secrets/set/set.command.ts index 6794c911d7..ab352a90d0 100644 --- a/apps/cli/src/commands/secrets/set/set.command.ts +++ b/apps/cli/src/commands/secrets/set/set.command.ts @@ -3,6 +3,8 @@ import { Argument, Command, Flag } from "effect/cli"; import { withPermissions } from "../../../command-internal/command-permissions/command-permissions.annotation.ts"; import { compose } from "../../../command-internal/command-permissions/model.ts"; +import { branchResolution } from "../../../command-internal/target/branch-resolution.ts"; +import { projectTargetFlags } from "../../../command-internal/target/target-flags.ts"; import { withCliConfigFlags } from "../../../config/cli-config-flags.ts"; import { cliConfigValuesLayer } from "../../../config/cli-config-values.layer.ts"; import { projectRefPicker } from "../../../config/project-ref.layer.ts"; @@ -14,10 +16,7 @@ import { secretsSet } from "./set.handler.ts"; import { withTargetPolicy } from "../../../command-internal/target/target-policy.ts"; const config = { - projectRef: Flag.String("project-ref").pipe( - Flag.withDescription("Project ref of the Supabase project."), - Flag.optional, - ), + ...projectTargetFlags(), envFile: Flag.String("env-file").pipe( Flag.withDescription("Read secrets from a .env file."), Flag.optional, @@ -51,6 +50,7 @@ export const secretsSetCommand = Command.make("set", config).pipe( }, projectRefPicker, linkedProjectCache, + branchResolution, ), ), Command.withHandler((flags) => diff --git a/apps/cli/src/commands/secrets/set/set.handler.ts b/apps/cli/src/commands/secrets/set/set.handler.ts index 560eddad17..ea4b38170c 100644 --- a/apps/cli/src/commands/secrets/set/set.handler.ts +++ b/apps/cli/src/commands/secrets/set/set.handler.ts @@ -11,7 +11,7 @@ import { type ResolvedCliConfig, } from "../../../config/cli-config-values.service.ts"; import { CommandSettings } from "../../../config/command-settings.service.ts"; -import { ProjectRefResolver } from "../../../config/project-ref.service.ts"; +import { resolveProjectTarget } from "../../../command-internal/target/resolve-project-target.ts"; import { DebugLogger } from "../../../shared/output/debug-logger.service.ts"; import { LinkedProjectCache } from "../../../telemetry/linked-project-cache.service.ts"; import { TelemetryState } from "../../../telemetry/telemetry-state.service.ts"; @@ -99,7 +99,6 @@ const noConfigSecrets: ConfigSecretSource = { edgeRuntime: undefined, resolvedCo export const secretsSet = Effect.fn("secrets.set")(function* (flags: SecretsSetFlags) { const output = yield* Output; const api = yield* CommandPlatformApi; - const resolver = yield* ProjectRefResolver; const debugLogger = yield* DebugLogger; const linkedProjectCache = yield* LinkedProjectCache; const telemetryState = yield* TelemetryState; @@ -109,7 +108,7 @@ export const secretsSet = Effect.fn("secrets.set")(function* (flags: SecretsSetF const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const ref = yield* resolver.resolve(flags.projectRef); + const ref = yield* resolveProjectTarget({ remote: flags.remote, projectRef: flags.projectRef }); yield* Effect.gen(function* () { // Source 1: `[edge_runtime.secrets]` from `supabase/config.toml`. Only resolved values are diff --git a/apps/cli/src/commands/secrets/unset/unset.command.ts b/apps/cli/src/commands/secrets/unset/unset.command.ts index 90aa321274..caaf4b65d9 100644 --- a/apps/cli/src/commands/secrets/unset/unset.command.ts +++ b/apps/cli/src/commands/secrets/unset/unset.command.ts @@ -3,6 +3,8 @@ import { Argument, Command, Flag } from "effect/cli"; import { withPermissions } from "../../../command-internal/command-permissions/command-permissions.annotation.ts"; import { compose } from "../../../command-internal/command-permissions/model.ts"; +import { branchResolution } from "../../../command-internal/target/branch-resolution.ts"; +import { projectTargetFlags } from "../../../command-internal/target/target-flags.ts"; import { projectRefPicker } from "../../../config/project-ref.layer.ts"; import { linkedProjectCache } from "../../../telemetry/linked-project-cache.layer.ts"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; @@ -12,10 +14,7 @@ import { secretsUnset } from "./unset.handler.ts"; import { withTargetPolicy } from "../../../command-internal/target/target-policy.ts"; const config = { - projectRef: Flag.String("project-ref").pipe( - Flag.withDescription("Project ref of the Supabase project."), - Flag.optional, - ), + ...projectTargetFlags(), names: Argument.String("NAME").pipe( Argument.withDescription("Secret names to unset."), Argument.variadic(), @@ -73,6 +72,7 @@ export const secretsUnsetCommand = Command.make("unset", config).pipe( }, projectRefPicker, linkedProjectCache, + branchResolution, ), ), Command.withHandler((flags) => diff --git a/apps/cli/src/commands/secrets/unset/unset.handler.ts b/apps/cli/src/commands/secrets/unset/unset.handler.ts index d7a270eb3a..28bdbaeece 100644 --- a/apps/cli/src/commands/secrets/unset/unset.handler.ts +++ b/apps/cli/src/commands/secrets/unset/unset.handler.ts @@ -2,7 +2,7 @@ import type { V1ListAllSecretsOutput } from "@supabase/api/effect"; import { Effect } from "effect"; import { CommandPlatformApi } from "../../../auth/command-platform-api.service.ts"; -import { ProjectRefResolver } from "../../../config/project-ref.service.ts"; +import { resolveProjectTarget } from "../../../command-internal/target/resolve-project-target.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"; @@ -39,7 +39,6 @@ const mapUnsetError = mapHttpError({ export const secretsUnset = Effect.fn("secrets.unset")(function* (flags: SecretsUnsetFlags) { const output = yield* Output; const api = yield* CommandPlatformApi; - const resolver = yield* ProjectRefResolver; const linkedProjectCache = yield* LinkedProjectCache; const telemetryState = yield* TelemetryState; @@ -56,7 +55,7 @@ export const secretsUnset = Effect.fn("secrets.unset")(function* (flags: Secrets }); } - const ref = yield* resolver.resolve(flags.projectRef); + const ref = yield* resolveProjectTarget({ remote: flags.remote, projectRef: flags.projectRef }); yield* Effect.gen(function* () { let names: ReadonlyArray<string> = flags.names; diff --git a/apps/cli/src/commands/snippets/download/download.command.ts b/apps/cli/src/commands/snippets/download/download.command.ts index c2ba83066b..70a9007d8a 100644 --- a/apps/cli/src/commands/snippets/download/download.command.ts +++ b/apps/cli/src/commands/snippets/download/download.command.ts @@ -1,8 +1,10 @@ -import { Argument, Command, Flag } from "effect/cli"; +import { Argument, Command } from "effect/cli"; import type * as CliCommand from "effect/cli/Command"; import { withPermissions } from "../../../command-internal/command-permissions/command-permissions.annotation.ts"; import { compose } from "../../../command-internal/command-permissions/model.ts"; +import { branchResolution } from "../../../command-internal/target/branch-resolution.ts"; +import { projectTargetFlags } from "../../../command-internal/target/target-flags.ts"; import { projectRefPicker } from "../../../config/project-ref.layer.ts"; import { linkedProjectCache } from "../../../telemetry/linked-project-cache.layer.ts"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; @@ -15,10 +17,7 @@ const config = { snippetId: Argument.String("snippet-id").pipe( Argument.withDescription("ID of the SQL snippet to download."), ), - projectRef: Flag.String("project-ref").pipe( - Flag.withDescription("Project ref of the Supabase project."), - Flag.optional, - ), + ...projectTargetFlags(), }; export type SnippetsDownloadFlags = CliCommand.Command.Config.Infer<typeof config>; @@ -36,6 +35,7 @@ export const snippetsDownloadCommand = Command.make("download", config).pipe( { operations: [{ operationId: "v1-get-a-snippet", kind: "required" }] }, projectRefPicker, linkedProjectCache, + branchResolution, ), ), Command.withHandler((flags) => diff --git a/apps/cli/src/commands/snippets/download/download.handler.ts b/apps/cli/src/commands/snippets/download/download.handler.ts index b0c255da76..a3d38a3bca 100644 --- a/apps/cli/src/commands/snippets/download/download.handler.ts +++ b/apps/cli/src/commands/snippets/download/download.handler.ts @@ -3,7 +3,7 @@ import { Effect, Predicate, Result, Schema } from "effect"; import * as HttpClientResponse from "effect/http/HttpClientResponse"; import { CommandPlatformApi } from "../../../auth/command-platform-api.service.ts"; -import { ProjectRefResolver } from "../../../config/project-ref.service.ts"; +import { resolveProjectTarget } from "../../../command-internal/target/resolve-project-target.ts"; import { Output } from "../../../shared/output/output.service.ts"; import { mapHttpError } from "../../../command-internal/http-errors.ts"; import { quoteBytes } from "../../../command-internal/byte-quote.ts"; @@ -121,12 +121,11 @@ export const snippetsDownload = Effect.fn("snippets.download")(function* ( ) { const output = yield* Output; const api = yield* CommandPlatformApi; - const resolver = yield* ProjectRefResolver; const linkedProjectCache = yield* LinkedProjectCache; const telemetryState = yield* TelemetryState; yield* Effect.gen(function* () { - const ref = yield* resolver.resolve(flags.projectRef); + const ref = yield* resolveProjectTarget({ remote: flags.remote, projectRef: flags.projectRef }); yield* Effect.gen(function* () { const snippetId = yield* Effect.fromResult(parseSnippetUuid(flags.snippetId)).pipe( diff --git a/apps/cli/src/commands/snippets/list/list.command.ts b/apps/cli/src/commands/snippets/list/list.command.ts index 6c6b34e953..896388af7c 100644 --- a/apps/cli/src/commands/snippets/list/list.command.ts +++ b/apps/cli/src/commands/snippets/list/list.command.ts @@ -1,8 +1,10 @@ -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"; import { compose } from "../../../command-internal/command-permissions/model.ts"; +import { branchResolution } from "../../../command-internal/target/branch-resolution.ts"; +import { projectTargetFlags } from "../../../command-internal/target/target-flags.ts"; import { projectRefPicker } from "../../../config/project-ref.layer.ts"; import { linkedProjectCache } from "../../../telemetry/linked-project-cache.layer.ts"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; @@ -12,10 +14,7 @@ import { snippetsList } from "./list.handler.ts"; import { withTargetPolicy } from "../../../command-internal/target/target-policy.ts"; const config = { - projectRef: Flag.String("project-ref").pipe( - Flag.withDescription("Project ref of the Supabase project."), - Flag.optional, - ), + ...projectTargetFlags(), }; export type SnippetsListFlags = CliCommand.Command.Config.Infer<typeof config>; @@ -37,6 +36,7 @@ export const snippetsListCommand = Command.make("list", config).pipe( { operations: [{ operationId: "v1-list-all-snippets", kind: "required" }] }, projectRefPicker, linkedProjectCache, + branchResolution, ), ), Command.withHandler((flags) => diff --git a/apps/cli/src/commands/snippets/list/list.handler.ts b/apps/cli/src/commands/snippets/list/list.handler.ts index 7f78e2ed31..7e9b5db9c9 100644 --- a/apps/cli/src/commands/snippets/list/list.handler.ts +++ b/apps/cli/src/commands/snippets/list/list.handler.ts @@ -3,7 +3,7 @@ import { Effect, Option, Predicate, Schema } from "effect"; import * as HttpClientResponse from "effect/http/HttpClientResponse"; import { CommandPlatformApi } from "../../../auth/command-platform-api.service.ts"; -import { ProjectRefResolver } from "../../../config/project-ref.service.ts"; +import { resolveProjectTarget } from "../../../command-internal/target/resolve-project-target.ts"; import { OutputFlag } from "../../../command-internal/global-flags.ts"; import { Output } from "../../../shared/output/output.service.ts"; import { encodeSortedJson } from "../../../command-internal/output.encoders.ts"; @@ -93,7 +93,6 @@ export const snippetsList = Effect.fn("snippets.list")(function* (flags: Snippet const output = yield* Output; const outputFlag = yield* OutputFlag; const api = yield* CommandPlatformApi; - const resolver = yield* ProjectRefResolver; const linkedProjectCache = yield* LinkedProjectCache; const telemetryState = yield* TelemetryState; @@ -103,7 +102,7 @@ export const snippetsList = Effect.fn("snippets.list")(function* (flags: Snippet // write linked-project cache (needs `ref`) // flush telemetry (no `ref` required) yield* Effect.gen(function* () { - const ref = yield* resolver.resolve(flags.projectRef); + const ref = yield* resolveProjectTarget({ remote: flags.remote, projectRef: flags.projectRef }); yield* Effect.gen(function* () { if (Option.getOrUndefined(outputFlag) === "env") { 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 4e635adb85..ee509eeb2c 100644 --- a/apps/cli/src/commands/ssl-enforcement/get/get.command.ts +++ b/apps/cli/src/commands/ssl-enforcement/get/get.command.ts @@ -1,9 +1,11 @@ import { Effect } from "effect"; -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"; import { compose } from "../../../command-internal/command-permissions/model.ts"; +import { branchResolution } from "../../../command-internal/target/branch-resolution.ts"; +import { projectTargetFlags } from "../../../command-internal/target/target-flags.ts"; import { projectRefPicker } from "../../../config/project-ref.layer.ts"; import { linkedProjectCache } from "../../../telemetry/linked-project-cache.layer.ts"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; @@ -18,10 +20,7 @@ import { sslEnforcementGet } from "./get.handler.ts"; import { withTargetPolicy } from "../../../command-internal/target/target-policy.ts"; const config = { - projectRef: Flag.String("project-ref").pipe( - Flag.withDescription("Project ref of the Supabase project."), - Flag.optional, - ), + ...projectTargetFlags(), } as const; export type SslEnforcementGetFlags = CliCommand.Command.Config.Infer<typeof config>; @@ -36,6 +35,7 @@ export const sslEnforcementGetCommand = Command.make("get", config).pipe( }, projectRefPicker, linkedProjectCache, + branchResolution, ), ), Command.withHandler((flags) => diff --git a/apps/cli/src/commands/ssl-enforcement/get/get.handler.ts b/apps/cli/src/commands/ssl-enforcement/get/get.handler.ts index 74b8cef176..334e6b0032 100644 --- a/apps/cli/src/commands/ssl-enforcement/get/get.handler.ts +++ b/apps/cli/src/commands/ssl-enforcement/get/get.handler.ts @@ -1,7 +1,7 @@ import { Effect, Option } from "effect"; import { CommandPlatformApi } from "../../../auth/command-platform-api.service.ts"; -import { ProjectRefResolver } from "../../../config/project-ref.service.ts"; +import { resolveProjectTarget } from "../../../command-internal/target/resolve-project-target.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"; @@ -33,7 +33,6 @@ export const sslEnforcementGet = Effect.fn("ssl-enforcement.get")(function* ( const output = yield* Output; const outputFlag = yield* OutputFlag; const api = yield* CommandPlatformApi; - const resolver = yield* ProjectRefResolver; const linkedProjectCache = yield* LinkedProjectCache; const telemetryState = yield* TelemetryState; @@ -41,7 +40,7 @@ export const sslEnforcementGet = Effect.fn("ssl-enforcement.get")(function* ( // emission fails. `linkedProjectCache.cache` requires a resolved ref, so // it wraps the inner sub-effect only. yield* Effect.gen(function* () { - const ref = yield* resolver.resolve(flags.projectRef); + const ref = yield* resolveProjectTarget({ remote: flags.remote, projectRef: flags.projectRef }); yield* Effect.gen(function* () { const fetching = 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 b0a9993988..31e2058686 100644 --- a/apps/cli/src/commands/ssl-enforcement/update/update.command.ts +++ b/apps/cli/src/commands/ssl-enforcement/update/update.command.ts @@ -4,6 +4,8 @@ import type * as CliCommand from "effect/cli/Command"; import { withPermissions } from "../../../command-internal/command-permissions/command-permissions.annotation.ts"; import { compose } from "../../../command-internal/command-permissions/model.ts"; +import { branchResolution } from "../../../command-internal/target/branch-resolution.ts"; +import { projectTargetFlags } from "../../../command-internal/target/target-flags.ts"; import { projectRefPicker } from "../../../config/project-ref.layer.ts"; import { linkedProjectCache } from "../../../telemetry/linked-project-cache.layer.ts"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; @@ -18,10 +20,7 @@ import { sslEnforcementUpdate } from "./update.handler.ts"; import { withTargetPolicy } from "../../../command-internal/target/target-policy.ts"; const config = { - projectRef: Flag.String("project-ref").pipe( - Flag.withDescription("Project ref of the Supabase project."), - Flag.optional, - ), + ...projectTargetFlags(), enableDbSslEnforcement: Flag.Boolean("enable-db-ssl-enforcement").pipe( Flag.withDescription( "Whether the DB should enable SSL enforcement for all external connections.", @@ -59,6 +58,7 @@ export const sslEnforcementUpdateCommand = Command.make("update", config).pipe( }, projectRefPicker, linkedProjectCache, + branchResolution, ), ), Command.withHandler((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 6355424059..53d9595bfa 100644 --- a/apps/cli/src/commands/ssl-enforcement/update/update.handler.ts +++ b/apps/cli/src/commands/ssl-enforcement/update/update.handler.ts @@ -1,7 +1,7 @@ import { Effect, Option } from "effect"; import { CommandPlatformApi } from "../../../auth/command-platform-api.service.ts"; -import { ProjectRefResolver } from "../../../config/project-ref.service.ts"; +import { resolveProjectTarget } from "../../../command-internal/target/resolve-project-target.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"; @@ -37,7 +37,6 @@ export const sslEnforcementUpdate = Effect.fn("ssl-enforcement.update")(function const output = yield* Output; const outputFlag = yield* OutputFlag; const api = yield* CommandPlatformApi; - const resolver = yield* ProjectRefResolver; const linkedProjectCache = yield* LinkedProjectCache; const telemetryState = yield* TelemetryState; @@ -52,7 +51,7 @@ export const sslEnforcementUpdate = Effect.fn("ssl-enforcement.update")(function return yield* new SslEnforcementNoEnableDisableFlagError(); } - const ref = yield* resolver.resolve(flags.projectRef); + const ref = yield* resolveProjectTarget({ remote: flags.remote, projectRef: flags.projectRef }); yield* Effect.gen(function* () { yield* requireHostedMutationConsent({ diff --git a/apps/cli/src/commands/sso/add/add.command.ts b/apps/cli/src/commands/sso/add/add.command.ts index a6f662c467..be67367bb3 100644 --- a/apps/cli/src/commands/sso/add/add.command.ts +++ b/apps/cli/src/commands/sso/add/add.command.ts @@ -3,6 +3,9 @@ import type * as CliCommand from "effect/cli/Command"; import { withPermissions } from "../../../command-internal/command-permissions/command-permissions.annotation.ts"; import { compose } from "../../../command-internal/command-permissions/model.ts"; +import { branchResolution } from "../../../command-internal/target/branch-resolution.ts"; +import { projectRefSafeFlags } from "../../../command-internal/target/project-ref-safe-flags.ts"; +import { projectTargetFlags } from "../../../command-internal/target/target-flags.ts"; import { projectRefPicker } from "../../../config/project-ref.layer.ts"; import { linkedProjectCache } from "../../../telemetry/linked-project-cache.layer.ts"; import { upgradeSuggestion } from "../../../command-internal/upgrade-suggest.ts"; @@ -21,10 +24,7 @@ export const ssoAddDomainsFlag = stringSliceFlag( ); const config = { - projectRef: Flag.String("project-ref").pipe( - Flag.withDescription("Project ref of the Supabase project."), - Flag.optional, - ), + ...projectTargetFlags(), // No `Flag.optional`: `--type` is required, enforced by the CLI parser. type: Flag.Literals("type", ["saml"] as const).pipe( Flag.withAlias("t"), @@ -93,13 +93,14 @@ export const ssoAddCommand = Command.make("add", config).pipe( upgradeSuggestion, projectRefPicker, linkedProjectCache, + branchResolution, ), ), Command.withHandler((flags) => ssoAdd(flags).pipe( withCommandTelemetry({ flags, - safeFlags: ["project-ref"], + safeFlags: projectRefSafeFlags(flags.projectRef), config, // Maps the `-t` alias back to `type` so telemetry records it as changed. aliases: { t: "type" }, diff --git a/apps/cli/src/commands/sso/add/add.handler.ts b/apps/cli/src/commands/sso/add/add.handler.ts index f2d3eb40bc..84b43c6d63 100644 --- a/apps/cli/src/commands/sso/add/add.handler.ts +++ b/apps/cli/src/commands/sso/add/add.handler.ts @@ -4,7 +4,7 @@ import * as HttpClientResponse from "effect/http/HttpClientResponse"; 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 { resolveProjectTarget } from "../../../command-internal/target/resolve-project-target.ts"; import { OutputFlag } from "../../../command-internal/global-flags.ts"; import { requireHostedMutationConsent } from "../../../command-internal/hosted-consent/hosted-consent.ts"; import { @@ -149,7 +149,6 @@ export const ssoAdd = Effect.fn("sso.add")(function* (flags: SsoAddFlags) { const outputFlag = yield* OutputFlag; const api = yield* CommandPlatformApi; const cliSettings = yield* CommandSettings; - const resolver = yield* ProjectRefResolver; const linkedProjectCache = yield* LinkedProjectCache; const telemetryState = yield* TelemetryState; const stdio = yield* Stdio.Stdio; @@ -235,7 +234,7 @@ export const ssoAdd = Effect.fn("sso.add")(function* (flags: SsoAddFlags) { const attributeMappingFile = argvStringValue(occurrences, "attribute-mapping-file"); const domains = argvSliceValue(occurrences, "domains", flags.domains); - const ref = yield* resolver.resolve(projectRef); + const ref = yield* resolveProjectTarget({ remote: flags.remote, projectRef }); // Use the argv-reconciled profile's host when it disagreed with // `--profile`, otherwise the config layer's — applies to the POST and diff --git a/apps/cli/src/commands/sso/info/info.command.ts b/apps/cli/src/commands/sso/info/info.command.ts index 66f585994d..e5f47c31ed 100644 --- a/apps/cli/src/commands/sso/info/info.command.ts +++ b/apps/cli/src/commands/sso/info/info.command.ts @@ -1,8 +1,11 @@ -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"; import { compose } from "../../../command-internal/command-permissions/model.ts"; +import { branchResolution } from "../../../command-internal/target/branch-resolution.ts"; +import { projectRefSafeFlags } from "../../../command-internal/target/project-ref-safe-flags.ts"; +import { projectTargetFlags } from "../../../command-internal/target/target-flags.ts"; import { projectRefPicker } from "../../../config/project-ref.layer.ts"; import { linkedProjectCache } from "../../../telemetry/linked-project-cache.layer.ts"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; @@ -12,10 +15,7 @@ import { ssoInfo } from "./info.handler.ts"; import { withTargetPolicy } from "../../../command-internal/target/target-policy.ts"; const config = { - projectRef: Flag.String("project-ref").pipe( - Flag.withDescription("Project ref of the Supabase project."), - Flag.optional, - ), + ...projectTargetFlags(), }; export type SsoInfoFlags = CliCommand.Command.Config.Infer<typeof config>; @@ -30,10 +30,12 @@ export const ssoInfoCommand = Command.make("info", config).pipe( description: "Get SSO info for a project", }, ]), - withPermissions(compose({ operations: [] }, projectRefPicker, linkedProjectCache)), + withPermissions( + compose({ operations: [] }, projectRefPicker, linkedProjectCache, branchResolution), + ), Command.withHandler((flags) => ssoInfo(flags).pipe( - withCommandTelemetry({ flags, safeFlags: ["project-ref"] }), + withCommandTelemetry({ flags, safeFlags: projectRefSafeFlags(flags.projectRef) }), withJsonErrorHandling, ), ), diff --git a/apps/cli/src/commands/sso/info/info.handler.ts b/apps/cli/src/commands/sso/info/info.handler.ts index 4f13229c2b..675eb2591c 100644 --- a/apps/cli/src/commands/sso/info/info.handler.ts +++ b/apps/cli/src/commands/sso/info/info.handler.ts @@ -1,6 +1,6 @@ import { Effect, Option } from "effect"; -import { ProjectRefResolver } from "../../../config/project-ref.service.ts"; +import { resolveProjectTarget } from "../../../command-internal/target/resolve-project-target.ts"; import { OutputFlag } from "../../../command-internal/global-flags.ts"; import { Output } from "../../../shared/output/output.service.ts"; import { @@ -17,12 +17,11 @@ import type { SsoInfoFlags } from "./info.command.ts"; export const ssoInfo = Effect.fn("sso.info")(function* (flags: SsoInfoFlags) { const output = yield* Output; const outputFlag = yield* OutputFlag; - const resolver = yield* ProjectRefResolver; const linkedProjectCache = yield* LinkedProjectCache; const telemetryState = yield* TelemetryState; yield* Effect.gen(function* () { - const ref = yield* resolver.resolve(flags.projectRef); + const ref = yield* resolveProjectTarget({ remote: flags.remote, projectRef: flags.projectRef }); yield* Effect.gen(function* () { const payload = buildInfoPayload(ref); diff --git a/apps/cli/src/commands/sso/list/list.command.ts b/apps/cli/src/commands/sso/list/list.command.ts index 9546e927c7..6b2e08703f 100644 --- a/apps/cli/src/commands/sso/list/list.command.ts +++ b/apps/cli/src/commands/sso/list/list.command.ts @@ -1,8 +1,11 @@ -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"; import { compose } from "../../../command-internal/command-permissions/model.ts"; +import { branchResolution } from "../../../command-internal/target/branch-resolution.ts"; +import { projectRefSafeFlags } from "../../../command-internal/target/project-ref-safe-flags.ts"; +import { projectTargetFlags } from "../../../command-internal/target/target-flags.ts"; import { projectRefPicker } from "../../../config/project-ref.layer.ts"; import { linkedProjectCache } from "../../../telemetry/linked-project-cache.layer.ts"; import { upgradeSuggestion } from "../../../command-internal/upgrade-suggest.ts"; @@ -13,10 +16,7 @@ import { ssoList } from "./list.handler.ts"; import { withTargetPolicy } from "../../../command-internal/target/target-policy.ts"; const config = { - projectRef: Flag.String("project-ref").pipe( - Flag.withDescription("Project ref of the Supabase project."), - Flag.optional, - ), + ...projectTargetFlags(), }; export type SsoListFlags = CliCommand.Command.Config.Infer<typeof config>; @@ -35,11 +35,12 @@ export const ssoListCommand = Command.make("list", config).pipe( upgradeSuggestion, projectRefPicker, linkedProjectCache, + branchResolution, ), ), Command.withHandler((flags) => ssoList(flags).pipe( - withCommandTelemetry({ flags, safeFlags: ["project-ref"] }), + withCommandTelemetry({ flags, safeFlags: projectRefSafeFlags(flags.projectRef) }), withJsonErrorHandling, ), ), diff --git a/apps/cli/src/commands/sso/list/list.handler.ts b/apps/cli/src/commands/sso/list/list.handler.ts index 50b0dc0528..e13f996914 100644 --- a/apps/cli/src/commands/sso/list/list.handler.ts +++ b/apps/cli/src/commands/sso/list/list.handler.ts @@ -2,7 +2,7 @@ import type { SupabaseApiError } from "@supabase/api/effect"; import { Effect, Option } from "effect"; import { CommandPlatformApi } from "../../../auth/command-platform-api.service.ts"; -import { ProjectRefResolver } from "../../../config/project-ref.service.ts"; +import { resolveProjectTarget } from "../../../command-internal/target/resolve-project-target.ts"; import { OutputFlag } from "../../../command-internal/global-flags.ts"; import { Output } from "../../../shared/output/output.service.ts"; import { encodeEnv, encodeSortedJson } from "../../../command-internal/output.encoders.ts"; @@ -64,12 +64,11 @@ export const ssoList = Effect.fn("sso.list")(function* (flags: SsoListFlags) { const output = yield* Output; const outputFlag = yield* OutputFlag; const api = yield* CommandPlatformApi; - const resolver = yield* ProjectRefResolver; const linkedProjectCache = yield* LinkedProjectCache; const telemetryState = yield* TelemetryState; yield* Effect.gen(function* () { - const ref = yield* resolver.resolve(flags.projectRef); + const ref = yield* resolveProjectTarget({ remote: flags.remote, projectRef: flags.projectRef }); yield* Effect.gen(function* () { const fetching = diff --git a/apps/cli/src/commands/sso/remove/remove.command.ts b/apps/cli/src/commands/sso/remove/remove.command.ts index 358e41f22c..e963a42418 100644 --- a/apps/cli/src/commands/sso/remove/remove.command.ts +++ b/apps/cli/src/commands/sso/remove/remove.command.ts @@ -1,8 +1,11 @@ -import { Argument, Command, Flag } from "effect/cli"; +import { Argument, Command } from "effect/cli"; import type * as CliCommand from "effect/cli/Command"; import { withPermissions } from "../../../command-internal/command-permissions/command-permissions.annotation.ts"; import { compose } from "../../../command-internal/command-permissions/model.ts"; +import { branchResolution } from "../../../command-internal/target/branch-resolution.ts"; +import { projectRefSafeFlags } from "../../../command-internal/target/project-ref-safe-flags.ts"; +import { projectTargetFlags } from "../../../command-internal/target/target-flags.ts"; import { projectRefPicker } from "../../../config/project-ref.layer.ts"; import { linkedProjectCache } from "../../../telemetry/linked-project-cache.layer.ts"; import { upgradeSuggestion } from "../../../command-internal/upgrade-suggest.ts"; @@ -13,10 +16,7 @@ import { ssoRemove } from "./remove.handler.ts"; import { withTargetPolicy } from "../../../command-internal/target/target-policy.ts"; const config = { - projectRef: Flag.String("project-ref").pipe( - Flag.withDescription("Project ref of the Supabase project."), - Flag.optional, - ), + ...projectTargetFlags(), providerId: Argument.String("provider-id").pipe( Argument.withDescription("The ID of the SSO identity provider to remove."), ), @@ -41,11 +41,12 @@ export const ssoRemoveCommand = Command.make("remove", config).pipe( upgradeSuggestion, projectRefPicker, linkedProjectCache, + branchResolution, ), ), Command.withHandler((flags) => ssoRemove(flags).pipe( - withCommandTelemetry({ flags, safeFlags: ["project-ref"] }), + withCommandTelemetry({ flags, safeFlags: projectRefSafeFlags(flags.projectRef) }), withJsonErrorHandling, ), ), diff --git a/apps/cli/src/commands/sso/remove/remove.handler.ts b/apps/cli/src/commands/sso/remove/remove.handler.ts index bf79747425..f562ae5b8e 100644 --- a/apps/cli/src/commands/sso/remove/remove.handler.ts +++ b/apps/cli/src/commands/sso/remove/remove.handler.ts @@ -2,7 +2,7 @@ import type { SupabaseApiError } from "@supabase/api/effect"; import { Effect, Option } from "effect"; import { CommandPlatformApi } from "../../../auth/command-platform-api.service.ts"; -import { ProjectRefResolver } from "../../../config/project-ref.service.ts"; +import { resolveProjectTarget } from "../../../command-internal/target/resolve-project-target.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"; @@ -62,14 +62,13 @@ export const ssoRemove = Effect.fn("sso.remove")(function* (flags: SsoRemoveFlag const output = yield* Output; const outputFlag = yield* OutputFlag; const api = yield* CommandPlatformApi; - const resolver = yield* ProjectRefResolver; const linkedProjectCache = yield* LinkedProjectCache; const telemetryState = yield* TelemetryState; yield* Effect.gen(function* () { const providerId = yield* Effect.fromResult(validateUuid(flags.providerId)); - const ref = yield* resolver.resolve(flags.projectRef); + const ref = yield* resolveProjectTarget({ remote: flags.remote, projectRef: flags.projectRef }); yield* Effect.gen(function* () { yield* requireHostedMutationConsent({ action: `Remove SSO provider ${providerId}` }); diff --git a/apps/cli/src/commands/sso/show/show.command.ts b/apps/cli/src/commands/sso/show/show.command.ts index b782268257..dd2e5b2ed6 100644 --- a/apps/cli/src/commands/sso/show/show.command.ts +++ b/apps/cli/src/commands/sso/show/show.command.ts @@ -3,6 +3,9 @@ import type * as CliCommand from "effect/cli/Command"; import { withPermissions } from "../../../command-internal/command-permissions/command-permissions.annotation.ts"; import { compose } from "../../../command-internal/command-permissions/model.ts"; +import { branchResolution } from "../../../command-internal/target/branch-resolution.ts"; +import { projectRefSafeFlags } from "../../../command-internal/target/project-ref-safe-flags.ts"; +import { projectTargetFlags } from "../../../command-internal/target/target-flags.ts"; import { projectRefPicker } from "../../../config/project-ref.layer.ts"; import { linkedProjectCache } from "../../../telemetry/linked-project-cache.layer.ts"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; @@ -12,10 +15,7 @@ import { ssoShow } from "./show.handler.ts"; import { withTargetPolicy } from "../../../command-internal/target/target-policy.ts"; const config = { - projectRef: Flag.String("project-ref").pipe( - Flag.withDescription("Project ref of the Supabase project."), - Flag.optional, - ), + ...projectTargetFlags(), metadata: Flag.Boolean("metadata").pipe( Flag.withDescription("Show SAML 2.0 XML Metadata only"), Flag.withDefault(false), @@ -46,11 +46,12 @@ export const ssoShowCommand = Command.make("show", config).pipe( }, projectRefPicker, linkedProjectCache, + branchResolution, ), ), Command.withHandler((flags) => ssoShow(flags).pipe( - withCommandTelemetry({ flags, safeFlags: ["project-ref"] }), + withCommandTelemetry({ flags, safeFlags: projectRefSafeFlags(flags.projectRef) }), withJsonErrorHandling, ), ), diff --git a/apps/cli/src/commands/sso/show/show.handler.ts b/apps/cli/src/commands/sso/show/show.handler.ts index 03c0d93678..62f31f041e 100644 --- a/apps/cli/src/commands/sso/show/show.handler.ts +++ b/apps/cli/src/commands/sso/show/show.handler.ts @@ -2,7 +2,7 @@ import type { SupabaseApiError } from "@supabase/api/effect"; import { Effect, Option } from "effect"; import { CommandPlatformApi } from "../../../auth/command-platform-api.service.ts"; -import { ProjectRefResolver } from "../../../config/project-ref.service.ts"; +import { resolveProjectTarget } from "../../../command-internal/target/resolve-project-target.ts"; import { OutputFlag } from "../../../command-internal/global-flags.ts"; import { Output } from "../../../shared/output/output.service.ts"; import { encodeSortedJson } from "../../../command-internal/output.encoders.ts"; @@ -50,14 +50,13 @@ export const ssoShow = Effect.fn("sso.show")(function* (flags: SsoShowFlags) { const output = yield* Output; const outputFlag = yield* OutputFlag; const api = yield* CommandPlatformApi; - const resolver = yield* ProjectRefResolver; const linkedProjectCache = yield* LinkedProjectCache; const telemetryState = yield* TelemetryState; yield* Effect.gen(function* () { const providerId = yield* Effect.fromResult(validateUuid(flags.providerId)); - const ref = yield* resolver.resolve(flags.projectRef); + const ref = yield* resolveProjectTarget({ remote: flags.remote, projectRef: flags.projectRef }); yield* Effect.gen(function* () { const fetching = diff --git a/apps/cli/src/commands/sso/update/update.command.ts b/apps/cli/src/commands/sso/update/update.command.ts index 2365e582a6..dca213b9cc 100644 --- a/apps/cli/src/commands/sso/update/update.command.ts +++ b/apps/cli/src/commands/sso/update/update.command.ts @@ -3,6 +3,9 @@ import type * as CliCommand from "effect/cli/Command"; import { withPermissions } from "../../../command-internal/command-permissions/command-permissions.annotation.ts"; import { compose } from "../../../command-internal/command-permissions/model.ts"; +import { branchResolution } from "../../../command-internal/target/branch-resolution.ts"; +import { projectRefSafeFlags } from "../../../command-internal/target/project-ref-safe-flags.ts"; +import { projectTargetFlags } from "../../../command-internal/target/target-flags.ts"; import { projectRefPicker } from "../../../config/project-ref.layer.ts"; import { linkedProjectCache } from "../../../telemetry/linked-project-cache.layer.ts"; import { upgradeSuggestion } from "../../../command-internal/upgrade-suggest.ts"; @@ -32,10 +35,7 @@ export const ssoUpdateRemoveDomainsFlag = stringSliceFlag( ); const config = { - projectRef: Flag.String("project-ref").pipe( - Flag.withDescription("Project ref of the Supabase project."), - Flag.optional, - ), + ...projectTargetFlags(), domains: ssoUpdateDomainsFlag, addDomains: ssoUpdateAddDomainsFlag, removeDomains: ssoUpdateRemoveDomainsFlag, @@ -110,11 +110,12 @@ export const ssoUpdateCommand = Command.make("update", config).pipe( upgradeSuggestion, projectRefPicker, linkedProjectCache, + branchResolution, ), ), Command.withHandler((flags) => ssoUpdate(flags).pipe( - withCommandTelemetry({ flags, safeFlags: ["project-ref"], config }), + withCommandTelemetry({ flags, safeFlags: projectRefSafeFlags(flags.projectRef), config }), withJsonErrorHandling, ), ), diff --git a/apps/cli/src/commands/sso/update/update.handler.ts b/apps/cli/src/commands/sso/update/update.handler.ts index 66b80228e4..72b89e022e 100644 --- a/apps/cli/src/commands/sso/update/update.handler.ts +++ b/apps/cli/src/commands/sso/update/update.handler.ts @@ -4,7 +4,7 @@ import * as HttpClientResponse from "effect/http/HttpClientResponse"; 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 { resolveProjectTarget } from "../../../command-internal/target/resolve-project-target.ts"; import { OutputFlag } from "../../../command-internal/global-flags.ts"; import { requireHostedMutationConsent } from "../../../command-internal/hosted-consent/hosted-consent.ts"; import { @@ -215,7 +215,6 @@ export const ssoUpdate = Effect.fn("sso.update")(function* (flags: SsoUpdateFlag const outputFlag = yield* OutputFlag; const api = yield* CommandPlatformApi; const cliSettings = yield* CommandSettings; - const resolver = yield* ProjectRefResolver; const linkedProjectCache = yield* LinkedProjectCache; const telemetryState = yield* TelemetryState; const stdio = yield* Stdio.Stdio; @@ -306,7 +305,7 @@ export const ssoUpdate = Effect.fn("sso.update")(function* (flags: SsoUpdateFlag const providerId = yield* Effect.fromResult(validateUuid(flags.providerId)); - const ref = yield* resolver.resolve(projectRefFlag); + const ref = yield* resolveProjectTarget({ remote: flags.remote, projectRef: projectRefFlag }); // Use the argv-reconciled profile's host when it disagreed with // `--profile`, otherwise the config layer's. 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 125e79c36f..905fba538d 100644 --- a/apps/cli/src/commands/vanity-subdomains/activate/activate.command.ts +++ b/apps/cli/src/commands/vanity-subdomains/activate/activate.command.ts @@ -4,6 +4,8 @@ import type * as CliCommand from "effect/cli/Command"; import { withPermissions } from "../../../command-internal/command-permissions/command-permissions.annotation.ts"; import { compose } from "../../../command-internal/command-permissions/model.ts"; +import { branchResolution } from "../../../command-internal/target/branch-resolution.ts"; +import { projectTargetFlags } from "../../../command-internal/target/target-flags.ts"; import { projectRefPicker } from "../../../config/project-ref.layer.ts"; import { linkedProjectCache } from "../../../telemetry/linked-project-cache.layer.ts"; import { upgradeSuggestion } from "../../../command-internal/upgrade-suggest.ts"; @@ -19,10 +21,7 @@ import { vanitySubdomainsActivate } from "./activate.handler.ts"; import { withTargetPolicy } from "../../../command-internal/target/target-policy.ts"; const config = { - projectRef: Flag.String("project-ref").pipe( - Flag.withDescription("Project ref of the Supabase project."), - Flag.optional, - ), + ...projectTargetFlags(), // Optional at parse time so the --experimental gate, login check, and project-ref // resolution all run first; required-ness is enforced in the handler after ref // resolution. @@ -58,6 +57,7 @@ export const vanitySubdomainsActivateCommand = Command.make("activate", config). upgradeSuggestion, projectRefPicker, linkedProjectCache, + branchResolution, ), ), Command.withHandler((flags) => 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 00caa00b66..d42defeab7 100644 --- a/apps/cli/src/commands/vanity-subdomains/activate/activate.handler.ts +++ b/apps/cli/src/commands/vanity-subdomains/activate/activate.handler.ts @@ -1,7 +1,7 @@ import { Effect, Option } from "effect"; import { CommandPlatformApi } from "../../../auth/command-platform-api.service.ts"; -import { ProjectRefResolver } from "../../../config/project-ref.service.ts"; +import { resolveProjectTarget } from "../../../command-internal/target/resolve-project-target.ts"; import { gateResponse, suggestUpgrade } from "../../../command-internal/upgrade-suggest.ts"; import { LinkedProjectCache } from "../../../telemetry/linked-project-cache.service.ts"; import { TelemetryState } from "../../../telemetry/telemetry-state.service.ts"; @@ -39,12 +39,11 @@ export const vanitySubdomainsActivate = Effect.fn("vanity-subdomains.activate")( const output = yield* Output; const outputFlag = yield* OutputFlag; const api = yield* CommandPlatformApi; - const resolver = yield* ProjectRefResolver; const linkedProjectCache = yield* LinkedProjectCache; const telemetryState = yield* TelemetryState; yield* Effect.gen(function* () { - const ref = yield* resolver.resolve(flags.projectRef); + const ref = yield* resolveProjectTarget({ remote: flags.remote, projectRef: flags.projectRef }); yield* Effect.gen(function* () { // This check sits inside both `Effect.ensuring` wrappers so telemetry and the 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 0aa327880f..ad5332cc0b 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 @@ -4,6 +4,8 @@ import type * as CliCommand from "effect/cli/Command"; import { withPermissions } from "../../../command-internal/command-permissions/command-permissions.annotation.ts"; import { compose } from "../../../command-internal/command-permissions/model.ts"; +import { branchResolution } from "../../../command-internal/target/branch-resolution.ts"; +import { projectTargetFlags } from "../../../command-internal/target/target-flags.ts"; import { projectRefPicker } from "../../../config/project-ref.layer.ts"; import { linkedProjectCache } from "../../../telemetry/linked-project-cache.layer.ts"; import { upgradeSuggestion } from "../../../command-internal/upgrade-suggest.ts"; @@ -19,10 +21,7 @@ import { vanitySubdomainsCheckAvailability } from "./check-availability.handler. import { withTargetPolicy } from "../../../command-internal/target/target-policy.ts"; const config = { - projectRef: Flag.String("project-ref").pipe( - Flag.withDescription("Project ref of the Supabase project."), - Flag.optional, - ), + ...projectTargetFlags(), // Optional at parse time so the --experimental gate, login check, and project-ref // resolution all run first; required-ness is enforced in the handler after ref // resolution. @@ -59,6 +58,7 @@ export const vanitySubdomainsCheckAvailabilityCommand = Command.make( upgradeSuggestion, projectRefPicker, linkedProjectCache, + branchResolution, ), ), Command.withHandler((flags) => diff --git a/apps/cli/src/commands/vanity-subdomains/check-availability/check-availability.handler.ts b/apps/cli/src/commands/vanity-subdomains/check-availability/check-availability.handler.ts index fb3527e87b..a47c55eaa8 100644 --- a/apps/cli/src/commands/vanity-subdomains/check-availability/check-availability.handler.ts +++ b/apps/cli/src/commands/vanity-subdomains/check-availability/check-availability.handler.ts @@ -1,7 +1,7 @@ import { Effect, Option } from "effect"; import { CommandPlatformApi } from "../../../auth/command-platform-api.service.ts"; -import { ProjectRefResolver } from "../../../config/project-ref.service.ts"; +import { resolveProjectTarget } from "../../../command-internal/target/resolve-project-target.ts"; import { gateResponse, suggestUpgrade } from "../../../command-internal/upgrade-suggest.ts"; import { LinkedProjectCache } from "../../../telemetry/linked-project-cache.service.ts"; import { TelemetryState } from "../../../telemetry/telemetry-state.service.ts"; @@ -37,12 +37,14 @@ export const vanitySubdomainsCheckAvailability = Effect.fn("vanity-subdomains.ch const output = yield* Output; const outputFlag = yield* OutputFlag; const api = yield* CommandPlatformApi; - const resolver = yield* ProjectRefResolver; const linkedProjectCache = yield* LinkedProjectCache; const telemetryState = yield* TelemetryState; yield* Effect.gen(function* () { - const ref = yield* resolver.resolve(flags.projectRef); + const ref = yield* resolveProjectTarget({ + remote: flags.remote, + projectRef: flags.projectRef, + }); yield* Effect.gen(function* () { // This check sits inside both `Effect.ensuring` wrappers so telemetry and the 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 87c36fc189..e777a8c069 100644 --- a/apps/cli/src/commands/vanity-subdomains/delete/delete.command.ts +++ b/apps/cli/src/commands/vanity-subdomains/delete/delete.command.ts @@ -1,9 +1,11 @@ import { Effect } from "effect"; -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"; import { compose } from "../../../command-internal/command-permissions/model.ts"; +import { branchResolution } from "../../../command-internal/target/branch-resolution.ts"; +import { projectTargetFlags } from "../../../command-internal/target/target-flags.ts"; import { projectRefPicker } from "../../../config/project-ref.layer.ts"; import { linkedProjectCache } from "../../../telemetry/linked-project-cache.layer.ts"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; @@ -18,10 +20,7 @@ import { vanitySubdomainsDelete } from "./delete.handler.ts"; import { withTargetPolicy } from "../../../command-internal/target/target-policy.ts"; const config = { - projectRef: Flag.String("project-ref").pipe( - Flag.withDescription("Project ref of the Supabase project."), - Flag.optional, - ), + ...projectTargetFlags(), } as const; export type VanitySubdomainsDeleteFlags = CliCommand.Command.Config.Infer<typeof config>; @@ -38,6 +37,7 @@ export const vanitySubdomainsDeleteCommand = Command.make("delete", config).pipe }, projectRefPicker, linkedProjectCache, + branchResolution, ), ), Command.withHandler((flags) => 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 ff323d60c1..e5e8d31204 100644 --- a/apps/cli/src/commands/vanity-subdomains/delete/delete.handler.ts +++ b/apps/cli/src/commands/vanity-subdomains/delete/delete.handler.ts @@ -1,7 +1,7 @@ import { Effect, Option } from "effect"; import { CommandPlatformApi } from "../../../auth/command-platform-api.service.ts"; -import { ProjectRefResolver } from "../../../config/project-ref.service.ts"; +import { resolveProjectTarget } from "../../../command-internal/target/resolve-project-target.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"; @@ -27,12 +27,11 @@ export const vanitySubdomainsDelete = Effect.fn("vanity-subdomains.delete")(func const output = yield* Output; const outputFlag = yield* OutputFlag; const api = yield* CommandPlatformApi; - const resolver = yield* ProjectRefResolver; const linkedProjectCache = yield* LinkedProjectCache; const telemetryState = yield* TelemetryState; yield* Effect.gen(function* () { - const ref = yield* resolver.resolve(flags.projectRef); + const ref = yield* resolveProjectTarget({ remote: flags.remote, projectRef: flags.projectRef }); yield* Effect.gen(function* () { yield* requireHostedMutationConsent({ 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 f74c2895aa..4772f9da26 100644 --- a/apps/cli/src/commands/vanity-subdomains/get/get.command.ts +++ b/apps/cli/src/commands/vanity-subdomains/get/get.command.ts @@ -1,9 +1,11 @@ import { Effect } from "effect"; -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"; import { compose } from "../../../command-internal/command-permissions/model.ts"; +import { branchResolution } from "../../../command-internal/target/branch-resolution.ts"; +import { projectTargetFlags } from "../../../command-internal/target/target-flags.ts"; import { projectRefPicker } from "../../../config/project-ref.layer.ts"; import { linkedProjectCache } from "../../../telemetry/linked-project-cache.layer.ts"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; @@ -18,10 +20,7 @@ import { vanitySubdomainsGet } from "./get.handler.ts"; import { withTargetPolicy } from "../../../command-internal/target/target-policy.ts"; const config = { - projectRef: Flag.String("project-ref").pipe( - Flag.withDescription("Project ref of the Supabase project."), - Flag.optional, - ), + ...projectTargetFlags(), }; export type VanitySubdomainsGetFlags = CliCommand.Command.Config.Infer<typeof config>; @@ -36,6 +35,7 @@ export const vanitySubdomainsGetCommand = Command.make("get", config).pipe( }, projectRefPicker, linkedProjectCache, + branchResolution, ), ), Command.withHandler((flags) => diff --git a/apps/cli/src/commands/vanity-subdomains/get/get.handler.ts b/apps/cli/src/commands/vanity-subdomains/get/get.handler.ts index 908d5a4529..1fd4d2c909 100644 --- a/apps/cli/src/commands/vanity-subdomains/get/get.handler.ts +++ b/apps/cli/src/commands/vanity-subdomains/get/get.handler.ts @@ -1,7 +1,7 @@ import { Effect, Option } from "effect"; import { CommandPlatformApi } from "../../../auth/command-platform-api.service.ts"; -import { ProjectRefResolver } from "../../../config/project-ref.service.ts"; +import { resolveProjectTarget } from "../../../command-internal/target/resolve-project-target.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"; @@ -41,12 +41,11 @@ export const vanitySubdomainsGet = Effect.fn("vanity-subdomains.get")(function* const output = yield* Output; const outputFlag = yield* OutputFlag; const api = yield* CommandPlatformApi; - const resolver = yield* ProjectRefResolver; const linkedProjectCache = yield* LinkedProjectCache; const telemetryState = yield* TelemetryState; yield* Effect.gen(function* () { - const ref = yield* resolver.resolve(flags.projectRef); + const ref = yield* resolveProjectTarget({ remote: flags.remote, projectRef: flags.projectRef }); yield* Effect.gen(function* () { const fetching = From 01f1f2af66fc6a6c856a0eb28d7a9bf8291d8d27 Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Mon, 19 Oct 2026 11:54:52 +0100 Subject: [PATCH 117/291] test(cli): cover --remote selection for hosted G-Z commands Switch the command tests to --remote forms and add per-command cases for --remote=<ref>, a bare --remote, --remote=<branch>, a rejected --remote=<url> and the deprecated --project-ref alias. --- .../network-bans/get/get.integration.test.ts | 78 ++++--- .../remove/remove.integration.test.ts | 67 +++++- .../get/get.integration.test.ts | 61 ++++-- .../update/update.integration.test.ts | 47 ++-- .../notebooks/notebooks.integration.test.ts | 113 +++++++++- .../notebooks/pull/pull.integration.test.ts | 7 +- .../notebooks/push/push.integration.test.ts | 10 +- .../postgres-config.integration.test.ts | 105 +++++++-- .../api-keys/api-keys.integration.test.ts | 56 +++-- .../secrets/list/list.integration.test.ts | 59 +++-- .../secrets/set/set.integration.test.ts | 57 +++++ .../secrets/unset/unset.integration.test.ts | 31 ++- .../download/download.integration.test.ts | 116 ++++++++-- .../snippets/list/list.integration.test.ts | 79 ++++--- .../get/get.integration.test.ts | 89 +++++--- .../update/update.integration.test.ts | 118 +++++----- .../commands/sso/add/add.integration.test.ts | 22 +- .../sso/info/info.integration.test.ts | 35 +-- .../sso/list/list.integration.test.ts | 91 ++++---- .../sso/remove/remove.integration.test.ts | 98 +++++++-- .../sso/show/show.integration.test.ts | 29 +++ .../sso/update/update.integration.test.ts | 16 +- .../vanity-subdomains.integration.test.ts | 127 +++++++++-- apps/cli/src/shared/cli/run.unit.test.ts | 4 +- .../subcommand-flag-suggestions.unit.test.ts | 39 ++-- .../output/normalize-error.unit.test.ts | 19 +- .../shared/output/text-formatter.unit.test.ts | 18 +- .../tests/helpers/hosted-remote-selection.ts | 202 ++++++++++++++++++ 28 files changed, 1368 insertions(+), 425 deletions(-) create mode 100644 apps/cli/tests/helpers/hosted-remote-selection.ts 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 3d50114f1b..a9f04665d3 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 @@ -15,6 +15,7 @@ import { useTempWorkdir, unverifiedTargetBanner, } from "../../../../tests/helpers/command-mocks.ts"; +import { describeHostedRemoteSelection } from "../../../../tests/helpers/hosted-remote-selection.ts"; import { assertPermissionDrift } from "../../../../tests/helpers/permission-drift.ts"; import { projectResponse, @@ -77,7 +78,7 @@ describe("network-bans get integration", () => { it.effect("writes the stderr heading and JSON array bytes in text mode", () => { const { layer, out } = setup(); return Effect.gen(function* () { - yield* networkBansGet({ projectRef: Option.none() }); + yield* networkBansGet({ remote: Option.none(), projectRef: Option.none() }); expect(out.stderrText).toBe(unverifiedTargetBanner() + "DB banned IPs:\n"); expect(out.stdoutText).toBe( `[ @@ -92,7 +93,7 @@ describe("network-bans get integration", () => { it.effect("emits TOML bytes for --output toml", () => { const { layer, out } = setup({ outputFlag: "toml" }); return Effect.gen(function* () { - yield* networkBansGet({ projectRef: Option.none() }); + yield* networkBansGet({ remote: Option.none(), projectRef: Option.none() }); 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)); @@ -101,7 +102,7 @@ describe("network-bans get integration", () => { it.effect("emits YAML bytes for --output yaml", () => { const { layer, out } = setup({ outputFlag: "yaml" }); return Effect.gen(function* () { - yield* networkBansGet({ projectRef: Option.none() }); + yield* networkBansGet({ remote: Option.none(), projectRef: Option.none() }); 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"); @@ -111,7 +112,7 @@ describe("network-bans get integration", () => { it.effect("emits JSON bytes for --output json", () => { const { layer, out } = setup({ outputFlag: "json" }); return Effect.gen(function* () { - yield* networkBansGet({ projectRef: Option.none() }); + yield* networkBansGet({ remote: Option.none(), projectRef: Option.none() }); expect(out.stderrText).toBe(unverifiedTargetBanner() + "DB banned IPs:\n"); expect(out.stdoutText).toBe( `[ @@ -126,7 +127,7 @@ describe("network-bans get integration", () => { it.effect("treats --output pretty as a JSON alias", () => { const { layer, out } = setup({ outputFlag: "pretty" }); return Effect.gen(function* () { - yield* networkBansGet({ projectRef: Option.none() }); + yield* networkBansGet({ remote: Option.none(), projectRef: Option.none() }); expect(out.stderrText).toBe(unverifiedTargetBanner() + "DB banned IPs:\n"); expect(out.stdoutText).toBe( `[ @@ -141,7 +142,9 @@ describe("network-bans get integration", () => { it.effect("fails with the env-not-supported error for --output env", () => { const { layer, out } = setup({ outputFlag: "env" }); return Effect.gen(function* () { - const exit = yield* Effect.exit(networkBansGet({ projectRef: Option.none() })); + const exit = yield* Effect.exit( + networkBansGet({ remote: Option.none(), projectRef: Option.none() }), + ); expect(Exit.isFailure(exit)).toBe(true); expect(out.stderrText).toBe(unverifiedTargetBanner() + "DB banned IPs:\n"); if (Exit.isFailure(exit)) { @@ -155,7 +158,7 @@ describe("network-bans get integration", () => { it.effect("emits a structured JSON success payload via --output-format=json", () => { const { layer, out } = setup({ format: "json" }); return Effect.gen(function* () { - yield* networkBansGet({ projectRef: Option.none() }); + yield* networkBansGet({ remote: Option.none(), projectRef: Option.none() }); const success = out.messages.find((m) => m.type === "success"); expect(success).toBeDefined(); expect(success?.data).toMatchObject({ @@ -169,7 +172,7 @@ describe("network-bans get integration", () => { it.effect("emits a result event via --output-format=stream-json", () => { const { layer, out } = setup({ format: "stream-json" }); return Effect.gen(function* () { - yield* networkBansGet({ projectRef: Option.none() }); + yield* networkBansGet({ remote: Option.none(), projectRef: Option.none() }); const success = out.messages.find((m) => m.type === "success"); expect(success).toBeDefined(); expect(success?.data).toMatchObject({ @@ -181,7 +184,7 @@ describe("network-bans get integration", () => { it.effect("--output wins over --output-format when both are set", () => { const { layer, out } = setup({ format: "json", outputFlag: "toml" }); return Effect.gen(function* () { - yield* networkBansGet({ projectRef: Option.none() }); + yield* networkBansGet({ remote: Option.none(), projectRef: Option.none() }); 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)); @@ -190,7 +193,7 @@ describe("network-bans get integration", () => { it.effect("posts to the /network-bans/retrieve endpoint with the resolved ref", () => { const { layer, api } = setup(); return Effect.gen(function* () { - yield* networkBansGet({ projectRef: Option.none() }); + yield* networkBansGet({ remote: Option.none(), projectRef: Option.none() }); expect(api.requests).toHaveLength(1); expect(api.requests[0]?.method).toBe("POST"); expect(api.requests[0]?.url).toContain(`/v1/projects/${VALID_REF}/network-bans/retrieve`); @@ -229,7 +232,7 @@ describe("network-bans get integration", () => { const fs = yield* FileSystem.FileSystem; const cachePath = `${tempRoot.current}/supabase/.temp/linked-project.json`; expect(yield* fs.exists(cachePath)).toBe(false); - yield* networkBansGet({ projectRef: Option.none() }); + yield* networkBansGet({ remote: Option.none(), projectRef: Option.none() }); expect(api.requests.map(({ method, url }) => `${method} ${url}`)).toEqual([ "GET https://api.supabase.com/v1/projects", `POST https://api.supabase.com/v1/projects/${VALID_REF}/network-bans/retrieve`, @@ -247,7 +250,7 @@ describe("network-bans get integration", () => { }).pipe(Effect.provide(layer)); }); - it.effect("ignores an empty --project-ref and resolves the saved ref without the picker", () => { + it.effect("rejects an empty --project-ref before the picker or any request", () => { const out = mockOutput(); const api = mockCommandPlatformApi({ response: { status: 201, body: SAMPLE_BANS } }); const layer = buildTestRuntime({ @@ -257,28 +260,24 @@ describe("network-bans get integration", () => { tty: mockTty({ stdinIsTty: true }), }); 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* networkBansGet({ projectRef: Option.some("") }); + const error = yield* networkBansGet({ + remote: Option.none(), + projectRef: Option.some(""), + }).pipe(Effect.flip); + expect(error).toMatchObject({ _tag: "TargetEmptyValueError", flag: "--project-ref" }); expect(out.promptSelectCalls).toHaveLength(0); - expect(api.requests.map(({ method, url }) => `${method} ${url}`)).toEqual([ - `POST https://api.supabase.com/v1/projects/${VALID_REF}/network-bans/retrieve`, - ]); - assertPermissionDrift({ - command: "network-bans get", - activeFlags: { "project-ref": "" }, - requests: api.requests, - exact: true, - }); + expect(api.requests).toEqual([]); }).pipe(Effect.provide(layer)); }); - it.effect("uses --project-ref flag value over CommandSettings.projectId", () => { + it.effect("uses the --remote=<ref> value over CommandSettings.projectId", () => { const flagRef = "zzzzzzzzzzzzzzzzzzzz"; const { layer, api } = setup(); return Effect.gen(function* () { - yield* networkBansGet({ projectRef: Option.some(flagRef) }); + yield* networkBansGet({ + remote: Option.some({ _tag: "Ref", ref: flagRef }), + projectRef: Option.none(), + }); expect(api.requests[0]?.url).toContain(`/v1/projects/${flagRef}/`); }).pipe(Effect.provide(layer)); }); @@ -286,7 +285,9 @@ describe("network-bans get integration", () => { it.effect("fails with NetworkBansGetUnexpectedStatusError on HTTP 503", () => { const { layer } = setup({ status: 503 }); return Effect.gen(function* () { - const exit = yield* Effect.exit(networkBansGet({ projectRef: Option.none() })); + const exit = yield* Effect.exit( + networkBansGet({ remote: Option.none(), projectRef: Option.none() }), + ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { const causeText = Cause.pretty(exit.cause); @@ -299,7 +300,9 @@ describe("network-bans get integration", () => { it.effect("reports a network error when the API transport fails", () => { const { layer } = setup({ network: "fail" }); return Effect.gen(function* () { - const exit = yield* Effect.exit(networkBansGet({ projectRef: Option.none() })); + const exit = yield* Effect.exit( + networkBansGet({ remote: Option.none(), projectRef: Option.none() }), + ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { const causeText = Cause.pretty(exit.cause); @@ -312,7 +315,9 @@ describe("network-bans get integration", () => { it.effect("emits a fail event when withJsonErrorHandling wraps a JSON-mode error", () => { const { layer, out } = setup({ format: "json", status: 503 }); return Effect.gen(function* () { - yield* networkBansGet({ projectRef: Option.none() }).pipe(withJsonErrorHandling); + yield* networkBansGet({ remote: Option.none(), projectRef: Option.none() }).pipe( + withJsonErrorHandling, + ); expect(out.messages.some((m) => m.type === "fail")).toBe(true); }).pipe(Effect.provide(layer)); }); @@ -320,7 +325,7 @@ describe("network-bans get integration", () => { it.effect("flushes telemetry and writes linked-project cache on success", () => { const { layer, telemetry, cache } = setupTracked(); return Effect.gen(function* () { - yield* networkBansGet({ projectRef: Option.none() }); + yield* networkBansGet({ remote: Option.none(), projectRef: Option.none() }); expect(telemetry.flushed).toBe(true); expect(cache.cached).toBe(true); }).pipe(Effect.provide(layer)); @@ -330,7 +335,9 @@ describe("network-bans get integration", () => { const { layer, telemetry, cache } = setupTracked({ status: 500 }); return Effect.gen(function* () { const exit = yield* Effect.exit( - networkBansGet({ projectRef: Option.none() }).pipe(Effect.provide(layer)), + networkBansGet({ remote: Option.none(), projectRef: Option.none() }).pipe( + Effect.provide(layer), + ), ); expect(Exit.isFailure(exit)).toBe(true); expect(telemetry.flushed).toBe(true); @@ -339,4 +346,11 @@ describe("network-bans get integration", () => { expect(cache.cached).toBe(true); }); }); + + describeHostedRemoteSelection({ + command: ["network-bans", "get"], + workdir: () => tempRoot.current, + respond: (request) => jsonResponse(request, 201, SAMPLE_BANS), + run: (selector, layer) => networkBansGet(selector).pipe(Effect.provide(layer)), + }); }); 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 8a8b909ea7..d54fbea580 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 @@ -3,6 +3,7 @@ import { describe, expect, it } from "@effect/vitest"; import { Cause, Effect, Exit, Layer, Option, FileSystem, Schema } from "effect"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; import { describeHostedConsent } from "../../../../tests/helpers/hosted-consent.ts"; +import { describeHostedRemoteSelection } from "../../../../tests/helpers/hosted-remote-selection.ts"; import { mockOutput, mockTty } from "../../../../tests/helpers/mocks.ts"; import { VALID_REF, @@ -96,6 +97,7 @@ describe("network-bans remove integration", () => { const { layer, out, api } = setup(); return Effect.gen(function* () { yield* networkBansRemove({ + remote: Option.none(), projectRef: Option.none(), dbUnbanIp: [], }); @@ -146,7 +148,7 @@ describe("network-bans remove integration", () => { const fs = yield* FileSystem.FileSystem; const cachePath = `${tempRoot.current}/supabase/.temp/linked-project.json`; expect(yield* fs.exists(cachePath)).toBe(false); - yield* networkBansRemove({ projectRef: Option.none(), dbUnbanIp: [] }); + yield* networkBansRemove({ remote: Option.none(), projectRef: Option.none(), dbUnbanIp: [] }); 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}/network-bans`, @@ -168,6 +170,7 @@ describe("network-bans remove integration", () => { const { layer, api } = setup(); return Effect.gen(function* () { yield* networkBansRemove({ + remote: Option.none(), projectRef: Option.none(), dbUnbanIp: ["12.3.4.5", "2001:db8:abcd:0012::0"], }); @@ -187,7 +190,7 @@ describe("network-bans remove integration", () => { const { layer, api } = setup(); return Effect.gen(function* () { const dbUnbanIp = yield* parseDbUnbanIp(["12.3.4.5,5.6.7.8"]); - yield* networkBansRemove({ projectRef: Option.none(), dbUnbanIp }); + yield* networkBansRemove({ remote: Option.none(), projectRef: Option.none(), dbUnbanIp }); expect(api.requests[0]?.body).toEqual({ ipv4_addresses: ["12.3.4.5", "5.6.7.8"], requester_ip: false, @@ -199,7 +202,7 @@ describe("network-bans remove integration", () => { const { layer, api } = setup(); return Effect.gen(function* () { const dbUnbanIp = yield* parseDbUnbanIp(["12.3.4.5", "5.6.7.8"]); - yield* networkBansRemove({ projectRef: Option.none(), dbUnbanIp }); + yield* networkBansRemove({ remote: Option.none(), projectRef: Option.none(), dbUnbanIp }); expect(api.requests[0]?.body).toEqual({ ipv4_addresses: ["12.3.4.5", "5.6.7.8"], requester_ip: false, @@ -211,7 +214,7 @@ describe("network-bans remove integration", () => { const { layer, api } = setup(); return Effect.gen(function* () { const dbUnbanIp = yield* parseDbUnbanIp(["12.3.4.5,5.6.7.8", "9.9.9.9"]); - yield* networkBansRemove({ projectRef: Option.none(), dbUnbanIp }); + yield* networkBansRemove({ remote: Option.none(), projectRef: Option.none(), dbUnbanIp }); expect(api.requests[0]?.body).toEqual({ ipv4_addresses: ["12.3.4.5", "5.6.7.8", "9.9.9.9"], requester_ip: false, @@ -223,7 +226,7 @@ describe("network-bans remove integration", () => { const { layer, api } = setup(); return Effect.gen(function* () { const dbUnbanIp = yield* parseDbUnbanIp(["12.3.4.5"]); - yield* networkBansRemove({ projectRef: Option.none(), dbUnbanIp }); + yield* networkBansRemove({ remote: Option.none(), projectRef: Option.none(), dbUnbanIp }); expect(api.requests[0]?.body).toEqual({ ipv4_addresses: ["12.3.4.5"], requester_ip: false, @@ -235,7 +238,9 @@ describe("network-bans remove integration", () => { const { layer, api } = setup(); return Effect.gen(function* () { const dbUnbanIp = yield* parseDbUnbanIp(["12.3.4.5,notanip"]); - const exit = yield* Effect.exit(networkBansRemove({ projectRef: Option.none(), dbUnbanIp })); + const exit = yield* Effect.exit( + networkBansRemove({ remote: Option.none(), projectRef: Option.none(), dbUnbanIp }), + ); expect(Exit.isFailure(exit)).toBe(true); expect(api.requests).toHaveLength(0); if (Exit.isFailure(exit)) { @@ -248,6 +253,7 @@ describe("network-bans remove integration", () => { const { layer, out } = setup({ outputFlag: "json" }); return Effect.gen(function* () { yield* networkBansRemove({ + remote: Option.none(), projectRef: Option.none(), dbUnbanIp: [], }); @@ -259,6 +265,7 @@ describe("network-bans remove integration", () => { const { layer, out } = setup({ outputFlag: "yaml" }); return Effect.gen(function* () { yield* networkBansRemove({ + remote: Option.none(), projectRef: Option.none(), dbUnbanIp: [], }); @@ -270,6 +277,7 @@ describe("network-bans remove integration", () => { const { layer, out } = setup({ format: "json" }); return Effect.gen(function* () { yield* networkBansRemove({ + remote: Option.none(), projectRef: Option.none(), dbUnbanIp: [], }); @@ -282,6 +290,7 @@ describe("network-bans remove integration", () => { const { layer, out } = setup({ format: "stream-json" }); return Effect.gen(function* () { yield* networkBansRemove({ + remote: Option.none(), projectRef: Option.none(), dbUnbanIp: [], }); @@ -294,6 +303,7 @@ describe("network-bans remove integration", () => { const { layer, out } = setup({ format: "json", outputFlag: "yaml" }); return Effect.gen(function* () { yield* networkBansRemove({ + remote: Option.none(), projectRef: Option.none(), dbUnbanIp: [], }); @@ -307,6 +317,7 @@ describe("network-bans remove integration", () => { return Effect.gen(function* () { const exit = yield* Effect.exit( networkBansRemove({ + remote: Option.none(), projectRef: Option.none(), dbUnbanIp: ["12.3.4"], }), @@ -335,6 +346,7 @@ describe("network-bans remove integration", () => { return Effect.gen(function* () { const exit = yield* Effect.exit( networkBansRemove({ + remote: Option.none(), projectRef: Option.none(), dbUnbanIp: ["12.3.4"], }), @@ -353,11 +365,24 @@ describe("network-bans remove integration", () => { it.effect( "surfaces the invalid-project-ref error, not the invalid-IP error, when both are wrong", () => { - const { layer, api } = setup(); + const out = mockOutput({ format: "text" }); + const api = mockCommandPlatformApi({ response: { status: 200, body: null } }); + const layer = Layer.mergeAll( + buildTestRuntime({ + out, + api, + cliSettings: mockCommandSettings({ + workdir: tempRoot.current, + projectId: Option.some("BADREF"), + }), + }), + consentApproved, + ); return Effect.gen(function* () { const exit = yield* Effect.exit( networkBansRemove({ - projectRef: Option.some("BADREF"), + remote: Option.none(), + projectRef: Option.none(), dbUnbanIp: ["12.3.4"], }), ); @@ -387,7 +412,11 @@ describe("network-bans remove integration", () => { ); return Effect.gen(function* () { const error = yield* withEmptyConfigEnv( - networkBansRemove({ projectRef: Option.none(), dbUnbanIp: ["1.2.3.4", "5.6.7.8"] }), + networkBansRemove({ + remote: Option.none(), + 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"); @@ -401,6 +430,7 @@ describe("network-bans remove integration", () => { return Effect.gen(function* () { const exit = yield* Effect.exit( networkBansRemove({ + remote: Option.none(), projectRef: Option.none(), dbUnbanIp: [], }), @@ -419,6 +449,7 @@ describe("network-bans remove integration", () => { return Effect.gen(function* () { const exit = yield* Effect.exit( networkBansRemove({ + remote: Option.none(), projectRef: Option.none(), dbUnbanIp: [], }), @@ -436,6 +467,7 @@ describe("network-bans remove integration", () => { const { layer, out } = setup({ format: "json", status: 503 }); return Effect.gen(function* () { yield* networkBansRemove({ + remote: Option.none(), projectRef: Option.none(), dbUnbanIp: [], }).pipe(withJsonErrorHandling); @@ -447,6 +479,7 @@ describe("network-bans remove integration", () => { const { layer, telemetry, cache } = setupTracked(); return Effect.gen(function* () { yield* networkBansRemove({ + remote: Option.none(), projectRef: Option.none(), dbUnbanIp: [], }); @@ -460,6 +493,7 @@ describe("network-bans remove integration", () => { return Effect.gen(function* () { const exit = yield* Effect.exit( networkBansRemove({ + remote: Option.none(), projectRef: Option.none(), dbUnbanIp: [], }).pipe(Effect.provide(layer)), @@ -469,10 +503,23 @@ describe("network-bans remove integration", () => { expect(cache.cached).toBe(true); }); }); + + describeHostedRemoteSelection({ + command: ["network-bans", "remove"], + args: ["--db-unban-ip", "1.2.3.4"], + workdir: () => tempRoot.current, + respond: (request) => jsonResponse(request, 200, null), + run: (selector, layer) => + networkBansRemove({ ...selector, dbUnbanIp: ["1.2.3.4"] }).pipe(Effect.provide(layer)), + }); }); describeHostedConsent("network-bans remove", { invoke: () => ({ - run: networkBansRemove({ projectRef: Option.some(VALID_REF), dbUnbanIp: ["1.2.3.4"] }), + run: networkBansRemove({ + remote: Option.some({ _tag: "Ref", ref: VALID_REF }), + projectRef: Option.none(), + dbUnbanIp: ["1.2.3.4"], + }), }), }); diff --git a/apps/cli/src/commands/network-restrictions/get/get.integration.test.ts b/apps/cli/src/commands/network-restrictions/get/get.integration.test.ts index df85c7de04..9b54cf8bf3 100644 --- a/apps/cli/src/commands/network-restrictions/get/get.integration.test.ts +++ b/apps/cli/src/commands/network-restrictions/get/get.integration.test.ts @@ -14,6 +14,7 @@ import { mockTelemetryStateTracked, useTempWorkdir, } from "../../../../tests/helpers/command-mocks.ts"; +import { describeHostedRemoteSelection } from "../../../../tests/helpers/hosted-remote-selection.ts"; import { assertPermissionDrift } from "../../../../tests/helpers/permission-drift.ts"; import { projectResponse, @@ -93,7 +94,7 @@ describe("network-restrictions get integration", () => { it.effect("prints the formatted text block when the response has v4 and v6 entries", () => { const { layer, out } = setup({ response: APPLIED_WITH_CIDRS }); return Effect.gen(function* () { - yield* networkRestrictionsGet({ projectRef: Option.none() }); + yield* networkRestrictionsGet({ remote: Option.none(), projectRef: Option.none() }); expect(out.stdoutText).toBe( "DB Allowed IPv4 CIDRs: &[1.2.3.0/24 5.6.7.0/24]\n" + "DB Allowed IPv6 CIDRs: &[2001:db8::/64]\n" + @@ -105,7 +106,7 @@ describe("network-restrictions get integration", () => { it.effect("prints `&[]` for both arrays when the API returns empty arrays", () => { const { layer, out } = setup({ response: APPLIED_NO_CIDRS }); return Effect.gen(function* () { - yield* networkRestrictionsGet({ projectRef: Option.none() }); + yield* networkRestrictionsGet({ remote: Option.none(), projectRef: Option.none() }); expect(out.stdoutText).toBe( "DB Allowed IPv4 CIDRs: &[]\n" + "DB Allowed IPv6 CIDRs: &[]\n" + @@ -117,7 +118,7 @@ describe("network-restrictions get integration", () => { it.effect("prints `<nil>` when the API omits the dbAllowedCidrs fields", () => { const { layer, out } = setup({ response: STORED_WITH_OMITTED_CIDRS }); return Effect.gen(function* () { - yield* networkRestrictionsGet({ projectRef: Option.none() }); + yield* networkRestrictionsGet({ remote: Option.none(), projectRef: Option.none() }); expect(out.stdoutText).toBe( "DB Allowed IPv4 CIDRs: <nil>\n" + "DB Allowed IPv6 CIDRs: <nil>\n" + @@ -134,7 +135,7 @@ describe("network-restrictions get integration", () => { }; const { layer, out } = setup({ response: stored }); return Effect.gen(function* () { - yield* networkRestrictionsGet({ projectRef: Option.none() }); + yield* networkRestrictionsGet({ remote: Option.none(), projectRef: Option.none() }); expect(out.stdoutText).toBe( "DB Allowed IPv4 CIDRs: &[1.2.3.0/24]\n" + "DB Allowed IPv6 CIDRs: &[]\n" + @@ -146,7 +147,7 @@ describe("network-restrictions get integration", () => { it.effect("emits a structured JSON success payload via --output-format=json", () => { const { layer, out } = setup({ format: "json", response: APPLIED_WITH_CIDRS }); return Effect.gen(function* () { - yield* networkRestrictionsGet({ projectRef: Option.none() }); + yield* networkRestrictionsGet({ remote: Option.none(), projectRef: Option.none() }); const success = out.messages.find((m) => m.type === "success"); expect(success).toBeDefined(); expect(success?.data).toMatchObject({ @@ -162,7 +163,7 @@ describe("network-restrictions get integration", () => { it.effect("emits a result event via --output-format=stream-json", () => { const { layer, out } = setup({ format: "stream-json", response: APPLIED_WITH_CIDRS }); return Effect.gen(function* () { - yield* networkRestrictionsGet({ projectRef: Option.none() }); + yield* networkRestrictionsGet({ remote: Option.none(), projectRef: Option.none() }); const success = out.messages.find((m) => m.type === "success"); expect(success).toBeDefined(); expect(success?.data).toMatchObject({ status: "applied" }); @@ -172,7 +173,7 @@ describe("network-restrictions get integration", () => { it.effect("emits JSON when --output=json", () => { const { layer, out } = setup({ outputFlag: "json", response: APPLIED_WITH_CIDRS }); return Effect.gen(function* () { - yield* networkRestrictionsGet({ projectRef: Option.none() }); + yield* networkRestrictionsGet({ remote: Option.none(), projectRef: Option.none() }); expect(out.stdoutText.startsWith("{")).toBe(true); expect(out.stdoutText.endsWith("\n")).toBe(true); expect(out.stdoutText).toContain('"status": "applied"'); @@ -183,7 +184,7 @@ describe("network-restrictions get integration", () => { it.effect("emits YAML when --output=yaml", () => { const { layer, out } = setup({ outputFlag: "yaml", response: APPLIED_WITH_CIDRS }); return Effect.gen(function* () { - yield* networkRestrictionsGet({ projectRef: Option.none() }); + yield* networkRestrictionsGet({ remote: Option.none(), projectRef: Option.none() }); expect(out.stdoutText).toContain("status: applied"); }).pipe(Effect.provide(layer)); }); @@ -191,7 +192,7 @@ describe("network-restrictions get integration", () => { it.effect("emits TOML when --output=toml", () => { const { layer, out } = setup({ outputFlag: "toml", response: APPLIED_WITH_CIDRS }); return Effect.gen(function* () { - yield* networkRestrictionsGet({ projectRef: Option.none() }); + yield* networkRestrictionsGet({ remote: Option.none(), projectRef: Option.none() }); expect(out.stdoutText).toContain("status = "); }).pipe(Effect.provide(layer)); }); @@ -199,7 +200,7 @@ describe("network-restrictions get integration", () => { it.effect("emits env output when --output=env", () => { const { layer, out } = setup({ outputFlag: "env", response: APPLIED_NO_CIDRS }); return Effect.gen(function* () { - yield* networkRestrictionsGet({ projectRef: Option.none() }); + yield* networkRestrictionsGet({ remote: Option.none(), projectRef: Option.none() }); expect(out.stdoutText).toContain('STATUS="applied"'); }).pipe(Effect.provide(layer)); }); @@ -207,7 +208,7 @@ describe("network-restrictions get integration", () => { it.effect("treats --output pretty identically to text mode", () => { const { layer, out } = setup({ outputFlag: "pretty", response: APPLIED_NO_CIDRS }); return Effect.gen(function* () { - yield* networkRestrictionsGet({ projectRef: Option.none() }); + yield* networkRestrictionsGet({ remote: Option.none(), projectRef: Option.none() }); expect(out.stdoutText).toBe( "DB Allowed IPv4 CIDRs: &[]\n" + "DB Allowed IPv6 CIDRs: &[]\n" + @@ -223,7 +224,7 @@ describe("network-restrictions get integration", () => { response: APPLIED_WITH_CIDRS, }); return Effect.gen(function* () { - yield* networkRestrictionsGet({ projectRef: Option.none() }); + yield* networkRestrictionsGet({ remote: Option.none(), projectRef: Option.none() }); expect(out.stdoutText.startsWith("{")).toBe(false); expect(out.stdoutText).toContain("status: applied"); }).pipe(Effect.provide(layer)); @@ -232,7 +233,7 @@ describe("network-restrictions get integration", () => { it.effect("hits the GET /v1/projects/{ref}/network-restrictions URL", () => { const { layer, api } = setup({ response: APPLIED_WITH_CIDRS }); return Effect.gen(function* () { - yield* networkRestrictionsGet({ projectRef: Option.none() }); + yield* networkRestrictionsGet({ remote: Option.none(), projectRef: Option.none() }); expect(api.requests).toHaveLength(1); expect(api.requests[0]?.method).toBe("GET"); expect(api.requests[0]?.url).toContain(`/v1/projects/${VALID_REF}/network-restrictions`); @@ -272,7 +273,7 @@ describe("network-restrictions get integration", () => { const fs = yield* FileSystem.FileSystem; const cachePath = `${tempRoot.current}/supabase/.temp/linked-project.json`; expect(yield* fs.exists(cachePath)).toBe(false); - yield* networkRestrictionsGet({ projectRef: Option.none() }); + yield* networkRestrictionsGet({ remote: Option.none(), projectRef: Option.none() }); expect(api.requests.map(({ method, url }) => `${method} ${url}`)).toEqual([ "GET https://api.supabase.com/v1/projects", `GET https://api.supabase.com/v1/projects/${VALID_REF}/network-restrictions`, @@ -294,11 +295,14 @@ describe("network-restrictions get integration", () => { }).pipe(Effect.provide(layer)); }); - it.effect("uses --project-ref flag value over CommandSettings.projectId", () => { + it.effect("uses the --remote=<ref> value over CommandSettings.projectId", () => { const flagRef = "zzzzzzzzzzzzzzzzzzzz"; const { layer, api } = setup({ response: APPLIED_WITH_CIDRS }); return Effect.gen(function* () { - yield* networkRestrictionsGet({ projectRef: Option.some(flagRef) }); + yield* networkRestrictionsGet({ + remote: Option.some({ _tag: "Ref", ref: flagRef }), + projectRef: Option.none(), + }); expect(api.requests[0]?.url).toContain(`/v1/projects/${flagRef}/`); }).pipe(Effect.provide(layer)); }); @@ -306,7 +310,9 @@ describe("network-restrictions get integration", () => { it.effect("reports an error message when the API returns a non-200 status", () => { const { layer } = setup({ status: 503, response: APPLIED_WITH_CIDRS }); return Effect.gen(function* () { - const exit = yield* Effect.exit(networkRestrictionsGet({ projectRef: Option.none() })); + const exit = yield* Effect.exit( + networkRestrictionsGet({ remote: Option.none(), projectRef: Option.none() }), + ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { const causeText = Cause.pretty(exit.cause); @@ -319,7 +325,9 @@ describe("network-restrictions get integration", () => { it.effect("reports an error message when the network is unreachable", () => { const { layer } = setup({ network: "fail" }); return Effect.gen(function* () { - const exit = yield* Effect.exit(networkRestrictionsGet({ projectRef: Option.none() })); + const exit = yield* Effect.exit( + networkRestrictionsGet({ remote: Option.none(), projectRef: Option.none() }), + ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { const causeText = Cause.pretty(exit.cause); @@ -332,7 +340,9 @@ describe("network-restrictions get integration", () => { it.effect("emits a fail event when withJsonErrorHandling wraps a JSON-mode error", () => { const { layer, out } = setup({ format: "json", status: 503, response: APPLIED_WITH_CIDRS }); return Effect.gen(function* () { - yield* networkRestrictionsGet({ projectRef: Option.none() }).pipe(withJsonErrorHandling); + yield* networkRestrictionsGet({ remote: Option.none(), projectRef: Option.none() }).pipe( + withJsonErrorHandling, + ); expect(out.messages.some((m) => m.type === "fail")).toBe(true); }).pipe(Effect.provide(layer)); }); @@ -340,7 +350,7 @@ describe("network-restrictions get integration", () => { it.effect("flushes telemetry and writes linked-project cache on success", () => { const { layer, telemetry, cache } = setupTracked({ response: APPLIED_WITH_CIDRS }); return Effect.gen(function* () { - yield* networkRestrictionsGet({ projectRef: Option.none() }); + yield* networkRestrictionsGet({ remote: Option.none(), projectRef: Option.none() }); expect(telemetry.flushed).toBe(true); expect(cache.cached).toBe(true); }).pipe(Effect.provide(layer)); @@ -353,7 +363,9 @@ describe("network-restrictions get integration", () => { }); return Effect.gen(function* () { const exit = yield* Effect.exit( - networkRestrictionsGet({ projectRef: Option.none() }).pipe(Effect.provide(layer)), + networkRestrictionsGet({ remote: Option.none(), projectRef: Option.none() }).pipe( + Effect.provide(layer), + ), ); expect(Exit.isFailure(exit)).toBe(true); expect(telemetry.flushed).toBe(true); @@ -362,4 +374,11 @@ describe("network-restrictions get integration", () => { expect(cache.cached).toBe(true); }); }); + + describeHostedRemoteSelection({ + command: ["network-restrictions", "get"], + workdir: () => tempRoot.current, + respond: (request) => jsonResponse(request, 200, APPLIED_WITH_CIDRS), + run: (selector, layer) => networkRestrictionsGet(selector).pipe(Effect.provide(layer)), + }); }); 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 ad0dffb5a7..fc64f9e63e 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 @@ -7,6 +7,7 @@ import { describe, expect, it } from "@effect/vitest"; import { Cause, Effect, Exit, Layer, Option, FileSystem, Schema } from "effect"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; import { describeHostedConsent } from "../../../../tests/helpers/hosted-consent.ts"; +import { describeHostedRemoteSelection } from "../../../../tests/helpers/hosted-remote-selection.ts"; import { mockOutput, mockTty } from "../../../../tests/helpers/mocks.ts"; import { VALID_REF, @@ -140,6 +141,7 @@ function setupTracked(opts: SetupOpts = {}) { } const baseFlags: NetworkRestrictionsUpdateFlags = { + remote: Option.none(), projectRef: Option.none(), dbAllowCidr: [], bypassCidrChecks: false, @@ -739,31 +741,36 @@ describe("network-restrictions update integration", () => { // Project ref resolution - it.effect("uses --project-ref flag value over CommandSettings.projectId", () => { + it.effect("uses the --remote=<ref> value over CommandSettings.projectId", () => { const flagRef = "zzzzzzzzzzzzzzzzzzzz"; const { layer, api } = setup({ postResponse: POST_EMPTY_APPLIED }); return Effect.gen(function* () { yield* networkRestrictionsUpdate({ ...baseFlags, - projectRef: Option.some(flagRef), + remote: Option.some({ _tag: "Ref", ref: flagRef }), }); expect(api.requests[0]?.url).toContain(`/v1/projects/${flagRef}/`); }).pipe(Effect.provide(layer)); }); it.effect("rejects a malformed resolved project ref before issuing any HTTP call", () => { - const { layer } = setup({ postResponse: POST_EMPTY_APPLIED }); - return Effect.gen(function* () { - const exit = yield* Effect.exit( - networkRestrictionsUpdate({ - ...baseFlags, - projectRef: Option.some("BADREF"), + const out = mockOutput({ format: "text" }); + const api = mockCommandPlatformApi(apiOpts({ postResponse: POST_EMPTY_APPLIED })); + const layer = Layer.mergeAll( + buildTestRuntime({ + out, + api, + cliSettings: mockCommandSettings({ + workdir: tempRoot.current, + projectId: Option.some("BADREF"), }), - ); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - expect(Cause.pretty(exit.cause)).toContain("InvalidProjectRefError"); - } + }), + consentApproved, + ); + return Effect.gen(function* () { + const error = yield* networkRestrictionsUpdate(baseFlags).pipe(Effect.flip); + expect(error).toMatchObject({ _tag: "InvalidProjectRefError" }); + expect(api.requests).toEqual([]); }).pipe(Effect.provide(layer)); }); @@ -819,12 +826,24 @@ describe("network-restrictions update integration", () => { }); }); +describeHostedRemoteSelection({ + command: ["network-restrictions", "update"], + args: ["--db-allow-cidr", "12.3.4.5/32"], + workdir: () => tempRoot.current, + respond: (request) => jsonResponse(request, 201, POST_APPLIED), + activeFlags: { append: false }, + run: (selector, layer) => + networkRestrictionsUpdate({ ...baseFlags, ...selector, dbAllowCidr: ["12.3.4.5/32"] }).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), + remote: Option.some({ _tag: "Ref", ref: VALID_REF }), dbAllowCidr: ["12.3.4.5/32"], }), }), diff --git a/apps/cli/src/commands/notebooks/notebooks.integration.test.ts b/apps/cli/src/commands/notebooks/notebooks.integration.test.ts index daf3a31300..e441a01a08 100644 --- a/apps/cli/src/commands/notebooks/notebooks.integration.test.ts +++ b/apps/cli/src/commands/notebooks/notebooks.integration.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "@effect/vitest"; -import { Deferred, Effect, Exit, Fiber, FileSystem, Option, Path, Schema } from "effect"; +import { Deferred, Effect, Exit, Fiber, FileSystem, Layer, Option, Path, Schema } from "effect"; import { notebookListPage, notebookResource, @@ -10,6 +10,9 @@ import { type NotebooksSetupOptions, } from "../../../tests/helpers/notebooks.ts"; import { useTempWorkdir } from "../../../tests/helpers/command-mocks.ts"; +import { assertPermissionDrift } from "../../../tests/helpers/permission-drift.ts"; +import type { RemoteValue } from "../../command-internal/target/remote-value.ts"; +import { CliArgs } from "../../shared/cli/cli-args.service.ts"; import { Output } from "../../shared/output/output.service.ts"; import { NonInteractiveError } from "../../shared/output/errors.ts"; import { EventCommandExecuted, PropExitCode } from "../../shared/telemetry/event-catalog.ts"; @@ -29,27 +32,42 @@ const LOCAL = '{"content":{"cells":[{"type":"markdown","text":"local edits"}]}}' const commands = ["pull", "push"] as const; type Command = (typeof commands)[number]; -const run = Effect.fnUntraced(function* ( - command: Command, - name?: string, - projectRef: Option.Option<string> = Option.some(NOTEBOOKS_PROJECT_REF), -) { +interface Selector { + readonly remote: Option.Option<RemoteValue>; + readonly projectRef: Option.Option<string>; +} + +const runWith = Effect.fnUntraced(function* (command: Command, selector: Selector, name?: string) { if (command === "pull") return yield* notebooksPullHandler({ - projectRef, + ...selector, notebookId: Option.fromUndefinedOr(name), }); return yield* notebooksPushHandler({ - projectRef, + ...selector, notebookName: Option.fromUndefinedOr(name), }); }); +const run = ( + command: Command, + name?: string, + ref: Option.Option<string> = Option.some(NOTEBOOKS_PROJECT_REF), +) => + runWith( + command, + { + remote: Option.map(ref, (value) => ({ _tag: "Ref", ref: value }) as const), + projectRef: Option.none(), + }, + name, + ); + function setup(command: Command, options: Omit<NotebooksSetupOptions, "workdir" | "command"> = {}) { return setupNotebooks({ workdir: temp.current, command, - args: ["notebooks", command, "--project-ref", NOTEBOOKS_PROJECT_REF], + args: ["notebooks", command, `--remote=${NOTEBOOKS_PROJECT_REF}`], routes: { [`GET ${notebooksRoute()}`]: { status: 200, body: notebookListPage({ notebooks: [] }) }, }, @@ -532,7 +550,7 @@ describe.each(commands)("notebooks %s command wiring", (command) => { event: EventCommandExecuted, properties: expect.objectContaining({ [PropExitCode]: 1, - flags: { "project-ref": NOTEBOOKS_PROJECT_REF }, + flags: { remote: NOTEBOOKS_PROJECT_REF }, command: `notebooks ${command}`, }), }); @@ -614,3 +632,78 @@ describe.each(commands)("notebooks %s command wiring", (command) => { }, ); }); + +describe.each(commands)("notebooks %s project selection", (command) => { + const BRANCH_REF = "branchprojectrefabcd"; + const DB_URL = "postgresql://postgres:pw@db.example.com:5432/postgres"; + const none = { remote: Option.none(), projectRef: Option.none() } satisfies Selector; + const branchesRoute = `GET /v1/projects/${NOTEBOOKS_PROJECT_REF}/branches`; + const branchRow = { + id: "11111111-2222-4333-8444-555555555555", + name: "feature-x", + project_ref: BRANCH_REF, + parent_project_ref: NOTEBOOKS_PROJECT_REF, + is_default: false, + persistent: false, + status: "MIGRATIONS_PASSED", + created_at: "2026-05-27T01:02:03Z", + updated_at: "2026-05-27T01:02:04Z", + with_data: true, + }; + + it.effect("acts on the project of the branch --remote=<branch> names", () => { + const { layer, http } = setup(command, { + args: ["notebooks", command, "--remote=feature-x"], + routes: { + [branchesRoute]: { status: 200, body: [branchRow] }, + [`GET /v2/projects/${BRANCH_REF}/notebooks`]: { + status: 200, + body: notebookListPage({ notebooks: [] }), + }, + }, + }); + return Effect.gen(function* () { + yield* runWith(command, { + ...none, + remote: Option.some({ _tag: "Branch", name: "feature-x" }), + }); + expect(http.routeKeys).toEqual([branchesRoute, `GET /v2/projects/${BRANCH_REF}/notebooks`]); + assertPermissionDrift({ + command: `notebooks ${command}`, + activeFlags: { remote: "feature-x" }, + requests: http.driftRequests, + }); + }).pipe(Effect.provide(layer)); + }); + + it.effect("rejects --remote=<url> before any request", () => { + const { layer, http } = setup(command, { + args: ["notebooks", command, `--remote=${DB_URL}`], + }); + return Effect.gen(function* () { + const error = yield* runWith(command, { + ...none, + remote: Option.some({ _tag: "DbUrl", url: DB_URL }), + }).pipe(Effect.flip); + expect(error).toMatchObject({ + _tag: "TargetNeedsProjectError", + message: `\`supabase notebooks ${command}\` needs a project, not a database URL.`, + }); + expect(http.requests).toEqual([]); + }).pipe(Effect.provide(layer)); + }); + + it.effect("keeps --project-ref working and points at --remote=<ref>", () => { + const argv = ["notebooks", command, "--project-ref", NOTEBOOKS_PROJECT_REF]; + const { layer, out, http } = setup(command, { args: argv }); + return Effect.gen(function* () { + yield* runWith(command, { ...none, projectRef: Option.some(NOTEBOOKS_PROJECT_REF) }); + expect(http.routeKeys).toEqual([`GET ${notebooksRoute()}`]); + expect( + out.messages.filter((message) => message.type === "warn").map((message) => message.message), + ).toEqual([ + `--project-ref is deprecated and will be removed in v4. Use --remote instead: supabase notebooks ${command} --remote=${NOTEBOOKS_PROJECT_REF}`, + ]); + }).pipe(Effect.provide(Layer.mergeAll(layer, Layer.succeed(CliArgs, { args: argv })))); + }); +}); diff --git a/apps/cli/src/commands/notebooks/pull/pull.integration.test.ts b/apps/cli/src/commands/notebooks/pull/pull.integration.test.ts index b58f468424..63d972f143 100644 --- a/apps/cli/src/commands/notebooks/pull/pull.integration.test.ts +++ b/apps/cli/src/commands/notebooks/pull/pull.integration.test.ts @@ -23,7 +23,12 @@ const SALES_ID = "44444444-4444-4444-8444-444444444444"; const ERRORS_ID = "55555555-5555-4555-8555-555555555555"; function flags(overrides: Partial<NotebooksPullFlags> = {}): NotebooksPullFlags { - return { notebookId: Option.none(), projectRef: Option.none(), ...overrides }; + return { + notebookId: Option.none(), + remote: Option.none(), + projectRef: Option.none(), + ...overrides, + }; } const temp = useTempWorkdir("supabase-notebooks-pull-"); 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 ee06352fce..543f8de6f9 100644 --- a/apps/cli/src/commands/notebooks/push/push.integration.test.ts +++ b/apps/cli/src/commands/notebooks/push/push.integration.test.ts @@ -36,7 +36,12 @@ const SALES_FILE = JSON.stringify({ }); function flags(overrides: Partial<NotebooksPushFlags> = {}): NotebooksPushFlags { - return { notebookName: Option.none(), projectRef: Option.none(), ...overrides }; + return { + notebookName: Option.none(), + remote: Option.none(), + projectRef: Option.none(), + ...overrides, + }; } const temp = useTempWorkdir("supabase-notebooks-push-"); @@ -487,7 +492,8 @@ describeHostedConsent("notebooks push", { return { run: notebooksPushCore({ notebookName: Option.none(), - projectRef: Option.some(VALID_REF), + remote: Option.some({ _tag: "Ref", ref: VALID_REF }), + projectRef: Option.none(), }).pipe(Effect.provide(http.layer)), mutations: () => http.requests.filter((request) => request.method !== "GET").length, }; 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 ac5526f6a7..4a90db6a0d 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, Layer, Option, FileSystem, Schema } from "effect"; import { describeHostedConsent } from "../../../tests/helpers/hosted-consent.ts"; +import { describeHostedRemoteSelection } from "../../../tests/helpers/hosted-remote-selection.ts"; import { mockOutput, mockTty } from "../../../tests/helpers/mocks.ts"; import { makeApiClient } from "@supabase/api/effect"; import * as HttpClientResponse from "effect/http/HttpClientResponse"; @@ -149,7 +150,7 @@ describe("postgres-config get", () => { const layer = runtimeWith({ out, api }); return Effect.gen(function* () { - yield* postgresConfigGet({ projectRef: Option.none() }); + yield* postgresConfigGet({ remote: Option.none(), projectRef: Option.none() }); expect(out.progressEvents[0]?.message).toBe("Fetching Postgres config..."); expect(out.stderrText).toBe( unverifiedTargetBanner() + @@ -175,7 +176,7 @@ describe("postgres-config get", () => { }); return Effect.gen(function* () { - yield* postgresConfigGet({ projectRef: Option.none() }); + yield* postgresConfigGet({ remote: Option.none(), projectRef: Option.none() }); expect(requestLines(api)).toEqual([ `GET ${PROJECTS_URL}`, `GET ${CONFIG_URL}`, @@ -203,7 +204,7 @@ describe("postgres-config get", () => { const layer = runtimeWith({ out, api }); return Effect.gen(function* () { - yield* postgresConfigGet({ projectRef: Option.none() }); + yield* postgresConfigGet({ remote: Option.none(), projectRef: Option.none() }); expect(out.stdoutText).toContain("1e+06"); expect(out.stdoutText).not.toContain("1000000"); }).pipe(Effect.provide(layer)); @@ -218,7 +219,7 @@ describe("postgres-config get", () => { const layer = runtimeWith({ out, api, outputFlag: "toml" }); return Effect.gen(function* () { - yield* postgresConfigGet({ projectRef: Option.none() }); + yield* postgresConfigGet({ remote: Option.none(), projectRef: Option.none() }); expect(out.stdoutText).toBe("max_connections = 100.0\n"); expect(out.stderrText).toBe(unverifiedTargetBanner()); }).pipe(Effect.provide(layer)); @@ -236,7 +237,7 @@ describe("postgres-config get", () => { const prettyApi = mockCommandPlatformApi({ response: { status: 200, body: responseBody }, }); - yield* postgresConfigGet({ projectRef: Option.none() }).pipe( + yield* postgresConfigGet({ remote: Option.none(), projectRef: Option.none() }).pipe( Effect.provide(runtimeWith({ out: prettyOut, api: prettyApi })), ); @@ -251,7 +252,7 @@ describe("postgres-config get", () => { const tomlApi = mockCommandPlatformApi({ response: { status: 200, body: responseBody }, }); - yield* postgresConfigGet({ projectRef: Option.none() }).pipe( + yield* postgresConfigGet({ remote: Option.none(), projectRef: Option.none() }).pipe( Effect.provide(runtimeWith({ out: tomlOut, api: tomlApi, outputFlag: "toml" })), ); @@ -269,7 +270,7 @@ describe("postgres-config get", () => { const layer = runtimeWith({ out, api, outputFlag: "env" }); return Effect.gen(function* () { - yield* postgresConfigGet({ projectRef: Option.none() }); + yield* postgresConfigGet({ remote: Option.none(), projectRef: Option.none() }); expect(out.stdoutText).toBe('TRACK_COMMIT_TIMESTAMP="true"\n'); }).pipe(Effect.provide(layer)); }); @@ -285,7 +286,9 @@ describe("postgres-config get", () => { response: { status: 200, body: { max_connections: 100 } }, }); const layer = runtimeWith({ out, api, outputFlag }); - yield* postgresConfigGet({ projectRef: Option.none() }).pipe(Effect.provide(layer)); + yield* postgresConfigGet({ remote: Option.none(), projectRef: Option.none() }).pipe( + Effect.provide(layer), + ); expect(out.stdoutText).toContain(expected); expect(out.stderrText).toBe(unverifiedTargetBanner()); } @@ -300,7 +303,7 @@ describe("postgres-config get", () => { const layer = runtimeWith({ out, api }); return Effect.gen(function* () { - yield* postgresConfigGet({ projectRef: Option.none() }); + yield* postgresConfigGet({ remote: Option.none(), projectRef: Option.none() }); const success = out.messages.find((message) => message.type === "success"); expect(success?.data).toEqual({ max_connections: 100 }); }).pipe(Effect.provide(layer)); @@ -314,7 +317,7 @@ describe("postgres-config get", () => { const layer = runtimeWith({ out, api, outputFlag: "toml" }); return Effect.gen(function* () { - yield* postgresConfigGet({ projectRef: Option.none() }); + yield* postgresConfigGet({ remote: Option.none(), projectRef: Option.none() }); expect(out.stdoutText).toBe("max_connections = 100.0\n"); expect(out.messages.some((message) => message.type === "success")).toBe(false); }).pipe(Effect.provide(layer)); @@ -328,7 +331,7 @@ describe("postgres-config get", () => { const layer = runtimeWith({ out, api, outputFlag: "pretty" }); return Effect.gen(function* () { - yield* postgresConfigGet({ projectRef: Option.none() }); + yield* postgresConfigGet({ remote: Option.none(), projectRef: Option.none() }); expect(out.stderrText).toBe( unverifiedTargetBanner() + "- Custom Postgres Config -\n- End of Custom Postgres Config -\n", @@ -346,7 +349,7 @@ describe("postgres-config get", () => { const layer = runtimeWith({ out, api }); return Effect.gen(function* () { - yield* postgresConfigGet({ projectRef: Option.none() }); + yield* postgresConfigGet({ remote: Option.none(), projectRef: Option.none() }); const success = out.messages.find((message) => message.type === "success"); expect(success?.data).toEqual({ max_connections: 100, stored_only_key: "x" }); }).pipe(Effect.provide(layer)); @@ -358,7 +361,9 @@ describe("postgres-config get", () => { const layer = runtimeWith({ out, api }); return Effect.gen(function* () { - const exit = yield* Effect.exit(postgresConfigGet({ projectRef: Option.none() })); + const exit = yield* Effect.exit( + postgresConfigGet({ remote: Option.none(), projectRef: Option.none() }), + ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { const causeText = Cause.pretty(exit.cause); @@ -374,7 +379,9 @@ describe("postgres-config get", () => { const layer = runtimeWith({ out, api }); return Effect.gen(function* () { - const exit = yield* Effect.exit(postgresConfigGet({ projectRef: Option.none() })); + const exit = yield* Effect.exit( + postgresConfigGet({ remote: Option.none(), projectRef: Option.none() }), + ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { const causeText = Cause.pretty(exit.cause); @@ -408,6 +415,7 @@ describe("postgres-config update", () => { return Effect.gen(function* () { yield* postgresConfigUpdate({ + remote: Option.none(), projectRef: Option.none(), config: [ "max_connections=100", @@ -451,6 +459,7 @@ describe("postgres-config update", () => { return Effect.gen(function* () { yield* postgresConfigUpdate({ + remote: Option.none(), projectRef: Option.none(), config: ["max_connections=100"], replaceExistingOverrides: false, @@ -485,6 +494,7 @@ describe("postgres-config update", () => { return Effect.gen(function* () { yield* postgresConfigUpdate({ + remote: Option.none(), projectRef: Option.none(), config: ["max_connections=100"], replaceExistingOverrides: true, @@ -540,6 +550,7 @@ describe("postgres-config update", () => { }); const layer = runtimeWith({ out, api, outputFlag: testCase.outputFlag }); yield* postgresConfigUpdate({ + remote: Option.none(), projectRef: Option.none(), config: ["max_connections=100"], replaceExistingOverrides: true, @@ -562,6 +573,7 @@ describe("postgres-config update", () => { return Effect.gen(function* () { const exit = yield* Effect.exit( postgresConfigUpdate({ + remote: Option.none(), projectRef: Option.none(), config: ["max_conections=100", "bogus=1", "max_connections=100"], replaceExistingOverrides: false, @@ -601,6 +613,7 @@ describe("postgres-config update", () => { return Effect.gen(function* () { yield* postgresConfigUpdate({ + remote: Option.none(), projectRef: Option.none(), config: ["max_connections=100"], replaceExistingOverrides: false, @@ -629,6 +642,7 @@ describe("postgres-config update", () => { return Effect.gen(function* () { yield* postgresConfigUpdate({ + remote: Option.none(), projectRef: Option.none(), config: ["max_connections=100"], replaceExistingOverrides: false, @@ -646,6 +660,7 @@ describe("postgres-config update", () => { return Effect.gen(function* () { const exit = yield* Effect.exit( postgresConfigUpdate({ + remote: Option.none(), projectRef: Option.none(), config: ["max_connections=lots"], replaceExistingOverrides: true, @@ -667,6 +682,7 @@ describe("postgres-config update", () => { return Effect.gen(function* () { const exit = yield* Effect.exit( postgresConfigUpdate({ + remote: Option.none(), projectRef: Option.none(), config: ["work_mem=4096"], replaceExistingOverrides: true, @@ -690,6 +706,7 @@ describe("postgres-config update", () => { return Effect.gen(function* () { yield* postgresConfigUpdate({ + remote: Option.none(), projectRef: Option.none(), config: ["work_mem=4096kB"], replaceExistingOverrides: true, @@ -710,6 +727,7 @@ describe("postgres-config update", () => { return Effect.gen(function* () { yield* postgresConfigUpdate({ + remote: Option.none(), projectRef: Option.none(), config: ["max_connections=100"], replaceExistingOverrides: true, @@ -730,6 +748,7 @@ describe("postgres-config update", () => { return Effect.gen(function* () { const exit = yield* Effect.exit( postgresConfigUpdate({ + remote: Option.none(), projectRef: Option.none(), config: ["max_connections=100"], replaceExistingOverrides: true, @@ -757,6 +776,7 @@ describe("postgres-config update", () => { return Effect.gen(function* () { const exit = yield* Effect.exit( postgresConfigUpdate({ + remote: Option.none(), projectRef: Option.none(), config: ["broken=value=again"], replaceExistingOverrides: false, @@ -790,6 +810,7 @@ describe("postgres-config update", () => { return Effect.gen(function* () { const exit = yield* Effect.exit( postgresConfigUpdate({ + remote: Option.none(), projectRef: Option.none(), config: ["max_connections=100"], replaceExistingOverrides: false, @@ -814,6 +835,7 @@ describe("postgres-config update", () => { return Effect.gen(function* () { const exit = yield* Effect.exit( postgresConfigUpdate({ + remote: Option.none(), projectRef: Option.none(), config: [], replaceExistingOverrides: false, @@ -845,6 +867,7 @@ describe("postgres-config update", () => { return Effect.gen(function* () { const exit = yield* Effect.exit( postgresConfigUpdate({ + remote: Option.none(), projectRef: Option.none(), config: [], replaceExistingOverrides: false, @@ -874,6 +897,7 @@ describe("postgres-config delete", () => { return Effect.gen(function* () { yield* postgresConfigDelete({ + remote: Option.none(), projectRef: Option.none(), config: [" max_connections "], noRestart: true, @@ -911,6 +935,7 @@ describe("postgres-config delete", () => { return Effect.gen(function* () { yield* postgresConfigDelete({ + remote: Option.none(), projectRef: Option.none(), config: ["max_connections"], noRestart: false, @@ -944,6 +969,7 @@ describe("postgres-config delete", () => { return Effect.gen(function* () { yield* postgresConfigDelete({ + remote: Option.none(), projectRef: Option.none(), config: ["stored_only_key"], noRestart: false, @@ -964,6 +990,7 @@ describe("postgres-config delete", () => { return Effect.gen(function* () { yield* postgresConfigDelete({ + remote: Option.none(), projectRef: Option.none(), config: ["max_connections"], noRestart: true, @@ -985,6 +1012,7 @@ describe("postgres-config delete", () => { return Effect.gen(function* () { yield* postgresConfigDelete({ + remote: Option.none(), projectRef: Option.none(), config: ["max_connections"], noRestart: false, @@ -1025,6 +1053,7 @@ describe("postgres-config delete", () => { }); const layer = runtimeWith({ out, api, outputFlag: testCase.outputFlag }); yield* postgresConfigDelete({ + remote: Option.none(), projectRef: Option.none(), config: ["max_connections"], noRestart: false, @@ -1057,6 +1086,7 @@ describe("postgres-config delete", () => { return Effect.gen(function* () { const exit = yield* Effect.exit( postgresConfigDelete({ + remote: Option.none(), projectRef: Option.none(), config: ["max_connections"], noRestart: false, @@ -1088,6 +1118,7 @@ describe("postgres-config delete", () => { return Effect.gen(function* () { const exit = yield* Effect.exit( postgresConfigDelete({ + remote: Option.none(), projectRef: Option.none(), config: ["max_connections"], noRestart: false, @@ -1109,6 +1140,7 @@ describe("postgres-config delete", () => { return Effect.gen(function* () { const exit = yield* Effect.exit( postgresConfigDelete({ + remote: Option.none(), projectRef: Option.none(), config: ["max_connections"], noRestart: false, @@ -1140,6 +1172,7 @@ describe("postgres-config delete", () => { return Effect.gen(function* () { const exit = yield* Effect.exit( postgresConfigDelete({ + remote: Option.none(), projectRef: Option.none(), config: ["max_connections"], noRestart: false, @@ -1155,6 +1188,44 @@ describe("postgres-config delete", () => { }); }); +describeHostedRemoteSelection({ + command: ["postgres-config", "get"], + workdir: () => tempRoot.current, + respond: (request) => jsonResponse(request, 200, { max_connections: 100 }), + run: (selector, layer) => postgresConfigGet(selector).pipe(Effect.provide(layer)), +}); + +describeHostedRemoteSelection({ + command: ["postgres-config", "update"], + args: ["--config", "max_connections=100"], + workdir: () => tempRoot.current, + activeFlags: { "replace-existing-overrides": false }, + respond: (request) => + jsonResponse(request, 200, request.method === "PUT" ? { max_connections: 100 } : {}), + run: (selector, layer) => + postgresConfigUpdate({ + ...selector, + config: ["max_connections=100"], + replaceExistingOverrides: false, + noRestart: false, + }).pipe(Effect.provide(layer)), +}); + +describeHostedRemoteSelection({ + command: ["postgres-config", "delete"], + args: ["--config", "max_connections"], + workdir: () => tempRoot.current, + activeFlags: { config: true, "no-restart": false }, + respond: (request) => + jsonResponse(request, 200, request.method === "PUT" ? {} : { max_connections: 100 }), + run: (selector, layer) => + postgresConfigDelete({ + ...selector, + config: ["max_connections"], + noRestart: false, + }).pipe(Effect.provide(layer)), +}); + describeHostedConsent("postgres-config update", { api: { byMethod: { @@ -1164,7 +1235,8 @@ describeHostedConsent("postgres-config update", { }, invoke: () => ({ run: postgresConfigUpdate({ - projectRef: Option.some(VALID_REF), + remote: Option.some({ _tag: "Ref", ref: VALID_REF }), + projectRef: Option.none(), config: ["max_connections=100"], replaceExistingOverrides: false, noRestart: false, @@ -1181,7 +1253,8 @@ describeHostedConsent("postgres-config delete", { }, invoke: () => ({ run: postgresConfigDelete({ - projectRef: Option.some(VALID_REF), + remote: Option.some({ _tag: "Ref", ref: VALID_REF }), + projectRef: Option.none(), config: ["max_connections"], noRestart: false, }), diff --git a/apps/cli/src/commands/projects/api-keys/api-keys.integration.test.ts b/apps/cli/src/commands/projects/api-keys/api-keys.integration.test.ts index 026283e993..3b93fba047 100644 --- a/apps/cli/src/commands/projects/api-keys/api-keys.integration.test.ts +++ b/apps/cli/src/commands/projects/api-keys/api-keys.integration.test.ts @@ -11,6 +11,7 @@ import { mockCommandPlatformApi, useTempWorkdir, } from "../../../../tests/helpers/command-mocks.ts"; +import { describeHostedRemoteSelection } from "../../../../tests/helpers/hosted-remote-selection.ts"; import { assertPermissionDrift } from "../../../../tests/helpers/permission-drift.ts"; import { realLinkedProjectCache } from "../../../../tests/helpers/permission-real-layers.ts"; import { projectsApiKeys } from "./api-keys.handler.ts"; @@ -83,7 +84,7 @@ describe("projects api-keys integration", () => { it.effect("lists api keys as a NAME / KEY VALUE table and masks null values", () => { const { layer, out } = setup(); return Effect.gen(function* () { - yield* projectsApiKeys({ projectRef: Option.none(), reveal: false }); + yield* projectsApiKeys({ remote: Option.none(), projectRef: Option.none(), reveal: false }); expect(out.stdoutText).toContain("NAME"); expect(out.stdoutText).toContain("KEY VALUE"); expect(out.stdoutText).toContain("anon-secret"); @@ -91,18 +92,22 @@ describe("projects api-keys integration", () => { }).pipe(Effect.provide(layer)); }); - it.effect("resolves the ref from --project-ref", () => { + it.effect("resolves the ref from --remote=<ref>", () => { const { layer, api } = setup(); return Effect.gen(function* () { - yield* projectsApiKeys({ projectRef: Option.some(FLAG_REF), reveal: false }); + yield* projectsApiKeys({ + remote: Option.some({ _tag: "Ref", ref: FLAG_REF }), + projectRef: Option.none(), + reveal: false, + }); expect(api.requests[0]?.url).toContain(`/v1/projects/${FLAG_REF}/api-keys`); }).pipe(Effect.provide(layer)); }); - it.effect("resolves the ref from the linked project when --project-ref is omitted", () => { + it.effect("resolves the ref from the linked project when --remote is omitted", () => { const { layer, api } = setup(); return Effect.gen(function* () { - yield* projectsApiKeys({ projectRef: Option.none(), reveal: false }); + yield* projectsApiKeys({ remote: Option.none(), projectRef: Option.none(), reveal: false }); expect(api.requests[0]?.url).toContain(`/v1/projects/${VALID_REF}/api-keys`); }).pipe(Effect.provide(layer)); }); @@ -110,7 +115,7 @@ describe("projects api-keys integration", () => { it.effect("omits the reveal query param by default", () => { const { layer, api } = setup(); return Effect.gen(function* () { - yield* projectsApiKeys({ projectRef: Option.none(), reveal: false }); + yield* projectsApiKeys({ remote: Option.none(), projectRef: Option.none(), reveal: false }); expect(api.requests[0]?.urlWithParams).not.toContain("reveal"); assertPermissionDrift({ command: "projects api-keys", @@ -124,7 +129,7 @@ describe("projects api-keys integration", () => { it.effect("sends reveal=true when --reveal is passed", () => { const { layer, api } = setup({ response: REVEALED_KEYS }); return Effect.gen(function* () { - yield* projectsApiKeys({ projectRef: Option.none(), reveal: true }); + yield* projectsApiKeys({ remote: Option.none(), projectRef: Option.none(), reveal: true }); expect(api.requests[0]?.urlWithParams).toContain("reveal=true"); assertPermissionDrift({ command: "projects api-keys", @@ -138,7 +143,7 @@ describe("projects api-keys integration", () => { it.effect("renders the revealed secret key in full in the text table", () => { const { layer, out } = setup({ response: REVEALED_KEYS }); return Effect.gen(function* () { - yield* projectsApiKeys({ projectRef: Option.none(), reveal: true }); + yield* projectsApiKeys({ remote: Option.none(), projectRef: Option.none(), reveal: true }); expect(out.stdoutText).toContain("sb_secret_revealed"); expect(out.stdoutText).not.toContain("******"); }).pipe(Effect.provide(layer)); @@ -147,7 +152,7 @@ describe("projects api-keys integration", () => { it.effect("includes the revealed secret in the env map for --output env --reveal", () => { const { layer, out } = setup({ outputFlag: "env", response: REVEALED_KEYS }); return Effect.gen(function* () { - yield* projectsApiKeys({ projectRef: Option.none(), reveal: true }); + yield* projectsApiKeys({ remote: Option.none(), projectRef: Option.none(), reveal: true }); expect(out.stdoutText).toContain('SUPABASE_SERVICE_ROLE_KEY="sb_secret_revealed"'); }).pipe(Effect.provide(layer)); }); @@ -155,7 +160,7 @@ describe("projects api-keys integration", () => { it.effect("carries the revealed secret in the { keys } payload for --output-format json", () => { const { layer, out } = setup({ format: "json", response: REVEALED_KEYS }); return Effect.gen(function* () { - yield* projectsApiKeys({ projectRef: Option.none(), reveal: true }); + yield* projectsApiKeys({ remote: Option.none(), projectRef: Option.none(), reveal: true }); const success = out.messages.find((m) => m.type === "success"); expect(success?.data).toMatchObject({ keys: REVEALED_KEYS }); }).pipe(Effect.provide(layer)); @@ -164,7 +169,7 @@ describe("projects api-keys integration", () => { it.effect("emits the revealed secret in the JSON array for --output json --reveal", () => { const { layer, out } = setup({ outputFlag: "json", response: REVEALED_KEYS }); return Effect.gen(function* () { - yield* projectsApiKeys({ projectRef: Option.none(), reveal: true }); + yield* projectsApiKeys({ remote: Option.none(), projectRef: Option.none(), reveal: true }); expect(out.stdoutText).toContain('"api_key": "sb_secret_revealed"'); }).pipe(Effect.provide(layer)); }); @@ -173,7 +178,7 @@ describe("projects api-keys integration", () => { const { layer } = setup({ projectId: Option.none() }); return Effect.gen(function* () { const exit = yield* Effect.exit( - projectsApiKeys({ projectRef: Option.none(), reveal: false }), + projectsApiKeys({ remote: Option.none(), projectRef: Option.none(), reveal: false }), ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { @@ -185,7 +190,7 @@ describe("projects api-keys integration", () => { it.effect("emits a success event with { keys } for --output-format json", () => { const { layer, out } = setup({ format: "json" }); return Effect.gen(function* () { - yield* projectsApiKeys({ projectRef: Option.none(), reveal: false }); + yield* projectsApiKeys({ remote: Option.none(), projectRef: Option.none(), reveal: false }); const success = out.messages.find((m) => m.type === "success"); expect(success?.data).toMatchObject({ keys: SAMPLE_KEYS }); }).pipe(Effect.provide(layer)); @@ -194,7 +199,7 @@ describe("projects api-keys integration", () => { it.effect("emits a success event for --output-format stream-json", () => { const { layer, out } = setup({ format: "stream-json" }); return Effect.gen(function* () { - yield* projectsApiKeys({ projectRef: Option.none(), reveal: false }); + yield* projectsApiKeys({ remote: Option.none(), projectRef: Option.none(), reveal: false }); expect(out.messages.find((m) => m.type === "success")).toBeDefined(); }).pipe(Effect.provide(layer)); }); @@ -202,7 +207,7 @@ describe("projects api-keys integration", () => { it.effect("encodes the SUPABASE_<NAME>_KEY map for --output env", () => { const { layer, out } = setup({ outputFlag: "env" }); return Effect.gen(function* () { - yield* projectsApiKeys({ projectRef: Option.none(), reveal: false }); + yield* projectsApiKeys({ remote: Option.none(), projectRef: Option.none(), reveal: false }); expect(out.stdoutText).toContain('SUPABASE_ANON_KEY="anon-secret"'); expect(out.stdoutText).toContain('SUPABASE_SERVICE_ROLE_KEY="******"'); }).pipe(Effect.provide(layer)); @@ -211,7 +216,7 @@ describe("projects api-keys integration", () => { it.effect("encodes the SUPABASE_<NAME>_KEY map for --output toml", () => { const { layer, out } = setup({ outputFlag: "toml" }); return Effect.gen(function* () { - yield* projectsApiKeys({ projectRef: Option.none(), reveal: false }); + yield* projectsApiKeys({ remote: Option.none(), projectRef: Option.none(), reveal: false }); expect(out.stdoutText).toContain('SUPABASE_ANON_KEY = "anon-secret"'); }).pipe(Effect.provide(layer)); }); @@ -219,7 +224,7 @@ describe("projects api-keys integration", () => { it.effect("emits a JSON array of api keys for --output json", () => { const { layer, out } = setup({ outputFlag: "json" }); return Effect.gen(function* () { - yield* projectsApiKeys({ projectRef: Option.none(), reveal: false }); + yield* projectsApiKeys({ remote: Option.none(), projectRef: Option.none(), reveal: false }); expect(out.stdoutText).toContain('"name": "anon"'); expect(out.stdoutText.startsWith("[\n")).toBe(true); }).pipe(Effect.provide(layer)); @@ -228,7 +233,7 @@ describe("projects api-keys integration", () => { it.effect("emits a YAML array for --output yaml", () => { const { layer, out } = setup({ outputFlag: "yaml" }); return Effect.gen(function* () { - yield* projectsApiKeys({ projectRef: Option.none(), reveal: false }); + yield* projectsApiKeys({ remote: Option.none(), projectRef: Option.none(), reveal: false }); expect(out.stdoutText).toContain("name: anon"); }).pipe(Effect.provide(layer)); }); @@ -237,7 +242,7 @@ describe("projects api-keys integration", () => { const { layer } = setup({ network: "fail" }); return Effect.gen(function* () { const exit = yield* Effect.exit( - projectsApiKeys({ projectRef: Option.none(), reveal: false }), + projectsApiKeys({ remote: Option.none(), projectRef: Option.none(), reveal: false }), ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { @@ -252,7 +257,7 @@ describe("projects api-keys integration", () => { const { layer } = setup({ status: 503, response: [] }); return Effect.gen(function* () { const exit = yield* Effect.exit( - projectsApiKeys({ projectRef: Option.none(), reveal: false }), + projectsApiKeys({ remote: Option.none(), projectRef: Option.none(), reveal: false }), ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { @@ -296,7 +301,7 @@ describe("projects api-keys integration", () => { const cachePath = `${tempRoot.current}/supabase/.temp/linked-project.json`; expect(yield* fs.exists(cachePath)).toBe(false); - yield* projectsApiKeys({ projectRef: Option.none(), reveal: false }); + yield* projectsApiKeys({ remote: Option.none(), projectRef: Option.none(), reveal: false }); expect(api.requests.map(({ method, url }) => `${method} ${url}`)).toEqual([ `GET ${API}/v1/projects`, @@ -315,4 +320,13 @@ describe("projects api-keys integration", () => { }); }).pipe(Effect.provide(layer)); }); + + describeHostedRemoteSelection({ + command: ["projects", "api-keys"], + workdir: () => tempRoot.current, + activeFlags: { reveal: false }, + respond: (request) => jsonResponse(request, 200, SAMPLE_KEYS), + run: (selector, layer) => + projectsApiKeys({ ...selector, reveal: false }).pipe(Effect.provide(layer)), + }); }); diff --git a/apps/cli/src/commands/secrets/list/list.integration.test.ts b/apps/cli/src/commands/secrets/list/list.integration.test.ts index 4c2edeb4a0..27a9ca1eed 100644 --- a/apps/cli/src/commands/secrets/list/list.integration.test.ts +++ b/apps/cli/src/commands/secrets/list/list.integration.test.ts @@ -12,6 +12,7 @@ import { mockCommandPlatformApi, useTempWorkdir, } from "../../../../tests/helpers/command-mocks.ts"; +import { describeHostedRemoteSelection } from "../../../../tests/helpers/hosted-remote-selection.ts"; import { assertPermissionDrift } from "../../../../tests/helpers/permission-drift.ts"; import { realLinkedProjectCache } from "../../../../tests/helpers/permission-real-layers.ts"; import { secretsList } from "./list.handler.ts"; @@ -76,7 +77,7 @@ describe("secrets list integration", () => { it.effect("renders a Glamour ASCII table with NAME and DIGEST columns in text mode", () => { const { layer, out } = setup({ response: SAMPLE_SECRETS }); return Effect.gen(function* () { - yield* secretsList({ projectRef: Option.none() }); + yield* secretsList({ remote: Option.none(), projectRef: Option.none() }); expect(out.stdoutText).toContain("NAME"); expect(out.stdoutText).toContain("DIGEST"); expect(out.stdoutText).toContain("BAR"); @@ -94,7 +95,7 @@ describe("secrets list integration", () => { ], }); return Effect.gen(function* () { - yield* secretsList({ projectRef: Option.none() }); + yield* secretsList({ remote: Option.none(), projectRef: Option.none() }); const alphaPos = out.stdoutText.indexOf("ALPHA"); const midPos = out.stdoutText.indexOf("MID"); const zedPos = out.stdoutText.indexOf("ZED"); @@ -109,7 +110,7 @@ describe("secrets list integration", () => { response: [{ name: "with|pipe", value: "digest" }], }); return Effect.gen(function* () { - yield* secretsList({ projectRef: Option.none() }); + yield* secretsList({ remote: Option.none(), projectRef: Option.none() }); // The renderer skips markdown escaping entirely, so the pipe passes through literally. expect(out.stdoutText).toContain("with|pipe"); }).pipe(Effect.provide(layer)); @@ -118,7 +119,7 @@ describe("secrets list integration", () => { it.effect("emits a success event with { secrets } for --output-format=json", () => { const { layer, out } = setup({ format: "json", response: SAMPLE_SECRETS }); return Effect.gen(function* () { - yield* secretsList({ projectRef: Option.none() }); + yield* secretsList({ remote: Option.none(), projectRef: Option.none() }); const success = out.messages.find((m) => m.type === "success"); expect(success).toBeDefined(); expect(success?.data).toMatchObject({ @@ -133,7 +134,7 @@ describe("secrets list integration", () => { it.effect("emits a success event for --output-format=stream-json", () => { const { layer, out } = setup({ format: "stream-json", response: SAMPLE_SECRETS }); return Effect.gen(function* () { - yield* secretsList({ projectRef: Option.none() }); + yield* secretsList({ remote: Option.none(), projectRef: Option.none() }); const success = out.messages.find((m) => m.type === "success"); expect(success).toBeDefined(); }).pipe(Effect.provide(layer)); @@ -142,7 +143,7 @@ describe("secrets list integration", () => { it.effect("emits byte-exact indented JSON to stdout for --output json", () => { const { layer, out } = setup({ outputFlag: "json", response: SAMPLE_SECRETS }); return Effect.gen(function* () { - yield* secretsList({ projectRef: Option.none() }); + yield* secretsList({ remote: Option.none(), projectRef: Option.none() }); expect(out.stdoutText).toBe( `[ { @@ -162,7 +163,7 @@ describe("secrets list integration", () => { it.effect("emits a YAML array to stdout for --output yaml", () => { const { layer, out } = setup({ outputFlag: "yaml", response: SAMPLE_SECRETS }); return Effect.gen(function* () { - yield* secretsList({ projectRef: Option.none() }); + yield* secretsList({ remote: Option.none(), projectRef: Option.none() }); expect(out.stdoutText).toContain("- name: BAR"); expect(out.stdoutText).toContain("value: digest-bar"); expect(out.stdoutText).toContain("- name: FOO"); @@ -172,7 +173,7 @@ describe("secrets list integration", () => { it.effect("wraps the array as { secrets = [...] } for --output toml", () => { const { layer, out } = setup({ outputFlag: "toml", response: SAMPLE_SECRETS }); return Effect.gen(function* () { - yield* secretsList({ projectRef: Option.none() }); + yield* secretsList({ remote: Option.none(), projectRef: Option.none() }); expect(out.stdoutText).toContain("[[secrets]]"); expect(out.stdoutText).toContain(' Name = "BAR"'); expect(out.stdoutText).toContain(' Value = "digest-bar"'); @@ -182,7 +183,9 @@ describe("secrets list integration", () => { it.effect("fails with SecretsEnvNotSupportedError for --output env", () => { const { layer } = setup({ outputFlag: "env", response: SAMPLE_SECRETS }); return Effect.gen(function* () { - const exit = yield* Effect.exit(secretsList({ projectRef: Option.none() })); + const exit = yield* Effect.exit( + secretsList({ remote: Option.none(), projectRef: Option.none() }), + ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { const causeText = Cause.pretty(exit.cause); @@ -195,7 +198,7 @@ describe("secrets list integration", () => { it.effect("treats --output pretty as identical to text mode (Glamour table)", () => { const { layer, out } = setup({ outputFlag: "pretty", response: SAMPLE_SECRETS }); return Effect.gen(function* () { - yield* secretsList({ projectRef: Option.none() }); + yield* secretsList({ remote: Option.none(), projectRef: Option.none() }); expect(out.stdoutText).toContain("DIGEST"); }).pipe(Effect.provide(layer)); }); @@ -207,7 +210,7 @@ describe("secrets list integration", () => { response: SAMPLE_SECRETS, }); return Effect.gen(function* () { - yield* secretsList({ projectRef: Option.none() }); + yield* secretsList({ remote: Option.none(), projectRef: Option.none() }); expect(out.stdoutText).toContain("- name: BAR"); expect(out.stdoutText.startsWith("[")).toBe(false); }).pipe(Effect.provide(layer)); @@ -216,22 +219,25 @@ describe("secrets list integration", () => { it.effect("passes the resolved project ref into the listAllSecrets URL", () => { const { layer, api } = setup({ response: SAMPLE_SECRETS }); return Effect.gen(function* () { - yield* secretsList({ projectRef: Option.none() }); + yield* secretsList({ remote: Option.none(), projectRef: Option.none() }); expect(api.requests).toHaveLength(1); expect(api.requests[0]?.url).toContain(`/v1/projects/${VALID_REF}/secrets`); assertPermissionDrift({ command: "secrets list", requests: api.requests, exact: true }); }).pipe(Effect.provide(layer)); }); - it.effect("uses --project-ref flag value over CommandSettings.projectId env", () => { + it.effect("uses the --remote=<ref> value over CommandSettings.projectId env", () => { const flagRef = "zzzzzzzzzzzzzzzzzzzz"; const { layer, api } = setup({ response: SAMPLE_SECRETS }); return Effect.gen(function* () { - yield* secretsList({ projectRef: Option.some(flagRef) }); + yield* secretsList({ + remote: Option.some({ _tag: "Ref", ref: flagRef }), + projectRef: Option.none(), + }); expect(api.requests[0]?.url).toContain(`/v1/projects/${flagRef}/`); assertPermissionDrift({ command: "secrets list", - activeFlags: { "project-ref": true }, + activeFlags: { remote: flagRef }, requests: api.requests, exact: true, }); @@ -255,7 +261,7 @@ describe("secrets list integration", () => { tty: mockTty({ stdinIsTty: true }), }); return Effect.gen(function* () { - yield* secretsList({ projectRef: Option.none() }); + yield* secretsList({ remote: Option.none(), projectRef: Option.none() }); expect(api.requests.map(({ method, url }) => `${method} ${url}`)).toEqual([ "GET https://api.supabase.com/v1/projects", `GET https://api.supabase.com/v1/projects/${VALID_REF}/secrets`, @@ -285,7 +291,7 @@ describe("secrets list integration", () => { const fs = yield* FileSystem.FileSystem; const cachePath = `${tempRoot.current}/supabase/.temp/linked-project.json`; expect(yield* fs.exists(cachePath)).toBe(false); - yield* secretsList({ projectRef: Option.none() }); + yield* secretsList({ remote: Option.none(), projectRef: Option.none() }); expect(yield* decodeJson(yield* fs.readFileString(cachePath))).toMatchObject({ ref: VALID_REF, }); @@ -300,7 +306,9 @@ describe("secrets list integration", () => { it.effect("fails with SecretsListUnexpectedStatusError on HTTP 503", () => { const { layer } = setup({ status: 503, response: [] }); return Effect.gen(function* () { - const exit = yield* Effect.exit(secretsList({ projectRef: Option.none() })); + const exit = yield* Effect.exit( + secretsList({ remote: Option.none(), projectRef: Option.none() }), + ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { const causeText = Cause.pretty(exit.cause); @@ -313,7 +321,9 @@ describe("secrets list integration", () => { it.effect("fails with SecretsListNetworkError on transport failure", () => { const { layer } = setup({ network: "fail" }); return Effect.gen(function* () { - const exit = yield* Effect.exit(secretsList({ projectRef: Option.none() })); + const exit = yield* Effect.exit( + secretsList({ remote: Option.none(), projectRef: Option.none() }), + ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { const causeText = Cause.pretty(exit.cause); @@ -326,8 +336,17 @@ describe("secrets list integration", () => { it.effect("withJsonErrorHandling emits a fail event in JSON mode on 503", () => { const { layer, out } = setup({ format: "json", status: 503, response: [] }); return Effect.gen(function* () { - yield* secretsList({ projectRef: Option.none() }).pipe(withJsonErrorHandling); + yield* secretsList({ remote: Option.none(), projectRef: Option.none() }).pipe( + withJsonErrorHandling, + ); expect(out.messages.some((m) => m.type === "fail")).toBe(true); }).pipe(Effect.provide(layer)); }); + + describeHostedRemoteSelection({ + command: ["secrets", "list"], + workdir: () => tempRoot.current, + respond: (request) => jsonResponse(request, 200, SAMPLE_SECRETS), + run: (selector, layer) => secretsList(selector).pipe(Effect.provide(layer)), + }); }); 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 3746088077..2d1591e576 100644 --- a/apps/cli/src/commands/secrets/set/set.integration.test.ts +++ b/apps/cli/src/commands/secrets/set/set.integration.test.ts @@ -17,6 +17,7 @@ import { HostedMutationDeclinedError } from "../../../command-internal/hosted-co import { TargetPolicy } from "../../../command-internal/target/target-policy.ts"; import { cliConfigValuesTestLayer } from "../../../../tests/helpers/config-values-layer.ts"; import { describeHostedConsent } from "../../../../tests/helpers/hosted-consent.ts"; +import { describeHostedRemoteSelection } from "../../../../tests/helpers/hosted-remote-selection.ts"; import { mockOutput, mockRuntimeInfo, @@ -149,6 +150,7 @@ describe("secrets set integration", () => { const { layer, out, api } = setup({ confirm: true }); return Effect.gen(function* () { yield* secretsSet({ + remote: Option.none(), projectRef: Option.none(), envFile: Option.none(), secrets: ["FOO=hunter2", "BAR=swordfish"], @@ -165,6 +167,7 @@ describe("secrets set integration", () => { const { layer, api } = setup({ confirm: false }); return Effect.gen(function* () { const error = yield* secretsSet({ + remote: Option.none(), projectRef: Option.none(), envFile: Option.none(), secrets: ["FOO=bar"], @@ -179,6 +182,7 @@ describe("secrets set integration", () => { const { layer, out, api } = setup(); return Effect.gen(function* () { yield* secretsSet({ + remote: Option.none(), projectRef: Option.none(), envFile: Option.none(), secrets: ["FOO=bar"], @@ -194,6 +198,7 @@ describe("secrets set integration", () => { const { layer, api } = setup(); return Effect.gen(function* () { yield* secretsSet({ + remote: Option.none(), projectRef: Option.none(), envFile: Option.none(), secrets: ["FOO=bar", "BAZ=qux"], @@ -212,6 +217,7 @@ describe("secrets set integration", () => { const { layer, out, api } = setup(); return Effect.gen(function* () { yield* secretsSet({ + remote: Option.none(), projectRef: Option.none(), envFile: Option.none(), secrets: Array.from({ length: 150 }, (_, i) => `KEY${i}=value${i}`), @@ -234,6 +240,7 @@ describe("secrets set integration", () => { const { layer, api } = setup(); return Effect.gen(function* () { yield* secretsSet({ + remote: Option.none(), projectRef: Option.none(), envFile: Option.none(), secrets: Array.from({ length: 250 }, (_, i) => `KEY${i}=value${i}`), @@ -284,6 +291,7 @@ describe("secrets set integration", () => { const cachePath = `${tempRoot.current}/supabase/.temp/linked-project.json`; expect(yield* fs.exists(cachePath)).toBe(false); yield* secretsSet({ + remote: Option.none(), projectRef: Option.none(), envFile: Option.none(), secrets: ["FOO=bar"], @@ -315,6 +323,7 @@ describe("secrets set integration", () => { return Effect.gen(function* () { const exit = yield* Effect.exit( secretsSet({ + remote: Option.none(), projectRef: Option.none(), envFile: Option.none(), secrets, @@ -337,6 +346,7 @@ describe("secrets set integration", () => { return Effect.gen(function* () { yield* writeTempFile("myfile.env", "FROM_FILE=fromvalue\n"); yield* secretsSet({ + remote: Option.none(), projectRef: Option.none(), envFile: Option.some("myfile.env"), secrets: [], @@ -357,6 +367,7 @@ describe("secrets set integration", () => { yield* writeTempFile("absolute.env", "ABS=value\n"); const abs = yield* tempPath("absolute.env"); yield* secretsSet({ + remote: Option.none(), projectRef: Option.none(), envFile: Option.some(abs), secrets: [], @@ -370,6 +381,7 @@ describe("secrets set integration", () => { return Effect.gen(function* () { yield* writeTempFile("override.env", "FOO=from-file\n"); yield* secretsSet({ + remote: Option.none(), projectRef: Option.none(), envFile: Option.some("override.env"), secrets: ["FOO=from-arg"], @@ -391,6 +403,7 @@ SHARED = "config-shared" ); yield* writeTempFile(".env-file", "SHARED=envfile-shared\n"); yield* secretsSet({ + remote: Option.none(), projectRef: Option.none(), envFile: Option.some(".env-file"), secrets: ["SHARED=cli-shared"], @@ -415,6 +428,7 @@ DB_URL = "env(MY_DB_URL)" `, ); yield* secretsSet({ + remote: Option.none(), projectRef: Option.none(), envFile: Option.none(), secrets: [], @@ -434,6 +448,7 @@ LITERAL = "plain-value" `, ); yield* secretsSet({ + remote: Option.none(), projectRef: Option.none(), envFile: Option.none(), secrets: [], @@ -463,6 +478,7 @@ NON_EMPTY = "config-value" `, ); yield* secretsSet({ + remote: Option.none(), projectRef: Option.none(), envFile: Option.none(), secrets: [], @@ -486,6 +502,7 @@ FOO = "literal-foo" `, ); yield* secretsSet({ + remote: Option.none(), projectRef: Option.none(), envFile: Option.none(), secrets: [], @@ -499,6 +516,7 @@ FOO = "literal-foo" const { layer, out, api } = setup(); return Effect.gen(function* () { yield* secretsSet({ + remote: Option.none(), projectRef: Option.none(), envFile: Option.none(), secrets: ["FOO=bar", "SUPABASE_BAD=x"], @@ -517,6 +535,7 @@ FOO = "literal-foo" return Effect.gen(function* () { const exit = yield* Effect.exit( secretsSet({ + remote: Option.none(), projectRef: Option.none(), envFile: Option.none(), secrets: ["SUPABASE_ONLY=x"], @@ -536,6 +555,7 @@ FOO = "literal-foo" return Effect.gen(function* () { const exit = yield* Effect.exit( secretsSet({ + remote: Option.none(), projectRef: Option.none(), envFile: Option.none(), secrets: ["NOTAPAIR"], @@ -556,6 +576,7 @@ FOO = "literal-foo" return Effect.gen(function* () { const exit = yield* Effect.exit( secretsSet({ + remote: Option.none(), projectRef: Option.none(), envFile: Option.some("does-not-exist.env"), secrets: [], @@ -582,6 +603,7 @@ FOO = "literal-foo" const envPath = yield* tempPath("private.env"); const exit = yield* Effect.exit( secretsSet({ + remote: Option.none(), projectRef: Option.none(), envFile: Option.some(envPath), secrets: [], @@ -607,6 +629,7 @@ FOO = "literal-foo" return Effect.gen(function* () { yield* writeConfig("this is not valid = = toml [[[\n"); yield* secretsSet({ + remote: Option.none(), projectRef: Option.none(), envFile: Option.none(), secrets: ["FOO=bar"], @@ -635,6 +658,7 @@ port = "not-a-number" `, ); yield* secretsSet({ + remote: Option.none(), projectRef: Option.none(), envFile: Option.none(), secrets: [], @@ -663,6 +687,7 @@ FROM_CONFIG = "config-value" `, ); yield* secretsSet({ + remote: Option.none(), projectRef: Option.none(), envFile: Option.none(), secrets: [], @@ -690,6 +715,7 @@ FROM_CONFIG = "config-value" ); yield* writeSupabaseDotEnv("THIS IS NOT A VALID DOTENV LINE\n"); yield* secretsSet({ + remote: Option.none(), projectRef: Option.none(), envFile: Option.none(), secrets: ["FOO=bar"], @@ -718,6 +744,7 @@ BAD = 123 `, ); yield* secretsSet({ + remote: Option.none(), projectRef: Option.none(), envFile: Option.none(), secrets: [], @@ -747,6 +774,7 @@ port = "not-a-number" `, ); yield* secretsSet({ + remote: Option.none(), projectRef: Option.none(), envFile: Option.none(), secrets: [], @@ -773,6 +801,7 @@ secrets = ["actual-secret"] `, ); yield* secretsSet({ + remote: Option.none(), projectRef: Option.none(), envFile: Option.none(), secrets: ["FOO=bar"], @@ -807,6 +836,7 @@ FROM_CONFIG = "remote-value" `, ); yield* secretsSet({ + remote: Option.none(), projectRef: Option.none(), envFile: Option.none(), secrets: [], @@ -841,6 +871,7 @@ FROM_CONFIG = "remote-value" `, ); yield* secretsSet({ + remote: Option.none(), projectRef: Option.none(), envFile: Option.none(), secrets: [], @@ -870,6 +901,7 @@ FROM_CONFIG = "remote-value" `, ); yield* secretsSet({ + remote: Option.none(), projectRef: Option.none(), envFile: Option.none(), secrets: [], @@ -891,6 +923,7 @@ FROM_CONFIG = "config-value" `, ); yield* secretsSet({ + remote: Option.none(), projectRef: Option.none(), envFile: Option.none(), secrets: [], @@ -921,6 +954,7 @@ project_id = "dupe-project-id" `, ); yield* secretsSet({ + remote: Option.none(), projectRef: Option.none(), envFile: Option.none(), secrets: ["FOO=bar"], @@ -949,6 +983,7 @@ project_id = "not-a-valid-ref" `, ); yield* secretsSet({ + remote: Option.none(), projectRef: Option.none(), envFile: Option.none(), secrets: ["FOO=bar"], @@ -976,6 +1011,7 @@ project_id = "not-a-valid-ref" ].join("\n"), ); yield* secretsSet({ + remote: Option.none(), projectRef: Option.none(), envFile: Option.none(), secrets: ["FOO=bar"], @@ -1002,6 +1038,7 @@ PLANTED_SECRET = ["sk_live_TOTALLY_REAL_SECRET_VALUE"] `, ); yield* secretsSet({ + remote: Option.none(), projectRef: Option.none(), envFile: Option.none(), secrets: ["FOO=bar"], @@ -1022,6 +1059,7 @@ PLANTED_SECRET = ["sk_live_TOTALLY_REAL_SECRET_VALUE"] yield* writeConfig("this is not valid = = toml [[[\n"); const exit = yield* Effect.exit( secretsSet({ + remote: Option.none(), projectRef: Option.none(), envFile: Option.none(), secrets: [], @@ -1050,6 +1088,7 @@ FROM_CONFIG = "config-value" `, ); const message = yield* secretsSet({ + remote: Option.none(), projectRef: Option.none(), envFile: Option.none(), secrets: ["FOO=bar"], @@ -1070,6 +1109,7 @@ FROM_CONFIG = "config-value" return Effect.gen(function* () { const exit = yield* Effect.exit( secretsSet({ + remote: Option.none(), projectRef: Option.none(), envFile: Option.none(), secrets: ["FOO=bar"], @@ -1089,6 +1129,7 @@ FROM_CONFIG = "config-value" return Effect.gen(function* () { const exit = yield* Effect.exit( secretsSet({ + remote: Option.none(), projectRef: Option.none(), envFile: Option.none(), secrets: ["FOO=bar"], @@ -1107,6 +1148,7 @@ FROM_CONFIG = "config-value" const { layer, out } = setup({ format: "json" }); return Effect.gen(function* () { yield* secretsSet({ + remote: Option.none(), projectRef: Option.none(), envFile: Option.none(), secrets: ["FOO=bar", "BAZ=qux"], @@ -1123,6 +1165,7 @@ FROM_CONFIG = "config-value" const { layer, out } = setup({ outputFlag: "json" }); return Effect.gen(function* () { yield* secretsSet({ + remote: Option.none(), projectRef: Option.none(), envFile: Option.none(), secrets: ["FOO=bar"], @@ -1134,10 +1177,24 @@ FROM_CONFIG = "config-value" ); }); +describeHostedRemoteSelection({ + command: ["secrets", "set"], + args: ["FOO=bar"], + workdir: () => tempRoot.current, + respond: (request) => jsonResponse(request, 201, null), + run: (selector, layer) => + secretsSet({ ...selector, envFile: Option.none(), secrets: ["FOO=bar"] }).pipe( + Effect.provide( + Layer.mergeAll(layer, cliConfigValuesTestLayer, mockDebugLoggerTracked().layer), + ), + ), +}); + describeHostedConsent("secrets set", { api: { response: { status: 201, body: null } }, invoke: () => ({ run: secretsSet({ + remote: Option.none(), projectRef: Option.none(), envFile: Option.none(), secrets: ["FOO=bar"], 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 9affb5abea..fe39c3118b 100644 --- a/apps/cli/src/commands/secrets/unset/unset.integration.test.ts +++ b/apps/cli/src/commands/secrets/unset/unset.integration.test.ts @@ -10,6 +10,7 @@ import { 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 { describeHostedRemoteSelection } from "../../../../tests/helpers/hosted-remote-selection.ts"; import { mockOutput, mockStdin, mockTty } from "../../../../tests/helpers/mocks.ts"; import { VALID_REF, @@ -104,7 +105,14 @@ function setup(opts: SetupOpts = {}) { } const unset = (flags: Partial<SecretsUnsetFlags> = {}) => - secretsUnset({ projectRef: Option.none(), names: [], all: false, dryRun: false, ...flags }); + secretsUnset({ + remote: Option.none(), + projectRef: Option.none(), + names: [], + all: false, + dryRun: false, + ...flags, + }); const methods = (api: { requests: ReadonlyArray<{ method: string }> }) => api.requests.map((r) => r.method); @@ -524,9 +532,28 @@ describe("secrets unset integration", () => { }); }); +describeHostedRemoteSelection({ + command: ["secrets", "unset"], + args: ["FOO"], + workdir: () => tempRoot.current, + activeFlags: { all: false, "dry-run": false }, + respond: (request) => + jsonResponse(request, 200, request.method === "GET" ? [{ name: "FOO", value: "d1" }] : null), + run: (selector, layer) => + secretsUnset({ ...selector, names: ["FOO"], all: false, dryRun: false }).pipe( + Effect.provide(layer), + ), +}); + describeHostedConsent("secrets unset", { api: { response: { status: 200, body: null } }, invoke: () => ({ - run: secretsUnset({ projectRef: Option.none(), names: ["FOO"], all: false, dryRun: false }), + run: secretsUnset({ + remote: Option.none(), + projectRef: Option.none(), + names: ["FOO"], + all: false, + dryRun: false, + }), }), }); diff --git a/apps/cli/src/commands/snippets/download/download.integration.test.ts b/apps/cli/src/commands/snippets/download/download.integration.test.ts index d9152c91f6..c6ac3a689f 100644 --- a/apps/cli/src/commands/snippets/download/download.integration.test.ts +++ b/apps/cli/src/commands/snippets/download/download.integration.test.ts @@ -13,6 +13,7 @@ import { mockTelemetryStateTracked, useTempWorkdir, } from "../../../../tests/helpers/command-mocks.ts"; +import { describeHostedRemoteSelection } from "../../../../tests/helpers/hosted-remote-selection.ts"; import { assertPermissionDrift } from "../../../../tests/helpers/permission-drift.ts"; import { projectResponse, @@ -92,7 +93,11 @@ describe("snippets download integration", () => { it.effect("prints raw SQL with a trailing newline in text mode", () => { const { layer, out } = setup(); return Effect.gen(function* () { - yield* snippetsDownload({ snippetId: VALID_ID, projectRef: Option.none() }); + yield* snippetsDownload({ + snippetId: VALID_ID, + remote: Option.none(), + projectRef: Option.none(), + }); expect(out.stdoutText).toBe(`${SQL}\n`); }).pipe(Effect.provide(layer)); }); @@ -100,7 +105,11 @@ describe("snippets download integration", () => { it.effect("prints only a newline in text mode when the snippet has no content", () => { const { layer, out } = setup({ response: { ...SNIPPET_RESPONSE, content: null } }); return Effect.gen(function* () { - yield* snippetsDownload({ snippetId: VALID_ID, projectRef: Option.none() }); + yield* snippetsDownload({ + snippetId: VALID_ID, + remote: Option.none(), + projectRef: Option.none(), + }); expect(out.stdoutText).toBe("\n"); }).pipe(Effect.provide(layer)); }); @@ -109,7 +118,11 @@ describe("snippets download integration", () => { const body = { ...SNIPPET_RESPONSE, owner: null, updated_by: null, content: null }; const { layer, out } = setup({ format: "json", response: body }); return Effect.gen(function* () { - yield* snippetsDownload({ snippetId: VALID_ID, projectRef: Option.none() }); + yield* snippetsDownload({ + snippetId: VALID_ID, + remote: Option.none(), + projectRef: Option.none(), + }); const success = out.messages.find((m) => m.type === "success"); expect(toJson(success?.data)).toBe(toJson(body)); }).pipe(Effect.provide(layer)); @@ -119,7 +132,7 @@ describe("snippets download integration", () => { const { layer } = setup({ response: { id: VALID_ID } }); return Effect.gen(function* () { const exit = yield* Effect.exit( - snippetsDownload({ snippetId: VALID_ID, projectRef: Option.none() }), + snippetsDownload({ snippetId: VALID_ID, remote: Option.none(), projectRef: Option.none() }), ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { @@ -149,7 +162,11 @@ describe("snippets download integration", () => { outputFlag: Option.some("json"), }); return Effect.gen(function* () { - yield* snippetsDownload({ snippetId: VALID_ID, projectRef: Option.none() }); + yield* snippetsDownload({ + snippetId: VALID_ID, + remote: Option.none(), + projectRef: Option.none(), + }); expect(out.stdoutText).toBe(`${SQL}\n`); }).pipe(Effect.provide(layer)); }); @@ -157,7 +174,11 @@ describe("snippets download integration", () => { it.effect("emits a success event with the full response under --output-format=json", () => { const { layer, out } = setup({ format: "json" }); return Effect.gen(function* () { - yield* snippetsDownload({ snippetId: VALID_ID, projectRef: Option.none() }); + yield* snippetsDownload({ + snippetId: VALID_ID, + remote: Option.none(), + projectRef: Option.none(), + }); const success = out.messages.find((m) => m.type === "success"); expect(success).toBeDefined(); expect(success?.data).toEqual(SNIPPET_RESPONSE); @@ -167,7 +188,11 @@ describe("snippets download integration", () => { it.effect("emits a result event with the full response under --output-format=stream-json", () => { const { layer, out } = setup({ format: "stream-json" }); return Effect.gen(function* () { - yield* snippetsDownload({ snippetId: VALID_ID, projectRef: Option.none() }); + yield* snippetsDownload({ + snippetId: VALID_ID, + remote: Option.none(), + projectRef: Option.none(), + }); const success = out.messages.find((m) => m.type === "success"); expect(success).toBeDefined(); expect(success?.data).toEqual(SNIPPET_RESPONSE); @@ -180,7 +205,11 @@ describe("snippets download integration", () => { const { layer, api, telemetry, cache } = setup(); return Effect.gen(function* () { const exit = yield* Effect.exit( - snippetsDownload({ snippetId: INVALID_ID, projectRef: Option.none() }), + snippetsDownload({ + snippetId: INVALID_ID, + remote: Option.none(), + projectRef: Option.none(), + }), ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { @@ -199,7 +228,11 @@ describe("snippets download integration", () => { const { layer } = setup(); return Effect.gen(function* () { const exit = yield* Effect.exit( - snippetsDownload({ snippetId: TOO_LONG_ID, projectRef: Option.none() }), + snippetsDownload({ + snippetId: TOO_LONG_ID, + remote: Option.none(), + projectRef: Option.none(), + }), ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { @@ -213,7 +246,11 @@ describe("snippets download integration", () => { const { layer } = setup(); return Effect.gen(function* () { const exit = yield* Effect.exit( - snippetsDownload({ snippetId: WRONG_FORMAT_ID, projectRef: Option.none() }), + snippetsDownload({ + snippetId: WRONG_FORMAT_ID, + remote: Option.none(), + projectRef: Option.none(), + }), ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { @@ -235,7 +272,11 @@ describe("snippets download integration", () => { it.effect("calls GET /v1/snippets/{id} with the validated UUID and no project_ref query", () => { const { layer, api } = setup(); return Effect.gen(function* () { - yield* snippetsDownload({ snippetId: VALID_ID, projectRef: Option.none() }); + yield* snippetsDownload({ + snippetId: VALID_ID, + remote: Option.none(), + projectRef: Option.none(), + }); expect(api.requests).toHaveLength(1); expect(api.requests[0]?.method).toBe("GET"); expect(api.requests[0]?.url).toContain(`/v1/snippets/${VALID_ID}`); @@ -249,25 +290,33 @@ describe("snippets download integration", () => { () => { const { layer, api } = setup(); return Effect.gen(function* () { - yield* snippetsDownload({ snippetId: UPPER_HEX32_ID, projectRef: Option.none() }); + yield* snippetsDownload({ + snippetId: UPPER_HEX32_ID, + remote: Option.none(), + projectRef: Option.none(), + }); expect(api.requests).toHaveLength(1); expect(api.requests[0]?.url).toContain(`/v1/snippets/${VALID_ID}`); }).pipe(Effect.provide(layer)); }, ); - it.effect("uses --project-ref flag value when resolving the linked-project cache", () => { + it.effect("uses the --remote=<ref> value when resolving the linked-project cache", () => { const flagRef = "zzzzzzzzzzzzzzzzzzzz"; const { layer, cache, api } = setup(); return Effect.gen(function* () { - yield* snippetsDownload({ snippetId: VALID_ID, projectRef: Option.some(flagRef) }); + yield* snippetsDownload({ + snippetId: VALID_ID, + remote: Option.some({ _tag: "Ref", ref: flagRef }), + projectRef: Option.none(), + }); // The download endpoint itself takes only the snippet ID, but the // resolved project ref still flows into the linked-project cache write. expect(cache.cached).toBe(true); expect(api.requests).toHaveLength(1); assertPermissionDrift({ command: "snippets download", - activeFlags: { "project-ref": true }, + activeFlags: { remote: flagRef }, requests: api.requests, exact: true, }); @@ -303,7 +352,11 @@ describe("snippets download integration", () => { const fs = yield* FileSystem.FileSystem; const cachePath = `${tempRoot.current}/supabase/.temp/linked-project.json`; expect(yield* fs.exists(cachePath)).toBe(false); - yield* snippetsDownload({ snippetId: VALID_ID, projectRef: Option.none() }); + yield* snippetsDownload({ + snippetId: VALID_ID, + remote: Option.none(), + projectRef: Option.none(), + }); expect(api.requests.map(({ method, url }) => `${method} ${url}`)).toEqual([ "GET https://api.supabase.com/v1/projects", `GET https://api.supabase.com/v1/snippets/${VALID_ID}`, @@ -322,7 +375,7 @@ describe("snippets download integration", () => { const { layer } = setup({ status: 503 }); return Effect.gen(function* () { const exit = yield* Effect.exit( - snippetsDownload({ snippetId: VALID_ID, projectRef: Option.none() }), + snippetsDownload({ snippetId: VALID_ID, remote: Option.none(), projectRef: Option.none() }), ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { @@ -337,7 +390,7 @@ describe("snippets download integration", () => { const { layer } = setup({ network: "fail" }); return Effect.gen(function* () { const exit = yield* Effect.exit( - snippetsDownload({ snippetId: VALID_ID, projectRef: Option.none() }), + snippetsDownload({ snippetId: VALID_ID, remote: Option.none(), projectRef: Option.none() }), ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { @@ -351,7 +404,11 @@ describe("snippets download integration", () => { it.effect("flushes telemetry and writes linked-project cache on success", () => { const { layer, telemetry, cache } = setup(); return Effect.gen(function* () { - yield* snippetsDownload({ snippetId: VALID_ID, projectRef: Option.none() }); + yield* snippetsDownload({ + snippetId: VALID_ID, + remote: Option.none(), + projectRef: Option.none(), + }); expect(telemetry.flushed).toBe(true); expect(cache.cached).toBe(true); }).pipe(Effect.provide(layer)); @@ -360,7 +417,9 @@ describe("snippets download integration", () => { it.effect("flushes telemetry and writes linked-project cache even on API failure", () => { const { layer, telemetry, cache } = setup({ status: 500 }); return Effect.gen(function* () { - yield* Effect.exit(snippetsDownload({ snippetId: VALID_ID, projectRef: Option.none() })); + yield* Effect.exit( + snippetsDownload({ snippetId: VALID_ID, remote: Option.none(), projectRef: Option.none() }), + ); expect(telemetry.flushed).toBe(true); expect(cache.cached).toBe(true); }).pipe(Effect.provide(layer)); @@ -369,10 +428,21 @@ describe("snippets download integration", () => { it.effect("emits a fail event when withJsonErrorHandling wraps a JSON-mode error", () => { const { layer, out } = setup({ format: "json", status: 503 }); return Effect.gen(function* () { - yield* snippetsDownload({ snippetId: VALID_ID, projectRef: Option.none() }).pipe( - withJsonErrorHandling, - ); + yield* snippetsDownload({ + snippetId: VALID_ID, + remote: Option.none(), + projectRef: Option.none(), + }).pipe(withJsonErrorHandling); expect(out.messages.some((m) => m.type === "fail")).toBe(true); }).pipe(Effect.provide(layer)); }); + + describeHostedRemoteSelection({ + command: ["snippets", "download"], + args: [VALID_ID], + workdir: () => tempRoot.current, + respond: (request) => jsonResponse(request, 200, SNIPPET_RESPONSE), + run: (selector, layer) => + snippetsDownload({ ...selector, snippetId: VALID_ID }).pipe(Effect.provide(layer)), + }); }); diff --git a/apps/cli/src/commands/snippets/list/list.integration.test.ts b/apps/cli/src/commands/snippets/list/list.integration.test.ts index bdc87953c0..aee6da721d 100644 --- a/apps/cli/src/commands/snippets/list/list.integration.test.ts +++ b/apps/cli/src/commands/snippets/list/list.integration.test.ts @@ -14,6 +14,7 @@ import { useTempWorkdir, } from "../../../../tests/helpers/command-mocks.ts"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; +import { describeHostedRemoteSelection } from "../../../../tests/helpers/hosted-remote-selection.ts"; import { assertPermissionDrift } from "../../../../tests/helpers/permission-drift.ts"; import { realLinkedProjectCache } from "../../../../tests/helpers/permission-real-layers.ts"; import { snippetsList } from "./list.handler.ts"; @@ -104,7 +105,7 @@ describe("snippets list integration", () => { it.effect("renders an ASCII table in text mode with all six columns", () => { const { layer, out } = setup(); return Effect.gen(function* () { - yield* snippetsList({ projectRef: Option.none() }); + yield* snippetsList({ remote: Option.none(), projectRef: Option.none() }); expect(out.stdoutText).toContain("ID"); expect(out.stdoutText).toContain("NAME"); expect(out.stdoutText).toContain("VISIBILITY"); @@ -120,7 +121,7 @@ describe("snippets list integration", () => { it.effect("preserves literal `|` characters in snippet name and owner username", () => { const { layer, out } = setup({ response: PIPE_RESPONSE }); return Effect.gen(function* () { - yield* snippetsList({ projectRef: Option.none() }); + yield* snippetsList({ remote: Option.none(), projectRef: Option.none() }); expect(out.stdoutText).toContain("name|with|pipes"); expect(out.stdoutText).toContain("user|name"); expect(out.stdoutText).not.toContain("\\|"); @@ -130,7 +131,7 @@ describe("snippets list integration", () => { it.effect("formats RFC3339 timestamps as UTC YYYY-MM-DD HH:MM:SS", () => { const { layer, out } = setup(); return Effect.gen(function* () { - yield* snippetsList({ projectRef: Option.none() }); + yield* snippetsList({ remote: Option.none(), projectRef: Option.none() }); expect(out.stdoutText).toContain("2023-10-13 17:48:58"); }).pipe(Effect.provide(layer)); }); @@ -138,7 +139,7 @@ describe("snippets list integration", () => { it.effect("leaves a non-RFC3339 inserted_at string untouched", () => { const { layer, out } = setup({ response: RAW_TIMESTAMP_RESPONSE }); return Effect.gen(function* () { - yield* snippetsList({ projectRef: Option.none() }); + yield* snippetsList({ remote: Option.none(), projectRef: Option.none() }); expect(out.stdoutText).toContain("not-an-rfc3339"); // The valid updated_at is still formatted. expect(out.stdoutText).toContain("2023-10-13 17:48:58"); @@ -148,7 +149,7 @@ describe("snippets list integration", () => { it.effect("emits a success event with the full response under --output-format=json", () => { const { layer, out } = setup({ format: "json" }); return Effect.gen(function* () { - yield* snippetsList({ projectRef: Option.none() }); + yield* snippetsList({ remote: Option.none(), projectRef: Option.none() }); const success = out.messages.find((m) => m.type === "success"); expect(success).toBeDefined(); expect(success?.data).toEqual(SINGLE_RESPONSE); @@ -176,7 +177,7 @@ describe("snippets list integration", () => { }; const { layer, out } = setup({ format: "json", response: body }); return Effect.gen(function* () { - yield* snippetsList({ projectRef: Option.none() }); + yield* snippetsList({ remote: Option.none(), projectRef: Option.none() }); const success = out.messages.find((m) => m.type === "success"); expect(toJson(success?.data)).toBe(toJson(body)); }).pipe(Effect.provide(layer)); @@ -185,7 +186,7 @@ describe("snippets list integration", () => { it.effect("keeps an empty data array empty under json", () => { const { layer, out } = setup({ format: "json", response: EMPTY_RESPONSE }); return Effect.gen(function* () { - yield* snippetsList({ projectRef: Option.none() }); + yield* snippetsList({ remote: Option.none(), projectRef: Option.none() }); const success = out.messages.find((m) => m.type === "success"); expect(toJson(success?.data)).toBe('{"data":[]}'); }).pipe(Effect.provide(layer)); @@ -198,7 +199,7 @@ describe("snippets list integration", () => { response: { data: [{ ...SNIPPET_BASE, owner: null, updated_by: null }] }, }); return Effect.gen(function* () { - yield* snippetsList({ projectRef: Option.none() }); + yield* snippetsList({ remote: Option.none(), projectRef: Option.none() }); const row = out.stdoutText.split("\n").find((line) => line.includes(SNIPPET_ID)); const cells = row?.split("|").map((cell) => cell.trim()); expect(cells?.[1]).toBe("Create table"); @@ -212,7 +213,7 @@ describe("snippets list integration", () => { response: { data: [{ ...SNIPPET_BASE, visibility: "team" }] }, }); return Effect.gen(function* () { - yield* snippetsList({ projectRef: Option.none() }); + yield* snippetsList({ remote: Option.none(), projectRef: Option.none() }); expect(out.stdoutText).toContain("team"); }).pipe(Effect.provide(layer)); }); @@ -220,7 +221,9 @@ describe("snippets list integration", () => { it.effect("fails with a decode network error when the body does not match the contract", () => { const { layer } = setup({ response: { data: [{ id: SNIPPET_ID }] } }); return Effect.gen(function* () { - const exit = yield* Effect.exit(snippetsList({ projectRef: Option.none() })); + const exit = yield* Effect.exit( + snippetsList({ remote: Option.none(), projectRef: Option.none() }), + ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { expect(Option.getOrUndefined(Cause.findErrorOption(exit.cause))).toMatchObject({ @@ -234,7 +237,7 @@ describe("snippets list integration", () => { it.effect("emits a result event under --output-format=stream-json", () => { const { layer, out } = setup({ format: "stream-json" }); return Effect.gen(function* () { - yield* snippetsList({ projectRef: Option.none() }); + yield* snippetsList({ remote: Option.none(), projectRef: Option.none() }); expect(out.messages.some((m) => m.type === "success")).toBe(true); }).pipe(Effect.provide(layer)); }); @@ -242,7 +245,7 @@ describe("snippets list integration", () => { it.effect("--output=json emits alphabetically-keyed JSON, preserving empty arrays", () => { const { layer, out } = setup({ outputFlag: "json", response: EMPTY_RESPONSE }); return Effect.gen(function* () { - yield* snippetsList({ projectRef: Option.none() }); + yield* snippetsList({ remote: Option.none(), projectRef: Option.none() }); // Raw-HTTP bypass echoes whatever the API sent — no `nullForEmptyArrays` // coercion (real responses always send `[]`, never null). expect(out.stdoutText).toBe(`{ @@ -255,7 +258,7 @@ describe("snippets list integration", () => { it.effect("--output=yaml emits a `data:` block", () => { const { layer, out } = setup({ outputFlag: "yaml" }); return Effect.gen(function* () { - yield* snippetsList({ projectRef: Option.none() }); + yield* snippetsList({ remote: Option.none(), projectRef: Option.none() }); expect(out.stdoutText).toContain("data:"); expect(out.stdoutText).toContain(SNIPPET_ID); }).pipe(Effect.provide(layer)); @@ -267,7 +270,9 @@ describe("snippets list integration", () => { // or explicit null). const { layer } = setup({ outputFlag: "toml" }); return Effect.gen(function* () { - const exit = yield* Effect.exit(snippetsList({ projectRef: Option.none() })); + const exit = yield* Effect.exit( + snippetsList({ remote: Option.none(), projectRef: Option.none() }), + ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { const causeText = Cause.pretty(exit.cause); @@ -282,7 +287,7 @@ describe("snippets list integration", () => { it.effect("--output=toml encodes an empty snippet list", () => { const { layer, out } = setup({ outputFlag: "toml", response: EMPTY_RESPONSE }); return Effect.gen(function* () { - yield* snippetsList({ projectRef: Option.none() }); + yield* snippetsList({ remote: Option.none(), projectRef: Option.none() }); expect(out.stdoutText).toBe("Data = []\n"); }).pipe(Effect.provide(layer)); }); @@ -292,7 +297,9 @@ describe("snippets list integration", () => { () => { const { layer, api, telemetry, cache } = setup({ outputFlag: "env" }); return Effect.gen(function* () { - const exit = yield* Effect.exit(snippetsList({ projectRef: Option.none() })); + const exit = yield* Effect.exit( + snippetsList({ remote: Option.none(), projectRef: Option.none() }), + ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { const causeText = Cause.pretty(exit.cause); @@ -309,7 +316,7 @@ describe("snippets list integration", () => { it.effect("--output=pretty falls through to the text renderer", () => { const { layer, out } = setup({ outputFlag: "pretty" }); return Effect.gen(function* () { - yield* snippetsList({ projectRef: Option.none() }); + yield* snippetsList({ remote: Option.none(), projectRef: Option.none() }); expect(out.stdoutText).toContain("VISIBILITY"); expect(out.stdoutText).toContain(SNIPPET_ID); }).pipe(Effect.provide(layer)); @@ -318,7 +325,7 @@ describe("snippets list integration", () => { it.effect("--output wins over --output-format when both are set", () => { const { layer, out } = setup({ format: "json", outputFlag: "yaml" }); return Effect.gen(function* () { - yield* snippetsList({ projectRef: Option.none() }); + yield* snippetsList({ remote: Option.none(), projectRef: Option.none() }); expect(out.stdoutText).toContain("data:"); expect(out.stdoutText.startsWith("{")).toBe(false); }).pipe(Effect.provide(layer)); @@ -327,22 +334,25 @@ describe("snippets list integration", () => { it.effect("passes the resolved project_ref as a `project_ref` query parameter", () => { const { layer, api } = setup(); return Effect.gen(function* () { - yield* snippetsList({ projectRef: Option.none() }); + yield* snippetsList({ remote: Option.none(), projectRef: Option.none() }); expect(api.requests).toHaveLength(1); expect(api.requests[0]?.urlWithParams).toContain(`/v1/snippets?project_ref=${VALID_REF}`); assertPermissionDrift({ command: "snippets list", requests: api.requests, exact: true }); }).pipe(Effect.provide(layer)); }); - it.effect("uses --project-ref flag value over the resolver's linked-project default", () => { + it.effect("uses the --remote=<ref> value over the resolver's linked-project default", () => { const flagRef = "zzzzzzzzzzzzzzzzzzzz"; const { layer, api } = setup(); return Effect.gen(function* () { - yield* snippetsList({ projectRef: Option.some(flagRef) }); + yield* snippetsList({ + remote: Option.some({ _tag: "Ref", ref: flagRef }), + projectRef: Option.none(), + }); expect(api.requests[0]?.urlWithParams).toContain(`project_ref=${flagRef}`); assertPermissionDrift({ command: "snippets list", - activeFlags: { "project-ref": true }, + activeFlags: { remote: flagRef }, requests: api.requests, exact: true, }); @@ -384,7 +394,7 @@ describe("snippets list integration", () => { const fs = yield* FileSystem.FileSystem; const cachePath = `${tempRoot.current}/supabase/.temp/linked-project.json`; expect(yield* fs.exists(cachePath)).toBe(false); - yield* snippetsList({ projectRef: Option.none() }); + yield* snippetsList({ remote: Option.none(), projectRef: Option.none() }); expect(yield* decodeJson(yield* fs.readFileString(cachePath))).toMatchObject({ ref: VALID_REF, }); @@ -399,7 +409,9 @@ describe("snippets list integration", () => { it.effect("fails with SnippetsListUnexpectedStatusError on HTTP 503", () => { const { layer } = setup({ status: 503 }); return Effect.gen(function* () { - const exit = yield* Effect.exit(snippetsList({ projectRef: Option.none() })); + const exit = yield* Effect.exit( + snippetsList({ remote: Option.none(), projectRef: Option.none() }), + ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { const causeText = Cause.pretty(exit.cause); @@ -412,7 +424,9 @@ describe("snippets list integration", () => { it.effect("fails with SnippetsListNetworkError on transport failure", () => { const { layer } = setup({ network: "fail" }); return Effect.gen(function* () { - const exit = yield* Effect.exit(snippetsList({ projectRef: Option.none() })); + const exit = yield* Effect.exit( + snippetsList({ remote: Option.none(), projectRef: Option.none() }), + ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { const causeText = Cause.pretty(exit.cause); @@ -425,7 +439,7 @@ describe("snippets list integration", () => { it.effect("flushes telemetry and writes linked-project cache on success", () => { const { layer, telemetry, cache } = setup(); return Effect.gen(function* () { - yield* snippetsList({ projectRef: Option.none() }); + yield* snippetsList({ remote: Option.none(), projectRef: Option.none() }); expect(telemetry.flushed).toBe(true); expect(cache.cached).toBe(true); }).pipe(Effect.provide(layer)); @@ -434,7 +448,7 @@ describe("snippets list integration", () => { it.effect("flushes telemetry and writes linked-project cache even on API failure", () => { const { layer, telemetry, cache } = setup({ status: 500 }); return Effect.gen(function* () { - yield* Effect.exit(snippetsList({ projectRef: Option.none() })); + yield* Effect.exit(snippetsList({ remote: Option.none(), projectRef: Option.none() })); expect(telemetry.flushed).toBe(true); expect(cache.cached).toBe(true); }).pipe(Effect.provide(layer)); @@ -443,8 +457,17 @@ describe("snippets list integration", () => { it.effect("emits a fail event when withJsonErrorHandling wraps a JSON-mode error", () => { const { layer, out } = setup({ format: "json", status: 503 }); return Effect.gen(function* () { - yield* snippetsList({ projectRef: Option.none() }).pipe(withJsonErrorHandling); + yield* snippetsList({ remote: Option.none(), projectRef: Option.none() }).pipe( + withJsonErrorHandling, + ); expect(out.messages.some((m) => m.type === "fail")).toBe(true); }).pipe(Effect.provide(layer)); }); + + describeHostedRemoteSelection({ + command: ["snippets", "list"], + workdir: () => tempRoot.current, + respond: (request) => jsonResponse(request, 200, SINGLE_RESPONSE), + run: (selector, layer) => snippetsList(selector).pipe(Effect.provide(layer)), + }); }); diff --git a/apps/cli/src/commands/ssl-enforcement/get/get.integration.test.ts b/apps/cli/src/commands/ssl-enforcement/get/get.integration.test.ts index 29a2c1e36f..202c962614 100644 --- a/apps/cli/src/commands/ssl-enforcement/get/get.integration.test.ts +++ b/apps/cli/src/commands/ssl-enforcement/get/get.integration.test.ts @@ -15,6 +15,7 @@ import { mockTelemetryStateTracked, useTempWorkdir, } from "../../../../tests/helpers/command-mocks.ts"; +import { describeHostedRemoteSelection } from "../../../../tests/helpers/hosted-remote-selection.ts"; import { assertPermissionDrift } from "../../../../tests/helpers/permission-drift.ts"; import { projectResponse, @@ -81,7 +82,7 @@ describe("ssl-enforcement get integration", () => { () => { const { layer, out } = setup({ response: SSL_ENFORCED }); return Effect.gen(function* () { - yield* sslEnforcementGet({ projectRef: Option.none() }); + yield* sslEnforcementGet({ remote: Option.none(), projectRef: Option.none() }); expect(out.stdoutText).toBe("SSL is being enforced.\n"); }).pipe(Effect.provide(layer)); }, @@ -90,7 +91,7 @@ describe("ssl-enforcement get integration", () => { it.effect('prints "SSL is *NOT* being enforced." when database=false', () => { const { layer, out } = setup({ response: SSL_NOT_ENFORCED }); return Effect.gen(function* () { - yield* sslEnforcementGet({ projectRef: Option.none() }); + yield* sslEnforcementGet({ remote: Option.none(), projectRef: Option.none() }); expect(out.stdoutText).toBe("SSL is *NOT* being enforced.\n"); }).pipe(Effect.provide(layer)); }); @@ -100,7 +101,7 @@ describe("ssl-enforcement get integration", () => { () => { const { layer, out } = setup({ response: SSL_DESIRED_BUT_NOT_APPLIED }); return Effect.gen(function* () { - yield* sslEnforcementGet({ projectRef: Option.none() }); + yield* sslEnforcementGet({ remote: Option.none(), projectRef: Option.none() }); expect(out.stdoutText).toBe("SSL is *NOT* being enforced.\n"); }).pipe(Effect.provide(layer)); }, @@ -109,7 +110,7 @@ describe("ssl-enforcement get integration", () => { it.effect("emits env output for --output env (exact bytes)", () => { const { layer, out } = setup({ outputFlag: "env", response: SSL_ENFORCED }); return Effect.gen(function* () { - yield* sslEnforcementGet({ projectRef: Option.none() }); + yield* sslEnforcementGet({ remote: Option.none(), projectRef: Option.none() }); expect(out.stdoutText).toBe('APPLIEDSUCCESSFULLY="true"\nCURRENTCONFIG_DATABASE="true"\n'); }).pipe(Effect.provide(layer)); }); @@ -117,7 +118,7 @@ describe("ssl-enforcement get integration", () => { it.effect("emits indented JSON for --output json (exact bytes)", () => { const { layer, out } = setup({ outputFlag: "json", response: SSL_ENFORCED }); return Effect.gen(function* () { - yield* sslEnforcementGet({ projectRef: Option.none() }); + yield* sslEnforcementGet({ remote: Option.none(), projectRef: Option.none() }); expect(out.stdoutText).toBe( `{ "appliedSuccessfully": true, @@ -133,7 +134,7 @@ describe("ssl-enforcement get integration", () => { it.effect("emits YAML for --output yaml", () => { const { layer, out } = setup({ outputFlag: "yaml", response: SSL_ENFORCED }); return Effect.gen(function* () { - yield* sslEnforcementGet({ projectRef: Option.none() }); + yield* sslEnforcementGet({ remote: Option.none(), projectRef: Option.none() }); expect(out.stdoutText).toContain("appliedsuccessfully: true"); expect(out.stdoutText).toContain("database: true"); }).pipe(Effect.provide(layer)); @@ -142,7 +143,7 @@ describe("ssl-enforcement get integration", () => { it.effect("emits TOML for --output toml", () => { const { layer, out } = setup({ outputFlag: "toml", response: SSL_ENFORCED }); return Effect.gen(function* () { - yield* sslEnforcementGet({ projectRef: Option.none() }); + yield* sslEnforcementGet({ remote: Option.none(), projectRef: Option.none() }); expect(out.stdoutText).toContain("AppliedSuccessfully = true"); expect(out.stdoutText).toContain("[CurrentConfig]"); }).pipe(Effect.provide(layer)); @@ -151,7 +152,7 @@ describe("ssl-enforcement get integration", () => { it.effect("treats --output pretty as identical to text mode", () => { const { layer, out } = setup({ outputFlag: "pretty", response: SSL_ENFORCED }); return Effect.gen(function* () { - yield* sslEnforcementGet({ projectRef: Option.none() }); + yield* sslEnforcementGet({ remote: Option.none(), projectRef: Option.none() }); expect(out.stdoutText).toBe("SSL is being enforced.\n"); }).pipe(Effect.provide(layer)); }); @@ -159,7 +160,7 @@ describe("ssl-enforcement get integration", () => { it.effect("emits a JSON success event when --output-format=json", () => { const { layer, out } = setup({ format: "json", response: SSL_ENFORCED }); return Effect.gen(function* () { - yield* sslEnforcementGet({ projectRef: Option.none() }); + yield* sslEnforcementGet({ remote: Option.none(), projectRef: Option.none() }); const success = out.messages.find((m) => m.type === "success"); expect(success).toBeDefined(); expect(success?.data).toMatchObject({ @@ -172,7 +173,7 @@ describe("ssl-enforcement get integration", () => { it.effect("emits a result event for --output-format=stream-json", () => { const { layer, out } = setup({ format: "stream-json", response: SSL_ENFORCED }); return Effect.gen(function* () { - yield* sslEnforcementGet({ projectRef: Option.none() }); + yield* sslEnforcementGet({ remote: Option.none(), projectRef: Option.none() }); const success = out.messages.find((m) => m.type === "success"); expect(success).toBeDefined(); expect(success?.data).toMatchObject({ currentConfig: { database: true } }); @@ -182,7 +183,7 @@ describe("ssl-enforcement get integration", () => { it.effect("--output wins over --output-format when both provided", () => { const { layer, out } = setup({ format: "json", outputFlag: "yaml", response: SSL_ENFORCED }); return Effect.gen(function* () { - yield* sslEnforcementGet({ projectRef: Option.none() }); + yield* sslEnforcementGet({ remote: Option.none(), projectRef: Option.none() }); expect(out.stdoutText).toContain("appliedsuccessfully: true"); expect(out.stdoutText.startsWith("{")).toBe(false); }).pipe(Effect.provide(layer)); @@ -191,7 +192,7 @@ describe("ssl-enforcement get integration", () => { it.effect("passes the resolved project ref into the getSslEnforcementConfig URL", () => { const { layer, api } = setup({ response: SSL_ENFORCED }); return Effect.gen(function* () { - yield* sslEnforcementGet({ projectRef: Option.none() }); + yield* sslEnforcementGet({ remote: Option.none(), projectRef: Option.none() }); expect(api.requests).toHaveLength(1); expect(api.requests[0]?.url).toContain(`/v1/projects/${VALID_REF}/ssl-enforcement`); assertPermissionDrift({ @@ -232,7 +233,7 @@ describe("ssl-enforcement get integration", () => { }); return Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; - yield* sslEnforcementGet({ projectRef: Option.none() }); + yield* sslEnforcementGet({ remote: Option.none(), projectRef: Option.none() }); expect(api.requests.map(({ method, url }) => `${method} ${url}`)).toEqual([ "GET https://api.supabase.com/v1/projects", `GET https://api.supabase.com/v1/projects/${VALID_REF}/ssl-enforcement`, @@ -251,11 +252,14 @@ describe("ssl-enforcement get integration", () => { }).pipe(Effect.provide(layer)); }); - it.effect("uses --project-ref flag value over CommandSettings.projectId", () => { + it.effect("uses the --remote=<ref> value over CommandSettings.projectId", () => { const flagRef = "zzzzzzzzzzzzzzzzzzzz"; const { layer, api } = setup({ response: SSL_ENFORCED }); return Effect.gen(function* () { - yield* sslEnforcementGet({ projectRef: Option.some(flagRef) }); + yield* sslEnforcementGet({ + remote: Option.some({ _tag: "Ref", ref: flagRef }), + projectRef: Option.none(), + }); expect(api.requests[0]?.url).toContain(`/v1/projects/${flagRef}/`); }).pipe(Effect.provide(layer)); }); @@ -285,7 +289,9 @@ describe("ssl-enforcement get integration", () => { }); const layer = buildTestRuntime({ out, api, cliSettings }); - yield* sslEnforcementGet({ projectRef: Option.none() }).pipe(Effect.provide(layer)); + yield* sslEnforcementGet({ remote: Option.none(), projectRef: Option.none() }).pipe( + Effect.provide(layer), + ); expect(api.requests[0]?.url).toContain(`/v1/projects/${fileRef}/`); }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), ); @@ -305,7 +311,9 @@ describe("ssl-enforcement get integration", () => { const layer = buildTestRuntime({ out, api, cliSettings }); const exit = yield* Effect.exit( - sslEnforcementGet({ projectRef: Option.none() }).pipe(Effect.provide(layer)), + sslEnforcementGet({ remote: Option.none(), projectRef: Option.none() }).pipe( + Effect.provide(layer), + ), ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { @@ -315,20 +323,32 @@ describe("ssl-enforcement get integration", () => { ); it.effect("fails with InvalidProjectRefError when the resolved ref is malformed", () => { - const { layer } = setup({ response: SSL_ENFORCED }); + const out = mockOutput({ format: "text" }); + const api = mockCommandPlatformApi({ response: { status: 200, body: SSL_ENFORCED } }); + const layer = buildTestRuntime({ + out, + api, + cliSettings: mockCommandSettings({ + workdir: tempRoot.current, + projectId: Option.some("BADREF"), + }), + }); return Effect.gen(function* () { - const exit = yield* Effect.exit(sslEnforcementGet({ projectRef: Option.some("BADREF") })); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - expect(Cause.pretty(exit.cause)).toContain("InvalidProjectRefError"); - } + const error = yield* sslEnforcementGet({ + remote: Option.none(), + projectRef: Option.none(), + }).pipe(Effect.flip); + expect(error).toMatchObject({ _tag: "InvalidProjectRefError" }); + expect(api.requests).toEqual([]); }).pipe(Effect.provide(layer)); }); it.effect("fails with SslEnforcementGetUnexpectedStatusError on HTTP 503", () => { const { layer } = setup({ status: 503, response: SSL_ENFORCED }); return Effect.gen(function* () { - const exit = yield* Effect.exit(sslEnforcementGet({ projectRef: Option.none() })); + const exit = yield* Effect.exit( + sslEnforcementGet({ remote: Option.none(), projectRef: Option.none() }), + ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { const causeText = Cause.pretty(exit.cause); @@ -341,7 +361,9 @@ describe("ssl-enforcement get integration", () => { it.effect("fails with SslEnforcementGetNetworkError on transport failure", () => { const { layer } = setup({ network: "fail" }); return Effect.gen(function* () { - const exit = yield* Effect.exit(sslEnforcementGet({ projectRef: Option.none() })); + const exit = yield* Effect.exit( + sslEnforcementGet({ remote: Option.none(), projectRef: Option.none() }), + ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { const causeText = Cause.pretty(exit.cause); @@ -354,7 +376,9 @@ describe("ssl-enforcement get integration", () => { it.effect("emits a fail event when withJsonErrorHandling wraps a JSON-mode error", () => { const { layer, out } = setup({ format: "json", status: 503, response: SSL_ENFORCED }); return Effect.gen(function* () { - yield* sslEnforcementGet({ projectRef: Option.none() }).pipe(withJsonErrorHandling); + yield* sslEnforcementGet({ remote: Option.none(), projectRef: Option.none() }).pipe( + withJsonErrorHandling, + ); expect(out.messages.some((m) => m.type === "fail")).toBe(true); }).pipe(Effect.provide(layer)); }); @@ -373,7 +397,7 @@ describe("ssl-enforcement get integration", () => { linkedProjectCache: cache.layer, }); return Effect.gen(function* () { - yield* sslEnforcementGet({ projectRef: Option.none() }); + yield* sslEnforcementGet({ remote: Option.none(), projectRef: Option.none() }); expect(telemetry.flushed).toBe(true); expect(cache.cached).toBe(true); }).pipe(Effect.provide(layer)); @@ -404,11 +428,20 @@ describe("ssl-enforcement get integration", () => { }); const exit = yield* Effect.exit( - sslEnforcementGet({ projectRef: Option.none() }).pipe(Effect.provide(layer)), + sslEnforcementGet({ remote: Option.none(), projectRef: Option.none() }).pipe( + Effect.provide(layer), + ), ); expect(Exit.isFailure(exit)).toBe(true); expect(telemetry.flushed).toBe(true); expect(cache.cached).toBe(false); }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), ); + + describeHostedRemoteSelection({ + command: ["ssl-enforcement", "get"], + workdir: () => tempRoot.current, + respond: (request) => jsonResponse(request, 200, SSL_ENFORCED), + run: (selector, layer) => sslEnforcementGet(selector).pipe(Effect.provide(layer)), + }); }); 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 1c08bb5bdf..b07c5a66b9 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 @@ -2,8 +2,8 @@ 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, Layer, Option, Path, Schema } from "effect"; -import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; import { describeHostedConsent } from "../../../../tests/helpers/hosted-consent.ts"; +import { describeHostedRemoteSelection } from "../../../../tests/helpers/hosted-remote-selection.ts"; import { mockOutput, mockTty } from "../../../../tests/helpers/mocks.ts"; import { VALID_REF, @@ -103,6 +103,7 @@ describe("ssl-enforcement update integration", () => { return Effect.gen(function* () { const exit = yield* Effect.exit( sslEnforcementUpdate({ + remote: Option.none(), projectRef: Option.none(), enableDbSslEnforcement: false, disableDbSslEnforcement: false, @@ -122,6 +123,7 @@ describe("ssl-enforcement update integration", () => { return Effect.gen(function* () { const exit = yield* Effect.exit( sslEnforcementUpdate({ + remote: Option.none(), projectRef: Option.none(), enableDbSslEnforcement: true, disableDbSslEnforcement: true, @@ -143,6 +145,7 @@ describe("ssl-enforcement update integration", () => { return Effect.gen(function* () { yield* Effect.exit( sslEnforcementUpdate({ + remote: Option.none(), projectRef: Option.none(), enableDbSslEnforcement: false, disableDbSslEnforcement: false, @@ -169,6 +172,7 @@ describe("ssl-enforcement update integration", () => { return Effect.gen(function* () { yield* Effect.exit( sslEnforcementUpdate({ + remote: Option.none(), projectRef: Option.none(), enableDbSslEnforcement: false, disableDbSslEnforcement: false, @@ -183,6 +187,7 @@ describe("ssl-enforcement update integration", () => { const { layer, api } = setup({ response: SSL_ENFORCED }); return Effect.gen(function* () { yield* sslEnforcementUpdate({ + remote: Option.none(), projectRef: Option.none(), enableDbSslEnforcement: true, disableDbSslEnforcement: false, @@ -237,6 +242,7 @@ describe("ssl-enforcement update integration", () => { return Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; yield* sslEnforcementUpdate({ + remote: Option.none(), projectRef: Option.none(), enableDbSslEnforcement: true, disableDbSslEnforcement: false, @@ -267,6 +273,7 @@ describe("ssl-enforcement update integration", () => { const { layer, api } = setup({ response: SSL_NOT_ENFORCED }); return Effect.gen(function* () { yield* sslEnforcementUpdate({ + remote: Option.none(), projectRef: Option.none(), enableDbSslEnforcement: false, disableDbSslEnforcement: true, @@ -291,6 +298,7 @@ describe("ssl-enforcement update integration", () => { const { layer, out } = setup({ response: SSL_ENFORCED }); return Effect.gen(function* () { yield* sslEnforcementUpdate({ + remote: Option.none(), projectRef: Option.none(), enableDbSslEnforcement: true, disableDbSslEnforcement: false, @@ -304,6 +312,7 @@ describe("ssl-enforcement update integration", () => { const { layer, out } = setup({ response: SSL_NOT_ENFORCED }); return Effect.gen(function* () { yield* sslEnforcementUpdate({ + remote: Option.none(), projectRef: Option.none(), enableDbSslEnforcement: false, disableDbSslEnforcement: true, @@ -318,6 +327,7 @@ describe("ssl-enforcement update integration", () => { const { layer, out } = setup({ response: SSL_DESIRED_BUT_NOT_APPLIED }); return Effect.gen(function* () { yield* sslEnforcementUpdate({ + remote: Option.none(), projectRef: Option.none(), enableDbSslEnforcement: true, disableDbSslEnforcement: false, @@ -331,6 +341,7 @@ describe("ssl-enforcement update integration", () => { const { layer, out } = setup({ outputFlag: "env", response: SSL_ENFORCED }); return Effect.gen(function* () { yield* sslEnforcementUpdate({ + remote: Option.none(), projectRef: Option.none(), enableDbSslEnforcement: true, disableDbSslEnforcement: false, @@ -343,6 +354,7 @@ describe("ssl-enforcement update integration", () => { const { layer, out } = setup({ outputFlag: "json", response: SSL_ENFORCED }); return Effect.gen(function* () { yield* sslEnforcementUpdate({ + remote: Option.none(), projectRef: Option.none(), enableDbSslEnforcement: true, disableDbSslEnforcement: false, @@ -363,6 +375,7 @@ describe("ssl-enforcement update integration", () => { const { layer, out } = setup({ outputFlag: "yaml", response: SSL_ENFORCED }); return Effect.gen(function* () { yield* sslEnforcementUpdate({ + remote: Option.none(), projectRef: Option.none(), enableDbSslEnforcement: true, disableDbSslEnforcement: false, @@ -376,6 +389,7 @@ describe("ssl-enforcement update integration", () => { const { layer, out } = setup({ outputFlag: "toml", response: SSL_ENFORCED }); return Effect.gen(function* () { yield* sslEnforcementUpdate({ + remote: Option.none(), projectRef: Option.none(), enableDbSslEnforcement: true, disableDbSslEnforcement: false, @@ -389,6 +403,7 @@ describe("ssl-enforcement update integration", () => { const { layer, out } = setup({ outputFlag: "pretty", response: SSL_ENFORCED }); return Effect.gen(function* () { yield* sslEnforcementUpdate({ + remote: Option.none(), projectRef: Option.none(), enableDbSslEnforcement: true, disableDbSslEnforcement: false, @@ -401,6 +416,7 @@ describe("ssl-enforcement update integration", () => { const { layer, out } = setup({ format: "json", response: SSL_ENFORCED }); return Effect.gen(function* () { yield* sslEnforcementUpdate({ + remote: Option.none(), projectRef: Option.none(), enableDbSslEnforcement: true, disableDbSslEnforcement: false, @@ -418,6 +434,7 @@ describe("ssl-enforcement update integration", () => { const { layer, out } = setup({ format: "stream-json", response: SSL_ENFORCED }); return Effect.gen(function* () { yield* sslEnforcementUpdate({ + remote: Option.none(), projectRef: Option.none(), enableDbSslEnforcement: true, disableDbSslEnforcement: false, @@ -432,6 +449,7 @@ describe("ssl-enforcement update integration", () => { const { layer, out } = setup({ format: "json", outputFlag: "yaml", response: SSL_ENFORCED }); return Effect.gen(function* () { yield* sslEnforcementUpdate({ + remote: Option.none(), projectRef: Option.none(), enableDbSslEnforcement: true, disableDbSslEnforcement: false, @@ -445,6 +463,7 @@ describe("ssl-enforcement update integration", () => { const { layer, api } = setup({ response: SSL_ENFORCED }); return Effect.gen(function* () { yield* sslEnforcementUpdate({ + remote: Option.none(), projectRef: Option.none(), enableDbSslEnforcement: true, disableDbSslEnforcement: false, @@ -454,12 +473,13 @@ describe("ssl-enforcement update integration", () => { }).pipe(Effect.provide(layer)); }); - it.effect("uses --project-ref flag value over CommandSettings.projectId", () => { + it.effect("uses the --remote=<ref> value over CommandSettings.projectId", () => { const flagRef = "zzzzzzzzzzzzzzzzzzzz"; const { layer, api } = setup({ response: SSL_ENFORCED }); return Effect.gen(function* () { yield* sslEnforcementUpdate({ - projectRef: Option.some(flagRef), + remote: Option.some({ _tag: "Ref", ref: flagRef }), + projectRef: Option.none(), enableDbSslEnforcement: true, disableDbSslEnforcement: false, }); @@ -490,6 +510,7 @@ describe("ssl-enforcement update integration", () => { const layer = Layer.mergeAll(buildTestRuntime({ out, api, cliSettings }), consentApproved); yield* sslEnforcementUpdate({ + remote: Option.none(), projectRef: Option.none(), enableDbSslEnforcement: true, disableDbSslEnforcement: false, @@ -514,6 +535,7 @@ describe("ssl-enforcement update integration", () => { const exit = yield* Effect.exit( sslEnforcementUpdate({ + remote: Option.none(), projectRef: Option.none(), enableDbSslEnforcement: true, disableDbSslEnforcement: false, @@ -527,70 +549,43 @@ describe("ssl-enforcement update integration", () => { ); it.effect("fails with InvalidProjectRefError when the resolved ref is malformed", () => { - const { layer } = setup({ response: SSL_ENFORCED }); - return Effect.gen(function* () { - const exit = yield* Effect.exit( - sslEnforcementUpdate({ - projectRef: Option.some("BADREF"), - enableDbSslEnforcement: true, - disableDbSslEnforcement: false, - }), - ); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - expect(Cause.pretty(exit.cause)).toContain("InvalidProjectRefError"); - } - }).pipe(Effect.provide(layer)); - }); - - it.effect("fails with SslEnforcementUpdateUnexpectedStatusError on HTTP 503", () => { - const { layer } = setup({ status: 503, response: SSL_ENFORCED }); - return Effect.gen(function* () { - const exit = yield* Effect.exit( - sslEnforcementUpdate({ - projectRef: Option.none(), - enableDbSslEnforcement: true, - disableDbSslEnforcement: false, + const out = mockOutput({ format: "text" }); + const api = mockCommandPlatformApi({ response: { status: 200, body: SSL_ENFORCED } }); + const layer = Layer.mergeAll( + buildTestRuntime({ + out, + api, + cliSettings: mockCommandSettings({ + workdir: tempRoot.current, + projectId: Option.some("BADREF"), }), - ); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - const causeText = Cause.pretty(exit.cause); - expect(causeText).toContain("SslEnforcementUpdateUnexpectedStatusError"); - expect(causeText).toContain("unexpected update SSL status 503"); - } - }).pipe(Effect.provide(layer)); - }); - - it.effect("fails with SslEnforcementUpdateNetworkError on transport failure", () => { - const { layer } = setup({ network: "fail" }); + }), + consentApproved, + ); return Effect.gen(function* () { - const exit = yield* Effect.exit( - sslEnforcementUpdate({ - projectRef: Option.none(), - enableDbSslEnforcement: true, - disableDbSslEnforcement: false, - }), - ); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - const causeText = Cause.pretty(exit.cause); - expect(causeText).toContain("SslEnforcementUpdateNetworkError"); - expect(causeText).toContain("failed to update ssl enforcement"); - } + const error = yield* sslEnforcementUpdate({ + remote: Option.none(), + projectRef: Option.none(), + enableDbSslEnforcement: true, + disableDbSslEnforcement: false, + }).pipe(Effect.flip); + expect(error).toMatchObject({ _tag: "InvalidProjectRefError" }); + expect(api.requests).toEqual([]); }).pipe(Effect.provide(layer)); }); - it.effect("emits a fail event when withJsonErrorHandling wraps a JSON-mode error", () => { - const { layer, out } = setup({ format: "json", status: 503, response: SSL_ENFORCED }); - return Effect.gen(function* () { - yield* sslEnforcementUpdate({ - projectRef: Option.none(), + describeHostedRemoteSelection({ + command: ["ssl-enforcement", "update"], + args: ["--enable-db-ssl-enforcement"], + workdir: () => tempRoot.current, + activeFlags: { "enable-db-ssl-enforcement": true, "disable-db-ssl-enforcement": false }, + respond: (request) => jsonResponse(request, 200, SSL_ENFORCED), + run: (selector, layer) => + sslEnforcementUpdate({ + ...selector, enableDbSslEnforcement: true, disableDbSslEnforcement: false, - }).pipe(withJsonErrorHandling); - expect(out.messages.some((m) => m.type === "fail")).toBe(true); - }).pipe(Effect.provide(layer)); + }).pipe(Effect.provide(layer)), }); }); @@ -603,7 +598,8 @@ describeHostedConsent("ssl-enforcement update", { }, invoke: () => ({ run: sslEnforcementUpdate({ - projectRef: Option.some(VALID_REF), + remote: Option.some({ _tag: "Ref", ref: VALID_REF }), + projectRef: Option.none(), enableDbSslEnforcement: true, disableDbSslEnforcement: false, }), 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 f05cc3c662..4061a9ea47 100644 --- a/apps/cli/src/commands/sso/add/add.integration.test.ts +++ b/apps/cli/src/commands/sso/add/add.integration.test.ts @@ -25,6 +25,7 @@ import { useTempWorkdir, } from "../../../../tests/helpers/command-mocks.ts"; import { describeHostedConsent } from "../../../../tests/helpers/hosted-consent.ts"; +import { describeHostedRemoteSelection } from "../../../../tests/helpers/hosted-remote-selection.ts"; import { mockAnalytics, mockOutput, mockTty } from "../../../../tests/helpers/mocks.ts"; import { assertPermissionDrift } from "../../../../tests/helpers/permission-drift.ts"; import { @@ -191,6 +192,7 @@ function setup(opts: SetupOpts = {}) { } const defaultFlags: SsoAddFlags = { + remote: Option.none(), projectRef: Option.none(), type: "saml", domains: [], @@ -384,7 +386,7 @@ describe("sso add integration", () => { ); it.effect( - "reconciles project-ref consuming --metadata-file: fails ref validation, never reads metadata", + "reconciles project-ref consuming --metadata-file: looks the value up as a branch, never reads metadata", () => { const { layer, api } = setup({ cliArgs: [ @@ -410,10 +412,12 @@ describe("sso add integration", () => { expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { const dump = Cause.pretty(exit.cause); - expect(dump).toContain("InvalidProjectRefError"); - expect(dump).toContain("Invalid project ref format. Must be like"); + expect(dump).toContain("LinkBranchListStatusError"); + expect(dump).not.toContain("SsoAddMetadataFileError"); } - expect(api.requests.some((r) => r.method === "POST")).toBe(false); + expect(api.requests.map(({ method, url }) => `${method} ${url}`)).toEqual([ + `GET ${DEFAULT_API_URL}/v1/projects/${VALID_REF}/branches`, + ]); }).pipe(Effect.provide(layer)); }, ); @@ -1333,8 +1337,16 @@ describe("sso add integration", () => { describeHostedConsent("sso add", { api: { response: { status: 201, body: RESPONSE_PROVIDER } }, invoke: () => ({ - run: ssoAdd({ ...defaultFlags, projectRef: Option.some(VALID_REF) }).pipe( + run: ssoAdd({ ...defaultFlags, remote: Option.some({ _tag: "Ref", ref: VALID_REF }) }).pipe( Effect.provide(Stdio.layerTest({ args: Effect.succeed(["sso", "add", "--type", "saml"]) })), ), }), }); + +describeHostedRemoteSelection({ + command: ["sso", "add"], + args: ["--type", "saml"], + workdir: () => tempRoot.current, + respond: (request) => jsonResponse(request, 201, RESPONSE_PROVIDER), + run: (selector, layer) => ssoAdd({ ...defaultFlags, ...selector }).pipe(Effect.provide(layer)), +}); diff --git a/apps/cli/src/commands/sso/info/info.integration.test.ts b/apps/cli/src/commands/sso/info/info.integration.test.ts index 6238db8b3f..62f31b878e 100644 --- a/apps/cli/src/commands/sso/info/info.integration.test.ts +++ b/apps/cli/src/commands/sso/info/info.integration.test.ts @@ -12,6 +12,7 @@ import { mockTelemetryStateTracked, useTempWorkdir, } from "../../../../tests/helpers/command-mocks.ts"; +import { describeHostedRemoteSelection } from "../../../../tests/helpers/hosted-remote-selection.ts"; import { assertPermissionDrift } from "../../../../tests/helpers/permission-drift.ts"; import { projectResponse, @@ -57,7 +58,7 @@ describe("sso info integration", () => { it.effect("renders a 3-row markdown table in text mode", () => { const { layer, out } = setup(); return Effect.gen(function* () { - yield* ssoInfo({ projectRef: Option.none() }); + yield* ssoInfo({ remote: Option.none(), projectRef: Option.none() }); expect(out.stdoutText).toContain("Single sign-on URL (ACS URL)"); expect(out.stdoutText).toContain("Audience URI (SP Entity ID)"); expect(out.stdoutText).toContain("Default Relay State"); @@ -70,7 +71,7 @@ describe("sso info integration", () => { it.effect("emits exactly one space between the ACS URL label and the column separator", () => { const { layer, out } = setup(); return Effect.gen(function* () { - yield* ssoInfo({ projectRef: Option.none() }); + yield* ssoInfo({ remote: Option.none(), projectRef: Option.none() }); expect(out.stdoutText).toContain(`Single sign-on URL (ACS URL) | ${expectedAcsUrl}`); expect(out.stdoutText).not.toContain("Single sign-on URL (ACS URL) |"); }).pipe(Effect.provide(layer)); @@ -79,7 +80,7 @@ describe("sso info integration", () => { it.effect("TS --output-format=json emits a structured payload", () => { const { layer, out } = setup({ format: "json" }); return Effect.gen(function* () { - yield* ssoInfo({ projectRef: Option.none() }); + yield* ssoInfo({ remote: Option.none(), projectRef: Option.none() }); const success = out.messages.find((m) => m.type === "success"); expect(success).toBeDefined(); expect(success?.data).toEqual({ @@ -93,7 +94,7 @@ describe("sso info integration", () => { it.effect("--output=env emits ACS_URL / ENTITY_ID / RELAY_STATE alphabetized", () => { const { layer, out } = setup({ outputFlag: "env" }); return Effect.gen(function* () { - yield* ssoInfo({ projectRef: Option.none() }); + yield* ssoInfo({ remote: Option.none(), projectRef: Option.none() }); expect(out.stdoutText).toContain("ACS_URL="); expect(out.stdoutText).toContain("ENTITY_ID="); expect(out.stdoutText).toContain("RELAY_STATE="); @@ -103,7 +104,7 @@ describe("sso info integration", () => { it.effect("--output=json sorts keys alphabetically and includes all three", () => { const { layer, out } = setup({ outputFlag: "json" }); return Effect.gen(function* () { - yield* ssoInfo({ projectRef: Option.none() }); + yield* ssoInfo({ remote: Option.none(), projectRef: Option.none() }); expect(out.stdoutText).toContain('"acs_url"'); expect(out.stdoutText).toContain('"entity_id"'); expect(out.stdoutText).toContain('"relay_state"'); @@ -116,7 +117,7 @@ describe("sso info integration", () => { it.effect("--output=yaml emits the three keys", () => { const { layer, out } = setup({ outputFlag: "yaml" }); return Effect.gen(function* () { - yield* ssoInfo({ projectRef: Option.none() }); + yield* ssoInfo({ remote: Option.none(), projectRef: Option.none() }); expect(out.stdoutText).toContain("acs_url:"); expect(out.stdoutText).toContain("entity_id:"); expect(out.stdoutText).toContain("relay_state:"); @@ -126,18 +127,21 @@ describe("sso info integration", () => { it.effect("--output=toml emits the three keys", () => { const { layer, out } = setup({ outputFlag: "toml" }); return Effect.gen(function* () { - yield* ssoInfo({ projectRef: Option.none() }); + yield* ssoInfo({ remote: Option.none(), projectRef: Option.none() }); expect(out.stdoutText).toContain("acs_url"); expect(out.stdoutText).toContain("entity_id"); expect(out.stdoutText).toContain("relay_state"); }).pipe(Effect.provide(layer)); }); - it.effect("URLs derive from --project-ref flag value when set", () => { + it.effect("URLs derive from the --remote=<ref> value when set", () => { const flagRef = "zzzzzzzzzzzzzzzzzzzz"; const { layer, out } = setup(); return Effect.gen(function* () { - yield* ssoInfo({ projectRef: Option.some(flagRef) }); + yield* ssoInfo({ + remote: Option.some({ _tag: "Ref", ref: flagRef }), + projectRef: Option.none(), + }); expect(out.stdoutText).toContain(`https://${flagRef}.supabase.co`); }).pipe(Effect.provide(layer)); }); @@ -145,7 +149,7 @@ describe("sso info integration", () => { it.effect("flushes telemetry + linked-project cache on success", () => { const { layer, telemetry, cache } = setup(); return Effect.gen(function* () { - yield* ssoInfo({ projectRef: Option.none() }); + yield* ssoInfo({ remote: Option.none(), projectRef: Option.none() }); expect(telemetry.flushed).toBe(true); expect(cache.cached).toBe(true); }).pipe(Effect.provide(layer)); @@ -154,7 +158,7 @@ describe("sso info integration", () => { it.effect("makes no Management API call of its own", () => { const { layer, api } = setup(); return Effect.gen(function* () { - yield* ssoInfo({ projectRef: Option.none() }); + yield* ssoInfo({ remote: Option.none(), projectRef: Option.none() }); expect(api.requests).toHaveLength(0); }).pipe(Effect.provide(layer)); }); @@ -187,7 +191,7 @@ describe("sso info integration", () => { const fs = yield* FileSystem.FileSystem; const cachePath = `${tempRoot.current}/supabase/.temp/linked-project.json`; expect(yield* fs.exists(cachePath)).toBe(false); - yield* ssoInfo({ projectRef: Option.none() }); + yield* ssoInfo({ remote: Option.none(), projectRef: Option.none() }); expect(api.requests.map(({ method, url }) => `${method} ${url}`)).toEqual([ "GET https://api.supabase.com/v1/projects", `GET https://api.supabase.com/v1/projects/${VALID_REF}`, @@ -200,3 +204,10 @@ describe("sso info integration", () => { }).pipe(Effect.provide(layer)); }); }); + +describeHostedRemoteSelection({ + command: ["sso", "info"], + workdir: () => tempRoot.current, + respond: (request) => jsonResponse(request, 200, {}), + run: (selector, layer) => ssoInfo(selector).pipe(Effect.provide(layer)), +}); diff --git a/apps/cli/src/commands/sso/list/list.integration.test.ts b/apps/cli/src/commands/sso/list/list.integration.test.ts index 057989037d..0bb0af4c42 100644 --- a/apps/cli/src/commands/sso/list/list.integration.test.ts +++ b/apps/cli/src/commands/sso/list/list.integration.test.ts @@ -12,6 +12,7 @@ import { mockTelemetryStateTracked, useTempWorkdir, } from "../../../../tests/helpers/command-mocks.ts"; +import { describeHostedRemoteSelection } from "../../../../tests/helpers/hosted-remote-selection.ts"; import { assertPermissionDrift } from "../../../../tests/helpers/permission-drift.ts"; import { projectResponse, @@ -153,7 +154,7 @@ describe("sso list integration", () => { it.effect("renders an ASCII table in text mode", () => { const { layer, out } = setup(); return Effect.gen(function* () { - yield* ssoList({ projectRef: Option.none() }); + yield* ssoList({ remote: Option.none(), projectRef: Option.none() }); expect(out.stdoutText).toContain("TYPE"); expect(out.stdoutText).toContain("IDENTITY PROVIDER ID"); expect(out.stdoutText).toContain("0b0d48f6-878b-4190-88d7-2ca33ed800bc"); @@ -179,7 +180,7 @@ describe("sso list integration", () => { }; const { layer, out } = setup({ outputFlag: "json", body: { items: [item] } }); return Effect.gen(function* () { - yield* ssoList({ projectRef: Option.none() }); + yield* ssoList({ remote: Option.none(), projectRef: Option.none() }); const emitted = yield* Schema.decodeEffect( Schema.fromJsonString( Schema.Struct({ @@ -211,7 +212,7 @@ describe("sso list integration", () => { it.effect("emits a success payload via --output-format=json", () => { const { layer, out } = setup({ format: "json" }); return Effect.gen(function* () { - yield* ssoList({ projectRef: Option.none() }); + yield* ssoList({ remote: Option.none(), projectRef: Option.none() }); const success = out.messages.find((m) => m.type === "success"); expect(success).toBeDefined(); expect(success?.data).toHaveProperty("providers.length", 1); @@ -221,7 +222,7 @@ describe("sso list integration", () => { it.effect("emits a result event via --output-format=stream-json", () => { const { layer, out } = setup({ format: "stream-json" }); return Effect.gen(function* () { - yield* ssoList({ projectRef: Option.none() }); + yield* ssoList({ remote: Option.none(), projectRef: Option.none() }); expect(out.messages.some((m) => m.type === "success")).toBe(true); }).pipe(Effect.provide(layer)); }); @@ -229,7 +230,7 @@ describe("sso list integration", () => { it.effect("--output=json wraps response in `{providers: …}`", () => { const { layer, out } = setup({ outputFlag: "json" }); return Effect.gen(function* () { - yield* ssoList({ projectRef: Option.none() }); + yield* ssoList({ remote: Option.none(), projectRef: Option.none() }); expect(out.stdoutText.startsWith("{")).toBe(true); expect(out.stdoutText).toContain('"providers"'); expect(out.stdoutText).toContain("0b0d48f6-878b-4190-88d7-2ca33ed800bc"); @@ -239,7 +240,7 @@ describe("sso list integration", () => { it.effect("--output=yaml emits providers key", () => { const { layer, out } = setup({ outputFlag: "yaml" }); return Effect.gen(function* () { - yield* ssoList({ projectRef: Option.none() }); + yield* ssoList({ remote: Option.none(), projectRef: Option.none() }); expect(out.stdoutText).toContain("providers:"); }).pipe(Effect.provide(layer)); }); @@ -247,7 +248,7 @@ describe("sso list integration", () => { it.effect("--output=toml emits provider data", () => { const { layer, out } = setup({ outputFlag: "toml" }); return Effect.gen(function* () { - yield* ssoList({ projectRef: Option.none() }); + yield* ssoList({ remote: Option.none(), projectRef: Option.none() }); expect(out.stdoutText.length).toBeGreaterThan(0); }).pipe(Effect.provide(layer)); }); @@ -262,7 +263,9 @@ describe("sso list integration", () => { }; const { layer, out } = setup({ outputFlag: "toml", body: { items: [item] } }); return Effect.gen(function* () { - const exit = yield* Effect.exit(ssoList({ projectRef: Option.none() })); + const exit = yield* Effect.exit( + ssoList({ remote: Option.none(), projectRef: Option.none() }), + ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { const dump = Cause.pretty(exit.cause); @@ -276,7 +279,7 @@ describe("sso list integration", () => { it.effect("--output=env emits a flat PROVIDERS= entry", () => { const { layer, out } = setup({ outputFlag: "env" }); return Effect.gen(function* () { - yield* ssoList({ projectRef: Option.none() }); + yield* ssoList({ remote: Option.none(), projectRef: Option.none() }); expect(out.stdoutText).toContain("PROVIDERS="); }).pipe(Effect.provide(layer)); }); @@ -284,7 +287,7 @@ describe("sso list integration", () => { it.effect("--output=pretty falls through to text rendering", () => { const { layer, out } = setup({ outputFlag: "pretty" }); return Effect.gen(function* () { - yield* ssoList({ projectRef: Option.none() }); + yield* ssoList({ remote: Option.none(), projectRef: Option.none() }); expect(out.stdoutText).toContain("IDENTITY PROVIDER ID"); }).pipe(Effect.provide(layer)); }); @@ -292,7 +295,9 @@ describe("sso list integration", () => { it.effect("rejects a 200 response containing a null provider item", () => { const { layer } = setup({ body: { items: [null] } }); return Effect.gen(function* () { - const exit = yield* Effect.exit(ssoList({ projectRef: Option.none() })); + const exit = yield* Effect.exit( + ssoList({ remote: Option.none(), projectRef: Option.none() }), + ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { const dump = Cause.pretty(exit.cause); @@ -309,7 +314,7 @@ describe("sso list integration", () => { it.effect("--output wins over --output-format when both set", () => { const { layer, out } = setup({ format: "json", outputFlag: "yaml" }); return Effect.gen(function* () { - yield* ssoList({ projectRef: Option.none() }); + yield* ssoList({ remote: Option.none(), projectRef: Option.none() }); expect(out.stdoutText.startsWith("{")).toBe(false); expect(out.stdoutText).toContain("providers:"); }).pipe(Effect.provide(layer)); @@ -318,7 +323,9 @@ describe("sso list integration", () => { it.effect("reports SAML-disabled error on 404", () => { const { layer } = setup({ status: 404, body: {} }); return Effect.gen(function* () { - const exit = yield* Effect.exit(ssoList({ projectRef: Option.none() })); + const exit = yield* Effect.exit( + ssoList({ remote: Option.none(), projectRef: Option.none() }), + ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { const dump = Cause.pretty(exit.cause); @@ -331,7 +338,7 @@ describe("sso list integration", () => { it.effect("fires cli_upgrade_suggested on 404 when entitlement is gated", () => { const { layer, analytics } = setup({ status: 404, body: {}, upgradeGate: "gated" }); return Effect.gen(function* () { - yield* Effect.exit(ssoList({ projectRef: Option.none() })); + yield* Effect.exit(ssoList({ remote: Option.none(), projectRef: Option.none() })); expect(analytics.captured.some((c) => c.event === EventUpgradeSuggested)).toBe(true); }).pipe(Effect.provide(layer)); }); @@ -339,7 +346,7 @@ describe("sso list integration", () => { it.effect("does NOT fire cli_upgrade_suggested when entitlement is not gated", () => { const { layer, analytics, api } = setup({ status: 404, body: {}, upgradeGate: "notGated" }); return Effect.gen(function* () { - yield* Effect.exit(ssoList({ projectRef: Option.none() })); + yield* Effect.exit(ssoList({ remote: Option.none(), projectRef: Option.none() })); expect(analytics.captured.some((c) => c.event === EventUpgradeSuggested)).toBe(false); expect(api.requests.map(({ method, url }) => `${method} ${url}`)).toEqual([ `GET https://api.supabase.com/v1/projects/${VALID_REF}/config/auth/sso/providers`, @@ -353,7 +360,9 @@ describe("sso list integration", () => { it.effect("reports unexpected-status error on 500", () => { const { layer } = setup({ status: 500, body: { error: "boom" } }); return Effect.gen(function* () { - const exit = yield* Effect.exit(ssoList({ projectRef: Option.none() })); + const exit = yield* Effect.exit( + ssoList({ remote: Option.none(), projectRef: Option.none() }), + ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { const dump = Cause.pretty(exit.cause); @@ -366,7 +375,9 @@ describe("sso list integration", () => { it.effect("reports network error on transport failure", () => { const { layer } = setup({ network: "fail" }); return Effect.gen(function* () { - const exit = yield* Effect.exit(ssoList({ projectRef: Option.none() })); + const exit = yield* Effect.exit( + ssoList({ remote: Option.none(), projectRef: Option.none() }), + ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { const dump = Cause.pretty(exit.cause); @@ -376,11 +387,14 @@ describe("sso list integration", () => { }).pipe(Effect.provide(layer)); }); - it.effect("uses --project-ref flag value over resolver default", () => { + it.effect("uses the --remote=<ref> value over resolver default", () => { const flagRef = "zzzzzzzzzzzzzzzzzzzz"; const { layer, api } = setup(); return Effect.gen(function* () { - yield* ssoList({ projectRef: Option.some(flagRef) }); + yield* ssoList({ + remote: Option.some({ _tag: "Ref", ref: flagRef }), + projectRef: Option.none(), + }); const ssoRequest = api.requests.find((r) => r.url.includes(`/v1/projects/${flagRef}/config/auth/sso/providers`), ); @@ -391,29 +405,21 @@ describe("sso list integration", () => { it.effect("hits GET /v1/projects/{ref}/config/auth/sso/providers", () => { const { layer, api } = setup(); return Effect.gen(function* () { - yield* ssoList({ projectRef: Option.none() }); + yield* ssoList({ remote: Option.none(), projectRef: Option.none() }); const ssoRequest = api.requests.find((r) => r.url.endsWith(`/config/auth/sso/providers`)); expect(ssoRequest?.method).toBe("GET"); assertPermissionDrift({ command: "sso list", requests: api.requests, exact: true }); }).pipe(Effect.provide(layer)); }); - it.effect("ignores an empty --project-ref and resolves the saved ref without the picker", () => { + it.effect("rejects an empty --project-ref before the picker or any request", () => { const { layer, api } = setup({ projectId: Option.none() }); 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* ssoList({ projectRef: Option.some("") }); - expect(api.requests.map(({ method, url }) => `${method} ${url}`)).toEqual([ - `GET https://api.supabase.com/v1/projects/${VALID_REF}/config/auth/sso/providers`, - ]); - assertPermissionDrift({ - command: "sso list", - activeFlags: { "project-ref": "" }, - requests: api.requests, - exact: true, - }); + const error = yield* ssoList({ remote: Option.none(), projectRef: Option.some("") }).pipe( + Effect.flip, + ); + expect(error).toMatchObject({ _tag: "TargetEmptyValueError", flag: "--project-ref" }); + expect(api.requests).toEqual([]); }).pipe(Effect.provide(layer)); }); @@ -450,7 +456,7 @@ describe("sso list integration", () => { const fs = yield* FileSystem.FileSystem; const cachePath = `${tempRoot.current}/supabase/.temp/linked-project.json`; expect(yield* fs.exists(cachePath)).toBe(false); - yield* ssoList({ projectRef: Option.none() }); + yield* ssoList({ remote: Option.none(), projectRef: Option.none() }); expect(api.requests.map(({ method, url }) => `${method} ${url}`)).toEqual([ "GET https://api.supabase.com/v1/projects", `GET https://api.supabase.com/v1/projects/${VALID_REF}/config/auth/sso/providers`, @@ -468,7 +474,7 @@ describe("sso list integration", () => { it.effect("flushes telemetry + linked-project cache on success", () => { const { layer, telemetry, cache } = setup(); return Effect.gen(function* () { - yield* ssoList({ projectRef: Option.none() }); + yield* ssoList({ remote: Option.none(), projectRef: Option.none() }); expect(telemetry.flushed).toBe(true); expect(cache.cached).toBe(true); }).pipe(Effect.provide(layer)); @@ -477,7 +483,7 @@ describe("sso list integration", () => { it.effect("flushes telemetry even on API failure", () => { const { layer, telemetry } = setup({ status: 500, body: {} }); return Effect.gen(function* () { - yield* Effect.exit(ssoList({ projectRef: Option.none() })); + yield* Effect.exit(ssoList({ remote: Option.none(), projectRef: Option.none() })); expect(telemetry.flushed).toBe(true); }).pipe(Effect.provide(layer)); }); @@ -485,8 +491,17 @@ describe("sso list integration", () => { it.effect("emits a fail event when withJsonErrorHandling wraps a JSON-mode error", () => { const { layer, out } = setup({ format: "json", status: 500, body: {} }); return Effect.gen(function* () { - yield* ssoList({ projectRef: Option.none() }).pipe(withJsonErrorHandling); + yield* ssoList({ remote: Option.none(), projectRef: Option.none() }).pipe( + withJsonErrorHandling, + ); expect(out.messages.some((m) => m.type === "fail")).toBe(true); }).pipe(Effect.provide(layer)); }); }); + +describeHostedRemoteSelection({ + command: ["sso", "list"], + workdir: () => tempRoot.current, + respond: (request) => jsonResponse(request, 200, { items: [PROVIDER_ITEM] }), + run: (selector, layer) => ssoList(selector).pipe(Effect.provide(layer)), +}); 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 6b685541c3..6517086057 100644 --- a/apps/cli/src/commands/sso/remove/remove.integration.test.ts +++ b/apps/cli/src/commands/sso/remove/remove.integration.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "@effect/vitest"; import { Cause, Effect, Exit, FileSystem, Layer, Option, Schema } from "effect"; import * as HttpClientResponse from "effect/http/HttpClientResponse"; import { describeHostedConsent } from "../../../../tests/helpers/hosted-consent.ts"; +import { describeHostedRemoteSelection } from "../../../../tests/helpers/hosted-remote-selection.ts"; import { mockAnalytics, mockOutput, mockTty } from "../../../../tests/helpers/mocks.ts"; import { buildTestRuntime, @@ -139,7 +140,7 @@ describe("sso remove integration", () => { const { layer } = setup(); return Effect.gen(function* () { const exit = yield* Effect.exit( - ssoRemove({ projectRef: Option.none(), providerId: "not-a-uuid" }), + ssoRemove({ remote: Option.none(), projectRef: Option.none(), providerId: "not-a-uuid" }), ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { @@ -151,7 +152,11 @@ describe("sso remove integration", () => { it.effect("DELETEs the correct path and renders provider in text mode", () => { const { layer, out, api } = setup(); return Effect.gen(function* () { - yield* ssoRemove({ projectRef: Option.none(), providerId: VALID_PROVIDER_ID }); + yield* ssoRemove({ + remote: Option.none(), + projectRef: Option.none(), + providerId: VALID_PROVIDER_ID, + }); const req = api.requests.find((r) => r.method === "DELETE"); expect(req?.url).toContain( `/v1/projects/${VALID_REF}/config/auth/sso/providers/${VALID_PROVIDER_ID}`, @@ -165,7 +170,11 @@ describe("sso remove integration", () => { const { layer } = setup({ status: 404, body: {} }); return Effect.gen(function* () { const exit = yield* Effect.exit( - ssoRemove({ projectRef: Option.none(), providerId: VALID_PROVIDER_ID }), + ssoRemove({ + remote: Option.none(), + projectRef: Option.none(), + providerId: VALID_PROVIDER_ID, + }), ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { @@ -178,7 +187,11 @@ describe("sso remove integration", () => { const { layer } = setup({ status: 500, body: { error: "boom" } }); return Effect.gen(function* () { const exit = yield* Effect.exit( - ssoRemove({ projectRef: Option.none(), providerId: VALID_PROVIDER_ID }), + ssoRemove({ + remote: Option.none(), + projectRef: Option.none(), + providerId: VALID_PROVIDER_ID, + }), ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { @@ -192,7 +205,13 @@ describe("sso remove integration", () => { it.effect("fires cli_upgrade_suggested when gated on 4xx", () => { const { layer, analytics, api } = setup({ status: 404, body: {}, upgradeGate: "gated" }); return Effect.gen(function* () { - yield* Effect.exit(ssoRemove({ projectRef: Option.none(), providerId: VALID_PROVIDER_ID })); + yield* Effect.exit( + ssoRemove({ + remote: Option.none(), + projectRef: Option.none(), + providerId: VALID_PROVIDER_ID, + }), + ); expect(analytics.captured.some((c) => c.event === EventUpgradeSuggested)).toBe(true); expect(api.requests.map(({ method, url }) => `${method} ${url}`)).toEqual([ `DELETE https://api.supabase.com/v1/projects/${VALID_REF}/config/auth/sso/providers/${VALID_PROVIDER_ID}`, @@ -206,7 +225,11 @@ describe("sso remove integration", () => { it.effect("--output=env emits nothing", () => { const { layer, out } = setup({ outputFlag: "env" }); return Effect.gen(function* () { - yield* ssoRemove({ projectRef: Option.none(), providerId: VALID_PROVIDER_ID }); + yield* ssoRemove({ + remote: Option.none(), + projectRef: Option.none(), + providerId: VALID_PROVIDER_ID, + }); expect(out.stdoutText).toBe(""); }).pipe(Effect.provide(layer)); }); @@ -214,7 +237,11 @@ describe("sso remove integration", () => { it.effect("--output=json encodes response verbatim", () => { const { layer, out } = setup({ outputFlag: "json" }); return Effect.gen(function* () { - yield* ssoRemove({ projectRef: Option.none(), providerId: VALID_PROVIDER_ID }); + yield* ssoRemove({ + remote: Option.none(), + projectRef: Option.none(), + providerId: VALID_PROVIDER_ID, + }); expect(out.stdoutText).toContain(VALID_PROVIDER_ID); }).pipe(Effect.provide(layer)); }); @@ -233,7 +260,11 @@ describe("sso remove integration", () => { const { layer, out } = setup({ outputFlag: "toml", body }); return Effect.gen(function* () { const exit = yield* Effect.exit( - ssoRemove({ projectRef: Option.none(), providerId: VALID_PROVIDER_ID }), + ssoRemove({ + remote: Option.none(), + projectRef: Option.none(), + providerId: VALID_PROVIDER_ID, + }), ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { @@ -248,7 +279,11 @@ describe("sso remove integration", () => { it.effect("TS --output-format=json emits success", () => { const { layer, out } = setup({ format: "json" }); return Effect.gen(function* () { - yield* ssoRemove({ projectRef: Option.none(), providerId: VALID_PROVIDER_ID }); + yield* ssoRemove({ + remote: Option.none(), + projectRef: Option.none(), + providerId: VALID_PROVIDER_ID, + }); expect(out.messages.some((m) => m.type === "success")).toBe(true); }).pipe(Effect.provide(layer)); }); @@ -290,7 +325,11 @@ describe("sso remove integration", () => { const fs = yield* FileSystem.FileSystem; const cachePath = `${tempRoot.current}/supabase/.temp/linked-project.json`; expect(yield* fs.exists(cachePath)).toBe(false); - yield* ssoRemove({ projectRef: Option.none(), providerId: VALID_PROVIDER_ID }); + yield* ssoRemove({ + remote: Option.none(), + projectRef: Option.none(), + providerId: VALID_PROVIDER_ID, + }); 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}/config/auth/sso/providers/${VALID_PROVIDER_ID}`, @@ -307,7 +346,11 @@ describe("sso remove integration", () => { it.effect("flushes telemetry + linked-project cache on success", () => { const { layer, telemetry, cache } = setup(); return Effect.gen(function* () { - yield* ssoRemove({ projectRef: Option.none(), providerId: VALID_PROVIDER_ID }); + yield* ssoRemove({ + remote: Option.none(), + projectRef: Option.none(), + providerId: VALID_PROVIDER_ID, + }); expect(telemetry.flushed).toBe(true); expect(cache.cached).toBe(true); }).pipe(Effect.provide(layer)); @@ -317,7 +360,11 @@ describe("sso remove integration", () => { const { layer } = setup({ network: "fail" }); return Effect.gen(function* () { const exit = yield* Effect.exit( - ssoRemove({ projectRef: Option.none(), providerId: VALID_PROVIDER_ID }), + ssoRemove({ + remote: Option.none(), + projectRef: Option.none(), + providerId: VALID_PROVIDER_ID, + }), ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { @@ -330,7 +377,11 @@ describe("sso remove integration", () => { const { layer } = setup({ rawBody: "{not json" }); return Effect.gen(function* () { const exit = yield* Effect.exit( - ssoRemove({ projectRef: Option.none(), providerId: VALID_PROVIDER_ID }), + ssoRemove({ + remote: Option.none(), + projectRef: Option.none(), + providerId: VALID_PROVIDER_ID, + }), ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { @@ -349,7 +400,11 @@ describe("sso remove integration", () => { const { layer } = setup({ body: {} }); return Effect.gen(function* () { const exit = yield* Effect.exit( - ssoRemove({ projectRef: Option.none(), providerId: VALID_PROVIDER_ID }), + ssoRemove({ + remote: Option.none(), + projectRef: Option.none(), + providerId: VALID_PROVIDER_ID, + }), ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { @@ -368,6 +423,19 @@ describe("sso remove integration", () => { describeHostedConsent("sso remove", { api: { response: { status: 200, body: PROVIDER } }, invoke: () => ({ - run: ssoRemove({ projectRef: Option.some(VALID_REF), providerId: VALID_PROVIDER_ID }), + run: ssoRemove({ + remote: Option.some({ _tag: "Ref", ref: VALID_REF }), + projectRef: Option.none(), + providerId: VALID_PROVIDER_ID, + }), }), }); + +describeHostedRemoteSelection({ + command: ["sso", "remove"], + args: [VALID_PROVIDER_ID], + workdir: () => tempRoot.current, + respond: (request) => jsonResponse(request, 200, PROVIDER), + run: (selector, layer) => + ssoRemove({ ...selector, providerId: VALID_PROVIDER_ID }).pipe(Effect.provide(layer)), +}); diff --git a/apps/cli/src/commands/sso/show/show.integration.test.ts b/apps/cli/src/commands/sso/show/show.integration.test.ts index 0c623b2f89..2ce142150b 100644 --- a/apps/cli/src/commands/sso/show/show.integration.test.ts +++ b/apps/cli/src/commands/sso/show/show.integration.test.ts @@ -12,6 +12,7 @@ import { mockTelemetryStateTracked, useTempWorkdir, } from "../../../../tests/helpers/command-mocks.ts"; +import { describeHostedRemoteSelection } from "../../../../tests/helpers/hosted-remote-selection.ts"; import { assertPermissionDrift } from "../../../../tests/helpers/permission-drift.ts"; import { projectResponse, @@ -77,6 +78,7 @@ describe("sso show integration", () => { return Effect.gen(function* () { const exit = yield* Effect.exit( ssoShow({ + remote: Option.none(), projectRef: Option.none(), providerId: "not-a-uuid", metadata: false, @@ -95,6 +97,7 @@ describe("sso show integration", () => { const { layer, out } = setup(); return Effect.gen(function* () { yield* ssoShow({ + remote: Option.none(), projectRef: Option.none(), providerId: VALID_PROVIDER_ID, metadata: false, @@ -110,6 +113,7 @@ describe("sso show integration", () => { return Effect.gen(function* () { const exit = yield* Effect.exit( ssoShow({ + remote: Option.none(), projectRef: Option.none(), providerId: VALID_PROVIDER_ID, metadata: false, @@ -130,6 +134,7 @@ describe("sso show integration", () => { return Effect.gen(function* () { const exit = yield* Effect.exit( ssoShow({ + remote: Option.none(), projectRef: Option.none(), providerId: VALID_PROVIDER_ID, metadata: false, @@ -149,6 +154,7 @@ describe("sso show integration", () => { return Effect.gen(function* () { const exit = yield* Effect.exit( ssoShow({ + remote: Option.none(), projectRef: Option.none(), providerId: VALID_PROVIDER_ID, metadata: false, @@ -166,6 +172,7 @@ describe("sso show integration", () => { return Effect.gen(function* () { const exit = yield* Effect.exit( ssoShow({ + remote: Option.none(), projectRef: Option.none(), providerId: VALID_PROVIDER_ID, metadata: false, @@ -184,6 +191,7 @@ describe("sso show integration", () => { const { layer, out } = setup({ outputFlag: "json" }); return Effect.gen(function* () { yield* ssoShow({ + remote: Option.none(), projectRef: Option.none(), providerId: VALID_PROVIDER_ID, metadata: false, @@ -199,6 +207,7 @@ describe("sso show integration", () => { const { layer, out } = setup({ outputFlag: "yaml" }); return Effect.gen(function* () { yield* ssoShow({ + remote: Option.none(), projectRef: Option.none(), providerId: VALID_PROVIDER_ID, metadata: false, @@ -224,6 +233,7 @@ updatedat: "2023-03-28T13:50:14.464Z" const { layer, out } = setup({ outputFlag: "toml" }); return Effect.gen(function* () { yield* ssoShow({ + remote: Option.none(), projectRef: Option.none(), providerId: VALID_PROVIDER_ID, metadata: false, @@ -247,6 +257,7 @@ UpdatedAt = "2023-03-28T13:50:14.464Z" const { layer, out } = setup({ format: "json" }); return Effect.gen(function* () { yield* ssoShow({ + remote: Option.none(), projectRef: Option.none(), providerId: VALID_PROVIDER_ID, metadata: false, @@ -260,6 +271,7 @@ UpdatedAt = "2023-03-28T13:50:14.464Z" const { layer, out } = setup({ outputFlag: "pretty" }); return Effect.gen(function* () { yield* ssoShow({ + remote: Option.none(), projectRef: Option.none(), providerId: VALID_PROVIDER_ID, metadata: false, @@ -272,6 +284,7 @@ UpdatedAt = "2023-03-28T13:50:14.464Z" const { layer, out, api } = setup(); return Effect.gen(function* () { yield* ssoShow({ + remote: Option.none(), projectRef: Option.none(), providerId: VALID_PROVIDER_ID, metadata: true, @@ -293,6 +306,7 @@ UpdatedAt = "2023-03-28T13:50:14.464Z" const { layer, out } = setup({ body: { id: VALID_PROVIDER_ID } }); return Effect.gen(function* () { yield* ssoShow({ + remote: Option.none(), projectRef: Option.none(), providerId: VALID_PROVIDER_ID, metadata: true, @@ -306,6 +320,7 @@ UpdatedAt = "2023-03-28T13:50:14.464Z" return Effect.gen(function* () { yield* Effect.exit( ssoShow({ + remote: Option.none(), projectRef: Option.none(), providerId: VALID_PROVIDER_ID, metadata: false, @@ -319,6 +334,7 @@ UpdatedAt = "2023-03-28T13:50:14.464Z" const { layer, telemetry, cache } = setup(); return Effect.gen(function* () { yield* ssoShow({ + remote: Option.none(), projectRef: Option.none(), providerId: VALID_PROVIDER_ID, metadata: false, @@ -332,6 +348,7 @@ UpdatedAt = "2023-03-28T13:50:14.464Z" const { layer, api } = setup(); return Effect.gen(function* () { yield* ssoShow({ + remote: Option.none(), projectRef: Option.none(), providerId: VALID_PROVIDER_ID, metadata: false, @@ -374,6 +391,7 @@ UpdatedAt = "2023-03-28T13:50:14.464Z" const cachePath = `${tempRoot.current}/supabase/.temp/linked-project.json`; expect(yield* fs.exists(cachePath)).toBe(false); yield* ssoShow({ + remote: Option.none(), projectRef: Option.none(), providerId: VALID_PROVIDER_ID, metadata: false, @@ -391,3 +409,14 @@ UpdatedAt = "2023-03-28T13:50:14.464Z" }).pipe(Effect.provide(layer)); }); }); + +describeHostedRemoteSelection({ + command: ["sso", "show"], + args: [VALID_PROVIDER_ID], + workdir: () => tempRoot.current, + respond: (request) => jsonResponse(request, 200, PROVIDER), + run: (selector, layer) => + ssoShow({ ...selector, providerId: VALID_PROVIDER_ID, metadata: false }).pipe( + Effect.provide(layer), + ), +}); 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 4b842baa63..194958ad2f 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 { describeHostedConsent } from "../../../../tests/helpers/hosted-consent.ts"; +import { describeHostedRemoteSelection } from "../../../../tests/helpers/hosted-remote-selection.ts"; import { mockAnalytics, mockOutput, @@ -206,6 +207,7 @@ const decodePutDomains = Schema.decodeUnknownEffect( ); const defaultFlags: SsoUpdateFlags = { + remote: Option.none(), projectRef: Option.none(), domains: [], addDomains: [], @@ -1684,10 +1686,22 @@ describeHostedConsent("sso update", { }, }, invoke: () => ({ - run: ssoUpdate({ ...defaultFlags, projectRef: Option.some(VALID_REF) }).pipe( + run: ssoUpdate({ + ...defaultFlags, + remote: Option.some({ _tag: "Ref", ref: VALID_REF }), + }).pipe( Effect.provide( Stdio.layerTest({ args: Effect.succeed(["sso", "update", VALID_PROVIDER_ID]) }), ), ), }), }); + +describeHostedRemoteSelection({ + command: ["sso", "update"], + args: [VALID_PROVIDER_ID], + workdir: () => tempRoot.current, + respond: (request) => + jsonResponse(request, 200, request.method === "GET" ? EXISTING_PROVIDER : RESPONSE_PROVIDER), + run: (selector, layer) => ssoUpdate({ ...defaultFlags, ...selector }).pipe(Effect.provide(layer)), +}); 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 755e44946d..4e8b33e81f 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 @@ -20,6 +20,7 @@ import { unverifiedTargetBanner, } from "../../../tests/helpers/command-mocks.ts"; import { describeHostedConsent } from "../../../tests/helpers/hosted-consent.ts"; +import { describeHostedRemoteSelection } from "../../../tests/helpers/hosted-remote-selection.ts"; import { assertPermissionDrift } from "../../../tests/helpers/permission-drift.ts"; import { projectResponse, @@ -175,7 +176,7 @@ describe("vanity-subdomains get", () => { const layer = runtimeWith({ out, api }); return Effect.gen(function* () { - yield* vanitySubdomainsGet({ projectRef: Option.none() }); + yield* vanitySubdomainsGet({ remote: Option.none(), projectRef: Option.none() }); expect(out.stdoutText).toBe("Status: custom-domain-used\nVanity subdomain: example.com\n"); expect(api.requests[0]?.method).toBe("GET"); expect(api.requests[0]?.url).toContain(`/v1/projects/${VALID_REF}/vanity-subdomain`); @@ -193,7 +194,7 @@ describe("vanity-subdomains get", () => { return Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; expect(yield* fs.exists(linkedProjectCachePath())).toBe(false); - yield* vanitySubdomainsGet({ projectRef: Option.none() }); + yield* vanitySubdomainsGet({ remote: Option.none(), projectRef: Option.none() }); expect(requestLines(api)).toEqual([ `GET ${API_URL}/v1/projects`, `GET ${API_URL}/v1/projects/${VALID_REF}/vanity-subdomain`, @@ -230,7 +231,9 @@ describe("vanity-subdomains get", () => { const layer = runtimeWith({ out, api, analytics }); return Effect.gen(function* () { - const exit = yield* Effect.exit(vanitySubdomainsGet({ projectRef: Option.none() })); + const exit = yield* Effect.exit( + vanitySubdomainsGet({ remote: Option.none(), projectRef: Option.none() }), + ); expect(Exit.isFailure(exit)).toBe(true); expect(api.requests).toHaveLength(1); assertPermissionDrift({ command: "vanity-subdomains get", requests: api.requests }); @@ -254,7 +257,9 @@ describe("vanity-subdomains get", () => { const layer = runtimeWith({ out, api, analytics }); return Effect.gen(function* () { - const exit = yield* Effect.exit(vanitySubdomainsGet({ projectRef: Option.none() })); + const exit = yield* Effect.exit( + vanitySubdomainsGet({ remote: Option.none(), projectRef: Option.none() }), + ); expect(Exit.isFailure(exit)).toBe(true); expect(api.requests).toHaveLength(1); expect(analytics.captured).toHaveLength(0); @@ -268,7 +273,7 @@ describe("vanity-subdomains get", () => { const layer = runtimeWith({ out, api }); return Effect.gen(function* () { - yield* vanitySubdomainsGet({ projectRef: Option.none() }); + yield* vanitySubdomainsGet({ remote: Option.none(), projectRef: Option.none() }); expect(out.stdoutText).toBe("Status: not-used\n"); }).pipe(Effect.provide(layer)); }); @@ -279,7 +284,7 @@ describe("vanity-subdomains get", () => { const layer = runtimeWith({ out, api, outputFlag: "json" }); return Effect.gen(function* () { - yield* vanitySubdomainsGet({ projectRef: Option.none() }); + yield* vanitySubdomainsGet({ remote: Option.none(), projectRef: Option.none() }); expect(out.stdoutText).toContain('"status": "custom-domain-used"'); expect(out.stdoutText).toContain('"custom_domain": "example.com"'); }).pipe(Effect.provide(layer)); @@ -291,7 +296,7 @@ describe("vanity-subdomains get", () => { const layer = runtimeWith({ out, api, outputFlag: "yaml" }); return Effect.gen(function* () { - yield* vanitySubdomainsGet({ projectRef: Option.none() }); + yield* vanitySubdomainsGet({ remote: Option.none(), projectRef: Option.none() }); expect(out.stdoutText).toContain("status: custom-domain-used"); // YAML output lowercases the whole field name. expect(out.stdoutText).toContain("customdomain: example.com"); @@ -304,7 +309,7 @@ describe("vanity-subdomains get", () => { const layer = runtimeWith({ out, api, outputFlag: "toml" }); return Effect.gen(function* () { - yield* vanitySubdomainsGet({ projectRef: Option.none() }); + yield* vanitySubdomainsGet({ remote: Option.none(), projectRef: Option.none() }); // CustomDomain precedes Status, with a single trailing newline. expect(out.stdoutText).toBe('CustomDomain = "example.com"\nStatus = "custom-domain-used"\n'); }).pipe(Effect.provide(layer)); @@ -316,7 +321,7 @@ describe("vanity-subdomains get", () => { const layer = runtimeWith({ out, api, outputFlag: "toml" }); return Effect.gen(function* () { - yield* vanitySubdomainsGet({ projectRef: Option.none() }); + yield* vanitySubdomainsGet({ remote: Option.none(), projectRef: Option.none() }); expect(out.stdoutText).toBe('Status = "not-used"\n'); }).pipe(Effect.provide(layer)); }); @@ -327,7 +332,7 @@ describe("vanity-subdomains get", () => { const layer = runtimeWith({ out, api, outputFlag: "env" }); return Effect.gen(function* () { - yield* vanitySubdomainsGet({ projectRef: Option.none() }); + yield* vanitySubdomainsGet({ remote: Option.none(), projectRef: Option.none() }); expect(out.stdoutText).toContain('STATUS="custom-domain-used"'); expect(out.stdoutText).toContain('CUSTOM_DOMAIN="example.com"'); }).pipe(Effect.provide(layer)); @@ -339,7 +344,7 @@ describe("vanity-subdomains get", () => { const layer = runtimeWith({ out, api }); return Effect.gen(function* () { - yield* vanitySubdomainsGet({ projectRef: Option.none() }); + yield* vanitySubdomainsGet({ remote: Option.none(), projectRef: Option.none() }); const success = out.messages.find((m) => m.type === "success"); expect(success?.data).toMatchObject({ status: "custom-domain-used" }); }).pipe(Effect.provide(layer)); @@ -351,7 +356,7 @@ describe("vanity-subdomains get", () => { const layer = runtimeWith({ out, api }); return Effect.gen(function* () { - yield* vanitySubdomainsGet({ projectRef: Option.none() }); + yield* vanitySubdomainsGet({ remote: Option.none(), projectRef: Option.none() }); const success = out.messages.find((m) => m.type === "success"); expect(success?.data).toMatchObject({ status: "custom-domain-used" }); }).pipe(Effect.provide(layer)); @@ -363,7 +368,9 @@ describe("vanity-subdomains get", () => { const layer = runtimeWith({ out, api }); return Effect.gen(function* () { - const exit = yield* Effect.exit(vanitySubdomainsGet({ projectRef: Option.none() })); + const exit = yield* Effect.exit( + vanitySubdomainsGet({ remote: Option.none(), projectRef: Option.none() }), + ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { const causeText = Cause.pretty(exit.cause); @@ -380,7 +387,9 @@ describe("vanity-subdomains get", () => { const layer = runtimeWith({ out, api }); return Effect.gen(function* () { - const exit = yield* Effect.exit(vanitySubdomainsGet({ projectRef: Option.none() })); + const exit = yield* Effect.exit( + vanitySubdomainsGet({ remote: Option.none(), projectRef: Option.none() }), + ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { const causeText = Cause.pretty(exit.cause); @@ -399,6 +408,7 @@ describe("vanity-subdomains check-availability", () => { return Effect.gen(function* () { yield* vanitySubdomainsCheckAvailability({ + remote: Option.none(), projectRef: Option.none(), desiredSubdomain: Option.some("example.com"), }); @@ -424,6 +434,7 @@ describe("vanity-subdomains check-availability", () => { const fs = yield* FileSystem.FileSystem; expect(yield* fs.exists(linkedProjectCachePath())).toBe(false); yield* vanitySubdomainsCheckAvailability({ + remote: Option.none(), projectRef: Option.none(), desiredSubdomain: Option.some("example.com"), }); @@ -452,6 +463,7 @@ describe("vanity-subdomains check-availability", () => { return Effect.gen(function* () { yield* vanitySubdomainsCheckAvailability({ + remote: Option.none(), projectRef: Option.none(), desiredSubdomain: Option.some("example.com"), }); @@ -466,6 +478,7 @@ describe("vanity-subdomains check-availability", () => { return Effect.gen(function* () { yield* vanitySubdomainsCheckAvailability({ + remote: Option.none(), projectRef: Option.none(), desiredSubdomain: Option.some("example.com"), }); @@ -480,6 +493,7 @@ describe("vanity-subdomains check-availability", () => { return Effect.gen(function* () { yield* vanitySubdomainsCheckAvailability({ + remote: Option.none(), projectRef: Option.none(), desiredSubdomain: Option.some("example.com"), }); @@ -494,6 +508,7 @@ describe("vanity-subdomains check-availability", () => { return Effect.gen(function* () { yield* vanitySubdomainsCheckAvailability({ + remote: Option.none(), projectRef: Option.none(), desiredSubdomain: Option.some("example.com"), }); @@ -508,6 +523,7 @@ describe("vanity-subdomains check-availability", () => { return Effect.gen(function* () { yield* vanitySubdomainsCheckAvailability({ + remote: Option.none(), projectRef: Option.none(), desiredSubdomain: Option.some("example.com"), }); @@ -525,6 +541,7 @@ describe("vanity-subdomains check-availability", () => { return Effect.gen(function* () { const exit = yield* Effect.exit( vanitySubdomainsCheckAvailability({ + remote: Option.none(), projectRef: Option.none(), desiredSubdomain: Option.some("example.com"), }), @@ -554,6 +571,7 @@ describe("vanity-subdomains check-availability", () => { return Effect.gen(function* () { const exit = yield* Effect.exit( vanitySubdomainsCheckAvailability({ + remote: Option.none(), projectRef: Option.none(), desiredSubdomain: Option.some("example.com"), }), @@ -575,6 +593,7 @@ describe("vanity-subdomains check-availability", () => { return Effect.gen(function* () { const error = yield* Effect.flip( vanitySubdomainsCheckAvailability({ + remote: Option.none(), projectRef: Option.none(), desiredSubdomain: Option.none(), }), @@ -604,6 +623,7 @@ describe("vanity-subdomains check-availability", () => { return Effect.gen(function* () { yield* vanitySubdomainsCheckAvailability({ + remote: Option.none(), projectRef: Option.none(), desiredSubdomain: Option.some(""), }); @@ -627,6 +647,7 @@ describe("vanity-subdomains activate", () => { return Effect.gen(function* () { yield* vanitySubdomainsActivate({ + remote: Option.none(), projectRef: Option.none(), desiredSubdomain: Option.some("example.com"), }); @@ -650,6 +671,7 @@ describe("vanity-subdomains activate", () => { const fs = yield* FileSystem.FileSystem; expect(yield* fs.exists(linkedProjectCachePath())).toBe(false); yield* vanitySubdomainsActivate({ + remote: Option.none(), projectRef: Option.none(), desiredSubdomain: Option.some("example.com"), }); @@ -678,6 +700,7 @@ describe("vanity-subdomains activate", () => { return Effect.gen(function* () { yield* vanitySubdomainsActivate({ + remote: Option.none(), projectRef: Option.none(), desiredSubdomain: Option.some("example.com"), }); @@ -692,6 +715,7 @@ describe("vanity-subdomains activate", () => { return Effect.gen(function* () { yield* vanitySubdomainsActivate({ + remote: Option.none(), projectRef: Option.none(), desiredSubdomain: Option.some("example.com"), }); @@ -707,6 +731,7 @@ describe("vanity-subdomains activate", () => { return Effect.gen(function* () { yield* vanitySubdomainsActivate({ + remote: Option.none(), projectRef: Option.none(), desiredSubdomain: Option.some("example.com"), }); @@ -721,6 +746,7 @@ describe("vanity-subdomains activate", () => { return Effect.gen(function* () { yield* vanitySubdomainsActivate({ + remote: Option.none(), projectRef: Option.none(), desiredSubdomain: Option.some("example.com"), }); @@ -735,6 +761,7 @@ describe("vanity-subdomains activate", () => { return Effect.gen(function* () { yield* vanitySubdomainsActivate({ + remote: Option.none(), projectRef: Option.none(), desiredSubdomain: Option.some("example.com"), }); @@ -752,6 +779,7 @@ describe("vanity-subdomains activate", () => { return Effect.gen(function* () { const exit = yield* Effect.exit( vanitySubdomainsActivate({ + remote: Option.none(), projectRef: Option.none(), desiredSubdomain: Option.some("example.com"), }), @@ -787,6 +815,7 @@ describe("vanity-subdomains activate", () => { return Effect.gen(function* () { const exit = yield* Effect.exit( vanitySubdomainsActivate({ + remote: Option.none(), projectRef: Option.none(), desiredSubdomain: Option.some("example.com"), }), @@ -809,6 +838,7 @@ describe("vanity-subdomains activate", () => { return Effect.gen(function* () { const error = yield* Effect.flip( vanitySubdomainsActivate({ + remote: Option.none(), projectRef: Option.none(), desiredSubdomain: Option.none(), }), @@ -838,6 +868,7 @@ describe("vanity-subdomains activate", () => { return Effect.gen(function* () { yield* vanitySubdomainsActivate({ + remote: Option.none(), projectRef: Option.none(), desiredSubdomain: Option.some(""), }); @@ -859,7 +890,7 @@ describe("vanity-subdomains delete", () => { const layer = runtimeWith({ out, api }); return Effect.gen(function* () { - yield* vanitySubdomainsDelete({ projectRef: Option.none() }); + yield* vanitySubdomainsDelete({ remote: Option.none(), projectRef: Option.none() }); expect(out.stderrText).toBe( unverifiedTargetBanner() + confirmedDelete + "Deleted vanity subdomain successfully.\n", ); @@ -879,7 +910,7 @@ describe("vanity-subdomains delete", () => { return Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; expect(yield* fs.exists(linkedProjectCachePath())).toBe(false); - yield* vanitySubdomainsDelete({ projectRef: Option.none() }); + yield* vanitySubdomainsDelete({ remote: Option.none(), projectRef: Option.none() }); expect(requestLines(api)).toEqual([ `GET ${API_URL}/v1/projects`, `DELETE ${API_URL}/v1/projects/${VALID_REF}/vanity-subdomain`, @@ -903,7 +934,7 @@ describe("vanity-subdomains delete", () => { const layer = runtimeWith({ out, api }); return Effect.gen(function* () { - yield* vanitySubdomainsDelete({ projectRef: Option.none() }); + yield* vanitySubdomainsDelete({ remote: Option.none(), projectRef: Option.none() }); const success = out.messages.find((m) => m.type === "success"); expect(success?.message).toBe("Deleted vanity subdomain successfully."); }).pipe(Effect.provide(layer)); @@ -915,7 +946,7 @@ describe("vanity-subdomains delete", () => { const layer = runtimeWith({ out, api, outputFlag: "json" }); return Effect.gen(function* () { - yield* vanitySubdomainsDelete({ projectRef: Option.none() }); + yield* vanitySubdomainsDelete({ remote: Option.none(), projectRef: Option.none() }); expect(out.stderrText).toBe( unverifiedTargetBanner() + confirmedDelete + "Deleted vanity subdomain successfully.\n", ); @@ -929,7 +960,9 @@ describe("vanity-subdomains delete", () => { const layer = runtimeWith({ out, api }); return Effect.gen(function* () { - const exit = yield* Effect.exit(vanitySubdomainsDelete({ projectRef: Option.none() })); + const exit = yield* Effect.exit( + vanitySubdomainsDelete({ remote: Option.none(), projectRef: Option.none() }), + ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { const causeText = Cause.pretty(exit.cause); @@ -946,7 +979,9 @@ describe("vanity-subdomains delete", () => { const layer = runtimeWith({ out, api }); return Effect.gen(function* () { - const exit = yield* Effect.exit(vanitySubdomainsDelete({ projectRef: Option.none() })); + const exit = yield* Effect.exit( + vanitySubdomainsDelete({ remote: Option.none(), projectRef: Option.none() }), + ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { const causeText = Cause.pretty(exit.cause); @@ -971,7 +1006,7 @@ describe("vanity-subdomains PersistentPostRun parity", () => { }); return Effect.gen(function* () { - yield* vanitySubdomainsGet({ projectRef: Option.none() }); + yield* vanitySubdomainsGet({ remote: Option.none(), projectRef: Option.none() }); expect(telemetry.flushed).toBe(true); expect(cache.cached).toBe(true); }).pipe(Effect.provide(layer)); @@ -990,7 +1025,9 @@ describe("vanity-subdomains PersistentPostRun parity", () => { }); return Effect.gen(function* () { - const exit = yield* Effect.exit(vanitySubdomainsGet({ projectRef: Option.none() })); + const exit = yield* Effect.exit( + vanitySubdomainsGet({ remote: Option.none(), projectRef: Option.none() }), + ); expect(Exit.isFailure(exit)).toBe(true); expect(telemetry.flushed).toBe(true); expect(cache.cached).toBe(true); @@ -1016,6 +1053,7 @@ describe("vanity-subdomains PersistentPostRun parity", () => { return Effect.gen(function* () { const error = yield* Effect.flip( vanitySubdomainsActivate({ + remote: Option.none(), projectRef: Option.none(), desiredSubdomain: Option.none(), }), @@ -1032,6 +1070,7 @@ describeHostedConsent("vanity-subdomains activate", { api: { response: { status: 201, body: SAMPLE_ACTIVATE } }, invoke: () => ({ run: vanitySubdomainsActivate({ + remote: Option.none(), projectRef: Option.none(), desiredSubdomain: Option.some("example"), }), @@ -1040,5 +1079,47 @@ describeHostedConsent("vanity-subdomains activate", { describeHostedConsent("vanity-subdomains delete", { api: { response: { status: 200, body: null } }, - invoke: () => ({ run: vanitySubdomainsDelete({ projectRef: Option.none() }) }), + invoke: () => ({ + run: vanitySubdomainsDelete({ remote: Option.none(), projectRef: Option.none() }), + }), +}); + +describeHostedRemoteSelection({ + command: ["vanity-subdomains", "get"], + workdir: () => tempRoot.current, + respond: (request) => jsonResponse(request, 200, SAMPLE_GET), + run: (selector, layer) => vanitySubdomainsGet(selector).pipe(Effect.provide(layer)), +}); + +describeHostedRemoteSelection({ + command: ["vanity-subdomains", "check-availability"], + args: ["--desired-subdomain", "example.com"], + workdir: () => tempRoot.current, + activeFlags: { "desired-subdomain": "example.com" }, + respond: (request) => jsonResponse(request, 201, SAMPLE_CHECK), + run: (selector, layer) => + vanitySubdomainsCheckAvailability({ + ...selector, + desiredSubdomain: Option.some("example.com"), + }).pipe(Effect.provide(layer)), +}); + +describeHostedRemoteSelection({ + command: ["vanity-subdomains", "activate"], + args: ["--desired-subdomain", "example.com"], + workdir: () => tempRoot.current, + activeFlags: { "desired-subdomain": "example.com" }, + respond: (request) => jsonResponse(request, 201, SAMPLE_ACTIVATE), + run: (selector, layer) => + vanitySubdomainsActivate({ + ...selector, + desiredSubdomain: Option.some("example.com"), + }).pipe(Effect.provide(layer)), +}); + +describeHostedRemoteSelection({ + command: ["vanity-subdomains", "delete"], + workdir: () => tempRoot.current, + respond: (request) => jsonResponse(request, 200, null), + run: (selector, layer) => vanitySubdomainsDelete(selector).pipe(Effect.provide(layer)), }); diff --git a/apps/cli/src/shared/cli/run.unit.test.ts b/apps/cli/src/shared/cli/run.unit.test.ts index 79514ea6bf..e1b8850906 100644 --- a/apps/cli/src/shared/cli/run.unit.test.ts +++ b/apps/cli/src/shared/cli/run.unit.test.ts @@ -248,7 +248,7 @@ describe("classifyParseErrorConsoleOutput", () => { expect( classifyParseErrorConsoleOutput(cause, { rootCommand: testRoot, - args: ["sso", "add", "--project-ref", "--type"], + args: ["sso", "add", "--metadata-url", "--type"], }), ).toBe("drop"); }); @@ -278,7 +278,7 @@ describe("classifyParseErrorConsoleOutput", () => { expect( classifyParseErrorConsoleOutput(cause, { rootCommand: testRoot, - args: ["sso", "add", "--project-ref", "--", "--type"], + args: ["sso", "add", "--metadata-url", "--", "--type"], }), ).toBe("flush-help-doc-to-stderr"); }); diff --git a/apps/cli/src/shared/cli/subcommand-flag-suggestions.unit.test.ts b/apps/cli/src/shared/cli/subcommand-flag-suggestions.unit.test.ts index 82f7fd9c19..731eeea508 100644 --- a/apps/cli/src/shared/cli/subcommand-flag-suggestions.unit.test.ts +++ b/apps/cli/src/shared/cli/subcommand-flag-suggestions.unit.test.ts @@ -2,10 +2,11 @@ import { CliError, Command } from "effect/cli"; import { describe, expect, it } from "vitest"; import { branchesCommand } from "../../commands/branches/branches.command.ts"; import { networkRestrictionsCommand } from "../../commands/network-restrictions/network-restrictions.command.ts"; +import { ssoCommand } from "../../commands/sso/sso.command.ts"; import { formatCliErrorsForDisplay } from "./subcommand-flag-suggestions.ts"; const testRoot = Command.make("supabase").pipe( - Command.withSubcommands([branchesCommand, networkRestrictionsCommand]), + Command.withSubcommands([branchesCommand, networkRestrictionsCommand, ssoCommand]), ); describe("subcommand flag placement suggestions", () => { @@ -13,38 +14,32 @@ describe("subcommand flag placement suggestions", () => { const errors = formatCliErrorsForDisplay( [ new CliError.UnrecognizedOption({ - option: "--project-ref", - command: ["supabase", "network-restrictions"], + option: "--attribute-mapping-file", + command: ["supabase", "sso"], suggestions: [], }), new CliError.UnknownSubcommand({ - subcommand: "jacraenyzrorgjhsdvvf", - parent: ["supabase", "network-restrictions"], + subcommand: "mapping.json", + parent: ["supabase", "sso"], suggestions: [], }), ], { rootCommand: testRoot, - args: [ - "network-restrictions", - "--project-ref", - "jacraenyzrorgjhsdvvf", - "get", - "--experimental", - ], + args: ["sso", "--attribute-mapping-file", "mapping.json", "add", "--experimental"], }, ); expect(errors.changed).toBe(true); expect(errors.errors).toHaveLength(1); expect(errors.errors[0]?.message).toContain( - "Unrecognized flag: --project-ref in command supabase network-restrictions", + "Unrecognized flag: --attribute-mapping-file in command supabase sso", ); expect(errors.errors[0]?.message).toContain( - "Hint: --project-ref is available on `supabase network-restrictions get` and `supabase network-restrictions update`.", + "Hint: --attribute-mapping-file is available on `supabase sso add` and `supabase sso update`.", ); expect(errors.errors[0]?.message).toContain( - "supabase network-restrictions get --project-ref <value>", + "supabase sso add --attribute-mapping-file <value>", ); expect(errors.errors[0]?.message).not.toContain("Unknown subcommand"); }); @@ -102,25 +97,27 @@ describe("subcommand flag placement suggestions", () => { const errors = formatCliErrorsForDisplay( [ new CliError.UnrecognizedOption({ - option: "--project-ref=jacraenyzrorgjhsdvvf", - command: ["supabase", "network-restrictions"], + option: "--attribute-mapping-file=mapping.json", + command: ["supabase", "sso"], suggestions: [], }), ], { rootCommand: testRoot, - args: ["network-restrictions", "--project-ref=jacraenyzrorgjhsdvvf", "get"], + args: ["sso", "--attribute-mapping-file=mapping.json", "add"], }, ); expect(errors.changed).toBe(true); expect(errors.errors[0]?.message).toContain( - "Hint: --project-ref is available on `supabase network-restrictions get` and `supabase network-restrictions update`.", + "Hint: --attribute-mapping-file is available on `supabase sso add` and `supabase sso update`.", ); expect(errors.errors[0]?.message).toContain( - "supabase network-restrictions get --project-ref <value>", + "supabase sso add --attribute-mapping-file <value>", + ); + expect(errors.errors[0]?.message).not.toContain( + "--attribute-mapping-file=mapping.json <value>", ); - expect(errors.errors[0]?.message).not.toContain("--project-ref=jacraenyzrorgjhsdvvf <value>"); }); it("collapses the doubled 'Expected: Expected' prefix for an invalid choice flag value", () => { diff --git a/apps/cli/src/shared/output/normalize-error.unit.test.ts b/apps/cli/src/shared/output/normalize-error.unit.test.ts index 69f3997ea0..90b7d67364 100644 --- a/apps/cli/src/shared/output/normalize-error.unit.test.ts +++ b/apps/cli/src/shared/output/normalize-error.unit.test.ts @@ -4,11 +4,12 @@ import { CliError, Command } from "effect/cli"; import { CliConfigParseError, CliProjectEnvParseError } from "@supabase/config"; import { branchesCommand } from "../../commands/branches/branches.command.ts"; import { networkRestrictionsCommand } from "../../commands/network-restrictions/network-restrictions.command.ts"; +import { ssoCommand } from "../../commands/sso/sso.command.ts"; import { CliProjectHomeNotDirectoryError } from "../config/cli-project-home.service.ts"; import { formatCliError, normalizeCause, normalizeCliError } from "./normalize-error.ts"; const testRoot = Command.make("supabase").pipe( - Command.withSubcommands([branchesCommand, networkRestrictionsCommand]), + Command.withSubcommands([branchesCommand, networkRestrictionsCommand, ssoCommand]), ); describe("normalizeCliError", () => { @@ -277,16 +278,16 @@ describe("normalizeCliError", () => { test("ShowHelp envelope unwraps multiple simultaneous errors and preserves the subcommand-flag hint (child flag with a value, before its subcommand)", () => { const error = { _tag: "ShowHelp", - commandPath: ["network-restrictions"], + commandPath: ["sso"], errors: [ new CliError.UnrecognizedOption({ - option: "--project-ref", - command: ["supabase", "network-restrictions"], + option: "--attribute-mapping-file", + command: ["supabase", "sso"], suggestions: [], }), new CliError.UnknownSubcommand({ - subcommand: "jacraenyzrorgjhsdvvf", - parent: ["supabase", "network-restrictions"], + subcommand: "mapping.json", + parent: ["supabase", "sso"], suggestions: [], }), ], @@ -294,15 +295,15 @@ describe("normalizeCliError", () => { const result = normalizeCliError(error, { rootCommand: testRoot, - args: ["network-restrictions", "--project-ref", "jacraenyzrorgjhsdvvf", "get"], + args: ["sso", "--attribute-mapping-file", "mapping.json", "add"], }); expect(result.code).toBe("UnrecognizedOption"); expect(result.message).toContain( - "Unrecognized flag: --project-ref in command supabase network-restrictions", + "Unrecognized flag: --attribute-mapping-file in command supabase sso", ); expect(result.message).toContain( - "Hint: --project-ref is available on `supabase network-restrictions get` and `supabase network-restrictions update`.", + "Hint: --attribute-mapping-file is available on `supabase sso add` and `supabase sso update`.", ); expect(result.message).not.toContain("Help requested"); }); diff --git a/apps/cli/src/shared/output/text-formatter.unit.test.ts b/apps/cli/src/shared/output/text-formatter.unit.test.ts index c464209373..fce9fd5e27 100644 --- a/apps/cli/src/shared/output/text-formatter.unit.test.ts +++ b/apps/cli/src/shared/output/text-formatter.unit.test.ts @@ -1,11 +1,9 @@ import { CliError, Command } from "effect/cli"; import { describe, expect, it } from "vitest"; -import { networkRestrictionsCommand } from "../../commands/network-restrictions/network-restrictions.command.ts"; +import { ssoCommand } from "../../commands/sso/sso.command.ts"; import { textCliOutputFormatter } from "./text-formatter.ts"; -const testRoot = Command.make("supabase").pipe( - Command.withSubcommands([networkRestrictionsCommand]), -); +const testRoot = Command.make("supabase").pipe(Command.withSubcommands([ssoCommand])); describe("textCliOutputFormatter", () => { it("preserves default parser suggestions for unchanged errors", () => { @@ -27,18 +25,18 @@ describe("textCliOutputFormatter", () => { it("preserves default parser suggestions for unchanged siblings in rewritten errors", () => { const formatter = textCliOutputFormatter({ rootCommand: testRoot, - args: ["network-restrictions", "--project-ref", "jacraenyzrorgjhsdvvf", "get", "--pla"], + args: ["sso", "--attribute-mapping-file", "mapping.json", "add", "--pla"], }); const text = formatter.formatErrors([ new CliError.UnrecognizedOption({ - option: "--project-ref", - command: ["supabase", "network-restrictions"], + option: "--attribute-mapping-file", + command: ["supabase", "sso"], suggestions: [], }), new CliError.UnknownSubcommand({ - subcommand: "jacraenyzrorgjhsdvvf", - parent: ["supabase", "network-restrictions"], + subcommand: "mapping.json", + parent: ["supabase", "sso"], suggestions: [], }), new CliError.UnrecognizedOption({ @@ -48,7 +46,7 @@ describe("textCliOutputFormatter", () => { }), ]); - expect(text).toContain("Hint: --project-ref is available on"); + expect(text).toContain("Hint: --attribute-mapping-file is available on"); expect(text).toContain("Unrecognized flag: --pla in command supabase projects create"); expect(text).toContain("Did you mean this?"); expect(text).toContain("--plan"); diff --git a/apps/cli/tests/helpers/hosted-remote-selection.ts b/apps/cli/tests/helpers/hosted-remote-selection.ts new file mode 100644 index 0000000000..3b98a87445 --- /dev/null +++ b/apps/cli/tests/helpers/hosted-remote-selection.ts @@ -0,0 +1,202 @@ +import { BunServices } from "@effect/platform-bun"; +import { describe, expect, it } from "@effect/vitest"; +import { Effect, FileSystem, Layer, Option, Stdio } from "effect"; +import type * as HttpClientRequest from "effect/http/HttpClientRequest"; +import type * as HttpClientResponse from "effect/http/HttpClientResponse"; + +import { YesFlag } from "../../src/command-internal/global-flags.ts"; +import type { RemoteValue } from "../../src/command-internal/target/remote-value.ts"; +import { TargetPolicy } from "../../src/command-internal/target/target-policy.ts"; +import { commandRuntimeLayer } from "../../src/shared/runtime/command-runtime.layer.ts"; +import { + VALID_REF, + buildTestRuntime, + jsonResponse, + mockCommandPlatformApi, + mockCommandSettings, + mockLinkedProjectCacheTracked, +} from "./command-mocks.ts"; +import { mockOutput } from "./mocks.ts"; +import { assertPermissionDrift } from "./permission-drift.ts"; +import type { ActiveFlags } from "./permission-registry.ts"; + +const OTHER_REF = "qrstuvwxyzabcdefghij"; +const BRANCH_REF = "branchprojectrefabcd"; +const DB_URL = "postgresql://postgres:pw@db.example.com:5432/postgres"; + +const branchRow = (name: string) => ({ + id: "11111111-2222-4333-8444-555555555555", + name, + project_ref: BRANCH_REF, + parent_project_ref: VALID_REF, + is_default: false, + persistent: false, + status: "MIGRATIONS_PASSED", + created_at: "2026-05-27T01:02:03Z", + updated_at: "2026-05-27T01:02:04Z", + with_data: true, +}); + +const hostedSelectionLayer = (options: { + readonly command: ReadonlyArray<string>; + readonly argv: ReadonlyArray<string>; + readonly workdir: string; + readonly respond: ( + request: HttpClientRequest.HttpClientRequest, + ) => HttpClientResponse.HttpClientResponse; +}) => { + const out = mockOutput({ format: "text" }); + const api = mockCommandPlatformApi({ + handler: (request) => + Effect.succeed( + request.url.endsWith("/branches") + ? jsonResponse(request, 200, [branchRow("feature-x")]) + : options.respond(request), + ), + }); + const cache = mockLinkedProjectCacheTracked(); + const layer = Layer.mergeAll( + buildTestRuntime({ + out, + api, + cliSettings: mockCommandSettings({ workdir: options.workdir, projectId: Option.none() }), + cliArgs: options.argv, + linkedProjectCache: cache.layer, + }), + commandRuntimeLayer(options.command).pipe(Layer.provide(BunServices.layer)), + Layer.succeed(YesFlag, true), + Layer.succeed(TargetPolicy, { group: "hosted", consent: "no" }), + Stdio.layerTest({ args: Effect.succeed(options.argv) }), + ); + return { layer, out, api, cache }; +}; + +type HostedSelectionLayer = ReturnType<typeof hostedSelectionLayer>["layer"]; + +interface HostedSelector { + readonly remote: Option.Option<RemoteValue>; + readonly projectRef: Option.Option<string>; +} + +interface HostedRemoteSelectionOptions { + /** The command path, e.g. `["network-bans", "get"]`. */ + readonly command: ReadonlyArray<string>; + /** Argv between the command path and the selector; plain tokens only, so the deprecation hint echoes them verbatim. */ + readonly args?: ReadonlyArray<string>; + readonly workdir: () => string; + /** The command's own API answer; the branch listing is answered by the helper. */ + readonly respond: ( + request: HttpClientRequest.HttpClientRequest, + ) => HttpClientResponse.HttpClientResponse; + /** Flags that decide which operations the command declares, for the permission drift check. */ + readonly activeFlags?: ActiveFlags; + /** Runs the handler with `selector` and the flags `args` stands for. */ + readonly run: ( + selector: HostedSelector, + layer: HostedSelectionLayer, + ) => Effect.Effect<unknown, unknown, never>; +} + +const none: HostedSelector = { remote: Option.none(), projectRef: Option.none() }; + +/** + * Registers the project-selection cases of a hosted-only command: `--remote=<ref>`, a bare + * `--remote`, `--remote=<branch>`, a rejected `--remote=<url>` and the deprecated `--project-ref`. + */ +export const describeHostedRemoteSelection = (options: HostedRemoteSelectionOptions): void => { + const commandText = options.command.join(" "); + const args = options.args ?? []; + + const harness = (selectorArgs: ReadonlyArray<string>) => { + const argv = [...options.command, ...args, ...selectorArgs]; + return hostedSelectionLayer({ + command: options.command, + argv, + workdir: options.workdir(), + respond: options.respond, + }); + }; + const linkParent = Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* fs.makeDirectory(`${options.workdir()}/supabase/.temp`, { recursive: true }); + yield* fs.writeFileString(`${options.workdir()}/supabase/.temp/project-ref`, VALID_REF); + }).pipe(Effect.provide(BunServices.layer)); + const expectActedOn = (run: ReturnType<typeof harness>, ref: string) => { + expect(run.cache.cachedRef).toBe(ref); + const pathRefs = run.api.requests + .filter((request) => !request.url.endsWith("/branches")) + .flatMap((request) => /\/v1\/projects\/([^/?]+)\//.exec(request.url)?.[1] ?? []); + expect(pathRefs).toEqual(pathRefs.map(() => ref)); + }; + const warnings = (out: ReturnType<typeof harness>["out"]) => + out.messages.filter((message) => message.type === "warn").map((message) => message.message); + + describe(`${commandText} remote selection`, () => { + it.effect("acts on the project --remote=<ref> names, with no deprecation warning", () => { + const run = harness([`--remote=${OTHER_REF}`]); + return Effect.gen(function* () { + yield* options.run( + { ...none, remote: Option.some({ _tag: "Ref", ref: OTHER_REF }) }, + run.layer, + ); + expectActedOn(run, OTHER_REF); + expect(warnings(run.out)).toEqual([]); + }); + }); + + it.effect("acts on the linked project for a bare --remote", () => { + const run = harness(["--remote"]); + return Effect.gen(function* () { + yield* linkParent; + yield* options.run({ ...none, remote: Option.some({ _tag: "Linked" }) }, run.layer); + expectActedOn(run, VALID_REF); + expect(warnings(run.out)).toEqual([]); + }); + }); + + it.effect("acts on the project of the branch --remote=<branch> names", () => { + const run = harness(["--remote=feature-x"]); + return Effect.gen(function* () { + yield* linkParent; + yield* options.run( + { ...none, remote: Option.some({ _tag: "Branch", name: "feature-x" }) }, + run.layer, + ); + expect(`${run.api.requests[0]?.method} ${run.api.requests[0]?.url}`).toBe( + `GET https://api.supabase.com/v1/projects/${VALID_REF}/branches`, + ); + expectActedOn(run, BRANCH_REF); + assertPermissionDrift({ + command: commandText, + activeFlags: { ...options.activeFlags, remote: "feature-x" }, + requests: run.api.requests, + }); + }); + }); + + it.effect("rejects --remote=<url> before any request", () => { + const { layer, api } = harness([`--remote=${DB_URL}`]); + return Effect.gen(function* () { + const error = yield* options + .run({ ...none, remote: Option.some({ _tag: "DbUrl", url: DB_URL }) }, layer) + .pipe(Effect.flip); + expect(error).toMatchObject({ + _tag: "TargetNeedsProjectError", + message: `\`supabase ${commandText}\` needs a project, not a database URL.`, + }); + expect(api.requests).toEqual([]); + }); + }); + + it.effect("keeps --project-ref working and points at --remote=<ref>", () => { + const run = harness(["--project-ref", OTHER_REF]); + return Effect.gen(function* () { + yield* options.run({ ...none, projectRef: Option.some(OTHER_REF) }, run.layer); + expectActedOn(run, OTHER_REF); + expect(warnings(run.out)).toEqual([ + `--project-ref is deprecated and will be removed in v4. Use --remote instead: ${["supabase", ...options.command, ...args].join(" ")} --remote=${OTHER_REF}`, + ]); + }); + }); + }); +}; From 8820be904b7c811b48c414993c94bbdab4e80361 Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Mon, 19 Oct 2026 11:54:53 +0100 Subject: [PATCH 118/291] docs(cli): describe --remote in hosted G-Z SIDE_EFFECTS --- .../cli/src/commands/gen/keys/SIDE_EFFECTS.md | 4 +- .../commands/network-bans/get/SIDE_EFFECTS.md | 68 ++++++++----- .../network-bans/remove/SIDE_EFFECTS.md | 57 ++++++----- .../network-restrictions/get/SIDE_EFFECTS.md | 66 ++++++++----- .../update/SIDE_EFFECTS.md | 58 ++++++----- .../commands/notebooks/pull/SIDE_EFFECTS.md | 91 ++++++++++------- .../commands/notebooks/push/SIDE_EFFECTS.md | 99 +++++++++++-------- .../postgres-config/delete/SIDE_EFFECTS.md | 59 ++++++----- .../postgres-config/get/SIDE_EFFECTS.md | 69 +++++++------ .../postgres-config/update/SIDE_EFFECTS.md | 59 ++++++----- .../cli/src/commands/projects/SIDE_EFFECTS.md | 27 ++--- .../projects/api-keys/SIDE_EFFECTS.md | 61 +++++++----- .../src/commands/secrets/list/SIDE_EFFECTS.md | 66 ++++++++----- .../src/commands/secrets/set/SIDE_EFFECTS.md | 68 ++++++++----- .../commands/secrets/unset/SIDE_EFFECTS.md | 54 ++++++---- .../snippets/download/SIDE_EFFECTS.md | 68 ++++++++----- .../commands/snippets/list/SIDE_EFFECTS.md | 66 ++++++++----- .../ssl-enforcement/get/SIDE_EFFECTS.md | 66 ++++++++----- .../ssl-enforcement/update/SIDE_EFFECTS.md | 76 ++++++++------ apps/cli/src/commands/sso/add/SIDE_EFFECTS.md | 58 ++++++----- .../cli/src/commands/sso/info/SIDE_EFFECTS.md | 52 ++++++---- .../cli/src/commands/sso/list/SIDE_EFFECTS.md | 62 +++++++----- .../src/commands/sso/remove/SIDE_EFFECTS.md | 68 ++++++++----- .../cli/src/commands/sso/show/SIDE_EFFECTS.md | 60 ++++++----- .../src/commands/sso/update/SIDE_EFFECTS.md | 60 ++++++----- .../activate/SIDE_EFFECTS.md | 72 ++++++++------ .../check-availability/SIDE_EFFECTS.md | 66 ++++++++----- .../vanity-subdomains/delete/SIDE_EFFECTS.md | 66 ++++++++----- .../vanity-subdomains/get/SIDE_EFFECTS.md | 60 ++++++----- 29 files changed, 1094 insertions(+), 712 deletions(-) diff --git a/apps/cli/src/commands/gen/keys/SIDE_EFFECTS.md b/apps/cli/src/commands/gen/keys/SIDE_EFFECTS.md index c969b9345d..a811a289a2 100644 --- a/apps/cli/src/commands/gen/keys/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/gen/keys/SIDE_EFFECTS.md @@ -42,7 +42,7 @@ regardless of `-o`/`--output`: ``` supabase gen keys was removed. -Use `supabase projects api-keys --project-ref <ref>` to read a project's API keys. +Use `supabase projects api-keys --remote=<ref>` to read a project's API keys. ``` ### `--output-format json` / `stream-json` @@ -60,4 +60,4 @@ One `cli_command_executed` event per invocation, with `exit_code: 1` and an - Unlisted (not shown in `supabase gen --help`), still reachable by exact name. - No flag value is read; the command fails identically regardless of - `--project-ref`/`--override-name`. + `--remote`/`--project-ref`/`--override-name`. 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 42f759bb95..81089dd9e0 100644 --- a/apps/cli/src/commands/network-bans/get/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/network-bans/get/SIDE_EFFECTS.md @@ -2,11 +2,11 @@ ## Files Read -| Path | Format | When | -| ---------------------------------------------- | ------------------------- | -------------------------------------------------------------------------------------------------------------------- | -| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | -| `<workdir>/supabase/.temp/linked-project.json` | JSON | always, after the project ref resolves — `linkedProjectCache` checks it exists to decide whether to write | -| `<workdir>/supabase/.temp/project-ref` | plain text (project ref) | when `--project-ref` flag and `SUPABASE_PROJECT_ID` env are both unset; also by `linkedProjectCache` on a cache miss | +| Path | Format | When | +| ---------------------------------------------- | ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | +| `<workdir>/supabase/.temp/linked-project.json` | JSON | always, after the project ref resolves — `linkedProjectCache` checks it exists to decide whether to write | +| `<workdir>/supabase/.temp/project-ref` | plain text (project ref) | when `--remote=<ref>` and `SUPABASE_PROJECT_ID` env are both unset, and as the linked parent for `--remote=<branch>`; also by `linkedProjectCache` on a cache miss | ## Files Written @@ -17,39 +17,41 @@ ## API Routes -| Method | Path | Auth | Request body | Response (used fields) | -| ------ | ------------------------------------------ | ------------ | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `GET` | `/v1/projects` | Bearer token | none | project list for the interactive picker — only when no ref comes from `--project-ref`, `SUPABASE_PROJECT_ID` or `.temp/project-ref` and stdin is a TTY with interactive output | -| `POST` | `/v1/projects/{ref}/network-bans/retrieve` | Bearer token | none | `{banned_ipv4_addresses}` | -| `GET` | `/v1/projects/{ref}` | Bearer token | none | `{ref, name, organization_id, organization_slug}` — `linkedProjectCache` fill, best-effort, skipped when `.temp/linked-project.json` exists, `.temp/project-ref` names another ref, or no token is available | +| Method | Path | Auth | Request body | Response (used fields) | +| ------ | ------------------------------------------ | ------------ | ------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `GET` | `/v1/projects` | Bearer token | none | project list for the interactive picker — only when no ref comes from `--remote=<ref>`, `SUPABASE_PROJECT_ID` or `.temp/project-ref` and stdin is a TTY with interactive output | +| `GET` | `/v1/projects/{parent}/branches` | Bearer token | none | `[{id, name, project_ref, is_default}]` — only for `--remote=<branch>` (or the deprecated `--project-ref=<branch>`), and for a bare `--remote` followed by a word that could name a branch; `{parent}` is `SUPABASE_PROJECT_ID`, else the linked project | +| `POST` | `/v1/projects/{ref}/network-bans/retrieve` | Bearer token | none | `{banned_ipv4_addresses}` | +| `GET` | `/v1/projects/{ref}` | Bearer token | none | `{ref, name, organization_id, organization_slug}` — `linkedProjectCache` fill, best-effort, skipped when `.temp/linked-project.json` exists, `.temp/project-ref` names another ref, or no token is available | The Management API exposes this read operation as `POST .../network-bans/retrieve` (not `GET`) — see `V1ListAllNetworkBans` in `packages/api/src/generated/contracts.ts`. ## 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`, then the interactive project picker on a TTY) | -| `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 `--remote=<ref>` is unset; also the parent project that `--remote=<branch>` is resolved against | no (falls back to `supabase/.temp/project-ref`, then the interactive project picker on a TTY) | +| `SUPABASE_EXPERIMENTAL` | enables `--experimental`-gated commands without the flag | no (pass `--experimental` instead; one of the two is required) | ## Exit Codes -| Code | Condition | -| ---- | ----------------------------------------------------------------------------------------------------------------------------------------- | -| `0` | success — network bans printed to stdout | -| `1` | `--experimental` not passed and `SUPABASE_EXPERIMENTAL` unset (`ExperimentalRequiredError`) — checked before ref resolution/API/telemetry | -| `1` | project ref unresolved (`ProjectRefNotLinkedError` / `InvalidProjectRefError`) | -| `1` | API non-2xx (`NetworkBansGetUnexpectedStatusError`) | -| `1` | transport failure (`NetworkBansGetNetworkError`) | -| `1` | `--output env` requested (`NetworkBansEnvNotSupportedError`) | +| Code | Condition | +| ---- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `0` | success — network bans printed to stdout | +| `1` | `--experimental` not passed and `SUPABASE_EXPERIMENTAL` unset (`ExperimentalRequiredError`) — checked before ref resolution/API/telemetry | +| `1` | project ref unresolved (`ProjectRefNotLinkedError`; `InvalidProjectRefError` for a malformed `SUPABASE_PROJECT_ID` or link-file ref) | +| `1` | invalid `--remote` selector — `--remote=<url>` (`TargetNeedsProjectError`), an empty value, `--remote` combined with `--project-ref`, or a branch the linked project does not have (`LinkBranchNotFoundError`) | +| `1` | API non-2xx (`NetworkBansGetUnexpectedStatusError`) | +| `1` | transport failure (`NetworkBansGetNetworkError`) | +| `1` | `--output env` requested (`NetworkBansEnvNotSupportedError`) | ## Telemetry Events Fired -| Event | When | Notable properties / groups | -| ---------------------- | ------------------------------------------ | -------------------------------------------------------------------- | -| `cli_command_executed` | post-run, success or failure (via wrapper) | `exit_code`, `duration_ms`, `flags` (`--project-ref` → `<redacted>`) | +| Event | When | Notable properties / groups | +| ---------------------- | ------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------- | +| `cli_command_executed` | post-run, success or failure (via wrapper) | `exit_code`, `duration_ms`, `flags` (`--remote` → `true` (bare), the ref, or `<redacted>`; `--project-ref` → `<redacted>`) | ## Output @@ -88,3 +90,15 @@ One `result` event whose `data` is the full response object. 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: …` banner naming the resolved project is printed on stderr in every output format; see [Command target](../../../../docs/command-target.md). +- Requires `--remote=<ref|branch>`, `SUPABASE_PROJECT_ID`, a linked project (`supabase/.temp/project-ref`) + or, on an interactive terminal, the project picker. A bare `--remote` targets the linked project. +- `--remote=<value>` is read as a project ref (20 lowercase letters), a branch of the linked + project (by name or UUID), or `true|yes|on|1` for the linked project. A database URL is rejected + (`supabase network-bans get` takes a project, not a database); `false|no|off|0`, `--no-remote` and an + empty value fail with `Leave out --remote to use the linked project.` +- A bare `--remote` followed by a word that is a branch of the linked project fails before any + other work with a `Did you mean --remote=<word>?` hint, since the word may have been meant as + the flag's value. +- The deprecated `--project-ref` is an alias of `--remote=<ref>` (a non-ref value is read as a + branch name) and prints a deprecation warning on stderr that names the replacement; it cannot + be combined with `--remote=<value>`. 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 5ddc2ad89e..1279813b29 100644 --- a/apps/cli/src/commands/network-bans/remove/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/network-bans/remove/SIDE_EFFECTS.md @@ -2,11 +2,11 @@ ## Files Read -| Path | Format | When | -| ---------------------------------------------- | ------------------------- | -------------------------------------------------------------------------------------------------------------------- | -| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | -| `<workdir>/supabase/.temp/linked-project.json` | JSON | always, after the project ref resolves — `linkedProjectCache` checks it exists to decide whether to write | -| `<workdir>/supabase/.temp/project-ref` | plain text (project ref) | when `--project-ref` flag and `SUPABASE_PROJECT_ID` env are both unset; also by `linkedProjectCache` on a cache miss | +| Path | Format | When | +| ---------------------------------------------- | ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | +| `<workdir>/supabase/.temp/linked-project.json` | JSON | always, after the project ref resolves — `linkedProjectCache` checks it exists to decide whether to write | +| `<workdir>/supabase/.temp/project-ref` | plain text (project ref) | when `--remote=<ref>` and `SUPABASE_PROJECT_ID` env are both unset, and as the linked parent for `--remote=<branch>`; also by `linkedProjectCache` on a cache miss | ## Files Written @@ -17,23 +17,24 @@ ## API Routes -| Method | Path | Auth | Request body | Response (used fields) | -| -------- | --------------------------------- | ------------ | --------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `GET` | `/v1/projects` | Bearer token | none | project list for the interactive picker — only when no ref comes from `--project-ref`, `SUPABASE_PROJECT_ID` or `.temp/project-ref` and stdin is a TTY with interactive output | -| `DELETE` | `/v1/projects/{ref}/network-bans` | Bearer token | `{ipv4_addresses: string[], requester_ip: boolean}` | none | -| `GET` | `/v1/projects/{ref}` | Bearer token | none | `{ref, name, organization_id, organization_slug}` — `linkedProjectCache` fill, best-effort, skipped when `.temp/linked-project.json` exists, `.temp/project-ref` names another ref, or no token is available | +| Method | Path | Auth | Request body | Response (used fields) | +| -------- | --------------------------------- | ------------ | --------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `GET` | `/v1/projects` | Bearer token | none | project list for the interactive picker — only when no ref comes from `--remote=<ref>`, `SUPABASE_PROJECT_ID` or `.temp/project-ref` and stdin is a TTY with interactive output | +| `GET` | `/v1/projects/{parent}/branches` | Bearer token | none | `[{id, name, project_ref, is_default}]` — only for `--remote=<branch>` (or the deprecated `--project-ref=<branch>`), and for a bare `--remote` followed by a word that could name a branch; `{parent}` is `SUPABASE_PROJECT_ID`, else the linked project | +| `DELETE` | `/v1/projects/{ref}/network-bans` | Bearer token | `{ipv4_addresses: string[], requester_ip: boolean}` | none | +| `GET` | `/v1/projects/{ref}` | Bearer token | none | `{ref, name, organization_id, organization_slug}` — `linkedProjectCache` fill, best-effort, skipped when `.temp/linked-project.json` exists, `.temp/project-ref` names another ref, or no token is available | `requester_ip` is `true` when no `--db-unban-ip` flags are passed (self-unban mode) and `false` otherwise. ## 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`, then the interactive project picker on a TTY) | -| `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) | +| 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 `--remote=<ref>` is unset; also the parent project that `--remote=<branch>` is resolved against | no (falls back to `supabase/.temp/project-ref`, then the interactive project picker on a TTY) | +| `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 @@ -43,7 +44,8 @@ | `1` | malformed CSV in a `--db-unban-ip` value, e.g. unterminated quote — stderr reproduces the fixed diagnostic text (`invalid argument "\"1.2.3.4" for "--db-unban-ip" flag: parse error on line 1, column 9: extraneous or missing " in quoted-field`; columns are 1-based byte offsets) — fails during flag parsing, before the `--experimental` gate, the handler, the `linked-project.json`/`telemetry.json` writes, and the `cli_command_executed` event | | `1` | `--experimental` not passed and `SUPABASE_EXPERIMENTAL` unset (`ExperimentalRequiredError`) — checked before ref resolution/API/telemetry | | `1` | invalid IP supplied via `--db-unban-ip` (`NetworkBansInvalidIpError`) | -| `1` | project ref unresolved (`ProjectRefNotLinkedError` / `InvalidProjectRefError`) | +| `1` | project ref unresolved (`ProjectRefNotLinkedError`; `InvalidProjectRefError` for a malformed `SUPABASE_PROJECT_ID` or link-file ref) | +| `1` | invalid `--remote` selector — `--remote=<url>` (`TargetNeedsProjectError`), an empty value, `--remote` combined with `--project-ref`, or a branch the linked project does not have (`LinkBranchNotFoundError`) | | `1` | API non-2xx (`NetworkBansRemoveUnexpectedStatusError`) | | `1` | transport failure (`NetworkBansRemoveNetworkError`) | | `1` | declined at the confirmation prompt (`HostedMutationDeclinedError`, `context canceled`) — nothing is changed | @@ -51,9 +53,9 @@ ## Telemetry Events Fired -| Event | When | Notable properties / groups | -| ---------------------- | ------------------------------------------ | ---------------------------------------------------------------------------------------------------- | -| `cli_command_executed` | post-run, success or failure (via wrapper) | `exit_code`, `duration_ms`, `flags` (`--project-ref` → `<redacted>`, `--db-unban-ip` → `<redacted>`) | +| Event | When | Notable properties / groups | +| ---------------------- | ------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `cli_command_executed` | post-run, success or failure (via wrapper) | `exit_code`, `duration_ms`, `flags` (`--remote` → `true` (bare), the ref, or `<redacted>`; `--project-ref` → `<redacted>`, `--db-unban-ip` → `<redacted>`) | ## Output @@ -83,7 +85,8 @@ One `result` event on success when the `--output` flag is unset. - The `--output` flag wins over `--output-format` when both are provided. - Requires `--db-unban-ip` flag to specify IP(s) to unban (repeatable). When omitted, the caller's own IP is unbanned (`requester_ip: true`). -- Requires `--project-ref`, `SUPABASE_PROJECT_ID`, a linked project (`supabase/.temp/project-ref`), or, on an interactive terminal, the project picker. +- Requires `--remote=<ref|branch>`, `SUPABASE_PROJECT_ID`, a linked project (`supabase/.temp/project-ref`) + or, on an interactive terminal, the project picker. A bare `--remote` targets the linked project. - `.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 that reaches the handler, including failures, but only once the `--experimental` gate is open. A malformed `--db-unban-ip` CSV value (e.g. an unterminated quote) fails during flag parsing — before the gate, the handler, both file writes, and the `cli_command_executed` event — even when `--experimental` is passed. - `network-bans` is an experimental command: `remove` requires `--experimental` (or @@ -93,3 +96,13 @@ One `result` event on success when the `--output` flag is unset. - 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. +- `--remote=<value>` is read as a project ref (20 lowercase letters), a branch of the linked + project (by name or UUID), or `true|yes|on|1` for the linked project. A database URL is rejected + (`supabase network-bans remove` takes a project, not a database); `false|no|off|0`, `--no-remote` and an + empty value fail with `Leave out --remote to use the linked project.` +- A bare `--remote` followed by a word that is a branch of the linked project fails before any + other work with a `Did you mean --remote=<word>?` hint, since the word may have been meant as + the flag's value. +- The deprecated `--project-ref` is an alias of `--remote=<ref>` (a non-ref value is read as a + branch name) and prints a deprecation warning on stderr that names the replacement; it cannot + be combined with `--remote=<value>`. 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 9b2f63e0c8..29392f673f 100644 --- a/apps/cli/src/commands/network-restrictions/get/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/network-restrictions/get/SIDE_EFFECTS.md @@ -2,11 +2,11 @@ ## Files Read -| Path | Format | When | -| ---------------------------------------------- | ------------------------- | -------------------------------------------------------------------------------------------------------------------- | -| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | -| `<workdir>/supabase/.temp/linked-project.json` | JSON | always, after the project ref resolves — `linkedProjectCache` checks it exists to decide whether to write | -| `<workdir>/supabase/.temp/project-ref` | plain text (project ref) | when `--project-ref` flag and `SUPABASE_PROJECT_ID` env are both unset; also by `linkedProjectCache` on a cache miss | +| Path | Format | When | +| ---------------------------------------------- | ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | +| `<workdir>/supabase/.temp/linked-project.json` | JSON | always, after the project ref resolves — `linkedProjectCache` checks it exists to decide whether to write | +| `<workdir>/supabase/.temp/project-ref` | plain text (project ref) | when `--remote=<ref>` and `SUPABASE_PROJECT_ID` env are both unset, and as the linked parent for `--remote=<branch>`; also by `linkedProjectCache` on a cache miss | ## Files Written @@ -17,36 +17,38 @@ ## API Routes -| Method | Path | Auth | Request body | Response (used fields) | -| ------ | ----------------------------------------- | ------------ | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `GET` | `/v1/projects` | Bearer token | none | project list for the interactive picker — only when no ref comes from `--project-ref`, `SUPABASE_PROJECT_ID` or `.temp/project-ref` and stdin is a TTY with interactive output | -| `GET` | `/v1/projects/{ref}/network-restrictions` | Bearer token | none | `{ config: { dbAllowedCidrs?: string[], dbAllowedCidrsV6?: string[] }, status: "stored" \| "applied" }` (see `V1GetNetworkRestrictionsOutput` in `packages/api`) | -| `GET` | `/v1/projects/{ref}` | Bearer token | none | `{ref, name, organization_id, organization_slug}` — `linkedProjectCache` fill, best-effort, skipped when `.temp/linked-project.json` exists, `.temp/project-ref` names another ref, or no token is available | +| Method | Path | Auth | Request body | Response (used fields) | +| ------ | ----------------------------------------- | ------------ | ------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `GET` | `/v1/projects` | Bearer token | none | project list for the interactive picker — only when no ref comes from `--remote=<ref>`, `SUPABASE_PROJECT_ID` or `.temp/project-ref` and stdin is a TTY with interactive output | +| `GET` | `/v1/projects/{parent}/branches` | Bearer token | none | `[{id, name, project_ref, is_default}]` — only for `--remote=<branch>` (or the deprecated `--project-ref=<branch>`), and for a bare `--remote` followed by a word that could name a branch; `{parent}` is `SUPABASE_PROJECT_ID`, else the linked project | +| `GET` | `/v1/projects/{ref}/network-restrictions` | Bearer token | none | `{ config: { dbAllowedCidrs?: string[], dbAllowedCidrsV6?: string[] }, status: "stored" \| "applied" }` (see `V1GetNetworkRestrictionsOutput` in `packages/api`) | +| `GET` | `/v1/projects/{ref}` | Bearer token | none | `{ref, name, organization_id, organization_slug}` — `linkedProjectCache` fill, best-effort, skipped when `.temp/linked-project.json` exists, `.temp/project-ref` names another ref, or no token is available | ## 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`, then the interactive project picker on a TTY) | -| `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 `--remote=<ref>` is unset; also the parent project that `--remote=<branch>` is resolved against | no (falls back to `supabase/.temp/project-ref`, then the interactive project picker on a TTY) | +| `SUPABASE_EXPERIMENTAL` | enables `--experimental`-gated commands without the flag | no (pass `--experimental` instead; one of the two is required) | ## Exit Codes -| Code | Condition | -| ---- | ----------------------------------------------------------------------------------------------------------------------------------------- | -| `0` | success — network-restrictions status printed to stdout | -| `1` | `--experimental` not passed and `SUPABASE_EXPERIMENTAL` unset (`ExperimentalRequiredError`) — checked before ref resolution/API/telemetry | -| `1` | project ref unresolved (`ProjectRefNotLinkedError` / `InvalidProjectRefError`) | -| `1` | API non-200 (`NetworkRestrictionsGetUnexpectedStatusError`) | -| `1` | transport failure (`NetworkRestrictionsGetNetworkError`) | +| Code | Condition | +| ---- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `0` | success — network-restrictions status printed to stdout | +| `1` | `--experimental` not passed and `SUPABASE_EXPERIMENTAL` unset (`ExperimentalRequiredError`) — checked before ref resolution/API/telemetry | +| `1` | project ref unresolved (`ProjectRefNotLinkedError`; `InvalidProjectRefError` for a malformed `SUPABASE_PROJECT_ID` or link-file ref) | +| `1` | invalid `--remote` selector — `--remote=<url>` (`TargetNeedsProjectError`), an empty value, `--remote` combined with `--project-ref`, or a branch the linked project does not have (`LinkBranchNotFoundError`) | +| `1` | API non-200 (`NetworkRestrictionsGetUnexpectedStatusError`) | +| `1` | transport failure (`NetworkRestrictionsGetNetworkError`) | ## Telemetry Events Fired -| Event | When | Notable properties / groups | -| ---------------------- | ---------------------------------------------------------------------------------------------- | -------------------------------------------------------------------- | -| `cli_command_executed` | post-run, success or failure (via wrapper); not fired when the `--experimental` gate is closed | `exit_code`, `duration_ms`, `flags` (`--project-ref` → `<redacted>`) | +| Event | When | Notable properties / groups | +| ---------------------- | ---------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- | +| `cli_command_executed` | post-run, success or failure (via wrapper); not fired when the `--experimental` gate is closed | `exit_code`, `duration_ms`, `flags` (`--remote` → `true` (bare), the ref, or `<redacted>`; `--project-ref` → `<redacted>`) | ## Output @@ -99,3 +101,15 @@ One `result` event whose `data` is the full response object. - `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: …` banner naming the resolved project is printed on stderr in every output format; see [Command target](../../../../docs/command-target.md). +- Requires `--remote=<ref|branch>`, `SUPABASE_PROJECT_ID`, a linked project (`supabase/.temp/project-ref`) + or, on an interactive terminal, the project picker. A bare `--remote` targets the linked project. +- `--remote=<value>` is read as a project ref (20 lowercase letters), a branch of the linked + project (by name or UUID), or `true|yes|on|1` for the linked project. A database URL is rejected + (`supabase network-restrictions get` takes a project, not a database); `false|no|off|0`, `--no-remote` and an + empty value fail with `Leave out --remote to use the linked project.` +- A bare `--remote` followed by a word that is a branch of the linked project fails before any + other work with a `Did you mean --remote=<word>?` hint, since the word may have been meant as + the flag's value. +- The deprecated `--project-ref` is an alias of `--remote=<ref>` (a non-ref value is read as a + branch name) and prints a deprecation warning on stderr that names the replacement; it cannot + be combined with `--remote=<value>`. 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 463377b76d..d051bd4c93 100644 --- a/apps/cli/src/commands/network-restrictions/update/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/network-restrictions/update/SIDE_EFFECTS.md @@ -2,11 +2,11 @@ ## Files Read -| Path | Format | When | -| ---------------------------------------------- | ------------------------- | -------------------------------------------------------------------------------------------------------------------- | -| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | -| `<workdir>/supabase/.temp/linked-project.json` | JSON | always, after the project ref resolves — `linkedProjectCache` checks it exists to decide whether to write | -| `<workdir>/supabase/.temp/project-ref` | plain text (project ref) | when `--project-ref` flag and `SUPABASE_PROJECT_ID` env are both unset; also by `linkedProjectCache` on a cache miss | +| Path | Format | When | +| ---------------------------------------------- | ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | +| `<workdir>/supabase/.temp/linked-project.json` | JSON | always, after the project ref resolves — `linkedProjectCache` checks it exists to decide whether to write | +| `<workdir>/supabase/.temp/project-ref` | plain text (project ref) | when `--remote=<ref>` and `SUPABASE_PROJECT_ID` env are both unset, and as the linked parent for `--remote=<branch>`; also by `linkedProjectCache` on a cache miss | ## Files Written @@ -17,12 +17,13 @@ ## API Routes -| Method | Path | Auth | Request body | Response (used fields) | -| ------- | ----------------------------------------------- | ------------ | ------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `GET` | `/v1/projects` | Bearer token | none | project list for the interactive picker — only when no ref comes from `--project-ref`, `SUPABASE_PROJECT_ID` or `.temp/project-ref` and stdin is a TTY with interactive output | -| `POST` | `/v1/projects/{ref}/network-restrictions/apply` | Bearer token | `{ dbAllowedCidrs: string[], dbAllowedCidrsV6: string[] }` | `{ config: { dbAllowedCidrs?, dbAllowedCidrsV6? }, status }` (`V1UpdateNetworkRestrictionsOutput`) | -| `PATCH` | `/v1/projects/{ref}/network-restrictions` | Bearer token | `{ add: { dbAllowedCidrs: string[], dbAllowedCidrsV6: string[] } }` | `{ config: { dbAllowedCidrs?: Array<{address, type}> }, status }` (`V1PatchNetworkRestrictionsOutput`) | -| `GET` | `/v1/projects/{ref}` | Bearer token | none | `{ref, name, organization_id, organization_slug}` — `linkedProjectCache` fill, best-effort, skipped when `.temp/linked-project.json` exists, `.temp/project-ref` names another ref, or no token is available | +| Method | Path | Auth | Request body | Response (used fields) | +| ------- | ----------------------------------------------- | ------------ | ------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `GET` | `/v1/projects` | Bearer token | none | project list for the interactive picker — only when no ref comes from `--remote=<ref>`, `SUPABASE_PROJECT_ID` or `.temp/project-ref` and stdin is a TTY with interactive output | +| `GET` | `/v1/projects/{parent}/branches` | Bearer token | none | `[{id, name, project_ref, is_default}]` — only for `--remote=<branch>` (or the deprecated `--project-ref=<branch>`), and for a bare `--remote` followed by a word that could name a branch; `{parent}` is `SUPABASE_PROJECT_ID`, else the linked project | +| `POST` | `/v1/projects/{ref}/network-restrictions/apply` | Bearer token | `{ dbAllowedCidrs: string[], dbAllowedCidrsV6: string[] }` | `{ config: { dbAllowedCidrs?, dbAllowedCidrsV6? }, status }` (`V1UpdateNetworkRestrictionsOutput`) | +| `PATCH` | `/v1/projects/{ref}/network-restrictions` | Bearer token | `{ add: { dbAllowedCidrs: string[], dbAllowedCidrsV6: string[] } }` | `{ config: { dbAllowedCidrs?: Array<{address, type}> }, status }` (`V1PatchNetworkRestrictionsOutput`) | +| `GET` | `/v1/projects/{ref}` | Bearer token | none | `{ref, name, organization_id, organization_slug}` — `linkedProjectCache` fill, best-effort, skipped when `.temp/linked-project.json` exists, `.temp/project-ref` names another ref, or no token is available | `POST /apply` is the default (replace mode). `PATCH` is used when `--append=true`. @@ -31,13 +32,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`, then the interactive project picker on a TTY) | -| `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) | +| 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 `--remote=<ref>` is unset; also the parent project that `--remote=<branch>` is resolved against | no (falls back to `supabase/.temp/project-ref`, then the interactive project picker on a TTY) | +| `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 @@ -48,7 +49,8 @@ when no `--db-allow-cidr` was supplied). | `1` | `--experimental` not passed and `SUPABASE_EXPERIMENTAL` unset (`ExperimentalRequiredError`) — checked before CIDR validation/ref/API | | `1` | CIDR parse failure — `NetworkRestrictionsInvalidCidrError` (`failed to parse IP: <input>`) | | `1` | private-IP rejection — `NetworkRestrictionsPrivateIpError` (`private IP provided: <input>`) | -| `1` | project ref unresolved (`ProjectRefNotLinkedError` / `InvalidProjectRefError`) | +| `1` | project ref unresolved (`ProjectRefNotLinkedError`; `InvalidProjectRefError` for a malformed `SUPABASE_PROJECT_ID` or link-file ref) | +| `1` | invalid `--remote` selector — `--remote=<url>` (`TargetNeedsProjectError`), an empty value, `--remote` combined with `--project-ref`, or a branch the linked project does not have (`LinkBranchNotFoundError`) | | `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 | @@ -56,9 +58,9 @@ when no `--db-allow-cidr` was supplied). ## Telemetry Events Fired -| Event | When | Notable properties / groups | -| ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------- | -| `cli_command_executed` | post-run, success or failure (via wrapper); not fired when the `--experimental` gate is closed or when flag parsing fails (malformed `--db-allow-cidr` CSV) | `exit_code`, `duration_ms`, `flags` (`--project-ref` → `<redacted>`) | +| Event | When | Notable properties / groups | +| ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- | +| `cli_command_executed` | post-run, success or failure (via wrapper); not fired when the `--experimental` gate is closed or when flag parsing fails (malformed `--db-allow-cidr` CSV) | `exit_code`, `duration_ms`, `flags` (`--remote` → `true` (bare), the ref, or `<redacted>`; `--project-ref` → `<redacted>`) | ## CIDR Validation (runs locally before any HTTP call) @@ -126,3 +128,15 @@ One `result` event whose `data` is the full response object. flag parsing, before the gate and the handler. - 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. +- Requires `--remote=<ref|branch>`, `SUPABASE_PROJECT_ID`, a linked project (`supabase/.temp/project-ref`) + or, on an interactive terminal, the project picker. A bare `--remote` targets the linked project. +- `--remote=<value>` is read as a project ref (20 lowercase letters), a branch of the linked + project (by name or UUID), or `true|yes|on|1` for the linked project. A database URL is rejected + (`supabase network-restrictions update` takes a project, not a database); `false|no|off|0`, `--no-remote` and an + empty value fail with `Leave out --remote to use the linked project.` +- A bare `--remote` followed by a word that is a branch of the linked project fails before any + other work with a `Did you mean --remote=<word>?` hint, since the word may have been meant as + the flag's value. +- The deprecated `--project-ref` is an alias of `--remote=<ref>` (a non-ref value is read as a + branch name) and prints a deprecation warning on stderr that names the replacement; it cannot + be combined with `--remote=<value>`. diff --git a/apps/cli/src/commands/notebooks/pull/SIDE_EFFECTS.md b/apps/cli/src/commands/notebooks/pull/SIDE_EFFECTS.md index 0f369bb1fd..40e13b1833 100644 --- a/apps/cli/src/commands/notebooks/pull/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/notebooks/pull/SIDE_EFFECTS.md @@ -7,14 +7,14 @@ the timestamps, the `owner` / `updated_by` identities, and `content.schema_versi ## Files Read -| Path | Format | When | -| ---------------------------------------------- | ---------- | ----------------------------------------------------------------------------------------------------------- | -| `<workdir>/supabase/notebooks/` | dir | when no notebook id is given — to preserve existing notebooks and find local-only notebooks | -| `<workdir>/supabase/notebooks/<name>.json` | JSON | only for a local-only notebook the user chose to create in the project | -| `<workdir>/supabase/.temp/project-ref` | plain text | when `--project-ref` and `SUPABASE_PROJECT_ID` are both unset | -| `<workdir>/supabase/.temp/linked-project.json` | JSON | existence check before the cache fill, once the project ref resolves | -| `<SUPABASE_HOME or ~/.supabase>/profile` | plain text | when neither `--profile` nor `SUPABASE_PROFILE` is set — names the profile, defaulting to `supabase` | -| `<SUPABASE_PROFILE>` (YAML) | YAML | when `SUPABASE_PROFILE` is a filesystem path rather than a built-in name; a read failure aborts the command | +| Path | Format | When | +| ---------------------------------------------- | ---------- | ---------------------------------------------------------------------------------------------------------------- | +| `<workdir>/supabase/notebooks/` | dir | when no notebook id is given — to preserve existing notebooks and find local-only notebooks | +| `<workdir>/supabase/notebooks/<name>.json` | JSON | only for a local-only notebook the user chose to create in the project | +| `<workdir>/supabase/.temp/project-ref` | plain text | when `--remote=<ref>` and `SUPABASE_PROJECT_ID` are both unset, and as the linked parent for `--remote=<branch>` | +| `<workdir>/supabase/.temp/linked-project.json` | JSON | existence check before the cache fill, once the project ref resolves | +| `<SUPABASE_HOME or ~/.supabase>/profile` | plain text | when neither `--profile` nor `SUPABASE_PROFILE` is set — names the profile, defaulting to `supabase` | +| `<SUPABASE_PROFILE>` (YAML) | YAML | when `SUPABASE_PROFILE` is a filesystem path rather than a built-in name; a read failure aborts the command | ## Files Written @@ -57,13 +57,14 @@ nothing else is reconciled. ## API Routes -| Method | Path | Auth | Request body | Response (used fields) | -| ------ | ----------------------------------- | ------ | ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| GET | `/v2/projects/{ref}/notebooks` | Bearer | — | `data[].id`, `data[].attributes.name`, `links.next` — broad pull only | -| GET | `/v2/projects/{ref}/notebooks/{id}` | Bearer | — | `data.{id,attributes.{name,description,favorite,content}}` | -| POST | `/v2/projects/{ref}/notebooks` | Bearer | `data.attributes` from the file | — (only for the create answer) | -| GET | `/v1/projects` | Bearer | — | `id`, `name`, `organization_slug`, `region` — project picker; only when no ref comes from `--project-ref`, `SUPABASE_PROJECT_ID` or `.temp/project-ref`, stdin is a TTY and output is interactive | -| GET | `/v1/projects/{ref}` | Bearer | — | linked-project cache fill — best-effort, only when `.temp/linked-project.json` is absent, `.temp/project-ref` is absent or names the same ref, and a token is available; failures are ignored | +| Method | Path | Auth | Request body | Response (used fields) | +| ------ | ----------------------------------- | ------------ | ------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `GET` | `/v1/projects/{parent}/branches` | Bearer token | none | `[{id, name, project_ref, is_default}]` — only for `--remote=<branch>` (or the deprecated `--project-ref=<branch>`), and for a bare `--remote` followed by a word that could name a branch; `{parent}` is `SUPABASE_PROJECT_ID`, else the linked project | +| GET | `/v2/projects/{ref}/notebooks` | Bearer | — | `data[].id`, `data[].attributes.name`, `links.next` — broad pull only | +| GET | `/v2/projects/{ref}/notebooks/{id}` | Bearer | — | `data.{id,attributes.{name,description,favorite,content}}` | +| POST | `/v2/projects/{ref}/notebooks` | Bearer | `data.attributes` from the file | — (only for the create answer) | +| GET | `/v1/projects` | Bearer | — | `id`, `name`, `organization_slug`, `region` — project picker; only when no ref comes from `--remote=<ref>`, `SUPABASE_PROJECT_ID` or `.temp/project-ref`, stdin is a TTY and output is interactive | +| GET | `/v1/projects/{ref}` | Bearer | — | linked-project cache fill — best-effort, only when `.temp/linked-project.json` is absent, `.temp/project-ref` is absent or names the same ref, and a token is available; failures are ignored | The list route is walked by following `links.next` until it is null, rather than by counting rows: a short page cannot be told from an exact multiple of the page @@ -71,28 +72,29 @@ size. ## Exit Codes -| Code | Condition | -| ---- | -------------------------------------------------------------------------------- | -| `0` | success | -| `1` | no project ref — not linked and no `--project-ref` | -| `1` | the supplied notebook id is not a UUID | -| `1` | the project holds two notebooks of one name during a broad pull | -| `1` | the notebooks path exists but cannot be read as a directory | -| `1` | an explicitly requested notebook name cannot be stored safely as a local file | -| `1` | a Management API call failed (transport, unexpected status, or undecodable body) | -| `1` | a local file the user chose to create in the project is not a readable notebook | -| `1` | a local notebook file cannot be written | -| `1` | `-o env`, which cannot represent the payload | +| Code | Condition | +| ---- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `0` | success | +| `1` | no project ref — not linked and no `--remote` | +| `1` | the supplied notebook id is not a UUID | +| `1` | the project holds two notebooks of one name during a broad pull | +| `1` | the notebooks path exists but cannot be read as a directory | +| `1` | an explicitly requested notebook name cannot be stored safely as a local file | +| `1` | a Management API call failed (transport, unexpected status, or undecodable body) | +| `1` | a local file the user chose to create in the project is not a readable notebook | +| `1` | a local notebook file cannot be written | +| `1` | `-o env`, which cannot represent the payload | +| `1` | invalid `--remote` selector — `--remote=<url>` (`TargetNeedsProjectError`), an empty value, `--remote` combined with `--project-ref`, or a branch the linked project does not have (`LinkBranchNotFoundError`) | ## 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_PROJECT_ID` | project ref when `--project-ref` is unset | no (falls back to `supabase/.temp/project-ref`, then the picker on a TTY) | -| `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_PROJECT_ID` | project ref when `--remote=<ref>` is unset; also the parent project that `--remote=<branch>` is resolved against | no (falls back to `supabase/.temp/project-ref`, then the picker on a TTY) | +| `SUPABASE_WORKDIR` | project directory the command acts on | no (falls back to `--workdir`, then the ancestor walk) | +| `SUPABASE_HOME` | directory holding `telemetry.json` | no (falls back to `~/.supabase`) | ## Telemetry Events Fired @@ -100,9 +102,9 @@ size. | ---------------------- | ---------------------------------------------- | -------------------------------------------------- | | `cli_command_executed` | post-handler, success or failure (via wrapper) | `exit_code`, `duration_ms`, `flags`, project group | -`--project-ref` is marked telemetry-safe, so its value is recorded verbatim — -the same call `functions download` makes for the same flag. The notebook id -argument is not recorded. +`--remote` is recorded as `true` (bare), the ref, or `<redacted>`; the deprecated +`--project-ref` is recorded verbatim only when it is ref-shaped, never a branch name. +The notebook id argument is not recorded. ## Filename safety and failures @@ -127,3 +129,18 @@ Network operations show progress in text mode; machine output remains payload-on runs. `-o env` is rejected by the handler before resolving the project. 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). + +## Notes + +- Requires `--remote=<ref|branch>`, `SUPABASE_PROJECT_ID`, a linked project (`supabase/.temp/project-ref`) + or, on an interactive terminal, the project picker. A bare `--remote` targets the linked project. +- `--remote=<value>` is read as a project ref (20 lowercase letters), a branch of the linked + project (by name or UUID), or `true|yes|on|1` for the linked project. A database URL is rejected + (`supabase notebooks pull` takes a project, not a database); `false|no|off|0`, `--no-remote` and an + empty value fail with `Leave out --remote to use the linked project.` +- A bare `--remote` followed by a word that is a branch of the linked project fails before any + other work with a `Did you mean --remote=<word>?` hint, since the word may have been meant as + the flag's value. +- The deprecated `--project-ref` is an alias of `--remote=<ref>` (a non-ref value is read as a + branch name) and prints a deprecation warning on stderr that names the replacement; it cannot + be combined with `--remote=<value>`. diff --git a/apps/cli/src/commands/notebooks/push/SIDE_EFFECTS.md b/apps/cli/src/commands/notebooks/push/SIDE_EFFECTS.md index 6bdf5583a4..c6c22ce698 100644 --- a/apps/cli/src/commands/notebooks/push/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/notebooks/push/SIDE_EFFECTS.md @@ -8,14 +8,14 @@ notebook, and one with no match creates a new notebook. ## Files Read -| Path | Format | When | -| ---------------------------------------------- | ---------- | ----------------------------------------------------------------------------------------------------------- | -| `<workdir>/supabase/notebooks/` | dir | always — the file names are the notebook names; missing means empty, while any other read failure aborts | -| `<workdir>/supabase/notebooks/<name>.json` | JSON | one per notebook being pushed; all of them are read and decoded before the first call | -| `<workdir>/supabase/.temp/project-ref` | plain text | when `--project-ref` and `SUPABASE_PROJECT_ID` are both unset | -| `<workdir>/supabase/.temp/linked-project.json` | JSON | existence check before the cache fill, once the project ref resolves | -| `<SUPABASE_HOME or ~/.supabase>/profile` | plain text | when neither `--profile` nor `SUPABASE_PROFILE` is set — names the profile, defaulting to `supabase` | -| `<SUPABASE_PROFILE>` (YAML) | YAML | when `SUPABASE_PROFILE` is a filesystem path rather than a built-in name; a read failure aborts the command | +| Path | Format | When | +| ---------------------------------------------- | ---------- | ---------------------------------------------------------------------------------------------------------------- | +| `<workdir>/supabase/notebooks/` | dir | always — the file names are the notebook names; missing means empty, while any other read failure aborts | +| `<workdir>/supabase/notebooks/<name>.json` | JSON | one per notebook being pushed; all of them are read and decoded before the first call | +| `<workdir>/supabase/.temp/project-ref` | plain text | when `--remote=<ref>` and `SUPABASE_PROJECT_ID` are both unset, and as the linked parent for `--remote=<branch>` | +| `<workdir>/supabase/.temp/linked-project.json` | JSON | existence check before the cache fill, once the project ref resolves | +| `<SUPABASE_HOME or ~/.supabase>/profile` | plain text | when neither `--profile` nor `SUPABASE_PROFILE` is set — names the profile, defaulting to `supabase` | +| `<SUPABASE_PROFILE>` (YAML) | YAML | when `SUPABASE_PROFILE` is a filesystem path rather than a built-in name; a read failure aborts the command | Every file is read and decoded **before** the first write, so a directory holding one unreadable notebook fails without having half-pushed the rest. @@ -63,15 +63,16 @@ entry are canonicalized and refused when they resolve outside the project root. ## API Routes -| Method | Path | Auth | Request body | Response (used fields) | -| ------ | ----------------------------------- | ------ | -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| GET | `/v2/projects/{ref}/notebooks` | Bearer | — | `data[].id`, `data[].attributes.name`, `links.next` | -| POST | `/v2/projects/{ref}/notebooks` | Bearer | `data.attributes` — name plus the file | — | -| PATCH | `/v2/projects/{ref}/notebooks/{id}` | Bearer | `data.attributes` — name plus the file | — | -| DELETE | `/v2/projects/{ref}/notebooks/{id}` | Bearer | — | — (only for the delete answer) | -| GET | `/v2/projects/{ref}/notebooks/{id}` | Bearer | — | — (only for the write-locally answer) | -| GET | `/v1/projects` | Bearer | — | `id`, `name`, `organization_slug`, `region` — project picker; only when no ref comes from `--project-ref`, `SUPABASE_PROJECT_ID` or `.temp/project-ref`, stdin is a TTY and output is interactive | -| GET | `/v1/projects/{ref}` | Bearer | — | linked-project cache fill — best-effort, only when `.temp/linked-project.json` is absent, `.temp/project-ref` is absent or names the same ref, and a token is available; failures are ignored | +| Method | Path | Auth | Request body | Response (used fields) | +| ------ | ----------------------------------- | ------------ | -------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `GET` | `/v1/projects/{parent}/branches` | Bearer token | none | `[{id, name, project_ref, is_default}]` — only for `--remote=<branch>` (or the deprecated `--project-ref=<branch>`), and for a bare `--remote` followed by a word that could name a branch; `{parent}` is `SUPABASE_PROJECT_ID`, else the linked project | +| GET | `/v2/projects/{ref}/notebooks` | Bearer | — | `data[].id`, `data[].attributes.name`, `links.next` | +| POST | `/v2/projects/{ref}/notebooks` | Bearer | `data.attributes` — name plus the file | — | +| PATCH | `/v2/projects/{ref}/notebooks/{id}` | Bearer | `data.attributes` — name plus the file | — | +| DELETE | `/v2/projects/{ref}/notebooks/{id}` | Bearer | — | — (only for the delete answer) | +| GET | `/v2/projects/{ref}/notebooks/{id}` | Bearer | — | — (only for the write-locally answer) | +| GET | `/v1/projects` | Bearer | — | `id`, `name`, `organization_slug`, `region` — project picker; only when no ref comes from `--remote=<ref>`, `SUPABASE_PROJECT_ID` or `.temp/project-ref`, stdin is a TTY and output is interactive | +| GET | `/v1/projects/{ref}` | Bearer | — | linked-project cache fill — best-effort, only when `.temp/linked-project.json` is absent, `.temp/project-ref` is absent or names the same ref, and a token is available; failures are ignored | `content` replaces the whole notebook body, and a cell keeps its identity by echoing back its `id` — which is why a pulled file carries cell ids and a push @@ -82,30 +83,31 @@ 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 | -| `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 | +| Code | Condition | +| ---- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `0` | success | +| `1` | no project ref — not linked and no `--remote` | +| `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 | +| `1` | invalid `--remote` selector — `--remote=<url>` (`TargetNeedsProjectError`), an empty value, `--remote` combined with `--project-ref`, or a branch the linked project does not have (`LinkBranchNotFoundError`) | ## 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_PROJECT_ID` | project ref when `--project-ref` is unset | no (falls back to `supabase/.temp/project-ref`, then the picker on a TTY) | -| `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) | +| 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_PROJECT_ID` | project ref when `--remote=<ref>` is unset; also the parent project that `--remote=<branch>` is resolved against | no (falls back to `supabase/.temp/project-ref`, then the picker on a TTY) | +| `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 @@ -113,9 +115,9 @@ never mentions `favorite` keeps whatever the dashboard set. | ---------------------- | ---------------------------------------------- | -------------------------------------------------- | | `cli_command_executed` | post-handler, success or failure (via wrapper) | `exit_code`, `duration_ms`, `flags`, project group | -`--project-ref` is marked telemetry-safe, so its value is recorded verbatim — -the same call `functions deploy` makes for the same flag. The notebook name -argument is user content and is not. +`--remote` is recorded as `true` (bare), the ref, or `<redacted>`; the deprecated +`--project-ref` is recorded verbatim only when it is ref-shaped, never a branch name. +The notebook name argument is user content and is not. ## Filename safety and failures @@ -138,3 +140,18 @@ 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. + +## Notes + +- Requires `--remote=<ref|branch>`, `SUPABASE_PROJECT_ID`, a linked project (`supabase/.temp/project-ref`) + or, on an interactive terminal, the project picker. A bare `--remote` targets the linked project. +- `--remote=<value>` is read as a project ref (20 lowercase letters), a branch of the linked + project (by name or UUID), or `true|yes|on|1` for the linked project. A database URL is rejected + (`supabase notebooks push` takes a project, not a database); `false|no|off|0`, `--no-remote` and an + empty value fail with `Leave out --remote to use the linked project.` +- A bare `--remote` followed by a word that is a branch of the linked project fails before any + other work with a `Did you mean --remote=<word>?` hint, since the word may have been meant as + the flag's value. +- The deprecated `--project-ref` is an alias of `--remote=<ref>` (a non-ref value is read as a + branch name) and prints a deprecation warning on stderr that names the replacement; it cannot + be combined with `--remote=<value>`. 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 57d5d248b2..be3b1b07f0 100644 --- a/apps/cli/src/commands/postgres-config/delete/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/postgres-config/delete/SIDE_EFFECTS.md @@ -2,11 +2,11 @@ ## Files Read -| Path | Format | When | -| ---------------------------------------------- | ------------------------- | -------------------------------------------------------------------------------------------------------------------- | -| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | -| `<workdir>/supabase/.temp/linked-project.json` | JSON | always, after the project ref resolves — `linkedProjectCache` checks it exists to decide whether to write | -| `<workdir>/supabase/.temp/project-ref` | plain text (project ref) | when `--project-ref` flag and `SUPABASE_PROJECT_ID` env are both unset; also by `linkedProjectCache` on a cache miss | +| Path | Format | When | +| ---------------------------------------------- | ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | +| `<workdir>/supabase/.temp/linked-project.json` | JSON | always, after the project ref resolves — `linkedProjectCache` checks it exists to decide whether to write | +| `<workdir>/supabase/.temp/project-ref` | plain text (project ref) | when `--remote=<ref>` and `SUPABASE_PROJECT_ID` env are both unset, and as the linked parent for `--remote=<branch>`; also by `linkedProjectCache` on a cache miss | ## Files Written @@ -17,12 +17,13 @@ ## API Routes -| Method | Path | Auth | Request body | Response (used fields) | -| ------ | --------------------------------------------- | ------------ | --------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `GET` | `/v1/projects` | Bearer token | none | project list for the interactive picker — only when no ref comes from `--project-ref`, `SUPABASE_PROJECT_ID` or `.temp/project-ref` and stdin is a TTY with interactive output | -| `GET` | `/v1/projects/{ref}/config/database/postgres` | Bearer token | none | full JSON object | -| `PUT` | `/v1/projects/{ref}/config/database/postgres` | Bearer token | current config minus deleted keys (`restart_database` optional) | full JSON object | -| `GET` | `/v1/projects/{ref}` | Bearer token | none | `{ref, name, organization_id, organization_slug}` — `linkedProjectCache` fill, best-effort, skipped when `.temp/linked-project.json` exists, `.temp/project-ref` names another ref, or no token is available | +| Method | Path | Auth | Request body | Response (used fields) | +| ------ | --------------------------------------------- | ------------ | --------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `GET` | `/v1/projects` | Bearer token | none | project list for the interactive picker — only when no ref comes from `--remote=<ref>`, `SUPABASE_PROJECT_ID` or `.temp/project-ref` and stdin is a TTY with interactive output | +| `GET` | `/v1/projects/{parent}/branches` | Bearer token | none | `[{id, name, project_ref, is_default}]` — only for `--remote=<branch>` (or the deprecated `--project-ref=<branch>`), and for a bare `--remote` followed by a word that could name a branch; `{parent}` is `SUPABASE_PROJECT_ID`, else the linked project | +| `GET` | `/v1/projects/{ref}/config/database/postgres` | Bearer token | none | full JSON object | +| `PUT` | `/v1/projects/{ref}/config/database/postgres` | Bearer token | current config minus deleted keys (`restart_database` optional) | full JSON object | +| `GET` | `/v1/projects/{ref}` | Bearer token | none | `{ref, name, organization_id, organization_slug}` — `linkedProjectCache` fill, best-effort, skipped when `.temp/linked-project.json` exists, `.temp/project-ref` names another ref, or no token is available | This command does not call a delete endpoint: it fetches the current config, removes the specified keys locally, then sends the remaining object back via `PUT`. @@ -30,13 +31,13 @@ The remaining stored overrides are forwarded unvalidated, so a stored key this C ## 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`, then the interactive project picker on a TTY) | -| `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) | +| 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 `--remote=<ref>` is unset; also the parent project that `--remote=<branch>` is resolved against | no (falls back to `supabase/.temp/project-ref`, then the interactive project picker on a TTY) | +| `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 @@ -45,7 +46,8 @@ The remaining stored overrides are forwarded unvalidated, so a stored key this C | `0` | success - Postgres config updated with the deleted keys removed | | `1` | malformed CSV in a `--config` value — fails during flag parsing, before the `--experimental` gate, the handler, and telemetry, with the exact diagnostic text on stderr (e.g. `invalid argument "\"max_connections" for "--config" flag: parse error on line 1, column 17: extraneous or missing " in quoted-field`; a blank-only value fails with `EOF`) — CLI-2005 | | `1` | `--experimental` not passed and `SUPABASE_EXPERIMENTAL` unset (`ExperimentalRequiredError`) - checked before ref resolution/API/telemetry | -| `1` | project ref unresolved (`ProjectRefNotLinkedError` / `InvalidProjectRefError`) | +| `1` | project ref unresolved (`ProjectRefNotLinkedError`; `InvalidProjectRefError` for a malformed `SUPABASE_PROJECT_ID` or link-file ref) | +| `1` | invalid `--remote` selector — `--remote=<url>` (`TargetNeedsProjectError`), an empty value, `--remote` combined with `--project-ref`, or a branch the linked project does not have (`LinkBranchNotFoundError`) | | `1` | initial GET non-2xx (`PostgresConfigGetUnexpectedStatusError`) | | `1` | initial GET transport failure (`PostgresConfigGetNetworkError`) | | `1` | PUT non-2xx (`PostgresConfigDeleteUnexpectedStatusError`) | @@ -56,9 +58,9 @@ The remaining stored overrides are forwarded unvalidated, so a stored key this C ## Telemetry Events Fired -| Event | When | Notable properties / groups | -| ---------------------- | ------------------------------------------ | --------------------------------------------------------------------- | -| `cli_command_executed` | post-run, success or failure (via wrapper) | `exit_code`, `duration_ms`, `flags` (`--project-ref` -> `<redacted>`) | +| Event | When | Notable properties / groups | +| ---------------------- | ------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------- | +| `cli_command_executed` | post-run, success or failure (via wrapper) | `exit_code`, `duration_ms`, `flags` (`--remote` → `true` (bare), the ref, or `<redacted>`; `--project-ref` → `<redacted>`) | ## Output @@ -104,7 +106,8 @@ One `result` event on success. - The `--output` flag wins over `--output-format` when both are provided. - Flags: `--config` (repeatable, config keys to delete), `--no-restart`. -- Requires `--project-ref`, `SUPABASE_PROJECT_ID`, a linked project (`supabase/.temp/project-ref`), or, on an interactive terminal, the project picker. +- Requires `--remote=<ref|branch>`, `SUPABASE_PROJECT_ID`, a linked project (`supabase/.temp/project-ref`) + or, on an interactive terminal, the project picker. A bare `--remote` targets the linked project. - Each config key is trimmed of whitespace before deletion. - `--no-restart` injects `restart_database = false` into the final `PUT` body. - `.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. @@ -116,3 +119,13 @@ One `result` event on success. - 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. +- `--remote=<value>` is read as a project ref (20 lowercase letters), a branch of the linked + project (by name or UUID), or `true|yes|on|1` for the linked project. A database URL is rejected + (`supabase postgres-config delete` takes a project, not a database); `false|no|off|0`, `--no-remote` and an + empty value fail with `Leave out --remote to use the linked project.` +- A bare `--remote` followed by a word that is a branch of the linked project fails before any + other work with a `Did you mean --remote=<word>?` hint, since the word may have been meant as + the flag's value. +- The deprecated `--project-ref` is an alias of `--remote=<ref>` (a non-ref value is read as a + branch name) and prints a deprecation warning on stderr that names the replacement; it cannot + be combined with `--remote=<value>`. 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 170bc0e3cb..737095f893 100644 --- a/apps/cli/src/commands/postgres-config/get/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/postgres-config/get/SIDE_EFFECTS.md @@ -2,11 +2,11 @@ ## Files Read -| Path | Format | When | -| ---------------------------------------------- | ------------------------- | -------------------------------------------------------------------------------------------------------------------- | -| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | -| `<workdir>/supabase/.temp/linked-project.json` | JSON | always, after the project ref resolves — `linkedProjectCache` checks it exists to decide whether to write | -| `<workdir>/supabase/.temp/project-ref` | plain text (project ref) | when `--project-ref` flag and `SUPABASE_PROJECT_ID` env are both unset; also by `linkedProjectCache` on a cache miss | +| Path | Format | When | +| ---------------------------------------------- | ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | +| `<workdir>/supabase/.temp/linked-project.json` | JSON | always, after the project ref resolves — `linkedProjectCache` checks it exists to decide whether to write | +| `<workdir>/supabase/.temp/project-ref` | plain text (project ref) | when `--remote=<ref>` and `SUPABASE_PROJECT_ID` env are both unset, and as the linked parent for `--remote=<branch>`; also by `linkedProjectCache` on a cache miss | ## Files Written @@ -17,37 +17,39 @@ ## API Routes -| Method | Path | Auth | Request body | Response (used fields) | -| ------ | --------------------------------------------- | ------------ | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `GET` | `/v1/projects` | Bearer token | none | project list for the interactive picker — only when no ref comes from `--project-ref`, `SUPABASE_PROJECT_ID` or `.temp/project-ref` and stdin is a TTY with interactive output | -| `GET` | `/v1/projects/{ref}/config/database/postgres` | Bearer token | none | full JSON object | -| `GET` | `/v1/projects/{ref}` | Bearer token | none | `{ref, name, organization_id, organization_slug}` — `linkedProjectCache` fill, best-effort, skipped when `.temp/linked-project.json` exists, `.temp/project-ref` names another ref, or no token is available | +| Method | Path | Auth | Request body | Response (used fields) | +| ------ | --------------------------------------------- | ------------ | ------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `GET` | `/v1/projects` | Bearer token | none | project list for the interactive picker — only when no ref comes from `--remote=<ref>`, `SUPABASE_PROJECT_ID` or `.temp/project-ref` and stdin is a TTY with interactive output | +| `GET` | `/v1/projects/{parent}/branches` | Bearer token | none | `[{id, name, project_ref, is_default}]` — only for `--remote=<branch>` (or the deprecated `--project-ref=<branch>`), and for a bare `--remote` followed by a word that could name a branch; `{parent}` is `SUPABASE_PROJECT_ID`, else the linked project | +| `GET` | `/v1/projects/{ref}/config/database/postgres` | Bearer token | none | full JSON object | +| `GET` | `/v1/projects/{ref}` | Bearer token | none | `{ref, name, organization_id, organization_slug}` — `linkedProjectCache` fill, best-effort, skipped when `.temp/linked-project.json` exists, `.temp/project-ref` names another ref, or no token is available | ## 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`, then the interactive project picker on a TTY) | -| `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 `--remote=<ref>` is unset; also the parent project that `--remote=<branch>` is resolved against | no (falls back to `supabase/.temp/project-ref`, then the interactive project picker on a TTY) | +| `SUPABASE_EXPERIMENTAL` | enables `--experimental`-gated commands without the flag | no (pass `--experimental` instead; one of the two is required) | ## Exit Codes -| Code | Condition | -| ---- | ----------------------------------------------------------------------------------------------------------------------------------------- | -| `0` | success - Postgres config printed | -| `1` | `--experimental` not passed and `SUPABASE_EXPERIMENTAL` unset (`ExperimentalRequiredError`) - checked before ref resolution/API/telemetry | -| `1` | project ref unresolved (`ProjectRefNotLinkedError` / `InvalidProjectRefError`) | -| `1` | API non-2xx (`PostgresConfigGetUnexpectedStatusError`) | -| `1` | transport failure (`PostgresConfigGetNetworkError`); the GET retries on 5xx and transport errors first | -| `1` | response body is not a JSON object (`PostgresConfigGetUnmarshalError`) | +| Code | Condition | +| ---- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `0` | success - Postgres config printed | +| `1` | `--experimental` not passed and `SUPABASE_EXPERIMENTAL` unset (`ExperimentalRequiredError`) - checked before ref resolution/API/telemetry | +| `1` | project ref unresolved (`ProjectRefNotLinkedError`; `InvalidProjectRefError` for a malformed `SUPABASE_PROJECT_ID` or link-file ref) | +| `1` | invalid `--remote` selector — `--remote=<url>` (`TargetNeedsProjectError`), an empty value, `--remote` combined with `--project-ref`, or a branch the linked project does not have (`LinkBranchNotFoundError`) | +| `1` | API non-2xx (`PostgresConfigGetUnexpectedStatusError`) | +| `1` | transport failure (`PostgresConfigGetNetworkError`); the GET retries on 5xx and transport errors first | +| `1` | response body is not a JSON object (`PostgresConfigGetUnmarshalError`) | ## Telemetry Events Fired -| Event | When | Notable properties / groups | -| ---------------------- | ------------------------------------------ | --------------------------------------------------------------------- | -| `cli_command_executed` | post-run, success or failure (via wrapper) | `exit_code`, `duration_ms`, `flags` (`--project-ref` -> `<redacted>`) | +| Event | When | Notable properties / groups | +| ---------------------- | ------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------- | +| `cli_command_executed` | post-run, success or failure (via wrapper) | `exit_code`, `duration_ms`, `flags` (`--remote` → `true` (bare), the ref, or `<redacted>`; `--project-ref` → `<redacted>`) | ## Output @@ -92,7 +94,8 @@ One `result` event on success. ## Notes - The `--output` flag wins over `--output-format` when both are provided. -- Requires `--project-ref`, `SUPABASE_PROJECT_ID`, a linked project (`supabase/.temp/project-ref`), or, on an interactive terminal, the project picker. +- Requires `--remote=<ref|branch>`, `SUPABASE_PROJECT_ID`, a linked project (`supabase/.temp/project-ref`) + or, on an interactive terminal, the project picker. A bare `--remote` targets the linked project. - `.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, including failures, but only once the `--experimental` gate is open. - `postgres-config` is an experimental command: `get` requires `--experimental` @@ -100,3 +103,13 @@ One `result` event on success. 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: …` banner naming the resolved project is printed on stderr in every output format; see [Command target](../../../../docs/command-target.md). +- `--remote=<value>` is read as a project ref (20 lowercase letters), a branch of the linked + project (by name or UUID), or `true|yes|on|1` for the linked project. A database URL is rejected + (`supabase postgres-config get` takes a project, not a database); `false|no|off|0`, `--no-remote` and an + empty value fail with `Leave out --remote to use the linked project.` +- A bare `--remote` followed by a word that is a branch of the linked project fails before any + other work with a `Did you mean --remote=<word>?` hint, since the word may have been meant as + the flag's value. +- The deprecated `--project-ref` is an alias of `--remote=<ref>` (a non-ref value is read as a + branch name) and prints a deprecation warning on stderr that names the replacement; it cannot + be combined with `--remote=<value>`. 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 cd991a69e7..44829fae22 100644 --- a/apps/cli/src/commands/postgres-config/update/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/postgres-config/update/SIDE_EFFECTS.md @@ -2,11 +2,11 @@ ## Files Read -| Path | Format | When | -| ---------------------------------------------- | ------------------------- | -------------------------------------------------------------------------------------------------------------------- | -| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | -| `<workdir>/supabase/.temp/linked-project.json` | JSON | always, after the project ref resolves — `linkedProjectCache` checks it exists to decide whether to write | -| `<workdir>/supabase/.temp/project-ref` | plain text (project ref) | when `--project-ref` flag and `SUPABASE_PROJECT_ID` env are both unset; also by `linkedProjectCache` on a cache miss | +| Path | Format | When | +| ---------------------------------------------- | ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | +| `<workdir>/supabase/.temp/linked-project.json` | JSON | always, after the project ref resolves — `linkedProjectCache` checks it exists to decide whether to write | +| `<workdir>/supabase/.temp/project-ref` | plain text (project ref) | when `--remote=<ref>` and `SUPABASE_PROJECT_ID` env are both unset, and as the linked parent for `--remote=<branch>`; also by `linkedProjectCache` on a cache miss | ## Files Written @@ -17,12 +17,13 @@ ## API Routes -| Method | Path | Auth | Request body | Response (used fields) | -| ------ | --------------------------------------------- | ------------ | -------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `GET` | `/v1/projects` | Bearer token | none | project list for the interactive picker — only when no ref comes from `--project-ref`, `SUPABASE_PROJECT_ID` or `.temp/project-ref` and stdin is a TTY with interactive output | -| `GET` | `/v1/projects/{ref}/config/database/postgres` | Bearer token | none | full JSON object | -| `PUT` | `/v1/projects/{ref}/config/database/postgres` | Bearer token | full config object (conditional GET merge unless replace mode) | full JSON object | -| `GET` | `/v1/projects/{ref}` | Bearer token | none | `{ref, name, organization_id, organization_slug}` — `linkedProjectCache` fill, best-effort, skipped when `.temp/linked-project.json` exists, `.temp/project-ref` names another ref, or no token is available | +| Method | Path | Auth | Request body | Response (used fields) | +| ------ | --------------------------------------------- | ------------ | -------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `GET` | `/v1/projects` | Bearer token | none | project list for the interactive picker — only when no ref comes from `--remote=<ref>`, `SUPABASE_PROJECT_ID` or `.temp/project-ref` and stdin is a TTY with interactive output | +| `GET` | `/v1/projects/{parent}/branches` | Bearer token | none | `[{id, name, project_ref, is_default}]` — only for `--remote=<branch>` (or the deprecated `--project-ref=<branch>`), and for a bare `--remote` followed by a word that could name a branch; `{parent}` is `SUPABASE_PROJECT_ID`, else the linked project | +| `GET` | `/v1/projects/{ref}/config/database/postgres` | Bearer token | none | full JSON object | +| `PUT` | `/v1/projects/{ref}/config/database/postgres` | Bearer token | full config object (conditional GET merge unless replace mode) | full JSON object | +| `GET` | `/v1/projects/{ref}` | Bearer token | none | `{ref, name, organization_id, organization_slug}` — `linkedProjectCache` fill, best-effort, skipped when `.temp/linked-project.json` exists, `.temp/project-ref` names another ref, or no token is available | The initial `GET` is skipped when `--replace-existing-overrides` is set. Otherwise the command fetches current overrides first, merges the new values locally, then sends the final merged object back via `PUT`. @@ -30,13 +31,13 @@ The keys passed with `--config` (and `restart_database`) are validated locally a ## 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`, then the interactive project picker on a TTY) | -| `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) | +| 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 `--remote=<ref>` is unset; also the parent project that `--remote=<branch>` is resolved against | no (falls back to `supabase/.temp/project-ref`, then the interactive project picker on a TTY) | +| `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,7 +47,8 @@ The keys passed with `--config` (and `restart_database`) are validated locally a | `1` | malformed CSV in a `--config` value — fails during flag parsing, before the `--experimental` gate, the handler, and telemetry, with the exact diagnostic text on stderr (e.g. `invalid argument "a\"b" for "--config" flag: parse error on line 1, column 2: bare " in non-quoted-field`; a blank-only value fails with `EOF`) — CLI-2005 | | `1` | `--experimental` not passed and `SUPABASE_EXPERIMENTAL` unset (`ExperimentalRequiredError`) - checked before ref resolution/API/telemetry | | `1` | malformed `--config` (`PostgresConfigInvalidConfigValueError`) | -| `1` | project ref unresolved (`ProjectRefNotLinkedError` / `InvalidProjectRefError`) | +| `1` | project ref unresolved (`ProjectRefNotLinkedError`; `InvalidProjectRefError` for a malformed `SUPABASE_PROJECT_ID` or link-file ref) | +| `1` | invalid `--remote` selector — `--remote=<url>` (`TargetNeedsProjectError`), an empty value, `--remote` combined with `--project-ref`, or a branch the linked project does not have (`LinkBranchNotFoundError`) | | `1` | initial GET non-2xx (`PostgresConfigGetUnexpectedStatusError`) | | `1` | initial GET transport failure (`PostgresConfigGetNetworkError`) | | `1` | PUT non-2xx (`PostgresConfigUpdateUnexpectedStatusError`) | @@ -58,9 +60,9 @@ The keys passed with `--config` (and `restart_database`) are validated locally a ## Telemetry Events Fired -| Event | When | Notable properties / groups | -| ---------------------- | ------------------------------------------ | --------------------------------------------------------------------- | -| `cli_command_executed` | post-run, success or failure (via wrapper) | `exit_code`, `duration_ms`, `flags` (`--project-ref` -> `<redacted>`) | +| Event | When | Notable properties / groups | +| ---------------------- | ------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------- | +| `cli_command_executed` | post-run, success or failure (via wrapper) | `exit_code`, `duration_ms`, `flags` (`--remote` → `true` (bare), the ref, or `<redacted>`; `--project-ref` → `<redacted>`) | ## Output @@ -106,7 +108,8 @@ One `result` event on success. - The `--output` flag wins over `--output-format` when both are provided. - Flags: `--config` (repeatable; each value must contain exactly one `=`, otherwise `PostgresConfigInvalidConfigValueError`), `--replace-existing-overrides`, `--no-restart`. -- Requires `--project-ref`, `SUPABASE_PROJECT_ID`, a linked project (`supabase/.temp/project-ref`), or, on an interactive terminal, the project picker. +- Requires `--remote=<ref|branch>`, `SUPABASE_PROJECT_ID`, a linked project (`supabase/.temp/project-ref`) + or, on an interactive terminal, the project picker. A bare `--remote` targets the linked project. - Integer-like values are coerced to integers, boolean-like values are coerced to booleans, and everything else stays stringly typed before the final JSON body is serialized. - Keys ending in `_timeout` are always stringified before the `PUT`. - `--no-restart` injects `restart_database = false` into the final request body. @@ -119,3 +122,13 @@ One `result` event on success. - 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. +- `--remote=<value>` is read as a project ref (20 lowercase letters), a branch of the linked + project (by name or UUID), or `true|yes|on|1` for the linked project. A database URL is rejected + (`supabase postgres-config update` takes a project, not a database); `false|no|off|0`, `--no-remote` and an + empty value fail with `Leave out --remote to use the linked project.` +- A bare `--remote` followed by a word that is a branch of the linked project fails before any + other work with a `Did you mean --remote=<word>?` hint, since the word may have been meant as + the flag's value. +- The deprecated `--project-ref` is an alias of `--remote=<ref>` (a non-ref value is read as a + branch name) and prints a deprecation warning on stderr that names the replacement; it cannot + be combined with `--remote=<value>`. diff --git a/apps/cli/src/commands/projects/SIDE_EFFECTS.md b/apps/cli/src/commands/projects/SIDE_EFFECTS.md index 14aa0f018a..f404e61fda 100644 --- a/apps/cli/src/commands/projects/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/projects/SIDE_EFFECTS.md @@ -27,14 +27,15 @@ subcommand's own `SIDE_EFFECTS.md`. ## API Routes -| Method | Path | Auth | Request body | Response (used fields) | -| -------- | ----------------------------- | ------------ | ------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------- | -| `GET` | `/v1/projects` | Bearer token | — | `[{id, ref, organization_slug, name, region, created_at, status, database}]` | -| `POST` | `/v1/projects` | Bearer token | `{name, organization_slug, db_pass, region?, desired_instance_size?, high_availability?, release_channel?, postgres_engine?}` (JSON) | `{id, ref, organization_slug, name, region, created_at, status}` | -| `DELETE` | `/v1/projects/{ref}` | Bearer token | — | `{id, ref, name}`; `404` → does-not-exist | -| `GET` | `/v1/projects/{ref}/api-keys` | Bearer token | — | `[{name, api_key?}]` | -| `GET` | `/v1/projects/{ref}` | Bearer token | — | linked-project cache fill, best-effort (`Effect.ensuring`) | -| `GET` | `/v1/organizations` | Bearer token | — | `[{id, slug, name}]` — `create` interactive org prompt only | +| Method | Path | Auth | Request body | Response (used fields) | +| -------- | -------------------------------- | ------------ | ------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------- | +| `GET` | `/v1/projects` | Bearer token | — | `[{id, ref, organization_slug, name, region, created_at, status, database}]` | +| `POST` | `/v1/projects` | Bearer token | `{name, organization_slug, db_pass, region?, desired_instance_size?, high_availability?, release_channel?, postgres_engine?}` (JSON) | `{id, ref, organization_slug, name, region, created_at, status}` | +| `DELETE` | `/v1/projects/{ref}` | Bearer token | — | `{id, ref, name}`; `404` → does-not-exist | +| `GET` | `/v1/projects/{parent}/branches` | Bearer token | — | `[{id, name, project_ref, is_default}]` — `api-keys` with `--remote=<branch>` only | +| `GET` | `/v1/projects/{ref}/api-keys` | Bearer token | — | `[{name, api_key?}]` | +| `GET` | `/v1/projects/{ref}` | Bearer token | — | linked-project cache fill, best-effort (`Effect.ensuring`) | +| `GET` | `/v1/organizations` | Bearer token | — | `[{id, slug, name}]` — `create` interactive org prompt only | ## Environment Variables @@ -63,8 +64,8 @@ subcommand's own `SIDE_EFFECTS.md`. | ---------------------- | ------------------------------------------ | ----------------------------------- | | `cli_command_executed` | post-run, success or failure (via wrapper) | `exit_code`, `duration_ms`, `flags` | -`safeFlags`: `--org-id` (`create`), `--project-ref` -(`api-keys`). No custom events beyond `cli_command_executed`. +`safeFlags`: `--org-id` (`create`), a ref-shaped `--project-ref` (`api-keys`); the shared +wrapper reports `--remote` as `true` (bare), the ref, or `<redacted>`. No custom events beyond `cli_command_executed`. ## Output (two-axis: `--output` × `--output-format`) @@ -97,5 +98,7 @@ subcommand's own `SIDE_EFFECTS.md`. 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). +- `api-keys` resolves `--remote=<ref|branch>` (or the deprecated `--project-ref` alias) via the shared + resolver (flag → env → `.temp/project-ref` → prompt on a TTY → error when unlinked). A bare + `--remote` targets the linked project, `--remote=<branch>` looks the branch up on the linked + project (`GET /v1/projects/{parent}/branches`), and `--remote=<url>` is rejected. diff --git a/apps/cli/src/commands/projects/api-keys/SIDE_EFFECTS.md b/apps/cli/src/commands/projects/api-keys/SIDE_EFFECTS.md index db5fcad922..f98669702e 100644 --- a/apps/cli/src/commands/projects/api-keys/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/projects/api-keys/SIDE_EFFECTS.md @@ -2,10 +2,10 @@ ## Files Read -| Path | Format | When | -| -------------------------------------- | ------------------------- | ----------------------------------------------------------------------- | -| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | -| `<workdir>/supabase/.temp/project-ref` | plain text (ref string) | when `--project-ref` is not provided, to resolve the linked project ref | +| Path | Format | When | +| -------------------------------------- | ------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | +| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | +| `<workdir>/supabase/.temp/project-ref` | plain text (ref string) | when `--remote=<ref>` and `SUPABASE_PROJECT_ID` are not provided, to resolve the linked project ref, and as the linked parent for `--remote=<branch>` | ## Files Written @@ -15,9 +15,10 @@ ## API Routes -| Method | Path | Auth | Request body | Response (used fields) | -| ------ | ------------------------------------------- | ------------ | ------------ | ------------------------------------------- | -| `GET` | `/v1/projects/{ref}/api-keys[?reveal=true]` | Bearer token | none | `[{name: string, api_key: string \| null}]` | +| Method | Path | Auth | Request body | Response (used fields) | +| ------ | ------------------------------------------- | ------------ | ------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `GET` | `/v1/projects/{parent}/branches` | Bearer token | none | `[{id, name, project_ref, is_default}]` — only for `--remote=<branch>` (or the deprecated `--project-ref=<branch>`), and for a bare `--remote` followed by a word that could name a branch; `{parent}` is `SUPABASE_PROJECT_ID`, else the linked project | +| `GET` | `/v1/projects/{ref}/api-keys[?reveal=true]` | Bearer token | none | `[{name: string, api_key: string \| null}]` | The `reveal=true` query param is sent only when `--reveal` is passed; it instructs the Management API to return the full secret keys (`sb_secret_...`) in `api_key` instead of @@ -32,26 +33,28 @@ Management API to return the full secret keys (`sb_secret_...`) in `api_key` ins ## Flags -| Flag | Type | Required | Description | -| --------------- | ------- | -------- | --------------------------------------------------------------------------- | -| `--project-ref` | string | no | Project ref of the Supabase project (resolved from linked config if absent) | -| `--reveal` | boolean | no | Reveal the secret API keys in full (sends `reveal=true`); default redacted | +| Flag | Type | Required | Description | +| --------------- | ------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `--remote` | string | no | Project to read keys from: `--remote=<ref>`, `--remote=<branch>` of the linked project, or a bare `--remote` for the linked project (linked config if absent) | +| `--project-ref` | string | no | Deprecated hidden alias of `--remote=<ref>`; prints a deprecation warning on stderr | +| `--reveal` | boolean | no | Reveal the secret API keys in full (sends `reveal=true`); default redacted | ## Exit Codes -| Code | Condition | -| ---- | --------------------------------------------------------------- | -| `0` | success — API keys printed to stdout | -| `1` | authentication error — no valid token found | -| `1` | API error — non-2xx response from `/v1/projects/{ref}/api-keys` | -| `1` | network / connection failure | -| `1` | project ref not provided and no linked project found | +| Code | Condition | +| ---- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `0` | success — API keys printed to stdout | +| `1` | authentication error — no valid token found | +| `1` | API error — non-2xx response from `/v1/projects/{ref}/api-keys` | +| `1` | network / connection failure | +| `1` | project ref not provided and no linked project found | +| `1` | invalid `--remote` selector — `--remote=<url>` (`TargetNeedsProjectError`), an empty value, `--remote` combined with `--project-ref`, or a branch the linked project does not have (`LinkBranchNotFoundError`) | ## Telemetry Events Fired -| Event | When | Notable properties / groups | -| ---------------------- | ------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------- | -| `cli_command_executed` | post-run, success or failure (via wrapper) | `exit_code`, `duration_ms`, `flags` (`--project-ref` is telemetry-safe; `--reveal`'s boolean value is logged but never the key values) | +| Event | When | Notable properties / groups | +| ---------------------- | ------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `cli_command_executed` | post-run, success or failure (via wrapper) | `exit_code`, `duration_ms`, `flags` (`--remote` → `true` (bare), the ref, or `<redacted>`; `--project-ref` is recorded only when it is ref-shaped; `--reveal`'s boolean value is logged but never the key values) | ## Output @@ -103,6 +106,16 @@ On failure, an `error` event is emitted instead: in the toml/env env map; the json/yaml encodings preserve the raw `null`. Passing `--reveal` makes the API return the secret values, so they print in full across all formats (issue #4775). -- The `--project-ref` flag is optional when the CLI is linked to a project via `supabase link`. - When omitted, the ref is resolved flag → env → `.temp/project-ref` → prompt on a TTY, - failing with a not-linked error otherwise. +- Requires `--remote=<ref|branch>`, `SUPABASE_PROJECT_ID`, a linked project (`supabase/.temp/project-ref`) + or, on an interactive terminal, the project picker; it fails with a not-linked error otherwise. A + bare `--remote` targets the linked project. +- `--remote=<value>` is read as a project ref (20 lowercase letters), a branch of the linked + project (by name or UUID), or `true|yes|on|1` for the linked project. A database URL is rejected + (`supabase projects api-keys` takes a project, not a database); `false|no|off|0`, `--no-remote` and an + empty value fail with `Leave out --remote to use the linked project.` +- A bare `--remote` followed by a word that is a branch of the linked project fails before any + other work with a `Did you mean --remote=<word>?` hint, since the word may have been meant as + the flag's value. +- The deprecated `--project-ref` is an alias of `--remote=<ref>` (a non-ref value is read as a + branch name) and prints a deprecation warning on stderr that names the replacement; it cannot + be combined with `--remote=<value>`. diff --git a/apps/cli/src/commands/secrets/list/SIDE_EFFECTS.md b/apps/cli/src/commands/secrets/list/SIDE_EFFECTS.md index 84585607f1..b198db2b25 100644 --- a/apps/cli/src/commands/secrets/list/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/secrets/list/SIDE_EFFECTS.md @@ -2,27 +2,28 @@ ## Files Read -| Path | Format | When | -| ----------------------------------------- | ------------------------- | --------------------------------------------------------------------------------------------- | -| `/proc/sys/kernel/osrelease` (Linux) | plain text | once on layer init — disables keyring on WSL (`WSL` / `Microsoft` substring match) | -| keyring `"Supabase CLI"` / `<profile>` | OS keychain | when `SUPABASE_ACCESS_TOKEN` unset and keyring available; account = `CommandSettings.profile` | -| keyring `"Supabase CLI"` / `access-token` | OS keychain | legacy-key fallback when the profile-keyed lookup misses | -| `~/.supabase/access-token` | plain text (token string) | last-resort fallback after env + keyring miss | -| `<workdir>/supabase/.temp/project-ref` | plain text | when `--project-ref` and `SUPABASE_PROJECT_ID` are both unset | +| Path | Format | When | +| ----------------------------------------- | ------------------------- | ---------------------------------------------------------------------------------------------------------------- | +| `/proc/sys/kernel/osrelease` (Linux) | plain text | once on layer init — disables keyring on WSL (`WSL` / `Microsoft` substring match) | +| keyring `"Supabase CLI"` / `<profile>` | OS keychain | when `SUPABASE_ACCESS_TOKEN` unset and keyring available; account = `CommandSettings.profile` | +| keyring `"Supabase CLI"` / `access-token` | OS keychain | legacy-key fallback when the profile-keyed lookup misses | +| `~/.supabase/access-token` | plain text (token string) | last-resort fallback after env + keyring miss | +| `<workdir>/supabase/.temp/project-ref` | plain text | when `--remote=<ref>` and `SUPABASE_PROJECT_ID` are both unset, and as the linked parent for `--remote=<branch>` | ## Files Written | Path | Format | When | | ------------------------------------------------ | ------ | ------------------------------------------------------------ | -| `~/.supabase/<workdir-hash>/linked-project.json` | JSON | always (in `Effect.ensuring`) after `--project-ref` resolves | +| `~/.supabase/<workdir-hash>/linked-project.json` | JSON | always (in `Effect.ensuring`) after the project ref resolves | | `~/.supabase/telemetry.json` | JSON | always (in `Effect.ensuring`) at end of command | ## API Routes -| Method | Path | Auth | Request body | Response (used fields) | -| ------ | ---------------------------- | ------------ | ------------ | --------------------------------------------------- | -| `GET` | `/v1/projects/{ref}/secrets` | Bearer token | none | `[{name, value, updated_at?}]` (value is digest) | -| `GET` | `/v1/projects` | Bearer token | none | `[{id, ref, name, ...}]` — TTY-prompt fallback only | +| Method | Path | Auth | Request body | Response (used fields) | +| ------ | -------------------------------- | ------------ | ------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `GET` | `/v1/projects/{ref}/secrets` | Bearer token | none | `[{name, value, updated_at?}]` (value is digest) | +| `GET` | `/v1/projects` | Bearer token | none | `[{id, ref, name, ...}]` — TTY-prompt fallback only | +| `GET` | `/v1/projects/{parent}/branches` | Bearer token | none | `[{id, name, project_ref, is_default}]` — only for `--remote=<branch>` (or the deprecated `--project-ref=<branch>`), and for a bare `--remote` followed by a word that could name a branch; `{parent}` is `SUPABASE_PROJECT_ID`, else the linked project | ## Environment Variables @@ -30,27 +31,28 @@ | ----------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------- | | `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_PROJECT_ID` | project ref fallback when `--remote=<ref>` is unset; also the parent project that `--remote=<branch>` is resolved against | 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 printed to stdout | -| `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` | `SecretsListUnexpectedStatusError` — non-2xx response from the secrets endpoint | -| `1` | `SecretsListNetworkError` — transport-level network failure | -| `1` | `SecretsEnvNotSupportedError` — `--output env` flag is rejected | +| Code | Condition | +| ---- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `0` | success — secrets printed to stdout | +| `1` | `AccessTokenRequiredError` — no token in env/keyring/file | +| `1` | `InvalidAccessTokenError` — token violates `^sbp_(oauth_\|v0_)?[a-f0-9]{40}$` | +| `1` | `ProjectRefNotLinkedError` — `--remote=<ref>` unset, env/file empty, and stdin not a TTY | +| `1` | invalid `--remote` selector — `--remote=<url>` (`TargetNeedsProjectError`), an empty value, `--remote` combined with `--project-ref`, or a branch the linked project does not have (`LinkBranchNotFoundError`) | +| `1` | `InvalidProjectRefError` — a `SUPABASE_PROJECT_ID` or link-file ref violates `^[a-z]{20}$` | +| `1` | `SecretsListUnexpectedStatusError` — non-2xx response from the secrets endpoint | +| `1` | `SecretsListNetworkError` — transport-level network failure | +| `1` | `SecretsEnvNotSupportedError` — `--output env` flag is rejected | ## Telemetry Events Fired -| Event | When | Notable properties / groups | -| ---------------------- | ------------------------------------------ | -------------------------------------------------------------------- | -| `cli_command_executed` | post-run, success or failure (via wrapper) | `exit_code`, `duration_ms`, `flags` (`--project-ref` → `<redacted>`) | +| Event | When | Notable properties / groups | +| ---------------------- | ------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------- | +| `cli_command_executed` | post-run, success or failure (via wrapper) | `exit_code`, `duration_ms`, `flags` (`--remote` → `true` (bare), the ref, or `<redacted>`; `--project-ref` → `<redacted>`) | ## Output @@ -91,3 +93,15 @@ One `result` NDJSON event on success containing `{secrets: [...]}` and `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. +- Requires `--remote=<ref|branch>`, `SUPABASE_PROJECT_ID`, a linked project (`supabase/.temp/project-ref`) + or, on an interactive terminal, the project picker. A bare `--remote` targets the linked project. +- `--remote=<value>` is read as a project ref (20 lowercase letters), a branch of the linked + project (by name or UUID), or `true|yes|on|1` for the linked project. A database URL is rejected + (`supabase secrets list` takes a project, not a database); `false|no|off|0`, `--no-remote` and an + empty value fail with `Leave out --remote to use the linked project.` +- A bare `--remote` followed by a word that is a branch of the linked project fails before any + other work with a `Did you mean --remote=<word>?` hint, since the word may have been meant as + the flag's value. +- The deprecated `--project-ref` is an alias of `--remote=<ref>` (a non-ref value is read as a + branch name) and prints a deprecation warning on stderr that names the replacement; it cannot + be combined with `--remote=<value>`. diff --git a/apps/cli/src/commands/secrets/set/SIDE_EFFECTS.md b/apps/cli/src/commands/secrets/set/SIDE_EFFECTS.md index ce98f31917..dd5411f6ea 100644 --- a/apps/cli/src/commands/secrets/set/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/secrets/set/SIDE_EFFECTS.md @@ -8,7 +8,7 @@ | keyring `"Supabase CLI"` / `<profile>` | OS keychain | when `SUPABASE_ACCESS_TOKEN` unset and keyring available; account = `CommandSettings.profile` | | keyring `"Supabase CLI"` / `access-token` | OS keychain | legacy-key fallback when the profile-keyed lookup misses | | `~/.supabase/access-token` | plain text (token string) | last-resort fallback after env + keyring miss | -| `<workdir>/supabase/.temp/project-ref` | plain text | when `--project-ref` and `SUPABASE_PROJECT_ID` are both unset | +| `<workdir>/supabase/.temp/project-ref` | plain text | when `--remote=<ref>` and `SUPABASE_PROJECT_ID` are both unset, and as the linked parent for `--remote=<branch>` | | `<workdir>/supabase/.temp/linked-project.json` | JSON | existence check before the cache fill (in `Effect.ensuring` after the project ref resolves) | | `<workdir>/supabase/config.json` or `config.toml` | JSON / TOML | always (for `[edge_runtime.secrets]`, `config.json` preferred) — via the `CliConfigValues` resolved config, with a matching `[remotes.*]` block applied; a parse failure is debug-logged and tolerated, a removed key is fatal. | | `<workdir>/.env*`, `<workdir>/supabase/.env*` | dotenv | always — project env files selected by `SUPABASE_ENV` (default `development`), context for `env(VAR)` interpolation in `[edge_runtime.secrets]` values; a variable the shell sets is never taken from a file | @@ -23,11 +23,12 @@ ## API Routes -| Method | Path | Auth | Request body | Response (used fields) | -| ------ | ---------------------------- | ------------ | -------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `POST` | `/v1/projects/{ref}/secrets` | Bearer token | `[{name: string, value: string}, ...]` | none (201 expected) | -| `GET` | `/v1/projects` | Bearer token | none | project picker — only when no ref comes from `--project-ref`, `SUPABASE_PROJECT_ID` or `.temp/project-ref`, stdin is a TTY and output is interactive | -| `GET` | `/v1/projects/{ref}` | Bearer token | none | linked-project cache fill — best-effort, only when `.temp/linked-project.json` is absent, `.temp/project-ref` is absent or names the same ref, and a token is available; failures are ignored | +| Method | Path | Auth | Request body | Response (used fields) | +| ------ | -------------------------------- | ------------ | -------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `POST` | `/v1/projects/{ref}/secrets` | Bearer token | `[{name: string, value: string}, ...]` | none (201 expected) | +| `GET` | `/v1/projects` | Bearer token | none | project picker — only when no ref comes from `--remote=<ref>`, `SUPABASE_PROJECT_ID` or `.temp/project-ref`, stdin is a TTY and output is interactive | +| `GET` | `/v1/projects/{parent}/branches` | Bearer token | none | `[{id, name, project_ref, is_default}]` — only for `--remote=<branch>` (or the deprecated `--project-ref=<branch>`), and for a bare `--remote` followed by a word that could name a branch; `{parent}` is `SUPABASE_PROJECT_ID`, else the linked project | +| `GET` | `/v1/projects/{ref}` | Bearer token | none | linked-project cache fill — best-effort, only when `.temp/linked-project.json` is absent, `.temp/project-ref` is absent or names the same ref, and a token is available; failures are ignored | ## Environment Variables @@ -35,36 +36,37 @@ | ----------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------- | | `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_PROJECT_ID` | project ref fallback when `--remote=<ref>` is unset; also the parent project that `--remote=<branch>` is resolved against | 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` | `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 | +| 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` — `--remote=<ref>` unset, env/file empty, and stdin not a TTY | +| `1` | invalid `--remote` selector — `--remote=<url>` (`TargetNeedsProjectError`), an empty value, `--remote` combined with `--project-ref`, or a branch the linked project does not have (`LinkBranchNotFoundError`) | +| `1` | `InvalidProjectRefError` — a `SUPABASE_PROJECT_ID` or link-file 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 -| Event | When | Notable properties / groups | -| ---------------------- | ------------------------------------------ | ----------------------------------------------------------------------------------- | -| `cli_command_executed` | post-run, success or failure (via wrapper) | `exit_code`, `duration_ms`, `flags` (`--project-ref` / `--env-file` → `<redacted>`) | +| Event | When | Notable properties / groups | +| ---------------------- | ------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------- | +| `cli_command_executed` | post-run, success or failure (via wrapper) | `exit_code`, `duration_ms`, `flags` (`--remote` → `true` (bare), the ref, or `<redacted>`; `--project-ref` / `--env-file` → `<redacted>`) | ## Output @@ -93,3 +95,15 @@ One `result` NDJSON event on success containing `{project_ref, count}` and `targ - A removed config key or a non-empty `SUPABASE_EXPERIMENTAL_ORIOLEDB_VERSION` **does** abort the command (exit 1, before any request) instead of being tolerated: silently dropping the config's `[edge_runtime.secrets]` would send a partial secret set. - A malformed `config.toml` does **not** abort the command — the error is logged to the debug logger and the command proceeds. `--env-file` and positional `NAME=VALUE` secrets always still apply. What happens to config-declared secrets depends on the failure class: a raw TOML/JSON syntax error drops everything (no `EdgeRuntime.Secrets`), but a schema-type error on an _unrelated_ field (e.g. `analytics.port` being a string) still leaves a valid `[edge_runtime.secrets]` section usable — the handler recovers it by re-decoding just that subtree. Pass `--debug` to see the logged parse error. - Sends `User-Agent: SupabaseCLI/<version>` and Bearer auth. No `X-Supabase-Command` headers. +- Requires `--remote=<ref|branch>`, `SUPABASE_PROJECT_ID`, a linked project (`supabase/.temp/project-ref`) + or, on an interactive terminal, the project picker. A bare `--remote` targets the linked project. +- `--remote=<value>` is read as a project ref (20 lowercase letters), a branch of the linked + project (by name or UUID), or `true|yes|on|1` for the linked project. A database URL is rejected + (`supabase secrets set` takes a project, not a database); `false|no|off|0`, `--no-remote` and an + empty value fail with `Leave out --remote to use the linked project.` +- A bare `--remote` followed by a word that is a branch of the linked project fails before any + other work with a `Did you mean --remote=<word>?` hint, since the word may have been meant as + the flag's value. +- The deprecated `--project-ref` is an alias of `--remote=<ref>` (a non-ref value is read as a + branch name) and prints a deprecation warning on stderr that names the replacement; it cannot + be combined with `--remote=<value>`. diff --git a/apps/cli/src/commands/secrets/unset/SIDE_EFFECTS.md b/apps/cli/src/commands/secrets/unset/SIDE_EFFECTS.md index b459f5052e..64b2e7ad59 100644 --- a/apps/cli/src/commands/secrets/unset/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/secrets/unset/SIDE_EFFECTS.md @@ -2,14 +2,14 @@ ## Files Read -| Path | Format | When | -| ---------------------------------------------- | ------------------------- | --------------------------------------------------------------------------------------------- | -| `/proc/sys/kernel/osrelease` (Linux) | plain text | once on layer init — disables keyring on WSL (`WSL` / `Microsoft` substring match) | -| keyring `"Supabase CLI"` / `<profile>` | OS keychain | when `SUPABASE_ACCESS_TOKEN` unset and keyring available; account = `CommandSettings.profile` | -| keyring `"Supabase CLI"` / `access-token` | OS keychain | legacy-key fallback when the profile-keyed lookup misses | -| `~/.supabase/access-token` | plain text (token string) | last-resort fallback after env + keyring miss | -| `<workdir>/supabase/.temp/project-ref` | plain text | when `--project-ref` and `SUPABASE_PROJECT_ID` are both unset | -| `<workdir>/supabase/.temp/linked-project.json` | JSON | existence check before the cache fill (in `Effect.ensuring` after the project ref resolves) | +| Path | Format | When | +| ---------------------------------------------- | ------------------------- | ---------------------------------------------------------------------------------------------------------------- | +| `/proc/sys/kernel/osrelease` (Linux) | plain text | once on layer init — disables keyring on WSL (`WSL` / `Microsoft` substring match) | +| keyring `"Supabase CLI"` / `<profile>` | OS keychain | when `SUPABASE_ACCESS_TOKEN` unset and keyring available; account = `CommandSettings.profile` | +| keyring `"Supabase CLI"` / `access-token` | OS keychain | legacy-key fallback when the profile-keyed lookup misses | +| `~/.supabase/access-token` | plain text (token string) | last-resort fallback after env + keyring miss | +| `<workdir>/supabase/.temp/project-ref` | plain text | when `--remote=<ref>` and `SUPABASE_PROJECT_ID` are both unset, and as the linked parent for `--remote=<branch>` | +| `<workdir>/supabase/.temp/linked-project.json` | JSON | existence check before the cache fill (in `Effect.ensuring` after the project ref resolves) | ## Files Written @@ -20,12 +20,13 @@ ## API Routes -| Method | Path | Auth | Request body | Response (used fields) | -| -------- | ---------------------------- | ------------ | --------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `GET` | `/v1/projects/{ref}/secrets` | Bearer token | none | `[{name}]` — `--all` path only, filters `SUPABASE_` prefix | -| `DELETE` | `/v1/projects/{ref}/secrets` | Bearer token | `["NAME", ...]` | none (200 expected; not called with `--dry-run`) | -| `GET` | `/v1/projects` | Bearer token | none | project picker — only when no ref comes from `--project-ref`, `SUPABASE_PROJECT_ID` or `.temp/project-ref`, stdin is a TTY and output is interactive | -| `GET` | `/v1/projects/{ref}` | Bearer token | none | linked-project cache fill — best-effort, only when `.temp/linked-project.json` is absent, `.temp/project-ref` is absent or names the same ref, and a token is available; failures are ignored | +| Method | Path | Auth | Request body | Response (used fields) | +| -------- | -------------------------------- | ------------ | --------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `GET` | `/v1/projects/{ref}/secrets` | Bearer token | none | `[{name}]` — `--all` path only, filters `SUPABASE_` prefix | +| `DELETE` | `/v1/projects/{ref}/secrets` | Bearer token | `["NAME", ...]` | none (200 expected; not called with `--dry-run`) | +| `GET` | `/v1/projects` | Bearer token | none | project picker — only when no ref comes from `--remote=<ref>`, `SUPABASE_PROJECT_ID` or `.temp/project-ref`, stdin is a TTY and output is interactive | +| `GET` | `/v1/projects/{parent}/branches` | Bearer token | none | `[{id, name, project_ref, is_default}]` — only for `--remote=<branch>` (or the deprecated `--project-ref=<branch>`), and for a bare `--remote` followed by a word that could name a branch; `{parent}` is `SUPABASE_PROJECT_ID`, else the linked project | +| `GET` | `/v1/projects/{ref}` | Bearer token | none | linked-project cache fill — best-effort, only when `.temp/linked-project.json` is absent, `.temp/project-ref` is absent or names the same ref, and a token is available; failures are ignored | ## Environment Variables @@ -34,7 +35,7 @@ | `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | | `SUPABASE_PROFILE` | selects API base URL: `supabase` → `api.supabase.com`, `supabase-staging` → `api.supabase.green`, `supabase-local` → `http://localhost:8080`. May alternatively be a filesystem path to a YAML profile with at least `api_url:` and optional `name:` (used by the cli-e2e test harness). | no (defaults to `supabase`) | | `SUPABASE_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_PROJECT_ID` | project ref fallback when `--remote=<ref>` is unset; also the parent project that `--remote=<branch>` is resolved against | 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 @@ -48,8 +49,9 @@ | `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` | `ProjectRefNotLinkedError` — `--remote=<ref>` unset, env/file empty, and stdin not a TTY | +| `1` | invalid `--remote` selector — `--remote=<url>` (`TargetNeedsProjectError`), an empty value, `--remote` combined with `--project-ref`, or a branch the linked project does not have (`LinkBranchNotFoundError`) | +| `1` | `InvalidProjectRefError` — a `SUPABASE_PROJECT_ID` or link-file 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) | @@ -59,9 +61,9 @@ ## Telemetry Events Fired -| Event | When | Notable properties / groups | -| ---------------------- | ------------------------------------------ | -------------------------------------------------------------------- | -| `cli_command_executed` | post-run, success or failure (via wrapper) | `exit_code`, `duration_ms`, `flags` (`--project-ref` → `<redacted>`) | +| Event | When | Notable properties / groups | +| ---------------------- | ------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------- | +| `cli_command_executed` | post-run, success or failure (via wrapper) | `exit_code`, `duration_ms`, `flags` (`--remote` → `true` (bare), the ref, or `<redacted>`; `--project-ref` → `<redacted>`) | ## Output @@ -102,3 +104,15 @@ One `result` NDJSON event on success containing `{project_ref, count, names, dry - 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. +- Requires `--remote=<ref|branch>`, `SUPABASE_PROJECT_ID`, a linked project (`supabase/.temp/project-ref`) + or, on an interactive terminal, the project picker. A bare `--remote` targets the linked project. +- `--remote=<value>` is read as a project ref (20 lowercase letters), a branch of the linked + project (by name or UUID), or `true|yes|on|1` for the linked project. A database URL is rejected + (`supabase secrets unset` takes a project, not a database); `false|no|off|0`, `--no-remote` and an + empty value fail with `Leave out --remote to use the linked project.` +- A bare `--remote` followed by a word that is a branch of the linked project fails before any + other work with a `Did you mean --remote=<word>?` hint, since the word may have been meant as + the flag's value. +- The deprecated `--project-ref` is an alias of `--remote=<ref>` (a non-ref value is read as a + branch name) and prints a deprecation warning on stderr that names the replacement; it cannot + be combined with `--remote=<value>`. diff --git a/apps/cli/src/commands/snippets/download/SIDE_EFFECTS.md b/apps/cli/src/commands/snippets/download/SIDE_EFFECTS.md index 37a1ab833e..0083d49764 100644 --- a/apps/cli/src/commands/snippets/download/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/snippets/download/SIDE_EFFECTS.md @@ -2,13 +2,13 @@ ## Files Read -| Path | Format | When | -| ---------------------------------------------- | ------------------------- | --------------------------------------------------------------------------------------------- | -| keyring `"Supabase CLI"` / `<profile>` | OS keychain | when `SUPABASE_ACCESS_TOKEN` unset and keyring available; account = `CommandSettings.profile` | -| keyring `"Supabase CLI"` / `access-token` | OS keychain | legacy-key fallback when the profile-keyed lookup misses | -| `~/.supabase/access-token` | plain text (token string) | last-resort fallback after env + keyring miss | -| `<workdir>/supabase/.temp/project-ref` | plain text | when `--project-ref` flag and `SUPABASE_PROJECT_ID` env are unset | -| `<workdir>/supabase/.temp/linked-project.json` | JSON | always — `linkedProjectCache` reads to decide whether to write | +| Path | Format | When | +| ---------------------------------------------- | ------------------------- | --------------------------------------------------------------------------------------------------------------- | +| keyring `"Supabase CLI"` / `<profile>` | OS keychain | when `SUPABASE_ACCESS_TOKEN` unset and keyring available; account = `CommandSettings.profile` | +| keyring `"Supabase CLI"` / `access-token` | OS keychain | legacy-key fallback when the profile-keyed lookup misses | +| `~/.supabase/access-token` | plain text (token string) | last-resort fallback after env + keyring miss | +| `<workdir>/supabase/.temp/project-ref` | plain text | when `--remote=<ref>` and `SUPABASE_PROJECT_ID` env are unset, and as the linked parent for `--remote=<branch>` | +| `<workdir>/supabase/.temp/linked-project.json` | JSON | always — `linkedProjectCache` reads to decide whether to write | ## Files Written @@ -19,11 +19,12 @@ ## API Routes -| Method | Path | Auth | Request body | Response (used fields) | -| ------ | -------------------- | ------------ | ------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `GET` | `/v1/snippets/{id}` | Bearer token | none | `{content: {sql, schema_version, favorite?}, id, name, visibility, owner, project, inserted_at, updated_at}` | -| `GET` | `/v1/projects` | Bearer token | none | project picker — only when no ref comes from `--project-ref`, `SUPABASE_PROJECT_ID` or `.temp/project-ref`, stdin is a TTY and output is interactive | -| `GET` | `/v1/projects/{ref}` | Bearer token | none | linked-project cache fill — best-effort, only when `.temp/linked-project.json` is absent, `.temp/project-ref` is absent or names the same ref, and a token is available; failures are ignored | +| Method | Path | Auth | Request body | Response (used fields) | +| ------ | -------------------------------- | ------------ | ------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `GET` | `/v1/snippets/{id}` | Bearer token | none | `{content: {sql, schema_version, favorite?}, id, name, visibility, owner, project, inserted_at, updated_at}` | +| `GET` | `/v1/projects` | Bearer token | none | project picker — only when no ref comes from `--remote=<ref>`, `SUPABASE_PROJECT_ID` or `.temp/project-ref`, stdin is a TTY and output is interactive | +| `GET` | `/v1/projects/{parent}/branches` | Bearer token | none | `[{id, name, project_ref, is_default}]` — only for `--remote=<branch>` (or the deprecated `--project-ref=<branch>`), and for a bare `--remote` followed by a word that could name a branch; `{parent}` is `SUPABASE_PROJECT_ID`, else the linked project | +| `GET` | `/v1/projects/{ref}` | Bearer token | none | linked-project cache fill — best-effort, only when `.temp/linked-project.json` is absent, `.temp/project-ref` is absent or names the same ref, and a token is available; failures are ignored | Only `content.sql` is rendered in text mode. The full payload is exposed via `--output-format json`. @@ -31,27 +32,28 @@ The snippet request carries no project ref and no query. The ref is resolved onl ## 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 `supabase/.temp/project-ref`) | -| `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_PROJECT_ID` | project ref fallback when `--remote=<ref>` is unset; also the parent project that `--remote=<branch>` is resolved against | no (falls back to `supabase/.temp/project-ref`) | +| `SUPABASE_PROFILE` | profile selector (built-in name or YAML file path) | no (defaults to `supabase`) | ## Exit Codes -| Code | Condition | -| ---- | ------------------------------------------------------------- | -| `0` | success — SQL written to stdout | -| `1` | `SnippetsInvalidIdError` — `<snippet-id>` is not a valid UUID | -| `1` | `InvalidProjectRefError` / `ProjectRefNotLinkedError` | -| `1` | `SnippetsDownloadUnexpectedStatusError` — non-2xx response | -| `1` | `SnippetsDownloadNetworkError` — transport-level failure | +| Code | Condition | +| ---- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `0` | success — SQL written to stdout | +| `1` | `SnippetsInvalidIdError` — `<snippet-id>` is not a valid UUID | +| `1` | `ProjectRefNotLinkedError`; `InvalidProjectRefError` for a malformed `SUPABASE_PROJECT_ID` or link-file ref | +| `1` | invalid `--remote` selector — `--remote=<url>` (`TargetNeedsProjectError`), an empty value, `--remote` combined with `--project-ref`, or a branch the linked project does not have (`LinkBranchNotFoundError`) | +| `1` | `SnippetsDownloadUnexpectedStatusError` — non-2xx response | +| `1` | `SnippetsDownloadNetworkError` — transport-level failure | ## Telemetry Events Fired -| Event | When | Notable properties | -| ---------------------- | ------------------------------------------ | -------------------------------------------------------------------- | -| `cli_command_executed` | post-run, success or failure (via wrapper) | `exit_code`, `duration_ms`, `flags` (`--project-ref` → `<redacted>`) | +| Event | When | Notable properties | +| ---------------------- | ------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------- | +| `cli_command_executed` | post-run, success or failure (via wrapper) | `exit_code`, `duration_ms`, `flags` (`--remote` → `true` (bare), the ref, or `<redacted>`; `--project-ref` → `<redacted>`) | ## Output @@ -87,3 +89,15 @@ NDJSON `success` event with the same full payload as `--output-format json`. - 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: …` banner naming the resolved project is printed on stderr in every output format; see [Command target](../../../../docs/command-target.md). +- Requires `--remote=<ref|branch>`, `SUPABASE_PROJECT_ID`, a linked project (`supabase/.temp/project-ref`) + or, on an interactive terminal, the project picker. A bare `--remote` targets the linked project. +- `--remote=<value>` is read as a project ref (20 lowercase letters), a branch of the linked + project (by name or UUID), or `true|yes|on|1` for the linked project. A database URL is rejected + (`supabase snippets download` takes a project, not a database); `false|no|off|0`, `--no-remote` and an + empty value fail with `Leave out --remote to use the linked project.` +- A bare `--remote` followed by a word that is a branch of the linked project fails before any + other work with a `Did you mean --remote=<word>?` hint, since the word may have been meant as + the flag's value. +- The deprecated `--project-ref` is an alias of `--remote=<ref>` (a non-ref value is read as a + branch name) and prints a deprecation warning on stderr that names the replacement; it cannot + be combined with `--remote=<value>`. diff --git a/apps/cli/src/commands/snippets/list/SIDE_EFFECTS.md b/apps/cli/src/commands/snippets/list/SIDE_EFFECTS.md index cdf10198af..727c39928d 100644 --- a/apps/cli/src/commands/snippets/list/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/snippets/list/SIDE_EFFECTS.md @@ -2,50 +2,52 @@ ## Files Read -| Path | Format | When | -| ---------------------------------------------- | ------------------------- | --------------------------------------------------------------------------------------------- | -| keyring `"Supabase CLI"` / `<profile>` | OS keychain | when `SUPABASE_ACCESS_TOKEN` unset and keyring available; account = `CommandSettings.profile` | -| keyring `"Supabase CLI"` / `access-token` | OS keychain | legacy-key fallback when the profile-keyed lookup misses | -| `~/.supabase/access-token` | plain text (token string) | last-resort fallback after env + keyring miss | -| `<workdir>/supabase/.temp/project-ref` | plain text | when `--project-ref` flag and `SUPABASE_PROJECT_ID` env are unset | -| `<workdir>/supabase/.temp/linked-project.json` | JSON | always — `linkedProjectCache` reads to decide whether to write | +| Path | Format | When | +| ---------------------------------------------- | ------------------------- | --------------------------------------------------------------------------------------------------------------- | +| keyring `"Supabase CLI"` / `<profile>` | OS keychain | when `SUPABASE_ACCESS_TOKEN` unset and keyring available; account = `CommandSettings.profile` | +| keyring `"Supabase CLI"` / `access-token` | OS keychain | legacy-key fallback when the profile-keyed lookup misses | +| `~/.supabase/access-token` | plain text (token string) | last-resort fallback after env + keyring miss | +| `<workdir>/supabase/.temp/project-ref` | plain text | when `--remote=<ref>` and `SUPABASE_PROJECT_ID` env are unset, and as the linked parent for `--remote=<branch>` | +| `<workdir>/supabase/.temp/linked-project.json` | JSON | always — `linkedProjectCache` reads to decide whether to write | ## Files Written | Path | Format | When | | ---------------------------------------------- | ------ | ------------------------------------------------ | | `~/.supabase/telemetry.json` | JSON | always (`Effect.ensuring(telemetryState.flush)`) | -| `<workdir>/supabase/.temp/linked-project.json` | JSON | best-effort after `--project-ref` resolves | +| `<workdir>/supabase/.temp/linked-project.json` | JSON | best-effort after the project ref resolves | ## API Routes -| Method | Path | Auth | Request body | Response (used fields) | -| ------ | -------------------------------- | ------------ | ------------ | -------------------------------------------------------------------------------------------- | -| `GET` | `/v1/snippets?project_ref=<ref>` | Bearer token | none | `{data: [{id, name, visibility, owner: {username}, inserted_at, updated_at, ...}], cursor?}` | +| Method | Path | Auth | Request body | Response (used fields) | +| ------ | -------------------------------- | ------------ | ------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `GET` | `/v1/projects/{parent}/branches` | Bearer token | none | `[{id, name, project_ref, is_default}]` — only for `--remote=<branch>` (or the deprecated `--project-ref=<branch>`), and for a bare `--remote` followed by a word that could name a branch; `{parent}` is `SUPABASE_PROJECT_ID`, else the linked project | +| `GET` | `/v1/snippets?project_ref=<ref>` | Bearer token | none | `{data: [{id, name, visibility, owner: {username}, inserted_at, updated_at, ...}], cursor?}` | ## 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 `supabase/.temp/project-ref`) | -| `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_PROJECT_ID` | project ref fallback when `--remote=<ref>` is unset; also the parent project that `--remote=<branch>` is resolved against | no (falls back to `supabase/.temp/project-ref`) | +| `SUPABASE_PROFILE` | profile selector (built-in name or YAML file path) | no (defaults to `supabase`) | ## Exit Codes -| Code | Condition | -| ---- | ------------------------------------------------------------- | -| `0` | success | -| `1` | `SnippetsEnvNotSupportedError` — `--output env` was requested | -| `1` | `InvalidProjectRefError` / `ProjectRefNotLinkedError` | -| `1` | `SnippetsListUnexpectedStatusError` — non-2xx response | -| `1` | `SnippetsListNetworkError` — transport-level failure | +| Code | Condition | +| ---- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `0` | success | +| `1` | `SnippetsEnvNotSupportedError` — `--output env` was requested | +| `1` | `ProjectRefNotLinkedError`; `InvalidProjectRefError` for a malformed `SUPABASE_PROJECT_ID` or link-file ref | +| `1` | invalid `--remote` selector — `--remote=<url>` (`TargetNeedsProjectError`), an empty value, `--remote` combined with `--project-ref`, or a branch the linked project does not have (`LinkBranchNotFoundError`) | +| `1` | `SnippetsListUnexpectedStatusError` — non-2xx response | +| `1` | `SnippetsListNetworkError` — transport-level failure | ## Telemetry Events Fired -| Event | When | Notable properties | -| ---------------------- | ------------------------------------------ | ---------------------------------------------------------------------- | -| `cli_command_executed` | post-run, success or failure (via wrapper) | `exit_code`, `duration_ms`, `flags` (`--project-ref` allowed verbatim) | +| Event | When | Notable properties | +| ---------------------- | ------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------- | +| `cli_command_executed` | post-run, success or failure (via wrapper) | `exit_code`, `duration_ms`, `flags` (`--remote` → `true` (bare), the ref, or `<redacted>`; `--project-ref` → `<redacted>`) | ## Output @@ -99,3 +101,15 @@ NDJSON `success` event with the full response as `data`. - `--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: …` banner naming the resolved project is printed on stderr in every output format; see [Command target](../../../../docs/command-target.md). +- Requires `--remote=<ref|branch>`, `SUPABASE_PROJECT_ID`, a linked project (`supabase/.temp/project-ref`) + or, on an interactive terminal, the project picker. A bare `--remote` targets the linked project. +- `--remote=<value>` is read as a project ref (20 lowercase letters), a branch of the linked + project (by name or UUID), or `true|yes|on|1` for the linked project. A database URL is rejected + (`supabase snippets list` takes a project, not a database); `false|no|off|0`, `--no-remote` and an + empty value fail with `Leave out --remote to use the linked project.` +- A bare `--remote` followed by a word that is a branch of the linked project fails before any + other work with a `Did you mean --remote=<word>?` hint, since the word may have been meant as + the flag's value. +- The deprecated `--project-ref` is an alias of `--remote=<ref>` (a non-ref value is read as a + branch name) and prints a deprecation warning on stderr that names the replacement; it cannot + be combined with `--remote=<value>`. 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 3468f334f7..bc52d0ff7a 100644 --- a/apps/cli/src/commands/ssl-enforcement/get/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/ssl-enforcement/get/SIDE_EFFECTS.md @@ -2,11 +2,11 @@ ## Files Read -| Path | Format | When | -| ---------------------------------------------- | ------------------------- | -------------------------------------------------------------------------------------------------------------------- | -| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | -| `<workdir>/supabase/.temp/linked-project.json` | JSON | always, after the project ref resolves — `linkedProjectCache` checks it exists to decide whether to write | -| `<workdir>/supabase/.temp/project-ref` | plain text (project ref) | when `--project-ref` flag and `SUPABASE_PROJECT_ID` env are both unset; also by `linkedProjectCache` on a cache miss | +| Path | Format | When | +| ---------------------------------------------- | ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | +| `<workdir>/supabase/.temp/linked-project.json` | JSON | always, after the project ref resolves — `linkedProjectCache` checks it exists to decide whether to write | +| `<workdir>/supabase/.temp/project-ref` | plain text (project ref) | when `--remote=<ref>` and `SUPABASE_PROJECT_ID` env are both unset, and as the linked parent for `--remote=<branch>`; also by `linkedProjectCache` on a cache miss | ## Files Written @@ -17,36 +17,38 @@ ## API Routes -| Method | Path | Auth | Request body | Response (used fields) | -| ------ | ------------------------------------ | ------------ | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `GET` | `/v1/projects` | Bearer token | none | project list for the interactive picker — only when no ref comes from `--project-ref`, `SUPABASE_PROJECT_ID` or `.temp/project-ref` and stdin is a TTY with interactive output | -| `GET` | `/v1/projects/{ref}/ssl-enforcement` | Bearer token | none | `{currentConfig: {database: boolean}, appliedSuccessfully: boolean}` | -| `GET` | `/v1/projects/{ref}` | Bearer token | none | `{ref, name, organization_id, organization_slug}` — `linkedProjectCache` fill, best-effort, skipped when `.temp/linked-project.json` exists, `.temp/project-ref` names another ref, or no token is available | +| Method | Path | Auth | Request body | Response (used fields) | +| ------ | ------------------------------------ | ------------ | ------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `GET` | `/v1/projects` | Bearer token | none | project list for the interactive picker — only when no ref comes from `--remote=<ref>`, `SUPABASE_PROJECT_ID` or `.temp/project-ref` and stdin is a TTY with interactive output | +| `GET` | `/v1/projects/{parent}/branches` | Bearer token | none | `[{id, name, project_ref, is_default}]` — only for `--remote=<branch>` (or the deprecated `--project-ref=<branch>`), and for a bare `--remote` followed by a word that could name a branch; `{parent}` is `SUPABASE_PROJECT_ID`, else the linked project | +| `GET` | `/v1/projects/{ref}/ssl-enforcement` | Bearer token | none | `{currentConfig: {database: boolean}, appliedSuccessfully: boolean}` | +| `GET` | `/v1/projects/{ref}` | Bearer token | none | `{ref, name, organization_id, organization_slug}` — `linkedProjectCache` fill, best-effort, skipped when `.temp/linked-project.json` exists, `.temp/project-ref` names another ref, or no token is available | ## 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`, then the interactive project picker on a TTY) | -| `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 `--remote=<ref>` is unset; also the parent project that `--remote=<branch>` is resolved against | no (falls back to `supabase/.temp/project-ref`, then the interactive project picker on a TTY) | +| `SUPABASE_EXPERIMENTAL` | enables `--experimental`-gated commands without the flag | no (pass `--experimental` instead; one of the two is required) | ## Exit Codes -| Code | Condition | -| ---- | ----------------------------------------------------------------------------------------------------------------------------------------- | -| `0` | success — SSL enforcement status printed to stdout | -| `1` | `--experimental` not passed and `SUPABASE_EXPERIMENTAL` unset (`ExperimentalRequiredError`) — checked before ref resolution/API/telemetry | -| `1` | project ref unresolved (`ProjectRefNotLinkedError` / `InvalidProjectRefError`) | -| `1` | API non-200 (`SslEnforcementGetUnexpectedStatusError`) | -| `1` | transport failure (`SslEnforcementGetNetworkError`) | +| Code | Condition | +| ---- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `0` | success — SSL enforcement status printed to stdout | +| `1` | `--experimental` not passed and `SUPABASE_EXPERIMENTAL` unset (`ExperimentalRequiredError`) — checked before ref resolution/API/telemetry | +| `1` | project ref unresolved (`ProjectRefNotLinkedError`; `InvalidProjectRefError` for a malformed `SUPABASE_PROJECT_ID` or link-file ref) | +| `1` | invalid `--remote` selector — `--remote=<url>` (`TargetNeedsProjectError`), an empty value, `--remote` combined with `--project-ref`, or a branch the linked project does not have (`LinkBranchNotFoundError`) | +| `1` | API non-200 (`SslEnforcementGetUnexpectedStatusError`) | +| `1` | transport failure (`SslEnforcementGetNetworkError`) | ## Telemetry Events Fired -| Event | When | Notable properties / groups | -| ---------------------- | ------------------------------------------ | -------------------------------------------------------------------- | -| `cli_command_executed` | post-run, success or failure (via wrapper) | `exit_code`, `duration_ms`, `flags` (`--project-ref` → `<redacted>`) | +| Event | When | Notable properties / groups | +| ---------------------- | ------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------- | +| `cli_command_executed` | post-run, success or failure (via wrapper) | `exit_code`, `duration_ms`, `flags` (`--remote` → `true` (bare), the ref, or `<redacted>`; `--project-ref` → `<redacted>`) | ## Output @@ -106,3 +108,15 @@ One `result` event: 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: …` banner naming the resolved project is printed on stderr in every output format; see [Command target](../../../../docs/command-target.md). +- Requires `--remote=<ref|branch>`, `SUPABASE_PROJECT_ID`, a linked project (`supabase/.temp/project-ref`) + or, on an interactive terminal, the project picker. A bare `--remote` targets the linked project. +- `--remote=<value>` is read as a project ref (20 lowercase letters), a branch of the linked + project (by name or UUID), or `true|yes|on|1` for the linked project. A database URL is rejected + (`supabase ssl-enforcement get` takes a project, not a database); `false|no|off|0`, `--no-remote` and an + empty value fail with `Leave out --remote to use the linked project.` +- A bare `--remote` followed by a word that is a branch of the linked project fails before any + other work with a `Did you mean --remote=<word>?` hint, since the word may have been meant as + the flag's value. +- The deprecated `--project-ref` is an alias of `--remote=<ref>` (a non-ref value is read as a + branch name) and prints a deprecation warning on stderr that names the replacement; it cannot + be combined with `--remote=<value>`. 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 e111a5b8e9..3e3bd01d7d 100644 --- a/apps/cli/src/commands/ssl-enforcement/update/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/ssl-enforcement/update/SIDE_EFFECTS.md @@ -2,11 +2,11 @@ ## Files Read -| Path | Format | When | -| ---------------------------------------------- | ------------------------- | -------------------------------------------------------------------------------------------------------------------- | -| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | -| `<workdir>/supabase/.temp/linked-project.json` | JSON | always, after the project ref resolves — `linkedProjectCache` checks it exists to decide whether to write | -| `<workdir>/supabase/.temp/project-ref` | plain text (project ref) | when `--project-ref` flag and `SUPABASE_PROJECT_ID` env are both unset; also by `linkedProjectCache` on a cache miss | +| Path | Format | When | +| ---------------------------------------------- | ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | +| `<workdir>/supabase/.temp/linked-project.json` | JSON | always, after the project ref resolves — `linkedProjectCache` checks it exists to decide whether to write | +| `<workdir>/supabase/.temp/project-ref` | plain text (project ref) | when `--remote=<ref>` and `SUPABASE_PROJECT_ID` env are both unset, and as the linked parent for `--remote=<branch>`; also by `linkedProjectCache` on a cache miss | ## Files Written @@ -17,41 +17,43 @@ ## API Routes -| Method | Path | Auth | Request body | Response (used fields) | -| ------ | ------------------------------------ | ------------ | ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `GET` | `/v1/projects` | Bearer token | none | project list for the interactive picker — only when no ref comes from `--project-ref`, `SUPABASE_PROJECT_ID` or `.temp/project-ref` and stdin is a TTY with interactive output | -| `PUT` | `/v1/projects/{ref}/ssl-enforcement` | Bearer token | `{requestedConfig: {database: boolean}}` | `{currentConfig: {database: boolean}, appliedSuccessfully: boolean}` | -| `GET` | `/v1/projects/{ref}` | Bearer token | none | `{ref, name, organization_id, organization_slug}` — `linkedProjectCache` fill, best-effort, skipped when `.temp/linked-project.json` exists, `.temp/project-ref` names another ref, or no token is available | +| Method | Path | Auth | Request body | Response (used fields) | +| ------ | ------------------------------------ | ------------ | ---------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `GET` | `/v1/projects` | Bearer token | none | project list for the interactive picker — only when no ref comes from `--remote=<ref>`, `SUPABASE_PROJECT_ID` or `.temp/project-ref` and stdin is a TTY with interactive output | +| `GET` | `/v1/projects/{parent}/branches` | Bearer token | none | `[{id, name, project_ref, is_default}]` — only for `--remote=<branch>` (or the deprecated `--project-ref=<branch>`), and for a bare `--remote` followed by a word that could name a branch; `{parent}` is `SUPABASE_PROJECT_ID`, else the linked project | +| `PUT` | `/v1/projects/{ref}/ssl-enforcement` | Bearer token | `{requestedConfig: {database: boolean}}` | `{currentConfig: {database: boolean}, appliedSuccessfully: boolean}` | +| `GET` | `/v1/projects/{ref}` | Bearer token | none | `{ref, name, organization_id, organization_slug}` — `linkedProjectCache` fill, best-effort, skipped when `.temp/linked-project.json` exists, `.temp/project-ref` names another ref, or no token is available | ## 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`, then the interactive project picker on a TTY) | -| `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) | +| 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 `--remote=<ref>` is unset; also the parent project that `--remote=<branch>` is resolved against | no (falls back to `supabase/.temp/project-ref`, then the interactive project picker on a TTY) | +| `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 -| Code | Condition | -| ---- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `0` | success — SSL enforcement status (post-update) printed to stdout | -| `1` | `--experimental` not passed and `SUPABASE_EXPERIMENTAL` unset (`ExperimentalRequiredError`) — checked in `.command.ts`, before the handler (and its flag validation/telemetry) ever runs | -| `1` | neither `--enable-db-ssl-enforcement` nor `--disable-db-ssl-enforcement` set (`SslEnforcementNoEnableDisableFlagError`) | -| `1` | both `--enable-db-ssl-enforcement` and `--disable-db-ssl-enforcement` set (`SslEnforcementMutuallyExclusiveFlagsError`) | -| `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 | +| Code | Condition | +| ---- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `0` | success — SSL enforcement status (post-update) printed to stdout | +| `1` | `--experimental` not passed and `SUPABASE_EXPERIMENTAL` unset (`ExperimentalRequiredError`) — checked in `.command.ts`, before the handler (and its flag validation/telemetry) ever runs | +| `1` | neither `--enable-db-ssl-enforcement` nor `--disable-db-ssl-enforcement` set (`SslEnforcementNoEnableDisableFlagError`) | +| `1` | both `--enable-db-ssl-enforcement` and `--disable-db-ssl-enforcement` set (`SslEnforcementMutuallyExclusiveFlagsError`) | +| `1` | project ref unresolved (`ProjectRefNotLinkedError`; `InvalidProjectRefError` for a malformed `SUPABASE_PROJECT_ID` or link-file ref) | +| `1` | invalid `--remote` selector — `--remote=<url>` (`TargetNeedsProjectError`), an empty value, `--remote` combined with `--project-ref`, or a branch the linked project does not have (`LinkBranchNotFoundError`) | +| `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 -| Event | When | Notable properties / groups | -| ---------------------- | ------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `cli_command_executed` | post-run, success or failure (via wrapper) | `exit_code`, `duration_ms`, `flags` (`--project-ref` → `<redacted>`; `--enable-db-ssl-enforcement` / `--disable-db-ssl-enforcement` booleans pass through) | +| Event | When | Notable properties / groups | +| ---------------------- | ------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `cli_command_executed` | post-run, success or failure (via wrapper) | `exit_code`, `duration_ms`, `flags` (`--remote` → `true` (bare), the ref, or `<redacted>`; `--project-ref` → `<redacted>`; `--enable-db-ssl-enforcement` / `--disable-db-ssl-enforcement` booleans pass through) | ## Output @@ -109,3 +111,15 @@ One `result` event: `cli_command_executed` event. - 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. +- Requires `--remote=<ref|branch>`, `SUPABASE_PROJECT_ID`, a linked project (`supabase/.temp/project-ref`) + or, on an interactive terminal, the project picker. A bare `--remote` targets the linked project. +- `--remote=<value>` is read as a project ref (20 lowercase letters), a branch of the linked + project (by name or UUID), or `true|yes|on|1` for the linked project. A database URL is rejected + (`supabase ssl-enforcement update` takes a project, not a database); `false|no|off|0`, `--no-remote` and an + empty value fail with `Leave out --remote to use the linked project.` +- A bare `--remote` followed by a word that is a branch of the linked project fails before any + other work with a `Did you mean --remote=<word>?` hint, since the word may have been meant as + the flag's value. +- The deprecated `--project-ref` is an alias of `--remote=<ref>` (a non-ref value is read as a + branch name) and prints a deprecation warning on stderr that names the replacement; it cannot + be combined with `--remote=<value>`. diff --git a/apps/cli/src/commands/sso/add/SIDE_EFFECTS.md b/apps/cli/src/commands/sso/add/SIDE_EFFECTS.md index 535cdfc919..b73897cb49 100644 --- a/apps/cli/src/commands/sso/add/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/sso/add/SIDE_EFFECTS.md @@ -2,15 +2,15 @@ ## Files Read -| Path | Format | When | -| ---------------------------------------------- | ------------------------- | --------------------------------------------------------------------------------------------- | -| keyring `"Supabase CLI"` / `<profile>` | OS keychain | when `SUPABASE_ACCESS_TOKEN` unset and keyring available; account = `CommandSettings.profile` | -| keyring `"Supabase CLI"` / `access-token` | OS keychain | legacy-key fallback when the profile-keyed lookup misses | -| `~/.supabase/access-token` | plain text (token string) | last-resort fallback after env + keyring miss | -| `<workdir>/supabase/.temp/linked-project.json` | JSON | always — `linkedProjectCache` reads to decide whether to write | -| `<workdir>/supabase/.temp/project-ref` | plain text | when `--project-ref` flag and `SUPABASE_PROJECT_ID` env are unset | -| `<metadata-file>` | XML (UTF-8) | when `--metadata-file` is provided | -| `<attribute-mapping-file>` | JSON | when `--attribute-mapping-file` is provided | +| Path | Format | When | +| ---------------------------------------------- | ------------------------- | --------------------------------------------------------------------------------------------------------------- | +| keyring `"Supabase CLI"` / `<profile>` | OS keychain | when `SUPABASE_ACCESS_TOKEN` unset and keyring available; account = `CommandSettings.profile` | +| keyring `"Supabase CLI"` / `access-token` | OS keychain | legacy-key fallback when the profile-keyed lookup misses | +| `~/.supabase/access-token` | plain text (token string) | last-resort fallback after env + keyring miss | +| `<workdir>/supabase/.temp/linked-project.json` | JSON | always — `linkedProjectCache` reads to decide whether to write | +| `<workdir>/supabase/.temp/project-ref` | plain text | when `--remote=<ref>` and `SUPABASE_PROJECT_ID` env are unset, and as the linked parent for `--remote=<branch>` | +| `<metadata-file>` | XML (UTF-8) | when `--metadata-file` is provided | +| `<attribute-mapping-file>` | JSON | when `--attribute-mapping-file` is provided | ## Files Written @@ -21,14 +21,15 @@ ## API Routes -| Method | Path | Auth | Request body | Response (used fields) | -| ------ | ---------------------------------------------- | ------------ | ------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `GET` | `/v1/projects` | Bearer token | none | project list for the interactive picker — only when no ref comes from `--project-ref`, `SUPABASE_PROJECT_ID` or `.temp/project-ref` and stdin is a TTY with interactive output | -| `POST` | `/v1/projects/{ref}/config/auth/sso/providers` | Bearer token | `{type, metadata_xml?, metadata_url?, domains?, attribute_mapping?, name_id_format?}` | `{id, saml?, domains?, created_at?, updated_at?}` (typed schema) | -| `GET` | `<metadata-url>` | none | `Accept: application/xml`, 10s timeout | XML body (UTF-8) — validation when `--skip-url-validation` not set | -| `GET` | `/v1/projects/{ref}` | Bearer token | none | `{organization_slug}` — upgrade-gate side-call on a 4xx with no plan-gate error in the body | -| `GET` | `/v1/organizations/{slug}/entitlements` | Bearer token | none | `{entitlements[].feature.key, .hasAccess}` — upgrade-gate side-call on a 4xx with no plan-gate error in the body | -| `GET` | `/v1/projects/{ref}` | Bearer token | none | `{ref, name, organization_id, organization_slug}` — `linkedProjectCache` fill, best-effort, skipped when `.temp/linked-project.json` exists, `.temp/project-ref` names another ref, or no token is available | +| Method | Path | Auth | Request body | Response (used fields) | +| ------ | ---------------------------------------------- | ------------ | ------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `GET` | `/v1/projects` | Bearer token | none | project list for the interactive picker — only when no ref comes from `--remote=<ref>`, `SUPABASE_PROJECT_ID` or `.temp/project-ref` and stdin is a TTY with interactive output | +| `GET` | `/v1/projects/{parent}/branches` | Bearer token | none | `[{id, name, project_ref, is_default}]` — only for `--remote=<branch>` (or the deprecated `--project-ref=<branch>`), and for a bare `--remote` followed by a word that could name a branch; `{parent}` is `SUPABASE_PROJECT_ID`, else the linked project | +| `POST` | `/v1/projects/{ref}/config/auth/sso/providers` | Bearer token | `{type, metadata_xml?, metadata_url?, domains?, attribute_mapping?, name_id_format?}` | `{id, saml?, domains?, created_at?, updated_at?}` (typed schema) | +| `GET` | `<metadata-url>` | none | `Accept: application/xml`, 10s timeout | XML body (UTF-8) — validation when `--skip-url-validation` not set | +| `GET` | `/v1/projects/{ref}` | Bearer token | none | `{organization_slug}` — upgrade-gate side-call on a 4xx with no plan-gate error in the body | +| `GET` | `/v1/organizations/{slug}/entitlements` | Bearer token | none | `{entitlements[].feature.key, .hasAccess}` — upgrade-gate side-call on a 4xx with no plan-gate error in the body | +| `GET` | `/v1/projects/{ref}` | Bearer token | none | `{ref, name, organization_id, organization_slug}` — `linkedProjectCache` fill, best-effort, skipped when `.temp/linked-project.json` exists, `.temp/project-ref` names another ref, or no token is available | The POST goes through `api.executeRaw` with the typed `v1CreateASsoProvider` operation, which keeps the response body undecoded so structured output emits it as the API returned it; the typed provider schema decodes a copy for the table view. `--attribute-mapping-file` is validated against the API's `attribute_mapping` schema before the request: a key the schema does not model @@ -64,13 +65,14 @@ is sent. A 2xx response whose body does not match the provider schema fails as | `1` | `SsoAddNetworkError` — transport failure or undecodable 2xx body | | `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 | +| `1` | invalid `--remote` selector — `--remote=<url>` (`TargetNeedsProjectError`), an empty value, `--remote` combined with `--project-ref`, or a branch the linked project does not have (`LinkBranchNotFoundError`) | ## Telemetry Events Fired -| Event | When | Notable properties | -| ----------------------- | ---------------------------------------------------- | ---------------------------------------------------------------------- | -| `cli_command_executed` | post-run, success or failure (via wrapper) | `exit_code`, `duration_ms`, `flags` (`--project-ref` allowed verbatim) | -| `cli_upgrade_suggested` | 4xx response **and** `auth.saml_2` entitlement gated | `feature_key: "auth.saml_2"`, `org_slug` | +| Event | When | Notable properties | +| ----------------------- | ---------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `cli_command_executed` | post-run, success or failure (via wrapper) | `exit_code`, `duration_ms`, `flags` (`--remote` → `true` (bare), the ref, or `<redacted>`; `--project-ref` is recorded only when it is ref-shaped, never a branch name) | +| `cli_upgrade_suggested` | 4xx response **and** `auth.saml_2` entitlement gated | `feature_key: "auth.saml_2"`, `org_slug` | ## Output @@ -94,7 +96,7 @@ Single `success` event with the parsed response as data. - `--type saml` is **required**. - `--metadata-file` and `--metadata-url` are mutually exclusive. Violations emit the exact template: `if any flags in the group [metadata-file metadata-url] are set none of the others can be; [metadata-file metadata-url] were all set`. "Set" means explicitly passed — an explicit empty value (`--metadata-file=`) still counts. -- Flag values are resolved by a raw-argv scan rather than the TS parser: every value the handler acts on (`--project-ref`, `--metadata-file`, `--metadata-url`, `--attribute-mapping-file`, `--domains`, `--name-id-format`, `--skip-url-validation`) is reconciled against that scan. E.g. `--project-ref --metadata-file x.xml --metadata-url u` hands `--metadata-file` to `--project-ref` as its value and fails ref validation — the metadata file is never read (CLI-1982). Repeated flags resolve last-wins (the TS parser is first-wins), and an occurrence with an invalid value — `--type` outside `[ saml ]`, a boolean outside the accepted spellings (`--skip-url-validation=yes`), or a `--name-id-format` outside the enum — fails with the `invalid argument …` message before every validation and request. +- Flag values are resolved by a raw-argv scan rather than the TS parser: every value the handler acts on (the deprecated `--project-ref` alias, `--metadata-file`, `--metadata-url`, `--attribute-mapping-file`, `--domains`, `--name-id-format`, `--skip-url-validation`) is reconciled against that scan. E.g. `--project-ref --metadata-file x.xml --metadata-url u` hands `--metadata-file` to `--project-ref` as its value, which is then looked up as a branch name and fails before any POST — the metadata file is never read (CLI-1982). `--remote` is read from the parsed flag. Repeated flags resolve last-wins (the TS parser is first-wins), and an occurrence with an invalid value — `--type` outside `[ saml ]`, a boolean outside the accepted spellings (`--skip-url-validation=yes`), or a `--name-id-format` outside the enum — fails with the `invalid argument …` message before every validation and request. - Required-ness uses the same scan: when the `--type` token is itself consumed as another flag's value (`--domains --type saml`), the command fails with the exact `required flag(s) "type" not set` before any request (required-flags validation runs before flag-group validation). `-t` shorthand occurrences are recognised by the scan and never trip this. - The workdir uses the same scan: the workdir-change step chdir's to the effective `--workdir` (last occurrence, even a flag-shaped consumed token like `--workdir --metadata-file`) or `SUPABASE_WORKDIR`, and a missing directory aborts with the exact `failed to change workdir: chdir …` before the required-flag check, the mutex check, and any request. A changed-but-empty `--workdir=` shadows the env var and falls back to the always-valid project-root walk-up. - The profile uses the same scan: whenever the effective `--profile`/`SUPABASE_PROFILE` token differs from the one the Effect parser gave the config layer (a `--profile` token consumed by another flag — `--domains --profile alternate.yml` targets the env/default profile, not `alternate.yml`; a flag-shaped consumed value — `--profile --metadata-url`; repeats, which resolve last-wins; an explicit `--profile supabase` shadowing the env; an untrimmed/empty persisted `~/.supabase/profile` file), the handler re-runs the profile loader on the effective token (`profile-load.ts`) — the POST targets that profile's `api_url`, and a token that can't be loaded aborts with the matching error (`failed to read profile: …` / `failed to parse profile: …` / `invalid profile: …`, byte-exact for the deterministic classes) before the workdir check and any request. Where the scan and the parser agree — every normal invocation — the config layer's resolution (including its pre-existing lenient missing/malformed-file fallback, which predates CLI-1982 and applies shell-wide) is used unchanged. The upgrade-gate fallback GETs and the linked-project cache fill also target the reconciled host. @@ -105,3 +107,15 @@ Single `success` event with the parsed response as data. - **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: …` 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. +- Requires `--remote=<ref|branch>`, `SUPABASE_PROJECT_ID`, a linked project (`supabase/.temp/project-ref`) + or, on an interactive terminal, the project picker. A bare `--remote` targets the linked project. +- `--remote=<value>` is read as a project ref (20 lowercase letters), a branch of the linked + project (by name or UUID), or `true|yes|on|1` for the linked project. A database URL is rejected + (`supabase sso add` takes a project, not a database); `false|no|off|0`, `--no-remote` and an + empty value fail with `Leave out --remote to use the linked project.` +- A bare `--remote` followed by a word that is a branch of the linked project fails before any + other work with a `Did you mean --remote=<word>?` hint, since the word may have been meant as + the flag's value. +- The deprecated `--project-ref` is an alias of `--remote=<ref>` (a non-ref value is read as a + branch name) and prints a deprecation warning on stderr that names the replacement; it cannot + be combined with `--remote=<value>`. diff --git a/apps/cli/src/commands/sso/info/SIDE_EFFECTS.md b/apps/cli/src/commands/sso/info/SIDE_EFFECTS.md index 814a1b37fe..7da36b0655 100644 --- a/apps/cli/src/commands/sso/info/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/sso/info/SIDE_EFFECTS.md @@ -2,13 +2,13 @@ ## Files Read -| Path | Format | When | -| ---------------------------------------------- | ------------------------- | --------------------------------------------------------------------------------------------- | -| keyring `"Supabase CLI"` / `<profile>` | OS keychain | when `SUPABASE_ACCESS_TOKEN` unset and keyring available; account = `CommandSettings.profile` | -| keyring `"Supabase CLI"` / `access-token` | OS keychain | legacy-key fallback when the profile-keyed lookup misses | -| `~/.supabase/access-token` | plain text (token string) | last-resort fallback after env + keyring miss | -| `<workdir>/supabase/.temp/project-ref` | plain text | when `--project-ref` flag and `SUPABASE_PROJECT_ID` env are unset | -| `<workdir>/supabase/.temp/linked-project.json` | JSON | always — `linkedProjectCache` reads to decide whether to write | +| Path | Format | When | +| ---------------------------------------------- | ------------------------- | --------------------------------------------------------------------------------------------------------------- | +| keyring `"Supabase CLI"` / `<profile>` | OS keychain | when `SUPABASE_ACCESS_TOKEN` unset and keyring available; account = `CommandSettings.profile` | +| keyring `"Supabase CLI"` / `access-token` | OS keychain | legacy-key fallback when the profile-keyed lookup misses | +| `~/.supabase/access-token` | plain text (token string) | last-resort fallback after env + keyring miss | +| `<workdir>/supabase/.temp/project-ref` | plain text | when `--remote=<ref>` and `SUPABASE_PROJECT_ID` env are unset, and as the linked parent for `--remote=<branch>` | +| `<workdir>/supabase/.temp/linked-project.json` | JSON | always — `linkedProjectCache` reads to decide whether to write | `info` makes no Management API call of its own, but the Management API client is built eagerly, so an access token is required: a missing one fails before the handler runs. @@ -25,10 +25,11 @@ access token is required: a missing one fails before the handler runs. `info` derives all three URLs locally from the project ref and makes no Management API call of its own. The ref resolution and the cache fill still call the API: -| Method | Path | Auth | Request body | Response (used fields) | -| ------ | -------------------- | ------------ | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `GET` | `/v1/projects` | Bearer token | none | project list for the interactive picker — only when no ref comes from `--project-ref`, `SUPABASE_PROJECT_ID` or `.temp/project-ref` and stdin is a TTY with interactive output | -| `GET` | `/v1/projects/{ref}` | Bearer token | none | `{ref, name, organization_id, organization_slug}` — `linkedProjectCache` fill, best-effort, skipped when `.temp/linked-project.json` exists, `.temp/project-ref` names another ref, or no token is available | +| Method | Path | Auth | Request body | Response (used fields) | +| ------ | -------------------------------- | ------------ | ------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `GET` | `/v1/projects` | Bearer token | none | project list for the interactive picker — only when no ref comes from `--remote=<ref>`, `SUPABASE_PROJECT_ID` or `.temp/project-ref` and stdin is a TTY with interactive output | +| `GET` | `/v1/projects/{parent}/branches` | Bearer token | none | `[{id, name, project_ref, is_default}]` — only for `--remote=<branch>` (or the deprecated `--project-ref=<branch>`), and for a bare `--remote` followed by a word that could name a branch; `{parent}` is `SUPABASE_PROJECT_ID`, else the linked project | +| `GET` | `/v1/projects/{ref}` | Bearer token | none | `{ref, name, organization_id, organization_slug}` — `linkedProjectCache` fill, best-effort, skipped when `.temp/linked-project.json` exists, `.temp/project-ref` names another ref, or no token is available | ## Environment Variables @@ -39,17 +40,18 @@ own. The ref resolution and the cache fill still call the API: ## Exit Codes -| Code | Condition | -| ---- | ---------------------------------------------------------- | -| `0` | success | -| `1` | `InvalidProjectRefError` — ref doesn't match `^[a-z]{20}$` | -| `1` | `ProjectRefNotLinkedError` — no ref source available | +| Code | Condition | +| ---- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `0` | success | +| `1` | `InvalidProjectRefError` — `SUPABASE_PROJECT_ID` or the link file holds a ref that doesn't match `^[a-z]{20}$` | +| `1` | `ProjectRefNotLinkedError` — no ref source available | +| `1` | invalid `--remote` selector — `--remote=<url>` (`TargetNeedsProjectError`), an empty value, `--remote` combined with `--project-ref`, or a branch the linked project does not have (`LinkBranchNotFoundError`) | ## Telemetry Events Fired -| Event | When | Notable properties | -| ---------------------- | ------------------------------------------ | ---------------------------------------------------------------------- | -| `cli_command_executed` | post-run, success or failure (via wrapper) | `exit_code`, `duration_ms`, `flags` (`--project-ref` allowed verbatim) | +| Event | When | Notable properties | +| ---------------------- | ------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `cli_command_executed` | post-run, success or failure (via wrapper) | `exit_code`, `duration_ms`, `flags` (`--remote` → `true` (bare), the ref, or `<redacted>`; `--project-ref` is recorded only when it is ref-shaped, never a branch name) | ## Output @@ -78,3 +80,15 @@ 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: …` banner naming the resolved project is printed on stderr in every output format; see [Command target](../../../../docs/command-target.md). +- Requires `--remote=<ref|branch>`, `SUPABASE_PROJECT_ID`, a linked project (`supabase/.temp/project-ref`) + or, on an interactive terminal, the project picker. A bare `--remote` targets the linked project. +- `--remote=<value>` is read as a project ref (20 lowercase letters), a branch of the linked + project (by name or UUID), or `true|yes|on|1` for the linked project. A database URL is rejected + (`supabase sso info` takes a project, not a database); `false|no|off|0`, `--no-remote` and an + empty value fail with `Leave out --remote to use the linked project.` +- A bare `--remote` followed by a word that is a branch of the linked project fails before any + other work with a `Did you mean --remote=<word>?` hint, since the word may have been meant as + the flag's value. +- The deprecated `--project-ref` is an alias of `--remote=<ref>` (a non-ref value is read as a + branch name) and prints a deprecation warning on stderr that names the replacement; it cannot + be combined with `--remote=<value>`. diff --git a/apps/cli/src/commands/sso/list/SIDE_EFFECTS.md b/apps/cli/src/commands/sso/list/SIDE_EFFECTS.md index 248c308c56..c360e34beb 100644 --- a/apps/cli/src/commands/sso/list/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/sso/list/SIDE_EFFECTS.md @@ -2,13 +2,13 @@ ## Files Read -| Path | Format | When | -| ---------------------------------------------- | ------------------------- | --------------------------------------------------------------------------------------------- | -| keyring `"Supabase CLI"` / `<profile>` | OS keychain | when `SUPABASE_ACCESS_TOKEN` unset and keyring available; account = `CommandSettings.profile` | -| keyring `"Supabase CLI"` / `access-token` | OS keychain | legacy-key fallback when the profile-keyed lookup misses | -| `~/.supabase/access-token` | plain text (token string) | last-resort fallback after env + keyring miss | -| `<workdir>/supabase/.temp/linked-project.json` | JSON | always — `linkedProjectCache` reads to decide whether to write | -| `<workdir>/supabase/.temp/project-ref` | plain text | when `--project-ref` flag and `SUPABASE_PROJECT_ID` env are unset | +| Path | Format | When | +| ---------------------------------------------- | ------------------------- | --------------------------------------------------------------------------------------------------------------- | +| keyring `"Supabase CLI"` / `<profile>` | OS keychain | when `SUPABASE_ACCESS_TOKEN` unset and keyring available; account = `CommandSettings.profile` | +| keyring `"Supabase CLI"` / `access-token` | OS keychain | legacy-key fallback when the profile-keyed lookup misses | +| `~/.supabase/access-token` | plain text (token string) | last-resort fallback after env + keyring miss | +| `<workdir>/supabase/.temp/linked-project.json` | JSON | always — `linkedProjectCache` reads to decide whether to write | +| `<workdir>/supabase/.temp/project-ref` | plain text | when `--remote=<ref>` and `SUPABASE_PROJECT_ID` env are unset, and as the linked parent for `--remote=<branch>` | ## Files Written @@ -19,13 +19,14 @@ ## API Routes -| Method | Path | Auth | Request body | Response (used fields) | -| ------ | ---------------------------------------------- | ------------ | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `GET` | `/v1/projects` | Bearer token | none | project list for the interactive picker — only when no ref comes from `--project-ref`, `SUPABASE_PROJECT_ID` or `.temp/project-ref` and stdin is a TTY with interactive output | -| `GET` | `/v1/projects/{ref}/config/auth/sso/providers` | Bearer token | none | `{items: [{id, saml?, domains?, created_at?, updated_at?}]}` | -| `GET` | `/v1/projects/{ref}` | Bearer token | none | `{organization_slug}` — upgrade-gate side-call on a 4xx with no plan-gate error in the body | -| `GET` | `/v1/organizations/{slug}/entitlements` | Bearer token | none | `{entitlements[].feature.key, .hasAccess}` — upgrade-gate side-call on a 4xx with no plan-gate error in the body | -| `GET` | `/v1/projects/{ref}` | Bearer token | none | `{ref, name, organization_id, organization_slug}` — `linkedProjectCache` fill, best-effort, skipped when `.temp/linked-project.json` exists, `.temp/project-ref` names another ref, or no token is available | +| Method | Path | Auth | Request body | Response (used fields) | +| ------ | ---------------------------------------------- | ------------ | ------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `GET` | `/v1/projects` | Bearer token | none | project list for the interactive picker — only when no ref comes from `--remote=<ref>`, `SUPABASE_PROJECT_ID` or `.temp/project-ref` and stdin is a TTY with interactive output | +| `GET` | `/v1/projects/{parent}/branches` | Bearer token | none | `[{id, name, project_ref, is_default}]` — only for `--remote=<branch>` (or the deprecated `--project-ref=<branch>`), and for a bare `--remote` followed by a word that could name a branch; `{parent}` is `SUPABASE_PROJECT_ID`, else the linked project | +| `GET` | `/v1/projects/{ref}/config/auth/sso/providers` | Bearer token | none | `{items: [{id, saml?, domains?, created_at?, updated_at?}]}` | +| `GET` | `/v1/projects/{ref}` | Bearer token | none | `{organization_slug}` — upgrade-gate side-call on a 4xx with no plan-gate error in the body | +| `GET` | `/v1/organizations/{slug}/entitlements` | Bearer token | none | `{entitlements[].feature.key, .hasAccess}` — upgrade-gate side-call on a 4xx with no plan-gate error in the body | +| `GET` | `/v1/projects/{ref}` | Bearer token | none | `{ref, name, organization_id, organization_slug}` — `linkedProjectCache` fill, best-effort, skipped when `.temp/linked-project.json` exists, `.temp/project-ref` names another ref, or no token is available | ## Environment Variables @@ -36,19 +37,20 @@ ## Exit Codes -| Code | Condition | -| ---- | --------------------------------------------------- | -| `0` | success | -| `1` | `SsoListSamlDisabledError` — 404 from list endpoint | -| `1` | `SsoListUnexpectedStatusError` — other non-2xx | -| `1` | `SsoListNetworkError` — transport-level failure | +| Code | Condition | +| ---- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `0` | success | +| `1` | `SsoListSamlDisabledError` — 404 from list endpoint | +| `1` | `SsoListUnexpectedStatusError` — other non-2xx | +| `1` | `SsoListNetworkError` — transport-level failure | +| `1` | invalid `--remote` selector — `--remote=<url>` (`TargetNeedsProjectError`), an empty value, `--remote` combined with `--project-ref`, or a branch the linked project does not have (`LinkBranchNotFoundError`) | ## Telemetry Events Fired -| Event | When | Notable properties | -| ----------------------- | ---------------------------------------------------- | ---------------------------------------------------------------------- | -| `cli_command_executed` | post-run, success or failure (via wrapper) | `exit_code`, `duration_ms`, `flags` (`--project-ref` allowed verbatim) | -| `cli_upgrade_suggested` | 4xx response **and** `auth.saml_2` entitlement gated | `feature_key: "auth.saml_2"`, `org_slug` | +| Event | When | Notable properties | +| ----------------------- | ---------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `cli_command_executed` | post-run, success or failure (via wrapper) | `exit_code`, `duration_ms`, `flags` (`--remote` → `true` (bare), the ref, or `<redacted>`; `--project-ref` is recorded only when it is ref-shaped, never a branch name) | +| `cli_upgrade_suggested` | 4xx response **and** `auth.saml_2` entitlement gated | `feature_key: "auth.saml_2"`, `org_slug` | ## Output @@ -73,3 +75,15 @@ 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: …` banner naming the resolved project is printed on stderr in every output format; see [Command target](../../../../docs/command-target.md). +- Requires `--remote=<ref|branch>`, `SUPABASE_PROJECT_ID`, a linked project (`supabase/.temp/project-ref`) + or, on an interactive terminal, the project picker. A bare `--remote` targets the linked project. +- `--remote=<value>` is read as a project ref (20 lowercase letters), a branch of the linked + project (by name or UUID), or `true|yes|on|1` for the linked project. A database URL is rejected + (`supabase sso list` takes a project, not a database); `false|no|off|0`, `--no-remote` and an + empty value fail with `Leave out --remote to use the linked project.` +- A bare `--remote` followed by a word that is a branch of the linked project fails before any + other work with a `Did you mean --remote=<word>?` hint, since the word may have been meant as + the flag's value. +- The deprecated `--project-ref` is an alias of `--remote=<ref>` (a non-ref value is read as a + branch name) and prints a deprecation warning on stderr that names the replacement; it cannot + be combined with `--remote=<value>`. diff --git a/apps/cli/src/commands/sso/remove/SIDE_EFFECTS.md b/apps/cli/src/commands/sso/remove/SIDE_EFFECTS.md index 3fc2638925..728c98de48 100644 --- a/apps/cli/src/commands/sso/remove/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/sso/remove/SIDE_EFFECTS.md @@ -2,13 +2,13 @@ ## Files Read -| Path | Format | When | -| ---------------------------------------------- | ------------------------- | --------------------------------------------------------------------------------------------- | -| keyring `"Supabase CLI"` / `<profile>` | OS keychain | when `SUPABASE_ACCESS_TOKEN` unset and keyring available; account = `CommandSettings.profile` | -| keyring `"Supabase CLI"` / `access-token` | OS keychain | legacy-key fallback when the profile-keyed lookup misses | -| `~/.supabase/access-token` | plain text (token string) | last-resort fallback after env + keyring miss | -| `<workdir>/supabase/.temp/linked-project.json` | JSON | always — `linkedProjectCache` reads to decide whether to write | -| `<workdir>/supabase/.temp/project-ref` | plain text | when `--project-ref` flag and `SUPABASE_PROJECT_ID` env are unset | +| Path | Format | When | +| ---------------------------------------------- | ------------------------- | --------------------------------------------------------------------------------------------------------------- | +| keyring `"Supabase CLI"` / `<profile>` | OS keychain | when `SUPABASE_ACCESS_TOKEN` unset and keyring available; account = `CommandSettings.profile` | +| keyring `"Supabase CLI"` / `access-token` | OS keychain | legacy-key fallback when the profile-keyed lookup misses | +| `~/.supabase/access-token` | plain text (token string) | last-resort fallback after env + keyring miss | +| `<workdir>/supabase/.temp/linked-project.json` | JSON | always — `linkedProjectCache` reads to decide whether to write | +| `<workdir>/supabase/.temp/project-ref` | plain text | when `--remote=<ref>` and `SUPABASE_PROJECT_ID` env are unset, and as the linked parent for `--remote=<branch>` | ## Files Written @@ -19,13 +19,14 @@ ## API Routes -| Method | Path | Auth | Request body | Response (used fields) | -| -------- | ------------------------------------------------------------ | ------------ | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `GET` | `/v1/projects` | Bearer token | none | project list for the interactive picker — only when no ref comes from `--project-ref`, `SUPABASE_PROJECT_ID` or `.temp/project-ref` and stdin is a TTY with interactive output | -| `DELETE` | `/v1/projects/{ref}/config/auth/sso/providers/{provider_id}` | Bearer token | none | `{id, saml?, domains?, created_at?, updated_at?}` | -| `GET` | `/v1/projects/{ref}` | Bearer token | none | `{organization_slug}` — upgrade-gate side-call on a 4xx with no plan-gate error in the body | -| `GET` | `/v1/organizations/{slug}/entitlements` | Bearer token | none | `{entitlements[].feature.key, .hasAccess}` — upgrade-gate side-call on a 4xx with no plan-gate error in the body | -| `GET` | `/v1/projects/{ref}` | Bearer token | none | `{ref, name, organization_id, organization_slug}` — `linkedProjectCache` fill, best-effort, skipped when `.temp/linked-project.json` exists, `.temp/project-ref` names another ref, or no token is available | +| Method | Path | Auth | Request body | Response (used fields) | +| -------- | ------------------------------------------------------------ | ------------ | ------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `GET` | `/v1/projects` | Bearer token | none | project list for the interactive picker — only when no ref comes from `--remote=<ref>`, `SUPABASE_PROJECT_ID` or `.temp/project-ref` and stdin is a TTY with interactive output | +| `GET` | `/v1/projects/{parent}/branches` | Bearer token | none | `[{id, name, project_ref, is_default}]` — only for `--remote=<branch>` (or the deprecated `--project-ref=<branch>`), and for a bare `--remote` followed by a word that could name a branch; `{parent}` is `SUPABASE_PROJECT_ID`, else the linked project | +| `DELETE` | `/v1/projects/{ref}/config/auth/sso/providers/{provider_id}` | Bearer token | none | `{id, saml?, domains?, created_at?, updated_at?}` | +| `GET` | `/v1/projects/{ref}` | Bearer token | none | `{organization_slug}` — upgrade-gate side-call on a 4xx with no plan-gate error in the body | +| `GET` | `/v1/organizations/{slug}/entitlements` | Bearer token | none | `{entitlements[].feature.key, .hasAccess}` — upgrade-gate side-call on a 4xx with no plan-gate error in the body | +| `GET` | `/v1/projects/{ref}` | Bearer token | none | `{ref, name, organization_id, organization_slug}` — `linkedProjectCache` fill, best-effort, skipped when `.temp/linked-project.json` exists, `.temp/project-ref` names another ref, or no token is available | ## Environment Variables @@ -37,22 +38,23 @@ ## 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 | -| `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 | +| 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 | +| `1` | invalid `--remote` selector — `--remote=<url>` (`TargetNeedsProjectError`), an empty value, `--remote` combined with `--project-ref`, or a branch the linked project does not have (`LinkBranchNotFoundError`) | ## Telemetry Events Fired -| Event | When | Notable properties | -| ----------------------- | ---------------------------------------------------- | ---------------------------------------------------------------------- | -| `cli_command_executed` | post-run, success or failure (via wrapper) | `exit_code`, `duration_ms`, `flags` (`--project-ref` allowed verbatim) | -| `cli_upgrade_suggested` | 4xx response **and** `auth.saml_2` entitlement gated | `feature_key: "auth.saml_2"`, `org_slug` | +| Event | When | Notable properties | +| ----------------------- | ---------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `cli_command_executed` | post-run, success or failure (via wrapper) | `exit_code`, `duration_ms`, `flags` (`--remote` → `true` (bare), the ref, or `<redacted>`; `--project-ref` is recorded only when it is ref-shaped, never a branch name) | +| `cli_upgrade_suggested` | 4xx response **and** `auth.saml_2` entitlement gated | `feature_key: "auth.saml_2"`, `org_slug` | ## Output @@ -78,3 +80,15 @@ Single `success` event with the removed provider's details as data. - The `<provider-id>` argument must be a valid UUID; UUIDs are accepted case-insensitively. - 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. +- Requires `--remote=<ref|branch>`, `SUPABASE_PROJECT_ID`, a linked project (`supabase/.temp/project-ref`) + or, on an interactive terminal, the project picker. A bare `--remote` targets the linked project. +- `--remote=<value>` is read as a project ref (20 lowercase letters), a branch of the linked + project (by name or UUID), or `true|yes|on|1` for the linked project. A database URL is rejected + (`supabase sso remove` takes a project, not a database); `false|no|off|0`, `--no-remote` and an + empty value fail with `Leave out --remote to use the linked project.` +- A bare `--remote` followed by a word that is a branch of the linked project fails before any + other work with a `Did you mean --remote=<word>?` hint, since the word may have been meant as + the flag's value. +- The deprecated `--project-ref` is an alias of `--remote=<ref>` (a non-ref value is read as a + branch name) and prints a deprecation warning on stderr that names the replacement; it cannot + be combined with `--remote=<value>`. diff --git a/apps/cli/src/commands/sso/show/SIDE_EFFECTS.md b/apps/cli/src/commands/sso/show/SIDE_EFFECTS.md index 586a4e1146..e144a1ea80 100644 --- a/apps/cli/src/commands/sso/show/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/sso/show/SIDE_EFFECTS.md @@ -2,13 +2,13 @@ ## Files Read -| Path | Format | When | -| ---------------------------------------------- | ------------------------- | --------------------------------------------------------------------------------------------- | -| keyring `"Supabase CLI"` / `<profile>` | OS keychain | when `SUPABASE_ACCESS_TOKEN` unset and keyring available; account = `CommandSettings.profile` | -| keyring `"Supabase CLI"` / `access-token` | OS keychain | legacy-key fallback when the profile-keyed lookup misses | -| `~/.supabase/access-token` | plain text (token string) | last-resort fallback after env + keyring miss | -| `<workdir>/supabase/.temp/linked-project.json` | JSON | always — `linkedProjectCache` reads to decide whether to write | -| `<workdir>/supabase/.temp/project-ref` | plain text | when `--project-ref` flag and `SUPABASE_PROJECT_ID` env are unset | +| Path | Format | When | +| ---------------------------------------------- | ------------------------- | --------------------------------------------------------------------------------------------------------------- | +| keyring `"Supabase CLI"` / `<profile>` | OS keychain | when `SUPABASE_ACCESS_TOKEN` unset and keyring available; account = `CommandSettings.profile` | +| keyring `"Supabase CLI"` / `access-token` | OS keychain | legacy-key fallback when the profile-keyed lookup misses | +| `~/.supabase/access-token` | plain text (token string) | last-resort fallback after env + keyring miss | +| `<workdir>/supabase/.temp/linked-project.json` | JSON | always — `linkedProjectCache` reads to decide whether to write | +| `<workdir>/supabase/.temp/project-ref` | plain text | when `--remote=<ref>` and `SUPABASE_PROJECT_ID` env are unset, and as the linked parent for `--remote=<branch>` | ## Files Written @@ -19,11 +19,12 @@ ## API Routes -| Method | Path | Auth | Request body | Response (used fields) | -| ------ | ------------------------------------------------------------ | ------------ | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `GET` | `/v1/projects` | Bearer token | none | project list for the interactive picker — only when no ref comes from `--project-ref`, `SUPABASE_PROJECT_ID` or `.temp/project-ref` and stdin is a TTY with interactive output | -| `GET` | `/v1/projects/{ref}/config/auth/sso/providers/{provider_id}` | Bearer token | none | `{id, saml?, domains?, created_at?, updated_at?}` | -| `GET` | `/v1/projects/{ref}` | Bearer token | none | `{ref, name, organization_id, organization_slug}` — `linkedProjectCache` fill, best-effort, skipped when `.temp/linked-project.json` exists, `.temp/project-ref` names another ref, or no token is available | +| Method | Path | Auth | Request body | Response (used fields) | +| ------ | ------------------------------------------------------------ | ------------ | ------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `GET` | `/v1/projects` | Bearer token | none | project list for the interactive picker — only when no ref comes from `--remote=<ref>`, `SUPABASE_PROJECT_ID` or `.temp/project-ref` and stdin is a TTY with interactive output | +| `GET` | `/v1/projects/{parent}/branches` | Bearer token | none | `[{id, name, project_ref, is_default}]` — only for `--remote=<branch>` (or the deprecated `--project-ref=<branch>`), and for a bare `--remote` followed by a word that could name a branch; `{parent}` is `SUPABASE_PROJECT_ID`, else the linked project | +| `GET` | `/v1/projects/{ref}/config/auth/sso/providers/{provider_id}` | Bearer token | none | `{id, saml?, domains?, created_at?, updated_at?}` | +| `GET` | `/v1/projects/{ref}` | Bearer token | none | `{ref, name, organization_id, organization_slug}` — `linkedProjectCache` fill, best-effort, skipped when `.temp/linked-project.json` exists, `.temp/project-ref` names another ref, or no token is available | Note: Unlike `list`/`add`/`update`/`remove`, `show` does **not** make upgrade-gate side-calls. @@ -37,20 +38,21 @@ side-calls. ## Exit Codes -| Code | Condition | -| ---- | ----------------------------------------------------------- | -| `0` | success | -| `1` | `SsoInvalidUuidError` — provider ID is not a canonical UUID | -| `1` | `SsoShowNotFoundError` — 404 from get endpoint | -| `1` | `SsoShowUnexpectedStatusError` — other non-2xx | -| `1` | `SsoShowEnvNotSupportedError` — `--output env` with show | -| `1` | `SsoShowNetworkError` — transport-level failure | +| Code | Condition | +| ---- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `0` | success | +| `1` | `SsoInvalidUuidError` — provider ID is not a canonical UUID | +| `1` | `SsoShowNotFoundError` — 404 from get endpoint | +| `1` | `SsoShowUnexpectedStatusError` — other non-2xx | +| `1` | `SsoShowEnvNotSupportedError` — `--output env` with show | +| `1` | `SsoShowNetworkError` — transport-level failure | +| `1` | invalid `--remote` selector — `--remote=<url>` (`TargetNeedsProjectError`), an empty value, `--remote` combined with `--project-ref`, or a branch the linked project does not have (`LinkBranchNotFoundError`) | ## Telemetry Events Fired -| Event | When | Notable properties | -| ---------------------- | ------------------------------------------ | ---------------------------------------------------------------------- | -| `cli_command_executed` | post-run, success or failure (via wrapper) | `exit_code`, `duration_ms`, `flags` (`--project-ref` allowed verbatim) | +| Event | When | Notable properties | +| ---------------------- | ------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `cli_command_executed` | post-run, success or failure (via wrapper) | `exit_code`, `duration_ms`, `flags` (`--remote` → `true` (bare), the ref, or `<redacted>`; `--project-ref` is recorded only when it is ref-shaped, never a branch name) | ## Output @@ -80,3 +82,15 @@ Single `success` event with the response as data. - `--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: …` banner naming the resolved project is printed on stderr in every output format; see [Command target](../../../../docs/command-target.md). +- Requires `--remote=<ref|branch>`, `SUPABASE_PROJECT_ID`, a linked project (`supabase/.temp/project-ref`) + or, on an interactive terminal, the project picker. A bare `--remote` targets the linked project. +- `--remote=<value>` is read as a project ref (20 lowercase letters), a branch of the linked + project (by name or UUID), or `true|yes|on|1` for the linked project. A database URL is rejected + (`supabase sso show` takes a project, not a database); `false|no|off|0`, `--no-remote` and an + empty value fail with `Leave out --remote to use the linked project.` +- A bare `--remote` followed by a word that is a branch of the linked project fails before any + other work with a `Did you mean --remote=<word>?` hint, since the word may have been meant as + the flag's value. +- The deprecated `--project-ref` is an alias of `--remote=<ref>` (a non-ref value is read as a + branch name) and prints a deprecation warning on stderr that names the replacement; it cannot + be combined with `--remote=<value>`. diff --git a/apps/cli/src/commands/sso/update/SIDE_EFFECTS.md b/apps/cli/src/commands/sso/update/SIDE_EFFECTS.md index 31ceef6559..776d1eed06 100644 --- a/apps/cli/src/commands/sso/update/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/sso/update/SIDE_EFFECTS.md @@ -2,15 +2,15 @@ ## Files Read -| Path | Format | When | -| ---------------------------------------------- | ------------------------- | --------------------------------------------------------------------------------------------- | -| keyring `"Supabase CLI"` / `<profile>` | OS keychain | when `SUPABASE_ACCESS_TOKEN` unset and keyring available; account = `CommandSettings.profile` | -| keyring `"Supabase CLI"` / `access-token` | OS keychain | legacy-key fallback when the profile-keyed lookup misses | -| `~/.supabase/access-token` | plain text (token string) | last-resort fallback after env + keyring miss | -| `<workdir>/supabase/.temp/linked-project.json` | JSON | always — `linkedProjectCache` reads to decide whether to write | -| `<workdir>/supabase/.temp/project-ref` | plain text | when `--project-ref` flag and `SUPABASE_PROJECT_ID` env are unset | -| `<metadata-file>` | XML (UTF-8) | when `--metadata-file` is provided | -| `<attribute-mapping-file>` | JSON | when `--attribute-mapping-file` is provided | +| Path | Format | When | +| ---------------------------------------------- | ------------------------- | --------------------------------------------------------------------------------------------------------------- | +| keyring `"Supabase CLI"` / `<profile>` | OS keychain | when `SUPABASE_ACCESS_TOKEN` unset and keyring available; account = `CommandSettings.profile` | +| keyring `"Supabase CLI"` / `access-token` | OS keychain | legacy-key fallback when the profile-keyed lookup misses | +| `~/.supabase/access-token` | plain text (token string) | last-resort fallback after env + keyring miss | +| `<workdir>/supabase/.temp/linked-project.json` | JSON | always — `linkedProjectCache` reads to decide whether to write | +| `<workdir>/supabase/.temp/project-ref` | plain text | when `--remote=<ref>` and `SUPABASE_PROJECT_ID` env are unset, and as the linked parent for `--remote=<branch>` | +| `<metadata-file>` | XML (UTF-8) | when `--metadata-file` is provided | +| `<attribute-mapping-file>` | JSON | when `--attribute-mapping-file` is provided | ## Files Written @@ -21,15 +21,16 @@ ## API Routes -| Method | Path | Auth | Request body | Response (used fields) | -| ------ | ------------------------------------------------------------ | ------------ | ------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `GET` | `/v1/projects` | Bearer token | none | project list for the interactive picker — only when no ref comes from `--project-ref`, `SUPABASE_PROJECT_ID` or `.temp/project-ref` and stdin is a TTY with interactive output | -| `GET` | `/v1/projects/{ref}/config/auth/sso/providers/{provider_id}` | Bearer token | none | `{id, saml?, domains?, created_at?, updated_at?}` | -| `PUT` | `/v1/projects/{ref}/config/auth/sso/providers/{provider_id}` | Bearer token | `{metadata_xml?, metadata_url?, domains, attribute_mapping?, name_id_format?}` | `{id, saml?, domains?, ...}` (typed schema) | -| `GET` | `<metadata-url>` | none | `Accept: application/xml`, 10s timeout | XML body (UTF-8) — validation when `--skip-url-validation` not set | -| `GET` | `/v1/projects/{ref}` | Bearer token | none | `{organization_slug}` — upgrade-gate side-call on a 4xx with no plan-gate error in the body | -| `GET` | `/v1/organizations/{slug}/entitlements` | Bearer token | none | `{entitlements[].feature.key, .hasAccess}` — upgrade-gate side-call on a 4xx with no plan-gate error in the body | -| `GET` | `/v1/projects/{ref}` | Bearer token | none | `{ref, name, organization_id, organization_slug}` — `linkedProjectCache` fill, best-effort, skipped when `.temp/linked-project.json` exists, `.temp/project-ref` names another ref, or no token is available | +| Method | Path | Auth | Request body | Response (used fields) | +| ------ | ------------------------------------------------------------ | ------------ | ------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `GET` | `/v1/projects` | Bearer token | none | project list for the interactive picker — only when no ref comes from `--remote=<ref>`, `SUPABASE_PROJECT_ID` or `.temp/project-ref` and stdin is a TTY with interactive output | +| `GET` | `/v1/projects/{parent}/branches` | Bearer token | none | `[{id, name, project_ref, is_default}]` — only for `--remote=<branch>` (or the deprecated `--project-ref=<branch>`), and for a bare `--remote` followed by a word that could name a branch; `{parent}` is `SUPABASE_PROJECT_ID`, else the linked project | +| `GET` | `/v1/projects/{ref}/config/auth/sso/providers/{provider_id}` | Bearer token | none | `{id, saml?, domains?, created_at?, updated_at?}` | +| `PUT` | `/v1/projects/{ref}/config/auth/sso/providers/{provider_id}` | Bearer token | `{metadata_xml?, metadata_url?, domains, attribute_mapping?, name_id_format?}` | `{id, saml?, domains?, ...}` (typed schema) | +| `GET` | `<metadata-url>` | none | `Accept: application/xml`, 10s timeout | XML body (UTF-8) — validation when `--skip-url-validation` not set | +| `GET` | `/v1/projects/{ref}` | Bearer token | none | `{organization_slug}` — upgrade-gate side-call on a 4xx with no plan-gate error in the body | +| `GET` | `/v1/organizations/{slug}/entitlements` | Bearer token | none | `{entitlements[].feature.key, .hasAccess}` — upgrade-gate side-call on a 4xx with no plan-gate error in the body | +| `GET` | `/v1/projects/{ref}` | Bearer token | none | `{ref, name, organization_id, organization_slug}` — `linkedProjectCache` fill, best-effort, skipped when `.temp/linked-project.json` exists, `.temp/project-ref` names another ref, or no token is available | The GET goes through the typed Management API client. The PUT goes through `api.executeRaw` with the typed `v1UpdateASsoProvider` operation, which keeps the response body undecoded so structured output @@ -68,13 +69,14 @@ classification), for the GET before any PUT. | `1` | `SsoUpdateNetworkError` — transport failure or undecodable 2xx body | | `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 | +| `1` | invalid `--remote` selector — `--remote=<url>` (`TargetNeedsProjectError`), an empty value, `--remote` combined with `--project-ref`, or a branch the linked project does not have (`LinkBranchNotFoundError`) | ## Telemetry Events Fired -| Event | When | Notable properties | -| ----------------------- | ---------------------------------------------------- | ---------------------------------------------------------------------- | -| `cli_command_executed` | post-run, success or failure (via wrapper) | `exit_code`, `duration_ms`, `flags` (`--project-ref` allowed verbatim) | -| `cli_upgrade_suggested` | 4xx response **and** `auth.saml_2` entitlement gated | `feature_key: "auth.saml_2"`, `org_slug` | +| Event | When | Notable properties | +| ----------------------- | ---------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `cli_command_executed` | post-run, success or failure (via wrapper) | `exit_code`, `duration_ms`, `flags` (`--remote` → `true` (bare), the ref, or `<redacted>`; `--project-ref` is recorded only when it is ref-shaped, never a branch name) | +| `cli_upgrade_suggested` | 4xx response **and** `auth.saml_2` entitlement gated | `feature_key: "auth.saml_2"`, `org_slug` | ## Output @@ -98,7 +100,7 @@ Single `success` event with the parsed response as data. - `--domains` is mutually exclusive with `--add-domains` and `--remove-domains`. - `--metadata-file` and `--metadata-url` are mutually exclusive. -- Flag values are resolved by a raw-argv scan rather than the TS parser: every value the handler acts on (`--project-ref`, `--metadata-file`, `--metadata-url`, `--attribute-mapping-file`, the three domain slices, `--name-id-format`, `--skip-url-validation`) is reconciled against that scan — same mechanism as `sso add`. Repeated flags resolve last-wins (the TS parser is first-wins), and an occurrence with an invalid value — a boolean outside the accepted spellings (`--skip-url-validation=yes`), or a `--name-id-format` outside the enum — fails with the `invalid argument …` message before any validation or request. +- Flag values are resolved by a raw-argv scan rather than the TS parser: every value the handler acts on (the deprecated `--project-ref` alias, `--metadata-file`, `--metadata-url`, `--attribute-mapping-file`, the three domain slices, `--name-id-format`, `--skip-url-validation`) is reconciled against that scan — same mechanism as `sso add`; `--remote` is read from the parsed flag. Repeated flags resolve last-wins (the TS parser is first-wins), and an occurrence with an invalid value — a boolean outside the accepted spellings (`--skip-url-validation=yes`), or a `--name-id-format` outside the enum — fails with the `invalid argument …` message before any validation or request. - Positional arity uses the same scan: the exact-one-argument rule is re-counted over the effective positionals (arity validation runs before every hook and flag validation), so a consumed flag token that orphans its parser-value (`--domains --metadata-url u <id>`) fails with the exact `accepts 1 arg(s), received 2` before the GET — and wins over both the mutex and invalid-UUID errors. - The workdir uses the same scan: the workdir-change step chdir's to the effective `--workdir` (last occurrence, even a flag-shaped consumed token like `--workdir --metadata-file`) or `SUPABASE_WORKDIR`, and a missing directory aborts with the exact `failed to change workdir: chdir …` after the arity check but before the mutex checks and any request. A changed-but-empty `--workdir=` shadows the env var and falls back to the always-valid project-root walk-up. - The profile uses the same scan: whenever the effective `--profile`/`SUPABASE_PROFILE` token differs from the one the Effect parser gave the config layer (repeats — the scan is last-wins where the parser is first-wins, so `--profile a.yml --profile b.yml` GETs and PUTs `b.yml`'s `api_url`; a flag-shaped consumed value — `--profile --add-domains`; an explicit `--profile supabase` shadowing the env; an untrimmed/empty persisted `~/.supabase/profile` file), the handler re-runs the profile loader on the effective token (`profile-load.ts`). Both the initial GET and the PUT then target that profile's `api_url` (a per-request base URL and token on the typed client), and a token that can't be loaded aborts with the matching error (`failed to read profile: …` / `failed to parse profile: …` / `invalid profile: …`, byte-exact for the deterministic classes) after the arity check, before the workdir and mutex checks and any request. The upgrade-gate fallback GETs and the linked-project cache fill also target the reconciled host. Where the scan and the parser agree — every normal invocation — the config layer's resolution (including its pre-existing lenient missing/malformed-file fallback, which predates CLI-1982 and applies shell-wide) is used unchanged. @@ -112,3 +114,15 @@ Single `success` event with the parsed response as data. - 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. +- Requires `--remote=<ref|branch>`, `SUPABASE_PROJECT_ID`, a linked project (`supabase/.temp/project-ref`) + or, on an interactive terminal, the project picker. A bare `--remote` targets the linked project. +- `--remote=<value>` is read as a project ref (20 lowercase letters), a branch of the linked + project (by name or UUID), or `true|yes|on|1` for the linked project. A database URL is rejected + (`supabase sso update` takes a project, not a database); `false|no|off|0`, `--no-remote` and an + empty value fail with `Leave out --remote to use the linked project.` +- A bare `--remote` followed by a word that is a branch of the linked project fails before any + other work with a `Did you mean --remote=<word>?` hint, since the word may have been meant as + the flag's value. +- The deprecated `--project-ref` is an alias of `--remote=<ref>` (a non-ref value is read as a + branch name) and prints a deprecation warning on stderr that names the replacement; it cannot + be combined with `--remote=<value>`. 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 0579677d3d..5daaa8b107 100644 --- a/apps/cli/src/commands/vanity-subdomains/activate/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/vanity-subdomains/activate/SIDE_EFFECTS.md @@ -2,11 +2,11 @@ ## Files Read -| Path | Format | When | -| ---------------------------------------------- | ------------------------- | -------------------------------------------------------------------------------------------------------------------- | -| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | -| `<workdir>/supabase/.temp/linked-project.json` | JSON | always, after the project ref resolves — `linkedProjectCache` checks it exists to decide whether to write | -| `<workdir>/supabase/.temp/project-ref` | plain text (project ref) | when `--project-ref` flag and `SUPABASE_PROJECT_ID` env are both unset; also by `linkedProjectCache` on a cache miss | +| Path | Format | When | +| ---------------------------------------------- | ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | +| `<workdir>/supabase/.temp/linked-project.json` | JSON | always, after the project ref resolves — `linkedProjectCache` checks it exists to decide whether to write | +| `<workdir>/supabase/.temp/project-ref` | plain text (project ref) | when `--remote=<ref>` and `SUPABASE_PROJECT_ID` env are both unset, and as the linked parent for `--remote=<branch>`; also by `linkedProjectCache` on a cache miss | ## Files Written @@ -17,36 +17,38 @@ ## API Routes -| Method | Path | Auth | Request body | Response (used fields) | -| ------ | ---------------------------------------------- | ------------ | ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `GET` | `/v1/projects` | Bearer token | none | project list for the interactive picker — only when no ref comes from `--project-ref`, `SUPABASE_PROJECT_ID` or `.temp/project-ref` and stdin is a TTY with interactive output | -| `POST` | `/v1/projects/{ref}/vanity-subdomain/activate` | Bearer token | `{ vanity_subdomain: string }` | `{ custom_domain: string }` | -| `GET` | `/v1/projects/{ref}` | Bearer token | none | `{organization_slug}` — upgrade-gate side-call on a 4xx with no plan-gate error in the body | -| `GET` | `/v1/organizations/{slug}/entitlements` | Bearer token | none | `{entitlements[].feature.key, .hasAccess}` (key `vanity_subdomain`) — upgrade-gate side-call on a 4xx with no plan-gate error in the body | -| `GET` | `/v1/projects/{ref}` | Bearer token | none | `{ref, name, organization_id, organization_slug}` — `linkedProjectCache` fill, best-effort, skipped when `.temp/linked-project.json` exists, `.temp/project-ref` names another ref, or no token is available | +| Method | Path | Auth | Request body | Response (used fields) | +| ------ | ---------------------------------------------- | ------------ | ------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `GET` | `/v1/projects` | Bearer token | none | project list for the interactive picker — only when no ref comes from `--remote=<ref>`, `SUPABASE_PROJECT_ID` or `.temp/project-ref` and stdin is a TTY with interactive output | +| `GET` | `/v1/projects/{parent}/branches` | Bearer token | none | `[{id, name, project_ref, is_default}]` — only for `--remote=<branch>` (or the deprecated `--project-ref=<branch>`), and for a bare `--remote` followed by a word that could name a branch; `{parent}` is `SUPABASE_PROJECT_ID`, else the linked project | +| `POST` | `/v1/projects/{ref}/vanity-subdomain/activate` | Bearer token | `{ vanity_subdomain: string }` | `{ custom_domain: string }` | +| `GET` | `/v1/projects/{ref}` | Bearer token | none | `{organization_slug}` — upgrade-gate side-call on a 4xx with no plan-gate error in the body | +| `GET` | `/v1/organizations/{slug}/entitlements` | Bearer token | none | `{entitlements[].feature.key, .hasAccess}` (key `vanity_subdomain`) — upgrade-gate side-call on a 4xx with no plan-gate error in the body | +| `GET` | `/v1/projects/{ref}` | Bearer token | none | `{ref, name, organization_id, organization_slug}` — `linkedProjectCache` fill, best-effort, skipped when `.temp/linked-project.json` exists, `.temp/project-ref` names another ref, or no token is available | ## 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`, then the interactive project picker on a TTY) | -| `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 | +| 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 `--remote=<ref>` is unset; also the parent project that `--remote=<branch>` is resolved against | no (falls back to `supabase/.temp/project-ref`, then the interactive project picker on a TTY) | +| `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 -| Code | Condition | -| ---- | ----------------------------------------------------------------------------------------------------------------------------------------- | -| `0` | success | -| `1` | `--experimental` not passed and `SUPABASE_EXPERIMENTAL` unset (`ExperimentalRequiredError`) — checked before ref resolution/API/telemetry | -| `1` | project ref unresolved (`ProjectRefNotLinkedError` / `InvalidProjectRefError`) | -| `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 | +| Code | Condition | +| ---- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `0` | success | +| `1` | `--experimental` not passed and `SUPABASE_EXPERIMENTAL` unset (`ExperimentalRequiredError`) — checked before ref resolution/API/telemetry | +| `1` | project ref unresolved (`ProjectRefNotLinkedError`; `InvalidProjectRefError` for a malformed `SUPABASE_PROJECT_ID` or link-file ref) | +| `1` | invalid `--remote` selector — `--remote=<url>` (`TargetNeedsProjectError`), an empty value, `--remote` combined with `--project-ref`, or a branch the linked project does not have (`LinkBranchNotFoundError`) | +| `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 @@ -94,3 +96,15 @@ One `result` event with the full response object. - 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. +- Requires `--remote=<ref|branch>`, `SUPABASE_PROJECT_ID`, a linked project (`supabase/.temp/project-ref`) + or, on an interactive terminal, the project picker. A bare `--remote` targets the linked project. +- `--remote=<value>` is read as a project ref (20 lowercase letters), a branch of the linked + project (by name or UUID), or `true|yes|on|1` for the linked project. A database URL is rejected + (`supabase vanity-subdomains activate` takes a project, not a database); `false|no|off|0`, `--no-remote` and an + empty value fail with `Leave out --remote to use the linked project.` +- A bare `--remote` followed by a word that is a branch of the linked project fails before any + other work with a `Did you mean --remote=<word>?` hint, since the word may have been meant as + the flag's value. +- The deprecated `--project-ref` is an alias of `--remote=<ref>` (a non-ref value is read as a + branch name) and prints a deprecation warning on stderr that names the replacement; it cannot + be combined with `--remote=<value>`. 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 e8887d0d8c..19722d0926 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 @@ -2,11 +2,11 @@ ## Files Read -| Path | Format | When | -| ---------------------------------------------- | ------------------------- | -------------------------------------------------------------------------------------------------------------------- | -| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | -| `<workdir>/supabase/.temp/linked-project.json` | JSON | always, after the project ref resolves — `linkedProjectCache` checks it exists to decide whether to write | -| `<workdir>/supabase/.temp/project-ref` | plain text (project ref) | when `--project-ref` flag and `SUPABASE_PROJECT_ID` env are both unset; also by `linkedProjectCache` on a cache miss | +| Path | Format | When | +| ---------------------------------------------- | ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | +| `<workdir>/supabase/.temp/linked-project.json` | JSON | always, after the project ref resolves — `linkedProjectCache` checks it exists to decide whether to write | +| `<workdir>/supabase/.temp/project-ref` | plain text (project ref) | when `--remote=<ref>` and `SUPABASE_PROJECT_ID` env are both unset, and as the linked parent for `--remote=<branch>`; also by `linkedProjectCache` on a cache miss | ## Files Written @@ -17,33 +17,35 @@ ## API Routes -| Method | Path | Auth | Request body | Response (used fields) | -| ------ | -------------------------------------------------------- | ------------ | ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `GET` | `/v1/projects` | Bearer token | none | project list for the interactive picker — only when no ref comes from `--project-ref`, `SUPABASE_PROJECT_ID` or `.temp/project-ref` and stdin is a TTY with interactive output | -| `POST` | `/v1/projects/{ref}/vanity-subdomain/check-availability` | Bearer token | `{ vanity_subdomain: string }` | `{ available: boolean }` | -| `GET` | `/v1/projects/{ref}` | Bearer token | none | `{organization_slug}` — upgrade-gate side-call on a 4xx with no plan-gate error in the body | -| `GET` | `/v1/organizations/{slug}/entitlements` | Bearer token | none | `{entitlements[].feature.key, .hasAccess}` (key `vanity_subdomain`) — upgrade-gate side-call on a 4xx with no plan-gate error in the body | -| `GET` | `/v1/projects/{ref}` | Bearer token | none | `{ref, name, organization_id, organization_slug}` — `linkedProjectCache` fill, best-effort, skipped when `.temp/linked-project.json` exists, `.temp/project-ref` names another ref, or no token is available | +| Method | Path | Auth | Request body | Response (used fields) | +| ------ | -------------------------------------------------------- | ------------ | ------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `GET` | `/v1/projects` | Bearer token | none | project list for the interactive picker — only when no ref comes from `--remote=<ref>`, `SUPABASE_PROJECT_ID` or `.temp/project-ref` and stdin is a TTY with interactive output | +| `GET` | `/v1/projects/{parent}/branches` | Bearer token | none | `[{id, name, project_ref, is_default}]` — only for `--remote=<branch>` (or the deprecated `--project-ref=<branch>`), and for a bare `--remote` followed by a word that could name a branch; `{parent}` is `SUPABASE_PROJECT_ID`, else the linked project | +| `POST` | `/v1/projects/{ref}/vanity-subdomain/check-availability` | Bearer token | `{ vanity_subdomain: string }` | `{ available: boolean }` | +| `GET` | `/v1/projects/{ref}` | Bearer token | none | `{organization_slug}` — upgrade-gate side-call on a 4xx with no plan-gate error in the body | +| `GET` | `/v1/organizations/{slug}/entitlements` | Bearer token | none | `{entitlements[].feature.key, .hasAccess}` (key `vanity_subdomain`) — upgrade-gate side-call on a 4xx with no plan-gate error in the body | +| `GET` | `/v1/projects/{ref}` | Bearer token | none | `{ref, name, organization_id, organization_slug}` — `linkedProjectCache` fill, best-effort, skipped when `.temp/linked-project.json` exists, `.temp/project-ref` names another ref, or no token is available | ## 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`, then the interactive project picker on a TTY) | -| `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 `--remote=<ref>` is unset; also the parent project that `--remote=<branch>` is resolved against | no (falls back to `supabase/.temp/project-ref`, then the interactive project picker on a TTY) | +| `SUPABASE_EXPERIMENTAL` | enables `--experimental`-gated commands without the flag | no (pass `--experimental` instead; one of the two is required) | ## Exit Codes -| Code | Condition | -| ---- | ----------------------------------------------------------------------------------------------------------------------------------------- | -| `0` | success | -| `1` | `--experimental` not passed and `SUPABASE_EXPERIMENTAL` unset (`ExperimentalRequiredError`) — checked before ref resolution/API/telemetry | -| `1` | project ref unresolved (`ProjectRefNotLinkedError` / `InvalidProjectRefError`) | -| `1` | `--desired-subdomain` omitted (`DesiredSubdomainRequiredError`) — checked after gate/login/ref resolution; telemetry still fires | -| `1` | API non-2xx (`VanitySubdomainsCheckUnexpectedStatusError`) | -| `1` | transport failure (`VanitySubdomainsCheckNetworkError`) | +| Code | Condition | +| ---- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `0` | success | +| `1` | `--experimental` not passed and `SUPABASE_EXPERIMENTAL` unset (`ExperimentalRequiredError`) — checked before ref resolution/API/telemetry | +| `1` | project ref unresolved (`ProjectRefNotLinkedError`; `InvalidProjectRefError` for a malformed `SUPABASE_PROJECT_ID` or link-file ref) | +| `1` | invalid `--remote` selector — `--remote=<url>` (`TargetNeedsProjectError`), an empty value, `--remote` combined with `--project-ref`, or a branch the linked project does not have (`LinkBranchNotFoundError`) | +| `1` | `--desired-subdomain` omitted (`DesiredSubdomainRequiredError`) — checked after gate/login/ref resolution; telemetry still fires | +| `1` | API non-2xx (`VanitySubdomainsCheckUnexpectedStatusError`) | +| `1` | transport failure (`VanitySubdomainsCheckNetworkError`) | ## Telemetry Events Fired @@ -91,3 +93,15 @@ One `result` event with the full response object. 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. +- Requires `--remote=<ref|branch>`, `SUPABASE_PROJECT_ID`, a linked project (`supabase/.temp/project-ref`) + or, on an interactive terminal, the project picker. A bare `--remote` targets the linked project. +- `--remote=<value>` is read as a project ref (20 lowercase letters), a branch of the linked + project (by name or UUID), or `true|yes|on|1` for the linked project. A database URL is rejected + (`supabase vanity-subdomains check-availability` takes a project, not a database); `false|no|off|0`, `--no-remote` and an + empty value fail with `Leave out --remote to use the linked project.` +- A bare `--remote` followed by a word that is a branch of the linked project fails before any + other work with a `Did you mean --remote=<word>?` hint, since the word may have been meant as + the flag's value. +- The deprecated `--project-ref` is an alias of `--remote=<ref>` (a non-ref value is read as a + branch name) and prints a deprecation warning on stderr that names the replacement; it cannot + be combined with `--remote=<value>`. 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 75fc10bdca..7316e40983 100644 --- a/apps/cli/src/commands/vanity-subdomains/delete/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/vanity-subdomains/delete/SIDE_EFFECTS.md @@ -2,11 +2,11 @@ ## Files Read -| Path | Format | When | -| ---------------------------------------------- | ------------------------- | -------------------------------------------------------------------------------------------------------------------- | -| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | -| `<workdir>/supabase/.temp/linked-project.json` | JSON | always, after the project ref resolves — `linkedProjectCache` checks it exists to decide whether to write | -| `<workdir>/supabase/.temp/project-ref` | plain text (project ref) | when `--project-ref` flag and `SUPABASE_PROJECT_ID` env are both unset; also by `linkedProjectCache` on a cache miss | +| Path | Format | When | +| ---------------------------------------------- | ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | +| `<workdir>/supabase/.temp/linked-project.json` | JSON | always, after the project ref resolves — `linkedProjectCache` checks it exists to decide whether to write | +| `<workdir>/supabase/.temp/project-ref` | plain text (project ref) | when `--remote=<ref>` and `SUPABASE_PROJECT_ID` env are both unset, and as the linked parent for `--remote=<branch>`; also by `linkedProjectCache` on a cache miss | ## Files Written @@ -17,33 +17,35 @@ ## API Routes -| Method | Path | Auth | Request body | Response (used fields) | -| -------- | ------------------------------------- | ------------ | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `GET` | `/v1/projects` | Bearer token | none | project list for the interactive picker — only when no ref comes from `--project-ref`, `SUPABASE_PROJECT_ID` or `.temp/project-ref` and stdin is a TTY with interactive output | -| `DELETE` | `/v1/projects/{ref}/vanity-subdomain` | Bearer token | none | none | -| `GET` | `/v1/projects/{ref}` | Bearer token | none | `{ref, name, organization_id, organization_slug}` — `linkedProjectCache` fill, best-effort, skipped when `.temp/linked-project.json` exists, `.temp/project-ref` names another ref, or no token is available | +| Method | Path | Auth | Request body | Response (used fields) | +| -------- | ------------------------------------- | ------------ | ------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `GET` | `/v1/projects` | Bearer token | none | project list for the interactive picker — only when no ref comes from `--remote=<ref>`, `SUPABASE_PROJECT_ID` or `.temp/project-ref` and stdin is a TTY with interactive output | +| `GET` | `/v1/projects/{parent}/branches` | Bearer token | none | `[{id, name, project_ref, is_default}]` — only for `--remote=<branch>` (or the deprecated `--project-ref=<branch>`), and for a bare `--remote` followed by a word that could name a branch; `{parent}` is `SUPABASE_PROJECT_ID`, else the linked project | +| `DELETE` | `/v1/projects/{ref}/vanity-subdomain` | Bearer token | none | none | +| `GET` | `/v1/projects/{ref}` | Bearer token | none | `{ref, name, organization_id, organization_slug}` — `linkedProjectCache` fill, best-effort, skipped when `.temp/linked-project.json` exists, `.temp/project-ref` names another ref, or no token is available | ## 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`, then the interactive project picker on a TTY) | -| `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 | +| 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 `--remote=<ref>` is unset; also the parent project that `--remote=<branch>` is resolved against | no (falls back to `supabase/.temp/project-ref`, then the interactive project picker on a TTY) | +| `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 -| Code | Condition | -| ---- | ----------------------------------------------------------------------------------------------------------------------------------------- | -| `0` | success | -| `1` | `--experimental` not passed and `SUPABASE_EXPERIMENTAL` unset (`ExperimentalRequiredError`) — checked before ref resolution/API/telemetry | -| `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 | +| Code | Condition | +| ---- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `0` | success | +| `1` | `--experimental` not passed and `SUPABASE_EXPERIMENTAL` unset (`ExperimentalRequiredError`) — checked before ref resolution/API/telemetry | +| `1` | project ref unresolved (`ProjectRefNotLinkedError`; `InvalidProjectRefError` for a malformed `SUPABASE_PROJECT_ID` or link-file ref) | +| `1` | invalid `--remote` selector — `--remote=<url>` (`TargetNeedsProjectError`), an empty value, `--remote` combined with `--project-ref`, or a branch the linked project does not have (`LinkBranchNotFoundError`) | +| `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 @@ -83,3 +85,15 @@ One `result` event when the `--output` flag is unset. - 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. +- Requires `--remote=<ref|branch>`, `SUPABASE_PROJECT_ID`, a linked project (`supabase/.temp/project-ref`) + or, on an interactive terminal, the project picker. A bare `--remote` targets the linked project. +- `--remote=<value>` is read as a project ref (20 lowercase letters), a branch of the linked + project (by name or UUID), or `true|yes|on|1` for the linked project. A database URL is rejected + (`supabase vanity-subdomains delete` takes a project, not a database); `false|no|off|0`, `--no-remote` and an + empty value fail with `Leave out --remote to use the linked project.` +- A bare `--remote` followed by a word that is a branch of the linked project fails before any + other work with a `Did you mean --remote=<word>?` hint, since the word may have been meant as + the flag's value. +- The deprecated `--project-ref` is an alias of `--remote=<ref>` (a non-ref value is read as a + branch name) and prints a deprecation warning on stderr that names the replacement; it cannot + be combined with `--remote=<value>`. 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 bae52b7d87..626f59c8b5 100644 --- a/apps/cli/src/commands/vanity-subdomains/get/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/vanity-subdomains/get/SIDE_EFFECTS.md @@ -2,11 +2,11 @@ ## Files Read -| Path | Format | When | -| ---------------------------------------------- | ------------------------- | -------------------------------------------------------------------------------------------------------------------- | -| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | -| `<workdir>/supabase/.temp/linked-project.json` | JSON | always, after the project ref resolves — `linkedProjectCache` checks it exists to decide whether to write | -| `<workdir>/supabase/.temp/project-ref` | plain text (project ref) | when `--project-ref` flag and `SUPABASE_PROJECT_ID` env are both unset; also by `linkedProjectCache` on a cache miss | +| Path | Format | When | +| ---------------------------------------------- | ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | +| `<workdir>/supabase/.temp/linked-project.json` | JSON | always, after the project ref resolves — `linkedProjectCache` checks it exists to decide whether to write | +| `<workdir>/supabase/.temp/project-ref` | plain text (project ref) | when `--remote=<ref>` and `SUPABASE_PROJECT_ID` env are both unset, and as the linked parent for `--remote=<branch>`; also by `linkedProjectCache` on a cache miss | ## Files Written @@ -17,30 +17,32 @@ ## API Routes -| Method | Path | Auth | Request body | Response (used fields) | -| ------ | ------------------------------------- | ------------ | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `GET` | `/v1/projects` | Bearer token | none | project list for the interactive picker — only when no ref comes from `--project-ref`, `SUPABASE_PROJECT_ID` or `.temp/project-ref` and stdin is a TTY with interactive output | -| `GET` | `/v1/projects/{ref}/vanity-subdomain` | Bearer token | none | `{ status: string, custom_domain?: string }` | -| `GET` | `/v1/projects/{ref}` | Bearer token | none | `{ref, name, organization_id, organization_slug}` — `linkedProjectCache` fill, best-effort, skipped when `.temp/linked-project.json` exists, `.temp/project-ref` names another ref, or no token is available | +| Method | Path | Auth | Request body | Response (used fields) | +| ------ | ------------------------------------- | ------------ | ------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `GET` | `/v1/projects` | Bearer token | none | project list for the interactive picker — only when no ref comes from `--remote=<ref>`, `SUPABASE_PROJECT_ID` or `.temp/project-ref` and stdin is a TTY with interactive output | +| `GET` | `/v1/projects/{parent}/branches` | Bearer token | none | `[{id, name, project_ref, is_default}]` — only for `--remote=<branch>` (or the deprecated `--project-ref=<branch>`), and for a bare `--remote` followed by a word that could name a branch; `{parent}` is `SUPABASE_PROJECT_ID`, else the linked project | +| `GET` | `/v1/projects/{ref}/vanity-subdomain` | Bearer token | none | `{ status: string, custom_domain?: string }` | +| `GET` | `/v1/projects/{ref}` | Bearer token | none | `{ref, name, organization_id, organization_slug}` — `linkedProjectCache` fill, best-effort, skipped when `.temp/linked-project.json` exists, `.temp/project-ref` names another ref, or no token is available | ## 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`, then the interactive project picker on a TTY) | -| `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 `--remote=<ref>` is unset; also the parent project that `--remote=<branch>` is resolved against | no (falls back to `supabase/.temp/project-ref`, then the interactive project picker on a TTY) | +| `SUPABASE_EXPERIMENTAL` | enables `--experimental`-gated commands without the flag | no (pass `--experimental` instead; one of the two is required) | ## Exit Codes -| Code | Condition | -| ---- | ----------------------------------------------------------------------------------------------------------------------------------------- | -| `0` | success | -| `1` | `--experimental` not passed and `SUPABASE_EXPERIMENTAL` unset (`ExperimentalRequiredError`) — checked before ref resolution/API/telemetry | -| `1` | project ref unresolved (`ProjectRefNotLinkedError` / `InvalidProjectRefError`) | -| `1` | API non-2xx (`VanitySubdomainsGetUnexpectedStatusError`) | -| `1` | transport failure (`VanitySubdomainsGetNetworkError`) | +| Code | Condition | +| ---- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `0` | success | +| `1` | `--experimental` not passed and `SUPABASE_EXPERIMENTAL` unset (`ExperimentalRequiredError`) — checked before ref resolution/API/telemetry | +| `1` | project ref unresolved (`ProjectRefNotLinkedError`; `InvalidProjectRefError` for a malformed `SUPABASE_PROJECT_ID` or link-file ref) | +| `1` | invalid `--remote` selector — `--remote=<url>` (`TargetNeedsProjectError`), an empty value, `--remote` combined with `--project-ref`, or a branch the linked project does not have (`LinkBranchNotFoundError`) | +| `1` | API non-2xx (`VanitySubdomainsGetUnexpectedStatusError`) | +| `1` | transport failure (`VanitySubdomainsGetNetworkError`) | ## Telemetry Events Fired @@ -83,3 +85,15 @@ One `result` event with the full response object. 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. +- Requires `--remote=<ref|branch>`, `SUPABASE_PROJECT_ID`, a linked project (`supabase/.temp/project-ref`) + or, on an interactive terminal, the project picker. A bare `--remote` targets the linked project. +- `--remote=<value>` is read as a project ref (20 lowercase letters), a branch of the linked + project (by name or UUID), or `true|yes|on|1` for the linked project. A database URL is rejected + (`supabase vanity-subdomains get` takes a project, not a database); `false|no|off|0`, `--no-remote` and an + empty value fail with `Leave out --remote to use the linked project.` +- A bare `--remote` followed by a word that is a branch of the linked project fails before any + other work with a `Did you mean --remote=<word>?` hint, since the word may have been meant as + the flag's value. +- The deprecated `--project-ref` is an alias of `--remote=<ref>` (a non-ref value is read as a + branch name) and prints a deprecation warning on stderr that names the replacement; it cannot + be combined with `--remote=<value>`. From 1ac298689bb9a7adb27f2696bfb6e1d1a58e1b20 Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Mon, 19 Oct 2026 11:55:43 +0100 Subject: [PATCH 119/291] test(cli): cover gen types through --remote with a warning check per alias --- .../gen/types/types.integration.test.ts | 602 ++++++++++++++---- 1 file changed, 470 insertions(+), 132 deletions(-) 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 222c149073..285c027b69 100644 --- a/apps/cli/src/commands/gen/types/types.integration.test.ts +++ b/apps/cli/src/commands/gen/types/types.integration.test.ts @@ -161,6 +161,21 @@ function defaultFlags(overrides: Partial<GenTypesFlags> = {}): GenTypesFlags { }; } +const BRANCH_PROJECT_REF = "branchprojectrefabcd"; + +const branchRow = (name: string): Record<string, unknown> => ({ + id: "11111111-2222-4333-8444-555555555555", + name, + project_ref: BRANCH_PROJECT_REF, + parent_project_ref: VALID_REF, + is_default: false, + persistent: false, + status: "MIGRATIONS_PASSED", + created_at: "2026-05-27T01:02:03Z", + updated_at: "2026-05-27T01:02:04Z", + with_data: true, +}); + function statusApiError(status: number, body: string) { const request = HttpClientRequest.get("https://api.supabase.test/v1/projects/ref"); const response = HttpClientResponse.fromWeb( @@ -423,6 +438,7 @@ const setup = Effect.fnUntraced(function* ( readonly poolerFallbackFails?: boolean; readonly generator?: ReturnType<typeof mockGenTypesGenerator>; readonly generatorOutput?: string; + readonly branches?: ReadonlyArray<Record<string, unknown>>; } = {}, ) { const workdir = opts.workdir ?? (yield* makeWorkdir("supabase-gen-types-")); @@ -501,6 +517,7 @@ const setup = Effect.fnUntraced(function* ( pool_mode: "transaction", }, ])), + listAllBranches: () => Effect.succeed(opts.branches ?? []), createLoginRole: opts.createLoginRole ?? (() => @@ -583,9 +600,9 @@ describe("gen types", () => { const { layer, out, api, linkedProjectCache, telemetry } = yield* setup({ projectTypes: "export type Database = {};", }); - yield* genTypes(defaultFlags({ projectRef: Option.some(VALID_REF) })).pipe( - Effect.provide(layer), - ); + yield* genTypes( + defaultFlags({ remote: Option.some({ _tag: "Ref", ref: VALID_REF } as const) }), + ).pipe(Effect.provide(layer)); expect(out.stdoutText).toBe("export type Database = {};"); expect(out.stderrText).toContain(unverifiedTargetBanner()); @@ -606,7 +623,9 @@ describe("gen types", () => { projectId: Option.some(VALID_REF), projectTypes: "export type Database = {};", }); - yield* genTypes(defaultFlags({ linked: true })).pipe(Effect.provide(layer)); + yield* genTypes(defaultFlags({ remote: Option.some({ _tag: "Linked" } as const) })).pipe( + Effect.provide(layer), + ); expect(out.stdoutText).toBe("export type Database = {};"); expect(out.stderrText).toContain(unverifiedTargetBanner()); @@ -628,7 +647,7 @@ describe("gen types", () => { }); yield* genTypes( defaultFlags({ - projectRef: Option.some(VALID_REF), + remote: Option.some({ _tag: "Ref", ref: VALID_REF } as const), schema: ["auth", "storage"], }), ).pipe(Effect.provide(layer)); @@ -655,7 +674,7 @@ describe("gen types", () => { }); yield* genTypes( defaultFlags({ - projectRef: Option.some(VALID_REF), + remote: Option.some({ _tag: "Ref", ref: VALID_REF } as const), }), ).pipe(Effect.provide(layer)); @@ -678,7 +697,9 @@ describe("gen types", () => { projectId: Option.some(VALID_REF), projectTypes: "ok", }); - yield* genTypes(defaultFlags({ linked: true })).pipe(Effect.provide(layer)); + yield* genTypes(defaultFlags({ remote: Option.some({ _tag: "Linked" } as const) })).pipe( + Effect.provide(layer), + ); expect(api.requests[0]).toEqual({ method: "generateTypescriptTypes", @@ -705,10 +726,9 @@ describe("gen types", () => { projectId: Option.some(VALID_REF), projectTypes: "ok", }); - const exit = yield* genTypes(defaultFlags({ projectRef: Option.some(VALID_REF) })).pipe( - Effect.provide(layer), - Effect.exit, - ); + const exit = yield* genTypes( + defaultFlags({ remote: Option.some({ _tag: "Ref", ref: VALID_REF } as const) }), + ).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { @@ -741,9 +761,9 @@ describe("gen types", () => { projectId: Option.some(VALID_REF), projectTypes: "ok", }); - yield* genTypes(defaultFlags({ projectRef: Option.some(VALID_REF) })).pipe( - Effect.provide(layer), - ); + yield* genTypes( + defaultFlags({ remote: Option.some({ _tag: "Ref", ref: VALID_REF } as const) }), + ).pipe(Effect.provide(layer)); expect(api.requests[0]).toEqual({ method: "generateTypescriptTypes", @@ -763,10 +783,9 @@ describe("gen types", () => { explicitWorkdir: true, projectId: Option.some(VALID_REF), }); - const exit = yield* genTypes(defaultFlags({ projectRef: Option.some(VALID_REF) })).pipe( - Effect.provide(layer), - Effect.exit, - ); + const exit = yield* genTypes( + defaultFlags({ remote: Option.some({ _tag: "Ref", ref: VALID_REF } as const) }), + ).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { @@ -788,10 +807,9 @@ describe("gen types", () => { skipConfig: true, projectId: Option.some(VALID_REF), }); - const exit = yield* genTypes(defaultFlags({ projectRef: Option.some(VALID_REF) })).pipe( - Effect.provide(layer), - Effect.exit, - ); + const exit = yield* genTypes( + defaultFlags({ remote: Option.some({ _tag: "Ref", ref: VALID_REF } as const) }), + ).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { @@ -835,9 +853,9 @@ describe("gen types", () => { 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({ projectRef: Option.some(VALID_REF) })).pipe( - Effect.provide(layer), - ); + yield* genTypes( + defaultFlags({ remote: Option.some({ _tag: "Ref", ref: VALID_REF } as const) }), + ).pipe(Effect.provide(layer)); expect(api.requests[0]).toEqual({ method: "generateTypescriptTypes", @@ -856,7 +874,9 @@ describe("gen types", () => { projectId: Option.some(VALID_REF), projectTypes: "ok", }); - yield* genTypes(defaultFlags({ linked: true })).pipe(Effect.provide(layer)); + yield* genTypes(defaultFlags({ remote: Option.some({ _tag: "Linked" } as const) })).pipe( + Effect.provide(layer), + ); expect(api.requests[0]).toEqual({ method: "generateTypescriptTypes", @@ -872,9 +892,9 @@ describe("gen types", () => { projectId: Option.some(VALID_REF), projectTypes: "ok", }); - yield* genTypes(defaultFlags({ projectRef: Option.some(VALID_REF) })).pipe( - Effect.provide(layer), - ); + yield* genTypes( + defaultFlags({ remote: Option.some({ _tag: "Ref", ref: VALID_REF } as const) }), + ).pipe(Effect.provide(layer)); expect(api.requests).toHaveLength(1); }).pipe(Effect.provide(BunServices.layer)), @@ -886,7 +906,9 @@ describe("gen types", () => { projectId: Option.some(VALID_REF), projectTypes: "ok", }); - yield* genTypes(defaultFlags({ linked: true, schema: ["auth"] })).pipe(Effect.provide(layer)); + yield* genTypes( + defaultFlags({ remote: Option.some({ _tag: "Linked" } as const), schema: ["auth"] }), + ).pipe(Effect.provide(layer)); expect(api.requests[0]).toEqual({ method: "generateTypescriptTypes", input: { ref: VALID_REF, included_schemas: "auth" }, @@ -911,7 +933,9 @@ describe("gen types", () => { projectId: Option.some(VALID_REF), projectTypes: "ok", }); - yield* genTypes(defaultFlags({ linked: true })).pipe(Effect.provide(layer)); + yield* genTypes(defaultFlags({ remote: Option.some({ _tag: "Linked" } as const) })).pipe( + Effect.provide(layer), + ); expect(api.requests[0]).toEqual({ method: "generateTypescriptTypes", input: { ref: VALID_REF, included_schemas: "public,auth,storage" }, @@ -924,9 +948,9 @@ describe("gen types", () => { withApiSchemas( Effect.gen(function* () { const { layer, api } = yield* setup({ skipConfig: true, projectTypes: "ok" }); - yield* genTypes(defaultFlags({ projectRef: Option.some(VALID_REF) })).pipe( - Effect.provide(layer), - ); + yield* genTypes( + defaultFlags({ remote: Option.some({ _tag: "Ref", ref: VALID_REF } as const) }), + ).pipe(Effect.provide(layer)); expect(api.requests[0]).toEqual({ method: "generateTypescriptTypes", input: { ref: VALID_REF, included_schemas: "public,auth,storage" }, @@ -961,9 +985,11 @@ describe("gen types", () => { ); const { layer, generator } = yield* setup({ workdir, - args: ["gen", "types", "--db-url", dbUrl], + args: ["gen", "types", `--remote=${dbUrl}`], }); - yield* genTypes(defaultFlags({ dbUrl: Option.some(dbUrl) })).pipe(Effect.provide(layer)); + yield* genTypes( + defaultFlags({ remote: Option.some({ _tag: "DbUrl", url: dbUrl } as const) }), + ).pipe(Effect.provide(layer)); expect(generator.calls[0]?.includedSchemas).toEqual(["public", "auth", "storage"]); }), ).pipe(Effect.provide(BunServices.layer)), @@ -1079,6 +1105,136 @@ describe("gen types", () => { expect(generator.calls).toHaveLength(0); }).pipe(Effect.provide(BunServices.layer)), ); + it.effect("generates for the project of the branch --remote=<branch> names", () => + Effect.gen(function* () { + const { layer, api, linkedProjectCache } = yield* setup({ + projectId: Option.some(VALID_REF), + branches: [branchRow("feature-x")], + projectTypes: "ok", + }); + yield* genTypes( + defaultFlags({ remote: Option.some({ _tag: "Branch", name: "feature-x" } as const) }), + ).pipe(Effect.provide(layer)); + + expect(api.requests).toEqual([ + { method: "listAllBranches", input: { ref: VALID_REF } }, + { + method: "generateTypescriptTypes", + input: { ref: BRANCH_PROJECT_REF, included_schemas: "public" }, + }, + ]); + expect(linkedProjectCache.cachedRef).toBe(BRANCH_PROJECT_REF); + }).pipe(Effect.provide(BunServices.layer)), + ); + + it.effect("fails with the branches that exist when --remote=<branch> matches none", () => + Effect.gen(function* () { + const { layer, api, generator } = yield* setup({ + projectId: Option.some(VALID_REF), + branches: [branchRow("feature-x")], + }); + const error = yield* genTypes( + defaultFlags({ remote: Option.some({ _tag: "Branch", name: "feature-y" } as const) }), + ).pipe(Effect.provide(layer), Effect.flip); + + expect(error).toMatchObject({ + _tag: "LinkBranchNotFoundError", + message: expect.stringContaining("Available branches: feature-x"), + }); + expect(api.requests.map(({ method }) => method)).toEqual(["listAllBranches"]); + expect(generator.calls).toHaveLength(0); + }).pipe(Effect.provide(BunServices.layer)), + ); + + it.effect("reads the database of --remote=<url> without a Management API call", () => + Effect.gen(function* () { + const dbUrl = "postgresql://postgres:postgres@127.0.0.1:5432/postgres"; + const { layer, api, dbConfig, out } = yield* setup({ + args: ["gen", "types", `--remote=${dbUrl}`], + }); + yield* genTypes( + defaultFlags({ remote: Option.some({ _tag: "DbUrl", url: dbUrl } as const) }), + ).pipe(Effect.provide(layer)); + + expect(dbConfig.resolves.map(({ target }) => target.kind)).toEqual(["db-url"]); + expect(api.requests).toEqual([]); + expect(out.messages.filter((message) => message.type === "warn")).toEqual([]); + }).pipe(Effect.provide(BunServices.layer)), + ); + + describe("deprecated selectors", () => { + const dbUrl = "postgresql://postgres:postgres@127.0.0.1:5432/postgres"; + const FLAG_REF = "flagflagflagflagflag"; + type Context = Effect.Success<ReturnType<typeof setup>>; + const cases: ReadonlyArray<{ + readonly alias: string; + readonly args: ReadonlyArray<string>; + readonly flags: Partial<GenTypesFlags>; + readonly warning: string; + readonly reached: (context: Context) => void; + }> = [ + { + alias: "--linked", + args: ["gen", "types", "--linked"], + flags: { linked: true }, + warning: + "--linked is deprecated and will be removed in v4. Use --remote instead: supabase gen types --remote", + reached: ({ api }) => + expect(api.requests).toContainEqual({ + method: "generateTypescriptTypes", + input: { ref: VALID_REF, included_schemas: "public" }, + }), + }, + { + alias: "--project-ref", + args: ["gen", "types", "--project-ref", FLAG_REF], + flags: { projectRef: Option.some(FLAG_REF) }, + warning: `--project-ref is deprecated and will be removed in v4. Use --remote instead: supabase gen types --remote=${FLAG_REF}`, + reached: ({ api }) => + expect(api.requests).toContainEqual({ + method: "generateTypescriptTypes", + input: { ref: FLAG_REF, included_schemas: "public" }, + }), + }, + { + alias: "--db-url", + args: ["gen", "types", "--db-url", dbUrl], + flags: { dbUrl: Option.some(dbUrl) }, + warning: + "--db-url is deprecated and will be removed in v4. Use --remote instead: supabase gen types --remote=<db-url>", + reached: ({ dbConfig }) => + expect(dbConfig.resolves.map(({ target }) => target.kind)).toEqual(["db-url"]), + }, + { + alias: "--local", + args: ["gen", "types", "--local"], + flags: { local: true }, + warning: + "--local isn't needed (local is the default) and will be removed in v4: supabase gen types", + reached: ({ generator }) => expect(generator.calls[0]?.isLocal).toBe(true), + }, + ]; + + for (const testCase of cases) { + it.effect(`${testCase.alias} still selects its target and warns`, () => + Effect.gen(function* () { + const context = yield* setup({ + args: testCase.args, + projectId: Option.some(VALID_REF), + projectTypes: "ok", + }); + yield* genTypes(defaultFlags(testCase.flags)).pipe(Effect.provide(context.layer)); + + testCase.reached(context); + expect( + context.out.messages + .filter((message) => message.type === "warn") + .map((message) => message.message), + ).toEqual([testCase.warning]); + }).pipe(Effect.provide(BunServices.layer)), + ); + } + }); }); it.effect("applies --query-timeout to the default local database", () => @@ -1101,7 +1257,7 @@ describe("gen types", () => { }); const exit = yield* genTypes( defaultFlags({ - projectRef: Option.some(VALID_REF), + remote: Option.some({ _tag: "Ref", ref: VALID_REF } as const), }), ).pipe(Effect.provide(layer), Effect.exit); @@ -1124,6 +1280,26 @@ describe("gen types", () => { ); describe("Flag mutex groups and argv-scan precedence", () => { + it.effect("rejects combining --remote and --local", () => + Effect.gen(function* () { + const { layer, telemetry, api, generator } = yield* setup({ + args: ["gen", "types", "--remote", "--local"], + }); + const error = yield* genTypes( + defaultFlags({ remote: Option.some({ _tag: "Linked" } as const), local: true }), + ).pipe(Effect.provide(layer), Effect.flip); + + expect(error).toMatchObject({ + _tag: "TargetSelectorConflictError", + message: + "Only one of --remote, --linked, --db-url and --local can be set; got --remote and --local.", + }); + expect(api.requests).toEqual([]); + expect(generator.calls).toHaveLength(0); + expect(telemetry.flushed).toBe(true); + }).pipe(Effect.provide(BunServices.layer)), + ); + it.effect("rejects combining --local and --linked", () => Effect.gen(function* () { const { layer, telemetry } = yield* setup({ @@ -1153,13 +1329,16 @@ describe("gen types", () => { it.effect("does not misdetect a mutex flag consumed as -s's value", () => Effect.gen(function* () { const { layer, api } = yield* setup({ - args: ["gen", "types", "--linked", "-s", "--query-timeout"], + args: ["gen", "types", "--remote", "-s", "--query-timeout"], projectId: Option.some(VALID_REF), projectTypes: "ok", }); - yield* genTypes(defaultFlags({ linked: true, schema: ["--query-timeout"] })).pipe( - Effect.provide(layer), - ); + yield* genTypes( + defaultFlags({ + remote: Option.some({ _tag: "Linked" } as const), + schema: ["--query-timeout"], + }), + ).pipe(Effect.provide(layer)); expect(api.requests[0]).toEqual({ method: "generateTypescriptTypes", @@ -1168,14 +1347,14 @@ describe("gen types", () => { }).pipe(Effect.provide(BunServices.layer)), ); - it.effect("rejects --swift-access-control with --linked (mutex group)", () => + it.effect("rejects --swift-access-control with --remote (mutex group)", () => Effect.gen(function* () { const { layer } = yield* setup({ - args: ["gen", "types", "--linked", "--swift-access-control", "public", "--lang", "swift"], + args: ["gen", "types", "--remote", "--swift-access-control", "public", "--lang", "swift"], }); const exit = yield* genTypes( defaultFlags({ - linked: true, + remote: Option.some({ _tag: "Linked" } as const), lang: "swift", "swift-access-control": Option.some("public"), }), @@ -1184,7 +1363,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 [remote linked project-ref swift-access-control] are set none of the others can be; [linked swift-access-control] were all set", + "if any flags in the group [remote linked project-ref swift-access-control] are set none of the others can be; [remote swift-access-control] were all set", ); } }).pipe(Effect.provide(BunServices.layer)), @@ -1349,21 +1528,20 @@ describe("gen types", () => { }).pipe(Effect.provide(BunServices.layer)), ); - it.effect("rejects --query-timeout with --linked (mutex group)", () => + it.effect("rejects --query-timeout with --remote (mutex group)", () => Effect.gen(function* () { const { layer } = yield* setup({ - args: ["gen", "types", "--linked", "--query-timeout", "20s"], + args: ["gen", "types", "--remote", "--query-timeout", "20s"], projectId: Option.some(VALID_REF), }); - const exit = yield* genTypes(defaultFlags({ linked: true, queryTimeout: "20s" })).pipe( - Effect.provide(layer), - Effect.exit, - ); + const exit = yield* genTypes( + defaultFlags({ remote: Option.some({ _tag: "Linked" } as const), 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 [remote linked project-ref query-timeout] are set none of the others can be; [linked query-timeout] were all set", + "if any flags in the group [remote linked project-ref query-timeout] are set none of the others can be; [query-timeout remote] were all set", ); } }).pipe(Effect.provide(BunServices.layer)), @@ -1389,12 +1567,11 @@ describe("gen types", () => { it.effect("fails on an invalid --query-timeout before any flag guard runs", () => Effect.gen(function* () { const { layer, telemetry } = yield* setup({ - args: ["gen", "types", "--linked", "--query-timeout", "bogus"], + args: ["gen", "types", "--remote", "--query-timeout", "bogus"], }); - const exit = yield* genTypes(defaultFlags({ linked: true, queryTimeout: "bogus" })).pipe( - Effect.provide(layer), - Effect.exit, - ); + const exit = yield* genTypes( + defaultFlags({ remote: Option.some({ _tag: "Linked" } as const), queryTimeout: "bogus" }), + ).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { @@ -1492,11 +1669,13 @@ 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", "--linked", "--"], + args: ["gen", "types", "--remote", "--"], projectId: Option.some(VALID_REF), projectTypes: "ok", }); - yield* genTypes(defaultFlags({ linked: true })).pipe(Effect.provide(layer)); + yield* genTypes(defaultFlags({ remote: Option.some({ _tag: "Linked" } as const) })).pipe( + Effect.provide(layer), + ); expect(api.requests).toHaveLength(1); }).pipe(Effect.provide(BunServices.layer)), ); @@ -1604,7 +1783,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-ref", VALID_REF], + args: ["gen", "types", "--lang", scenario.lang, `--remote=${VALID_REF}`], generatorOutput: scenario.stdout, dbConfigResolve: (input) => Effect.succeed( @@ -1632,7 +1811,7 @@ describe("gen types", () => { }); yield* genTypes( defaultFlags({ - projectRef: Option.some(VALID_REF), + remote: Option.some({ _tag: "Ref", ref: VALID_REF } as const), lang: scenario.lang, }), ).pipe(Effect.provide(layer)); @@ -1676,7 +1855,7 @@ describe("gen types", () => { yield* writeTempFile(workdir, "project-ref", VALID_REF); const { layer, dbConfig } = yield* setup({ workdir, - args: ["gen", "types", "--lang", "go", "--linked"], + args: ["gen", "types", "--lang", "go", "--remote"], dbConfigResolve: () => Effect.succeed( remoteResolvedConfig({ @@ -1688,7 +1867,9 @@ describe("gen types", () => { }), ), }); - yield* genTypes(defaultFlags({ linked: true, lang: "go" })).pipe(Effect.provide(layer)); + yield* genTypes( + defaultFlags({ remote: Option.some({ _tag: "Linked" } as const), lang: "go" }), + ).pipe(Effect.provide(layer)); expect(dbConfig.resolves).toHaveLength(1); expect(dbConfig.resolves[0]?.target).toEqual({ @@ -1703,7 +1884,7 @@ describe("gen types", () => { 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-ref", VALID_REF], + args: ["gen", "types", "--lang", "go", `--remote=${VALID_REF}`], dbConfigResolve: () => Effect.succeed( remoteResolvedConfig({ @@ -1718,7 +1899,7 @@ describe("gen types", () => { }); yield* genTypes( defaultFlags({ - projectRef: Option.some(VALID_REF), + remote: Option.some({ _tag: "Ref", ref: VALID_REF } as const), lang: "go", }), ).pipe(Effect.provide(layer)); @@ -1781,11 +1962,11 @@ describe("gen types", () => { ); const { layer, generator } = yield* setup({ workdir, - args: ["gen", "types", "--lang", "go", "--project-ref", VALID_REF], + args: ["gen", "types", "--lang", "go", `--remote=${VALID_REF}`], }); yield* genTypes( defaultFlags({ - projectRef: Option.some(VALID_REF), + remote: Option.some({ _tag: "Ref", ref: VALID_REF } as const), lang: "go", }), ).pipe(Effect.provide(layer)); @@ -1816,11 +1997,11 @@ describe("gen types", () => { const { layer, generator } = yield* setup({ workdir, projectId: Option.some(VALID_REF), - args: ["gen", "types", "--lang", "go", "--linked"], + args: ["gen", "types", "--lang", "go", "--remote"], }); yield* genTypes( defaultFlags({ - linked: true, + remote: Option.some({ _tag: "Linked" } as const), lang: "go", }), ).pipe(Effect.provide(layer)); @@ -1834,7 +2015,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-ref", VALID_REF], + args: ["gen", "types", "--lang", "python", `--remote=${VALID_REF}`], generatorOutput: "class PublicMovies(BaseModel):", getProject: () => Effect.fail(statusApiError(404, `{"message":"Preview branch not found"}`)), @@ -1858,7 +2039,7 @@ describe("gen types", () => { }); yield* genTypes( defaultFlags({ - projectRef: Option.some(VALID_REF), + remote: Option.some({ _tag: "Ref", ref: VALID_REF } as const), lang: "python", }), ).pipe(Effect.provide(layer)); @@ -1885,7 +2066,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-ref", VALID_REF], + args: ["gen", "types", "--lang", "python", `--remote=${VALID_REF}`], generatorOutput: "class PublicMovies(BaseModel):", getProject: () => Effect.fail(statusApiError(404, `{"message":"Not found"}`)), getABranchConfig: ({ branch_id_or_ref }) => @@ -1904,7 +2085,7 @@ describe("gen types", () => { }); yield* genTypes( defaultFlags({ - projectRef: Option.some(VALID_REF), + remote: Option.some({ _tag: "Ref", ref: VALID_REF } as const), lang: "python", }), ).pipe(Effect.provide(layer)); @@ -1921,7 +2102,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-ref", VALID_REF], + args: ["gen", "types", "--lang", "python", `--remote=${VALID_REF}`], getProject: () => Effect.fail(statusApiError(404, `{"message":"Preview branch not found"}`)), getABranchConfig: ({ branch_id_or_ref }) => @@ -1938,7 +2119,7 @@ describe("gen types", () => { }); const exit = yield* genTypes( defaultFlags({ - projectRef: Option.some(VALID_REF), + remote: Option.some({ _tag: "Ref", ref: VALID_REF } as const), lang: "python", }), ).pipe(Effect.provide(layer), Effect.exit); @@ -1974,7 +2155,7 @@ describe("gen types", () => { () => Effect.succeed("type RetriedViaPooler struct {}"), ]); const { layer, out, dbConfig } = yield* setup({ - args: ["gen", "types", "--lang", "go", "--project-ref", VALID_REF], + args: ["gen", "types", "--lang", "go", `--remote=${VALID_REF}`], generator, dbConfigResolve: () => Effect.succeed( @@ -1990,7 +2171,7 @@ describe("gen types", () => { }); yield* genTypes( defaultFlags({ - projectRef: Option.some(VALID_REF), + remote: Option.some({ _tag: "Ref", ref: VALID_REF } as const), lang: "go", }), ).pipe(Effect.provide(layer)); @@ -2023,7 +2204,7 @@ describe("gen types", () => { () => Effect.succeed("type RetriedViaPooler struct {}"), ]); const { layer, out, dbConfig } = yield* setup({ - args: ["gen", "types", "--lang", "go", "--project-ref", VALID_REF], + args: ["gen", "types", "--lang", "go", `--remote=${VALID_REF}`], generator, dbConfigResolve: () => Effect.succeed( @@ -2039,7 +2220,7 @@ describe("gen types", () => { }); yield* genTypes( defaultFlags({ - projectRef: Option.some(VALID_REF), + remote: Option.some({ _tag: "Ref", ref: VALID_REF } as const), lang: "go", }), ).pipe(Effect.provide(layer)); @@ -2056,7 +2237,7 @@ describe("gen types", () => { Effect.gen(function* () { const generator = sequentialGenerator([() => Effect.fail(nonIpv6Failure())]); const { layer, dbConfig } = yield* setup({ - args: ["gen", "types", "--lang", "go", "--project-ref", VALID_REF], + args: ["gen", "types", "--lang", "go", `--remote=${VALID_REF}`], generator, dbConfigResolve: () => Effect.succeed( @@ -2077,7 +2258,10 @@ describe("gen types", () => { }), }); const exit = yield* genTypes( - defaultFlags({ projectRef: Option.some(VALID_REF), lang: "go" }), + defaultFlags({ + remote: Option.some({ _tag: "Ref", ref: VALID_REF } as const), + lang: "go", + }), ).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isFailure(exit)).toBe(true); @@ -2095,7 +2279,7 @@ describe("gen types", () => { () => Effect.fail(ipv6Failure()), ]); const { layer, dbConfig } = yield* setup({ - args: ["gen", "types", "--lang", "go", "--project-ref", VALID_REF], + args: ["gen", "types", "--lang", "go", `--remote=${VALID_REF}`], generator, dbConfigResolve: () => Effect.succeed( @@ -2116,7 +2300,10 @@ describe("gen types", () => { }), }); const exit = yield* genTypes( - defaultFlags({ projectRef: Option.some(VALID_REF), lang: "go" }), + defaultFlags({ + remote: Option.some({ _tag: "Ref", ref: VALID_REF } as const), + lang: "go", + }), ).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isFailure(exit)).toBe(true); @@ -2131,7 +2318,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-ref", VALID_REF], + args: ["gen", "types", "--lang", "go", `--remote=${VALID_REF}`], generator, dbConfigResolve: () => Effect.succeed( @@ -2152,7 +2339,10 @@ describe("gen types", () => { }), }); const exit = yield* genTypes( - defaultFlags({ projectRef: Option.some(VALID_REF), lang: "go" }), + defaultFlags({ + remote: Option.some({ _tag: "Ref", ref: VALID_REF } as const), + lang: "go", + }), ).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isFailure(exit)).toBe(true); @@ -2166,7 +2356,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-ref", VALID_REF], + args: ["gen", "types", "--lang", "dart", `--remote=${VALID_REF}`], generator, dbConfigResolve: () => Effect.succeed( @@ -2187,7 +2377,10 @@ describe("gen types", () => { }), }); const exit = yield* genTypes( - defaultFlags({ projectRef: Option.some(VALID_REF), lang: "dart" }), + defaultFlags({ + remote: Option.some({ _tag: "Ref", ref: VALID_REF } as const), + lang: "dart", + }), ).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isFailure(exit)).toBe(true); @@ -2201,7 +2394,7 @@ describe("gen types", () => { Effect.gen(function* () { const generator = sequentialGenerator([() => Effect.fail(ipv6Failure())]); const { layer } = yield* setup({ - args: ["gen", "types", "--lang", "go", "--project-ref", VALID_REF], + args: ["gen", "types", "--lang", "go", `--remote=${VALID_REF}`], generator, dbConfigResolve: () => Effect.succeed( @@ -2216,7 +2409,10 @@ describe("gen types", () => { poolerFallbackFails: true, }); const exit = yield* genTypes( - defaultFlags({ projectRef: Option.some(VALID_REF), lang: "go" }), + defaultFlags({ + remote: Option.some({ _tag: "Ref", ref: VALID_REF } as const), + lang: "go", + }), ).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isFailure(exit)).toBe(true); @@ -2236,7 +2432,7 @@ describe("gen types", () => { () => Effect.succeed("class RetriedViaBranchPooler(BaseModel):"), ]); const { layer, api } = yield* setup({ - args: ["gen", "types", "--lang", "python", "--project-ref", VALID_REF], + args: ["gen", "types", "--lang", "python", `--remote=${VALID_REF}`], generator, getProject: () => Effect.fail(statusApiError(404, `{"message":"Not found"}`)), getABranchConfig: ({ branch_id_or_ref }) => @@ -2272,7 +2468,7 @@ describe("gen types", () => { }); yield* genTypes( defaultFlags({ - projectRef: Option.some(VALID_REF), + remote: Option.some({ _tag: "Ref", ref: VALID_REF } as const), lang: "python", }), ).pipe(Effect.provide(layer)); @@ -2291,7 +2487,7 @@ describe("gen types", () => { Effect.gen(function* () { const generator = sequentialGenerator([() => Effect.fail(ipv6Failure())]); const { layer, api } = yield* setup({ - args: ["gen", "types", "--lang", "python", "--project-ref", VALID_REF], + args: ["gen", "types", "--lang", "python", `--remote=${VALID_REF}`], generator, getProject: () => Effect.fail(statusApiError(404, `{"message":"Not found"}`)), getABranchConfig: ({ branch_id_or_ref }) => @@ -2327,7 +2523,7 @@ describe("gen types", () => { }); const exit = yield* genTypes( defaultFlags({ - projectRef: Option.some(VALID_REF), + remote: Option.some({ _tag: "Ref", ref: VALID_REF } as const), lang: "python", }), ).pipe(Effect.provide(layer), Effect.exit); @@ -2359,9 +2555,10 @@ describe("gen types", () => { }); yield* genTypes( defaultFlags({ - dbUrl: Option.some( - `postgresql://postgres:postgres@db.${VALID_REF}.supabase.co:5432/postgres`, - ), + remote: Option.some({ + _tag: "DbUrl", + url: `postgresql://postgres:postgres@db.${VALID_REF}.supabase.co:5432/postgres`, + } as const), }), ).pipe(Effect.provide(layer)); @@ -2386,9 +2583,10 @@ describe("gen types", () => { }); yield* genTypes( defaultFlags({ - dbUrl: Option.some( - "postgresql://postgres.ref:pw@aws-0-us-east-1.pooler.supabase.com:6543/postgres", - ), + remote: Option.some({ + _tag: "DbUrl", + url: "postgresql://postgres.ref:pw@aws-0-us-east-1.pooler.supabase.com:6543/postgres", + } as const), }), ).pipe(Effect.provide(layer)); @@ -2414,9 +2612,10 @@ describe("gen types", () => { }); yield* genTypes( defaultFlags({ - dbUrl: Option.some( - `postgresql://postgres:postgres@db.${VALID_REF}.supabase.co:5432/postgres?sslmode=disable`, - ), + remote: Option.some({ + _tag: "DbUrl", + url: `postgresql://postgres:postgres@db.${VALID_REF}.supabase.co:5432/postgres?sslmode=disable`, + } as const), }), ).pipe(Effect.provide(layer)); @@ -2441,7 +2640,10 @@ describe("gen types", () => { }); yield* genTypes( defaultFlags({ - dbUrl: Option.some("postgresql://postgres:postgres@db.example.net:5432/postgres"), + remote: Option.some({ + _tag: "DbUrl", + url: "postgresql://postgres:postgres@db.example.net:5432/postgres", + } as const), }), ).pipe(Effect.provide(layer)); @@ -2466,7 +2668,10 @@ describe("gen types", () => { }); yield* genTypes( defaultFlags({ - dbUrl: Option.some("postgresql://postgres:postgres@127.0.0.1:5432/postgres"), + remote: Option.some({ + _tag: "DbUrl", + url: "postgresql://postgres:postgres@127.0.0.1:5432/postgres", + } as const), }), ).pipe(Effect.provide(layer)); @@ -3090,7 +3295,10 @@ describe("gen types", () => { }); yield* genTypes( defaultFlags({ - dbUrl: Option.some("postgresql://postgres:postgres@127.0.0.1:5432/postgres"), + remote: Option.some({ + _tag: "DbUrl", + url: "postgresql://postgres:postgres@127.0.0.1:5432/postgres", + } as const), schema: ["public"], }), ).pipe(Effect.provide(layer)); @@ -3108,7 +3316,10 @@ describe("gen types", () => { const { layer, dbConfig, generator } = yield* setup(); yield* genTypes( defaultFlags({ - dbUrl: Option.some("postgresql://postgres:postgres@127.0.0.1:5432/postgres"), + remote: Option.some({ + _tag: "DbUrl", + url: "postgresql://postgres:postgres@127.0.0.1:5432/postgres", + } as const), }), ).pipe(Effect.provide(layer)); @@ -3135,7 +3346,10 @@ describe("gen types", () => { const { layer, generator } = yield* setup(); yield* genTypes( defaultFlags({ - dbUrl: Option.some("postgresql://postgres:postgres@127.0.0.1:5432/postgres"), + remote: Option.some({ + _tag: "DbUrl", + url: "postgresql://postgres:postgres@127.0.0.1:5432/postgres", + } as const), queryTimeout: "400ms", }), ).pipe(Effect.provide(layer)); @@ -3151,7 +3365,10 @@ describe("gen types", () => { const { layer, generator } = yield* setup(); yield* genTypes( defaultFlags({ - dbUrl: Option.some("postgresql://postgres:postgres@127.0.0.1:5432/postgres"), + remote: Option.some({ + _tag: "DbUrl", + url: "postgresql://postgres:postgres@127.0.0.1:5432/postgres", + } as const), queryTimeout: "0s", }), ).pipe(Effect.provide(layer)); @@ -3169,7 +3386,10 @@ describe("gen types", () => { const { layer, generator } = yield* setup(); yield* genTypes( defaultFlags({ - dbUrl: Option.some("postgresql://postgres:postgres@127.0.0.1:5432/postgres"), + remote: Option.some({ + _tag: "DbUrl", + url: "postgresql://postgres:postgres@127.0.0.1:5432/postgres", + } as const), lang: "swift", schema: ["public"], "swift-access-control": Option.some("public"), @@ -3192,11 +3412,14 @@ describe("gen types", () => { () => Effect.gen(function* () { const { layer, generator, out } = yield* setup({ - args: ["gen", "types", "--db-url", "postgresql://x", "--postgrest-v9-compat"], + args: ["gen", "types", "--remote=postgresql://x", "--postgrest-v9-compat"], }); yield* genTypes( defaultFlags({ - dbUrl: Option.some("postgresql://postgres:postgres@127.0.0.1:5432/postgres"), + remote: Option.some({ + _tag: "DbUrl", + url: "postgresql://postgres:postgres@127.0.0.1:5432/postgres", + } as const), postgrestV9Compat: true, }), ).pipe(Effect.provide(layer)); @@ -3215,7 +3438,10 @@ describe("gen types", () => { }); yield* genTypes( defaultFlags({ - dbUrl: Option.some("postgresql://postgres:postgres@127.0.0.1:5432/postgres"), + remote: Option.some({ + _tag: "DbUrl", + url: "postgresql://postgres:postgres@127.0.0.1:5432/postgres", + } as const), lang: "go", schema: ["public"], }), @@ -3281,6 +3507,9 @@ const setupRecorded = Effect.fnUntraced(function* (opts: { if (pathname === "/v1/projects") { return jsonResponse(request, 200, [projectBody(opts.pickRef ?? VALID_REF)]); } + if (/^\/v1\/projects\/[^/]+\/branches$/u.test(pathname)) { + return jsonResponse(request, 200, [branchRow("feature-x")]); + } const branchMatch = /^\/v1\/branches\/(?<ref>[^/]+)$/u.exec(pathname); if (branchMatch?.groups?.["ref"] !== undefined) { return jsonResponse(request, 200, { @@ -3327,6 +3556,7 @@ const setupRecorded = Effect.fnUntraced(function* (opts: { cliSettings, telemetry: telemetry.layer, tty: mockTty({ stdinIsTty: opts.pickRef !== undefined }), + cliArgs: opts.args, linkedProjectCache: realLinkedProjectCache({ httpClient: http.layer, cliSettings: mockCommandSettings({ workdir, projectId: Option.none() }), @@ -3384,13 +3614,15 @@ const methodPaths = (requests: ReadonlyArray<{ readonly method: string; readonly requests.map(({ method, url }) => `${method} ${new URL(url).pathname}`); describe("gen types permissions through the real layers", () => { - it.effect("generates typescript for --project-id with one types call and the cache fill", () => + it.effect("generates typescript for --remote=<ref> with one types call and the cache fill", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const ctx = yield* setupRecorded({ - args: ["gen", "types", "--project-id", VALID_REF], + args: ["gen", "types", `--remote=${VALID_REF}`], }); - yield* ctx.run(genTypes(defaultFlags({ projectId: Option.some(VALID_REF) }))); + yield* ctx.run( + genTypes(defaultFlags({ remote: Option.some({ _tag: "Ref", ref: VALID_REF } as const) })), + ); expect(ctx.out.stdoutText).toBe("export type Database = {};"); expect(methodPaths(ctx.api.requests)).toEqual([ @@ -3401,26 +3633,85 @@ describe("gen types permissions through the real layers", () => { // The cache fill also calls the project lookup; without it, no TypeScript entry may require one. assertPermissionDrift({ command: "gen types", - activeFlags: { ...DEFAULT_SWITCHES, linked: true, "project-id": true, lang: "typescript" }, + activeFlags: { ...DEFAULT_SWITCHES, remote: VALID_REF, lang: "typescript" }, requests: ctx.api.requests, exact: true, }); expect(yield* fs.exists(`${ctx.workdir}/supabase/.temp/linked-project.json`)).toBe(true); assertPermissionDrift({ command: "gen types", - activeFlags: { ...DEFAULT_SWITCHES, linked: true, "project-id": true, lang: "typescript" }, + activeFlags: { ...DEFAULT_SWITCHES, remote: VALID_REF, lang: "typescript" }, requests: ctx.recordedRequests(), exact: true, }); }).pipe(Effect.provide(BunServices.layer)), ); + it.effect("still generates typescript through the deprecated --project-id alias", () => + Effect.gen(function* () { + const ctx = yield* setupRecorded({ + args: ["gen", "types", "--project-id", VALID_REF], + }); + yield* ctx.run(genTypes(defaultFlags({ projectId: Option.some(VALID_REF) }))); + + expect(methodPaths(ctx.api.requests)).toEqual([ + `GET /v1/projects/${VALID_REF}/types/typescript`, + ]); + expect( + ctx.out.messages.filter((message) => message.type === "warn").map(({ message }) => message), + ).toEqual([ + `--project-id is deprecated and will be removed in v4. Use --remote instead: supabase gen types --remote=${VALID_REF}`, + ]); + assertPermissionDrift({ + command: "gen types", + activeFlags: { ...DEFAULT_SWITCHES, linked: true, "project-id": true, lang: "typescript" }, + requests: ctx.api.requests, + exact: true, + }); + }).pipe(Effect.provide(BunServices.layer)), + ); + + it.effect( + "looks the branch --remote=<branch> names up in the linked project, then generates", + () => + Effect.gen(function* () { + const ctx = yield* setupRecorded({ + args: ["gen", "types", "--remote=feature-x"], + projectRefFile: VALID_REF, + cacheRef: BRANCH_PROJECT_REF, + }); + yield* ctx.run( + genTypes( + defaultFlags({ remote: Option.some({ _tag: "Branch", name: "feature-x" } as const) }), + ), + ); + + expect(methodPaths(ctx.api.requests)).toEqual([ + `GET /v1/projects/${VALID_REF}/branches`, + `GET /v1/projects/${BRANCH_PROJECT_REF}/types/typescript`, + ]); + assertPermissionDrift({ + command: "gen types", + activeFlags: { ...DEFAULT_SWITCHES, remote: "feature-x", lang: "typescript" }, + requests: ctx.api.requests, + exact: true, + }); + }).pipe(Effect.provide(BunServices.layer)), + ); + it.effect("looks the project up, then reaches the database through the pooler for go", () => Effect.gen(function* () { const ctx = yield* setupRecorded({ - args: ["gen", "types", "--project-id", VALID_REF, "--lang", "go"], + args: ["gen", "types", `--remote=${VALID_REF}`, "--lang", "go"], }); - yield* ctx.run(genTypes(defaultFlags({ projectId: Option.some(VALID_REF), lang: "go" }))); + yield* ctx.run( + genTypes( + defaultFlags({ + remote: Option.some({ _tag: "Ref", ref: VALID_REF } as const), + lang: "go", + }), + ), + ); expect(methodPaths(ctx.api.requests)).toEqual([`GET /v1/projects/${VALID_REF}`]); expect(ctx.stubbed.map(({ method, path }) => `${method} ${path}`)).toEqual([ @@ -3431,7 +3722,7 @@ describe("gen types permissions through the real layers", () => { expect(ctx.generator.calls[0]?.conn.host).toBe("aws-0-us-east-1.pooler.supabase.com"); assertPermissionDrift({ command: "gen types", - activeFlags: { ...DEFAULT_SWITCHES, linked: true, "project-id": true, lang: "go" }, + activeFlags: { ...DEFAULT_SWITCHES, remote: VALID_REF, lang: "go" }, requests: ctx.recordedRequests(), exact: true, }); @@ -3439,11 +3730,11 @@ describe("gen types permissions through the real layers", () => { ); it.effect( - "retries through the pooler after an IPv6 failure on the direct host for --linked", + "retries through the pooler after an IPv6 failure on the direct host for --remote", () => Effect.gen(function* () { const ctx = yield* setupRecorded({ - args: ["gen", "types", "--linked", "--lang", "go"], + args: ["gen", "types", "--remote", "--lang", "go"], projectRefFile: VALID_REF, generator: sequentialGenerator([ () => Effect.fail(ipv6Failure()), @@ -3453,7 +3744,9 @@ describe("gen types permissions through the real layers", () => { yield* ctx.run( Effect.gen(function* () { yield* withReachableDirectHost(fixtureNet); - yield* genTypes(defaultFlags({ linked: true, lang: "go" })); + yield* genTypes( + defaultFlags({ remote: Option.some({ _tag: "Linked" } as const), lang: "go" }), + ); }), ); @@ -3470,7 +3763,7 @@ describe("gen types permissions through the real layers", () => { ]); assertPermissionDrift({ command: "gen types", - activeFlags: { ...DEFAULT_SWITCHES, linked: true, lang: "go" }, + activeFlags: { ...DEFAULT_SWITCHES, remote: true, lang: "go" }, requests: ctx.recordedRequests(), exact: true, }); @@ -3480,7 +3773,7 @@ describe("gen types permissions through the real layers", () => { it.effect("falls back to the preview branch config when the project lookup returns 404", () => Effect.gen(function* () { const ctx = yield* setupRecorded({ - args: ["gen", "types", "--project-id", BRANCH_REF, "--lang", "go"], + args: ["gen", "types", `--remote=${BRANCH_REF}`, "--lang", "go"], branchOf: BRANCH_REF, cacheRef: BRANCH_REF, generator: sequentialGenerator([ @@ -3488,7 +3781,14 @@ describe("gen types permissions through the real layers", () => { () => Effect.succeed("type Public struct {}"), ]), }); - yield* ctx.run(genTypes(defaultFlags({ projectId: Option.some(BRANCH_REF), lang: "go" }))); + yield* ctx.run( + genTypes( + defaultFlags({ + remote: Option.some({ _tag: "Ref", ref: BRANCH_REF } as const), + lang: "go", + }), + ), + ); expect(methodPaths(ctx.api.requests)).toEqual([ `GET /v1/projects/${BRANCH_REF}`, @@ -3504,20 +3804,20 @@ describe("gen types permissions through the real layers", () => { expect(methodPaths(ctx.http.calls)).toEqual([`GET /v1/projects/${BRANCH_REF}`]); assertPermissionDrift({ command: "gen types", - activeFlags: { ...DEFAULT_SWITCHES, linked: true, "project-id": true, lang: "go" }, + activeFlags: { ...DEFAULT_SWITCHES, remote: BRANCH_REF, lang: "go" }, requests: ctx.recordedRequests(), exact: true, }); }).pipe(Effect.provide(BunServices.layer)), ); - it.effect("asks which project to use for --linked when no ref is saved", () => + it.effect("asks which project to use for a bare --remote when no ref is saved", () => Effect.gen(function* () { const ctx = yield* setupRecorded({ - args: ["gen", "types", "--linked"], + args: ["gen", "types", "--remote"], pickRef: VALID_REF, }); - yield* ctx.run(genTypes(defaultFlags({ linked: true }))); + yield* ctx.run(genTypes(defaultFlags({ remote: Option.some({ _tag: "Linked" } as const) }))); expect(methodPaths(ctx.api.requests)).toEqual([ "GET /v1/projects", @@ -3526,7 +3826,7 @@ describe("gen types permissions through the real layers", () => { expect(methodPaths(ctx.http.calls)).toEqual([`GET /v1/projects/${VALID_REF}`]); assertPermissionDrift({ command: "gen types", - activeFlags: { ...DEFAULT_SWITCHES, linked: true, lang: "typescript" }, + activeFlags: { ...DEFAULT_SWITCHES, remote: true, lang: "typescript" }, requests: ctx.recordedRequests(), exact: true, }); @@ -3562,6 +3862,29 @@ describe("gen types permissions through the real layers", () => { activeFlags: { ...DEFAULT_SWITCHES, local: true, lang: "typescript" }, generates: true, }, + { + name: "--remote=<url>, whatever the language", + args: [ + "gen", + "types", + "--remote=postgresql://postgres:postgres@127.0.0.1:5432/postgres", + "--lang", + "go", + ], + flags: defaultFlags({ + remote: Option.some({ + _tag: "DbUrl", + url: "postgresql://postgres:postgres@127.0.0.1:5432/postgres", + } as const), + lang: "go", + }), + activeFlags: { + ...DEFAULT_SWITCHES, + remote: "postgresql://postgres:postgres@127.0.0.1:5432/postgres", + lang: "go", + }, + generates: true, + }, { name: "--db-url, whatever the language", args: [ @@ -3580,7 +3903,22 @@ describe("gen types permissions through the real layers", () => { generates: true, }, { - name: "--postgrest-v9-compat without --db-url, which fails first", + name: "--postgrest-v9-compat without a database URL, which fails first", + args: ["gen", "types", `--remote=${VALID_REF}`, "--postgrest-v9-compat"], + flags: defaultFlags({ + remote: Option.some({ _tag: "Ref", ref: VALID_REF } as const), + postgrestV9Compat: true, + }), + activeFlags: { + ...DEFAULT_SWITCHES, + "postgrest-v9-compat": true, + remote: VALID_REF, + lang: "typescript", + }, + generates: false, + }, + { + name: "--postgrest-v9-compat with the deprecated --project-id alias, which fails first", args: ["gen", "types", "--project-id", VALID_REF, "--postgrest-v9-compat"], flags: defaultFlags({ projectId: Option.some(VALID_REF), postgrestV9Compat: true }), activeFlags: { From e4d7730e577bac580007bdcabe4aecbb0e550ca6 Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Mon, 19 Oct 2026 11:56:38 +0100 Subject: [PATCH 120/291] chore(cli): name --remote in the comments of the database, storage and seed commands --- apps/cli/src/commands/gen/types/types.command.ts | 4 ++-- apps/cli/src/commands/gen/types/types.handler.ts | 6 +++--- apps/cli/src/commands/gen/types/types.layers.ts | 2 +- apps/cli/src/commands/migration/squash/squash.handler.ts | 6 +++--- apps/cli/src/commands/seed/buckets/buckets.errors.ts | 4 ++-- apps/cli/src/commands/seed/buckets/buckets.handler.ts | 4 ++-- apps/cli/src/commands/storage/storage.command.ts | 4 ++-- 7 files changed, 15 insertions(+), 15 deletions(-) diff --git a/apps/cli/src/commands/gen/types/types.command.ts b/apps/cli/src/commands/gen/types/types.command.ts index b0d587cd0a..6bc55794d5 100644 --- a/apps/cli/src/commands/gen/types/types.command.ts +++ b/apps/cli/src/commands/gen/types/types.command.ts @@ -107,7 +107,7 @@ export type GenTypesFlags = CliCommand.Command.Config.Infer<typeof flagsConfig> const NON_TYPESCRIPT = GEN_TYPES_LANGUAGES.filter((lang) => lang !== "typescript"); const nonTypescript = { flag: "lang", oneOf: NON_TYPESCRIPT } as const; const notBranch = "the ref names a project, not a preview branch"; -// Without a linked target the run uses a local or `--db-url` database, or fails before calling the API. +// Without a linked target the run uses a local or `--remote=<url>` database, or fails before calling the API. const remoteTarget: ReadonlyArray<EntryCondition> = [ { dbTarget: ["linked"] }, { flag: "postgrest-v9-compat", is: false }, @@ -157,7 +157,7 @@ export const genTypesCommand = Command.make("types", commandConfig).pipe( operationId: "v1-get-pooler-config", kind: "required", when: [nonTypescript], - context: `${notBranch}, the direct database host is unreachable (IPv4-only network) and there is no usable saved supabase/.temp/pooler-url (--project-ref and --project-id ignore the saved file)`, + context: `${notBranch}, the direct database host is unreachable (IPv4-only network) and there is no usable saved supabase/.temp/pooler-url (--remote=<ref>, --project-ref and --project-id ignore the saved file)`, }, { operationId: "v1-get-pooler-config", diff --git a/apps/cli/src/commands/gen/types/types.handler.ts b/apps/cli/src/commands/gen/types/types.handler.ts index 0da7dc017d..7eaf7b1dc2 100644 --- a/apps/cli/src/commands/gen/types/types.handler.ts +++ b/apps/cli/src/commands/gen/types/types.handler.ts @@ -129,7 +129,7 @@ const POSTGREST_V9_COMPAT_DEPRECATION_LINE = /** * Every value-taking flag `gen types` parses, telling `scanArgvFlags` which bare tokens - * consume the next argv token as their value. Boolean flags (`--local`, `--linked`, + * consume the next argv token as their value. Boolean flags (`--remote`, `--local`, `--linked`, * `--postgrest-v9-compat`) are excluded since they never consume a following token. */ const GEN_TYPES_SCAN_SPEC = { @@ -254,7 +254,7 @@ export const genTypes = Effect.fn("gen.types")(function* (flags: GenTypesFlags) const languageOptions = languageOptionValues(flags); // `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. + // merged in the same load; omitted for the local and `--remote=<url>` paths. const loadApiSchemas = (projectRef?: string) => configValues .load({ @@ -303,7 +303,7 @@ export const genTypes = Effect.fn("gen.types")(function* (flags: GenTypesFlags) }) : Effect.void; - // `--schema` skips the config load entirely, so a `--db-url --schema ...` invocation must not + // `--schema` skips the config load entirely, so a `--remote=<url> --schema ...` invocation must not // fail just because the workdir has no project config. const resolveIncludedSchemas = (projectRef?: string) => schemas.length > 0 diff --git a/apps/cli/src/commands/gen/types/types.layers.ts b/apps/cli/src/commands/gen/types/types.layers.ts index f30f4be646..3472d82f19 100644 --- a/apps/cli/src/commands/gen/types/types.layers.ts +++ b/apps/cli/src/commands/gen/types/types.layers.ts @@ -26,7 +26,7 @@ import { GenTypesGenerator } from "./types.generator.service.ts"; /** * Avoids `managementApiRuntimeLayer`, which eagerly builds the platform API client and * requires an access token before the handler can choose the local/db-url branch — `gen types - * --local`/`--db-url` don't use the Management API. + * --local`/`--remote=<url>` don't use the Management API. */ export const genTypesRuntimeLayer = (() => { const cliSettings = commandSettingsLayer.pipe(Layer.provide(debugLoggerLayer)); diff --git a/apps/cli/src/commands/migration/squash/squash.handler.ts b/apps/cli/src/commands/migration/squash/squash.handler.ts index e7a9dcdacf..8b1fb84138 100644 --- a/apps/cli/src/commands/migration/squash/squash.handler.ts +++ b/apps/cli/src/commands/migration/squash/squash.handler.ts @@ -447,7 +447,7 @@ const runSquash = Effect.fnUntraced(function* (flags: MigrationSquashFlags) { const squashFlow = Effect.gen(function* () { const migrationsDir = path.join(cliSettings.workdir, "supabase", "migrations"); - // Reads the remote-merged config before resolving the connection (like db diff --linked); + // Reads the remote-merged config before resolving the connection (like `db diff --remote`); // read unconditionally since the shadow is provisioned locally either way. const toml = yield* readDbToml(fs, path, cliSettings.workdir, linkedRef); if (toml.appliedRemote !== undefined) { @@ -465,8 +465,8 @@ const runSquash = Effect.fnUntraced(function* (flags: MigrationSquashFlags) { linkedRef, ); - // The resolver owns --password/DB_PASSWORD/temp-login-role/IPv6 handling for - // --linked, so squash needs no bespoke password prompt. + // The resolver owns --password/DB_PASSWORD/temp-login-role/IPv6 handling for a + // remote target, so squash needs no bespoke password prompt. const cfg = yield* resolver.resolveConnection({ target, dnsResolver, diff --git a/apps/cli/src/commands/seed/buckets/buckets.errors.ts b/apps/cli/src/commands/seed/buckets/buckets.errors.ts index 37c69350e0..1aaa817f4a 100644 --- a/apps/cli/src/commands/seed/buckets/buckets.errors.ts +++ b/apps/cli/src/commands/seed/buckets/buckets.errors.ts @@ -26,8 +26,8 @@ export class SeedConfigLoadError extends Data.TaggedError("SeedConfigLoadError") /** * The resolved `--workdir`/`SUPABASE_WORKDIR` doesn't exist or isn't a - * directory. Only reachable when explicitly set — beats the `--project-ref` - * guard and every network call. + * directory. Only reachable when explicitly set — beats the target-flag + * guards and every network call. */ export class SeedWorkdirError extends Data.TaggedError("SeedWorkdirError")<{ readonly message: string; diff --git a/apps/cli/src/commands/seed/buckets/buckets.handler.ts b/apps/cli/src/commands/seed/buckets/buckets.handler.ts index 4503dead01..0c6a1742e0 100644 --- a/apps/cli/src/commands/seed/buckets/buckets.handler.ts +++ b/apps/cli/src/commands/seed/buckets/buckets.handler.ts @@ -15,8 +15,8 @@ import { SeedMissingProjectConfigError, SeedWorkdirError } from "./buckets.error /** * `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 resolution, hosted consent and telemetry. + * seeding work lives in the hoisted `seedBucketsRun` (shared with `db reset`); this + * handler owns target resolution, hosted consent and telemetry. */ export const seedBuckets = Effect.fn("seed.buckets")(function* (flags: BucketsFlags) { const telemetryState = yield* TelemetryState; diff --git a/apps/cli/src/commands/storage/storage.command.ts b/apps/cli/src/commands/storage/storage.command.ts index 85df777834..99df57fdc1 100644 --- a/apps/cli/src/commands/storage/storage.command.ts +++ b/apps/cli/src/commands/storage/storage.command.ts @@ -7,8 +7,8 @@ import { storageRmCommand } from "./rm/rm.command.ts"; export const storageCommand = Command.make("storage").pipe( Command.withDescription("Manage Supabase Storage objects."), Command.withShortDescription("Manage Supabase Storage objects"), - // `--linked`/`--local` are declared per-leaf (see `storage.flags.ts`), not as + // `--remote` and its aliases are declared per-leaf (see `storage.flags.ts`), not as // group scoped globals, because Effect CLI requires global-flag names to be - // unique tree-wide and `seed` already owns `linked`/`local`. + // unique tree-wide and `seed` already owns `remote`/`linked`/`local`. Command.withSubcommands([storageLsCommand, storageCpCommand, storageMvCommand, storageRmCommand]), ); From 22684554b69269e26060b34020187c72fe07aaab Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Mon, 19 Oct 2026 12:04:21 +0100 Subject: [PATCH 121/291] fix(cli): send target deprecation warnings to stderr in every output mode Alias deprecation warnings went through Output.warn, which the text layer writes to stdout, so piped payloads such as gen types output carried the warning. They are now yellow Warning: lines on stderr, like the pooler fallback warning. --- .../target/remote-resolution.ts | 5 +- .../target/remote-resolution.unit.test.ts | 96 +++++++++++++++++++ ...resolve-project-target.integration.test.ts | 10 +- .../target/resolve-target.integration.test.ts | 34 ++++++- .../deploy/deploy.integration.test.ts | 6 +- .../gen/types/types.integration.test.ts | 37 ++++++- .../seed/buckets/buckets.integration.test.ts | 2 +- 7 files changed, 178 insertions(+), 12 deletions(-) create mode 100644 apps/cli/src/command-internal/target/remote-resolution.unit.test.ts diff --git a/apps/cli/src/command-internal/target/remote-resolution.ts b/apps/cli/src/command-internal/target/remote-resolution.ts index 959c4d2c26..68b3f706d5 100644 --- a/apps/cli/src/command-internal/target/remote-resolution.ts +++ b/apps/cli/src/command-internal/target/remote-resolution.ts @@ -4,6 +4,7 @@ import { CommandSettings } from "../../config/command-settings.service.ts"; import { CliArgs } from "../../shared/cli/cli-args.service.ts"; import { CommandTarget } from "../../shared/output/command-target.service.ts"; import { Output } from "../../shared/output/output.service.ts"; +import { yellow } from "../colors.ts"; import { resolveLinkedParentRef } from "../parent-project-ref.ts"; import { findBranch, @@ -31,7 +32,7 @@ const bareRemoteOperand = (argv: ReadonlyArray<string>): string | undefined => { return couldNameBranch(next) ? next : undefined; }; -/** Prints one warning per deprecated selector flag the run used, rebuilt from the real command line when it is known. */ +/** Prints one warning per deprecated selector flag the run used on stderr in every output format, rebuilt from the real command line when it is known. */ export const warnTargetDeprecations = Effect.fnUntraced(function* ( deprecations: ReadonlyArray<TargetDeprecation>, ) { @@ -42,7 +43,7 @@ export const warnTargetDeprecations = Effect.fnUntraced(function* ( Option.map(yield* Effect.serviceOption(CliArgs), (cli) => cli.args), ); for (const message of deprecationMessages(deprecations, { argv, projectHost })) { - yield* output.warn(message); + yield* output.raw(`${yellow(`Warning: ${message}`)}\n`, "stderr"); } }); diff --git a/apps/cli/src/command-internal/target/remote-resolution.unit.test.ts b/apps/cli/src/command-internal/target/remote-resolution.unit.test.ts new file mode 100644 index 0000000000..85177c9ae8 --- /dev/null +++ b/apps/cli/src/command-internal/target/remote-resolution.unit.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, it } from "@effect/vitest"; +import { Effect, Layer, Sink, Stdio, Stream } from "effect"; +import { vi } from "vitest"; + +import { stripAnsi } from "../../../tests/helpers/ansi.ts"; +import { mockCommandSettings } from "../../../tests/helpers/command-mocks.ts"; +import { mockTty } from "../../../tests/helpers/mocks.ts"; +import { CliArgs } from "../../shared/cli/cli-args.service.ts"; +import { + jsonOutputLayer, + streamJsonOutputLayer, + textOutputLayer, +} from "../../shared/output/output.layer.ts"; +import { Output } from "../../shared/output/output.service.ts"; +import type { Tty } from "../../shared/runtime/tty.service.ts"; +import { warnTargetDeprecations } from "./remote-resolution.ts"; +import type { TargetDeprecation } from "./target-selector.ts"; + +const clack = vi.hoisted(() => ({ + log: { message: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn(), step: vi.fn() }, +})); + +vi.mock("@clack/prompts", () => ({ + log: clack.log, + intro: vi.fn(), + outro: vi.fn(), + cancel: vi.fn(), + isCancel: vi.fn(() => false), + spinner: vi.fn(), +})); + +const sink = (chunks: Array<string>) => + Sink.forEach((item: string | Uint8Array) => + Effect.sync(() => { + chunks.push(typeof item === "string" ? item : new TextDecoder().decode(item)); + }), + ); + +const PAYLOAD = "export type Database = {}\n"; +const linkedAlias: TargetDeprecation = { + kind: "remote-alias", + flags: ["--linked"], + remote: { _tag: "Linked" }, +}; + +const runWith = ( + outputLayer: Layer.Layer<Output, never, Stdio.Stdio | Tty>, + deprecations: ReadonlyArray<TargetDeprecation>, +) => { + const stdout: Array<string> = []; + const stderr: Array<string> = []; + const stdio = Layer.succeed( + Stdio.Stdio, + Stdio.make({ + args: Effect.succeed([]), + stdin: Stream.empty, + stdout: () => sink(stdout), + stderr: () => sink(stderr), + }), + ); + return Effect.gen(function* () { + yield* warnTargetDeprecations(deprecations); + const output = yield* Output; + yield* output.raw(PAYLOAD); + }).pipe( + Effect.provide( + Layer.mergeAll( + outputLayer, + mockCommandSettings({ workdir: "/unused" }), + Layer.succeed(CliArgs, { args: ["gen", "types", "--linked"] }), + ).pipe(Layer.provide(Layer.merge(stdio, mockTty()))), + ), + Effect.map(() => ({ stdout: stdout.join(""), stderr: stderr.join("") })), + ); +}; + +describe("warnTargetDeprecations", () => { + it.effect.each([ + { format: "text", layer: textOutputLayer }, + { format: "json", layer: jsonOutputLayer }, + { format: "stream-json", layer: streamJsonOutputLayer }, + ])("leaves stdout to the payload in $format mode", ({ layer }) => + Effect.gen(function* () { + const remoteRun = yield* runWith(layer, []); + const aliasRun = yield* runWith(layer, [linkedAlias]); + + expect(aliasRun.stdout).toBe(PAYLOAD); + expect(aliasRun.stdout).toBe(remoteRun.stdout); + expect(remoteRun.stderr).toBe(""); + expect(stripAnsi(aliasRun.stderr)).toBe( + "Warning: --linked is deprecated and will be removed in v4. Use --remote instead: supabase gen types --remote\n", + ); + expect(clack.log.warn).not.toHaveBeenCalled(); + }), + ); +}); diff --git a/apps/cli/src/command-internal/target/resolve-project-target.integration.test.ts b/apps/cli/src/command-internal/target/resolve-project-target.integration.test.ts index 307d1fe923..832652ef97 100644 --- a/apps/cli/src/command-internal/target/resolve-project-target.integration.test.ts +++ b/apps/cli/src/command-internal/target/resolve-project-target.integration.test.ts @@ -7,6 +7,7 @@ import { mockCommandPlatformApi, mockCommandSettings, } from "../../../tests/helpers/command-mocks.ts"; +import { stripAnsi } from "../../../tests/helpers/ansi.ts"; import { mockOutput } from "../../../tests/helpers/mocks.ts"; import { ProjectRefNotLinkedError } from "../../config/project-ref.errors.ts"; import { ProjectRefResolver } from "../../config/project-ref.service.ts"; @@ -95,7 +96,12 @@ const run = ( return { exit, stderr: out.stderrText, - warnings: out.messages.filter((m) => m.type === "warn").map((m) => m.message), + stdout: out.stdoutText, + warnings: stripAnsi(out.stderrText) + .split("\n") + .filter((line) => line.startsWith("Warning: ")) + .map((line) => line.slice("Warning: ".length)), + outputWarnCalls: out.messages.filter((m) => m.type === "warn"), refFlags, branchRequests: api.requests.length, }; @@ -141,6 +147,8 @@ describe("resolveProjectTarget", () => { expect(result.warnings).toEqual([ `--project-ref is deprecated and will be removed in v4. Use --remote instead: supabase functions deploy hello --remote=${FLAG_REF}`, ]); + expect(result.outputWarnCalls).toEqual([]); + expect(result.stdout).toBe(""); }), ); 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 7f1e4a10d3..2f7229fb73 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 @@ -8,6 +8,7 @@ import { mockCommandPlatformApi, mockCommandSettings, } from "../../../tests/helpers/command-mocks.ts"; +import { stripAnsi } from "../../../tests/helpers/ansi.ts"; import { mockOutput } from "../../../tests/helpers/mocks.ts"; import { ProjectRefNotLinkedError } from "../../config/project-ref.errors.ts"; import { ProjectRefResolver } from "../../config/project-ref.service.ts"; @@ -15,6 +16,7 @@ import type { ProjectRefSource } from "../../config/project-ref.service.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 type { RemoteValue } from "./remote-value.ts"; import { resolveTarget } from "./resolve-target.ts"; import { renderLocalTarget } from "./target-identity.ts"; @@ -85,6 +87,7 @@ const run = ( readonly savedRef?: string; readonly cache?: { readonly ref: string; readonly name?: string }; readonly args?: ReadonlyArray<string>; + readonly format?: OutputFormat; readonly branches?: ReadonlyArray<ReturnType<typeof branchRow>> | "unavailable"; } = {}, ) => @@ -106,7 +109,7 @@ const run = ( ); } const resolver = projectRefResolver({ linked: options.linked ?? true }); - const out = mockOutput(); + const out = mockOutput({ format: options.format ?? "text" }); const api = mockCommandPlatformApi({ handler: (request) => Effect.succeed( @@ -146,8 +149,13 @@ const run = ( const attributes = spans.find((span) => span.name === "Target.resolve")?.attributes; return { exit, + stdout: out.stdoutText, stderr: out.stderrText, - warnings: out.messages.filter((m) => m.type === "warn").map((m) => m.message), + warnings: stripAnsi(out.stderrText) + .split("\n") + .filter((line) => line.startsWith("Warning: ")) + .map((line) => line.slice("Warning: ".length)), + outputWarnCalls: out.messages.filter((m) => m.type === "warn"), refFlags: resolver.flags, apiRequests: api.requests.map( (request) => `${request.method} ${new URL(request.url).pathname}`, @@ -372,6 +380,24 @@ describe("resolveTarget", () => { }), ); + it.effect.each(["text", "json", "stream-json"] satisfies Array<OutputFormat>)( + "writes the warning to stderr and nothing to stdout in %s mode", + (format) => + Effect.gen(function* () { + const result = yield* run( + "selectable", + { ...none, linked: true }, + { args: ["db", "push", "--linked"], format }, + ); + expect(succeeded(result.exit)).toMatchObject({ kind: "linked" }); + expect(result.warnings).toEqual([ + "--linked is deprecated and will be removed in v4. Use --remote instead: supabase db push --remote", + ]); + expect(result.outputWarnCalls).toEqual([]); + expect(result.stdout).toBe(""); + }), + ); + it.effect("folds --linked and --project-ref into one warning that ends in --remote=<ref>", () => Effect.gen(function* () { const result = yield* run( @@ -490,7 +516,9 @@ describe("resolveTarget", () => { it.effect("adds no hint when --local 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"); + expect(stripAnsi(result.stderr)).toBe( + "Warning: --local isn't needed (local is the default) and will be removed in v4\nTarget: local stack (127.0.0.1:54322)\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 3ecdcff3ad..561ca07b40 100644 --- a/apps/cli/src/commands/functions/deploy/deploy.integration.test.ts +++ b/apps/cli/src/commands/functions/deploy/deploy.integration.test.ts @@ -37,6 +37,7 @@ import { withConfigEnv, withEnvVar, } from "../../../../tests/helpers/command-mocks.ts"; +import { stripAnsi } from "../../../../tests/helpers/ansi.ts"; import { cliConfigValuesTestLayer } from "../../../../tests/helpers/config-values-layer.ts"; import { mockOutput, mockRuntimeInfo, mockTty } from "../../../../tests/helpers/mocks.ts"; import { assertPermissionDrift } from "../../../../tests/helpers/permission-drift.ts"; @@ -494,7 +495,10 @@ describe("functions deploy", () => { ) .map((request) => request.url); const warnings = () => - out.messages.filter((message) => message.type === "warn").map((message) => message.message); + stripAnsi(out.stderrText) + .split("\n") + .filter((line) => line.startsWith("Warning: ")) + .map((line) => line.slice("Warning: ".length)); return { api, layer, deployedTo, warnings }; }; 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 222c149073..7ab1bbf753 100644 --- a/apps/cli/src/commands/gen/types/types.integration.test.ts +++ b/apps/cli/src/commands/gen/types/types.integration.test.ts @@ -33,6 +33,7 @@ import { resolveWorkdir } from "../../../config/command-settings.layer.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 { stripAnsi } from "../../../../tests/helpers/ansi.ts"; import { mockOutput, mockTty } from "../../../../tests/helpers/mocks.ts"; import { assertPermissionDrift } from "../../../../tests/helpers/permission-drift.ts"; import { @@ -999,10 +1000,9 @@ describe("gen types", () => { Effect.provide(layer), ); - expect(out.messages).toContainEqual({ - type: "warn", - message: `--project-id is deprecated and will be removed in v4. Use --remote instead: supabase gen types --remote=${VALID_REF}`, - }); + expect(stripAnsi(out.stderrText)).toContain( + `Warning: --project-id is deprecated and will be removed in v4. Use --remote instead: supabase gen types --remote=${VALID_REF}\n`, + ); expect(out.stderrText).toContain(unverifiedTargetBanner()); expect(api.requests).toContainEqual({ method: "generateTypescriptTypes", @@ -1011,6 +1011,35 @@ describe("gen types", () => { }).pipe(Effect.provide(BunServices.layer)), ); + it.effect.each(["text", "json"] as const)( + "keeps stdout identical to --remote=<ref> and warns on stderr for --project-id in %s mode", + (format) => + Effect.gen(function* () { + const alias = yield* setup({ + format, + args: ["gen", "types", "--project-id", VALID_REF], + projectTypes: "export type Database = {}\n", + }); + yield* genTypes(defaultFlags({ projectId: Option.some(VALID_REF) })).pipe( + Effect.provide(alias.layer), + ); + const canonical = yield* setup({ + format, + args: ["gen", "types", `--remote=${VALID_REF}`], + projectTypes: "export type Database = {}\n", + }); + yield* genTypes( + defaultFlags({ remote: Option.some({ _tag: "Ref", ref: VALID_REF } as const) }), + ).pipe(Effect.provide(canonical.layer)); + + expect(alias.out.stdoutText).toBe("export type Database = {}\n"); + expect(alias.out.stdoutText).toBe(canonical.out.stdoutText); + expect(alias.out.messages.filter((message) => message.type === "warn")).toEqual([]); + expect(stripAnsi(alias.out.stderrText)).toContain("Warning: --project-id is deprecated"); + expect(canonical.out.stderrText).not.toContain("deprecated"); + }).pipe(Effect.provide(BunServices.layer)), + ); + it.effect("rejects --project-id together with --project-ref", () => Effect.gen(function* () { const { layer, api, telemetry } = yield* setup({ 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 337f24cec0..2c6fb756c1 100644 --- a/apps/cli/src/commands/seed/buckets/buckets.integration.test.ts +++ b/apps/cli/src/commands/seed/buckets/buckets.integration.test.ts @@ -81,7 +81,7 @@ interface MockRoute { const DEFAULT_FLAGS: BucketsFlags = { remote: Option.none(), linked: false, - local: true, + local: false, projectRef: Option.none(), }; From 95662edc3ea9f7a31a1575659e752555ae9551ed Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Mon, 19 Oct 2026 12:04:52 +0100 Subject: [PATCH 122/291] fix(cli): skip command-path words in the bare --remote ambiguity check supabase seed --remote buckets looked up the buckets subcommand as a branch, and failed offline. The check now shares the argv scan that joins --remote operands, so a word the command reads as a path segment is never taken as a possible branch. --- .../target/remote-resolution.ts | 21 +--- .../target/resolve-target.integration.test.ts | 45 ++++++++- apps/cli/src/shared/cli/cli-args.service.ts | 4 + apps/cli/src/shared/cli/remote-argv.ts | 58 +++++++++-- .../src/shared/cli/remote-argv.unit.test.ts | 95 ++++++++++++++++++- apps/cli/src/shared/cli/run.ts | 29 ++++-- 6 files changed, 215 insertions(+), 37 deletions(-) diff --git a/apps/cli/src/command-internal/target/remote-resolution.ts b/apps/cli/src/command-internal/target/remote-resolution.ts index 68b3f706d5..c912b51267 100644 --- a/apps/cli/src/command-internal/target/remote-resolution.ts +++ b/apps/cli/src/command-internal/target/remote-resolution.ts @@ -2,6 +2,7 @@ import { Effect, Option } from "effect"; import { CommandSettings } from "../../config/command-settings.service.ts"; import { CliArgs } from "../../shared/cli/cli-args.service.ts"; +import { bareRemoteOperand } from "../../shared/cli/remote-argv.ts"; import { CommandTarget } from "../../shared/output/command-target.service.ts"; import { Output } from "../../shared/output/output.service.ts"; import { yellow } from "../colors.ts"; @@ -12,26 +13,12 @@ import { resolveLinkBranchRef, type BranchResolution, } from "./branch-resolution.ts"; -import { couldNameBranch, type RemoteValue } from "./remote-value.ts"; +import type { RemoteValue } from "./remote-value.ts"; import { deprecationMessages, remoteFlagText } from "./target-deprecation.ts"; import { renderBranchTarget } from "./target-identity.ts"; import type { TargetDeprecation } from "./target-selector.ts"; import { TargetRemoteAmbiguousError } from "./target.errors.ts"; -/** - * The word a bare `--remote` is directly followed by when it could name a branch. A bare - * `--remote` ahead of an operand reaches the command as a flag plus a positional, so the word may - * be meant as `--remote=<word>`. - */ -const bareRemoteOperand = (argv: ReadonlyArray<string>): string | undefined => { - const end = argv.indexOf("--"); - const flags = end === -1 ? argv : argv.slice(0, end); - const index = flags.indexOf("--remote"); - const next = index === -1 ? undefined : flags[index + 1]; - if (next === undefined || next.startsWith("-")) return undefined; - return couldNameBranch(next) ? next : undefined; -}; - /** Prints one warning per deprecated selector flag the run used on stderr in every output format, rebuilt from the real command line when it is known. */ export const warnTargetDeprecations = Effect.fnUntraced(function* ( deprecations: ReadonlyArray<TargetDeprecation>, @@ -56,7 +43,9 @@ export const rejectAmbiguousRemote = Effect.fn("Target.checkRemoteOperand")(func ) { if (!Option.exists(remote, (value) => value._tag === "Linked")) return; const cliArgs = yield* Effect.serviceOption(CliArgs); - const operand = Option.isSome(cliArgs) ? bareRemoteOperand(cliArgs.value.args) : undefined; + const operand = Option.isSome(cliArgs) + ? bareRemoteOperand(cliArgs.value.args, cliArgs.value.remoteLeaf) + : undefined; if (operand === undefined) return; const parent = yield* resolveLinkedParentRef(); if (parent.kind !== "resolved") return; 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 2f7229fb73..e8583d0f91 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 @@ -10,10 +10,12 @@ import { } from "../../../tests/helpers/command-mocks.ts"; import { stripAnsi } from "../../../tests/helpers/ansi.ts"; import { mockOutput } from "../../../tests/helpers/mocks.ts"; +import { rootCommandForFeatures } from "../../cli/root.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 { CliArgs } from "../../shared/cli/cli-args.service.ts"; +import { remoteArgvLeafFor, rewriteRemoteArgv } from "../../shared/cli/run.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"; @@ -55,6 +57,13 @@ const branchRow = (name: string, projectRef = BRANCH_REF) => ({ with_data: true, }); +const root = rootCommandForFeatures(); + +const cliArgsFor = (raw: ReadonlyArray<string>) => { + const args = rewriteRemoteArgv(root, raw); + return { args, remoteLeaf: remoteArgvLeafFor(root, args) }; +}; + const projectRefResolver = (options: { readonly linked: boolean }) => { const flags: Array<Option.Option<string>> = []; const layer = Layer.succeed(ProjectRefResolver, { @@ -87,6 +96,7 @@ const run = ( readonly savedRef?: string; readonly cache?: { readonly ref: string; readonly name?: string }; readonly args?: ReadonlyArray<string>; + readonly cliArgs?: ReturnType<typeof cliArgsFor>; readonly format?: OutputFormat; readonly branches?: ReadonlyArray<ReturnType<typeof branchRow>> | "unavailable"; } = {}, @@ -139,7 +149,11 @@ const run = ( workdir: dir, projectId: options.projectId ?? Option.none(), }), - options.args === undefined ? Layer.empty : Layer.succeed(CliArgs, { args: options.args }), + options.cliArgs !== undefined + ? Layer.succeed(CliArgs, options.cliArgs) + : options.args === undefined + ? Layer.empty + : Layer.succeed(CliArgs, { args: options.args }), ), ), Effect.withTracer(tracer), @@ -356,6 +370,35 @@ describe("resolveTarget", () => { }), ); + it.effect.each([ + { name: "seed buckets", raw: ["seed", "--remote", "buckets"] }, + { name: "db push", raw: ["db", "--remote", "push"] }, + ])("does not look up the $name subcommand as a branch", ({ raw }) => + Effect.gen(function* () { + const result = yield* run("selectable", bare, { + cliArgs: cliArgsFor(raw), + savedRef: FILE_REF, + branches: "unavailable", + }); + expect(succeeded(result.exit)).toMatchObject({ kind: "linked", ref: FILE_REF }); + expect(result.apiRequests).toEqual([]); + }), + ); + + it.effect("still fails for a branch the command would otherwise take as its operand", () => + Effect.gen(function* () { + const result = yield* run("selectable", bare, { + cliArgs: cliArgsFor(["db", "pull", "--remote", "feature-x"]), + savedRef: FILE_REF, + branches: [branchRow("feature-x")], + }); + expect(failureOf(result.exit)).toMatchObject({ + _tag: "TargetRemoteAmbiguousError", + message: `feature-x is a branch of project ${FILE_REF}. Did you mean --remote=feature-x?`, + }); + }), + ); + it.effect("does not look up branches when no project is linked", () => Effect.gen(function* () { const result = yield* run("selectable", bare, { args, branches: [branchRow("feature-x")] }); diff --git a/apps/cli/src/shared/cli/cli-args.service.ts b/apps/cli/src/shared/cli/cli-args.service.ts index d7fc3825b2..1fccd2add8 100644 --- a/apps/cli/src/shared/cli/cli-args.service.ts +++ b/apps/cli/src/shared/cli/cli-args.service.ts @@ -1,7 +1,11 @@ import { Context } from "effect"; +import type { RemoteArgvLeaf } from "./remote-argv.ts"; + export interface CliArgsShape { readonly args: ReadonlyArray<string>; + /** How the resolved command reads its operands; present only when argv holds a bare `--remote`. */ + readonly remoteLeaf?: RemoteArgvLeaf | undefined; } export class CliArgs extends Context.Service<CliArgs, CliArgsShape>()("supabase/cli/CliArgs") {} diff --git a/apps/cli/src/shared/cli/remote-argv.ts b/apps/cli/src/shared/cli/remote-argv.ts index da742e8d2c..02ede7a248 100644 --- a/apps/cli/src/shared/cli/remote-argv.ts +++ b/apps/cli/src/shared/cli/remote-argv.ts @@ -18,15 +18,16 @@ export interface RemoteArgvLeaf { const LINKED_LITERALS: ReadonlySet<string> = new Set(["true", "yes", "on", "1"]); const DB_URL_PATTERN = /^postgres(ql)?:\/\//; -/** - * Joins a bare `--remote <word>` into `--remote=<word>` when the word cannot be one of the - * command's operands: the command takes none, the word is a project ref, a database URL or a - * boolean literal, or the command already has more operands than it takes. - */ -export function joinRemoteOperands( - args: ReadonlyArray<string>, - leaf: RemoteArgvLeaf, -): ReadonlyArray<string> { +interface RemoteArgvScan { + /** Indexes of the bare (non-flag, non-flag-value) tokens ahead of any `--`. */ + readonly bare: ReadonlyArray<number>; + /** Indexes of the `--remote` tokens that carry no inline value. */ + readonly remotes: ReadonlyArray<number>; + /** Index of the first `--`, or `args.length`. */ + readonly end: number; +} + +const scanRemoteArgv = (args: ReadonlyArray<string>, leaf: RemoteArgvLeaf): RemoteArgvScan => { const bare: Array<number> = []; const remotes: Array<number> = []; let end = args.length; @@ -50,6 +51,19 @@ export function joinRemoteOperands( index += 1; } } + return { bare, remotes, end }; +}; + +/** + * Joins a bare `--remote <word>` into `--remote=<word>` when the word cannot be one of the + * command's operands: the command takes none, the word is a project ref, a database URL or a + * boolean literal, or the command already has more operands than it takes. + */ +export function joinRemoteOperands( + args: ReadonlyArray<string>, + leaf: RemoteArgvLeaf, +): ReadonlyArray<string> { + const { bare, remotes, end } = scanRemoteArgv(args, leaf); const operandCount = bare.length - leaf.pathLength + (end < args.length ? args.length - end - 1 : 0); @@ -76,6 +90,32 @@ export function joinRemoteOperands( }); } +const UNKNOWN_LEAF: RemoteArgvLeaf = { + pathLength: 0, + operandCapacity: Infinity, + consumesNextToken: () => false, +}; + +/** + * The word a bare `--remote` is directly followed by when the command took it as an operand and + * it could name a branch, so it may be meant as `--remote=<word>`. Without a `leaf` no word is + * known to be a command-path segment. + */ +export function bareRemoteOperand( + args: ReadonlyArray<string>, + leaf: RemoteArgvLeaf = UNKNOWN_LEAF, +): string | undefined { + const { bare, remotes } = scanRemoteArgv(args, leaf); + for (const index of remotes) { + const word = args[index + 1]; + if (word === undefined || word.startsWith("-")) continue; + const ordinal = bare.indexOf(index + 1); + if (ordinal === -1 || ordinal < leaf.pathLength) continue; + if (couldNameBranch(word)) return word; + } + return undefined; +} + /** * The operand capacity of the command `path` resolves to, or `undefined` when it does not accept * `--remote` (as its own flag, or as a persistent flag of an ancestor such as `seed`). diff --git a/apps/cli/src/shared/cli/remote-argv.unit.test.ts b/apps/cli/src/shared/cli/remote-argv.unit.test.ts index 482af85b44..835b86d50c 100644 --- a/apps/cli/src/shared/cli/remote-argv.unit.test.ts +++ b/apps/cli/src/shared/cli/remote-argv.unit.test.ts @@ -1,8 +1,8 @@ import { describe, expect, it } from "vitest"; import { rootCommandForFeatures } from "../../cli/root.ts"; -import { joinRemoteOperands, type RemoteArgvLeaf } from "./remote-argv.ts"; -import { rewriteRemoteArgv } from "./run.ts"; +import { bareRemoteOperand, joinRemoteOperands, type RemoteArgvLeaf } from "./remote-argv.ts"; +import { remoteArgvLeafFor, rewriteRemoteArgv } from "./run.ts"; const REF = "abcdefghijklmnopqrst"; const URL = "postgresql://postgres:secret@db.example.com:5432/postgres"; @@ -125,6 +125,74 @@ describe("joinRemoteOperands", () => { }); }); +describe("bareRemoteOperand", () => { + const cases: ReadonlyArray<{ + readonly name: string; + readonly leaf: RemoteArgvLeaf | undefined; + readonly args: ReadonlyArray<string>; + readonly expected: string | undefined; + }> = [ + { + name: "returns the branch-shaped word the command took as its operand", + leaf: oneOperand, + args: ["db", "pull", "--remote", "feature-x"], + expected: "feature-x", + }, + { + name: "skips a word that is a command-path segment", + leaf: noOperands, + args: ["seed", "--remote", "buckets"], + expected: undefined, + }, + { + name: "skips a path segment that follows another path segment", + leaf: noOperands, + args: ["db", "--remote", "push"], + expected: undefined, + }, + { + name: "counts path segments past another flag's value", + leaf: oneOperand, + args: ["db", "-p", "secret", "pull", "--remote", "feature-x"], + expected: "feature-x", + }, + { + name: "treats every word as an operand when the command is unknown", + leaf: undefined, + args: ["seed", "--remote", "buckets"], + expected: "buckets", + }, + { + name: "skips a word that cannot name a branch", + leaf: oneOperand, + args: ["storage", "ls", "--remote", "ss:///bucket"], + expected: undefined, + }, + { + name: "skips a flag", + leaf: oneOperand, + args: ["db", "pull", "--remote", "--dry-run"], + expected: undefined, + }, + { + name: "skips a word after --", + leaf: oneOperand, + args: ["db", "pull", "--remote", "--", "feature-x"], + expected: undefined, + }, + { + name: "ignores an inline value", + leaf: oneOperand, + args: ["db", "pull", "--remote=feature-x", "other"], + expected: undefined, + }, + ]; + + it.each(cases)("$name", ({ leaf, args, expected }) => { + expect(bareRemoteOperand(args, leaf)).toBe(expected); + }); +}); + describe("rewriteRemoteArgv", () => { const root = rootCommandForFeatures(); const cases: ReadonlyArray<{ @@ -188,3 +256,26 @@ describe("rewriteRemoteArgv", () => { expect(rewriteRemoteArgv(root, args)).toEqual(expected); }); }); + +describe("bareRemoteOperand on the real command tree", () => { + const root = rootCommandForFeatures(); + const operandOf = (raw: ReadonlyArray<string>) => { + const args = rewriteRemoteArgv(root, raw); + return bareRemoteOperand(args, remoteArgvLeafFor(root, args)); + }; + + it.each([ + { name: "seed's subcommand", args: ["seed", "--remote", "buckets"] }, + { name: "db's subcommand", args: ["db", "--remote", "push"] }, + ])("does not read $name as an operand", ({ args }) => { + expect(operandOf(args)).toBeUndefined(); + }); + + it("reads db pull's migration-name operand, which may be a branch", () => { + expect(operandOf(["db", "pull", "--remote", "feature-x"])).toBe("feature-x"); + }); + + it("finds nothing once every word is joined into --remote=<word>", () => { + expect(operandOf(["db", "push", "--remote", "feature-x"])).toBeUndefined(); + }); +}); diff --git a/apps/cli/src/shared/cli/run.ts b/apps/cli/src/shared/cli/run.ts index b2ce3e2cd2..dee701baa0 100644 --- a/apps/cli/src/shared/cli/run.ts +++ b/apps/cli/src/shared/cli/run.ts @@ -59,7 +59,7 @@ import { resolveAgentOutputFormatFromArgs, ROOT_BOOLEAN_FLAGS, } from "./agent-output.ts"; -import { joinRemoteOperands, remoteOperandCapacity } from "./remote-argv.ts"; +import { joinRemoteOperands, remoteOperandCapacity, type RemoteArgvLeaf } from "./remote-argv.ts"; import { SuccessTrailer, successTrailerLayer } from "./success-trailer.ts"; import type { CliErrorSuggestionContext } from "./subcommand-flag-suggestions.ts"; import { @@ -188,17 +188,17 @@ function valueTakingFlagTokenPredicateForArgv( return (token) => isGlobalValueFlagToken(token) || leafPredicate(token); } -/** `args` with a bare `--remote <word>` joined to `--remote=<word>` wherever the command's operands leave no other reading. */ -export function rewriteRemoteArgv( +/** How the command `args` resolves to reads its operands, or `undefined` without `--remote` or when the command takes no `--remote`. */ +export function remoteArgvLeafFor( rootCommand: Command.Command.Any, args: ReadonlyArray<string>, -): ReadonlyArray<string> { - if (!args.includes("--remote")) return args; +): RemoteArgvLeaf | undefined { + if (!args.includes("--remote")) return undefined; const path = resolvedCommandPathForArgv(rootCommand, extractCommandPath(args)); const operandCapacity = remoteOperandCapacity(rootCommand, path); - if (operandCapacity === undefined) return args; + if (operandCapacity === undefined) return undefined; const isValueTakingToken = valueTakingFlagTokenPredicateForArgv(rootCommand, args); - return joinRemoteOperands(args, { + return { pathLength: path.length - 1, operandCapacity, consumesNextToken: (token) => { @@ -208,7 +208,16 @@ export function rewriteRemoteArgv( shortClusterConsumesNextToken(token, isValueTakingToken) ); }, - }); + }; +} + +/** `args` with a bare `--remote <word>` joined to `--remote=<word>` wherever the command's operands leave no other reading. */ +export function rewriteRemoteArgv( + rootCommand: Command.Command.Any, + args: ReadonlyArray<string>, +): ReadonlyArray<string> { + const leaf = remoteArgvLeafFor(rootCommand, args); + return leaf === undefined ? args : joinRemoteOperands(args, leaf); } /** Whether `token` is an occurrence of the long or exact-short boolean flag `name`, bare or valued (`--help`, `--help=false`). */ @@ -623,7 +632,9 @@ function cliProgramFor< Layer.provideMerge(projectLinkStateLayer), Layer.provideMerge(runtimeLayer), Layer.provideMerge(fallbackCommandLayer), - Layer.provideMerge(Layer.succeed(CliArgs, { args })), + Layer.provideMerge( + Layer.succeed(CliArgs, { args, remoteLeaf: remoteArgvLeafFor(rootCommand, args) }), + ), Layer.provideMerge(BunServices.layer), ); return withoutParseErrorHelpDump( From 2a484ef208011829b3340598b72dc6f2f2b15a99 Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Mon, 19 Oct 2026 12:05:44 +0100 Subject: [PATCH 123/291] docs(cli): describe the migration commands through --remote and its aliases --- .../commands/migration/down/SIDE_EFFECTS.md | 82 ++++++++----- .../commands/migration/fetch/SIDE_EFFECTS.md | 76 ++++++++---- .../commands/migration/list/SIDE_EFFECTS.md | 90 +++++++++----- .../commands/migration/repair/SIDE_EFFECTS.md | 110 +++++++++++------- .../commands/migration/squash/SIDE_EFFECTS.md | 77 +++++++----- .../src/commands/migration/up/SIDE_EFFECTS.md | 82 ++++++++----- 6 files changed, 334 insertions(+), 183 deletions(-) diff --git a/apps/cli/src/commands/migration/down/SIDE_EFFECTS.md b/apps/cli/src/commands/migration/down/SIDE_EFFECTS.md index 47d9629a29..fc5a18031a 100644 --- a/apps/cli/src/commands/migration/down/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/migration/down/SIDE_EFFECTS.md @@ -2,44 +2,54 @@ ## Files Read -| 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 | +| Path | Format | When | +| ---------------------------------------------- | ---------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `<workdir>/supabase/migrations/` | directory | always, to read migration files | +| `<workdir>/supabase/config.toml` | TOML | always, for the `[db]` settings, with a matched `[remotes.<ref>]` block over the base for a remote project (flag > shell env > project `.env*` > config) | +| `~/.supabase/access-token` | plain text | when `SUPABASE_ACCESS_TOKEN` unset and the target is a remote project (or a branch is looked up) | +| `<workdir>/supabase/.temp/project-ref` | plain text | a remote project: resolves the ref (bare `--remote` with no `SUPABASE_PROJECT_ID`) and tells whether it is the linked project; with no selector, only to word the linked-checkout hint | +| `<workdir>/supabase/.temp/linked-project.json` | JSON | to name the project in the `Target:` banner and the linked-checkout hint, and as the parent project for `--remote=<branch>` | ## Files Written -| Path | Format | When | -| ---- | ------ | ---- | -| — | — | — | +| Path | Format | When | +| ---------------------------------------------- | ------ | ----------------------------------------------------------- | +| `<workdir>/supabase/.temp/linked-project.json` | JSON | remote project, on completion — caches the project identity | ## API Routes -| Method | Path | Auth | Request body | Response (used fields) | -| ------ | ---- | ---- | ------------ | ---------------------- | -| — | — | — | — | — | +Local targets and database URLs make none. + +| Method | Path | Auth | Request body | Response (used fields) | +| -------- | ------------------------------------------- | ------------ | ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `GET` | `/v1/projects` | Bearer token | none | `[{id, name, organization_slug, region}]` — project picker: bare `--remote` with no ref from `SUPABASE_PROJECT_ID` or `.temp/project-ref`, with stdin and stdout TTYs and a non-JSON output format | +| `GET` | `/v1/projects/{parent}/branches` | Bearer token | none | `[{id, name, project_ref, is_default}]` — only for `--remote=<branch>` (or the deprecated `--project-ref=<branch>`), and for a bare `--remote` followed by a word that could name a branch; `{parent}` is `SUPABASE_PROJECT_ID`, else the linked project | +| `POST` | `/v1/projects/{ref}/cli/login-role` | Bearer token | `{ read_only: false }` | temporary `role` and `password` — remote project with no database password | +| `GET` | `/v1/projects/{ref}/config/database/pooler` | Bearer token | none | primary pooler `connection_string` — remote project on an IPv4-only network (direct host unreachable), only when the ref came from `--remote=<ref>` or `--remote=<branch>`, or differs from the linked project; a differing ref ignores the saved `.temp/pooler-url` | +| `POST` | `/v1/projects/{ref}/network-bans/retrieve` | Bearer token | none | `banned_ipv4_addresses` — remote project, from the 3rd failed temporary-role connect | +| `DELETE` | `/v1/projects/{ref}/network-bans` | Bearer token | `{ ipv4_addresses, requester_ip: false }` | none — remote project, only when bans are listed and `--yes` or a shell `SUPABASE_YES` is set (clears every listed address); otherwise the connect keeps retrying and fails with `LinkedDbNetworkBannedError` once the retries run out | +| `GET` | `/v1/projects/{ref}` | Bearer token | none | `{ref, name, organization_id, organization_slug}` — linked-project cache miss only (best-effort, post-run): written only when `.temp/linked-project.json` doesn't exist, `.temp/project-ref` doesn't name another ref, an access token exists and the lookup returns 200 | ## Environment Variables | 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_ACCESS_TOKEN` | auth token for a remote project | no (falls back to keyring → `~/.supabase/access-token`) | +| `SUPABASE_PROJECT_ID` | project ref for a bare `--remote`, and the parent project for `--remote=<branch>` | 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` | conflicting target selectors (see Notes) | -| `1` | hosted target without `--yes` in a run that cannot prompt (`HostedMutationConsentRequiredError`) | -| `1` | hosted confirmation declined (`context canceled`) | +| Code | Condition | +| ---- | ---------------------------------------------------------------------------------------------------------------------- | +| `0` | success | +| `1` | database connection failure | +| `1` | migration SQL execution error | +| `1` | conflicting target selectors (see Notes) | +| `1` | `--remote=<branch>` matches no branch of the linked project (`LinkBranchNotFoundError`, lists the branches that exist) | +| `1` | hosted target without `--yes` in a run that cannot prompt (`HostedMutationConsentRequiredError`) | +| `1` | hosted confirmation declined (`context canceled`) | ## Output @@ -60,7 +70,7 @@ Same structured result delivered as an NDJSON `result` event. ## Prompts -- A hosted target (the linked project or a non-local `--db-url`) asks once, after the migrations +- A hosted target (a remote project or a non-local database 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; @@ -70,12 +80,26 @@ Same structured result delivered as an NDJSON `result` event. ## 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. +- **Target.** With no selector the command runs against the local database; when the directory is + linked the local `Target:` banner ends with `· default; pass --remote for <project>`. `--remote` + targets the linked project (`SUPABASE_PROJECT_ID`, else `.temp/project-ref`). `--remote=<value>` + is read as a project ref (20 lowercase letters), a database URL (`postgres://` or + `postgresql://`), `true|yes|on|1` (the linked project), or otherwise the name of a branch of the + linked project, which resolves to that branch's own project. `false|no|off|0`, `--no-remote` and + an empty value fail with `Leave out --remote to target the local database.` A bare `--remote` + followed by a word that is a branch of the linked project fails with a + `Did you mean --remote=<word>?` hint, since the word may have been meant as the flag's value. The + resolved target is announced on stderr (`Target: …`) in every output format and, in JSON results, + as a `target` object. +- **Deprecated selectors.** `--linked`, `--project-ref`, `--db-url` and `--local` are hidden aliases + that still select their target and print a warning on stderr naming the replacement, for example + `--linked is deprecated and will be removed in v4. Use --remote instead: supabase migration down --remote` + and + `--local isn't needed (local is the default) and will be removed in v4: supabase migration down`; + a database URL is never echoed. Two selectors together fail + (`Only one of --remote, --linked, --db-url and --local can be set; got …`), `--project-ref` cannot + be combined with `--local`, `--db-url` or `--remote=<value>`, an empty `--db-url` or + `--project-ref` is rejected, and `--linked=false` selects nothing. - `--last` (default 1) resets up to the last n migration versions; must be `> 0` and `<` the number of applied migrations. - Takes no positional arguments. diff --git a/apps/cli/src/commands/migration/fetch/SIDE_EFFECTS.md b/apps/cli/src/commands/migration/fetch/SIDE_EFFECTS.md index 1cb52b3b26..608d3ee592 100644 --- a/apps/cli/src/commands/migration/fetch/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/migration/fetch/SIDE_EFFECTS.md @@ -2,41 +2,52 @@ ## Files Read -| 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 | +| Path | Format | When | +| ---------------------------------------------- | ---------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `<workdir>/supabase/config.toml` | TOML | always, for the `[db]` settings, with a matched `[remotes.<ref>]` block over the base for a remote project (flag > shell env > project `.env*` > config) | +| `~/.supabase/access-token` | plain text | when `SUPABASE_ACCESS_TOKEN` unset and the target is a remote project (or a branch is looked up) | +| `<workdir>/supabase/.temp/project-ref` | plain text | a remote project: resolves the ref (bare `--remote` with no `SUPABASE_PROJECT_ID`) and tells whether it is the linked project; with no selector, only to word the linked-checkout hint | +| `<workdir>/supabase/.temp/linked-project.json` | JSON | to name the project in the `Target:` banner and the linked-checkout hint, and as the parent project for `--remote=<branch>` | +| `<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 | 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 | +| `<workdir>/supabase/.temp/linked-project.json` | JSON | remote project, on completion — caches the project identity | ## API Routes -| Method | Path | Auth | Request body | Response (used fields) | -| ------ | ---- | ---- | ------------ | ---------------------- | -| — | — | — | — | — | +Local targets and database URLs make none. + +| Method | Path | Auth | Request body | Response (used fields) | +| -------- | ------------------------------------------- | ------------ | ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `GET` | `/v1/projects` | Bearer token | none | `[{id, name, organization_slug, region}]` — project picker: bare `--remote` with no ref from `SUPABASE_PROJECT_ID` or `.temp/project-ref`, with stdin and stdout TTYs and a non-JSON output format | +| `GET` | `/v1/projects/{parent}/branches` | Bearer token | none | `[{id, name, project_ref, is_default}]` — only for `--remote=<branch>` (or the deprecated `--project-ref=<branch>`), and for a bare `--remote` followed by a word that could name a branch; `{parent}` is `SUPABASE_PROJECT_ID`, else the linked project | +| `POST` | `/v1/projects/{ref}/cli/login-role` | Bearer token | `{ read_only: false }` | temporary `role` and `password` — remote project with no database password | +| `GET` | `/v1/projects/{ref}/config/database/pooler` | Bearer token | none | primary pooler `connection_string` — remote project on an IPv4-only network (direct host unreachable), only when the ref came from `--remote=<ref>` or `--remote=<branch>`, or differs from the linked project; a differing ref ignores the saved `.temp/pooler-url` | +| `POST` | `/v1/projects/{ref}/network-bans/retrieve` | Bearer token | none | `banned_ipv4_addresses` — remote project, from the 3rd failed temporary-role connect | +| `DELETE` | `/v1/projects/{ref}/network-bans` | Bearer token | `{ ipv4_addresses, requester_ip: false }` | none — remote project, only when bans are listed and `--yes` or a shell `SUPABASE_YES` is set (clears every listed address); otherwise the connect keeps retrying and fails with `LinkedDbNetworkBannedError` once the retries run out | +| `GET` | `/v1/projects/{ref}` | Bearer token | none | `{ref, name, organization_id, organization_slug}` — linked-project cache miss only (best-effort, post-run): written only when `.temp/linked-project.json` doesn't exist, `.temp/project-ref` doesn't name another ref, an access token exists and the lookup returns 200 | ## Environment Variables -| 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) | +| Variable | Purpose | Required? | +| ----------------------- | --------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- | +| `SUPABASE_ACCESS_TOKEN` | auth token for a remote project | no (falls back to keyring → `~/.supabase/access-token`) | +| `SUPABASE_PROJECT_ID` | project ref for a bare `--remote`, and the parent project for `--remote=<branch>` | no | +| `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` | conflicting target flags (see Notes) | +| Code | Condition | +| ---- | ---------------------------------------------------------------------------------------------------------------------- | +| `0` | success | +| `1` | database connection failure | +| `1` | failed to write migration files | +| `1` | conflicting target selectors (see Notes) | +| `1` | `--remote=<branch>` matches no branch of the linked project (`LinkBranchNotFoundError`, lists the branches that exist) | ## Output @@ -70,11 +81,26 @@ Same structured `files` result delivered as an NDJSON `result` event. ## Notes -- 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. +- **Target.** With no selector the command runs against the local database; when the directory is + linked the local `Target:` banner ends with `· default; pass --remote for <project>`. `--remote` + targets the linked project (`SUPABASE_PROJECT_ID`, else `.temp/project-ref`). `--remote=<value>` + is read as a project ref (20 lowercase letters), a database URL (`postgres://` or + `postgresql://`), `true|yes|on|1` (the linked project), or otherwise the name of a branch of the + linked project, which resolves to that branch's own project. `false|no|off|0`, `--no-remote` and + an empty value fail with `Leave out --remote to target the local database.` A bare `--remote` + followed by a word that is a branch of the linked project fails with a + `Did you mean --remote=<word>?` hint, since the word may have been meant as the flag's value. The + resolved target is announced on stderr (`Target: …`) in every output format and, in JSON results, + as a `target` object. +- **Deprecated selectors.** `--linked`, `--project-ref`, `--db-url` and `--local` are hidden aliases + that still select their target and print a warning on stderr naming the replacement, for example + `--linked is deprecated and will be removed in v4. Use --remote instead: supabase migration fetch --remote` + and + `--local isn't needed (local is the default) and will be removed in v4: supabase migration fetch`; + a database URL is never echoed. Two selectors together fail + (`Only one of --remote, --linked, --db-url and --local can be set; got …`), `--project-ref` cannot + be combined with `--local`, `--db-url` or `--remote=<value>`, an empty `--db-url` or + `--project-ref` is rejected, and `--linked=false` selects nothing. - 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/list/SIDE_EFFECTS.md b/apps/cli/src/commands/migration/list/SIDE_EFFECTS.md index b460207bc8..08f5a32d15 100644 --- a/apps/cli/src/commands/migration/list/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/migration/list/SIDE_EFFECTS.md @@ -2,32 +2,41 @@ ## Files Read -| 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 | +| Path | Format | When | +| ---- | ------ | ---- | +| `<workdir>/supabase/config.toml` | TOML | always, for the `[db]` settings, with a matched `[remotes.<ref>]` block over the base for a remote project (flag > shell env > project `.env*` > config) | +| `~/.supabase/access-token` | plain text | when `SUPABASE_ACCESS_TOKEN` unset and the target is a remote project (or a branch is looked up) | +| `<workdir>/supabase/.temp/project-ref` | plain text | a remote project: resolves the ref (bare `--remote` with no `SUPABASE_PROJECT_ID`) and tells whether it is the linked project; with no selector, only to word the linked-checkout hint | +| `<workdir>/supabase/.temp/linked-project.json` | JSON | to name the project in the `Target:` banner and the linked-checkout hint, and as the parent project for `--remote=<branch>` | +| `<workdir>/supabase/migrations/` | directory | always, to list local migration files | ## Files Written -| Path | Format | When | -| ---- | ------ | ---- | -| — | — | — | +| Path | Format | When | +| ---------------------------------------------- | ------ | ----------------------------------------------------------- | +| `<workdir>/supabase/.temp/linked-project.json` | JSON | remote project, on completion — caches the project identity | ## API Routes +Local targets and database URLs make none. + | Method | Path | Auth | Request body | Response (used fields) | | ------ | ---- | ---- | ------------ | ---------------------- | -| — | — | — | — | — | +| `GET` | `/v1/projects` | Bearer token | none | `[{id, name, organization_slug, region}]` — project picker: bare `--remote` with no ref from `SUPABASE_PROJECT_ID` or `.temp/project-ref`, with stdin and stdout TTYs and a non-JSON output format | +| `GET` | `/v1/projects/{parent}/branches` | Bearer token | none | `[{id, name, project_ref, is_default}]` — only for `--remote=<branch>` (or the deprecated `--project-ref=<branch>`), and for a bare `--remote` followed by a word that could name a branch; `{parent}` is `SUPABASE_PROJECT_ID`, else the linked project | +| `POST` | `/v1/projects/{ref}/cli/login-role` | Bearer token | `{ read_only: false }` | temporary `role` and `password` — remote project with no database password | +| `GET` | `/v1/projects/{ref}/config/database/pooler` | Bearer token | none | primary pooler `connection_string` — remote project on an IPv4-only network (direct host unreachable), only when the ref came from `--remote=<ref>` or `--remote=<branch>`, or differs from the linked project; a differing ref ignores the saved `.temp/pooler-url` | +| `POST` | `/v1/projects/{ref}/network-bans/retrieve` | Bearer token | none | `banned_ipv4_addresses` — remote project, from the 3rd failed temporary-role connect | +| `DELETE` | `/v1/projects/{ref}/network-bans` | Bearer token | `{ ipv4_addresses, requester_ip: false }` | none — remote project, only when bans are listed and `--yes` or a shell `SUPABASE_YES` is set (clears every listed address); otherwise the connect keeps retrying and fails with `LinkedDbNetworkBannedError` once the retries run out | +| `GET` | `/v1/projects/{ref}` | Bearer token | none | `{ref, name, organization_id, organization_slug}` — linked-project cache miss only (best-effort, post-run): written only when `.temp/linked-project.json` doesn't exist, `.temp/project-ref` doesn't name another ref, an access token exists and the lookup returns 200 | ## Environment Variables -| 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 | +| Variable | Purpose | Required? | +| ----------------------- | --------------------------------------------------------------------------------- | ------------------------------------------------------- | +| `SUPABASE_ACCESS_TOKEN` | auth token for a remote project | no (falls back to keyring → `~/.supabase/access-token`) | +| `SUPABASE_DB_PASSWORD` | password for the remote project's database connection (`--password`/`-p` wins) | no | +| `SUPABASE_PROJECT_ID` | project ref for a bare `--remote`, and the parent project for `--remote=<branch>` | no | ## Exit Codes @@ -35,15 +44,16 @@ | ---- | -------------------------------------------- | | `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) | +| `1` | `--password` with a local target or `--remote=<db-url>` | +| `1` | failed to open migrations directory | +| `1` | conflicting target selectors (see Notes) | +| `1` | `--remote=<branch>` matches no branch of the linked project (`LinkBranchNotFoundError`, lists the branches that exist) | ## Output ### `--output-format text` -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 +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 remote project or a database URL and `Local DB` for the local database. Queries `SELECT version FROM supabase_migrations.schema_migrations ORDER BY version` (a missing table → empty database column). @@ -58,17 +68,35 @@ Same structured `migrations` result delivered as an NDJSON `result` event. ## 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. +- **Target.** With no selector the command runs against the local database; when the directory is + linked the local `Target:` banner ends with `· default; pass --remote for <project>`. `--remote` + targets the linked project (`SUPABASE_PROJECT_ID`, else `.temp/project-ref`). `--remote=<value>` + is read as a project ref (20 lowercase letters), a database URL (`postgres://` or + `postgresql://`), `true|yes|on|1` (the linked project), or otherwise the name of a branch of the + linked project, which resolves to that branch's own project. `false|no|off|0`, `--no-remote` and + an empty value fail with `Leave out --remote to target the local database.` A bare `--remote` + followed by a word that is a branch of the linked project fails with a + `Did you mean --remote=<word>?` hint, since the word may have been meant as the flag's value. The + resolved target is announced on stderr (`Target: …`) in every output format and, in JSON results, + as a `target` object. +- **Deprecated selectors.** `--linked`, `--project-ref`, `--db-url` and `--local` are hidden aliases + that still select their target and print a warning on stderr naming the replacement, for example + `--linked is deprecated and will be removed in v4. Use --remote instead: supabase migration list --remote` + and + `--local isn't needed (local is the default) and will be removed in v4: supabase migration list`; + a database URL is never echoed. Two selectors together fail + (`Only one of --remote, --linked, --db-url and --local can be set; got …`), `--project-ref` cannot + be combined with `--local`, `--db-url` or `--remote=<value>`, an empty `--db-url` or + `--project-ref` is rejected, and `--linked=false` selects nothing. - 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 +- **`--password`** applies only to a remote project. With `--remote=<db-url>` (or `--db-url`) it + fails with + `--password can't be used with --remote=<db-url>. Put the password in the connection string: postgres://USER:PASSWORD@HOST:PORT/postgres`; + with a local target it fails with + `--password only applies to a linked project. The local database uses [db].password from supabase/config.toml.` + followed by `To use the linked project instead, pass --remote.` when the directory is linked (exit + 1). For a remote 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). + (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). diff --git a/apps/cli/src/commands/migration/repair/SIDE_EFFECTS.md b/apps/cli/src/commands/migration/repair/SIDE_EFFECTS.md index 5d6f80678d..eb66b3ed1f 100644 --- a/apps/cli/src/commands/migration/repair/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/migration/repair/SIDE_EFFECTS.md @@ -2,46 +2,56 @@ ## Files Read -| 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 | +| Path | Format | When | +| ---------------------------------------------- | ---------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `<workdir>/supabase/config.toml` | TOML | always, for the `[db]` settings, with a matched `[remotes.<ref>]` block over the base for a remote project (flag > shell env > project `.env*` > config) | +| `~/.supabase/access-token` | plain text | when `SUPABASE_ACCESS_TOKEN` unset and the target is a remote project (or a branch is looked up) | +| `<workdir>/supabase/.temp/project-ref` | plain text | a remote project: resolves the ref (bare `--remote` with no `SUPABASE_PROJECT_ID`) and tells whether it is the linked project; with no selector, only to word the linked-checkout hint | +| `<workdir>/supabase/.temp/linked-project.json` | JSON | to name the project in the `Target:` banner and the linked-checkout hint, and as the parent project for `--remote=<branch>` | +| `<workdir>/supabase/migrations/` | directory | `--status applied` (the matching file per version) and repair-all (every local version), before connecting | ## Files Written -| Path | Format | When | -| ---- | ------ | ---- | -| — | — | — | +| Path | Format | When | +| ---------------------------------------------- | ------ | ----------------------------------------------------------- | +| `<workdir>/supabase/.temp/linked-project.json` | JSON | remote project, on completion — caches the project identity | ## API Routes -| Method | Path | Auth | Request body | Response (used fields) | -| ------ | ---- | ---- | ------------ | ---------------------- | -| — | — | — | — | — | +Local targets and database URLs make none. + +| Method | Path | Auth | Request body | Response (used fields) | +| -------- | ------------------------------------------- | ------------ | ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `GET` | `/v1/projects` | Bearer token | none | `[{id, name, organization_slug, region}]` — project picker: bare `--remote` with no ref from `SUPABASE_PROJECT_ID` or `.temp/project-ref`, with stdin and stdout TTYs and a non-JSON output format | +| `GET` | `/v1/projects/{parent}/branches` | Bearer token | none | `[{id, name, project_ref, is_default}]` — only for `--remote=<branch>` (or the deprecated `--project-ref=<branch>`), and for a bare `--remote` followed by a word that could name a branch; `{parent}` is `SUPABASE_PROJECT_ID`, else the linked project | +| `POST` | `/v1/projects/{ref}/cli/login-role` | Bearer token | `{ read_only: false }` | temporary `role` and `password` — remote project with no database password | +| `GET` | `/v1/projects/{ref}/config/database/pooler` | Bearer token | none | primary pooler `connection_string` — remote project on an IPv4-only network (direct host unreachable), only when the ref came from `--remote=<ref>` or `--remote=<branch>`, or differs from the linked project; a differing ref ignores the saved `.temp/pooler-url` | +| `POST` | `/v1/projects/{ref}/network-bans/retrieve` | Bearer token | none | `banned_ipv4_addresses` — remote project, from the 3rd failed temporary-role connect | +| `DELETE` | `/v1/projects/{ref}/network-bans` | Bearer token | `{ ipv4_addresses, requester_ip: false }` | none — remote project, only when bans are listed and `--yes` or a shell `SUPABASE_YES` is set (clears every listed address); otherwise the connect keeps retrying and fails with `LinkedDbNetworkBannedError` once the retries run out | +| `GET` | `/v1/projects/{ref}` | Bearer token | none | `{ref, name, organization_id, organization_slug}` — linked-project cache miss only (best-effort, post-run): written only when `.temp/linked-project.json` doesn't exist, `.temp/project-ref` doesn't name another ref, an access token exists and the lookup returns 200 | ## Environment Variables -| 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 | +| Variable | Purpose | Required? | +| ----------------------- | ------------------------------------------------------------------------------------------ | ------------------------------------------------------- | +| `SUPABASE_ACCESS_TOKEN` | auth token for a remote project | no (falls back to keyring → `~/.supabase/access-token`) | +| `SUPABASE_DB_PASSWORD` | password for the remote project's database connection (`--password`/`-p` takes precedence) | no | +| `SUPABASE_PROJECT_ID` | project ref for a bare `--remote`, and the parent project for `--remote=<branch>` | 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 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`) | +| Code | Condition | +| ---- | ---------------------------------------------------------------------------------------------------------------------- | +| `0` | success | +| `1` | database connection failure | +| `1` | `--password` with a local target or `--remote=<db-url>` | +| `1` | invalid or missing `--status` flag | +| `1` | conflicting target selectors (see Notes) | +| `1` | `--remote=<branch>` matches no branch of the linked project (`LinkBranchNotFoundError`, lists the branches that exist) | +| `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 @@ -71,7 +81,7 @@ Same structured result delivered as an NDJSON `result` event. ## Prompts -- A hosted target (the linked project or a non-local `--db-url`) asks once, after the versions and +- A hosted target (a remote project or a non-local database 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 @@ -81,12 +91,26 @@ Same structured result delivered as an NDJSON `result` event. ## 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. +- **Target.** With no selector the command runs against the local database; when the directory is + linked the local `Target:` banner ends with `· default; pass --remote for <project>`. `--remote` + targets the linked project (`SUPABASE_PROJECT_ID`, else `.temp/project-ref`). `--remote=<value>` + is read as a project ref (20 lowercase letters), a database URL (`postgres://` or + `postgresql://`), `true|yes|on|1` (the linked project), or otherwise the name of a branch of the + linked project, which resolves to that branch's own project. `false|no|off|0`, `--no-remote` and + an empty value fail with `Leave out --remote to target the local database.` A bare `--remote` + followed by a word that is a branch of the linked project fails with a + `Did you mean --remote=<word>?` hint, since the word may have been meant as the flag's value. The + resolved target is announced on stderr (`Target: …`) in every output format and, in JSON results, + as a `target` object. +- **Deprecated selectors.** `--linked`, `--project-ref`, `--db-url` and `--local` are hidden aliases + that still select their target and print a warning on stderr naming the replacement, for example + `--linked is deprecated and will be removed in v4. Use --remote instead: supabase migration repair --remote` + and + `--local isn't needed (local is the default) and will be removed in v4: supabase migration repair`; + a database URL is never echoed. Two selectors together fail + (`Only one of --remote, --linked, --db-url and --local can be set; got …`), `--project-ref` cannot + be combined with `--local`, `--db-url` or `--remote=<value>`, an empty `--db-url` or + `--project-ref` is rejected, and `--linked=false` selects nothing. - `--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 @@ -94,10 +118,14 @@ Same structured result delivered as an NDJSON `result` event. - In `applied` mode, reads the matching `supabase/migrations/<version>_*.sql` file 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 +- **`--password`** applies only to a remote project. With `--remote=<db-url>` (or `--db-url`) it + fails with + `--password can't be used with --remote=<db-url>. Put the password in the connection string: postgres://USER:PASSWORD@HOST:PORT/postgres`; + with a local target it fails with + `--password only applies to a linked project. The local database uses [db].password from supabase/config.toml.` + followed by `To use the linked project instead, pass --remote.` when the directory is linked (exit + 1). For a remote 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). + (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). diff --git a/apps/cli/src/commands/migration/squash/SIDE_EFFECTS.md b/apps/cli/src/commands/migration/squash/SIDE_EFFECTS.md index 649d9c0b3e..5159f2981f 100644 --- a/apps/cli/src/commands/migration/squash/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/migration/squash/SIDE_EFFECTS.md @@ -29,8 +29,9 @@ 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 target — skipped when `--project-ref` (or `SUPABASE_PROJECT_ID`) is set | -| `~/.supabase/access-token` | plain text | linked target without `--password`/`SUPABASE_ACCESS_TOKEN` | +| `<workdir>/supabase/.temp/{project-ref,postgres-version,pooler-url}` | plain text | a remote project: `project-ref` resolves the ref (bare `--remote` with no `SUPABASE_PROJECT_ID`) and tells whether it is the linked project; with no selector, `project-ref` only, to word the linked-checkout hint | +| `<workdir>/supabase/.temp/linked-project.json` | JSON | to name the project in the `Target:` banner and the linked-checkout hint, and as the parent project for `--remote=<branch>` | +| `~/.supabase/access-token` | plain text | a remote project without `--password`/`SUPABASE_ACCESS_TOKEN`, or when a branch is looked up | | `~/.docker/config.json` + Docker context store | JSON | resolving the Docker hostname for shadow/pg_dump containers | ## Files Written @@ -40,7 +41,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 target (post-run cache, even when the command itself fails or the confirmation is declined) | +| `<workdir>/supabase/.temp/linked-project.json` | JSON | remote project (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) | @@ -75,17 +76,19 @@ live shadow available. ## API Routes -| 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) | +| Method | Path | Auth | Purpose | +| ----------- | ----------------------------------------------------------------------------- | ------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| — | — | — | local target or database URL: none | +| GET | `/v1/projects` | Bearer | project picker: bare `--remote` with no ref from `SUPABASE_PROJECT_ID` or `.temp/project-ref`, interactive terminal only | +| GET | `/v1/projects/{parent}/branches` | Bearer | only for `--remote=<branch>` (or the deprecated `--project-ref=<branch>`), and for a bare `--remote` followed by a word that could name a branch; `{parent}` is `SUPABASE_PROJECT_ID`, else the linked project | +| POST | `/v1/projects/{ref}/cli/login-role` | Bearer | remote project: temp login role when no password | +| GET | `/v1/projects/{ref}/config/database/pooler` | Bearer | remote project: IPv4 pooler fallback (IPv6-only network), only when the ref came from `--remote=<ref>` or `--remote=<branch>`, or differs from the linked project | +| POST/DELETE | `/v1/projects/{ref}/network-bans/retrieve`, `/v1/projects/{ref}/network-bans` | Bearer | remote project: unban during pooler login retry, only under `--yes` | +| GET | `/v1/projects/{ref}` | Bearer | remote project: linked-project cache (post-run, unconditional) | ## Environment Variables -`SUPABASE_YES` (shell only; confirms a hosted run), `SUPABASE_TARGET`, `SUPABASE_DB_PASSWORD` (linked target only), `SUPABASE_ACCESS_TOKEN`, `SUPABASE_SERVICES_HOSTNAME`, +`SUPABASE_YES` (shell only; confirms a hosted run), `SUPABASE_PROJECT_ID`, `SUPABASE_DB_PASSWORD` (remote project only), `SUPABASE_ACCESS_TOKEN`, `SUPABASE_SERVICES_HOSTNAME`, `DOCKER_HOST`/`DOCKER_CONTEXT`/`DOCKER_CONFIG`, `SUPABASE_NETWORK_ID`, `SUPABASE_INTERNAL_IMAGE_REGISTRY`, `SUPABASE_USE_SLIM_IMAGES` (current-pin shadow Postgres and PG15+ realtime/storage/auth migrate-job images → slim `ghcr.io/supabase/cli`; historical pins, PG14, OrioleDB tags the catalog does not pin, flag-off `15.8.1.085` stay on docker.io), `SUPABASE_PROJECT_ID`, `SUPABASE_DEBUG`, `SUPABASE_EXPERIMENTAL`, `SUPABASE_SHADOW_CACHE` (stack shadow baseline cache; on by default, falsy disables restore and publication). @@ -96,7 +99,8 @@ live shadow available. | ----- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `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` | `--password` with a local target or `--remote=<db-url>` | +| `1` | `--remote=<branch>` matches no branch of the linked project (`LinkBranchNotFoundError`, lists the branches that exist) | | `1` | hosted target without `--yes` in a run that cannot prompt (`HostedMutationConsentRequiredError`); hosted confirmation declined (`context canceled`) — nothing is changed | | `130` | SIGINT | @@ -108,7 +112,7 @@ stderr, in order (path-dependent): ``` 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) +Loading config override: [remotes.<ref>] (only when a remote project 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) @@ -146,25 +150,42 @@ code or the rest of the payload. ## 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. 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 +- **Target.** With no selector the command runs against the local database; when the directory is + linked the local `Target:` banner ends with `· default; pass --remote for <project>`. `--remote` + targets the linked project (`SUPABASE_PROJECT_ID`, else `.temp/project-ref`). `--remote=<value>` + is read as a project ref (20 lowercase letters), a database URL (`postgres://` or + `postgresql://`), `true|yes|on|1` (the linked project), or otherwise the name of a branch of the + linked project, which resolves to that branch's own project. `false|no|off|0`, `--no-remote` and + an empty value fail with `Leave out --remote to target the local database.` A bare `--remote` + followed by a word that is a branch of the linked project fails with a + `Did you mean --remote=<word>?` hint, since the word may have been meant as the flag's value. 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. +- **Deprecated selectors.** `--linked`, `--project-ref`, `--db-url` and `--local` are hidden aliases + that still select their target and print a warning on stderr naming the replacement, for example + `--linked is deprecated and will be removed in v4. Use --remote instead: supabase migration squash --remote` + and + `--local isn't needed (local is the default) and will be removed in v4: supabase migration squash`; + a database URL is never echoed. Two selectors together fail + (`Only one of --remote, --linked, --db-url and --local can be set; got …`), `--project-ref` cannot + be combined with `--local`, `--db-url` or `--remote=<value>`, an empty `--db-url` or + `--project-ref` is rejected, and `--linked=false` selects nothing. +- **Consent.** A hosted target (a remote project or a non-local database 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 +- **`--password`** applies only to a remote project. With `--remote=<db-url>` (or `--db-url`) it + fails with + `--password can't be used with --remote=<db-url>. Put the password in the connection string: postgres://USER:PASSWORD@HOST:PORT/postgres`; + with a local target it fails with + `--password only applies to a linked project. The local database uses [db].password from supabase/config.toml.` + followed by `To use the linked project instead, pass --remote.` when the directory is linked (exit + 1). For a remote 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). + (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). - The shadow gets `setupDatabase` only — **no** `CREATE DATABASE contrib_regression` (unlike `db diff`/`db pull`). - `--version` is compared **lexically** against zero-padded timestamps. diff --git a/apps/cli/src/commands/migration/up/SIDE_EFFECTS.md b/apps/cli/src/commands/migration/up/SIDE_EFFECTS.md index 514c78f370..074c1f1769 100644 --- a/apps/cli/src/commands/migration/up/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/migration/up/SIDE_EFFECTS.md @@ -2,44 +2,54 @@ ## Files Read -| 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 | +| Path | Format | When | +| ---------------------------------------------- | ---------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `<workdir>/supabase/migrations/` | directory | always, to read pending migration files | +| `<workdir>/supabase/config.toml` | TOML | always, for the `[db]` settings, with a matched `[remotes.<ref>]` block over the base for a remote project (flag > shell env > project `.env*` > config) | +| `~/.supabase/access-token` | plain text | when `SUPABASE_ACCESS_TOKEN` unset and the target is a remote project (or a branch is looked up) | +| `<workdir>/supabase/.temp/project-ref` | plain text | a remote project: resolves the ref (bare `--remote` with no `SUPABASE_PROJECT_ID`) and tells whether it is the linked project; with no selector, only to word the linked-checkout hint | +| `<workdir>/supabase/.temp/linked-project.json` | JSON | to name the project in the `Target:` banner and the linked-checkout hint, and as the parent project for `--remote=<branch>` | ## Files Written -| Path | Format | When | -| ---- | ------ | ---- | -| — | — | — | +| Path | Format | When | +| ---------------------------------------------- | ------ | ----------------------------------------------------------- | +| `<workdir>/supabase/.temp/linked-project.json` | JSON | remote project, on completion — caches the project identity | ## API Routes -| Method | Path | Auth | Request body | Response (used fields) | -| ------ | ---- | ---- | ------------ | ---------------------- | -| — | — | — | — | — | +Local targets and database URLs make none. + +| Method | Path | Auth | Request body | Response (used fields) | +| -------- | ------------------------------------------- | ------------ | ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `GET` | `/v1/projects` | Bearer token | none | `[{id, name, organization_slug, region}]` — project picker: bare `--remote` with no ref from `SUPABASE_PROJECT_ID` or `.temp/project-ref`, with stdin and stdout TTYs and a non-JSON output format | +| `GET` | `/v1/projects/{parent}/branches` | Bearer token | none | `[{id, name, project_ref, is_default}]` — only for `--remote=<branch>` (or the deprecated `--project-ref=<branch>`), and for a bare `--remote` followed by a word that could name a branch; `{parent}` is `SUPABASE_PROJECT_ID`, else the linked project | +| `POST` | `/v1/projects/{ref}/cli/login-role` | Bearer token | `{ read_only: false }` | temporary `role` and `password` — remote project with no database password | +| `GET` | `/v1/projects/{ref}/config/database/pooler` | Bearer token | none | primary pooler `connection_string` — remote project on an IPv4-only network (direct host unreachable), only when the ref came from `--remote=<ref>` or `--remote=<branch>`, or differs from the linked project; a differing ref ignores the saved `.temp/pooler-url` | +| `POST` | `/v1/projects/{ref}/network-bans/retrieve` | Bearer token | none | `banned_ipv4_addresses` — remote project, from the 3rd failed temporary-role connect | +| `DELETE` | `/v1/projects/{ref}/network-bans` | Bearer token | `{ ipv4_addresses, requester_ip: false }` | none — remote project, only when bans are listed and `--yes` or a shell `SUPABASE_YES` is set (clears every listed address); otherwise the connect keeps retrying and fails with `LinkedDbNetworkBannedError` once the retries run out | +| `GET` | `/v1/projects/{ref}` | Bearer token | none | `{ref, name, organization_id, organization_slug}` — linked-project cache miss only (best-effort, post-run): written only when `.temp/linked-project.json` doesn't exist, `.temp/project-ref` doesn't name another ref, an access token exists and the lookup returns 200 | ## Environment Variables | 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_ACCESS_TOKEN` | auth token for a remote project | no (falls back to keyring → `~/.supabase/access-token`) | +| `SUPABASE_PROJECT_ID` | project ref for a bare `--remote`, and the parent project for `--remote=<branch>` | 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` | conflicting target selectors (see Notes) | -| `1` | hosted target without `--yes` in a run that cannot prompt (`HostedMutationConsentRequiredError`) | -| `1` | hosted confirmation declined (`context canceled`) | +| Code | Condition | +| ---- | ---------------------------------------------------------------------------------------------------------------------- | +| `0` | success | +| `1` | database connection failure | +| `1` | migration SQL execution error | +| `1` | conflicting target selectors (see Notes) | +| `1` | `--remote=<branch>` matches no branch of the linked project (`LinkBranchNotFoundError`, lists the branches that exist) | +| `1` | hosted target without `--yes` in a run that cannot prompt (`HostedMutationConsentRequiredError`) | +| `1` | hosted confirmation declined (`context canceled`) | ## Output @@ -61,7 +71,7 @@ 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 +- A hosted target (a remote project or a non-local database 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 @@ -70,12 +80,26 @@ Same structured `applied` result delivered as an NDJSON `result` event. ## 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. +- **Target.** With no selector the command runs against the local database; when the directory is + linked the local `Target:` banner ends with `· default; pass --remote for <project>`. `--remote` + targets the linked project (`SUPABASE_PROJECT_ID`, else `.temp/project-ref`). `--remote=<value>` + is read as a project ref (20 lowercase letters), a database URL (`postgres://` or + `postgresql://`), `true|yes|on|1` (the linked project), or otherwise the name of a branch of the + linked project, which resolves to that branch's own project. `false|no|off|0`, `--no-remote` and + an empty value fail with `Leave out --remote to target the local database.` A bare `--remote` + followed by a word that is a branch of the linked project fails with a + `Did you mean --remote=<word>?` hint, since the word may have been meant as the flag's value. The + resolved target is announced on stderr (`Target: …`) in every output format and, in JSON results, + as a `target` object. +- **Deprecated selectors.** `--linked`, `--project-ref`, `--db-url` and `--local` are hidden aliases + that still select their target and print a warning on stderr naming the replacement, for example + `--linked is deprecated and will be removed in v4. Use --remote instead: supabase migration up --remote` + and + `--local isn't needed (local is the default) and will be removed in v4: supabase migration up`; a + database URL is never echoed. Two selectors together fail + (`Only one of --remote, --linked, --db-url and --local can be set; got …`), `--project-ref` cannot + be combined with `--local`, `--db-url` or `--remote=<value>`, an empty `--db-url` or + `--project-ref` is rejected, and `--linked=false` selects nothing. - `--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 From b53b7e7bf1554df9337765526e383546d7be40ae Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Mon, 19 Oct 2026 12:06:46 +0100 Subject: [PATCH 124/291] docs(cli): describe inspect db and report through --remote and its aliases --- .../src/commands/inspect/db/SIDE_EFFECTS.md | 98 +++++++++++-------- .../commands/inspect/report/SIDE_EFFECTS.md | 97 +++++++++++------- 2 files changed, 118 insertions(+), 77 deletions(-) diff --git a/apps/cli/src/commands/inspect/db/SIDE_EFFECTS.md b/apps/cli/src/commands/inspect/db/SIDE_EFFECTS.md index 144e53fe5d..1a547836a1 100644 --- a/apps/cli/src/commands/inspect/db/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/inspect/db/SIDE_EFFECTS.md @@ -2,22 +2,21 @@ 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 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. +target from `--remote` (or a deprecated alias; 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 | 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 `[db]` settings, with a matched `[remotes.<ref>]` block over the base for a remote project (flag > shell env > project `.env*` > config) | +| `<workdir>/supabase/.temp/project-ref` | plain text | a remote project: resolves the ref (bare `--remote` with no `SUPABASE_PROJECT_ID`) and tells whether it is the linked project; with no selector, only to word the linked-checkout hint | +| `<workdir>/supabase/.temp/linked-project.json` | JSON | to name the project in the `Target:` banner and the linked-checkout hint, and as the parent project for `--remote=<branch>` | +| `~/.supabase/access-token` | plain text | when `SUPABASE_ACCESS_TOKEN` unset and the target is a remote project (or a branch is looked up) | +| `.pgpass` / `pg_service.conf` | libpq | only if referenced by a database URL (`--remote=<db-url>`) | +| `$PGSSLROOTCERT` CA bundle | PEM | only if a database URL sets `sslrootcert` / `PGSSLROOTCERT` | Connection resolution and all of the above are handled inside `DbConfigResolver` (`command-internal/db-config.layer.ts`); the command adds @@ -29,26 +28,28 @@ no new config reads. | ---------------------------- | ------ | ----------------------------------------- | | `~/.supabase/telemetry.json` | JSON | always, post-run (`TelemetryState.flush`) | -## API Routes (MAY fire on a linked target, inside the resolver) +## API Routes (MAY fire on a remote project, 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 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 | +Local targets and database URLs make none. + +| Method | Path | Auth | Request body | Response (used fields) | +| -------- | ------------------------------------------- | ------------ | ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `GET` | `/v1/projects` | Bearer token | none | `[{id, name, organization_slug, region}]` — project picker: bare `--remote` with no ref from `SUPABASE_PROJECT_ID` or `.temp/project-ref`, with stdin and stdout TTYs and a non-JSON output format | +| `GET` | `/v1/projects/{parent}/branches` | Bearer token | none | `[{id, name, project_ref, is_default}]` — only for `--remote=<branch>` (or the deprecated `--project-ref=<branch>`), and for a bare `--remote` followed by a word that could name a branch; `{parent}` is `SUPABASE_PROJECT_ID`, else the linked project | +| `POST` | `/v1/projects/{ref}/cli/login-role` | Bearer token | `{ read_only: false }` | temporary `role` and `password` — remote project with no database password | +| `GET` | `/v1/projects/{ref}/config/database/pooler` | Bearer token | none | primary pooler `connection_string` — remote project on an IPv4-only network (direct host unreachable), only when the ref came from `--remote=<ref>` or `--remote=<branch>`, or differs from the linked project; a differing ref ignores the saved `.temp/pooler-url` | +| `POST` | `/v1/projects/{ref}/network-bans/retrieve` | Bearer token | none | `banned_ipv4_addresses` — remote project, from the 3rd failed temporary-role connect | +| `DELETE` | `/v1/projects/{ref}/network-bans` | Bearer token | `{ ipv4_addresses, requester_ip: false }` | none — remote project, only when bans are listed and `--yes` or a shell `SUPABASE_YES` is set (clears 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 -| 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 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 | +| Variable | Purpose | Required? | +| ---------------------------------------------------- | --------------------------------------------------------------------------------- | --------------------------------------- | +| `SUPABASE_DB_PASSWORD` / `DB_PASSWORD` | database password (linked/local) | no (prompts / config fallback) | +| `SUPABASE_ACCESS_TOKEN` | Management API auth (remote project only) | no (falls back to keyring / token file) | +| `SUPABASE_PROJECT_ID` | project ref for a bare `--remote`, and the parent project for `--remote=<branch>` | no (config resolution fallback) | +| `SUPABASE_YES` | shell value lets a remote connection clear a network ban | no | +| libpq vars (`PGSSLROOTCERT`, `PGCONNECT_TIMEOUT`, …) | honored when a database URL is used | no | ## Database Queries @@ -86,11 +87,12 @@ flags, run no query and open no connection, and exit 1 with ## Exit Codes -| 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` | +| Code | Condition | +| ---- | ---------------------------------------------------------------------------------------------------------------------- | +| `0` | success | +| `1` | conflicting target selectors, empty `--db-url`/`--project-ref`, resolution, connection, or query failure | +| `1` | `--project-ref` combined with `--local`, `--db-url` or `--remote=<value>` | +| `1` | `--remote=<branch>` matches no branch of the linked project (`LinkBranchNotFoundError`, lists the branches that exist) | ## Telemetry Events Fired @@ -127,11 +129,27 @@ Emit one extra stderr line before the table: ## Notes -- 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. +- **Target.** With no selector the command runs against the local database; when the directory is + linked the local `Target:` banner ends with `· default; pass --remote for <project>`. `--remote` + targets the linked project (`SUPABASE_PROJECT_ID`, else `.temp/project-ref`). `--remote=<value>` + is read as a project ref (20 lowercase letters), a database URL (`postgres://` or + `postgresql://`), `true|yes|on|1` (the linked project), or otherwise the name of a branch of the + linked project, which resolves to that branch's own project. `false|no|off|0`, `--no-remote` and + an empty value fail with `Leave out --remote to target the local database.` A bare `--remote` + followed by a word that is a branch of the linked project fails with a + `Did you mean --remote=<word>?` hint, since the word may have been meant as the flag's value. The + resolved target is announced on stderr (`Target: …`) in every output format and, in JSON results, + as a `target` object. +- **Deprecated selectors.** `--linked`, `--project-ref`, `--db-url` and `--local` are hidden aliases + that still select their target and print a warning on stderr naming the replacement, for example + `--linked is deprecated and will be removed in v4. Use --remote instead: supabase inspect db locks --remote` + and + `--local isn't needed (local is the default) and will be removed in v4: supabase inspect db locks`; + a database URL is never echoed. Two selectors together fail + (`Only one of --remote, --linked, --db-url and --local can be set; got …`), `--project-ref` cannot + be combined with `--local`, `--db-url` or `--remote=<value>`, an empty `--db-url` or + `--project-ref` is rejected, and `--linked=false` selects nothing. +- The Management API stack is built lazily, only for a remote project or a branch lookup, so a + local or database-URL target never requires an access token. - All queries are read-only `SELECT`s; the command performs no writes to the database. - Shared verbatim by every `inspect db` subcommand via `INSPECT_DB_FLAGS`. diff --git a/apps/cli/src/commands/inspect/report/SIDE_EFFECTS.md b/apps/cli/src/commands/inspect/report/SIDE_EFFECTS.md index 864cbdab96..264e5b4a32 100644 --- a/apps/cli/src/commands/inspect/report/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/inspect/report/SIDE_EFFECTS.md @@ -2,21 +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. 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. +validating those CSVs. The target is `--remote` (or a deprecated alias), else the local +database. 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, 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) and the `[db]` subtree, with a matched `[remotes.<ref>]` block over the base for a remote project | +| `<workdir>/supabase/.env*` (nested) | dotenv | always — `env(VAR)` expansion for `[db]` and rule string fields | +| `<workdir>/supabase/.temp/pooler-url` | plain text | remote project (pooler connection string) | +| `<workdir>/supabase/.temp/project-ref` | plain text | a remote project: resolves the ref (bare `--remote` with no `SUPABASE_PROJECT_ID`) and tells whether it is the linked project; with no selector, only to word the linked-checkout hint | +| `<workdir>/supabase/.temp/linked-project.json` | JSON | to name the project in the `Target:` banner and the linked-checkout hint, and as the parent project for `--remote=<branch>` | +| `~/.supabase/access-token` | plain text | when `SUPABASE_ACCESS_TOKEN` unset and the target is a remote project (or a branch is looked up) | +| `<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 @@ -55,38 +54,46 @@ wasn't refreshed this run. ## API Routes -| Method | Path | Auth | Request body | Response (used fields) | -| ------ | ---- | ---- | ------------ | ---------------------- | -| — | — | — | — | — | +Local targets and database URLs make none. + +| Method | Path | Auth | Request body | Response (used fields) | +| -------- | ------------------------------------------- | ------------ | ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `GET` | `/v1/projects` | Bearer token | none | `[{id, name, organization_slug, region}]` — project picker: bare `--remote` with no ref from `SUPABASE_PROJECT_ID` or `.temp/project-ref`, with stdin and stdout TTYs and a non-JSON output format | +| `GET` | `/v1/projects/{parent}/branches` | Bearer token | none | `[{id, name, project_ref, is_default}]` — only for `--remote=<branch>` (or the deprecated `--project-ref=<branch>`), and for a bare `--remote` followed by a word that could name a branch; `{parent}` is `SUPABASE_PROJECT_ID`, else the linked project | +| `POST` | `/v1/projects/{ref}/cli/login-role` | Bearer token | `{ read_only: false }` | temporary `role` and `password` — remote project with no database password | +| `GET` | `/v1/projects/{ref}/config/database/pooler` | Bearer token | none | primary pooler `connection_string` — remote project on an IPv4-only network (direct host unreachable), only when the ref came from `--remote=<ref>` or `--remote=<branch>`, or differs from the linked project; a differing ref ignores the saved `.temp/pooler-url` | +| `POST` | `/v1/projects/{ref}/network-bans/retrieve` | Bearer token | none | `banned_ipv4_addresses` — remote project, from the 3rd failed temporary-role connect | +| `DELETE` | `/v1/projects/{ref}/network-bans` | Bearer token | `{ ipv4_addresses, requester_ip: false }` | none — remote project, only when bans are listed and `--yes` or a shell `SUPABASE_YES` is set (clears 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 | 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 +CSV HEADER`). The Management API is used lazily only for a remote project or a branch lookup, 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` once the connect retries run out while bans are still listed. ## 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_PROJECT_ID` | project ref for a bare `--remote`, and the parent project for `--remote=<branch>` | no | +| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no | +| `SUPABASE_YES` | shell value lets a remote 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` | -| `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` | +| 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` | conflicting target selectors, or an empty `--db-url` / `--project-ref` | +| `1` | `--project-ref` combined with `--local`, `--db-url` or `--remote=<value>` | +| `1` | `--remote=<branch>` matches no branch of the linked project (`LinkBranchNotFoundError`, lists the branches that exist) | A **per-rule** csvq evaluation error does **not** fail the command — it becomes the rule's STATUS cell. @@ -125,7 +132,23 @@ instead a structured result is emitted. The stderr `Target:` banner is still pri ## Notes -- 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. +- **Target.** With no selector the command runs against the local database; when the directory is + linked the local `Target:` banner ends with `· default; pass --remote for <project>`. `--remote` + targets the linked project (`SUPABASE_PROJECT_ID`, else `.temp/project-ref`). `--remote=<value>` + is read as a project ref (20 lowercase letters), a database URL (`postgres://` or + `postgresql://`), `true|yes|on|1` (the linked project), or otherwise the name of a branch of the + linked project, which resolves to that branch's own project. `false|no|off|0`, `--no-remote` and + an empty value fail with `Leave out --remote to target the local database.` A bare `--remote` + followed by a word that is a branch of the linked project fails with a + `Did you mean --remote=<word>?` hint, since the word may have been meant as the flag's value. The + resolved target is announced on stderr (`Target: …`) in every output format and, in JSON results, + as a `target` object. +- **Deprecated selectors.** `--linked`, `--project-ref`, `--db-url` and `--local` are hidden aliases + that still select their target and print a warning on stderr naming the replacement, for example + `--linked is deprecated and will be removed in v4. Use --remote instead: supabase inspect report --remote` + and + `--local isn't needed (local is the default) and will be removed in v4: supabase inspect report`; + a database URL is never echoed. Two selectors together fail + (`Only one of --remote, --linked, --db-url and --local can be set; got …`), `--project-ref` cannot + be combined with `--local`, `--db-url` or `--remote=<value>`, an empty `--db-url` or + `--project-ref` is rejected, and `--linked=false` selects nothing. From 34048e6a5c67c6000c490d790526da506b130084 Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Mon, 19 Oct 2026 12:09:50 +0100 Subject: [PATCH 125/291] docs(cli): describe storage and seed buckets through --remote and its aliases --- .../src/commands/seed/buckets/SIDE_EFFECTS.md | 114 ++++++++++------- .../src/commands/storage/cp/SIDE_EFFECTS.md | 106 +++++++++------- .../src/commands/storage/ls/SIDE_EFFECTS.md | 119 ++++++++++-------- .../src/commands/storage/mv/SIDE_EFFECTS.md | 96 ++++++++------ .../src/commands/storage/rm/SIDE_EFFECTS.md | 90 +++++++------ 5 files changed, 316 insertions(+), 209 deletions(-) diff --git a/apps/cli/src/commands/seed/buckets/SIDE_EFFECTS.md b/apps/cli/src/commands/seed/buckets/SIDE_EFFECTS.md index 48ccca2178..f4a1d88827 100644 --- a/apps/cli/src/commands/seed/buckets/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/seed/buckets/SIDE_EFFECTS.md @@ -1,15 +1,16 @@ # `supabase seed buckets` Seeds Supabase Storage buckets from `[storage.buckets]` and -`[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 +`[storage.vector]` in `supabase/config.toml`. With no selector the command targets the local +stack; `--remote` targets the linked project, `--remote=<ref>` another project and +`--remote=<branch>` a branch of the linked project (see +[ADR 0033](../../../../../../docs/adr/0033-target-selection-and-hosted-mutation-consent.md)). `--local`, `--linked` and +`--project-ref` are deprecated aliases. A run against a 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 -[`docs/stack-commands.md`](../../../../docs/stack-commands.md). `--linked`/`--project-ref` +[`docs/stack-commands.md`](../../../../docs/stack-commands.md). `--remote` targeting is unchanged under either backend; see Notes for the stack-backend endpoint, credential, capability-state, and error behavior. `db reset --local`'s bucket seeding (under the stack backend) goes through this same seeding core; see that command's own @@ -17,20 +18,22 @@ 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 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 | +| Path | Format | When | +| ---------------------------------------------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `<workdir>/supabase/config.toml` | TOML | always, to read `[storage.buckets]` / `[storage.vector]` config; on a remote project, 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 | a remote project: resolves the ref (bare `--remote` with no `SUPABASE_PROJECT_ID`) and tells whether it is the linked project; with no selector, only to word the linked-checkout hint | +| `<workdir>/supabase/.temp/linked-project.json` | JSON | to name the project in the `Target:` banner and the linked-checkout hint, and as the parent project for `--remote=<branch>` | +| `~/.supabase/access-token` | plain text | when `SUPABASE_ACCESS_TOKEN` unset and the target is a remote project (or a branch is looked up) | +| `<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 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. | +| Path | Format | When | +| ---------------------------------------------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `<workdir>/supabase/.temp/linked-project.json` | JSON | remote project 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 @@ -49,7 +52,7 @@ observed JWT secret. `[api]`/`[api.tls]` fields and their `SUPABASE_API_*` overr consulted. The stack is opened without launching the owner or any service member. See Notes for the capability-state gate. -**Remote (`--linked`):** `https://<ref>.<projectHost>` (default host: `supabase.co`). +**Remote project:** `https://<ref>.<projectHost>` (default host: `supabase.co`). Auth: an `apikey` header set to the service-role key; an `Authorization: Bearer <key>` header is also sent, except when the key is an opaque `sb_...` key, which is treated @@ -77,32 +80,37 @@ 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 the target is linked. +`[storage.analytics].enabled = true` AND the target is a remote project. -### Management API routes (remote linked target only, when env var not set) +### Management API routes (remote project only) -| Method | Path | When | Response (used fields) | -| ------ | ----------------------------------------- | ------------------------------------------- | ---------------------------------------------- | -| `GET` | `/v1/projects/{ref}/api-keys?reveal=true` | `SUPABASE_AUTH_SERVICE_ROLE_KEY` is not set | `[{name, api_key, type, secret_jwt_template}]` | +| Method | Path | When | Response (used fields) | +| ------ | ----------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------- | +| `GET` | `/v1/projects` | project picker: bare `--remote` with no ref from `SUPABASE_PROJECT_ID` or `.temp/project-ref`, with stdin and stdout TTYs and a non-JSON output format | `[{id, name, organization_slug, region}]` | +| `GET` | `/v1/projects/{parent}/branches` | only for `--remote=<branch>` (or the deprecated `--project-ref=<branch>`), and for a bare `--remote` followed by a word that could name a branch; `{parent}` is `SUPABASE_PROJECT_ID`, else the linked project | `[{id, name, project_ref, is_default}]` | +| `GET` | `/v1/projects/{ref}/api-keys?reveal=true` | `SUPABASE_AUTH_SERVICE_ROLE_KEY` is not set | `[{name, api_key, type, secret_jwt_template}]` | +| `GET` | `/v1/projects/{ref}` | linked-project cache miss only (best-effort, post-run): written only when `.temp/linked-project.json` doesn't exist, `.temp/project-ref` doesn't name another ref, an access token exists and the lookup returns 200 | `{ref, name, organization_id, organization_slug}` | ## Environment Variables -| Variable | Purpose | Required? | -| --------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------ | -| `SUPABASE_EXPERIMENTAL_STACK` | `1`/`0` selects the stack backend for local runs, overriding `experimental.stack` | no (→ `experimental.stack` → legacy) | -| `SUPABASE_SERVICES_HOSTNAME` | legacy backend only: override the local services host (highest precedence; shell env, then project `.env*`) | no | -| `DOCKER_HOST` | legacy backend only, shell env only (a project `.env*` value is ignored): when a `tcp://host:port` endpoint, the local services host falls back to it before `127.0.0.1` | no | -| `SUPABASE_AUTH_SERVICE_ROLE_KEY` | when set and non-empty: for `--linked`, used as the service-role key (skips Management API key fetch; shell env only); for local, legacy-backend runs, used as the service-role key instead of `auth.service_role_key` — shell env OR project dotenv (shell wins), `encrypted:` values decrypted | no | -| `SUPABASE_AUTH_JWT_SECRET` | local, legacy-backend runs only: when set and non-empty, overrides `auth.jwt_secret` for service-role key derivation — shell env OR project dotenv (shell wins), `encrypted:` values decrypted | no | -| `SUPABASE_YES` | 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 | +| Variable | Purpose | Required? | +| --------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------- | +| `SUPABASE_EXPERIMENTAL_STACK` | `1`/`0` selects the stack backend for local runs, overriding `experimental.stack` | no (→ `experimental.stack` → legacy) | +| `SUPABASE_SERVICES_HOSTNAME` | legacy backend only: override the local services host (highest precedence; shell env, then project `.env*`) | no | +| `DOCKER_HOST` | legacy backend only, shell env only (a project `.env*` value is ignored): when a `tcp://host:port` endpoint, the local services host falls back to it before `127.0.0.1` | no | +| `SUPABASE_AUTH_SERVICE_ROLE_KEY` | when set and non-empty: for a remote project, 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_PROJECT_ID` | project ref for a bare `--remote`, and the parent project for `--remote=<branch>` | no | +| `SUPABASE_ACCESS_TOKEN` | Management API auth for a remote project | no (falls back to keyring → `~/.supabase/access-token`) | +| `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 | Code | Condition | | ---- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `0` | success (including the empty-config short-circuit, provided the local, legacy-backend config checks pass: `[api]` override decode, non-zero enabled port, TLS cert/key pairing, `[auth]` override decrypt and `jwt_secret` length — these checks are skipped entirely on the stack backend) | -| `1` | resolved `--workdir`/`SUPABASE_WORKDIR` doesn't exist or isn't a directory (`SeedWorkdirError`) — beats the `--project-ref` guard and every network call | +| `1` | resolved `--workdir`/`SUPABASE_WORKDIR` doesn't exist or isn't a directory (`SeedWorkdirError`) — beats the target-selector guards and every network call | | `1` | an explicit `--workdir`/`SUPABASE_WORKDIR` holds no project config (`SeedMissingProjectConfigError`) — precedes the api-keys fetch and any Storage call | | `1` | `supabase/config.toml` parse failure | | `1` | `auth.jwt_secret` / `auth.service_role_key` (or their env overrides) set to an undecryptable `encrypted:` value — local, legacy backend only, enforced even when nothing is configured to seed | @@ -115,7 +123,9 @@ 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` | `--local` with `--linked`, or `--project-ref` with `--local` (`TargetSelectorConflictError`, `TargetProjectRefConflictError`) — after the workdir checks and before any Storage call | +| `1` | two of `--remote`, `--linked` and `--local`, or `--project-ref` with `--local` or `--remote=<value>` (`TargetSelectorConflictError`, `TargetProjectRefConflictError`) — after the workdir checks and before any Storage call | +| `1` | `--remote=<db-url>` (`TargetNeedsProjectError`): seeding talks to the Storage API, which needs a project, not a database URL; no Storage request is sent | +| `1` | `--remote=<branch>` matches no branch of the linked project (`LinkBranchNotFoundError`, lists the branches that exist) | | `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`) | @@ -135,7 +145,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>) +Target: <remote project> | local stack (<host>:<port>) Creating Storage bucket: <name> Updating Storage bucket: <id> Updating analytics buckets... @@ -187,13 +197,29 @@ stdout and a terminal `result`/`error` event is emitted. ## Notes -- **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.** With no selector the command runs against the local stack; when the directory is + linked the local `Target:` banner ends with `· default; pass --remote for <project>`. `--remote` + targets the linked project (`SUPABASE_PROJECT_ID`, else `.temp/project-ref`). `--remote=<value>` + is read as a project ref (20 lowercase letters), `true|yes|on|1` (the linked project), or + otherwise the name of a branch of the linked project, which resolves to that branch's own project. + A database URL (`--remote=<db-url>`) fails with a `needs a project, not a database URL` error and + sends no request. `false|no|off|0`, `--no-remote` and an empty value fail with + `Leave out --remote to target the local Storage API.` A bare `--remote` followed by a word that is + a branch of the linked project fails with a `Did you mean --remote=<word>?` hint, since the word + may have been meant as the flag's value. The resolved target is announced on stderr (`Target: …`) + in every output format and, in JSON results, as a `target` object. `--remote`, `--linked` and + `--local` are scoped globals on the `seed` group, so they are also accepted before the subcommand + token. +- **Deprecated selectors.** `--linked`, `--project-ref` and `--local` are hidden aliases that still + select their target and print a warning on stderr naming the replacement, for example + `--linked is deprecated and will be removed in v4. Use --remote instead: supabase seed buckets --remote` + and + `--local isn't needed (local is the default) and will be removed in v4: supabase seed buckets`. + Two selectors together fail + (`Only one of --remote, --linked, --db-url and --local can be set; got …`), `--project-ref` cannot + be combined with `--local` or `--remote=<value>`, an empty `--project-ref` is rejected, and + `--linked=false` selects nothing. +- **Target banner.** A remote project 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 @@ -203,12 +229,12 @@ stdout and a terminal `result`/`error` event is emitted. `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 +- **Remote project — 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 `storage.buckets`, `storage.vector`, `storage.analytics`). Local runs load the base config verbatim with no merge. -- **Remote (`--linked`).** The remote base URL is `https://<ref>.<projectHost>` +- **Remote project.** The remote base URL is `https://<ref>.<projectHost>` (default: `supabase.co`). The service-role key is read from `SUPABASE_AUTH_SERVICE_ROLE_KEY` if set; otherwise fetched via `GET /v1/projects/{ref}/api-keys?reveal=true`. @@ -225,7 +251,7 @@ 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 a linked target. It is never + gated on `[storage.analytics].enabled = true` AND a remote project. 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 diff --git a/apps/cli/src/commands/storage/cp/SIDE_EFFECTS.md b/apps/cli/src/commands/storage/cp/SIDE_EFFECTS.md index 7462f1dea8..11e95ebfcf 100644 --- a/apps/cli/src/commands/storage/cp/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/storage/cp/SIDE_EFFECTS.md @@ -3,9 +3,10 @@ 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. -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 +With no selector the command targets the local stack. `--remote` targets the linked project, +`--remote=<ref>` another project and `--remote=<branch>` a branch of the linked project (see +[ADR 0033](../../../../../../docs/adr/0033-target-selection-and-hosted-mutation-consent.md)); +`--local`, `--linked` and `--project-ref` are deprecated aliases. 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. @@ -15,45 +16,47 @@ stack-backend endpoint, credential, capability-state, and error behavior. | Path | Format | When | | ---------------------------------------------- | ---------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `<workdir>/supabase/config.toml` | TOML | always (local creds; `[storage.buckets.*]` for bucket auto-create). With an explicit `--workdir`/`SUPABASE_WORKDIR` on a LOCAL target, a missing project config is now a hard failure (`StorageMissingProjectConfigError`) rather than a fall-back to embedded defaults — the default `api.port` would otherwise point the operation at a different local stack; a REMOTE (`--project-ref`/`--linked`) target never hard-fails on this, since credential resolution there reads the Management API, not local config | -| `~/.supabase/access-token` | plain text | linked path, when `SUPABASE_ACCESS_TOKEN` unset | -| `<workdir>/supabase/.temp/project-ref` | plain text | linked path, when `--project-ref` and `SUPABASE_PROJECT_ID` are both unset | -| `<workdir>/supabase/.temp/linked-project.json` | JSON | linked path, existence check before the cache fill | +| `~/.supabase/access-token` | plain text | remote project or branch lookup, when `SUPABASE_ACCESS_TOKEN` unset | +| `<workdir>/supabase/.temp/project-ref` | plain text | a remote project: resolves the ref (bare `--remote` with no `SUPABASE_PROJECT_ID`) and tells whether it is the linked project; with no selector, only to word the linked-checkout hint | +| `<workdir>/supabase/.temp/linked-project.json` | JSON | the `Target:` banner and the linked-checkout hint, the parent project for `--remote=<branch>`, and the existence check before the cache fill | | local Kong TLS cert/key | PEM | legacy backend only, local + `api.enabled` + `api.tls.enabled` | | `<workdir>/supabase/.env*`, `<workdir>/.env*` | dotenv | local path: read on both backends for `env(VAR)` interpolation while loading `config.toml`; on the legacy backend also to resolve the `SUPABASE_API_*` overrides for the gateway URL/TLS (#6452) and `SUPABASE_AUTH_{JWT_SECRET,SERVICE_ROLE_KEY}` for the service-role key | | upload source files | bytes | upload: sniff (≤512 bytes) + streamed body | ## Files Written -| Path | Format | When | -| ---------------------------------------------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| download destination files | bytes | download (single: O_EXCL `wx`; recursive: O_TRUNC `w`) | -| download destination parent dirs | dir | recursive download (`mkdir -p`) | -| `<workdir>/supabase/.temp/linked-project.json` | JSON | post-run, linked path (`--local` false) — only when no cached file exists, `.temp/project-ref` is absent or names the same ref, a token is available, and the project lookup succeeds | -| `~/.supabase/telemetry.json` | JSON | post-run (always) | +| Path | Format | When | +| ---------------------------------------------- | ------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| download destination files | bytes | download (single: O_EXCL `wx`; recursive: O_TRUNC `w`) | +| download destination parent dirs | dir | recursive download (`mkdir -p`) | +| `<workdir>/supabase/.temp/linked-project.json` | JSON | post-run, remote project — only when no cached file exists, `.temp/project-ref` is absent or names the same ref, a token is available, and the project lookup succeeds | +| `~/.supabase/telemetry.json` | JSON | post-run (always) | ## API Routes Auth: `apikey` always; `Authorization: Bearer <key>` unless the key is `sb_`-prefixed. -| Method | Path | Request body / headers | Response | -| ------ | ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------- | -| `GET` | `/storage/v1/object/{path}` | — (download) | binary stream | -| `POST` | `/storage/v1/object/{path}` | file stream; `Content-Type`, `Cache-Control`, `x-upsert` (recursive only) | — | -| `POST` | `/storage/v1/object/list/{bucket}` | `{prefix, search?, limit:100, offset?}` (recursive walk + dst detection) | `[{name, id?}]` | -| `GET` | `/storage/v1/bucket` | — (recursive walk to bucket root) | `[{name, id}]` | -| `POST` | `/storage/v1/bucket` | `{name, public?, file_size_limit?, allowed_mime_types?}` (auto-create on 404) | `{name}` | -| `GET` | `/v1/projects/{ref}/api-keys?reveal=true` | — (linked, Management API; skipped when `SUPABASE_AUTH_SERVICE_ROLE_KEY` is set to a non-empty value in the shell env) | service-role key | -| `GET` | `/v1/projects/{ref}` | — (linked, Management API; best-effort cache fill after the ref resolves, only when `.temp/linked-project.json` is absent, `.temp/project-ref` is absent or names the same ref, and a token is available; failures are ignored) | `ref`, `name`, `organization_id`, `organization_slug` | +| Method | Path | Request body / headers | Response | +| ------ | ----------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------- | +| `GET` | `/storage/v1/object/{path}` | — (download) | binary stream | +| `POST` | `/storage/v1/object/{path}` | file stream; `Content-Type`, `Cache-Control`, `x-upsert` (recursive only) | — | +| `POST` | `/storage/v1/object/list/{bucket}` | `{prefix, search?, limit:100, offset?}` (recursive walk + dst detection) | `[{name, id?}]` | +| `GET` | `/storage/v1/bucket` | — (recursive walk to bucket root) | `[{name, id}]` | +| `POST` | `/storage/v1/bucket` | `{name, public?, file_size_limit?, allowed_mime_types?}` (auto-create on 404) | `{name}` | +| `GET` | `/v1/projects` | — (Management API; project picker: bare `--remote` with no ref from `SUPABASE_PROJECT_ID` or `.temp/project-ref`, with stdin and stdout TTYs and a non-JSON output format) | `[{id, name, organization_slug, region}]` | +| `GET` | `/v1/projects/{parent}/branches` | — (Management API; only for `--remote=<branch>` (or the deprecated `--project-ref=<branch>`), and for a bare `--remote` followed by a word that could name a branch; `{parent}` is `SUPABASE_PROJECT_ID`, else the linked project) | `[{id, name, project_ref, is_default}]` | +| `GET` | `/v1/projects/{ref}/api-keys?reveal=true` | — (remote project, Management API; skipped when `SUPABASE_AUTH_SERVICE_ROLE_KEY` is set to a non-empty value in the shell env) | service-role key | +| `GET` | `/v1/projects/{ref}` | — (remote project, Management API; best-effort cache fill after the ref resolves, only when `.temp/linked-project.json` is absent, `.temp/project-ref` is absent or names the same ref, and a token is available; failures are ignored) | `ref`, `name`, `organization_id`, `organization_slug` | ## Environment Variables `SUPABASE_AUTH_JWT_SECRET`, `SUPABASE_SERVICES_HOSTNAME`, and the `SUPABASE_API_*` override family are legacy backend only. `SUPABASE_AUTH_SERVICE_ROLE_KEY` is legacy backend only -for `--local`; on the linked path a non-empty shell value skips the api-keys call under either +for the local target; on a remote project a non-empty shell value skips the api-keys call under either backend. `SUPABASE_ACCESS_TOKEN`, `SUPABASE_PROJECT_ID`, and `SUPABASE_EXPERIMENTAL_STACK` (backend selection) apply to either backend — same roles as `storage ls`. -`SUPABASE_PROJECT_ID`'s linked-ref resolution is superseded by `--project-ref` when set. +`SUPABASE_PROJECT_ID` supplies the ref for a bare `--remote` and the parent project for `--remote=<branch>`. `storage` is an experimental command: `cp` requires `--experimental` (or `SUPABASE_EXPERIMENTAL`), else it exits 1 with @@ -61,16 +64,18 @@ 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` | `--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`) | +| 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` | two of `--remote`, `--linked` and `--local`, or `--project-ref` with `--local` or `--remote=<value>` (`TargetSelectorConflictError`, `TargetProjectRefConflictError`), after the experimental gate and the workdir check and before any Storage request | +| `1` | `--remote=<db-url>` (`TargetNeedsProjectError`): the Storage API needs a project, not a database URL; no Storage request is sent | +| `1` | `--remote=<branch>` matches no branch of the linked project (`LinkBranchNotFoundError`, lists the branches that exist) | +| `1` | upload to a remote 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,20 +100,35 @@ backend — same roles as `storage ls`. ## Telemetry Events Fired -| Event | When | Notable properties | -| ---------------------- | ------------------------------------------ | --------------------------------------------------------------------------------------------- | -| `cli_command_executed` | post-run, success or failure (via wrapper) | `flags` (recursive/cache-control/content-type/jobs/linked/local; non-boolean values redacted) | +| Event | When | Notable properties | +| ---------------------- | ------------------------------------------ | ---------------------------------------------------------------------------------------------------- | +| `cli_command_executed` | post-run, success or failure (via wrapper) | `flags` (recursive/cache-control/content-type/jobs/remote/linked/local; non-boolean values redacted) | ## Notes -- **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.** With no selector the command runs against the local stack; when the directory is + linked the local `Target:` banner ends with `· default; pass --remote for <project>`. `--remote` + targets the linked project (`SUPABASE_PROJECT_ID`, else `.temp/project-ref`). `--remote=<value>` + is read as a project ref (20 lowercase letters), `true|yes|on|1` (the linked project), or + otherwise the name of a branch of the linked project, which resolves to that branch's own project. + A database URL (`--remote=<db-url>`) fails with a `needs a project, not a database URL` error and + sends no request. `false|no|off|0`, `--no-remote` and an empty value fail with + `Leave out --remote to target the local Storage API.` A bare `--remote` followed by a word that is + a branch of the linked project fails with a `Did you mean --remote=<word>?` hint, since the word + may have been meant as the flag's value. The resolved target is announced on stderr (`Target: …`) + in every output format and, in JSON results, as a `target` object. +- **Deprecated selectors.** `--linked`, `--project-ref` and `--local` are hidden aliases that still + select their target and print a warning on stderr naming the replacement, for example + `--linked is deprecated and will be removed in v4. Use --remote instead: supabase storage cp --remote` + and `--local isn't needed (local is the default) and will be removed in v4: supabase storage cp`. + Two selectors together fail + (`Only one of --remote, --linked, --db-url and --local can be set; got …`), `--project-ref` cannot + be combined with `--local` or `--remote=<value>`, an empty `--project-ref` is rejected, and + `--linked=false` selects nothing. +- **Target banner.** A remote project 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: +- **Hosted consent.** Uploading to a remote 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 @@ -123,7 +143,7 @@ backend — same roles as `storage ls`. - `--jobs`/`-j` bounds upload/download concurrency (default 1). - The runtime default for `--content-type` is `""` (empty ⇒ auto-detect). - Relative local paths resolve against the original cwd. -- **Stack backend (`--local`).** The Storage endpoint comes from the selected composition's +- **Stack backend (local target).** 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, which reads stack state under `SUPABASE_HOME`; `cp` opens saved state without launching diff --git a/apps/cli/src/commands/storage/ls/SIDE_EFFECTS.md b/apps/cli/src/commands/storage/ls/SIDE_EFFECTS.md index b3b357b4ff..f9757d17fa 100644 --- a/apps/cli/src/commands/storage/ls/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/storage/ls/SIDE_EFFECTS.md @@ -1,10 +1,11 @@ # `supabase storage ls [path]` -Lists objects/buckets by path prefix against the Storage gateway (local stack or linked project). +Lists objects/buckets by path prefix against the Storage gateway (local stack or a remote project). -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 +With no selector the command targets the local stack. `--remote` targets the linked project, +`--remote=<ref>` another project and `--remote=<branch>` a branch of the linked project (see +[ADR 0033](../../../../../../docs/adr/0033-target-selection-and-hosted-mutation-consent.md)); +`--local`, `--linked` and `--project-ref` are deprecated aliases. 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. @@ -14,42 +15,44 @@ stack-backend endpoint, credential, capability-state, and error behavior. | Path | Format | When | | ---------------------------------------------- | ---------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `<workdir>/supabase/config.toml` | TOML | always (local creds/baseUrl; `[remotes.*]` merge when linked). With an explicit `--workdir`/`SUPABASE_WORKDIR` on a LOCAL target, a missing project config is now a hard failure (`StorageMissingProjectConfigError`) rather than a fall-back to embedded defaults — the default `api.port` would otherwise point the operation at a different local stack; a REMOTE (`--project-ref`/`--linked`) target never hard-fails on this, since credential resolution there reads the Management API, not local config | -| `~/.supabase/access-token` | plain text | linked path, when `SUPABASE_ACCESS_TOKEN` unset | -| `<workdir>/supabase/.temp/project-ref` | plain text | linked path, when `--project-ref` and `SUPABASE_PROJECT_ID` are both unset | -| `<workdir>/supabase/.temp/linked-project.json` | JSON | linked path, existence check before the cache fill | +| `~/.supabase/access-token` | plain text | remote project or branch lookup, when `SUPABASE_ACCESS_TOKEN` unset | +| `<workdir>/supabase/.temp/project-ref` | plain text | a remote project: resolves the ref (bare `--remote` with no `SUPABASE_PROJECT_ID`) and tells whether it is the linked project; with no selector, only to word the linked-checkout hint | +| `<workdir>/supabase/.temp/linked-project.json` | JSON | the `Target:` banner and the linked-checkout hint, the parent project for `--remote=<branch>`, and the existence check before the cache fill | | local Kong TLS cert/key | PEM | legacy backend only, local + `api.enabled` + `api.tls.enabled` | | `<workdir>/supabase/.env*`, `<workdir>/.env*` | dotenv | local path: read on both backends for `env(VAR)` interpolation while loading `config.toml`; on the legacy backend also to resolve the `SUPABASE_API_*` overrides for the gateway URL/TLS (#6452) and `SUPABASE_AUTH_{JWT_SECRET,SERVICE_ROLE_KEY}` for the service-role key | ## Files Written -| Path | Format | When | -| ---------------------------------------------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `<workdir>/supabase/.temp/linked-project.json` | JSON | post-run, linked path (`--local` false) — only when no cached file exists, `.temp/project-ref` is absent or names the same ref, a token is available, and the project lookup succeeds | -| `~/.supabase/telemetry.json` | JSON | post-run (always) | +| Path | Format | When | +| ---------------------------------------------- | ------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `<workdir>/supabase/.temp/linked-project.json` | JSON | post-run, remote project — only when no cached file exists, `.temp/project-ref` is absent or names the same ref, a token is available, and the project lookup succeeds | +| `~/.supabase/telemetry.json` | JSON | post-run (always) | ## API Routes Auth: `apikey` header always; `Authorization: Bearer <key>` unless the key is `sb_`-prefixed. -| Method | Path | Request body | Response (used) | -| ------ | ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------- | -| `POST` | `/storage/v1/object/list/{bucket}` | `{prefix, search?, limit:100, offset?}` | `[{name, id?}]` (id null ⇒ dir) | -| `GET` | `/storage/v1/bucket` | — | `[{name, id}]` | -| `GET` | `/v1/projects/{ref}/api-keys?reveal=true` | — (linked, Management API; skipped when `SUPABASE_AUTH_SERVICE_ROLE_KEY` is set to a non-empty value in the shell env) | api-key list → service-role key | -| `GET` | `/v1/projects/{ref}` | — (linked, Management API; best-effort cache fill after the ref resolves, only when `.temp/linked-project.json` is absent, `.temp/project-ref` is absent or names the same ref, and a token is available; failures are ignored) | `ref`, `name`, `organization_id`, `organization_slug` | +| Method | Path | Request body | Response (used) | +| ------ | ----------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------- | +| `POST` | `/storage/v1/object/list/{bucket}` | `{prefix, search?, limit:100, offset?}` | `[{name, id?}]` (id null ⇒ dir) | +| `GET` | `/storage/v1/bucket` | — | `[{name, id}]` | +| `GET` | `/v1/projects` | — (Management API; project picker: bare `--remote` with no ref from `SUPABASE_PROJECT_ID` or `.temp/project-ref`, with stdin and stdout TTYs and a non-JSON output format) | `[{id, name, organization_slug, region}]` | +| `GET` | `/v1/projects/{parent}/branches` | — (Management API; only for `--remote=<branch>` (or the deprecated `--project-ref=<branch>`), and for a bare `--remote` followed by a word that could name a branch; `{parent}` is `SUPABASE_PROJECT_ID`, else the linked project) | `[{id, name, project_ref, is_default}]` | +| `GET` | `/v1/projects/{ref}/api-keys?reveal=true` | — (remote project, Management API; skipped when `SUPABASE_AUTH_SERVICE_ROLE_KEY` is set to a non-empty value in the shell env) | api-key list → service-role key | +| `GET` | `/v1/projects/{ref}` | — (remote project, Management API; best-effort cache fill after the ref resolves, only when `.temp/linked-project.json` is absent, `.temp/project-ref` is absent or names the same ref, and a token is available; failures are ignored) | `ref`, `name`, `organization_id`, `organization_slug` | ## Environment Variables -| Variable | Purpose | Required? | -| --------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------ | -| `SUPABASE_AUTH_SERVICE_ROLE_KEY` | linked: bypass tenant key fetch (shell env only); local: explicit key, shell or project dotenv, `encrypted:` decrypted — legacy backend only | no | -| `SUPABASE_AUTH_JWT_SECRET` | local: derive service-role key, shell or project dotenv, `encrypted:` decrypted — legacy backend only | no (→ `auth.jwt_secret` → default) | -| `SUPABASE_ACCESS_TOKEN` | linked: Management API auth | no (→ `~/.supabase/access-token`) | -| `SUPABASE_PROJECT_ID` | linked: project-ref resolution, superseded by `--project-ref` when set | no | -| `SUPABASE_SERVICES_HOSTNAME` | local baseUrl host — legacy backend only | no (→ Docker host → `127.0.0.1`) | -| `SUPABASE_API_PORT`, `SUPABASE_API_EXTERNAL_URL`, `SUPABASE_API_TLS_ENABLED`, `SUPABASE_API_TLS_CERT_PATH`, `SUPABASE_API_TLS_KEY_PATH`, `SUPABASE_API_ENABLED` | local: override the matching `[api]` fields for the gateway URL/TLS, shell env or project dotenv (shell wins; #6452) — legacy backend only | no | -| `SUPABASE_EXPERIMENTAL_STACK` | `1`/`0` selects the stack backend for `--local`, overriding `experimental.stack` | no (→ `experimental.stack` → legacy) | -| `SUPABASE_EXPERIMENTAL` | experimental gate: `--experimental` equivalent | yes, unless `--experimental` given | +| Variable | Purpose | Required? | +| --------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------ | +| `SUPABASE_AUTH_SERVICE_ROLE_KEY` | remote project: bypass tenant key fetch (shell env only); local: explicit key, shell or project dotenv, `encrypted:` decrypted — legacy backend only | no | +| `SUPABASE_AUTH_JWT_SECRET` | local: derive service-role key, shell or project dotenv, `encrypted:` decrypted — legacy backend only | no (→ `auth.jwt_secret` → default) | +| `SUPABASE_ACCESS_TOKEN` | remote project: Management API auth | no (→ `~/.supabase/access-token`) | +| `SUPABASE_PROJECT_ID` | remote project: ref for a bare `--remote`, and the parent project for `--remote=<branch>` | no | +| `SUPABASE_SERVICES_HOSTNAME` | local baseUrl host — legacy backend only | no (→ Docker host → `127.0.0.1`) | +| `SUPABASE_API_PORT`, `SUPABASE_API_EXTERNAL_URL`, `SUPABASE_API_TLS_ENABLED`, `SUPABASE_API_TLS_CERT_PATH`, `SUPABASE_API_TLS_KEY_PATH`, `SUPABASE_API_ENABLED` | local: override the matching `[api]` fields for the gateway URL/TLS, shell env or project dotenv (shell wins; #6452) — legacy backend only | no | +| `SUPABASE_EXPERIMENTAL_STACK` | `1`/`0` selects the stack backend for the local target, overriding `experimental.stack` | no (→ `experimental.stack` → legacy) | +| `SUPABASE_EXPERIMENTAL` | experimental gate: `--experimental` equivalent | yes, unless `--experimental` given | `storage` is an experimental command: every subcommand requires `--experimental` (or `SUPABASE_EXPERIMENTAL`), else it exits 1 with @@ -57,15 +60,17 @@ Auth: `apikey` header always; `Authorization: Bearer <key>` unless the key is `s ## 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 URL / url-parse error / 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` | 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 URL / url-parse error / API non-2xx / network / auth / config parse | +| `1` | two of `--remote`, `--linked` and `--local`, or `--project-ref` with `--local` or `--remote=<value>` (`TargetSelectorConflictError`, `TargetProjectRefConflictError`), after the experimental gate and the workdir check and before any Storage request | +| `1` | `--remote=<db-url>` (`TargetNeedsProjectError`): the Storage API needs a project, not a database URL; no Storage request is sent | +| `1` | `--remote=<branch>` matches no branch of the linked project (`LinkBranchNotFoundError`, lists the branches that exist) | +| `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 @@ -88,9 +93,9 @@ Pagination prints `Loading page: <N>` to **stderr**. ## Telemetry Events Fired -| Event | When | Notable properties | -| ---------------------- | ------------------------------------------ | -------------------------------- | -| `cli_command_executed` | post-run, success or failure (via wrapper) | `flags` (recursive/linked/local) | +| Event | When | Notable properties | +| ---------------------- | ------------------------------------------ | --------------------------------------- | +| `cli_command_executed` | post-run, success or failure (via wrapper) | `flags` (recursive/remote/linked/local) | No custom storage telemetry events. @@ -99,19 +104,35 @@ 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). -- **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.** With no selector the command runs against the local stack; when the directory is + linked the local `Target:` banner ends with `· default; pass --remote for <project>`. `--remote` + targets the linked project (`SUPABASE_PROJECT_ID`, else `.temp/project-ref`). `--remote=<value>` + is read as a project ref (20 lowercase letters), `true|yes|on|1` (the linked project), or + otherwise the name of a branch of the linked project, which resolves to that branch's own project. + A database URL (`--remote=<db-url>`) fails with a `needs a project, not a database URL` error and + sends no request. `false|no|off|0`, `--no-remote` and an empty value fail with + `Leave out --remote to target the local Storage API.` A bare `--remote` followed by a word that is + a branch of the linked project fails with a `Did you mean --remote=<word>?` hint, since the word + may have been meant as the flag's value. The resolved target is announced on stderr (`Target: …`) + in every output format and, in JSON results, as a `target` object. +- **Deprecated selectors.** `--linked`, `--project-ref` and `--local` are hidden aliases that still + select their target and print a warning on stderr naming the replacement, for example + `--linked is deprecated and will be removed in v4. Use --remote instead: supabase storage ls --remote` + and `--local isn't needed (local is the default) and will be removed in v4: supabase storage ls`. + Two selectors together fail + (`Only one of --remote, --linked, --db-url and --local can be set; got …`), `--project-ref` cannot + be combined with `--local` or `--remote=<value>`, an empty `--project-ref` is rejected, and + `--linked=false` selects nothing. +- **Target banner.** A remote project 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. +- `--remote` and the deprecated `--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 them; the only behavioural cost is that they must follow the + subcommand token (`storage ls --remote`, not `storage --remote 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 +- **Stack backend (local target).** 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, which reads stack state under `SUPABASE_HOME`; `ls` opens saved state without launching diff --git a/apps/cli/src/commands/storage/mv/SIDE_EFFECTS.md b/apps/cli/src/commands/storage/mv/SIDE_EFFECTS.md index 9256252d52..c122b825ce 100644 --- a/apps/cli/src/commands/storage/mv/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/storage/mv/SIDE_EFFECTS.md @@ -4,9 +4,10 @@ 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. -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 +With no selector the command targets the local stack. `--remote` targets the linked project, +`--remote=<ref>` another project and `--remote=<branch>` a branch of the linked project (see +[ADR 0033](../../../../../../docs/adr/0033-target-selection-and-hosted-mutation-consent.md)); +`--local`, `--linked` and `--project-ref` are deprecated aliases. 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. @@ -16,39 +17,41 @@ stack-backend endpoint, credential, capability-state, and error behavior. | Path | Format | When | | ---------------------------------------------- | ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `<workdir>/supabase/config.toml` | TOML | always (local creds; `[remotes.*]` merge when linked). With an explicit `--workdir`/`SUPABASE_WORKDIR` on a LOCAL target, a missing project config is now a hard failure (`StorageMissingProjectConfigError`) rather than a fall-back to embedded defaults — the default `api.port` would otherwise point the operation at a different local stack; a REMOTE (`--project-ref`/`--linked`) target never hard-fails on this, since credential resolution there reads the Management API, not local config | -| `~/.supabase/access-token` | plain text | linked path, when `SUPABASE_ACCESS_TOKEN` unset | -| `<workdir>/supabase/.temp/project-ref` | plain text | linked path, when `--project-ref` and `SUPABASE_PROJECT_ID` are both unset | -| `<workdir>/supabase/.temp/linked-project.json` | JSON | linked path, existence check before the cache fill | +| `~/.supabase/access-token` | plain text | remote project or branch lookup, when `SUPABASE_ACCESS_TOKEN` unset | +| `<workdir>/supabase/.temp/project-ref` | plain text | a remote project: resolves the ref (bare `--remote` with no `SUPABASE_PROJECT_ID`) and tells whether it is the linked project; with no selector, only to word the linked-checkout hint | +| `<workdir>/supabase/.temp/linked-project.json` | JSON | the `Target:` banner and the linked-checkout hint, the parent project for `--remote=<branch>`, and the existence check before the cache fill | | local Kong TLS cert/key | PEM | legacy backend only, local + `api.enabled` + `api.tls.enabled` | | `<workdir>/supabase/.env*`, `<workdir>/.env*` | dotenv | local path: read on both backends for `env(VAR)` interpolation while loading `config.toml`; on the legacy backend also to resolve the `SUPABASE_API_*` overrides for the gateway URL/TLS (#6452) and `SUPABASE_AUTH_{JWT_SECRET,SERVICE_ROLE_KEY}` for the service-role key | ## Files Written -| Path | Format | When | -| ---------------------------------------------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `<workdir>/supabase/.temp/linked-project.json` | JSON | post-run, linked path (`--local` false) — only when no cached file exists, `.temp/project-ref` is absent or names the same ref, a token is available, and the project lookup succeeds | -| `~/.supabase/telemetry.json` | JSON | post-run (always) | +| Path | Format | When | +| ---------------------------------------------- | ------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `<workdir>/supabase/.temp/linked-project.json` | JSON | post-run, remote project — only when no cached file exists, `.temp/project-ref` is absent or names the same ref, a token is available, and the project lookup succeeds | +| `~/.supabase/telemetry.json` | JSON | post-run (always) | ## API Routes Auth: `apikey` always; `Authorization: Bearer <key>` unless the key is `sb_`-prefixed. -| Method | Path | Request body | Response | -| ------ | ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------- | -| `POST` | `/storage/v1/object/move` | `{bucketId, sourceKey, destinationKey}` | `{message}` | -| `POST` | `/storage/v1/object/list/{bucket}` | `{prefix, search?, limit:100, offset?}` (recursive fallback) | `[{name, id?}]` | -| `GET` | `/v1/projects/{ref}/api-keys?reveal=true` | — (linked, Management API; skipped when `SUPABASE_AUTH_SERVICE_ROLE_KEY` is set to a non-empty value in the shell env) | service-role key | -| `GET` | `/v1/projects/{ref}` | — (linked, Management API; best-effort cache fill after the ref resolves, only when `.temp/linked-project.json` is absent, `.temp/project-ref` is absent or names the same ref, and a token is available; failures are ignored) | `ref`, `name`, `organization_id`, `organization_slug` | +| Method | Path | Request body | Response | +| ------ | ----------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------- | +| `POST` | `/storage/v1/object/move` | `{bucketId, sourceKey, destinationKey}` | `{message}` | +| `POST` | `/storage/v1/object/list/{bucket}` | `{prefix, search?, limit:100, offset?}` (recursive fallback) | `[{name, id?}]` | +| `GET` | `/v1/projects` | — (Management API; project picker: bare `--remote` with no ref from `SUPABASE_PROJECT_ID` or `.temp/project-ref`, with stdin and stdout TTYs and a non-JSON output format) | `[{id, name, organization_slug, region}]` | +| `GET` | `/v1/projects/{parent}/branches` | — (Management API; only for `--remote=<branch>` (or the deprecated `--project-ref=<branch>`), and for a bare `--remote` followed by a word that could name a branch; `{parent}` is `SUPABASE_PROJECT_ID`, else the linked project) | `[{id, name, project_ref, is_default}]` | +| `GET` | `/v1/projects/{ref}/api-keys?reveal=true` | — (remote project, Management API; skipped when `SUPABASE_AUTH_SERVICE_ROLE_KEY` is set to a non-empty value in the shell env) | service-role key | +| `GET` | `/v1/projects/{ref}` | — (remote project, Management API; best-effort cache fill after the ref resolves, only when `.temp/linked-project.json` is absent, `.temp/project-ref` is absent or names the same ref, and a token is available; failures are ignored) | `ref`, `name`, `organization_id`, `organization_slug` | ## Environment Variables `SUPABASE_AUTH_JWT_SECRET`, `SUPABASE_SERVICES_HOSTNAME`, and the `SUPABASE_API_*` override family are legacy backend only. `SUPABASE_AUTH_SERVICE_ROLE_KEY` is legacy backend only -for `--local`; on the linked path a non-empty shell value skips the api-keys call under either +for the local target; on a remote project a non-empty shell value skips the api-keys call under either backend. `SUPABASE_ACCESS_TOKEN`, `SUPABASE_PROJECT_ID`, and `SUPABASE_EXPERIMENTAL_STACK` (backend selection) apply to either backend — same roles as `storage ls`. -`SUPABASE_PROJECT_ID`'s linked-ref resolution is superseded by `--project-ref` when set. +`SUPABASE_PROJECT_ID` supplies the ref for a bare `--remote` and the parent project for `--remote=<branch>`. `storage` is an experimental command: `mv` requires `--experimental` (or `SUPABASE_EXPERIMENTAL`), else it exits 1 with @@ -56,16 +59,18 @@ 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` | `--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`) | +| 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` | two of `--remote`, `--linked` and `--local`, or `--project-ref` with `--local` or `--remote=<value>` (`TargetSelectorConflictError`, `TargetProjectRefConflictError`), after the experimental gate and the workdir check and before any Storage request | +| `1` | `--remote=<db-url>` (`TargetNeedsProjectError`): the Storage API needs a project, not a database URL; no Storage request is sent | +| `1` | `--remote=<branch>` matches no branch of the linked project (`LinkBranchNotFoundError`, lists the branches that exist) | +| `1` | move on a remote 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,20 +95,35 @@ backend — same roles as `storage ls`. ## Telemetry Events Fired -| Event | When | Notable properties | -| ---------------------- | ------------------------------------------ | -------------------------------- | -| `cli_command_executed` | post-run, success or failure (via wrapper) | `flags` (recursive/linked/local) | +| Event | When | Notable properties | +| ---------------------- | ------------------------------------------ | --------------------------------------- | +| `cli_command_executed` | post-run, success or failure (via wrapper) | `flags` (recursive/remote/linked/local) | ## Notes -- **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.** With no selector the command runs against the local stack; when the directory is + linked the local `Target:` banner ends with `· default; pass --remote for <project>`. `--remote` + targets the linked project (`SUPABASE_PROJECT_ID`, else `.temp/project-ref`). `--remote=<value>` + is read as a project ref (20 lowercase letters), `true|yes|on|1` (the linked project), or + otherwise the name of a branch of the linked project, which resolves to that branch's own project. + A database URL (`--remote=<db-url>`) fails with a `needs a project, not a database URL` error and + sends no request. `false|no|off|0`, `--no-remote` and an empty value fail with + `Leave out --remote to target the local Storage API.` A bare `--remote` followed by a word that is + a branch of the linked project fails with a `Did you mean --remote=<word>?` hint, since the word + may have been meant as the flag's value. The resolved target is announced on stderr (`Target: …`) + in every output format and, in JSON results, as a `target` object. +- **Deprecated selectors.** `--linked`, `--project-ref` and `--local` are hidden aliases that still + select their target and print a warning on stderr naming the replacement, for example + `--linked is deprecated and will be removed in v4. Use --remote instead: supabase storage mv --remote` + and `--local isn't needed (local is the default) and will be removed in v4: supabase storage mv`. + Two selectors together fail + (`Only one of --remote, --linked, --db-url and --local can be set; got …`), `--project-ref` cannot + be combined with `--local` or `--remote=<value>`, an empty `--project-ref` is rejected, and + `--linked=false` selects nothing. +- **Target banner.** A remote project 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 +- **Hosted consent.** Moving on a remote 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 @@ -113,7 +133,7 @@ backend — same roles as `storage ls`. 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 +- **Stack backend (local target).** 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, which reads stack state under `SUPABASE_HOME`; `mv` opens saved state without launching diff --git a/apps/cli/src/commands/storage/rm/SIDE_EFFECTS.md b/apps/cli/src/commands/storage/rm/SIDE_EFFECTS.md index 355ff78cea..4df81ab87a 100644 --- a/apps/cli/src/commands/storage/rm/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/storage/rm/SIDE_EFFECTS.md @@ -3,11 +3,12 @@ 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. The local stack -confirms each bucket; a linked project asks once for the whole run. +confirms each bucket; a remote project asks once for the whole run. -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 +With no selector the command targets the local stack. `--remote` targets the linked project, +`--remote=<ref>` another project and `--remote=<branch>` a branch of the linked project (see +[ADR 0033](../../../../../../docs/adr/0033-target-selection-and-hosted-mutation-consent.md)); +`--local`, `--linked` and `--project-ref` are deprecated aliases. 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. @@ -17,42 +18,44 @@ stack-backend endpoint, credential, capability-state, and error behavior. | Path | Format | When | | ---------------------------------------------- | ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `<workdir>/supabase/config.toml` | TOML | always (local creds; `[remotes.*]` merge when linked). With an explicit `--workdir`/`SUPABASE_WORKDIR` on a LOCAL target, a missing project config is now a hard failure (`StorageMissingProjectConfigError`) rather than a fall-back to embedded defaults — the default `api.port` would otherwise point the operation at a different local stack; a REMOTE (`--project-ref`/`--linked`) target never hard-fails on this, since credential resolution there reads the Management API, not local config | -| `~/.supabase/access-token` | plain text | linked path, when `SUPABASE_ACCESS_TOKEN` unset | -| `<workdir>/supabase/.temp/project-ref` | plain text | linked path, when `--project-ref` and `SUPABASE_PROJECT_ID` are both unset | -| `<workdir>/supabase/.temp/linked-project.json` | JSON | linked path, existence check before the cache fill | +| `~/.supabase/access-token` | plain text | remote project or branch lookup, when `SUPABASE_ACCESS_TOKEN` unset | +| `<workdir>/supabase/.temp/project-ref` | plain text | a remote project: resolves the ref (bare `--remote` with no `SUPABASE_PROJECT_ID`) and tells whether it is the linked project; with no selector, only to word the linked-checkout hint | +| `<workdir>/supabase/.temp/linked-project.json` | JSON | the `Target:` banner and the linked-checkout hint, the parent project for `--remote=<branch>`, and the existence check before the cache fill | | local Kong TLS cert/key | PEM | legacy backend only, local + `api.enabled` + `api.tls.enabled` | | `<workdir>/supabase/.env*`, `<workdir>/.env*` | dotenv | always, to resolve `SUPABASE_YES` (CLI-1878) and, on both backends, for `env(VAR)` interpolation while loading `config.toml`; on the local, legacy-backend path also the `SUPABASE_API_*` overrides for the gateway URL/TLS (#6452) and `SUPABASE_AUTH_{JWT_SECRET,SERVICE_ROLE_KEY}` for the service-role key | ## Files Written -| Path | Format | When | -| ---------------------------------------------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `<workdir>/supabase/.temp/linked-project.json` | JSON | post-run, linked path (`--local` false) — only when no cached file exists, `.temp/project-ref` is absent or names the same ref, a token is available, and the project lookup succeeds | -| `~/.supabase/telemetry.json` | JSON | post-run (always) | +| Path | Format | When | +| ---------------------------------------------- | ------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `<workdir>/supabase/.temp/linked-project.json` | JSON | post-run, remote project — only when no cached file exists, `.temp/project-ref` is absent or names the same ref, a token is available, and the project lookup succeeds | +| `~/.supabase/telemetry.json` | JSON | post-run (always) | ## API Routes Auth: `apikey` always; `Authorization: Bearer <key>` unless the key is `sb_`-prefixed. -| Method | Path | Request body | Response | -| -------- | ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------- | -| `DELETE` | `/storage/v1/object/{bucket}` | `{prefixes}` (chunked by 1000) | `[{name, ...}]` | -| `DELETE` | `/storage/v1/bucket/{id}` | — (recursive on an empty prefix) | `{message}` | -| `POST` | `/storage/v1/object/list/{bucket}` | `{prefix, search?, limit:100, offset?}` (recursive walk) | `[{name, id?}]` | -| `GET` | `/storage/v1/bucket` | — (no-args + `-r`: delete all buckets) | `[{name, id}]` | -| `GET` | `/v1/projects/{ref}/api-keys?reveal=true` | — (linked, Management API; skipped when `SUPABASE_AUTH_SERVICE_ROLE_KEY` is set to a non-empty value in the shell env) | service-role key | -| `GET` | `/v1/projects/{ref}` | — (linked, Management API; best-effort cache fill after the ref resolves, only when `.temp/linked-project.json` is absent, `.temp/project-ref` is absent or names the same ref, and a token is available; failures are ignored) | `ref`, `name`, `organization_id`, `organization_slug` | +| Method | Path | Request body | Response | +| -------- | ----------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------- | +| `DELETE` | `/storage/v1/object/{bucket}` | `{prefixes}` (chunked by 1000) | `[{name, ...}]` | +| `DELETE` | `/storage/v1/bucket/{id}` | — (recursive on an empty prefix) | `{message}` | +| `POST` | `/storage/v1/object/list/{bucket}` | `{prefix, search?, limit:100, offset?}` (recursive walk) | `[{name, id?}]` | +| `GET` | `/storage/v1/bucket` | — (no-args + `-r`: delete all buckets) | `[{name, id}]` | +| `GET` | `/v1/projects` | — (Management API; project picker: bare `--remote` with no ref from `SUPABASE_PROJECT_ID` or `.temp/project-ref`, with stdin and stdout TTYs and a non-JSON output format) | `[{id, name, organization_slug, region}]` | +| `GET` | `/v1/projects/{parent}/branches` | — (Management API; only for `--remote=<branch>` (or the deprecated `--project-ref=<branch>`), and for a bare `--remote` followed by a word that could name a branch; `{parent}` is `SUPABASE_PROJECT_ID`, else the linked project) | `[{id, name, project_ref, is_default}]` | +| `GET` | `/v1/projects/{ref}/api-keys?reveal=true` | — (remote project, Management API; skipped when `SUPABASE_AUTH_SERVICE_ROLE_KEY` is set to a non-empty value in the shell env) | service-role key | +| `GET` | `/v1/projects/{ref}` | — (remote project, Management API; best-effort cache fill after the ref resolves, only when `.temp/linked-project.json` is absent, `.temp/project-ref` is absent or names the same ref, and a token is available; failures are ignored) | `ref`, `name`, `organization_id`, `organization_slug` | ## Environment Variables `SUPABASE_AUTH_JWT_SECRET`, `SUPABASE_SERVICES_HOSTNAME`, and the `SUPABASE_API_*` override family are legacy backend only. `SUPABASE_AUTH_SERVICE_ROLE_KEY` is legacy backend only -for `--local`; on the linked path a non-empty shell value skips the api-keys call under either +for the local target; on a remote project a non-empty shell value skips the api-keys call under either backend. `SUPABASE_ACCESS_TOKEN`, `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. +wins); for a remote project only `--yes` or a shell `SUPABASE_YES` counts. +`SUPABASE_PROJECT_ID` supplies the ref for a bare `--remote` and the parent project for `--remote=<branch>`. `storage` is an experimental command: `rm` requires `--experimental` (or `SUPABASE_EXPERIMENTAL`), else it exits 1 with @@ -67,8 +70,10 @@ wins); for a linked project only `--yes` or a shell `SUPABASE_YES` counts. | `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` | two of `--remote`, `--linked` and `--local`, or `--project-ref` with `--local` or `--remote=<value>` (`TargetSelectorConflictError`, `TargetProjectRefConflictError`), after the experimental gate and the workdir check and before any Storage request | +| `1` | `--remote=<db-url>` (`TargetNeedsProjectError`): the Storage API needs a project, not a database URL; no Storage request is sent | +| `1` | `--remote=<branch>` matches no branch of the linked project (`LinkBranchNotFoundError`, lists the branches that exist) | +| `1` | remote 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`) | @@ -89,7 +94,7 @@ wins); for a linked project only `--yes` or a shell `SUPABASE_YES` counts. 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 +`StorageRmConfirmationRequiredError` before any Storage request; on a remote 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. @@ -108,23 +113,38 @@ Requires `--yes`/`SUPABASE_YES` on the same terms as `json`. ## Telemetry Events Fired -| Event | When | Notable properties | -| ---------------------- | ------------------------------------------ | -------------------------------- | -| `cli_command_executed` | post-run, success or failure (via wrapper) | `flags` (recursive/linked/local) | +| Event | When | Notable properties | +| ---------------------- | ------------------------------------------ | --------------------------------------- | +| `cli_command_executed` | post-run, success or failure (via wrapper) | `flags` (recursive/remote/linked/local) | ## Notes -- **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.** With no selector the command runs against the local stack; when the directory is + linked the local `Target:` banner ends with `· default; pass --remote for <project>`. `--remote` + targets the linked project (`SUPABASE_PROJECT_ID`, else `.temp/project-ref`). `--remote=<value>` + is read as a project ref (20 lowercase letters), `true|yes|on|1` (the linked project), or + otherwise the name of a branch of the linked project, which resolves to that branch's own project. + A database URL (`--remote=<db-url>`) fails with a `needs a project, not a database URL` error and + sends no request. `false|no|off|0`, `--no-remote` and an empty value fail with + `Leave out --remote to target the local Storage API.` A bare `--remote` followed by a word that is + a branch of the linked project fails with a `Did you mean --remote=<word>?` hint, since the word + may have been meant as the flag's value. The resolved target is announced on stderr (`Target: …`) + in every output format and, in JSON results, as a `target` object. +- **Deprecated selectors.** `--linked`, `--project-ref` and `--local` are hidden aliases that still + select their target and print a warning on stderr naming the replacement, for example + `--linked is deprecated and will be removed in v4. Use --remote instead: supabase storage rm --remote` + and `--local isn't needed (local is the default) and will be removed in v4: supabase storage rm`. + Two selectors together fail + (`Only one of --remote, --linked, --db-url and --local can be set; got …`), `--project-ref` cannot + be combined with `--local` or `--remote=<value>`, an empty `--project-ref` is rejected, and + `--linked=false` selects nothing. +- **Target banner.** A remote project 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. -- **Hosted consent.** A linked project asks once, after the bucket plan is known (for `-r` with no +- **Hosted consent.** A remote 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 @@ -140,7 +160,7 @@ Requires `--yes`/`SUPABASE_YES` on the same terms as `json`. - Explicit deletes are attempted first ("in case the paths resolve to extensionless files"); prefixes not returned as removed are then walked recursively when `-r` is set. - Object deletes are chunked at `DELETE_OBJECTS_LIMIT` (1000) per request. -- **Stack backend (`--local`).** The Storage endpoint comes from the selected composition's +- **Stack backend (local target).** 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, which reads stack state under `SUPABASE_HOME`; `rm` opens saved state without launching From 500e0d276ede2cfd7c8531e43e35d078bdd28317 Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Mon, 19 Oct 2026 12:13:24 +0100 Subject: [PATCH 126/291] refactor(cli): select hosted A-F command targets with --remote backups, branches, config diff/pull/push, pull, domains, encryption, experimental compute and functions list/delete/download take the foundation's projectTargetFlags() instead of a hand-declared --project-ref. config diff/pull/push and pull resolve ref, branch name and branch UUID from --remote through requestedConfigTarget. branches act on a parent project, so --remote=<ref> picks it and a branch-shaped value is rejected naming --remote=<ref>. The pull retry hints and compute retry suggestions use the --remote=<ref> form. --- .../src/command-internal/project-target.ts | 31 +++++++++++-- .../target/project-selection.ts | 31 +++++++++++++ .../src/commands/backups/list/list.command.ts | 10 ++--- .../src/commands/backups/list/list.handler.ts | 5 +-- .../backups/restore/restore.command.ts | 8 ++-- .../backups/restore/restore.handler.ts | 5 +-- .../src/commands/branches/branches.target.ts | 44 +++++++++++++++++++ .../branches/create/create.command.ts | 10 ++--- .../branches/create/create.handler.ts | 4 +- .../branches/delete/delete.command.ts | 12 ++--- .../branches/delete/delete.handler.ts | 4 +- .../branches/disable/disable.command.ts | 12 ++--- .../branches/disable/disable.handler.ts | 4 +- .../src/commands/branches/get/get.command.ts | 12 ++--- .../src/commands/branches/get/get.handler.ts | 4 +- .../commands/branches/list/list.command.ts | 12 ++--- .../commands/branches/list/list.handler.ts | 4 +- .../commands/branches/pause/pause.command.ts | 12 ++--- .../commands/branches/pause/pause.handler.ts | 4 +- .../branches/unpause/unpause.command.ts | 12 ++--- .../branches/unpause/unpause.handler.ts | 4 +- .../branches/update/update.command.ts | 10 ++--- .../branches/update/update.handler.ts | 4 +- .../src/commands/config/diff/diff.command.ts | 11 ++--- .../src/commands/config/diff/diff.errors.ts | 6 +-- .../src/commands/config/diff/diff.format.ts | 2 +- .../src/commands/config/diff/diff.handler.ts | 8 ++-- .../src/commands/config/pull/pull.command.ts | 10 +---- .../src/commands/config/pull/pull.errors.ts | 6 +-- .../src/commands/config/pull/pull.handler.ts | 9 ++-- .../src/commands/config/pull/pull.scope.ts | 6 +-- .../config/push/push.branch-target.ts | 2 +- .../src/commands/config/push/push.command.ts | 16 +++---- .../src/commands/config/push/push.errors.ts | 8 ++-- .../src/commands/config/push/push.handler.ts | 9 ++-- .../domains/activate/activate.command.ts | 8 ++-- .../domains/activate/activate.handler.ts | 5 +-- .../commands/domains/create/create.command.ts | 8 ++-- .../commands/domains/create/create.handler.ts | 5 +-- .../commands/domains/delete/delete.command.ts | 8 ++-- .../commands/domains/delete/delete.handler.ts | 5 +-- .../src/commands/domains/get/get.command.ts | 8 ++-- .../src/commands/domains/get/get.handler.ts | 5 +-- .../domains/reverify/reverify.command.ts | 8 ++-- .../domains/reverify/reverify.handler.ts | 5 +-- .../get-root-key/get-root-key.command.ts | 10 ++--- .../get-root-key/get-root-key.handler.ts | 5 +-- .../update-root-key.command.ts | 10 ++--- .../update-root-key.handler.ts | 5 +-- .../experimental/compute/compute.output.ts | 26 ++++++----- .../compute/delete/delete.command.ts | 10 ++--- .../compute/delete/delete.handler.ts | 12 ++--- .../experimental/compute/list/list.command.ts | 10 ++--- .../experimental/compute/list/list.handler.ts | 8 ++-- .../experimental/compute/logs/logs.command.ts | 8 ++-- .../experimental/compute/logs/logs.handler.ts | 12 ++--- .../experimental/compute/push/push.command.ts | 8 ++-- .../experimental/compute/push/push.handler.ts | 14 +++--- .../compute/status/status.command.ts | 11 +++-- .../compute/status/status.handler.ts | 12 ++--- .../functions/delete/delete.command.ts | 20 ++++++--- .../functions/delete/delete.handler.ts | 13 +++--- .../functions/download/download.command.ts | 18 +++++--- .../functions/download/download.handler.ts | 11 +++-- .../commands/functions/list/list.command.ts | 20 ++++++--- .../commands/functions/list/list.handler.ts | 10 ++--- apps/cli/src/commands/pull/pull.aggregate.ts | 6 +-- apps/cli/src/commands/pull/pull.command.ts | 13 ++---- apps/cli/src/commands/pull/pull.errors.ts | 12 ++--- apps/cli/src/commands/pull/pull.format.ts | 2 +- apps/cli/src/commands/pull/pull.handler.ts | 6 ++- apps/cli/src/commands/pull/pull.steps.ts | 3 +- apps/cli/src/shared/functions/delete.ts | 9 ++-- apps/cli/src/shared/functions/download.ts | 7 +-- 74 files changed, 416 insertions(+), 311 deletions(-) create mode 100644 apps/cli/src/command-internal/target/project-selection.ts create mode 100644 apps/cli/src/commands/branches/branches.target.ts diff --git a/apps/cli/src/command-internal/project-target.ts b/apps/cli/src/command-internal/project-target.ts index 2152727bb9..1eab188f26 100644 --- a/apps/cli/src/command-internal/project-target.ts +++ b/apps/cli/src/command-internal/project-target.ts @@ -18,6 +18,8 @@ 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 { selectProject } from "./target/project-selection.ts"; +import type { ProjectTargetInput } from "./target/resolve-project-target.ts"; import { type LinkedTarget, linkedTarget } from "./target/resolve-target.ts"; import { renderBranchTarget } from "./target/target-identity.ts"; import { @@ -27,7 +29,7 @@ import { } from "../shared/telemetry/error-actionability.ts"; /** - * The resolved comparison/pull target for a command that accepts `--project-ref` + * The resolved comparison/pull target for a command that accepts `--remote` * (`config diff`, `config pull`, `config push`, `pull`): a project ref, plus the branch * name/UUID it was given, if any. */ @@ -160,15 +162,36 @@ function reclassifyBranchNotFoundError<E extends ConfigTargetResolveFailure, C>( return cause.status === 404 ? Effect.fail(notFoundError) : Effect.fail(cause); } -/** The calls `resolveConfigTarget` makes: a branch lookup for a non-ref `--project-ref`, the project picker when no ref is given or linked. */ +/** The calls `resolveConfigTarget` makes: a branch lookup for a non-ref `--remote`, the project picker when no ref is given or linked. */ export const configTarget = compose( + withCondition(branchRef, { flag: "remote", present: true }), withCondition(branchRef, { flag: "project-ref", present: true }), projectRefPicker, ); +/** The ref, branch name or UUID that `--remote` (or the deprecated `--project-ref`) names; none means the linked project. */ +export const requestedConfigTarget = Effect.fn("ProjectTarget.requested")(function* ( + input: ProjectTargetInput, +) { + const { selection } = yield* selectProject(input); + switch (selection.explicit) { + case "branch": + return Option.some(selection.name); + case "linked": + return selection.ref; + case undefined: + return Option.none<string>(); + case "local": + case "db-url": + return yield* Effect.die( + new Error(`a project-only selector cannot select "${selection.explicit}"`), + ); + } +}); + /** - * Resolves `--project-ref` to a {@link ConfigTarget}: a project ref, or the name/UUID of a - * branch on the linked project. A ref-shaped value (20 lowercase letters) is always a project ref. + * Resolves the requested `--remote` value to a {@link ConfigTarget}: a project ref, or the name/UUID + * of a branch on the linked project. A ref-shaped value (20 lowercase letters) is always a project ref. * * A UUID branch needs no parent ref, so it works unlinked; a name branch resolves the parent * ref eagerly, before any spinner starts, so an unlinked or stale link fails immediately. diff --git a/apps/cli/src/command-internal/target/project-selection.ts b/apps/cli/src/command-internal/target/project-selection.ts new file mode 100644 index 0000000000..32fcc938cf --- /dev/null +++ b/apps/cli/src/command-internal/target/project-selection.ts @@ -0,0 +1,31 @@ +import { Effect, Option, Result } from "effect"; + +import { + CommandRuntime, + getCommandRuntimeCommand, +} from "../../shared/runtime/command-runtime.service.ts"; +import { warnTargetDeprecations } from "./remote-resolution.ts"; +import type { ProjectTargetInput } from "./resolve-project-target.ts"; +import { parseTargetSelector } from "./target-selector.ts"; + +/** + * Parses the `--remote` and deprecated `--project-ref` of a hosted command and prints the alias + * warnings, leaving the lookup to the caller; a database URL fails as a selector error. + */ +export const selectProject = Effect.fn("Target.selectProject")(function* ( + input: ProjectTargetInput, +) { + const runtime = yield* Effect.serviceOption(CommandRuntime); + const commandName = Option.isSome(runtime) + ? `\`supabase ${getCommandRuntimeCommand(runtime.value)}\`` + : undefined; + const { selection, deprecations } = yield* Result.match( + parseTargetSelector( + { ...input, local: false, linked: false }, + commandName === undefined ? {} : { commandName }, + ), + { onFailure: Effect.fail, onSuccess: Effect.succeed }, + ); + yield* warnTargetDeprecations(deprecations); + return { selection, commandName: commandName ?? "This command" }; +}); diff --git a/apps/cli/src/commands/backups/list/list.command.ts b/apps/cli/src/commands/backups/list/list.command.ts index 48df7b585c..02262f2b0c 100644 --- a/apps/cli/src/commands/backups/list/list.command.ts +++ b/apps/cli/src/commands/backups/list/list.command.ts @@ -1,5 +1,5 @@ import type * as CliCommand from "effect/cli/Command"; -import { Command, Flag } from "effect/cli"; +import { Command } from "effect/cli"; import { withPermissions } from "../../../command-internal/command-permissions/command-permissions.annotation.ts"; import { compose } from "../../../command-internal/command-permissions/model.ts"; @@ -10,12 +10,11 @@ import { managementApiRuntimeLayer } from "../../../command-internal/management- import { withCommandTelemetry } from "../../../telemetry/command-telemetry.ts"; import { backupsList } from "./list.handler.ts"; import { withTargetPolicy } from "../../../command-internal/target/target-policy.ts"; +import { branchResolution } from "../../../command-internal/target/branch-resolution.ts"; +import { projectTargetFlags } from "../../../command-internal/target/target-flags.ts"; const config = { - projectRef: Flag.String("project-ref").pipe( - Flag.withDescription("Project ref of the Supabase project."), - Flag.optional, - ), + ...projectTargetFlags(), } as const; export type BackupsListFlags = CliCommand.Command.Config.Infer<typeof config>; @@ -38,6 +37,7 @@ export const backupsListCommand = Command.make("list", config).pipe( { operations: [{ operationId: "v1-list-all-backups", kind: "required" }] }, projectRefPicker, linkedProjectCache, + branchResolution, ), ), Command.withHandler((flags) => diff --git a/apps/cli/src/commands/backups/list/list.handler.ts b/apps/cli/src/commands/backups/list/list.handler.ts index c6595152ff..b3d944e38e 100644 --- a/apps/cli/src/commands/backups/list/list.handler.ts +++ b/apps/cli/src/commands/backups/list/list.handler.ts @@ -2,7 +2,7 @@ import type { V1ListAllBackupsOutput } from "@supabase/api/effect"; import { Effect, Option } from "effect"; import { CommandPlatformApi } from "../../../auth/command-platform-api.service.ts"; -import { ProjectRefResolver } from "../../../config/project-ref.service.ts"; +import { resolveProjectTarget } from "../../../command-internal/target/resolve-project-target.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"; @@ -93,11 +93,10 @@ export const backupsList = Effect.fn("backups.list")(function* (flags: BackupsLi const output = yield* Output; const outputFlag = yield* OutputFlag; const api = yield* CommandPlatformApi; - const resolver = yield* ProjectRefResolver; const linkedProjectCache = yield* LinkedProjectCache; const telemetryState = yield* TelemetryState; - const ref = yield* resolver.resolve(flags.projectRef); + const ref = yield* resolveProjectTarget({ remote: flags.remote, projectRef: flags.projectRef }); yield* Effect.gen(function* () { // Spinner is text-mode only; in JSON/stream-json it would leave a dangling `[task] start:` diff --git a/apps/cli/src/commands/backups/restore/restore.command.ts b/apps/cli/src/commands/backups/restore/restore.command.ts index 7049722198..c00007c752 100644 --- a/apps/cli/src/commands/backups/restore/restore.command.ts +++ b/apps/cli/src/commands/backups/restore/restore.command.ts @@ -10,12 +10,11 @@ import { managementApiRuntimeLayer } from "../../../command-internal/management- import { withCommandTelemetry } from "../../../telemetry/command-telemetry.ts"; import { backupsRestore } from "./restore.handler.ts"; import { withTargetPolicy } from "../../../command-internal/target/target-policy.ts"; +import { branchResolution } from "../../../command-internal/target/branch-resolution.ts"; +import { projectTargetFlags } from "../../../command-internal/target/target-flags.ts"; const config = { - projectRef: Flag.String("project-ref").pipe( - Flag.withDescription("Project ref of the Supabase project."), - Flag.optional, - ), + ...projectTargetFlags(), timestamp: Flag.Int("timestamp").pipe( Flag.withAlias("t"), Flag.withDescription("The recovery time target in seconds since epoch."), @@ -42,6 +41,7 @@ export const backupsRestoreCommand = Command.make("restore", config).pipe( }, projectRefPicker, linkedProjectCache, + branchResolution, ), ), Command.withHandler((flags) => diff --git a/apps/cli/src/commands/backups/restore/restore.handler.ts b/apps/cli/src/commands/backups/restore/restore.handler.ts index 9206771d74..a3ee883cae 100644 --- a/apps/cli/src/commands/backups/restore/restore.handler.ts +++ b/apps/cli/src/commands/backups/restore/restore.handler.ts @@ -1,7 +1,7 @@ import { Effect, Option } from "effect"; import { CommandPlatformApi } from "../../../auth/command-platform-api.service.ts"; -import { ProjectRefResolver } from "../../../config/project-ref.service.ts"; +import { resolveProjectTarget } from "../../../command-internal/target/resolve-project-target.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"; @@ -26,11 +26,10 @@ export const backupsRestore = Effect.fn("backups.restore")(function* (flags: Bac const output = yield* Output; const outputFlag = yield* OutputFlag; const api = yield* CommandPlatformApi; - const resolver = yield* ProjectRefResolver; const linkedProjectCache = yield* LinkedProjectCache; const telemetryState = yield* TelemetryState; - const ref = yield* resolver.resolve(flags.projectRef); + const ref = yield* resolveProjectTarget({ remote: flags.remote, projectRef: flags.projectRef }); const recoveryTimeTargetUnix = Option.getOrElse(flags.timestamp, () => 0); yield* Effect.gen(function* () { diff --git a/apps/cli/src/commands/branches/branches.target.ts b/apps/cli/src/commands/branches/branches.target.ts new file mode 100644 index 0000000000..d55d06b866 --- /dev/null +++ b/apps/cli/src/commands/branches/branches.target.ts @@ -0,0 +1,44 @@ +import { Effect, Option } from "effect"; + +import { PROJECT_REF_PATTERN } from "../../config/project-ref.service.ts"; + +import { block } from "../../command-internal/command-permissions/model.ts"; +import { resolveParentScopedProjectRef } from "../../command-internal/parent-project-ref.ts"; +import { selectProject } from "../../command-internal/target/project-selection.ts"; +import type { ProjectTargetInput } from "../../command-internal/target/resolve-project-target.ts"; +import { TargetNeedsProjectError } from "../../command-internal/target/target.errors.ts"; + +/** + * The parent project a `branches` command acts on: `--remote=<ref>` names one, and a bare + * `--remote` or no selector falls through to the linked parent. The branch itself is an argument, + * so a branch name is no valid `--remote` value here. + */ +export const resolveBranchesParent = Effect.fn("branches.resolveParent")(function* ( + input: ProjectTargetInput, +) { + const { selection, commandName } = yield* selectProject(input); + switch (selection.explicit) { + case "branch": + return yield* new TargetNeedsProjectError({ + message: `${commandName} acts on a parent project, so --remote takes its project ref, not a branch name. Use --remote=<ref>.`, + }); + case "linked": + return yield* resolveParentScopedProjectRef(selection.ref); + case undefined: + return yield* resolveParentScopedProjectRef(Option.none()); + case "local": + case "db-url": + return yield* Effect.die( + new Error(`a project-only selector cannot select "${selection.explicit}"`), + ); + } +}); + +/** `--project-ref` is safe to report only when ref-shaped; a branch-shaped value is a user-chosen name. */ +export const branchesSafeFlags = (flags: { readonly projectRef: Option.Option<string> }) => + Option.isSome(flags.projectRef) && PROJECT_REF_PATTERN.test(flags.projectRef.value) + ? ["project-ref"] + : []; + +/** `--remote` only picks the parent project; it never changes which operations a branches command calls. */ +export const branchesParentTarget = block("branches-parent-target", [], ["remote"]); diff --git a/apps/cli/src/commands/branches/create/create.command.ts b/apps/cli/src/commands/branches/create/create.command.ts index 8f222e7cb9..29514bf977 100644 --- a/apps/cli/src/commands/branches/create/create.command.ts +++ b/apps/cli/src/commands/branches/create/create.command.ts @@ -11,6 +11,8 @@ import { managementApiRuntimeLayer } from "../../../command-internal/management- import { withCommandTelemetry } from "../../../telemetry/command-telemetry.ts"; import { branchesCreate } from "./create.handler.ts"; import { withTargetPolicy } from "../../../command-internal/target/target-policy.ts"; +import { projectTargetFlags } from "../../../command-internal/target/target-flags.ts"; +import { branchesParentTarget, branchesSafeFlags } from "../branches.target.ts"; const BRANCH_REGIONS = [ "ap-east-1", @@ -59,10 +61,7 @@ const config = { Argument.withDescription("Name for the new branch."), Argument.optional, ), - projectRef: Flag.String("project-ref").pipe( - Flag.withDescription("Project ref of the Supabase project."), - Flag.optional, - ), + ...projectTargetFlags(), region: Flag.Literals("region", BRANCH_REGIONS).pipe( Flag.withDescription("Select a region to deploy the branch database."), Flag.optional, @@ -102,6 +101,7 @@ export const branchesCreateCommand = Command.make("create", config).pipe( operations: [{ operationId: "v1-create-a-branch", kind: "required" }], noApiEffectFlags: ["region", "size", "persistent", "with-data", "notify-url", "git-branch"], }, + branchesParentTarget, projectRefPicker, upgradeSuggestion, linkedProjectCache, @@ -109,7 +109,7 @@ export const branchesCreateCommand = Command.make("create", config).pipe( ), Command.withHandler((flags) => branchesCreate(flags).pipe( - withCommandTelemetry({ flags, safeFlags: ["project-ref"], config }), + withCommandTelemetry({ flags, safeFlags: branchesSafeFlags(flags), config }), withJsonErrorHandling, ), ), diff --git a/apps/cli/src/commands/branches/create/create.handler.ts b/apps/cli/src/commands/branches/create/create.handler.ts index d996c932c4..e96096e6c7 100644 --- a/apps/cli/src/commands/branches/create/create.handler.ts +++ b/apps/cli/src/commands/branches/create/create.handler.ts @@ -14,7 +14,7 @@ import { encodeStructYaml, } from "../../../command-internal/struct-output.encoders.ts"; import { mapHttpError } from "../../../command-internal/http-errors.ts"; -import { resolveParentScopedProjectRef } from "../../../command-internal/parent-project-ref.ts"; +import { resolveBranchesParent } from "../branches.target.ts"; import { gateMapError } from "../../../command-internal/upgrade-suggest.ts"; import { BRANCH_RESPONSE_SHAPE } from "../branches.response-shape.ts"; import { @@ -67,7 +67,7 @@ export const branchesCreate = Effect.fn("branches.create")(function* (flags: Bra // `branches` is parent-scoped: after `supabase link <branch>`, `supabase/.temp/project-ref` // holds the branch's own ref, and the platform 403s on that ref for every branches-management // endpoint. - const ref = yield* resolveParentScopedProjectRef(flags.projectRef); + const ref = yield* resolveBranchesParent({ remote: flags.remote, projectRef: flags.projectRef }); yield* Effect.annotateCurrentSpan("project.ref", ref); yield* Effect.gen(function* () { diff --git a/apps/cli/src/commands/branches/delete/delete.command.ts b/apps/cli/src/commands/branches/delete/delete.command.ts index 48bd9ad3d7..72741fba1a 100644 --- a/apps/cli/src/commands/branches/delete/delete.command.ts +++ b/apps/cli/src/commands/branches/delete/delete.command.ts @@ -1,4 +1,4 @@ -import { Argument, Command, Flag } from "effect/cli"; +import { Argument, Command } from "effect/cli"; import type * as CliCommand from "effect/cli/Command"; import { withPermissions } from "../../../command-internal/command-permissions/command-permissions.annotation.ts"; @@ -12,16 +12,15 @@ import { managementApiRuntimeLayer } from "../../../command-internal/management- import { withCommandTelemetry } from "../../../telemetry/command-telemetry.ts"; import { branchesDelete } from "./delete.handler.ts"; import { withTargetPolicy } from "../../../command-internal/target/target-policy.ts"; +import { projectTargetFlags } from "../../../command-internal/target/target-flags.ts"; +import { branchesParentTarget, branchesSafeFlags } from "../branches.target.ts"; const config = { name: Argument.String("name").pipe( Argument.withDescription("Branch name or ID to delete."), Argument.optional, ), - projectRef: Flag.String("project-ref").pipe( - Flag.withDescription("Project ref of the Supabase project."), - Flag.optional, - ), + ...projectTargetFlags(), } as const; export type BranchesDeleteFlags = CliCommand.Command.Config.Infer<typeof config>; @@ -31,6 +30,7 @@ export const branchesDeleteCommand = Command.make("delete", config).pipe( withPermissions( compose( { operations: [{ operationId: "v1-delete-a-branch", kind: "required" }] }, + branchesParentTarget, projectRefPicker, branchPrompt, branchRef, @@ -39,7 +39,7 @@ export const branchesDeleteCommand = Command.make("delete", config).pipe( ), Command.withHandler((flags) => branchesDelete(flags).pipe( - withCommandTelemetry({ flags, safeFlags: ["project-ref"] }), + withCommandTelemetry({ flags, safeFlags: branchesSafeFlags(flags) }), withJsonErrorHandling, ), ), diff --git a/apps/cli/src/commands/branches/delete/delete.handler.ts b/apps/cli/src/commands/branches/delete/delete.handler.ts index 2a1dc3edb0..9203078103 100644 --- a/apps/cli/src/commands/branches/delete/delete.handler.ts +++ b/apps/cli/src/commands/branches/delete/delete.handler.ts @@ -6,7 +6,7 @@ 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 { resolveBranchesParent } from "../branches.target.ts"; import { BranchesDeleteNetworkError, BranchesDeleteUnexpectedStatusError, @@ -31,7 +31,7 @@ export const branchesDelete = Effect.fn("branches.delete")(function* (flags: Bra // `branches` is parent-scoped: after `supabase link <branch>`, `supabase/.temp/project-ref` // holds the branch's own ref, and the platform 403s on that ref for every branches-management // endpoint. - const ref = yield* resolveParentScopedProjectRef(flags.projectRef); + const ref = yield* resolveBranchesParent({ remote: flags.remote, projectRef: flags.projectRef }); yield* Effect.annotateCurrentSpan("project.ref", ref); yield* Effect.gen(function* () { diff --git a/apps/cli/src/commands/branches/disable/disable.command.ts b/apps/cli/src/commands/branches/disable/disable.command.ts index f1cafca9fe..8753e8a22f 100644 --- a/apps/cli/src/commands/branches/disable/disable.command.ts +++ b/apps/cli/src/commands/branches/disable/disable.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"; @@ -10,12 +10,11 @@ import { managementApiRuntimeLayer } from "../../../command-internal/management- import { withCommandTelemetry } from "../../../telemetry/command-telemetry.ts"; import { branchesDisable } from "./disable.handler.ts"; import { withTargetPolicy } from "../../../command-internal/target/target-policy.ts"; +import { projectTargetFlags } from "../../../command-internal/target/target-flags.ts"; +import { branchesParentTarget, branchesSafeFlags } from "../branches.target.ts"; const config = { - projectRef: Flag.String("project-ref").pipe( - Flag.withDescription("Project ref of the Supabase project."), - Flag.optional, - ), + ...projectTargetFlags(), } as const; export type BranchesDisableFlags = CliCommand.Command.Config.Infer<typeof config>; @@ -26,13 +25,14 @@ export const branchesDisableCommand = Command.make("disable", config).pipe( withPermissions( compose( { operations: [{ operationId: "v1-disable-preview-branching", kind: "required" }] }, + branchesParentTarget, projectRefPicker, linkedProjectCache, ), ), Command.withHandler((flags) => branchesDisable(flags).pipe( - withCommandTelemetry({ flags, safeFlags: ["project-ref"] }), + withCommandTelemetry({ flags, safeFlags: branchesSafeFlags(flags) }), withJsonErrorHandling, ), ), diff --git a/apps/cli/src/commands/branches/disable/disable.handler.ts b/apps/cli/src/commands/branches/disable/disable.handler.ts index 3407df0834..0049af559b 100644 --- a/apps/cli/src/commands/branches/disable/disable.handler.ts +++ b/apps/cli/src/commands/branches/disable/disable.handler.ts @@ -6,7 +6,7 @@ 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 { resolveBranchesParent } from "../branches.target.ts"; import { BranchesDisableNetworkError, BranchesDisableUnexpectedStatusError, @@ -31,7 +31,7 @@ export const branchesDisable = Effect.fn("branches.disable")(function* ( // `branches` is PARENT-scoped: after `supabase link <branch>`, // `supabase/.temp/project-ref` holds the branch's own ref, and the platform // 403s on that ref for every branches-management endpoint. - const ref = yield* resolveParentScopedProjectRef(flags.projectRef); + const ref = yield* resolveBranchesParent({ remote: flags.remote, projectRef: flags.projectRef }); yield* Effect.annotateCurrentSpan("project.ref", ref); yield* Effect.gen(function* () { diff --git a/apps/cli/src/commands/branches/get/get.command.ts b/apps/cli/src/commands/branches/get/get.command.ts index b1c72d4e71..7031ebba6a 100644 --- a/apps/cli/src/commands/branches/get/get.command.ts +++ b/apps/cli/src/commands/branches/get/get.command.ts @@ -1,4 +1,4 @@ -import { Argument, Command, Flag } from "effect/cli"; +import { Argument, Command } from "effect/cli"; import type * as CliCommand from "effect/cli/Command"; import { withPermissions } from "../../../command-internal/command-permissions/command-permissions.annotation.ts"; @@ -14,6 +14,8 @@ import { managementApiRuntimeLayer } from "../../../command-internal/management- import { withCommandTelemetry } from "../../../telemetry/command-telemetry.ts"; import { branchesGet } from "./get.handler.ts"; import { withTargetPolicy } from "../../../command-internal/target/target-policy.ts"; +import { projectTargetFlags } from "../../../command-internal/target/target-flags.ts"; +import { branchesParentTarget, branchesSafeFlags } from "../branches.target.ts"; // `get.handler.ts` skips the api-keys and pooler calls for `-o pretty`, and with no `-o` when the output format is text. const MACHINE_OUTPUT: ReadonlyArray<{ @@ -49,10 +51,7 @@ const config = { Argument.withDescription("Branch name or ID."), Argument.optional, ), - projectRef: Flag.String("project-ref").pipe( - Flag.withDescription("Project ref of the Supabase project."), - Flag.optional, - ), + ...projectTargetFlags(), } as const; export type BranchesGetFlags = CliCommand.Command.Config.Infer<typeof config>; @@ -83,6 +82,7 @@ export const branchesGetCommand = Command.make("get", config).pipe( ), ], }, + branchesParentTarget, projectRefPicker, branchPrompt, linkedProjectCache, @@ -90,7 +90,7 @@ export const branchesGetCommand = Command.make("get", config).pipe( ), Command.withHandler((flags) => branchesGet(flags).pipe( - withCommandTelemetry({ flags, safeFlags: ["project-ref"] }), + withCommandTelemetry({ flags, safeFlags: branchesSafeFlags(flags) }), withJsonErrorHandling, ), ), diff --git a/apps/cli/src/commands/branches/get/get.handler.ts b/apps/cli/src/commands/branches/get/get.handler.ts index f569363250..24dbeda809 100644 --- a/apps/cli/src/commands/branches/get/get.handler.ts +++ b/apps/cli/src/commands/branches/get/get.handler.ts @@ -20,7 +20,7 @@ import { encodeYaml, } from "../../../command-internal/output.encoders.ts"; import { mapHttpError } from "../../../command-internal/http-errors.ts"; -import { resolveParentScopedProjectRef } from "../../../command-internal/parent-project-ref.ts"; +import { resolveBranchesParent } from "../branches.target.ts"; import { BranchesApiKeysNetworkError, BranchesApiKeysUnexpectedStatusError, @@ -83,7 +83,7 @@ export const branchesGet = Effect.fn("branches.get")(function* (flags: BranchesG // `branches` is parent-scoped: after `supabase link <branch>`, `supabase/.temp/project-ref` // holds the branch's own ref, which the platform 403s on for every branches-management endpoint. - const ref = yield* resolveParentScopedProjectRef(flags.projectRef); + const ref = yield* resolveBranchesParent({ remote: flags.remote, projectRef: flags.projectRef }); yield* Effect.annotateCurrentSpan("project.ref", ref); yield* Effect.gen(function* () { diff --git a/apps/cli/src/commands/branches/list/list.command.ts b/apps/cli/src/commands/branches/list/list.command.ts index 472954e4c3..932908bfae 100644 --- a/apps/cli/src/commands/branches/list/list.command.ts +++ b/apps/cli/src/commands/branches/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"; @@ -10,12 +10,11 @@ import { managementApiRuntimeLayer } from "../../../command-internal/management- import { withCommandTelemetry } from "../../../telemetry/command-telemetry.ts"; import { branchesList } from "./list.handler.ts"; import { withTargetPolicy } from "../../../command-internal/target/target-policy.ts"; +import { projectTargetFlags } from "../../../command-internal/target/target-flags.ts"; +import { branchesParentTarget, branchesSafeFlags } from "../branches.target.ts"; const config = { - projectRef: Flag.String("project-ref").pipe( - Flag.withDescription("Project ref of the Supabase project."), - Flag.optional, - ), + ...projectTargetFlags(), } as const; export type BranchesListFlags = CliCommand.Command.Config.Infer<typeof config>; @@ -26,13 +25,14 @@ export const branchesListCommand = Command.make("list", config).pipe( withPermissions( compose( { operations: [{ operationId: "v1-list-all-branches", kind: "required" }] }, + branchesParentTarget, projectRefPicker, linkedProjectCache, ), ), Command.withHandler((flags) => branchesList(flags).pipe( - withCommandTelemetry({ flags, safeFlags: ["project-ref"] }), + withCommandTelemetry({ flags, safeFlags: branchesSafeFlags(flags) }), withJsonErrorHandling, ), ), diff --git a/apps/cli/src/commands/branches/list/list.handler.ts b/apps/cli/src/commands/branches/list/list.handler.ts index 7fcf8a0dbc..af58afbdb1 100644 --- a/apps/cli/src/commands/branches/list/list.handler.ts +++ b/apps/cli/src/commands/branches/list/list.handler.ts @@ -13,7 +13,7 @@ import { encodeStructYaml, } from "../../../command-internal/struct-output.encoders.ts"; import { mapHttpError } from "../../../command-internal/http-errors.ts"; -import { resolveParentScopedProjectRef } from "../../../command-internal/parent-project-ref.ts"; +import { resolveBranchesParent } from "../branches.target.ts"; import { BRANCHES_LIST_SHAPE, BRANCHES_TOML_WRAPPER_SHAPE } from "../branches.response-shape.ts"; import { BranchesEnvNotSupportedError, @@ -42,7 +42,7 @@ export const branchesList = Effect.fn("branches.list")(function* (flags: Branche // `branches` is parent-scoped: after `supabase link <branch>`, `supabase/.temp/project-ref` // holds the branch's own ref, which the platform 403s on for every branches-management endpoint. - const ref = yield* resolveParentScopedProjectRef(flags.projectRef); + const ref = yield* resolveBranchesParent({ remote: flags.remote, projectRef: flags.projectRef }); yield* Effect.annotateCurrentSpan("project.ref", ref); yield* Effect.gen(function* () { diff --git a/apps/cli/src/commands/branches/pause/pause.command.ts b/apps/cli/src/commands/branches/pause/pause.command.ts index 424b3d0e80..0d324d9ecf 100644 --- a/apps/cli/src/commands/branches/pause/pause.command.ts +++ b/apps/cli/src/commands/branches/pause/pause.command.ts @@ -1,4 +1,4 @@ -import { Argument, Command, Flag } from "effect/cli"; +import { Argument, Command } from "effect/cli"; import type * as CliCommand from "effect/cli/Command"; import { withPermissions } from "../../../command-internal/command-permissions/command-permissions.annotation.ts"; @@ -12,16 +12,15 @@ import { managementApiRuntimeLayer } from "../../../command-internal/management- import { withCommandTelemetry } from "../../../telemetry/command-telemetry.ts"; import { branchesPause } from "./pause.handler.ts"; import { withTargetPolicy } from "../../../command-internal/target/target-policy.ts"; +import { projectTargetFlags } from "../../../command-internal/target/target-flags.ts"; +import { branchesParentTarget, branchesSafeFlags } from "../branches.target.ts"; const config = { name: Argument.String("name").pipe( Argument.withDescription("Branch name or ID to pause."), Argument.optional, ), - projectRef: Flag.String("project-ref").pipe( - Flag.withDescription("Project ref of the Supabase project."), - Flag.optional, - ), + ...projectTargetFlags(), } as const; export type BranchesPauseFlags = CliCommand.Command.Config.Infer<typeof config>; @@ -32,6 +31,7 @@ export const branchesPauseCommand = Command.make("pause", config).pipe( withPermissions( compose( { operations: [{ operationId: "v1-pause-a-project", kind: "required" }] }, + branchesParentTarget, projectRefPicker, branchPrompt, branchRef, @@ -40,7 +40,7 @@ export const branchesPauseCommand = Command.make("pause", config).pipe( ), Command.withHandler((flags) => branchesPause(flags).pipe( - withCommandTelemetry({ flags, safeFlags: ["project-ref"] }), + withCommandTelemetry({ flags, safeFlags: branchesSafeFlags(flags) }), withJsonErrorHandling, ), ), diff --git a/apps/cli/src/commands/branches/pause/pause.handler.ts b/apps/cli/src/commands/branches/pause/pause.handler.ts index 7bd6c83418..8e22aeb17c 100644 --- a/apps/cli/src/commands/branches/pause/pause.handler.ts +++ b/apps/cli/src/commands/branches/pause/pause.handler.ts @@ -6,7 +6,7 @@ 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 { resolveBranchesParent } from "../branches.target.ts"; import { BranchesPauseNetworkError, BranchesPauseUnexpectedStatusError, @@ -30,7 +30,7 @@ export const branchesPause = Effect.fn("branches.pause")(function* (flags: Branc // `branches` is parent-scoped: after `supabase link <branch>`, `supabase/.temp/project-ref` // holds the branch's own ref, which the platform 403s on for every branches-management endpoint. - const ref = yield* resolveParentScopedProjectRef(flags.projectRef); + const ref = yield* resolveBranchesParent({ remote: flags.remote, projectRef: flags.projectRef }); yield* Effect.annotateCurrentSpan("project.ref", ref); yield* Effect.gen(function* () { diff --git a/apps/cli/src/commands/branches/unpause/unpause.command.ts b/apps/cli/src/commands/branches/unpause/unpause.command.ts index b9f8c8eeed..0fb8dc7d56 100644 --- a/apps/cli/src/commands/branches/unpause/unpause.command.ts +++ b/apps/cli/src/commands/branches/unpause/unpause.command.ts @@ -1,4 +1,4 @@ -import { Argument, Command, Flag } from "effect/cli"; +import { Argument, Command } from "effect/cli"; import type * as CliCommand from "effect/cli/Command"; import { withPermissions } from "../../../command-internal/command-permissions/command-permissions.annotation.ts"; @@ -12,16 +12,15 @@ import { managementApiRuntimeLayer } from "../../../command-internal/management- import { withCommandTelemetry } from "../../../telemetry/command-telemetry.ts"; import { branchesUnpause } from "./unpause.handler.ts"; import { withTargetPolicy } from "../../../command-internal/target/target-policy.ts"; +import { projectTargetFlags } from "../../../command-internal/target/target-flags.ts"; +import { branchesParentTarget, branchesSafeFlags } from "../branches.target.ts"; const config = { name: Argument.String("name").pipe( Argument.withDescription("Branch name or ID to unpause."), Argument.optional, ), - projectRef: Flag.String("project-ref").pipe( - Flag.withDescription("Project ref of the Supabase project."), - Flag.optional, - ), + ...projectTargetFlags(), } as const; export type BranchesUnpauseFlags = CliCommand.Command.Config.Infer<typeof config>; @@ -32,6 +31,7 @@ export const branchesUnpauseCommand = Command.make("unpause", config).pipe( withPermissions( compose( { operations: [{ operationId: "v1-restore-a-project", kind: "required" }] }, + branchesParentTarget, projectRefPicker, branchPrompt, branchRef, @@ -40,7 +40,7 @@ export const branchesUnpauseCommand = Command.make("unpause", config).pipe( ), Command.withHandler((flags) => branchesUnpause(flags).pipe( - withCommandTelemetry({ flags, safeFlags: ["project-ref"] }), + withCommandTelemetry({ flags, safeFlags: branchesSafeFlags(flags) }), withJsonErrorHandling, ), ), diff --git a/apps/cli/src/commands/branches/unpause/unpause.handler.ts b/apps/cli/src/commands/branches/unpause/unpause.handler.ts index e1a4bdb60f..46fc125cd2 100644 --- a/apps/cli/src/commands/branches/unpause/unpause.handler.ts +++ b/apps/cli/src/commands/branches/unpause/unpause.handler.ts @@ -6,7 +6,7 @@ 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 { resolveBranchesParent } from "../branches.target.ts"; import { BranchesUnpauseNetworkError, BranchesUnpauseUnexpectedStatusError, @@ -32,7 +32,7 @@ export const branchesUnpause = Effect.fn("branches.unpause")(function* ( // `branches` is parent-scoped: after `supabase link <branch>`, `supabase/.temp/project-ref` // holds the branch's own ref, which the platform 403s on for every branches-management endpoint. - const ref = yield* resolveParentScopedProjectRef(flags.projectRef); + const ref = yield* resolveBranchesParent({ remote: flags.remote, projectRef: flags.projectRef }); yield* Effect.annotateCurrentSpan("project.ref", ref); yield* Effect.gen(function* () { diff --git a/apps/cli/src/commands/branches/update/update.command.ts b/apps/cli/src/commands/branches/update/update.command.ts index c811ab6dab..729c6d2ce3 100644 --- a/apps/cli/src/commands/branches/update/update.command.ts +++ b/apps/cli/src/commands/branches/update/update.command.ts @@ -13,6 +13,8 @@ import { managementApiRuntimeLayer } from "../../../command-internal/management- import { withCommandTelemetry } from "../../../telemetry/command-telemetry.ts"; import { branchesUpdate } from "./update.handler.ts"; import { withTargetPolicy } from "../../../command-internal/target/target-policy.ts"; +import { projectTargetFlags } from "../../../command-internal/target/target-flags.ts"; +import { branchesParentTarget, branchesSafeFlags } from "../branches.target.ts"; const BRANCH_STATUSES = [ "RUNNING_MIGRATIONS", @@ -27,10 +29,7 @@ const config = { Argument.withDescription("Branch name or ID to update."), Argument.optional, ), - projectRef: Flag.String("project-ref").pipe( - Flag.withDescription("Project ref of the Supabase project."), - Flag.optional, - ), + ...projectTargetFlags(), name: Flag.String("name").pipe(Flag.withDescription("Rename the preview branch."), Flag.optional), gitBranch: Flag.String("git-branch").pipe( Flag.withDescription("Change the associated git branch."), @@ -63,6 +62,7 @@ export const branchesUpdateCommand = Command.make("update", config).pipe( operations: [{ operationId: "v1-update-a-branch-config", kind: "required" }], noApiEffectFlags: ["name", "git-branch", "persistent", "status", "notify-url"], }, + branchesParentTarget, projectRefPicker, branchPrompt, branchRef, @@ -72,7 +72,7 @@ export const branchesUpdateCommand = Command.make("update", config).pipe( ), Command.withHandler((flags) => branchesUpdate(flags).pipe( - withCommandTelemetry({ flags, safeFlags: ["project-ref"], config }), + withCommandTelemetry({ flags, safeFlags: branchesSafeFlags(flags), config }), withJsonErrorHandling, ), ), diff --git a/apps/cli/src/commands/branches/update/update.handler.ts b/apps/cli/src/commands/branches/update/update.handler.ts index 253a1d438b..caa21755aa 100644 --- a/apps/cli/src/commands/branches/update/update.handler.ts +++ b/apps/cli/src/commands/branches/update/update.handler.ts @@ -13,7 +13,7 @@ import { } 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 { resolveBranchesParent } from "../branches.target.ts"; import { gateMapError } from "../../../command-internal/upgrade-suggest.ts"; import { BRANCH_RESPONSE_SHAPE } from "../branches.response-shape.ts"; import { @@ -43,7 +43,7 @@ export const branchesUpdate = Effect.fn("branches.update")(function* (flags: Bra // `branches` is parent-scoped: after `supabase link <branch>`, `supabase/.temp/project-ref` // holds the branch's own ref, which the platform 403s on for every branches-management endpoint. - const ref = yield* resolveParentScopedProjectRef(flags.projectRef); + const ref = yield* resolveBranchesParent({ remote: flags.remote, projectRef: flags.projectRef }); yield* Effect.annotateCurrentSpan("project.ref", ref); yield* Effect.gen(function* () { diff --git a/apps/cli/src/commands/config/diff/diff.command.ts b/apps/cli/src/commands/config/diff/diff.command.ts index 286b52b471..6bda3f2859 100644 --- a/apps/cli/src/commands/config/diff/diff.command.ts +++ b/apps/cli/src/commands/config/diff/diff.command.ts @@ -15,15 +15,10 @@ import { managementApiRuntimeLayer } from "../../../command-internal/management- import { withCommandTelemetry } from "../../../telemetry/command-telemetry.ts"; import { configDiff } from "./diff.handler.ts"; import { withTargetPolicy } from "../../../command-internal/target/target-policy.ts"; +import { projectTargetFlags } from "../../../command-internal/target/target-flags.ts"; const config = { - // Accepts either a project ref or a branch of the linked project; no separate `--target` flag. - projectRef: Flag.String("project-ref").pipe( - Flag.withDescription( - "Project ref of the Supabase project, or the name (or UUID) of one of its branches. Values that are exactly 20 lowercase letters are always treated as project refs.", - ), - Flag.optional, - ), + ...projectTargetFlags(), exitCode: Flag.Boolean("exit-code").pipe( Flag.withDescription( "Exit with status 2 when any difference is found (errors keep exiting 1).", @@ -38,7 +33,7 @@ export type ConfigDiffFlags = CliCommand.Command.Config.Infer<typeof config>; export const configDiffHandler = (flags: ConfigDiffFlags) => configDiff(flags).pipe( - // `--project-ref` also accepts branch names, so its value is safe to log only when it's + // `--project-ref` can also carry a branch name, so its value is safe to log only when it's // actually ref-shaped — a user-created branch name must never reach PostHog. withCommandTelemetry({ flags, diff --git a/apps/cli/src/commands/config/diff/diff.errors.ts b/apps/cli/src/commands/config/diff/diff.errors.ts index d025953d27..b64f680c26 100644 --- a/apps/cli/src/commands/config/diff/diff.errors.ts +++ b/apps/cli/src/commands/config/diff/diff.errors.ts @@ -51,12 +51,12 @@ export class ConfigDiffOutputFlagUnsupportedError extends Data.TaggedError( const targetErrors = mintConfigTargetErrors("ConfigDiff"); -/** `--project-ref` named a branch the parent project does not have. */ +/** `--remote` named a branch the parent project does not have. */ export const ConfigDiffBranchNotFoundError = targetErrors.BranchNotFoundError; export type ConfigDiffBranchNotFoundError = InstanceType<typeof ConfigDiffBranchNotFoundError>; /** - * `--project-ref` named a branch by name, but no project is linked to search branches under — + * `--remote` named a branch by name, but no project is linked to search branches under — * none of `SUPABASE_PROJECT_ID`, `supabase/.temp/linked-project.json`, or * `supabase/.temp/project-ref` yielded a candidate. Mirrors `LinkBranchNotLinkedError`. */ @@ -64,7 +64,7 @@ export const ConfigDiffBranchNotLinkedError = targetErrors.BranchNotLinkedError; export type ConfigDiffBranchNotLinkedError = InstanceType<typeof ConfigDiffBranchNotLinkedError>; /** - * `--project-ref` named a branch by name, and a parent-project candidate exists but isn't + * `--remote` named a branch by name, and a parent-project candidate exists but isn't * ref-shaped — corrupt or stale linked state. Mirrors `LinkParentRefInvalidError`. */ export const ConfigDiffParentRefInvalidError = targetErrors.ParentRefInvalidError; diff --git a/apps/cli/src/commands/config/diff/diff.format.ts b/apps/cli/src/commands/config/diff/diff.format.ts index 9052d1d224..2ba05a6712 100644 --- a/apps/cli/src/commands/config/diff/diff.format.ts +++ b/apps/cli/src/commands/config/diff/diff.format.ts @@ -21,7 +21,7 @@ import { export interface ConfigDiffContext { /** The resolved comparison target's project ref. */ readonly projectRef: string; - /** The branch name or UUID `--project-ref` carried, when it named one. */ + /** The branch name or UUID `--remote` carried, when it named one. */ readonly branch: string | undefined; /** Matched `[remotes.<name>]` block, when the local operand was merged. */ readonly appliedRemote: string | undefined; diff --git a/apps/cli/src/commands/config/diff/diff.handler.ts b/apps/cli/src/commands/config/diff/diff.handler.ts index f649b2837f..6aecb81594 100644 --- a/apps/cli/src/commands/config/diff/diff.handler.ts +++ b/apps/cli/src/commands/config/diff/diff.handler.ts @@ -22,6 +22,7 @@ import { } from "../../../command-internal/http-errors.ts"; import { configTargetErrorsFor, + requestedConfigTarget, resolveConfigTarget, } from "../../../command-internal/project-target.ts"; import { configIsRecord } from "../config.paths.ts"; @@ -81,9 +82,6 @@ export const configDiff = Effect.fn("config.diff")(function* (flags: ConfigDiffF const outputFlag = yield* OutputFlag; const fs = yield* FileSystem.FileSystem; - // An empty `--project-ref` value is absent, mirroring the resolver's own rule. - const requested = Option.filter(flags.projectRef, (value) => value.length > 0); - const makeLoadError = (message: string) => new ConfigDiffLoadConfigError({ message }); // Set once the target ref resolves, so the Effect.ensuring cache write below only fires for @@ -115,6 +113,10 @@ export const configDiff = Effect.fn("config.diff")(function* (flags: ConfigDiffF ); // See resolveConfigTarget's doc comment for the target-resolution rules this preserves. + const requested = yield* requestedConfigTarget({ + remote: flags.remote, + projectRef: flags.projectRef, + }); const { ref, branch } = yield* resolveConfigTarget( requested, configTargetErrors, diff --git a/apps/cli/src/commands/config/pull/pull.command.ts b/apps/cli/src/commands/config/pull/pull.command.ts index 02de06cb42..f014a79477 100644 --- a/apps/cli/src/commands/config/pull/pull.command.ts +++ b/apps/cli/src/commands/config/pull/pull.command.ts @@ -17,16 +17,10 @@ import { managementApiRuntimeLayer } from "../../../command-internal/management- import { withCommandTelemetry } from "../../../telemetry/command-telemetry.ts"; import { configPull } from "./pull.handler.ts"; import { withTargetPolicy } from "../../../command-internal/target/target-policy.ts"; +import { projectTargetFlags } from "../../../command-internal/target/target-flags.ts"; const config = { - // Accepts either a project ref or a branch name/UUID of the linked project; there's no - // separate --target flag. - projectRef: Flag.String("project-ref").pipe( - Flag.withDescription( - "Project ref of the Supabase project, or the name (or UUID) of one of its branches. Values that are exactly 20 lowercase letters are always treated as project refs.", - ), - Flag.optional, - ), + ...projectTargetFlags(), remoteLabel: Flag.String("remote-label").pipe( Flag.withDescription( "Name of the [remotes.*] block to write into, overriding the block config pull would otherwise reuse or create.", diff --git a/apps/cli/src/commands/config/pull/pull.errors.ts b/apps/cli/src/commands/config/pull/pull.errors.ts index f277f54554..37b2800e5d 100644 --- a/apps/cli/src/commands/config/pull/pull.errors.ts +++ b/apps/cli/src/commands/config/pull/pull.errors.ts @@ -52,16 +52,16 @@ export class ConfigPullOutputFlagUnsupportedError extends Data.TaggedError( const targetErrors = mintConfigTargetErrors("ConfigPull"); -/** `--project-ref` named a branch the parent project does not have. */ +/** `--remote` named a branch the parent project does not have. */ export const ConfigPullBranchNotFoundError = targetErrors.BranchNotFoundError; export type ConfigPullBranchNotFoundError = InstanceType<typeof ConfigPullBranchNotFoundError>; -/** `--project-ref` named a branch (by name), but no project is linked to search branches under. */ +/** `--remote` named a branch (by name), but no project is linked to search branches under. */ export const ConfigPullBranchNotLinkedError = targetErrors.BranchNotLinkedError; export type ConfigPullBranchNotLinkedError = InstanceType<typeof ConfigPullBranchNotLinkedError>; /** - * `--project-ref` named a branch (by name), and a parent-project candidate exists but is not + * `--remote` named a branch (by name), and a parent-project candidate exists but is not * ref-shaped — corrupt or stale linked state. */ export const ConfigPullParentRefInvalidError = targetErrors.ParentRefInvalidError; diff --git a/apps/cli/src/commands/config/pull/pull.handler.ts b/apps/cli/src/commands/config/pull/pull.handler.ts index 10584fcf53..454d1f5e38 100644 --- a/apps/cli/src/commands/config/pull/pull.handler.ts +++ b/apps/cli/src/commands/config/pull/pull.handler.ts @@ -9,6 +9,7 @@ import { } from "../../../command-internal/http-errors.ts"; import { configTargetErrorsFor, + requestedConfigTarget, resolveConfigTarget, type ConfigTarget, } from "../../../command-internal/project-target.ts"; @@ -225,9 +226,7 @@ export const configPull = Effect.fn("config.pull")(function* (flags: ConfigPullF const linkedProjectCache = yield* LinkedProjectCache; const telemetryState = yield* TelemetryState; - // An empty `--project-ref`/`--remote-label` value is absent, mirroring the - // target resolver's own rule. - const requested = Option.filter(flags.projectRef, (value) => value.length > 0); + // An empty `--remote-label` value is absent. const remoteLabel = Option.getOrUndefined( Option.filter(flags.remoteLabel, (value) => value.length > 0), ); @@ -257,6 +256,10 @@ export const configPull = Effect.fn("config.pull")(function* (flags: ConfigPullF const source = yield* openConfigPullSource(); // Resolves the pull target via resolveConfigTarget, shared with config diff/config push. + const requested = yield* requestedConfigTarget({ + remote: flags.remote, + projectRef: flags.projectRef, + }); const { ref, branch } = yield* resolveConfigTarget( requested, configTargetErrors, diff --git a/apps/cli/src/commands/config/pull/pull.scope.ts b/apps/cli/src/commands/config/pull/pull.scope.ts index 9f2ac47bc0..5a40a236e0 100644 --- a/apps/cli/src/commands/config/pull/pull.scope.ts +++ b/apps/cli/src/commands/config/pull/pull.scope.ts @@ -79,13 +79,13 @@ export interface ResolveConfigPullDestinationInput { readonly interpolatedRemotes: unknown; readonly projectRef: string; /** - * The branch name the target was resolved from, when `--project-ref` named one by name rather + * The branch name the target was resolved from, when `--remote` named one by name rather * than UUID; `undefined` for a ref-shaped, linked-fallback, or UUID branch target (a UUID is * never a good label). */ readonly branchLabelCandidate: string | undefined; /** - * Whether `--project-ref` named a branch at all, by name or UUID — distinct from + * Whether `--remote` named a branch at all, by name or UUID — distinct from * `branchLabelCandidate`: a UUID branch target still creates a `[remotes.*]` block, falling * back to the resolved ref as its label. */ @@ -217,6 +217,6 @@ export function resolveConfigPullDestination( }); } - // Otherwise: a ref-shaped --project-ref or linked-fallback target writes to the config root. + // Otherwise: a ref-shaped --remote or linked-fallback target writes to the config root. return { ok: true, destination: { kind: "root" } }; } 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 5c84c42797..1eeef10f97 100644 --- a/apps/cli/src/commands/config/push/push.branch-target.ts +++ b/apps/cli/src/commands/config/push/push.branch-target.ts @@ -16,7 +16,7 @@ import { Output } from "../../../shared/output/output.service.ts"; * What `ref` actually is, resolved for `config push`'s payload: * * - `"project"` — `ref` is the linked project itself. - * - `"branch"` — `ref` is confirmed to be a preview branch (an explicit `--project-ref + * - `"branch"` — `ref` is confirmed to be a preview branch (an explicit `--remote * <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 diff --git a/apps/cli/src/commands/config/push/push.command.ts b/apps/cli/src/commands/config/push/push.command.ts index 77f83aba1d..5b0fea7e7f 100644 --- a/apps/cli/src/commands/config/push/push.command.ts +++ b/apps/cli/src/commands/config/push/push.command.ts @@ -1,5 +1,5 @@ import { 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"; @@ -16,16 +16,10 @@ import { managementApiRuntimeLayer } from "../../../command-internal/management- import { withCommandTelemetry } from "../../../telemetry/command-telemetry.ts"; import { configPush } from "./push.handler.ts"; import { withTargetPolicy } from "../../../command-internal/target/target-policy.ts"; +import { projectTargetFlags } from "../../../command-internal/target/target-flags.ts"; const config = { - // Accepts either a project ref or a branch name/UUID of the linked project; there's no - // separate --target flag. - projectRef: Flag.String("project-ref").pipe( - Flag.withDescription( - "Project ref of the Supabase project, or the name (or UUID) of one of its branches. Values that are exactly 20 lowercase letters are always treated as project refs.", - ), - Flag.optional, - ), + ...projectTargetFlags(), } as const; export type ConfigPushFlags = CliCommand.Command.Config.Infer<typeof config>; @@ -73,7 +67,7 @@ export const configPushCommand = Command.make("push", config).pipe( { operationId: "v1-get-project", kind: "best-effort", - context: "--project-ref is absent, empty or a 20-letter project ref (the branch probe)", + context: "--remote is absent, bare or a 20-letter project ref (the branch probe)", }, { operationId: "v1-list-project-addons", kind: "required" }, { operationId: "v2-get-project-config", kind: "required" }, @@ -118,7 +112,7 @@ export const configPushCommand = Command.make("push", config).pipe( }, withContext( branchTarget, - "the target is a branch (a UUID --project-ref, or the probe returned 404) and supabase/.temp/linked-project.json names a different project", + "the target is a branch (a UUID --remote, or the probe returned 404) and supabase/.temp/linked-project.json names a different project", ), linkedProjectCache, ), diff --git a/apps/cli/src/commands/config/push/push.errors.ts b/apps/cli/src/commands/config/push/push.errors.ts index 3349ff96e8..26beaf5c00 100644 --- a/apps/cli/src/commands/config/push/push.errors.ts +++ b/apps/cli/src/commands/config/push/push.errors.ts @@ -66,16 +66,16 @@ export class ConfigPushWorkdirError extends Data.TaggedError( } } -// --project-ref accepts a project ref, or the name (or UUID) of one of its branches; mirrors +// --remote accepts a project ref, or the name (or UUID) of one of its branches; mirrors // config diff's own error set under push's own names. const targetErrors = mintConfigTargetErrors("ConfigPush"); -/** `--project-ref` named a branch the parent project does not have. */ +/** `--remote` named a branch the parent project does not have. */ export const ConfigPushBranchNotFoundError = targetErrors.BranchNotFoundError; export type ConfigPushBranchNotFoundError = InstanceType<typeof ConfigPushBranchNotFoundError>; /** - * `--project-ref` named a branch (by name), but no project is linked to + * `--remote` named a branch (by name), but no project is linked to * search for branches under — none of `SUPABASE_PROJECT_ID`, * `supabase/.temp/linked-project.json`, or `supabase/.temp/project-ref` * yielded a candidate. @@ -84,7 +84,7 @@ export const ConfigPushBranchNotLinkedError = targetErrors.BranchNotLinkedError; export type ConfigPushBranchNotLinkedError = InstanceType<typeof ConfigPushBranchNotLinkedError>; /** - * `--project-ref` named a branch (by name), and a parent-project candidate + * `--remote` named a branch (by name), and a parent-project candidate * exists but is not ref-shaped — corrupt or stale linked state. */ export const ConfigPushParentRefInvalidError = targetErrors.ParentRefInvalidError; diff --git a/apps/cli/src/commands/config/push/push.handler.ts b/apps/cli/src/commands/config/push/push.handler.ts index c170347c84..043e12bf3d 100644 --- a/apps/cli/src/commands/config/push/push.handler.ts +++ b/apps/cli/src/commands/config/push/push.handler.ts @@ -23,6 +23,7 @@ import { } from "../../../command-internal/http-errors.ts"; import { configTargetErrorsFor, + requestedConfigTarget, resolveConfigTarget, } from "../../../command-internal/project-target.ts"; import { @@ -224,10 +225,6 @@ export const configPush = Effect.fn("config.push")(function* (flags: ConfigPushF const telemetryState = yield* TelemetryState; const fs = yield* FileSystem.FileSystem; - // `--project-ref` accepts a project ref, or the name (or UUID) of a branch of the linked - // project. An empty value is treated as absent, mirroring the resolver's own rule. - const requestedRef = Option.filter(flags.projectRef, (value) => value.length > 0); - // Set once ref resolution succeeds, so the linked-project cache write below only fires for // invocations that got that far. const resolvedRef = yield* Ref.make(Option.none<string>()); @@ -255,6 +252,10 @@ export const configPush = Effect.fn("config.push")(function* (flags: ConfigPushF // the resolved config load before its one schema decode, so a base document that's invalid without its // overlay must never be decoded on its own — this can cost a network round trip before a // malformed `config.toml` is caught. + const requestedRef = yield* requestedConfigTarget({ + remote: flags.remote, + projectRef: flags.projectRef, + }); const { ref, branch } = yield* resolveConfigTarget( requestedRef, configTargetErrors, diff --git a/apps/cli/src/commands/domains/activate/activate.command.ts b/apps/cli/src/commands/domains/activate/activate.command.ts index dfd82e9508..6fcd145010 100644 --- a/apps/cli/src/commands/domains/activate/activate.command.ts +++ b/apps/cli/src/commands/domains/activate/activate.command.ts @@ -12,12 +12,11 @@ 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"; +import { branchResolution } from "../../../command-internal/target/branch-resolution.ts"; +import { projectTargetFlags } from "../../../command-internal/target/target-flags.ts"; const config = { - projectRef: Flag.String("project-ref").pipe( - Flag.withDescription("Project ref of the Supabase project."), - Flag.optional, - ), + ...projectTargetFlags(), // Kept parsed (and hidden) only so using it produces the removal error instead of an // unknown-flag parse error. includeRawOutput: Flag.Boolean("include-raw-output").pipe( @@ -51,6 +50,7 @@ export const domainsActivateCommand = Command.make("activate", config).pipe( }, projectRefPicker, linkedProjectCache, + branchResolution, ), ), Command.withHandler((flags) => diff --git a/apps/cli/src/commands/domains/activate/activate.handler.ts b/apps/cli/src/commands/domains/activate/activate.handler.ts index 940cb7cbe9..2d34e86bc0 100644 --- a/apps/cli/src/commands/domains/activate/activate.handler.ts +++ b/apps/cli/src/commands/domains/activate/activate.handler.ts @@ -1,7 +1,7 @@ import { Effect } from "effect"; import { CommandPlatformApi } from "../../../auth/command-platform-api.service.ts"; -import { ProjectRefResolver } from "../../../config/project-ref.service.ts"; +import { resolveProjectTarget } from "../../../command-internal/target/resolve-project-target.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"; @@ -18,11 +18,10 @@ export const domainsActivate = Effect.fn("domains.activate")(function* ( ) { const output = yield* Output; const api = yield* CommandPlatformApi; - const resolver = yield* ProjectRefResolver; const linkedProjectCache = yield* LinkedProjectCache; const telemetryState = yield* TelemetryState; - const ref = yield* resolver.resolve(flags.projectRef); + const ref = yield* resolveProjectTarget({ remote: flags.remote, projectRef: flags.projectRef }); yield* Effect.gen(function* () { yield* requireHostedMutationConsent({ diff --git a/apps/cli/src/commands/domains/create/create.command.ts b/apps/cli/src/commands/domains/create/create.command.ts index 9afb9635b0..a49c7e5ea6 100644 --- a/apps/cli/src/commands/domains/create/create.command.ts +++ b/apps/cli/src/commands/domains/create/create.command.ts @@ -12,12 +12,11 @@ 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"; +import { branchResolution } from "../../../command-internal/target/branch-resolution.ts"; +import { projectTargetFlags } from "../../../command-internal/target/target-flags.ts"; const config = { - projectRef: Flag.String("project-ref").pipe( - Flag.withDescription("Project ref of the Supabase project."), - Flag.optional, - ), + ...projectTargetFlags(), customHostname: Flag.String("custom-hostname").pipe( Flag.withDescription("The custom hostname to use for your Supabase project."), ), @@ -55,6 +54,7 @@ export const domainsCreateCommand = Command.make("create", config).pipe( }, projectRefPicker, linkedProjectCache, + branchResolution, ), ), Command.withHandler((flags) => diff --git a/apps/cli/src/commands/domains/create/create.handler.ts b/apps/cli/src/commands/domains/create/create.handler.ts index 10c369c5fd..a8263ed726 100644 --- a/apps/cli/src/commands/domains/create/create.handler.ts +++ b/apps/cli/src/commands/domains/create/create.handler.ts @@ -3,7 +3,7 @@ import * as HttpClient from "effect/http/HttpClient"; 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 { resolveProjectTarget } from "../../../command-internal/target/resolve-project-target.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"; @@ -21,11 +21,10 @@ export const domainsCreate = Effect.fn("domains.create")(function* (flags: Domai const httpClient = yield* HttpClient.HttpClient; const api = yield* CommandPlatformApi; const cliSettings = yield* CommandSettings; - const resolver = yield* ProjectRefResolver; const linkedProjectCache = yield* LinkedProjectCache; const telemetryState = yield* TelemetryState; - const ref = yield* resolver.resolve(flags.projectRef); + const ref = yield* resolveProjectTarget({ remote: flags.remote, projectRef: flags.projectRef }); // Write the linked-project cache and persist the telemetry state file on // success and failure. diff --git a/apps/cli/src/commands/domains/delete/delete.command.ts b/apps/cli/src/commands/domains/delete/delete.command.ts index e51a00f77a..203797de95 100644 --- a/apps/cli/src/commands/domains/delete/delete.command.ts +++ b/apps/cli/src/commands/domains/delete/delete.command.ts @@ -12,12 +12,11 @@ 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"; +import { branchResolution } from "../../../command-internal/target/branch-resolution.ts"; +import { projectTargetFlags } from "../../../command-internal/target/target-flags.ts"; const config = { - projectRef: Flag.String("project-ref").pipe( - Flag.withDescription("Project ref of the Supabase project."), - Flag.optional, - ), + ...projectTargetFlags(), // Kept parsed (and hidden) only so using it produces the removal error instead of an // unknown-flag parse error. includeRawOutput: Flag.Boolean("include-raw-output").pipe( @@ -49,6 +48,7 @@ export const domainsDeleteCommand = Command.make("delete", config).pipe( }, projectRefPicker, linkedProjectCache, + branchResolution, ), ), Command.withHandler((flags) => diff --git a/apps/cli/src/commands/domains/delete/delete.handler.ts b/apps/cli/src/commands/domains/delete/delete.handler.ts index b5a58682d9..248e428e8c 100644 --- a/apps/cli/src/commands/domains/delete/delete.handler.ts +++ b/apps/cli/src/commands/domains/delete/delete.handler.ts @@ -1,7 +1,7 @@ import { Effect } from "effect"; import { CommandPlatformApi } from "../../../auth/command-platform-api.service.ts"; -import { ProjectRefResolver } from "../../../config/project-ref.service.ts"; +import { resolveProjectTarget } from "../../../command-internal/target/resolve-project-target.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"; @@ -16,11 +16,10 @@ const DELETE_SUCCESS_MESSAGE = "Deleted custom hostname config successfully."; export const domainsDelete = Effect.fn("domains.delete")(function* (flags: DomainsDeleteFlags) { const output = yield* Output; const api = yield* CommandPlatformApi; - const resolver = yield* ProjectRefResolver; const linkedProjectCache = yield* LinkedProjectCache; const telemetryState = yield* TelemetryState; - const ref = yield* resolver.resolve(flags.projectRef); + const ref = yield* resolveProjectTarget({ remote: flags.remote, projectRef: flags.projectRef }); yield* Effect.gen(function* () { yield* requireHostedMutationConsent({ action: "Delete the custom hostname config" }); diff --git a/apps/cli/src/commands/domains/get/get.command.ts b/apps/cli/src/commands/domains/get/get.command.ts index 82d3a13f79..26a1f623b7 100644 --- a/apps/cli/src/commands/domains/get/get.command.ts +++ b/apps/cli/src/commands/domains/get/get.command.ts @@ -12,12 +12,11 @@ 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"; +import { branchResolution } from "../../../command-internal/target/branch-resolution.ts"; +import { projectTargetFlags } from "../../../command-internal/target/target-flags.ts"; const config = { - projectRef: Flag.String("project-ref").pipe( - Flag.withDescription("Project ref of the Supabase project."), - Flag.optional, - ), + ...projectTargetFlags(), // Kept parsed (and hidden) only so using it produces the removal error instead of an // unknown-flag parse error. includeRawOutput: Flag.Boolean("include-raw-output").pipe( @@ -51,6 +50,7 @@ export const domainsGetCommand = Command.make("get", config).pipe( }, projectRefPicker, linkedProjectCache, + branchResolution, ), ), Command.withHandler((flags) => diff --git a/apps/cli/src/commands/domains/get/get.handler.ts b/apps/cli/src/commands/domains/get/get.handler.ts index 1d3fae0082..6fe34d7341 100644 --- a/apps/cli/src/commands/domains/get/get.handler.ts +++ b/apps/cli/src/commands/domains/get/get.handler.ts @@ -1,7 +1,7 @@ import { Effect } from "effect"; import { CommandPlatformApi } from "../../../auth/command-platform-api.service.ts"; -import { ProjectRefResolver } from "../../../config/project-ref.service.ts"; +import { resolveProjectTarget } from "../../../command-internal/target/resolve-project-target.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"; @@ -15,11 +15,10 @@ const mapGetError = mapDomainsHttpError("get"); export const domainsGet = Effect.fn("domains.get")(function* (flags: DomainsGetFlags) { const output = yield* Output; const api = yield* CommandPlatformApi; - const resolver = yield* ProjectRefResolver; const linkedProjectCache = yield* LinkedProjectCache; const telemetryState = yield* TelemetryState; - const ref = yield* resolver.resolve(flags.projectRef); + const ref = yield* resolveProjectTarget({ remote: flags.remote, projectRef: flags.projectRef }); // Write the linked-project cache and persist the telemetry state file on // success and failure. diff --git a/apps/cli/src/commands/domains/reverify/reverify.command.ts b/apps/cli/src/commands/domains/reverify/reverify.command.ts index a7cae54ea3..09b8f152cf 100644 --- a/apps/cli/src/commands/domains/reverify/reverify.command.ts +++ b/apps/cli/src/commands/domains/reverify/reverify.command.ts @@ -12,12 +12,11 @@ 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"; +import { branchResolution } from "../../../command-internal/target/branch-resolution.ts"; +import { projectTargetFlags } from "../../../command-internal/target/target-flags.ts"; const config = { - projectRef: Flag.String("project-ref").pipe( - Flag.withDescription("Project ref of the Supabase project."), - Flag.optional, - ), + ...projectTargetFlags(), // Kept parsed (and hidden) only so using it produces the removal error instead of an // unknown-flag parse error. includeRawOutput: Flag.Boolean("include-raw-output").pipe( @@ -49,6 +48,7 @@ export const domainsReverifyCommand = Command.make("reverify", config).pipe( }, projectRefPicker, linkedProjectCache, + branchResolution, ), ), Command.withHandler((flags) => diff --git a/apps/cli/src/commands/domains/reverify/reverify.handler.ts b/apps/cli/src/commands/domains/reverify/reverify.handler.ts index 6b2afd4b8e..8058e2cc1f 100644 --- a/apps/cli/src/commands/domains/reverify/reverify.handler.ts +++ b/apps/cli/src/commands/domains/reverify/reverify.handler.ts @@ -1,7 +1,7 @@ import { Effect } from "effect"; import { CommandPlatformApi } from "../../../auth/command-platform-api.service.ts"; -import { ProjectRefResolver } from "../../../config/project-ref.service.ts"; +import { resolveProjectTarget } from "../../../command-internal/target/resolve-project-target.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"; @@ -17,11 +17,10 @@ export const domainsReverify = Effect.fn("domains.reverify")(function* ( ) { const output = yield* Output; const api = yield* CommandPlatformApi; - const resolver = yield* ProjectRefResolver; const linkedProjectCache = yield* LinkedProjectCache; const telemetryState = yield* TelemetryState; - const ref = yield* resolver.resolve(flags.projectRef); + const ref = yield* resolveProjectTarget({ remote: flags.remote, projectRef: flags.projectRef }); yield* Effect.gen(function* () { const reverifying = 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 522097d1d2..128c22c098 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 @@ -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"; @@ -10,12 +10,11 @@ import { managementApiRuntimeLayer } from "../../../command-internal/management- import { withCommandTelemetry } from "../../../telemetry/command-telemetry.ts"; import { encryptionGetRootKey } from "./get-root-key.handler.ts"; import { withTargetPolicy } from "../../../command-internal/target/target-policy.ts"; +import { branchResolution } from "../../../command-internal/target/branch-resolution.ts"; +import { projectTargetFlags } from "../../../command-internal/target/target-flags.ts"; const config = { - projectRef: Flag.String("project-ref").pipe( - Flag.withDescription("Project ref of the Supabase project."), - Flag.optional, - ), + ...projectTargetFlags(), } as const; export type EncryptionGetRootKeyFlags = CliCommand.Command.Config.Infer<typeof config>; @@ -30,6 +29,7 @@ export const encryptionGetRootKeyCommand = Command.make("get-root-key", config). }, projectRefPicker, linkedProjectCache, + branchResolution, ), ), Command.withHandler((flags) => diff --git a/apps/cli/src/commands/encryption/get-root-key/get-root-key.handler.ts b/apps/cli/src/commands/encryption/get-root-key/get-root-key.handler.ts index d3be365590..4abd82b5ca 100644 --- a/apps/cli/src/commands/encryption/get-root-key/get-root-key.handler.ts +++ b/apps/cli/src/commands/encryption/get-root-key/get-root-key.handler.ts @@ -1,7 +1,7 @@ import { Effect } from "effect"; import { CommandPlatformApi } from "../../../auth/command-platform-api.service.ts"; -import { ProjectRefResolver } from "../../../config/project-ref.service.ts"; +import { resolveProjectTarget } from "../../../command-internal/target/resolve-project-target.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"; @@ -15,11 +15,10 @@ export const encryptionGetRootKey = Effect.fn("encryption.get-root-key")(functio ) { const output = yield* Output; const api = yield* CommandPlatformApi; - const resolver = yield* ProjectRefResolver; const linkedProjectCache = yield* LinkedProjectCache; const telemetryState = yield* TelemetryState; - const ref = yield* resolver.resolve(flags.projectRef); + const ref = yield* resolveProjectTarget({ remote: flags.remote, projectRef: flags.projectRef }); // Write the linked-project cache and persist the telemetry state file on // success and failure. 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 fee2e9f693..468a92ff86 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 @@ -1,5 +1,5 @@ import { Layer } from "effect"; -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"; @@ -13,12 +13,11 @@ import { managementApiRuntimeLayer } from "../../../command-internal/management- import { withCommandTelemetry } from "../../../telemetry/command-telemetry.ts"; import { encryptionUpdateRootKey } from "./update-root-key.handler.ts"; import { withTargetPolicy } from "../../../command-internal/target/target-policy.ts"; +import { branchResolution } from "../../../command-internal/target/branch-resolution.ts"; +import { projectTargetFlags } from "../../../command-internal/target/target-flags.ts"; const config = { - projectRef: Flag.String("project-ref").pipe( - Flag.withDescription("Project ref of the Supabase project."), - Flag.optional, - ), + ...projectTargetFlags(), } as const; export type EncryptionUpdateRootKeyFlags = CliCommand.Command.Config.Infer<typeof config>; @@ -41,6 +40,7 @@ export const encryptionUpdateRootKeyCommand = Command.make("update-root-key", co }, projectRefPicker, linkedProjectCache, + branchResolution, ), ), Command.withHandler((flags) => 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 8931a79a8d..0ddea40858 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 @@ -3,7 +3,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 { resolveProjectTarget } from "../../../command-internal/target/resolve-project-target.ts"; import { Output } from "../../../shared/output/output.service.ts"; import { Stdin } from "../../../shared/runtime/stdin.service.ts"; import { LinkedProjectCache } from "../../../telemetry/linked-project-cache.service.ts"; @@ -21,12 +21,11 @@ export const encryptionUpdateRootKey = Effect.fn("encryption.update-root-key")(f ) { const output = yield* Output; const api = yield* CommandPlatformApi; - const resolver = yield* ProjectRefResolver; const stdin = yield* Stdin; const linkedProjectCache = yield* LinkedProjectCache; const telemetryState = yield* TelemetryState; - const ref = yield* resolver.resolve(flags.projectRef); + const ref = yield* resolveProjectTarget({ remote: flags.remote, projectRef: flags.projectRef }); // In text mode, the piped-input path writes the prompt to stderr and echoes a // trailing newline to stdout after reading; on a TTY the masked prompt uses diff --git a/apps/cli/src/commands/experimental/compute/compute.output.ts b/apps/cli/src/commands/experimental/compute/compute.output.ts index d0ebe63de6..0859da875c 100644 --- a/apps/cli/src/commands/experimental/compute/compute.output.ts +++ b/apps/cli/src/commands/experimental/compute/compute.output.ts @@ -1,5 +1,6 @@ import { Effect, Option } from "effect"; import { OutputFlag } from "../../../command-internal/global-flags.ts"; +import type { RemoteValue } from "../../../command-internal/target/remote-value.ts"; import { Output } from "../../../shared/output/output.service.ts"; import { encodeSortedJson, @@ -93,16 +94,21 @@ export const rejectComputeEnvOutput = Effect.fnUntraced(function* () { }); /** - * The `--project-ref` a retry suggestion has to carry, or `""` when it came - * from the link. + * The ` --remote=<ref>` a retry suggestion has to carry, or `""` when the project came from the + * link. * - * Dropping an explicit `--project-ref` from a copy-pasted suggestion would - * re-resolve to whichever project is currently linked — for `delete --yes`, - * a same-named compute deleted without a prompt. Keyed off the flag, not the - * resolved ref, so a link-supplied ref isn't echoed back as noise; an empty - * flag value counts as "not supplied", matching `ProjectRefResolver`. + * Dropping an explicit selector from a copy-pasted suggestion would re-resolve to whichever + * project is currently linked — for `delete --yes`, a same-named compute deleted without a + * prompt. Keyed off the flags, not the resolved ref, so a link-supplied ref isn't echoed back as + * noise; the resolved ref also stands in for a branch name, which needs no second lookup. */ -export const computeProjectRefSuffix = (projectRef: Option.Option<string>): string => - Option.isSome(projectRef) && projectRef.value.length > 0 - ? ` --project-ref ${projectRef.value}` +export const computeRemoteSuffix = ( + flags: { + readonly remote: Option.Option<RemoteValue>; + readonly projectRef: Option.Option<string>; + }, + ref: string, +): string => + Option.isSome(flags.projectRef) || Option.exists(flags.remote, (value) => value._tag !== "Linked") + ? ` --remote=${ref}` : ""; 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 5d54499682..afd2adae6d 100644 --- a/apps/cli/src/commands/experimental/compute/delete/delete.command.ts +++ b/apps/cli/src/commands/experimental/compute/delete/delete.command.ts @@ -1,4 +1,4 @@ -import { Argument, Command, Flag } from "effect/cli"; +import { Argument, Command } from "effect/cli"; import type * as CliCommand from "effect/cli/Command"; import { withPermissions } from "../../../../command-internal/command-permissions/command-permissions.annotation.ts"; import { compose, withKind } from "../../../../command-internal/command-permissions/model.ts"; @@ -12,16 +12,15 @@ import { managementApiRuntimeLayer } from "../../../../command-internal/manageme import { withCommandTelemetry } from "../../../../telemetry/command-telemetry.ts"; import { computeDelete } from "./delete.handler.ts"; import { withTargetPolicy } from "../../../../command-internal/target/target-policy.ts"; +import { branchResolution } from "../../../../command-internal/target/branch-resolution.ts"; +import { projectTargetFlags } from "../../../../command-internal/target/target-flags.ts"; // No local `--yes`: it is a root persistent flag every other confirming command // reads through `resolveYes`, so redeclaring it here would shadow the // global, list `--yes` twice in `--help`, and quietly ignore `SUPABASE_YES`. const config = { name: Argument.String("name").pipe(Argument.withDescription("Compute to delete.")), - projectRef: Flag.String("project-ref").pipe( - Flag.withDescription("Project ref of the Supabase project."), - Flag.optional, - ), + ...projectTargetFlags(), } as const; export type ComputeDeleteFlags = CliCommand.Command.Config.Infer<typeof config>; @@ -47,6 +46,7 @@ export const computeDeleteCommand = Command.make("delete", config).pipe( { operations: [{ operationId: "v2-delete-a-compute-instance", kind: "required" }] }, projectRefPicker, linkedProjectCache, + branchResolution, ), ), Command.withHandler((flags) => 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 c68862ce5a..d324f1f6b2 100644 --- a/apps/cli/src/commands/experimental/compute/delete/delete.handler.ts +++ b/apps/cli/src/commands/experimental/compute/delete/delete.handler.ts @@ -6,7 +6,7 @@ import { renderComputeDetails } from "../compute.format.ts"; import { emitComputeMachineOutput, rejectComputeEnvOutput, - computeProjectRefSuffix, + computeRemoteSuffix, } from "../compute.output.ts"; import { CommandPlatformApi } from "../../../../auth/command-platform-api.service.ts"; import { displayPath } from "../../../../shared/compute/compute-paths.ts"; @@ -17,7 +17,7 @@ import { } 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 { resolveProjectTarget } from "../../../../command-internal/target/resolve-project-target.ts"; import { LinkedProjectCache } from "../../../../telemetry/linked-project-cache.service.ts"; import { TelemetryState } from "../../../../telemetry/telemetry-state.service.ts"; import { @@ -38,7 +38,6 @@ export const computeDelete = Effect.fn("compute.delete")(function* (flags: Compu const output = yield* Output; const path = yield* Path.Path; const api = yield* CommandPlatformApi; - const resolver = yield* ProjectRefResolver; const linkedProjectCache = yield* LinkedProjectCache; const telemetryState = yield* TelemetryState; const yes = yield* resolveYes; @@ -47,10 +46,13 @@ export const computeDelete = Effect.fn("compute.delete")(function* (flags: Compu // them; everything that can fail on its own — loading `config.toml`, // validating the name, resolving the compute — belongs inside, so those // failures still flush telemetry. Same shape as `config/push`. - const projectRef = yield* resolver.resolve(flags.projectRef); + const projectRef = yield* resolveProjectTarget({ + remote: flags.remote, + projectRef: flags.projectRef, + }); // Every retry this command suggests is for a *destructive* re-run, so the ref // has to survive the copy-paste. - const refSuffix = computeProjectRefSuffix(flags.projectRef); + const refSuffix = computeRemoteSuffix(flags, projectRef); yield* Effect.gen(function* () { const project = yield* loadComputeProjectForReporting(); 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 ffb79699f4..ff2538b2a2 100644 --- a/apps/cli/src/commands/experimental/compute/list/list.command.ts +++ b/apps/cli/src/commands/experimental/compute/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"; import { compose } from "../../../../command-internal/command-permissions/model.ts"; @@ -11,12 +11,11 @@ import { managementApiRuntimeLayer } from "../../../../command-internal/manageme import { withCommandTelemetry } from "../../../../telemetry/command-telemetry.ts"; import { computeList } from "./list.handler.ts"; import { withTargetPolicy } from "../../../../command-internal/target/target-policy.ts"; +import { branchResolution } from "../../../../command-internal/target/branch-resolution.ts"; +import { projectTargetFlags } from "../../../../command-internal/target/target-flags.ts"; const config = { - projectRef: Flag.String("project-ref").pipe( - Flag.withDescription("Project ref of the Supabase project."), - Flag.optional, - ), + ...projectTargetFlags(), } as const; export type ComputeListFlags = CliCommand.Command.Config.Infer<typeof config>; @@ -37,6 +36,7 @@ export const computeListCommand = Command.make("list", config).pipe( { operations: [{ operationId: "v2-list-all-compute-instances", kind: "required" }] }, projectRefPicker, linkedProjectCache, + branchResolution, ), ), Command.withHandler((flags) => diff --git a/apps/cli/src/commands/experimental/compute/list/list.handler.ts b/apps/cli/src/commands/experimental/compute/list/list.handler.ts index 05e68e5766..760185f9cb 100644 --- a/apps/cli/src/commands/experimental/compute/list/list.handler.ts +++ b/apps/cli/src/commands/experimental/compute/list/list.handler.ts @@ -9,7 +9,7 @@ import { CommandSettings } from "../../../../config/command-settings.service.ts" import { formatApiSize } from "../../../../shared/compute/compute-runtimes.ts"; import { computeUrl } from "../../../../shared/compute/compute-url.ts"; import { listCompute, type ComputeRecord } from "../../../../shared/compute/compute-api.ts"; -import { ProjectRefResolver } from "../../../../config/project-ref.service.ts"; +import { resolveProjectTarget } from "../../../../command-internal/target/resolve-project-target.ts"; import { LinkedProjectCache } from "../../../../telemetry/linked-project-cache.service.ts"; import { TelemetryState } from "../../../../telemetry/telemetry-state.service.ts"; import { discoverComputeNames, loadComputeProject } from "../compute.shared.ts"; @@ -96,7 +96,6 @@ export const computeList = Effect.fn("compute.list")(function* (flags: ComputeLi const output = yield* Output; const path = yield* Path.Path; const api = yield* CommandPlatformApi; - const resolver = yield* ProjectRefResolver; const linkedProjectCache = yield* LinkedProjectCache; const telemetryState = yield* TelemetryState; const settings = yield* CommandSettings; @@ -104,7 +103,10 @@ export const computeList = Effect.fn("compute.list")(function* (flags: ComputeLi // Resolved here, outside the block below, since caching it is one of that // block's own finalizers — everything else that can fail belongs inside so // those failures still flush telemetry. - const projectRef = yield* resolver.resolve(flags.projectRef); + const projectRef = yield* resolveProjectTarget({ + remote: flags.remote, + projectRef: flags.projectRef, + }); yield* Effect.gen(function* () { const project = yield* loadComputeProject; 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 9ec2aa2c81..cc36a49839 100644 --- a/apps/cli/src/commands/experimental/compute/logs/logs.command.ts +++ b/apps/cli/src/commands/experimental/compute/logs/logs.command.ts @@ -14,6 +14,8 @@ import { import { withCommandTelemetry } from "../../../../telemetry/command-telemetry.ts"; import { computeLogs } from "./logs.handler.ts"; import { withTargetPolicy } from "../../../../command-internal/target/target-policy.ts"; +import { branchResolution } from "../../../../command-internal/target/branch-resolution.ts"; +import { projectTargetFlags } from "../../../../command-internal/target/target-flags.ts"; /** * The endpoint's own ceiling is the SQL `LIMIT`, so this bound is the CLI's @@ -27,10 +29,7 @@ const MAX_TAIL = 1000; const config = { name: Argument.String("name").pipe(Argument.withDescription("Compute to read logs for.")), - projectRef: Flag.String("project-ref").pipe( - Flag.withDescription("Project ref of the Supabase project."), - Flag.optional, - ), + ...projectTargetFlags(), kind: Flag.Literals("kind", COMPUTE_LOG_KINDS).pipe( Flag.withDescription( "Limit to one log stream: app (the compute's own output), requests (HTTP access), " + @@ -111,6 +110,7 @@ export const computeLogsCommand = Command.make("logs", config).pipe( withContext(computeRead, "the log query returned no rows, or --tail 0 with --follow"), projectRefPicker, linkedProjectCache, + branchResolution, ), ), Command.withHandler((flags) => diff --git a/apps/cli/src/commands/experimental/compute/logs/logs.handler.ts b/apps/cli/src/commands/experimental/compute/logs/logs.handler.ts index b4f3f79d59..3b66049618 100644 --- a/apps/cli/src/commands/experimental/compute/logs/logs.handler.ts +++ b/apps/cli/src/commands/experimental/compute/logs/logs.handler.ts @@ -5,7 +5,7 @@ import { aqua } from "../../../../command-internal/colors.ts"; import { emitComputeMachineOutput, rejectComputeEnvOutput, - computeProjectRefSuffix, + computeRemoteSuffix, } from "../compute.output.ts"; import { renderComputeLogLine, computeLogLevel, computeLogText } from "../compute-logs.format.ts"; import { ProcessControl } from "../../../../shared/runtime/process-control.service.ts"; @@ -30,7 +30,7 @@ import { ComputeApiNetworkError, ComputeApiUnexpectedStatusError, } from "../../../../shared/compute/compute.errors.ts"; -import { ProjectRefResolver } from "../../../../config/project-ref.service.ts"; +import { resolveProjectTarget } from "../../../../command-internal/target/resolve-project-target.ts"; import { LinkedProjectCache } from "../../../../telemetry/linked-project-cache.service.ts"; import { TelemetryState } from "../../../../telemetry/telemetry-state.service.ts"; import { validateComputeName } from "../compute.shared.ts"; @@ -142,7 +142,6 @@ export const computeLogs = Effect.fn("compute.logs")(function* ( ) { const output = yield* Output; const api = yield* CommandPlatformApi; - const resolver = yield* ProjectRefResolver; const linkedProjectCache = yield* LinkedProjectCache; const telemetryState = yield* TelemetryState; const processControl = yield* ProcessControl; @@ -151,8 +150,11 @@ export const computeLogs = Effect.fn("compute.logs")(function* ( // checkout fails inside `resolve` after the command has already run. Only the // linked-project cache stays under the ref, since it has nothing to write without one. yield* Effect.gen(function* () { - const projectRef = yield* resolver.resolve(flags.projectRef); - const refSuffix = computeProjectRefSuffix(flags.projectRef); + const projectRef = yield* resolveProjectTarget({ + remote: flags.remote, + projectRef: flags.projectRef, + }); + const refSuffix = computeRemoteSuffix(flags, projectRef); yield* Effect.gen(function* () { const name = yield* validateComputeName(flags.name); 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 5daa8972d2..17031c2b3c 100644 --- a/apps/cli/src/commands/experimental/compute/push/push.command.ts +++ b/apps/cli/src/commands/experimental/compute/push/push.command.ts @@ -17,6 +17,8 @@ import { managementApiRuntimeLayer } from "../../../../command-internal/manageme import { withCommandTelemetry } from "../../../../telemetry/command-telemetry.ts"; import { computePush } from "./push.handler.ts"; import { withTargetPolicy } from "../../../../command-internal/target/target-policy.ts"; +import { branchResolution } from "../../../../command-internal/target/branch-resolution.ts"; +import { projectTargetFlags } from "../../../../command-internal/target/target-flags.ts"; const config = { names: Argument.String("name").pipe( @@ -56,10 +58,7 @@ const config = { ), Flag.withDefault(false), ), - projectRef: Flag.String("project-ref").pipe( - Flag.withDescription("Project ref of the Supabase project."), - Flag.optional, - ), + ...projectTargetFlags(), } as const; export type ComputePushFlags = CliCommand.Command.Config.Infer<typeof config>; @@ -107,6 +106,7 @@ export const computePushCommand = Command.make("push", config).pipe( ), projectRefPicker, linkedProjectCache, + branchResolution, ), ), Command.withHandler((flags) => 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 8204f9ce56..64553a4dbf 100644 --- a/apps/cli/src/commands/experimental/compute/push/push.handler.ts +++ b/apps/cli/src/commands/experimental/compute/push/push.handler.ts @@ -6,7 +6,7 @@ import { emitComputeMachineOutput, rejectComputeEnvOutput, computeMachineOutputRequested, - computeProjectRefSuffix, + computeRemoteSuffix, } from "../compute.output.ts"; import { aqua } from "../../../../command-internal/colors.ts"; import { CommandPlatformApi } from "../../../../auth/command-platform-api.service.ts"; @@ -51,7 +51,7 @@ import { 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 { resolveProjectTarget } from "../../../../command-internal/target/resolve-project-target.ts"; import { LinkedProjectCache } from "../../../../telemetry/linked-project-cache.service.ts"; import { TelemetryState } from "../../../../telemetry/telemetry-state.service.ts"; import { @@ -222,7 +222,7 @@ const deployOneCompute = Effect.fn("compute.push.deployOne")(function* (input: { readonly name: string; readonly projectRef: string; /** - * ` --project-ref <ref>` when the flag supplied the ref, `""` when the link + * ` --remote=<ref>` when a flag selected the project, `""` when the link * did — the follow-up hint below is copy-pasted verbatim, so it has to carry * whatever the user typed to reach this project. */ @@ -545,14 +545,16 @@ export const computePush = Effect.fn("compute.push")(function* ( ) { const output = yield* Output; const path = yield* Path.Path; - const resolver = yield* ProjectRefResolver; const linkedProjectCache = yield* LinkedProjectCache; const telemetryState = yield* TelemetryState; // Resolved here, outside the block below, since caching it is one of that // block's own finalizers — everything else that can fail belongs inside so // those failures still flush telemetry. - const projectRef = yield* resolver.resolve(flags.projectRef); + const projectRef = yield* resolveProjectTarget({ + remote: flags.remote, + projectRef: flags.projectRef, + }); yield* Effect.gen(function* () { const project = yield* loadComputeProject; @@ -574,7 +576,7 @@ export const computePush = Effect.fn("compute.push")(function* ( } const names = [...new Set(requested)]; - const refSuffix = computeProjectRefSuffix(flags.projectRef); + const refSuffix = computeRemoteSuffix(flags, projectRef); if (Option.isNone(flags.exposure)) { for (const name of names) { 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 3b619fda95..ee72bb05e1 100644 --- a/apps/cli/src/commands/experimental/compute/status/status.command.ts +++ b/apps/cli/src/commands/experimental/compute/status/status.command.ts @@ -1,4 +1,4 @@ -import { Argument, Command, Flag } from "effect/cli"; +import { Argument, Command } from "effect/cli"; import type * as CliCommand from "effect/cli/Command"; import { withPermissions } from "../../../../command-internal/command-permissions/command-permissions.annotation.ts"; import { compose } from "../../../../command-internal/command-permissions/model.ts"; @@ -12,13 +12,12 @@ import { managementApiRuntimeLayer } from "../../../../command-internal/manageme import { withCommandTelemetry } from "../../../../telemetry/command-telemetry.ts"; import { computeStatus } from "./status.handler.ts"; import { withTargetPolicy } from "../../../../command-internal/target/target-policy.ts"; +import { branchResolution } from "../../../../command-internal/target/branch-resolution.ts"; +import { projectTargetFlags } from "../../../../command-internal/target/target-flags.ts"; const config = { name: Argument.String("name").pipe(Argument.withDescription("Compute to inspect.")), - projectRef: Flag.String("project-ref").pipe( - Flag.withDescription("Project ref of the Supabase project."), - Flag.optional, - ), + ...projectTargetFlags(), } as const; export type ComputeStatusFlags = CliCommand.Command.Config.Infer<typeof config>; @@ -34,7 +33,7 @@ export const computeStatusCommand = Command.make("status", config).pipe( description: "Inspect a specific compute", }, ]), - withPermissions(compose(computeRead, projectRefPicker, linkedProjectCache)), + withPermissions(compose(computeRead, projectRefPicker, linkedProjectCache, branchResolution)), Command.withHandler((flags) => computeStatus(flags).pipe(withCommandTelemetry({ flags }), withJsonErrorHandling), ), diff --git a/apps/cli/src/commands/experimental/compute/status/status.handler.ts b/apps/cli/src/commands/experimental/compute/status/status.handler.ts index c3c7648976..ac6aae13ed 100644 --- a/apps/cli/src/commands/experimental/compute/status/status.handler.ts +++ b/apps/cli/src/commands/experimental/compute/status/status.handler.ts @@ -6,7 +6,7 @@ import { renderComputeDetails } from "../compute.format.ts"; import { emitComputeMachineOutput, rejectComputeEnvOutput, - computeProjectRefSuffix, + computeRemoteSuffix, } from "../compute.output.ts"; import { CommandPlatformApi } from "../../../../auth/command-platform-api.service.ts"; import { CommandSettings } from "../../../../config/command-settings.service.ts"; @@ -15,7 +15,7 @@ import { formatApiSize } from "../../../../shared/compute/compute-runtimes.ts"; import { computeUrl } from "../../../../shared/compute/compute-url.ts"; import { getCompute } from "../../../../shared/compute/compute-api.ts"; import { ComputeNotDeployedError } from "../../../../shared/compute/compute.errors.ts"; -import { ProjectRefResolver } from "../../../../config/project-ref.service.ts"; +import { resolveProjectTarget } from "../../../../command-internal/target/resolve-project-target.ts"; import { LinkedProjectCache } from "../../../../telemetry/linked-project-cache.service.ts"; import { TelemetryState } from "../../../../telemetry/telemetry-state.service.ts"; import { @@ -36,7 +36,6 @@ export const computeStatus = Effect.fn("compute.status")(function* (flags: Compu const output = yield* Output; const path = yield* Path.Path; const api = yield* CommandPlatformApi; - const resolver = yield* ProjectRefResolver; const linkedProjectCache = yield* LinkedProjectCache; const telemetryState = yield* TelemetryState; const settings = yield* CommandSettings; @@ -44,8 +43,11 @@ export const computeStatus = Effect.fn("compute.status")(function* (flags: Compu // Resolved here, outside the block below, since caching it is one of that // block's own finalizers — everything else that can fail belongs inside so // those failures still flush telemetry. - const projectRef = yield* resolver.resolve(flags.projectRef); - const refSuffix = computeProjectRefSuffix(flags.projectRef); + const projectRef = yield* resolveProjectTarget({ + remote: flags.remote, + projectRef: flags.projectRef, + }); + const refSuffix = computeRemoteSuffix(flags, projectRef); yield* Effect.gen(function* () { const project = yield* loadComputeProjectForReporting(); diff --git a/apps/cli/src/commands/functions/delete/delete.command.ts b/apps/cli/src/commands/functions/delete/delete.command.ts index 030e5981e4..a43bcfde6f 100644 --- a/apps/cli/src/commands/functions/delete/delete.command.ts +++ b/apps/cli/src/commands/functions/delete/delete.command.ts @@ -1,7 +1,9 @@ -import { Argument, Command, Flag } from "effect/cli"; +import { Option } from "effect"; +import { Argument, Command } from "effect/cli"; import type * as CliCommand from "effect/cli/Command"; import { withPermissions } from "../../../command-internal/command-permissions/command-permissions.annotation.ts"; import { compose } from "../../../command-internal/command-permissions/model.ts"; +import { PROJECT_REF_PATTERN } from "../../../config/project-ref.service.ts"; import { projectRefPicker } from "../../../config/project-ref.layer.ts"; import { linkedProjectCache } from "../../../telemetry/linked-project-cache.layer.ts"; import { FUNCTIONS_PROJECT_REF_SAFE_FLAGS } from "../../../shared/functions/functions.shared.ts"; @@ -10,15 +12,14 @@ import { managementApiRuntimeLayer } from "../../../command-internal/management- import { withCommandTelemetry } from "../../../telemetry/command-telemetry.ts"; import { functionsDelete } from "./delete.handler.ts"; import { withTargetPolicy } from "../../../command-internal/target/target-policy.ts"; +import { branchResolution } from "../../../command-internal/target/branch-resolution.ts"; +import { projectTargetFlags } from "../../../command-internal/target/target-flags.ts"; const config = { functionName: Argument.String("Function name").pipe( Argument.withDescription("Name of the Function to delete."), ), - projectRef: Flag.String("project-ref").pipe( - Flag.withDescription("Project ref of the Supabase project."), - Flag.optional, - ), + ...projectTargetFlags(), } as const; export type FunctionsDeleteFlags = CliCommand.Command.Config.Infer<typeof config>; @@ -27,7 +28,13 @@ export type FunctionsDeleteFlags = CliCommand.Command.Config.Infer<typeof config // uses below, instead of re-asserting the generic instrumentation mechanism. export const functionsDeleteHandler = (flags: FunctionsDeleteFlags) => functionsDelete(flags).pipe( - withCommandTelemetry({ flags, safeFlags: FUNCTIONS_PROJECT_REF_SAFE_FLAGS }), + withCommandTelemetry({ + flags, + safeFlags: + Option.isSome(flags.projectRef) && PROJECT_REF_PATTERN.test(flags.projectRef.value) + ? FUNCTIONS_PROJECT_REF_SAFE_FLAGS + : [], + }), withJsonErrorHandling, ); @@ -51,6 +58,7 @@ export const functionsDeleteCommand = Command.make("delete", config).pipe( { operations: [{ operationId: "v1-delete-a-function", kind: "required" }] }, projectRefPicker, linkedProjectCache, + branchResolution, ), ), Command.withHandler(functionsDeleteHandler), diff --git a/apps/cli/src/commands/functions/delete/delete.handler.ts b/apps/cli/src/commands/functions/delete/delete.handler.ts index ddd47691b4..fa21795c81 100644 --- a/apps/cli/src/commands/functions/delete/delete.handler.ts +++ b/apps/cli/src/commands/functions/delete/delete.handler.ts @@ -2,7 +2,7 @@ import { Effect, Option, Ref } from "effect"; import { deleteFunction } from "../../../shared/functions/delete.ts"; import { aqua } from "../../../command-internal/colors.ts"; import { CommandPlatformApi } from "../../../auth/command-platform-api.service.ts"; -import { ProjectRefResolver } from "../../../config/project-ref.service.ts"; +import { resolveProjectTarget } from "../../../command-internal/target/resolve-project-target.ts"; import { LinkedProjectCache } from "../../../telemetry/linked-project-cache.service.ts"; import { TelemetryState } from "../../../telemetry/telemetry-state.service.ts"; import type { FunctionsDeleteFlags } from "./delete.command.ts"; @@ -11,19 +11,18 @@ export const functionsDelete = Effect.fn("functions.delete")(function* ( flags: FunctionsDeleteFlags, ) { const api = yield* CommandPlatformApi; - const resolver = yield* ProjectRefResolver; const linkedProjectCache = yield* LinkedProjectCache; const telemetryState = yield* TelemetryState; const resolvedProjectRef = yield* Ref.make(Option.none<string>()); yield* deleteFunction( - { slug: flags.functionName, projectRef: flags.projectRef }, + { slug: flags.functionName }, { api, - resolveProjectRef: (projectRef) => - resolver - .resolve(projectRef) - .pipe(Effect.tap((ref) => Ref.set(resolvedProjectRef, Option.some(ref)))), + resolveProjectRef: resolveProjectTarget({ + remote: flags.remote, + projectRef: flags.projectRef, + }).pipe(Effect.tap((ref) => Ref.set(resolvedProjectRef, Option.some(ref)))), // The "Deleted Function" line is stdout-bound, so the TTY gate must check stdout. styleIdentifier: (text) => aqua(text, process.stdout), }, diff --git a/apps/cli/src/commands/functions/download/download.command.ts b/apps/cli/src/commands/functions/download/download.command.ts index 914bfb64f4..75bc3ba119 100644 --- a/apps/cli/src/commands/functions/download/download.command.ts +++ b/apps/cli/src/commands/functions/download/download.command.ts @@ -5,6 +5,7 @@ import { withPermissions } from "../../../command-internal/command-permissions/c import { compose } from "../../../command-internal/command-permissions/model.ts"; import { withCliConfigFlags } from "../../../config/cli-config-flags.ts"; import { cliConfigValuesLayer } from "../../../config/cli-config-values.layer.ts"; +import { PROJECT_REF_PATTERN } from "../../../config/project-ref.service.ts"; import { projectRefPicker } from "../../../config/project-ref.layer.ts"; import { linkedProjectCache } from "../../../telemetry/linked-project-cache.layer.ts"; import { functionsDownload as functionsDownloadCalls } from "../../../shared/functions/download.ts"; @@ -17,16 +18,15 @@ 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"; +import { branchResolution } from "../../../command-internal/target/branch-resolution.ts"; +import { projectTargetFlags } from "../../../command-internal/target/target-flags.ts"; const config = { functionName: Argument.String("Function name").pipe( Argument.withDescription("Name of the Function to download. Downloads all if omitted."), Argument.optional, ), - projectRef: Flag.String("project-ref").pipe( - Flag.withDescription("Project ref of the Supabase project."), - Flag.optional, - ), + ...projectTargetFlags(), useApi: Flag.Boolean("use-api").pipe( Flag.withDescription("Unbundle functions server-side without using Docker."), Flag.withDefault(false), @@ -49,9 +49,14 @@ export type FunctionsDownloadFlags = CliCommand.Command.Config.Infer<typeof conf // Exported so integration tests can drive the exact wiring `Command.withHandler` // uses below, instead of re-asserting the generic instrumentation mechanism. +const projectRefSafeFlags = (flags: FunctionsDownloadFlags) => + Option.isSome(flags.projectRef) && PROJECT_REF_PATTERN.test(flags.projectRef.value) + ? FUNCTIONS_PROJECT_REF_SAFE_FLAGS + : []; + export const functionsDownloadHandler = (flags: FunctionsDownloadFlags) => functionsDownload(flags).pipe( - withCommandTelemetry({ flags, safeFlags: FUNCTIONS_PROJECT_REF_SAFE_FLAGS }), + withCommandTelemetry({ flags, safeFlags: projectRefSafeFlags(flags) }), withJsonErrorHandling, ); @@ -67,7 +72,7 @@ const functionsDownloadLegacyBundleHandler = (flags: FunctionsDownloadFlags) => "--legacy-bundle", `Retry with \`supabase functions download --use-api ${slug}\` to unbundle server-side without Docker. If that also fails and the Function was deployed with a CLI older than 1.120.0, redeploy it with the current CLI.`, ).pipe( - withCommandTelemetry({ flags, safeFlags: FUNCTIONS_PROJECT_REF_SAFE_FLAGS }), + withCommandTelemetry({ flags, safeFlags: projectRefSafeFlags(flags) }), withJsonErrorHandling, Effect.provide(removedFlagLayer), ); @@ -97,6 +102,7 @@ export const functionsDownloadCommand = Command.make("download", config).pipe( functionsDownloadCalls, projectRefPicker, linkedProjectCache, + branchResolution, ), ), Command.withHandler((flags) => diff --git a/apps/cli/src/commands/functions/download/download.handler.ts b/apps/cli/src/commands/functions/download/download.handler.ts index c55f9d6612..345235b00e 100644 --- a/apps/cli/src/commands/functions/download/download.handler.ts +++ b/apps/cli/src/commands/functions/download/download.handler.ts @@ -6,7 +6,7 @@ import { aqua, bold, yellow } from "../../../command-internal/colors.ts"; import { functionsLocalConfigLoader } from "../../../command-internal/functions-local-config.ts"; 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 { resolveProjectTarget } from "../../../command-internal/target/resolve-project-target.ts"; import { LinkedProjectCache } from "../../../telemetry/linked-project-cache.service.ts"; import { TelemetryState } from "../../../telemetry/telemetry-state.service.ts"; import type { FunctionsDownloadFlags } from "./download.command.ts"; @@ -16,7 +16,6 @@ export const functionsDownload = Effect.fn("functions.download")(function* ( ) { const api = yield* CommandPlatformApi; const cliSettings = yield* CommandSettings; - const resolver = yield* ProjectRefResolver; const linkedProjectCache = yield* LinkedProjectCache; const telemetryState = yield* TelemetryState; const output = yield* Output; @@ -41,10 +40,10 @@ export const functionsDownload = Effect.fn("functions.download")(function* ( styleAqua: (text) => aqua(text), // Written to stderr, matching `yellow`'s default TTY gate. styleWarning: (text) => yellow(text), - resolveProjectRef: (projectRef) => - resolver - .resolve(projectRef) - .pipe(Effect.tap((ref) => Ref.set(resolvedProjectRef, Option.some(ref)))), + resolveProjectRef: resolveProjectTarget({ + remote: flags.remote, + projectRef: flags.projectRef, + }).pipe(Effect.tap((ref) => Ref.set(resolvedProjectRef, Option.some(ref)))), }); yield* Effect.annotateCurrentSpan("function.count", result.slugs.length); diff --git a/apps/cli/src/commands/functions/list/list.command.ts b/apps/cli/src/commands/functions/list/list.command.ts index e7baa562e6..4faa7d1668 100644 --- a/apps/cli/src/commands/functions/list/list.command.ts +++ b/apps/cli/src/commands/functions/list/list.command.ts @@ -1,7 +1,9 @@ -import { Command, Flag } from "effect/cli"; +import { Option } from "effect"; +import { Command } from "effect/cli"; import type * as CliCommand from "effect/cli/Command"; import { withPermissions } from "../../../command-internal/command-permissions/command-permissions.annotation.ts"; import { compose } from "../../../command-internal/command-permissions/model.ts"; +import { PROJECT_REF_PATTERN } from "../../../config/project-ref.service.ts"; import { projectRefPicker } from "../../../config/project-ref.layer.ts"; import { linkedProjectCache } from "../../../telemetry/linked-project-cache.layer.ts"; import { FUNCTIONS_PROJECT_REF_SAFE_FLAGS } from "../../../shared/functions/functions.shared.ts"; @@ -10,12 +12,11 @@ import { managementApiRuntimeLayer } from "../../../command-internal/management- import { withCommandTelemetry } from "../../../telemetry/command-telemetry.ts"; import { functionsList } from "./list.handler.ts"; import { withTargetPolicy } from "../../../command-internal/target/target-policy.ts"; +import { branchResolution } from "../../../command-internal/target/branch-resolution.ts"; +import { projectTargetFlags } from "../../../command-internal/target/target-flags.ts"; const config = { - projectRef: Flag.String("project-ref").pipe( - Flag.withDescription("Project ref of the Supabase project."), - Flag.optional, - ), + ...projectTargetFlags(), } as const; export type FunctionsListFlags = CliCommand.Command.Config.Infer<typeof config>; @@ -38,11 +39,18 @@ export const functionsListCommand = Command.make("list", config).pipe( { operations: [{ operationId: "v1-list-all-functions", kind: "required" }] }, projectRefPicker, linkedProjectCache, + branchResolution, ), ), Command.withHandler((flags) => functionsList(flags).pipe( - withCommandTelemetry({ flags, safeFlags: FUNCTIONS_PROJECT_REF_SAFE_FLAGS }), + withCommandTelemetry({ + flags, + safeFlags: + Option.isSome(flags.projectRef) && PROJECT_REF_PATTERN.test(flags.projectRef.value) + ? FUNCTIONS_PROJECT_REF_SAFE_FLAGS + : [], + }), withJsonErrorHandling, ), ), diff --git a/apps/cli/src/commands/functions/list/list.handler.ts b/apps/cli/src/commands/functions/list/list.handler.ts index 00b1098239..f88f912c66 100644 --- a/apps/cli/src/commands/functions/list/list.handler.ts +++ b/apps/cli/src/commands/functions/list/list.handler.ts @@ -4,7 +4,7 @@ import { Effect, Option, Ref, Result } from "effect"; import { OutputFlag } from "../../../command-internal/global-flags.ts"; import { Output } from "../../../shared/output/output.service.ts"; import { CommandPlatformApi } from "../../../auth/command-platform-api.service.ts"; -import { ProjectRefResolver } from "../../../config/project-ref.service.ts"; +import { resolveProjectTarget } from "../../../command-internal/target/resolve-project-target.ts"; import { mapHttpError, sanitizeErrorBody } from "../../../command-internal/http-errors.ts"; import { LinkedProjectCache } from "../../../telemetry/linked-project-cache.service.ts"; import { TelemetryState } from "../../../telemetry/telemetry-state.service.ts"; @@ -34,15 +34,15 @@ export const functionsList = Effect.fn("functions.list")(function* (flags: Funct const output = yield* Output; const outputFlag = yield* OutputFlag; const api = yield* CommandPlatformApi; - const resolver = yield* ProjectRefResolver; const linkedProjectCache = yield* LinkedProjectCache; const telemetryState = yield* TelemetryState; const resolvedProjectRef = yield* Ref.make(Option.none<string>()); yield* Effect.gen(function* () { - const ref = yield* resolver - .resolve(flags.projectRef) - .pipe(Effect.tap((projectRef) => Ref.set(resolvedProjectRef, Option.some(projectRef)))); + const ref = yield* resolveProjectTarget({ + remote: flags.remote, + projectRef: flags.projectRef, + }).pipe(Effect.tap((projectRef) => Ref.set(resolvedProjectRef, Option.some(projectRef)))); yield* Effect.annotateCurrentSpan("project.ref", ref); const fetching = diff --git a/apps/cli/src/commands/pull/pull.aggregate.ts b/apps/cli/src/commands/pull/pull.aggregate.ts index cd1d65a104..60472e2fd4 100644 --- a/apps/cli/src/commands/pull/pull.aggregate.ts +++ b/apps/cli/src/commands/pull/pull.aggregate.ts @@ -264,12 +264,12 @@ export function pullRetryHint( ): string { const remoteLabelFlag = pullRemoteLabelFlag(remoteLabel); const commandByStep: Record<PullStepId, string> = { - config: `supabase config pull --project-ref ${ref}${remoteLabelFlag}`, + config: `supabase config pull${remoteLabelFlag} --remote=${ref}`, migration_history: `supabase migration fetch --remote=${ref}`, // `db pull` rejects an ambient `--experimental`/`SUPABASE_EXPERIMENTAL` without `--declarative`; // `--experimental=false` keeps this suggested command from failing on it. db: `supabase db pull --experimental=false --remote=${ref}`, - functions: `supabase functions download --project-ref ${ref}`, + functions: `supabase functions download --remote=${ref}`, }; return `To retry just this step, run: ${commandByStep[step]}`; } @@ -283,7 +283,7 @@ export function pullWithMigrationHistoryCommand( ref: string, remoteLabel: string | undefined, ): string { - return `supabase pull --with-migration-history --project-ref ${ref}${pullRemoteLabelFlag(remoteLabel)}`; + return `supabase pull --with-migration-history${pullRemoteLabelFlag(remoteLabel)} --remote=${ref}`; } /** diff --git a/apps/cli/src/commands/pull/pull.command.ts b/apps/cli/src/commands/pull/pull.command.ts index 31fbbba757..724b682140 100644 --- a/apps/cli/src/commands/pull/pull.command.ts +++ b/apps/cli/src/commands/pull/pull.command.ts @@ -21,15 +21,10 @@ 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"; +import { projectTargetFlags } from "../../command-internal/target/target-flags.ts"; const config = { - // Accepts either a project ref or a branch name of the linked project — no separate `--target`. - projectRef: Flag.String("project-ref").pipe( - Flag.withDescription( - "Project ref of the Supabase project, or the name (or UUID) of one of its branches. Values that are exactly 20 lowercase letters are always treated as project refs.", - ), - Flag.optional, - ), + ...projectTargetFlags(), remoteLabel: Flag.String("remote-label").pipe( Flag.withDescription( "Name of the [remotes.*] block the config step writes into, overriding the block it would otherwise reuse or create.", @@ -64,7 +59,7 @@ export type PullFlags = CliCommand.Command.Config.Infer<typeof config>; // uses below, instead of re-implementing the `safeFlags`/telemetry wrapper inline. export const pullHandler = (flags: PullFlags) => pull(flags).pipe( - // `--project-ref` accepts branch names, so its value is only safe to log verbatim when it's + // `--project-ref` can carry a branch name, so its value is only safe to log verbatim when it's // actually ref-shaped — a user-created branch name must never reach PostHog. // `--remote-label` is a free-form, user-chosen string and is never safe to log verbatim. withCommandTelemetry({ @@ -111,7 +106,7 @@ export const pullCommand = Command.make("pull", config).pipe( kind: "required", when: [stepsRun], context: - "the direct database host is unreachable (IPv4-only network) and there is no usable saved supabase/.temp/pooler-url; the database steps get the resolved ref, so this needs no --project-ref", + "the direct database host is unreachable (IPv4-only network) and there is no usable saved supabase/.temp/pooler-url; the database steps get the resolved ref, so this needs no --remote", }, ], noApiEffectFlags: ["remote-label", "force", "with-migration-history"], diff --git a/apps/cli/src/commands/pull/pull.errors.ts b/apps/cli/src/commands/pull/pull.errors.ts index 15fc2a6e61..ba838580c2 100644 --- a/apps/cli/src/commands/pull/pull.errors.ts +++ b/apps/cli/src/commands/pull/pull.errors.ts @@ -20,25 +20,25 @@ interface PullStatusErrorArgs { } /** - * `--project-ref` target-resolution errors, minted via `mintConfigTargetErrors`. Its tags are + * `--remote` target-resolution errors, minted via `mintConfigTargetErrors`. Its tags are * template-interpolated, so `error-actionability-coverage.unit.test.ts`'s static scan can't see * them there — each class is re-exported below under its own top-level export so the scan finds it. */ const pullTargetErrors = mintConfigTargetErrors("Pull"); -/** `--project-ref` named a branch the parent project does not have. */ +/** `--remote` named a branch the parent project does not have. */ export const PullBranchNotFoundError = pullTargetErrors.BranchNotFoundError; export type PullBranchNotFoundError = InstanceType<typeof PullBranchNotFoundError>; /** - * `--project-ref` named a branch (by name), but no project is linked to + * `--remote` named a branch (by name), but no project is linked to * search for branches under. */ export const PullBranchNotLinkedError = pullTargetErrors.BranchNotLinkedError; export type PullBranchNotLinkedError = InstanceType<typeof PullBranchNotLinkedError>; /** - * `--project-ref` named a branch (by name), and a parent-project candidate + * `--remote` named a branch (by name), and a parent-project candidate * exists but is not ref-shaped — corrupt or stale linked state. */ export const PullParentRefInvalidError = pullTargetErrors.ParentRefInvalidError; @@ -48,7 +48,7 @@ export type PullParentRefInvalidError = InstanceType<typeof PullParentRefInvalid export const PullBranchNotReadyError = pullTargetErrors.BranchNotReadyError; export type PullBranchNotReadyError = InstanceType<typeof PullBranchNotReadyError>; -/** A transport/decode failure resolving a branch-shaped `--project-ref` (`GET`-by-UUID or +/** A transport/decode failure resolving a branch-shaped `--remote` (`GET`-by-UUID or * `FIND`-by-name). */ export class PullBranchResolveNetworkError extends Data.TaggedError( "PullBranchResolveNetworkError", @@ -60,7 +60,7 @@ export class PullBranchResolveNetworkError extends Data.TaggedError( } } -/** An unexpected HTTP status resolving a branch-shaped `--project-ref`. */ +/** An unexpected HTTP status resolving a branch-shaped `--remote`. */ export class PullBranchResolveStatusError extends Data.TaggedError( "PullBranchResolveStatusError", )<PullStatusErrorArgs> { diff --git a/apps/cli/src/commands/pull/pull.format.ts b/apps/cli/src/commands/pull/pull.format.ts index dd9102dbdb..03ae110a43 100644 --- a/apps/cli/src/commands/pull/pull.format.ts +++ b/apps/cli/src/commands/pull/pull.format.ts @@ -148,7 +148,7 @@ export interface PullConfirmMessageInput { /** The resolved target project ref — named in the header line so the confirmation body says * which project/branch is about to be written to. */ readonly ref: string; - /** The branch name `--project-ref` resolved, when it named one; `undefined` for a ref-shaped + /** The branch name `--remote` resolved, when it named one; `undefined` for a ref-shaped * or linked-fallback target. */ readonly branch: string | undefined; /** `config pull`'s own rendered diff body (`renderConfigPullText`), when there was diff --git a/apps/cli/src/commands/pull/pull.handler.ts b/apps/cli/src/commands/pull/pull.handler.ts index 6f84daa0cb..afc1331cc3 100644 --- a/apps/cli/src/commands/pull/pull.handler.ts +++ b/apps/cli/src/commands/pull/pull.handler.ts @@ -16,6 +16,7 @@ import { LinkedProjectCache } from "../../telemetry/linked-project-cache.service import { TelemetryState } from "../../telemetry/telemetry-state.service.ts"; import { configTargetErrorsFor, + requestedConfigTarget, resolveConfigTarget, } from "../../command-internal/project-target.ts"; import { unexpectedStatusMessage } from "../../command-internal/http-errors.ts"; @@ -202,7 +203,6 @@ export const pull = Effect.fn("pull")(function* (flags: PullFlags) { const telemetryState = yield* TelemetryState; const machineErrorContext = yield* MachineErrorContext; - const requested = Option.filter(flags.projectRef, (value) => value.length > 0); const remoteLabel = Option.getOrUndefined( Option.filter(flags.remoteLabel, (value) => value.length > 0), ); @@ -227,6 +227,10 @@ export const pull = Effect.fn("pull")(function* (flags: PullFlags) { // instead of re-resolving (ADR 0024). const { ref, branch, linked, runPlan, source } = yield* Effect.gen(function* () { const pullSource = yield* openConfigPullSource(); + const requested = yield* requestedConfigTarget({ + remote: flags.remote, + projectRef: flags.projectRef, + }); const target = yield* resolveConfigTarget(requested, pullTargetErrors, mapBranchResolveError); yield* Ref.set(resolvedRef, Option.some(target.ref)); diff --git a/apps/cli/src/commands/pull/pull.steps.ts b/apps/cli/src/commands/pull/pull.steps.ts index 50d1eb91d7..e1f8048552 100644 --- a/apps/cli/src/commands/pull/pull.steps.ts +++ b/apps/cli/src/commands/pull/pull.steps.ts @@ -116,7 +116,6 @@ export const pullFunctionsStep = Effect.fnUntraced(function* (context: PullStepC const result: DownloadFunctionsResult = yield* downloadFunctions( { functionName: Option.none(), - projectRef: Option.some(context.ref), useApi: false, useDocker: true, }, @@ -129,7 +128,7 @@ export const pullFunctionsStep = Effect.fnUntraced(function* (context: PullStepC styleEmphasis: (text) => bold(text), styleAqua: (text) => aqua(text), styleWarning: (text) => yellow(text), - resolveProjectRef: () => Effect.succeed(context.ref), + resolveProjectRef: Effect.succeed(context.ref), }, ); return { kind: "downloaded", result } satisfies PullFunctionsStepOutcome; diff --git a/apps/cli/src/shared/functions/delete.ts b/apps/cli/src/shared/functions/delete.ts index b6c34c7c7c..4932c1fe82 100644 --- a/apps/cli/src/shared/functions/delete.ts +++ b/apps/cli/src/shared/functions/delete.ts @@ -4,7 +4,7 @@ import { SupabaseApiInputError, type ApiClient, } from "@supabase/api/effect"; -import { Effect, type Option } from "effect"; +import { Effect } 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"; @@ -18,14 +18,11 @@ import { invalidFunctionSlugDetail, validateFunctionSlugMessage } from "./functi export interface DeleteFunctionOptions { readonly slug: string; - readonly projectRef: Option.Option<string>; } export interface DeleteFunctionDependencies<ResolveError, ResolveRequirements> { readonly api: ApiClient; - readonly resolveProjectRef: ( - projectRef: Option.Option<string>, - ) => Effect.Effect<string, ResolveError, ResolveRequirements>; + readonly resolveProjectRef: Effect.Effect<string, ResolveError, ResolveRequirements>; /** * Optional shell-specific styling for the slug/ref in the success line. Defaults to identity * (plain text); keeping the hook injected keeps this shared module free of CLI-specific @@ -52,7 +49,7 @@ export const deleteFunction = Effect.fn("functions.delete")(function* < const output = yield* Output; yield* validateSlug(flags.slug); - const projectRef = yield* dependencies.resolveProjectRef(flags.projectRef); + const projectRef = yield* dependencies.resolveProjectRef; yield* requireHostedMutationConsent({ action: `Delete Function ${flags.slug}` }); const response = yield* dependencies.api diff --git a/apps/cli/src/shared/functions/download.ts b/apps/cli/src/shared/functions/download.ts index 76eed70072..3ed76cb311 100644 --- a/apps/cli/src/shared/functions/download.ts +++ b/apps/cli/src/shared/functions/download.ts @@ -55,7 +55,6 @@ const DOCKER_ESZIP_DIR = "/root/eszips"; export interface DownloadFunctionsOptions { readonly functionName: Option.Option<string>; - readonly projectRef: Option.Option<string>; readonly useApi: boolean; readonly useDocker: boolean; } @@ -113,9 +112,7 @@ interface EdgeRuntimeImageDependencies { export interface DownloadFunctionsDependencies<ResolveError, ResolveRequirements> extends DownloadDockerRuntimeDependencies, EdgeRuntimeImageDependencies { - readonly resolveProjectRef: ( - projectRef: Option.Option<string>, - ) => Effect.Effect<string, ResolveError, ResolveRequirements>; + readonly resolveProjectRef: Effect.Effect<string, ResolveError, ResolveRequirements>; } interface DownloadMetadata { @@ -1176,7 +1173,7 @@ export const downloadFunctions = Effect.fn("functions.download")(function* < yield* validateSlug(flags.functionName.value); } - const projectRef = yield* dependencies.resolveProjectRef(flags.projectRef); + const projectRef = yield* dependencies.resolveProjectRef; // Resolved unconditionally here, before checking `useDocker` or whether // Docker is running: an invalid `supabase/config.toml` (e.g. a bad From 2b11d8ba0e20145f30c18684b5e7b9e3d4daf4f2 Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Mon, 19 Oct 2026 12:13:26 +0100 Subject: [PATCH 127/291] test(cli): cover --remote target selection for hosted A-F commands Hosted commands run a shared selection matrix: --remote=<ref>, the deprecated --project-ref alias with its warning, --remote=<url> rejected before any request, and --remote=<branch> resolved through the branch lookup (or rejected for the parent-scoped branches commands). The config family and pull get the same cases, and their branch tests move to --remote. --- apps/cli/src/cli/complete.unit.test.ts | 6 +- .../backups/list/list.integration.test.ts | 71 ++- .../restore/restore.integration.test.ts | 53 +- .../branches.target.integration.test.ts | 137 ++++ .../branches/branches.target.unit.test.ts | 20 + .../create/create.integration.test.ts | 1 + .../delete/delete.integration.test.ts | 1 + .../disable/disable.integration.test.ts | 1 + .../branches/get/get.integration.test.ts | 1 + .../branches/list/list.integration.test.ts | 94 +-- .../branches/pause/pause.integration.test.ts | 1 + .../unpause/unpause.integration.test.ts | 1 + .../update/update.integration.test.ts | 1 + .../config/diff/diff.integration.test.ts | 221 ++++--- .../config/pull/pull.integration.test.ts | 126 +++- .../config/push/push.integration.test.ts | 584 +++++++++++------- .../activate/activate.integration.test.ts | 12 +- .../domains/create/create.integration.test.ts | 12 + .../domains/delete/delete.integration.test.ts | 12 +- .../domains/get/get.integration.test.ts | 12 +- .../reverify/reverify.integration.test.ts | 12 +- .../get-root-key.integration.test.ts | 17 +- .../update-root-key.integration.test.ts | 15 +- .../compute/compute.output.unit.test.ts | 37 +- .../compute/delete/delete.integration.test.ts | 62 +- .../compute/list/list.integration.test.ts | 79 ++- .../compute/logs/logs.integration.test.ts | 29 +- .../compute/push/push.integration.test.ts | 39 +- .../compute/status/status.integration.test.ts | 71 ++- .../delete/delete.integration.test.ts | 30 +- .../download/download.integration.test.ts | 201 +++--- .../functions/list/list.integration.test.ts | 99 ++- .../commands/pull/pull.aggregate.unit.test.ts | 22 +- .../commands/pull/pull.integration.test.ts | 184 ++++-- apps/cli/src/docs/docs-spec.unit.test.ts | 2 +- .../src/shared/cli/hidden-flag.unit.test.ts | 2 +- .../subcommand-flag-suggestions.unit.test.ts | 9 +- .../tests/helpers/compute-target-selection.ts | 79 +++ .../tests/helpers/hosted-target-selection.ts | 298 +++++++++ 39 files changed, 1940 insertions(+), 714 deletions(-) create mode 100644 apps/cli/src/commands/branches/branches.target.integration.test.ts create mode 100644 apps/cli/src/commands/branches/branches.target.unit.test.ts create mode 100644 apps/cli/tests/helpers/compute-target-selection.ts create mode 100644 apps/cli/tests/helpers/hosted-target-selection.ts diff --git a/apps/cli/src/cli/complete.unit.test.ts b/apps/cli/src/cli/complete.unit.test.ts index 53ae5990cb..4c9db33590 100644 --- a/apps/cli/src/cli/complete.unit.test.ts +++ b/apps/cli/src/cli/complete.unit.test.ts @@ -579,12 +579,10 @@ describe("respondToComplete", () => { "restore", "--timestamp", "0x10", - "--p", + "--r", ]); expect(result?.directive).toBe(CompletionDirective.NoFileComp); - expect(result?.candidates.map((c) => c.name)).toEqual( - expect.arrayContaining(["--profile", "--project-ref"]), - ); + expect(result?.candidates.map((c) => c.name)).toEqual(expect.arrayContaining(["--remote"])); }); it("rejects a value one past int64 max for a plain (non-uint) integer flag", () => { diff --git a/apps/cli/src/commands/backups/list/list.integration.test.ts b/apps/cli/src/commands/backups/list/list.integration.test.ts index 2ddc67d6da..2eb3f1a72e 100644 --- a/apps/cli/src/commands/backups/list/list.integration.test.ts +++ b/apps/cli/src/commands/backups/list/list.integration.test.ts @@ -12,6 +12,7 @@ import { mockCommandPlatformApi, useTempWorkdir, } from "../../../../tests/helpers/command-mocks.ts"; +import { describeHostedTargetSelection } from "../../../../tests/helpers/hosted-target-selection.ts"; import { assertPermissionDrift } from "../../../../tests/helpers/permission-drift.ts"; import { realLinkedProjectCache } from "../../../../tests/helpers/permission-real-layers.ts"; import { backupsList } from "./list.handler.ts"; @@ -66,6 +67,7 @@ interface SetupOpts { network?: "fail"; apiUrl?: string; userAgent?: string; + projectId?: string; } const tempRoot = useTempWorkdir("supabase-backups-list-int-"); @@ -82,6 +84,7 @@ function setup(opts: SetupOpts = {}) { workdir: tempRoot.current, apiUrl: opts.apiUrl, userAgent: opts.userAgent, + ...(opts.projectId === undefined ? {} : { projectId: Option.some(opts.projectId) }), }); const layer = buildTestRuntime({ out, @@ -96,7 +99,7 @@ describe("backups list integration", () => { it.effect("renders a PITR-only table when no physical backups exist", () => { const { layer, out } = setup({ response: PITR_RESPONSE }); return Effect.gen(function* () { - yield* backupsList({ projectRef: Option.none() }); + yield* backupsList({ remote: Option.none(), projectRef: Option.none() }); expect(out.stdoutText).toContain("REGION"); expect(out.stdoutText).toContain("WALG"); expect(out.stdoutText).toContain("PITR"); @@ -110,7 +113,7 @@ describe("backups list integration", () => { it.effect("renders a logical backups table with PHYSICAL classification", () => { const { layer, out } = setup({ response: LOGICAL_RESPONSE }); return Effect.gen(function* () { - yield* backupsList({ projectRef: Option.none() }); + yield* backupsList({ remote: Option.none(), projectRef: Option.none() }); expect(out.stdoutText).toContain("BACKUP TYPE"); expect(out.stdoutText).toContain("PHYSICAL"); expect(out.stdoutText).toContain("COMPLETED"); @@ -121,7 +124,7 @@ describe("backups list integration", () => { it.effect("translates ap-southeast-1 to Southeast Asia (Singapore)", () => { const { layer, out } = setup({ response: PITR_RESPONSE }); return Effect.gen(function* () { - yield* backupsList({ projectRef: Option.none() }); + yield* backupsList({ remote: Option.none(), projectRef: Option.none() }); expect(out.stdoutText).toContain("Southeast Asia (Singapore)"); }).pipe(Effect.provide(layer)); }); @@ -129,7 +132,7 @@ describe("backups list integration", () => { it.effect("emits a JSON success event when --output-format=json", () => { const { layer, out } = setup({ format: "json", response: PITR_RESPONSE }); return Effect.gen(function* () { - yield* backupsList({ projectRef: Option.none() }); + yield* backupsList({ remote: Option.none(), projectRef: Option.none() }); const success = out.messages.find((m) => m.type === "success"); expect(success).toBeDefined(); expect(success?.data).toMatchObject({ region: "ap-southeast-1", walg_enabled: true }); @@ -139,7 +142,7 @@ describe("backups list integration", () => { it.effect("emits a result event for --output-format=stream-json", () => { const { layer, out } = setup({ format: "stream-json", response: PITR_RESPONSE }); return Effect.gen(function* () { - yield* backupsList({ projectRef: Option.none() }); + yield* backupsList({ remote: Option.none(), projectRef: Option.none() }); const success = out.messages.find((m) => m.type === "success"); expect(success).toBeDefined(); expect(success?.data).toMatchObject({ region: "ap-southeast-1" }); @@ -149,7 +152,7 @@ describe("backups list integration", () => { it.effect("emits indented JSON to stdout for --output json", () => { const { layer, out } = setup({ outputFlag: "json", response: PITR_RESPONSE }); return Effect.gen(function* () { - yield* backupsList({ projectRef: Option.none() }); + yield* backupsList({ remote: Option.none(), projectRef: Option.none() }); expect(out.stdoutText).toBe( `{ "backups": null, @@ -166,7 +169,7 @@ describe("backups list integration", () => { it.effect("emits YAML to stdout for --output yaml", () => { const { layer, out } = setup({ outputFlag: "yaml", response: PITR_RESPONSE }); return Effect.gen(function* () { - yield* backupsList({ projectRef: Option.none() }); + yield* backupsList({ remote: Option.none(), projectRef: Option.none() }); expect(out.stdoutText).toContain("region: ap-southeast-1"); expect(out.stdoutText).toContain("walgenabled: true"); }).pipe(Effect.provide(layer)); @@ -175,7 +178,7 @@ describe("backups list integration", () => { it.effect("emits TOML to stdout for --output toml", () => { const { layer, out } = setup({ outputFlag: "toml", response: PITR_RESPONSE }); return Effect.gen(function* () { - yield* backupsList({ projectRef: Option.none() }); + yield* backupsList({ remote: Option.none(), projectRef: Option.none() }); expect(out.stdoutText).toContain('Region = "ap-southeast-1"'); expect(out.stdoutText).toContain("WalgEnabled = true"); }).pipe(Effect.provide(layer)); @@ -184,7 +187,7 @@ describe("backups list integration", () => { it.effect("emits [[Backups]] array-of-tables for --output toml with logical backups", () => { const { layer, out } = setup({ outputFlag: "toml", response: LOGICAL_RESPONSE }); return Effect.gen(function* () { - yield* backupsList({ projectRef: Option.none() }); + yield* backupsList({ remote: Option.none(), projectRef: Option.none() }); expect(out.stdoutText).toBe(`PitrEnabled = true Region = "ap-southeast-1" WalgEnabled = true @@ -203,7 +206,7 @@ WalgEnabled = true it.effect("emits KEY=VALUE lines for --output env", () => { const { layer, out } = setup({ outputFlag: "env", response: PITR_RESPONSE }); return Effect.gen(function* () { - yield* backupsList({ projectRef: Option.none() }); + yield* backupsList({ remote: Option.none(), projectRef: Option.none() }); expect(out.stdoutText).toContain('REGION="ap-southeast-1"'); expect(out.stdoutText).toContain('WALG_ENABLED="true"'); }).pipe(Effect.provide(layer)); @@ -212,7 +215,7 @@ WalgEnabled = true it.effect("treats --output pretty as identical to text mode (Glamour table)", () => { const { layer, out } = setup({ outputFlag: "pretty", response: PITR_RESPONSE }); return Effect.gen(function* () { - yield* backupsList({ projectRef: Option.none() }); + yield* backupsList({ remote: Option.none(), projectRef: Option.none() }); expect(out.stdoutText).toContain("Southeast Asia (Singapore)"); }).pipe(Effect.provide(layer)); }); @@ -224,7 +227,7 @@ WalgEnabled = true response: PITR_RESPONSE, }); return Effect.gen(function* () { - yield* backupsList({ projectRef: Option.none() }); + yield* backupsList({ remote: Option.none(), projectRef: Option.none() }); expect(out.stdoutText).toContain("region: ap-southeast-1"); expect(out.stdoutText.startsWith("{")).toBe(false); }).pipe(Effect.provide(layer)); @@ -233,22 +236,25 @@ WalgEnabled = true it.effect("passes the resolved project ref into the listAllBackups URL", () => { const { layer, api } = setup({ response: PITR_RESPONSE }); return Effect.gen(function* () { - yield* backupsList({ projectRef: Option.none() }); + yield* backupsList({ remote: Option.none(), projectRef: Option.none() }); expect(api.requests).toHaveLength(1); expect(api.requests[0]?.url).toContain(`/v1/projects/${VALID_REF}/database/backups`); assertPermissionDrift({ command: "backups list", requests: api.requests, exact: true }); }).pipe(Effect.provide(layer)); }); - it.effect("uses --project-ref flag value over CommandSettings.projectId env", () => { + it.effect("uses the --remote=<ref> value over CommandSettings.projectId env", () => { const flagRef = "zzzzzzzzzzzzzzzzzzzz"; const { layer, api } = setup({ response: PITR_RESPONSE }); return Effect.gen(function* () { - yield* backupsList({ projectRef: Option.some(flagRef) }); + yield* backupsList({ + remote: Option.some({ _tag: "Ref", ref: flagRef }), + projectRef: Option.none(), + }); expect(api.requests[0]?.url).toContain(`/v1/projects/${flagRef}/`); assertPermissionDrift({ command: "backups list", - activeFlags: { "project-ref": true }, + activeFlags: { remote: flagRef }, requests: api.requests, exact: true, }); @@ -288,7 +294,7 @@ WalgEnabled = true const fs = yield* FileSystem.FileSystem; const cachePath = `${tempRoot.current}/supabase/.temp/linked-project.json`; expect(yield* fs.exists(cachePath)).toBe(false); - yield* backupsList({ projectRef: Option.none() }); + yield* backupsList({ remote: Option.none(), projectRef: Option.none() }); expect(api.requests.map(({ method, url }) => `${method} ${url}`)).toEqual([ "GET https://api.supabase.com/v1/projects", `GET https://api.supabase.com/v1/projects/${VALID_REF}/database/backups`, @@ -320,7 +326,7 @@ WalgEnabled = true yield* fs.makeDirectory(tempDir, { recursive: true }); yield* fs.writeFileString(path.join(tempDir, "project-ref"), fileRef); - yield* backupsList({ projectRef: Option.none() }); + yield* backupsList({ remote: Option.none(), projectRef: Option.none() }); expect(api.requests[0]?.url).toContain(`/v1/projects/${fileRef}/`); }).pipe(Effect.provide(layer)); }); @@ -336,7 +342,9 @@ WalgEnabled = true return Effect.gen(function* () { const exit = yield* Effect.exit( - backupsList({ projectRef: Option.none() }).pipe(Effect.provide(layer)), + backupsList({ remote: Option.none(), projectRef: Option.none() }).pipe( + Effect.provide(layer), + ), ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { @@ -346,9 +354,11 @@ WalgEnabled = true }); it.effect("fails with InvalidProjectRefError when the resolved ref is malformed", () => { - const { layer } = setup({ response: PITR_RESPONSE }); + const { layer } = setup({ response: PITR_RESPONSE, projectId: "BADREF" }); return Effect.gen(function* () { - const exit = yield* Effect.exit(backupsList({ projectRef: Option.some("BADREF") })); + const exit = yield* Effect.exit( + backupsList({ remote: Option.none(), projectRef: Option.none() }), + ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { expect(Cause.pretty(exit.cause)).toContain("InvalidProjectRefError"); @@ -359,7 +369,9 @@ WalgEnabled = true it.effect("fails with BackupListUnexpectedStatusError on HTTP 503", () => { const { layer } = setup({ status: 503, response: PITR_RESPONSE }); return Effect.gen(function* () { - const exit = yield* Effect.exit(backupsList({ projectRef: Option.none() })); + const exit = yield* Effect.exit( + backupsList({ remote: Option.none(), projectRef: Option.none() }), + ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { const causeText = Cause.pretty(exit.cause); @@ -372,7 +384,9 @@ WalgEnabled = true it.effect("fails with BackupListNetworkError on transport failure", () => { const { layer } = setup({ network: "fail", response: PITR_RESPONSE }); return Effect.gen(function* () { - const exit = yield* Effect.exit(backupsList({ projectRef: Option.none() })); + const exit = yield* Effect.exit( + backupsList({ remote: Option.none(), projectRef: Option.none() }), + ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { const causeText = Cause.pretty(exit.cause); @@ -385,7 +399,9 @@ WalgEnabled = true it.effect("emits a fail event when withJsonErrorHandling wraps a JSON-mode error", () => { const { layer, out } = setup({ format: "json", status: 503, response: PITR_RESPONSE }); return Effect.gen(function* () { - yield* backupsList({ projectRef: Option.none() }).pipe(withJsonErrorHandling); + yield* backupsList({ remote: Option.none(), projectRef: Option.none() }).pipe( + withJsonErrorHandling, + ); expect(out.messages.some((m) => m.type === "fail")).toBe(true); }).pipe(Effect.provide(layer)); }); @@ -396,7 +412,7 @@ WalgEnabled = true userAgent: "SupabaseCLI/1.42.0", }); return Effect.gen(function* () { - yield* backupsList({ projectRef: Option.none() }); + yield* backupsList({ remote: Option.none(), projectRef: Option.none() }); const headers = api.requests[0]?.headers; expect(headers?.["user-agent"]).toBe("SupabaseCLI/1.42.0"); expect(headers?.["x-supabase-command"]).toBeUndefined(); @@ -404,3 +420,8 @@ WalgEnabled = true }).pipe(Effect.provide(layer)); }); }); + +describeHostedTargetSelection("backups list", { + respond: (request) => Effect.succeed(jsonResponse(request, 200, PITR_RESPONSE)), + run: (flags) => backupsList(flags), +}); 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 cb758bcde8..adc1eb3c2a 100644 --- a/apps/cli/src/commands/backups/restore/restore.integration.test.ts +++ b/apps/cli/src/commands/backups/restore/restore.integration.test.ts @@ -13,6 +13,7 @@ import { unverifiedTargetBanner, } from "../../../../tests/helpers/command-mocks.ts"; import { describeHostedConsent } from "../../../../tests/helpers/hosted-consent.ts"; +import { describeHostedTargetSelection } from "../../../../tests/helpers/hosted-target-selection.ts"; import { assertPermissionDrift } from "../../../../tests/helpers/permission-drift.ts"; import { realLinkedProjectCache } from "../../../../tests/helpers/permission-real-layers.ts"; import { backupsRestore } from "./restore.handler.ts"; @@ -78,6 +79,7 @@ describe("backups restore integration", () => { const { layer, api } = setup(); return Effect.gen(function* () { yield* backupsRestore({ + remote: Option.none(), projectRef: Option.none(), timestamp: Option.none(), }); @@ -91,6 +93,7 @@ describe("backups restore integration", () => { const { layer, api } = setup(); return Effect.gen(function* () { yield* backupsRestore({ + remote: Option.none(), projectRef: Option.none(), timestamp: Option.some(1_707_407_047), }); @@ -108,6 +111,7 @@ describe("backups restore integration", () => { const { layer, out } = setup(); return Effect.gen(function* () { yield* backupsRestore({ + remote: Option.none(), projectRef: Option.none(), timestamp: Option.none(), }); @@ -122,6 +126,7 @@ describe("backups restore integration", () => { const { layer, out } = setup({ format: "json" }); return Effect.gen(function* () { yield* backupsRestore({ + remote: Option.none(), projectRef: Option.none(), timestamp: Option.none(), }); @@ -135,6 +140,7 @@ describe("backups restore integration", () => { const { layer, out } = setup({ format: "stream-json" }); return Effect.gen(function* () { yield* backupsRestore({ + remote: Option.none(), projectRef: Option.none(), timestamp: Option.none(), }); @@ -147,6 +153,7 @@ describe("backups restore integration", () => { const { layer, out } = setup({ outputFlag: "json" }); return Effect.gen(function* () { yield* backupsRestore({ + remote: Option.none(), projectRef: Option.none(), timestamp: Option.none(), }); @@ -162,6 +169,7 @@ describe("backups restore integration", () => { const { layer, out } = setup({ outputFlag: "yaml" }); return Effect.gen(function* () { yield* backupsRestore({ + remote: Option.none(), projectRef: Option.none(), timestamp: Option.none(), }); @@ -173,18 +181,19 @@ describe("backups restore integration", () => { }, ); - it.effect("uses --project-ref flag over CommandSettings.projectId", () => { + it.effect("uses the --remote=<ref> value over CommandSettings.projectId", () => { const flagRef = "zzzzzzzzzzzzzzzzzzzz"; const { layer, api } = setup(); return Effect.gen(function* () { yield* backupsRestore({ - projectRef: Option.some(flagRef), + remote: Option.some({ _tag: "Ref", ref: flagRef }), + projectRef: Option.none(), timestamp: Option.none(), }); expect(api.requests[0]?.url).toContain(`/v1/projects/${flagRef}/`); assertPermissionDrift({ command: "backups restore", - activeFlags: { "project-ref": true }, + activeFlags: { remote: flagRef }, requests: api.requests, exact: true, }); @@ -195,7 +204,11 @@ describe("backups restore integration", () => { const { layer } = setup({ status: 503 }); return Effect.gen(function* () { const exit = yield* Effect.exit( - backupsRestore({ projectRef: Option.none(), timestamp: Option.none() }), + backupsRestore({ + remote: Option.none(), + projectRef: Option.none(), + timestamp: Option.none(), + }), ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { @@ -210,7 +223,11 @@ describe("backups restore integration", () => { const { layer } = setup({ network: "fail" }); return Effect.gen(function* () { const exit = yield* Effect.exit( - backupsRestore({ projectRef: Option.none(), timestamp: Option.none() }), + backupsRestore({ + remote: Option.none(), + projectRef: Option.none(), + timestamp: Option.none(), + }), ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { @@ -232,9 +249,11 @@ describe("backups restore integration", () => { return Effect.gen(function* () { const exit = yield* Effect.exit( - backupsRestore({ projectRef: Option.none(), timestamp: Option.none() }).pipe( - Effect.provide(layer), - ), + backupsRestore({ + remote: Option.none(), + projectRef: Option.none(), + timestamp: Option.none(), + }).pipe(Effect.provide(layer)), ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { @@ -283,7 +302,11 @@ describe("backups restore integration", () => { const fs = yield* FileSystem.FileSystem; const cachePath = `${tempRoot.current}/supabase/.temp/linked-project.json`; expect(yield* fs.exists(cachePath)).toBe(false); - yield* backupsRestore({ projectRef: Option.none(), timestamp: Option.none() }); + yield* backupsRestore({ + remote: Option.none(), + projectRef: Option.none(), + timestamp: Option.none(), + }); expect(out.promptSelectCalls).toHaveLength(1); expect(out.stderrText).toContain(`Started PITR restore: ${VALID_REF}\n`); expect(api.requests.map(({ method, url }) => `${method} ${url}`)).toEqual([ @@ -303,6 +326,7 @@ describe("backups restore integration", () => { const { layer, api } = setup(); return Effect.gen(function* () { yield* backupsRestore({ + remote: Option.none(), projectRef: Option.none(), timestamp: Option.some(42), }); @@ -314,6 +338,15 @@ describe("backups restore integration", () => { describeHostedConsent("backups restore", { api: { response: { status: 201, body: null } }, invoke: () => ({ - run: backupsRestore({ projectRef: Option.none(), timestamp: Option.some(1_707_407_047) }), + run: backupsRestore({ + remote: Option.none(), + projectRef: Option.none(), + timestamp: Option.some(1_707_407_047), + }), }), }); + +describeHostedTargetSelection("backups restore", { + respond: (request) => Effect.succeed(jsonResponse(request, 201, null)), + run: (flags) => backupsRestore({ ...flags, timestamp: Option.none() }), +}); diff --git a/apps/cli/src/commands/branches/branches.target.integration.test.ts b/apps/cli/src/commands/branches/branches.target.integration.test.ts new file mode 100644 index 0000000000..4296674e40 --- /dev/null +++ b/apps/cli/src/commands/branches/branches.target.integration.test.ts @@ -0,0 +1,137 @@ +import { Effect, Option } from "effect"; + +import { jsonResponse } from "../../../tests/helpers/command-mocks.ts"; +import { describeHostedTargetSelection } from "../../../tests/helpers/hosted-target-selection.ts"; +import { branchesCreate } from "./create/create.handler.ts"; +import { branchesDelete } from "./delete/delete.handler.ts"; +import { branchesDisable } from "./disable/disable.handler.ts"; +import { branchesGet } from "./get/get.handler.ts"; +import { branchesList } from "./list/list.handler.ts"; +import { branchesPause } from "./pause/pause.handler.ts"; +import { branchesUnpause } from "./unpause/unpause.handler.ts"; +import { branchesUpdate } from "./update/update.handler.ts"; + +const BRANCH_UUID = "11111111-1111-4111-8111-111111111111"; +const BRANCH_PROJECT_REF = "cccccccccccccccccccc"; + +const branchRow = (projectRef: string) => ({ + id: BRANCH_UUID, + name: "feat-x", + project_ref: projectRef, + parent_project_ref: "bbbbbbbbbbbbbbbbbbbb", + is_default: false, + persistent: false, + status: "MIGRATIONS_PASSED", + created_at: "2026-05-27T01:02:03Z", + updated_at: "2026-05-27T01:02:04Z", + with_data: false, +}); + +const BRANCH_CONFIG = { + ref: BRANCH_UUID, + postgres_version: "15", + postgres_engine: "15", + release_channel: "ga", + status: "ACTIVE_HEALTHY", + db_host: "h", + db_port: 5432, +}; + +/** A parent project with one branch, `feat-x`, whose project ref is `lookupRef`. */ +const respondWithBranch = (lookupRef: string) => (request: Parameters<typeof jsonResponse>[0]) => { + const { pathname } = new URL(request.url); + const reply = (status: number, body: unknown) => + Effect.succeed(jsonResponse(request, status, body)); + if (request.method === "GET" && /^\/v1\/projects\/[a-z]{20}\/branches\/feat-x$/.test(pathname)) { + return reply(200, branchRow(lookupRef)); + } + if (request.method === "GET" && /^\/v1\/projects\/[a-z]{20}\/branches$/.test(pathname)) { + return reply(200, [branchRow(lookupRef)]); + } + if (request.method === "POST" && /^\/v1\/projects\/[a-z]{20}\/branches$/.test(pathname)) { + return reply(201, branchRow(lookupRef)); + } + if (request.method === "GET" && pathname.startsWith("/v1/branches/")) { + return reply(200, BRANCH_CONFIG); + } + if (request.method === "DELETE" && pathname.startsWith("/v1/branches/")) { + return reply(200, { message: "ok" }); + } + if (request.method === "PATCH" && pathname.startsWith("/v1/branches/")) { + return reply(200, branchRow(BRANCH_PROJECT_REF)); + } + return reply(200, null); +}; + +describeHostedTargetSelection("branches list", { + mode: "parent", + respond: respondWithBranch(BRANCH_PROJECT_REF), + run: (flags) => branchesList(flags), +}); + +describeHostedTargetSelection("branches create", { + mode: "parent", + operands: ["feat-x"], + respond: respondWithBranch(BRANCH_PROJECT_REF), + run: (flags) => + branchesCreate({ + ...flags, + name: Option.some("feat-x"), + region: Option.none(), + size: Option.none(), + persistent: Option.none(), + withData: Option.none(), + notifyUrl: Option.none(), + gitBranch: Option.none(), + }), +}); + +describeHostedTargetSelection("branches get", { + mode: "parent", + operands: ["feat-x"], + respond: respondWithBranch(BRANCH_UUID), + run: (flags) => branchesGet({ ...flags, name: Option.some("feat-x") }), +}); + +describeHostedTargetSelection("branches update", { + mode: "parent", + operands: ["feat-x", "--persistent"], + respond: respondWithBranch(BRANCH_UUID), + run: (flags) => + branchesUpdate({ + ...flags, + branchId: Option.some("feat-x"), + name: Option.none(), + gitBranch: Option.none(), + persistent: Option.some(true), + status: Option.none(), + notifyUrl: Option.none(), + }), +}); + +describeHostedTargetSelection("branches delete", { + mode: "parent", + operands: ["feat-x"], + respond: respondWithBranch(BRANCH_UUID), + run: (flags) => branchesDelete({ ...flags, name: Option.some("feat-x") }), +}); + +describeHostedTargetSelection("branches pause", { + mode: "parent", + operands: ["feat-x"], + respond: respondWithBranch(BRANCH_PROJECT_REF), + run: (flags) => branchesPause({ ...flags, name: Option.some("feat-x") }), +}); + +describeHostedTargetSelection("branches unpause", { + mode: "parent", + operands: ["feat-x"], + respond: respondWithBranch(BRANCH_PROJECT_REF), + run: (flags) => branchesUnpause({ ...flags, name: Option.some("feat-x") }), +}); + +describeHostedTargetSelection("branches disable", { + mode: "parent", + respond: respondWithBranch(BRANCH_PROJECT_REF), + run: (flags) => branchesDisable(flags), +}); diff --git a/apps/cli/src/commands/branches/branches.target.unit.test.ts b/apps/cli/src/commands/branches/branches.target.unit.test.ts new file mode 100644 index 0000000000..b50b180df8 --- /dev/null +++ b/apps/cli/src/commands/branches/branches.target.unit.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from "@effect/vitest"; +import { Option } from "effect"; + +import { branchesSafeFlags } from "./branches.target.ts"; + +describe("branchesSafeFlags", () => { + it("reports --project-ref when it is a project ref", () => { + expect(branchesSafeFlags({ projectRef: Option.some("abcdefghijklmnopqrst") })).toEqual([ + "project-ref", + ]); + }); + + it("withholds a --project-ref value that is a branch name", () => { + expect(branchesSafeFlags({ projectRef: Option.some("feature-x") })).toEqual([]); + }); + + it("reports nothing without --project-ref", () => { + expect(branchesSafeFlags({ projectRef: Option.none() })).toEqual([]); + }); +}); 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 b72d2508d8..96b6e4ad73 100644 --- a/apps/cli/src/commands/branches/create/create.integration.test.ts +++ b/apps/cli/src/commands/branches/create/create.integration.test.ts @@ -173,6 +173,7 @@ function setupTracked(opts: SetupOpts = {}) { const baseFlags: BranchesCreateFlags = { name: Option.none(), + remote: Option.none(), projectRef: Option.none(), region: Option.none(), size: Option.none(), 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 f5e9a1e525..fa39e8661a 100644 --- a/apps/cli/src/commands/branches/delete/delete.integration.test.ts +++ b/apps/cli/src/commands/branches/delete/delete.integration.test.ts @@ -139,6 +139,7 @@ function setupTracked(opts: SetupOpts = {}) { const baseFlags: BranchesDeleteFlags = { name: Option.none(), + remote: Option.none(), projectRef: Option.none(), }; 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 ae2880e073..28540ea442 100644 --- a/apps/cli/src/commands/branches/disable/disable.integration.test.ts +++ b/apps/cli/src/commands/branches/disable/disable.integration.test.ts @@ -82,6 +82,7 @@ function setupTracked(opts: SetupOpts = {}) { } const baseFlags: BranchesDisableFlags = { + remote: Option.none(), projectRef: Option.none(), }; diff --git a/apps/cli/src/commands/branches/get/get.integration.test.ts b/apps/cli/src/commands/branches/get/get.integration.test.ts index 930fa9a1da..822a9e0d38 100644 --- a/apps/cli/src/commands/branches/get/get.integration.test.ts +++ b/apps/cli/src/commands/branches/get/get.integration.test.ts @@ -202,6 +202,7 @@ function setup(opts: SetupOpts = {}) { const baseFlags: BranchesGetFlags = { name: Option.none(), + remote: Option.none(), projectRef: Option.none(), }; 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 568b5b2cc1..ff1dc09a83 100644 --- a/apps/cli/src/commands/branches/list/list.integration.test.ts +++ b/apps/cli/src/commands/branches/list/list.integration.test.ts @@ -144,7 +144,7 @@ describe("branches list integration", () => { it.effect("renders a Glamour table with all 8 columns in text mode", () => { const { layer, out } = setup({ response: [SAMPLE_BRANCH] }); return Effect.gen(function* () { - yield* branchesList({ projectRef: Option.none() }); + yield* branchesList({ remote: Option.none(), projectRef: Option.none() }); expect(out.stdoutText).toContain("ID"); expect(out.stdoutText).toContain("NAME"); expect(out.stdoutText).toContain("DEFAULT"); @@ -161,7 +161,7 @@ describe("branches list integration", () => { it.effect("renders literal `|` characters in branch fields", () => { const { layer, out } = setup({ response: [SAMPLE_BRANCH_PIPE] }); return Effect.gen(function* () { - yield* branchesList({ projectRef: Option.none() }); + yield* branchesList({ remote: Option.none(), projectRef: Option.none() }); expect(out.stdoutText).toContain("with|pipe"); expect(out.stdoutText).toContain("g|pipe"); }).pipe(Effect.provide(layer)); @@ -170,7 +170,7 @@ describe("branches list integration", () => { it.effect("renders an empty table when API returns []", () => { const { layer, out } = setup({ response: [] }); return Effect.gen(function* () { - yield* branchesList({ projectRef: Option.none() }); + yield* branchesList({ remote: Option.none(), projectRef: Option.none() }); expect(out.stdoutText).toContain("STATUS"); expect(out.stdoutText).not.toContain("feat-1"); }).pipe(Effect.provide(layer)); @@ -179,7 +179,7 @@ describe("branches list integration", () => { it.effect("emits a success event with { branches } for --output-format=json", () => { const { layer, out } = setup({ format: "json", response: [SAMPLE_BRANCH] }); return Effect.gen(function* () { - yield* branchesList({ projectRef: Option.none() }); + yield* branchesList({ remote: Option.none(), projectRef: Option.none() }); const success = out.messages.find((m) => m.type === "success"); expect(success).toBeDefined(); expect(success?.data).toMatchObject({ branches: [SAMPLE_BRANCH] }); @@ -189,7 +189,7 @@ describe("branches list integration", () => { it.effect("emits a success event for --output-format=stream-json", () => { const { layer, out } = setup({ format: "stream-json", response: [SAMPLE_BRANCH] }); return Effect.gen(function* () { - yield* branchesList({ projectRef: Option.none() }); + yield* branchesList({ remote: Option.none(), projectRef: Option.none() }); expect(out.messages.find((m) => m.type === "success")).toBeDefined(); }).pipe(Effect.provide(layer)); }); @@ -197,7 +197,7 @@ describe("branches list integration", () => { it.effect("emits byte-exact indented JSON for --output json", () => { const { layer, out } = setup({ outputFlag: "json", response: [SAMPLE_BRANCH] }); return Effect.gen(function* () { - yield* branchesList({ projectRef: Option.none() }); + yield* branchesList({ remote: Option.none(), projectRef: Option.none() }); expect(out.stdoutText.startsWith("[\n {\n")).toBe(true); expect(out.stdoutText.endsWith("]\n")).toBe(true); expect(out.stdoutText).toContain('"created_at": "2026-05-27T01:02:03Z"'); @@ -220,7 +220,7 @@ describe("branches list integration", () => { }; const { layer, out } = setup({ outputFlag: "yaml", response: [SAMPLE_BRANCH, zeroBranch] }); return Effect.gen(function* () { - yield* branchesList({ projectRef: Option.none() }); + yield* branchesList({ remote: Option.none(), projectRef: Option.none() }); expect(out.stdoutText).toBe(`- createdat: 2026-05-27T01:02:03Z deletionscheduledat: null gitbranch: feat-1 @@ -262,7 +262,7 @@ describe("branches list integration", () => { it.effect("emits nothing for --output toml when the branch list is empty", () => { const { layer, out } = setup({ outputFlag: "toml", response: [] }); return Effect.gen(function* () { - yield* branchesList({ projectRef: Option.none() }); + yield* branchesList({ remote: Option.none(), projectRef: Option.none() }); expect(out.stdoutText).toBe(""); }).pipe(Effect.provide(layer)); }); @@ -270,7 +270,7 @@ describe("branches list integration", () => { it.effect("wraps result as { branches = [...] } for --output toml", () => { const { layer, out } = setup({ outputFlag: "toml", response: [SAMPLE_BRANCH] }); return Effect.gen(function* () { - yield* branchesList({ projectRef: Option.none() }); + yield* branchesList({ remote: Option.none(), projectRef: Option.none() }); expect(out.stdoutText).toBe(`[[branches]] CreatedAt = 2026-05-27T01:02:03Z GitBranch = "feat-1" @@ -290,7 +290,9 @@ describe("branches list integration", () => { it.effect("fails with BranchesEnvNotSupportedError for --output env", () => { const { layer } = setup({ outputFlag: "env", response: [SAMPLE_BRANCH] }); return Effect.gen(function* () { - const exit = yield* Effect.exit(branchesList({ projectRef: Option.none() })); + const exit = yield* Effect.exit( + branchesList({ remote: Option.none(), projectRef: Option.none() }), + ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { const causeText = Cause.pretty(exit.cause); @@ -303,7 +305,7 @@ describe("branches list integration", () => { it.effect("treats --output pretty as identical to text mode (table render)", () => { const { layer, out } = setup({ outputFlag: "pretty", response: [SAMPLE_BRANCH] }); return Effect.gen(function* () { - yield* branchesList({ projectRef: Option.none() }); + yield* branchesList({ remote: Option.none(), projectRef: Option.none() }); expect(out.stdoutText).toContain("STATUS"); }).pipe(Effect.provide(layer)); }); @@ -315,7 +317,7 @@ describe("branches list integration", () => { response: [SAMPLE_BRANCH], }); return Effect.gen(function* () { - yield* branchesList({ projectRef: Option.none() }); + yield* branchesList({ remote: Option.none(), projectRef: Option.none() }); expect(out.stdoutText).toContain("name: feat-1"); }).pipe(Effect.provide(layer)); }); @@ -323,7 +325,7 @@ describe("branches list integration", () => { it.effect("passes the resolved project ref to listAllBranches", () => { const { layer, api } = setup({ response: [SAMPLE_BRANCH] }); return Effect.gen(function* () { - yield* branchesList({ projectRef: Option.none() }); + yield* branchesList({ remote: Option.none(), projectRef: Option.none() }); expect(api.requests).toHaveLength(1); expect(api.requests[0]?.url).toContain(`/v1/projects/${VALID_REF}/branches`); assertPermissionDrift({ command: "branches list", requests: api.requests, exact: true }); @@ -363,7 +365,7 @@ describe("branches list integration", () => { const fs = yield* FileSystem.FileSystem; const cachePath = `${tempRoot.current}/supabase/.temp/linked-project.json`; expect(yield* fs.exists(cachePath)).toBe(false); - yield* branchesList({ projectRef: Option.none() }); + yield* branchesList({ remote: Option.none(), projectRef: Option.none() }); expect(api.requests.map(({ method, url }) => `${method} ${url}`)).toEqual([ "GET https://api.supabase.com/v1/projects", `GET https://api.supabase.com/v1/projects/${VALID_REF}/branches`, @@ -389,7 +391,7 @@ describe("branches list integration", () => { return Effect.gen(function* () { yield* writeProjectRefFile(workdir, BRANCH_OWN_REF); yield* writeLinkedProjectCacheFile(workdir, PARENT_REF); - yield* branchesList({ projectRef: Option.none() }); + yield* branchesList({ remote: Option.none(), projectRef: Option.none() }); expect(api.requests).toHaveLength(1); expect(api.requests[0]?.url).toContain(`/v1/projects/${PARENT_REF}/branches`); expect(out.stdoutText).toContain("STATUS"); @@ -407,7 +409,7 @@ describe("branches list integration", () => { return Effect.gen(function* () { yield* writeProjectRefFile(workdir, BRANCH_OWN_REF); yield* writeLinkedProjectCacheFile(workdir, PARENT_REF); - yield* branchesList({ projectRef: Option.none() }); + yield* branchesList({ remote: Option.none(), projectRef: Option.none() }); expect(out.stderrText).toBe( `Target: project Parent Project (${PARENT_REF}) · production\n`, ); @@ -415,7 +417,7 @@ describe("branches list integration", () => { }); it.effect( - "an explicit --project-ref still wins over both the cache and the project-ref file", + "an explicit --remote=<ref> still wins over both the cache and the project-ref file", () => { const { layer, api, workdir } = setup({ projectId: Option.none(), @@ -424,11 +426,14 @@ describe("branches list integration", () => { return Effect.gen(function* () { yield* writeProjectRefFile(workdir, BRANCH_OWN_REF); yield* writeLinkedProjectCacheFile(workdir, PARENT_REF); - yield* branchesList({ projectRef: Option.some(EXPLICIT_REF) }); + yield* branchesList({ + remote: Option.some({ _tag: "Ref", ref: EXPLICIT_REF }), + projectRef: Option.none(), + }); expect(api.requests[0]?.url).toContain(`/v1/projects/${EXPLICIT_REF}/branches`); assertPermissionDrift({ command: "branches list", - activeFlags: { "project-ref": true }, + activeFlags: { remote: EXPLICIT_REF }, requests: api.requests, exact: true, }); @@ -437,7 +442,7 @@ describe("branches list integration", () => { ); it.effect( - "an empty --project-ref resolves the linked parent from the project-ref file without the picker", + "a bare --remote resolves the linked parent from the project-ref file without the picker", () => { const api = mockCommandPlatformApi({ response: { status: 200, body: [SAMPLE_BRANCH] }, @@ -451,14 +456,17 @@ describe("branches list integration", () => { }); return Effect.gen(function* () { yield* writeProjectRefFile(tempRoot.current, FILE_ONLY_REF); - yield* branchesList({ projectRef: Option.some("") }); + yield* branchesList({ + remote: Option.some({ _tag: "Linked" }), + projectRef: Option.none(), + }); expect(api.requests.map(({ method, url }) => `${method} ${url}`)).toEqual([ `GET https://api.supabase.com/v1/projects/${FILE_ONLY_REF}/branches`, ]); expect(out.promptSelectCalls).toHaveLength(0); assertPermissionDrift({ command: "branches list", - activeFlags: { "project-ref": "" }, + activeFlags: { remote: true }, requests: api.requests, exact: true, }); @@ -476,7 +484,7 @@ describe("branches list integration", () => { return Effect.gen(function* () { yield* writeProjectRefFile(workdir, BRANCH_OWN_REF); yield* writeLinkedProjectCacheFile(workdir, CACHE_REF); - yield* branchesList({ projectRef: Option.none() }); + yield* branchesList({ remote: Option.none(), projectRef: Option.none() }); expect(api.requests[0]?.url).toContain(`/v1/projects/${ENV_REF}/branches`); }).pipe(Effect.provide(layer)); }, @@ -492,7 +500,7 @@ describe("branches list integration", () => { return Effect.gen(function* () { yield* writeProjectRefFile(workdir, BRANCH_OWN_REF); yield* writeLinkedProjectCacheFile(workdir, PARENT_REF); - yield* branchesList({ projectRef: Option.none() }); + yield* branchesList({ remote: Option.none(), projectRef: Option.none() }); expect(api.requests[0]?.url).toContain(`/v1/projects/${PARENT_REF}/branches`); }).pipe(Effect.provide(layer)); }, @@ -508,7 +516,9 @@ describe("branches list integration", () => { return Effect.gen(function* () { yield* writeProjectRefFile(workdir, FILE_ONLY_REF); yield* writeLinkedProjectCacheFile(workdir, CACHE_REF); - const exit = yield* Effect.exit(branchesList({ projectRef: Option.none() })); + const exit = yield* Effect.exit( + branchesList({ remote: Option.none(), projectRef: Option.none() }), + ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { expect(Cause.pretty(exit.cause)).toContain("InvalidProjectRefError"); @@ -523,7 +533,9 @@ describe("branches list integration", () => { () => { const { layer, api } = setup({ projectId: Option.some("not-a-valid-ref") }); return Effect.gen(function* () { - const exit = yield* Effect.exit(branchesList({ projectRef: Option.none() })); + const exit = yield* Effect.exit( + branchesList({ remote: Option.none(), projectRef: Option.none() }), + ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { expect(Cause.pretty(exit.cause)).toContain("InvalidProjectRefError"); @@ -536,7 +548,9 @@ describe("branches list integration", () => { it.effect("nothing linked anywhere, non-TTY, fails with the unchanged not-linked error", () => { const { layer, api } = setup({ projectId: Option.none() }); return Effect.gen(function* () { - const exit = yield* Effect.exit(branchesList({ projectRef: Option.none() })); + const exit = yield* Effect.exit( + branchesList({ remote: Option.none(), projectRef: Option.none() }), + ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { expect(Cause.pretty(exit.cause)).toContain("ProjectRefNotLinkedError"); @@ -554,7 +568,9 @@ describe("branches list integration", () => { }); return Effect.gen(function* () { yield* writeLinkedProjectCacheFile(workdir, PARENT_REF); - const exit = yield* Effect.exit(branchesList({ projectRef: Option.none() })); + const exit = yield* Effect.exit( + branchesList({ remote: Option.none(), projectRef: Option.none() }), + ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { expect(Cause.pretty(exit.cause)).toContain("ProjectRefNotLinkedError"); @@ -573,7 +589,7 @@ describe("branches list integration", () => { }); return Effect.gen(function* () { yield* writeProjectRefFile(workdir, FILE_ONLY_REF); - yield* branchesList({ projectRef: Option.none() }); + yield* branchesList({ remote: Option.none(), projectRef: Option.none() }); expect(api.requests[0]?.url).toContain(`/v1/projects/${FILE_ONLY_REF}/branches`); }).pipe(Effect.provide(layer)); }, @@ -595,7 +611,7 @@ describe("branches list integration", () => { }); return Effect.gen(function* () { yield* writeProjectRefFile(workdir, SAMPLE_BRANCH.project_ref); - yield* branchesList({ projectRef: Option.none() }); + yield* branchesList({ remote: Option.none(), projectRef: Option.none() }); expect(out.stdoutText).toContain("feat-1 (active)"); expect(out.stdoutText).not.toContain("other (active)"); }).pipe(Effect.provide(layer)); @@ -611,7 +627,7 @@ describe("branches list integration", () => { }); return Effect.gen(function* () { yield* writeProjectRefFile(workdir, SAMPLE_BRANCH.project_ref); - yield* branchesList({ projectRef: Option.none() }); + yield* branchesList({ remote: Option.none(), projectRef: Option.none() }); expect(out.stdoutText).not.toContain("active"); }).pipe(Effect.provide(layer)); }, @@ -627,7 +643,7 @@ describe("branches list integration", () => { }); return Effect.gen(function* () { yield* writeProjectRefFile(workdir, SAMPLE_BRANCH.project_ref); - yield* branchesList({ projectRef: Option.none() }); + yield* branchesList({ remote: Option.none(), projectRef: Option.none() }); const success = out.messages.find((m) => m.type === "success"); expect(success?.data).toEqual({ branches: [SAMPLE_BRANCH] }); }).pipe(Effect.provide(layer)); @@ -640,7 +656,7 @@ describe("branches list integration", () => { response: [SAMPLE_BRANCH], }); return Effect.gen(function* () { - yield* branchesList({ projectRef: Option.none() }); + yield* branchesList({ remote: Option.none(), projectRef: Option.none() }); expect(out.stdoutText).not.toContain("(active)"); }).pipe(Effect.provide(layer)); }); @@ -649,7 +665,9 @@ describe("branches list integration", () => { it.effect("fails with BranchesListUnexpectedStatusError on HTTP 503", () => { const { layer } = setup({ status: 503, response: [] }); return Effect.gen(function* () { - const exit = yield* Effect.exit(branchesList({ projectRef: Option.none() })); + const exit = yield* Effect.exit( + branchesList({ remote: Option.none(), projectRef: Option.none() }), + ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { const causeText = Cause.pretty(exit.cause); @@ -662,7 +680,9 @@ describe("branches list integration", () => { it.effect("fails with BranchesListNetworkError on transport failure", () => { const { layer } = setup({ network: "fail" }); return Effect.gen(function* () { - const exit = yield* Effect.exit(branchesList({ projectRef: Option.none() })); + const exit = yield* Effect.exit( + branchesList({ remote: Option.none(), projectRef: Option.none() }), + ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { const causeText = Cause.pretty(exit.cause); @@ -675,7 +695,7 @@ describe("branches list integration", () => { it.effect("writes linked-project cache + telemetry state on success", () => { const { layer, telemetry, cache } = setupTracked(); return Effect.gen(function* () { - yield* branchesList({ projectRef: Option.none() }); + yield* branchesList({ remote: Option.none(), projectRef: Option.none() }); expect(telemetry.flushed).toBe(true); expect(cache.cached).toBe(true); }).pipe(Effect.provide(layer)); @@ -684,7 +704,7 @@ describe("branches list integration", () => { it.effect("writes linked-project cache + telemetry state on failure", () => { const { layer, telemetry, cache } = setupTracked({ status: 503 }); return Effect.gen(function* () { - yield* Effect.exit(branchesList({ projectRef: Option.none() })); + yield* Effect.exit(branchesList({ remote: Option.none(), projectRef: Option.none() })); expect(telemetry.flushed).toBe(true); expect(cache.cached).toBe(true); }).pipe(Effect.provide(layer)); 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 224f2b659e..90256bfc9f 100644 --- a/apps/cli/src/commands/branches/pause/pause.integration.test.ts +++ b/apps/cli/src/commands/branches/pause/pause.integration.test.ts @@ -168,6 +168,7 @@ function setupTracked(opts: SetupOpts = {}) { const baseFlags: BranchesPauseFlags = { name: Option.none(), + remote: Option.none(), projectRef: Option.none(), }; 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 bdab2bcd95..61e0f8d9ab 100644 --- a/apps/cli/src/commands/branches/unpause/unpause.integration.test.ts +++ b/apps/cli/src/commands/branches/unpause/unpause.integration.test.ts @@ -95,6 +95,7 @@ function setup(opts: SetupOpts = {}) { const baseFlags: BranchesUnpauseFlags = { name: Option.none(), + remote: Option.none(), projectRef: Option.none(), }; 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 bde425372e..3dabc2fecc 100644 --- a/apps/cli/src/commands/branches/update/update.integration.test.ts +++ b/apps/cli/src/commands/branches/update/update.integration.test.ts @@ -180,6 +180,7 @@ function setupTracked(opts: SetupOpts = {}) { const baseFlags: BranchesUpdateFlags = { branchId: Option.none(), + remote: Option.none(), projectRef: Option.none(), name: Option.none(), gitBranch: Option.none(), 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 7f2e136468..1b2220cea6 100644 --- a/apps/cli/src/commands/config/diff/diff.integration.test.ts +++ b/apps/cli/src/commands/config/diff/diff.integration.test.ts @@ -29,6 +29,7 @@ import { assertPermissionDrift } from "../../../../tests/helpers/permission-drif import { realLinkedProjectCache } from "../../../../tests/helpers/permission-real-layers.ts"; import { resolveWorkdir } from "../../../config/command-settings.layer.ts"; import { GLOBAL_OUTPUT_FORMATS } from "../../../command-internal/global-flags.ts"; +import type { RemoteValue } from "../../../command-internal/target/remote-value.ts"; import { commandRuntimeLayer } from "../../../shared/runtime/command-runtime.layer.ts"; import { configDiffHandler } from "./diff.command.ts"; import { CONFIG_DIFF_PAYLOAD_VERSION } from "./diff.format.ts"; @@ -84,7 +85,7 @@ const PROJECT: (typeof V1ListAllProjectsOutput.Type)[number] = { }, }; -/** V1GetABranch body for the branch-name `--project-ref` lookup. */ +/** V1GetABranch body for the branch-name `--remote` lookup. */ const BRANCH_BY_NAME = { id: BRANCH_UUID, name: "staging", @@ -98,7 +99,7 @@ const BRANCH_BY_NAME = { with_data: false, }; -/** V1GetABranchConfig body for the UUID `--project-ref` lookup. */ +/** V1GetABranchConfig body for the UUID `--remote` lookup. */ const BRANCH_CONFIG = { ref: BRANCH_REF, postgres_version: "15", @@ -128,6 +129,8 @@ interface SetupOpts { /** cliSettings.explicitWorkdir override — true iff --workdir/SUPABASE_WORKDIR was set verbatim. */ readonly explicitWorkdir?: boolean; readonly analytics?: ReturnType<typeof mockContextualAnalytics>; + /** The argv the deprecation warnings render their replacement command from. */ + readonly cliArgs?: ReadonlyArray<string>; } function setup(opts: SetupOpts = {}) { @@ -181,6 +184,7 @@ function setup(opts: SetupOpts = {}) { processControl, outputFlag: opts.outputFlag === undefined ? Option.none() : Option.some(opts.outputFlag), ...(opts.analytics === undefined ? {} : { analytics: opts.analytics }), + ...(opts.cliArgs === undefined ? {} : { cliArgs: opts.cliArgs }), }), projectFiles, cliConfigValuesTestLayer, @@ -189,10 +193,20 @@ function setup(opts: SetupOpts = {}) { } const noFlags = { + remote: Option.none<RemoteValue>(), projectRef: Option.none<string>(), exitCode: false, }; +const remoteRef = (ref: string) => ({ + ...noFlags, + remote: Option.some<RemoteValue>({ _tag: "Ref", ref }), +}); +const remoteBranch = (name: string) => ({ + ...noFlags, + remote: Option.some<RemoteValue>({ _tag: "Branch", name }), +}); + describe("config diff integration", () => { it.effect("reports drift against the linked project without touching the config file", () => { const { layer, out, processControl, telemetry, linkedProjectCache, api } = setup({ @@ -562,13 +576,13 @@ describe("config diff integration", () => { }).pipe(Effect.provide(layer)); }); - it.effect("a branch-named --project-ref resolves via the parent project", () => { + it.effect("a branch-named --remote resolves via the parent project", () => { const { layer, out, api } = setup({ toml: 'project_id = "test"\n', v2: { status: 200, body: v2Response({ ref: BRANCH_REF }) }, }); return Effect.gen(function* () { - yield* configDiff({ ...noFlags, projectRef: Option.some("staging") }); + yield* configDiff(remoteBranch("staging")); expect(out.stderrText).toContain( `Comparing against 'staging' (branch ${BRANCH_REF}) using base config`, ); @@ -579,7 +593,7 @@ describe("config diff integration", () => { expect(urls.some((url) => url.includes(`/v2/projects/${BRANCH_REF}/config`))).toBe(true); assertPermissionDrift({ command: "config diff", - activeFlags: { "project-ref": true }, + activeFlags: { remote: true }, requests: api.requests, }); }).pipe(Effect.provide(layer)); @@ -605,7 +619,7 @@ describe("config diff integration", () => { path.join(temp, "linked-project.json"), yield* jsonText({ ref: VALID_REF }), ); - yield* configDiff({ ...noFlags, projectRef: Option.some("staging") }); + yield* configDiff(remoteBranch("staging")); const urls = api.requests.map((request) => request.url); expect(urls.some((url) => url.includes(`/v1/projects/${VALID_REF}/branches/staging`))).toBe( true, @@ -613,21 +627,21 @@ describe("config diff integration", () => { expect(urls.some((url) => url.includes(`/v1/projects/${BRANCH_REF}/`))).toBe(false); assertPermissionDrift({ command: "config diff", - activeFlags: { "project-ref": true }, + activeFlags: { remote: true }, requests: api.requests, }); }).pipe(Effect.provide(layer)); }, ); - it.effect("a UUID --project-ref resolves directly, even in an unlinked directory", () => { + it.effect("a UUID --remote resolves directly, even in an unlinked directory", () => { const { layer, api, out } = setup({ toml: 'project_id = "test"\n', v2: { status: 200, body: v2Response({ ref: BRANCH_REF }) }, linked: false, }); return Effect.gen(function* () { - yield* configDiff({ ...noFlags, projectRef: Option.some(BRANCH_UUID) }); + yield* configDiff(remoteBranch(BRANCH_UUID)); const urls = api.requests.map((request) => request.url); expect(urls.some((url) => url.includes(`/v1/branches/${BRANCH_UUID}`))).toBe(true); expect(urls.some((url) => url.includes(`/v2/projects/${BRANCH_REF}/config`))).toBe(true); @@ -637,40 +651,102 @@ describe("config diff integration", () => { ); assertPermissionDrift({ command: "config diff", - activeFlags: { "project-ref": true }, + activeFlags: { remote: true }, requests: api.requests, }); }).pipe(Effect.provide(layer)); }); - it.effect("a ref-shaped --project-ref never touches the branches API", () => { + it.effect("a ref-shaped --remote never touches the branches API", () => { const { layer, api } = setup({ toml: 'project_id = "test"\n', v2: { status: 200, body: v2Response({ ref: BRANCH_REF }) }, }); return Effect.gen(function* () { - yield* configDiff({ ...noFlags, projectRef: Option.some(BRANCH_REF) }); + yield* configDiff(remoteRef(BRANCH_REF)); const urls = api.requests.map((request) => request.url); expect(urls.some((url) => url.includes("/branches/"))).toBe(false); expect(urls.some((url) => url.includes(`/v2/projects/${BRANCH_REF}/config`))).toBe(true); assertPermissionDrift({ command: "config diff", - activeFlags: { "project-ref": true }, + activeFlags: { remote: true }, requests: api.requests, exact: true, }); }).pipe(Effect.provide(layer)); }); + it.effect("a bare --remote diffs against the linked project", () => { + const { layer, out, api } = setup({ toml: 'project_id = "test"\n' }); + return Effect.gen(function* () { + yield* configDiff({ ...noFlags, remote: Option.some<RemoteValue>({ _tag: "Linked" }) }); + expect(out.stderrText).toContain(`Comparing against project ${VALID_REF} using base config`); + expect(api.requests.some((request) => request.url.includes("/branches/"))).toBe(false); + assertPermissionDrift({ + command: "config diff", + activeFlags: { remote: true }, + requests: api.requests, + exact: true, + }); + }).pipe(Effect.provide(layer)); + }); + + it.effect("--project-ref still resolves a branch name and warns to use --remote", () => { + const { layer, out, api } = setup({ + toml: 'project_id = "test"\n', + v2: { status: 200, body: v2Response({ ref: BRANCH_REF }) }, + cliArgs: ["config", "diff", "--project-ref", "staging"], + }); + return Effect.gen(function* () { + yield* configDiff({ ...noFlags, projectRef: Option.some("staging") }); + expect( + out.messages.filter((message) => message.type === "warn").map(({ message }) => message), + ).toEqual([ + "--project-ref is deprecated and will be removed in v4. Use --remote instead: supabase config diff --remote=staging", + ]); + expect(out.stderrText).toContain( + `Comparing against 'staging' (branch ${BRANCH_REF}) using base config`, + ); + expect( + api.requests.some((request) => + request.url.includes(`/v1/projects/${VALID_REF}/branches/staging`), + ), + ).toBe(true); + }).pipe(Effect.provide(layer)); + }); + + it.effect("--remote=<url> is rejected before any request", () => { + const { layer, api } = setup({ toml: 'project_id = "test"\n' }); + return Effect.gen(function* () { + const error = yield* configDiff({ + ...noFlags, + remote: Option.some<RemoteValue>({ + _tag: "DbUrl", + url: "postgresql://postgres:pw@db.example.com:5432/postgres", + }), + }).pipe(Effect.flip); + expect(error).toMatchObject({ + _tag: "TargetNeedsProjectError", + message: "`supabase config diff` needs a project, not a database URL.", + }); + expect(api.requests).toEqual([]); + }).pipe( + Effect.provide( + Layer.mergeAll( + layer, + commandRuntimeLayer(["config", "diff"]).pipe(Layer.provide(BunServices.layer)), + ), + ), + ); + }); + it.effect("an unknown branch fails with a branches-list suggestion", () => { const { layer, telemetry, linkedProjectCache } = setup({ toml: 'project_id = "test"\n', branchByName: { status: 404, body: { message: "not found" } }, }); return Effect.gen(function* () { - const exit = yield* configDiff({ ...noFlags, projectRef: Option.some("ghost") }).pipe( - Effect.exit, - ); + const exit = yield* configDiff(remoteBranch("ghost")).pipe(Effect.exit); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { const causeText = Cause.pretty(exit.cause); @@ -691,9 +767,7 @@ describe("config diff integration", () => { branchByName: { status: 500, body: { message: "boom" } }, }); return Effect.gen(function* () { - const exit = yield* configDiff({ ...noFlags, projectRef: Option.some("staging") }).pipe( - Effect.exit, - ); + const exit = yield* configDiff(remoteBranch("staging")).pipe(Effect.exit); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { expect(Cause.pretty(exit.cause)).toContain("ConfigDiffBranchResolveStatusError"); @@ -812,9 +886,7 @@ describe("config diff integration", () => { const { layer, api, telemetry } = setup({ toml: "not [valid toml\n" }); return Effect.gen(function* () { const path = yield* Path.Path; - const exit = yield* configDiff({ ...noFlags, projectRef: Option.some("staging") }).pipe( - Effect.exit, - ); + const exit = yield* configDiff(remoteBranch("staging")).pipe(Effect.exit); expect(Exit.isFailure(exit)).toBe(true); // The message names the actual file that failed to parse, workdir-relative regardless of // invocation cwd. @@ -1093,7 +1165,7 @@ describe("config diff integration", () => { format: "json", }); return Effect.gen(function* () { - yield* configDiff({ ...noFlags, projectRef: Option.some("staging") }); + yield* configDiff(remoteBranch("staging")); const success = out.messages.find((message) => message.type === "success"); const data = yield* decodeJsonRecord(success?.data); expect(data["target"]).toEqual({ @@ -1306,41 +1378,32 @@ describe("config diff integration", () => { }).pipe(Effect.provide(layer)); }); - it.effect( - "a branch-NAME --project-ref in an unlinked dir fails immediately, naming the value", - () => { - const { layer, api, telemetry, linkedProjectCache } = setup({ - toml: 'project_id = "test"\n', - linked: false, - }); - return Effect.gen(function* () { - const exit = yield* configDiff({ - ...noFlags, - projectRef: Option.some("somebranch"), - }).pipe(Effect.exit); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - const causeText = Cause.pretty(exit.cause); - expect(causeText).toContain("ConfigDiffBranchNotLinkedError"); - expect(causeText).toContain('"somebranch"'); - } - expect(api.requests).toHaveLength(0); - expect(telemetry.flushed).toBe(true); - expect(linkedProjectCache.cachedRef).toBeUndefined(); - }).pipe(Effect.provide(layer)); - }, - ); + it.effect("a branch-NAME --remote in an unlinked dir fails immediately, naming the value", () => { + const { layer, api, telemetry, linkedProjectCache } = setup({ + toml: 'project_id = "test"\n', + linked: false, + }); + return Effect.gen(function* () { + const exit = yield* configDiff(remoteBranch("somebranch")).pipe(Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const causeText = Cause.pretty(exit.cause); + expect(causeText).toContain("ConfigDiffBranchNotLinkedError"); + expect(causeText).toContain('"somebranch"'); + } + expect(api.requests).toHaveLength(0); + expect(telemetry.flushed).toBe(true); + expect(linkedProjectCache.cachedRef).toBeUndefined(); + }).pipe(Effect.provide(layer)); + }); - it.effect("a branch-NAME --project-ref with a corrupt linked ref reports it as invalid", () => { + it.effect("a branch-NAME --remote with a corrupt linked ref reports it as invalid", () => { const { layer, api } = setup({ toml: 'project_id = "test"\n', projectId: Option.some("not-a-valid-ref"), }); return Effect.gen(function* () { - const exit = yield* configDiff({ - ...noFlags, - projectRef: Option.some("somebranch"), - }).pipe(Effect.exit); + const exit = yield* configDiff(remoteBranch("somebranch")).pipe(Effect.exit); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { const causeText = Cause.pretty(exit.cause); @@ -1358,10 +1421,7 @@ describe("config diff integration", () => { branchByName: { status: 200, body: { ...BRANCH_BY_NAME, project_ref: "" } }, }); return Effect.gen(function* () { - const exit = yield* configDiff({ - ...noFlags, - projectRef: Option.some("staging"), - }).pipe(Effect.exit); + const exit = yield* configDiff(remoteBranch("staging")).pipe(Effect.exit); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { const causeText = Cause.pretty(exit.cause); @@ -1434,37 +1494,50 @@ describe("config diff integration", () => { describe("config diff telemetry wiring", () => { // Uses configDiffHandler (not the bare handler) since the safeFlags guard lives in the command // wiring, not the handler itself. - const wiringLayer = (analytics: ReturnType<typeof mockContextualAnalytics>, projectRef: string) => + const wiringLayer = ( + analytics: ReturnType<typeof mockContextualAnalytics>, + args: ReadonlyArray<string>, + ) => Layer.mergeAll( setup({ toml: 'project_id = "test"\n', analytics }).layer, commandRuntimeLayer(["config", "diff"]).pipe(Layer.provide(BunServices.layer)), - Stdio.layerTest({ - args: Effect.succeed(["config", "diff", "--project-ref", projectRef]), - }), + Stdio.layerTest({ args: Effect.succeed(["config", "diff", ...args]) }), ); + const flagsOf = (analytics: ReturnType<typeof mockContextualAnalytics>) => + analytics.captured.find((c) => c.event === "cli_command_executed")?.properties["flags"]; + + it.effect("logs a ref-shaped --remote verbatim in cli_command_executed", () => { + const analytics = mockContextualAnalytics(); + return Effect.gen(function* () { + yield* Effect.exit(configDiffHandler(remoteRef(VALID_REF))); + expect(flagsOf(analytics)).toEqual({ remote: VALID_REF }); + }).pipe(Effect.provide(wiringLayer(analytics, [`--remote=${VALID_REF}`]))); + }); + + it.effect("redacts a branch-name --remote", () => { + const analytics = mockContextualAnalytics(); + return Effect.gen(function* () { + yield* Effect.exit(configDiffHandler(remoteBranch("staging"))); + expect(flagsOf(analytics)).toEqual({ remote: "<redacted>" }); + }).pipe(Effect.provide(wiringLayer(analytics, ["--remote=staging"]))); + }); + it.effect("logs a ref-shaped --project-ref verbatim in cli_command_executed", () => { const analytics = mockContextualAnalytics(); return Effect.gen(function* () { - yield* Effect.exit( - configDiffHandler({ projectRef: Option.some(VALID_REF), exitCode: false }), - ); - const event = analytics.captured.find((c) => c.event === "cli_command_executed"); - expect(event?.properties["flags"]).toEqual({ "project-ref": VALID_REF }); - }).pipe(Effect.provide(wiringLayer(analytics, VALID_REF))); + yield* Effect.exit(configDiffHandler({ ...noFlags, projectRef: Option.some(VALID_REF) })); + expect(flagsOf(analytics)).toEqual({ "project-ref": VALID_REF }); + }).pipe(Effect.provide(wiringLayer(analytics, ["--project-ref", VALID_REF]))); }); it.effect("redacts a branch-name-shaped --project-ref", () => { - // --project-ref also accepts branch names; a user-created name must never reach PostHog - // verbatim. + // A user-created branch name must never reach PostHog verbatim. const analytics = mockContextualAnalytics(); return Effect.gen(function* () { - yield* Effect.exit( - configDiffHandler({ projectRef: Option.some("staging"), exitCode: false }), - ); - const event = analytics.captured.find((c) => c.event === "cli_command_executed"); - expect(event?.properties["flags"]).toEqual({ "project-ref": "<redacted>" }); - }).pipe(Effect.provide(wiringLayer(analytics, "staging"))); + yield* Effect.exit(configDiffHandler({ ...noFlags, projectRef: Option.some("staging") })); + expect(flagsOf(analytics)).toEqual({ "project-ref": "<redacted>" }); + }).pipe(Effect.provide(wiringLayer(analytics, ["--project-ref", "staging"]))); }); }); 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 0648e4369b..e822a6032c 100644 --- a/apps/cli/src/commands/config/pull/pull.integration.test.ts +++ b/apps/cli/src/commands/config/pull/pull.integration.test.ts @@ -46,6 +46,8 @@ import { import { realLinkedProjectCache } from "../../../../tests/helpers/permission-real-layers.ts"; import { configPullRun } from "../../../command-internal/config-pull-run.ts"; import { GLOBAL_OUTPUT_FORMATS, YesFlag } from "../../../command-internal/global-flags.ts"; +import type { RemoteValue } from "../../../command-internal/target/remote-value.ts"; +import { commandRuntimeLayer } from "../../../shared/runtime/command-runtime.layer.ts"; import { Output } from "../../../shared/output/output.service.ts"; import { configPull, @@ -255,7 +257,7 @@ function v2Response( }; } -/** V1FindBranch body for the branch-name `--project-ref` lookup. */ +/** V1FindBranch body for the branch-name `--remote` lookup. */ const BRANCH_BY_NAME = { id: "11111111-1111-4111-8111-111111111111", name: "staging", @@ -269,7 +271,7 @@ const BRANCH_BY_NAME = { with_data: false, }; -/** V1GetABranchConfig body for the UUID `--project-ref` lookup. */ +/** V1GetABranchConfig body for the UUID `--remote` lookup. */ const BRANCH_CONFIG = { ref: BRANCH_REF, postgres_version: "15", @@ -377,6 +379,8 @@ interface SetupOpts { /** Serves the project list and project lookup, answers the project picker with `VALID_REF`, and * writes the linked-project cache through the real cache layer. */ readonly pickerAndRealCache?: boolean; + /** The argv the deprecation warnings render their replacement command from. */ + readonly cliArgs?: ReadonlyArray<string>; readonly yes?: boolean; readonly stdinIsTty?: boolean; readonly confirm?: ReadonlyArray<boolean>; @@ -479,6 +483,7 @@ function setup(opts: SetupOpts = {}) { processControl, tty: mockTty({ stdinIsTty: opts.stdinIsTty ?? false, stdoutIsTty: false }), outputFlag: opts.outputFlag === undefined ? Option.none() : Option.some(opts.outputFlag), + ...(opts.cliArgs === undefined ? {} : { cliArgs: opts.cliArgs }), }), mockStdin(opts.stdinIsTty ?? false), Layer.succeed(YesFlag, opts.yes ?? false), @@ -492,12 +497,16 @@ function setup(opts: SetupOpts = {}) { } const noFlags: ConfigPullFlags = { + remote: Option.none<RemoteValue>(), projectRef: Option.none<string>(), remoteLabel: Option.none<string>(), dryRun: false, force: false, }; +const branchRemote = (name: string) => Option.some<RemoteValue>({ _tag: "Branch", name }); +const refRemote = (ref: string) => Option.some<RemoteValue>({ _tag: "Ref", ref }); + /** Number of lines that differ between two texts of equal line count. */ function countChangedLines(before: string, after: string): number { const b = before.split("\n"); @@ -625,7 +634,7 @@ describe("config pull integration", () => { }, }); return Effect.gen(function* () { - yield* configPull({ ...noFlags, projectRef: Option.some("staging") }); + yield* configPull({ ...noFlags, remote: branchRemote("staging") }); const after = yield* readConfig; expect(after.startsWith(before)).toBe(true); const remotesIndex = after.indexOf("[remotes.staging]"); @@ -884,7 +893,7 @@ describe("config pull integration", () => { ].join("\n"); const { layer, out } = setup({ toml: before, yes: true }); return Effect.gen(function* () { - yield* configPull({ ...noFlags, projectRef: Option.some("staging") }); + yield* configPull({ ...noFlags, remote: branchRemote("staging") }); expect(out.stderrText).toContain("→ [remotes.prod]"); expect(out.stderrText).not.toContain("[remotes.staging]"); const after = yield* readConfig; @@ -910,7 +919,7 @@ describe("config pull integration", () => { return Effect.gen(function* () { yield* configPull({ ...noFlags, - projectRef: Option.some("staging"), + remote: branchRemote("staging"), remoteLabel: Option.some("prod"), }); expect(out.stderrText).toContain("→ [remotes.prod]"); @@ -933,7 +942,7 @@ describe("config pull integration", () => { const { layer, out } = setup({ toml: before, stdinIsTty: true, confirm: [true] }); return Effect.gen(function* () { const path = yield* Path.Path; - yield* configPull({ ...noFlags, projectRef: Option.some("staging") }); + yield* configPull({ ...noFlags, remote: branchRemote("staging") }); expect(out.promptConfirmCalls[0]?.message).toBe( `Apply 1 change(s) to ${path.join("supabase", "config.toml")} [remotes.prod]?`, ); @@ -1065,7 +1074,7 @@ describe("config pull integration", () => { return Effect.gen(function* () { const exit = yield* configPull({ ...noFlags, - projectRef: Option.some("staging"), + remote: branchRemote("staging"), }).pipe(Effect.exit); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { @@ -1385,7 +1394,7 @@ describe("config pull integration", () => { }, }); return Effect.gen(function* () { - yield* configPull({ ...noFlags, projectRef: Option.some("staging") }); + yield* configPull({ ...noFlags, remote: branchRemote("staging") }); expect(out.stdoutText).not.toContain("Warnings:"); expect(out.stdoutText).not.toContain("also configures the local stack"); expect(yield* readConfig).toContain('site_url = "https://staging.example.com"'); @@ -1413,7 +1422,7 @@ describe("config pull integration", () => { ].join("\n"); const { layer, out } = setup({ toml: before, yes: true }); return Effect.gen(function* () { - yield* configPull({ ...noFlags, projectRef: Option.some("staging") }); + yield* configPull({ ...noFlags, remote: branchRemote("staging") }); expect(out.stdoutText).toContain( "api.max_rows already matches the config root's value — this remote block now carries a redundant copy.", ); @@ -1555,7 +1564,7 @@ describe("config pull integration", () => { return Effect.gen(function* () { const exit = yield* configPull({ ...noFlags, - projectRef: Option.some("staging"), + remote: branchRemote("staging"), }).pipe(Effect.exit); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { @@ -1579,7 +1588,7 @@ describe("config pull integration", () => { }); return Effect.gen(function* () { const path = yield* Path.Path; - yield* configPull({ ...noFlags, projectRef: Option.some("staging") }); + yield* configPull({ ...noFlags, remote: branchRemote("staging") }); expect(first.out.promptConfirmCalls).toHaveLength(1); expect(first.out.promptConfirmCalls[0]?.message).toContain("Create [remotes.staging] in"); expect(first.out.promptConfirmCalls[0]?.message).toContain( @@ -1605,7 +1614,7 @@ describe("config pull integration", () => { format: "json", v2: { status: 200, body: v2Response({ ref: BRANCH_REF }) }, }); - yield* configPull({ ...noFlags, projectRef: Option.some("staging") }).pipe( + yield* configPull({ ...noFlags, remote: branchRemote("staging") }).pipe( Effect.provide(second.layer), ); const success = second.out.messages.find((message) => message.type === "success"); @@ -1641,7 +1650,7 @@ describe("config pull integration", () => { return Effect.gen(function* () { yield* configPull({ ...noFlags, - projectRef: Option.some("staging"), + remote: branchRemote("staging"), dryRun: true, }); expect(yield* readConfig).toBe(before); @@ -1663,7 +1672,7 @@ describe("config pull integration", () => { v2: { status: 200, body: v2Response({ ref: BRANCH_REF }) }, }); return Effect.gen(function* () { - yield* configPull({ ...noFlags, projectRef: Option.some("staging") }); + yield* configPull({ ...noFlags, remote: branchRemote("staging") }); expect(yield* readConfig).toBe(before); expect(out.promptConfirmCalls).toHaveLength(1); expect(out.stdoutText).toContain("[remotes.staging] not created (declined)."); @@ -1747,14 +1756,71 @@ describe("config pull integration", () => { }); }); - it.effect("a branch-named target resolves via the linked parent project", () => { + it.effect("--remote=<ref> pulls from that project without a branch lookup", () => { const { layer, api, out } = setup({ + toml: 'project_id = "test"\n', + yes: true, + projectId: Option.none(), + v2: { status: 200, body: v2Response({ ref: BRANCH_REF }) }, + }); + return Effect.gen(function* () { + yield* configPull({ ...noFlags, remote: refRemote(BRANCH_REF) }); + expect(out.stderrText).toContain(`Pulling config from project ${BRANCH_REF}`); + expect(api.requests.some((request) => request.url.includes("/branches/"))).toBe(false); + }).pipe(Effect.provide(layer)); + }); + + it.effect("--project-ref still resolves a branch name and warns to use --remote", () => { + const { layer, out } = setup({ toml: 'project_id = "test"\n', yes: true, v2: { status: 200, body: v2Response({ ref: BRANCH_REF }) }, + cliArgs: ["config", "pull", "--project-ref", "staging"], }); return Effect.gen(function* () { yield* configPull({ ...noFlags, projectRef: Option.some("staging") }); + expect( + out.messages.filter((message) => message.type === "warn").map(({ message }) => message), + ).toEqual([ + "--project-ref is deprecated and will be removed in v4. Use --remote instead: supabase config pull --remote=staging", + ]); + expect(out.stderrText).toContain(`Pulling config from 'staging' (branch ${BRANCH_REF})`); + }).pipe(Effect.provide(layer)); + }); + + it.effect("--remote=<url> is rejected before any request", () => { + const { layer, api } = setup({ toml: 'project_id = "test"\n', yes: true }); + return Effect.gen(function* () { + const error = yield* configPull({ + ...noFlags, + remote: Option.some<RemoteValue>({ + _tag: "DbUrl", + url: "postgresql://postgres:pw@db.example.com:5432/postgres", + }), + }).pipe(Effect.flip); + expect(error).toMatchObject({ + _tag: "TargetNeedsProjectError", + message: "`supabase config pull` needs a project, not a database URL.", + }); + expect(api.requests).toEqual([]); + }).pipe( + Effect.provide( + Layer.mergeAll( + layer, + commandRuntimeLayer(["config", "pull"]).pipe(Layer.provide(BunServices.layer)), + ), + ), + ); + }); + + it.effect("a branch-named target resolves via the linked parent project", () => { + const { layer, api, out } = setup({ + toml: 'project_id = "test"\n', + yes: true, + v2: { status: 200, body: v2Response({ ref: BRANCH_REF }) }, + }); + return Effect.gen(function* () { + yield* configPull({ ...noFlags, remote: branchRemote("staging") }); expect(out.stderrText).toContain(`Pulling config from 'staging' (branch ${BRANCH_REF})`); const urls = api.requests.map((request) => request.url); expect(urls.some((url) => url.includes(`/v1/projects/${VALID_REF}/branches/staging`))).toBe( @@ -1763,7 +1829,7 @@ describe("config pull integration", () => { expect(urls.some((url) => url.includes(`/v2/projects/${BRANCH_REF}/config`))).toBe(true); assertPermissionDrift({ command: "config pull", - activeFlags: { "project-ref": true }, + activeFlags: { remote: true }, requests: api.requests, }); }).pipe(Effect.provide(layer)); @@ -1786,7 +1852,7 @@ describe("config pull integration", () => { }, }); return Effect.gen(function* () { - yield* configPull({ ...noFlags, projectRef: Option.some(BRANCH_UUID) }); + yield* configPull({ ...noFlags, remote: branchRemote(BRANCH_UUID) }); const urls = api.requests.map((request) => request.url); expect(urls.some((url) => url.includes(`/v1/branches/${BRANCH_UUID}`))).toBe(true); expect(urls.some((url) => url.includes(`/v2/projects/${BRANCH_REF}/config`))).toBe(true); @@ -1798,7 +1864,7 @@ describe("config pull integration", () => { expect(yield* readConfig).toContain(`[remotes.${BRANCH_REF}]`); assertPermissionDrift({ command: "config pull", - activeFlags: { "project-ref": true }, + activeFlags: { remote: true }, requests: api.requests, }); }).pipe(Effect.provide(layer)); @@ -1811,13 +1877,13 @@ describe("config pull integration", () => { v2: { status: 200, body: v2Response({ ref: BRANCH_REF }) }, }); return Effect.gen(function* () { - yield* configPull({ ...noFlags, projectRef: Option.some(BRANCH_REF) }); + yield* configPull({ ...noFlags, remote: refRemote(BRANCH_REF) }); const urls = api.requests.map((request) => request.url); expect(urls.some((url) => url.includes("/branches/"))).toBe(false); expect(urls.some((url) => url.includes(`/v2/projects/${BRANCH_REF}/config`))).toBe(true); assertPermissionDrift({ command: "config pull", - activeFlags: { "project-ref": true }, + activeFlags: { remote: true }, requests: api.requests, exact: true, }); @@ -1834,7 +1900,7 @@ describe("config pull integration", () => { return Effect.gen(function* () { const exit = yield* configPull({ ...noFlags, - projectRef: Option.some("somebranch"), + remote: branchRemote("somebranch"), }).pipe(Effect.exit); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { @@ -1857,7 +1923,7 @@ describe("config pull integration", () => { return Effect.gen(function* () { const exit = yield* configPull({ ...noFlags, - projectRef: Option.some("somebranch"), + remote: branchRemote("somebranch"), }).pipe(Effect.exit); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { @@ -1876,7 +1942,7 @@ describe("config pull integration", () => { branchByName: { status: 404, body: { message: "not found" } }, }); return Effect.gen(function* () { - const exit = yield* configPull({ ...noFlags, projectRef: Option.some("ghost") }).pipe( + const exit = yield* configPull({ ...noFlags, remote: branchRemote("ghost") }).pipe( Effect.exit, ); expect(Exit.isFailure(exit)).toBe(true); @@ -1897,7 +1963,7 @@ describe("config pull integration", () => { return Effect.gen(function* () { const exit = yield* configPull({ ...noFlags, - projectRef: Option.some("staging"), + remote: branchRemote("staging"), }).pipe(Effect.exit); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { @@ -1917,7 +1983,7 @@ describe("config pull integration", () => { return Effect.gen(function* () { const exit = yield* configPull({ ...noFlags, - projectRef: Option.some("staging"), + remote: branchRemote("staging"), }).pipe(Effect.exit); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { @@ -1932,7 +1998,7 @@ describe("config pull integration", () => { const path = yield* Path.Path; const exit = yield* configPull({ ...noFlags, - projectRef: Option.some("staging"), + remote: branchRemote("staging"), }).pipe(Effect.exit); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { @@ -2075,7 +2141,7 @@ describe("config pull integration", () => { return Effect.gen(function* () { const exit = yield* configPull({ ...noFlags, - projectRef: Option.some("staging"), + remote: branchRemote("staging"), }).pipe(Effect.exit); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { @@ -2268,7 +2334,7 @@ describe("config pull integration", () => { v2: { status: 200, body: v2Response({ ref: BRANCH_REF }) }, }); return Effect.gen(function* () { - yield* configPull({ ...noFlags, projectRef: Option.some("staging") }); + yield* configPull({ ...noFlags, remote: branchRemote("staging") }); const success = out.messages.find((message) => message.type === "success"); expect(success?.data).toMatchObject({ target: { branch: "staging" } }); }).pipe(Effect.provide(layer)); @@ -2281,7 +2347,7 @@ describe("config pull integration", () => { branchByName: { status: 404, body: { message: "not found" } }, }); return Effect.gen(function* () { - const exit = yield* configPull({ ...noFlags, projectRef: Option.some("ghost") }).pipe( + const exit = yield* configPull({ ...noFlags, remote: branchRemote("ghost") }).pipe( Effect.exit, ); expect(Exit.isFailure(exit)).toBe(true); @@ -2777,7 +2843,7 @@ describe("config pull integration", () => { }, }); return Effect.gen(function* () { - yield* configPull({ ...noFlags, projectRef: Option.some(MERGE_CHECK_REF) }); + yield* configPull({ ...noFlags, remote: refRemote(MERGE_CHECK_REF) }); const after = yield* readConfig; expect(after).toContain("[remotes.staging.auth.sms.twilio]"); expect(after).toContain("enabled = false"); 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 632d11e55d..f8e2f76486 100644 --- a/apps/cli/src/commands/config/push/push.integration.test.ts +++ b/apps/cli/src/commands/config/push/push.integration.test.ts @@ -48,6 +48,7 @@ import { HostedMutationConsentRequiredError, HostedMutationDeclinedError, } from "../../../command-internal/hosted-consent/hosted-consent.errors.ts"; +import type { RemoteValue } from "../../../command-internal/target/remote-value.ts"; import { TargetPolicy } from "../../../command-internal/target/target-policy.ts"; import { cliConfigProviderLayer } from "../../../shared/config/cli-config-provider.layer.ts"; import { commandRuntimeLayer } from "../../../shared/runtime/command-runtime.layer.ts"; @@ -215,7 +216,7 @@ const BRANCH_LIST_ITEM = { with_data: false, }; -/** `V1GetABranch` body for a branch-name `--project-ref` lookup. */ +/** `V1GetABranch` body for a branch-name `--remote` lookup. */ const BRANCH_BY_NAME = { id: BRANCH_UUID, name: "staging", @@ -229,7 +230,7 @@ const BRANCH_BY_NAME = { with_data: false, }; -/** `V1GetABranchConfig` body for a UUID `--project-ref` lookup. */ +/** `V1GetABranchConfig` body for a UUID `--remote` lookup. */ const BRANCH_CONFIG = { ref: UUID_TARGET_REF, postgres_version: "15", @@ -292,6 +293,8 @@ interface SetupOptions { /** Answers the project picker with `REF` and writes the linked-project cache through the real * cache layer. */ readonly pickerAndRealCache?: boolean; + /** The argv the deprecation warnings render their replacement command from. */ + readonly cliArgs?: ReadonlyArray<string>; } type PushApiRoutes = Pick< @@ -453,6 +456,7 @@ function setup(opts: SetupOptions) { (opts.interactive ?? true), }), ...(opts.analytics === undefined ? {} : { analytics: opts.analytics }), + ...(opts.cliArgs === undefined ? {} : { cliArgs: opts.cliArgs }), }), mockStdin(opts.stdinIsTty ?? true), Layer.succeed(YesFlag, opts.yes ?? false), @@ -474,7 +478,9 @@ describe("config push integration", () => { postgrestPatch: { status: 500, body: { message: "boom" } }, }); return Effect.gen(function* () { - const exit = yield* configPush({ projectRef: Option.none() }).pipe(Effect.exit); + const exit = yield* configPush({ remote: Option.none(), projectRef: Option.none() }).pipe( + Effect.exit, + ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { expect(Cause.pretty(exit.cause)).toContain("ConfigPushApiUpdateStatusError"); @@ -491,7 +497,9 @@ describe("config push integration", () => { postgrestPatch: "fail", }); return Effect.gen(function* () { - const exit = yield* configPush({ projectRef: Option.none() }).pipe(Effect.exit); + const exit = yield* configPush({ remote: Option.none(), projectRef: Option.none() }).pipe( + Effect.exit, + ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { expect(Cause.pretty(exit.cause)).toContain("ConfigPushApiUpdateNetworkError"); @@ -502,7 +510,7 @@ describe("config push integration", () => { it.effect("names supabase/config.toml on malformed config.toml, before any network call", () => { const { layer, api } = setup({ toml: "malformed", yes: true }); return Effect.gen(function* () { - const message = yield* configPush({ projectRef: Option.none() }).pipe( + const message = yield* configPush({ remote: Option.none(), projectRef: Option.none() }).pipe( Effect.catchTag("ConfigPushLoadConfigError", (error) => Effect.succeed(error.message)), ); expect(message).toContain("failed to parse supabase/config.toml:"); @@ -529,7 +537,7 @@ describe("config push integration", () => { ); return Effect.gen(function* () { yield* writeWorkdirFile(["supabase", "config.json"], "{not valid json"); - const message = yield* configPush({ projectRef: Option.none() }).pipe( + const message = yield* configPush({ remote: Option.none(), projectRef: Option.none() }).pipe( Effect.catchTag("ConfigPushLoadConfigError", (error) => Effect.succeed(error.message)), ); expect(message).toContain("failed to parse supabase/config.json:"); @@ -561,7 +569,7 @@ schemas = ["public", "remote_schema"] }, }); return Effect.gen(function* () { - yield* configPush({ projectRef: Option.none() }); + yield* configPush({ remote: Option.none(), 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("Comparison scope:"), @@ -594,7 +602,7 @@ schemas = ["public"] "SUPABASE_API_SCHEMAS", "public,env_schema", Effect.gen(function* () { - yield* configPush({ projectRef: Option.none() }); + yield* configPush({ remote: Option.none(), projectRef: Option.none() }); const update = api.requests.find( (r) => r.method === "PATCH" && r.url.includes("/postgrest"), ); @@ -626,7 +634,7 @@ schemas = ["public"] "SUPABASE_API_SCHEMAS", "public,env_schema", Effect.gen(function* () { - yield* configPush({ projectRef: Option.none() }); + yield* configPush({ remote: Option.none(), projectRef: Option.none() }); expect(out.stderrText).toContain( "Pushing 1 value set by environment variables: api.schemas (SUPABASE_API_SCHEMAS)\n", ); @@ -640,7 +648,7 @@ schemas = ["public"] const { layer, out } = setup({ toml: schemasToml, yes: true, v2: remoteSchemas }); return Effect.gen(function* () { yield* writeWorkdirFile(["supabase", ".env"], "SUPABASE_API_SCHEMAS=public,env_schema\n"); - yield* configPush({ projectRef: Option.none() }); + yield* configPush({ remote: Option.none(), projectRef: Option.none() }); expect(out.stderrText).toContain( "Pushing 1 value set by environment variables: api.schemas (SUPABASE_API_SCHEMAS, supabase/.env)\n", ); @@ -655,7 +663,7 @@ schemas = ["public"] v2: remoteSchemas, }); return Effect.gen(function* () { - yield* configPush({ projectRef: Option.none() }); + yield* configPush({ remote: Option.none(), projectRef: Option.none() }); expect(out.stderrText).not.toContain("set by environment variables"); expect(out.stderrText).not.toContain("(from SUPABASE_"); }).pipe(Effect.provide(layer)); @@ -667,7 +675,7 @@ schemas = ["public"] "SUPABASE_API_SCHEMAS", "public", Effect.gen(function* () { - yield* configPush({ projectRef: Option.none() }); + yield* configPush({ remote: Option.none(), projectRef: Option.none() }); expect(out.stderrText).not.toContain("set by environment variables"); }), ).pipe(Effect.provide(layer)); @@ -684,7 +692,7 @@ schemas = ["public"] "SUPABASE_API_SCHEMAS", "public,env_schema", Effect.gen(function* () { - yield* configPush({ projectRef: Option.none() }); + yield* configPush({ remote: Option.none(), projectRef: Option.none() }); const success = out.messages.find((message) => message.type === "success"); const data = yield* decodeJsonRecord(success?.data); expect(data["env_sourced"]).toEqual([ @@ -715,8 +723,8 @@ schemas = ["public", "remote_schema"] "SUPABASE_API_SCHEMAS", "public,env_schema", Effect.gen(function* () { - yield* configDiff({ projectRef: Option.none(), exitCode: false }); - yield* configPush({ projectRef: Option.none() }); + yield* configDiff({ remote: Option.none(), projectRef: Option.none(), exitCode: false }); + yield* configPush({ remote: Option.none(), projectRef: Option.none() }); const diff = out.messages.find( (message) => isJsonRecord(message.data) && "changes" in message.data, @@ -762,7 +770,7 @@ schemas = ["public", "remote_schema"] "SUPABASE_REMOTES_STAGING_PROJECT_ID", REF, Effect.gen(function* () { - yield* configPush({ projectRef: Option.none() }); + yield* configPush({ remote: Option.none(), projectRef: Option.none() }); expect(out.stderrText).toContain("Loading config override: [remotes.staging]"); const update = api.requests.find( (r) => r.method === "PATCH" && r.url.includes("/postgrest"), @@ -778,7 +786,7 @@ schemas = ["public", "remote_schema"] yes: true, }); return Effect.gen(function* () { - const message = yield* configPush({ projectRef: Option.none() }).pipe( + const message = yield* configPush({ remote: Option.none(), projectRef: Option.none() }).pipe( Effect.catchTag("ConfigPushLoadConfigError", (error) => Effect.succeed(error.message)), ); expect(message).toContain("duplicate project_id for [remotes."); @@ -793,7 +801,9 @@ schemas = ["public", "remote_schema"] addons: { status: 503, body: {} }, }); return Effect.gen(function* () { - const exit = yield* configPush({ projectRef: Option.none() }).pipe(Effect.exit); + const exit = yield* configPush({ remote: Option.none(), projectRef: Option.none() }).pipe( + Effect.exit, + ); expect(Exit.isFailure(exit)).toBe(true); expect(api.requests.some((r) => r.url.includes("/v2/projects/"))).toBe(false); @@ -813,7 +823,7 @@ max_rows = 1000 yes: true, }); return Effect.gen(function* () { - yield* configPush({ projectRef: Option.none() }); + yield* configPush({ remote: Option.none(), projectRef: Option.none() }); // The scope line prints on every run, not just when a block is missing. expect(out.stderrText).toContain( "Comparison scope: api, auth, database, pooler, realtime, storage", @@ -843,7 +853,7 @@ max_rows = 1000 }, }); return Effect.gen(function* () { - yield* configPush({ projectRef: Option.none() }); + yield* configPush({ remote: Option.none(), projectRef: Option.none() }); expect(out.stderrText).toContain("Remote API config is up to date."); expect(api.requests.some((r) => r.method === "PATCH" && r.url.includes("/postgrest"))).toBe( false, @@ -869,7 +879,7 @@ max_rows = 1000 }, }); return Effect.gen(function* () { - yield* configPush({ projectRef: Option.none() }); + yield* configPush({ remote: Option.none(), projectRef: Option.none() }); const update = api.requests.find((r) => r.method === "PATCH" && r.url.includes("/postgrest")); expect(update).toBeDefined(); expect(update?.body).toMatchObject({ db_schema: "public,graphql_public" }); @@ -882,7 +892,7 @@ max_rows = 1000 confirm: [true], }); return Effect.gen(function* () { - yield* configPush({ projectRef: Option.none() }); + yield* configPush({ remote: Option.none(), 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]?.opts?.defaultValue).toBe(false); @@ -896,7 +906,9 @@ max_rows = 1000 confirm: [false], }); return Effect.gen(function* () { - const error = yield* configPush({ projectRef: Option.none() }).pipe(Effect.flip); + const error = yield* configPush({ remote: Option.none(), 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]"); @@ -910,7 +922,7 @@ max_rows = 1000 yes: true, }); return Effect.gen(function* () { - yield* configPush({ projectRef: Option.none() }); + yield* configPush({ remote: Option.none(), projectRef: Option.none() }); expect(out.stderrText).toContain( `Confirmed by --yes: Push config changes to API on project ${REF}`, ); @@ -927,7 +939,7 @@ max_rows = 1000 stdinIsTty: false, }); return Effect.gen(function* () { - yield* configPush({ projectRef: Option.none() }); + yield* configPush({ remote: Option.none(), projectRef: Option.none() }); expect(out.promptConfirmCalls).toHaveLength(0); expect(api.requests.some((r) => r.method !== "GET")).toBe(false); }).pipe(Effect.provide(layer)); @@ -944,7 +956,7 @@ max_rows = 1000 workdir: sub, explicitWorkdir: true, }); - const exit = yield* configPush({ projectRef: Option.none() }).pipe( + const exit = yield* configPush({ remote: Option.none(), projectRef: Option.none() }).pipe( Effect.exit, Effect.provide(layer), ); @@ -981,7 +993,9 @@ max_rows = 1000 cliConfigValuesTestLayer, ); return Effect.gen(function* () { - const exit = yield* configPush({ projectRef: Option.none() }).pipe(Effect.exit); + const exit = yield* configPush({ remote: Option.none(), projectRef: Option.none() }).pipe( + Effect.exit, + ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { const causeText = Cause.pretty(exit.cause); @@ -1003,7 +1017,7 @@ max_rows = 1000 workdir: missing, explicitWorkdir: true, }); - const exit = yield* configPush({ projectRef: Option.none() }).pipe( + const exit = yield* configPush({ remote: Option.none(), projectRef: Option.none() }).pipe( Effect.exit, Effect.provide(layer), ); @@ -1029,7 +1043,7 @@ max_rows = 1000 workdir: notADirectory, explicitWorkdir: true, }); - const exit = yield* configPush({ projectRef: Option.none() }).pipe( + const exit = yield* configPush({ remote: Option.none(), projectRef: Option.none() }).pipe( Effect.exit, Effect.provide(layer), ); @@ -1052,7 +1066,7 @@ max_rows = 1000 yes: true, }); return Effect.gen(function* () { - yield* configPush({ projectRef: Option.none() }); + yield* configPush({ remote: Option.none(), projectRef: Option.none() }); const success = out.messages.find((m) => m.type === "success"); expect(success).toBeDefined(); const data = yield* decodeJsonRecord(success?.data); @@ -1092,7 +1106,7 @@ max_rows = 1000 addons: { status: 503, body: {} }, }); return Effect.gen(function* () { - yield* configPush({ projectRef: Option.none() }).pipe(Effect.exit); + yield* configPush({ remote: Option.none(), projectRef: Option.none() }).pipe(Effect.exit); expect(telemetry.flushed).toBe(true); expect(linkedProjectCache.cached).toBe(true); }).pipe(Effect.provide(layer)); @@ -1116,9 +1130,11 @@ max_rows = 1000 cliConfigValuesTestLayer, ); return Effect.gen(function* () { - const exit = yield* configPush({ projectRef: Option.none() }).pipe(Effect.exit); + const exit = yield* configPush({ remote: Option.none(), projectRef: Option.none() }).pipe( + Effect.exit, + ); expect(Exit.isFailure(exit)).toBe(true); - const message = yield* configPush({ projectRef: Option.none() }).pipe( + const message = yield* configPush({ remote: Option.none(), projectRef: Option.none() }).pipe( Effect.catchTag("ConfigPushLoadConfigError", (error) => Effect.succeed(error.message)), ); expect(message).toBe( @@ -1145,7 +1161,7 @@ max_rows = 1000 }, }); return Effect.gen(function* () { - yield* configPush({ projectRef: Option.none() }); + yield* configPush({ remote: Option.none(), projectRef: Option.none() }); const update = api.requests.find((r) => r.method === "PATCH" && r.url.includes("/postgrest")); expect(update).toBeDefined(); expect(update?.body).toEqual({ @@ -1192,7 +1208,7 @@ statement_timeout = "8s" }, }); return Effect.gen(function* () { - yield* configPush({ projectRef: Option.none() }); + yield* configPush({ remote: Option.none(), projectRef: Option.none() }); const put = api.requests.find( (r) => r.method === "PUT" && r.url.includes("/config/database/postgres"), ); @@ -1220,7 +1236,7 @@ max_frequency = "10s" `; const { layer, api } = setup({ toml, yes: true }); return Effect.gen(function* () { - yield* configPush({ projectRef: Option.none() }); + yield* configPush({ remote: Option.none(), projectRef: Option.none() }); const update = api.requests.find( (r) => r.method === "PATCH" && r.url.includes("/config/auth"), ); @@ -1247,7 +1263,7 @@ otp_expiry = 120 `; const { layer, api } = setup({ toml, yes: true }); return Effect.gen(function* () { - yield* configPush({ projectRef: Option.none() }); + yield* configPush({ remote: Option.none(), projectRef: Option.none() }); const update = api.requests.find( (r) => r.method === "PATCH" && r.url.includes("/config/auth"), ); @@ -1262,7 +1278,7 @@ otp_expiry = 120 format: "json", }); return Effect.gen(function* () { - yield* configPush({ projectRef: Option.none() }); + yield* configPush({ remote: Option.none(), projectRef: Option.none() }); expect(out.stderrText).toContain( "Note: 1 declared property has no Management API field and was not pushed: db.pooler.pool_mode (change it from the dashboard).", ); @@ -1295,7 +1311,10 @@ otp_expiry = 120 yes: true, v2: { status: testCase.status, body: { message: "boom" } }, }); - const message = yield* configPush({ projectRef: Option.none() }).pipe( + const message = yield* configPush({ + remote: Option.none(), + projectRef: Option.none(), + }).pipe( Effect.catchTag("ConfigPushConfigReadStatusError", (error) => Effect.succeed(error.message), ), @@ -1312,7 +1331,9 @@ otp_expiry = 120 it.effect("a config-read transport failure maps to the read network error", () => { const { layer, telemetry } = setup({ toml: `project_id = "test"\n`, yes: true, v2: "fail" }); return Effect.gen(function* () { - const exit = yield* configPush({ projectRef: Option.none() }).pipe(Effect.exit); + const exit = yield* configPush({ remote: Option.none(), projectRef: Option.none() }).pipe( + Effect.exit, + ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { expect(Cause.pretty(exit.cause)).toContain("ConfigPushConfigReadNetworkError"); @@ -1338,7 +1359,9 @@ otp_expiry = 120 }, }); return Effect.gen(function* () { - const exit = yield* configPush({ projectRef: Option.none() }).pipe(Effect.exit); + const exit = yield* configPush({ remote: Option.none(), projectRef: Option.none() }).pipe( + Effect.exit, + ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { expect(Cause.pretty(exit.cause)).toContain("ProjectConfigParseError"); @@ -1363,7 +1386,9 @@ otp_expiry = 120 v2: { status: 200, body: v2Response({ attributes: () => ({}) }) }, }); return Effect.gen(function* () { - const exit = yield* configPush({ projectRef: Option.none() }).pipe(Effect.exit); + const exit = yield* configPush({ remote: Option.none(), projectRef: Option.none() }).pipe( + Effect.exit, + ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { expect(Cause.pretty(exit.cause)).toContain("ConfigPushConfigEmptyError"); @@ -1412,7 +1437,7 @@ otp_expiry = 120 }, }); return Effect.gen(function* () { - yield* configPush({ projectRef: Option.none() }); + yield* configPush({ remote: Option.none(), projectRef: Option.none() }); expect(out.stderrText).toContain( "Note: 1 declared property is not managed by config push and was not compared; run `supabase config diff` to list them.", ); @@ -1436,7 +1461,7 @@ otp_expiry = 120 yes: true, }); return Effect.gen(function* () { - yield* configPush({ projectRef: Option.none() }); + yield* configPush({ remote: Option.none(), projectRef: Option.none() }); const update = api.requests.find( (r) => r.method === "PATCH" && r.url.includes("/config/auth"), ); @@ -1469,7 +1494,7 @@ otp_expiry = 120 }, }); return Effect.gen(function* () { - yield* configPush({ projectRef: Option.none() }); + yield* configPush({ remote: Option.none(), projectRef: Option.none() }); expect(out.stderrText).toContain( "Note: 1 remote property is not declared in supabase/config.toml and was left unchanged (config push no longer resets undeclared properties to their defaults; run `supabase config diff` to inspect).", ); @@ -1593,7 +1618,7 @@ content_path = "./templates/password_changed.html" return Effect.gen(function* () { yield* writeWorkdirFile(["templates", "invite.html"], "<h1>Invite</h1>"); yield* writeWorkdirFile(["templates", "password_changed.html"], "<p>Password changed</p>"); - yield* configPush({ projectRef: Option.none() }); + yield* configPush({ remote: Option.none(), projectRef: Option.none() }); const update = apiMock.requests.find((r) => r.method === "updateAuthServiceConfig"); expect(update).toBeDefined(); const input = yield* decodeJsonRecord(update?.input); @@ -1620,7 +1645,9 @@ content_path = "./templates/missing.html" v1: { updateAuthServiceConfig: () => Effect.succeed({}) }, }); return Effect.gen(function* () { - const exit = yield* configPush({ projectRef: Option.none() }).pipe(Effect.exit); + const exit = yield* configPush({ remote: Option.none(), projectRef: Option.none() }).pipe( + Effect.exit, + ); expect(Exit.isFailure(exit)).toBe(true); expect(apiMock.requests).toHaveLength(0); }).pipe(Effect.provide(layer)); @@ -1644,7 +1671,9 @@ content_path = "./templates/invite.html" runtimeCwd: nestedCwd, v1: { updateAuthServiceConfig: () => Effect.succeed({}) }, }); - yield* configPush({ projectRef: Option.none() }).pipe(Effect.provide(layer)); + yield* configPush({ remote: Option.none(), projectRef: Option.none() }).pipe( + Effect.provide(layer), + ); const update = apiMock.requests.find((r) => r.method === "updateAuthServiceConfig"); expect(update).toBeDefined(); const input = yield* decodeJsonRecord(update?.input); @@ -1667,7 +1696,7 @@ secret = "my-plaintext-secret" v1: { updateAuthServiceConfig: () => Effect.succeed({}) }, }); return Effect.gen(function* () { - yield* configPush({ projectRef: Option.none() }); + yield* configPush({ remote: Option.none(), projectRef: Option.none() }); const update = apiMock.requests.find((r) => r.method === "updateAuthServiceConfig"); expect(update).toBeDefined(); const input = yield* decodeJsonRecord(update?.input); @@ -1699,7 +1728,7 @@ secret = "${DOTENVX_ENCRYPTED_VALUE}" "DOTENV_PRIVATE_KEY", DOTENVX_PRIVATE_KEY, Effect.gen(function* () { - yield* configPush({ projectRef: Option.none() }); + yield* configPush({ remote: Option.none(), projectRef: Option.none() }); const update = apiMock.requests.find((r) => r.method === "updateAuthServiceConfig"); expect(update).toBeDefined(); const input = yield* decodeJsonRecord(update?.input); @@ -1723,9 +1752,10 @@ secret = "${DOTENVX_ENCRYPTED_VALUE}" "DOTENV_PRIVATE_KEY", undefined, Effect.gen(function* () { - const message = yield* configPush({ projectRef: Option.none() }).pipe( - Effect.catchTag("CliConfigValueError", (error) => Effect.succeed(error.message)), - ); + const message = yield* configPush({ + remote: Option.none(), + projectRef: Option.none(), + }).pipe(Effect.catchTag("CliConfigValueError", (error) => Effect.succeed(error.message))); expect(message).toBe("failed to parse config: missing private key"); expect(api.requests).toHaveLength(0); }).pipe(Effect.provide(layer)), @@ -1747,9 +1777,10 @@ openai_api_key = "${DOTENVX_ENCRYPTED_VALUE}" "DOTENV_PRIVATE_KEY", undefined, Effect.gen(function* () { - const message = yield* configPush({ projectRef: Option.none() }).pipe( - Effect.catchTag("CliConfigValueError", (error) => Effect.succeed(error.message)), - ); + const message = yield* configPush({ + remote: Option.none(), + projectRef: Option.none(), + }).pipe(Effect.catchTag("CliConfigValueError", (error) => Effect.succeed(error.message))); expect(message).toBe("failed to parse config: missing private key"); expect(api.requests).toHaveLength(0); }).pipe(Effect.provide(layer)), @@ -1773,9 +1804,10 @@ secret = "env(CAPTCHA_SECRET_FROM_ROOT_ENV)" [".env"], `CAPTCHA_SECRET_FROM_ROOT_ENV="${DOTENVX_ENCRYPTED_VALUE}"\n`, ); - const message = yield* configPush({ projectRef: Option.none() }).pipe( - Effect.catchTag("CliConfigValueError", (error) => Effect.succeed(error.message)), - ); + const message = yield* configPush({ + remote: Option.none(), + projectRef: Option.none(), + }).pipe(Effect.catchTag("CliConfigValueError", (error) => Effect.succeed(error.message))); expect(message).toBe("failed to parse config: missing private key"); expect(api.requests).toHaveLength(0); }).pipe(Effect.provide(layer)), @@ -1792,7 +1824,10 @@ my_secret = "${DOTENVX_ENCRYPTED_VALUE}" "DOTENV_PRIVATE_KEY", undefined, Effect.gen(function* () { - const message = yield* configPush({ projectRef: Option.none() }).pipe( + const message = yield* configPush({ + remote: Option.none(), + projectRef: Option.none(), + }).pipe( Effect.catchTag("ConfigPushLoadConfigError", (error) => Effect.succeed(error.message)), ); expect(message).toBe("failed to parse config: missing private key"); @@ -1810,7 +1845,10 @@ secret = "${DOTENVX_ENCRYPTED_VALUE}" `; const { layer, api } = setup({ toml, yes: true }); return Effect.gen(function* () { - const message = yield* configPush({ projectRef: Option.none() }).pipe( + const message = yield* configPush({ + remote: Option.none(), + projectRef: Option.none(), + }).pipe( Effect.catchTag("ConfigPushLoadConfigError", (error) => Effect.succeed(error.message)), ); expect(message).toBe( @@ -1829,7 +1867,7 @@ secret = "${DOTENVX_ENCRYPTED_VALUE}" yes: true, }); return Effect.gen(function* () { - yield* configPush({ projectRef: Option.none() }); + yield* configPush({ remote: Option.none(), projectRef: Option.none() }); const update = api.requests.find( (r) => r.method === "PATCH" && r.url.includes("/config/storage"), ); @@ -1848,7 +1886,7 @@ allowed_cidrs_v6 = ["::1/128"] `; const { layer, api } = setup({ toml, yes: true }); return Effect.gen(function* () { - yield* configPush({ projectRef: Option.none() }); + yield* configPush({ remote: Option.none(), projectRef: Option.none() }); const apply = api.requests.find( (r) => r.method === "POST" && r.url.includes("/network-restrictions/apply"), ); @@ -1866,7 +1904,7 @@ allowed_cidrs_v6 = ["::1/128"] yes: true, }); return Effect.gen(function* () { - yield* configPush({ projectRef: Option.none() }); + yield* configPush({ remote: Option.none(), projectRef: Option.none() }); const update = api.requests.find( (r) => r.method === "PUT" && r.url.includes("/ssl-enforcement"), ); @@ -1881,7 +1919,7 @@ allowed_cidrs_v6 = ["::1/128"] yes: true, }); return Effect.gen(function* () { - yield* configPush({ projectRef: Option.none() }); + yield* configPush({ remote: Option.none(), projectRef: Option.none() }); expect(out.stderrText).toContain("Would enable webhooks for project:"); expect( api.requests.some( @@ -1907,7 +1945,7 @@ allowed_cidrs_v6 = ["::1/128"] const cachePath = yield* workdirPath("supabase", ".temp", "linked-project.json"); expect(yield* fs.exists(cachePath)).toBe(false); - yield* configPush({ projectRef: Option.none() }); + yield* configPush({ remote: Option.none(), projectRef: Option.none() }); expect(api.requests.map(({ method, url }) => `${method} ${url}`)).toEqual([ `GET ${API}/v1/projects`, @@ -1933,7 +1971,7 @@ allowed_cidrs_v6 = ["::1/128"] project: { status: 403, body: { message: "forbidden" } }, }); return Effect.gen(function* () { - yield* configPush({ projectRef: Option.none() }); + yield* configPush({ remote: Option.none(), projectRef: Option.none() }); expect(api.requests.map(({ method, url }) => `${method} ${url}`)).toEqual([ `GET ${API}/v1/projects/${REF}`, `GET ${API}/v1/projects/${REF}/billing/addons`, @@ -1952,7 +1990,7 @@ allowed_cidrs_v6 = ["::1/128"] v1: {}, }); return Effect.gen(function* () { - yield* configPush({ projectRef: Option.none() }); + yield* configPush({ remote: Option.none(), projectRef: Option.none() }); expect(methodsOf(apiMock)).not.toContain("updateSslEnforcementConfig"); const success = out.messages.find((m) => m.type === "success"); const data = yield* decodeJsonRecord(success?.data); @@ -1973,7 +2011,9 @@ allowed_cidrs_v6 = ["::1/128"] v1: { enableDatabaseWebhook: () => Effect.fail(statusCodeFailure(500)) }, }); return Effect.gen(function* () { - const exit = yield* configPush({ projectRef: Option.none() }).pipe(Effect.exit); + const exit = yield* configPush({ remote: Option.none(), projectRef: Option.none() }).pipe( + Effect.exit, + ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { expect(Cause.pretty(exit.cause)).toContain("ConfigPushEnableWebhookStatusError"); @@ -1985,7 +2025,9 @@ allowed_cidrs_v6 = ["::1/128"] const toml = `${BASE_DISABLED}[experimental.webhooks]\nenabled = true\n`; const { layer, apiMock, out } = setupService({ toml, confirm: [false] }); return Effect.gen(function* () { - const error = yield* configPush({ projectRef: Option.none() }).pipe(Effect.flip); + const error = yield* configPush({ remote: Option.none(), projectRef: Option.none() }).pipe( + Effect.flip, + ); expect(error).toBeInstanceOf(HostedMutationDeclinedError); expect(out.stderrText).toContain("Would enable webhooks for project:"); expect(out.promptConfirmCalls[0]?.message).toContain("Push config changes to Webhooks"); @@ -2003,7 +2045,7 @@ allowed_cidrs_v6 = ["::1/128"] format: "json", }); return Effect.gen(function* () { - yield* configPush({ projectRef: Option.none() }); + yield* configPush({ remote: Option.none(), projectRef: Option.none() }); expect(methodsOf(apiMock)).not.toContain("updateAuthServiceConfig"); const success = out.messages.find((m) => m.type === "success"); const data = yield* decodeJsonRecord(success?.data); @@ -2026,7 +2068,7 @@ allowed_cidrs_v6 = ["::1/128"] format: "json", }); return Effect.gen(function* () { - yield* configPush({ projectRef: Option.none() }); + yield* configPush({ remote: Option.none(), projectRef: Option.none() }); expect(methodsOf(apiMock)).not.toContain("updateStorageConfig"); const success = out.messages.find((m) => m.type === "success"); const data = yield* decodeJsonRecord(success?.data); @@ -2076,7 +2118,7 @@ sender_name = "My Project" v1: { updateAuthServiceConfig: () => Effect.succeed(authWriteResponseFixture()) }, }); return Effect.gen(function* () { - yield* configPush({ projectRef: Option.none() }); + yield* configPush({ remote: Option.none(), projectRef: Option.none() }); expect(methodsOf(apiMock)).toContain("updateAuthServiceConfig"); const success = out.messages.find((m) => m.type === "success"); const data = yield* decodeJsonRecord(success?.data); @@ -2112,7 +2154,7 @@ sender_name = "My Project" }, }); return Effect.gen(function* () { - yield* configPush({ projectRef: Option.none() }); + yield* configPush({ remote: Option.none(), projectRef: Option.none() }); expect(methodsOf(apiMock)).not.toContain("updateAuthServiceConfig"); const success = out.messages.find((m) => m.type === "success"); const data = yield* decodeJsonRecord(success?.data); @@ -2142,7 +2184,9 @@ sender_name = "My Project" v1: { updatePostgrestServiceConfig: () => Effect.succeed({}) }, }); return Effect.gen(function* () { - const exit = yield* configPush({ projectRef: Option.none() }).pipe(Effect.exit); + const exit = yield* configPush({ remote: Option.none(), projectRef: Option.none() }).pipe( + Effect.exit, + ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { expect(Cause.pretty(exit.cause)).toContain("ConfigPushConfigEmptyError"); @@ -2181,7 +2225,7 @@ secret = "same-secret" }, }); return Effect.gen(function* () { - yield* configPush({ projectRef: Option.none() }); + yield* configPush({ remote: Option.none(), projectRef: Option.none() }); expect(out.stderrText).toContain("Remote Auth config is up to date."); expect(methodsOf(apiMock)).not.toContain("updateAuthServiceConfig"); }).pipe(Effect.provide(layer)); @@ -2217,7 +2261,7 @@ secret = "new-secret" v1: { updateAuthServiceConfig: () => Effect.succeed({}) }, }); return Effect.gen(function* () { - yield* configPush({ projectRef: Option.none() }); + yield* configPush({ remote: Option.none(), projectRef: Option.none() }); const update = apiMock.requests.find((r) => r.method === "updateAuthServiceConfig"); expect(update).toBeDefined(); const input = yield* decodeJsonRecord(update?.input); @@ -2245,7 +2289,7 @@ secret = "env(MISSING_CAPTCHA_SECRET)" v1: { updateAuthServiceConfig: () => Effect.succeed({}) }, }); return Effect.gen(function* () { - yield* configPush({ projectRef: Option.none() }); + yield* configPush({ remote: Option.none(), projectRef: Option.none() }); const update = apiMock.requests.find((r) => r.method === "updateAuthServiceConfig"); expect(update).toBeDefined(); const input = yield* decodeJsonRecord(update?.input); @@ -2277,7 +2321,7 @@ secret = "env(MISSING_CAPTCHA_SECRET)" v1: { updateAuthServiceConfig: () => Effect.succeed({}) }, }); return Effect.gen(function* () { - yield* configPush({ projectRef: Option.none() }); + yield* configPush({ remote: Option.none(), 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)); @@ -2295,7 +2339,7 @@ secret = "env(MISSING_CAPTCHA_SECRET)" v1: { updateAuthServiceConfig: () => Effect.succeed({}) }, }); return Effect.gen(function* () { - yield* configPush({ projectRef: Option.none() }); + yield* configPush({ remote: Option.none(), projectRef: Option.none() }); const update = apiMock.requests.find((r) => r.method === "updateAuthServiceConfig"); expect(update).toBeDefined(); const input = yield* decodeJsonRecord(update?.input); @@ -2324,7 +2368,7 @@ secret = "env(MISSING_CAPTCHA_SECRET)" v1: { updateAuthServiceConfig: () => Effect.succeed({}) }, }); return Effect.gen(function* () { - yield* configPush({ projectRef: Option.none() }); + yield* configPush({ remote: Option.none(), projectRef: Option.none() }); expect(out.stderrText).not.toContain("will cost you"); const update = apiMock.requests.find((r) => r.method === "updateAuthServiceConfig"); expect(update).toBeDefined(); @@ -2343,7 +2387,9 @@ secret = "env(MISSING_CAPTCHA_SECRET)" v1: { updateAuthServiceConfig: () => Effect.succeed({}) }, }); return Effect.gen(function* () { - const error = yield* configPush({ projectRef: Option.none() }).pipe(Effect.flip); + const error = yield* configPush({ remote: Option.none(), 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"); @@ -2359,7 +2405,7 @@ secret = "env(MISSING_CAPTCHA_SECRET)" v1: { updateAuthServiceConfig: () => Effect.succeed({}) }, }); return Effect.gen(function* () { - yield* configPush({ projectRef: Option.none() }); + yield* configPush({ remote: Option.none(), projectRef: Option.none() }); const update = apiMock.requests.find((r) => r.method === "updateAuthServiceConfig"); const input = yield* decodeJsonRecord(update?.input); expect(input["mfa_phone_verify_enabled"]).toBe(true); @@ -2374,7 +2420,9 @@ secret = "env(MISSING_CAPTCHA_SECRET)" v1: { updatePostgresConfig: () => v1TransportFailure("boom") }, }); return Effect.gen(function* () { - const exit = yield* configPush({ projectRef: Option.none() }).pipe(Effect.exit); + const exit = yield* configPush({ remote: Option.none(), projectRef: Option.none() }).pipe( + Effect.exit, + ); expect(Exit.isFailure(exit)).toBe(true); }).pipe(Effect.provide(layer)); }); @@ -2387,7 +2435,9 @@ secret = "env(MISSING_CAPTCHA_SECRET)" v1: { updateNetworkRestrictions: () => v1TransportFailure("boom") }, }); return Effect.gen(function* () { - const exit = yield* configPush({ projectRef: Option.none() }).pipe(Effect.exit); + const exit = yield* configPush({ remote: Option.none(), projectRef: Option.none() }).pipe( + Effect.exit, + ); expect(Exit.isFailure(exit)).toBe(true); }).pipe(Effect.provide(layer)); }); @@ -2400,7 +2450,9 @@ secret = "env(MISSING_CAPTCHA_SECRET)" v1: { updateSslEnforcementConfig: () => v1TransportFailure("boom") }, }); return Effect.gen(function* () { - const exit = yield* configPush({ projectRef: Option.none() }).pipe(Effect.exit); + const exit = yield* configPush({ remote: Option.none(), projectRef: Option.none() }).pipe( + Effect.exit, + ); expect(Exit.isFailure(exit)).toBe(true); }).pipe(Effect.provide(layer)); }); @@ -2413,7 +2465,9 @@ secret = "env(MISSING_CAPTCHA_SECRET)" v1: { updateAuthServiceConfig: () => v1TransportFailure("boom") }, }); return Effect.gen(function* () { - const exit = yield* configPush({ projectRef: Option.none() }).pipe(Effect.exit); + const exit = yield* configPush({ remote: Option.none(), projectRef: Option.none() }).pipe( + Effect.exit, + ); expect(Exit.isFailure(exit)).toBe(true); }).pipe(Effect.provide(layer)); }); @@ -2426,7 +2480,9 @@ secret = "env(MISSING_CAPTCHA_SECRET)" v1: { updateStorageConfig: () => v1TransportFailure("boom") }, }); return Effect.gen(function* () { - const exit = yield* configPush({ projectRef: Option.none() }).pipe(Effect.exit); + const exit = yield* configPush({ remote: Option.none(), projectRef: Option.none() }).pipe( + Effect.exit, + ); expect(Exit.isFailure(exit)).toBe(true); }).pipe(Effect.provide(layer)); }); @@ -2439,7 +2495,9 @@ secret = "env(MISSING_CAPTCHA_SECRET)" v1: { enableDatabaseWebhook: () => v1TransportFailure("ECONNRESET") }, }); return Effect.gen(function* () { - const exit = yield* configPush({ projectRef: Option.none() }).pipe(Effect.exit); + const exit = yield* configPush({ remote: Option.none(), projectRef: Option.none() }).pipe( + Effect.exit, + ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { expect(Cause.pretty(exit.cause)).toContain("ConfigPushEnableWebhookNetworkError"); @@ -2457,7 +2515,7 @@ allowed_cidrs_v6 = ["::/0"] `; const { layer, apiMock, out } = setupService({ toml, yes: true }); return Effect.gen(function* () { - yield* configPush({ projectRef: Option.none() }); + yield* configPush({ remote: Option.none(), projectRef: Option.none() }); expect(out.stderrText).toContain("Remote DB Network restrictions config is up to date."); expect(methodsOf(apiMock)).not.toContain("updateNetworkRestrictions"); }).pipe(Effect.provide(layer)); @@ -2470,7 +2528,7 @@ allowed_cidrs_v6 = ["::/0"] const toml = `${BASE_DISABLED}[db.ssl_enforcement]\nenabled = false\n`; const { layer, apiMock, out } = setupService({ toml, yes: true }); return Effect.gen(function* () { - yield* configPush({ projectRef: Option.none() }); + yield* configPush({ remote: Option.none(), projectRef: Option.none() }); expect(out.stderrText).toContain("Remote DB SSL enforcement config is up to date."); expect(methodsOf(apiMock)).not.toContain("updateSslEnforcementConfig"); }).pipe(Effect.provide(layer)); @@ -2510,7 +2568,7 @@ allowed_cidrs = ["9.9.9.9/32"] v1: { updateNetworkRestrictions: () => Effect.succeed({}) }, }); return Effect.gen(function* () { - yield* configPush({ projectRef: Option.none() }); + yield* configPush({ remote: Option.none(), projectRef: Option.none() }); const update = apiMock.requests.find((r) => r.method === "updateNetworkRestrictions"); expect(update).toBeDefined(); const input = yield* decodeJsonRecord(update?.input); @@ -2555,7 +2613,7 @@ max_buckets = 20 v1: { updateStorageConfig: () => Effect.succeed({}) }, }); return Effect.gen(function* () { - yield* configPush({ projectRef: Option.none() }); + yield* configPush({ remote: Option.none(), projectRef: Option.none() }); const update = apiMock.requests.find((r) => r.method === "updateStorageConfig"); expect(update).toBeDefined(); const input = yield* decodeJsonRecord(update?.input); @@ -2593,7 +2651,7 @@ secret = "gh-secret" v1: { updateAuthServiceConfig: () => Effect.succeed({}) }, }); return Effect.gen(function* () { - yield* configPush({ projectRef: Option.none() }); + yield* configPush({ remote: Option.none(), projectRef: Option.none() }); const update = apiMock.requests.find((r) => r.method === "updateAuthServiceConfig"); expect(update).toBeDefined(); const input = yield* decodeJsonRecord(update?.input); @@ -2630,7 +2688,7 @@ max_buckets = 99 v1: { updateStorageConfig: () => Effect.succeed({}) }, }); return Effect.gen(function* () { - yield* configPush({ projectRef: Option.none() }); + yield* configPush({ remote: Option.none(), projectRef: Option.none() }); const update = apiMock.requests.find((r) => r.method === "updateStorageConfig"); expect(update).toBeDefined(); const input = yield* decodeJsonRecord(update?.input); @@ -2676,7 +2734,7 @@ secret = "super-secret" }, }); return Effect.gen(function* () { - yield* configPush({ projectRef: Option.none() }); + yield* configPush({ remote: Option.none(), projectRef: Option.none() }); expect(out.stderrText).toContain( "Comparison scope: api, database, pooler, realtime, storage (not returned: auth)", ); @@ -2724,7 +2782,7 @@ secret = "env(MISSING_CAPTCHA_SECRET)" }, }); return Effect.gen(function* () { - yield* configPush({ projectRef: Option.none() }); + yield* configPush({ remote: Option.none(), projectRef: Option.none() }); expect(out.stderrText).not.toContain("credential value was not pushed"); expect(methodsOf(apiMock)).not.toContain("updateAuthServiceConfig"); const success = out.messages.find((m) => m.type === "success"); @@ -2747,7 +2805,7 @@ secret = "env(MISSING_CAPTCHA_SECRET)" const toml = `project_id = "test"\n[storage]\nfile_size_limit = "not-a-size"\n`; const { layer, apiMock, out } = setupService({ toml, format: "json" }); return Effect.gen(function* () { - yield* configPush({ projectRef: Option.none() }); + yield* configPush({ remote: Option.none(), projectRef: Option.none() }); expect(out.stderrText).toContain( "Remote Storage config has 1 difference config push cannot write (see notes below).", ); @@ -2782,7 +2840,9 @@ secret = "new-secret" `; const { layer, apiMock, out } = setupService({ toml, confirm: [false] }); return Effect.gen(function* () { - const error = yield* configPush({ projectRef: Option.none() }).pipe(Effect.flip); + const error = yield* configPush({ remote: Option.none(), projectRef: Option.none() }).pipe( + Effect.flip, + ); expect(error).toBeInstanceOf(HostedMutationDeclinedError); expect(methodsOf(apiMock)).not.toContain("updateAuthServiceConfig"); expect(out.stderrText).toContain("auth.captcha.secret [secret]"); @@ -2802,7 +2862,7 @@ secret = "irrelevant" v1: { updateAuthServiceConfig: () => Effect.succeed({}) }, }); return Effect.gen(function* () { - yield* configPush({ projectRef: Option.none() }); + yield* configPush({ remote: Option.none(), projectRef: Option.none() }); // Declaring `enabled = false` is itself a routed `local_only` change, so the write still // runs even though the gated secret must never ride along inside it. const update = apiMock.requests.find((r) => r.method === "updateAuthServiceConfig"); @@ -2833,7 +2893,7 @@ secret = "irrelevant" yes: true, }); return Effect.gen(function* () { - yield* configPush({ projectRef: Option.none() }); + yield* configPush({ remote: Option.none(), projectRef: Option.none() }); expect(out.stderrText).toContain("Loading config override: [remotes.evil[31m red]"); expect(out.stderrText).not.toContain("\u001b"); }).pipe(Effect.provide(layer)); @@ -2855,7 +2915,7 @@ content_path = "./templates-content-only/invite.html" }); return Effect.gen(function* () { yield* writeWorkdirFile(["templates-content-only", "invite.html"], "<h1>Invite</h1>"); - yield* configPush({ projectRef: Option.none() }); + yield* configPush({ remote: Option.none(), projectRef: Option.none() }); expect(out.stderrText).toContain( "Would update Auth service with:\nauth.email.template.invite.content [content]\n local: (file content from content_path)\n remote: (differs)\n\n", ); @@ -2893,7 +2953,7 @@ secret = "new-secret" v1: { updateAuthServiceConfig: () => Effect.succeed({}) }, }); return Effect.gen(function* () { - yield* configPush({ projectRef: Option.none() }); + yield* configPush({ remote: Option.none(), projectRef: Option.none() }); const success = out.messages.find((m) => m.type === "success"); const data = yield* decodeJsonRecord(success?.data); const services = yield* decodeJsonRecords(data["services"]); @@ -2930,7 +2990,7 @@ secrets = "v1,whsec_abc" v1: { updateAuthServiceConfig: () => Effect.succeed({}) }, }); return Effect.gen(function* () { - yield* configPush({ projectRef: Option.none() }); + yield* configPush({ remote: Option.none(), projectRef: Option.none() }); const update = apiMock.requests.find((r) => r.method === "updateAuthServiceConfig"); expect(update).toBeDefined(); const input = yield* decodeJsonRecord(update?.input); @@ -2956,7 +3016,9 @@ secrets = "v1,whsec_abc" v2: { status: 200, body: [] }, }); return Effect.gen(function* () { - const exit = yield* configPush({ projectRef: Option.none() }).pipe(Effect.exit); + const exit = yield* configPush({ remote: Option.none(), projectRef: Option.none() }).pipe( + Effect.exit, + ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { expect(Cause.pretty(exit.cause)).toContain("ConfigPushConfigReadNetworkError"); @@ -2974,7 +3036,9 @@ secrets = "v1,whsec_abc" v2: { status: 200, malformedJson: true }, }); return Effect.gen(function* () { - const exit = yield* configPush({ projectRef: Option.none() }).pipe(Effect.exit); + const exit = yield* configPush({ remote: Option.none(), projectRef: Option.none() }).pipe( + Effect.exit, + ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { expect(Cause.pretty(exit.cause)).toContain("ConfigPushConfigReadNetworkError"); @@ -2993,7 +3057,7 @@ secrets = "v1,whsec_abc" yes: true, }); return Effect.gen(function* () { - yield* configPush({ projectRef: Option.none() }); + yield* configPush({ remote: Option.none(), projectRef: Option.none() }); const success = out.messages.find((m) => m.type === "success"); expect(success?.message).toBe(`1 property pushed to ${REF}.`); }).pipe(Effect.provide(layer)); @@ -3010,7 +3074,7 @@ secrets = "v1,whsec_abc" v1: { enableDatabaseWebhook: () => Effect.succeed({}) }, }); return Effect.gen(function* () { - yield* configPush({ projectRef: Option.none() }); + yield* configPush({ remote: Option.none(), projectRef: Option.none() }); const success = out.messages.find((m) => m.type === "success"); expect(success?.message).toBe(`1 property pushed to ${REF}.`); const data = yield* decodeJsonRecord(success?.data); @@ -3063,7 +3127,7 @@ describe("config push branch/project target detection (CLI-2168)", () => { () => { const { layer, out, api } = setupLinkedBranchPush({ yes: true }); return Effect.gen(function* () { - yield* configPush({ projectRef: Option.none() }); + yield* configPush({ remote: Option.none(), projectRef: Option.none() }); expect(out.stderrText).toContain(`Target: project ${BRANCH_REF}\n`); expect(out.stderrText).not.toContain("· branch"); expect( @@ -3094,7 +3158,7 @@ describe("config push branch/project target detection (CLI-2168)", () => { v2: { status: 200, body: v2Response({ ref: BRANCH_REF }) }, }); return Effect.gen(function* () { - yield* configPush({ projectRef: Option.none() }); + yield* configPush({ remote: Option.none(), projectRef: Option.none() }); const data = yield* decodeJsonRecord(out.messages.find((m) => m.type === "success")?.data); expect(data["target"]).toEqual({ kind: "linked", @@ -3121,7 +3185,7 @@ describe("config push branch/project target detection (CLI-2168)", () => { v2: { status: 200, body: v2Response({ ref: PROBE_REF }) }, }); return Effect.gen(function* () { - yield* configPush({ projectRef: Option.none() }); + yield* configPush({ remote: Option.none(), projectRef: Option.none() }); expect(out.stderrText).not.toContain("· branch ("); expect(api.requests.some((r) => r.url.includes("/branches"))).toBe(false); @@ -3136,7 +3200,9 @@ describe("config push branch/project target detection (CLI-2168)", () => { confirm: [false], }); return Effect.gen(function* () { - const error = yield* configPush({ projectRef: Option.none() }).pipe(Effect.flip); + const error = yield* configPush({ remote: Option.none(), 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); @@ -3151,7 +3217,7 @@ describe("config push branch/project target detection (CLI-2168)", () => { () => { const { layer, out, api } = setupLinkedBranchPush({ yes: true }); return Effect.gen(function* () { - yield* configPush({ projectRef: Option.none() }); + yield* configPush({ remote: Option.none(), projectRef: Option.none() }); expect(out.stderrText).toContain( `Confirmed by --yes: Push config changes to API on project ${BRANCH_REF}`, ); @@ -3165,7 +3231,7 @@ describe("config push branch/project target detection (CLI-2168)", () => { 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* () { - yield* configPush({ projectRef: Option.none() }); + yield* configPush({ remote: Option.none(), projectRef: Option.none() }); const success = out.messages.find((m) => m.type === "success"); const data = yield* decodeJsonRecord(success?.data); expect(data["project_ref"]).toBe(BRANCH_REF); @@ -3194,7 +3260,7 @@ describe("config push branch/project target detection (CLI-2168)", () => { v2: { status: 200, body: v2Response({ ref: PROBE_REF }) }, }); return Effect.gen(function* () { - yield* configPush({ projectRef: Option.none() }); + yield* configPush({ remote: Option.none(), projectRef: Option.none() }); expect(out.stderrText).not.toContain(PARENT_REF); expect( api.requests.some((r) => r.url.includes(`/v1/projects/${PARENT_REF}/branches`)), @@ -3213,7 +3279,7 @@ describe("config push branch/project target detection (CLI-2168)", () => { v2: { status: 200, body: v2Response({ ref: PROBE_REF }) }, }); return Effect.gen(function* () { - yield* configPush({ projectRef: Option.none() }); + yield* configPush({ remote: Option.none(), projectRef: Option.none() }); expect(out.stderrText).not.toContain("· branch ("); expect(api.requests.some((r) => r.url.includes("/branches"))).toBe(false); }).pipe(Effect.provide(layer)); @@ -3229,7 +3295,7 @@ describe("config push branch/project target detection (CLI-2168)", () => { v2: { status: 200, body: v2Response({ ref: PROBE_REF }) }, }); return Effect.gen(function* () { - yield* configPush({ projectRef: Option.none() }); + yield* configPush({ remote: Option.none(), projectRef: Option.none() }); expect(out.stderrText).not.toContain("not-a-real-ref"); expect(api.requests.some((r) => r.url.includes("/branches"))).toBe(false); }).pipe(Effect.provide(layer)); @@ -3248,7 +3314,7 @@ describe("config push branch/project target detection (CLI-2168)", () => { v2: { status: 200, body: v2Response({ ref: PROBE_REF }) }, }); return Effect.gen(function* () { - yield* configPush({ projectRef: Option.none() }); + yield* configPush({ remote: Option.none(), projectRef: Option.none() }); 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( @@ -3282,7 +3348,7 @@ describe("config push branch/project target detection (CLI-2168)", () => { v2: { status: 200, body: v2Response({ ref: PROBE_REF }) }, }); return Effect.gen(function* () { - yield* configPush({ projectRef: Option.none() }); + yield* configPush({ remote: Option.none(), projectRef: Option.none() }); expect( api.requests.some((r) => r.url.includes(`/v1/projects/${PARENT_REF}/branches`)), ).toBe(true); @@ -3302,7 +3368,7 @@ describe("config push branch/project target detection (CLI-2168)", () => { v2: { status: 200, body: v2Response({ ref: PROBE_REF }) }, }); return Effect.gen(function* () { - yield* configPush({ projectRef: Option.none() }); + yield* configPush({ remote: Option.none(), projectRef: Option.none() }); expect(out.stderrText).toContain(`Target: project ${PROBE_REF}`); expect(api.requests.some((r) => r.method === "PATCH" && r.url.includes("/postgrest"))).toBe( true, @@ -3325,7 +3391,7 @@ describe("config push branch/project target detection (CLI-2168)", () => { v2: { status: 200, body: v2Response({ ref: PROBE_REF }) }, }); return Effect.gen(function* () { - yield* configPush({ projectRef: Option.none() }); + yield* configPush({ remote: Option.none(), projectRef: Option.none() }); const success = out.messages.find((m) => m.type === "success"); const data = yield* decodeJsonRecord(success?.data); expect(data["project_ref"]).toBe(PROBE_REF); @@ -3348,7 +3414,7 @@ describe("config push branch/project target detection (CLI-2168)", () => { v2: { status: 200, body: v2Response({ ref: PROBE_REF }) }, }); return Effect.gen(function* () { - yield* configPush({ projectRef: Option.none() }); + yield* configPush({ remote: Option.none(), projectRef: Option.none() }); const data = yield* decodeJsonRecord(out.messages.find((m) => m.type === "success")?.data); expect(data["target"]).toEqual({ kind: "linked", @@ -3366,9 +3432,14 @@ describe("config push branch/project target detection (CLI-2168)", () => { // `FileSystem` fake. }); -describe("config push --project-ref branch name/UUID resolution (CLI-2289)", () => { +const pushRemoteBranch = (name: string) => ({ + remote: Option.some<RemoteValue>({ _tag: "Branch", name }), + projectRef: Option.none<string>(), +}); + +describe("config push --remote branch name/UUID resolution (CLI-2289)", () => { it.effect( - "--project-ref <branch-name> resolves via the already-known parent, no extra live probe", + "--remote=<branch-name> resolves via the already-known parent, no extra live probe", () => { const { layer, api } = setup({ toml: BRANCH_PUSH_TOML, @@ -3377,7 +3448,7 @@ describe("config push --project-ref branch name/UUID resolution (CLI-2289)", () v2: { status: 200, body: v2Response({ ref: BRANCH_REF }) }, }); return Effect.gen(function* () { - yield* configPush({ projectRef: Option.some("staging") }); + yield* configPush(pushRemoteBranch("staging")); expect( api.requests.some( (r) => r.method === "GET" && new URL(r.url).pathname === `/v1/projects/${BRANCH_REF}`, @@ -3387,13 +3458,13 @@ describe("config push --project-ref branch name/UUID resolution (CLI-2289)", () assertPermissionDrift({ command: "config push", requests: api.requests, - activeFlags: { "project-ref": true }, + activeFlags: { remote: true }, }); }).pipe(Effect.provide(layer)); }, ); - it.effect("--project-ref <branch-name> announces the parent project and resolved branch", () => { + it.effect("--remote=<branch-name> announces the parent project and resolved branch", () => { const { layer, out } = setup({ toml: BRANCH_PUSH_TOML, yes: true, @@ -3401,90 +3472,144 @@ describe("config push --project-ref branch name/UUID resolution (CLI-2289)", () v2: { status: 200, body: v2Response({ ref: BRANCH_REF }) }, }); return Effect.gen(function* () { - yield* configPush({ projectRef: Option.some("staging") }); + yield* configPush(pushRemoteBranch("staging")); expect(out.stderrText).toContain(`Target: project ${REF} · branch staging (${BRANCH_REF})\n`); }).pipe(Effect.provide(layer)); }); - it.effect( - "--project-ref <branch-name> resolution works without a spinner in json output mode", - () => { - const { layer, out } = setup({ - toml: BRANCH_PUSH_TOML, - yes: true, - format: "json", - 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") }); - const success = out.messages.find((m) => m.type === "success"); - const data = yield* decodeJsonRecord(success?.data); - expect(data["is_branch"]).toBe(true); - expect(data["branch"]).toBe("staging"); - expect(data["parent_project_ref"]).toBe(REF); - }).pipe(Effect.provide(layer)); - }, - ); + it.effect("--remote=<branch-name> resolution works without a spinner in json output mode", () => { + const { layer, out } = setup({ + toml: BRANCH_PUSH_TOML, + yes: true, + format: "json", + branchByName: { status: 200, body: BRANCH_BY_NAME }, + v2: { status: 200, body: v2Response({ ref: BRANCH_REF }) }, + }); + return Effect.gen(function* () { + yield* configPush(pushRemoteBranch("staging")); + const success = out.messages.find((m) => m.type === "success"); + const data = yield* decodeJsonRecord(success?.data); + expect(data["is_branch"]).toBe(true); + expect(data["branch"]).toBe("staging"); + expect(data["parent_project_ref"]).toBe(REF); + }).pipe(Effect.provide(layer)); + }); - it.effect( - "--project-ref <uuid> resolves without any linked project (CLI-2289 regression)", - () => { - const { layer, out, api } = setup({ - toml: BRANCH_PUSH_TOML, - yes: true, - projectId: Option.none(), - project: { status: 404, body: {} }, - branchById: { status: 200, body: BRANCH_CONFIG }, - v2: { status: 200, body: v2Response({ ref: UUID_TARGET_REF }) }, + it.effect("--remote=<uuid> resolves without any linked project (CLI-2289 regression)", () => { + const { layer, out, api } = setup({ + toml: BRANCH_PUSH_TOML, + yes: true, + 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(pushRemoteBranch(BRANCH_UUID)).pipe(Effect.exit); + expect(Exit.isSuccess(exit)).toBe(true); + 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); + expect(branchRequests[0]?.url).toContain(`/v1/branches/${BRANCH_UUID}`); + + assertPermissionDrift({ + command: "config push", + requests: api.requests, + activeFlags: { remote: true }, }); - 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(`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); - expect(branchRequests[0]?.url).toContain(`/v1/branches/${BRANCH_UUID}`); + }).pipe(Effect.provide(layer)); + }); - assertPermissionDrift({ - command: "config push", - requests: api.requests, - activeFlags: { "project-ref": true }, - }); - }).pipe(Effect.provide(layer)); - }, - ); + it.effect("--remote=<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(pushRemoteBranch(BRANCH_UUID)).pipe(Effect.flip); + expect(error).toBeInstanceOf(HostedMutationConsentRequiredError); + expect(out.stderrText).toContain(`Target: 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 }) }, + it.effect("--project-ref still resolves a branch name and warns to use --remote", () => { + 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 }) }, + cliArgs: ["config", "push", "--project-ref", "staging"], + }); + return Effect.gen(function* () { + yield* configPush({ remote: Option.none(), projectRef: Option.some("staging") }); + expect( + out.messages.filter((message) => message.type === "warn").map(({ message }) => message), + ).toEqual([ + "--project-ref is deprecated and will be removed in v4. Use --remote instead: supabase config push --remote=staging", + ]); + expect(out.stderrText).toContain(`Target: project ${REF} · branch staging (${BRANCH_REF})\n`); + }).pipe(Effect.provide(layer)); + }); + + it.effect("--remote=<ref> pushes to that project without a branch lookup", () => { + const { layer, api } = setup({ + toml: BRANCH_PUSH_TOML, + yes: true, + projectId: Option.none(), + v2: { status: 200, body: v2Response({ ref: BRANCH_REF }) }, + }); + return Effect.gen(function* () { + yield* configPush({ + remote: Option.some<RemoteValue>({ _tag: "Ref", ref: BRANCH_REF }), + projectRef: Option.none(), }); - return Effect.gen(function* () { - const error = yield* configPush({ projectRef: Option.some(BRANCH_UUID) }).pipe(Effect.flip); - expect(error).toBeInstanceOf(HostedMutationConsentRequiredError); - expect(out.stderrText).toContain(`Target: branch ${UUID_TARGET_REF}`); - expect(api.requests.some((r) => r.method === "PATCH")).toBe(false); - }).pipe(Effect.provide(layer)); - }, - ); + expect(api.requests.some((r) => r.url.includes("/branches/"))).toBe(false); + expect(api.requests.some((r) => r.url.includes(`/v2/projects/${BRANCH_REF}/config`))).toBe( + true, + ); + }).pipe(Effect.provide(layer)); + }); - it.effect("--project-ref <unknown-branch-name> fails with a branches-list suggestion", () => { + it.effect("--remote=<url> is rejected before any request", () => { + const { layer, api } = setup({ toml: BRANCH_PUSH_TOML, yes: true }); + return Effect.gen(function* () { + const error = yield* configPush({ + remote: Option.some<RemoteValue>({ + _tag: "DbUrl", + url: "postgresql://postgres:pw@db.example.com:5432/postgres", + }), + projectRef: Option.none(), + }).pipe(Effect.flip); + expect(error).toMatchObject({ + _tag: "TargetNeedsProjectError", + message: "`supabase config push` needs a project, not a database URL.", + }); + expect(api.requests).toEqual([]); + }).pipe( + Effect.provide( + Layer.mergeAll( + layer, + commandRuntimeLayer(["config", "push"]).pipe(Layer.provide(BunServices.layer)), + ), + ), + ); + }); + + it.effect("--remote=<unknown-branch-name> fails with a branches-list suggestion", () => { const { layer, api, telemetry, linkedProjectCache } = setup({ toml: BRANCH_PUSH_TOML, yes: true, branchByName: { status: 404, body: { message: "not found" } }, }); return Effect.gen(function* () { - const exit = yield* configPush({ projectRef: Option.some("ghost") }).pipe(Effect.exit); + const exit = yield* configPush(pushRemoteBranch("ghost")).pipe(Effect.exit); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { const causeText = Cause.pretty(exit.cause); @@ -3510,7 +3635,7 @@ describe("config push --project-ref branch name/UUID resolution (CLI-2289)", () branchByName: { status: 404, body: { message: "not found" } }, }); return Effect.gen(function* () { - const exit = yield* configPush({ projectRef: Option.some("ghost") }).pipe(Effect.exit); + const exit = yield* configPush(pushRemoteBranch("ghost")).pipe(Effect.exit); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { expect(Cause.pretty(exit.cause)).toContain("ConfigPushBranchNotFoundError"); @@ -3520,14 +3645,14 @@ describe("config push --project-ref branch name/UUID resolution (CLI-2289)", () }, ); - it.effect("--project-ref <branch-name> in an unlinked directory fails naming the value", () => { + it.effect("--remote=<branch-name> in an unlinked directory fails naming the value", () => { const { layer, api, telemetry, linkedProjectCache } = setup({ toml: BRANCH_PUSH_TOML, yes: true, projectId: Option.none(), }); return Effect.gen(function* () { - const exit = yield* configPush({ projectRef: Option.some("somebranch") }).pipe(Effect.exit); + const exit = yield* configPush(pushRemoteBranch("somebranch")).pipe(Effect.exit); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { const causeText = Cause.pretty(exit.cause); @@ -3540,14 +3665,14 @@ describe("config push --project-ref branch name/UUID resolution (CLI-2289)", () }).pipe(Effect.provide(layer)); }); - it.effect("--project-ref <branch-name> with a corrupt linked ref reports it as invalid", () => { + it.effect("--remote=<branch-name> with a corrupt linked ref reports it as invalid", () => { const { layer, api, telemetry, linkedProjectCache } = setup({ toml: BRANCH_PUSH_TOML, yes: true, projectId: Option.some("not-a-valid-ref"), }); return Effect.gen(function* () { - const exit = yield* configPush({ projectRef: Option.some("somebranch") }).pipe(Effect.exit); + const exit = yield* configPush(pushRemoteBranch("somebranch")).pipe(Effect.exit); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { const causeText = Cause.pretty(exit.cause); @@ -3567,7 +3692,7 @@ describe("config push --project-ref branch name/UUID resolution (CLI-2289)", () branchByName: { status: 200, body: { ...BRANCH_BY_NAME, project_ref: "" } }, }); return Effect.gen(function* () { - const exit = yield* configPush({ projectRef: Option.some("staging") }).pipe(Effect.exit); + const exit = yield* configPush(pushRemoteBranch("staging")).pipe(Effect.exit); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { const causeText = Cause.pretty(exit.cause); @@ -3589,7 +3714,7 @@ describe("config push --project-ref branch name/UUID resolution (CLI-2289)", () branchByName: "fail", }); return Effect.gen(function* () { - const exit = yield* configPush({ projectRef: Option.some("staging") }).pipe(Effect.exit); + const exit = yield* configPush(pushRemoteBranch("staging")).pipe(Effect.exit); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { expect(Cause.pretty(exit.cause)).toContain("ConfigPushBranchResolveNetworkError"); @@ -3606,7 +3731,7 @@ describe("config push --project-ref branch name/UUID resolution (CLI-2289)", () branchByName: { status: 500, body: { message: "boom" } }, }); return Effect.gen(function* () { - const exit = yield* configPush({ projectRef: Option.some("staging") }).pipe(Effect.exit); + const exit = yield* configPush(pushRemoteBranch("staging")).pipe(Effect.exit); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { expect(Cause.pretty(exit.cause)).toContain("ConfigPushBranchResolveStatusError"); @@ -3620,33 +3745,56 @@ describe("config push --project-ref branch name/UUID resolution (CLI-2289)", () describe("config push telemetry wiring", () => { // Drives the `Command.withHandler` wiring (`configPushHandler`), not the bare handler: the // safeFlags guard lives in the wiring, and nothing validates `--project-ref` before it fires. - const wiringLayer = (analytics: ReturnType<typeof mockAnalytics>, projectRef: string) => + const wiringLayer = (analytics: ReturnType<typeof mockAnalytics>, args: ReadonlyArray<string>) => Layer.mergeAll( setup({ toml: `project_id = "test"\n`, yes: true, analytics }).layer, commandRuntimeLayer(["config", "push"]).pipe(Layer.provide(BunServices.layer)), - Stdio.layerTest({ - args: Effect.succeed(["config", "push", "--project-ref", projectRef]), - }), + Stdio.layerTest({ args: Effect.succeed(["config", "push", ...args]) }), ); + const flagsOf = (analytics: ReturnType<typeof mockAnalytics>) => + analytics.captured.find((c) => c.event === "cli_command_executed")?.properties["flags"]; + + it.effect("logs a ref-shaped --remote verbatim in cli_command_executed", () => { + const analytics = mockContextualAnalytics(); + return Effect.gen(function* () { + yield* Effect.exit( + configPushHandler({ + remote: Option.some<RemoteValue>({ _tag: "Ref", ref: REF }), + projectRef: Option.none(), + }), + ); + expect(flagsOf(analytics)).toEqual({ remote: REF }); + }).pipe(Effect.provide(wiringLayer(analytics, [`--remote=${REF}`]))); + }); + + it.effect("redacts a branch-name --remote", () => { + const analytics = mockContextualAnalytics(); + return Effect.gen(function* () { + yield* Effect.exit(configPushHandler(pushRemoteBranch("staging"))); + expect(flagsOf(analytics)).toEqual({ remote: "<redacted>" }); + }).pipe(Effect.provide(wiringLayer(analytics, ["--remote=staging"]))); + }); + it.effect("logs a ref-shaped --project-ref verbatim in cli_command_executed", () => { const analytics = mockContextualAnalytics(); - const ref = REF; return Effect.gen(function* () { - yield* Effect.exit(configPushHandler({ projectRef: Option.some(ref) })); - const event = analytics.captured.find((c) => c.event === "cli_command_executed"); - expect(event?.properties["flags"]).toEqual({ "project-ref": ref }); - }).pipe(Effect.provide(wiringLayer(analytics, ref))); + yield* Effect.exit( + configPushHandler({ remote: Option.none(), projectRef: Option.some(REF) }), + ); + expect(flagsOf(analytics)).toEqual({ "project-ref": REF }); + }).pipe(Effect.provide(wiringLayer(analytics, ["--project-ref", REF]))); }); it.effect("redacts a --project-ref value that is not ref-shaped", () => { const analytics = mockContextualAnalytics(); const value = "s3cret-paste-mistake"; return Effect.gen(function* () { - yield* Effect.exit(configPushHandler({ projectRef: Option.some(value) })); - const event = analytics.captured.find((c) => c.event === "cli_command_executed"); - expect(event?.properties["flags"]).toEqual({ "project-ref": "<redacted>" }); - }).pipe(Effect.provide(wiringLayer(analytics, value))); + yield* Effect.exit( + configPushHandler({ remote: Option.none(), projectRef: Option.some(value) }), + ); + expect(flagsOf(analytics)).toEqual({ "project-ref": "<redacted>" }); + }).pipe(Effect.provide(wiringLayer(analytics, ["--project-ref", value]))); }); }); @@ -3663,6 +3811,8 @@ describeHostedConsent("config push", { ); }).pipe(Effect.provide(BunServices.layer)), invoke: () => ({ - run: configPush({ projectRef: Option.none() }).pipe(Effect.provide(cliConfigValuesTestLayer)), + run: configPush({ remote: Option.none(), projectRef: Option.none() }).pipe( + Effect.provide(cliConfigValuesTestLayer), + ), }), }); 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 b54ed32f6c..9f91574859 100644 --- a/apps/cli/src/commands/domains/activate/activate.integration.test.ts +++ b/apps/cli/src/commands/domains/activate/activate.integration.test.ts @@ -14,6 +14,7 @@ import { useTempWorkdir, } from "../../../../tests/helpers/command-mocks.ts"; import { describeHostedConsent } from "../../../../tests/helpers/hosted-consent.ts"; +import { describeHostedTargetSelection } from "../../../../tests/helpers/hosted-target-selection.ts"; import { assertPermissionDrift } from "../../../../tests/helpers/permission-drift.ts"; import { projectResponse, @@ -83,7 +84,11 @@ function setup(opts: SetupOpts = {}) { return { layer, out, api, analytics, telemetry, linkedProjectCache }; } -const baseFlags = { projectRef: Option.none<string>(), includeRawOutput: Option.none<boolean>() }; +const baseFlags = { + remote: Option.none(), + projectRef: Option.none<string>(), + includeRawOutput: Option.none<boolean>(), +}; describe("domains activate integration", () => { it.effect("suggests upgrade from entitlement_required envelope on 400", () => { @@ -241,3 +246,8 @@ describeHostedConsent("domains activate", { api: { response: { status: 201, body: HOSTNAME_RESPONSE } }, invoke: () => ({ run: domainsActivate(baseFlags) }), }); + +describeHostedTargetSelection("domains activate", { + respond: (request) => Effect.succeed(jsonResponse(request, 201, HOSTNAME_RESPONSE)), + run: (flags) => domainsActivate({ ...baseFlags, ...flags }), +}); 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 b4ae502a5d..a73af421a2 100644 --- a/apps/cli/src/commands/domains/create/create.integration.test.ts +++ b/apps/cli/src/commands/domains/create/create.integration.test.ts @@ -19,6 +19,7 @@ import { } from "../../../../tests/helpers/command-mocks.ts"; import { DomainsCnameError } from "../domains.errors.ts"; import { describeHostedConsent } from "../../../../tests/helpers/hosted-consent.ts"; +import { describeHostedTargetSelection } from "../../../../tests/helpers/hosted-target-selection.ts"; import { assertPermissionDrift } from "../../../../tests/helpers/permission-drift.ts"; import { projectResponse, @@ -125,6 +126,7 @@ function setup(opts: SetupOpts = {}) { function flags() { return { + remote: Option.none(), projectRef: Option.none<string>(), customHostname: CUSTOM_HOSTNAME, includeRawOutput: Option.none<boolean>(), @@ -395,3 +397,13 @@ describeHostedConsent("domains create", { api: apiOptions({}), invoke: () => ({ run: domainsCreate(flags()) }), }); + +describeHostedTargetSelection("domains create", { + respond: (request, ref) => + Effect.succeed( + request.url.includes("1.1.1.1") + ? jsonResponse(request, 200, { Answer: [{ type: 5, data: `${ref}.supabase.co.` }] }) + : jsonResponse(request, 201, HOSTNAME_RESPONSE), + ), + run: (selection) => domainsCreate({ ...flags(), ...selection }), +}); 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 0300689908..7af583b03c 100644 --- a/apps/cli/src/commands/domains/delete/delete.integration.test.ts +++ b/apps/cli/src/commands/domains/delete/delete.integration.test.ts @@ -14,6 +14,7 @@ import { unverifiedTargetBanner, } from "../../../../tests/helpers/command-mocks.ts"; import { describeHostedConsent } from "../../../../tests/helpers/hosted-consent.ts"; +import { describeHostedTargetSelection } from "../../../../tests/helpers/hosted-target-selection.ts"; import { assertPermissionDrift } from "../../../../tests/helpers/permission-drift.ts"; import { projectResponse, @@ -64,7 +65,11 @@ function setup(opts: SetupOpts = {}) { return { layer, out, api, telemetry, linkedProjectCache }; } -const baseFlags = { projectRef: Option.none<string>(), includeRawOutput: Option.none<boolean>() }; +const baseFlags = { + remote: Option.none(), + projectRef: Option.none<string>(), + includeRawOutput: Option.none<boolean>(), +}; describe("domains delete integration", () => { it.effect("prints the success line to stderr in text mode", () => { @@ -206,3 +211,8 @@ describeHostedConsent("domains delete", { api: { response: { status: 200, body: {} } }, invoke: () => ({ run: domainsDelete(baseFlags) }), }); + +describeHostedTargetSelection("domains delete", { + respond: (request) => Effect.succeed(jsonResponse(request, 200, {})), + run: (flags) => domainsDelete({ ...baseFlags, ...flags }), +}); 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 1231193747..ab61e36eba 100644 --- a/apps/cli/src/commands/domains/get/get.integration.test.ts +++ b/apps/cli/src/commands/domains/get/get.integration.test.ts @@ -14,6 +14,7 @@ import { useTempWorkdir, unverifiedTargetBanner, } from "../../../../tests/helpers/command-mocks.ts"; +import { describeHostedTargetSelection } from "../../../../tests/helpers/hosted-target-selection.ts"; import { assertPermissionDrift } from "../../../../tests/helpers/permission-drift.ts"; import { projectResponse, @@ -88,7 +89,11 @@ function setup(opts: SetupOpts = {}) { return { layer, out, api, analytics, telemetry, linkedProjectCache }; } -const baseFlags = { projectRef: Option.none<string>(), includeRawOutput: Option.none<boolean>() }; +const baseFlags = { + remote: Option.none(), + projectRef: Option.none<string>(), + includeRawOutput: Option.none<boolean>(), +}; describe("domains get integration", () => { it.effect("prints the hostname status to stderr in text mode", () => { @@ -485,3 +490,8 @@ describe("domains get integration", () => { }, ); }); + +describeHostedTargetSelection("domains get", { + respond: (request) => Effect.succeed(jsonResponse(request, 200, HOSTNAME_RESPONSE)), + run: (flags) => domainsGet({ ...baseFlags, ...flags }), +}); 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 fc119c1390..5eb4006728 100644 --- a/apps/cli/src/commands/domains/reverify/reverify.integration.test.ts +++ b/apps/cli/src/commands/domains/reverify/reverify.integration.test.ts @@ -14,6 +14,7 @@ import { useTempWorkdir, unverifiedTargetBanner, } from "../../../../tests/helpers/command-mocks.ts"; +import { describeHostedTargetSelection } from "../../../../tests/helpers/hosted-target-selection.ts"; import { assertPermissionDrift } from "../../../../tests/helpers/permission-drift.ts"; import { projectResponse, @@ -75,7 +76,11 @@ function setup(opts: SetupOpts = {}) { return { layer, out, api, analytics, telemetry, linkedProjectCache }; } -const baseFlags = { projectRef: Option.none<string>(), includeRawOutput: Option.none<boolean>() }; +const baseFlags = { + remote: Option.none(), + projectRef: Option.none<string>(), + includeRawOutput: Option.none<boolean>(), +}; describe("domains reverify integration", () => { it.effect("suggests upgrade from entitlement_required envelope on 400", () => { @@ -224,3 +229,8 @@ describe("domains reverify integration", () => { }, ); }); + +describeHostedTargetSelection("domains reverify", { + respond: (request) => Effect.succeed(jsonResponse(request, 201, HOSTNAME_RESPONSE)), + run: (flags) => domainsReverify({ ...baseFlags, ...flags }), +}); 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 1590deb216..333c0fb598 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 @@ -13,6 +13,7 @@ import { useTempWorkdir, unverifiedTargetBanner, } from "../../../../tests/helpers/command-mocks.ts"; +import { describeHostedTargetSelection } from "../../../../tests/helpers/hosted-target-selection.ts"; import { assertPermissionDrift } from "../../../../tests/helpers/permission-drift.ts"; import { projectResponse, @@ -55,7 +56,7 @@ function setup(opts: SetupOpts = {}) { return { layer, out, api, telemetry, linkedProjectCache }; } -const baseFlags = { projectRef: Option.none<string>() }; +const baseFlags = { remote: Option.none(), projectRef: Option.none<string>() }; describe("encryption get-root-key integration", () => { it.effect("prints the root key to stdout in text mode", () => { @@ -86,15 +87,18 @@ describe("encryption get-root-key integration", () => { }).pipe(Effect.provide(layer)); }); - it.effect("resolves the ref from the --project-ref flag", () => { + it.effect("resolves the ref from the --remote=<ref> flag", () => { const flagRef = "zzzzzzzzzzzzzzzzzzzz"; const { layer, api } = setup(); return Effect.gen(function* () { - yield* encryptionGetRootKey({ projectRef: Option.some(flagRef) }); + yield* encryptionGetRootKey({ + remote: Option.some({ _tag: "Ref", ref: flagRef }), + projectRef: Option.none(), + }); expect(api.requests[0]?.url).toContain(`/v1/projects/${flagRef}/pgsodium`); assertPermissionDrift({ command: "encryption get-root-key", - activeFlags: { "project-ref": true }, + activeFlags: { remote: flagRef }, requests: api.requests, exact: true, }); @@ -215,3 +219,8 @@ describe("encryption get-root-key integration", () => { }).pipe(Effect.provide(layer)); }); }); + +describeHostedTargetSelection("encryption get-root-key", { + respond: (request) => Effect.succeed(jsonResponse(request, 200, ROOT_KEY_RESPONSE)), + run: (flags) => encryptionGetRootKey(flags), +}); 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 14b056ced6..b90b7b371a 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 @@ -16,6 +16,7 @@ import { } from "../../../../tests/helpers/command-mocks.ts"; import { YesFlag } from "../../../command-internal/global-flags.ts"; import { TargetPolicy } from "../../../command-internal/target/target-policy.ts"; +import { describeHostedTargetSelection } from "../../../../tests/helpers/hosted-target-selection.ts"; import { assertPermissionDrift } from "../../../../tests/helpers/permission-drift.ts"; import { projectResponse, @@ -70,7 +71,7 @@ function setup(opts: SetupOpts = {}) { return { layer, out, api, telemetry, linkedProjectCache }; } -const baseFlags = { projectRef: Option.none<string>() }; +const baseFlags = { remote: Option.none(), projectRef: Option.none<string>() }; describe("encryption update-root-key integration", () => { it.effect("reads a piped root key and PUTs it, printing the finished message to stderr", () => { @@ -268,8 +269,14 @@ describe("encryption update-root-key integration", () => { 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")), - ), + run: encryptionUpdateRootKey({ + remote: Option.some({ _tag: "Ref", ref: VALID_REF }), + projectRef: Option.none(), + }).pipe(Effect.provide(mockStdin(false, "new-key"))), }), }); + +describeHostedTargetSelection("encryption update-root-key", { + respond: (request) => Effect.succeed(jsonResponse(request, 200, ROOT_KEY_RESPONSE)), + run: (flags) => encryptionUpdateRootKey(flags).pipe(Effect.provide(mockStdin(false, "new-key"))), +}); diff --git a/apps/cli/src/commands/experimental/compute/compute.output.unit.test.ts b/apps/cli/src/commands/experimental/compute/compute.output.unit.test.ts index 2c80a4aa73..9193439ba8 100644 --- a/apps/cli/src/commands/experimental/compute/compute.output.unit.test.ts +++ b/apps/cli/src/commands/experimental/compute/compute.output.unit.test.ts @@ -1,22 +1,37 @@ import { Option } from "effect"; import { describe, expect, it } from "vitest"; -import { computeProjectRefSuffix } from "./compute.output.ts"; +import { computeRemoteSuffix } from "./compute.output.ts"; -describe("computeProjectRefSuffix", () => { - it("carries an explicit --project-ref into the suggestion", () => { - expect(computeProjectRefSuffix(Option.some("abcdefghijklmnopqrst"))).toBe( - " --project-ref abcdefghijklmnopqrst", +const REF = "abcdefghijklmnopqrst"; +const none = { remote: Option.none(), projectRef: Option.none() } as const; + +describe("computeRemoteSuffix", () => { + it("carries an explicit --remote=<ref> into the suggestion", () => { + expect( + computeRemoteSuffix({ ...none, remote: Option.some({ _tag: "Ref", ref: REF }) }, REF), + ).toBe(` --remote=${REF}`); + }); + + it("carries the deprecated --project-ref as --remote=<ref>", () => { + expect(computeRemoteSuffix({ ...none, projectRef: Option.some(REF) }, REF)).toBe( + ` --remote=${REF}`, ); }); + it("carries a branch as the ref it resolved to", () => { + expect( + computeRemoteSuffix( + { ...none, remote: Option.some({ _tag: "Branch", name: "staging" }) }, + REF, + ), + ).toBe(` --remote=${REF}`); + }); + it("adds nothing when the ref came from the link", () => { - expect(computeProjectRefSuffix(Option.none())).toBe(""); + expect(computeRemoteSuffix(none, REF)).toBe(""); }); - // `--project-ref ""` resolves from the environment or the linked-project file, - // so echoing the flag back would suggest a command ending in a valueless - // `--project-ref` that cannot be pasted and re-run. - it("adds nothing when the flag was supplied empty", () => { - expect(computeProjectRefSuffix(Option.some(""))).toBe(""); + it("adds nothing for a bare --remote, which names the linked project", () => { + expect(computeRemoteSuffix({ ...none, remote: Option.some({ _tag: "Linked" }) }, REF)).toBe(""); }); }); 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 24e80dca34..a5c51f3e4e 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 @@ -9,6 +9,7 @@ import { computeRoute, COMPUTE_PROJECT_REF, } from "../../../../../tests/helpers/compute.ts"; +import { describeComputeTargetSelection } from "../../../../../tests/helpers/compute-target-selection.ts"; import { assertPermissionDrift } from "../../../../../tests/helpers/permission-drift.ts"; import { projectResponse } from "../../../../../tests/helpers/permission-real-layers.ts"; import { @@ -64,6 +65,7 @@ describe("compute delete", () => { return yield* Effect.gen(function* () { yield* computeDelete({ name: "api", + remote: Option.none(), projectRef: Option.none(), }); @@ -114,7 +116,7 @@ describe("compute delete", () => { return yield* Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; - yield* computeDelete({ name: "api", projectRef: Option.none() }); + yield* computeDelete({ name: "api", remote: Option.none(), projectRef: Option.none() }); expect(http.routeKeys).toEqual([ "GET /v1/projects", @@ -156,7 +158,11 @@ describe("compute delete", () => { }); return yield* Effect.gen(function* () { - yield* computeDelete({ name: "api", projectRef: Option.some(otherRef) }); + yield* computeDelete({ + name: "api", + remote: Option.some({ _tag: "Ref", ref: otherRef }), + projectRef: Option.none(), + }); expect(http.routeKeys).toContain(`DELETE /v2/projects/${otherRef}/compute/api`); }).pipe(Effect.provide(layer)); @@ -179,7 +185,7 @@ describe("compute delete", () => { }); return yield* Effect.gen(function* () { - yield* computeDelete({ name: "api", projectRef: Option.none() }); + yield* computeDelete({ name: "api", remote: Option.none(), projectRef: Option.none() }); expect(http.routeKeys).toEqual([getRoute, deleteRoute]); assertPermissionDrift({ @@ -204,7 +210,7 @@ describe("compute delete", () => { }); return yield* Effect.gen(function* () { - yield* computeDelete({ name: "api", projectRef: Option.none() }); + yield* computeDelete({ name: "api", remote: Option.none(), projectRef: Option.none() }); expect(out.stderrText).toContain('Permanently delete compute "api"'); // No count is quoted: the read that would have supplied one was refused. @@ -228,7 +234,7 @@ describe("compute delete", () => { }); return yield* Effect.gen(function* () { - yield* computeDelete({ name: "api", projectRef: Option.none() }); + yield* computeDelete({ name: "api", remote: Option.none(), projectRef: Option.none() }); expect(out.stdoutText).toContain("Deleted Compute"); expect(out.stdoutText).not.toContain("nothing to delete"); @@ -255,6 +261,7 @@ describe("compute delete", () => { return yield* Effect.gen(function* () { const error = yield* computeDelete({ name: "api", + remote: Option.none(), projectRef: Option.none(), }).pipe(Effect.flip); @@ -270,7 +277,7 @@ describe("compute delete", () => { const { layer, out, http } = setupCompute({ workdir: repo.dir, routes, yes: true }); return yield* Effect.gen(function* () { - yield* computeDelete({ name: "api", projectRef: Option.none() }); + yield* computeDelete({ name: "api", remote: Option.none(), projectRef: Option.none() }); expect(http.routeKeys).toEqual([getRoute, deleteRoute]); expect(out.promptTextCalls).toEqual([]); @@ -295,6 +302,7 @@ describe("compute delete", () => { return yield* Effect.gen(function* () { const error = yield* computeDelete({ name: "api", + remote: Option.none(), projectRef: Option.none(), }).pipe(Effect.flip); @@ -330,6 +338,7 @@ describe("compute delete", () => { return yield* Effect.gen(function* () { const error = yield* computeDelete({ name: "api", + remote: Option.none(), projectRef: Option.none(), }).pipe(Effect.flip); @@ -361,6 +370,7 @@ describe("compute delete", () => { return yield* Effect.gen(function* () { const error = yield* computeDelete({ name: "api", + remote: Option.none(), projectRef: Option.none(), }).pipe(Effect.flip); @@ -383,6 +393,7 @@ describe("compute delete", () => { return yield* Effect.gen(function* () { const error = yield* computeDelete({ name: "api", + remote: Option.none(), projectRef: Option.none(), }).pipe(Effect.flip); @@ -409,6 +420,7 @@ describe("compute delete", () => { return yield* Effect.gen(function* () { const error = yield* computeDelete({ name: "api", + remote: Option.none(), projectRef: Option.none(), }).pipe(Effect.flip); @@ -432,6 +444,7 @@ describe("compute delete", () => { return yield* Effect.gen(function* () { const error = yield* computeDelete({ name: "api", + remote: Option.none(), projectRef: Option.none(), }).pipe(Effect.flip); @@ -454,7 +467,7 @@ describe("compute delete", () => { }); return yield* Effect.gen(function* () { - yield* computeDelete({ name: "api", projectRef: Option.none() }); + yield* computeDelete({ name: "api", remote: Option.none(), projectRef: Option.none() }); // Nothing to delete, so nothing is asked of the API beyond the lookup. expect(http.routeKeys).toEqual([getRoute]); @@ -475,7 +488,7 @@ describe("compute delete", () => { }); return yield* Effect.gen(function* () { - yield* computeDelete({ name: "api", projectRef: Option.none() }); + yield* computeDelete({ name: "api", remote: Option.none(), projectRef: Option.none() }); const parsed: unknown = yield* Schema.decodeEffect(Schema.fromJsonString(Schema.Unknown))( out.stdoutText, @@ -500,7 +513,7 @@ describe("compute delete", () => { }); return yield* Effect.gen(function* () { - yield* computeDelete({ name: "api", projectRef: Option.none() }); + yield* computeDelete({ name: "api", remote: Option.none(), projectRef: Option.none() }); expect(out.stdoutText).toContain("Kept"); }).pipe(Effect.provide(layer)); @@ -519,6 +532,7 @@ describe("compute delete", () => { return yield* Effect.gen(function* () { const error = yield* computeDelete({ name: "api", + remote: Option.none(), projectRef: Option.none(), }).pipe(Effect.flip); @@ -539,7 +553,7 @@ describe("compute delete", () => { }); return yield* Effect.gen(function* () { - yield* computeDelete({ name: "api", projectRef: Option.none() }); + yield* computeDelete({ name: "api", remote: Option.none(), projectRef: Option.none() }); const success = out.messages.findLast( (message) => message.type === "success" && message.data !== undefined, @@ -579,7 +593,7 @@ describe("compute delete", () => { }); return yield* Effect.gen(function* () { - yield* computeDelete({ name: "api", projectRef: Option.none() }); + yield* computeDelete({ name: "api", remote: Option.none(), projectRef: Option.none() }); expect(out.stderrText).toContain("1 running instance will be terminated"); expect(out.stderrText).not.toContain("3 running"); @@ -609,7 +623,7 @@ describe("compute delete", () => { }); return yield* Effect.gen(function* () { - yield* computeDelete({ name: "api", projectRef: Option.none() }); + yield* computeDelete({ name: "api", remote: Option.none(), projectRef: Option.none() }); expect(out.stderrText).toContain("2 running instances will be terminated"); }).pipe(Effect.provide(layer)); @@ -640,7 +654,7 @@ describe("compute delete", () => { }); return yield* Effect.gen(function* () { - yield* computeDelete({ name: "api", projectRef: Option.none() }); + yield* computeDelete({ name: "api", remote: Option.none(), projectRef: Option.none() }); expect(out.stderrText).toContain('Permanently delete compute "api"'); expect(out.stderrText).not.toContain("will be terminated"); @@ -667,7 +681,7 @@ describe("compute delete", () => { }); return yield* Effect.gen(function* () { - yield* computeDelete({ name: "stray", projectRef: Option.none() }); + yield* computeDelete({ name: "stray", remote: Option.none(), projectRef: Option.none() }); expect(out.stdoutText).toContain("Deleted Compute"); expect(out.stdoutText).not.toContain("Kept"); @@ -693,7 +707,7 @@ describe("compute delete", () => { }); return yield* Effect.gen(function* () { - yield* computeDelete({ name: "root", projectRef: Option.none() }); + yield* computeDelete({ name: "root", remote: Option.none(), projectRef: Option.none() }); expect(out.stdoutText).toContain("Deleted Compute"); }).pipe(Effect.provide(layer)); @@ -714,7 +728,7 @@ describe("compute delete", () => { const { layer, out } = setupCompute({ workdir: repo.dir, routes, yes: true }); return yield* Effect.gen(function* () { - yield* computeDelete({ name: "api", projectRef: Option.none() }); + yield* computeDelete({ name: "api", remote: Option.none(), projectRef: Option.none() }); expect(out.stdoutText).toContain("supabase/config.toml entry"); expect(out.stdoutText).not.toContain("compute push api"); @@ -732,7 +746,7 @@ describe("compute delete", () => { const { layer, out, http } = setupCompute({ workdir: repo.dir, routes, yes: true }); return yield* Effect.gen(function* () { - yield* computeDelete({ name: "api", projectRef: Option.none() }); + yield* computeDelete({ name: "api", remote: Option.none(), projectRef: Option.none() }); expect(http.routeKeys).toContain(deleteRoute); expect(out.stdoutText).toContain("Deleted Compute"); @@ -752,10 +766,22 @@ describeHostedConsent("compute delete", { invoke: ({ workdir }) => { const { http } = setupCompute({ workdir, routes }); return { - run: computeDelete({ name: "api", projectRef: Option.none() }).pipe( + run: computeDelete({ name: "api", remote: Option.none(), projectRef: Option.none() }).pipe( Effect.provide(Layer.mergeAll(http.layer, configValuesLayer())), ), mutations: () => http.requests.filter((request) => request.method === "DELETE").length, }; }, }); + +describeComputeTargetSelection("compute delete", { + operands: ["api"], + routes: (ref) => ({ + [`GET /v2/projects/${ref}/compute/api`]: { + status: 200, + body: { data: computeResource({ name: "api", runtime: "node" }) }, + }, + [`DELETE /v2/projects/${ref}/compute/api`]: { status: 204 }, + }), + run: (flags) => computeDelete({ name: "api", ...flags }), +}); diff --git a/apps/cli/src/commands/experimental/compute/list/list.integration.test.ts b/apps/cli/src/commands/experimental/compute/list/list.integration.test.ts index 39ffa9a863..1382f1eb29 100644 --- a/apps/cli/src/commands/experimental/compute/list/list.integration.test.ts +++ b/apps/cli/src/commands/experimental/compute/list/list.integration.test.ts @@ -8,6 +8,7 @@ import { computeRoute, COMPUTE_PROJECT_REF, } from "../../../../../tests/helpers/compute.ts"; +import { describeComputeTargetSelection } from "../../../../../tests/helpers/compute-target-selection.ts"; import { assertPermissionDrift } from "../../../../../tests/helpers/permission-drift.ts"; import { projectResponse } from "../../../../../tests/helpers/permission-real-layers.ts"; import { resolveWorkdir } from "../../../../config/command-settings.layer.ts"; @@ -62,7 +63,7 @@ describe("compute list", () => { }); return yield* Effect.gen(function* () { - yield* computeList({ projectRef: Option.none() }); + yield* computeList({ remote: Option.none(), projectRef: Option.none() }); const stdout = out.stdoutText; expect(stdout).toContain("NAME"); @@ -112,7 +113,7 @@ describe("compute list", () => { return yield* Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; - yield* computeList({ projectRef: Option.none() }); + yield* computeList({ remote: Option.none(), projectRef: Option.none() }); expect(http.routeKeys).toEqual([ "GET /v1/projects", @@ -141,7 +142,7 @@ describe("compute list", () => { }); return yield* Effect.gen(function* () { - yield* computeList({ projectRef: Option.none() }); + yield* computeList({ remote: Option.none(), projectRef: Option.none() }); const row = out.stdoutText.split("\n").find((line) => line.includes("ghost")); expect(row).toBeDefined(); @@ -173,7 +174,7 @@ describe("compute list", () => { }); return yield* Effect.gen(function* () { - yield* computeList({ projectRef: Option.none() }); + yield* computeList({ remote: Option.none(), projectRef: Option.none() }); expect(out.stderrText).toContain("stray"); expect(out.stderrText).toContain("guess the runtime"); @@ -209,7 +210,7 @@ describe("compute list", () => { }); return yield* Effect.gen(function* () { - yield* computeList({ projectRef: Option.none() }); + yield* computeList({ remote: Option.none(), projectRef: Option.none() }); expect(out.stderrText).toContain("spare, stray are deployed but not in"); expect(out.stderrText).toContain("guess the runtime"); @@ -234,7 +235,7 @@ describe("compute list", () => { }); return yield* Effect.gen(function* () { - yield* computeList({ projectRef: Option.none() }); + yield* computeList({ remote: Option.none(), projectRef: Option.none() }); expect(out.stdoutText).toContain("deleting"); }).pipe(Effect.provide(layer)); @@ -258,7 +259,7 @@ describe("compute list", () => { }); return yield* Effect.gen(function* () { - yield* computeList({ projectRef: Option.none() }); + yield* computeList({ remote: Option.none(), projectRef: Option.none() }); expect(out.stderrText).toContain("no source in this project"); expect(out.stderrText).not.toContain("guess the runtime"); @@ -275,7 +276,7 @@ describe("compute list", () => { }); return yield* Effect.gen(function* () { - yield* computeList({ projectRef: Option.none() }); + yield* computeList({ remote: Option.none(), projectRef: Option.none() }); expect(out.stdoutText).toContain( "No compute found. Scaffold one with supabase compute new <name>.", @@ -299,7 +300,7 @@ describe("compute list", () => { }); return yield* Effect.gen(function* () { - yield* computeList({ projectRef: Option.none() }); + yield* computeList({ remote: Option.none(), projectRef: Option.none() }); const success = out.messages.findLast( (message) => message.type === "success" && message.data !== undefined, @@ -348,7 +349,7 @@ describe("compute list", () => { }); return yield* Effect.gen(function* () { - yield* computeList({ projectRef: Option.none() }); + yield* computeList({ remote: Option.none(), projectRef: Option.none() }); // `-o` payloads own stdout outright: no clack success line may share it. const parsed = yield* Schema.decodeEffect( @@ -373,7 +374,9 @@ describe("compute list", () => { }); return yield* Effect.gen(function* () { - const error = yield* computeList({ projectRef: Option.none() }).pipe(Effect.flip); + const error = yield* computeList({ remote: Option.none(), projectRef: Option.none() }).pipe( + Effect.flip, + ); expect(error).toBeInstanceOf(ComputeEnvNotSupportedError); expect(http.routeKeys).toEqual([]); @@ -403,7 +406,9 @@ describe("compute list", () => { }); return yield* Effect.gen(function* () { - const error = yield* computeList({ projectRef: Option.none() }).pipe(Effect.flip); + const error = yield* computeList({ remote: Option.none(), projectRef: Option.none() }).pipe( + Effect.flip, + ); expect(error).toBeInstanceOf(ComputeUnavailableError); }).pipe(Effect.provide(layer)); @@ -429,7 +434,9 @@ describe("compute list", () => { }); return yield* Effect.gen(function* () { - const error = yield* computeList({ projectRef: Option.none() }).pipe(Effect.flip); + const error = yield* computeList({ remote: Option.none(), projectRef: Option.none() }).pipe( + Effect.flip, + ); expect(error).toBeInstanceOf(ComputeUnavailableError); expect(error).not.toBeInstanceOf(ComputeProjectNotFoundError); @@ -456,7 +463,9 @@ describe("compute list", () => { }); return yield* Effect.gen(function* () { - const error = yield* computeList({ projectRef: Option.none() }).pipe(Effect.flip); + const error = yield* computeList({ remote: Option.none(), projectRef: Option.none() }).pipe( + Effect.flip, + ); assert.instanceOf(error, ComputeRouteNotFoundError); expect(error).not.toBeInstanceOf(ComputeProjectNotFoundError); @@ -481,7 +490,9 @@ describe("compute list", () => { }); return yield* Effect.gen(function* () { - const error = yield* computeList({ projectRef: Option.none() }).pipe(Effect.flip); + const error = yield* computeList({ remote: Option.none(), projectRef: Option.none() }).pipe( + Effect.flip, + ); expect(error).toBeInstanceOf(ComputeProjectNotFoundError); expect(error).toMatchObject({ suggestion: expect.stringContaining("supabase link") }); @@ -498,7 +509,9 @@ describe("compute list", () => { }); return yield* Effect.gen(function* () { - const error = yield* computeList({ projectRef: Option.none() }).pipe(Effect.flip); + const error = yield* computeList({ remote: Option.none(), projectRef: Option.none() }).pipe( + Effect.flip, + ); expect(error).toBeInstanceOf(ComputeApiUnexpectedStatusError); expect(http.routeKeys).toEqual([listRoute]); @@ -511,7 +524,7 @@ describe("compute list", () => { }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), ); - it.effect("uses an explicit --project-ref without a linked project", () => + it.effect("uses an explicit --remote=<ref> without a linked project", () => Effect.gen(function* () { const repo = yield* project(); const { layer, http } = setupCompute({ @@ -523,13 +536,16 @@ describe("compute list", () => { }); return yield* Effect.gen(function* () { - yield* computeList({ projectRef: Option.some("qrstuvwxyzabcdefghij") }); + yield* computeList({ + remote: Option.some({ _tag: "Ref", ref: "qrstuvwxyzabcdefghij" }), + projectRef: Option.none(), + }); expect(http.routeKeys).toEqual(["GET /v2/projects/qrstuvwxyzabcdefghij/compute"]); assertPermissionDrift({ command: "compute list", variant: { computeEnabled: true }, - activeFlags: { "project-ref": "qrstuvwxyzabcdefghij" }, + activeFlags: { remote: "qrstuvwxyzabcdefghij" }, requests: http.driftRequests, exact: true, }); @@ -543,7 +559,9 @@ describe("compute list", () => { const { layer } = setupCompute({ workdir: repo.dir, linked: false }); return yield* Effect.gen(function* () { - const error = yield* computeList({ projectRef: Option.none() }).pipe(Effect.flip); + const error = yield* computeList({ remote: Option.none(), projectRef: Option.none() }).pipe( + Effect.flip, + ); expect(error).toBeInstanceOf(ProjectRefNotLinkedError); }).pipe(Effect.provide(layer)); @@ -571,7 +589,7 @@ describe("compute list", () => { }); return yield* Effect.gen(function* () { - yield* computeList({ projectRef: Option.none() }); + yield* computeList({ remote: Option.none(), projectRef: Option.none() }); expect(out.stdoutText).toContain("scaffolded"); expect(out.stdoutText).not.toContain("No compute found"); @@ -597,7 +615,7 @@ describe("compute list", () => { }); return yield* Effect.gen(function* () { - yield* computeList({ projectRef: Option.none() }); + yield* computeList({ remote: Option.none(), projectRef: Option.none() }); const success = out.messages.findLast( (message) => message.type === "success" && message.data !== undefined, @@ -627,7 +645,7 @@ describe("compute list", () => { }); return yield* Effect.gen(function* () { - yield* computeList({ projectRef: Option.none() }); + yield* computeList({ remote: Option.none(), projectRef: Option.none() }); expect(out.stdoutText).toContain("project_ref = "); expect(out.stdoutText).not.toContain("undefined"); @@ -650,7 +668,7 @@ describe("compute list", () => { }); return yield* Effect.gen(function* () { - yield* computeList({ projectRef: Option.none() }); + yield* computeList({ remote: Option.none(), projectRef: Option.none() }); expect(out.stdoutText).toContain("project_ref:"); expect(out.stdoutText).toContain("name: api"); @@ -679,7 +697,7 @@ describe("compute list", () => { }); return yield* Effect.gen(function* () { - yield* computeList({ projectRef: Option.none() }); + yield* computeList({ remote: Option.none(), projectRef: Option.none() }); expect(out.stdoutText).toContain("NAME"); expect(out.stdoutText).not.toContain("project_ref = "); @@ -693,7 +711,7 @@ describe("compute list", () => { const { layer, telemetry } = setupCompute({ workdir: repo.dir }); return yield* Effect.gen(function* () { - yield* computeList({ projectRef: Option.none() }).pipe(Effect.flip); + yield* computeList({ remote: Option.none(), projectRef: Option.none() }).pipe(Effect.flip); expect(telemetry.flushed).toBe(true); }).pipe(Effect.provide(layer)); @@ -725,7 +743,7 @@ describe("compute list", () => { }); return yield* Effect.gen(function* () { - yield* computeList({ projectRef: Option.none() }); + yield* computeList({ remote: Option.none(), projectRef: Option.none() }); const row = out.stdoutText.split("\n").find((line) => line.includes("api")); expect(row).toBeDefined(); @@ -759,7 +777,7 @@ describe("compute list", () => { }); return yield* Effect.gen(function* () { - yield* computeList({ projectRef: Option.none() }); + yield* computeList({ remote: Option.none(), projectRef: Option.none() }); expect(out.stdoutText).not.toContain("api"); expect(out.stdoutText).toContain("No compute found."); @@ -767,3 +785,8 @@ describe("compute list", () => { }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), ); }); + +describeComputeTargetSelection("compute list", { + routes: (ref) => ({ [`GET /v2/projects/${ref}/compute`]: { status: 200, body: { data: [] } } }), + run: (flags) => computeList(flags), +}); diff --git a/apps/cli/src/commands/experimental/compute/logs/logs.integration.test.ts b/apps/cli/src/commands/experimental/compute/logs/logs.integration.test.ts index c5558f1685..594aae761d 100644 --- a/apps/cli/src/commands/experimental/compute/logs/logs.integration.test.ts +++ b/apps/cli/src/commands/experimental/compute/logs/logs.integration.test.ts @@ -33,6 +33,7 @@ import { mockCommandSettings, VALID_TOKEN, } from "../../../../../tests/helpers/command-mocks.ts"; +import { describeComputeTargetSelection } from "../../../../../tests/helpers/compute-target-selection.ts"; import { assertPermissionDrift } from "../../../../../tests/helpers/permission-drift.ts"; import { projectResponse } from "../../../../../tests/helpers/permission-real-layers.ts"; import { @@ -81,6 +82,7 @@ function project() { function flags(overrides: Partial<Parameters<typeof computeLogs>[0]> = {}) { return { name: "api", + remote: Option.none(), projectRef: Option.none(), kind: Option.none(), tail: 100, @@ -844,7 +846,7 @@ describe("compute logs", () => { }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), ); - it.effect("uses the project ref from the flag and echoes it in suggestions", () => + it.effect("uses the project ref from --remote and echoes it in suggestions", () => Effect.gen(function* () { const repo = yield* project(); const { layer } = setupCompute({ @@ -858,13 +860,13 @@ describe("compute logs", () => { return yield* Effect.gen(function* () { const error = yield* computeLogs( - flags({ projectRef: Option.some(COMPUTE_PROJECT_REF) }), + flags({ remote: Option.some({ _tag: "Ref", ref: COMPUTE_PROJECT_REF }) }), ).pipe(Effect.flip); // A copy-pasted suggestion must not silently re-resolve to whatever this // checkout happens to be linked to. const suggestion = error instanceof ComputeNotDeployedError ? error.suggestion : ""; - expect(suggestion).toContain(`--project-ref ${COMPUTE_PROJECT_REF}`); + expect(suggestion).toContain(`--remote=${COMPUTE_PROJECT_REF}`); }).pipe(Effect.provide(layer)); }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), ); @@ -944,7 +946,13 @@ describe("compute logs", () => { Effect.withTracerEnabled(true), ); - expect(spans.map((span) => span.name)).toEqual(["compute.logs", "v1GetProjectLogs", "GET"]); + expect(spans.map((span) => span.name)).toEqual([ + "compute.logs", + "Target.resolveProject", + "Target.resolveRemote", + "v1GetProjectLogs", + "GET", + ]); expect(spans[0]?.attributes.get("poll.attempt_count")).toBe(4); }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), ); @@ -1465,3 +1473,16 @@ describe("compute logs", () => { }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), ); }); + +describeComputeTargetSelection("compute logs", { + operands: ["api"], + activeFlags: { follow: false }, + files: { + "supabase/config.toml": CONFIG, + "supabase/compute/api/index.js": "export default {};\n", + }, + routes: (ref) => ({ + [`GET /v1/projects/${ref}/analytics/endpoints/logs`]: logsResponse([computeLogRow({})]), + }), + run: (selection) => computeLogs(flags(selection)), +}); 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 faa23cd001..413a2b2d62 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 @@ -13,6 +13,7 @@ import { } from "../../../../../tests/helpers/compute.ts"; import { configValuesLayer } from "../../../../../tests/helpers/config-values-layer.ts"; import { describeHostedConsent } from "../../../../../tests/helpers/hosted-consent.ts"; +import { describeComputeTargetSelection } from "../../../../../tests/helpers/compute-target-selection.ts"; import { assertPermissionDrift } from "../../../../../tests/helpers/permission-drift.ts"; import { projectResponse } from "../../../../../tests/helpers/permission-real-layers.ts"; import { ProjectRefNotLinkedError } from "../../../../config/project-ref.errors.ts"; @@ -76,6 +77,7 @@ function flags(overrides: Partial<ComputePushFlags> = {}): ComputePushFlags { instances: Option.none(), exposure: Option.none(), noWait: false, + remote: Option.none(), projectRef: Option.none(), ...overrides, }; @@ -421,12 +423,12 @@ describe("compute push", () => { return yield* Effect.gen(function* () { const error = yield* push({ names: [], - projectRef: Option.some(COMPUTE_PROJECT_REF), + remote: Option.some({ _tag: "Ref", ref: COMPUTE_PROJECT_REF }), }).pipe(Effect.flip); expect(error).toBeInstanceOf(MissingComputeExposureError); expect(error).toMatchObject({ suggestion: expect.stringContaining( - `push web --exposure public --project-ref ${COMPUTE_PROJECT_REF}`, + `push web --exposure public --remote=${COMPUTE_PROJECT_REF}`, ), }); expect(http.requests).toHaveLength(0); @@ -933,14 +935,16 @@ describe("compute push", () => { }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), ); - describe("carries an explicit --project-ref into its hints", () => { + describe("carries an explicit --remote=<ref> into its hints", () => { const unlinked = (repoDir: string, routeOverrides = {}) => setupPush({ workdir: repoDir, linked: false, routes: routes(routeOverrides), }); - const withRef = { projectRef: Option.some(COMPUTE_PROJECT_REF) }; + const withRef: Partial<ComputePushFlags> = { + remote: Option.some({ _tag: "Ref", ref: COMPUTE_PROJECT_REF }), + }; it.effect("in the still-building trailer under --no-wait", () => Effect.gen(function* () { @@ -951,7 +955,7 @@ describe("compute push", () => { yield* push({ ...withRef, noWait: true }); expect(out.stderrText).toContain( - `supabase compute status api --project-ref ${COMPUTE_PROJECT_REF}`, + `supabase compute status api --remote=${COMPUTE_PROJECT_REF}`, ); }).pipe(Effect.provide(layer)); }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), @@ -973,7 +977,7 @@ describe("compute push", () => { expect(error).toBeInstanceOf(ComputeBuildFailedError); expect(error).toMatchObject({ suggestion: expect.stringContaining( - `supabase compute push api --project-ref ${COMPUTE_PROJECT_REF}`, + `supabase compute push api --remote=${COMPUTE_PROJECT_REF}`, ), }); }).pipe(Effect.provide(layer)); @@ -998,7 +1002,7 @@ describe("compute push", () => { expect(error).toBeInstanceOf(ComputeBuildTimeoutError); expect(error).toMatchObject({ suggestion: expect.stringContaining( - `supabase compute status api --project-ref ${COMPUTE_PROJECT_REF}`, + `supabase compute status api --remote=${COMPUTE_PROJECT_REF}`, ), }); }).pipe(Effect.provide(layer)); @@ -1016,7 +1020,7 @@ describe("compute push", () => { yield* push({ noWait: true }); expect(out.stderrText).toContain("supabase compute status api"); - expect(out.stderrText).not.toContain("--project-ref"); + expect(out.stderrText).not.toContain("--remote"); }).pipe(Effect.provide(layer)); }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), ); @@ -1784,7 +1788,7 @@ describe("compute push", () => { }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), ); - it.effect("requires a linked project or an explicit --project-ref", () => + it.effect("requires a linked project or an explicit --remote=<ref>", () => Effect.gen(function* () { const repo = yield* project(); const { layer } = setupPush({ workdir: repo.dir, linked: false, routes: routes() }); @@ -2252,3 +2256,20 @@ describeHostedConsent("compute push", { }; }, }); + +describeComputeTargetSelection("compute push", { + operands: ["api"], + files: { + "supabase/config.toml": `project_id = "demo"\n\n[compute.api]\nruntime = "node"\nsize = "2gb"\n`, + "supabase/compute/api/index.js": "export default {};\n", + }, + activeFlags: { "no-wait": true }, + routes: (ref) => + Object.fromEntries( + Object.entries(routes()).map(([route, handler]) => [ + route.replace(COMPUTE_PROJECT_REF, ref), + handler, + ]), + ), + run: (selection) => push({ ...selection, noWait: true }), +}); diff --git a/apps/cli/src/commands/experimental/compute/status/status.integration.test.ts b/apps/cli/src/commands/experimental/compute/status/status.integration.test.ts index e6360674b3..a8d0a11777 100644 --- a/apps/cli/src/commands/experimental/compute/status/status.integration.test.ts +++ b/apps/cli/src/commands/experimental/compute/status/status.integration.test.ts @@ -8,6 +8,7 @@ import { computeRoute, COMPUTE_PROJECT_REF, } from "../../../../../tests/helpers/compute.ts"; +import { describeComputeTargetSelection } from "../../../../../tests/helpers/compute-target-selection.ts"; import { assertBlockDrift, assertPermissionDrift, @@ -60,7 +61,7 @@ describe("compute status", () => { }); return yield* Effect.gen(function* () { - yield* computeStatus({ name: "api", projectRef: Option.none() }); + yield* computeStatus({ name: "api", remote: Option.none(), projectRef: Option.none() }); const stdout = out.stdoutText; expect(stdout).toContain("State"); @@ -112,7 +113,7 @@ describe("compute status", () => { return yield* Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; - yield* computeStatus({ name: "api", projectRef: Option.none() }); + yield* computeStatus({ name: "api", remote: Option.none(), projectRef: Option.none() }); expect(http.routeKeys).toEqual([ "GET /v1/projects", @@ -148,7 +149,7 @@ describe("compute status", () => { }); return yield* Effect.gen(function* () { - yield* computeStatus({ name: "api", projectRef: Option.none() }); + yield* computeStatus({ name: "api", remote: Option.none(), projectRef: Option.none() }); const runtimeLine = out.stdoutText .split("\n") @@ -173,7 +174,7 @@ describe("compute status", () => { }); return yield* Effect.gen(function* () { - yield* computeStatus({ name: "api", projectRef: Option.none() }); + yield* computeStatus({ name: "api", remote: Option.none(), projectRef: Option.none() }); expect(out.stdoutText).toContain("2 declared"); }).pipe(Effect.provide(layer)); @@ -200,7 +201,7 @@ describe("compute status", () => { }); return yield* Effect.gen(function* () { - yield* computeStatus({ name: "api", projectRef: Option.none() }); + yield* computeStatus({ name: "api", remote: Option.none(), projectRef: Option.none() }); expect(out.stderrText).toContain("backend unreachable"); }).pipe(Effect.provide(layer)); @@ -231,7 +232,7 @@ describe("compute status", () => { }); return yield* Effect.gen(function* () { - yield* computeStatus({ name: "api", projectRef: Option.none() }); + yield* computeStatus({ name: "api", remote: Option.none(), projectRef: Option.none() }); expect(out.stdoutText).toContain("3/3 ready"); expect(out.stdoutText).not.toContain("3/1 ready"); @@ -263,7 +264,7 @@ describe("compute status", () => { }); return yield* Effect.gen(function* () { - yield* computeStatus({ name: "api", projectRef: Option.none() }); + yield* computeStatus({ name: "api", remote: Option.none(), projectRef: Option.none() }); expect(out.stdoutText).toContain("deleting"); expect(out.stdoutText).not.toContain("re-run supabase compute push"); @@ -292,7 +293,7 @@ describe("compute status", () => { }); return yield* Effect.gen(function* () { - yield* computeStatus({ name: "api", projectRef: Option.none() }); + yield* computeStatus({ name: "api", remote: Option.none(), projectRef: Option.none() }); expect(out.stdoutText).toContain("failed"); expect(out.stdoutText).toContain("exit status 1"); @@ -316,7 +317,7 @@ describe("compute status", () => { }); return yield* Effect.gen(function* () { - yield* computeStatus({ name: "api", projectRef: Option.none() }); + yield* computeStatus({ name: "api", remote: Option.none(), projectRef: Option.none() }); expect(out.stdoutText).toContain("deleting"); }).pipe(Effect.provide(layer)); @@ -341,6 +342,7 @@ describe("compute status", () => { return yield* Effect.gen(function* () { const error = yield* computeStatus({ name: "api", + remote: Option.none(), projectRef: Option.none(), }).pipe(Effect.flip); @@ -378,6 +380,7 @@ describe("compute status", () => { return yield* Effect.gen(function* () { const error = yield* computeStatus({ name: "api", + remote: Option.none(), projectRef: Option.none(), }).pipe(Effect.flip); @@ -404,6 +407,7 @@ describe("compute status", () => { return yield* Effect.gen(function* () { const error = yield* computeStatus({ name: "api", + remote: Option.none(), projectRef: Option.none(), }).pipe(Effect.flip); @@ -435,6 +439,7 @@ describe("compute status", () => { return yield* Effect.gen(function* () { const error = yield* computeStatus({ name: "api", + remote: Option.none(), projectRef: Option.none(), }).pipe(Effect.flip); @@ -452,6 +457,7 @@ describe("compute status", () => { return yield* Effect.gen(function* () { const error = yield* computeStatus({ name: "My_Compute", + remote: Option.none(), projectRef: Option.none(), }).pipe(Effect.flip); @@ -479,7 +485,7 @@ describe("compute status", () => { }); return yield* Effect.gen(function* () { - yield* computeStatus({ name: "api", projectRef: Option.none() }); + yield* computeStatus({ name: "api", remote: Option.none(), projectRef: Option.none() }); expect(out.stdoutText).toContain(path.join("packages", "api")); }).pipe(Effect.provide(layer)); @@ -505,7 +511,7 @@ describe("compute status", () => { }); return yield* Effect.gen(function* () { - yield* computeStatus({ name: "api", projectRef: Option.none() }); + yield* computeStatus({ name: "api", remote: Option.none(), projectRef: Option.none() }); expect(out.stdoutText).toContain("active"); expect(out.stdoutText).not.toContain("Source"); @@ -535,7 +541,7 @@ describe("compute status", () => { }); return yield* Effect.gen(function* () { - yield* computeStatus({ name: "api", projectRef: Option.none() }); + yield* computeStatus({ name: "api", remote: Option.none(), projectRef: Option.none() }); const success = out.messages.findLast( (message) => message.type === "success" && message.data !== undefined, @@ -572,7 +578,7 @@ describe("compute status", () => { }); return yield* Effect.gen(function* () { - yield* computeStatus({ name: "api", projectRef: Option.none() }); + yield* computeStatus({ name: "api", remote: Option.none(), projectRef: Option.none() }); const results = out.messages.filter( (message) => message.type === "success" && message.data !== undefined, @@ -599,7 +605,7 @@ describe("compute status", () => { }); return yield* Effect.gen(function* () { - yield* computeStatus({ name: "stray", projectRef: Option.none() }); + yield* computeStatus({ name: "stray", remote: Option.none(), projectRef: Option.none() }); expect(out.stdoutText).not.toContain("compute/stray"); expect(out.stdoutText).not.toContain("Source"); @@ -624,7 +630,7 @@ describe("compute status", () => { }); return yield* Effect.gen(function* () { - yield* computeStatus({ name: "root", projectRef: Option.none() }); + yield* computeStatus({ name: "root", remote: Option.none(), projectRef: Option.none() }); expect(out.stdoutText).toContain("active"); expect(http.routeKeys).toEqual([`GET ${computeRoute("/root")}`]); @@ -650,7 +656,7 @@ describe("compute status", () => { }); return yield* Effect.gen(function* () { - yield* computeStatus({ name: "api", projectRef: Option.none() }); + yield* computeStatus({ name: "api", remote: Option.none(), projectRef: Option.none() }); expect(out.stdoutText).toContain("compute_name = "); expect(out.stdoutText).not.toContain("undefined"); @@ -676,7 +682,11 @@ describe("compute status", () => { }); return yield* Effect.gen(function* () { - yield* computeStatus({ name: "api", projectRef: Option.some(otherRef) }); + yield* computeStatus({ + name: "api", + remote: Option.some({ _tag: "Ref", ref: otherRef }), + projectRef: Option.none(), + }); expect(http.routeKeys).toEqual([`GET /v2/projects/${otherRef}/compute/api`]); expect(out.stdoutText).toContain("active"); @@ -703,7 +713,7 @@ describe("compute status", () => { }); return yield* Effect.gen(function* () { - yield* computeStatus({ name: "api", projectRef: Option.none() }); + yield* computeStatus({ name: "api", remote: Option.none(), projectRef: Option.none() }); expect(out.stdoutText).toContain("private"); expect(out.stdoutText).not.toContain("URL"); @@ -722,9 +732,11 @@ describe("compute status", () => { }); return yield* Effect.gen(function* () { - const error = yield* computeStatus({ name: "api", projectRef: Option.none() }).pipe( - Effect.flip, - ); + const error = yield* computeStatus({ + name: "api", + remote: Option.none(), + projectRef: Option.none(), + }).pipe(Effect.flip); expect(error).toBeInstanceOf(ComputeEnvNotSupportedError); expect(http.routeKeys).toEqual([]); @@ -738,10 +750,25 @@ describe("compute status", () => { const { layer, telemetry } = setupCompute({ workdir: repo.dir }); return yield* Effect.gen(function* () { - yield* computeStatus({ name: "Not_A_Label", projectRef: Option.none() }).pipe(Effect.flip); + yield* computeStatus({ + name: "Not_A_Label", + remote: Option.none(), + projectRef: Option.none(), + }).pipe(Effect.flip); expect(telemetry.flushed).toBe(true); }).pipe(Effect.provide(layer)); }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), ); }); + +describeComputeTargetSelection("compute status", { + operands: ["api"], + routes: (ref) => ({ + [`GET /v2/projects/${ref}/compute/api`]: { + status: 200, + body: { data: computeResource({ name: "api", runtime: "node" }) }, + }, + }), + run: (flags) => computeStatus({ name: "api", ...flags }), +}); 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 5b6f8afbcf..c2d14b0747 100644 --- a/apps/cli/src/commands/functions/delete/delete.integration.test.ts +++ b/apps/cli/src/commands/functions/delete/delete.integration.test.ts @@ -17,6 +17,7 @@ import { useTempWorkdir, } from "../../../../tests/helpers/command-mocks.ts"; import { describeHostedConsent } from "../../../../tests/helpers/hosted-consent.ts"; +import { describeHostedTargetSelection } from "../../../../tests/helpers/hosted-target-selection.ts"; import { mockContextualAnalytics, mockOutput, mockTty } from "../../../../tests/helpers/mocks.ts"; import { assertPermissionDrift } from "../../../../tests/helpers/permission-drift.ts"; import { @@ -55,6 +56,7 @@ describe("functions delete", () => { return Effect.gen(function* () { yield* functionsDelete({ functionName: "hello-world", + remote: Option.none(), projectRef: Option.none(), }); @@ -76,7 +78,7 @@ describe("functions delete", () => { }).pipe(Effect.provide(layer)); }); - it.effect("uses an explicit project ref", () => { + it.effect("uses an explicit --remote=<ref>", () => { const out = mockOutput({ format: "text" }); const api = mockCommandPlatformApi({ response: { status: 200, body: null } }); const layer = Layer.mergeAll( @@ -94,13 +96,14 @@ describe("functions delete", () => { return Effect.gen(function* () { yield* functionsDelete({ functionName: "hello-world", - projectRef: Option.some("qrstuvwxyzabcdefghij"), + remote: Option.some({ _tag: "Ref", ref: "qrstuvwxyzabcdefghij" }), + projectRef: Option.none(), }); expect(api.requests[0]?.url).toContain("/projects/qrstuvwxyzabcdefghij/functions/"); assertPermissionDrift({ command: "functions delete", - activeFlags: { "project-ref": "qrstuvwxyzabcdefghij" }, + activeFlags: { remote: "qrstuvwxyzabcdefghij" }, requests: api.requests, exact: true, }); @@ -141,7 +144,11 @@ describe("functions delete", () => { const fs = yield* FileSystem.FileSystem; const cachePath = `${tempRoot.current}/supabase/.temp/linked-project.json`; expect(yield* fs.exists(cachePath)).toBe(false); - yield* functionsDelete({ functionName: "hello-world", projectRef: Option.none() }); + yield* functionsDelete({ + functionName: "hello-world", + remote: Option.none(), + projectRef: Option.none(), + }); 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}/functions/hello-world`, @@ -178,7 +185,7 @@ describe("functions delete", () => { return Effect.gen(function* () { const exit = yield* Effect.exit( - functionsDelete({ functionName, projectRef: Option.none() }), + functionsDelete({ functionName, remote: Option.none(), projectRef: Option.none() }), ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { @@ -217,6 +224,7 @@ describe("functions delete", () => { return Effect.gen(function* () { yield* functionsDeleteHandler({ functionName: "hello-world", + remote: Option.none(), projectRef: Option.some("abcdefghijklmnopqrst"), }); @@ -229,6 +237,16 @@ describe("functions delete", () => { describeHostedConsent("functions delete", { api: { response: { status: 200, body: null } }, invoke: () => ({ - run: functionsDelete({ functionName: "hello-world", projectRef: Option.none() }), + run: functionsDelete({ + functionName: "hello-world", + remote: Option.none(), + projectRef: Option.none(), + }), }), }); + +describeHostedTargetSelection("functions delete", { + operands: ["hello-world"], + respond: (request) => Effect.succeed(jsonResponse(request, 200, null)), + run: (flags) => functionsDelete({ functionName: "hello-world", ...flags }), +}); diff --git a/apps/cli/src/commands/functions/download/download.integration.test.ts b/apps/cli/src/commands/functions/download/download.integration.test.ts index cf424642d2..a72bb93acb 100644 --- a/apps/cli/src/commands/functions/download/download.integration.test.ts +++ b/apps/cli/src/commands/functions/download/download.integration.test.ts @@ -35,6 +35,7 @@ import { withEnvVar, } from "../../../../tests/helpers/command-mocks.ts"; import { cliConfigValuesTestLayer } from "../../../../tests/helpers/config-values-layer.ts"; +import { describeHostedTargetSelection } from "../../../../tests/helpers/hosted-target-selection.ts"; import { mockContextualAnalytics, mockOutput, mockTty } from "../../../../tests/helpers/mocks.ts"; import { assertBlockDrift, @@ -151,6 +152,7 @@ const workdirProjectId = Effect.gen(function* () { const baseFlags: FunctionsDownloadFlags = { functionName: Option.some("hello-world"), + remote: Option.none(), projectRef: Option.none(), useApi: false, useDocker: false, @@ -212,8 +214,7 @@ describe("functions download", () => { "functions", "download", "hello-world", - "--project-ref", - "abcdefghijklmnopqrst", + "--remote=abcdefghijklmnopqrst", ]), }), ); @@ -330,8 +331,7 @@ describe("functions download", () => { "download", "hello-world", "--use-api", - "--project-ref", - PROJECT_ID, + `--remote=${PROJECT_ID}`, ]), }), ); @@ -364,8 +364,7 @@ describe("functions download", () => { "functions", "download", "hello-world", - "--project-ref", - PROJECT_ID, + `--remote=${PROJECT_ID}`, ], ) { return Layer.mergeAll( @@ -477,7 +476,7 @@ describe("functions download", () => { Effect.succeed( HttpClientResponse.fromWeb(request, new Response(body, { status: 200 })), ), - ["functions", "download", "--project-ref", PROJECT_ID], + ["functions", "download", `--remote=${PROJECT_ID}`], ), ), ), @@ -505,7 +504,7 @@ describe("functions download", () => { Layer.mergeAll( nativeDownloadLayer( (request) => Effect.succeed(jsonResponse(request, 200, {})), - ["functions", "download", "hello-world", "--use-docker", "--project-ref", PROJECT_ID], + ["functions", "download", "hello-world", "--use-docker", `--remote=${PROJECT_ID}`], ), child.layer, ), @@ -535,7 +534,7 @@ describe("functions download", () => { Layer.mergeAll( nativeDownloadLayer( (request) => Effect.succeed(jsonResponse(request, 200, {})), - ["functions", "download", "hello-world", "--use-docker", "--project-ref", PROJECT_ID], + ["functions", "download", "hello-world", "--use-docker", `--remote=${PROJECT_ID}`], ), child.layer, ), @@ -579,8 +578,7 @@ describe("functions download", () => { "download", "hello-world", "--use-docker", - "--project-ref", - PROJECT_ID, + `--remote=${PROJECT_ID}`, ]), }), ); @@ -612,7 +610,7 @@ describe("functions download", () => { }), child.layer, Stdio.layerTest({ - args: Effect.succeed(["functions", "download", "hello-world", "--project-ref", PROJECT_ID]), + args: Effect.succeed(["functions", "download", "hello-world", `--remote=${PROJECT_ID}`]), }), ); @@ -666,8 +664,7 @@ describe("functions download", () => { "download", "hello-world", "--use-api", - "--project-ref", - "abcdefghijklmnopqrst", + "--remote=abcdefghijklmnopqrst", ]), }), ); @@ -708,8 +705,7 @@ describe("functions download", () => { "download", "hello-world", "--use-api=false", - "--project-ref", - PROJECT_ID, + `--remote=${PROJECT_ID}`, ]), }), ); @@ -749,8 +745,7 @@ describe("functions download", () => { "functions", "download", "hello-world", - "--project-ref", - PROJECT_ID, + `--remote=${PROJECT_ID}`, "--output-format", "json", ]), @@ -800,8 +795,7 @@ describe("functions download", () => { args: Effect.succeed([ "functions", "download", - "--project-ref", - PROJECT_ID, + `--remote=${PROJECT_ID}`, "--output-format", "json", ]), @@ -866,8 +860,7 @@ describe("functions download", () => { "download", "hello-world", "--use-docker", - "--project-ref", - PROJECT_ID, + `--remote=${PROJECT_ID}`, ]), }), ); @@ -947,8 +940,7 @@ describe("functions download", () => { "download", "hello-world", "--use-docker", - "--project-ref", - PROJECT_ID, + `--remote=${PROJECT_ID}`, ]), }), ); @@ -998,8 +990,7 @@ describe("functions download", () => { "download", "hello-world", "--use-docker", - "--project-ref", - PROJECT_ID, + `--remote=${PROJECT_ID}`, ]), }), ); @@ -1030,8 +1021,7 @@ describe("functions download", () => { "download", "hello-world", "--use-docker", - "--project-ref", - PROJECT_ID, + `--remote=${PROJECT_ID}`, "--network-id", "custom-network", ]), @@ -1074,8 +1064,7 @@ describe("functions download", () => { "download", "hello-world", "--use-docker", - "--project-ref", - PROJECT_ID, + `--remote=${PROJECT_ID}`, "--network-id=", ]), }), @@ -1112,8 +1101,7 @@ describe("functions download", () => { "download", "hello-world", "--use-docker", - "--project-ref", - PROJECT_ID, + `--remote=${PROJECT_ID}`, "--network-id", "old-network", "--network-id", @@ -1151,8 +1139,7 @@ describe("functions download", () => { "download", "hello-world", "--use-docker", - "--project-ref", - PROJECT_ID, + `--remote=${PROJECT_ID}`, "--network-id", "custom-network", "--network-id=", @@ -1174,7 +1161,7 @@ describe("functions download", () => { "does not climb to an ancestor project's config.toml for the Docker download path", () => { // A nested workdir without its own config.toml is named after the workdir, - // not an ancestor's `project_id` and not `--project-ref`. + // not an ancestor's `project_id` and not `--remote`. const out = mockOutput({ format: "text" }); const api = mockCommandPlatformApi(); const child = mockChildProcessSpawner({ exitCode: 0 }); @@ -1193,8 +1180,7 @@ describe("functions download", () => { "download", "hello-world", "--use-docker", - "--project-ref", - PROJECT_ID, + `--remote=${PROJECT_ID}`, ]), }), ); @@ -1240,8 +1226,7 @@ describe("functions download", () => { "download", "hello-world", "--use-docker", - "--project-ref", - PROJECT_ID, + `--remote=${PROJECT_ID}`, ]), }), ); @@ -1288,8 +1273,7 @@ describe("functions download", () => { "download", "hello-world", "--use-docker", - "--project-ref", - PROJECT_ID, + `--remote=${PROJECT_ID}`, "--network-id", "container:redis", ]), @@ -1326,8 +1310,7 @@ describe("functions download", () => { "download", "hello-world", "--use-docker", - "--project-ref", - PROJECT_ID, + `--remote=${PROJECT_ID}`, ]), }), ); @@ -1368,8 +1351,7 @@ describe("functions download", () => { "download", "hello-world", "--use-docker", - "--project-ref", - PROJECT_ID, + `--remote=${PROJECT_ID}`, "--debug", ]), }), @@ -1408,8 +1390,7 @@ describe("functions download", () => { "download", "hello-world", "--use-docker", - "--project-ref", - PROJECT_ID, + `--remote=${PROJECT_ID}`, "--debug=false", ]), }), @@ -1446,13 +1427,7 @@ describe("functions download", () => { }), child.layer, Stdio.layerTest({ - args: Effect.succeed([ - "functions", - "download", - "hello-world", - "--project-ref", - PROJECT_ID, - ]), + args: Effect.succeed(["functions", "download", "hello-world", `--remote=${PROJECT_ID}`]), }), ); @@ -1495,8 +1470,7 @@ describe("functions download", () => { "download", "hello-world", "--use-docker", - "--project-ref", - PROJECT_ID, + `--remote=${PROJECT_ID}`, ]), }), ); @@ -1538,8 +1512,7 @@ describe("functions download", () => { "download", "hello-world", "--use-docker", - "--project-ref", - PROJECT_ID, + `--remote=${PROJECT_ID}`, ]), }), ); @@ -1588,8 +1561,7 @@ describe("functions download", () => { "download", "hello-world", "--use-docker", - "--project-ref", - PROJECT_ID, + `--remote=${PROJECT_ID}`, ]), }), ); @@ -1643,8 +1615,7 @@ describe("functions download", () => { "download", "hello-world", "--use-docker", - "--project-ref", - PROJECT_ID, + `--remote=${PROJECT_ID}`, ]), }), ); @@ -1691,8 +1662,7 @@ describe("functions download", () => { "download", "hello-world", "--use-docker", - "--project-ref", - PROJECT_ID, + `--remote=${PROJECT_ID}`, ]), }), ); @@ -1745,8 +1715,7 @@ describe("functions download", () => { args: Effect.succeed([ "functions", "download", - "--project-ref", - "abcdefghijklmnopqrst", + "--remote=abcdefghijklmnopqrst", "--output-format", "json", ]), @@ -1791,8 +1760,7 @@ describe("functions download", () => { args: Effect.succeed([ "functions", "download", - "--project-ref", - "abcdefghijklmnopqrst", + "--remote=abcdefghijklmnopqrst", "--output-format", "json", ]), @@ -1834,8 +1802,7 @@ describe("functions download", () => { args: Effect.succeed([ "functions", "download", - "--project-ref", - "abcdefghijklmnopqrst", + "--remote=abcdefghijklmnopqrst", "--output-format", "json", ]), @@ -1883,7 +1850,7 @@ describe("functions download", () => { cliSettings: mockCommandSettings({ workdir: tempRoot.current }), }), Stdio.layerTest({ - args: Effect.succeed(["functions", "download", "--project-ref", PROJECT_ID]), + args: Effect.succeed(["functions", "download", `--remote=${PROJECT_ID}`]), }), ); @@ -1926,8 +1893,7 @@ describe("functions download", () => { "functions", "download", "../../etc", - "--project-ref", - "abcdefghijklmnopqrst", + "--remote=abcdefghijklmnopqrst", ]), }), ); @@ -1943,6 +1909,45 @@ describe("functions download", () => { }).pipe(Effect.provide(layer)); }); + it.effect("does not redact a --remote=<ref> in cli_command_executed", () => { + const out = mockOutput({ format: "text" }); + const api = mockCommandPlatformApi({ + handler: (request) => + request.url.endsWith("/body") + ? Effect.succeed(multipartResponse(request)) + : Effect.succeed(jsonResponse(request, 200, {})), + }); + const analytics = mockContextualAnalytics(); + const layer = Layer.mergeAll( + cliConfigValuesTestLayer, + buildTestRuntime({ + out, + api, + cliSettings: mockCommandSettings({ workdir: tempRoot.current }), + analytics, + }), + commandRuntimeLayer(["functions", "download"]).pipe(Layer.provide(BunCrypto.layer)), + Stdio.layerTest({ + args: Effect.succeed([ + "functions", + "download", + "hello-world", + "--remote=abcdefghijklmnopqrst", + ]), + }), + ); + + return Effect.gen(function* () { + yield* functionsDownloadHandler({ + ...baseFlags, + remote: Option.some({ _tag: "Ref", ref: "abcdefghijklmnopqrst" }), + }); + + const event = analytics.captured.find((c) => c.event === "cli_command_executed"); + expect(event?.properties.flags).toEqual({ remote: "abcdefghijklmnopqrst" }); + }).pipe(Effect.provide(layer)); + }); + it.effect("does not redact --project-ref in cli_command_executed", () => { const out = mockOutput({ format: "text" }); const api = mockCommandPlatformApi({ @@ -2035,8 +2040,7 @@ describe("functions download", () => { "functions", "download", "hello-world", - "--project-ref", - PROJECT_ID, + `--remote=${PROJECT_ID}`, ]), }), ); @@ -2081,8 +2085,7 @@ describe("functions download", () => { "functions", "download", "hello-world", - "--project-ref", - PROJECT_ID, + `--remote=${PROJECT_ID}`, ]), }), ); @@ -2131,8 +2134,7 @@ describe("functions download", () => { "download", "hello-world", "--use-docker", - "--project-ref", - PROJECT_ID, + `--remote=${PROJECT_ID}`, ]), }), ); @@ -2170,8 +2172,7 @@ describe("functions download", () => { "download", "hello-world", "--use-docker", - "--project-ref", - PROJECT_ID, + `--remote=${PROJECT_ID}`, ]), }), ); @@ -2209,8 +2210,7 @@ describe("functions download", () => { "download", "hello-world", "--use-docker", - "--project-ref", - PROJECT_ID, + `--remote=${PROJECT_ID}`, "--network-id", "flag-network", ]), @@ -2252,8 +2252,7 @@ describe("functions download", () => { "download", "hello-world", "--use-docker", - "--project-ref", - PROJECT_ID, + `--remote=${PROJECT_ID}`, ]), }), ); @@ -2333,8 +2332,7 @@ describe("functions download", () => { "download", "hello-world", "--use-docker", - "--project-ref", - PROJECT_ID, + `--remote=${PROJECT_ID}`, ]), }), ); @@ -2372,8 +2370,7 @@ describe("functions download", () => { "download", "hello-world", "--use-docker", - "--project-ref", - PROJECT_ID, + `--remote=${PROJECT_ID}`, ]), }), ); @@ -2415,13 +2412,7 @@ describe("functions download", () => { }), child.layer, Stdio.layerTest({ - args: Effect.succeed([ - "functions", - "download", - "hello-world", - "--project-ref", - PROJECT_ID, - ]), + args: Effect.succeed(["functions", "download", "hello-world", `--remote=${PROJECT_ID}`]), }), ); @@ -2433,10 +2424,10 @@ describe("functions download", () => { { api: platformApi, projectRoot: tempRoot.current, - rawArgs: ["functions", "download", "hello-world", "--project-ref", PROJECT_ID], + rawArgs: ["functions", "download", "hello-world", `--remote=${PROJECT_ID}`], localConfigLoader: functionsLocalConfigLoader, edgeRuntimeVersion: "1.69.12", - resolveProjectRef: () => Effect.succeed(PROJECT_ID), + resolveProjectRef: Effect.succeed(PROJECT_ID), styleWarning: (text) => `<warn>${text}</warn>`, }, ); @@ -2446,3 +2437,17 @@ describe("functions download", () => { }); }); }); + +describeHostedTargetSelection("functions download", { + operands: ["hello-world"], + respond: (request) => + Effect.succeed( + request.url.endsWith("/body") ? multipartResponse(request) : jsonResponse(request, 200, {}), + ), + run: (flags) => + functionsDownload({ ...baseFlags, ...flags }).pipe( + Effect.provide( + Layer.mergeAll(cliConfigValuesTestLayer, Stdio.layerTest({ args: Effect.succeed([]) })), + ), + ), +}); 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 82a640151d..5d053ef182 100644 --- a/apps/cli/src/commands/functions/list/list.integration.test.ts +++ b/apps/cli/src/commands/functions/list/list.integration.test.ts @@ -13,6 +13,7 @@ import { mockTelemetryStateTracked, useTempWorkdir, } from "../../../../tests/helpers/command-mocks.ts"; +import { describeHostedTargetSelection } from "../../../../tests/helpers/hosted-target-selection.ts"; import { assertPermissionDrift } from "../../../../tests/helpers/permission-drift.ts"; import { projectResponse, @@ -117,7 +118,7 @@ describe("functions list integration", () => { it.effect("renders a Glamour table with all 6 columns in text mode", () => { const { layer, out } = setup(); return Effect.gen(function* () { - yield* functionsList({ projectRef: Option.none() }); + yield* functionsList({ remote: Option.none(), projectRef: Option.none() }); expect(out.stdoutText).toContain("ID"); expect(out.stdoutText).toContain("NAME"); expect(out.stdoutText).toContain("SLUG"); @@ -132,7 +133,7 @@ describe("functions list integration", () => { it.effect("renders literal `|` characters in table cells", () => { const { layer, out } = setup({ response: [PIPE_FUNCTION] }); return Effect.gen(function* () { - yield* functionsList({ projectRef: Option.none() }); + yield* functionsList({ remote: Option.none(), projectRef: Option.none() }); expect(out.stdoutText).toContain("Hello|World"); expect(out.stdoutText).toContain("hello|world"); }).pipe(Effect.provide(layer)); @@ -141,7 +142,7 @@ describe("functions list integration", () => { it.effect("renders an empty table when the API returns []", () => { const { layer, out } = setup({ response: [] }); return Effect.gen(function* () { - yield* functionsList({ projectRef: Option.none() }); + yield* functionsList({ remote: Option.none(), projectRef: Option.none() }); expect(out.stdoutText).toContain("UPDATED_AT (UTC)"); expect(out.stdoutText).not.toContain("Hello World"); }).pipe(Effect.provide(layer)); @@ -150,7 +151,7 @@ describe("functions list integration", () => { it.effect("emits a success event with { functions } for --output-format=json", () => { const { layer, out } = setup({ format: "json" }); return Effect.gen(function* () { - yield* functionsList({ projectRef: Option.none() }); + yield* functionsList({ remote: Option.none(), projectRef: Option.none() }); const success = out.messages.find((message) => message.type === "success"); expect(success).toBeDefined(); expect(success?.data).toMatchObject({ functions: [SAMPLE_FUNCTION] }); @@ -160,7 +161,7 @@ describe("functions list integration", () => { it.effect("emits a success event for --output-format=stream-json", () => { const { layer, out } = setup({ format: "stream-json" }); return Effect.gen(function* () { - yield* functionsList({ projectRef: Option.none() }); + yield* functionsList({ remote: Option.none(), projectRef: Option.none() }); expect(out.messages.find((message) => message.type === "success")).toBeDefined(); }).pipe(Effect.provide(layer)); }); @@ -168,7 +169,7 @@ describe("functions list integration", () => { it.effect("emits byte-exact indented JSON for --output json", () => { const { layer, out } = setup({ outputFlag: "json" }); return Effect.gen(function* () { - yield* functionsList({ projectRef: Option.none() }); + yield* functionsList({ remote: Option.none(), projectRef: Option.none() }); expect(out.stdoutText.startsWith("[\n {\n")).toBe(true); expect(out.stdoutText.endsWith("]\n")).toBe(true); expect(out.stdoutText).toContain('"created_at": 1687423025152'); @@ -179,7 +180,7 @@ describe("functions list integration", () => { it.effect("emits a YAML array for --output yaml", () => { const { layer, out } = setup({ outputFlag: "yaml" }); return Effect.gen(function* () { - yield* functionsList({ projectRef: Option.none() }); + yield* functionsList({ remote: Option.none(), projectRef: Option.none() }); expect(out.stdoutText).toContain("createdat: 1687423025152"); expect(out.stdoutText).toContain("entrypointpath: functions/hello-world/index.ts"); expect(out.stdoutText).toContain("verifyjwt: true"); @@ -191,7 +192,7 @@ describe("functions list integration", () => { it.effect("wraps the result as { functions = [...] } for --output toml", () => { const { layer, out } = setup({ outputFlag: "toml" }); return Effect.gen(function* () { - yield* functionsList({ projectRef: Option.none() }); + yield* functionsList({ remote: Option.none(), projectRef: Option.none() }); // Array-of-table keys are indented by 2 spaces. expect(out.stdoutText).toContain(`[[functions]] CreatedAt = 1687423025152 @@ -208,7 +209,9 @@ describe("functions list integration", () => { it.effect("fails with FunctionsEnvNotSupportedError for --output env", () => { const { layer } = setup({ outputFlag: "env" }); return Effect.gen(function* () { - const exit = yield* Effect.exit(functionsList({ projectRef: Option.none() })); + const exit = yield* Effect.exit( + functionsList({ remote: Option.none(), projectRef: Option.none() }), + ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { const causeText = Cause.pretty(exit.cause); @@ -221,7 +224,7 @@ describe("functions list integration", () => { it.effect("treats --output pretty as identical to text mode (table render)", () => { const { layer, out } = setup({ outputFlag: "pretty" }); return Effect.gen(function* () { - yield* functionsList({ projectRef: Option.none() }); + yield* functionsList({ remote: Option.none(), projectRef: Option.none() }); expect(out.stdoutText).toContain("Hello World"); expect(out.stdoutText).toContain("UPDATED_AT (UTC)"); }).pipe(Effect.provide(layer)); @@ -230,7 +233,7 @@ describe("functions list integration", () => { it.effect("lets --output pretty win over --output-format json", () => { const { layer, out } = setup({ format: "json", outputFlag: "pretty" }); return Effect.gen(function* () { - yield* functionsList({ projectRef: Option.none() }); + yield* functionsList({ remote: Option.none(), projectRef: Option.none() }); expect(out.stdoutText).toContain("Hello World"); expect(out.stdoutText).toContain("UPDATED_AT (UTC)"); expect(out.messages.find((message) => message.type === "success")).toBeUndefined(); @@ -240,7 +243,7 @@ describe("functions list integration", () => { it.effect("--output flag wins over --output-format", () => { const { layer, out } = setup({ format: "json", outputFlag: "yaml" }); return Effect.gen(function* () { - yield* functionsList({ projectRef: Option.none() }); + yield* functionsList({ remote: Option.none(), projectRef: Option.none() }); expect(out.stdoutText).toContain("name: Hello World"); expect(out.stdoutText.startsWith("{")).toBe(false); }).pipe(Effect.provide(layer)); @@ -249,7 +252,7 @@ describe("functions list integration", () => { it.effect("passes the resolved project ref to listAllFunctions", () => { const { layer, api } = setup(); return Effect.gen(function* () { - yield* functionsList({ projectRef: Option.none() }); + yield* functionsList({ remote: Option.none(), projectRef: Option.none() }); expect(api.requests).toHaveLength(1); expect(api.requests[0]?.url).toContain(`/v1/projects/${VALID_REF}/functions`); assertPermissionDrift({ command: "functions list", requests: api.requests, exact: true }); @@ -283,7 +286,7 @@ describe("functions list integration", () => { const fs = yield* FileSystem.FileSystem; const cachePath = `${tempRoot.current}/supabase/.temp/linked-project.json`; expect(yield* fs.exists(cachePath)).toBe(false); - yield* functionsList({ projectRef: Option.none() }); + yield* functionsList({ remote: Option.none(), projectRef: Option.none() }); expect(api.requests.map(({ method, url }) => `${method} ${url}`)).toEqual([ "GET https://api.supabase.com/v1/projects", `GET https://api.supabase.com/v1/projects/${VALID_REF}/functions`, @@ -297,7 +300,7 @@ describe("functions list integration", () => { }).pipe(Effect.provide(layer)); }); - it.effect("skips the project picker when --project-ref is empty and a ref file exists", () => { + it.effect("skips the project picker for a bare --remote when a ref file exists", () => { const savedRef = "qrstuvwxyzabcdefghij"; const out = mockOutput(); const api = mockCommandPlatformApi({ response: { status: 200, body: [SAMPLE_FUNCTION] } }); @@ -311,35 +314,50 @@ describe("functions list integration", () => { const fs = yield* FileSystem.FileSystem; yield* fs.makeDirectory(`${tempRoot.current}/supabase/.temp`, { recursive: true }); yield* fs.writeFileString(`${tempRoot.current}/supabase/.temp/project-ref`, savedRef); - yield* functionsList({ projectRef: Option.some("") }); + yield* functionsList({ remote: Option.some({ _tag: "Linked" }), projectRef: Option.none() }); expect(api.requests.map(({ method, url }) => `${method} ${url}`)).toEqual([ `GET https://api.supabase.com/v1/projects/${savedRef}/functions`, ]); assertPermissionDrift({ command: "functions list", - activeFlags: { "project-ref": "" }, + activeFlags: { remote: true }, requests: api.requests, exact: true, }); }).pipe(Effect.provide(layer)); }); + it.effect("rejects an empty --project-ref before any request", () => { + const { layer, api } = setup(); + return Effect.gen(function* () { + const error = yield* functionsList({ + remote: Option.none(), + projectRef: Option.some(""), + }).pipe(Effect.flip); + expect(error).toMatchObject({ _tag: "TargetEmptyValueError", flag: "--project-ref" }); + expect(api.requests).toEqual([]); + }).pipe(Effect.provide(layer)); + }); + it.effect("accepts unknown future function status strings", () => { const { layer, out } = setup({ response: [UNKNOWN_STATUS_FUNCTION] }); return Effect.gen(function* () { - yield* functionsList({ projectRef: Option.none() }); + yield* functionsList({ remote: Option.none(), projectRef: Option.none() }); expect(out.stdoutText).toContain("PAUSED_FOR_REBALANCE"); }).pipe(Effect.provide(layer)); }); - it.effect("uses --project-ref over the linked project default", () => { + it.effect("uses --remote=<ref> over the linked project default", () => { const { layer, api } = setup(); return Effect.gen(function* () { - yield* functionsList({ projectRef: Option.some("qrstuvwxyzabcdefghij") }); + yield* functionsList({ + remote: Option.some({ _tag: "Ref", ref: "qrstuvwxyzabcdefghij" }), + projectRef: Option.none(), + }); expect(api.requests[0]?.url).toContain("/v1/projects/qrstuvwxyzabcdefghij/functions"); assertPermissionDrift({ command: "functions list", - activeFlags: { "project-ref": "qrstuvwxyzabcdefghij" }, + activeFlags: { remote: "qrstuvwxyzabcdefghij" }, requests: api.requests, exact: true, }); @@ -349,7 +367,9 @@ describe("functions list integration", () => { it.effect("fails with FunctionsListUnexpectedStatusError on HTTP 503", () => { const { layer, api } = setup({ status: 503, response: [] }); return Effect.gen(function* () { - const exit = yield* Effect.exit(functionsList({ projectRef: Option.none() })); + const exit = yield* Effect.exit( + functionsList({ remote: Option.none(), projectRef: Option.none() }), + ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { const causeText = Cause.pretty(exit.cause); @@ -366,7 +386,9 @@ describe("functions list integration", () => { it.effect("fails with FunctionsListNetworkError on transport failure", () => { const { layer } = setup({ network: "fail" }); return Effect.gen(function* () { - const exit = yield* Effect.exit(functionsList({ projectRef: Option.none() })); + const exit = yield* Effect.exit( + functionsList({ remote: Option.none(), projectRef: Option.none() }), + ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { const causeText = Cause.pretty(exit.cause); @@ -393,7 +415,9 @@ describe("functions list integration", () => { const cliSettings = mockCommandSettings({ workdir: tempRoot.current }); const layer = buildTestRuntime({ out, api, cliSettings }); return Effect.gen(function* () { - const exit = yield* Effect.exit(functionsList({ projectRef: Option.none() })); + const exit = yield* Effect.exit( + functionsList({ remote: Option.none(), projectRef: Option.none() }), + ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { const causeText = Cause.pretty(exit.cause); @@ -420,7 +444,9 @@ describe("functions list integration", () => { const cliSettings = mockCommandSettings({ workdir: tempRoot.current }); const layer = buildTestRuntime({ out, api, cliSettings }); return Effect.gen(function* () { - const exit = yield* Effect.exit(functionsList({ projectRef: Option.none() })); + const exit = yield* Effect.exit( + functionsList({ remote: Option.none(), projectRef: Option.none() }), + ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { const causeText = Cause.pretty(exit.cause); @@ -434,7 +460,9 @@ describe("functions list integration", () => { it.effect("fails on invalid optional field types", () => { const { layer } = setup({ response: [INVALID_OPTIONAL_FUNCTION] }); return Effect.gen(function* () { - const exit = yield* Effect.exit(functionsList({ projectRef: Option.none() })); + const exit = yield* Effect.exit( + functionsList({ remote: Option.none(), projectRef: Option.none() }), + ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { const causeText = Cause.pretty(exit.cause); @@ -447,7 +475,9 @@ describe("functions list integration", () => { it.effect("fails on non-integer numeric fields", () => { const { layer } = setup({ response: [NON_INTEGER_FUNCTION] }); return Effect.gen(function* () { - const exit = yield* Effect.exit(functionsList({ projectRef: Option.none() })); + const exit = yield* Effect.exit( + functionsList({ remote: Option.none(), projectRef: Option.none() }), + ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { const causeText = Cause.pretty(exit.cause); @@ -460,7 +490,7 @@ describe("functions list integration", () => { it.effect("writes linked-project cache + telemetry state on success", () => { const { layer, telemetry, cache } = setupTracked(); return Effect.gen(function* () { - yield* functionsList({ projectRef: Option.none() }); + yield* functionsList({ remote: Option.none(), projectRef: Option.none() }); expect(telemetry.flushed).toBe(true); expect(cache.cached).toBe(true); }).pipe(Effect.provide(layer)); @@ -469,7 +499,7 @@ describe("functions list integration", () => { it.effect("writes linked-project cache + telemetry state on failure", () => { const { layer, telemetry, cache } = setupTracked({ status: 503, response: [] }); return Effect.gen(function* () { - yield* Effect.exit(functionsList({ projectRef: Option.none() })); + yield* Effect.exit(functionsList({ remote: Option.none(), projectRef: Option.none() })); expect(telemetry.flushed).toBe(true); expect(cache.cached).toBe(true); }).pipe(Effect.provide(layer)); @@ -503,7 +533,7 @@ describe("functions list integration", () => { }), ); return Effect.gen(function* () { - yield* Effect.exit(functionsList({ projectRef: Option.none() })); + yield* Effect.exit(functionsList({ remote: Option.none(), projectRef: Option.none() })); expect(telemetry.flushed).toBe(true); expect(cache.cached).toBe(false); expect(api.requests).toHaveLength(0); @@ -513,8 +543,15 @@ describe("functions list integration", () => { it.effect("emits a fail event when withJsonErrorHandling wraps a JSON-mode error", () => { const { layer, out } = setup({ format: "json", status: 503, response: [] }); return Effect.gen(function* () { - yield* functionsList({ projectRef: Option.none() }).pipe(withJsonErrorHandling); + yield* functionsList({ remote: Option.none(), projectRef: Option.none() }).pipe( + withJsonErrorHandling, + ); expect(out.messages.some((message) => message.type === "fail")).toBe(true); }).pipe(Effect.provide(layer)); }); }); + +describeHostedTargetSelection("functions list", { + respond: (request) => Effect.succeed(jsonResponse(request, 200, [SAMPLE_FUNCTION])), + run: (flags) => functionsList(flags), +}); 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 8c79bfc95f..dad453da32 100644 --- a/apps/cli/src/commands/pull/pull.aggregate.unit.test.ts +++ b/apps/cli/src/commands/pull/pull.aggregate.unit.test.ts @@ -408,33 +408,33 @@ describe("pullFailedStepResult", () => { describe("pullRetryHint", () => { const ref = "abcdefghijklmnopqrst"; - it("names 'supabase config pull --project-ref <ref>' for the config step", () => { + it("names 'supabase config pull --remote=<ref>' for the config step", () => { expect(pullRetryHint("config", ref, undefined)).toBe( - "To retry just this step, run: supabase config pull --project-ref abcdefghijklmnopqrst", + "To retry just this step, run: supabase config pull --remote=abcdefghijklmnopqrst", ); }); it("appends a shell-quoted --remote-label to the config hint when one was passed", () => { expect(pullRetryHint("config", ref, "staging-remote")).toBe( - "To retry just this step, run: supabase config pull --project-ref abcdefghijklmnopqrst --remote-label 'staging-remote'", + "To retry just this step, run: supabase config pull --remote-label 'staging-remote' --remote=abcdefghijklmnopqrst", ); }); it("shell-quotes a --remote-label containing whitespace, so the suggested command stays copy-pasteable", () => { expect(pullRetryHint("config", ref, "staging remote")).toBe( - "To retry just this step, run: supabase config pull --project-ref abcdefghijklmnopqrst --remote-label 'staging remote'", + "To retry just this step, run: supabase config pull --remote-label 'staging remote' --remote=abcdefghijklmnopqrst", ); }); it("escapes an embedded single quote in --remote-label using the POSIX '\"'\"' pattern", () => { expect(pullRetryHint("config", ref, "o'brien")).toBe( - "To retry just this step, run: supabase config pull --project-ref abcdefghijklmnopqrst --remote-label 'o'\"'\"'brien'", + "To retry just this step, run: supabase config pull --remote-label 'o'\"'\"'brien' --remote=abcdefghijklmnopqrst", ); }); it("strips control characters from --remote-label before quoting it (CWE-117)", () => { expect(pullRetryHint("config", ref, "staging\r\nFAKE")).toBe( - "To retry just this step, run: supabase config pull --project-ref abcdefghijklmnopqrst --remote-label 'staging FAKE'", + "To retry just this step, run: supabase config pull --remote-label 'staging FAKE' --remote=abcdefghijklmnopqrst", ); }); @@ -450,9 +450,9 @@ describe("pullRetryHint", () => { ); }); - it("names 'supabase functions download --project-ref <ref>' for the functions step", () => { + it("names 'supabase functions download --remote=<ref>' for the functions step", () => { expect(pullRetryHint("functions", ref, undefined)).toBe( - "To retry just this step, run: supabase functions download --project-ref abcdefghijklmnopqrst", + "To retry just this step, run: supabase functions download --remote=abcdefghijklmnopqrst", ); }); @@ -468,15 +468,15 @@ describe("pullRetryHint", () => { describe("pullWithMigrationHistoryCommand", () => { const ref = "abcdefghijklmnopqrst"; - it("names the resolved --project-ref, so a blind rerun can never silently retarget the linked project", () => { + it("names the resolved --remote=<ref>, so a blind rerun can never silently retarget the linked project", () => { expect(pullWithMigrationHistoryCommand(ref, undefined)).toBe( - "supabase pull --with-migration-history --project-ref abcdefghijklmnopqrst", + "supabase pull --with-migration-history --remote=abcdefghijklmnopqrst", ); }); it("appends a shell-quoted --remote-label when one was passed, matching pullRetryHint's own rendering", () => { expect(pullWithMigrationHistoryCommand(ref, "staging remote")).toBe( - "supabase pull --with-migration-history --project-ref abcdefghijklmnopqrst --remote-label 'staging remote'", + "supabase pull --with-migration-history --remote-label 'staging remote' --remote=abcdefghijklmnopqrst", ); }); }); diff --git a/apps/cli/src/commands/pull/pull.integration.test.ts b/apps/cli/src/commands/pull/pull.integration.test.ts index 9774976ac9..8d8d62c9db 100644 --- a/apps/cli/src/commands/pull/pull.integration.test.ts +++ b/apps/cli/src/commands/pull/pull.integration.test.ts @@ -88,6 +88,7 @@ import type { ResolvedDbConnection, } from "../../command-internal/db-config.types.ts"; import { HostedMutationDeclinedError } from "../../command-internal/hosted-consent/hosted-consent.errors.ts"; +import type { RemoteValue } from "../../command-internal/target/remote-value.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"; @@ -222,8 +223,12 @@ function stepLine(text: string, step: string): string { return line === undefined ? "" : line.trim().replace(/\s+/g, " "); } +const branchRemote = (name: string) => Option.some<RemoteValue>({ _tag: "Branch", name }); +const refRemote = (ref: string) => Option.some<RemoteValue>({ _tag: "Ref", ref }); + function pullFlags(overrides: Partial<PullFlags> = {}): PullFlags { return { + remote: overrides.remote ?? Option.none(), projectRef: overrides.projectRef ?? Option.none(), remoteLabel: overrides.remoteLabel ?? Option.none(), dryRun: overrides.dryRun ?? false, @@ -518,7 +523,7 @@ interface ApiOpts { readonly configResponse?: unknown; readonly functionSlugs?: ReadonlyArray<string>; readonly functionsListStatus?: number; - /** A transport (not status-code) failure resolving a branch-name `--project-ref`. */ + /** A transport (not status-code) failure resolving a branch-name `--remote`. */ readonly branchNetworkFails?: boolean; /** Fails only this slug's `/body` download with a 500; every other slug still downloads * normally, proving a partial functions-step download reports earlier slugs as written. */ @@ -661,6 +666,8 @@ interface SetupOpts { readonly projectId?: Option.Option<string>; /** Serves `GET /v1/projects` and answers the project picker with `VALID_REF`. */ readonly picksProject?: boolean; + /** The argv the deprecation warnings render their replacement command from. */ + readonly cliArgs?: ReadonlyArray<string>; } interface MachineSetupOpts extends SetupOpts { @@ -804,7 +811,7 @@ function setupRuntime( }), Layer.succeed(YesFlag, opts.yes ?? false), Layer.succeed(TargetPolicy, { group: "hosted", consent: "no" }), - Layer.succeed(CliArgs, { args: [] }), + Layer.succeed(CliArgs, { args: opts.cliArgs ?? [] }), // Listed after `buildTestRuntime` so its spawner overrides the real one `BunServices.layer` // provides (last-wins). substrate.layer, @@ -935,30 +942,27 @@ describe("pull integration", () => { }, ); - 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;"] }], + it.effect("a branch-name --remote 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({ remote: branchRemote("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", }); - 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)); - }, - ); + }).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", @@ -979,8 +983,49 @@ describe("pull integration", () => { }, ); + it.effect("--project-ref still resolves a branch name and warns to use --remote", () => { + const { layer, out } = setup({ + yes: true, + cliArgs: ["pull", "--project-ref", "staging"], + }); + return Effect.gen(function* () { + yield* writeConfig(); + yield* runPull(pullFlags({ projectRef: Option.some("staging"), dryRun: true })); + expect( + out.messages.filter((message) => message.type === "warn").map(({ message }) => message), + ).toEqual([ + "--project-ref is deprecated and will be removed in v4. Use --remote instead: supabase pull --remote=staging", + ]); + }).pipe(Effect.provide(layer)); + }); + + it.effect("--remote=<url> is rejected before any request", () => { + const { layer, api } = setup({ yes: true }); + return Effect.gen(function* () { + yield* writeConfig(); + const error = yield* runPull( + pullFlags({ + remote: Option.some<RemoteValue>({ + _tag: "DbUrl", + url: "postgresql://postgres:pw@db.example.com:5432/postgres", + }), + dryRun: true, + }), + ).pipe(Effect.flip); + expect(error).toMatchObject({ + _tag: "TargetNeedsProjectError", + message: "`supabase pull` needs a project, not a database URL.", + }); + expect(api.requests).toEqual([]); + }).pipe( + Effect.provide( + Layer.mergeAll(layer, commandRuntimeLayer(["pull"]).pipe(Layer.provide(BunServices.layer))), + ), + ); + }); + it.effect( - "a branch-name --project-ref resolves exactly once: one branch lookup, one config GET, no picker prompt", + "a branch-name --remote resolves exactly once: one branch lookup, one config GET, no picker prompt", () => { const { layer, out, api } = setup({ yes: true, @@ -990,7 +1035,7 @@ describe("pull integration", () => { return Effect.gen(function* () { yield* writeConfig(); yield* seedLocalMigration("20260101000000"); - yield* runPull(pullFlags({ projectRef: Option.some("staging") })); + yield* runPull(pullFlags({ remote: branchRemote("staging") })); const branchLookups = api.requests.filter((request) => request.url.includes("/branches/")); expect(branchLookups).toHaveLength(1); @@ -999,7 +1044,7 @@ describe("pull integration", () => { expect(out.promptSelectCalls).toHaveLength(0); assertPermissionDrift({ command: "pull", - activeFlags: { ...STEP_SWITCHES, "project-ref": "staging" }, + activeFlags: { ...STEP_SWITCHES, remote: "staging" }, requests: api.requests, }); }).pipe(Effect.provide(layer)); @@ -1646,12 +1691,12 @@ describe("pull integration", () => { ); it.effect( - "a network failure resolving a branch-name --project-ref fails with a network error, not a status error", + "a network failure resolving a branch-name --remote fails with a network error, not a status error", () => { const { layer } = setup({ api: { branchNetworkFails: true } }); return Effect.gen(function* () { yield* writeConfig(); - const exit = yield* Effect.exit(runPull(pullFlags({ projectRef: Option.some("staging") }))); + const exit = yield* Effect.exit(runPull(pullFlags({ remote: branchRemote("staging") }))); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { expect(Cause.pretty(exit.cause)).toContain("PullBranchResolveNetworkError"); @@ -1780,7 +1825,7 @@ describe("pull integration", () => { expect(stepLine(out.stdoutText, "functions")).toContain("changed"); expect(out.stdoutText).toContain( - `To retry just this step, run: supabase config pull --project-ref ${VALID_REF}`, + `To retry just this step, run: supabase config pull --remote=${VALID_REF}`, ); }).pipe(Effect.provide(layer)); }, @@ -1811,7 +1856,7 @@ describe("pull integration", () => { expect(stepLine(out.stdoutText, "config")).toContain("failed"); expect(out.stdoutText).toContain( - `To retry just this step, run: supabase config pull --project-ref ${VALID_REF} --remote-label 'staging-remote'`, + `To retry just this step, run: supabase config pull --remote-label 'staging-remote' --remote=${VALID_REF}`, ); }).pipe(Effect.provide(layer)); }, @@ -1838,15 +1883,13 @@ describe("pull integration", () => { return Effect.gen(function* () { yield* writeConfig("[api]\nmax_rows = 500\n"); yield* seedLocalMigration("20260101000000"); - const exit = yield* Effect.exit( - runPull(pullFlags({ projectRef: Option.some("staging") })), - ); + const exit = yield* Effect.exit(runPull(pullFlags({ remote: branchRemote("staging") }))); expect(Exit.isFailure(exit)).toBe(true); expect(stepLine(out.stdoutText, "config")).toContain("failed"); expect(yield* readConfigFile).not.toContain("[remotes.staging]"); expect(out.stdoutText).toContain( - `To retry just this step, run: supabase config pull --project-ref ${BRANCH_REF} --remote-label 'staging'`, + `To retry just this step, run: supabase config pull --remote-label 'staging' --remote=${BRANCH_REF}`, ); }).pipe(Effect.provide(layer)); }, @@ -1995,7 +2038,7 @@ describe("pull integration", () => { const suggestion = envelope.steps.db.failure.suggestion; const migrationHistoryHintIndex = suggestion.indexOf( - `Alternatively, rerun \`supabase pull --with-migration-history --project-ref ${VALID_REF}\``, + `Alternatively, rerun \`supabase pull --with-migration-history --remote=${VALID_REF}\``, ); const retryHintIndex = suggestion.indexOf( `To retry just this step, run: supabase db pull --experimental=false --remote=${VALID_REF}`, @@ -2027,7 +2070,7 @@ describe("pull integration", () => { capturingStdio.stdout[0], ); expect(envelope.steps.db.failure.suggestion).toContain( - `Alternatively, rerun \`supabase pull --with-migration-history --project-ref ${VALID_REF} --remote-label 'staging remote'\``, + `Alternatively, rerun \`supabase pull --with-migration-history --remote-label 'staging remote' --remote=${VALID_REF}\``, ); }).pipe(Effect.provide(layer)); }, @@ -2122,7 +2165,7 @@ describe("pull integration", () => { expect(stepLine(out.stdoutText, "functions")).toContain("failed"); expect(out.stdoutText).toContain("500"); expect(out.stdoutText).toContain( - `To retry just this step, run: supabase functions download --project-ref ${VALID_REF}`, + `To retry just this step, run: supabase functions download --remote=${VALID_REF}`, ); }).pipe(Effect.provide(layer)); }, @@ -2169,15 +2212,33 @@ describe("pull integration", () => { ); }); -// The raw `--project-ref` value in `cli_command_executed`'s `flags` map is read from the +// The raw `--remote`/`--project-ref` value in `cli_command_executed`'s `flags` map is read from the // literal argv (`Stdio.args`), a separate source from the `PullFlags` object the handler runs // on, so each case below overrides `Stdio.args` on top of `setup()`'s default. describe("pull telemetry wiring", () => { - const withProjectRefArgs = (base: ReturnType<typeof setup>["layer"], projectRef: string) => - Layer.mergeAll( - base, - Stdio.layerTest({ args: Effect.succeed(["pull", "--project-ref", projectRef]) }), - ); + const withArgs = (base: ReturnType<typeof setup>["layer"], args: ReadonlyArray<string>) => + Layer.mergeAll(base, Stdio.layerTest({ args: Effect.succeed(["pull", ...args]) })); + + it.effect("logs a ref-shaped --remote verbatim in cli_command_executed", () => { + const { layer, analytics } = setup({ yes: true }); + return Effect.gen(function* () { + yield* writeConfig(); + yield* runPull(pullFlags({ remote: refRemote(VALID_REF), dryRun: true })); + const event = analytics.captured.find((c) => c.event === "cli_command_executed"); + expect(event?.properties["flags"]).toEqual({ remote: VALID_REF }); + }).pipe(Effect.provide(withArgs(layer, [`--remote=${VALID_REF}`]))); + }); + + it.effect("redacts a branch-name --remote in cli_command_executed", () => { + // A user-created branch name must never reach PostHog verbatim. + const { layer, analytics } = setup({ yes: true }); + return Effect.gen(function* () { + yield* writeConfig(); + yield* runPull(pullFlags({ remote: branchRemote("staging"), dryRun: true })); + const event = analytics.captured.find((c) => c.event === "cli_command_executed"); + expect(event?.properties["flags"]).toEqual({ remote: "<redacted>" }); + }).pipe(Effect.provide(withArgs(layer, ["--remote=staging"]))); + }); it.effect("logs a ref-shaped --project-ref verbatim in cli_command_executed", () => { const { layer, analytics } = setup({ yes: true }); @@ -2186,19 +2247,17 @@ describe("pull telemetry wiring", () => { yield* runPull(pullFlags({ projectRef: Option.some(VALID_REF), dryRun: true })); const event = analytics.captured.find((c) => c.event === "cli_command_executed"); expect(event?.properties["flags"]).toEqual({ "project-ref": VALID_REF }); - }).pipe(Effect.provide(withProjectRefArgs(layer, VALID_REF))); + }).pipe(Effect.provide(withArgs(layer, ["--project-ref", VALID_REF]))); }); it.effect("redacts a branch-name-shaped --project-ref in cli_command_executed", () => { - // `--project-ref` accepts branch names too; a user-created branch name must never reach - // PostHog verbatim. const { layer, analytics } = setup({ yes: true }); return Effect.gen(function* () { yield* writeConfig(); yield* runPull(pullFlags({ projectRef: Option.some("staging"), dryRun: true })); const event = analytics.captured.find((c) => c.event === "cli_command_executed"); expect(event?.properties["flags"]).toEqual({ "project-ref": "<redacted>" }); - }).pipe(Effect.provide(withProjectRefArgs(layer, "staging"))); + }).pipe(Effect.provide(withArgs(layer, ["--project-ref", "staging"]))); }); }); @@ -2337,19 +2396,28 @@ describe("pull permissions through the real layers", () => { const REF_SCENARIOS: ReadonlyArray<{ readonly name: string; + readonly remote: Option.Option<RemoteValue>; readonly projectRef: Option.Option<string>; - readonly flag: Readonly<Record<string, string>>; + readonly flag: Readonly<Record<string, string | boolean>>; }> = [ - { name: "no --project-ref", projectRef: Option.none(), flag: {} }, + { name: "no selector", remote: Option.none(), projectRef: Option.none(), flag: {} }, { - name: "--project-ref with the ref", - projectRef: Option.some(VALID_REF), - flag: { "project-ref": VALID_REF }, + name: "--remote=<ref>", + remote: refRemote(VALID_REF), + projectRef: Option.none(), + flag: { remote: VALID_REF }, }, { - name: "an empty --project-ref, which resolves from settings", - projectRef: Option.some(""), - flag: { "project-ref": "" }, + name: "a bare --remote, which resolves from settings", + remote: Option.some<RemoteValue>({ _tag: "Linked" }), + projectRef: Option.none(), + flag: { remote: true }, + }, + { + name: "the deprecated --project-ref with the ref", + remote: Option.none(), + projectRef: Option.some(VALID_REF), + flag: { "project-ref": VALID_REF }, }, ]; @@ -2363,7 +2431,7 @@ describe("pull permissions through the real layers", () => { yield* TestClock.setTime(AFTER_SEEDED_MIGRATIONS); yield* stubManagementApi(stubbed, serveLinkedDatabase(VALID_REF)); yield* writeConfig("[api]\nmax_rows = 500\n"); - yield* runPull(pullFlags({ projectRef: scenario.projectRef })); + yield* runPull(pullFlags({ remote: scenario.remote, projectRef: scenario.projectRef })); expect(asLines(stubbed)).toEqual(FULL_RUN_SEQUENCE); expect(asApiLines(api.requests)).toEqual([ diff --git a/apps/cli/src/docs/docs-spec.unit.test.ts b/apps/cli/src/docs/docs-spec.unit.test.ts index 0b8202ef28..1309fec21f 100644 --- a/apps/cli/src/docs/docs-spec.unit.test.ts +++ b/apps/cli/src/docs/docs-spec.unit.test.ts @@ -313,7 +313,7 @@ describe("review fixes stay fixed", () => { `${command.id} publishes include-raw-output`, ).toBe(false); } - expect(byId.get("supabase-domains-get")?.flags.map((flag) => flag.id)).toContain("project-ref"); + expect(byId.get("supabase-domains-get")?.flags.map((flag) => flag.id)).toContain("remote"); }); it("renders required variadic usage from the override table", () => { 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 8dd9c9a16f..417909c6b4 100644 --- a/apps/cli/src/shared/cli/hidden-flag.unit.test.ts +++ b/apps/cli/src/shared/cli/hidden-flag.unit.test.ts @@ -117,7 +117,7 @@ describe("native hidden flags", () => { ]); expect(buildHelpDoc(functionsDownloadCommand).flags.map((flag) => flag.name)).toEqual([ - "project-ref", + "remote", "use-api", ]); diff --git a/apps/cli/src/shared/cli/subcommand-flag-suggestions.unit.test.ts b/apps/cli/src/shared/cli/subcommand-flag-suggestions.unit.test.ts index 82f7fd9c19..c9ce594372 100644 --- a/apps/cli/src/shared/cli/subcommand-flag-suggestions.unit.test.ts +++ b/apps/cli/src/shared/cli/subcommand-flag-suggestions.unit.test.ts @@ -76,19 +76,14 @@ describe("subcommand flag placement suggestions", () => { const errors = formatCliErrorsForDisplay( [ new CliError.UnrecognizedOption({ - option: "--project-ref", + option: "--remote=abcdefghijklmnopqrst", command: ["supabase", "branches"], suggestions: [], }), - new CliError.UnknownSubcommand({ - subcommand: "abcdefghijklmnopqrst", - parent: ["supabase", "branches"], - suggestions: [], - }), ], { rootCommand: testRoot, - args: ["branches", "--project-ref", "abcdefghijklmnopqrst", "get"], + args: ["branches", "--remote=abcdefghijklmnopqrst", "get"], }, ); diff --git a/apps/cli/tests/helpers/compute-target-selection.ts b/apps/cli/tests/helpers/compute-target-selection.ts new file mode 100644 index 0000000000..ba01e10f84 --- /dev/null +++ b/apps/cli/tests/helpers/compute-target-selection.ts @@ -0,0 +1,79 @@ +import { BunServices } from "@effect/platform-bun"; +import { Effect, FileSystem, Layer, Path } from "effect"; + +import { commandRuntimeLayer } from "../../src/shared/runtime/command-runtime.layer.ts"; +import { COMPUTE_PROJECT_REF, setupCompute } from "./compute.ts"; +import type { ComputeHttpRoutes } from "./compute.ts"; +import { describeTargetSelectionMatrix, branchListRow } from "./hosted-target-selection.ts"; +import type { TargetSelectionFlags } from "./hosted-target-selection.ts"; +import type { ActiveFlags } from "./permission-registry.ts"; + +type ComputeSetup = ReturnType<typeof setupCompute>; + +/** Everything a compute leaf's handler can require while `describeComputeTargetSelection` runs it. */ +type ComputeTargetSelectionServices = Layer.Success<ComputeSetup["layer"]>; + +export interface ComputeTargetSelectionScenario { + readonly operands?: ReadonlyArray<string>; + /** Files the leaf reads from the project directory, relative to it. */ + readonly files?: Readonly<Record<string, string>>; + /** The routes the leaf calls on the project it acts on; the harness adds the branch lookup. */ + readonly routes: (ref: string) => ComputeHttpRoutes; + readonly run: ( + flags: TargetSelectionFlags, + ) => Effect.Effect<unknown, unknown, ComputeTargetSelectionServices>; + readonly activeFlags?: ActiveFlags; +} + +const writeFiles = (workdir: string, files: Readonly<Record<string, string>>) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + for (const [relativePath, contents] of Object.entries(files)) { + const absolutePath = path.join(workdir, relativePath); + yield* fs.makeDirectory(path.dirname(absolutePath), { recursive: true }); + yield* fs.writeFileString(absolutePath, contents); + } + }).pipe(Effect.provide(BunServices.layer)); + +/** `describeTargetSelectionMatrix` for a `compute` leaf, whose runtime is `setupCompute` over stubbed routes. */ +export function describeComputeTargetSelection( + path: string, + scenario: ComputeTargetSelectionScenario, +): void { + describeTargetSelectionMatrix<ComputeTargetSelectionServices>(path, { + mode: "project", + operands: scenario.operands ?? [], + ...(scenario.activeFlags === undefined ? {} : { activeFlags: scenario.activeFlags }), + variant: { computeEnabled: true }, + run: scenario.run, + prepare: (workdir) => writeFiles(workdir, scenario.files ?? {}), + linkParent: (workdir) => + writeFiles(workdir, { "supabase/.temp/project-ref": COMPUTE_PROJECT_REF }), + harness: ({ selectorArgs, expectedRef, workdir }) => { + const { out, http, layer } = setupCompute({ + workdir, + cliArgs: selectorArgs, + yes: true, + routes: { + ...scenario.routes(expectedRef), + [`GET /v1/projects/${COMPUTE_PROJECT_REF}/branches`]: { + status: 200, + body: [branchListRow("feature-x")], + }, + }, + }); + return { + layer: Layer.mergeAll( + layer, + commandRuntimeLayer(path.split(" ")).pipe(Layer.provide(BunServices.layer)), + ), + requests: () => http.driftRequests, + warnings: () => + out.messages + .filter((message) => message.type === "warn") + .map((message) => message.message), + }; + }, + }); +} diff --git a/apps/cli/tests/helpers/hosted-target-selection.ts b/apps/cli/tests/helpers/hosted-target-selection.ts new file mode 100644 index 0000000000..b6626ba2f5 --- /dev/null +++ b/apps/cli/tests/helpers/hosted-target-selection.ts @@ -0,0 +1,298 @@ +import { BunServices } from "@effect/platform-bun"; +import { describe, expect, it } from "@effect/vitest"; +import { Effect, FileSystem, Layer, Option, Path } from "effect"; + +import { YesFlag } from "../../src/command-internal/global-flags.ts"; +import type { RemoteValue } from "../../src/command-internal/target/remote-value.ts"; +import { TargetPolicy } from "../../src/command-internal/target/target-policy.ts"; +import { commandRuntimeLayer } from "../../src/shared/runtime/command-runtime.layer.ts"; +import { + VALID_REF, + buildTestRuntime, + jsonResponse, + mockCommandPlatformApi, + mockCommandSettings, + useTempWorkdir, +} from "./command-mocks.ts"; +import type { ApiHandler } from "./command-mocks.ts"; +import { mockOutput } from "./mocks.ts"; +import { assertPermissionDrift } from "./permission-drift.ts"; +import type { ActiveFlags, PermissionVariant } from "./permission-registry.ts"; + +const OTHER_REF = "qrstuvwxyzabcdefghij"; +const BRANCH_REF = "branchprojectrefabcd"; +const DB_URL = "postgresql://postgres:pw@db.example.com:5432/postgres"; +const API_URL = "https://api.supabase.com"; +const PROJECT_REF_IN_URL = /\/v[12]\/projects\/([a-z]{20})(?:[/?]|$)/; + +/** The branch list the harness answers a `--remote=feature-x` lookup with. */ +export const branchListRow = (name: string) => ({ + id: "11111111-2222-4333-8444-555555555555", + name, + project_ref: BRANCH_REF, + parent_project_ref: VALID_REF, + is_default: false, + persistent: false, + status: "MIGRATIONS_PASSED", + created_at: "2026-05-27T01:02:03Z", + updated_at: "2026-05-27T01:02:04Z", + with_data: true, +}); + +export interface TargetSelectionFlags { + readonly remote: Option.Option<RemoteValue>; + readonly projectRef: Option.Option<string>; +} + +interface RecordedCall { + readonly method: string; + readonly url: string; + readonly urlParams?: string; +} + +interface TargetSelectionHarness<R> { + readonly layer: Layer.Layer<R, unknown>; + readonly requests: () => ReadonlyArray<RecordedCall>; + readonly warnings: () => ReadonlyArray<string>; +} + +export interface TargetSelectionMatrix<R> { + /** + * `"project"`: the command acts on the project `--remote` names, so a branch name is looked up + * under the linked project. `"parent"`: it acts on a parent project, so a branch name is + * rejected. + */ + readonly mode: "project" | "parent"; + /** Positional words the command line carries, in order, ahead of the selector flag. */ + readonly operands: ReadonlyArray<string>; + /** Flags the command's permission declaration conditions on, beyond the selector under test. */ + readonly activeFlags?: ActiveFlags; + readonly variant?: PermissionVariant; + /** Builds the runtime for one run; `selectorArgs` is the selector's share of the command line. */ + readonly harness: (input: { + readonly selectorArgs: ReadonlyArray<string>; + readonly expectedRef: string; + readonly workdir: string; + }) => TargetSelectionHarness<R>; + /** Seeds the workdir before every run. */ + readonly prepare?: (workdir: string) => Effect.Effect<void, unknown, R>; + /** Marks the workdir as linked to {@link VALID_REF}, the parent a branch name resolves under. */ + readonly linkParent: (workdir: string) => Effect.Effect<void, unknown, R>; + readonly run: (flags: TargetSelectionFlags) => Effect.Effect<unknown, unknown, R>; +} + +const projectRefsOf = (requests: ReadonlyArray<RecordedCall>): ReadonlyArray<string> => [ + ...new Set( + requests.flatMap((request) => { + const ref = PROJECT_REF_IN_URL.exec(new URL(request.url, API_URL).pathname)?.[1]; + return ref === undefined ? [] : [ref]; + }), + ), +]; + +/** + * Registers the `--remote` selection matrix for the leaf `path`: `--remote=<ref>` with no + * warning, the `--project-ref` alias with its deprecation warning, a database URL rejected before + * any request, and a branch name looked up under the linked project (or rejected, for a + * parent-scoped command). + */ +export function describeTargetSelectionMatrix<R>( + path: string, + matrix: TargetSelectionMatrix<R>, +): void { + const words = path.split(" "); + const commandLine = [...words, ...matrix.operands].join(" "); + const commandName = `\`supabase ${path}\``; + const extraFlags = matrix.activeFlags ?? {}; + const driftVariant = matrix.variant === undefined ? {} : { variant: matrix.variant }; + // A parent-scoped command goes on to touch the branch's own project, so only the first project it reaches is the parent. + const expectActedOn = (requests: ReadonlyArray<RecordedCall>, ref: string) => { + const refs = projectRefsOf(requests); + if (matrix.mode === "parent") expect(refs[0]).toBe(ref); + else expect(refs).toEqual([ref]); + }; + + describe(`target selection: ${path}`, () => { + const tempRoot = useTempWorkdir("supabase-target-selection-"); + + const prepare = Effect.suspend(() => matrix.prepare?.(tempRoot.current) ?? Effect.void); + + const harness = (selectorArgs: ReadonlyArray<string>, expectedRef: string) => + matrix.harness({ + selectorArgs: [...words, ...matrix.operands, ...selectorArgs], + expectedRef, + workdir: tempRoot.current, + }); + + it.effect("uses the project --remote=<ref> names, with no deprecation warning", () => { + const { layer, requests, warnings } = harness([`--remote=${OTHER_REF}`], OTHER_REF); + return Effect.gen(function* () { + yield* prepare; + yield* matrix.run({ + remote: Option.some({ _tag: "Ref", ref: OTHER_REF }), + projectRef: Option.none(), + }); + + expectActedOn(requests(), OTHER_REF); + expect(warnings()).toEqual([]); + assertPermissionDrift({ + command: path, + ...driftVariant, + activeFlags: { ...extraFlags, remote: OTHER_REF }, + requests: requests(), + }); + }).pipe(Effect.provide(layer)); + }); + + it.effect("keeps --project-ref working and points at --remote=<ref>", () => { + const { layer, requests, warnings } = harness(["--project-ref", OTHER_REF], OTHER_REF); + return Effect.gen(function* () { + yield* prepare; + yield* matrix.run({ remote: Option.none(), projectRef: Option.some(OTHER_REF) }); + + expectActedOn(requests(), OTHER_REF); + expect(warnings()).toEqual([ + `--project-ref is deprecated and will be removed in v4. Use --remote instead: supabase ${commandLine} --remote=${OTHER_REF}`, + ]); + }).pipe(Effect.provide(layer)); + }); + + it.effect("rejects a database URL before any request", () => { + const { layer, requests } = harness([`--remote=${DB_URL}`], VALID_REF); + return Effect.gen(function* () { + yield* prepare; + const error = yield* matrix + .run({ remote: Option.some({ _tag: "DbUrl", url: DB_URL }), projectRef: Option.none() }) + .pipe(Effect.flip); + + expect(error).toMatchObject({ + _tag: "TargetNeedsProjectError", + message: `${commandName} needs a project, not a database URL.`, + }); + expect(requests()).toEqual([]); + }).pipe(Effect.provide(layer)); + }); + + if (matrix.mode === "parent") { + it.effect("rejects a branch name and points at --remote=<ref>", () => { + const { layer, requests } = harness(["--remote=feature-x"], VALID_REF); + return Effect.gen(function* () { + yield* prepare; + const error = yield* matrix + .run({ + remote: Option.some({ _tag: "Branch", name: "feature-x" }), + projectRef: Option.none(), + }) + .pipe(Effect.flip); + + expect(error).toMatchObject({ + _tag: "TargetNeedsProjectError", + message: `${commandName} acts on a parent project, so --remote takes its project ref, not a branch name. Use --remote=<ref>.`, + }); + expect(requests()).toEqual([]); + }).pipe(Effect.provide(layer)); + }); + return; + } + + it.effect("acts on the project of the branch --remote=<branch> names", () => { + const { layer, requests } = harness(["--remote=feature-x"], BRANCH_REF); + return Effect.gen(function* () { + yield* prepare; + yield* matrix.linkParent(tempRoot.current); + + yield* matrix.run({ + remote: Option.some({ _tag: "Branch", name: "feature-x" }), + projectRef: Option.none(), + }); + + expect(requests()[0]).toMatchObject({ + method: "GET", + url: `${API_URL}/v1/projects/${VALID_REF}/branches`, + }); + expect(projectRefsOf(requests())).toEqual([VALID_REF, BRANCH_REF]); + assertPermissionDrift({ + command: path, + ...driftVariant, + activeFlags: { ...extraFlags, remote: "feature-x" }, + requests: requests(), + }); + }).pipe(Effect.provide(layer)); + }); + }); +} + +const hostedLayer = ( + words: ReadonlyArray<string>, + runtime: Parameters<typeof buildTestRuntime>[0], +) => + Layer.mergeAll( + buildTestRuntime(runtime), + commandRuntimeLayer(words).pipe(Layer.provide(BunServices.layer)), + Layer.succeed(YesFlag, true), + Layer.succeed(TargetPolicy, { group: "hosted", consent: "yes" }), + ); + +/** Everything a hosted leaf's handler can require while `describeHostedTargetSelection` runs it. */ +type HostedTargetSelectionServices = Layer.Success<ReturnType<typeof hostedLayer>>; + +export interface HostedTargetSelectionScenario { + readonly mode?: "project" | "parent"; + readonly operands?: ReadonlyArray<string>; + /** + * The command's own success response, given the project ref the run should act on; in + * `"project"` mode the harness answers the branch lookup itself. + */ + readonly respond: ( + request: Parameters<ApiHandler>[0], + expectedRef: string, + ) => ReturnType<ApiHandler>; + readonly run: ( + flags: TargetSelectionFlags, + ) => Effect.Effect<unknown, unknown, HostedTargetSelectionServices>; + readonly activeFlags?: ActiveFlags; +} + +/** `describeTargetSelectionMatrix` for a hosted leaf whose runtime is `buildTestRuntime` over a mocked Management API. */ +export function describeHostedTargetSelection( + path: string, + scenario: HostedTargetSelectionScenario, +): void { + const mode = scenario.mode ?? "project"; + describeTargetSelectionMatrix<HostedTargetSelectionServices>(path, { + mode, + operands: scenario.operands ?? [], + ...(scenario.activeFlags === undefined ? {} : { activeFlags: scenario.activeFlags }), + run: scenario.run, + harness: ({ selectorArgs, expectedRef, workdir }) => { + const out = mockOutput({ format: "text" }); + const api = mockCommandPlatformApi({ + handler: (request) => + mode === "project" && request.method === "GET" && request.url.endsWith("/branches") + ? Effect.succeed(jsonResponse(request, 200, [branchListRow("feature-x")])) + : scenario.respond(request, expectedRef), + }); + return { + layer: hostedLayer(path.split(" "), { + out, + api, + cliSettings: mockCommandSettings({ workdir, projectId: Option.none() }), + cliArgs: selectorArgs, + }), + requests: () => api.requests, + warnings: () => + out.messages + .filter((message) => message.type === "warn") + .map((message) => message.message), + }; + }, + linkParent: (workdir) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const temp = path.join(workdir, "supabase", ".temp"); + yield* fs.makeDirectory(temp, { recursive: true }); + yield* fs.writeFileString(path.join(temp, "project-ref"), VALID_REF); + }).pipe(Effect.provide(BunServices.layer)), + }); +} From 4831f9af9ec6a46926de703f8b7829bfaa3909bb Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Mon, 19 Oct 2026 12:13:26 +0100 Subject: [PATCH 128/291] docs(cli): describe --remote in hosted A-F SIDE_EFFECTS Each command documents --remote (bare, =<ref>, =<branch> where valid, and the rejected database URL), the default target, and the deprecation warning the --project-ref alias prints on stderr. --- .../src/commands/backups/list/SIDE_EFFECTS.md | 44 ++++++++------- .../commands/backups/restore/SIDE_EFFECTS.md | 28 ++++++---- .../commands/branches/create/SIDE_EFFECTS.md | 56 ++++++++++--------- .../commands/branches/delete/SIDE_EFFECTS.md | 18 +++--- .../commands/branches/disable/SIDE_EFFECTS.md | 12 ++-- .../src/commands/branches/get/SIDE_EFFECTS.md | 20 ++++--- .../commands/branches/list/SIDE_EFFECTS.md | 54 +++++++++--------- .../commands/branches/pause/SIDE_EFFECTS.md | 18 +++--- .../commands/branches/unpause/SIDE_EFFECTS.md | 18 +++--- .../commands/branches/update/SIDE_EFFECTS.md | 24 ++++---- .../src/commands/config/diff/SIDE_EFFECTS.md | 24 ++++---- .../src/commands/config/pull/SIDE_EFFECTS.md | 30 +++++----- .../src/commands/config/push/SIDE_EFFECTS.md | 50 +++++++++-------- apps/cli/src/commands/domains/SIDE_EFFECTS.md | 30 +++++----- .../src/commands/encryption/SIDE_EFFECTS.md | 38 +++++++------ .../compute/delete/SIDE_EFFECTS.md | 11 +++- .../experimental/compute/list/SIDE_EFFECTS.md | 21 ++++--- .../experimental/compute/logs/SIDE_EFFECTS.md | 15 +++-- .../experimental/compute/push/SIDE_EFFECTS.md | 17 ++++-- .../compute/status/SIDE_EFFECTS.md | 11 +++- .../commands/functions/delete/SIDE_EFFECTS.md | 38 +++++++------ .../functions/download/SIDE_EFFECTS.md | 30 +++++----- .../commands/functions/list/SIDE_EFFECTS.md | 32 ++++++----- apps/cli/src/commands/pull/SIDE_EFFECTS.md | 40 ++++++------- 24 files changed, 383 insertions(+), 296 deletions(-) diff --git a/apps/cli/src/commands/backups/list/SIDE_EFFECTS.md b/apps/cli/src/commands/backups/list/SIDE_EFFECTS.md index d1814e5135..6160d35021 100644 --- a/apps/cli/src/commands/backups/list/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/backups/list/SIDE_EFFECTS.md @@ -8,7 +8,7 @@ | keyring `"Supabase CLI"` / `<profile>` | OS keychain | when `SUPABASE_ACCESS_TOKEN` unset and keyring available; account = `CommandSettings.profile` | | keyring `"Supabase CLI"` / `access-token` | OS keychain | legacy-key fallback when the profile-keyed lookup misses | | `~/.supabase/access-token` | plain text (token string) | last-resort fallback after env + keyring miss | -| `<workdir>/supabase/.temp/project-ref` | plain text | when `--project-ref` and `SUPABASE_PROJECT_ID` are both unset | +| `<workdir>/supabase/.temp/project-ref` | plain text | when neither `--remote=<ref>` nor `SUPABASE_PROJECT_ID` is set | ## Files Written @@ -19,11 +19,12 @@ ## API Routes -| Method | Path | Auth | Request body | Response (used fields) | -| ------ | ------------------------------------- | ------------ | ------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `GET` | `/v1/projects/{ref}/database/backups` | Bearer token | none | `{region, walg_enabled, pitr_enabled, backups: [{inserted_at, status, is_physical_backup}], physical_backup_data: {earliest_physical_backup_date_unix?, latest_physical_backup_date_unix?}}` | -| `GET` | `/v1/projects` | Bearer token | none | `[{id, ref, name, organization_slug, region, ...}]` — TTY-prompt fallback only | -| `GET` | `/v1/projects/{ref}` | Bearer token | none | `{ref, name, organization_id, organization_slug}` — linked-project cache miss only (best-effort, after the command) | +| Method | Path | Auth | Request body | Response (used fields) | +| ------ | ------------------------------------- | ------------ | ------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `GET` | `/v1/projects/{ref}/database/backups` | Bearer token | none | `{region, walg_enabled, pitr_enabled, backups: [{inserted_at, status, is_physical_backup}], physical_backup_data: {earliest_physical_backup_date_unix?, latest_physical_backup_date_unix?}}` | +| `GET` | `/v1/projects` | Bearer token | none | `[{id, ref, name, organization_slug, region, ...}]` — TTY-prompt fallback only | +| `GET` | `/v1/projects/{ref}` | Bearer token | none | `{ref, name, organization_id, organization_slug}` — linked-project cache miss only (best-effort, after the command) | +| `GET` | `/v1/projects/{parent}/branches` | Bearer token | none | `[{id, name, project_ref, is_default}]` — only for `--remote=<branch>` (or the deprecated `--project-ref=<branch>`), and for a bare `--remote` followed by a word that could name a branch; `{parent}` is `SUPABASE_PROJECT_ID`, else the linked project | ## Environment Variables @@ -31,26 +32,26 @@ | ----------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------- | | `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_PROJECT_ID` | project ref fallback when `--remote=<ref>` is not given | 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 — backup list printed to stdout | -| `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` | `BackupListUnexpectedStatusError` — non-2xx response from the backups endpoint | -| `1` | `BackupListNetworkError` — transport-level network failure | +| Code | Condition | +| ---- | ------------------------------------------------------------------------------------- | +| `0` | success — backup list printed to stdout | +| `1` | `AccessTokenRequiredError` — no token in env/keyring/file | +| `1` | `InvalidAccessTokenError` — token violates `^sbp_(oauth_\|v0_)?[a-f0-9]{40}$` | +| `1` | `ProjectRefNotLinkedError` — no `--remote=<ref>`, env/file empty, and stdin not a TTY | +| `1` | `InvalidProjectRefError` — resolved ref violates `^[a-z]{20}$` | +| `1` | `BackupListUnexpectedStatusError` — non-2xx response from the backups endpoint | +| `1` | `BackupListNetworkError` — transport-level network failure | ## Telemetry Events Fired -| Event | When | Notable properties / groups | -| ---------------------- | ------------------------------------------ | -------------------------------------------------------------------- | -| `cli_command_executed` | post-run, success or failure (via wrapper) | `exit_code`, `duration_ms`, `flags` (`--project-ref` → `<redacted>`) | +| Event | When | Notable properties / groups | +| ---------------------- | ------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `cli_command_executed` | post-run, success or failure (via wrapper) | `exit_code`, `duration_ms`, `flags` (`--remote=<ref>` → the ref, a bare `--remote` → `true`, any other `--remote` value → `<redacted>`; `--project-ref` → `<redacted>`) | ## Output @@ -91,7 +92,10 @@ One `result` NDJSON event on success containing the backup response object. ## Notes -- Requires `--project-ref`, `SUPABASE_PROJECT_ID`, a populated `<workdir>/supabase/.temp/project-ref` file, or a TTY for the interactive project picker. +- Targets the linked project unless `--remote=<ref>` is given; a bare `--remote` also targets the linked project. `--remote=<value>` is read as a project ref (20 lowercase letters), a branch of the linked project (by name or UUID, resolved through `GET /v1/projects/{parent}/branches`), or `true|yes|on|1` for the linked project. A database URL is rejected (`supabase backups list` takes a project, not a database); `false|no|off|0`, `--no-remote` and an empty value fail with `Leave out --remote to use the linked project.` +- A bare `--remote` followed by a word that is a branch of the linked project fails before any other work with a `Did you mean --remote=<word>?` hint, since the word may have been meant as the flag's value. +- The deprecated `--project-ref` is an alias of `--remote=<ref>` and prints a deprecation warning on stderr that names the replacement; it cannot be combined with `--remote=<value>`. An empty `--project-ref` is rejected. +- Requires `--remote=<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. diff --git a/apps/cli/src/commands/backups/restore/SIDE_EFFECTS.md b/apps/cli/src/commands/backups/restore/SIDE_EFFECTS.md index 3148ffa702..0e271ecb71 100644 --- a/apps/cli/src/commands/backups/restore/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/backups/restore/SIDE_EFFECTS.md @@ -8,7 +8,7 @@ | keyring `"Supabase CLI"` / `<profile>` | OS keychain | when `SUPABASE_ACCESS_TOKEN` unset and keyring available; account = `CommandSettings.profile` | | keyring `"Supabase CLI"` / `access-token` | OS keychain | legacy-key fallback when the profile-keyed lookup misses | | `~/.supabase/access-token` | plain text (token string) | last-resort fallback after env + keyring miss | -| `<workdir>/supabase/.temp/project-ref` | plain text | when `--project-ref` and `SUPABASE_PROJECT_ID` are both unset | +| `<workdir>/supabase/.temp/project-ref` | plain text | when neither `--remote=<ref>` nor `SUPABASE_PROJECT_ID` is set | ## Files Written @@ -19,11 +19,12 @@ ## API Routes -| Method | Path | Auth | Request body | Response (used fields) | -| ------ | -------------------------------------------------- | ------------ | ------------------------------------ | ------------------------------------------------------------------------------------------------------------------- | -| `POST` | `/v1/projects/{ref}/database/backups/restore-pitr` | Bearer token | `{recovery_time_target_unix: int64}` | none (201 Created) | -| `GET` | `/v1/projects` | Bearer token | none | `[{id, ref, name, organization_slug, region, ...}]` — TTY-prompt fallback only | -| `GET` | `/v1/projects/{ref}` | Bearer token | none | `{ref, name, organization_id, organization_slug}` — linked-project cache miss only (best-effort, after the command) | +| Method | Path | Auth | Request body | Response (used fields) | +| ------ | -------------------------------------------------- | ------------ | ------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `POST` | `/v1/projects/{ref}/database/backups/restore-pitr` | Bearer token | `{recovery_time_target_unix: int64}` | none (201 Created) | +| `GET` | `/v1/projects` | Bearer token | none | `[{id, ref, name, organization_slug, region, ...}]` — TTY-prompt fallback only | +| `GET` | `/v1/projects/{ref}` | Bearer token | none | `{ref, name, organization_id, organization_slug}` — linked-project cache miss only (best-effort, after the command) | +| `GET` | `/v1/projects/{parent}/branches` | Bearer token | none | `[{id, name, project_ref, is_default}]` — only for `--remote=<branch>` (or the deprecated `--project-ref=<branch>`), and for a bare `--remote` followed by a word that could name a branch; `{parent}` is `SUPABASE_PROJECT_ID`, else the linked project | ## Environment Variables @@ -31,7 +32,7 @@ | ----------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------- | | `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | | `SUPABASE_PROFILE` | selects API base URL: `supabase` → `api.supabase.com`, `supabase-staging` → `api.supabase.green`, `supabase-local` → `http://localhost:8080`. May alternatively be a filesystem path to a YAML profile with at least `api_url:` and optional `name:` (used by the cli-e2e test harness). | no (defaults to `supabase`) | -| `SUPABASE_PROJECT_ID` | project ref fallback when `--project-ref` is unset | no (also reads `<workdir>/supabase/.temp/project-ref` then prompts on TTY) | +| `SUPABASE_PROJECT_ID` | project ref fallback when `--remote=<ref>` is not given | 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 | @@ -42,7 +43,7 @@ | `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` | `ProjectRefNotLinkedError` — no `--remote=<ref>`, 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 | @@ -51,9 +52,9 @@ ## Telemetry Events Fired -| Event | When | Notable properties / groups | -| ---------------------- | ------------------------------------------ | ------------------------------------------------------------------------------------ | -| `cli_command_executed` | post-run, success or failure (via wrapper) | `exit_code`, `duration_ms`, `flags` (`--project-ref` / `--timestamp` → `<redacted>`) | +| Event | When | Notable properties / groups | +| ---------------------- | ------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `cli_command_executed` | post-run, success or failure (via wrapper) | `exit_code`, `duration_ms`, `flags` (`--remote=<ref>` → the ref, a bare `--remote` → `true`, any other `--remote` value → `<redacted>`; `--project-ref` / `--timestamp` → `<redacted>`) | ## Output @@ -79,9 +80,12 @@ One `result` NDJSON event on success with the project ref payload. ## Notes +- Targets the linked project unless `--remote=<ref>` is given; a bare `--remote` also targets the linked project. `--remote=<value>` is read as a project ref (20 lowercase letters), a branch of the linked project (by name or UUID, resolved through `GET /v1/projects/{parent}/branches`), or `true|yes|on|1` for the linked project. A database URL is rejected (`supabase backups restore` takes a project, not a database); `false|no|off|0`, `--no-remote` and an empty value fail with `Leave out --remote to use the linked project.` +- A bare `--remote` followed by a word that is a branch of the linked project fails before any other work with a `Did you mean --remote=<word>?` hint, since the word may have been meant as the flag's value. +- The deprecated `--project-ref` is an alias of `--remote=<ref>` and prints a deprecation warning on stderr that names the replacement; it cannot be combined with `--remote=<value>`. An empty `--project-ref` is rejected. - `--timestamp` / `-t` accepts seconds since Unix epoch (int64). Defaults to `0`, which the API interprets as "now". - Known gap: the generated `V1RestorePitrBackupInput` schema enforces `recovery_time_target_unix >= 0`, so a negative value is rejected locally with a schema decode error instead of being forwarded to the API (the underlying field has no lower bound). Resolving this requires an upstream OpenAPI spec change. -- Requires `--project-ref`, `SUPABASE_PROJECT_ID`, a populated `<workdir>/supabase/.temp/project-ref` file, or a TTY for the interactive project picker. +- Requires `--remote=<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. diff --git a/apps/cli/src/commands/branches/create/SIDE_EFFECTS.md b/apps/cli/src/commands/branches/create/SIDE_EFFECTS.md index 154a24a2b0..a4e40602e2 100644 --- a/apps/cli/src/commands/branches/create/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/branches/create/SIDE_EFFECTS.md @@ -2,14 +2,14 @@ ## Files Read -| Path | Format | When | -| ---------------------------------------------- | ------------------------- | ------------------------------------------------------------------------------------------------------------------------- | -| keyring `"Supabase CLI"` / `<profile>` | OS keychain | when `SUPABASE_ACCESS_TOKEN` unset and keyring available; account = `CommandSettings.profile` | -| keyring `"Supabase CLI"` / `access-token` | OS keychain | legacy-key fallback when the profile-keyed lookup misses | -| `~/.supabase/access-token` | plain text (token string) | last-resort fallback after env + keyring miss | -| `<workdir>/supabase/.temp/linked-project.json` | JSON (`ref` field) | when `--project-ref` is unset, as the 2nd PARENT-ref candidate (CLI-2167 follow-up — see `branches list/SIDE_EFFECTS.md`) | -| `<workdir>/supabase/.temp/project-ref` | plain text | when `--project-ref` and `SUPABASE_PROJECT_ID` are both unset, as the 3rd (last) PARENT-ref candidate | -| `<cwd>/.git/HEAD` (walking parents) | plain text | when the positional `[name]` arg is omitted — fallback branch name detection | +| Path | Format | When | +| ---------------------------------------------- | ------------------------- | ------------------------------------------------------------------------------------------------------------------------------ | +| keyring `"Supabase CLI"` / `<profile>` | OS keychain | when `SUPABASE_ACCESS_TOKEN` unset and keyring available; account = `CommandSettings.profile` | +| keyring `"Supabase CLI"` / `access-token` | OS keychain | legacy-key fallback when the profile-keyed lookup misses | +| `~/.supabase/access-token` | plain text (token string) | last-resort fallback after env + keyring miss | +| `<workdir>/supabase/.temp/linked-project.json` | JSON (`ref` field) | when `--remote=<ref>` is not given, as the 2nd PARENT-ref candidate (CLI-2167 follow-up — see `branches list/SIDE_EFFECTS.md`) | +| `<workdir>/supabase/.temp/project-ref` | plain text | when neither `--remote=<ref>` nor `SUPABASE_PROJECT_ID` is set, as the 3rd (last) PARENT-ref candidate | +| `<cwd>/.git/HEAD` (walking parents) | plain text | when the positional `[name]` arg is omitted — fallback branch name detection | ## Files Written @@ -20,24 +20,24 @@ ## API Routes -| Method | Path | Auth | Request body | Response (used fields) | -| ------ | ----------------------------------------------- | ------------ | ---------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `POST` | `/v1/projects/{ref}/branches` | Bearer token | `{branch_name, is_default: false, git_branch?, region?, desired_instance_size?, persistent?, with_data?, notify_url?}` | full `BranchResponse` | -| `GET` | `/v1/projects/{ref}` (on 4xx gated) | Bearer token | none | `{organization_slug}` | -| `GET` | `/v1/organizations/{slug}/entitlements` (gated) | Bearer token | none | `[{feature: {key}, hasAccess, ...}]` | -| `GET` | `/v1/projects` | Bearer token | none | `[{id, name, organization_slug, region, …}]` — no project ref from `--project-ref`, `SUPABASE_PROJECT_ID` or `.temp/project-ref`, with stdin and stdout TTYs and a non-JSON output format (project picker) | -| `GET` | `/v1/projects/{ref}` | Bearer token | none | `{ref, name, organization_id, organization_slug}` — linked-project cache miss only (best-effort, after the command) | +| Method | Path | Auth | Request body | Response (used fields) | +| ------ | ----------------------------------------------- | ------------ | ---------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `POST` | `/v1/projects/{ref}/branches` | Bearer token | `{branch_name, is_default: false, git_branch?, region?, desired_instance_size?, persistent?, with_data?, notify_url?}` | full `BranchResponse` | +| `GET` | `/v1/projects/{ref}` (on 4xx gated) | Bearer token | none | `{organization_slug}` | +| `GET` | `/v1/organizations/{slug}/entitlements` (gated) | Bearer token | none | `[{feature: {key}, hasAccess, ...}]` | +| `GET` | `/v1/projects` | Bearer token | none | `[{id, name, organization_slug, region, …}]` — no project ref from `--remote=<ref>`, `SUPABASE_PROJECT_ID` or `.temp/project-ref`, with stdin and stdout TTYs and a non-JSON output format (project picker) | +| `GET` | `/v1/projects/{ref}` | Bearer token | none | `{ref, name, organization_id, organization_slug}` — linked-project cache miss only (best-effort, after the command) | ## 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 YAML profile path | no | -| `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 | +| 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 YAML profile path | no | +| `SUPABASE_PROJECT_ID` | PARENT project ref fallback (1st candidate) when `--remote=<ref>` is not given (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 @@ -52,10 +52,10 @@ ## Telemetry Events Fired -| Event | When | Notable properties / groups | -| ----------------------- | ----------------------------------------------- | ----------------------------------------------------------------- | -| `cli_command_executed` | post-run, success or failure (via wrapper) | `exit_code`, `duration_ms`, `flags` (`--project-ref` whitelisted) | -| `cli_upgrade_suggested` | on 4xx with `branching_limit` entitlement gated | `{feature_key: "branching_limit", org_slug}` | +| Event | When | Notable properties / groups | +| ----------------------- | ----------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `cli_command_executed` | post-run, success or failure (via wrapper) | `exit_code`, `duration_ms`, `flags` (`--remote=<ref>` → the ref, a bare `--remote` → `true`; `--project-ref` whitelisted when ref-shaped, otherwise redacted) | +| `cli_upgrade_suggested` | on 4xx with `branching_limit` entitlement gated | `{feature_key: "branching_limit", org_slug}` | ## Output @@ -69,5 +69,7 @@ In `--output-format json` / `stream-json`, a `success` event carries the respons ## Notes +- `--remote=<ref>` names the parent project; with no selector or a bare `--remote` the parent comes from the chain above. A branch name or UUID is rejected with `` `supabase branches create` acts on a parent project, so --remote takes its project ref, not a branch name. Use --remote=<ref>. ``, and a database URL with `` `supabase branches create` needs a project, not a database URL. ``; `false|no|off|0`, `--no-remote` and an empty value fail with `Leave out --remote to use the linked project.` The branch itself stays a positional argument. +- The deprecated `--project-ref` is an alias of `--remote=<ref>` and prints a deprecation warning on stderr that names the replacement; it cannot be combined with `--remote=<value>`. An empty `--project-ref` is rejected. - 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 18e894924a..3249829257 100644 --- a/apps/cli/src/commands/branches/delete/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/branches/delete/SIDE_EFFECTS.md @@ -13,14 +13,14 @@ Same auth fallback chain as every Management-API command. Project-ref discovery ## API Routes -| Method | Path | Auth | When | Response | -| -------- | ------------------------------------ | ------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------- | -| `GET` | `/v1/projects/{ref}/branches/{name}` | Bearer token | branch input is not a UUID and not a `^[a-z]{20}$` ref pattern | `{project_ref}` | -| `GET` | `/v1/branches/{branch_id_or_ref}` | Bearer token | branch input is a UUID | `{ref}` | -| `DELETE` | `/v1/branches/{branch_id_or_ref}` | Bearer token | always — `force` query param omitted | `{ message: "ok" }` | -| `GET` | `/v1/projects/{ref}/branches` | Bearer token | branch argument omitted or empty and stdin is a TTY (branch picker) | `[{name, project_ref, …}]` | -| `GET` | `/v1/projects` | Bearer token | no project ref from `--project-ref`, `SUPABASE_PROJECT_ID` or `.temp/project-ref`, with stdin and stdout TTYs and a non-JSON output format (project picker) | `[{id, name, organization_slug, region, …}]` | -| `GET` | `/v1/projects/{ref}` | Bearer token | linked-project cache miss only (best-effort, after the command) | `{ref, name, organization_id, organization_slug}` | +| Method | Path | Auth | When | Response | +| -------- | ------------------------------------ | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------- | +| `GET` | `/v1/projects/{ref}/branches/{name}` | Bearer token | branch input is not a UUID and not a `^[a-z]{20}$` ref pattern | `{project_ref}` | +| `GET` | `/v1/branches/{branch_id_or_ref}` | Bearer token | branch input is a UUID | `{ref}` | +| `DELETE` | `/v1/branches/{branch_id_or_ref}` | Bearer token | always — `force` query param omitted | `{ message: "ok" }` | +| `GET` | `/v1/projects/{ref}/branches` | Bearer token | branch argument omitted or empty and stdin is a TTY (branch picker) | `[{name, project_ref, …}]` | +| `GET` | `/v1/projects` | Bearer token | no project ref from `--remote=<ref>`, `SUPABASE_PROJECT_ID` or `.temp/project-ref`, with stdin and stdout TTYs and a non-JSON output format (project picker) | `[{id, name, organization_slug, region, …}]` | +| `GET` | `/v1/projects/{ref}` | Bearer token | linked-project cache miss only (best-effort, after the command) | `{ref, name, organization_id, organization_slug}` | ## Environment Variables @@ -57,5 +57,7 @@ Single `success` event carrying `{project_ref}`. ## Notes +- `--remote=<ref>` names the parent project; with no selector or a bare `--remote` the parent comes from the chain above. A branch name or UUID is rejected with `` `supabase branches delete` acts on a parent project, so --remote takes its project ref, not a branch name. Use --remote=<ref>. ``, and a database URL with `` `supabase branches delete` needs a project, not a database URL. ``; `false|no|off|0`, `--no-remote` and an empty value fail with `Leave out --remote to use the linked project.` The branch itself stays a positional argument. +- The deprecated `--project-ref` is an alias of `--remote=<ref>` and prints a deprecation warning on stderr that names the replacement; it cannot be combined with `--remote=<value>`. An empty `--project-ref` is rejected. - 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 d38adfcc7b..73b029484e 100644 --- a/apps/cli/src/commands/branches/disable/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/branches/disable/SIDE_EFFECTS.md @@ -15,11 +15,11 @@ Same auth fallback chain as every Management-API command. Project-ref discovery ## API Routes -| Method | Path | Auth | Request body | Response | -| -------- | ----------------------------- | ------------ | ------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `DELETE` | `/v1/projects/{ref}/branches` | Bearer token | none | none (expect 200) | -| `GET` | `/v1/projects` | Bearer token | none | `[{id, name, organization_slug, region, …}]` — no project ref from `--project-ref`, `SUPABASE_PROJECT_ID` or `.temp/project-ref`, with stdin and stdout TTYs and a non-JSON output format (project picker) | -| `GET` | `/v1/projects/{ref}` | Bearer token | none | `{ref, name, organization_id, organization_slug}` — linked-project cache miss only (best-effort, after the command) | +| Method | Path | Auth | Request body | Response | +| -------- | ----------------------------- | ------------ | ------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `DELETE` | `/v1/projects/{ref}/branches` | Bearer token | none | none (expect 200) | +| `GET` | `/v1/projects` | Bearer token | none | `[{id, name, organization_slug, region, …}]` — no project ref from `--remote=<ref>`, `SUPABASE_PROJECT_ID` or `.temp/project-ref`, with stdin and stdout TTYs and a non-JSON output format (project picker) | +| `GET` | `/v1/projects/{ref}` | Bearer token | none | `{ref, name, organization_id, organization_slug}` — linked-project cache miss only (best-effort, after the command) | ## Environment Variables @@ -55,5 +55,7 @@ Single `success` event carrying `{project_ref}`. ## Notes +- `--remote=<ref>` names the parent project; with no selector or a bare `--remote` the parent comes from the chain above. A branch name or UUID is rejected with `` `supabase branches disable` acts on a parent project, so --remote takes its project ref, not a branch name. Use --remote=<ref>. ``, and a database URL with `` `supabase branches disable` needs a project, not a database URL. ``; `false|no|off|0`, `--no-remote` and an empty value fail with `Leave out --remote to use the linked project.` The branch itself stays a positional argument. +- The deprecated `--project-ref` is an alias of `--remote=<ref>` and prints a deprecation warning on stderr that names the replacement; it cannot be combined with `--remote=<value>`. An empty `--project-ref` is rejected. - 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 21f7f122c3..473f09ab37 100644 --- a/apps/cli/src/commands/branches/get/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/branches/get/SIDE_EFFECTS.md @@ -13,15 +13,15 @@ Same auth fallback chain (env / keyring / `~/.supabase/access-token`) as every M ## API Routes -| Method | Path | Auth | When | Response (used fields) | -| ------ | ------------------------------------------- | ------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------- | -| `GET` | `/v1/projects/{ref}/branches/{name}` | Bearer token | input is neither a UUID nor a `^[a-z]{20}$` ref pattern (named-branch lookup) | `{project_ref, ...}` | -| `GET` | `/v1/branches/{branch_id_or_ref}` | Bearer token | always — branch detail / config | `{ref, db_host, db_port, db_user?, db_pass?, jwt_secret?, postgres_version, status, …}` | -| `GET` | `/v1/projects/{ref}/api-keys` | Bearer token | with `-o` other than `pretty`, or with no `-o` when the output format is json or stream-json (`--output-format`, `--agent yes`, or a detected coding agent) | `[{name, api_key?}]` | -| `GET` | `/v1/projects/{ref}/config/database/pooler` | Bearer token | with `-o` other than `pretty`, or with no `-o` when the output format is json or stream-json (`--output-format`, `--agent yes`, or a detected coding agent) | `[SupavisorConfigResponse]` — handler filters for `database_type === "PRIMARY"` | -| `GET` | `/v1/projects/{ref}/branches` | Bearer token | branch argument omitted or empty and stdin is a TTY (branch picker) | `[{name, project_ref, …}]` | -| `GET` | `/v1/projects` | Bearer token | no project ref from `--project-ref`, `SUPABASE_PROJECT_ID` or `.temp/project-ref`, with stdin and stdout TTYs and a non-JSON output format (project picker) | `[{id, name, organization_slug, region, …}]` | -| `GET` | `/v1/projects/{ref}` | Bearer token | linked-project cache miss only (best-effort, after the command) | `{ref, name, organization_id, organization_slug}` | +| Method | Path | Auth | When | Response (used fields) | +| ------ | ------------------------------------------- | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------- | +| `GET` | `/v1/projects/{ref}/branches/{name}` | Bearer token | input is neither a UUID nor a `^[a-z]{20}$` ref pattern (named-branch lookup) | `{project_ref, ...}` | +| `GET` | `/v1/branches/{branch_id_or_ref}` | Bearer token | always — branch detail / config | `{ref, db_host, db_port, db_user?, db_pass?, jwt_secret?, postgres_version, status, …}` | +| `GET` | `/v1/projects/{ref}/api-keys` | Bearer token | with `-o` other than `pretty`, or with no `-o` when the output format is json or stream-json (`--output-format`, `--agent yes`, or a detected coding agent) | `[{name, api_key?}]` | +| `GET` | `/v1/projects/{ref}/config/database/pooler` | Bearer token | with `-o` other than `pretty`, or with no `-o` when the output format is json or stream-json (`--output-format`, `--agent yes`, or a detected coding agent) | `[SupavisorConfigResponse]` — handler filters for `database_type === "PRIMARY"` | +| `GET` | `/v1/projects/{ref}/branches` | Bearer token | branch argument omitted or empty and stdin is a TTY (branch picker) | `[{name, project_ref, …}]` | +| `GET` | `/v1/projects` | Bearer token | no project ref from `--remote=<ref>`, `SUPABASE_PROJECT_ID` or `.temp/project-ref`, with stdin and stdout TTYs and a non-JSON output format (project picker) | `[{id, name, organization_slug, region, …}]` | +| `GET` | `/v1/projects/{ref}` | Bearer token | linked-project cache miss only (best-effort, after the command) | `{ref, name, organization_id, organization_slug}` | ## Environment Variables @@ -56,4 +56,6 @@ Emits the standard-env projection: `POSTGRES_URL` (pooled, falls back to direct ## Notes +- `--remote=<ref>` names the parent project; with no selector or a bare `--remote` the parent comes from the chain above. A branch name or UUID is rejected with `` `supabase branches get` acts on a parent project, so --remote takes its project ref, not a branch name. Use --remote=<ref>. ``, and a database URL with `` `supabase branches get` needs a project, not a database URL. ``; `false|no|off|0`, `--no-remote` and an empty value fail with `Leave out --remote to use the linked project.` The branch itself stays a positional argument. +- The deprecated `--project-ref` is an alias of `--remote=<ref>` and prints a deprecation warning on stderr that names the replacement; it cannot be combined with `--remote=<value>`. An empty `--project-ref` is rejected. - 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 3c995b7f38..f6cd56304b 100644 --- a/apps/cli/src/commands/branches/list/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/branches/list/SIDE_EFFECTS.md @@ -2,19 +2,19 @@ ## Files Read -| Path | Format | When | -| ---------------------------------------------- | ------------------------- | ----------------------------------------------------------------------------------------------------- | -| keyring `"Supabase CLI"` / `<profile>` | OS keychain | when `SUPABASE_ACCESS_TOKEN` unset and keyring available; account = `CommandSettings.profile` | -| keyring `"Supabase CLI"` / `access-token` | OS keychain | legacy-key fallback when the profile-keyed lookup misses | -| `<workdir>/supabase/.temp/linked-project.json` | JSON (`ref` field) | when `--project-ref` is unset, as the 2nd PARENT-ref candidate (CLI-2167 follow-up, see below) | -| `<workdir>/supabase/.temp/project-ref` | plain text | when `--project-ref` and `SUPABASE_PROJECT_ID` are both unset, as the 3rd (last) PARENT-ref candidate | -| `~/.supabase/access-token` | plain text (token string) | last-resort fallback after env + keyring miss | +| Path | Format | When | +| ---------------------------------------------- | ------------------------- | ------------------------------------------------------------------------------------------------------ | +| keyring `"Supabase CLI"` / `<profile>` | OS keychain | when `SUPABASE_ACCESS_TOKEN` unset and keyring available; account = `CommandSettings.profile` | +| keyring `"Supabase CLI"` / `access-token` | OS keychain | legacy-key fallback when the profile-keyed lookup misses | +| `<workdir>/supabase/.temp/linked-project.json` | JSON (`ref` field) | when `--remote=<ref>` is not given, as the 2nd PARENT-ref candidate (CLI-2167 follow-up, see below) | +| `<workdir>/supabase/.temp/project-ref` | plain text | when neither `--remote=<ref>` nor `SUPABASE_PROJECT_ID` is set, as the 3rd (last) PARENT-ref candidate | +| `~/.supabase/access-token` | plain text (token string) | last-resort fallback after env + keyring miss | > `branches` is PARENT-scoped: after `supabase link <branch>`, the on-disk `project-ref` file > holds the BRANCH's own ref, and the Management API returns 403 for a branch ref on every > branches-management endpoint. Every `branches` subcommand therefore resolves the project ref > via `resolveParentScopedProjectRef` (`command-internal/parent-project-ref.ts`) instead -> of calling `ProjectRefResolver.resolve` directly: an explicit `--project-ref` still wins +> of calling `ProjectRefResolver.resolve` directly: an explicit `--remote=<ref>` still wins > outright; otherwise the PARENT is resolved as env `SUPABASE_PROJECT_ID` → `linked-project.json`'s > `ref` → the `project-ref` file, first ref-shaped candidate wins, falling through to > `resolver.resolve(None)`'s ordinary env/prompt/not-linked behavior when no candidate is @@ -36,11 +36,11 @@ ## API Routes -| Method | Path | Auth | Request body | Response (used fields) | -| ------ | ----------------------------- | ------------ | ------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `GET` | `/v1/projects/{ref}/branches` | Bearer token | none | `[{id, name, project_ref, parent_project_ref, is_default, git_branch?, persistent, status, created_at, updated_at, with_data, ...}]` | -| `GET` | `/v1/projects` | Bearer token | none | `[{id, name, organization_slug, region, …}]` — no project ref from `--project-ref`, `SUPABASE_PROJECT_ID` or `.temp/project-ref`, with stdin and stdout TTYs and a non-JSON output format (project picker) | -| `GET` | `/v1/projects/{ref}` | Bearer token | none | `{ref, name, organization_id, organization_slug}` — linked-project cache miss only (best-effort, after the command) | +| Method | Path | Auth | Request body | Response (used fields) | +| ------ | ----------------------------- | ------------ | ------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `GET` | `/v1/projects/{ref}/branches` | Bearer token | none | `[{id, name, project_ref, parent_project_ref, is_default, git_branch?, persistent, status, created_at, updated_at, with_data, ...}]` | +| `GET` | `/v1/projects` | Bearer token | none | `[{id, name, organization_slug, region, …}]` — no project ref from `--remote=<ref>`, `SUPABASE_PROJECT_ID` or `.temp/project-ref`, with stdin and stdout TTYs and a non-JSON output format (project picker) | +| `GET` | `/v1/projects/{ref}` | Bearer token | none | `{ref, name, organization_id, organization_slug}` — linked-project cache miss only (best-effort, after the command) | ## Environment Variables @@ -48,26 +48,26 @@ | ----------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------- | | `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | | `SUPABASE_PROFILE` | selects API base URL: `supabase` → `api.supabase.com`, `supabase-staging` → `api.supabase.green`, `supabase-local` → `http://localhost:8080`. May alternatively be a filesystem path to a YAML profile with at least `api_url:` and optional `name:` (used by the cli-e2e test harness). | no (defaults to `supabase`) | -| `SUPABASE_PROJECT_ID` | PARENT project ref fallback (1st candidate) when `--project-ref` is unset (CLI-2167 follow-up) | no (also reads `linked-project.json` → `<workdir>/supabase/.temp/project-ref` then prompts on TTY) | +| `SUPABASE_PROJECT_ID` | PARENT project ref fallback (1st candidate) when `--remote=<ref>` is not given (CLI-2167 follow-up) | no (also reads `linked-project.json` → `<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 — branches printed to stdout | -| `1` | `AccessTokenRequiredError` — no token in env/keyring/file | -| `1` | `ProjectRefNotLinkedError` — `--project-ref` unset, env/file empty, non-TTY | -| `1` | `InvalidProjectRefError` — resolved ref violates `^[a-z]{20}$` | -| `1` | `BranchesListUnexpectedStatusError` — non-2xx response from list endpoint | -| `1` | `BranchesListNetworkError` — transport-level network failure | -| `1` | `BranchesEnvNotSupportedError` — `--output env` flag is rejected | +| Code | Condition | +| ---- | ------------------------------------------------------------------------- | +| `0` | success — branches printed to stdout | +| `1` | `AccessTokenRequiredError` — no token in env/keyring/file | +| `1` | `ProjectRefNotLinkedError` — no `--remote=<ref>`, env/file empty, non-TTY | +| `1` | `InvalidProjectRefError` — resolved ref violates `^[a-z]{20}$` | +| `1` | `BranchesListUnexpectedStatusError` — non-2xx response from list endpoint | +| `1` | `BranchesListNetworkError` — transport-level network failure | +| `1` | `BranchesEnvNotSupportedError` — `--output env` flag is rejected | ## Telemetry Events Fired -| Event | When | Notable properties / groups | -| ---------------------- | ------------------------------------------ | ----------------------------------------------------------------- | -| `cli_command_executed` | post-run, success or failure (via wrapper) | `exit_code`, `duration_ms`, `flags` (`--project-ref` whitelisted) | +| Event | When | Notable properties / groups | +| ---------------------- | ------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `cli_command_executed` | post-run, success or failure (via wrapper) | `exit_code`, `duration_ms`, `flags` (`--remote=<ref>` → the ref, a bare `--remote` → `true`; `--project-ref` whitelisted when ref-shaped, otherwise redacted) | ## Output @@ -107,6 +107,8 @@ One `result` NDJSON event with `{branches: [...]}`. ## Notes +- `--remote=<ref>` names the parent project; with no selector or a bare `--remote` the parent comes from the chain above. A branch name or UUID is rejected with `` `supabase branches list` acts on a parent project, so --remote takes its project ref, not a branch name. Use --remote=<ref>. ``, and a database URL with `` `supabase branches list` needs a project, not a database URL. ``; `false|no|off|0`, `--no-remote` and an empty value fail with `Leave out --remote to use the linked project.` The branch itself stays a positional argument. +- The deprecated `--project-ref` is an alias of `--remote=<ref>` and prints a deprecation warning on stderr that names the replacement; it cannot be combined with `--remote=<value>`. An empty `--project-ref` is rejected. - Timestamps are formatted as UTC `YYYY-MM-DD HH:MM:SS`. - Sends `User-Agent: SupabaseCLI/<version>` and Bearer auth. No `X-Supabase-Command` headers. diff --git a/apps/cli/src/commands/branches/pause/SIDE_EFFECTS.md b/apps/cli/src/commands/branches/pause/SIDE_EFFECTS.md index d6448dc027..612157f332 100644 --- a/apps/cli/src/commands/branches/pause/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/branches/pause/SIDE_EFFECTS.md @@ -13,14 +13,14 @@ Same auth fallback chain as every Management-API command. Project-ref discovery ## API Routes -| Method | Path | Auth | When | Response | -| ------ | ------------------------------------ | ------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------- | -| `GET` | `/v1/projects/{ref}/branches/{name}` | Bearer token | branch input is not a UUID and not a `^[a-z]{20}$` ref pattern | `{project_ref}` | -| `GET` | `/v1/branches/{branch_id_or_ref}` | Bearer token | branch input is a UUID | `{ref}` | -| `POST` | `/v1/projects/{branch_ref}/pause` | Bearer token | always — final pause action | none (expect 200) | -| `GET` | `/v1/projects/{ref}/branches` | Bearer token | branch argument omitted or empty and stdin is a TTY (branch picker) | `[{name, project_ref, …}]` | -| `GET` | `/v1/projects` | Bearer token | no project ref from `--project-ref`, `SUPABASE_PROJECT_ID` or `.temp/project-ref`, with stdin and stdout TTYs and a non-JSON output format (project picker) | `[{id, name, organization_slug, region, …}]` | -| `GET` | `/v1/projects/{ref}` | Bearer token | linked-project cache miss only (best-effort, after the command) | `{ref, name, organization_id, organization_slug}` | +| Method | Path | Auth | When | Response | +| ------ | ------------------------------------ | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------- | +| `GET` | `/v1/projects/{ref}/branches/{name}` | Bearer token | branch input is not a UUID and not a `^[a-z]{20}$` ref pattern | `{project_ref}` | +| `GET` | `/v1/branches/{branch_id_or_ref}` | Bearer token | branch input is a UUID | `{ref}` | +| `POST` | `/v1/projects/{branch_ref}/pause` | Bearer token | always — final pause action | none (expect 200) | +| `GET` | `/v1/projects/{ref}/branches` | Bearer token | branch argument omitted or empty and stdin is a TTY (branch picker) | `[{name, project_ref, …}]` | +| `GET` | `/v1/projects` | Bearer token | no project ref from `--remote=<ref>`, `SUPABASE_PROJECT_ID` or `.temp/project-ref`, with stdin and stdout TTYs and a non-JSON output format (project picker) | `[{id, name, organization_slug, region, …}]` | +| `GET` | `/v1/projects/{ref}` | Bearer token | linked-project cache miss only (best-effort, after the command) | `{ref, name, organization_id, organization_slug}` | ## Environment Variables @@ -51,5 +51,7 @@ Silent on success in every mode (the handler writes nothing itself; the target b ## Notes +- `--remote=<ref>` names the parent project; with no selector or a bare `--remote` the parent comes from the chain above. A branch name or UUID is rejected with `` `supabase branches pause` acts on a parent project, so --remote takes its project ref, not a branch name. Use --remote=<ref>. ``, and a database URL with `` `supabase branches pause` needs a project, not a database URL. ``; `false|no|off|0`, `--no-remote` and an empty value fail with `Leave out --remote to use the linked project.` The branch itself stays a positional argument. +- The deprecated `--project-ref` is an alias of `--remote=<ref>` and prints a deprecation warning on stderr that names the replacement; it cannot be combined with `--remote=<value>`. An empty `--project-ref` is rejected. - 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 2316038a22..bc7b51ca35 100644 --- a/apps/cli/src/commands/branches/unpause/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/branches/unpause/SIDE_EFFECTS.md @@ -13,14 +13,14 @@ Same auth fallback chain as every Management-API command. Project-ref discovery ## API Routes -| Method | Path | Auth | When | Response | -| ------ | ------------------------------------ | ------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------- | -| `GET` | `/v1/projects/{ref}/branches/{name}` | Bearer token | branch input is not a UUID and not a `^[a-z]{20}$` ref pattern | `{project_ref}` | -| `GET` | `/v1/branches/{branch_id_or_ref}` | Bearer token | branch input is a UUID | `{ref}` | -| `POST` | `/v1/projects/{branch_ref}/restore` | Bearer token | always — final unpause action | none (expect 200) | -| `GET` | `/v1/projects/{ref}/branches` | Bearer token | branch argument omitted or empty and stdin is a TTY (branch picker) | `[{name, project_ref, …}]` | -| `GET` | `/v1/projects` | Bearer token | no project ref from `--project-ref`, `SUPABASE_PROJECT_ID` or `.temp/project-ref`, with stdin and stdout TTYs and a non-JSON output format (project picker) | `[{id, name, organization_slug, region, …}]` | -| `GET` | `/v1/projects/{ref}` | Bearer token | linked-project cache miss only (best-effort, after the command) | `{ref, name, organization_id, organization_slug}` | +| Method | Path | Auth | When | Response | +| ------ | ------------------------------------ | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------- | +| `GET` | `/v1/projects/{ref}/branches/{name}` | Bearer token | branch input is not a UUID and not a `^[a-z]{20}$` ref pattern | `{project_ref}` | +| `GET` | `/v1/branches/{branch_id_or_ref}` | Bearer token | branch input is a UUID | `{ref}` | +| `POST` | `/v1/projects/{branch_ref}/restore` | Bearer token | always — final unpause action | none (expect 200) | +| `GET` | `/v1/projects/{ref}/branches` | Bearer token | branch argument omitted or empty and stdin is a TTY (branch picker) | `[{name, project_ref, …}]` | +| `GET` | `/v1/projects` | Bearer token | no project ref from `--remote=<ref>`, `SUPABASE_PROJECT_ID` or `.temp/project-ref`, with stdin and stdout TTYs and a non-JSON output format (project picker) | `[{id, name, organization_slug, region, …}]` | +| `GET` | `/v1/projects/{ref}` | Bearer token | linked-project cache miss only (best-effort, after the command) | `{ref, name, organization_id, organization_slug}` | ## Environment Variables @@ -51,5 +51,7 @@ Silent on success in every mode (the handler writes nothing itself; the target b ## Notes +- `--remote=<ref>` names the parent project; with no selector or a bare `--remote` the parent comes from the chain above. A branch name or UUID is rejected with `` `supabase branches unpause` acts on a parent project, so --remote takes its project ref, not a branch name. Use --remote=<ref>. ``, and a database URL with `` `supabase branches unpause` needs a project, not a database URL. ``; `false|no|off|0`, `--no-remote` and an empty value fail with `Leave out --remote to use the linked project.` The branch itself stays a positional argument. +- The deprecated `--project-ref` is an alias of `--remote=<ref>` and prints a deprecation warning on stderr that names the replacement; it cannot be combined with `--remote=<value>`. An empty `--project-ref` is rejected. - 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 8f5b68ad56..6f17467eb5 100644 --- a/apps/cli/src/commands/branches/update/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/branches/update/SIDE_EFFECTS.md @@ -13,16 +13,16 @@ Same auth fallback chain as every Management-API command. Project-ref discovery ## API Routes -| Method | Path | Auth | When | Request body | Response | -| ------- | ----------------------------------------------- | ------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------- | ------------------------------------------------- | -| `GET` | `/v1/projects/{ref}/branches/{name}` | Bearer token | branch input is not a UUID and not a `^[a-z]{20}$` ref pattern | none | `{project_ref}` | -| `GET` | `/v1/branches/{branch_id_or_ref}` | Bearer token | branch input is a UUID | none | `{ref}` | -| `PATCH` | `/v1/branches/{branch_id_or_ref}` | Bearer token | always | `{branch_name?, git_branch?, persistent?, status?, notify_url?}` (only set flags emitted) | full `BranchResponse` | -| `GET` | `/v1/projects/{ref}` (on 4xx gated) | Bearer token | upgrade-suggest path | none | `{organization_slug}` | -| `GET` | `/v1/organizations/{slug}/entitlements` (gated) | Bearer token | upgrade-suggest path | none | `[{feature: {key}, hasAccess, ...}]` | -| `GET` | `/v1/projects/{ref}/branches` | Bearer token | branch argument omitted or empty and stdin is a TTY (branch picker) | none | `[{name, project_ref, …}]` | -| `GET` | `/v1/projects` | Bearer token | no project ref from `--project-ref`, `SUPABASE_PROJECT_ID` or `.temp/project-ref`, with stdin and stdout TTYs and a non-JSON output format (project picker) | none | `[{id, name, organization_slug, region, …}]` | -| `GET` | `/v1/projects/{ref}` | Bearer token | linked-project cache miss only (best-effort, after the command) | none | `{ref, name, organization_id, organization_slug}` | +| Method | Path | Auth | When | Request body | Response | +| ------- | ----------------------------------------------- | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------- | ------------------------------------------------- | +| `GET` | `/v1/projects/{ref}/branches/{name}` | Bearer token | branch input is not a UUID and not a `^[a-z]{20}$` ref pattern | none | `{project_ref}` | +| `GET` | `/v1/branches/{branch_id_or_ref}` | Bearer token | branch input is a UUID | none | `{ref}` | +| `PATCH` | `/v1/branches/{branch_id_or_ref}` | Bearer token | always | `{branch_name?, git_branch?, persistent?, status?, notify_url?}` (only set flags emitted) | full `BranchResponse` | +| `GET` | `/v1/projects/{ref}` (on 4xx gated) | Bearer token | upgrade-suggest path | none | `{organization_slug}` | +| `GET` | `/v1/organizations/{slug}/entitlements` (gated) | Bearer token | upgrade-suggest path | none | `[{feature: {key}, hasAccess, ...}]` | +| `GET` | `/v1/projects/{ref}/branches` | Bearer token | branch argument omitted or empty and stdin is a TTY (branch picker) | none | `[{name, project_ref, …}]` | +| `GET` | `/v1/projects` | Bearer token | no project ref from `--remote=<ref>`, `SUPABASE_PROJECT_ID` or `.temp/project-ref`, with stdin and stdout TTYs and a non-JSON output format (project picker) | none | `[{id, name, organization_slug, region, …}]` | +| `GET` | `/v1/projects/{ref}` | Bearer token | linked-project cache miss only (best-effort, after the command) | none | `{ref, name, organization_id, organization_slug}` | ## Environment Variables @@ -58,6 +58,8 @@ 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. +- `--remote=<ref>` names the parent project; with no selector or a bare `--remote` the parent comes from the chain above. A branch name or UUID is rejected with `` `supabase branches update` acts on a parent project, so --remote takes its project ref, not a branch name. Use --remote=<ref>. ``, and a database URL with `` `supabase branches update` needs a project, not a database URL. ``; `false|no|off|0`, `--no-remote` and an empty value fail with `Leave out --remote to use the linked project.` The branch itself stays a positional argument. +- The deprecated `--project-ref` is an alias of `--remote=<ref>` and prints a deprecation warning on stderr that names the replacement; it cannot be combined with `--remote=<value>`. An empty `--project-ref` is rejected. +- The upgrade-suggest call uses the branch's own resolved project ref (`resolveBranchProjectRef`) — not the parent `--remote=<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/config/diff/SIDE_EFFECTS.md b/apps/cli/src/commands/config/diff/SIDE_EFFECTS.md index bec9e9bb91..b3678dd425 100644 --- a/apps/cli/src/commands/config/diff/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/config/diff/SIDE_EFFECTS.md @@ -12,8 +12,8 @@ writes `config.toml` or any remote configuration.** | --------------------------------------------------------- | ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `<workdir>/supabase/config.toml` or `config.json` | TOML/JSON | always, before any network call (the config load probes `config.json` first, then `config.toml` — a missing file or parse error aborts, exit 1, naming whichever file actually failed); re-read after target resolution when the file declares `[remotes.*]`, to apply the matching overlay | | `<workdir>/{supabase/,}.env{.<env>.local,.local,.<env>,}` | dotenv | always: supplies `env(VAR)` references inside `config.toml` and the `SUPABASE_*` project-env tier of the local operand. `<env>` is `SUPABASE_ENV` (default `development`); `.env.local` is skipped when `SUPABASE_ENV=test`; the first file to set a key wins and the `supabase/` tier beats the project-root tier | -| `<workdir>/supabase/.temp/project-ref` | plain text | project-ref fallback (flag → `SUPABASE_PROJECT_ID` → this file); parent-ref candidate for a branch-name `--project-ref` (checked eagerly, BEFORE any spinner or branch lookup) | -| `<workdir>/supabase/.temp/linked-project.json` | JSON | parent-ref candidate for a branch-name `--project-ref` (same eager pre-check); existence-checked for the telemetry cache write below | +| `<workdir>/supabase/.temp/project-ref` | plain text | project-ref fallback (`--remote=<ref>` → `SUPABASE_PROJECT_ID` → this file); parent-ref candidate for a branch-name `--remote` (checked eagerly, BEFORE any spinner or branch lookup) | +| `<workdir>/supabase/.temp/linked-project.json` | JSON | parent-ref candidate for a branch-name `--remote` (same eager pre-check); existence-checked for the telemetry cache write below | | `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | ## Files Written @@ -31,17 +31,17 @@ that finds differences. All Bearer-authenticated, all read-only. -| # | Purpose | Method | Path | Success | Notes | -| --- | ----------------------- | ------ | ------------------------------------ | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| 0a | branch by UUID | GET | `/v1/branches/{branch_id}` | 200 | only when `--project-ref` is a UUID; needs no linked project (no parent pre-check) | -| 0b | branch by name | GET | `/v1/projects/{ref}/branches/{name}` | 200 | only when `--project-ref` is a NAME (not a ref/UUID); the parent ref is resolved from local state BEFORE this call — an absent/invalid parent fails without making this request; 404 → "branch not found" | -| 1 | effective remote config | GET | `/v2/projects/{ref}/config` | 200 | always (after target resolution); 401/403/404 get purpose-written messages, other statuses the generic `unexpected status N: body` shape | +| # | Purpose | Method | Path | Success | Notes | +| --- | ----------------------- | ------ | ------------------------------------ | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 0a | branch by UUID | GET | `/v1/branches/{branch_id}` | 200 | only when `--remote` is a UUID; needs no linked project (no parent pre-check) | +| 0b | branch by name | GET | `/v1/projects/{ref}/branches/{name}` | 200 | only when `--remote` is a NAME (not a ref/UUID); the parent ref is resolved from local state BEFORE this call — an absent/invalid parent fails without making this request; 404 → "branch not found" | +| 1 | effective remote config | GET | `/v2/projects/{ref}/config` | 200 | always (after target resolution); 401/403/404 get purpose-written messages, other statuses the generic `unexpected status N: body` shape | ## Environment Variables | Variable | Purpose | Required? | | ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `SUPABASE_PROJECT_ID` | project ref (flag → this → `.temp/project-ref` → prompt) | no | +| `SUPABASE_PROJECT_ID` | project ref (`--remote=<ref>` → this → `.temp/project-ref` → prompt) | no | | `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | | `SUPABASE_PROFILE` | API profile selection | no | | `SUPABASE_WORKDIR` | working directory `supabase/config.toml`/`config.json` is read from (`--workdir` takes priority) | no — when unset, the CLI walks up from cwd looking for `supabase/config.toml` or `supabase/config.json`; when SET (flag or env) the directory is used exactly as given and **no ancestor is searched**, so a path with no `supabase/` of its own fails instead of loading a parent project's config | @@ -73,9 +73,9 @@ same as any other non-zero exit. | `1` | resolved `--workdir`/`SUPABASE_WORKDIR` doesn't exist or isn't a directory (`ConfigDiffWorkdirError`) — beats the config read and every network call | | `1` | missing or malformed `supabase/config.toml`/`config.json` (`ConfigDiffLoadConfigError`) — a missing file suggests `supabase init` only for a DEFAULTED workdir; with an explicit workdir the message names the resolved path instead and never suggests `init` | | `1` | a flag, `SUPABASE_*` variable, project `.env*` file or config value fails to decode (`CliConfigValueError`) — names the source that supplied it; surfaced as-is, not wrapped in the command's load error | -| `1` | branch-name `--project-ref` with no linked parent project (`ConfigDiffBranchNotLinkedError`) | -| `1` | branch-name `--project-ref` with a corrupt/invalid linked parent ref (`ConfigDiffParentRefInvalidError`) | -| `1` | unknown branch (branch-name `--project-ref` 404, `ConfigDiffBranchNotFoundError`) | +| `1` | branch-name `--remote` with no linked parent project (`ConfigDiffBranchNotLinkedError`) | +| `1` | branch-name `--remote` with a corrupt/invalid linked parent ref (`ConfigDiffParentRefInvalidError`) | +| `1` | unknown branch (branch-name `--remote` 404, `ConfigDiffBranchNotFoundError`) | | `1` | resolved branch has no project ref yet — still provisioning (`ConfigDiffBranchNotReadyError`) | | `1` | two `[remotes.*]` blocks declare the same `project_id` as the target ref | | `1` | remote config read failure (network, 401/403/404, or other unexpected status) | @@ -146,6 +146,8 @@ and emit nothing. ## Notes +- `--remote=<value>` selects the target: a project ref (20 lowercase letters), or the name or UUID of a branch of the linked project (resolved through `GET /v1/projects/{ref}/branches/{name}` or `GET /v1/branches/{id}`). A bare `--remote` and no selector both target the linked project. A database URL is rejected (`supabase config diff` takes a project, not a database); `false|no|off|0`, `--no-remote` and an empty value fail with `Leave out --remote to use the linked project.` +- The deprecated `--project-ref` is an alias of `--remote=<ref>` (it still accepts a branch name or UUID) and prints a deprecation warning on stderr that names the replacement; it cannot be combined with `--remote=<value>`. An empty `--project-ref` is rejected. - 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. diff --git a/apps/cli/src/commands/config/pull/SIDE_EFFECTS.md b/apps/cli/src/commands/config/pull/SIDE_EFFECTS.md index 8a8eda2e7f..d7d99ad9b7 100644 --- a/apps/cli/src/commands/config/pull/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/config/pull/SIDE_EFFECTS.md @@ -17,8 +17,8 @@ declining any other answer. Never writes on `--dry-run`, on a declined prompt, o | `<workdir>/supabase/config.toml` or `config.json` | TOML/JSON | re-loaded WITH the `[remotes.*]` overlay applied, only when the resolved target ref matches an EXISTING block (block reuse selects it as the destination, `remoteNameForProjectRef`) — a brand-new block has nothing to overlay yet, so this second load is skipped in that case | | same file, raw on-disk text (TOCTOU re-read) | TOML/JSON | immediately before writing, once the confirmation prompt has been answered — only reached when at least one change is planned and the run is not `--dry-run`; bytes differing from the earlier read abort the write (`ConfigPullFileChangedError`) instead of overwriting a file that changed while the prompt was on screen | | `<workdir>/{supabase/,}.env{.<env>.local,.local,.<env>,}` | dotenv | always: supplies `env(VAR)` references inside `config.toml`; a `SUPABASE_*` value in these files is not compared (pull compares the file's own declared values). `<env>` is `SUPABASE_ENV` (default `development`); `.env.local` is skipped when `SUPABASE_ENV=test`; the first file to set a key wins and the `supabase/` tier beats the project-root tier | -| `<workdir>/supabase/.temp/project-ref` | plain text | project-ref fallback (flag → `SUPABASE_PROJECT_ID` → this file); parent-ref candidate for a branch-name `--project-ref` (checked eagerly, BEFORE any spinner or branch lookup) | -| `<workdir>/supabase/.temp/linked-project.json` | JSON | parent-ref candidate for a branch-name `--project-ref` (same eager pre-check); existence-checked for the telemetry cache write below | +| `<workdir>/supabase/.temp/project-ref` | plain text | project-ref fallback (`--remote=<ref>` → `SUPABASE_PROJECT_ID` → this file); parent-ref candidate for a branch-name `--remote` (checked eagerly, BEFORE any spinner or branch lookup) | +| `<workdir>/supabase/.temp/linked-project.json` | JSON | parent-ref candidate for a branch-name `--remote` (same eager pre-check); existence-checked for the telemetry cache write below | | `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | ## Files Written @@ -53,17 +53,17 @@ declining any other answer. Never writes on `--dry-run`, on a declined prompt, o All Bearer-authenticated, all read-only (pull never calls a write endpoint — every change lands locally via the file write above). -| # | Purpose | Method | Path | Success | Notes | -| --- | ----------------------- | ------ | ------------------------------------ | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| 0a | branch by UUID | GET | `/v1/branches/{branch_id}` | 200 | only when `--project-ref` is a UUID; needs no linked project (no parent pre-check) | -| 0b | branch by name | GET | `/v1/projects/{ref}/branches/{name}` | 200 | only when `--project-ref` is a NAME (not a ref/UUID); the parent ref is resolved from local state BEFORE this call — an absent/invalid parent fails without making this request; 404 → "branch not found" | -| 1 | effective remote config | GET | `/v2/projects/{ref}/config` | 200 | always (after target resolution); 401/403/404 get purpose-written messages, other statuses the generic `unexpected status N: body` shape | +| # | Purpose | Method | Path | Success | Notes | +| --- | ----------------------- | ------ | ------------------------------------ | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 0a | branch by UUID | GET | `/v1/branches/{branch_id}` | 200 | only when `--remote` is a UUID; needs no linked project (no parent pre-check) | +| 0b | branch by name | GET | `/v1/projects/{ref}/branches/{name}` | 200 | only when `--remote` is a NAME (not a ref/UUID); the parent ref is resolved from local state BEFORE this call — an absent/invalid parent fails without making this request; 404 → "branch not found" | +| 1 | effective remote config | GET | `/v2/projects/{ref}/config` | 200 | always (after target resolution); 401/403/404 get purpose-written messages, other statuses the generic `unexpected status N: body` shape | ## Environment Variables | Variable | Purpose | Required? | | ---------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `SUPABASE_PROJECT_ID` | project ref (flag → this → `.temp/project-ref` → prompt) | no | +| `SUPABASE_PROJECT_ID` | project ref (`--remote=<ref>` → this → `.temp/project-ref` → prompt) | no | | `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | | `SUPABASE_PROFILE` | API profile selection | no | | `SUPABASE_WORKDIR` | working directory `config.toml`/`.json` is read from and written to (`--workdir` flag takes priority) | no — when unset, the CLI walks up from cwd looking for `supabase/config.toml` or `supabase/config.json`; when SET (flag or env) the directory is used exactly as given and **no ancestor is searched**, so a path with no `supabase/` of its own fails (with no `supabase init` hint — see the exit-code table) instead of loading a parent project's config | @@ -85,9 +85,9 @@ exit `0`: neither is a failure. | `1` | resolved `--workdir`/`SUPABASE_WORKDIR` doesn't exist or isn't a directory (`ConfigPullWorkdirError`) — beats the base config load and every network call | | `1` | missing or malformed `supabase/config.toml`/`config.json` (`ConfigPullLoadConfigError`) — a missing file suggests `supabase init` only for a DEFAULTED workdir; with an explicit workdir the message names the resolved path instead and never suggests `init` | | `1` | a flag, `SUPABASE_*` variable, project `.env*` file or config value fails to decode (`CliConfigValueError`) — names the source that supplied it; surfaced as-is, not wrapped in the command's load error | -| `1` | branch-name `--project-ref` with no linked parent project (`ConfigPullBranchNotLinkedError`) | -| `1` | branch-name `--project-ref` with a corrupt/invalid linked parent ref (`ConfigPullParentRefInvalidError`) | -| `1` | unknown branch (branch-name `--project-ref` 404, `ConfigPullBranchNotFoundError`) | +| `1` | branch-name `--remote` with no linked parent project (`ConfigPullBranchNotLinkedError`) | +| `1` | branch-name `--remote` with a corrupt/invalid linked parent ref (`ConfigPullParentRefInvalidError`) | +| `1` | unknown branch (branch-name `--remote` 404, `ConfigPullBranchNotFoundError`) | | `1` | resolved branch has no project ref yet — still provisioning (`ConfigPullBranchNotReadyError`) | | `1` | `--remote-label`, or a branch-derived label, names an existing block tracking a different project, or a `--remote-label` names nothing while a different block already tracks the target ref (`ConfigPullRemoteLabelCollisionError`) | | `1` | the target ref only matches a `[remotes.*]` block's `project_id` via `env(...)` resolution, never its raw literal (`ConfigPullRemoteEnvRefError`) | @@ -101,9 +101,9 @@ exit `0`: neither is a failure. ## Telemetry Events Fired -| Event | When | Notable properties / groups | -| ---------------------- | --------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `cli_command_executed` | post-run, success or failure (via `withCommandTelemetry`) | `exit_code`, `duration_ms`, `flags`; `--project-ref`'s value is only in `safeFlags` (logged verbatim) when it is ref-shaped (`PROJECT_REF_PATTERN`) — a user-created branch name is redacted; `--remote-label` is never in `safeFlags` | +| Event | When | Notable properties / groups | +| ---------------------- | --------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `cli_command_executed` | post-run, success or failure (via `withCommandTelemetry`) | `exit_code`, `duration_ms`, `flags`; `--remote=<ref>` is logged as the ref, a bare `--remote` as `true`, and a branch name or UUID is redacted; the deprecated `--project-ref`'s value is only in `safeFlags` (logged verbatim) when it is ref-shaped (`PROJECT_REF_PATTERN`) — a user-created branch name is redacted; `--remote-label` is never in `safeFlags` | ## Output @@ -182,6 +182,8 @@ the -o/--output flag is not supported by config pull; use --output-format json|s ## Notes +- `--remote=<value>` selects the target: a project ref (20 lowercase letters), or the name or UUID of a branch of the linked project (resolved through `GET /v1/projects/{ref}/branches/{name}` or `GET /v1/branches/{id}`). A bare `--remote` and no selector both target the linked project. A database URL is rejected (`supabase config pull` takes a project, not a database); `false|no|off|0`, `--no-remote` and an empty value fail with `Leave out --remote to use the linked project.` +- The deprecated `--project-ref` is an alias of `--remote=<ref>` (it still accepts a branch name or UUID) and prints a deprecation warning on stderr that names the replacement; it cannot be combined with `--remote=<value>`. An empty `--project-ref` is rejected. - **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. diff --git a/apps/cli/src/commands/config/push/SIDE_EFFECTS.md b/apps/cli/src/commands/config/push/SIDE_EFFECTS.md index d288797d47..f29448308f 100644 --- a/apps/cli/src/commands/config/push/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/config/push/SIDE_EFFECTS.md @@ -1,7 +1,7 @@ # `supabase config push` 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 +linked project's (or, via `--remote=<branch-name-or-uuid>`, one of its 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 / @@ -19,8 +19,8 @@ your file doesn't declare is never written. | `<workdir>/supabase/config.json` or `config.toml` | JSON / TOML | always (`config.json` preferred when both exist), AFTER the target ref is resolved (branch/UUID resolution's own network call, when it applies, runs first — see Notes) — with the resolved ref passed in the SAME resolvedConfig load so a matching `[remotes.<name>]` block's overlay is merged before the one full schema decode (parse error aborts, exit 1). An explicit `--workdir`/`SUPABASE_WORKDIR` is used exactly as given, with no ancestor search, and (CLI-2285) is probed for a project's presence BEFORE target resolution, so a typo'd `--workdir` fails without burning a branch-name/UUID lookup's network round trip | | `<workdir>/{supabase/,}.env{.<env>.local,.local,.<env>,}` | dotenv | always: supplies `env(VAR)` references inside `config.toml`, the `SUPABASE_*` project-env tier of the pushed config, and `DOTENV_PRIVATE_KEY`(`_*`) values for decrypting `encrypted:` secrets. `<env>` is `SUPABASE_ENV` (default `development`); `.env.local` is skipped when `SUPABASE_ENV=test`; the first file to set a key wins and the `supabase/` tier beats the project-root tier | | Auth email template HTML (`content_path`) | HTML | always (CLI-2314 — no longer gated on `auth.enabled`, which controls only the local GoTrue Docker service, not this write); paths resolved per the rules below, CONFINED to the project root (CLI-2320) — a relative `..` escape or an absolute path outside the root aborts before the file is read | -| `<workdir>/supabase/.temp/project-ref` | plain text | project-ref fallback (flag → `SUPABASE_PROJECT_ID` → this file); also re-read (its exact value compared against the resolved ref) when the resolved ref is CERTAIN to be a branch (a UUID-resolved `--project-ref`, or the target-detection probe's 404) — only once a cache candidate exists to correlate it against, to decide whether that candidate parent can be trusted | -| `<workdir>/supabase/.temp/linked-project.json` | JSON | existence check only, to decide whether the cache write below is skipped (`ensureProjectGroupsCached` telemetry cache — see `db/lint`'s Notes for the full mechanism); ALSO parsed (`ref`/`name`) whenever the resolved ref is CERTAIN to be a branch (a UUID-resolved `--project-ref`, or the target-detection probe's 404), to name its parent project | +| `<workdir>/supabase/.temp/project-ref` | plain text | project-ref fallback (`--remote=<ref>` → `SUPABASE_PROJECT_ID` → this file); also re-read (its exact value compared against the resolved ref) when the resolved ref is CERTAIN to be a branch (a UUID-resolved `--remote`, or the target-detection probe's 404) — only once a cache candidate exists to correlate it against, to decide whether that candidate parent can be trusted | +| `<workdir>/supabase/.temp/linked-project.json` | JSON | existence check only, to decide whether the cache write below is skipped (`ensureProjectGroupsCached` telemetry cache — see `db/lint`'s Notes for the full mechanism); ALSO parsed (`ref`/`name`) whenever the resolved ref is CERTAIN to be a branch (a UUID-resolved `--remote`, or the target-detection probe's 404), to name its parent project | | `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | ## Files Written @@ -39,21 +39,21 @@ 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 consent question is declined (a decline writes nothing at all). -| # | Resource | Method | Path | Success | Notes | -| --- | ------------------------ | ------ | ----------------------------------------------- | ---------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| -2 | branch resolution (name) | GET | `/v1/projects/{parent_ref}/branches/{name}` | 200 | only when `--project-ref` names a branch by name (CLI-2289); `parent_ref` from the currently linked project | -| -2 | branch resolution (UUID) | GET | `/v1/branches/{id}` | 200 | only when `--project-ref` is a UUID (CLI-2289); needs no linked project | -| -1 | target detection | GET | `/v1/projects/{ref}` | 200 or 404 | CLI-2168: 200 = plain project (its `name` is shown); 404 = `ref` is a preview branch. Wrapped in a `"Checking project..."` task and bounded at 5s. Entirely best-effort/diagnostic: a TIMEOUT, a transport failure, or any OTHER status degrades to an "unknown" target (never silently "project" — that would skip the confirmation gate for a real branch — and never a hard failure that aborts an otherwise-successful push, e.g. for a scoped token that can write service config but can't read the project record). Skipped entirely when `--project-ref` already named a branch by name/UUID above. | -| -1 | branch name lookup | GET | `/v1/projects/{parent_ref}/branches` | 200 | only on the 404 path above, or a UUID-resolved target (never when the probe degrades to "unknown" — that never attempts recovery) — and only when `.temp/linked-project.json` names a different, ref-shaped project to treat as the parent (a branch-name `--project-ref` never triggers it); best-effort, 5s-bounded — a failure only omits the branch's name/parent from the echoed message, it never fails the command | -| 0 | cost matrix | GET | `/v1/projects/{ref}/billing/addons` | 200 | typed client; cost map for 1-variant addons; fetched after the target probe and the branch confirmation gate | -| 1 | effective project config | GET | `/v2/projects/{ref}/config` | 200 | the ONLY read this command makes — see below | -| 2 | api | PATCH | `/v1/projects/{ref}/postgrest` | 200 | only when `api` has a pushable change, its response block was returned, and the change is kept | -| 3 | db.settings | PUT | `/v1/projects/{ref}/config/database/postgres` | 200 | same conditions (this resource's own local gate is "always on") | -| 4 | db.network_restrictions | POST | `/v1/projects/{ref}/network-restrictions/apply` | 201 | same conditions, plus `db.network_restrictions.enabled` locally; body always carries both CIDR arrays together | -| 5 | db.ssl_enforcement | PUT | `/v1/projects/{ref}/ssl-enforcement` | 200 | same conditions, plus `[db.ssl_enforcement]` declared locally | -| 6 | auth | PATCH | `/v1/projects/{ref}/config/auth` | 2xx | same conditions (this resource's own local gate is "always on" too, CLI-2314 — `auth.enabled` only controls the local GoTrue Docker service, not this write); MFA phone/webauthn gated by an addon cost prompt when `verify_enabled` or `enroll_enabled` is being turned on and the add-on is not already active on the project | -| 7 | storage | PATCH | `/v1/projects/{ref}/config/storage` | 2xx | same conditions (this resource's own local gate is "always on" too, CLI-2314 — `storage.enabled` only controls the local Storage Docker service, not this write) | -| 8 | experimental.webhooks | POST | `/v1/projects/{ref}/database/webhooks/enable` | 2xx | only if local `experimental.webhooks.enabled`; no GET/diff; a kept enable reports `changes: [["experimental","webhooks","enabled"]]` (no config-diff path backs it) so it still counts toward the pushed-property total | +| # | Resource | Method | Path | Success | Notes | +| --- | ------------------------ | ------ | ----------------------------------------------- | ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| -2 | branch resolution (name) | GET | `/v1/projects/{parent_ref}/branches/{name}` | 200 | only when `--remote` names a branch by name (CLI-2289); `parent_ref` from the currently linked project | +| -2 | branch resolution (UUID) | GET | `/v1/branches/{id}` | 200 | only when `--remote` is a UUID (CLI-2289); needs no linked project | +| -1 | target detection | GET | `/v1/projects/{ref}` | 200 or 404 | CLI-2168: 200 = plain project (its `name` is shown); 404 = `ref` is a preview branch. Wrapped in a `"Checking project..."` task and bounded at 5s. Entirely best-effort/diagnostic: a TIMEOUT, a transport failure, or any OTHER status degrades to an "unknown" target (never silently "project" — that would skip the confirmation gate for a real branch — and never a hard failure that aborts an otherwise-successful push, e.g. for a scoped token that can write service config but can't read the project record). Skipped entirely when `--remote` already named a branch by name/UUID above. | +| -1 | branch name lookup | GET | `/v1/projects/{parent_ref}/branches` | 200 | only on the 404 path above, or a UUID-resolved target (never when the probe degrades to "unknown" — that never attempts recovery) — and only when `.temp/linked-project.json` names a different, ref-shaped project to treat as the parent (a branch-name `--remote` never triggers it); best-effort, 5s-bounded — a failure only omits the branch's name/parent from the echoed message, it never fails the command | +| 0 | cost matrix | GET | `/v1/projects/{ref}/billing/addons` | 200 | typed client; cost map for 1-variant addons; fetched after the target probe and the branch confirmation gate | +| 1 | effective project config | GET | `/v2/projects/{ref}/config` | 200 | the ONLY read this command makes — see below | +| 2 | api | PATCH | `/v1/projects/{ref}/postgrest` | 200 | only when `api` has a pushable change, its response block was returned, and the change is kept | +| 3 | db.settings | PUT | `/v1/projects/{ref}/config/database/postgres` | 200 | same conditions (this resource's own local gate is "always on") | +| 4 | db.network_restrictions | POST | `/v1/projects/{ref}/network-restrictions/apply` | 201 | same conditions, plus `db.network_restrictions.enabled` locally; body always carries both CIDR arrays together | +| 5 | db.ssl_enforcement | PUT | `/v1/projects/{ref}/ssl-enforcement` | 200 | same conditions, plus `[db.ssl_enforcement]` declared locally | +| 6 | auth | PATCH | `/v1/projects/{ref}/config/auth` | 2xx | same conditions (this resource's own local gate is "always on" too, CLI-2314 — `auth.enabled` only controls the local GoTrue Docker service, not this write); MFA phone/webauthn gated by an addon cost prompt when `verify_enabled` or `enroll_enabled` is being turned on and the add-on is not already active on the project | +| 7 | storage | PATCH | `/v1/projects/{ref}/config/storage` | 2xx | same conditions (this resource's own local gate is "always on" too, CLI-2314 — `storage.enabled` only controls the local Storage Docker service, not this write) | +| 8 | experimental.webhooks | POST | `/v1/projects/{ref}/database/webhooks/enable` | 2xx | only if local `experimental.webhooks.enabled`; no GET/diff; a kept enable reports `changes: [["experimental","webhooks","enabled"]]` (no config-diff path backs it) so it still counts toward the pushed-property total | **Row 1 is the only per-resource `GET` this command makes** — no per-service `GET /v1/…` request remains anywhere in this table; row 1's single response @@ -121,7 +121,7 @@ cannot be resolved from either the read or the file — reported in their own | Variable | Purpose | Required? | | -------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `SUPABASE_PROJECT_ID` | project ref (flag → this → `.temp/project-ref` → prompt) | no | +| `SUPABASE_PROJECT_ID` | project ref (`--remote=<ref>` → 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` | 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`) | @@ -139,7 +139,7 @@ cannot be resolved from either the read or the file — reported in their own | `1` | an explicit `--workdir`/`SUPABASE_WORKDIR` holds no project config (`ConfigPushLoadConfigError`) — checked again, right before target resolution, so a typo'd workdir fails without a branch-lookup round trip; a DEFAULTED workdir with no linked project instead fails with the not-linked error below | | `1` | `HostedMutationDeclinedError` — the consent question was declined (rendered as `context canceled`); nothing was written, though the diff and cost lines were already printed | | `1` | `HostedMutationConsentRequiredError` — there is something to write, no `--yes`/shell `SUPABASE_YES`, and the run cannot prompt (stdin not a TTY, redirected stdout, or machine output); nothing was written | -| `1` | `--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` | `--remote` names a branch that doesn't exist, isn't provisioned yet, or fails to resolve (network/status failure); or names a branch by name while no project is linked, or the linked parent ref is invalid (CLI-2289) | | `1` | malformed or missing `config.toml`/`config.json` | | `1` | `config.toml`/`config.json` uses a removed key (`[inbucket]`, `experimental.orioledb_version` unless empty, `[auth.external.linkedin]`, `[auth.external.slack]`, at the top level or in a `[remotes.*]` block), or `SUPABASE_EXPERIMENTAL_ORIOLEDB_VERSION` is set to a non-empty value — the message names every removed key with its replacement; nothing is pushed | | `1` | a flag, `SUPABASE_*` variable, project `.env*` file or config value fails to decode (`CliConfigValueError`) — names the source that supplied it; surfaced as-is, not wrapped in `ConfigPushLoadConfigError` | @@ -158,7 +158,7 @@ cannot be resolved from either the read or the file — reported in their own All diagnostics on **stderr**, no stdout. The `Target:` banner prints first (`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 +(<ref>)` for a branch named with `--remote`, 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 target the banner @@ -349,9 +349,11 @@ 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) 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). +- `--remote=<value>` selects the target: a project ref (20 lowercase letters), or the name or UUID of a branch of the linked project (resolved through `GET /v1/projects/{ref}/branches/{name}` or `GET /v1/branches/{id}`). A bare `--remote` and no selector both target the linked project. A database URL is rejected (`supabase config push` takes a project, not a database); `false|no|off|0`, `--no-remote` and an empty value fail with `Leave out --remote to use the linked project.` +- The deprecated `--project-ref` is an alias of `--remote=<ref>` (it still accepts a branch name or UUID) and prints a deprecation warning on stderr that names the replacement; it cannot be combined with `--remote=<value>`. An empty `--project-ref` is rejected. +- **`--remote` 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) before doing anything else. When `--remote` 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 `--remote` 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 `--remote=<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. - 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/domains/SIDE_EFFECTS.md b/apps/cli/src/commands/domains/SIDE_EFFECTS.md index 4e6c793f95..f80fc1abf5 100644 --- a/apps/cli/src/commands/domains/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/domains/SIDE_EFFECTS.md @@ -9,7 +9,7 @@ Cloudflare DNS-over-HTTPS CNAME pre-check. | Path | Format | When | | ---------------------------------------------- | ------------------------- | ------------------------------------------------------------------------------- | | `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | -| `<workdir>/supabase/.temp/project-ref` | plain text | when `--project-ref` flag and `SUPABASE_PROJECT_ID` env are unset | +| `<workdir>/supabase/.temp/project-ref` | plain text | when neither `--remote=<ref>` nor `SUPABASE_PROJECT_ID` is set | | `<workdir>/supabase/.temp/linked-project.json` | JSON | existence check only — `linkedProjectCache` skips its fill when the file exists | ## Files Written @@ -21,23 +21,24 @@ Cloudflare DNS-over-HTTPS CNAME pre-check. ## API Routes -| Method | Path | Auth | Request body | Response (used fields) | -| -------- | ----------------------------------------------- | ------------ | --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `GET` | `/v1/projects` | Bearer token | none | project list for the interactive picker — only when no ref comes from `--project-ref`, `SUPABASE_PROJECT_ID` or `.temp/project-ref` and stdin is a TTY with interactive output | -| `GET` | `https://1.1.1.1/dns-query?name=<host>&type=5` | none | none | `{Answer: [{type, data}]}` — first CNAME answer (`create` only, before the POST) | -| `POST` | `/v1/projects/{ref}/custom-hostname/initialize` | Bearer token | `{custom_hostname: string}` | 201 → `{status, custom_hostname, data: {result: {ssl, custom_origin_server, …}}}` (`create`) | -| `GET` | `/v1/projects/{ref}/custom-hostname` | Bearer token | none | 200 → same response shape (`get`) | -| `POST` | `/v1/projects/{ref}/custom-hostname/reverify` | Bearer token | none | 201 → same response shape (`reverify`) | -| `POST` | `/v1/projects/{ref}/custom-hostname/activate` | Bearer token | none | 201 → same response shape (`activate`) | -| `DELETE` | `/v1/projects/{ref}/custom-hostname` | Bearer token | none | 200 → empty/void body (`delete`) | -| `GET` | `/v1/projects/{ref}` | Bearer token | none | `{ref, name, organization_id, organization_slug}` — `linkedProjectCache` fill, best-effort, skipped when `.temp/linked-project.json` exists, `.temp/project-ref` names another ref, or no token is available | +| Method | Path | Auth | Request body | Response (used fields) | +| -------- | ----------------------------------------------- | ------------ | --------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `GET` | `/v1/projects` | Bearer token | none | project list for the interactive picker — only when no ref comes from `--remote=<ref>`, `SUPABASE_PROJECT_ID` or `.temp/project-ref` and stdin is a TTY with interactive output | +| `GET` | `https://1.1.1.1/dns-query?name=<host>&type=5` | none | none | `{Answer: [{type, data}]}` — first CNAME answer (`create` only, before the POST) | +| `POST` | `/v1/projects/{ref}/custom-hostname/initialize` | Bearer token | `{custom_hostname: string}` | 201 → `{status, custom_hostname, data: {result: {ssl, custom_origin_server, …}}}` (`create`) | +| `GET` | `/v1/projects/{ref}/custom-hostname` | Bearer token | none | 200 → same response shape (`get`) | +| `POST` | `/v1/projects/{ref}/custom-hostname/reverify` | Bearer token | none | 201 → same response shape (`reverify`) | +| `POST` | `/v1/projects/{ref}/custom-hostname/activate` | Bearer token | none | 201 → same response shape (`activate`) | +| `DELETE` | `/v1/projects/{ref}/custom-hostname` | Bearer token | none | 200 → empty/void body (`delete`) | +| `GET` | `/v1/projects/{ref}` | Bearer token | none | `{ref, name, organization_id, organization_slug}` — `linkedProjectCache` fill, best-effort, skipped when `.temp/linked-project.json` exists, `.temp/project-ref` names another ref, or no token is available | +| `GET` | `/v1/projects/{parent}/branches` | Bearer token | none | `[{id, name, project_ref, is_default}]` — only for `--remote=<branch>` (or the deprecated `--project-ref=<branch>`), and for a bare `--remote` followed by a word that could name a branch; `{parent}` is `SUPABASE_PROJECT_ID`, else the linked project | ## 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 `supabase/.temp/project-ref`, then the picker on a TTY) | +| `SUPABASE_PROJECT_ID` | project ref fallback when `--remote=<ref>` is not given | no (falls back to `supabase/.temp/project-ref`, then the picker on a TTY) | | `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 | @@ -96,9 +97,12 @@ suppressed on stderr. `delete` ignores `-o`. ## Notes +- Targets the linked project unless `--remote=<ref>` is given; a bare `--remote` also targets the linked project. `--remote=<value>` is read as a project ref (20 lowercase letters), a branch of the linked project (by name or UUID, resolved through `GET /v1/projects/{parent}/branches`), or `true|yes|on|1` for the linked project. A database URL is rejected (these commands take a project, not a database); `false|no|off|0`, `--no-remote` and an empty value fail with `Leave out --remote to use the linked project.` +- A bare `--remote` followed by a word that is a branch of the linked project fails before any other work with a `Did you mean --remote=<word>?` hint, since the word may have been meant as the flag's value. +- The deprecated `--project-ref` is an alias of `--remote=<ref>` and prints a deprecation warning on stderr that names the replacement; it cannot be combined with `--remote=<value>`. An empty `--project-ref` is rejected. - `--custom-hostname` is required for `create`. - `create` validates the CNAME via Cloudflare DNS-over-HTTPS (`https://1.1.1.1`, 10s timeout) before initializing; on failure it short-circuits before any POST. -- All subcommands resolve the ref via `--project-ref` → `SUPABASE_PROJECT_ID` env → `supabase/.temp/project-ref` → interactive project picker (only when stdin is a TTY and output is interactive). +- All subcommands resolve the ref via `--remote=<ref>` → `SUPABASE_PROJECT_ID` env → `supabase/.temp/project-ref` → interactive project picker (only when stdin is a TTY and output is interactive). - The project-ref fallback env var is `SUPABASE_PROJECT_ID`. - **Notes:** - `--include-raw-output` is removed on every subcommand, `delete` included. Passing it (any value, including `=false`) fails with `--include-raw-output was removed.` and the suggestion `Use -o json instead.`, before credentials are resolved or any API work runs, so it needs no login. `RemovedSurfaceError` records `cli_command_executed` with `error_fingerprint` ending in `:removed_flag`, and the post-run `telemetry.json` read and write under `SUPABASE_HOME` still happens. The flag stays parsed (hidden) only so misuse reaches this error instead of an unknown-flag parse error; its rejection lives in `domains.removed-flag.ts`. diff --git a/apps/cli/src/commands/encryption/SIDE_EFFECTS.md b/apps/cli/src/commands/encryption/SIDE_EFFECTS.md index 2f0c76200c..9834a37dac 100644 --- a/apps/cli/src/commands/encryption/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/encryption/SIDE_EFFECTS.md @@ -6,12 +6,12 @@ additionally reads the new key from stdin. ## Files Read -| Path | Format | When | -| ---------------------------------------------- | ------------------------- | -------------------------------------------------------------------------------------------------------------------- | -| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | -| `<workdir>/supabase/.temp/linked-project.json` | JSON | always, after the project ref resolves — `linkedProjectCache` checks it exists to decide whether to write | -| `<workdir>/supabase/.temp/project-ref` | plain text (project ref) | when `--project-ref` flag and `SUPABASE_PROJECT_ID` env are both unset; also by `linkedProjectCache` on a cache miss | -| stdin | raw bytes / masked TTY | `update-root-key` only — masked TTY input or piped bytes (the key) | +| Path | Format | When | +| ---------------------------------------------- | ------------------------- | ------------------------------------------------------------------------------------------------------------ | +| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | +| `<workdir>/supabase/.temp/linked-project.json` | JSON | always, after the project ref resolves — `linkedProjectCache` checks it exists to decide whether to write | +| `<workdir>/supabase/.temp/project-ref` | plain text (project ref) | when neither `--remote=<ref>` nor `SUPABASE_PROJECT_ID` is set; also by `linkedProjectCache` on a cache miss | +| stdin | raw bytes / masked TTY | `update-root-key` only — masked TTY input or piped bytes (the key) | ## Files Written @@ -22,12 +22,13 @@ additionally reads the new key from stdin. ## API Routes -| Method | Path | Auth | Request body | Response (used fields) | -| ------ | ----------------------------- | ------------ | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `GET` | `/v1/projects` | Bearer token | none | project list for the interactive picker — only when no ref comes from `--project-ref`, `SUPABASE_PROJECT_ID` or `.temp/project-ref` and stdin is a TTY with interactive output | -| `GET` | `/v1/projects/{ref}/pgsodium` | Bearer token | none | `{root_key}` | -| `PUT` | `/v1/projects/{ref}/pgsodium` | Bearer token | `{root_key}` | `{root_key}` | -| `GET` | `/v1/projects/{ref}` | Bearer token | none | `{ref, name, organization_id, organization_slug}` — `linkedProjectCache` fill, best-effort, skipped when `.temp/linked-project.json` exists, `.temp/project-ref` names another ref, or no token is available | +| Method | Path | Auth | Request body | Response (used fields) | +| ------ | -------------------------------- | ------------ | ------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `GET` | `/v1/projects` | Bearer token | none | project list for the interactive picker — only when no ref comes from `--remote=<ref>`, `SUPABASE_PROJECT_ID` or `.temp/project-ref` and stdin is a TTY with interactive output | +| `GET` | `/v1/projects/{ref}/pgsodium` | Bearer token | none | `{root_key}` | +| `PUT` | `/v1/projects/{ref}/pgsodium` | Bearer token | `{root_key}` | `{root_key}` | +| `GET` | `/v1/projects/{ref}` | Bearer token | none | `{ref, name, organization_id, organization_slug}` — `linkedProjectCache` fill, best-effort, skipped when `.temp/linked-project.json` exists, `.temp/project-ref` names another ref, or no token is available | +| `GET` | `/v1/projects/{parent}/branches` | Bearer token | none | `[{id, name, project_ref, is_default}]` — only for `--remote=<branch>` (or the deprecated `--project-ref=<branch>`), and for a bare `--remote` followed by a word that could name a branch; `{parent}` is `SUPABASE_PROJECT_ID`, else the linked project | `get-root-key` calls `GET`; `update-root-key` calls `PUT`. @@ -36,7 +37,7 @@ additionally reads the new key from stdin. | 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 `supabase/.temp/project-ref`, then the interactive project picker on a TTY) | +| `SUPABASE_PROJECT_ID` | project ref (fallback when `--remote=<ref>` is not given) | no (falls back to `supabase/.temp/project-ref`, then the interactive project picker on a TTY) | | `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) | @@ -53,9 +54,9 @@ additionally reads the new key from stdin. ## Telemetry Events Fired -| Event | When | Notable properties / groups | -| ---------------------- | ------------------------------------------ | ----------------------------------------------------------------------------------- | -| `cli_command_executed` | post-run, success or failure (via wrapper) | `exit_code`, `duration_ms`, `flags` (`--project-ref` redacted — not telemetry-safe) | +| Event | When | Notable properties / groups | +| ---------------------- | ------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `cli_command_executed` | post-run, success or failure (via wrapper) | `exit_code`, `duration_ms`, `flags` (`--remote=<ref>` → the ref, a bare `--remote` → `true`, any other `--remote` value → `<redacted>`; `--project-ref` redacted — not telemetry-safe) | No custom `phtelemetry.*` events in `internal/encryption/`. @@ -81,7 +82,10 @@ One `result` event carrying `{root_key}` (both subcommands). ## Notes -- Requires `--project-ref`, `SUPABASE_PROJECT_ID`, or a linked project. +- Targets the linked project unless `--remote=<ref>` is given; a bare `--remote` also targets the linked project. `--remote=<value>` is read as a project ref (20 lowercase letters), a branch of the linked project (by name or UUID, resolved through `GET /v1/projects/{parent}/branches`), or `true|yes|on|1` for the linked project. A database URL is rejected (these commands take a project, not a database); `false|no|off|0`, `--no-remote` and an empty value fail with `Leave out --remote to use the linked project.` +- A bare `--remote` followed by a word that is a branch of the linked project fails before any other work with a `Did you mean --remote=<word>?` hint, since the word may have been meant as the flag's value. +- The deprecated `--project-ref` is an alias of `--remote=<ref>` and prints a deprecation warning on stderr that names the replacement; it cannot be combined with `--remote=<value>`. An empty `--project-ref` is rejected. +- Requires `--remote=<ref>`, `SUPABASE_PROJECT_ID`, or a linked project. - `update-root-key` reads the key from stdin: a real TTY is read with a masked prompt; piped stdin is decoded as UTF-8 and whitespace-trimmed. An empty or whitespace-only key sends an empty `root_key` (the TTY masked prompt also trims). 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 73b5406111..2c0c5a6030 100644 --- a/apps/cli/src/commands/experimental/compute/delete/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/experimental/compute/delete/SIDE_EFFECTS.md @@ -22,7 +22,7 @@ and the command handler does not run. See the [Compute command guide](../../../. | `<workdir>/supabase/config.toml` | TOML | when no `config.json` exists — the same, on the same best-effort terms, with the same explicit-vs-default workdir rule | | `<compute source>/` | directory | canonicalised and stat'd, to decide whether the kept-source line is stated at all | | `<SUPABASE_HOME or ~/.supabase>/access-token` | plain text | when `SUPABASE_ACCESS_TOKEN` is unset and the keyring holds no credential | -| `<workdir>/supabase/.temp/project-ref` | plain text | when neither `--project-ref` nor `SUPABASE_PROJECT_ID` is set — names the linked project | +| `<workdir>/supabase/.temp/project-ref` | plain text | when neither `--remote=<ref>` nor `SUPABASE_PROJECT_ID` is set — names the linked project | | `<SUPABASE_HOME or ~/.supabase>/profile` | plain text | when neither `--profile` nor `SUPABASE_PROFILE` is set — names the profile, defaulting to `supabase` | | `<SUPABASE_PROFILE>` (YAML) | YAML | when `SUPABASE_PROFILE` is a filesystem path rather than a built-in name; a read failure aborts the command | @@ -55,6 +55,7 @@ known) are written to stderr before the typed-name prompt. | `DELETE` | `/v2/projects/{ref}/compute/{name}` | Bearer token | none | status only. Not sent when the `GET` shows nothing deployed under that name | | `GET` | `/v1/projects` | Bearer token | none | `id`, `name`, `organization_slug`, `region` — only when no ref resolved and the session is interactive, to populate the project picker | | `GET` | `/v1/projects/{ref}` | Bearer token | none | `ref`, `name`, `organization_id`, `organization_slug` — linked-project cache miss only (best-effort, post-run): written only when `<workdir>/supabase/.temp/linked-project.json` doesn't exist, `.temp/project-ref` doesn't name another ref, an access token exists and the lookup returns 200 | +| `GET` | `/v1/projects/{parent}/branches` | Bearer token | none | `[{id, name, project_ref, is_default}]` — only for `--remote=<branch>` (or the deprecated `--project-ref=<branch>`), and for a bare `--remote` followed by a word that could name a branch; `{parent}` is `SUPABASE_PROJECT_ID`, else the linked project | ## Exit Codes @@ -75,7 +76,7 @@ known) are written to stderr before the typed-name prompt. | `SUPABASE_EXPERIMENTAL_COMPUTE` | command registration (boolean `1`/`true` enables, `0`/`false` disables; unset/empty uses `experimental.compute`; non-boolean values are errors) | no | | `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | | `SUPABASE_PROFILE` | built-in profile name or YAML file path | no (falls back to `~/.supabase/profile` -> `supabase`) | -| `SUPABASE_PROJECT_ID` | project ref, consulted after `--project-ref` | no (falls back to `supabase/.temp/project-ref`, then the picker) | +| `SUPABASE_PROJECT_ID` | project ref, consulted after `--remote=<ref>` | no (falls back to `supabase/.temp/project-ref`, then the picker) | | `SUPABASE_WORKDIR` | project directory the command acts on | no (falls back to `--workdir`, then the ancestor walk) — read exactly as given when SET (flag or env), with **no ancestor search**; a DEFAULTED workdir may still resolve an ancestor project's config from a subdirectory (CLI-2285) | | `SUPABASE_HOME` | directory holding `telemetry.json` | no (falls back to `~/.supabase`) | | `SUPABASE_YES` | auto-confirms the deletion, as `--yes` does | no (defaults to prompting) | @@ -105,3 +106,9 @@ A structured emission is the end of the run: the handler returns at branch below it — the kept-nothing notice and the redeploy trailer — is reached. `-o pretty`, `table` and `csv` are the exception, since they encode nothing and fall through to that same text branch. + +## Notes + +- Targets the linked project unless `--remote=<ref>` is given; a bare `--remote` also targets the linked project. `--remote=<value>` is read as a project ref (20 lowercase letters), a branch of the linked project (by name or UUID, resolved through `GET /v1/projects/{parent}/branches`), or `true|yes|on|1` for the linked project. A database URL is rejected (`supabase experimental compute delete` takes a project, not a database); `false|no|off|0`, `--no-remote` and an empty value fail with `Leave out --remote to use the linked project.` +- A bare `--remote` followed by a word that is a branch of the linked project fails before any other work with a `Did you mean --remote=<word>?` hint, since the word may have been meant as the flag's value. +- The deprecated `--project-ref` is an alias of `--remote=<ref>` and prints a deprecation warning on stderr that names the replacement; it cannot be combined with `--remote=<value>`. An empty `--project-ref` is rejected. diff --git a/apps/cli/src/commands/experimental/compute/list/SIDE_EFFECTS.md b/apps/cli/src/commands/experimental/compute/list/SIDE_EFFECTS.md index 821f32e52d..8d40a388a9 100644 --- a/apps/cli/src/commands/experimental/compute/list/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/experimental/compute/list/SIDE_EFFECTS.md @@ -22,7 +22,7 @@ and the command handler does not run. See the [Compute command guide](../../../. | `<workdir>/supabase/config.toml` | TOML | always when no `config.json` exists — the same entries, with the same explicit-vs-default workdir rule | | `<workdir>/supabase/compute/` | directory | always — enumerated and each child stat'd, so a directory with no `[compute.<name>]` entry still appears in the inventory. Absent reads as no compute; any other read failure fails the command | | `<SUPABASE_HOME or ~/.supabase>/access-token` | plain text | when `SUPABASE_ACCESS_TOKEN` is unset and the keyring holds no credential | -| `<workdir>/supabase/.temp/project-ref` | plain text | when neither `--project-ref` nor `SUPABASE_PROJECT_ID` is set — names the linked project | +| `<workdir>/supabase/.temp/project-ref` | plain text | when neither `--remote=<ref>` nor `SUPABASE_PROJECT_ID` is set — names the linked project | | `<SUPABASE_HOME or ~/.supabase>/profile` | plain text | when neither `--profile` nor `SUPABASE_PROFILE` is set — names the profile, defaulting to `supabase` | | `<SUPABASE_PROFILE>` (YAML) | YAML | when `SUPABASE_PROFILE` is a filesystem path rather than a built-in name; a read failure aborts the command | @@ -35,11 +35,12 @@ and the command handler does not run. See the [Compute command guide](../../../. ## API Routes -| Method | Path | Auth | Request body | Response (used fields) | -| ------ | ---------------------------- | ------------ | ------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `GET` | `/v2/projects/{ref}/compute` | Bearer token | none | `data[].id`, `data[].attributes.spec/build_state/deleting` | -| `GET` | `/v1/projects` | Bearer token | none | `id`, `name`, `organization_slug`, `region` — only when no ref resolved and the session is interactive, to populate the project picker | -| `GET` | `/v1/projects/{ref}` | Bearer token | none | `ref`, `name`, `organization_id`, `organization_slug` — linked-project cache miss only (best-effort, post-run): written only when `<workdir>/supabase/.temp/linked-project.json` doesn't exist, `.temp/project-ref` doesn't name another ref, an access token exists and the lookup returns 200 | +| Method | Path | Auth | Request body | Response (used fields) | +| ------ | -------------------------------- | ------------ | ------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `GET` | `/v2/projects/{ref}/compute` | Bearer token | none | `data[].id`, `data[].attributes.spec/build_state/deleting` | +| `GET` | `/v1/projects` | Bearer token | none | `id`, `name`, `organization_slug`, `region` — only when no ref resolved and the session is interactive, to populate the project picker | +| `GET` | `/v1/projects/{ref}` | Bearer token | none | `ref`, `name`, `organization_id`, `organization_slug` — linked-project cache miss only (best-effort, post-run): written only when `<workdir>/supabase/.temp/linked-project.json` doesn't exist, `.temp/project-ref` doesn't name another ref, an access token exists and the lookup returns 200 | +| `GET` | `/v1/projects/{parent}/branches` | Bearer token | none | `[{id, name, project_ref, is_default}]` — only for `--remote=<branch>` (or the deprecated `--project-ref=<branch>`), and for a bare `--remote` followed by a word that could name a branch; `{parent}` is `SUPABASE_PROJECT_ID`, else the linked project | ## Exit Codes @@ -55,7 +56,7 @@ and the command handler does not run. See the [Compute command guide](../../../. | `SUPABASE_EXPERIMENTAL_COMPUTE` | command registration (boolean `1`/`true` enables, `0`/`false` disables; unset/empty uses `experimental.compute`; non-boolean values are errors) | no | | `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | | `SUPABASE_PROFILE` | built-in profile name or YAML file path | no (falls back to `~/.supabase/profile` -> `supabase`) | -| `SUPABASE_PROJECT_ID` | project ref, consulted after `--project-ref` | no (falls back to `supabase/.temp/project-ref`, then the picker) | +| `SUPABASE_PROJECT_ID` | project ref, consulted after `--remote=<ref>` | no (falls back to `supabase/.temp/project-ref`, then the picker) | | `SUPABASE_WORKDIR` | project directory the command acts on | no (falls back to `--workdir`, then the ancestor walk) — read exactly as given when SET (flag or env), with **no ancestor search**; a DEFAULTED workdir may still resolve an ancestor project's config from a subdirectory (CLI-2285) | | `SUPABASE_HOME` | directory holding `telemetry.json` | no (falls back to `~/.supabase`) | @@ -82,3 +83,9 @@ wrapper emits for every command. The text table omits each compute's URL — it is the same host and prefix on every row, and carrying it made the table 137 columns wide. Every machine format still carries `url` per compute, and `compute status` renders it. + +## Notes + +- Targets the linked project unless `--remote=<ref>` is given; a bare `--remote` also targets the linked project. `--remote=<value>` is read as a project ref (20 lowercase letters), a branch of the linked project (by name or UUID, resolved through `GET /v1/projects/{parent}/branches`), or `true|yes|on|1` for the linked project. A database URL is rejected (`supabase experimental compute list` takes a project, not a database); `false|no|off|0`, `--no-remote` and an empty value fail with `Leave out --remote to use the linked project.` +- A bare `--remote` followed by a word that is a branch of the linked project fails before any other work with a `Did you mean --remote=<word>?` hint, since the word may have been meant as the flag's value. +- The deprecated `--project-ref` is an alias of `--remote=<ref>` and prints a deprecation warning on stderr that names the replacement; it cannot be combined with `--remote=<value>`. An empty `--project-ref` is rejected. diff --git a/apps/cli/src/commands/experimental/compute/logs/SIDE_EFFECTS.md b/apps/cli/src/commands/experimental/compute/logs/SIDE_EFFECTS.md index 707723933b..482ac36937 100644 --- a/apps/cli/src/commands/experimental/compute/logs/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/experimental/compute/logs/SIDE_EFFECTS.md @@ -20,7 +20,7 @@ no discovery or side effects. | Path | Format | When | | --------------------------------------------- | ---------- | ----------------------------------------------------------------------------------------------------------- | | `<SUPABASE_HOME or ~/.supabase>/access-token` | plain text | when `SUPABASE_ACCESS_TOKEN` is unset and the keyring holds no credential | -| `<workdir>/supabase/.temp/project-ref` | plain text | when neither `--project-ref` nor `SUPABASE_PROJECT_ID` is set — names the linked project | +| `<workdir>/supabase/.temp/project-ref` | plain text | when neither `--remote=<ref>` nor `SUPABASE_PROJECT_ID` is set — names the linked project | | `<SUPABASE_HOME or ~/.supabase>/profile` | plain text | when neither `--profile` nor `SUPABASE_PROFILE` is set — names the profile, defaulting to `supabase` | | `<SUPABASE_PROFILE>` (YAML) | YAML | when `SUPABASE_PROFILE` is a filesystem path rather than a built-in name; a read failure aborts the command | @@ -43,6 +43,7 @@ command's output depends on local state — there is no source path to report | `GET` | `/v2/projects/{ref}/compute/{name}` | Bearer token | none — **only when there are no log rows** (the query returned none, or `--tail 0 --follow` ran no query), to tell "not deployed" from "deployed and quiet" | presence only | | `GET` | `/v1/projects` | Bearer token | none — only when no ref resolved and the session is interactive | project picker | | `GET` | `/v1/projects/{ref}` | Bearer token | none | `ref`, `name`, `organization_id`, `organization_slug` — linked-project cache miss only (best-effort, post-run): written only when `<workdir>/supabase/.temp/linked-project.json` doesn't exist, `.temp/project-ref` doesn't name another ref, an access token exists and the lookup returns 200 | +| `GET` | `/v1/projects/{parent}/branches` | Bearer token | none | `[{id, name, project_ref, is_default}]` — only for `--remote=<branch>` (or the deprecated `--project-ref=<branch>`), and for a bare `--remote` followed by a word that could name a branch; `{parent}` is `SUPABASE_PROJECT_ID`, else the linked project | Requires the `analytics_logs_read` permission, and the project must be on the Compute private-alpha allow-list — an unenrolled project answers 404. @@ -112,7 +113,7 @@ guaranteed and is deduplicated on the Logflare-minted `id`. | `SUPABASE_EXPERIMENTAL_COMPUTE` | command registration (boolean `1`/`true` enables, `0`/`false` disables; unset/empty uses `experimental.compute`; non-boolean values are errors) | no | | `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | | `SUPABASE_PROFILE` | built-in profile name or YAML file path | no (falls back to `~/.supabase/profile` -> `supabase`) | -| `SUPABASE_PROJECT_ID` | project ref, consulted after `--project-ref` | no (falls back to `supabase/.temp/project-ref`, then the picker) | +| `SUPABASE_PROJECT_ID` | project ref, consulted after `--remote=<ref>` | no (falls back to `supabase/.temp/project-ref`, then the picker) | | `SUPABASE_WORKDIR` | project directory the command acts on | no (falls back to `--workdir`, then the ancestor walk) | | `SUPABASE_HOME` | directory holding `telemetry.json` | no (falls back to `~/.supabase`) | @@ -123,8 +124,8 @@ guaranteed and is deduplicated on the Logflare-minted `id`. | `cli_command_executed` | post-run, success or failure (via wrapper) | `exit_code`, `duration_ms`, `flags` | `--kind` is a choice flag, so its value is logged verbatim (a closed enum carries -no user data). `--project-ref` is not on this command's safe list, so its value is -redacted. No custom events. +no user data). `--remote=<ref>` is logged as the ref and a bare `--remote` as `true`; any other +`--remote` value, and `--project-ref`, is redacted. No custom events. ## Output Formats @@ -165,3 +166,9 @@ A `worker_guest_logs` message is bytes the tenant's own code printed. Control an escape sequences are stripped before it reaches a terminal, so a compute cannot reposition the cursor or forge CLI output; interior newlines and indentation are preserved so a stack trace survives intact. + +## Notes + +- Targets the linked project unless `--remote=<ref>` is given; a bare `--remote` also targets the linked project. `--remote=<value>` is read as a project ref (20 lowercase letters), a branch of the linked project (by name or UUID, resolved through `GET /v1/projects/{parent}/branches`), or `true|yes|on|1` for the linked project. A database URL is rejected (`supabase experimental compute logs` takes a project, not a database); `false|no|off|0`, `--no-remote` and an empty value fail with `Leave out --remote to use the linked project.` +- A bare `--remote` followed by a word that is a branch of the linked project fails before any other work with a `Did you mean --remote=<word>?` hint, since the word may have been meant as the flag's value. +- The deprecated `--project-ref` is an alias of `--remote=<ref>` and prints a deprecation warning on stderr that names the replacement; it cannot be combined with `--remote=<value>`. An empty `--project-ref` is rejected. 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 4d544ab895..9112585af4 100644 --- a/apps/cli/src/commands/experimental/compute/push/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/experimental/compute/push/SIDE_EFFECTS.md @@ -21,7 +21,7 @@ and the command handler does not run. See the [Compute command guide](../../../. | `<workdir>/supabase/config.json` | JSON | always when present — preferred over `config.toml`; each compute's runtime, size, exposure, instances, source. An explicit `--workdir`/`SUPABASE_WORKDIR` is read exactly as given, with no ancestor search; with a DEFAULTED workdir the loader may resolve an ancestor project's `config.json` from a subdirectory (CLI-2285), and the resolved compute `source` below resolves against that SAME ancestor root | | `<workdir>/supabase/config.toml` | TOML | always when no `config.json` exists — the same compute fields, with the same explicit-vs-default workdir rule | | `<compute source>/**` | any | always — packaged into the build context, minus whatever `[compute.<name>] exclude` matches. An excluded directory is not descended into, so nothing under it is read at all | -| `<workdir>/supabase/.temp/project-ref` | plain text | when neither `--project-ref` nor `SUPABASE_PROJECT_ID` is set — names the linked project | +| `<workdir>/supabase/.temp/project-ref` | plain text | when neither `--remote=<ref>` nor `SUPABASE_PROJECT_ID` is set — names the linked project | | `<SUPABASE_HOME or ~/.supabase>/profile` | plain text | when neither `--profile` nor `SUPABASE_PROFILE` is set — names the profile, defaulting to `supabase` | | `<SUPABASE_PROFILE>` (YAML) | YAML | when `SUPABASE_PROFILE` is a filesystem path rather than a built-in name; a read failure aborts the command | @@ -50,8 +50,9 @@ deploying unasked. Stdin is never read for the answer. | `POST` or `PUT` | presigned upload URL (control-plane storage) | URL signature — **no** Supabase credentials | `.tar.gz` build context | status only. The method is the one the upload slot names: `POST` if it says so, else `PUT` | | `POST` | `/v2/projects/{ref}/compute/{name}/deploy` | Bearer token | `{data:{type,attributes:{spec,context_upload_id}}}` | `data.attributes.build_state` | | `GET` | `/v2/projects/{ref}/compute/{name}` | Bearer token | none | `build_state`, `state_reason`, `image_version`, `spec` | -| `GET` | `/v1/projects` | Bearer token | none | `id`, `name`, `organization_slug`, `region` — project picker: only when no ref resolves from `--project-ref`, `SUPABASE_PROJECT_ID` or `supabase/.temp/project-ref` and stdin and stdout are TTYs | +| `GET` | `/v1/projects` | Bearer token | none | `id`, `name`, `organization_slug`, `region` — project picker: only when no ref resolves from `--remote=<ref>`, `SUPABASE_PROJECT_ID` or `supabase/.temp/project-ref` and stdin and stdout are TTYs | | `GET` | `/v1/projects/{ref}` | Bearer token | none | `ref`, `name`, `organization_id`, `organization_slug` — linked-project cache miss only (best-effort, post-run): written only when `<workdir>/supabase/.temp/linked-project.json` doesn't exist, `.temp/project-ref` doesn't name another ref, an access token exists and the lookup returns 200 | +| `GET` | `/v1/projects/{parent}/branches` | Bearer token | none | `[{id, name, project_ref, is_default}]` — only for `--remote=<branch>` (or the deprecated `--project-ref=<branch>`), and for a bare `--remote` followed by a word that could name a branch; `{parent}` is `SUPABASE_PROJECT_ID`, else the linked project | `GET /v2/projects/{ref}/compute/{name}` is polled until `build_state` leaves `building`. It is skipped entirely in two cases: under `--no-wait`, and when @@ -84,7 +85,7 @@ run reports the accepted spec the deploy response returned. | ------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `SUPABASE_EXPERIMENTAL_COMPUTE` | command registration (boolean `1`/`true` enables, `0`/`false` disables; unset/empty uses `experimental.compute`; non-boolean values are errors) | no | | `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | -| `SUPABASE_PROJECT_ID` | project ref, consulted after `--project-ref` | no (falls back to `supabase/.temp/project-ref`, then the picker) | +| `SUPABASE_PROJECT_ID` | project ref, consulted after `--remote=<ref>` | no (falls back to `supabase/.temp/project-ref`, then the picker) | | `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`) | @@ -112,8 +113,8 @@ once, at the end of the run rather than between compute. **Text output only** — like the rest of the human deploy report it sits behind `output.format === "text"` and the `-o` check, so `--output-format json`, `stream-json` and every `-o` mode emit no hint. Machine callers read -`build_state` from the payload instead. The hint carries an explicit -`--project-ref` when the flag supplied one, since it is copy-pasted verbatim. +`build_state` from the payload instead. The hint carries +`--remote=<ref>`, the resolved project ref, when a `--remote=<value>` or `--project-ref` was given, since it is copy-pasted verbatim. A multi-compute run stops at the first failure, and names on stderr in **every** format, machine ones included, both the compute it never attempted and — under @@ -189,3 +190,9 @@ adding a hoisted `node_modules` link to `exclude` is a real answer to The presigned upload above is the one request whose URL is itself a credential. `--debug` logs every request URL, so `httpClientLayer` redacts query strings that carry a signature. + +## Notes + +- Targets the linked project unless `--remote=<ref>` is given; a bare `--remote` also targets the linked project. `--remote=<value>` is read as a project ref (20 lowercase letters), a branch of the linked project (by name or UUID, resolved through `GET /v1/projects/{parent}/branches`), or `true|yes|on|1` for the linked project. A database URL is rejected (`supabase experimental compute push` takes a project, not a database); `false|no|off|0`, `--no-remote` and an empty value fail with `Leave out --remote to use the linked project.` +- A bare `--remote` followed by a word that is a branch of the linked project fails before any other work with a `Did you mean --remote=<word>?` hint, since the word may have been meant as the flag's value. +- The deprecated `--project-ref` is an alias of `--remote=<ref>` and prints a deprecation warning on stderr that names the replacement; it cannot be combined with `--remote=<value>`. An empty `--project-ref` is rejected. diff --git a/apps/cli/src/commands/experimental/compute/status/SIDE_EFFECTS.md b/apps/cli/src/commands/experimental/compute/status/SIDE_EFFECTS.md index c8e0aacbfe..05e66b0d21 100644 --- a/apps/cli/src/commands/experimental/compute/status/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/experimental/compute/status/SIDE_EFFECTS.md @@ -22,7 +22,7 @@ and the command handler does not run. See the [Compute command guide](../../../. | `<workdir>/supabase/config.toml` | TOML | when no `config.json` exists — the same, on the same best-effort terms, with the same explicit-vs-default workdir rule | | `<compute source>/` | directory | canonicalised and stat'd, to decide whether the source row is stated at all | | `<SUPABASE_HOME or ~/.supabase>/access-token` | plain text | when `SUPABASE_ACCESS_TOKEN` is unset and the keyring holds no credential | -| `<workdir>/supabase/.temp/project-ref` | plain text | when neither `--project-ref` nor `SUPABASE_PROJECT_ID` is set — names the linked project | +| `<workdir>/supabase/.temp/project-ref` | plain text | when neither `--remote=<ref>` nor `SUPABASE_PROJECT_ID` is set — names the linked project | | `<SUPABASE_HOME or ~/.supabase>/profile` | plain text | when neither `--profile` nor `SUPABASE_PROFILE` is set — names the profile, defaulting to `supabase` | | `<SUPABASE_PROFILE>` (YAML) | YAML | when `SUPABASE_PROFILE` is a filesystem path rather than a built-in name; a read failure aborts the command | @@ -40,6 +40,7 @@ and the command handler does not run. See the [Compute command guide](../../../. | `GET` | `/v2/projects/{ref}/compute/{name}` | Bearer token | none | `spec`, `build_state`, `state_reason`, `image_version`, `instances`, `instances_error`, `deleting` | | `GET` | `/v1/projects` | Bearer token | none | `id`, `name`, `organization_slug`, `region` — only when no ref resolved and the session is interactive, to populate the project picker | | `GET` | `/v1/projects/{ref}` | Bearer token | none | `ref`, `name`, `organization_id`, `organization_slug` — linked-project cache miss only (best-effort, post-run): written only when `<workdir>/supabase/.temp/linked-project.json` doesn't exist, `.temp/project-ref` doesn't name another ref, an access token exists and the lookup returns 200 | +| `GET` | `/v1/projects/{parent}/branches` | Bearer token | none | `[{id, name, project_ref, is_default}]` — only for `--remote=<branch>` (or the deprecated `--project-ref=<branch>`), and for a bare `--remote` followed by a word that could name a branch; `{parent}` is `SUPABASE_PROJECT_ID`, else the linked project | ## Exit Codes @@ -57,7 +58,7 @@ and the command handler does not run. See the [Compute command guide](../../../. | `SUPABASE_EXPERIMENTAL_COMPUTE` | command registration (boolean `1`/`true` enables, `0`/`false` disables; unset/empty uses `experimental.compute`; non-boolean values are errors) | no | | `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | | `SUPABASE_PROFILE` | built-in profile name or YAML file path | no (falls back to `~/.supabase/profile` -> `supabase`) | -| `SUPABASE_PROJECT_ID` | project ref, consulted after `--project-ref` | no (falls back to `supabase/.temp/project-ref`, then the picker) | +| `SUPABASE_PROJECT_ID` | project ref, consulted after `--remote=<ref>` | no (falls back to `supabase/.temp/project-ref`, then the picker) | | `SUPABASE_WORKDIR` | project directory the command acts on | no (falls back to `--workdir`, then the ancestor walk) — read exactly as given when SET (flag or env), with **no ancestor search**; a DEFAULTED workdir may still resolve an ancestor project's config from a subdirectory (CLI-2285) | | `SUPABASE_HOME` | directory holding `telemetry.json` | no (falls back to `~/.supabase`) | @@ -86,3 +87,9 @@ A structured emission is the end of the run: the handler returns at branch below it — the instance tally and the build-retry trailer — is reached. `-o pretty`, `table` and `csv` are the exception, since they encode nothing and fall through to that same text branch. + +## Notes + +- Targets the linked project unless `--remote=<ref>` is given; a bare `--remote` also targets the linked project. `--remote=<value>` is read as a project ref (20 lowercase letters), a branch of the linked project (by name or UUID, resolved through `GET /v1/projects/{parent}/branches`), or `true|yes|on|1` for the linked project. A database URL is rejected (`supabase experimental compute status` takes a project, not a database); `false|no|off|0`, `--no-remote` and an empty value fail with `Leave out --remote to use the linked project.` +- A bare `--remote` followed by a word that is a branch of the linked project fails before any other work with a `Did you mean --remote=<word>?` hint, since the word may have been meant as the flag's value. +- The deprecated `--project-ref` is an alias of `--remote=<ref>` and prints a deprecation warning on stderr that names the replacement; it cannot be combined with `--remote=<value>`. An empty `--project-ref` is rejected. diff --git a/apps/cli/src/commands/functions/delete/SIDE_EFFECTS.md b/apps/cli/src/commands/functions/delete/SIDE_EFFECTS.md index 36be3bc84c..d7d7ede8d7 100644 --- a/apps/cli/src/commands/functions/delete/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/functions/delete/SIDE_EFFECTS.md @@ -2,13 +2,13 @@ ## Files Read -| Path | Format | When | -| ----------------------------------------------- | ---------- | ------------------------------------------------------------- | -| `<SUPABASE_HOME or ~/.supabase>/access-token` | plain text | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | -| `<SUPABASE_HOME or ~/.supabase>/profile` | plain text | when `--profile` and `SUPABASE_PROFILE` are both unset | -| `<profile>.yaml` | YAML | when `SUPABASE_PROFILE` or `--profile` points to a file | -| `<workdir>/supabase/.temp/project-ref` | plain text | when `--project-ref` and `SUPABASE_PROJECT_ID` are both unset | -| `<SUPABASE_HOME or ~/.supabase>/telemetry.json` | JSON | when present, before post-run telemetry state is refreshed | +| Path | Format | When | +| ----------------------------------------------- | ---------- | -------------------------------------------------------------- | +| `<SUPABASE_HOME or ~/.supabase>/access-token` | plain text | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | +| `<SUPABASE_HOME or ~/.supabase>/profile` | plain text | when `--profile` and `SUPABASE_PROFILE` are both unset | +| `<profile>.yaml` | YAML | when `SUPABASE_PROFILE` or `--profile` points to a file | +| `<workdir>/supabase/.temp/project-ref` | plain text | when neither `--remote=<ref>` nor `SUPABASE_PROJECT_ID` is set | +| `<SUPABASE_HOME or ~/.supabase>/telemetry.json` | JSON | when present, before post-run telemetry state is refreshed | ## Files Written @@ -19,11 +19,12 @@ ## API Routes -| Method | Path | Auth | Request body | Response (used fields) | -| -------- | ------------------------------------- | ------------ | ------------ | ----------------------------------------------------- | -| `DELETE` | `/v1/projects/{ref}/functions/{slug}` | Bearer token | none | none (a `404` fails with `FunctionNotFoundError`) | -| `GET` | `/v1/projects` | Bearer token | none | project picker options when no ref is supplied in TTY | -| `GET` | `/v1/projects/{ref}` | Bearer token | none | linked project metadata used by the post-run cache | +| Method | Path | Auth | Request body | Response (used fields) | +| -------- | ------------------------------------- | ------------ | ------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `DELETE` | `/v1/projects/{ref}/functions/{slug}` | Bearer token | none | none (a `404` fails with `FunctionNotFoundError`) | +| `GET` | `/v1/projects` | Bearer token | none | project picker options when no ref is supplied in TTY | +| `GET` | `/v1/projects/{ref}` | Bearer token | none | linked project metadata used by the post-run cache | +| `GET` | `/v1/projects/{parent}/branches` | Bearer token | none | `[{id, name, project_ref, is_default}]` — only for `--remote=<branch>` (or the deprecated `--project-ref=<branch>`), and for a bare `--remote` followed by a word that could name a branch; `{parent}` is `SUPABASE_PROJECT_ID`, else the linked project | ## Environment Variables @@ -33,7 +34,7 @@ | `SUPABASE_HOME` | overrides where `telemetry.json` and `profile` are read/written | no (defaults to `~/.supabase`) | | `SUPABASE_NO_KEYRING` | disables the OS keyring, forcing the access-token file fallback | no | | `SUPABASE_PROFILE` | select a built-in profile or YAML profile file with `api_url:` | no (falls back to `~/.supabase/profile` -> `supabase`) | -| `SUPABASE_PROJECT_ID` | provides the project ref when `--project-ref` is unset | no (falls back to `<workdir>/supabase/.temp/project-ref`) | +| `SUPABASE_PROJECT_ID` | provides the project ref when `--remote=<ref>` is not given | 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) | @@ -50,9 +51,9 @@ ## Telemetry Events Fired -| Event | When | Notable properties / groups | -| ---------------------- | ------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------- | -| `cli_command_executed` | post-run, success or failure (via wrapper) | `exit_code`, `duration_ms`, `flags` (`project-ref` recorded verbatim, matching `functions list`; every other flag redacted) | +| Event | When | Notable properties / groups | +| ---------------------- | ------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `cli_command_executed` | post-run, success or failure (via wrapper) | `exit_code`, `duration_ms`, `flags` (`remote` → the ref for `--remote=<ref>`, `true` for a bare `--remote`, redacted otherwise; the deprecated `project-ref` recorded verbatim when ref-shaped, matching `functions list`; every other flag redacted) | ## Output @@ -70,9 +71,12 @@ Prints the same structured success result, including `target`, and needs `--yes` ## Notes +- Targets the linked project unless `--remote=<ref>` is given; a bare `--remote` also targets the linked project. `--remote=<value>` is read as a project ref (20 lowercase letters), a branch of the linked project (by name or UUID, resolved through `GET /v1/projects/{parent}/branches`), or `true|yes|on|1` for the linked project. A database URL is rejected (`supabase functions delete` takes a project, not a database); `false|no|off|0`, `--no-remote` and an empty value fail with `Leave out --remote to use the linked project.` +- A bare `--remote` followed by a word that is a branch of the linked project fails before any other work with a `Did you mean --remote=<word>?` hint, since the word may have been meant as the flag's value. +- The deprecated `--project-ref` is an alias of `--remote=<ref>` and prints a deprecation warning on stderr that names the replacement; it cannot be combined with `--remote=<value>`. An empty `--project-ref` is rejected. - 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). +- Requires a linked project (`--remote=<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. diff --git a/apps/cli/src/commands/functions/download/SIDE_EFFECTS.md b/apps/cli/src/commands/functions/download/SIDE_EFFECTS.md index 2b9a9315cd..4c3c8de9b8 100644 --- a/apps/cli/src/commands/functions/download/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/functions/download/SIDE_EFFECTS.md @@ -7,7 +7,7 @@ | `<SUPABASE_HOME or ~/.supabase>/access-token` | plain text | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | | `<SUPABASE_HOME or ~/.supabase>/profile` | plain text | when `--profile` and `SUPABASE_PROFILE` are both unset | | `<profile>.yaml` | YAML | when `SUPABASE_PROFILE` or `--profile` points to a file | -| `<workdir>/supabase/.temp/project-ref` | plain text | when `--project-ref` and `SUPABASE_PROJECT_ID` are both unset | +| `<workdir>/supabase/.temp/project-ref` | plain text | when neither `--remote=<ref>` nor `SUPABASE_PROJECT_ID` is set | | `<workdir>/supabase/.temp/edge-runtime-version` | plain text | Read unconditionally by `resolveEdgeRuntimeVersionPin()` in the handler, before the shared downloader chooses `--use-api` vs Docker — only affects the resolved edge-runtime image tag on the Docker-unbundle path | | `<workdir>/supabase/{.env,.env.local,.env.<SUPABASE_ENV>,.env.<SUPABASE_ENV>.local}` and the same four at the project root | dotenv | Docker-unbundle path only, before resolving config.toml — project dotenv (`loadCliProjectEnvFiles`), merged into the `SUPABASE_*` overrides below and threaded into registry resolution | | `<workdir>/supabase/config.json` or `config.toml` | JSON / TOML | Read unconditionally after resolving the project ref, before checking `--use-api`/`--use-docker` or whether Docker is running — resolves `edge_runtime.deno_version` and `project_id` for the Docker-unbundle path. The effective values come from `CliConfigValues` (flag, shell, project dotenv, then config), so `config.json` is read like every other command; the Docker project id is `project_id` or, when unset, the workdir name. A malformed config fails here even on the `--use-api` invocation. Also runs the full `Config.Validate` pipeline (`resolveLocalConfigValues`, same one `start`/`stop`/`status` already use) — an invalid config (bad `db.major_version`, malformed auth hook, etc.) now fails the Docker-unbundle path up front, even for fields this command never otherwise reads. | @@ -25,13 +25,14 @@ ## API Routes -| Method | Path | Auth | Request body | Response (used fields) | -| ------ | ------------------------------------------ | ------------ | ------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `GET` | `/v1/projects/{ref}/functions` | Bearer token | none | function slugs, when downloading all | -| `GET` | `/v1/projects/{ref}/functions/{slug}` | Bearer token | none | entrypoint path, when absent from multipart metadata (server-side path only: `--use-api`, `--use-docker=false`, or Docker not running) | -| `GET` | `/v1/projects/{ref}/functions/{slug}/body` | Bearer token | none | Server-side path: multipart function source (`Accept: multipart/form-data`). Docker-unbundle: raw eszip bytes; a `Content-Encoding: br` response is decoded transparently by the HTTP transport, not by this command | -| `GET` | `/v1/projects` | Bearer token | none | project picker options when no ref is supplied in TTY | -| `GET` | `/v1/projects/{ref}` | Bearer token | none | linked project metadata used by the post-run cache | +| Method | Path | Auth | Request body | Response (used fields) | +| ------ | ------------------------------------------ | ------------ | ------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `GET` | `/v1/projects/{ref}/functions` | Bearer token | none | function slugs, when downloading all | +| `GET` | `/v1/projects/{ref}/functions/{slug}` | Bearer token | none | entrypoint path, when absent from multipart metadata (server-side path only: `--use-api`, `--use-docker=false`, or Docker not running) | +| `GET` | `/v1/projects/{ref}/functions/{slug}/body` | Bearer token | none | Server-side path: multipart function source (`Accept: multipart/form-data`). Docker-unbundle: raw eszip bytes; a `Content-Encoding: br` response is decoded transparently by the HTTP transport, not by this command | +| `GET` | `/v1/projects` | Bearer token | none | project picker options when no ref is supplied in TTY | +| `GET` | `/v1/projects/{ref}` | Bearer token | none | linked project metadata used by the post-run cache | +| `GET` | `/v1/projects/{parent}/branches` | Bearer token | none | `[{id, name, project_ref, is_default}]` — only for `--remote=<branch>` (or the deprecated `--project-ref=<branch>`), and for a bare `--remote` followed by a word that could name a branch; `{parent}` is `SUPABASE_PROJECT_ID`, else the linked project | ## Subprocesses @@ -54,7 +55,7 @@ in text mode, to stderr in machine-output modes (CLI-1546). | `SUPABASE_HOME` | overrides where `telemetry.json` and `profile` are read/written | no (defaults to `~/.supabase`) | | `SUPABASE_NO_KEYRING` | disables the OS keyring, forcing the access-token file fallback | no | | `SUPABASE_PROFILE` | select a built-in profile or YAML profile file with `api_url:` | no (falls back to `~/.supabase/profile` -> `supabase`) | -| `SUPABASE_PROJECT_ID` | provides the project ref when `--project-ref` is unset; Docker-unbundle path: also read from project dotenv now (previously ambient-shell-only), overriding `project_id` for Docker network/volume/label naming and `Config.Validate` | no (falls back to `<workdir>/supabase/.temp/project-ref`) | +| `SUPABASE_PROJECT_ID` | provides the project ref when `--remote=<ref>` is not given; Docker-unbundle path: also read from project dotenv now (previously ambient-shell-only), overriding `project_id` for Docker network/volume/label naming and `Config.Validate` | no (falls back to `<workdir>/supabase/.temp/project-ref`) | | `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) | | `SUPABASE_ENV` | Docker-unbundle path: selects environment-specific dotenv files (`.env.<env>.local`, `.env.<env>`) | no (defaults to `development`) | | `BITBUCKET_CLONE_DIR` | Docker-unbundle path: when defined (including an empty value), skips creating the named Deno-cache volume and omits its bind mount from the `docker run` command (Bitbucket's restricted Docker environment rejects both) | no | @@ -77,9 +78,9 @@ in text mode, to stderr in machine-output modes (CLI-1546). ## Telemetry Events Fired -| Event | When | Notable properties / groups | -| ---------------------- | ------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `cli_command_executed` | post-run, success or failure (via wrapper) | `exit_code`, `duration_ms`, `flags` (`project-ref` recorded verbatim, matching `functions list`/`delete`; `use-api`/`use-docker`/`legacy-bundle` also recorded verbatim since they are boolean flags; no flag on this command is currently redacted); a rejected `--legacy-bundle` fails with `error_fingerprint` ending in `:removed_flag` (`RemovedSurfaceError`) | +| Event | When | Notable properties / groups | +| ---------------------- | ------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `cli_command_executed` | post-run, success or failure (via wrapper) | `exit_code`, `duration_ms`, `flags` (`remote` → the ref for `--remote=<ref>`, `true` for a bare `--remote`, redacted otherwise; the deprecated `project-ref` recorded verbatim when ref-shaped, matching `functions list`/`delete`; `use-api`/`use-docker`/`legacy-bundle` also recorded verbatim since they are boolean flags; no flag on this command is currently redacted); a rejected `--legacy-bundle` fails with `error_fingerprint` ending in `:removed_flag` (`RemovedSurfaceError`) | ## Output @@ -102,11 +103,14 @@ Same envelope as `json` above (including on the Docker-unbundle path). ## Notes +- Targets the linked project unless `--remote=<ref>` is given; a bare `--remote` also targets the linked project. `--remote=<value>` is read as a project ref (20 lowercase letters), a branch of the linked project (by name or UUID, resolved through `GET /v1/projects/{parent}/branches`), or `true|yes|on|1` for the linked project. A database URL is rejected (`supabase functions download` takes a project, not a database); `false|no|off|0`, `--no-remote` and an empty value fail with `Leave out --remote to use the linked project.` +- A bare `--remote` followed by a word that is a branch of the linked project fails before any other work with a `Did you mean --remote=<word>?` hint, since the word may have been meant as the flag's value. +- The deprecated `--project-ref` is an alias of `--remote=<ref>` and prints a deprecation warning on stderr that names the replacement; it cannot be combined with `--remote=<value>`. An empty `--project-ref` is rejected. - **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, 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). +- Requires a linked project (`--remote=<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 diff --git a/apps/cli/src/commands/functions/list/SIDE_EFFECTS.md b/apps/cli/src/commands/functions/list/SIDE_EFFECTS.md index 7e6eb526ef..a86d60f043 100644 --- a/apps/cli/src/commands/functions/list/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/functions/list/SIDE_EFFECTS.md @@ -2,13 +2,13 @@ ## Files Read -| Path | Format | When | -| ----------------------------------------------- | ---------- | ------------------------------------------------------------- | -| `~/.supabase/access-token` | plain text | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | -| `~/.supabase/profile` | plain text | when `--profile` and `SUPABASE_PROFILE` are both unset | -| `<profile>.yaml` | YAML | when `SUPABASE_PROFILE` or `--profile` points to a file | -| `<workdir>/supabase/.temp/project-ref` | plain text | when `--project-ref` and `SUPABASE_PROJECT_ID` are both unset | -| `<SUPABASE_HOME or ~/.supabase>/telemetry.json` | JSON | when present, before post-run telemetry state is refreshed | +| Path | Format | When | +| ----------------------------------------------- | ---------- | -------------------------------------------------------------- | +| `~/.supabase/access-token` | plain text | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | +| `~/.supabase/profile` | plain text | when `--profile` and `SUPABASE_PROFILE` are both unset | +| `<profile>.yaml` | YAML | when `SUPABASE_PROFILE` or `--profile` points to a file | +| `<workdir>/supabase/.temp/project-ref` | plain text | when neither `--remote=<ref>` nor `SUPABASE_PROJECT_ID` is set | +| `<SUPABASE_HOME or ~/.supabase>/telemetry.json` | JSON | when present, before post-run telemetry state is refreshed | ## Files Written @@ -19,11 +19,12 @@ ## API Routes -| Method | Path | Auth | Request body | Response (used fields) | -| ------ | ------------------------------ | ------------ | ------------ | ------------------------------------------------------ | -| `GET` | `/v1/projects/{ref}/functions` | Bearer token | none | `[{id, name, slug, status, version, updated_at, ...}]` | -| `GET` | `/v1/projects` | Bearer token | none | project picker options when no ref is supplied in TTY | -| `GET` | `/v1/projects/{ref}` | Bearer token | none | linked project metadata used by the post-run cache | +| Method | Path | Auth | Request body | Response (used fields) | +| ------ | -------------------------------- | ------------ | ------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `GET` | `/v1/projects/{ref}/functions` | Bearer token | none | `[{id, name, slug, status, version, updated_at, ...}]` | +| `GET` | `/v1/projects` | Bearer token | none | project picker options when no ref is supplied in TTY | +| `GET` | `/v1/projects/{ref}` | Bearer token | none | linked project metadata used by the post-run cache | +| `GET` | `/v1/projects/{parent}/branches` | Bearer token | none | `[{id, name, project_ref, is_default}]` — only for `--remote=<branch>` (or the deprecated `--project-ref=<branch>`), and for a bare `--remote` followed by a word that could name a branch; `{parent}` is `SUPABASE_PROJECT_ID`, else the linked project | ## Environment Variables @@ -32,7 +33,7 @@ | `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring -> `~/.supabase/access-token`) | | `SUPABASE_HOME` | overrides where `telemetry.json` is read and written | no (defaults to `~/.supabase`) | | `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_PROJECT_ID` | provides the project ref when `--remote=<ref>` is not given | no (falls back to `<workdir>/supabase/.temp/project-ref`) | | `SUPABASE_WORKDIR` | sets `<workdir>` for local Supabase temp files | no (falls back to `--workdir` -> current working dir) | ## Exit Codes @@ -67,7 +68,10 @@ Prints the same structured success result, including `target`. ## Notes -- Requires a linked project (`--project-ref`, `SUPABASE_PROJECT_ID`, or `<workdir>/supabase/.temp/project-ref`). +- Targets the linked project unless `--remote=<ref>` is given; a bare `--remote` also targets the linked project. `--remote=<value>` is read as a project ref (20 lowercase letters), a branch of the linked project (by name or UUID, resolved through `GET /v1/projects/{parent}/branches`), or `true|yes|on|1` for the linked project. A database URL is rejected (`supabase functions list` takes a project, not a database); `false|no|off|0`, `--no-remote` and an empty value fail with `Leave out --remote to use the linked project.` +- A bare `--remote` followed by a word that is a branch of the linked project fails before any other work with a `Did you mean --remote=<word>?` hint, since the word may have been meant as the flag's value. +- The deprecated `--project-ref` is an alias of `--remote=<ref>` and prints a deprecation warning on stderr that names the replacement; it cannot be combined with `--remote=<value>`. An empty `--project-ref` is rejected. +- Requires a linked project (`--remote=<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: diff --git a/apps/cli/src/commands/pull/SIDE_EFFECTS.md b/apps/cli/src/commands/pull/SIDE_EFFECTS.md index ab8b45fb1d..24a379fbe8 100644 --- a/apps/cli/src/commands/pull/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/pull/SIDE_EFFECTS.md @@ -28,8 +28,8 @@ banner comes from the target resolution, not from the sub-steps); see | ---------------------------------------------------------- | ---------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `<workdir>/supabase/config.toml` or `config.json` | TOML/JSON | always, before any network call — `openConfigPullSource`, the SAME base load `config pull` itself opens (no `[remotes.*]` overlay yet); see `config/pull/SIDE_EFFECTS.md` for the loader's own rules | | `<workdir>/supabase/.env`, `.env.local` | dotenv | always, to resolve `env(VAR)` references inside `config.toml` (via the config step's loader) | -| `<workdir>/supabase/.temp/project-ref` | plain text | project-ref fallback (flag → `SUPABASE_PROJECT_ID` → this file) when `--project-ref` is absent; parent-ref candidate for a branch-name `--project-ref` (checked eagerly, before any spinner or branch lookup) | -| `<workdir>/supabase/.temp/linked-project.json` | JSON | parent-ref candidate for a branch-name `--project-ref` (same eager pre-check) | +| `<workdir>/supabase/.temp/project-ref` | plain text | project-ref fallback (`--remote=<ref>` → `SUPABASE_PROJECT_ID` → this file) when `--remote` is absent; parent-ref candidate for a branch-name `--remote` (checked eagerly, before any spinner or branch lookup) | +| `<workdir>/supabase/.temp/linked-project.json` | JSON | parent-ref candidate for a branch-name `--remote` (same eager pre-check) | | `~/.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 | @@ -97,8 +97,8 @@ each location's check is independent, so a spawn failure on one does not affect | # | Purpose | Method | Path | When | | --- | ----------------------- | ------ | ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------- | -| 1a | branch by UUID | GET | `/v1/branches/{branch_id}` | `--project-ref` is a branch UUID — resolved once, up front (ADR 0024 "resolve once"); needs no linked project | -| 1b | branch by name | GET | `/v1/projects/{ref}/branches/{name}` | `--project-ref` is a branch NAME (not a ref/UUID); the parent ref is resolved from local state first | +| 1a | branch by UUID | GET | `/v1/branches/{branch_id}` | `--remote` is a branch UUID — resolved once, up front (ADR 0024 "resolve once"); needs no linked project | +| 1b | branch by name | GET | `/v1/projects/{ref}/branches/{name}` | `--remote` is a branch NAME (not a ref/UUID); the parent ref is resolved from local state first | | 2 | effective remote config | GET | `/v2/projects/{ref}/config` | the config step's own fetch (`planConfigPullRun`) — guaranteed single by the plan/apply split, never repeated for a dry run or a real apply | Every other route belongs to a reused sub-step, called with the already-resolved `ref` (no @@ -152,7 +152,7 @@ Docker-dependent. | 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_PROJECT_ID` | project ref (`--remote=<ref>` → 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 | @@ -168,16 +168,16 @@ functions) — `pull` does not read or override any of these itself, it only sup ## Exit Codes -| Code | Condition | -| ---- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `0` | every step succeeded (any mix of `changed`/`unchanged`/`skipped`) | -| `0` | `--dry-run` (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`) | -| `1` | `supabase/config.toml`/`.json`, `supabase/migrations`, and/or `supabase/functions` has uncommitted or untracked changes and no human will read the warning (`PullUncommittedChangesError`) — see Git above | -| `1` | any one step fails (config, migration history, db, or functions) — every OTHER step still runs and is reported, but the process exits non-zero and re-fails with the FIRST original failure's own cause/classification | +| Code | Condition | +| ---- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `0` | every step succeeded (any mix of `changed`/`unchanged`/`skipped`) | +| `0` | `--dry-run` (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 `--remote` target-resolution failure (`PullBranchNotLinkedError` / `PullParentRefInvalidError` / `PullBranchNotFoundError` / `PullBranchNotReadyError` / `PullBranchResolveNetworkError` / `PullBranchResolveStatusError`) | +| `1` | `supabase/config.toml`/`.json`, `supabase/migrations`, and/or `supabase/functions` has uncommitted or untracked changes and no human will read the warning (`PullUncommittedChangesError`) — see Git above | +| `1` | any one step fails (config, migration history, db, or functions) — every OTHER step still runs and is reported, but the process exits non-zero and re-fails with the FIRST original failure's own cause/classification | **Difference from standalone `db pull`:** when the db step finds the remote already in sync with local migrations (`DbPullInSyncError`), `pull` reports it as `status: "unchanged"` @@ -186,9 +186,9 @@ condition. Every other step's failure still counts as a `pull` failure. ## Telemetry Events Fired -| Event | When | Notable properties / groups | -| ---------------------- | --------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `cli_command_executed` | post-run, success or failure (via `withCommandTelemetry`) | `exit_code`, `duration_ms`, `flags`; `--project-ref`'s value is only in `safeFlags` (logged verbatim) when it is ref-shaped (`PROJECT_REF_PATTERN`) — a user-created branch name is redacted | +| Event | When | Notable properties / groups | +| ---------------------- | --------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `cli_command_executed` | post-run, success or failure (via `withCommandTelemetry`) | `exit_code`, `duration_ms`, `flags`; `--remote=<ref>` is logged as the ref, a bare `--remote` as `true`, and a branch name or UUID is redacted; the deprecated `--project-ref`'s value is only in `safeFlags` (logged verbatim) when it is ref-shaped (`PROJECT_REF_PATTERN`) — a user-created branch name is redacted | Exactly one `cli_command_executed` fires per `pull` invocation. None of the four reused steps fire their own `cli_command_executed` (or any other event) when run through `pull` — each is called as a @@ -240,7 +240,7 @@ Shape (`pull.format.ts`): "schema_version": 1, "target": { // the command target (docs/command-target.md), plus: - "branch": "...", // only when --project-ref named a branch + "branch": "...", // only when --remote named a branch }, "dry_run": false, "confirmed": true, @@ -293,6 +293,8 @@ the -o/--output flag is not supported by pull; use --output-format json|stream-j ## Notes +- `--remote=<value>` selects the target: a project ref (20 lowercase letters), or the name or UUID of a branch of the linked project (resolved through `GET /v1/projects/{ref}/branches/{name}` or `GET /v1/branches/{id}`). A bare `--remote` and no selector both target the linked project. A database URL is rejected (`supabase pull` takes a project, not a database); `false|no|off|0`, `--no-remote` and an empty value fail with `Leave out --remote to use the linked project.` +- The deprecated `--project-ref` is an alias of `--remote=<ref>` (it still accepts a branch name or UUID) and prints a deprecation warning on stderr that names the replacement; it cannot be combined with `--remote=<value>`. An empty `--project-ref` is rejected. - **Steps run sequentially, not in parallel**, refining ADR 0004's "runs all sub-syncs in parallel" aspiration for `pull` specifically — see [ADR 0024](../../../../../docs/adr/0024-top-level-pull-orchestration.md) for the two real data From 32cadf02c0ef727ccc0a47e456db9e22442744ad Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Mon, 19 Oct 2026 12:14:03 +0100 Subject: [PATCH 129/291] docs(cli): describe gen types through --remote and its aliases --- .../src/commands/gen/types/SIDE_EFFECTS.md | 302 +++++++++--------- 1 file changed, 158 insertions(+), 144 deletions(-) diff --git a/apps/cli/src/commands/gen/types/SIDE_EFFECTS.md b/apps/cli/src/commands/gen/types/SIDE_EFFECTS.md index 35752a241f..b5f378c9ae 100644 --- a/apps/cli/src/commands/gen/types/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/gen/types/SIDE_EFFECTS.md @@ -1,124 +1,126 @@ # `supabase gen types` -Generates PostgREST client types against a direct PostgreSQL connection. -Introspection runs in-process via `@supabase/postgrest-typegen`; the language -comes from the `@supabase/typegen` registry, which lists every `--lang` value and -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. 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`. +Generates PostgREST client types against a direct PostgreSQL connection. Introspection runs +in-process via `@supabase/postgrest-typegen`; the language comes from the `@supabase/typegen` +registry, which lists every `--lang` value and 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. The command is `selectable` and never asks for +hosted-mutation consent. With no selector it targets the local database; `--remote` targets a remote +project: bare for the linked project, `--remote=<ref>`, `--remote=<branch>` for a branch of the +linked project, or `--remote=<db-url>` for a database URL. TypeScript output for a remote project +still comes from the Management API; every other language and target (including local and a database +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_*`. `--linked`, `--project-ref`, `--project-id`, `--db-url` and +`--local` are deprecated aliases. ## Files Read -| 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` | +| Path | Format | When | +| ----------------------------------------------------------------------- | ------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `<workdir>/supabase/.temp/project-ref` | plain text | a remote project: resolves the ref (bare `--remote` with no `SUPABASE_PROJECT_ID`) and tells whether it is the linked project; with no selector, only to word the linked-checkout hint | +| `<workdir>/supabase/.temp/linked-project.json` | JSON | to name the project in the `Target:` banner and the linked-checkout hint, and as the parent project for `--remote=<branch>` | +| `~/.supabase/access-token` | plain text | when `SUPABASE_ACCESS_TOKEN` unset and the target is a remote project (or a branch is looked up) | +| `<workdir>/supabase/config.toml` or `config.json` | TOML/JSON | when selecting schemas on a remote project (a matched `[remotes.<ref>]` block over the base) or with `--remote=<db-url>` (`--schema` omitted; any `--schema` skips the schema load entirely). The local target 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 `--remote=<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 | the local target; resolves the same nested environment overrides as the CLI. Every `--remote=<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 | `--remote=<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 | the local target only, when `db.major_version > 14` — forces v9 compat if the tag contains `v9` | ## Files Written -| 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 | +| Path | Format | When | +| ---------------------------------------------- | ------ | ----------------------------------------------------------------------------------------------------------------------------------------- | +| `<workdir>/supabase/.temp/linked-project.json` | JSON | a remote project (`--remote`, `--remote=<ref>`, `--remote=<branch>` or an alias), via `LinkedProjectCache.cache`, when the file is absent | -`--local` and `--db-url` write no files. +The local target and `--remote=<db-url>` write no files. ## API Routes -| Method | Path | Auth | Request body | Response (used fields) | -| -------- | ------------------------------------------- | ------------ | ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ | -| `GET` | `/v1/projects/{ref}/types/typescript` | Bearer token | none | TypeScript type definitions text | -| `GET` | `/v1/projects/{ref}` | Bearer token | none | non-TypeScript lookup (`404` ⇒ branch ref); also the linked-project cache fill on every project-ref path | -| `GET` | `/v1/branches/{branch_id_or_ref}` | Bearer token | none | `db_host`, `db_port`, `db_user`, `db_pass` | -| `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` | -| `POST` | `/v1/projects/{ref}/network-bans/retrieve` | Bearer token | none | `banned_ipv4_addresses` | -| `DELETE` | `/v1/projects/{ref}/network-bans` | Bearer token | `{ ipv4_addresses, requester_ip: false }` | none | -| `GET` | `/v1/projects` | Bearer token | none | project list for the ref prompt: no ref from a flag, `SUPABASE_PROJECT_ID` or `.temp/project-ref`, and an interactive terminal | - -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. 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). The network-ban calls happen only on project refs, from the third attempt -to connect as the temporary login role, and never for preview branches. `--local` and -`--db-url` do not call the Management API. +| Method | Path | Auth | Request body | Response (used fields) | +| -------- | ------------------------------------------- | ------------ | ----------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `GET` | `/v1/projects/{ref}/types/typescript` | Bearer token | none | TypeScript type definitions text | +| `GET` | `/v1/projects/{ref}` | Bearer token | none | non-TypeScript lookup (`404` ⇒ branch ref); also the linked-project cache fill on every remote project | +| `GET` | `/v1/branches/{branch_id_or_ref}` | Bearer token | none | `db_host`, `db_port`, `db_user`, `db_pass` | +| `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` | +| `POST` | `/v1/projects/{ref}/network-bans/retrieve` | Bearer token | none | `banned_ipv4_addresses` | +| `DELETE` | `/v1/projects/{ref}/network-bans` | Bearer token | `{ ipv4_addresses, requester_ip: false }` | none | +| `GET` | `/v1/projects` | Bearer token | none | project list for the ref prompt: bare `--remote` with no ref from `SUPABASE_PROJECT_ID` or `.temp/project-ref`, with stdin and stdout TTYs and a non-JSON output format | +| `GET` | `/v1/projects/{parent}/branches` | Bearer token | none | `[{id, name, project_ref, is_default}]` — only for `--remote=<branch>` (or an alias given a branch name), and for a bare `--remote` followed by a word that could name a branch; `{parent}` is `SUPABASE_PROJECT_ID`, else the linked project | + +The TypeScript endpoint is called for a remote project (`--remote`, `--remote=<ref>`, +`--remote=<branch>` or an alias) when `--lang=typescript`. For other languages on a remote project +(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, remote project 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. A ref given by flag (`--remote=<ref>`, `--remote=<branch>` or an alias), or one +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). The network-ban calls happen only on project refs, from the third attempt +to connect as the temporary login role, and never for preview branches. `--remote=<branch>` resolves +the name to that branch's own project ref through the branch list before any of these calls. The +local target and `--remote=<db-url>` do not call the Management API. ## Subprocesses -| Command | When | Purpose | -| ------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- | -| `docker`/`podman container inspect supabase_db_<project_id>` | `--local`, only when the selected backend is the legacy Docker Compose stack (`[experimental].stack` off) | assert `supabase start` is running, against the shell-selected daemon (a project `.env*` is not forwarded to the spawn) | -| `dart run supabase_typegen --output -` | `--lang dart` on every target, in the directory the command was run from | generate the types from the `GeneratorMetadata` JSON on stdin | +| Command | When | Purpose | +| ------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- | +| `docker`/`podman container inspect supabase_db_<project_id>` | the local target, only when the selected backend is the legacy Docker Compose stack (`[experimental].stack` off) | assert `supabase start` is running, against the shell-selected daemon (a project `.env*` is not forwarded to the spawn) | +| `dart run supabase_typegen --output -` | `--lang dart` on every target, in the directory the command was run from | generate the types from the `GeneratorMetadata` JSON on stdin | Any other `--lang` whose registry entry is out-of-process runs its own tool the same way: stdout is the output, stderr is folded into the error on failure. The tool inherits the CLI's entire environment, so a `PUB_CACHE` or proxy setting reaches it unchanged. Introspection and the in-process languages never shell out. On a native or Docker-based managed stack -(`[experimental].stack` on), `--local` never inspects a container; it +(`[experimental].stack` on), the local target never inspects a container; it resolves the stack's database connection through `DbConfigResolver` the same -way `--db-url` does. +way `--remote=<db-url>` does. ## Environment Variables -| 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) | +| Variable | Purpose | Required? | +| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `SUPABASE_ACCESS_TOKEN` | auth token for a remote project | no (falls back to keyring → `~/.supabase/access-token`) | +| `SUPABASE_PROJECT_ID` | local Docker container and network project ID; the project ref of a bare `--remote`, and the parent project for `--remote=<branch>` | 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, remote project, `--remote=<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 `--remote=<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 `--remote=<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 remote project (shell env > project `.env*` > config); withheld, with a 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).` line, when the target differs from `.temp/project-ref`, which mints a temporary login role instead. **Not read by the local target**, 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` | 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`) | +| Code | Condition | +| ---- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `0` | success — types printed to stdout | +| `1` | conflicting target flags (see Notes): two selectors, `--project-ref` or `--project-id` combined with `--local`, `--db-url` or `--remote=<value>`, or `--project-id` combined with `--project-ref` (`TargetSelectorConflictError`, `TargetProjectRefConflictError`) | +| `1` | an empty `--db-url`, `--project-ref` or `--project-id` (`TargetEmptyValueError`), `--remote` switched off or given an empty value, or a bare `--remote` followed by a word that is a branch of the linked project (`TargetRemoteAmbiguousError`) | +| `1` | `--remote=<branch>` matches no branch of the linked project (`LinkBranchNotFoundError`, lists the branches that exist) | +| `1` | a remote project selector (`--remote`, `--remote=<ref>`, `--remote=<branch>`, `--linked`, `--project-ref` or `--project-id`) combined with `--postgrest-v9-compat`, `--query-timeout` or a language flag (`GenTypesFlagUsageError`); `--remote=<db-url>` is outside the group | +| `1` | `--postgrest-v9-compat` used without `--remote=<db-url>` or `--db-url` (`GenTypesFlagUsageError`) | +| `1` | a positional language other than `typescript` passed without `--lang` (`GenTypesFlagUsageError`) | +| `1` | invalid `--query-timeout` duration or invalid database URL | +| `1` | `--network-id` set (`GenTypesNetworkIdUnsupportedError`) — generation runs in-process and cannot join a Docker network | +| `1` | `supabase start` not running (local target on the legacy Docker Compose stack) (`GenTypesLocalDbNotRunningError`) | +| `1` | the local target 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 @@ -127,11 +129,12 @@ 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. On the legacy Docker Compose -stack the banner prints before the running check, so a stopped stack still shows the chosen +Postgres host) and `Connecting to …` for database introspection. With the local default in a +linked directory the banner ends with `· default; pass --remote for <project>`. 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 and will be removed in v4; use --project-ref instead.` on stderr. +Each deprecated selector additionally prints its warning on stderr, for example +`--project-id is deprecated and will be removed in v4. Use --remote instead: supabase gen types --remote=<ref>`. ### `--output-format json` @@ -144,51 +147,63 @@ Not applicable. ## Notes - TypeScript generated in-process is unformatted. stderr suggests `npx oxfmt <generated-file.ts>`. - 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`; `--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` (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 + TypeScript for a remote project still comes from the Management API. +- **Target.** With no selector the command runs against the local database; when the directory is + linked the local `Target:` banner ends with `· default; pass --remote for <project>`. `--remote` + targets the linked project (`SUPABASE_PROJECT_ID`, else `.temp/project-ref`). `--remote=<value>` + is read as a project ref (20 lowercase letters), a database URL (`postgres://` or + `postgresql://`), `true|yes|on|1` (the linked project), or otherwise the name of a branch of the + linked project, which resolves to that branch's own project. `false|no|off|0`, `--no-remote` and + an empty value fail with `Leave out --remote to target the local database.` A bare `--remote` + followed by a word that is a branch of the linked project fails with a + `Did you mean --remote=<word>?` hint, since the word may have been meant as the flag's value. The + resolved target is announced on stderr (`Target: …`) in every output format. +- **Deprecated selectors.** `--linked`, `--project-ref`, `--project-id`, `--db-url` and `--local` + are hidden aliases that still select their target and print a warning on stderr naming the + replacement, for example + `--linked is deprecated and will be removed in v4. Use --remote instead: supabase gen types --remote`, + `--project-id is deprecated and will be removed in v4. Use --remote instead: supabase gen types --remote=<ref>` + and `--local isn't needed (local is the default) and will be removed in v4: supabase gen types`; a + database URL is never echoed. Two selectors together fail + (`Only one of --remote, --linked, --db-url and --local can be set; got …`), `--project-ref` and + `--project-id` cannot be combined with `--local`, `--db-url` or `--remote=<value>`, an empty + `--db-url`, `--project-ref` or `--project-id` is rejected, and `--linked=false` selects nothing. + `--project-id` is an alias of `--project-ref`; passing both fails with + `--project-id is a deprecated alias of --project-ref; pass only one of them.` +- **Remote project flag groups.** `--remote` (bare, or with a ref or branch), `--linked`, + `--project-ref` and `--project-id` count as one member of each group + `[remote linked project-ref <flag>]`, where `<flag>` is `postgrest-v9-compat`, `query-timeout` or a + language flag (`swift-access-control`). Passing the selector together with any of them fails with + `if any flags in the group [remote linked project-ref <flag>] are set none of the others can be; [<set flags, sorted>] were all set`, + reporting the first violated group in that order. `--remote=<db-url>` and `--db-url` are not + members, so they accept all three. +- With the local target, 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 - a linked target) > default. The whole config is decoded up front where it is loaded, + a remote project) > 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. + host-reachable `--remote=<db-url>` instead. - **TLS.** There is no SSL probe on any path. - - A linked target (including a resolved preview branch) connects with `sslmode=require` and the bundled Supabase CA pinned + - A remote project (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 + - `--remote=<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. - The local target uses no TLS. -- **Project-ref language generation:** - `--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 +- **Remote project language generation.** `--lang` accepts `typescript` (default), `go`, `swift`, or + `python`. A remote project 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 database URL is required. Do not revert this to a hard error. The flag groups + block `--swift-access-control`, `--query-timeout` and `--postgrest-v9-compat` whenever a remote + project selector is passed, so a remote project always generates with defaults (`internal` access + control, one-to-one detection on, 15s timeout). Use `--remote=<db-url>` for 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: @@ -200,35 +215,34 @@ Not applicable. 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 TypeScript is still generated server-side by the + - TypeScript for a remote project 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-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. + `--swift-access-control`, which accepts `internal` (default), `public`, `private` or `package`. + Every such flag is mutually exclusive with a remote project selector; on the local and + `--remote=<db-url>` 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 one-to-one relationship detection off (the registry's `detect-one-to-one-relationships` - consumer option), must be used together with `--db-url` (error: - `--postgrest-v9-compat must used together with --db-url` — note the typo, preserved + consumer option), must be used together with `--remote=<db-url>` (or `--db-url`) (error: + `--postgrest-v9-compat must used together with --remote=<db-url>` — note the typo, preserved intentionally), and prints this line on stderr when passed: ``` Flag --postgrest-v9-compat has been deprecated, PostgREST 9 reached end of life; the flag still disables one-to-one relationship detection. ``` - `--local` still forces detection off when the local PostgREST image tag contains `v9`. + The local target still forces detection off when the local PostgREST image tag contains `v9`. -- `--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-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). +- `--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 a remote project selector; on the local and `--remote=<db-url>` paths it is + accepted and applied for every language that connects to the database. - 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 linked target resolves a - project ref — it's skipped for the local and `--db-url` targets. +- The linked-project telemetry cache is written only when a remote project resolves a project ref — + it's skipped for the local target and `--remote=<db-url>`. +- Telemetry records a `--project-ref` or `--project-id` value only when it is a 20-letter project + ref; a branch name is never recorded. From bc9f15f167d7841014fbc2f4cbcaf4ffff68c57d Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Mon, 19 Oct 2026 12:17:02 +0100 Subject: [PATCH 130/291] refactor(cli): resolve --remote=<branch> in the db commands Adds the branch lookup permissions and platform API factory to every db command that selects a target, lets the pooler fallback permission follow --remote, and points the removed db remote commands and follow-up hints at --remote forms. --- .../db-bootstrap/reset-local-database.ts | 4 ++-- apps/cli/src/command-internal/db-config.layer.ts | 7 +++++++ apps/cli/src/command-internal/test-db.layers.ts | 1 + .../cli/src/commands/db/advisors/advisors.command.ts | 2 ++ apps/cli/src/commands/db/diff/diff.command.ts | 12 +++++++++++- apps/cli/src/commands/db/dump/dump.command.ts | 2 ++ apps/cli/src/commands/db/dump/dump.handler.ts | 2 +- apps/cli/src/commands/db/dump/dump.layers.ts | 1 + apps/cli/src/commands/db/lint/lint.command.ts | 2 ++ apps/cli/src/commands/db/lint/lint.layers.ts | 1 + apps/cli/src/commands/db/pull/pull.command.ts | 2 ++ apps/cli/src/commands/db/push/push.command.ts | 2 ++ apps/cli/src/commands/db/push/push.layers.ts | 1 + apps/cli/src/commands/db/query/query.command.ts | 4 +++- .../commands/db/remote/changes/changes.command.ts | 8 ++++---- .../src/commands/db/remote/commit/commit.command.ts | 2 +- apps/cli/src/commands/db/reset/reset.command.ts | 2 ++ .../db/schema/declarative/declarative.flow.ts | 4 ++-- .../schema/declarative/declarative.flow.unit.test.ts | 6 +++--- .../schema/declarative/generate/generate.command.ts | 2 ++ apps/cli/src/commands/db/test/test.command.ts | 5 +++-- apps/cli/src/commands/test/db/db.command.ts | 5 +++-- 22 files changed, 58 insertions(+), 19 deletions(-) diff --git a/apps/cli/src/command-internal/db-bootstrap/reset-local-database.ts b/apps/cli/src/command-internal/db-bootstrap/reset-local-database.ts index 6d36e9a426..2e20184b23 100644 --- a/apps/cli/src/command-internal/db-bootstrap/reset-local-database.ts +++ b/apps/cli/src/command-internal/db-bootstrap/reset-local-database.ts @@ -212,7 +212,7 @@ export const resetLocalDatabase = Effect.fn("DbBootstrap.resetLocalDatabase")(fu // waits for `ready`. See docs/stack-commands.md#storage-and-bucket-seeding. const skipSeeding = ( reason: string, - nextStep = "Run supabase seed buckets --local once Storage is available.", + nextStep = "Run supabase seed buckets once Storage is available.", ) => output.raw( `${yellow("WARNING:")} skipped seeding storage buckets: ${reason} ${nextStep}\n`, @@ -230,7 +230,7 @@ export const resetLocalDatabase = Effect.fn("DbBootstrap.resetLocalDatabase")(fu yield* skipSeeding( describeStorageCapability(capability), decision === "disabled" - ? "Set [storage] enabled = true in supabase/config.toml, run supabase stack stop followed by supabase stack start without -x storage, then supabase seed buckets --local." + ? "Set [storage] enabled = true in supabase/config.toml, run supabase stack stop followed by supabase stack start without -x storage, then supabase seed buckets." : undefined, ); return; diff --git a/apps/cli/src/command-internal/db-config.layer.ts b/apps/cli/src/command-internal/db-config.layer.ts index fb76d33369..51f2e67040 100644 --- a/apps/cli/src/command-internal/db-config.layer.ts +++ b/apps/cli/src/command-internal/db-config.layer.ts @@ -65,6 +65,13 @@ 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: "remote" }], + context: + "--remote names a project or branch other than the linked one, 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", diff --git a/apps/cli/src/command-internal/test-db.layers.ts b/apps/cli/src/command-internal/test-db.layers.ts index 90f826b13e..5896566793 100644 --- a/apps/cli/src/command-internal/test-db.layers.ts +++ b/apps/cli/src/command-internal/test-db.layers.ts @@ -62,6 +62,7 @@ export const testDbRuntimeLayer = (commandPath: ReadonlyArray<string>) => dockerRunLayer, cliSettings, projectRef, + platformApiFactory, // 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 c6ee162f7e..6a513d086a 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 { branchResolution } from "../../../command-internal/target/branch-resolution.ts"; import { targetFlags } from "../../../command-internal/target/target-flags.ts"; import { withTargetPolicy } from "../../../command-internal/target/target-policy.ts"; @@ -68,6 +69,7 @@ export const dbAdvisorsCommand = Command.make("advisors", config).pipe( }, dbConfig, withCondition(linkedProjectCache, { dbTarget: ["linked"] }), + branchResolution, ), { default: "local" }, ), diff --git a/apps/cli/src/commands/db/diff/diff.command.ts b/apps/cli/src/commands/db/diff/diff.command.ts index 609400e098..1e754b25be 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 { branchResolution } from "../../../command-internal/target/branch-resolution.ts"; import { targetFlags } from "../../../command-internal/target/target-flags.ts"; import { withTargetPolicy } from "../../../command-internal/target/target-policy.ts"; @@ -94,6 +95,14 @@ export type DbDiffFlags = CliCommand.Command.Config.Infer<typeof config>; const explicitLinkedSide = (flag: "from" | "to") => withCondition(compose(dbConfigLinked, linkedProjectCache), { flag, oneOf: ["linked", "remote"] }); +const explicitBranchSide = (flag: "from" | "to") => + ({ + operationId: "v1-list-all-branches", + kind: "required", + when: [{ flag, present: true }], + context: `--${flag} is a branch name or UUID, not local, migrations, remote, a project ref or a URL`, + }) as const; + export const dbDiffCommand = Command.make("diff", config).pipe( Command.withDescription( "Compares a shadow built from supabase/migrations with a live database (the local database by default, or the one --remote selects). Declarative files under supabase/schemas are not part of this baseline. Output is printed by default; in normal mode, -f names and saves the complete diff as a migration and does not filter objects. Explicit --from/--to output is flattened review SQL, not a portable apply script.", @@ -103,7 +112,7 @@ export const dbDiffCommand = Command.make("diff", config).pipe( withDbTarget( compose( { - operations: [], + operations: [explicitBranchSide("from"), explicitBranchSide("to")], noApiEffectFlags: [ "use-migra", "use-pgadmin", @@ -119,6 +128,7 @@ export const dbDiffCommand = Command.make("diff", config).pipe( withCondition(linkedProjectCache, { dbTarget: ["linked"] }), explicitLinkedSide("from"), explicitLinkedSide("to"), + branchResolution, ), { default: "local" }, ), diff --git a/apps/cli/src/commands/db/dump/dump.command.ts b/apps/cli/src/commands/db/dump/dump.command.ts index e26fe5c413..3cc49812db 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 { branchResolution } from "../../../command-internal/target/branch-resolution.ts"; import { targetFlags } from "../../../command-internal/target/target-flags.ts"; import { withTargetPolicy } from "../../../command-internal/target/target-policy.ts"; @@ -120,6 +121,7 @@ export const dbDumpCommand = Command.make("dump", config).pipe( dbConfig, dbConfigPoolerFallback, withCondition(linkedProjectCache, { dbTarget: ["linked"] }), + branchResolution, ), { default: "local" }, ), diff --git a/apps/cli/src/commands/db/dump/dump.handler.ts b/apps/cli/src/commands/db/dump/dump.handler.ts index 946f941947..2ff73b6411 100644 --- a/apps/cli/src/commands/db/dump/dump.handler.ts +++ b/apps/cli/src/commands/db/dump/dump.handler.ts @@ -133,7 +133,7 @@ export const dbDump = Effect.fn("db.dump")(function* (flags: DbDumpFlags) { } } - // 3. Resolve the target (a selector flag, else the workspace target) and connect to it. + // 3. Resolve the target (`--remote` or an alias, else local) and connect to it. const target = yield* resolveTarget({ remote: flags.remote, local: flags.local, diff --git a/apps/cli/src/commands/db/dump/dump.layers.ts b/apps/cli/src/commands/db/dump/dump.layers.ts index d2004b4711..12f2e4771f 100644 --- a/apps/cli/src/commands/db/dump/dump.layers.ts +++ b/apps/cli/src/commands/db/dump/dump.layers.ts @@ -74,6 +74,7 @@ export const dbDumpRuntimeLayer = Layer.mergeAll( cliSettings, projectRef, linkedProjectCache, + platformApiFactory, identityStitchLayer, telemetryStateLayer, stackApiLayer, diff --git a/apps/cli/src/commands/db/lint/lint.command.ts b/apps/cli/src/commands/db/lint/lint.command.ts index b1b1720418..a4f7568f6e 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 { branchResolution } from "../../../command-internal/target/branch-resolution.ts"; import { targetFlags } from "../../../command-internal/target/target-flags.ts"; import { withTargetPolicy } from "../../../command-internal/target/target-policy.ts"; @@ -50,6 +51,7 @@ export const dbLintCommand = Command.make("lint", config).pipe( { operations: [], noApiEffectFlags: ["schema", "level", "fail-on"] }, dbConfig, withCondition(linkedProjectCache, { dbTarget: ["linked"] }), + branchResolution, ), { default: "local" }, ), diff --git a/apps/cli/src/commands/db/lint/lint.layers.ts b/apps/cli/src/commands/db/lint/lint.layers.ts index f3c5c291cc..c0f1e23425 100644 --- a/apps/cli/src/commands/db/lint/lint.layers.ts +++ b/apps/cli/src/commands/db/lint/lint.layers.ts @@ -69,6 +69,7 @@ export const dbLintRuntimeLayer = Layer.mergeAll( credentials, projectRef, linkedProjectCache, + platformApiFactory, // Exposed at top level so `withCommandTelemetry` can read `stitchedDistinctId()`. identityStitchLayer, telemetryStateLayer, diff --git a/apps/cli/src/commands/db/pull/pull.command.ts b/apps/cli/src/commands/db/pull/pull.command.ts index df54b92755..25487a848a 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 { branchResolution } from "../../../command-internal/target/branch-resolution.ts"; import { targetFlags } from "../../../command-internal/target/target-flags.ts"; import { withTargetPolicy } from "../../../command-internal/target/target-policy.ts"; @@ -125,6 +126,7 @@ export const dbPullCommand = Command.make("pull", config).pipe( dbConfig, dbConfigPoolerFallback, withCondition(linkedProjectCache, { dbTarget: ["linked"] }), + branchResolution, ), { default: "local" }, ), diff --git a/apps/cli/src/commands/db/push/push.command.ts b/apps/cli/src/commands/db/push/push.command.ts index 10d79133a3..bf672167d2 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 { branchResolution } from "../../../command-internal/target/branch-resolution.ts"; import { targetFlags } from "../../../command-internal/target/target-flags.ts"; import { withTargetPolicy } from "../../../command-internal/target/target-policy.ts"; @@ -73,6 +74,7 @@ export const dbPushCommand = Command.make("push", config).pipe( }, dbConfig, withCondition(linkedProjectCache, { dbTarget: ["linked"] }), + branchResolution, ), { default: "local" }, ), diff --git a/apps/cli/src/commands/db/push/push.layers.ts b/apps/cli/src/commands/db/push/push.layers.ts index 8845ad9a70..793cad5d75 100644 --- a/apps/cli/src/commands/db/push/push.layers.ts +++ b/apps/cli/src/commands/db/push/push.layers.ts @@ -67,6 +67,7 @@ export const dbPushRuntimeLayer = Layer.mergeAll( credentials, projectRef, linkedProjectCache, + platformApiFactory, identityStitchLayer, telemetryStateLayer, commandRuntimeLayer(["db", "push"]), diff --git a/apps/cli/src/commands/db/query/query.command.ts b/apps/cli/src/commands/db/query/query.command.ts index ebf4b97615..ed26a293f5 100644 --- a/apps/cli/src/commands/db/query/query.command.ts +++ b/apps/cli/src/commands/db/query/query.command.ts @@ -15,6 +15,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 { branchResolution } from "../../../command-internal/target/branch-resolution.ts"; import { targetFlags } from "../../../command-internal/target/target-flags.ts"; import { withTargetPolicy } from "../../../command-internal/target/target-policy.ts"; @@ -55,9 +56,10 @@ export const dbQueryCommand = Command.make("query", config).pipe( operations: [ { operationId: "v1-run-a-query", kind: "required", when: [{ dbTarget: ["linked"] }] }, ], - noApiEffectFlags: ["file", "project-ref", "read-only"], + noApiEffectFlags: ["file", "read-only"], }, withCondition(linkedProjectCache, { dbTarget: ["linked"] }), + branchResolution, ), { default: "local" }, ), 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 d554ac340e..5bdb2d1254 100644 --- a/apps/cli/src/commands/db/remote/changes/changes.command.ts +++ b/apps/cli/src/commands/db/remote/changes/changes.command.ts @@ -27,20 +27,20 @@ const config = { name: "password", alias: "p", description: - "Database password for the linked project (--linked/--project-ref). Not used with --local or --db-url.", + "Database password for the project selected with --remote. Not used for the local database or a database URL.", }), } as const; export const dbRemoteChangesCommand = Command.make("changes", config).pipe( - Command.withDescription("Removed: use `supabase db diff --linked` instead."), - Command.withShortDescription("Removed: use `db diff --linked` instead"), + Command.withDescription("Removed: use `supabase db diff --remote` instead."), + Command.withShortDescription("Removed: use `db diff --remote` instead"), // Removed: fails before any Management API call, so no flag changes what it calls. withPermissions({ operations: [], noApiEffectFlags: ["db-url", "linked", "password", "schema"], }), Command.withHandler(() => - removedCommand("Use `supabase db diff --linked` instead.").pipe( + removedCommand("Use `supabase db diff --remote` instead.").pipe( withCommandTelemetry(), withJsonErrorHandling, ), 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 cf467fbb1a..debcc0a695 100644 --- a/apps/cli/src/commands/db/remote/commit/commit.command.ts +++ b/apps/cli/src/commands/db/remote/commit/commit.command.ts @@ -27,7 +27,7 @@ const config = { name: "password", alias: "p", description: - "Database password for the linked project (--linked/--project-ref). Not used with --local or --db-url.", + "Database password for the project selected with --remote. Not used for the local database or a database URL.", }), } as const; diff --git a/apps/cli/src/commands/db/reset/reset.command.ts b/apps/cli/src/commands/db/reset/reset.command.ts index 948dbed59f..f77b0d0900 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 { branchResolution } from "../../../command-internal/target/branch-resolution.ts"; import { targetFlags } from "../../../command-internal/target/target-flags.ts"; import { withTargetPolicy } from "../../../command-internal/target/target-policy.ts"; @@ -53,6 +54,7 @@ export const dbResetCommand = Command.make("reset", config).pipe( { operations: [], noApiEffectFlags: ["no-seed", "sql-paths", "version", "last"] }, dbConfig, withCondition(linkedProjectCache, { dbTarget: ["linked"] }), + branchResolution, ), { default: "local" }, ), diff --git a/apps/cli/src/commands/db/schema/declarative/declarative.flow.ts b/apps/cli/src/commands/db/schema/declarative/declarative.flow.ts index 239c3c55fc..cb0372a8c9 100644 --- a/apps/cli/src/commands/db/schema/declarative/declarative.flow.ts +++ b/apps/cli/src/commands/db/schema/declarative/declarative.flow.ts @@ -306,10 +306,10 @@ function stagedExportCommands(context: StagedExportContext): ReadonlyArray<strin const generateCommand = context.platform === "windows" ? [ - ` supabase db schema declarative generate --local --overwrite --output-dir ${staged}${schemas} --experimental`, + ` supabase db schema declarative generate --overwrite --output-dir ${staged}${schemas} --experimental`, ] : [ - " supabase db schema declarative generate --local --overwrite \\", + " supabase db schema declarative generate --overwrite \\", ` --output-dir ${staged}${schemas} --experimental`, ]; return [ diff --git a/apps/cli/src/commands/db/schema/declarative/declarative.flow.unit.test.ts b/apps/cli/src/commands/db/schema/declarative/declarative.flow.unit.test.ts index 395c05bca8..078c34e0b8 100644 --- a/apps/cli/src/commands/db/schema/declarative/declarative.flow.unit.test.ts +++ b/apps/cli/src/commands/db/schema/declarative/declarative.flow.unit.test.ts @@ -113,7 +113,7 @@ describe("classifyDeclarativeCompatibilityGap", () => { context: { declarativeDir: "supabase/schemas", schema: [], platform: "posix" }, }); expect(suggestion).toContain( - "generate --local --overwrite \\\n --output-dir supabase/schemas-next --experimental", + "generate --overwrite \\\n --output-dir supabase/schemas-next --experimental", ); }); @@ -172,7 +172,7 @@ describe("formatDeclarativeUpgradeGate", () => { [ "Upgrade without changing the active supabase/schemas tree:", "", - " supabase db schema declarative generate --local --overwrite \\", + " supabase db schema declarative generate --overwrite \\", " --output-dir supabase/schemas-next --experimental", " # review supabase/schemas-next", " rm -rf supabase/schemas && mv supabase/schemas-next supabase/schemas", @@ -219,7 +219,7 @@ describe("formatDeclarativeUpgradeGate", () => { }); expect(gate.suggestion).toContain( - " supabase db schema declarative generate --local --overwrite --output-dir 'supabase/custom schema-next' --experimental", + " supabase db schema declarative generate --overwrite --output-dir 'supabase/custom schema-next' --experimental", ); expect(gate.suggestion).toContain( " Remove-Item -Recurse -Force -ErrorAction Stop 'supabase/custom schema'; Move-Item 'supabase/custom schema-next' 'supabase/custom schema'", 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 be473be83d..1c77c54234 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 @@ -21,6 +21,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 { branchResolution } from "../../../../../command-internal/target/branch-resolution.ts"; import { targetFlags } from "../../../../../command-internal/target/target-flags.ts"; import { withTargetPolicy } from "../../../../../command-internal/target/target-policy.ts"; @@ -90,6 +91,7 @@ export const dbSchemaDeclarativeGenerateCommand = Command.make("generate", confi }, dbConfig, withCondition(linkedProjectCache, { dbTarget: ["linked"] }), + branchResolution, ), { default: "local" }, ), diff --git a/apps/cli/src/commands/db/test/test.command.ts b/apps/cli/src/commands/db/test/test.command.ts index 6941e68528..779dd118fb 100644 --- a/apps/cli/src/commands/db/test/test.command.ts +++ b/apps/cli/src/commands/db/test/test.command.ts @@ -1,7 +1,7 @@ import { Command } from "effect/cli"; import { withPermissions } from "../../../command-internal/command-permissions/command-permissions.annotation.ts"; -import { withDbTarget } from "../../../command-internal/command-permissions/model.ts"; +import { compose, withDbTarget } from "../../../command-internal/command-permissions/model.ts"; import { dbConfig } from "../../../command-internal/db-config.layer.ts"; import { TEST_DB_DESCRIPTION, @@ -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 { branchResolution } from "../../../command-internal/target/branch-resolution.ts"; import { withTargetPolicy } from "../../../command-internal/target/target-policy.ts"; /** @@ -23,7 +24,7 @@ import { withTargetPolicy } from "../../../command-internal/target/target-policy export const dbTestCommand = Command.make("test", testDbConfig).pipe( Command.withDescription(TEST_DB_DESCRIPTION), Command.withShortDescription(TEST_DB_SHORT), - withPermissions(withDbTarget(dbConfig, { default: "local" })), + withPermissions(withDbTarget(compose(dbConfig, branchResolution), { default: "local" })), Command.withHandler(runTestDbCommand), // `["db", "test"]`, not `["test", "db"]`: the `cli_command_executed` // telemetry records the actual invoked command path, which differs by diff --git a/apps/cli/src/commands/test/db/db.command.ts b/apps/cli/src/commands/test/db/db.command.ts index 55f358b2b0..b962266868 100644 --- a/apps/cli/src/commands/test/db/db.command.ts +++ b/apps/cli/src/commands/test/db/db.command.ts @@ -1,7 +1,7 @@ import { Command } from "effect/cli"; import { withPermissions } from "../../../command-internal/command-permissions/command-permissions.annotation.ts"; -import { withDbTarget } from "../../../command-internal/command-permissions/model.ts"; +import { compose, withDbTarget } from "../../../command-internal/command-permissions/model.ts"; import { dbConfig } from "../../../command-internal/db-config.layer.ts"; import { TEST_DB_DESCRIPTION, @@ -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 { branchResolution } from "../../../command-internal/target/branch-resolution.ts"; import { withTargetPolicy } from "../../../command-internal/target/target-policy.ts"; /** @@ -21,7 +22,7 @@ import { withTargetPolicy } from "../../../command-internal/target/target-policy export const testDbCommand = Command.make("db", testDbConfig).pipe( Command.withDescription(TEST_DB_DESCRIPTION), Command.withShortDescription(TEST_DB_SHORT), - withPermissions(withDbTarget(dbConfig, { default: "local" })), + withPermissions(withDbTarget(compose(dbConfig, branchResolution), { default: "local" })), Command.withHandler(runTestDbCommand), Command.provide(testDbRuntimeLayer(["test", "db"])), Command.provide(cliConfigValuesLayer), From 7c7793f83f047a3518ffce1cc6d4355d8f8d8198 Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Mon, 19 Oct 2026 12:17:08 +0100 Subject: [PATCH 131/291] test(cli): cover --remote target selection in the db commands Switches the db command tests to --remote as the primary path, makes bare db push and db pull explicit about hosted versus local, and keeps table-driven coverage for each deprecated alias and its warning, branch lookups, and db diff --from/--to values. --- .../test-db.integration.test.ts | 164 +++++- .../db/advisors/advisors.integration.test.ts | 197 +++++-- .../commands/db/diff/diff.integration.test.ts | 350 ++++++++++-- .../commands/db/dump/dump.integration.test.ts | 328 +++++++---- .../commands/db/lint/lint.integration.test.ts | 155 +++++- .../commands/db/pull/pull.integration.test.ts | 522 ++++++++++++------ .../commands/db/push/push.integration.test.ts | 183 ++++-- .../db/query/query.integration.test.ts | 441 ++++++++++----- .../db/reset/reset.integration.test.ts | 446 +++++++++------ .../generate/generate.integration.test.ts | 282 +++++++--- .../declarative/sync/sync.integration.test.ts | 4 +- .../commands/db/test/test.integration.test.ts | 33 +- apps/cli/tests/helpers/db-remote-selection.ts | 34 ++ 13 files changed, 2288 insertions(+), 851 deletions(-) create mode 100644 apps/cli/tests/helpers/db-remote-selection.ts 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 e0053311ac..d8683e41d5 100644 --- a/apps/cli/src/command-internal/test-db.integration.test.ts +++ b/apps/cli/src/command-internal/test-db.integration.test.ts @@ -28,6 +28,11 @@ import { mockTelemetryStateTracked, useTempWorkdir, } from "../../tests/helpers/command-mocks.ts"; +import { + BRANCH_PROJECT_REF, + branchListApi, + warningsOf, +} from "../../tests/helpers/db-remote-selection.ts"; import { assertPermissionDrift } from "../../tests/helpers/permission-drift.ts"; import { asRecordedRequests, @@ -346,6 +351,8 @@ function setup(opts: SetupOpts = {}) { return { layer, out, telemetry, connection, docker, bundled, resolver }; } +const REMOTE_LINKED = Option.some({ _tag: "Linked" } as const); + const flags = (over: Partial<Parameters<typeof testDb>[0]> = {}) => ({ paths: over.paths ?? [], remote: over.remote ?? Option.none(), @@ -461,7 +468,9 @@ describe("test db integration", () => { isLocal: false, }); return Effect.gen(function* () { - yield* testDb(flags({ dbUrl: Option.some("postgres://x") })); + yield* testDb( + flags({ remote: Option.some({ _tag: "DbUrl", url: "postgres://x" } as const) }), + ); const run = docker.lastOpts; expect(run?.network).toEqual({ _tag: "host" }); expect(run?.env["PGHOST"]).toBe(REMOTE_CONN.host); @@ -479,8 +488,10 @@ describe("test db integration", () => { return Effect.gen(function* () { yield* testDb( flags({ - local: false, - dbUrl: Option.some("postgresql://postgres:postgres@127.0.0.1:54322/postgres"), + remote: Option.some({ + _tag: "DbUrl", + url: "postgresql://postgres:postgres@127.0.0.1:54322/postgres", + }), }), ); expect(docker.lastOpts).toBeUndefined(); @@ -492,7 +503,7 @@ describe("test db integration", () => { }).pipe(Effect.provide(Layer.mergeAll(layer, stackBackendLayer("stack")))); }); - it.live("stack --db-url native keeps PGHOST when isLocal is true", () => { + it.live("stack --remote=<db-url> native keeps PGHOST when isLocal is true", () => { const { layer, docker, bundled } = setup({ conn: { ...LOCAL_CONN, host: "db.internal" }, isLocal: true, @@ -501,8 +512,10 @@ describe("test db integration", () => { return Effect.gen(function* () { yield* testDb( flags({ - dbUrl: Option.some("postgresql://postgres:postgres@db.internal:54322/postgres"), - local: false, + remote: Option.some({ + _tag: "DbUrl", + url: "postgresql://postgres:postgres@db.internal:54322/postgres", + }), }), ); expect(docker.lastOpts).toBeUndefined(); @@ -511,14 +524,14 @@ describe("test db integration", () => { }).pipe(Effect.provide(Layer.mergeAll(layer, stackBackendLayer("stack")))); }); - it.live("stack --linked native keeps the remote PGHOST", () => { + it.live("stack --remote native keeps the remote PGHOST", () => { const { layer, docker, bundled } = setup({ conn: REMOTE_CONN, isLocal: false, dockerInstalled: false, }); return Effect.gen(function* () { - yield* testDb(flags({ local: false, linked: true })); + yield* testDb(flags({ remote: REMOTE_LINKED })); expect(docker.lastOpts).toBeUndefined(); expect(bundled.lastOpts?.runtime).toEqual({ kind: "native" }); expect(bundled.lastOpts?.env?.["PGHOST"]).toBe(REMOTE_CONN.host); @@ -532,7 +545,9 @@ describe("test db integration", () => { dnsResolver: "https", }); return Effect.gen(function* () { - yield* testDb(flags({ dbUrl: Option.some("postgres://x") })); + yield* testDb( + flags({ remote: Option.some({ _tag: "DbUrl", url: "postgres://x" } as const) }), + ); expect(connection.connectCalls[0]?.dnsResolver).toBe("https"); }).pipe(Effect.provide(layer)); }); @@ -732,11 +747,11 @@ describe("test db integration", () => { }).pipe(Effect.provide(layer)); }); - it.live("selects the project given via --project-ref alone", () => { + it.live("selects the project given via --remote=<ref> alone", () => { const FLAG_REF = "flagflagflagflagflag"; const { layer, resolver } = setup({ conn: REMOTE_CONN, isLocal: false }); return Effect.gen(function* () { - yield* testDb(flags({ projectRef: Option.some(FLAG_REF) })); + yield* testDb(flags({ remote: Option.some({ _tag: "Ref", ref: FLAG_REF } as const) })); expect(resolver.inputs[0]?.target).toMatchObject({ kind: "linked", ref: FLAG_REF }); }).pipe(Effect.provide(layer)); }); @@ -793,7 +808,12 @@ describe("test db integration", () => { }); return Effect.gen(function* () { const exit = yield* Effect.exit( - testDb(flags({ dbUrl: Option.some("postgres://x"), paths: ["/abs/a_test.sql"] })), + testDb( + flags({ + remote: Option.some({ _tag: "DbUrl", url: "postgres://x" } as const), + paths: ["/abs/a_test.sql"], + }), + ), ); expect(Exit.isFailure(exit) && Cause.squash(exit.cause)).toBeInstanceOf( HostedMutationDeclinedError, @@ -835,7 +855,7 @@ describe("test db integration", () => { }).pipe(Effect.provide(BunServices.layer)); }); - it.effect("mints a login role through the real resolver for --linked --project-ref", () => { + it.effect("mints a login role through the real resolver for --remote=<ref>", () => { const { layer, connection } = setup({ realLayers: true, workdir: tempWorkdir.current, @@ -843,7 +863,7 @@ describe("test db integration", () => { const requests: Array<StubbedRequest> = []; return Effect.gen(function* () { yield* stubManagementApi(requests, serveLinkedDatabase(VALID_REF)); - yield* testDb(flags({ linked: true, local: false, projectRef: Option.some(VALID_REF) })); + yield* testDb(flags({ remote: Option.some({ _tag: "Ref", ref: VALID_REF } as const) })); expect(connection.connectCalls[0]?.isLocal).toBe(false); expect(requests.map(({ method, path }) => `${method} ${path}`)).toEqual([ `GET /v1/projects/${VALID_REF}/config/database/pooler`, @@ -851,7 +871,7 @@ describe("test db integration", () => { ]); assertPermissionDrift({ command: "test db", - activeFlags: { linked: true, "project-ref": VALID_REF }, + activeFlags: { remote: VALID_REF }, requests: asRecordedRequests(requests), exact: true, }); @@ -896,12 +916,124 @@ describe("test db integration", () => { }); }); +describe("test db target selection", () => { + const FLAG_PROJECT_REF = "flagflagflagflagflag"; + + const resolvedTarget = ( + selector: Partial<Parameters<typeof testDb>[0]>, + options: { readonly branchApi?: ReturnType<typeof branchListApi> } = {}, + ) => { + const { layer, out, resolver } = setup({ conn: REMOTE_CONN }); + const provided = + options.branchApi === undefined + ? layer + : Layer.mergeAll( + layer, + mockCommandSettings({ workdir: "/work/project", projectId: Option.some(VALID_REF) }), + options.branchApi.layer, + ); + return Effect.gen(function* () { + yield* testDb(flags(selector)).pipe(Effect.provide(provided)); + return { target: resolver.inputs[0]?.target, warnings: warningsOf(out) }; + }); + }; + + it.live("targets the local database, with no warning, when nothing selects one", () => + Effect.gen(function* () { + const { target, warnings } = yield* resolvedTarget({}); + expect(target).toEqual({ kind: "local" }); + expect(warnings).toEqual([]); + }), + ); + + it.live("targets the project --remote=<ref> names, with no warning", () => + Effect.gen(function* () { + const { target, warnings } = yield* resolvedTarget({ + remote: Option.some({ _tag: "Ref", ref: FLAG_PROJECT_REF } as const), + }); + expect(target).toMatchObject({ kind: "linked", ref: FLAG_PROJECT_REF, source: "flag" }); + expect(warnings).toEqual([]); + }), + ); + + it.live("targets the database --remote=<db-url> names, with no warning", () => + Effect.gen(function* () { + const { target, warnings } = yield* resolvedTarget({ + remote: Option.some({ + _tag: "DbUrl", + url: "postgresql://postgres:pw@db.example.com/db", + } as const), + }); + expect(target?.kind).toBe("db-url"); + expect(warnings).toEqual([]); + }), + ); + + it.live("targets the project of the branch --remote=<branch> names", () => { + const branchApi = branchListApi(); + return Effect.gen(function* () { + const { target, warnings } = yield* resolvedTarget( + { remote: Option.some({ _tag: "Branch", name: "feature-x" }) }, + { branchApi }, + ); + expect(target).toMatchObject({ kind: "linked", ref: BRANCH_PROJECT_REF, source: "flag" }); + expect(warnings).toEqual([]); + assertPermissionDrift({ + command: "test db", + activeFlags: { remote: "feature-x" }, + requests: branchApi.requests, + }); + }); + }); + + it.live.each([ + { + alias: "--linked", + flags: { linked: true }, + kind: "linked", + warning: "--linked is deprecated and will be removed in v4. Use --remote instead", + }, + { + alias: "--project-ref", + flags: { projectRef: Option.some(FLAG_PROJECT_REF) }, + kind: "linked", + warning: "--project-ref is deprecated and will be removed in v4. Use --remote instead", + }, + { + alias: "--db-url", + flags: { dbUrl: Option.some("postgresql://postgres:pw@db.example.com/db") }, + kind: "db-url", + warning: "--db-url is deprecated and will be removed in v4. Use --remote instead", + }, + { + alias: "--local", + flags: { local: true }, + kind: "local", + warning: "--local isn't needed (local is the default) and will be removed in v4", + }, + ] satisfies ReadonlyArray<{ + alias: string; + flags: Partial<Parameters<typeof testDb>[0]>; + kind: string; + warning: string; + }>)("keeps $alias working and warns that it is deprecated", ({ flags, kind, warning }) => + Effect.gen(function* () { + const { target, warnings } = yield* resolvedTarget(flags); + expect(target?.kind).toBe(kind); + expect(warnings).toEqual([expect.stringContaining(warning)]); + }), + ); +}); + describeHostedConsent("test db", { invoke: () => { const connection = mockDbConnection({}); return { run: testDb( - flags({ linked: true, projectRef: Option.some(VALID_REF), paths: ["supabase/tests"] }), + flags({ + remote: Option.some({ _tag: "Ref", ref: VALID_REF } as const), + paths: ["supabase/tests"], + }), ).pipe( Effect.provide( Layer.mergeAll( 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 a0942a4a53..5e4eaeaa9b 100644 --- a/apps/cli/src/commands/db/advisors/advisors.integration.test.ts +++ b/apps/cli/src/commands/db/advisors/advisors.integration.test.ts @@ -11,6 +11,11 @@ vi.mock("node:net", (importOriginal) => ); import { cliConfigValuesTestLayer } from "../../../../tests/helpers/config-values-layer.ts"; +import { + BRANCH_PROJECT_REF, + branchListApi, + warningsOf, +} from "../../../../tests/helpers/db-remote-selection.ts"; import { mockOutput, mockProcessControl } from "../../../../tests/helpers/mocks.ts"; import { VALID_REF, @@ -171,7 +176,7 @@ function mockProjectRef() { const layer = Layer.succeed(ProjectRefResolver, { resolve: () => Effect.succeed(VALID_REF), resolveForLink: () => Effect.succeed(VALID_REF), - // An explicit `--project-ref` wins over the workdir's own ref, so a test can prove the flag + // An explicit `--remote=<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(() => { @@ -193,7 +198,7 @@ function mockProjectRef() { }; } -/** Validating credentials mock — the advisors `--linked` token gate calls +/** Validating credentials mock — the advisors hosted token gate calls * `CommandCredentials.getAccessToken`, which fails hard on a malformed token * and returns None when no token is present. */ function mockCredentials(opts: { token?: "valid" | "none" | "invalid" } = {}) { @@ -328,6 +333,8 @@ function setup(opts: SetupOpts = {}) { }; } +const REMOTE_LINKED: DbAdvisorsFlags["remote"] = Option.some({ _tag: "Linked" }); + const flags = (over: Partial<DbAdvisorsFlags> = {}): DbAdvisorsFlags => ({ dbUrl: over.dbUrl ?? Option.none<string>(), remote: over.remote ?? Option.none(), @@ -488,7 +495,8 @@ describe("db advisors — local", () => { }); }); -const linkedFlags = (over: Partial<DbAdvisorsFlags> = {}) => flags({ linked: true, ...over }); +const linkedFlags = (over: Partial<DbAdvisorsFlags> = {}) => + flags({ remote: REMOTE_LINKED, ...over }); describe("db advisors — linked", () => { const securityLint = { @@ -526,7 +534,9 @@ describe("db advisors — linked", () => { return Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; yield* stubManagementApi(resolverRequests, serveLinkedDatabase(VALID_REF)); - yield* dbAdvisors(linkedFlags({ projectRef: Option.some(VALID_REF) })); + yield* dbAdvisors( + linkedFlags({ remote: Option.some({ _tag: "Ref", ref: VALID_REF } as const) }), + ); expect(resolverRequests.map(({ method, path }) => `${method} ${path}`)).toEqual([ `GET /v1/projects/${VALID_REF}/config/database/pooler`, `POST /v1/projects/${VALID_REF}/cli/login-role`, @@ -541,7 +551,7 @@ describe("db advisors — linked", () => { ); assertPermissionDrift({ command: "db advisors", - activeFlags: { linked: true, "project-ref": VALID_REF }, + activeFlags: { remote: VALID_REF }, requests: [...asRecordedRequests(resolverRequests), ...api.requests], exact: true, }); @@ -553,33 +563,36 @@ describe("db advisors — linked", () => { }, ); - it.effect("makes no Management API request for --local through the real layers", () => { - const { layer, api } = setup({ - rows: [], - realLayers: true, - }); - const resolverRequests: Array<StubbedRequest> = []; - return Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - yield* stubManagementApi(resolverRequests, () => undefined); - yield* dbAdvisors(flags({ local: true })); - expect(resolverRequests).toEqual([]); - expect(api.requests).toEqual([]); - expect(yield* fs.exists(`${tempRoot.current}/supabase/.temp/linked-project.json`)).toBe( - false, - ); - assertPermissionDrift({ - command: "db advisors", - activeFlags: { local: true }, - requests: [], - exact: true, + it.effect( + "makes no Management API request for the default local target through the real layers", + () => { + const { layer, api } = setup({ + rows: [], + realLayers: true, }); - }).pipe( - Effect.scoped, - Effect.provide(Layer.mergeAll(layer, BunServices.layer)), - provideEnv({ SUPABASE_ACCESS_TOKEN: VALID_TOKEN }), - ); - }); + const resolverRequests: Array<StubbedRequest> = []; + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* stubManagementApi(resolverRequests, () => undefined); + yield* dbAdvisors(flags()); + expect(resolverRequests).toEqual([]); + expect(api.requests).toEqual([]); + expect(yield* fs.exists(`${tempRoot.current}/supabase/.temp/linked-project.json`)).toBe( + false, + ); + assertPermissionDrift({ + command: "db advisors", + activeFlags: {}, + requests: [], + exact: true, + }); + }).pipe( + Effect.scoped, + Effect.provide(Layer.mergeAll(layer, BunServices.layer)), + provideEnv({ SUPABASE_ACCESS_TOKEN: VALID_TOKEN }), + ); + }, + ); it.effect( "fails on a 403 from the security advisors without asking for the performance ones", @@ -596,7 +609,7 @@ describe("db advisors — linked", () => { ]); assertPermissionDrift({ command: "db advisors", - activeFlags: { linked: true, type: "all" }, + activeFlags: { remote: true, type: "all" }, requests: api.requests, }); }).pipe(Effect.provide(layer)); @@ -615,7 +628,7 @@ describe("db advisors — linked", () => { expect(urls.some((u) => u.includes("/advisors/performance"))).toBe(true); assertPermissionDrift({ command: "db advisors", - activeFlags: { linked: true, type: "all", level: true }, + activeFlags: { remote: true, type: "all", level: true }, requests: api.requests, exact: true, }); @@ -626,7 +639,7 @@ describe("db advisors — linked", () => { }); it.effect( - "fetches advisors for the project given via --project-ref, overriding the workdir's own ref", + "fetches advisors for the project given via --remote=<ref>, overriding the workdir's own ref", () => { // The fake resolver's own fallback (VALID_REF) represents whatever the workdir would // resolve to absent the flag; the flag must win over it and drive both API and cache. @@ -636,7 +649,10 @@ describe("db advisors — linked", () => { }); return Effect.gen(function* () { yield* dbAdvisors( - flags({ type: Option.some("security"), projectRef: Option.some(FLAG_REF) }), + flags({ + type: Option.some("security"), + remote: Option.some({ _tag: "Ref", ref: FLAG_REF } as const), + }), ); expect( api.requests.some((r) => r.url.includes(`/v1/projects/${FLAG_REF}/advisors/security`)), @@ -708,7 +724,7 @@ describe("db advisors — linked", () => { expect(urls.some((u) => u.includes("/advisors/performance"))).toBe(false); assertPermissionDrift({ command: "db advisors", - activeFlags: { linked: true, type: "security" }, + activeFlags: { remote: true, type: "security" }, requests: api.requests, exact: true, }); @@ -724,7 +740,7 @@ describe("db advisors — linked", () => { expect(urls.some((u) => u.includes("/advisors/security"))).toBe(false); assertPermissionDrift({ command: "db advisors", - activeFlags: { linked: true, type: "performance" }, + activeFlags: { remote: true, type: "performance" }, requests: api.requests, exact: true, }); @@ -841,3 +857,110 @@ describe("db advisors — linked", () => { }).pipe(Effect.provide(layer)); }); }); + +describe("db advisors — target selection", () => { + const FLAG_PROJECT_REF = "flagflagflagflagflag"; + + const resolvedTarget = ( + selector: Partial<DbAdvisorsFlags>, + options: { readonly branchApi?: ReturnType<typeof branchListApi> } = {}, + ) => { + const { layer, out, resolver } = setup({ + rows: [], + securityLints: [], + performanceLints: [], + }); + const provided = + options.branchApi === undefined ? layer : Layer.merge(layer, options.branchApi.layer); + return Effect.gen(function* () { + yield* dbAdvisors(flags(selector)).pipe(Effect.provide(provided)); + return { target: resolver.resolveInputs[0]?.target, warnings: warningsOf(out) }; + }); + }; + + it.effect("targets the local database, with no warning, when nothing selects one", () => + Effect.gen(function* () { + const { target, warnings } = yield* resolvedTarget({}); + expect(target).toEqual({ kind: "local" }); + expect(warnings).toEqual([]); + }), + ); + + it.effect("targets the project --remote=<ref> names, with no warning", () => + Effect.gen(function* () { + const { target, warnings } = yield* resolvedTarget({ + remote: Option.some({ _tag: "Ref", ref: FLAG_PROJECT_REF } as const), + }); + expect(target).toMatchObject({ kind: "linked", ref: FLAG_PROJECT_REF, source: "flag" }); + expect(warnings).toEqual([]); + }), + ); + + it.effect("targets the database --remote=<db-url> names, with no warning", () => + Effect.gen(function* () { + const { target, warnings } = yield* resolvedTarget({ + remote: Option.some({ + _tag: "DbUrl", + url: "postgresql://postgres:pw@db.example.com/db", + } as const), + }); + expect(target?.kind).toBe("db-url"); + expect(warnings).toEqual([]); + }), + ); + + it.effect("targets the project of the branch --remote=<branch> names", () => { + const branchApi = branchListApi(); + return Effect.gen(function* () { + const { target, warnings } = yield* resolvedTarget( + { remote: Option.some({ _tag: "Branch", name: "feature-x" }) }, + { branchApi }, + ); + expect(target).toMatchObject({ kind: "linked", ref: BRANCH_PROJECT_REF, source: "flag" }); + expect(warnings).toEqual([]); + assertPermissionDrift({ + command: "db advisors", + activeFlags: { remote: "feature-x" }, + requests: branchApi.requests, + }); + }); + }); + + it.effect.each([ + { + alias: "--linked", + flags: { linked: true }, + kind: "linked", + warning: "--linked is deprecated and will be removed in v4. Use --remote instead", + }, + { + alias: "--project-ref", + flags: { projectRef: Option.some(FLAG_PROJECT_REF) }, + kind: "linked", + warning: "--project-ref is deprecated and will be removed in v4. Use --remote instead", + }, + { + alias: "--db-url", + flags: { dbUrl: Option.some("postgresql://postgres:pw@db.example.com/db") }, + kind: "db-url", + warning: "--db-url is deprecated and will be removed in v4. Use --remote instead", + }, + { + alias: "--local", + flags: { local: true }, + kind: "local", + warning: "--local isn't needed (local is the default) and will be removed in v4", + }, + ] satisfies ReadonlyArray<{ + alias: string; + flags: Partial<DbAdvisorsFlags>; + kind: string; + warning: string; + }>)("keeps $alias working and warns that it is deprecated", ({ flags, kind, warning }) => + Effect.gen(function* () { + const { target, warnings } = yield* resolvedTarget(flags); + expect(target?.kind).toBe(kind); + expect(warnings).toEqual([expect.stringContaining(warning)]); + }), + ); +}); 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 bf6f60cbb9..18c9e2e35d 100644 --- a/apps/cli/src/commands/db/diff/diff.integration.test.ts +++ b/apps/cli/src/commands/db/diff/diff.integration.test.ts @@ -28,6 +28,11 @@ vi.mock("node:net", (importOriginal) => ); import { stripAnsi } from "../../../../tests/helpers/ansi.ts"; +import { + BRANCH_PROJECT_REF, + branchListApi, + warningsOf, +} from "../../../../tests/helpers/db-remote-selection.ts"; import { assertPermissionDrift } from "../../../../tests/helpers/permission-drift.ts"; import { asRecordedRequests, @@ -146,7 +151,7 @@ interface SetupOpts { // `Option.none()` to exercise the config.toml/workdir-basename fallback. readonly projectId?: Option.Option<string>; // Simulates an unlinked workdir: the project-ref resolver fails with - // `ProjectRefNotLinkedError` absent an explicit `--project-ref` flag. + // `ProjectRefNotLinkedError` absent an explicit `--remote=<ref>`. readonly linkedFails?: boolean; // Per-differ-run `--json-diff` stdout, indexed by run order; falls back to `""` // once exhausted, so a single-run test only needs one element. @@ -552,6 +557,8 @@ function setup(workdir: string, opts: SetupOpts = {}) { }; } +const REMOTE_LINKED: DbDiffFlags["remote"] = Option.some({ _tag: "Linked" }); + const flags = (over: Partial<DbDiffFlags> = {}): DbDiffFlags => ({ useMigra: over.useMigra ?? Option.none(), usePgAdmin: over.usePgAdmin ?? Option.none(), @@ -943,7 +950,7 @@ describe("db diff", () => { diffSql: "alter table x;\n", }); return Effect.gen(function* () { - yield* dbDiff(flags({ linked: true })); + yield* dbDiff(flags({ remote: REMOTE_LINKED })); expect(s.databaseDiffCalls).toHaveLength(1); expect(stderr(s.out)).not.toContain("are not read"); }).pipe(Effect.provide(s.layer)); @@ -1158,7 +1165,7 @@ describe("db diff", () => { diffSql: "alter table x;\n", }); return Effect.gen(function* () { - yield* dbDiff(flags({ linked: true })); + yield* dbDiff(flags({ remote: REMOTE_LINKED })); expect(s.databaseDiffCalls[0]?.target.connectOptions.isLocal).toBe(false); expect(s.databaseDiffCalls[0]?.source.connectOptions.isLocal).toBe(true); }).pipe(Effect.provide(s.layer)); @@ -1189,7 +1196,7 @@ describe("db diff", () => { diffSql: "alter table x;\n", }); return Effect.gen(function* () { - yield* dbDiff(flags({ linked: true })); + yield* dbDiff(flags({ remote: REMOTE_LINKED })); const createArgs = s.shadowSpawned.find((c) => c.args[0] === "create")?.args ?? []; expect(createArgs).toContain("--tmpfs"); expect(s.dockerCalls).toEqual([]); @@ -1218,7 +1225,7 @@ describe("db diff", () => { diffSql: "", }); return Effect.gen(function* () { - yield* dbDiff(flags({ linked: true, file: Option.some("declared") })); + yield* dbDiff(flags({ remote: REMOTE_LINKED, file: Option.some("declared") })); expect(stderr(s.out)).toContain( "Run supabase db schema declarative sync --experimental to generate", ); @@ -1255,12 +1262,237 @@ describe("db diff", () => { diffSql: "alter table x;\n", }); return Effect.gen(function* () { - yield* dbDiff(flags({ linked: true })); + yield* dbDiff(flags({ remote: REMOTE_LINKED })); expect(s.cache.cached).toBe(true); }).pipe(Effect.provide(s.layer)); }); - it.effect("diffs the project given via --project-ref without a linked workdir", () => { + describe("target selection", () => { + const FLAG_PROJECT_REF = "flagflagflagflagflag"; + + const resolvedTarget = ( + selector: Partial<DbDiffFlags>, + options: { readonly branchApi?: ReturnType<typeof branchListApi> } = {}, + ) => { + const s = setup(tmp.current, { + projectId: Option.some(VALID_REF), + diffSql: "alter table x;\n", + }); + const provided = + options.branchApi === undefined ? s.layer : Layer.merge(s.layer, options.branchApi.layer); + return Effect.gen(function* () { + yield* dbDiff(flags(selector)).pipe(Effect.provide(provided)); + return { target: s.resolverCalls[0]?.target, warnings: warningsOf(s.out) }; + }); + }; + + it.effect("targets the local database, with no warning, when nothing selects one", () => + Effect.gen(function* () { + const { target, warnings } = yield* resolvedTarget({}); + expect(target).toEqual({ kind: "local" }); + expect(warnings).toEqual([]); + }), + ); + + it.effect("targets the linked project for a bare --remote, with no warning", () => + Effect.gen(function* () { + const { target, warnings } = yield* resolvedTarget({ remote: REMOTE_LINKED }); + expect(target).toMatchObject({ kind: "linked", source: "file" }); + expect(warnings).toEqual([]); + }), + ); + + it.effect("targets the project --remote=<ref> names, with no warning", () => + Effect.gen(function* () { + const { target, warnings } = yield* resolvedTarget({ + remote: Option.some({ _tag: "Ref", ref: FLAG_PROJECT_REF } as const), + }); + expect(target).toMatchObject({ kind: "linked", ref: FLAG_PROJECT_REF, source: "flag" }); + expect(warnings).toEqual([]); + }), + ); + + it.effect("targets the database --remote=<db-url> names, with no warning", () => + Effect.gen(function* () { + const { target, warnings } = yield* resolvedTarget({ + remote: Option.some({ + _tag: "DbUrl", + url: "postgresql://postgres:pw@db.example.com/db", + } as const), + }); + expect(target?.kind).toBe("db-url"); + expect(warnings).toEqual([]); + }), + ); + + it.effect("targets the project of the branch --remote=<branch> names", () => { + const branchApi = branchListApi(); + return Effect.gen(function* () { + const { target, warnings } = yield* resolvedTarget( + { remote: Option.some({ _tag: "Branch", name: "feature-x" }) }, + { branchApi }, + ); + expect(target).toMatchObject({ kind: "linked", ref: BRANCH_PROJECT_REF, source: "flag" }); + expect(warnings).toEqual([]); + assertPermissionDrift({ + command: "db diff", + activeFlags: { remote: "feature-x" }, + requests: branchApi.requests, + }); + }); + }); + + it.effect.each([ + { + alias: "--linked", + flags: { linked: true }, + kind: "linked", + warning: "--linked is deprecated and will be removed in v4. Use --remote instead", + }, + { + alias: "--project-ref", + flags: { projectRef: Option.some(FLAG_PROJECT_REF) }, + kind: "linked", + warning: "--project-ref is deprecated and will be removed in v4. Use --remote instead", + }, + { + alias: "--db-url", + flags: { dbUrl: Option.some("postgresql://postgres:pw@db.example.com/db") }, + kind: "db-url", + warning: "--db-url is deprecated and will be removed in v4. Use --remote instead", + }, + { + alias: "--local", + flags: { local: true }, + kind: "local", + warning: "--local isn't needed (local is the default) and will be removed in v4", + }, + ] satisfies ReadonlyArray<{ + alias: string; + flags: Partial<DbDiffFlags>; + kind: string; + warning: string; + }>)("keeps $alias working and warns that it is deprecated", ({ flags, kind, warning }) => + Effect.gen(function* () { + const { target, warnings } = yield* resolvedTarget(flags); + expect(target?.kind).toBe(kind); + expect(warnings).toEqual([expect.stringContaining(warning)]); + }), + ); + }); + + describe("explicit --from and --to values", () => { + const FLAG_PROJECT_REF = "flagflagflagflagflag"; + const DB_URL = "postgresql://postgres:pw@db.example.com/db"; + + const sides = (["from", "to"] as const).flatMap((side) => + [ + { + value: "remote", + expected: { kind: "linked", source: "file" }, + warning: undefined, + }, + { + value: FLAG_PROJECT_REF, + expected: { kind: "linked", ref: FLAG_PROJECT_REF, source: "flag" }, + warning: undefined, + }, + { + value: "feature-x", + expected: { kind: "linked", ref: BRANCH_PROJECT_REF, source: "flag" }, + warning: undefined, + }, + { + value: "linked", + expected: { kind: "linked", source: "file" }, + warning: `--${side} linked is deprecated and will be removed in v4. Use --${side} remote instead.`, + }, + ].map((row) => ({ side, ...row })), + ); + + it.effect.each(sides)( + "--$side $value resolves the hosted project it names", + ({ side, value, expected, warning }) => { + const s = setup(tmp.current, { + isLocal: false, + projectId: Option.some(VALID_REF), + diffSql: "create table e ();\n", + }); + const other = side === "from" ? "to" : "from"; + return Effect.gen(function* () { + yield* dbDiff( + flags({ + [side]: Option.some(value), + [other]: Option.some("migrations"), + }), + ).pipe(Effect.provide(Layer.merge(s.layer, branchListApi().layer))); + expect(s.resolverCalls.map((call) => call.target)).toEqual([ + expect.objectContaining(expected), + ]); + expect(warningsOf(s.out)).toEqual(warning === undefined ? [] : [warning]); + }); + }, + ); + + it.effect.each( + (["from", "to"] as const).map((side) => ({ + side, + other: side === "from" ? "to" : "from", + })), + )( + "--$side <db-url> diffs the database it names without minting a connection", + ({ side, other }) => { + const s = setup(tmp.current, { diffSql: "create table u ();\n" }); + return Effect.gen(function* () { + yield* dbDiff( + flags({ [side]: Option.some(DB_URL), [other]: Option.some("migrations") }), + ).pipe(Effect.provide(s.layer)); + const endpoint = + side === "from" ? s.explicitDiffCalls[0]?.source : s.explicitDiffCalls[0]?.desired; + expect(endpoint).toEqual({ + kind: "database", + ref: DB_URL, + connectOptions: { isLocal: false, dnsResolver: "native" }, + }); + expect(s.resolverCalls).toEqual([]); + }); + }, + ); + + it.effect("--from local --to migrations reaches no hosted project", () => { + const s = setup(tmp.current, { diffSql: "create table l ();\n" }); + return Effect.gen(function* () { + yield* dbDiff(flags({ from: Option.some("local"), to: Option.some("migrations") })).pipe( + Effect.provide(s.layer), + ); + expect(s.explicitDiffCalls[0]?.source).toMatchObject({ kind: "database" }); + expect(s.explicitDiffCalls[0]?.desired).toEqual({ kind: "migrations" }); + expect(s.resolverCalls).toEqual([]); + expect(warningsOf(s.out)).toEqual([]); + }); + }); + + it.effect("--from <branch> asks the linked project's branch list", () => { + const branchApi = branchListApi(); + const s = setup(tmp.current, { + isLocal: false, + projectId: Option.some(VALID_REF), + diffSql: "create table e ();\n", + }); + return Effect.gen(function* () { + yield* dbDiff( + flags({ from: Option.some("feature-x"), to: Option.some("migrations") }), + ).pipe(Effect.provide(Layer.merge(s.layer, branchApi.layer))); + assertPermissionDrift({ + command: "db diff", + activeFlags: { from: "feature-x", to: "migrations" }, + requests: branchApi.requests, + }); + }); + }); + }); + + it.effect("diffs the project given via --remote=<ref> without a linked workdir", () => { // `linkedFails: true` simulates an unlinked workdir; only the flag can resolve a ref. const FLAG_REF = "flagflagflagflagflag"; const s = setup(tmp.current, { @@ -1269,13 +1501,13 @@ describe("db diff", () => { linkedFails: true, }); return Effect.gen(function* () { - yield* dbDiff(flags({ linked: true, projectRef: Option.some(FLAG_REF) })); + yield* dbDiff(flags({ remote: Option.some({ _tag: "Ref", ref: FLAG_REF } as const) })); expect(s.cache.cached).toBe(true); expect(s.cache.cachedRef).toBe(FLAG_REF); }).pipe(Effect.provide(s.layer)); }); - it.effect("--project-ref overrides an already-linked workdir's project ref", () => { + it.effect("--remote=<ref> overrides an already-linked workdir's project ref", () => { const FLAG_REF = "flagflagflagflagflag"; // A distinct `linkedRef` proves the flag, not the workdir's own linked ref, wins. const s = setup(tmp.current, { @@ -1284,14 +1516,14 @@ describe("db diff", () => { diffSql: "alter table x;\n", }); return Effect.gen(function* () { - yield* dbDiff(flags({ linked: true, projectRef: Option.some(FLAG_REF) })); + yield* dbDiff(flags({ remote: Option.some({ _tag: "Ref", ref: FLAG_REF } as const) })); expect(s.cache.cached).toBe(true); expect(s.cache.cachedRef).toBe(FLAG_REF); expect(s.cache.cachedRef).not.toBe("abcdefghijklmnopqrst"); }).pipe(Effect.provide(s.layer)); }); - it.effect("rejects --project-ref combined with an explicit --local target", () => { + it.effect("rejects the deprecated --project-ref combined with --local", () => { const FLAG_REF = "flagflagflagflagflag"; const s = setup(tmp.current); return Effect.gen(function* () { @@ -1308,7 +1540,7 @@ describe("db diff", () => { }); it.effect( - "explicit --from linked --to migrations --project-ref proceeds and uses the flag ref", + "explicit --from remote --to migrations --remote=<ref> proceeds and uses the flag ref", () => { // `[remotes.staging]`'s `project_id` matches the flag ref, not `opts.linkedRef` // (left unset), so the override only applies if the flag resolved it. @@ -1332,9 +1564,9 @@ describe("db diff", () => { return Effect.gen(function* () { yield* dbDiff( flags({ - from: Option.some("linked"), + from: Option.some("remote"), to: Option.some("migrations"), - projectRef: Option.some("flagflagflagflagflag"), + remote: Option.some({ _tag: "Ref", ref: "flagflagflagflagflag" } as const), }), ); expect(s.explicitDiffCalls[0]?.toml?.majorVersion).toBe(14); @@ -1348,10 +1580,10 @@ describe("db diff", () => { ); it.effect( - "explicit --from local --to migrations --project-ref proceeds and applies the flag ref's remote override", + "explicit --from local --to migrations --remote=<ref> proceeds and applies the flag ref's remote override", () => { - // 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. + // Same `[remotes.staging]` fixture as the `--from remote` case above, but here + // `--remote=<ref>` alone selects the linked target instead of a literal `remote` side. const s = setup(tmp.current, { files: { "supabase/config.toml": [ @@ -1374,7 +1606,7 @@ describe("db diff", () => { flags({ from: Option.some("local"), to: Option.some("migrations"), - projectRef: Option.some("flagflagflagflagflag"), + remote: Option.some({ _tag: "Ref", ref: "flagflagflagflagflag" } as const), }), ); expect(s.explicitDiffCalls[0]?.toml?.majorVersion).toBe(14); @@ -1401,7 +1633,7 @@ describe("db diff", () => { diffSql: "alter table x;\n", }); return Effect.gen(function* () { - const exit = yield* dbDiff(flags({ linked: true })).pipe(Effect.exit); + const exit = yield* dbDiff(flags({ remote: REMOTE_LINKED })).pipe(Effect.exit); expect(Exit.isFailure(exit)).toBe(true); expect(s.cache.cached).toBe(true); expect(s.cache.cachedRef).toBe("abcdefghijklmnopqrst"); @@ -1485,7 +1717,7 @@ describe("db diff", () => { }, ); - it.effect("--use-pgadmin --linked honors --project-ref like the other native engines", () => { + it.effect("--use-pgadmin --remote honors --remote=<ref> like the other native engines", () => { const FLAG_REF = "flagflagflagflagflag"; const s = setup(tmp.current, { isLocal: false, @@ -1496,8 +1728,7 @@ describe("db diff", () => { yield* dbDiff( flags({ usePgAdmin: Option.some(true), - linked: true, - projectRef: Option.some(FLAG_REF), + remote: Option.some({ _tag: "Ref", ref: FLAG_REF } as const), }), ); expect(s.differCalls).toHaveLength(1); @@ -1507,7 +1738,7 @@ describe("db diff", () => { }); it.effect( - "--use-pgadmin --linked succeeds when only the [remotes.<ref>] override fixes an invalid base config", + "--use-pgadmin --remote succeeds when only the [remotes.<ref>] override fixes an invalid base config", () => { // pgadmin shares the same target resolve as migra/pg-delta, so it validates the // remote-merged config and succeeds. @@ -1530,7 +1761,7 @@ describe("db diff", () => { pgadminStdout: [JSON.stringify([pgadminEntry()])], }); return Effect.gen(function* () { - yield* dbDiff(flags({ usePgAdmin: Option.some(true), linked: true })); + yield* dbDiff(flags({ usePgAdmin: Option.some(true), remote: REMOTE_LINKED })); expect(stderr(s.out)).toContain("Loading config override: [remotes.staging]"); expect(s.differCalls).toHaveLength(1); }).pipe(Effect.provide(s.layer)); @@ -1538,7 +1769,7 @@ describe("db diff", () => { ); it.effect( - "--use-pgadmin --linked's preflight probe targets the resolved LINKED project id, not the base config's", + "--use-pgadmin --remote's preflight probe targets the resolved LINKED project id, not the base config's", () => { // The preflight probe's project id comes from the config after the linked // remote merge, not the base config's own `project_id`. @@ -1557,7 +1788,7 @@ describe("db diff", () => { pgadminStdout: [JSON.stringify([pgadminEntry()])], }); return Effect.gen(function* () { - yield* dbDiff(flags({ usePgAdmin: Option.some(true), linked: true })); + yield* dbDiff(flags({ usePgAdmin: Option.some(true), remote: REMOTE_LINKED })); // `mockShadowContainerCliSpawner` distinguishes this preflight probe from the // shadow's own health-check inspect by the `supabase_db_` container-name prefix. const inspectTargets = s.shadowSpawned @@ -1838,10 +2069,10 @@ describe("db diff", () => { }).pipe(Effect.provide(s.layer)); }); - it.effect("explicit --from local --to linked prints the diff to stdout", () => { + it.effect("explicit --from local --to remote prints the diff to stdout", () => { const s = setup(tmp.current, { isLocal: false, diffSql: "create table e ();\n" }); return Effect.gen(function* () { - yield* dbDiff(flags({ from: Option.some("local"), to: Option.some("linked") })); + yield* dbDiff(flags({ from: Option.some("local"), to: Option.some("remote") })); expect(s.explicitDiffCalls[0]).toMatchObject({ source: { kind: "database", @@ -1968,7 +2199,7 @@ describe("db diff", () => { }).pipe(Effect.provide(s.layer)); }); - it.effect("explicit --from linked --to migrations passes the linked ref to the strategy", () => { + it.effect("explicit --from remote --to migrations passes the linked ref to the strategy", () => { // Linked resolves first, so the later migrations catalog uses the remote-merged config. const s = setup(tmp.current, { files: { @@ -1989,7 +2220,7 @@ describe("db diff", () => { diffSql: "create table m ();\n", }); return Effect.gen(function* () { - yield* dbDiff(flags({ from: Option.some("linked"), to: Option.some("migrations") })); + yield* dbDiff(flags({ from: Option.some("remote"), to: Option.some("migrations") })); expect(s.explicitDiffCalls[0]?.desired).toEqual({ kind: "migrations", projectRef: "abcdefghijklmnopqrst", @@ -2000,7 +2231,7 @@ describe("db diff", () => { }).pipe(Effect.provide(s.layer)); }); - it.effect("explicit --from migrations --to linked passes base config to the strategy", () => { + it.effect("explicit --from migrations --to remote passes base config to the strategy", () => { // Migrations resolves before linked here, so the catalog must use base config // (no ref forwarded yet). const s = setup(tmp.current, { @@ -2027,16 +2258,16 @@ describe("db diff", () => { diffSql: "create table m ();\n", }); return Effect.gen(function* () { - yield* dbDiff(flags({ from: Option.some("migrations"), to: Option.some("linked") })); + yield* dbDiff(flags({ from: Option.some("migrations"), to: Option.some("remote") })); expect(s.explicitDiffCalls[0]?.source).toEqual({ kind: "migrations" }); expect(s.explicitDiffCalls[0]?.toml?.majorVersion).toBe(17); expect(s.explicitDiffCalls[0]?.toml?.webhooksEnabled).toBe(false); }).pipe(Effect.provide(s.layer)); }); - it.effect("explicit --from local --to migrations --linked seeds the merged config", () => { - // A changed --linked remote-merges the config before the explicit refs resolve, - // even though neither explicit ref is itself `linked`. + it.effect("explicit --from local --to migrations --remote seeds the merged config", () => { + // A changed --remote merges the matching [remotes.*] block before the explicit refs resolve, + // even though neither explicit ref is itself `remote`. const s = setup(tmp.current, { files: { "supabase/config.toml": [ @@ -2060,7 +2291,7 @@ describe("db diff", () => { flags({ from: Option.some("local"), to: Option.some("migrations"), - linked: true, + remote: REMOTE_LINKED, }), ); expect(s.explicitDiffCalls[0]?.desired).toEqual({ @@ -2070,7 +2301,7 @@ describe("db diff", () => { }).pipe(Effect.provide(s.layer)); }); - it.effect("explicit --from local --to migrations --linked validates the merged config", () => { + it.effect("explicit --from local --to migrations --remote validates the merged config", () => { // The base config read is deferred until after the linked preflight, so a base // config only valid after the remote merge doesn't fail early. const s = setup(tmp.current, { @@ -2096,7 +2327,7 @@ describe("db diff", () => { flags({ from: Option.some("local"), to: Option.some("migrations"), - linked: true, + remote: REMOTE_LINKED, }), ).pipe(Effect.exit); expect(Exit.isSuccess(exit)).toBe(true); @@ -2124,14 +2355,14 @@ describe("db diff", () => { }).pipe(Effect.provide(s.layer)); }); - it.effect("explicit mode accepts --db-url without minting a connection", () => { + it.effect("explicit mode accepts --remote=<db-url> without minting a connection", () => { const s = setup(tmp.current, { diffSql: "create table p ();\n" }); return Effect.gen(function* () { yield* dbDiff( flags({ from: Option.some("local"), to: Option.some("local"), - dbUrl: Option.some("postgresql://x"), + remote: Option.some({ _tag: "DbUrl", url: "postgresql://x" } as const), }), ); expect(s.resolverCalls).toEqual([]); @@ -2844,7 +3075,7 @@ describe("db diff", () => { flags({ usePgAdmin: Option.some(true), from: Option.some("local"), - to: Option.some("linked"), + to: Option.some("remote"), }), ); expect(s.differCalls).toEqual([]); @@ -2950,13 +3181,13 @@ describe("db diff permissions through the real resolver and cache", () => { const cacheCalls = (calls: ReadonlyArray<{ readonly method: string; readonly url: string }>) => calls.map(({ method, url }) => `${method} ${url}`); - it.effect("mints a login role and fills the cache for --linked", () => { + it.effect("mints a login role and fills the cache for --remote=<ref>", () => { const s = setup(tmp.current, { realLayers: true, diffSql: "alter table x;\n" }); const requests: Array<StubbedRequest> = []; return Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; yield* stubManagementApi(requests, serveLinkedDatabase(VALID_REF)); - yield* dbDiff(flags({ linked: true, projectRef: Option.some(VALID_REF) })).pipe( + yield* dbDiff(flags({ remote: Option.some({ _tag: "Ref", ref: VALID_REF } as const) })).pipe( Effect.provide(s.layer), ); expect(asLines(requests)).toEqual([poolerRequest, loginRoleRequest]); @@ -2964,7 +3195,7 @@ describe("db diff permissions through the real resolver and cache", () => { expect(yield* fs.exists(`${tmp.current}/supabase/.temp/linked-project.json`)).toBe(true); assertPermissionDrift({ command: "db diff", - activeFlags: { linked: true, "project-ref": VALID_REF }, + activeFlags: { remote: VALID_REF }, requests: [...asRecordedRequests(requests), ...s.projectHttp.calls], exact: true, }); @@ -2998,13 +3229,13 @@ describe("db diff permissions through the real resolver and cache", () => { }); for (const side of ["from", "to"] as const) { - it.effect(`resolves the linked project for --${side} linked without --linked`, () => { + it.effect(`resolves the project --remote=<ref> names for --${side} remote`, () => { const s = setup(tmp.current, { realLayers: true, diffSql: "create table e ();\n" }); const requests: Array<StubbedRequest> = []; const refs = side === "from" - ? { from: "linked", to: "migrations" } - : { from: "migrations", to: "linked" }; + ? { from: "remote", to: "migrations" } + : { from: "migrations", to: "remote" }; return Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; yield* stubManagementApi(requests, serveLinkedDatabase(VALID_REF)); @@ -3012,7 +3243,7 @@ describe("db diff permissions through the real resolver and cache", () => { flags({ from: Option.some(refs.from), to: Option.some(refs.to), - projectRef: Option.some(VALID_REF), + remote: Option.some({ _tag: "Ref", ref: VALID_REF } as const), }), ).pipe(Effect.provide(s.layer)); expect(asLines(requests)).toEqual([poolerRequest, loginRoleRequest]); @@ -3020,7 +3251,7 @@ describe("db diff permissions through the real resolver and cache", () => { expect(yield* fs.exists(`${tmp.current}/supabase/.temp/linked-project.json`)).toBe(true); assertPermissionDrift({ command: "db diff", - activeFlags: { from: refs.from, to: refs.to, "project-ref": VALID_REF }, + activeFlags: { from: refs.from, to: refs.to, remote: VALID_REF }, requests: [...asRecordedRequests(requests), ...s.projectHttp.calls], exact: true, }); @@ -3055,22 +3286,22 @@ describe("db diff permissions through the real resolver and cache", () => { ); }); - it.effect("mints one login role shared by both sides when --from and --to are linked", () => { + it.effect("mints one login role shared by both sides when --from and --to are remote", () => { const s = setup(tmp.current, { realLayers: true, diffSql: "create table e ();\n" }); const requests: Array<StubbedRequest> = []; return Effect.gen(function* () { yield* stubManagementApi(requests, serveLinkedDatabase(VALID_REF)); yield* dbDiff( flags({ - from: Option.some("linked"), - to: Option.some("linked"), - projectRef: Option.some(VALID_REF), + from: Option.some("remote"), + to: Option.some("remote"), + remote: Option.some({ _tag: "Ref", ref: VALID_REF } as const), }), ).pipe(Effect.provide(s.layer)); expect(asLines(requests)).toEqual([poolerRequest, loginRoleRequest]); assertPermissionDrift({ command: "db diff", - activeFlags: { from: "linked", to: "linked", "project-ref": VALID_REF }, + activeFlags: { from: "remote", to: "remote", remote: VALID_REF }, requests: [...asRecordedRequests(requests), ...s.projectHttp.calls], exact: true, }); @@ -3081,23 +3312,22 @@ describe("db diff permissions through the real resolver and cache", () => { ); }); - it.effect("mints one login role shared by the --linked preflight and both linked sides", () => { + it.effect("mints one login role shared by the --remote preflight and both remote sides", () => { const s = setup(tmp.current, { realLayers: true, diffSql: "create table e ();\n" }); const requests: Array<StubbedRequest> = []; return Effect.gen(function* () { yield* stubManagementApi(requests, serveLinkedDatabase(VALID_REF)); yield* dbDiff( flags({ - from: Option.some("linked"), - to: Option.some("linked"), - linked: true, - projectRef: Option.some(VALID_REF), + from: Option.some("remote"), + to: Option.some("remote"), + remote: Option.some({ _tag: "Ref", ref: VALID_REF } as const), }), ).pipe(Effect.provide(s.layer)); expect(asLines(requests)).toEqual([poolerRequest, loginRoleRequest]); assertPermissionDrift({ command: "db diff", - activeFlags: { linked: true, from: "linked", to: "linked", "project-ref": VALID_REF }, + activeFlags: { remote: VALID_REF, from: "remote", to: "remote" }, requests: [...asRecordedRequests(requests), ...s.projectHttp.calls], exact: true, }); 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 74beaaa209..58fdfb7753 100644 --- a/apps/cli/src/commands/db/dump/dump.integration.test.ts +++ b/apps/cli/src/commands/db/dump/dump.integration.test.ts @@ -27,6 +27,11 @@ vi.mock("node:net", (importOriginal) => const reachableDirectHost = withReachableDirectHost(fixtureNet); +import { + BRANCH_PROJECT_REF, + branchListApi, + warningsOf, +} from "../../../../tests/helpers/db-remote-selection.ts"; import { mockOutput, mockTty, processEnvLayer } from "../../../../tests/helpers/mocks.ts"; import { unusedGateway } from "../../../../tests/helpers/unused-stack.ts"; import { withReachableDirectHost } from "../../../../tests/helpers/refuse-invalid-db-hosts.ts"; @@ -281,7 +286,7 @@ function mockResolver(opts: { } /** - * Mocks `ProjectRefResolver`'s `resolveRef`: an explicit `--project-ref` flag wins, a malformed + * Mocks `ProjectRefResolver`'s `resolveRef`: an explicit `--remote=<ref>` wins, a malformed * value fails with `InvalidProjectRefError`, and `opts.projectId` stands in for the project id * consulted before falling back to `opts.ref`. */ @@ -529,6 +534,8 @@ function setup(opts: SetupOpts = {}) { return { layer, out, telemetry, resolver, docker, bundled, cache, projectHttp }; } +const REMOTE_LINKED: DbDumpFlags["remote"] = Option.some({ _tag: "Linked" }); + const flags = (over: Partial<DbDumpFlags> = {}): DbDumpFlags => ({ dryRun: over.dryRun ?? false, dataOnly: over.dataOnly ?? Option.none(), @@ -567,7 +574,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 })).pipe(Effect.exit); expect(Exit.isFailure(exit)).toBe(true); expect(failMessage(exit)).toBe(`required flag(s) "data-only" not set`); }).pipe(Effect.provide(layer)); @@ -578,9 +585,9 @@ 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: true }), - ).pipe(Effect.exit); + const exit = yield* dbDump(flags({ useCopy: true, dataOnly: Option.some(false) })).pipe( + Effect.exit, + ); expect(Exit.isSuccess(exit)).toBe(true); }).pipe(Effect.provide(layer)); }, @@ -589,9 +596,7 @@ 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"] })).pipe(Effect.exit); expect(Exit.isFailure(exit)).toBe(true); expect(failMessage(exit)).toBe(`required flag(s) "data-only" not set`); }).pipe(Effect.provide(layer)); @@ -649,7 +654,11 @@ describe("db dump integration", () => { const { layer, resolver } = setup(); return Effect.gen(function* () { yield* dbDump( - flags({ dbUrl: Option.some("postgresql://x"), password: Option.some("pw"), dryRun: true }), + flags({ + remote: Option.some({ _tag: "DbUrl", url: "postgresql://x" } as const), + password: Option.some("pw"), + dryRun: true, + }), ); expect(resolver.calls[0]).toMatchObject({ target: { kind: "db-url" }, @@ -683,7 +692,7 @@ describe("db dump integration", () => { 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: true })); + yield* dbDump(flags({ dryRun: 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"'); @@ -697,7 +706,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: true, file: Option.some(filePath) })); + yield* dbDump(flags({ dryRun: 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); @@ -709,7 +718,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: true, file: Option.some("") })); + yield* dbDump(flags({ dryRun: 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(); @@ -726,7 +735,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 })).pipe(Effect.exit); expect(Exit.isFailure(exit)).toBe(true); expect(failMessage(exit)).toContain( "Invalid config for remotes.staging.project_id. Must be like: abcdefghijklmnopqrst", @@ -738,7 +747,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: true })); + yield* dbDump(flags()); expect(out.stderrText).toContain("Dumping schemas from local database..."); expect(out.stdoutText).toBe("CREATE SCHEMA public;\n"); expect(docker.lastOpts?.cmd).toEqual([ @@ -756,7 +765,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: true })); + yield* dbDump(flags({ dataOnly: Option.some(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)); @@ -765,7 +774,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 })); expect(docker.lastOpts?.env["EXTRA_FLAGS"]).toBeUndefined(); }).pipe(Effect.provide(layer)); }); @@ -773,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: true })); + yield* dbDump(flags({ roleOnly: Option.some(true) })); expect(out.stderrText).toContain("Dumping roles from local database..."); expect(docker.lastOpts?.env["RESERVED_ROLES"]).toBeDefined(); }).pipe(Effect.provide(layer)); @@ -782,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: true })); + yield* dbDump(flags({ schema: ["public", "auth"] })); expect(docker.lastOpts?.env["EXTRA_FLAGS"]).toBe("--schema=public|auth"); }).pipe(Effect.provide(layer)); }); @@ -792,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: true })); + yield* dbDump(flags({ schema: ["public", "auth"] })); expect(docker.lastOpts?.env["EXTRA_FLAGS"]).toBe("--schema=public|auth"); }).pipe(Effect.provide(layer)); }); @@ -804,7 +813,7 @@ describe("db dump integration", () => { workdir: tmp.current, }); return Effect.gen(function* () { - yield* dbDump(flags({ local: true, file: Option.some("out.sql") })); + yield* dbDump(flags({ 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)); @@ -813,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: true })); + yield* dbDump(flags()); expect(docker.lastOpts?.network).toEqual({ _tag: "named", name: "custom_net" }); }).pipe(Effect.provide(layer)); }); @@ -832,7 +841,7 @@ describe("db dump integration", () => { path.join(tmp.current, "supabase", ".env"), "SUPABASE_NETWORK_ID=dotenv-net\n", ); - yield* dbDump(flags({ local: true })); + yield* dbDump(flags()); expect(docker.lastOpts?.network).toEqual({ _tag: "named", name: "dotenv-net" }); }).pipe((body) => withEnvVar("SUPABASE_NETWORK_ID", undefined, body), Effect.provide(layer)); }, @@ -869,7 +878,10 @@ describe("db dump integration", () => { return Effect.gen(function* () { yield* dbDump( flags({ - dbUrl: Option.some("postgresql://custom:secret@127.0.0.1:54322/custom"), + remote: Option.some({ + _tag: "DbUrl", + url: "postgresql://custom:secret@127.0.0.1:54322/custom", + }), }), ); expect(bundled.lastOpts?.env).toMatchObject({ @@ -893,7 +905,12 @@ describe("db dump integration", () => { const { layer, bundled } = setup({ conn, isLocal: false, platform: "win32", stdout: "" }); return Effect.gen(function* () { yield* dbDump( - flags({ dbUrl: Option.some("postgresql://postgres:postgres@127.0.0.1:55432/postgres") }), + flags({ + remote: Option.some({ + _tag: "DbUrl", + url: "postgresql://postgres:postgres@127.0.0.1:55432/postgres", + }), + }), ); expect(bundled.lastOpts?.runtime).toEqual({ kind: "container", engine: "docker" }); expect(bundled.lastOpts?.network).toBe("host"); @@ -914,7 +931,12 @@ describe("db dump integration", () => { }); return Effect.gen(function* () { yield* dbDump( - flags({ dbUrl: Option.some("postgresql://postgres:postgres@127.0.0.1:55432/postgres") }), + flags({ + remote: Option.some({ + _tag: "DbUrl", + url: "postgresql://postgres:postgres@127.0.0.1:55432/postgres", + }), + }), ); expect(docker.lastOpts?.network).toEqual({ _tag: "named", name: "custom_net" }); expect(docker.lastOpts?.env).toMatchObject({ @@ -933,7 +955,7 @@ describe("db dump integration", () => { resolveFails: true, }); return Effect.gen(function* () { - const exit = yield* dbDump(flags({ linked: true })).pipe(Effect.exit); + const exit = yield* dbDump(flags({ remote: REMOTE_LINKED })).pipe(Effect.exit); expect(Exit.isFailure(exit)).toBe(true); expect(resolver.calls[0]?.target.kind).toBe("linked"); expect(cache.cached).toBe(true); @@ -950,9 +972,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({ remote: Option.some({ _tag: "Ref", ref: FLAG_REF } as const) }), + ).pipe(Effect.exit); expect(Exit.isFailure(exit)).toBe(true); expect(cache.cached).toBe(true); expect(cache.cachedRef).toBe(FLAG_REF); @@ -966,7 +988,7 @@ describe("db dump integration", () => { // 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: true })).pipe(Effect.exit); + const exit = yield* dbDump(flags({ remote: REMOTE_LINKED })).pipe(Effect.exit); expect(Exit.isFailure(exit)).toBe(true); expect(cache.cached).toBe(false); }).pipe(Effect.provide(layer)); @@ -980,12 +1002,12 @@ describe("db dump integration", () => { stdout: "CREATE SCHEMA public;\n", }); return Effect.gen(function* () { - yield* dbDump(flags({ linked: true })); + yield* dbDump(flags({ remote: REMOTE_LINKED })); expect(cache.cached).toBe(true); }).pipe(Effect.provide(layer)); }); - it.effect("dumps the project given via --project-ref without a linked workdir", () => { + it.effect("dumps the project given via --remote=<ref> without a linked workdir", () => { // No fixed `opts.ref`; only the flag can resolve a ref here. const FLAG_REF = "flagflagflagflagflag"; const { layer, cache, resolver } = setup({ @@ -994,14 +1016,14 @@ describe("db dump integration", () => { stdout: "CREATE SCHEMA public;\n", }); return Effect.gen(function* () { - yield* dbDump(flags({ linked: true, projectRef: Option.some(FLAG_REF) })); + yield* dbDump(flags({ remote: Option.some({ _tag: "Ref", ref: FLAG_REF } as const) })); 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)); }); - it.effect("--project-ref overrides an already-linked workdir's project ref", () => { + it.effect("--remote=<ref> overrides an already-linked workdir's project ref", () => { const FLAG_REF = "flagflagflagflagflag"; // A distinct fixed ref proves the flag, not the workdir's own ref, wins. const { layer, cache } = setup({ @@ -1011,28 +1033,25 @@ describe("db dump integration", () => { stdout: "CREATE SCHEMA public;\n", }); return Effect.gen(function* () { - yield* dbDump(flags({ linked: true, projectRef: Option.some(FLAG_REF) })); + yield* dbDump(flags({ remote: Option.some({ _tag: "Ref", ref: FLAG_REF } as const) })); expect(cache.cached).toBe(true); expect(cache.cachedRef).toBe(FLAG_REF); expect(cache.cachedRef).not.toBe("abcdefghijklmnopqrst"); }).pipe(Effect.provide(layer)); }); - it.effect( - "reads a --project-ref that is not a ref as a branch name and fails before resolving or caching", - () => { - const { layer, cache, resolver } = setup(); - return Effect.gen(function* () { - const exit = yield* dbDump(flags({ linked: true, projectRef: Option.some("BADREF") })).pipe( - Effect.exit, - ); - expect(Exit.isFailure(exit)).toBe(true); - expect(failMessage(exit)).toContain('Cannot resolve "BADREF"'); - expect(resolver.calls).toEqual([]); - expect(cache.cached).toBe(false); - }).pipe(Effect.provide(layer)); - }, - ); + it.effect("fails before resolving or caching when --remote=<branch> names no branch", () => { + const { layer, cache, resolver } = setup({ projectId: Option.some(VALID_REF) }); + return Effect.gen(function* () { + const exit = yield* dbDump( + flags({ remote: Option.some({ _tag: "Branch", name: "BADREF" }) }), + ).pipe(Effect.exit, Effect.provide(Layer.merge(layer, branchListApi().layer))); + expect(Exit.isFailure(exit)).toBe(true); + expect(failMessage(exit)).toContain('Branch "BADREF" not found'); + expect(resolver.calls).toEqual([]); + expect(cache.cached).toBe(false); + }); + }); it.effect("rejects --project-ref combined with an explicit --local target", () => { const FLAG_REF = "flagflagflagflagflag"; @@ -1047,12 +1066,126 @@ describe("db dump integration", () => { }).pipe(Effect.provide(layer)); }); + describe("target selection", () => { + const FLAG_PROJECT_REF = "flagflagflagflagflag"; + + const resolvedTarget = ( + selector: Partial<DbDumpFlags>, + options: { readonly branchApi?: ReturnType<typeof branchListApi> } = {}, + ) => { + const { layer, out, resolver } = setup({ + isLocal: true, + projectId: Option.some(VALID_REF), + }); + const provided = + options.branchApi === undefined ? layer : Layer.merge(layer, options.branchApi.layer); + return Effect.gen(function* () { + yield* dbDump(flags({ dryRun: true, ...selector })).pipe(Effect.provide(provided)); + return { target: resolver.calls[0]?.target, warnings: warningsOf(out) }; + }); + }; + + it.effect("targets the local database, with no warning, when nothing selects one", () => + Effect.gen(function* () { + const { target, warnings } = yield* resolvedTarget({}); + expect(target).toEqual({ kind: "local" }); + expect(warnings).toEqual([]); + }), + ); + + it.effect("targets the linked project for a bare --remote, with no warning", () => + Effect.gen(function* () { + const { target, warnings } = yield* resolvedTarget({ remote: REMOTE_LINKED }); + expect(target).toMatchObject({ kind: "linked" }); + expect(warnings).toEqual([]); + }), + ); + + it.effect("targets the project --remote=<ref> names, with no warning", () => + Effect.gen(function* () { + const { target, warnings } = yield* resolvedTarget({ + remote: Option.some({ _tag: "Ref", ref: FLAG_PROJECT_REF } as const), + }); + expect(target).toMatchObject({ kind: "linked", ref: FLAG_PROJECT_REF, source: "flag" }); + expect(warnings).toEqual([]); + }), + ); + + it.effect("targets the database --remote=<db-url> names, with no warning", () => + Effect.gen(function* () { + const { target, warnings } = yield* resolvedTarget({ + remote: Option.some({ + _tag: "DbUrl", + url: "postgresql://postgres:pw@db.example.com/db", + } as const), + }); + expect(target?.kind).toBe("db-url"); + expect(warnings).toEqual([]); + }), + ); + + it.effect("targets the project of the branch --remote=<branch> names", () => { + const branchApi = branchListApi(); + return Effect.gen(function* () { + const { target, warnings } = yield* resolvedTarget( + { remote: Option.some({ _tag: "Branch", name: "feature-x" }) }, + { branchApi }, + ); + expect(target).toMatchObject({ kind: "linked", ref: BRANCH_PROJECT_REF, source: "flag" }); + expect(warnings).toEqual([]); + assertPermissionDrift({ + command: "db dump", + activeFlags: { "dry-run": true, remote: "feature-x" }, + requests: branchApi.requests, + }); + }); + }); + + it.effect.each([ + { + alias: "--linked", + flags: { linked: true }, + kind: "linked", + warning: "--linked is deprecated and will be removed in v4. Use --remote instead", + }, + { + alias: "--project-ref", + flags: { projectRef: Option.some(FLAG_PROJECT_REF) }, + kind: "linked", + warning: "--project-ref is deprecated and will be removed in v4. Use --remote instead", + }, + { + alias: "--db-url", + flags: { dbUrl: Option.some("postgresql://postgres:pw@db.example.com/db") }, + kind: "db-url", + warning: "--db-url is deprecated and will be removed in v4. Use --remote instead", + }, + { + alias: "--local", + flags: { local: true }, + kind: "local", + warning: "--local isn't needed (local is the default) and will be removed in v4", + }, + ] satisfies ReadonlyArray<{ + alias: string; + flags: Partial<DbDumpFlags>; + kind: string; + warning: string; + }>)("keeps $alias working and warns that it is deprecated", ({ flags, kind, warning }) => + Effect.gen(function* () { + const { target, warnings } = yield* resolvedTarget(flags); + expect(target?.kind).toBe(kind); + expect(warnings).toEqual([expect.stringContaining(warning)]); + }), + ); + }); + it.effect("writes the dump to --file and reports the absolute path on stderr", () => { const { layer, out } = setup({ isLocal: true, stdout: "CREATE SCHEMA public;\n" }); return Effect.gen(function* () { const path = yield* Path.Path; const filePath = path.join(tmp.current, "out.sql"); - yield* dbDump(flags({ local: true, file: Option.some(filePath) })); + yield* dbDump(flags({ 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); @@ -1064,7 +1197,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: true })).pipe(Effect.exit); + const exit = yield* dbDump(flags()).pipe(Effect.exit); expect(Exit.isFailure(exit)).toBe(true); expect(failMessage(exit)).toBe("error running container: exit 1"); }).pipe(Effect.provide(layer)); @@ -1091,7 +1224,7 @@ describe("db dump integration", () => { ], }); return Effect.gen(function* () { - yield* dbDump(flags({ linked: true })); + yield* dbDump(flags({ remote: REMOTE_LINKED })); expect(docker.allOpts).toHaveLength(2); expect(resolver.fallbackCalls).toHaveLength(1); expect(resolver.fallbackCalls[0]?.target).toMatchObject({ kind: "linked", ref: VALID_REF }); @@ -1110,7 +1243,7 @@ describe("db dump integration", () => { results: [{ exitCode: 1, stderr: IPV6_STDERR }], }); return Effect.gen(function* () { - const exit = yield* dbDump(flags({ linked: true })).pipe(Effect.exit); + const exit = yield* dbDump(flags({ remote: REMOTE_LINKED })).pipe(Effect.exit); expect(Exit.isFailure(exit)).toBe(true); expect(failMessage(exit)).toBe("error running container: exit 1"); expect(resolver.fallbackCalls).toHaveLength(1); // attempted @@ -1126,7 +1259,7 @@ describe("db dump integration", () => { results: [{ exitCode: 1, stderr: "permission denied for schema public" }], }); return Effect.gen(function* () { - const exit = yield* dbDump(flags({ linked: true })).pipe(Effect.exit); + const exit = yield* dbDump(flags({ remote: REMOTE_LINKED })).pipe(Effect.exit); expect(Exit.isFailure(exit)).toBe(true); expect(failMessage(exit)).toBe("error running container: exit 1"); expect(docker.allOpts).toHaveLength(1); @@ -1142,7 +1275,7 @@ describe("db dump integration", () => { results: [{ exitCode: 1, stderr: IPV6_STDERR }], }); return Effect.gen(function* () { - const exit = yield* dbDump(flags({ linked: true })).pipe(Effect.exit); + const exit = yield* dbDump(flags({ remote: REMOTE_LINKED })).pipe(Effect.exit); expect(Exit.isFailure(exit)).toBe(true); expect(failMessage(exit)).toBe("error running container: exit 1"); expect(resolver.fallbackCalls).toHaveLength(1); @@ -1165,7 +1298,7 @@ describe("db dump integration", () => { ], }); return Effect.gen(function* () { - const exit = yield* dbDump(flags({ linked: true })).pipe(Effect.exit); + const exit = yield* dbDump(flags({ remote: REMOTE_LINKED })).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 @@ -1181,7 +1314,7 @@ describe("db dump integration", () => { results: [{ exitCode: 1, stderr: "permission denied for schema public" }], }); return Effect.gen(function* () { - const exit = yield* dbDump(flags({ linked: true })).pipe(Effect.exit); + const exit = yield* dbDump(flags({ remote: REMOTE_LINKED })).pipe(Effect.exit); expect(Exit.isFailure(exit)).toBe(true); expect(failSuggestion(exit)).toBeUndefined(); }).pipe(Effect.provide(layer)); @@ -1190,7 +1323,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: true })); + yield* dbDump(flags()); expect(out.stdoutText).toBe("CREATE SCHEMA x;\n"); expect(out.messages.find((m) => m.type === "success")).toBeUndefined(); }).pipe(Effect.provide(layer)); @@ -1203,7 +1336,7 @@ describe("db dump integration", () => { stdout: "CREATE SCHEMA x;\n", }); return Effect.gen(function* () { - yield* dbDump(flags({ local: true })); + yield* dbDump(flags()); expect(out.stdoutText).toBe("CREATE SCHEMA x;\n"); }).pipe(Effect.provide(layer)); }); @@ -1278,7 +1411,7 @@ describe("db dump integration", () => { env: { MSYSTEM: "" }, }); return Effect.gen(function* () { - yield* dbDump(flags({ local: true })); + yield* dbDump(flags()); expect(out.stdoutText).toBe(UNICODE_SQL); expect(out.stderrText).toContain("WARNING:"); expect(out.stderrText).toContain(NON_ASCII_WARNING); @@ -1294,7 +1427,7 @@ describe("db dump integration", () => { workdir: tmp.current, }); return Effect.gen(function* () { - yield* dbDump(flags({ local: true, file: Option.some("out.sql") })); + yield* dbDump(flags({ 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); @@ -1318,7 +1451,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: true })); + yield* dbDump(flags()); expect(out.stdoutText).toBe(over.stdout ?? UNICODE_SQL); expect(out.stderrText).not.toContain(NON_ASCII_WARNING); }).pipe(Effect.provide(layer)); @@ -1330,7 +1463,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: true })); + yield* dbDump(flags()); expect(out.stdoutText).toContain('CREATE TABLE IF NOT EXISTS "public"'); }).pipe( Effect.provide( @@ -1351,7 +1484,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 a --project-ref target", () => { + it.effect("mints a login role and fills the cache for a --remote=<ref> target", () => { const { layer, out, projectHttp } = setup({ workdir: tmp.current, realLayers: true, @@ -1361,7 +1494,9 @@ describe("db dump permissions through the real resolver and cache", () => { return Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; yield* stubManagementApi(requests, serveLinkedDatabase(VALID_REF)); - yield* dbDump(flags({ projectRef: Option.some(VALID_REF) })).pipe(Effect.provide(layer)); + yield* dbDump(flags({ remote: Option.some({ _tag: "Ref", ref: VALID_REF } as const) })).pipe( + Effect.provide(layer), + ); expect(out.stdoutText).toBe("CREATE SCHEMA x;\n"); expect(asLines(requests)).toEqual([poolerRequest, loginRoleRequest]); expect(projectHttp.calls.map(({ method, url }) => `${method} ${url}`)).toEqual([ @@ -1370,14 +1505,14 @@ describe("db dump permissions through the real resolver and cache", () => { expect(yield* fs.exists(`${tmp.current}/supabase/.temp/linked-project.json`)).toBe(true); assertPermissionDrift({ command: "db dump", - activeFlags: { "project-ref": VALID_REF }, + activeFlags: { remote: VALID_REF }, requests: [...asRecordedRequests(requests), ...projectHttp.calls], exact: true, }); // The resolve-phase calls belong to the database config block itself, not to the // fallback block, which declares the same operations. assertBlockDrift(dbConfig, { - activeFlags: { linked: true, "project-ref": VALID_REF }, + activeFlags: { remote: VALID_REF }, requests: asRecordedRequests(requests), exact: true, }); @@ -1395,49 +1530,52 @@ describe("db dump permissions through the real resolver and cache", () => { yield* stubManagementApi(requests, serveLinkedDatabase(VALID_REF)); yield* dbDump( flags({ - projectRef: Option.some(VALID_REF), + remote: Option.some({ _tag: "Ref", ref: VALID_REF } as const), password: Option.some("secret"), }), ).pipe(Effect.provide(layer)); expect(asLines(requests)).toEqual([poolerRequest]); assertPermissionDrift({ command: "db dump", - activeFlags: { linked: true, "project-ref": VALID_REF, password: true }, + activeFlags: { remote: VALID_REF, password: true }, requests: [...asRecordedRequests(requests), ...projectHttp.calls], exact: true, }); // The resolve-phase calls belong to the database config block itself, not to the // fallback block, which declares the same operations. assertBlockDrift(dbConfig, { - activeFlags: { linked: true, "project-ref": VALID_REF, password: true }, + activeFlags: { remote: VALID_REF, password: true }, requests: asRecordedRequests(requests), exact: true, }); }).pipe(Effect.scoped, Effect.provide(BunServices.layer)); }); - it.effect("makes no Management API request for --local and caches nothing", () => { - const { layer, projectHttp } = setup({ - workdir: tmp.current, - realLayers: true, - stdout: "CREATE SCHEMA x;\n", - }); - const requests: Array<StubbedRequest> = []; - return Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - yield* stubManagementApi(requests, () => undefined); - 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); - assertPermissionDrift({ - command: "db dump", - activeFlags: { local: true }, - requests: [], - exact: true, + it.effect( + "makes no Management API request for the default local target and caches nothing", + () => { + const { layer, projectHttp } = setup({ + workdir: tmp.current, + realLayers: true, + stdout: "CREATE SCHEMA x;\n", }); - }).pipe(Effect.scoped, Effect.provide(BunServices.layer)); - }); + const requests: Array<StubbedRequest> = []; + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* stubManagementApi(requests, () => undefined); + yield* dbDump(flags()).pipe(Effect.provide(layer)); + expect(requests).toEqual([]); + expect(projectHttp.calls).toEqual([]); + expect(yield* fs.exists(`${tmp.current}/supabase/.temp/linked-project.json`)).toBe(false); + assertPermissionDrift({ + command: "db dump", + activeFlags: {}, + requests: [], + exact: true, + }); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)); + }, + ); it.effect("retries through the pooler after a container IPv6 failure", () => { const { layer, docker, projectHttp } = setup({ @@ -1452,18 +1590,18 @@ describe("db dump permissions through the real resolver and cache", () => { return Effect.gen(function* () { yield* reachableDirectHost; const fs = yield* FileSystem.FileSystem; - // No --project-ref: the linked ref comes from the saved file, so only the fallback block + // No --remote=<ref>: the linked ref comes from the saved file, so only the fallback block // accounts for the pooler config request. 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({ linked: true })).pipe(Effect.provide(layer)); + yield* dbDump(flags({ remote: REMOTE_LINKED })).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]); assertPermissionDrift({ command: "db dump", - activeFlags: { linked: true }, + activeFlags: { remote: 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 9e6e6c7cf7..31f6001bb7 100644 --- a/apps/cli/src/commands/db/lint/lint.integration.test.ts +++ b/apps/cli/src/commands/db/lint/lint.integration.test.ts @@ -11,6 +11,11 @@ vi.mock("node:net", (importOriginal) => ); import { cliConfigValuesTestLayer } from "../../../../tests/helpers/config-values-layer.ts"; +import { + BRANCH_PROJECT_REF, + branchListApi, + warningsOf, +} from "../../../../tests/helpers/db-remote-selection.ts"; import { mockOutput, mockProcessControl } from "../../../../tests/helpers/mocks.ts"; import { VALID_REF, @@ -173,7 +178,7 @@ function mockConnection(opts: { }; } -/** Project-ref resolver mock; an explicit `--project-ref` wins over the workdir's own ref. */ +/** Project-ref resolver mock; an explicit `--remote=<ref>` wins over the workdir's own ref. */ function mockProjectRef() { const calls: Array<string> = []; const layer = Layer.succeed(ProjectRefResolver, { @@ -258,6 +263,8 @@ function setup(opts: SetupOpts = {}) { return { layer, out, resolver, connection, telemetry, processControl, projectRef, cache, http }; } +const REMOTE_LINKED: DbLintFlags["remote"] = Option.some({ _tag: "Linked" }); + const flags = (over: Partial<DbLintFlags> = {}): DbLintFlags => ({ dbUrl: over.dbUrl ?? Option.none<string>(), remote: over.remote ?? Option.none(), @@ -494,10 +501,15 @@ describe("db lint", () => { }).pipe(Effect.provide(layer)); }); - it.effect("connects to the --db-url target", () => { + it.effect("connects to the --remote=<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") })); + yield* dbLint( + flags({ + schema: ["public"], + remote: Option.some({ _tag: "DbUrl", url: "postgres://x" } as const), + }), + ); expect(resolver.resolveInput?.target.kind).toBe("db-url"); }).pipe(Effect.provide(layer)); }); @@ -514,22 +526,24 @@ describe("db lint", () => { }).pipe(Effect.provide(layer)); }); - it.effect("writes the linked-project cache for --linked", () => { + it.effect("writes the linked-project cache for a bare --remote", () => { const { layer, projectRef, cache } = setup({ isLocal: false, checkRows: { public: [] } }); return Effect.gen(function* () { - yield* dbLint(flags({ schema: ["public"], linked: true })); + yield* dbLint(flags({ schema: ["public"], remote: REMOTE_LINKED })); expect(projectRef.calls).toContain("resolveRef"); expect(cache.cached).toBe(true); }).pipe(Effect.provide(layer)); }); - it.effect("lints the project given via --project-ref, overriding the workdir's own ref", () => { + it.effect("lints the project given via --remote=<ref>, overriding the workdir's own ref", () => { // `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: [] } }); return Effect.gen(function* () { - yield* dbLint(flags({ schema: ["public"], projectRef: Option.some(FLAG_REF) })); + yield* dbLint( + flags({ schema: ["public"], remote: Option.some({ _tag: "Ref", ref: FLAG_REF } as const) }), + ); expect(cache.cached).toBe(true); expect(cache.cachedRef).toBe(FLAG_REF); expect(cache.cachedRef).not.toBe(VALID_REF); @@ -547,7 +561,10 @@ describe("db lint", () => { const fs = yield* FileSystem.FileSystem; yield* stubManagementApi(requests, serveLinkedDatabase(VALID_REF)); yield* dbLint( - flags({ schema: ["public"], linked: true, projectRef: Option.some(VALID_REF) }), + flags({ + schema: ["public"], + remote: Option.some({ _tag: "Ref", ref: VALID_REF } as const), + }), ); expect(requests.map(({ method, path }) => `${method} ${path}`)).toEqual([ `GET /v1/projects/${VALID_REF}/config/database/pooler`, @@ -559,7 +576,7 @@ describe("db lint", () => { expect(yield* fs.exists(`${tempRoot.current}/supabase/.temp/linked-project.json`)).toBe(true); assertPermissionDrift({ command: "db lint", - activeFlags: { linked: true, "project-ref": VALID_REF, schema: true }, + activeFlags: { remote: VALID_REF, schema: true }, requests: [...asRecordedRequests(requests), ...http.calls], exact: true, }); @@ -571,14 +588,14 @@ describe("db lint", () => { }); it.effect( - "makes no Management API request for --local through the real resolver and cache", + "makes no Management API request for the default local target through the real resolver and cache", () => { const { layer, http } = setup({ checkRows: { public: [] }, realLayers: true }); const requests: Array<StubbedRequest> = []; return Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; yield* stubManagementApi(requests, () => undefined); - yield* dbLint(flags({ schema: ["public"], local: true })); + yield* dbLint(flags({ schema: ["public"] })); expect(requests).toEqual([]); expect(http.calls).toEqual([]); expect(yield* fs.exists(`${tempRoot.current}/supabase/.temp/linked-project.json`)).toBe( @@ -586,7 +603,7 @@ describe("db lint", () => { ); assertPermissionDrift({ command: "db lint", - activeFlags: { local: true, schema: true }, + activeFlags: { schema: true }, requests: [], exact: true, }); @@ -619,7 +636,7 @@ describe("db lint", () => { ); }); - it.effect("--project-ref alone reaches the linked database through the real resolver", () => { + it.effect("--remote=<ref> alone reaches the linked database through the real resolver", () => { const { layer, http } = setup({ isLocal: false, checkRows: { public: [] }, @@ -629,7 +646,12 @@ describe("db lint", () => { return Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; yield* stubManagementApi(requests, serveLinkedDatabase(VALID_REF)); - yield* dbLint(flags({ schema: ["public"], projectRef: Option.some(VALID_REF) })); + yield* dbLint( + flags({ + schema: ["public"], + remote: Option.some({ _tag: "Ref", ref: VALID_REF } as const), + }), + ); expect(requests.map(({ method, path }) => `${method} ${path}`)).toEqual([ `GET /v1/projects/${VALID_REF}/config/database/pooler`, `POST /v1/projects/${VALID_REF}/cli/login-role`, @@ -637,7 +659,7 @@ describe("db lint", () => { expect(yield* fs.exists(`${tempRoot.current}/supabase/.temp/linked-project.json`)).toBe(true); assertPermissionDrift({ command: "db lint", - activeFlags: { "project-ref": VALID_REF, schema: true }, + activeFlags: { remote: VALID_REF, schema: true }, requests: [...asRecordedRequests(requests), ...http.calls], exact: true, }); @@ -698,3 +720,106 @@ describe("db lint", () => { }).pipe(Effect.provide(layer)); }); }); + +describe("db lint target selection", () => { + const FLAG_PROJECT_REF = "flagflagflagflagflag"; + + const resolvedTarget = ( + selector: Partial<DbLintFlags>, + options: { readonly branchApi?: ReturnType<typeof branchListApi> } = {}, + ) => { + const { layer, out, resolver } = setup({ checkRows: { public: [] } }); + const provided = + options.branchApi === undefined ? layer : Layer.merge(layer, options.branchApi.layer); + return Effect.gen(function* () { + yield* dbLint(flags({ schema: ["public"], ...selector })).pipe(Effect.provide(provided)); + return { target: resolver.resolveInput?.target, warnings: warningsOf(out) }; + }); + }; + + it.effect("targets the local database, with no warning, when nothing selects one", () => + Effect.gen(function* () { + const { target, warnings } = yield* resolvedTarget({}); + expect(target).toEqual({ kind: "local" }); + expect(warnings).toEqual([]); + }), + ); + + it.effect("targets the project --remote=<ref> names, with no warning", () => + Effect.gen(function* () { + const { target, warnings } = yield* resolvedTarget({ + remote: Option.some({ _tag: "Ref", ref: FLAG_PROJECT_REF } as const), + }); + expect(target).toMatchObject({ kind: "linked", ref: FLAG_PROJECT_REF, source: "flag" }); + expect(warnings).toEqual([]); + }), + ); + + it.effect("targets the database --remote=<db-url> names, with no warning", () => + Effect.gen(function* () { + const { target, warnings } = yield* resolvedTarget({ + remote: Option.some({ + _tag: "DbUrl", + url: "postgresql://postgres:pw@db.example.com/db", + } as const), + }); + expect(target?.kind).toBe("db-url"); + expect(warnings).toEqual([]); + }), + ); + + it.effect("targets the project of the branch --remote=<branch> names", () => { + const branchApi = branchListApi(); + return Effect.gen(function* () { + const { target, warnings } = yield* resolvedTarget( + { remote: Option.some({ _tag: "Branch", name: "feature-x" }) }, + { branchApi }, + ); + expect(target).toMatchObject({ kind: "linked", ref: BRANCH_PROJECT_REF, source: "flag" }); + expect(warnings).toEqual([]); + assertPermissionDrift({ + command: "db lint", + activeFlags: { schema: true, remote: "feature-x" }, + requests: branchApi.requests, + }); + }); + }); + + it.effect.each([ + { + alias: "--linked", + flags: { linked: true }, + kind: "linked", + warning: "--linked is deprecated and will be removed in v4. Use --remote instead", + }, + { + alias: "--project-ref", + flags: { projectRef: Option.some(FLAG_PROJECT_REF) }, + kind: "linked", + warning: "--project-ref is deprecated and will be removed in v4. Use --remote instead", + }, + { + alias: "--db-url", + flags: { dbUrl: Option.some("postgresql://postgres:pw@db.example.com/db") }, + kind: "db-url", + warning: "--db-url is deprecated and will be removed in v4. Use --remote instead", + }, + { + alias: "--local", + flags: { local: true }, + kind: "local", + warning: "--local isn't needed (local is the default) and will be removed in v4", + }, + ] satisfies ReadonlyArray<{ + alias: string; + flags: Partial<DbLintFlags>; + kind: string; + warning: string; + }>)("keeps $alias working and warns that it is deprecated", ({ flags, kind, warning }) => + Effect.gen(function* () { + const { target, warnings } = yield* resolvedTarget(flags); + expect(target?.kind).toBe(kind); + expect(warnings).toEqual([expect.stringContaining(warning)]); + }), + ); +}); 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 8ec3e848e0..9e72cd0c8d 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,11 @@ vi.mock("node:net", (importOriginal) => const reachableDirectHost = withReachableDirectHost(fixtureNet); import { stripAnsi } from "../../../../tests/helpers/ansi.ts"; +import { + BRANCH_PROJECT_REF, + branchListApi, + warningsOf, +} from "../../../../tests/helpers/db-remote-selection.ts"; import { describeHostedConsent } from "../../../../tests/helpers/hosted-consent.ts"; import { withReachableDirectHost } from "../../../../tests/helpers/refuse-invalid-db-hosts.ts"; import { @@ -185,7 +190,7 @@ interface SetupOpts { // `Option.none()` to exercise the config.toml/workdir-basename fallback. readonly projectId?: Option.Option<string>; // Simulates an unlinked workdir: the project-ref resolver fails with - // `ProjectRefNotLinkedError` absent an explicit `--project-ref` flag. + // `ProjectRefNotLinkedError` absent an explicit `--remote=<ref>`. readonly linkedFails?: boolean; // Swaps in the stateful Docker model (real `stop`/`cp`/`start`), required by the // shadow baseline cache tests. @@ -570,6 +575,8 @@ function setup(workdir: string, opts: SetupOpts = {}) { }; } +const REMOTE_LINKED: DbPullFlags["remote"] = Option.some({ _tag: "Linked" }); + const flags = (over: Partial<DbPullFlags> = {}): DbPullFlags => ({ name: over.name ?? Option.none(), declarative: over.declarative ?? Option.none(), @@ -578,11 +585,7 @@ const flags = (over: Partial<DbPullFlags> = {}): DbPullFlags => ({ strictCoverage: over.strictCoverage ?? false, schema: over.schema ?? [], dbUrl: over.dbUrl ?? Option.none(), - remote: - over.remote ?? - (over.local === true || over.linked === true || Option.isSome(over.dbUrl ?? Option.none()) - ? Option.none() - : Option.some({ _tag: "Linked" as const })), + remote: over.remote ?? Option.none(), linked: over.linked ?? false, local: over.local ?? false, projectRef: over.projectRef ?? Option.none(), @@ -636,7 +639,9 @@ describe("db pull", () => { yes: true, }); return Effect.gen(function* () { - yield* dbPull(flags({ diffEngine: Option.some("pg-delta"), strictCoverage: true })); + yield* dbPull( + flags({ remote: REMOTE_LINKED, diffEngine: Option.some("pg-delta"), strictCoverage: true }), + ); const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const dir = path.join(tmp.current, "supabase", "migrations"); @@ -665,7 +670,7 @@ describe("db pull", () => { }).pipe(Effect.provide(s.layer)); }); - it.effect("pulls from the project given via --project-ref without a linked workdir", () => { + it.effect("pulls from the project given via --remote=<ref> without a linked workdir", () => { // `linkedFails: true` simulates an unlinked workdir; only the flag can resolve a ref. const FLAG_REF = "flagflagflagflagflag"; const s = setup(tmp.current, { @@ -678,14 +683,17 @@ describe("db pull", () => { }); return Effect.gen(function* () { yield* dbPull( - flags({ diffEngine: Option.some("pg-delta"), projectRef: Option.some(FLAG_REF) }), + flags({ + diffEngine: Option.some("pg-delta"), + remote: Option.some({ _tag: "Ref", ref: FLAG_REF } as const), + }), ); expect(s.cache.cached).toBe(true); expect(s.cache.cachedRef).toBe(FLAG_REF); }).pipe(Effect.provide(s.layer)); }); - it.effect("--project-ref overrides an already-linked workdir's project ref", () => { + it.effect("--remote=<ref> overrides an already-linked workdir's project ref", () => { const FLAG_REF = "flagflagflagflagflag"; const s = setup(tmp.current, { migrations: ["20240101000000"], @@ -697,7 +705,10 @@ describe("db pull", () => { }); return Effect.gen(function* () { yield* dbPull( - flags({ diffEngine: Option.some("pg-delta"), projectRef: Option.some(FLAG_REF) }), + flags({ + diffEngine: Option.some("pg-delta"), + remote: Option.some({ _tag: "Ref", ref: FLAG_REF } as const), + }), ); expect(s.cache.cached).toBe(true); expect(s.cache.cachedRef).toBe(FLAG_REF); @@ -705,7 +716,7 @@ describe("db pull", () => { }).pipe(Effect.provide(s.layer)); }); - it.effect("rejects --project-ref combined with an explicit --local target", () => { + it.effect("rejects the deprecated --project-ref combined with --local", () => { const FLAG_REF = "flagflagflagflagflag"; const s = setup(tmp.current, {}); return Effect.gen(function* () { @@ -718,16 +729,145 @@ describe("db pull", () => { }).pipe(Effect.provide(s.layer)); }); - it.effect("honors --project-ref on the declarative export", () => { + it.effect("honors --remote=<ref> on the declarative export", () => { const FLAG_REF = "flagflagflagflagflag"; const s = setup(tmp.current, { edgeStdout: EXPORT_JSON }); return Effect.gen(function* () { - yield* dbPull(flags({ declarative: Option.some(true), projectRef: Option.some(FLAG_REF) })); + yield* dbPull( + flags({ + declarative: Option.some(true), + remote: Option.some({ _tag: "Ref", ref: FLAG_REF } as const), + }), + ); expect(s.engineCalls[0]?.operation).toBe("export"); expect(s.engineCalls[0]?.projectRef).toBe(FLAG_REF); }).pipe(Effect.provide(s.layer)); }); + describe("target selection", () => { + const FLAG_PROJECT_REF = "flagflagflagflagflag"; + + const resolvedTarget = ( + selector: Partial<DbPullFlags>, + options: { readonly branchApi?: ReturnType<typeof branchListApi> } = {}, + ) => { + const s = setup(tmp.current, { edgeStdout: EXPORT_JSON, projectId: Option.some(VALID_REF) }); + const provided = + options.branchApi === undefined ? s.layer : Layer.merge(s.layer, options.branchApi.layer); + return Effect.gen(function* () { + yield* dbPull(flags({ declarative: Option.some(true), ...selector })).pipe( + Effect.provide(provided), + ); + return { target: s.resolveCalls[0]?.target, warnings: warningsOf(s.out) }; + }); + }; + + it.effect("targets the local database, with no warning, when nothing selects one", () => + Effect.gen(function* () { + const { target, warnings } = yield* resolvedTarget({}); + expect(target).toEqual({ kind: "local" }); + expect(warnings).toEqual([]); + }), + ); + + it.effect("targets the linked project for a bare --remote, with no warning", () => + Effect.gen(function* () { + const { target, warnings } = yield* resolvedTarget({ remote: REMOTE_LINKED }); + expect(target).toMatchObject({ kind: "linked", ref: VALID_REF, source: "file" }); + expect(warnings).toEqual([]); + }), + ); + + it.effect("targets the project --remote=<ref> names, with no warning", () => + Effect.gen(function* () { + const { target, warnings } = yield* resolvedTarget({ + remote: Option.some({ _tag: "Ref", ref: FLAG_PROJECT_REF } as const), + }); + expect(target).toMatchObject({ kind: "linked", ref: FLAG_PROJECT_REF, source: "flag" }); + expect(warnings).toEqual([]); + }), + ); + + it.effect("targets the database --remote=<db-url> names, with no warning", () => + Effect.gen(function* () { + const { target, warnings } = yield* resolvedTarget({ + remote: Option.some({ + _tag: "DbUrl", + url: "postgresql://postgres:pw@db.example.com/db", + } as const), + }); + expect(target?.kind).toBe("db-url"); + expect(warnings).toEqual([]); + }), + ); + + it.effect("targets the project of the branch --remote=<branch> names", () => { + const branchApi = branchListApi(); + return Effect.gen(function* () { + const { target, warnings } = yield* resolvedTarget( + { remote: Option.some({ _tag: "Branch", name: "feature-x" }) }, + { branchApi }, + ); + expect(target).toMatchObject({ kind: "linked", ref: BRANCH_PROJECT_REF, source: "flag" }); + expect(warnings).toEqual([]); + assertPermissionDrift({ + command: "db pull", + activeFlags: { declarative: true, remote: "feature-x" }, + requests: branchApi.requests, + }); + }); + }); + + it.effect("rejects --remote combined with --local", () => { + const s = setup(tmp.current, {}); + return Effect.gen(function* () { + const error = yield* dbPull(flags({ remote: REMOTE_LINKED, local: true })).pipe( + Effect.flip, + ); + expect(error).toMatchObject({ _tag: "TargetSelectorConflictError" }); + expect(s.resolveCalls).toEqual([]); + }).pipe(Effect.provide(s.layer)); + }); + + it.effect.each([ + { + alias: "--linked", + flags: { linked: true }, + kind: "linked", + warning: "--linked is deprecated and will be removed in v4. Use --remote instead", + }, + { + alias: "--project-ref", + flags: { projectRef: Option.some(FLAG_PROJECT_REF) }, + kind: "linked", + warning: "--project-ref is deprecated and will be removed in v4. Use --remote instead", + }, + { + alias: "--db-url", + flags: { dbUrl: Option.some("postgresql://postgres:pw@db.example.com/db") }, + kind: "db-url", + warning: "--db-url is deprecated and will be removed in v4. Use --remote instead", + }, + { + alias: "--local", + flags: { local: true }, + kind: "local", + warning: "--local isn't needed (local is the default) and will be removed in v4", + }, + ] satisfies ReadonlyArray<{ + alias: string; + flags: Partial<DbPullFlags>; + kind: string; + warning: string; + }>)("keeps $alias working and warns that it is deprecated", ({ flags, kind, warning }) => + Effect.gen(function* () { + const { target, warnings } = yield* resolvedTarget(flags); + expect(target?.kind).toBe(kind); + expect(warnings).toEqual([expect.stringContaining(warning)]); + }), + ); + }); + it.effect( "a pg-delta plan with transaction boundaries writes one ordered migration file per unit", () => { @@ -747,7 +887,7 @@ describe("db pull", () => { yes: true, }); return Effect.gen(function* () { - yield* dbPull(flags({ diffEngine: Option.some("pg-delta") })); + yield* dbPull(flags({ remote: REMOTE_LINKED, diffEngine: Option.some("pg-delta") })); const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const dir = path.join(tmp.current, "supabase", "migrations"); @@ -797,7 +937,7 @@ describe("db pull", () => { yes: true, }); return Effect.gen(function* () { - yield* dbPull(flags({ diffEngine: Option.some("pg-delta") })); + yield* dbPull(flags({ remote: REMOTE_LINKED, diffEngine: Option.some("pg-delta") })); const success = s.out.messages.find((m) => m.type === "success"); const data = yield* Schema.decodeUnknownEffect(PulledSchemaFiles)(success?.data); expect(data.schemaFiles).toHaveLength(3); @@ -817,7 +957,9 @@ describe("db pull", () => { yes: true, }); return Effect.gen(function* () { - const error = yield* dbPull(flags({ diffEngine: Option.some("pg-delta") })).pipe(Effect.flip); + const error = yield* dbPull( + flags({ remote: REMOTE_LINKED, diffEngine: Option.some("pg-delta") }), + ).pipe(Effect.flip); expect(error.message).toContain("failed to parse pg-delta diff output"); expect(error.message).not.toContain("No schema changes found"); }).pipe(Effect.provide(s.layer)); @@ -853,7 +995,7 @@ describe("db pull", () => { yes: true, }); return Effect.gen(function* () { - yield* dbPull(flags()); + yield* dbPull(flags({ remote: REMOTE_LINKED })); expect(streamText(s.out, "stderr")).toContain( "schema_paths no longer changes the migrations baseline", ); @@ -878,7 +1020,7 @@ describe("db pull", () => { yes: true, }); return Effect.gen(function* () { - yield* dbPull(flags()); + yield* dbPull(flags({ remote: REMOTE_LINKED })); // `enabled = false` selecting migra is proven by `edgeStdout` parsing as raw SQL // (a pg-delta selection would instead try — and fail — to `JSON.parse` it). expect(s.shadowSpawned.filter((call) => call.args[0] === "create")).toHaveLength(1); @@ -906,7 +1048,7 @@ describe("db pull", () => { env: { SUPABASE_EXPERIMENTAL_PGDELTA_ENABLED: "false" }, }); return Effect.gen(function* () { - yield* dbPull(flags()); + yield* dbPull(flags({ remote: REMOTE_LINKED })); expect(s.engineCalls).toHaveLength(0); expect(s.edgeCalls).toHaveLength(1); }).pipe(Effect.provide(s.layer)); @@ -922,7 +1064,7 @@ describe("db pull", () => { env: { SUPABASE_PROJECT_ID: "test" }, }); return Effect.gen(function* () { - yield* dbPull(flags({ diffEngine: Option.some("migra") })); + yield* dbPull(flags({ remote: REMOTE_LINKED, diffEngine: Option.some("migra") })); expect(s.edgeCalls).toHaveLength(1); expect(s.edgeCalls[0]?.binds).toEqual(["supabase_edge_runtime_test:/root/.cache/deno:rw"]); expect(s.spawnedBeforeEdgeRun[0]).toContainEqual([ @@ -946,7 +1088,7 @@ describe("db pull", () => { files: { "supabase/.env": "BITBUCKET_CLONE_DIR=/opt/atlassian/pipelines/agent/build\n" }, }); return Effect.gen(function* () { - yield* dbPull(flags({ diffEngine: Option.some("migra") })); + yield* dbPull(flags({ remote: REMOTE_LINKED, diffEngine: Option.some("migra") })); expect(s.edgeCalls).toHaveLength(1); expect( s.shadowSpawned.filter( @@ -984,7 +1126,7 @@ describe("db pull", () => { edgeStdout: "", }); return Effect.gen(function* () { - const error = yield* dbPull(flags()).pipe(Effect.flip); + const error = yield* dbPull(flags({ remote: REMOTE_LINKED })).pipe(Effect.flip); expect(error.message).toContain("failed to read TLS cert"); expect(s.resolveCalls).toHaveLength(0); expect(s.connectedDatabases).toHaveLength(0); @@ -995,7 +1137,9 @@ describe("db pull", () => { it.effect("pull --declarative exports declarative files (no migration)", () => { const s = setup(tmp.current, { edgeStdout: EXPORT_JSON }); return Effect.gen(function* () { - yield* dbPull(flags({ declarative: Option.some(true), strictCoverage: true })); + yield* dbPull( + flags({ remote: REMOTE_LINKED, declarative: Option.some(true), strictCoverage: true }), + ); expect(s.engineCalls[0]?.operation).toBe("export"); expect(s.engineCalls[0]?.strictCoverage).toBe(true); expect(s.edgeRunCount).toBe(0); @@ -1031,7 +1175,7 @@ describe("db pull", () => { it.effect("declarative export does not provision a baseline shadow", () => { const s = setup(tmp.current, {}); return Effect.gen(function* () { - yield* dbPull(flags({ declarative: Option.some(true) })); + yield* dbPull(flags({ remote: REMOTE_LINKED, declarative: Option.some(true) })); expect(s.engineCalls[0]?.operation).toBe("export"); expect(s.shadowSpawned.filter((call) => call.args[0] === "create")).toEqual([]); }).pipe(Effect.provide(s.layer)); @@ -1043,7 +1187,7 @@ describe("db pull", () => { ])('declarative export passes PGDELTA_DEBUG="$value" to pg-delta', ({ value, debug }) => { const s = setup(tmp.current, {}); return Effect.gen(function* () { - yield* dbPull(flags({ declarative: Option.some(true) })); + yield* dbPull(flags({ remote: REMOTE_LINKED, declarative: Option.some(true) })); expect(s.engineCalls.map((call) => call.debug)).toEqual([debug]); }).pipe(Effect.provide(s.layer), (body) => withConfigEnv({ PGDELTA_DEBUG: value }, body)); }); @@ -1060,7 +1204,7 @@ describe("db pull", () => { edgeStdout: EXPORT_JSON, }); return Effect.gen(function* () { - yield* dbPull(flags({ declarative: Option.some(true) })); + yield* dbPull(flags({ remote: REMOTE_LINKED, declarative: Option.some(true) })); const path = yield* Path.Path; const config = yield* readFileText(path.join(tmp.current, "supabase", "config.toml")); expect(config).toContain("[db.migrations]"); @@ -1080,7 +1224,7 @@ describe("db pull", () => { edgeStdout: EXPORT_JSON, }); return Effect.gen(function* () { - yield* dbPull(flags({ declarative: Option.some(true) })); + yield* dbPull(flags({ remote: REMOTE_LINKED, declarative: Option.some(true) })); const path = yield* Path.Path; const config = yield* readFileText(path.join(tmp.current, "supabase", "config.toml")); expect(config).toBe(original); @@ -1098,7 +1242,7 @@ describe("db pull", () => { edgeStdout: EXPORT_JSON, }); return Effect.gen(function* () { - yield* dbPull(flags({ declarative: Option.some(true) })); + yield* dbPull(flags({ remote: REMOTE_LINKED, declarative: Option.some(true) })); const path = yield* Path.Path; const config = yield* readFileText(path.join(tmp.current, "supabase", "config.toml")); expect(config).toContain('schema_paths = [\n "schemas",\n]'); @@ -1115,7 +1259,7 @@ describe("db pull", () => { return Effect.gen(function* () { const path = yield* Path.Path; const expectedProjectId = path.basename(tmp.current); - yield* dbPull(flags({ declarative: Option.some(true) })); + yield* dbPull(flags({ remote: REMOTE_LINKED, declarative: Option.some(true) })); expect(s.engineCalls[0]?.projectId).toBe(expectedProjectId); }).pipe(Effect.provide(s.layer)); }); @@ -1138,7 +1282,7 @@ describe("db pull", () => { projectId: Option.some("unrelated-env-project"), }); return Effect.gen(function* () { - yield* dbPull(flags({ declarative: Option.some(true), linked: true })); + yield* dbPull(flags({ declarative: Option.some(true), remote: REMOTE_LINKED })); expect(s.engineCalls[0]?.projectId).toBe("abcdefghijklmnopqrst"); }).pipe(Effect.provide(s.layer)); }, @@ -1150,7 +1294,7 @@ describe("db pull", () => { remoteVersions: ["20240101000000"], }); return Effect.gen(function* () { - const exit = yield* dbPull(flags()).pipe(Effect.exit); + const exit = yield* dbPull(flags({ remote: REMOTE_LINKED })).pipe(Effect.exit); expect(Exit.isFailure(exit)).toBe(true); }).pipe(Effect.provide(s.layer)); }); @@ -1165,7 +1309,7 @@ describe("db pull", () => { yes: true, }); return Effect.gen(function* () { - yield* dbPull(flags({ diffEngine: Option.some("migra") })); + yield* dbPull(flags({ remote: REMOTE_LINKED, diffEngine: Option.some("migra") })); // pg_dump ran with the schema-dump env (internal-schema exclude + comment strip). expect(s.dumpCalls).toHaveLength(1); expect(s.dumpCalls[0]?.env["EXTRA_SED"]).toBe("/^--/d"); @@ -1203,7 +1347,7 @@ describe("db pull", () => { yes: true, }); return Effect.gen(function* () { - yield* dbPull(flags({ diffEngine: Option.some("migra") })); + yield* dbPull(flags({ remote: REMOTE_LINKED, diffEngine: Option.some("migra") })); const success = s.out.messages.find((m) => m.type === "success"); // `schemaWritten` is the real native path (not null as when delegated). expect(success?.data).toMatchObject({ @@ -1227,7 +1371,9 @@ describe("db pull", () => { yes: true, }); return Effect.gen(function* () { - const exit = yield* dbPull(flags({ diffEngine: Option.some("migra") })).pipe(Effect.exit); + const exit = yield* dbPull( + flags({ remote: REMOTE_LINKED, diffEngine: Option.some("migra") }), + ).pipe(Effect.exit); expect(Exit.isSuccess(exit)).toBe(true); const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -1252,7 +1398,9 @@ describe("db pull", () => { yes: true, }); return Effect.gen(function* () { - const error = yield* dbPull(flags({ diffEngine: Option.some("migra") })).pipe(Effect.flip); + const error = yield* dbPull( + flags({ remote: REMOTE_LINKED, diffEngine: Option.some("migra") }), + ).pipe(Effect.flip); expect(error.message).toContain("Cannot read properties of undefined"); expect(error.message).not.toContain("No schema changes found"); }).pipe(Effect.provide(s.layer)); @@ -1270,7 +1418,9 @@ describe("db pull", () => { yes: true, }); return Effect.gen(function* () { - const error = yield* dbPull(flags({ diffEngine: Option.some("migra") })).pipe(Effect.flip); + const error = yield* dbPull( + flags({ remote: REMOTE_LINKED, diffEngine: Option.some("migra") }), + ).pipe(Effect.flip); expect(error.message).toBe("No schema changes found"); const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -1291,7 +1441,9 @@ describe("db pull", () => { return Effect.gen(function* () { const path = yield* Path.Path; const dir = path.join(tmp.current, "supabase", "migrations"); - const first = yield* dbPull(flags({ diffEngine: Option.some("migra") })).pipe(Effect.flip); + const first = yield* dbPull( + flags({ remote: REMOTE_LINKED, diffEngine: Option.some("migra") }), + ).pipe(Effect.flip); expect(first.message).toBe("No schema changes found"); const fs = yield* FileSystem.FileSystem; expect((yield* fs.exists(dir)) ? yield* fs.readDirectory(dir) : []).toEqual([]); @@ -1299,7 +1451,9 @@ describe("db pull", () => { // Without the cleanup, the leftover seed file would desync this second run's // reconciliation: an empty remote history plus a local-only version is a // `DbPullMigrationConflictError`, not a repeat "No schema changes found". - const second = yield* dbPull(flags({ diffEngine: Option.some("migra") })).pipe(Effect.flip); + const second = yield* dbPull( + flags({ remote: REMOTE_LINKED, diffEngine: Option.some("migra") }), + ).pipe(Effect.flip); expect(second).toMatchObject({ _tag: "DbPullInSyncError", message: "No schema changes found", @@ -1325,7 +1479,9 @@ describe("db pull", () => { yes: true, }); return Effect.gen(function* () { - const error = yield* dbPull(flags({ diffEngine: Option.some("migra") })).pipe(Effect.flip); + const error = yield* dbPull( + flags({ remote: REMOTE_LINKED, diffEngine: Option.some("migra") }), + ).pipe(Effect.flip); expect(error.message).toBe("No schema changes found"); expect(s.dumpCalls).toHaveLength(2); // direct attempt + pooler retry expect(s.historyUpserts).toHaveLength(0); // no migration-history row written @@ -1341,7 +1497,9 @@ describe("db pull", () => { yes: true, }); return Effect.gen(function* () { - const error = yield* dbPull(flags({ diffEngine: Option.some("migra") })).pipe(Effect.flip); + const error = yield* dbPull( + flags({ remote: REMOTE_LINKED, diffEngine: Option.some("migra") }), + ).pipe(Effect.flip); expect(error.message).toContain("error running container: exit 1"); // The diff pass never ran — the dump failure aborts before provisioning a shadow. expect(s.shadowSpawned.filter((c) => c.args[0] === "create")).toEqual([]); @@ -1349,7 +1507,7 @@ describe("db pull", () => { }); it.effect("an initial-pull dump retries via the IPv4 pooler on an IPv6 failure", () => { - // A `--linked` direct-host dump that fails over IPv6 retries once through the + // A `--remote` direct-host dump that fails over IPv6 retries once through the // transaction pooler. const s = setup(tmp.current, { remoteVersions: [], @@ -1360,7 +1518,7 @@ describe("db pull", () => { yes: true, }); return Effect.gen(function* () { - yield* dbPull(flags({ diffEngine: Option.some("migra") })); + yield* dbPull(flags({ remote: REMOTE_LINKED, diffEngine: Option.some("migra") })); expect(s.dumpCalls).toHaveLength(2); // direct attempt + pooler retry expect(s.poolerFallbackCalls).toHaveLength(1); const err = streamText(s.out, "stderr"); @@ -1380,7 +1538,9 @@ describe("db pull", () => { yes: true, }); return Effect.gen(function* () { - const error = yield* dbPull(flags({ diffEngine: Option.some("migra") })).pipe(Effect.flip); + const error = yield* dbPull( + flags({ remote: REMOTE_LINKED, diffEngine: Option.some("migra") }), + ).pipe(Effect.flip); expect(error.message).toContain("error running container: exit 1"); expect(s.poolerFallbackCalls).toHaveLength(1); // gate checked, no pooler resolved expect(streamText(s.out, "stderr")).not.toContain("Retrying via the IPv4 connection pooler"); @@ -1396,7 +1556,7 @@ describe("db pull", () => { return Effect.gen(function* () { // The message and non-zero exit are the contract; the generic --debug footer is // replaced with this explanation instead. - const error = yield* dbPull(flags()).pipe(Effect.flip); + const error = yield* dbPull(flags({ remote: REMOTE_LINKED })).pipe(Effect.flip); expect(error).toMatchObject({ _tag: "DbPullInSyncError", message: "No schema changes found", @@ -1414,7 +1574,9 @@ describe("db pull", () => { yes: true, }); return Effect.gen(function* () { - const error = yield* dbPull(flags({ diffEngine: Option.some("pg-delta") })).pipe(Effect.flip); + const error = yield* dbPull( + flags({ remote: REMOTE_LINKED, diffEngine: Option.some("pg-delta") }), + ).pipe(Effect.flip); expect(error.message).toBe("No schema changes found"); expect(s.engineCalls.map((call) => call.debug)).toEqual([false]); const fs = yield* FileSystem.FileSystem; @@ -1444,7 +1606,7 @@ describe("db pull", () => { }); const error = yield* withConfigEnv( { PGDELTA_DEBUG: "1" }, - dbPull(flags({ diffEngine: Option.some("pg-delta") })).pipe( + dbPull(flags({ remote: REMOTE_LINKED, diffEngine: Option.some("pg-delta") })).pipe( Effect.flip, Effect.provide(s.layer), ), @@ -1464,7 +1626,7 @@ describe("db pull", () => { promptConfirmResponses: [true], }); return Effect.gen(function* () { - yield* dbPull(flags({ diffEngine: Option.some("migra") })); + yield* dbPull(flags({ remote: REMOTE_LINKED, diffEngine: Option.some("migra") })); expect(s.out.promptConfirmCalls).toHaveLength(1); expect(s.out.promptConfirmCalls[0]?.message).toContain( "Update the remote migration history table", @@ -1484,7 +1646,9 @@ describe("db pull", () => { promptConfirmResponses: [false], }); return Effect.gen(function* () { - const error = yield* dbPull(flags({ diffEngine: Option.some("migra") })).pipe(Effect.flip); + const error = yield* dbPull( + flags({ remote: REMOTE_LINKED, diffEngine: Option.some("migra") }), + ).pipe(Effect.flip); expect(error).toBeInstanceOf(HostedMutationDeclinedError); expect(yield* migrationFiles(tmp.current)).toEqual(["20240101000000_local.sql"]); expect(s.historyUpserts.length).toBe(0); @@ -1502,7 +1666,9 @@ describe("db pull", () => { promptConfirmResponses: [false], }); return Effect.gen(function* () { - const error = yield* dbPull(flags({ diffEngine: Option.some("migra") })).pipe(Effect.flip); + const error = yield* dbPull( + flags({ remote: REMOTE_LINKED, diffEngine: Option.some("migra") }), + ).pipe(Effect.flip); expect(error).toBeInstanceOf(HostedMutationDeclinedError); expect(s.dumpCalls).toHaveLength(0); expect(yield* migrationFiles(tmp.current)).toEqual([]); @@ -1521,7 +1687,9 @@ describe("db pull", () => { promptConfirmResponses: [false], }); return Effect.gen(function* () { - const error = yield* dbPull(flags({ diffEngine: Option.some("pg-delta") })).pipe(Effect.flip); + const error = yield* dbPull( + flags({ remote: REMOTE_LINKED, diffEngine: Option.some("pg-delta") }), + ).pipe(Effect.flip); expect(error).toBeInstanceOf(HostedMutationDeclinedError); expect(streamText(s.out, "stderr")).toContain( "Write 2 migration files to supabase/migrations.", @@ -1539,7 +1707,9 @@ describe("db pull", () => { stdinIsTty: true, }); return Effect.gen(function* () { - const error = yield* dbPull(flags({ diffEngine: Option.some("migra") })).pipe(Effect.flip); + const error = yield* dbPull( + flags({ remote: REMOTE_LINKED, diffEngine: Option.some("migra") }), + ).pipe(Effect.flip); expect(error).toMatchObject({ _tag: "DbPullInSyncError" }); expect(s.out.promptConfirmCalls).toHaveLength(0); }).pipe(Effect.provide(s.layer)); @@ -1554,7 +1724,9 @@ describe("db pull", () => { pipedAnswers: ["y"], }); return Effect.gen(function* () { - const error = yield* dbPull(flags({ diffEngine: Option.some("migra") })).pipe(Effect.flip); + const error = yield* dbPull( + flags({ remote: REMOTE_LINKED, 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); @@ -1569,7 +1741,7 @@ describe("db pull", () => { stdinIsTty: true, }); return Effect.gen(function* () { - yield* dbPull(flags({ local: true, diffEngine: Option.some("migra") })); + yield* dbPull(flags({ diffEngine: Option.some("migra") })); expect(s.out.promptConfirmCalls).toHaveLength(0); expect(s.historyUpserts.length).toBe(1); }).pipe(Effect.provide(s.layer)); @@ -1589,7 +1761,9 @@ describe("db pull", () => { historyUpdateFailWith: "connection reset by peer", }); return Effect.gen(function* () { - const error = yield* dbPull(flags({ diffEngine: Option.some("migra") })).pipe(Effect.flip); + const error = yield* dbPull( + flags({ remote: REMOTE_LINKED, diffEngine: Option.some("migra") }), + ).pipe(Effect.flip); expect(error).toMatchObject({ _tag: "DbPullWriteError" }); const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -1614,7 +1788,7 @@ describe("db pull", () => { yes: true, }); return Effect.gen(function* () { - yield* dbPull(flags({ diffEngine: Option.some("migra") })); + yield* dbPull(flags({ remote: REMOTE_LINKED, 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("Schema written to"); @@ -1638,7 +1812,7 @@ describe("db pull", () => { yes: true, }); return Effect.gen(function* () { - yield* dbPull(flags({ diffEngine: Option.some("migra") })); + yield* dbPull(flags({ remote: REMOTE_LINKED, diffEngine: Option.some("migra") })); expect(s.dumpCalls.length).toBeGreaterThanOrEqual(1); // The pg_dump container image is rewritten to the configured mirror. expect(s.dumpCalls[0]?.image).toMatch(/^my-mirror\.example\.com\/supabase\//u); @@ -1670,7 +1844,7 @@ describe("db pull", () => { yes: true, }); return Effect.gen(function* () { - yield* dbPull(flags({ diffEngine: Option.some("migra") })); + yield* dbPull(flags({ remote: REMOTE_LINKED, diffEngine: Option.some("migra") })); expect(s.dumpCalls.length).toBeGreaterThanOrEqual(1); expect(s.dumpCalls[0]?.network).toEqual({ _tag: "named", name: "dotenv-net" }); }).pipe(Effect.provide(s.layer), withEmptyConfigEnv); @@ -1688,7 +1862,7 @@ describe("db pull", () => { yes: true, }); return Effect.gen(function* () { - yield* dbPull(flags({ local: true, diffEngine: Option.some("migra") })); + yield* dbPull(flags({ 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); @@ -1704,7 +1878,7 @@ describe("db pull", () => { yes: true, }); return Effect.gen(function* () { - yield* dbPull(flags({ local: true, diffEngine: Option.some("migra") })); + yield* dbPull(flags({ diffEngine: Option.some("migra") })); expect(s.dumpCalls[0]?.env["PGHOST"]).toBe("127.0.0.1"); }).pipe(Effect.provide(s.layer), withEmptyConfigEnv); }); @@ -1716,7 +1890,7 @@ describe("db pull", () => { args: ["db", "pull", "--experimental", "--declarative"], }); return Effect.gen(function* () { - yield* dbPull(flags({ declarative: Option.some(true) })); + yield* dbPull(flags({ remote: REMOTE_LINKED, declarative: Option.some(true) })); expect(s.engineCalls[0]?.operation).toBe("export"); expect(streamText(s.out, "stderr")).toContain( "Preparing declarative schema export using pg-delta...", @@ -1735,7 +1909,7 @@ describe("db pull", () => { args: ["db", "pull", "--experimental=false"], }); return Effect.gen(function* () { - yield* dbPull(flags({ diffEngine: Option.some("migra") })); + yield* dbPull(flags({ remote: REMOTE_LINKED, diffEngine: Option.some("migra") })); expect(s.historyUpserts.length).toBe(1); }).pipe(Effect.provide(s.layer), (body) => withConfigEnv({ SUPABASE_EXPERIMENTAL: "true" }, body), @@ -1757,7 +1931,7 @@ describe("db pull", () => { yes: true, }); return Effect.gen(function* () { - yield* dbPull(flags()); + yield* dbPull(flags({ remote: REMOTE_LINKED })); expect(s.engineCalls).toHaveLength(0); expect(s.edgeCalls).toHaveLength(1); }).pipe(Effect.provide(s.layer)); @@ -1772,7 +1946,7 @@ describe("db pull", () => { yes: true, }); return Effect.gen(function* () { - yield* dbPull(flags()); + yield* dbPull(flags({ remote: REMOTE_LINKED })); expect(s.engineCalls[0]?.operation).toBe("diff"); expect(s.edgeCalls).toHaveLength(0); }).pipe(Effect.provide(s.layer)); @@ -1789,7 +1963,7 @@ describe("db pull", () => { yes: true, }); return Effect.gen(function* () { - yield* dbPull(flags()); + yield* dbPull(flags({ remote: REMOTE_LINKED })); expect(s.engineCalls).toHaveLength(0); expect(s.edgeCalls).toHaveLength(1); }).pipe(Effect.provide(s.layer)); @@ -1805,13 +1979,13 @@ describe("db pull", () => { yes: true, }); return Effect.gen(function* () { - yield* dbPull(flags({ diffEngine: Option.some("pg-delta") })); + yield* dbPull(flags({ remote: REMOTE_LINKED, diffEngine: Option.some("pg-delta") })); expect(s.engineCalls[0]?.operation).toBe("diff"); expect(s.edgeCalls).toHaveLength(0); }).pipe(Effect.provide(s.layer)); }); - it.effect("db pull --local with pg-delta-next diffs against the live local database", () => { + it.effect("db pull with pg-delta-next diffs against the live local database by default", () => { const s = setup(tmp.current, { migrations: ["20240101000000"], files: { @@ -1822,51 +1996,59 @@ describe("db pull", () => { yes: true, }); return Effect.gen(function* () { - yield* dbPull(flags({ local: true, diffEngine: Option.some("pg-delta") })); + yield* dbPull(flags({ 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)); }); - it.effect("db pull --local with migra uses the declarative target override", () => { - // A real declarative schema file makes the native `loadDeclaredSchemas` branch - // non-empty, so `prepareShadowSource` redirects the diff target to the shadow's - // own `contrib_regression` override database. - const s = setup(tmp.current, { - migrations: ["20240101000000"], - files: { - "supabase/schemas/public.sql": "select 1;\n", - }, - remoteVersions: ["20240101000000"], - edgeStdout: "create table remote ();\n", - yes: true, - }); - return Effect.gen(function* () { - yield* dbPull(flags({ local: true, diffEngine: Option.some("migra") })); - expect(s.connectedDatabases).toContain("contrib_regression"); - expect(s.resolveCalls[0]?.target).toEqual({ kind: "local" }); - }).pipe(Effect.provide(s.layer)); - }); - - it.effect("db pull --local suggests repair commands without a target flag", () => { - const s = setup(tmp.current, { - migrations: ["20240102000000"], - remoteVersions: ["20240101000000"], - }); - return Effect.gen(function* () { - 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)) - : undefined; - expect(failure).toMatchObject({ - suggestion: expect.stringContaining("migration repair --status reverted 20240101000000\n"), + it.effect( + "db pull with migra uses the declarative target override on the default local target", + () => { + // A real declarative schema file makes the native `loadDeclaredSchemas` branch + // non-empty, so `prepareShadowSource` redirects the diff target to the shadow's + // own `contrib_regression` override database. + const s = setup(tmp.current, { + migrations: ["20240101000000"], + files: { + "supabase/schemas/public.sql": "select 1;\n", + }, + remoteVersions: ["20240101000000"], + edgeStdout: "create table remote ();\n", + yes: true, }); - expect(failure).toMatchObject({ - suggestion: expect.stringContaining("migration repair --status applied 20240102000000\n"), + return Effect.gen(function* () { + yield* dbPull(flags({ diffEngine: Option.some("migra") })); + expect(s.connectedDatabases).toContain("contrib_regression"); + expect(s.resolveCalls[0]?.target).toEqual({ kind: "local" }); + }).pipe(Effect.provide(s.layer)); + }, + ); + + it.effect( + "db pull on the default local target suggests repair commands without a target flag", + () => { + const s = setup(tmp.current, { + migrations: ["20240102000000"], + remoteVersions: ["20240101000000"], }); - }).pipe(Effect.provide(s.layer)); - }); + return Effect.gen(function* () { + const exit = yield* dbPull(flags()).pipe(Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + const failure = Exit.isFailure(exit) + ? Option.getOrUndefined(Cause.findErrorOption(exit.cause)) + : undefined; + expect(failure).toMatchObject({ + suggestion: expect.stringContaining( + "migration repair --status reverted 20240101000000\n", + ), + }); + expect(failure).toMatchObject({ + suggestion: expect.stringContaining("migration repair --status applied 20240102000000\n"), + }); + }).pipe(Effect.provide(s.layer)); + }, + ); it.effect( "a migration name with a path separator fails instead of an empty-version repair", @@ -1881,7 +2063,11 @@ describe("db pull", () => { }); return Effect.gen(function* () { const exit = yield* dbPull( - flags({ name: Option.some("foo/bar"), diffEngine: Option.some("migra") }), + flags({ + remote: REMOTE_LINKED, + name: Option.some("foo/bar"), + diffEngine: Option.some("migra"), + }), ).pipe(Effect.exit); expect(Exit.isFailure(exit)).toBe(true); expect(s.historyUpserts.length).toBe(0); @@ -1904,6 +2090,7 @@ describe("db pull", () => { return Effect.gen(function* () { const exit = yield* dbPull( flags({ + remote: REMOTE_LINKED, name: Option.some("dir/20250101000000_backfill"), diffEngine: Option.some("migra"), }), @@ -1923,7 +2110,9 @@ describe("db pull", () => { stdinIsTty: true, }); return Effect.gen(function* () { - const error = yield* dbPull(flags({ diffEngine: Option.some("migra") })).pipe(Effect.flip); + const error = yield* dbPull( + flags({ remote: REMOTE_LINKED, 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(); @@ -1956,7 +2145,7 @@ describe("db pull", () => { resolvedRef: "abcdefghijklmnopqrst", }); return Effect.gen(function* () { - yield* dbPull(flags({ linked: true })); + yield* dbPull(flags({ remote: REMOTE_LINKED })); 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, @@ -1978,7 +2167,7 @@ describe("db pull", () => { resolvedRef: "abcdefghijklmnopqrst", }); return Effect.gen(function* () { - const exit = yield* dbPull(flags({ linked: true })).pipe(Effect.exit); + const exit = yield* dbPull(flags({ remote: REMOTE_LINKED })).pipe(Effect.exit); expect(Exit.isFailure(exit)).toBe(true); expect(s.cache.cached).toBe(true); expect(s.cache.cachedRef).toBe("abcdefghijklmnopqrst"); @@ -2013,7 +2202,7 @@ describe("db pull", () => { resolvedRef: "abcdefghijklmnopqrst", }); return Effect.gen(function* () { - yield* dbPull(flags({ linked: true, diffEngine: Option.some("migra") })); + yield* dbPull(flags({ remote: REMOTE_LINKED, diffEngine: Option.some("migra") })); const createArgs = s.shadowSpawned.find((c) => c.args[0] === "create")?.args ?? []; expect(createArgs).toContain("--tmpfs"); }).pipe(Effect.provide(s.layer)); @@ -2033,7 +2222,7 @@ describe("db pull", () => { poolerAvailable: true, }); return Effect.gen(function* () { - yield* dbPull(flags({ linked: true, diffEngine: Option.some("pg-delta") })); + yield* dbPull(flags({ remote: REMOTE_LINKED, 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"); @@ -2058,7 +2247,7 @@ describe("db pull", () => { poolerAvailable: true, }); return Effect.gen(function* () { - yield* dbPull(flags({ linked: true, declarative: Option.some(true) })); + yield* dbPull(flags({ remote: REMOTE_LINKED, 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; @@ -2078,9 +2267,9 @@ describe("db pull", () => { poolerAvailable: false, }); return Effect.gen(function* () { - const exit = yield* dbPull(flags({ linked: true, diffEngine: Option.some("pg-delta") })).pipe( - Effect.exit, - ); + const exit = yield* dbPull( + flags({ remote: REMOTE_LINKED, 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); @@ -2096,9 +2285,9 @@ describe("db pull", () => { poolerAvailable: true, }); return Effect.gen(function* () { - const exit = yield* dbPull(flags({ linked: true, diffEngine: Option.some("pg-delta") })).pipe( - Effect.exit, - ); + const exit = yield* dbPull( + flags({ remote: REMOTE_LINKED, 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); @@ -2109,7 +2298,11 @@ describe("db pull", () => { const s = setup(tmp.current); return Effect.gen(function* () { const exit = yield* dbPull( - flags({ declarative: Option.some(true), diffEngine: Option.some("migra") }), + flags({ + remote: REMOTE_LINKED, + declarative: Option.some(true), + diffEngine: Option.some("migra"), + }), ).pipe(Effect.exit); expect(Exit.isFailure(exit)).toBe(true); }).pipe(Effect.provide(s.layer)); @@ -2155,7 +2348,13 @@ describe("db pull", () => { path.join(tmp.current, "_supabase_home"), withConfigEnv( { SUPABASE_SHADOW_CACHE: "1" }, - dbPull(flags(engine === "migra" ? { diffEngine: Option.some("migra") } : {})), + dbPull( + flags( + engine === "migra" + ? { remote: REMOTE_LINKED, diffEngine: Option.some("migra") } + : { remote: REMOTE_LINKED }, + ), + ), ).pipe(Effect.provide(s.layer)), ).pipe(Effect.as(s)); }); @@ -2192,14 +2391,17 @@ describe("db pull permissions through the real resolver and cache", () => { const cacheRequest = `GET https://api.supabase.com/v1/projects/${VALID_REF}`; const savedRef = { "supabase/.temp/project-ref": VALID_REF }; - 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 --remote=<ref>", () => { const s = setup(tmp.current, { realLayers: true }); const requests: Array<StubbedRequest> = []; return Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; yield* stubManagementApi(requests, serveLinkedDatabase(VALID_REF)); yield* dbPull( - flags({ declarative: Option.some(true), projectRef: Option.some(VALID_REF) }), + flags({ + declarative: Option.some(true), + remote: Option.some({ _tag: "Ref", ref: VALID_REF } as const), + }), ).pipe(Effect.provide(s.layer)); expect(s.engineCalls.map((call) => call.operation)).toEqual(["export"]); expect(asLines(requests)).toEqual([poolerRequest, loginRoleRequest]); @@ -2209,14 +2411,14 @@ describe("db pull permissions through the real resolver and cache", () => { expect(yield* fs.exists(`${tmp.current}/supabase/.temp/linked-project.json`)).toBe(true); assertPermissionDrift({ command: "db pull", - activeFlags: { declarative: true, "project-ref": VALID_REF }, + activeFlags: { declarative: true, remote: VALID_REF }, requests: [...asRecordedRequests(requests), ...s.projectHttp.calls], exact: true, }); // The resolve-phase calls belong to the database config block itself, not to the // fallback block, which declares the same operations. assertBlockDrift(dbConfig, { - activeFlags: { linked: true, "project-ref": VALID_REF }, + activeFlags: { remote: VALID_REF }, requests: asRecordedRequests(requests), exact: true, }); @@ -2235,27 +2437,21 @@ describe("db pull permissions through the real resolver and cache", () => { yield* dbPull( flags({ declarative: Option.some(true), - linked: true, - projectRef: Option.some(VALID_REF), + remote: Option.some({ _tag: "Ref", ref: VALID_REF } as const), password: Option.some("secret"), }), ).pipe(Effect.provide(s.layer)); expect(asLines(requests)).toEqual([poolerRequest]); assertPermissionDrift({ command: "db pull", - activeFlags: { - declarative: true, - linked: true, - "project-ref": VALID_REF, - password: true, - }, + activeFlags: { declarative: true, remote: VALID_REF, password: true }, requests: [...asRecordedRequests(requests), ...s.projectHttp.calls], exact: true, }); // The resolve-phase calls belong to the database config block itself, not to the // fallback block, which declares the same operations. assertBlockDrift(dbConfig, { - activeFlags: { linked: true, "project-ref": VALID_REF, password: true }, + activeFlags: { remote: VALID_REF, password: true }, requests: asRecordedRequests(requests), exact: true, }); @@ -2266,34 +2462,35 @@ describe("db pull permissions through the real resolver and cache", () => { ); }); - it.effect("makes no Management API request for --local and caches nothing", () => { - const s = setup(tmp.current, { realLayers: true }); - const requests: Array<StubbedRequest> = []; - return Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - yield* stubManagementApi(requests, () => undefined); - yield* dbPull(flags({ declarative: Option.some(true), local: true })).pipe( - Effect.provide(s.layer), + it.effect( + "makes no Management API request for the default local target and caches nothing", + () => { + const s = setup(tmp.current, { realLayers: true }); + const requests: Array<StubbedRequest> = []; + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* stubManagementApi(requests, () => undefined); + yield* dbPull(flags({ declarative: Option.some(true) })).pipe(Effect.provide(s.layer)); + expect(s.engineCalls.map((call) => call.operation)).toEqual(["export"]); + expect(requests).toEqual([]); + expect(s.projectHttp.calls).toEqual([]); + expect(yield* fs.exists(`${tmp.current}/supabase/.temp/linked-project.json`)).toBe(false); + assertPermissionDrift({ + command: "db pull", + activeFlags: { declarative: true }, + requests: [], + exact: true, + }); + }).pipe( + Effect.scoped, + Effect.provide(BunServices.layer), + provideEnv({ SUPABASE_ACCESS_TOKEN: VALID_TOKEN }), ); - expect(s.engineCalls.map((call) => call.operation)).toEqual(["export"]); - expect(requests).toEqual([]); - expect(s.projectHttp.calls).toEqual([]); - expect(yield* fs.exists(`${tmp.current}/supabase/.temp/linked-project.json`)).toBe(false); - assertPermissionDrift({ - command: "db pull", - activeFlags: { declarative: true, local: true }, - requests: [], - exact: true, - }); - }).pipe( - Effect.scoped, - Effect.provide(BunServices.layer), - provideEnv({ SUPABASE_ACCESS_TOKEN: VALID_TOKEN }), - ); - }); + }, + ); it.effect("retries the initial dump through the pooler after a container IPv6 failure", () => { - // No --project-ref: the ref comes from the saved file, so only the fallback block accounts + // No --remote=<ref>: the ref comes from the saved file, so only the fallback block accounts // for the pooler config request. const s = setup(tmp.current, { realLayers: true, @@ -2309,10 +2506,9 @@ describe("db pull permissions through the real resolver and cache", () => { yield* reachableDirectHost; yield* stubManagementApi(requests, serveLinkedDatabase(VALID_REF)); // The initial pg_dump seed only runs on the migra engine; pg-delta is the default. - const error = yield* dbPull(flags({ diffEngine: Option.some("migra") })).pipe( - Effect.provide(s.layer), - Effect.flip, - ); + const error = yield* dbPull( + flags({ remote: REMOTE_LINKED, diffEngine: Option.some("migra") }), + ).pipe(Effect.provide(s.layer), Effect.flip); expect(error.message).toBe("No schema changes found"); expect(s.dumpCalls).toHaveLength(2); expect(s.dumpCalls[1]?.env["PGHOST"]).toBe("aws-0-us-east-1.pooler.supabase.com"); @@ -2345,7 +2541,7 @@ describeHostedConsent("db pull", { edgeStdout: "create table remote ();\n", }); return { - run: dbPull(flags({ diffEngine: Option.some("migra") })).pipe( + run: dbPull(flags({ remote: REMOTE_LINKED, 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/push/push.integration.test.ts b/apps/cli/src/commands/db/push/push.integration.test.ts index 6aaa17d212..7d7df1a165 100644 --- a/apps/cli/src/commands/db/push/push.integration.test.ts +++ b/apps/cli/src/commands/db/push/push.integration.test.ts @@ -14,6 +14,11 @@ vi.mock("node:net", (importOriginal) => import { describeHostedConsent } from "../../../../tests/helpers/hosted-consent.ts"; import { mockOutput, mockStdin, mockTty } from "../../../../tests/helpers/mocks.ts"; +import { + BRANCH_PROJECT_REF, + branchListApi, + warningsOf, +} from "../../../../tests/helpers/db-remote-selection.ts"; import { assertPermissionDrift } from "../../../../tests/helpers/permission-drift.ts"; import { asRecordedRequests, @@ -83,11 +88,13 @@ const DEFAULT_FLAGS: DbPushFlags = { dbUrl: Option.none(), remote: Option.none(), linked: false, - local: true, + local: false, projectRef: Option.none(), password: Option.none(), }; +const REMOTE_LINKED: DbPushFlags["remote"] = Option.some({ _tag: "Linked" }); + function mockResolver( opts: { isLocal?: boolean; onResolve?: (input: DbConnectionInput) => void } = {}, ) { @@ -372,7 +379,7 @@ describe("db push", () => { 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, linked: true }).pipe( + const exit = yield* dbPush({ ...DEFAULT_FLAGS, local: true, linked: true }).pipe( Effect.provide(layer), Effect.exit, ); @@ -507,8 +514,7 @@ describe("db push", () => { return Effect.gen(function* () { const exit = yield* dbPush({ ...DEFAULT_FLAGS, - dbUrl: Option.some(dbUrl), - local: false, + remote: Option.some({ _tag: "DbUrl", url: dbUrl } as const), }).pipe(Effect.provide(layer), Effect.exit); expect(failSuggestion(exit)).toContain( "migration repair --status reverted 20240101000000 --remote=<db-url>", @@ -524,7 +530,7 @@ describe("db push", () => { remoteMigrations: ["20240101000000"], }); return Effect.gen(function* () { - const exit = yield* dbPush({ ...DEFAULT_FLAGS, local: false, linked: true }).pipe( + const exit = yield* dbPush({ ...DEFAULT_FLAGS, remote: REMOTE_LINKED }).pipe( Effect.provide(layer), Effect.exit, ); @@ -687,7 +693,7 @@ describe("db push", () => { }); describe("hosted consent on a linked project", () => { - const linked = { ...DEFAULT_FLAGS, local: false, linked: true }; + const linked = { ...DEFAULT_FLAGS, remote: REMOTE_LINKED }; const allFlags = { ...linked, includeRoles: true, includeSeed: Option.some(true) }; const allFiles = { ...migrationFile("20240101000000"), @@ -838,7 +844,7 @@ describe("db push", () => { }, ); - it.effect("asks before pushing to a --db-url that is not local", () => { + it.effect("asks before pushing to a --remote=<db-url> that is not local", () => { const { layer, out, conn } = setup(tmp.current, { toml: 'project_id = "test"\n', files: migrationFile("20240101000000"), @@ -848,8 +854,10 @@ describe("db push", () => { return Effect.gen(function* () { yield* dbPush({ ...DEFAULT_FLAGS, - local: false, - dbUrl: Option.some("postgresql://postgres:pw@db.example.com:5432/postgres"), + remote: Option.some({ + _tag: "DbUrl", + url: "postgresql://postgres:pw@db.example.com:5432/postgres", + }), }).pipe(Effect.provide(layer)); expect(out.promptConfirmCalls).toHaveLength(1); expect(conn.execs).toContain("BEGIN"); @@ -1083,11 +1091,7 @@ describe("db push", () => { projectRef: VALID_REF, }); return Effect.gen(function* () { - yield* dbPush({ - ...DEFAULT_FLAGS, - local: false, - remote: Option.some({ _tag: "Linked" } as const), - }).pipe(Effect.provide(layer)); + yield* dbPush({ ...DEFAULT_FLAGS, remote: REMOTE_LINKED }).pipe(Effect.provide(layer)); expect(resolver.calls[0]).toMatchObject({ target: { kind: "linked", ref: VALID_REF, source: "file", matchesLinkedRef: true }, hasPasswordFlag: true, @@ -1096,6 +1100,107 @@ describe("db push", () => { }); }); + describe("target selection", () => { + const resolvedTarget = ( + flags: Partial<DbPushFlags>, + options: { readonly branchApi?: ReturnType<typeof branchListApi> } = {}, + ) => { + const { layer, out, resolver } = setup(tmp.current, { toml: 'project_id = "test"\n' }); + const provided = + options.branchApi === undefined ? layer : Layer.merge(layer, options.branchApi.layer); + return Effect.gen(function* () { + yield* dbPush({ ...DEFAULT_FLAGS, ...flags }).pipe(Effect.provide(provided)); + return { target: resolver.calls[0]?.target, warnings: warningsOf(out) }; + }); + }; + + it.effect("targets the local database, with no warning, when nothing selects one", () => + Effect.gen(function* () { + const { target, warnings } = yield* resolvedTarget({}); + expect(target).toEqual({ kind: "local" }); + expect(warnings).toEqual([]); + }), + ); + + it.effect("targets the project --remote=<ref> names, with no warning", () => + Effect.gen(function* () { + const { target, warnings } = yield* resolvedTarget({ + remote: Option.some({ _tag: "Ref", ref: FLAG_PROJECT_REF } as const), + }); + expect(target).toMatchObject({ kind: "linked", ref: FLAG_PROJECT_REF, source: "flag" }); + expect(warnings).toEqual([]); + }), + ); + + it.effect("targets the database --remote=<db-url> names, with no warning", () => + Effect.gen(function* () { + const { target, warnings } = yield* resolvedTarget({ + remote: Option.some({ + _tag: "DbUrl", + url: "postgresql://postgres:pw@db.example.com/db", + } as const), + }); + expect(target?.kind).toBe("db-url"); + expect(warnings).toEqual([]); + }), + ); + + it.effect("targets the project of the branch --remote=<branch> names", () => { + const branchApi = branchListApi(); + return Effect.gen(function* () { + const { target, warnings } = yield* resolvedTarget( + { remote: Option.some({ _tag: "Branch", name: "feature-x" }) }, + { branchApi }, + ); + expect(target).toMatchObject({ kind: "linked", ref: BRANCH_PROJECT_REF, source: "flag" }); + expect(warnings).toEqual([]); + assertPermissionDrift({ + command: "db push", + activeFlags: { remote: "feature-x" }, + requests: branchApi.requests, + }); + }); + }); + + it.effect.each([ + { + alias: "--linked", + flags: { linked: true }, + kind: "linked", + warning: "--linked is deprecated and will be removed in v4. Use --remote instead", + }, + { + alias: "--project-ref", + flags: { projectRef: Option.some(FLAG_PROJECT_REF) }, + kind: "linked", + warning: "--project-ref is deprecated and will be removed in v4. Use --remote instead", + }, + { + alias: "--db-url", + flags: { dbUrl: Option.some("postgresql://postgres:pw@db.example.com/db") }, + kind: "db-url", + warning: "--db-url is deprecated and will be removed in v4. Use --remote instead", + }, + { + alias: "--local", + flags: { local: true }, + kind: "local", + warning: "--local isn't needed (local is the default) and will be removed in v4", + }, + ] satisfies ReadonlyArray<{ + alias: string; + flags: Partial<DbPushFlags>; + kind: string; + warning: string; + }>)("keeps $alias working and warns that it is deprecated", ({ flags, kind, warning }) => + Effect.gen(function* () { + const { target, warnings } = yield* resolvedTarget(flags); + expect(target?.kind).toBe(kind); + expect(warnings).toEqual([expect.stringContaining(warning)]); + }), + ); + }); + it.effect("mints a login role and fills the cache through the real resolver and cache", () => { const { layer, projectHttp } = setup(tmp.current, { toml: 'project_id = "test"\n', @@ -1107,8 +1212,7 @@ describe("db push", () => { yield* stubManagementApi(requests, serveLinkedDatabase(VALID_REF)); yield* dbPush({ ...DEFAULT_FLAGS, - local: false, - projectRef: Option.some(VALID_REF), + remote: Option.some({ _tag: "Ref", ref: VALID_REF } as const), }).pipe(Effect.provide(layer)); expect(requests.map(({ method, path }) => `${method} ${path}`)).toEqual([ `GET /v1/projects/${VALID_REF}/config/database/pooler`, @@ -1120,7 +1224,7 @@ describe("db push", () => { expect(yield* fs.exists(`${tmp.current}/supabase/.temp/linked-project.json`)).toBe(true); assertPermissionDrift({ command: "db push", - activeFlags: { "project-ref": VALID_REF }, + activeFlags: { remote: VALID_REF }, requests: [...asRecordedRequests(requests), ...projectHttp.calls], exact: true, }); @@ -1141,9 +1245,7 @@ describe("db push", () => { yield* stubManagementApi(requests, serveLinkedDatabase(VALID_REF)); yield* dbPush({ ...DEFAULT_FLAGS, - local: false, - linked: true, - projectRef: Option.some(VALID_REF), + remote: Option.some({ _tag: "Ref", ref: VALID_REF } as const), password: Option.some("secret"), }).pipe(Effect.provide(layer)); expect(requests.map(({ method, path }) => `${method} ${path}`)).toEqual([ @@ -1151,7 +1253,7 @@ describe("db push", () => { ]); assertPermissionDrift({ command: "db push", - activeFlags: { linked: true, "project-ref": VALID_REF, password: true }, + activeFlags: { remote: VALID_REF, password: true }, requests: [...asRecordedRequests(requests), ...projectHttp.calls], exact: true, }); @@ -1163,7 +1265,7 @@ describe("db push", () => { }); it.effect( - "makes no Management API request for --local through the real resolver and cache", + "makes no Management API request for the default local target through the real resolver and cache", () => { const { layer, projectHttp } = setup(tmp.current, { toml: 'project_id = "test"\n', @@ -1179,7 +1281,7 @@ describe("db push", () => { expect(yield* fs.exists(`${tmp.current}/supabase/.temp/linked-project.json`)).toBe(false); assertPermissionDrift({ command: "db push", - activeFlags: { local: true }, + activeFlags: {}, requests: [], exact: true, }); @@ -1330,7 +1432,7 @@ describe("db push", () => { confirm: [true], }); return Effect.gen(function* () { - yield* dbPush({ ...DEFAULT_FLAGS, local: false, linked: true }).pipe(Effect.provide(layer)); + yield* dbPush({ ...DEFAULT_FLAGS, remote: REMOTE_LINKED }).pipe(Effect.provide(layer)); expect(out.stderrText).not.toContain("Skipping migrations because it is disabled"); expect(out.stderrText).toContain("Applying migration 20240101000000"); }).pipe((body) => withEnvVar("SUPABASE_DB_MIGRATIONS_ENABLED", "true", body)); @@ -1342,7 +1444,7 @@ describe("db push", () => { projectRef: VALID_REF, }); return Effect.gen(function* () { - yield* dbPush({ ...DEFAULT_FLAGS, local: false, linked: true }).pipe(Effect.provide(layer)); + yield* dbPush({ ...DEFAULT_FLAGS, remote: REMOTE_LINKED }).pipe(Effect.provide(layer)); expect(out.stderrText).toContain("Loading config override: [remotes.preview]"); }); }); @@ -1356,7 +1458,7 @@ describe("db push", () => { env: { SUPABASE_REMOTES_PREVIEW_PROJECT_ID: VALID_REF }, }); return Effect.gen(function* () { - yield* dbPush({ ...DEFAULT_FLAGS, local: false, linked: true }).pipe(Effect.provide(layer)); + yield* dbPush({ ...DEFAULT_FLAGS, remote: REMOTE_LINKED }).pipe(Effect.provide(layer)); expect(out.stderrText).toContain("Loading config override: [remotes.preview]"); }); }, @@ -1368,7 +1470,7 @@ describe("db push", () => { projectRef: VALID_REF, }); return Effect.gen(function* () { - yield* dbPush({ ...DEFAULT_FLAGS, local: false, linked: true }).pipe(Effect.provide(layer)); + yield* dbPush({ ...DEFAULT_FLAGS, remote: REMOTE_LINKED }).pipe(Effect.provide(layer)); expect(out.stderrText).not.toContain("Loading config override"); }); }); @@ -1382,7 +1484,7 @@ describe("db push", () => { yes: true, }); return Effect.gen(function* () { - yield* dbPush({ ...DEFAULT_FLAGS, local: false, linked: true }).pipe(Effect.provide(layer)); + yield* dbPush({ ...DEFAULT_FLAGS, remote: REMOTE_LINKED }).pipe(Effect.provide(layer)); expect(out.stderrText).toContain("Confirmed by --yes: Apply 1 migration"); expect(linkedCache.cached).toBe(true); expect(linkedCache.cachedRef).toBe(VALID_REF); @@ -1391,7 +1493,7 @@ describe("db push", () => { }); }); - it.effect("pushes to the project given via --project-ref without a linked workdir", () => { + it.effect("pushes to the project --remote=<ref> names without a linked workdir", () => { // `linkedFails: true` simulates an unlinked workdir; only the flag can resolve a ref. const { layer, out, linkedCache, resolver } = setup(tmp.current, { toml: 'project_id = "test"\n', @@ -1403,9 +1505,7 @@ describe("db push", () => { return Effect.gen(function* () { yield* dbPush({ ...DEFAULT_FLAGS, - local: false, - linked: true, - projectRef: Option.some(FLAG_PROJECT_REF), + remote: Option.some({ _tag: "Ref", ref: FLAG_PROJECT_REF } as const), }).pipe(Effect.provide(layer)); expect(linkedCache.cached).toBe(true); expect(linkedCache.cachedRef).toBe(FLAG_PROJECT_REF); @@ -1420,7 +1520,7 @@ describe("db push", () => { }); }); - it.effect("--project-ref drives which [remotes.<ref>] block merges into config", () => { + it.effect("--remote=<ref> drives which [remotes.<ref>] block merges into config", () => { // `[remotes.staging]`'s `project_id` matches the flag ref, not the resolver's own // `VALID_REF` fallback, so the override only announces if the flag resolved it. const { layer, out } = setup(tmp.current, { @@ -1429,15 +1529,13 @@ describe("db push", () => { return Effect.gen(function* () { yield* dbPush({ ...DEFAULT_FLAGS, - local: false, - linked: true, - projectRef: Option.some(FLAG_PROJECT_REF), + remote: Option.some({ _tag: "Ref", ref: FLAG_PROJECT_REF } as const), }).pipe(Effect.provide(layer)); expect(out.stderrText).toContain("Loading config override: [remotes.staging]"); }); }); - it.effect("--project-ref overrides an already-linked workdir's project ref", () => { + it.effect("--remote=<ref> overrides an already-linked workdir's project ref", () => { const { layer, linkedCache } = setup(tmp.current, { toml: 'project_id = "test"\n', files: migrationFile("20240101000000"), @@ -1448,9 +1546,7 @@ describe("db push", () => { return Effect.gen(function* () { yield* dbPush({ ...DEFAULT_FLAGS, - local: false, - linked: true, - projectRef: Option.some(FLAG_PROJECT_REF), + remote: Option.some({ _tag: "Ref", ref: FLAG_PROJECT_REF } as const), }).pipe(Effect.provide(layer)); expect(linkedCache.cached).toBe(true); expect(linkedCache.cachedRef).toBe(FLAG_PROJECT_REF); @@ -1466,6 +1562,7 @@ describe("db push", () => { return Effect.gen(function* () { const exit = yield* dbPush({ ...DEFAULT_FLAGS, + local: true, projectRef: Option.some(FLAG_PROJECT_REF), }).pipe(Effect.provide(layer), Effect.exit); expect(failError(exit)._tag).toBe("TargetProjectRefConflictError"); @@ -1492,11 +1589,7 @@ describeHostedConsent("db push", { invoke: ({ out }) => { const conn = mockConnection({}); return { - run: dbPush({ - ...DEFAULT_FLAGS, - local: false, - remote: Option.some({ _tag: "Linked" } as const), - }).pipe( + run: dbPush({ ...DEFAULT_FLAGS, remote: REMOTE_LINKED }).pipe( Effect.provide( Layer.mergeAll( conn.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 a9c3081cf9..a5b61e6864 100644 --- a/apps/cli/src/commands/db/query/query.integration.test.ts +++ b/apps/cli/src/commands/db/query/query.integration.test.ts @@ -32,6 +32,11 @@ import { useTempWorkdir, } from "../../../../tests/helpers/command-mocks.ts"; import { cliConfigValuesTestLayer } from "../../../../tests/helpers/config-values-layer.ts"; +import { + BRANCH_PROJECT_REF, + branchListApi, + warningsOf, +} from "../../../../tests/helpers/db-remote-selection.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"; @@ -98,21 +103,28 @@ 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: boolean | undefined, resolveFails = false) { +function mockResolver( + isLocal: boolean | undefined, + resolveFails = false, + resolvedTargets: Array<DbConnectionInput["target"]> = [], +) { return Layer.succeed(DbConfigResolver, { resolveConnection: (input: DbConnectionInput) => - resolveFails - ? Effect.fail( - new DbConfigParseUrlError({ - message: "failed to parse connection string: invalid dsn", - }), - ) - : 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), + Effect.suspend(() => { + resolvedTargets.push(input.target); + return resolveFails + ? Effect.fail( + new DbConfigParseUrlError({ + message: "failed to parse connection string: invalid dsn", + }), + ) + : 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()), }); } @@ -202,7 +214,7 @@ function mockTelemetryOutputFormat() { } function mockProjectRef(unlinked = false, refReadFails = false) { - // An explicit `--project-ref` wins first, then ProjectRefNotLinkedError when unlinked, or a + // An explicit `--remote=<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 @@ -303,6 +315,7 @@ function setup(opts: SetupOpts = {}) { const telemetry = mockTelemetryStateTracked(); const cache = mockLinkedProjectCacheTracked(); const telemetryOutputFormat = mockTelemetryOutputFormat(); + const resolvedTargets: Array<DbConnectionInput["target"]> = []; const platformApi = mockCommandPlatformApi({ handler: (request) => Effect.succeed( @@ -351,7 +364,7 @@ function setup(opts: SetupOpts = {}) { dbConnection: dbConnection.layer, env: { SUPABASE_ACCESS_TOKEN: VALID_TOKEN }, }) - : mockResolver(opts.isLocal, opts.resolveFails), + : mockResolver(opts.isLocal, opts.resolveFails, resolvedTargets), dbConnection.layer, mockProjectRef(opts.unlinked, opts.refReadFails), cliConfigValuesTestLayer, @@ -378,9 +391,20 @@ function setup(opts: SetupOpts = {}) { platformApi.factoryLayer, BunServices.layer, ); - return { layer, out, telemetry, cache, telemetryOutputFormat, platformApi, dbConnection }; + return { + layer, + out, + telemetry, + cache, + telemetryOutputFormat, + platformApi, + dbConnection, + resolvedTargets, + }; } +const REMOTE_LINKED: DbQueryFlags["remote"] = Option.some({ _tag: "Linked" }); + const flags = (over: Partial<DbQueryFlags> = {}): DbQueryFlags => ({ sql: over.sql ?? Option.none(), dbUrl: over.dbUrl ?? Option.none(), @@ -407,7 +431,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: true })); + yield* dbQuery(flags({ sql: Option.some("select * from users") })); expect(out.stdoutText).toContain("│ id │ name │"); expect(out.stdoutText).toContain("│ 1 │ alice │"); expect(cache.cached).toBe(false); @@ -425,33 +449,39 @@ describe("db query integration", () => { }, }); return Effect.gen(function* () { - yield* dbQuery(flags({ sql: Option.some("select 1"), local: true })); + yield* dbQuery(flags({ sql: Option.some("select 1") })); expect(out.stdoutText).toContain("│ 1000000 │ 1e+06 │"); }).pipe(Effect.provide(layer)); }); - 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("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( + "asks with a preview of the SQL before running it on a hosted --remote=<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("delete from\n users"), + remote: Option.some({ _tag: "DbUrl", url: "postgres://x/y" } as const), + }), + ); + 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 })); + yield* dbQuery(flags({ sql: Option.some("delete from users") })); expect(out.promptConfirmCalls).toEqual([]); expect(out.stdoutText).toContain("alice"); }).pipe(Effect.provide(layer)); @@ -460,7 +490,7 @@ describe("db query integration", () => { 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: true })).pipe(Effect.exit); + const exit = yield* dbQuery(flags()).pipe(Effect.exit); expect(failMessage(exit)).toBe( "no SQL query provided. Pass SQL as an argument, via --file, or pipe to stdin", ); @@ -470,7 +500,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: true })); + yield* dbQuery(flags()); expect(out.stdoutText).toContain("alice"); }).pipe(Effect.provide(layer)); }); @@ -483,7 +513,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: true, file: Option.some(filePath) })); + yield* dbQuery(flags({ file: Option.some(filePath) })); expect(out.stdoutText).toContain("alice"); }).pipe(Effect.scoped, Effect.provide(layer)); }); @@ -495,9 +525,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: true, file: Option.some("q.sql") })).pipe( - Effect.provide(layer), - ); + yield* dbQuery(flags({ file: Option.some("q.sql") })).pipe(Effect.provide(layer)); expect(out.stdoutText).toContain("alice"); }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), ); @@ -505,9 +533,9 @@ describe("db query integration", () => { it.effect("errors when --file cannot be read", () => { const { layer } = setup(); return Effect.gen(function* () { - const exit = yield* dbQuery( - flags({ local: true, file: Option.some("/no/such/file.sql") }), - ).pipe(Effect.exit); + const exit = yield* dbQuery(flags({ file: Option.some("/no/such/file.sql") })).pipe( + Effect.exit, + ); expect(failMessage(exit)).toContain("failed to read SQL file"); }).pipe(Effect.provide(layer)); }); @@ -515,7 +543,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: true })).pipe(Effect.exit); + const exit = yield* dbQuery(flags()).pipe(Effect.exit); expect(failMessage(exit)).toBe("no SQL provided via stdin"); }).pipe(Effect.provide(layer)); }); @@ -523,7 +551,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: true })); + yield* dbQuery(flags({ sql: Option.some("create table t()") })); expect(out.stdoutText).toBe("CREATE TABLE\n"); }).pipe(Effect.provide(layer)); }); @@ -531,7 +559,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: true })); + yield* dbQuery(flags({ sql: Option.some("select 1") })); const parsed = yield* decodeJson(out.stdoutText); expect(parsed).toHaveProperty("boundary", BOUNDARY); expect(parsed).toHaveProperty("rows", [ @@ -545,7 +573,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: true })); + yield* dbQuery(flags({ sql: Option.some("select 1") })); expect(yield* decodeJson(out.stdoutText)).toHaveProperty("boundary", BOUNDARY); }).pipe(Effect.provide(layer)); }); @@ -553,7 +581,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: true })); + yield* dbQuery(flags({ sql: Option.some("select 1") })); const parsed = yield* decodeJson(out.stdoutText); expect(Array.isArray(parsed)).toBe(true); expect(parsed).toEqual([ @@ -566,7 +594,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: true })); + yield* dbQuery(flags({ sql: Option.some("select 1") })); expect(yield* decodeJson(out.stdoutText)).toEqual([ { id: 1, name: "alice" }, { id: 2, name: "bob" }, @@ -577,7 +605,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: true })); + yield* dbQuery(flags({ sql: Option.some("select 1") })); expect(out.stdoutText.trimEnd().split("\n")).toHaveLength(1); expect(yield* decodeJson(out.stdoutText)).toEqual( expect.objectContaining({ @@ -606,7 +634,7 @@ describe("db query integration", () => { format: "stream-json", }); return Effect.gen(function* () { - yield* dbQuery(flags({ sql: Option.some("select 1"), local: true })); + yield* dbQuery(flags({ sql: Option.some("select 1") })); expect(out.stdoutText.trimEnd().split("\n")).toHaveLength(1); expect(out.stdoutText).toContain('"n": 9223372036854775807'); }).pipe(Effect.provide(layer)); @@ -620,7 +648,7 @@ describe("db query integration", () => { outputFlag: "pretty", }); return Effect.gen(function* () { - yield* dbQuery(flags({ sql: Option.some("select 1"), local: true })); + yield* dbQuery(flags({ sql: Option.some("select 1") })); expect(out.stdoutText).toContain("│ id │ name │"); expect(out.messages.find((message) => message.type === "success")).toBeUndefined(); }).pipe(Effect.provide(layer)); @@ -633,9 +661,9 @@ describe("db query integration", () => { outputFlag: "json", }); return Effect.gen(function* () { - const exit = yield* dbQuery( - flags({ sql: Option.some("select 'NaN'::float8"), local: true }), - ).pipe(Effect.exit); + const exit = yield* dbQuery(flags({ sql: Option.some("select 'NaN'::float8") })).pipe( + Effect.exit, + ); expect(Exit.isFailure(exit)).toBe(true); expect(failMessage(exit)).toContain("json: unsupported value: NaN"); expect(out.stdoutText).toBe(""); @@ -647,17 +675,11 @@ 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: true })).pipe( - Effect.provide(human.layer), - ); + yield* dbQuery(flags({ sql: Option.some("select 1") })).pipe(Effect.provide(human.layer)); expect(human.telemetryOutputFormat.format).toBe("table"); - yield* dbQuery(flags({ sql: Option.some("select 1"), local: true })).pipe( - Effect.provide(agent.layer), - ); + yield* dbQuery(flags({ sql: Option.some("select 1") })).pipe(Effect.provide(agent.layer)); expect(agent.telemetryOutputFormat.format).toBe("json"); - yield* dbQuery(flags({ sql: Option.some("select 1"), local: true })).pipe( - Effect.provide(csv.layer), - ); + yield* dbQuery(flags({ sql: Option.some("select 1") })).pipe(Effect.provide(csv.layer)); expect(csv.telemetryOutputFormat.format).toBe("csv"); }); }); @@ -665,7 +687,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: true })); + yield* dbQuery(flags({ sql: Option.some("select 1") })); expect(out.stdoutText).toBe("id,name\n1,alice\n2,bob\n"); }).pipe(Effect.provide(layer)); }); @@ -673,7 +695,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: true })); + yield* dbQuery(flags({ sql: Option.some("select 1") })); expect(out.stdoutText).toContain("│ id │ name │"); expect(out.stdoutText).not.toContain("boundary"); }).pipe(Effect.provide(layer)); @@ -682,7 +704,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: true })); + yield* dbQuery(flags({ sql: Option.some("select 1") })); expect(out.stdoutText).toBe("id,name\n1,alice\n2,bob\n"); }).pipe(Effect.provide(layer)); }); @@ -694,7 +716,7 @@ describe("db query integration", () => { rlsTables: ["public.users"], }); return Effect.gen(function* () { - yield* dbQuery(flags({ sql: Option.some("select 1"), local: true })); + yield* dbQuery(flags({ sql: Option.some("select 1") })); expect(yield* decodeJson(out.stdoutText)).toHaveProperty(["advisory", "id"], "rls_disabled"); }).pipe(Effect.provide(layer)); }); @@ -702,16 +724,19 @@ 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: true })); + yield* dbQuery(flags({ sql: Option.some("select 1") })); expect(yield* decodeJson(out.stdoutText)).not.toHaveProperty("advisory"); }).pipe(Effect.provide(layer)); }); - it.effect("resolves the --db-url/config before reading SQL", () => { + it.effect("resolves the --remote=<db-url>/config before reading SQL", () => { const { layer } = setup({ resolveFails: true }); return Effect.gen(function* () { const exit = yield* dbQuery( - flags({ dbUrl: Option.some("bad"), file: Option.some("/nope/missing.sql") }), + flags({ + remote: Option.some({ _tag: "DbUrl", url: "bad" } as const), + file: Option.some("/nope/missing.sql"), + }), ).pipe(Effect.exit); expect(Exit.isFailure(exit)).toBe(true); expect(failMessage(exit)).toContain("failed to parse connection string"); @@ -721,18 +746,16 @@ 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") })).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", () => { + it.effect("rejects conflicting targets (--remote --local) before running any SQL", () => { const { layer, cache, dbConnection } = setup(); return Effect.gen(function* () { const exit = yield* dbQuery( - flags({ sql: Option.some("select 1"), linked: true, local: true }), + flags({ sql: Option.some("select 1"), remote: REMOTE_LINKED, local: true }), ).pipe(Effect.exit); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { @@ -752,12 +775,12 @@ describe("db query integration", () => { }).pipe(Effect.provide(layer)); }); - it.effect("fails an unlinked --linked query without prompting for a project", () => { + it.effect("fails an unlinked bare --remote 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"), remote: REMOTE_LINKED }), + ).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)); @@ -766,9 +789,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"), remote: REMOTE_LINKED }), + ).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"); @@ -781,7 +804,7 @@ describe("db query integration", () => { linkedBody: '[{"name":"alice","id":1}]', }); return Effect.gen(function* () { - yield* dbQuery(flags({ sql: Option.some("select 1"), linked: true })); + yield* dbQuery(flags({ sql: Option.some("select 1"), remote: REMOTE_LINKED })); expect(out.stdoutText).toContain("│ name │ id │"); expect(platformApi.requests).toHaveLength(1); expect(platformApi.requests[0]).toMatchObject({ @@ -792,7 +815,7 @@ describe("db query integration", () => { expect(cache.cached).toBe(true); assertPermissionDrift({ command: "db query", - activeFlags: { linked: true }, + activeFlags: { remote: true }, requests: platformApi.requests, exact: true, }); @@ -810,7 +833,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: true })); + yield* dbQuery(flags({ sql: Option.some("select 1"), remote: REMOTE_LINKED })); expect(yield* decodeJson(yield* fs.readFileString(cachePath))).toMatchObject({ ref: REF }); expect(platformApi.requests.map(({ method, url }) => `${method} ${url}`)).toEqual([ `POST https://api.supabase.com/v1/projects/${REF}/database/query`, @@ -818,7 +841,7 @@ describe("db query integration", () => { ]); assertPermissionDrift({ command: "db query", - activeFlags: { linked: true }, + activeFlags: { remote: true }, requests: platformApi.requests, exact: true, }); @@ -852,14 +875,13 @@ describe("db query integration", () => { yield* dbQuery( flags({ sql: Option.some("select 1"), - linked: true, - projectRef: Option.some(REF), + remote: Option.some({ _tag: "Ref", ref: REF } as const), }), ); expect(resolverRequests).toEqual([]); assertPermissionDrift({ command: "db query", - activeFlags: { linked: true, "project-ref": REF }, + activeFlags: { remote: REF }, requests: platformApi.requests, exact: true, }); @@ -877,7 +899,7 @@ describe("db query integration", () => { }, ); - it.effect("queries the project given via --project-ref without a linked workdir", () => { + it.effect("queries the project given via --remote=<ref> without a linked workdir", () => { const FLAG_REF = "flagflagflagflagflag"; const { layer, out, cache, platformApi } = setup({ linkedStatus: 201, @@ -888,8 +910,7 @@ describe("db query integration", () => { yield* dbQuery( flags({ sql: Option.some("select 1"), - linked: true, - projectRef: Option.some(FLAG_REF), + remote: Option.some({ _tag: "Ref", ref: FLAG_REF } as const), }), ); expect(out.stdoutText).toContain("│ name │ id │"); @@ -902,14 +923,14 @@ describe("db query integration", () => { expect(cache.cachedRef).toBe(FLAG_REF); assertPermissionDrift({ command: "db query", - activeFlags: { linked: true, "project-ref": FLAG_REF }, + activeFlags: { remote: FLAG_REF }, requests: platformApi.requests, exact: true, }); }).pipe(Effect.provide(layer)); }); - it.effect("--project-ref overrides an already-linked workdir's project ref", () => { + it.effect("--remote=<ref> overrides an already-linked workdir's project ref", () => { const FLAG_REF = "flagflagflagflagflag"; const { layer, cache, platformApi } = setup({ linkedStatus: 201, @@ -919,8 +940,7 @@ describe("db query integration", () => { yield* dbQuery( flags({ sql: Option.some("select 1"), - linked: true, - projectRef: Option.some(FLAG_REF), + remote: Option.some({ _tag: "Ref", ref: FLAG_REF } as const), }), ); expect( @@ -934,14 +954,19 @@ describe("db query integration", () => { }).pipe(Effect.provide(layer)); }); - it.effect("selects the linked project from --project-ref alone", () => { + it.effect("selects the linked project from --remote=<ref> alone", () => { const FLAG_REF = "flagflagflagflagflag"; const { layer, cache, platformApi } = 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) })); + yield* dbQuery( + flags({ + sql: Option.some("select 1"), + remote: Option.some({ _tag: "Ref", ref: FLAG_REF } as const), + }), + ); expect( platformApi.requests.some((req) => req.url.includes(`/v1/projects/${FLAG_REF}/database/query`), @@ -992,7 +1017,7 @@ describe("db query integration", () => { linkedBody: '[{"id":1}]', }); return Effect.gen(function* () { - yield* dbQuery(flags({ sql: Option.some("select 1"), linked: true })); + yield* dbQuery(flags({ sql: Option.some("select 1"), remote: REMOTE_LINKED })); expect(out.stdoutText).toContain("│ id │"); expect(cache.cached).toBe(true); }).pipe(Effect.provide(layer)); @@ -1001,7 +1026,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: true })).pipe(Effect.exit); + const exit = yield* dbQuery(flags({ remote: REMOTE_LINKED })).pipe(Effect.exit); expect(Exit.isFailure(exit)).toBe(true); expect(failMessage(exit)).toContain("no SQL query provided"); expect(cache.cached).toBe(true); @@ -1016,7 +1041,7 @@ describe("db query integration", () => { linkedBody: '{"message":"syntax error"}', }); return Effect.gen(function* () { - const exit = yield* dbQuery(flags({ sql: Option.some("bad"), linked: true })).pipe( + const exit = yield* dbQuery(flags({ sql: Option.some("bad"), remote: REMOTE_LINKED })).pipe( Effect.exit, ); expect(failMessage(exit)).toBe('unexpected status 400: {"message":"syntax error"}'); @@ -1028,7 +1053,7 @@ describe("db query integration", () => { it.effect("strips terminal control characters from a linked error body", () => { const { layer } = setup({ linkedStatus: 400, linkedBody: "bad\u001b[31m query" }); return Effect.gen(function* () { - const exit = yield* dbQuery(flags({ sql: Option.some("bad"), linked: true })).pipe( + const exit = yield* dbQuery(flags({ sql: Option.some("bad"), remote: REMOTE_LINKED })).pipe( Effect.exit, ); expect(failMessage(exit)).toBe("unexpected status 400: bad[31m query"); @@ -1038,7 +1063,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"), remote: REMOTE_LINKED })); expect(out.stdoutText).toBe(""); }).pipe(Effect.provide(layer)); }); @@ -1046,7 +1071,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: true })); + yield* dbQuery(flags({ sql: Option.some("insert ..."), remote: REMOTE_LINKED })); expect(out.stdoutText).toBe('{"command":"INSERT"}\n'); }).pipe(Effect.provide(layer)); }); @@ -1054,7 +1079,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: true })); + yield* dbQuery(flags({ sql: Option.some("create ..."), remote: REMOTE_LINKED })); expect(out.stdoutText).toBe("CREATE TABLE\n"); }).pipe(Effect.provide(layer)); }); @@ -1066,7 +1091,7 @@ describe("db query integration", () => { linkedBody: '[{"id":1}]', }); return Effect.gen(function* () { - yield* dbQuery(flags({ sql: Option.some("select 1"), linked: true })); + yield* dbQuery(flags({ sql: Option.some("select 1"), remote: REMOTE_LINKED })); const parsed = yield* decodeJson(out.stdoutText); expect(parsed).toHaveProperty("boundary", BOUNDARY); expect(parsed).toHaveProperty("rows", [{ id: 1 }]); @@ -1077,7 +1102,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: true })); + yield* dbQuery(flags({ sql: Option.some("select 1"), remote: REMOTE_LINKED })); expect(out.stdoutText).toBe(""); }).pipe(Effect.provide(layer)); }); @@ -1085,7 +1110,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: true })); + yield* dbQuery(flags({ sql: Option.some("select 1"), remote: REMOTE_LINKED })); expect(out.stdoutText).toContain("NULL"); expect(out.stdoutText).toContain("│ 1"); }).pipe(Effect.provide(layer)); @@ -1094,19 +1119,19 @@ 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"), remote: REMOTE_LINKED }), + ).pipe(Effect.exit); expect(failMessage(exit)).toContain("failed to execute query: "); }).pipe(Effect.provide(layer)); }); - it.effect("requires login before querying --linked", () => { + it.effect("requires login before querying a bare --remote", () => { 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"), remote: REMOTE_LINKED }), + ).pipe(Effect.exit); expect(failMessage(exit)).toContain("Access token not provided"); }).pipe(Effect.provide(layer)); }); @@ -1114,20 +1139,20 @@ 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"), remote: REMOTE_LINKED }), + ).pipe(Effect.exit); expect(Exit.isFailure(exit)).toBe(true); expect(failMessage(exit)).toContain("Invalid access token format"); expect(out.stdoutText).toBe(""); }).pipe(Effect.provide(layer)); }); - it.effect("runs the --linked login preflight before reading --file", () => { + it.effect("runs the --remote login preflight before reading --file", () => { const { layer } = setup({ accessToken: Option.none() }); return Effect.gen(function* () { const exit = yield* dbQuery( - flags({ linked: true, file: Option.some("/no/such/file.sql") }), + flags({ remote: REMOTE_LINKED, 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"); @@ -1138,7 +1163,7 @@ describe("db query integration", () => { 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 })); + yield* dbQuery(flags({ sql: Option.some("select 1"), readOnly: true })); expect(dbConnection.statements).toEqual([ "BEGIN READ ONLY", READ_ONLY_LOCK_TIMEOUT_SQL, @@ -1153,9 +1178,9 @@ describe("db query integration", () => { 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(sql), local: true, readOnly: true }), - ).pipe(Effect.exit); + const exit = yield* dbQuery(flags({ sql: Option.some(sql), readOnly: true })).pipe( + Effect.exit, + ); expect(failMessage(exit)).toContain("read-only transaction"); expect(dbConnection.statements).toEqual([ "BEGIN READ ONLY", @@ -1169,12 +1194,12 @@ describe("db query integration", () => { 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 })); + yield* dbQuery(flags({ sql: Option.some("insert into t values (1)") })); expect(out.stdoutText).toBe("CREATE TABLE\n"); }).pipe(Effect.provide(layer)); }); - it.effect("runs in a read-only transaction on a --db-url target", () => { + it.effect("runs in a read-only transaction on a --remote=<db-url> target", () => { const { layer, dbConnection } = setup({ result: SELECT_RESULT, isLocal: false, @@ -1184,7 +1209,7 @@ describe("db query integration", () => { yield* dbQuery( flags({ sql: Option.some("select 1"), - dbUrl: Option.some("postgres://x/y"), + remote: Option.some({ _tag: "DbUrl", url: "postgres://x/y" } as const), readOnly: true, }), ); @@ -1200,7 +1225,9 @@ describe("db query integration", () => { it.effect("sends read_only to the Management API for a linked target", () => { const { layer, platformApi } = setup({ linkedStatus: 201, linkedBody: '[{"id":1}]' }); return Effect.gen(function* () { - yield* dbQuery(flags({ sql: Option.some("select 1"), linked: true, readOnly: true })); + yield* dbQuery( + flags({ sql: Option.some("select 1"), remote: REMOTE_LINKED, readOnly: true }), + ); expect(platformApi.requests.map(({ body }) => body)).toEqual([ { query: "select 1", read_only: true }, ]); @@ -1208,9 +1235,13 @@ describe("db query integration", () => { }); const rejectedTargets = [ - ["a local target", { local: true }, {}], - ["a --db-url target", { dbUrl: Option.some("postgres://x/y") }, { isLocal: false }], - ["a linked target", { linked: true }, {}], + ["the default local target", {}, {}], + [ + "a --remote=<db-url> target", + { remote: Option.some({ _tag: "DbUrl", url: "postgres://x/y" } as const) }, + { isLocal: false }, + ], + ["a linked target", { remote: REMOTE_LINKED }, {}], ] as const; for (const [name, target, opts] of rejectedTargets) { it.effect( @@ -1270,9 +1301,9 @@ describe("db query integration", () => { 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); + const exit = yield* dbQuery(flags({ sql: Option.some(sql), readOnly: true })).pipe( + Effect.exit, + ); expect(Exit.isFailure(exit) && Cause.squash(exit.cause)).toBeInstanceOf( DbQueryReadOnlyStatementError, ); @@ -1285,7 +1316,7 @@ describe("db query integration", () => { 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* () { - yield* dbQuery(flags({ sql: Option.some("select 1"), linked: true })); + yield* dbQuery(flags({ sql: Option.some("select 1"), remote: REMOTE_LINKED })); expect(platformApi.requests.map(({ body }) => body)).toEqual([{ query: "select 1" }]); }).pipe(Effect.provide(layer)); }); @@ -1294,18 +1325,17 @@ describe("db query integration", () => { 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, - ); + const exit = yield* dbQuery( + flags({ sql: Option.some("select 1"), remote: REMOTE_LINKED }), + ).pipe(Effect.provide(refused.layer), Effect.exit); expect(Exit.isFailure(exit) && Cause.squash(exit.cause)).toBeInstanceOf( HostedMutationConsentRequiredError, ); expect(refused.platformApi.requests).toEqual([]); - yield* dbQuery(flags({ sql: Option.some("select 1"), linked: true, readOnly: true })).pipe( - Effect.provide(allowed.layer), - ); + yield* dbQuery( + flags({ sql: Option.some("select 1"), remote: REMOTE_LINKED, readOnly: true }), + ).pipe(Effect.provide(allowed.layer)); expect(allowed.out.promptConfirmCalls).toEqual([]); expect(allowed.platformApi.requests).toHaveLength(1); }); @@ -1313,6 +1343,124 @@ describe("db query integration", () => { }); }); +describe("db query target selection", () => { + const FLAG_PROJECT_REF = "flagflagflagflagflag"; + + const observed = ( + selector: Partial<DbQueryFlags>, + branchApi?: ReturnType<typeof branchListApi>, + ) => { + const { layer, out, platformApi, resolvedTargets } = setup({ + result: SELECT_RESULT, + linkedStatus: 201, + linkedBody: '[{"name":"alice","id":1}]', + }); + const provided = branchApi === undefined ? layer : Layer.merge(layer, branchApi.layer); + return Effect.gen(function* () { + yield* dbQuery(flags({ sql: Option.some("select 1"), ...selector })).pipe( + Effect.provide(provided), + ); + const hostedRef = platformApi.requests + .filter((request) => request.method === "POST") + .map((request) => request.url.match(/\/v1\/projects\/([^/]+)\/database\/query$/u)?.[1]) + .find((ref) => ref !== undefined); + return { + hostedRef, + target: resolvedTargets[0], + warnings: warningsOf(out), + }; + }); + }; + + it.effect("queries the local database, with no warning, when nothing selects one", () => + Effect.gen(function* () { + const { hostedRef, target, warnings } = yield* observed({}); + expect(hostedRef).toBeUndefined(); + expect(target).toEqual({ kind: "local" }); + expect(warnings).toEqual([]); + }), + ); + + it.effect("queries the project --remote=<ref> names over HTTP, with no warning", () => + Effect.gen(function* () { + const { hostedRef, warnings } = yield* observed({ + remote: Option.some({ _tag: "Ref", ref: FLAG_PROJECT_REF } as const), + }); + expect(hostedRef).toBe(FLAG_PROJECT_REF); + expect(warnings).toEqual([]); + }), + ); + + it.effect("queries the database --remote=<db-url> names, with no warning", () => + Effect.gen(function* () { + const { hostedRef, target, warnings } = yield* observed({ + remote: Option.some({ + _tag: "DbUrl", + url: "postgresql://postgres:pw@db.example.com/db", + } as const), + }); + expect(hostedRef).toBeUndefined(); + expect(target?.kind).toBe("db-url"); + expect(warnings).toEqual([]); + }), + ); + + it.effect("queries the project of the branch --remote=<branch> names over HTTP", () => { + const branchApi = branchListApi(); + return Effect.gen(function* () { + const { hostedRef, warnings } = yield* observed( + { remote: Option.some({ _tag: "Branch", name: "feature-x" }) }, + branchApi, + ); + expect(hostedRef).toBe(BRANCH_PROJECT_REF); + expect(warnings).toEqual([]); + assertPermissionDrift({ + command: "db query", + activeFlags: { remote: "feature-x" }, + requests: branchApi.requests, + }); + }); + }); + + it.effect.each([ + { + alias: "--linked", + flags: { linked: true }, + expected: { hostedRef: REF }, + warning: "--linked is deprecated and will be removed in v4. Use --remote instead", + }, + { + alias: "--project-ref", + flags: { projectRef: Option.some(FLAG_PROJECT_REF) }, + expected: { hostedRef: FLAG_PROJECT_REF }, + warning: "--project-ref is deprecated and will be removed in v4. Use --remote instead", + }, + { + alias: "--db-url", + flags: { dbUrl: Option.some("postgresql://postgres:pw@db.example.com/db") }, + expected: { target: expect.objectContaining({ kind: "db-url" }) }, + warning: "--db-url is deprecated and will be removed in v4. Use --remote instead", + }, + { + alias: "--local", + flags: { local: true }, + expected: { target: { kind: "local" } }, + warning: "--local isn't needed (local is the default) and will be removed in v4", + }, + ] satisfies ReadonlyArray<{ + alias: string; + flags: Partial<DbQueryFlags>; + expected: object; + warning: string; + }>)("keeps $alias working and warns that it is deprecated", ({ flags, expected, warning }) => + Effect.gen(function* () { + const result = yield* observed(flags); + expect(result).toMatchObject(expected); + expect(result.warnings).toEqual([expect.stringContaining(warning)]); + }), + ); +}); + describeHostedConsent("db query", { invoke: () => { const platformApi = mockCommandPlatformApi({ response: { status: 201, body: [{ id: 1 }] } }); @@ -1320,8 +1468,7 @@ describeHostedConsent("db query", { run: dbQuery( flags({ sql: Option.some("insert into t values (1)"), - linked: true, - projectRef: Option.some(REF), + remote: Option.some({ _tag: "Ref", ref: REF } as const), }), ).pipe( Effect.provide( 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 678978820f..487d49ccfb 100644 --- a/apps/cli/src/commands/db/reset/reset.integration.test.ts +++ b/apps/cli/src/commands/db/reset/reset.integration.test.ts @@ -50,6 +50,11 @@ import { sequentialExecBatch, transportFailure, } from "../../../../tests/helpers/command-mocks.ts"; +import { + BRANCH_PROJECT_REF, + branchListApi, + warningsOf, +} from "../../../../tests/helpers/db-remote-selection.ts"; import { assertPermissionDrift } from "../../../../tests/helpers/permission-drift.ts"; import { asRecordedRequests, @@ -138,6 +143,8 @@ const CONN: PgConnInput = { database: "postgres", }; +const REMOTE_LINKED: DbResetFlags["remote"] = Option.some({ _tag: "Linked" }); + const DEFAULT_FLAGS: DbResetFlags = { dbUrl: Option.none(), remote: Option.none(), @@ -929,7 +936,7 @@ function setup( replicationSlotQueryFails?: boolean; failStatement?: { readonly sql: string; readonly code?: string; readonly message: string }; // Simulates an unlinked workdir: the project-ref resolver fails with `ProjectRefNotLinkedError` - // absent an explicit `--project-ref` flag, instead of falling back to `opts.ref`. + // absent an explicit `--remote=<ref>`, instead of falling back to `opts.ref`. linkedFails?: boolean; stackBackend?: boolean; stackDatabaseReady?: boolean; @@ -977,7 +984,7 @@ function setup( const telemetry = mockTelemetryStateTracked(); const linkedCache = mockLinkedProjectCacheTracked(); // The local-reset bucket-seed core statically requires the (lazy) Management-API factory, - // though `--local` never invokes it. + // though a local reset never invokes it. const platformApi = mockCommandPlatformApiService({}); const resolver = mockResolver({ isLocal: opts.isLocal ?? false, @@ -1088,7 +1095,7 @@ function setup( Layer.succeed(CommandPlatformApiFactory, { make: CommandPlatformApi.pipe(Effect.provide(platformApi.layer)), }), - Layer.succeed(CliArgs, { args: opts.args ?? ["db", "reset", "--linked"] }), + Layer.succeed(CliArgs, { args: opts.args ?? ["db", "reset", "--remote"] }), Layer.succeed(YesFlag, opts.yes ?? false), Layer.succeed(OutputFlag, Option.none()), Layer.succeed(TargetPolicy, { group: "selectable", consent: "no" }), @@ -1135,7 +1142,7 @@ describe("db reset", () => { it.effect("recreates the container, waits healthy, and runs the setup pipeline", () => { const { layer, out, child, telemetry } = setup(tmp.current, { toml: 'project_id = "test"\n', - args: ["db", "reset", "--local"], + args: ["db", "reset"], isLocal: true, }); return withEnvVar( @@ -1179,13 +1186,12 @@ describe("db reset", () => { ...migrationFile("20240101000000", "create table version_one_marker ();"), ...migrationFile("20240202000000", "create table version_two_marker ();"), }, - args: ["db", "reset", "--local"], + args: ["db", "reset"], isLocal: true, }); return Effect.gen(function* () { yield* dbReset({ ...DEFAULT_FLAGS, - local: true, version: Option.some("20240101000000"), }).pipe(Effect.provide(layer)); expect(conn.execs.some((sql) => sql.includes("create table version_one_marker ()"))).toBe( @@ -1205,7 +1211,7 @@ describe("db reset", () => { ...migrationFile("20240101000000", "create table pg15_marker ();"), "supabase/seed.sql": "insert into pg15_seed_marker values (1);", }, - args: ["db", "reset", "--local"], + args: ["db", "reset"], isLocal: true, }); return Effect.gen(function* () { @@ -1222,13 +1228,11 @@ describe("db reset", () => { noSeed: true, toml: 'project_id = "test"\n', files: { "supabase/seed.sql": "insert into t values (1);" }, - args: ["db", "reset", "--local"], + args: ["db", "reset"], isLocal: true, }); return Effect.gen(function* () { - yield* dbReset({ ...DEFAULT_FLAGS, local: true, noSeed: Option.some(true) }).pipe( - Effect.provide(layer), - ); + yield* dbReset({ ...DEFAULT_FLAGS, noSeed: Option.some(true) }).pipe(Effect.provide(layer)); expect(conn.execs.some((sql) => sql.includes("insert into t values (1)"))).toBe(false); }); }); @@ -1240,11 +1244,11 @@ describe("db reset", () => { env: { SUPABASE_DB_SEED_ENABLED: "true" }, toml: 'project_id = "test"\n\n[db.seed]\nenabled = false\n', files: { "supabase/seed.sql": "insert into t values (3);" }, - args: ["db", "reset", "--local"], + args: ["db", "reset"], isLocal: true, }); return Effect.gen(function* () { - yield* dbReset({ ...DEFAULT_FLAGS, local: true }).pipe(Effect.provide(layer)); + yield* dbReset(DEFAULT_FLAGS).pipe(Effect.provide(layer)); expect(conn.execs.some((sql) => sql.includes("insert into t values (3)"))).toBe(true); }); }, @@ -1255,11 +1259,11 @@ describe("db reset", () => { env: { SUPABASE_DB_SEED_ENABLED: "false" }, toml: 'project_id = "test"\n', files: { "supabase/seed.sql": "insert into t values (4);" }, - args: ["db", "reset", "--local"], + args: ["db", "reset"], isLocal: true, }); return Effect.gen(function* () { - yield* dbReset({ ...DEFAULT_FLAGS, local: true }).pipe(Effect.provide(layer)); + yield* dbReset(DEFAULT_FLAGS).pipe(Effect.provide(layer)); expect(conn.execs.some((sql) => sql.includes("insert into t values (4)"))).toBe(false); }); }); @@ -1270,13 +1274,12 @@ describe("db reset", () => { sqlPaths: ["custom-seed.sql"], toml: 'project_id = "test"\n', files: { "supabase/custom-seed.sql": "insert into t values (5);" }, - args: ["db", "reset", "--local"], + args: ["db", "reset"], isLocal: true, }); return Effect.gen(function* () { yield* dbReset({ ...DEFAULT_FLAGS, - local: true, sqlPaths: Option.some(["custom-seed.sql"]), }).pipe(Effect.provide(layer)); expect(conn.execs.some((sql) => sql.includes("insert into t values (5)"))).toBe(true); @@ -1288,13 +1291,12 @@ describe("db reset", () => { sqlPaths: ["custom-seed.sql"], toml: 'project_id = "test"\n\n[db.seed]\nenabled = false\n', files: { "supabase/custom-seed.sql": "insert into t values (2);" }, - args: ["db", "reset", "--local"], + args: ["db", "reset"], isLocal: true, }); return Effect.gen(function* () { yield* dbReset({ ...DEFAULT_FLAGS, - local: true, sqlPaths: Option.some(["custom-seed.sql"]), }).pipe(Effect.provide(layer)); expect(conn.execs.some((sql) => sql.includes("insert into t values (2)"))).toBe(true); @@ -1306,7 +1308,7 @@ describe("db reset", () => { () => { const { layer, child } = setup(tmp.current, { toml: 'project_id = "test"\n', - args: ["db", "reset", "--local"], + args: ["db", "reset"], isLocal: true, routeOpts: { running: false }, }); @@ -1326,7 +1328,7 @@ describe("db reset", () => { () => { const { layer, child } = setup(tmp.current, { toml: 'project_id = "test"\n', - args: ["db", "reset", "--local"], + args: ["db", "reset"], isLocal: true, routeOpts: { running: true }, }); @@ -1354,7 +1356,7 @@ describe("db reset", () => { it.effect("resets the stack database without Compose volume recreate", () => { const { layer, child, stackApi, catalogApplied, out } = setup(tmp.current, { toml: 'project_id = "test"\n', - args: ["db", "reset", "--local"], + args: ["db", "reset"], isLocal: true, stackBackend: true, }); @@ -1377,7 +1379,7 @@ describe("db reset", () => { it.effect("preserves the existing stack composition while rebuilding the database", () => { const { layer, catalogApplied } = setup(tmp.current, { toml: 'project_id = "test"\n', - args: ["db", "reset", "--local"], + args: ["db", "reset"], isLocal: true, stackBackend: true, }); @@ -1390,7 +1392,7 @@ describe("db reset", () => { it.effect("keeps a service excluded from the saved stack out of schema provisioning", () => { const { layer, catalogApplied } = setup(tmp.current, { toml: 'project_id = "test"\n', - args: ["db", "reset", "--local"], + args: ["db", "reset"], isLocal: true, stackBackend: true, stackStorageState: "disabled", @@ -1404,7 +1406,7 @@ describe("db reset", () => { it.effect("provisions configured service schemas for a postgres-only stack", () => { const { layer, catalogApplied } = setup(tmp.current, { toml: 'project_id = "test"\n', - args: ["db", "reset", "--local"], + args: ["db", "reset"], isLocal: true, stackBackend: true, stackPostgresOnly: true, @@ -1418,7 +1420,7 @@ describe("db reset", () => { it.effect("skips schemas for services disabled in config on a postgres-only stack", () => { const { layer, catalogApplied } = setup(tmp.current, { toml: 'project_id = "test"\n[storage]\nenabled = false\n', - args: ["db", "reset", "--local"], + args: ["db", "reset"], isLocal: true, stackBackend: true, stackPostgresOnly: true, @@ -1434,7 +1436,7 @@ describe("db reset", () => { toml: 'project_id = "test"\n', files: { ...migrationFile("20240101000000", "create table stack_reset_failure ();") }, execFailsOn: "create table stack_reset_failure", - args: ["db", "reset", "--local"], + args: ["db", "reset"], isLocal: true, stackBackend: true, }); @@ -1458,7 +1460,7 @@ describe("db reset", () => { "[auth]", 'jwt_secret = "super-secret-jwt-token-with-at-least-32-characters-long"', ].join("\n"), - args: ["db", "reset", "--local"], + args: ["db", "reset"], isLocal: true, yes: true, stackBackend: true, @@ -1499,10 +1501,10 @@ describe("db reset", () => { }); }); - it.effect("fails --local reset when the stack database is not running", () => { + it.effect("fails a default local reset when the stack database is not running", () => { const { layer, stackApi } = setup(tmp.current, { toml: 'project_id = "test"\n', - args: ["db", "reset", "--local"], + args: ["db", "reset"], isLocal: true, stackBackend: true, stackDatabaseReady: false, @@ -1518,7 +1520,7 @@ describe("db reset", () => { it.effect("reports a stack whose owner is absent as not running", () => { const { layer, stackApi } = setup(tmp.current, { toml: 'project_id = "test"\n', - args: ["db", "reset", "--local"], + args: ["db", "reset"], isLocal: true, stackBackend: true, stackStatusFailure: new StackError({ @@ -1542,7 +1544,7 @@ describe("db reset", () => { "Stack test is served by release 1.0.0+old, but this client is release 1.0.0+new; stop or destroy the stack (both work across releases) and retry"; const { layer, stackApi } = setup(tmp.current, { toml: 'project_id = "test"\n', - args: ["db", "reset", "--local"], + args: ["db", "reset"], isLocal: true, stackBackend: true, stackStatusFailure: new StackError({ @@ -1561,7 +1563,7 @@ describe("db reset", () => { it.effect("reports a database that becomes unavailable after the reset as not running", () => { const { layer, stackApi } = setup(tmp.current, { toml: 'project_id = "test"\n', - args: ["db", "reset", "--local"], + args: ["db", "reset"], isLocal: true, stackBackend: true, stackUnavailableAfterReset: true, @@ -1584,7 +1586,7 @@ describe("db reset", () => { const client = recordingStackStorageHttpClient(); const { layer } = setup(tmp.current, { toml: BUCKET_TOML, - args: ["db", "reset", "--local"], + args: ["db", "reset"], isLocal: true, yes: true, stackBackend: true, @@ -1611,7 +1613,7 @@ describe("db reset", () => { const client = recordingStackStorageHttpClient(); const { layer, out } = setup(tmp.current, { toml: BUCKET_TOML, - args: ["db", "reset", "--local"], + args: ["db", "reset"], isLocal: true, stackBackend: true, stackStorageState: "disabled", @@ -1625,10 +1627,10 @@ describe("db reset", () => { ); expect(out.stderrText).toContain( "Set [storage] enabled = true in supabase/config.toml, run supabase stack stop " + - "followed by supabase stack start without -x storage, then supabase seed buckets --local.", + "followed by supabase stack start without -x storage, then supabase seed buckets.", ); expect(out.stderrText).not.toContain( - "Run supabase seed buckets --local once Storage is available.", + "Run supabase seed buckets once Storage is available.", ); expect(client.requests).toHaveLength(0); }); @@ -1641,7 +1643,7 @@ describe("db reset", () => { const client = recordingStackStorageHttpClient(); const { layer, out } = setup(tmp.current, { toml: 'project_id = "test"\n', - args: ["db", "reset", "--local"], + args: ["db", "reset"], isLocal: true, stackBackend: true, stackStorageState: "disabled", @@ -1662,7 +1664,7 @@ describe("db reset", () => { const client = recordingStackStorageHttpClient(); const { layer, out } = setup(tmp.current, { toml: BUCKET_TOML, - args: ["db", "reset", "--local"], + args: ["db", "reset"], isLocal: true, stackBackend: true, stackStorageState: "stopped", @@ -1674,9 +1676,7 @@ describe("db reset", () => { expect(out.stderrText).toContain( "WARNING: skipped seeding storage buckets: Storage is stopped for this stack.", ); - expect(out.stderrText).toContain( - "Run supabase seed buckets --local once Storage is available.", - ); + expect(out.stderrText).toContain("Run supabase seed buckets once Storage is available."); expect(client.requests).toHaveLength(0); }); }, @@ -1688,7 +1688,7 @@ describe("db reset", () => { const client = recordingStackStorageHttpClient(); const { layer } = setup(tmp.current, { toml: BUCKET_TOML, - args: ["db", "reset", "--local"], + args: ["db", "reset"], isLocal: true, yes: true, stackBackend: true, @@ -1714,7 +1714,7 @@ describe("db reset", () => { const client = recordingStackStorageHttpClient(); const { layer, out } = setup(tmp.current, { toml: BUCKET_TOML, - args: ["db", "reset", "--local"], + args: ["db", "reset"], isLocal: true, yes: true, stackBackend: true, @@ -1742,7 +1742,7 @@ describe("db reset", () => { const client = recordingStackStorageHttpClientBucketCreateFails(500); const { layer, out } = setup(tmp.current, { toml: BUCKET_TOML, - args: ["db", "reset", "--local"], + args: ["db", "reset"], isLocal: true, yes: true, stackBackend: true, @@ -1754,9 +1754,7 @@ describe("db reset", () => { const exit = yield* dbReset(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isSuccess(exit)).toBe(true); expect(out.stderrText).toContain("WARNING: skipped seeding storage buckets:"); - expect(out.stderrText).toContain( - "Run supabase seed buckets --local once Storage is available.", - ); + expect(out.stderrText).toContain("Run supabase seed buckets once Storage is available."); expect(out.stderrText).toContain("Finished "); }); }, @@ -1768,7 +1766,7 @@ describe("db reset", () => { const client = recordingStackStorageHttpClientBucketListTransportFails(); const { layer, out } = setup(tmp.current, { toml: BUCKET_TOML, - args: ["db", "reset", "--local"], + args: ["db", "reset"], isLocal: true, yes: true, stackBackend: true, @@ -1780,9 +1778,7 @@ describe("db reset", () => { const exit = yield* dbReset(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isSuccess(exit)).toBe(true); expect(out.stderrText).toContain("WARNING: skipped seeding storage buckets:"); - expect(out.stderrText).toContain( - "Run supabase seed buckets --local once Storage is available.", - ); + expect(out.stderrText).toContain("Run supabase seed buckets once Storage is available."); expect(out.stderrText).toContain("Finished "); }); }, @@ -1793,7 +1789,7 @@ describe("db reset", () => { () => { const { layer, out } = setup(tmp.current, { toml: BUCKET_TOML, - args: ["db", "reset", "--local"], + args: ["db", "reset"], isLocal: true, stackBackend: true, stackStorageState: "failed", @@ -1803,7 +1799,7 @@ describe("db reset", () => { return Effect.gen(function* () { yield* dbReset(DEFAULT_FLAGS).pipe(Effect.provide(layer)); expect(out.stderrText).toContain( - "WARNING: skipped seeding storage buckets: Storage failed to start for this stack: boom. Run supabase seed buckets --local once Storage is available.", + "WARNING: skipped seeding storage buckets: Storage failed to start for this stack: boom. Run supabase seed buckets once Storage is available.", ); expect(out.stderrText).not.toContain("Storage is failed"); }); @@ -1815,7 +1811,7 @@ describe("db reset", () => { () => { const { layer, out } = setup(tmp.current, { toml: BUCKET_TOML, - args: ["db", "reset", "--local"], + args: ["db", "reset"], isLocal: true, stackBackend: true, stackStorageState: "dormant", @@ -1827,9 +1823,7 @@ describe("db reset", () => { expect(out.stderrText).toContain( "WARNING: skipped seeding storage buckets: The stack exposes no Storage endpoint.", ); - expect(out.stderrText).toContain( - "Run supabase seed buckets --local once Storage is available.", - ); + expect(out.stderrText).toContain("Run supabase seed buckets once Storage is available."); }); }, ); @@ -1840,7 +1834,7 @@ describe("db reset", () => { const client = recordingStackStorageHttpClient(); const { layer, out } = setup(tmp.current, { toml: 'project_id = "test"\n', - args: ["db", "reset", "--local"], + args: ["db", "reset"], isLocal: true, stackBackend: true, stackStorageState: "dormant", @@ -1861,7 +1855,7 @@ describe("db reset", () => { () => { const { layer, child } = setup(tmp.current, { toml: 'project_id = "unterminated\n', - args: ["db", "reset", "--local"], + args: ["db", "reset"], isLocal: true, }); return Effect.gen(function* () { @@ -1883,7 +1877,7 @@ describe("db reset", () => { it.effect("seeds buckets after a local reset when storage is ready", () => { const { layer, child } = setup(tmp.current, { toml: 'project_id = "test"\n', - args: ["db", "reset", "--local"], + args: ["db", "reset"], isLocal: true, }); return Effect.gen(function* () { @@ -1901,7 +1895,7 @@ describe("db reset", () => { it.effect("skips bucket seeding when storage is absent (any inspect error)", () => { const { layer, out } = setup(tmp.current, { toml: 'project_id = "test"\n', - args: ["db", "reset", "--local"], + args: ["db", "reset"], isLocal: true, routeOpts: { storageMissing: true }, }); @@ -1916,7 +1910,7 @@ describe("db reset", () => { () => { const { layer, out, requests } = setup(tmp.current, { toml: 'project_id = "test"\n[storage.buckets.images]\n', - args: ["db", "reset", "--local"], + args: ["db", "reset"], isLocal: true, storageRoutes: [ { @@ -1950,7 +1944,7 @@ describe("db reset", () => { () => { const { layer, out, requests } = setup(tmp.current, { toml: 'project_id = "test"\n[storage.vector]\nenabled = true\n[storage.vector.buckets.embeddings]\n', - args: ["db", "reset", "--local"], + args: ["db", "reset"], isLocal: true, storageRoutes: [ { method: "GET", match: "/storage/v1/bucket", body: [] }, @@ -1982,7 +1976,7 @@ describe("db reset", () => { const { layer, out, requests } = setup(tmp.current, { toml: 'project_id = "test"\n[storage.vector]\nenabled = true\n[storage.vector.buckets.embeddings]\n', files: { "supabase/.env": "SUPABASE_YES=true\n" }, - args: ["db", "reset", "--local"], + args: ["db", "reset"], isLocal: true, storageRoutes: [ { method: "GET", match: "/storage/v1/bucket", body: [] }, @@ -2017,7 +2011,7 @@ describe("db reset", () => { it.effect("prunes a stale vector bucket when a piped y answers the confirmation", () => { const { layer, out, requests } = setup(tmp.current, { toml: 'project_id = "test"\n[storage.vector]\nenabled = true\n[storage.vector.buckets.embeddings]\n', - args: ["db", "reset", "--local"], + args: ["db", "reset"], isLocal: true, pipedStdin: "y\n", stdinIsTty: false, @@ -2049,7 +2043,7 @@ describe("db reset", () => { it.effect("uses the detected git branch in the Finished line", () => { const { layer, out } = setup(tmp.current, { toml: 'project_id = "test"\n', - args: ["db", "reset", "--local"], + args: ["db", "reset"], isLocal: true, }); return withConfigEnv( @@ -2065,7 +2059,7 @@ describe("db reset", () => { it.effect("emits a json result for a local reset", () => { const { layer, out } = setup(tmp.current, { toml: 'project_id = "test"\n', - args: ["db", "reset", "--local"], + args: ["db", "reset"], isLocal: true, format: "json", }); @@ -2079,7 +2073,7 @@ describe("db reset", () => { it.effect("still flushes telemetry when the recreate itself fails", () => { const { layer, telemetry } = setup(tmp.current, { toml: 'project_id = "test"\n', - args: ["db", "reset", "--local"], + args: ["db", "reset"], isLocal: true, route: (args) => { if (args[0] === "container" && args[1] === "rm") { @@ -2103,7 +2097,7 @@ describe("db reset", () => { it.effect("fails the whole command with the exact suggestion when Kong reload fails", () => { const { layer } = setup(tmp.current, { toml: 'project_id = "test"\n', - args: ["db", "reset", "--local"], + args: ["db", "reset"], isLocal: true, routeOpts: { kongReloadFails: true }, }); @@ -2129,7 +2123,7 @@ describe("db reset", () => { it.effect("skips the reload without failing when Kong is excluded from the stack", () => { const { layer, out, child } = setup(tmp.current, { toml: 'project_id = "test"\n', - args: ["db", "reset", "--local"], + args: ["db", "reset"], isLocal: true, routeOpts: { kongMissing: true }, }); @@ -2143,7 +2137,7 @@ describe("db reset", () => { it.effect("skips the reload without failing when Kong is present but stopped", () => { const { layer, out, child } = setup(tmp.current, { toml: 'project_id = "test"\n', - args: ["db", "reset", "--local"], + args: ["db", "reset"], isLocal: true, routeOpts: { kongNotRunning: true }, }); @@ -2157,7 +2151,7 @@ describe("db reset", () => { it.effect("fails the command when a satellite restart fails", () => { const { layer } = setup(tmp.current, { toml: 'project_id = "test"\n', - args: ["db", "reset", "--local"], + args: ["db", "reset"], isLocal: true, routeOpts: { restartFails: ["supabase_storage_test"] }, }); @@ -2177,7 +2171,7 @@ describe("db reset", () => { () => { const { layer, out, child, conn } = setup(tmp.current, { toml: PG14_TOML, - args: ["db", "reset", "--local"], + args: ["db", "reset"], isLocal: true, }); return Effect.gen(function* () { @@ -2219,7 +2213,7 @@ describe("db reset", () => { // rich context (`At statement: <index>` + statement text) a real migration failure would. const { layer } = setup(tmp.current, { toml: PG14_TOML, - args: ["db", "reset", "--local"], + args: ["db", "reset"], isLocal: true, failStatement: { sql: "CREATE DATABASE postgres WITH OWNER postgres", @@ -2241,7 +2235,7 @@ describe("db reset", () => { it.effect("swallows a disconnect-clients failure when the code is invalid_catalog_name", () => { const { layer, conn } = setup(tmp.current, { toml: PG14_TOML, - args: ["db", "reset", "--local"], + args: ["db", "reset"], isLocal: true, failStatement: { sql: "ALTER DATABASE postgres ALLOW_CONNECTIONS false", @@ -2260,7 +2254,7 @@ describe("db reset", () => { it.effect("surfaces a disconnect-clients failure for any other error code", () => { const { layer } = setup(tmp.current, { toml: PG14_TOML, - args: ["db", "reset", "--local"], + args: ["db", "reset"], isLocal: true, failStatement: { sql: "ALTER DATABASE postgres ALLOW_CONNECTIONS false", @@ -2280,7 +2274,7 @@ describe("db reset", () => { it.effect("swallows a disconnect-clients failure that is not a PgError at all", () => { const { layer, conn } = setup(tmp.current, { toml: PG14_TOML, - args: ["db", "reset", "--local"], + args: ["db", "reset"], isLocal: true, failStatement: { sql: "ALTER DATABASE postgres ALLOW_CONNECTIONS false", @@ -2303,7 +2297,7 @@ describe("db reset", () => { // `isSqlState(code)` before comparing against `3D000`, not just `code !== undefined`. const { layer, conn } = setup(tmp.current, { toml: PG14_TOML, - args: ["db", "reset", "--local"], + args: ["db", "reset"], isLocal: true, failStatement: { sql: "ALTER DATABASE postgres ALLOW_CONNECTIONS false", @@ -2325,7 +2319,7 @@ describe("db reset", () => { () => { const { layer, conn } = setup(tmp.current, { toml: PG14_TOML, - args: ["db", "reset", "--local"], + args: ["db", "reset"], isLocal: true, replicationSlotCounts: [2, 1, 0], }); @@ -2341,7 +2335,7 @@ describe("db reset", () => { it.effect("fails permanently (no retry) when counting replication slots itself fails", () => { const { layer, conn } = setup(tmp.current, { toml: PG14_TOML, - args: ["db", "reset", "--local"], + args: ["db", "reset"], isLocal: true, replicationSlotQueryFails: true, }); @@ -2362,7 +2356,7 @@ describe("db reset", () => { () => { const { layer } = setup(tmp.current, { toml: PG14_TOML, - args: ["db", "reset", "--local"], + args: ["db", "reset"], isLocal: true, replicationSlotCounts: [1], }); @@ -2382,13 +2376,11 @@ describe("db reset", () => { noSeed: true, toml: PG14_TOML, files: { "supabase/seed.sql": "insert into t values (9);" }, - args: ["db", "reset", "--local"], + args: ["db", "reset"], isLocal: true, }); return Effect.gen(function* () { - yield* dbReset({ ...DEFAULT_FLAGS, local: true, noSeed: Option.some(true) }).pipe( - Effect.provide(layer), - ); + yield* dbReset({ ...DEFAULT_FLAGS, noSeed: Option.some(true) }).pipe(Effect.provide(layer)); expect(conn.execs.some((sql) => sql.includes("insert into t values (9)"))).toBe(false); }); }); @@ -2400,7 +2392,7 @@ describe("db reset", () => { ...migrationFile("20240101000000", "create table pg14_marker ();"), "supabase/seed.sql": "insert into pg14_seed_marker values (1);", }, - args: ["db", "reset", "--local"], + args: ["db", "reset"], isLocal: true, }); return Effect.gen(function* () { @@ -2419,7 +2411,7 @@ describe("db reset", () => { "20240101000000", "select net.http_post(url := 'https://example.com');", ), - args: ["db", "reset", "--local"], + args: ["db", "reset"], isLocal: true, }); return Effect.gen(function* () { @@ -2445,7 +2437,7 @@ describe("db reset", () => { // `supabase start`, surfacing as drift in the next engine's shadow baseline. const { layer, conn } = setup(tmp.current, { toml: PG14_TOML, - args: ["db", "reset", "--local"], + args: ["db", "reset"], isLocal: true, }); return Effect.gen(function* () { @@ -2470,13 +2462,12 @@ describe("db reset", () => { ...migrationFile("20240101000000", "create table version_one_marker ();"), ...migrationFile("20240202000000", "create table version_two_marker ();"), }, - args: ["db", "reset", "--local"], + args: ["db", "reset"], isLocal: true, }); return Effect.gen(function* () { yield* dbReset({ ...DEFAULT_FLAGS, - local: true, version: Option.some("20240101000000"), }).pipe(Effect.provide(layer)); expect(conn.execs.some((sql) => sql.includes("create table version_one_marker ()"))).toBe( @@ -2494,7 +2485,7 @@ describe("db reset", () => { () => { const { layer, conn } = setup(tmp.current, { toml: PG14_TOML, - args: ["db", "reset", "--local"], + args: ["db", "reset"], isLocal: true, }); return Effect.gen(function* () { @@ -2514,7 +2505,7 @@ describe("db reset", () => { const { layer, conn } = setup(tmp.current, { toml: 'project_id = "test"\n[db]\nmajor_version = 14\n[db.migrations]\nschema_paths = ["schema.sql"]\n\n[experimental.pgdelta]\nenabled = false\n', files: { "supabase/schema.sql": "create table schema_paths_marker ();" }, - args: ["db", "reset", "--local"], + args: ["db", "reset"], isLocal: true, experimental: true, }); @@ -2532,7 +2523,7 @@ describe("db reset", () => { it.live("a container health-check timeout fails the whole recreate", () => { const { layer } = setup(tmp.current, { toml: `project_id = "test"\n${FAST_HEALTH_TOML}`, - args: ["db", "reset", "--local"], + args: ["db", "reset"], isLocal: true, routeOpts: { neverHealthy: true }, }); @@ -2547,7 +2538,7 @@ describe("db reset", () => { it.effect("fails a remote reset on a malformed config.toml", () => { const { layer } = setup(tmp.current, { toml: 'project_id = "unterminated\n' }); return Effect.gen(function* () { - const exit = yield* dbReset({ ...DEFAULT_FLAGS, linked: true }).pipe( + const exit = yield* dbReset({ ...DEFAULT_FLAGS, remote: REMOTE_LINKED }).pipe( Effect.provide(layer), Effect.exit, ); @@ -2572,7 +2563,7 @@ describe("db reset", () => { "MIGRATIONS_ENABLED", "true", Effect.gen(function* () { - yield* dbReset({ ...DEFAULT_FLAGS, linked: true }).pipe(Effect.provide(layer)); + yield* dbReset({ ...DEFAULT_FLAGS, remote: REMOTE_LINKED }).pipe(Effect.provide(layer)); expect(out.stderrText).toContain("Applying migration 20240101000000_test.sql..."); }), ).pipe((body) => withConfigEnv({ MIGRATIONS_ENABLED: "true" }, body)); @@ -2598,7 +2589,7 @@ describe("db reset", () => { return Effect.gen(function* () { const exit = yield* dbReset({ ...DEFAULT_FLAGS, - linked: true, + remote: REMOTE_LINKED, version: Option.some("20240101000000"), last: Option.some(1), }).pipe(Effect.provide(layer), Effect.exit); @@ -2612,7 +2603,7 @@ describe("db reset", () => { return Effect.gen(function* () { const exit = yield* dbReset({ ...DEFAULT_FLAGS, - linked: true, + remote: REMOTE_LINKED, version: Option.some("not-a-number"), }).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isFailure(exit)).toBe(true); @@ -2630,7 +2621,7 @@ describe("db reset", () => { return Effect.gen(function* () { const exit = yield* dbReset({ ...DEFAULT_FLAGS, - linked: true, + remote: REMOTE_LINKED, version: Option.some("20240101000000"), }).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isFailure(exit)).toBe(true); @@ -2647,7 +2638,7 @@ describe("db reset", () => { return Effect.gen(function* () { const exit = yield* dbReset({ ...DEFAULT_FLAGS, - linked: true, + remote: REMOTE_LINKED, version: Option.some("99999999999999999999"), }).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isFailure(exit)).toBe(true); @@ -2668,7 +2659,7 @@ describe("db reset", () => { return Effect.gen(function* () { yield* dbReset({ ...DEFAULT_FLAGS, - linked: true, + remote: REMOTE_LINKED, version: Option.some(""), }).pipe(Effect.provide(layer)); expect(out.stderrText).toContain("Resetting remote database..."); @@ -2682,7 +2673,7 @@ describe("db reset", () => { confirm: [false], }); return Effect.gen(function* () { - const exit = yield* dbReset({ ...DEFAULT_FLAGS, linked: true }).pipe( + const exit = yield* dbReset({ ...DEFAULT_FLAGS, remote: REMOTE_LINKED }).pipe( Effect.provide(layer), Effect.exit, ); @@ -2702,7 +2693,7 @@ describe("db reset", () => { confirm: [true], }); return Effect.gen(function* () { - yield* dbReset({ ...DEFAULT_FLAGS, linked: true }).pipe(Effect.provide(layer)); + yield* dbReset({ ...DEFAULT_FLAGS, remote: REMOTE_LINKED }).pipe(Effect.provide(layer)); expect(out.stderrText).toContain("Resetting remote database..."); expect(out.stderrText).not.toContain("Connecting to"); expect(conn.execs.some((s) => s.includes("drop schema if exists"))).toBe(true); @@ -2716,7 +2707,7 @@ describe("db reset", () => { const { layer, conn, projectHttp } = setup(tmp.current, { toml: 'project_id = "test"\n', files: migrationFile("20240101000000"), - args: ["db", "reset", "--linked", "--project-ref", VALID_REF], + args: ["db", "reset", `--remote=${VALID_REF}`], confirm: [true], realLayers: true, }); @@ -2726,8 +2717,7 @@ describe("db reset", () => { yield* stubManagementApi(requests, serveLinkedDatabase(VALID_REF)); yield* dbReset({ ...DEFAULT_FLAGS, - linked: true, - projectRef: Option.some(VALID_REF), + remote: Option.some({ _tag: "Ref", ref: VALID_REF } as const), }).pipe(Effect.provide(layer)); expect(requests.map(({ method, path }) => `${method} ${path}`)).toEqual([ `GET /v1/projects/${VALID_REF}/config/database/pooler`, @@ -2740,7 +2730,7 @@ describe("db reset", () => { expect(yield* fs.exists(`${tmp.current}/supabase/.temp/linked-project.json`)).toBe(true); assertPermissionDrift({ command: "db reset", - activeFlags: { linked: true, "project-ref": VALID_REF }, + activeFlags: { remote: VALID_REF }, requests: [...asRecordedRequests(requests), ...projectHttp.calls], exact: true, }); @@ -2776,11 +2766,11 @@ describe("db reset", () => { }); it.effect( - "makes no Management API request for --local through the real resolver and cache", + "makes no Management API request for the default local target through the real resolver and cache", () => { const { layer, projectHttp } = setup(tmp.current, { toml: 'project_id = "test"\n', - args: ["db", "reset", "--local"], + args: ["db", "reset"], realLayers: true, }); const requests: Array<StubbedRequest> = []; @@ -2790,7 +2780,7 @@ describe("db reset", () => { Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; yield* stubManagementApi(requests, () => undefined); - yield* dbReset({ ...DEFAULT_FLAGS, local: true }).pipe(Effect.provide(layer)); + yield* dbReset(DEFAULT_FLAGS).pipe(Effect.provide(layer)); expect(requests).toEqual([]); expect(projectHttp.calls).toEqual([]); expect(yield* fs.exists(`${tmp.current}/supabase/.temp/linked-project.json`)).toBe( @@ -2798,7 +2788,7 @@ describe("db reset", () => { ); assertPermissionDrift({ command: "db reset", - activeFlags: { local: true }, + activeFlags: {}, requests: [], exact: true, }); @@ -2819,7 +2809,7 @@ describe("db reset", () => { confirm: [true], }); return Effect.gen(function* () { - const exit = yield* dbReset({ ...DEFAULT_FLAGS, linked: true }).pipe( + const exit = yield* dbReset({ ...DEFAULT_FLAGS, remote: REMOTE_LINKED }).pipe( Effect.provide(layer), Effect.exit, ); @@ -2837,7 +2827,7 @@ describe("db reset", () => { confirm: [true], }); return Effect.gen(function* () { - const exit = yield* dbReset({ ...DEFAULT_FLAGS, linked: true }).pipe( + const exit = yield* dbReset({ ...DEFAULT_FLAGS, remote: REMOTE_LINKED }).pipe( Effect.provide(layer), Effect.exit, ); @@ -2858,7 +2848,7 @@ describe("db reset", () => { // No `confirm` responses: the prompt must auto-confirm. }); return Effect.gen(function* () { - yield* dbReset({ ...DEFAULT_FLAGS, linked: true }).pipe(Effect.provide(layer)); + yield* dbReset({ ...DEFAULT_FLAGS, remote: REMOTE_LINKED }).pipe(Effect.provide(layer)); expect(conn.execs.some((s) => s.includes("drop schema if exists"))).toBe(true); }); }); @@ -2869,7 +2859,7 @@ describe("db reset", () => { resolveFails: true, }); return Effect.gen(function* () { - const exit = yield* dbReset({ ...DEFAULT_FLAGS, linked: true }).pipe( + const exit = yield* dbReset({ ...DEFAULT_FLAGS, remote: REMOTE_LINKED }).pipe( Effect.provide(layer), Effect.exit, ); @@ -2879,7 +2869,7 @@ describe("db reset", () => { }); }); - it.effect("resets the project given via --project-ref without a linked workdir", () => { + it.effect("resets the project given via --remote=<ref> without a linked workdir", () => { const FLAG_REF = "flagflagflagflagflag"; const { layer, conn, linkedCache } = setup(tmp.current, { toml: 'project_id = "test"\n', @@ -2889,8 +2879,7 @@ describe("db reset", () => { return Effect.gen(function* () { yield* dbReset({ ...DEFAULT_FLAGS, - linked: true, - projectRef: Option.some(FLAG_REF), + remote: Option.some({ _tag: "Ref", ref: FLAG_REF } as const), }).pipe(Effect.provide(layer)); expect(conn.execs.some((s) => s.includes("drop schema if exists"))).toBe(true); expect(linkedCache.cached).toBe(true); @@ -2898,7 +2887,7 @@ describe("db reset", () => { }); }); - it.effect("--project-ref overrides an already-linked workdir's project ref", () => { + it.effect("--remote=<ref> overrides an already-linked workdir's project ref", () => { const FLAG_REF = "flagflagflagflagflag"; const { layer, linkedCache } = setup(tmp.current, { toml: 'project_id = "test"\n', @@ -2908,8 +2897,7 @@ describe("db reset", () => { return Effect.gen(function* () { yield* dbReset({ ...DEFAULT_FLAGS, - linked: true, - projectRef: Option.some(FLAG_REF), + remote: Option.some({ _tag: "Ref", ref: FLAG_REF } as const), }).pipe(Effect.provide(layer)); expect(linkedCache.cached).toBe(true); expect(linkedCache.cachedRef).toBe(FLAG_REF); @@ -2949,7 +2937,7 @@ describe("db reset", () => { return Effect.gen(function* () { yield* dbReset({ ...DEFAULT_FLAGS, - linked: true, + remote: REMOTE_LINKED, version: Option.some("20240101000000"), }).pipe(Effect.provide(layer)); expect(out.stderrText).toContain("Resetting remote database to version: 20240101000000"); @@ -2970,7 +2958,7 @@ describe("db reset", () => { }); return Effect.gen(function* () { // last=1 → revert the most recent → reset to version 20240101000000. - yield* dbReset({ ...DEFAULT_FLAGS, linked: true, last: Option.some(1) }).pipe( + yield* dbReset({ ...DEFAULT_FLAGS, remote: REMOTE_LINKED, last: Option.some(1) }).pipe( Effect.provide(layer), ); expect(out.stderrText).toContain("Resetting remote database to version: 20240101000000"); @@ -2985,7 +2973,7 @@ describe("db reset", () => { }); return Effect.gen(function* () { // last=2 with 2 local migrations → revert all → version "-". - yield* dbReset({ ...DEFAULT_FLAGS, linked: true, last: Option.some(2) }).pipe( + yield* dbReset({ ...DEFAULT_FLAGS, remote: REMOTE_LINKED, last: Option.some(2) }).pipe( Effect.provide(layer), ); expect(out.stderrText).toContain("Resetting remote database to version: -"); @@ -3003,7 +2991,7 @@ describe("db reset", () => { confirm: [true], }); return Effect.gen(function* () { - yield* dbReset({ ...DEFAULT_FLAGS, linked: true, noSeed: Option.some(true) }).pipe( + yield* dbReset({ ...DEFAULT_FLAGS, remote: REMOTE_LINKED, noSeed: Option.some(true) }).pipe( Effect.provide(layer), ); expect(out.stderrText).not.toContain("Seeding data from"); @@ -3055,7 +3043,7 @@ describe("db reset", () => { confirm: [true], }); return Effect.gen(function* () { - yield* dbReset({ ...DEFAULT_FLAGS, linked: true }).pipe(Effect.provide(layer)); + yield* dbReset({ ...DEFAULT_FLAGS, remote: REMOTE_LINKED }).pipe(Effect.provide(layer)); const order = conn.execs .map((s) => /create table (\w+) \(\)/.exec(s)?.[1]) .filter((name): name is string => name !== undefined); @@ -3077,7 +3065,7 @@ describe("db reset", () => { confirm: [true], }); return Effect.gen(function* () { - yield* dbReset({ ...DEFAULT_FLAGS, linked: true }).pipe(Effect.provide(layer)); + yield* dbReset({ ...DEFAULT_FLAGS, remote: REMOTE_LINKED }).pipe(Effect.provide(layer)); expect(conn.execs.some((s) => s.includes("create table dir_top"))).toBe(true); expect(conn.execs.some((s) => s.includes("create table dir_nested"))).toBe(true); }); @@ -3094,7 +3082,7 @@ describe("db reset", () => { confirm: [true], }); return Effect.gen(function* () { - yield* dbReset({ ...DEFAULT_FLAGS, linked: true }).pipe(Effect.provide(layer)); + yield* dbReset({ ...DEFAULT_FLAGS, remote: REMOTE_LINKED }).pipe(Effect.provide(layer)); expect(conn.execs.some((s) => s.includes("drop schema if exists"))).toBe(true); expect(conn.execs.some((s) => s.includes("create table migrated_table"))).toBe(false); expect(out.stderrText).not.toContain("Applying migration"); @@ -3115,7 +3103,7 @@ describe("db reset", () => { confirm: [true], }); return Effect.gen(function* () { - yield* dbReset({ ...DEFAULT_FLAGS, linked: true }).pipe(Effect.provide(layer)); + yield* dbReset({ ...DEFAULT_FLAGS, remote: REMOTE_LINKED }).pipe(Effect.provide(layer)); expect(conn.execs.some((s) => s.includes("create table migrated_table"))).toBe(true); expect(conn.execs.some((s) => s.includes("create table schema_users"))).toBe(false); expect(out.stderrText).toContain("Applying migration"); @@ -3142,7 +3130,7 @@ describe("db reset", () => { }); return Effect.gen(function* () { // Declining the prompt proves the warning was printed before it. - const exit = yield* dbReset({ ...DEFAULT_FLAGS, linked: true }).pipe( + const exit = yield* dbReset({ ...DEFAULT_FLAGS, remote: REMOTE_LINKED }).pipe( Effect.provide(layer), Effect.exit, ); @@ -3174,7 +3162,7 @@ describe("db reset", () => { return Effect.gen(function* () { yield* dbReset({ ...DEFAULT_FLAGS, - linked: true, + remote: REMOTE_LINKED, version: Option.some("20240101000000"), }).pipe(Effect.provide(layer)); expect(conn.execs.some((s) => s.includes("create table migrated_table"))).toBe(true); @@ -3192,7 +3180,7 @@ describe("db reset", () => { confirm: [true], }); return Effect.gen(function* () { - const exit = yield* dbReset({ ...DEFAULT_FLAGS, linked: true }).pipe( + const exit = yield* dbReset({ ...DEFAULT_FLAGS, remote: REMOTE_LINKED }).pipe( Effect.provide(layer), Effect.exit, ); @@ -3226,7 +3214,7 @@ describe("db reset", () => { confirm: [true], }); return Effect.gen(function* () { - yield* dbReset({ ...DEFAULT_FLAGS, linked: true }).pipe(Effect.provide(layer)); + yield* dbReset({ ...DEFAULT_FLAGS, remote: REMOTE_LINKED }).pipe(Effect.provide(layer)); expect(conn.execs.some((s) => s.includes("create table schema_users"))).toBe(true); expect(out.stderrText).not.toContain("no files matched pattern"); }); @@ -3245,7 +3233,7 @@ describe("db reset", () => { execFailsMessage: 'syntax error at or near "not"', }); return Effect.gen(function* () { - const exit = yield* dbReset({ ...DEFAULT_FLAGS, linked: true }).pipe( + const exit = yield* dbReset({ ...DEFAULT_FLAGS, remote: REMOTE_LINKED }).pipe( Effect.provide(layer), Effect.exit, ); @@ -3320,7 +3308,7 @@ describe("db reset", () => { yield* fs.makeDirectory(path.dirname(schemaFile), { recursive: true }); yield* fs.writeFileString(schemaFile, "create table schema_users ();"); const deniedFs = yield* denyAccess(schemaFile, "readFileString", 0o644); - const exit = yield* dbReset({ ...DEFAULT_FLAGS, linked: true }).pipe( + const exit = yield* dbReset({ ...DEFAULT_FLAGS, remote: REMOTE_LINKED }).pipe( Effect.provideService(FileSystem.FileSystem, deniedFs), Effect.provide(layer), Effect.exit, @@ -3361,7 +3349,7 @@ describe("db reset", () => { "create table schema_users ();", ); const deniedFs = yield* denyAccess(schemasDir, "readDirectory", 0o755); - const exit = yield* dbReset({ ...DEFAULT_FLAGS, linked: true }).pipe( + const exit = yield* dbReset({ ...DEFAULT_FLAGS, remote: REMOTE_LINKED }).pipe( Effect.provideService(FileSystem.FileSystem, deniedFs), Effect.provide(layer), Effect.exit, @@ -3400,7 +3388,7 @@ describe("db reset", () => { }); return withEmptyConfigEnv( Effect.gen(function* () { - yield* dbReset({ ...DEFAULT_FLAGS, linked: true }).pipe(Effect.provide(layer)); + yield* dbReset({ ...DEFAULT_FLAGS, remote: REMOTE_LINKED }).pipe(Effect.provide(layer)); expect(conn.execs.some((s) => s.includes("create table schema_users"))).toBe(true); expect(conn.execs.some((s) => s.includes("create table migrated_table"))).toBe(false); expect(out.stderrText).not.toContain("Applying migration"); @@ -3428,20 +3416,23 @@ describe("db reset", () => { }); it.effect( - "applies configured schema files and skips seeding on an experimental remote --db-url reset", + "applies configured schema files and skips seeding on an experimental --remote=<db-url> reset", () => { const { layer, conn, resolver } = setup(tmp.current, { noSeed: true, toml: 'project_id = "test"\n\n[db.migrations]\nschema_paths = ["schemas/*.sql"]\n\n[experimental.pgdelta]\nenabled = false\n', files: { "supabase/schemas/01_users.sql": "create table schema_users ();" }, experimental: true, - args: ["db", "reset", "--db-url", "postgresql://db.example.com:5432/postgres"], + args: ["db", "reset", "--remote=postgresql://db.example.com:5432/postgres"], confirm: [true], }); return Effect.gen(function* () { yield* dbReset({ ...DEFAULT_FLAGS, - dbUrl: Option.some("postgresql://db.example.com:5432/postgres"), + remote: Option.some({ + _tag: "DbUrl", + url: "postgresql://db.example.com:5432/postgres", + }), noSeed: Option.some(true), }).pipe(Effect.provide(layer)); expect(conn.execs.some((s) => s.includes("create table schema_users"))).toBe(true); @@ -3455,13 +3446,16 @@ describe("db reset", () => { const { layer, out, conn } = setup(tmp.current, { toml: 'project_id = "test"\n', files: migrationFile("20240101000000"), - args: ["db", "reset", "--db-url", "postgresql://localhost:54322/postgres"], + args: ["db", "reset", "--remote=postgresql://localhost:54322/postgres"], isLocal: true, }); return Effect.gen(function* () { yield* dbReset({ ...DEFAULT_FLAGS, - dbUrl: Option.some("postgresql://localhost:54322/postgres"), + remote: Option.some({ + _tag: "DbUrl", + url: "postgresql://localhost:54322/postgres", + } as const), version: Option.some("20240101000000"), }).pipe(Effect.provide(layer)); expect(out.stderrText).toContain("Resetting local database to version: 20240101000000"); @@ -3469,11 +3463,11 @@ describe("db reset", () => { }); }); - it.effect("resets a remote --db-url target without loading a remote config override", () => { + it.effect("resets a --remote=<db-url> target without loading a remote config override", () => { const { layer, out, conn } = setup(tmp.current, { // No config file → embedded defaults (migrations + seed enabled). files: migrationFile("20240101000000"), - args: ["db", "reset", "--db-url", "postgresql://db.example.com:5432/postgres"], + args: ["db", "reset", "--remote=postgresql://db.example.com:5432/postgres"], isLocal: false, omitRef: true, confirm: [true], @@ -3481,7 +3475,10 @@ describe("db reset", () => { return Effect.gen(function* () { yield* dbReset({ ...DEFAULT_FLAGS, - dbUrl: Option.some("postgresql://db.example.com:5432/postgres"), + remote: Option.some({ + _tag: "DbUrl", + url: "postgresql://db.example.com:5432/postgres", + } as const), }).pipe(Effect.provide(layer)); expect(out.stderrText).toContain("Resetting remote database..."); expect(conn.execs.some((s) => s.includes("drop schema if exists"))).toBe(true); @@ -3495,7 +3492,7 @@ describe("db reset", () => { ref: VALID_REF, }); return Effect.gen(function* () { - yield* dbReset({ ...DEFAULT_FLAGS, linked: true }).pipe(Effect.provide(layer)); + yield* dbReset({ ...DEFAULT_FLAGS, remote: REMOTE_LINKED }).pipe(Effect.provide(layer)); expect(out.stderrText).toContain("Loading config override: [remotes.preview]"); }); }); @@ -3510,7 +3507,7 @@ describe("db reset", () => { confirm: [true], }); return Effect.gen(function* () { - yield* dbReset({ ...DEFAULT_FLAGS, linked: true }).pipe(Effect.provide(layer)); + yield* dbReset({ ...DEFAULT_FLAGS, remote: REMOTE_LINKED }).pipe(Effect.provide(layer)); expect(conn.execs.some((s) => s.includes("drop schema if exists"))).toBe(true); expect(out.stderrText).not.toContain("Applying migration"); expect(out.stderrText).not.toContain("Seeding data from"); @@ -3525,7 +3522,7 @@ describe("db reset", () => { yes: true, }); return Effect.gen(function* () { - yield* dbReset({ ...DEFAULT_FLAGS, linked: true }).pipe(Effect.provide(layer)); + yield* dbReset({ ...DEFAULT_FLAGS, remote: REMOTE_LINKED }).pipe(Effect.provide(layer)); const success = out.messages.find((m) => m.type === "success"); expect(success?.data).toEqual({ version: "" }); }); @@ -3539,7 +3536,7 @@ describe("db reset", () => { }); return Effect.gen(function* () { // json mode's default-false prompt has no confirm response, so it declines and cancels. - const exit = yield* dbReset({ ...DEFAULT_FLAGS, linked: true }).pipe( + const exit = yield* dbReset({ ...DEFAULT_FLAGS, remote: REMOTE_LINKED }).pipe( Effect.provide(layer), Effect.exit, ); @@ -3571,7 +3568,11 @@ describe("db reset", () => { confirm: opts.confirm, env: opts.env, }); - const flags = { ...DEFAULT_FLAGS, linked: true, sqlPaths: Option.some(["custom-seed.sql"]) }; + const flags = { + ...DEFAULT_FLAGS, + remote: REMOTE_LINKED, + sqlPaths: Option.some(["custom-seed.sql"]), + }; const seeded = (out: { readonly stderrText: string }) => out.stderrText.includes("Seeding data from supabase/custom-seed.sql..."); const SEED_PLAN = "seed 1 file into [remotes.preview] (seeding enabled by --sql-paths)"; @@ -3617,7 +3618,7 @@ describe("db reset", () => { sqlPaths: undefined, }); return Effect.gen(function* () { - yield* dbReset({ ...DEFAULT_FLAGS, linked: true }).pipe(Effect.provide(layer)); + yield* dbReset({ ...DEFAULT_FLAGS, remote: REMOTE_LINKED }).pipe(Effect.provide(layer)); 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..."); @@ -3631,7 +3632,7 @@ describe("db reset", () => { confirm: [true], }); return Effect.gen(function* () { - yield* dbReset({ ...DEFAULT_FLAGS, linked: true }).pipe(Effect.provide(layer)); + yield* dbReset({ ...DEFAULT_FLAGS, remote: REMOTE_LINKED }).pipe(Effect.provide(layer)); expect(out.promptConfirmCalls).toHaveLength(1); expect(out.stderrText).not.toContain("seed 1 file"); expect(out.stderrText).not.toContain("Seeding data from"); @@ -3664,7 +3665,7 @@ describe("db reset", () => { sqlPaths: undefined, }); return Effect.gen(function* () { - yield* dbReset({ ...DEFAULT_FLAGS, linked: true }).pipe(Effect.provide(layer)); + yield* dbReset({ ...DEFAULT_FLAGS, remote: REMOTE_LINKED }).pipe(Effect.provide(layer)); expect(out.promptConfirmCalls).toHaveLength(1); expect(out.stderrText).toContain("seed 1 file into [remotes.preview]"); }); @@ -3676,7 +3677,7 @@ describe("db reset", () => { return Effect.gen(function* () { const exit = yield* dbReset({ ...DEFAULT_FLAGS, - linked: true, + remote: REMOTE_LINKED, noSeed: Option.some(true), sqlPaths: Option.some(["seed.sql"]), }).pipe(Effect.provide(layer), Effect.exit); @@ -3692,7 +3693,7 @@ describe("db reset", () => { return Effect.gen(function* () { const exit = yield* dbReset({ ...DEFAULT_FLAGS, - linked: true, + remote: REMOTE_LINKED, sqlPaths: Option.some([""]), }).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isFailure(exit)).toBe(true); @@ -3709,7 +3710,7 @@ describe("db reset", () => { return Effect.gen(function* () { const exit = yield* dbReset({ ...DEFAULT_FLAGS, - linked: true, + remote: REMOTE_LINKED, last: Option.some(-1), }).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isFailure(exit)).toBe(true); @@ -3735,7 +3736,7 @@ describe("db reset", () => { yield* fs.writeFileString(absSeed, "insert into t values (3);"); yield* dbReset({ ...DEFAULT_FLAGS, - linked: true, + remote: REMOTE_LINKED, sqlPaths: Option.some([absSeed]), }).pipe(Effect.provide(layer)); // Seed paths are reported forward-slashed on every platform, drive letter included. @@ -3759,7 +3760,7 @@ describe("db reset", () => { return Effect.gen(function* () { yield* dbReset({ ...DEFAULT_FLAGS, - linked: true, + remote: REMOTE_LINKED, sqlPaths: Option.some(["custom-seed.sql"]), }).pipe(Effect.provide(layer)); expect(out.stderrText).toContain("--sql-paths overrides [db.seed].sql_paths"); @@ -3783,7 +3784,7 @@ describe("db reset", () => { return Effect.gen(function* () { yield* dbReset({ ...DEFAULT_FLAGS, - linked: true, + remote: REMOTE_LINKED, sqlPaths: Option.some(["custom-seed.sql"]), }).pipe(Effect.provide(layer)); expect(conn.execs.some((s) => s.includes("create table schema_users"))).toBe(true); @@ -3794,6 +3795,128 @@ describe("db reset", () => { }); }); +describe("db reset target selection", () => { + const tmp = useTempWorkdir("supabase-db-reset-selection-"); + const FLAG_PROJECT_REF = "flagflagflagflagflag"; + + const resolvedTarget = ( + selector: Partial<DbResetFlags>, + options: { readonly branchApi?: ReturnType<typeof branchListApi> } = {}, + ) => { + const { layer, out, resolver } = setup(tmp.current, { + toml: 'project_id = "test"\n', + files: { "supabase/seed.sql": "select 1;\n" }, + args: ["db", "reset"], + confirm: [true], + isLocal: selector.local === true || Object.keys(selector).length === 0, + }); + const provided = + options.branchApi === undefined ? layer : Layer.merge(layer, options.branchApi.layer); + return withEnvVar( + "GITHUB_HEAD_REF", + undefined, + Effect.gen(function* () { + yield* dbReset({ ...DEFAULT_FLAGS, ...selector }).pipe(Effect.provide(provided)); + return { target: resolver.inputs[0]?.target, warnings: warningsOf(out) }; + }), + ); + }; + + it.effect("targets the local database, with no warning, when nothing selects one", () => + Effect.gen(function* () { + const { target, warnings } = yield* resolvedTarget({}); + expect(target).toEqual({ kind: "local" }); + expect(warnings).toEqual([]); + }), + ); + + it.effect("targets the linked project for a bare --remote, with no warning", () => + Effect.gen(function* () { + const { target, warnings } = yield* resolvedTarget({ remote: REMOTE_LINKED }); + expect(target).toMatchObject({ kind: "linked", ref: VALID_REF, source: "file" }); + expect(warnings).toEqual([]); + }), + ); + + it.effect("targets the project --remote=<ref> names, with no warning", () => + Effect.gen(function* () { + const { target, warnings } = yield* resolvedTarget({ + remote: Option.some({ _tag: "Ref", ref: FLAG_PROJECT_REF } as const), + }); + expect(target).toMatchObject({ kind: "linked", ref: FLAG_PROJECT_REF, source: "flag" }); + expect(warnings).toEqual([]); + }), + ); + + it.effect("targets the database --remote=<db-url> names, with no warning", () => + Effect.gen(function* () { + const { target, warnings } = yield* resolvedTarget({ + remote: Option.some({ + _tag: "DbUrl", + url: "postgresql://postgres:pw@db.example.com/db", + } as const), + }); + expect(target?.kind).toBe("db-url"); + expect(warnings).toEqual([]); + }), + ); + + it.effect("targets the project of the branch --remote=<branch> names", () => { + const branchApi = branchListApi(); + return Effect.gen(function* () { + const { target, warnings } = yield* resolvedTarget( + { remote: Option.some({ _tag: "Branch", name: "feature-x" }) }, + { branchApi }, + ); + expect(target).toMatchObject({ kind: "linked", ref: BRANCH_PROJECT_REF, source: "flag" }); + expect(warnings).toEqual([]); + assertPermissionDrift({ + command: "db reset", + activeFlags: { remote: "feature-x" }, + requests: branchApi.requests, + }); + }); + }); + + it.effect.each([ + { + alias: "--linked", + flags: { linked: true }, + kind: "linked", + warning: "--linked is deprecated and will be removed in v4. Use --remote instead", + }, + { + alias: "--project-ref", + flags: { projectRef: Option.some(FLAG_PROJECT_REF) }, + kind: "linked", + warning: "--project-ref is deprecated and will be removed in v4. Use --remote instead", + }, + { + alias: "--db-url", + flags: { dbUrl: Option.some("postgresql://postgres:pw@db.example.com/db") }, + kind: "db-url", + warning: "--db-url is deprecated and will be removed in v4. Use --remote instead", + }, + { + alias: "--local", + flags: { local: true }, + kind: "local", + warning: "--local isn't needed (local is the default) and will be removed in v4", + }, + ] satisfies ReadonlyArray<{ + alias: string; + flags: Partial<DbResetFlags>; + kind: string; + warning: string; + }>)("keeps $alias working and warns that it is deprecated", ({ flags, kind, warning }) => + Effect.gen(function* () { + const { target, warnings } = yield* resolvedTarget(flags); + expect(target?.kind).toBe(kind); + expect(warnings).toEqual([expect.stringContaining(warning)]); + }), + ); +}); + describeHostedConsent("db reset", { setup: (workdir) => Effect.gen(function* () { @@ -3807,7 +3930,10 @@ describeHostedConsent("db reset", { const child = mockContainerCliSpawner(defaultLocalResetRoute()); const stackApi = mockResetStackApi({ workdir, ready: true }); return { - run: dbReset({ ...DEFAULT_FLAGS, linked: true, projectRef: Option.some(VALID_REF) }).pipe( + run: dbReset({ + ...DEFAULT_FLAGS, + remote: Option.some({ _tag: "Ref", ref: VALID_REF } as const), + }).pipe( Effect.scoped, Effect.provide( Layer.mergeAll( 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 0a129a0cfe..4b1a08f603 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 @@ -16,6 +16,11 @@ vi.mock("node:net", (importOriginal) => const reachableDirectHost = withReachableDirectHost(fixtureNet); import { stripAnsi } from "../../../../../../tests/helpers/ansi.ts"; import { withReachableDirectHost } from "../../../../../../tests/helpers/refuse-invalid-db-hosts.ts"; +import { + BRANCH_PROJECT_REF, + branchListApi, + warningsOf, +} from "../../../../../../tests/helpers/db-remote-selection.ts"; import { assertPermissionDrift } from "../../../../../../tests/helpers/permission-drift.ts"; import { asRecordedRequests, @@ -437,6 +442,8 @@ function setup(workdir: string, opts: SetupOpts = {}) { }; } +const REMOTE_LINKED: DbSchemaDeclarativeGenerateFlags["remote"] = Option.some({ _tag: "Linked" }); + const flags = ( over: Partial<DbSchemaDeclarativeGenerateFlags> = {}, ): DbSchemaDeclarativeGenerateFlags => ({ @@ -473,7 +480,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: true })); + yield* dbSchemaDeclarativeGenerate(flags()); expect( yield* fs.exists( path.join(tmp.current, "supabase", "schemas", "public", "tables", "players.sql"), @@ -486,7 +493,7 @@ 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: true }))); + const error = yield* Effect.flip(dbSchemaDeclarativeGenerate(flags())); expect(error).toBeInstanceOf(DeclarativeNotEnabledError); }).pipe(Effect.provide(layer)); }); @@ -550,7 +557,7 @@ describe("db schema declarative generate integration", () => { yield* disablePgDelta(tmp.current); const error = yield* withConfigEnv( { [ENV]: "1" }, - Effect.flip(dbSchemaDeclarativeGenerate(flags({ local: true }))), + Effect.flip(dbSchemaDeclarativeGenerate(flags())), ); expect(error).toBeInstanceOf(DeclarativeNotEnabledError); }).pipe(Effect.provide(layer)); @@ -596,7 +603,7 @@ describe("db schema declarative generate integration", () => { path.join(tmp.current, "supabase", "database", "public.sql"), "create table a();", ); - yield* dbSchemaDeclarativeGenerate(flags({ local: true })); + yield* dbSchemaDeclarativeGenerate(flags()); expect(stripAnsi(s.out.stderrText)).toContain( "WARNING: found declarative schema files in supabase/database, but the default declarative directory is now supabase/schemas.", ); @@ -604,12 +611,12 @@ describe("db schema declarative generate integration", () => { }).pipe(Effect.provide(s.layer)); }); - it.effect("explicit --local: exports from the local database and writes files", () => { + it.effect("exports from the default local database and writes files", () => { 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: true })); + yield* dbSchemaDeclarativeGenerate(flags()); expect(s.engineExportCalls[0]?.targetRef).toContain( "postgresql://postgres:postgres@127.0.0.1:54322", ); @@ -629,11 +636,11 @@ describe("db schema declarative generate integration", () => { }); it.effect( - "explicit --local on the stack backend exports from stack credentials, not toml [db].port", + "the default local target on the stack backend exports from stack credentials, not toml [db].port", () => { const s = setup(tmp.current, { experimental: true, stackBackend: true }); return Effect.gen(function* () { - yield* dbSchemaDeclarativeGenerate(flags({ local: true })); + yield* dbSchemaDeclarativeGenerate(flags()); expect(s.engineExportCalls[0]?.targetRef).toContain( "postgresql://postgres:stack-secret@127.0.0.1:54329", ); @@ -666,10 +673,12 @@ describe("db schema declarative generate integration", () => { }).pipe(Effect.provide(s.layer)); }); - it.effect("--project-ref selects that project without a linked workdir", () => { + it.effect("--remote=<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) })); + yield* dbSchemaDeclarativeGenerate( + flags({ remote: Option.some({ _tag: "Ref", ref: VALID_REF } as const) }), + ); expect(s.resolverCalls.map((call) => call.target)).toEqual([ expect.objectContaining({ kind: "linked", ref: VALID_REF, source: "flag" }), ]); @@ -749,9 +758,7 @@ describe("db schema declarative generate integration", () => { const configPath = path.join(tmp.current, "supabase", "config.toml"); yield* fs.writeFileString(configPath, config); const destination = path.join("supabase", "database-next"); - yield* dbSchemaDeclarativeGenerate( - flags({ local: true, outputDir: Option.some(destination) }), - ); + yield* dbSchemaDeclarativeGenerate(flags({ outputDir: Option.some(destination) })); expect( yield* fs.readFileString( @@ -784,8 +791,11 @@ describe("db schema declarative generate integration", () => { ); for (const [label, target] of [ - ["--local", { local: true }], - ["--db-url", { dbUrl: Option.some("postgresql://x") }], + ["the default local target", {}], + [ + "--remote=<db-url>", + { remote: Option.some({ _tag: "DbUrl", url: "postgresql://x" } as const) }, + ], ] as const) { it.effect(`rejects --password combined with ${label}`, () => { const s = setup(tmp.current, { experimental: true, realLayers: true }); @@ -810,9 +820,7 @@ describe("db schema declarative generate integration", () => { const destination = path.join(tmp.current, "staged-schema"); yield* fs.makeDirectory(destination, { recursive: true }); yield* fs.writeFileString(path.join(destination, "keep.sql"), "select 'keep';"); - yield* dbSchemaDeclarativeGenerate( - flags({ local: true, outputDir: Option.some(destination) }), - ); + yield* dbSchemaDeclarativeGenerate(flags({ 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); expect(s.out.rawChunks.some((chunk) => chunk.text.includes("Skipped writing"))).toBe(true); @@ -831,7 +839,7 @@ describe("db schema declarative generate integration", () => { yield* Effect.gen(function* () { for (const output of ["", ".", "..", path.dirname(projectDir)]) { const error = yield* dbSchemaDeclarativeGenerate( - flags({ local: true, outputDir: Option.some(output), overwrite: true }), + flags({ outputDir: Option.some(output), overwrite: true }), ).pipe(Effect.flip); expect(error).toMatchObject({ _tag: "DeclarativeWriteError", @@ -850,9 +858,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: true, outputDir: Option.some("staged-schema") }), - ); + yield* dbSchemaDeclarativeGenerate(flags({ outputDir: Option.some("staged-schema") })); expect( yield* fs.exists( path.join(tmp.current, "staged-schema", "public", "tables", "players.sql"), @@ -862,10 +868,10 @@ describe("db schema declarative generate integration", () => { }).pipe(Effect.provide(s.layer)); }); - it.effect("explicit --local checks the local Postgres image before generating", () => { + it.effect("the default local target 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: true }))); + const error = yield* Effect.flip(dbSchemaDeclarativeGenerate(flags())); expect(error).toMatchObject({ _tag: "DeclarativeShadowDbError", message: "local Postgres container image is stale", @@ -889,7 +895,7 @@ describe("db schema declarative generate integration", () => { path.join(tmp.current, "supabase", "schemas", "existing.sql"), "create table x ();", ); - yield* dbSchemaDeclarativeGenerate(flags({ local: true })); + yield* dbSchemaDeclarativeGenerate(flags()); const written = yield* fs.readFileString( path.join(tmp.current, "supabase", "schemas", "public", "tables", "players.sql"), ); @@ -905,7 +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: true }))); + const exit = yield* Effect.exit(dbSchemaDeclarativeGenerate(flags())); expect(Exit.isFailure(exit)).toBe(true); expect(yield* fs.readFileString(path.join(tmp.current, "supabase", "schemas"))).toBe( "not a directory", @@ -916,10 +922,12 @@ describe("db schema declarative generate integration", () => { }).pipe(Effect.provide(s.layer)); }); - it.effect("explicit --db-url: resolves the remote URL via the resolver", () => { + it.effect("--remote=<db-url> resolves the URL via the resolver", () => { const s = setup(tmp.current, { experimental: true }); return Effect.gen(function* () { - yield* dbSchemaDeclarativeGenerate(flags({ dbUrl: Option.some("postgres://remote/db") })); + yield* dbSchemaDeclarativeGenerate( + flags({ remote: Option.some({ _tag: "DbUrl", url: "postgres://remote/db" } as const) }), + ); expect(s.resolverCalls.length).toBe(1); expect(s.engineExportCalls[0]?.targetRef).toContain("@db.remote:5432"); expect(s.ensureStartedCalls).toBe(0); @@ -942,7 +950,7 @@ describe("db schema declarative generate integration", () => { "", ].join("\n"), ); - yield* dbSchemaDeclarativeGenerate(flags({ local: true })); + yield* dbSchemaDeclarativeGenerate(flags()); expect(yield* fs.exists(path.join(absSchema, "public", "tables", "players.sql"))).toBe(true); expect( yield* fs.readFileString(path.join(absSchema, "public", "tables", "players.sql")), @@ -956,7 +964,7 @@ describe("db schema declarative generate integration", () => { }).pipe(Effect.scoped, Effect.provide(s.layer)); }); - it.effect("explicit --linked applies a matching [remotes.<ref>] schema-path override", () => { + it.effect("--remote applies a matching [remotes.<ref>] schema-path override", () => { const ref = "abcdefghijklmnopqrst"; const s = setup(tmp.current, { experimental: true, projectId: Option.some(ref) }); return Effect.gen(function* () { @@ -976,7 +984,7 @@ describe("db schema declarative generate integration", () => { "", ].join("\n"), ); - yield* dbSchemaDeclarativeGenerate(flags({ linked: true })); + yield* dbSchemaDeclarativeGenerate(flags({ remote: REMOTE_LINKED })); const written = yield* fs.readFileString( path.join(tmp.current, "supabase", "remote_schema", "public", "tables", "players.sql"), ); @@ -985,11 +993,11 @@ describe("db schema declarative generate integration", () => { }).pipe(Effect.provide(s.layer)); }); - it.effect("caches the linked project after generate --linked", () => { + it.effect("caches the linked project after generate --remote", () => { const ref = "abcdefghijklmnopqrst"; const s = setup(tmp.current, { experimental: true, projectId: Option.some(ref) }); return Effect.gen(function* () { - yield* dbSchemaDeclarativeGenerate(flags({ linked: true })); + yield* dbSchemaDeclarativeGenerate(flags({ remote: REMOTE_LINKED })); expect(s.cache.cached).toBe(true); expect(s.cache.cacheCount).toBe(1); expect(s.telemetry.flushCount).toBe(1); @@ -1005,33 +1013,32 @@ describe("db schema declarative generate integration", () => { }).pipe(Effect.provide(s.layer)); }); - it.effect( - "explicit --linked gates pg-delta on base config, not a remote enabled override", - () => { - const ref = "abcdefghijklmnopqrst"; - const s = setup(tmp.current, { experimental: false, projectId: Option.some(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 = "base"', - "[experimental.pgdelta]", - "enabled = false", - "[remotes.prod]", - `project_id = "${ref}"`, - "[remotes.prod.experimental.pgdelta]", - "enabled = true", - "", - ].join("\n"), - ); - const error = yield* Effect.flip(dbSchemaDeclarativeGenerate(flags({ linked: true }))); - expect(error).toBeInstanceOf(DeclarativeNotEnabledError); - }).pipe(Effect.provide(s.layer)); - }, - ); + it.effect("--remote gates pg-delta on base config, not a remote enabled override", () => { + const ref = "abcdefghijklmnopqrst"; + const s = setup(tmp.current, { experimental: false, projectId: Option.some(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 = "base"', + "[experimental.pgdelta]", + "enabled = false", + "[remotes.prod]", + `project_id = "${ref}"`, + "[remotes.prod.experimental.pgdelta]", + "enabled = true", + "", + ].join("\n"), + ); + const error = yield* Effect.flip( + dbSchemaDeclarativeGenerate(flags({ remote: REMOTE_LINKED })), + ); + expect(error).toBeInstanceOf(DeclarativeNotEnabledError); + }).pipe(Effect.provide(s.layer)); + }); it.effect( "existing declarative files without --overwrite are skipped when the prompt is declined", @@ -1080,9 +1087,7 @@ describe("db schema declarative generate integration", () => { it.effect("passes --strict-coverage through to the engine export", () => { const s = setup(tmp.current, { experimental: true }); return Effect.gen(function* () { - yield* dbSchemaDeclarativeGenerate( - flags({ local: true, noCache: true, strictCoverage: true }), - ); + yield* dbSchemaDeclarativeGenerate(flags({ noCache: true, strictCoverage: true })); expect(s.engineExportCalls).toEqual([expect.objectContaining({ strictCoverage: true })]); }).pipe(Effect.provide(s.layer)); }); @@ -1093,7 +1098,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: true })); + yield* dbSchemaDeclarativeGenerate(flags()); expect(s.engineExportCalls.map((call) => call.debug)).toEqual([debug]); }).pipe(Effect.provide(s.layer), (body) => withConfigEnv({ PGDELTA_DEBUG: value }, body)); }); @@ -1101,7 +1106,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: true })).pipe(Effect.flip); + const error = yield* dbSchemaDeclarativeGenerate(flags()).pipe(Effect.flip); expect(error).toMatchObject({ _tag: "PgDeltaEngineError", message: "declarative export failed", @@ -1117,7 +1122,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: true })); + yield* dbSchemaDeclarativeGenerate(flags()); const manifest = yield* Schema.decodeEffect(Schema.fromJsonString(Schema.Unknown))( yield* fs.readFileString( path.join(tmp.current, "supabase", "schemas", ".pgdelta-export.json"), @@ -1148,7 +1153,7 @@ describe("db schema declarative generate permissions through the real resolver a yield* fs.writeFileString(`${tmp.current}/supabase/.temp/project-ref`, VALID_REF); }); - it.effect("mints a login role and fills the cache for --linked", () => { + it.effect("mints a login role and fills the cache for a bare --remote", () => { const s = setup(tmp.current, { realLayers: true, projectId: Option.some(VALID_REF) }); const requests: Array<StubbedRequest> = []; return Effect.gen(function* () { @@ -1156,14 +1161,16 @@ 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: true })).pipe(Effect.provide(s.layer)); + yield* dbSchemaDeclarativeGenerate(flags({ remote: REMOTE_LINKED })).pipe( + Effect.provide(s.layer), + ); expect(s.engineExportCalls).toHaveLength(1); 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 generate", - activeFlags: { linked: true }, + activeFlags: { remote: true }, requests: [...asRecordedRequests(requests), ...s.projectHttp.calls], exact: true, }); @@ -1174,7 +1181,7 @@ describe("db schema declarative generate permissions through the real resolver a ); }); - it.effect("does not mint a login role for --linked with --password", () => { + it.effect("does not mint a login role for --remote with --password", () => { const s = setup(tmp.current, { realLayers: true, projectId: Option.some(VALID_REF) }); const requests: Array<StubbedRequest> = []; return Effect.gen(function* () { @@ -1182,13 +1189,13 @@ describe("db schema declarative generate permissions through the real resolver a yield* saveLinkedRef; yield* stubManagementApi(requests, serveLinkedDatabase(VALID_REF)); yield* dbSchemaDeclarativeGenerate( - flags({ linked: true, password: Option.some("secret") }), + flags({ remote: REMOTE_LINKED, password: Option.some("secret") }), ).pipe(Effect.provide(s.layer)); expect(requests).toEqual([]); expect(cacheCalls(s.projectHttp.calls)).toEqual([cacheRequest]); assertPermissionDrift({ command: "db schema declarative generate", - activeFlags: { linked: true, password: true }, + activeFlags: { remote: true, password: true }, requests: [...asRecordedRequests(requests), ...s.projectHttp.calls], exact: true, }); @@ -1199,19 +1206,19 @@ describe("db schema declarative generate permissions through the real resolver a ); }); - it.effect("makes no Management API request for --local", () => { + it.effect("makes no Management API request for the default local target", () => { const s = setup(tmp.current, { realLayers: true, projectId: Option.some(VALID_REF) }); const requests: Array<StubbedRequest> = []; return Effect.gen(function* () { yield* saveLinkedRef; yield* stubManagementApi(requests, () => undefined); - yield* dbSchemaDeclarativeGenerate(flags({ local: true })).pipe(Effect.provide(s.layer)); + yield* dbSchemaDeclarativeGenerate(flags()).pipe(Effect.provide(s.layer)); expect(s.engineExportCalls).toHaveLength(1); expect(requests).toEqual([]); expect(s.projectHttp.calls).toEqual([]); assertPermissionDrift({ command: "db schema declarative generate", - activeFlags: { local: true }, + activeFlags: {}, requests: [], exact: true, }); @@ -1222,21 +1229,26 @@ describe("db schema declarative generate permissions through the real resolver a ); }); - it.effect("makes no Management API request for --db-url", () => { + it.effect("makes no Management API request for --remote=<db-url>", () => { const s = setup(tmp.current, { realLayers: true, projectId: Option.some(VALID_REF) }); const requests: Array<StubbedRequest> = []; return Effect.gen(function* () { yield* saveLinkedRef; yield* stubManagementApi(requests, () => undefined); yield* dbSchemaDeclarativeGenerate( - flags({ dbUrl: Option.some("postgres://alice:secret@example.com:6543/appdb") }), + flags({ + remote: Option.some({ + _tag: "DbUrl", + url: "postgres://alice:secret@example.com:6543/appdb", + }), + }), ).pipe(Effect.provide(s.layer)); expect(s.engineExportCalls).toHaveLength(1); expect(requests).toEqual([]); expect(s.projectHttp.calls).toEqual([]); assertPermissionDrift({ command: "db schema declarative generate", - activeFlags: { "db-url": true }, + activeFlags: { remote: true }, requests: [], exact: true, }); @@ -1247,19 +1259,19 @@ describe("db schema declarative generate permissions through the real resolver a ); }); - it.effect("fetches the pooler and mints a login role for --project-ref", () => { + it.effect("fetches the pooler and mints a login role for --remote=<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), - ); + yield* dbSchemaDeclarativeGenerate( + flags({ remote: Option.some({ _tag: "Ref", ref: VALID_REF } as const) }), + ).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 }, + activeFlags: { remote: VALID_REF }, requests: [...asRecordedRequests(requests), ...s.projectHttp.calls], exact: true, }); @@ -1270,3 +1282,107 @@ describe("db schema declarative generate permissions through the real resolver a ); }); }); + +describe("db schema declarative generate target selection", () => { + const tmp = useTempWorkdir(); + const FLAG_PROJECT_REF = "flagflagflagflagflag"; + + const resolvedTarget = ( + selector: Partial<DbSchemaDeclarativeGenerateFlags>, + options: { readonly branchApi?: ReturnType<typeof branchListApi> } = {}, + ) => { + const s = setup(tmp.current, { experimental: true, projectId: Option.some(VALID_REF) }); + const provided = + options.branchApi === undefined ? s.layer : Layer.merge(s.layer, options.branchApi.layer); + return Effect.gen(function* () { + yield* dbSchemaDeclarativeGenerate(flags(selector)).pipe(Effect.provide(provided)); + return { target: s.resolverCalls[0]?.target, warnings: warningsOf(s.out) }; + }); + }; + + it.effect("targets the local database, with no warning, when nothing selects one", () => + Effect.gen(function* () { + const { target, warnings } = yield* resolvedTarget({}); + expect(target).toEqual({ kind: "local" }); + expect(warnings).toEqual([]); + }), + ); + + it.effect("targets the project --remote=<ref> names, with no warning", () => + Effect.gen(function* () { + const { target, warnings } = yield* resolvedTarget({ + remote: Option.some({ _tag: "Ref", ref: FLAG_PROJECT_REF } as const), + }); + expect(target).toMatchObject({ kind: "linked", ref: FLAG_PROJECT_REF, source: "flag" }); + expect(warnings).toEqual([]); + }), + ); + + it.effect("targets the database --remote=<db-url> names, with no warning", () => + Effect.gen(function* () { + const { target, warnings } = yield* resolvedTarget({ + remote: Option.some({ + _tag: "DbUrl", + url: "postgresql://postgres:pw@db.example.com/db", + } as const), + }); + expect(target?.kind).toBe("db-url"); + expect(warnings).toEqual([]); + }), + ); + + it.effect("targets the project of the branch --remote=<branch> names", () => { + const branchApi = branchListApi(); + return Effect.gen(function* () { + const { target, warnings } = yield* resolvedTarget( + { remote: Option.some({ _tag: "Branch", name: "feature-x" }) }, + { branchApi }, + ); + expect(target).toMatchObject({ kind: "linked", ref: BRANCH_PROJECT_REF, source: "flag" }); + expect(warnings).toEqual([]); + assertPermissionDrift({ + command: "db schema declarative generate", + activeFlags: { remote: "feature-x" }, + requests: branchApi.requests, + }); + }); + }); + + it.effect.each([ + { + alias: "--linked", + flags: { linked: true }, + kind: "linked", + warning: "--linked is deprecated and will be removed in v4. Use --remote instead", + }, + { + alias: "--project-ref", + flags: { projectRef: Option.some(FLAG_PROJECT_REF) }, + kind: "linked", + warning: "--project-ref is deprecated and will be removed in v4. Use --remote instead", + }, + { + alias: "--db-url", + flags: { dbUrl: Option.some("postgresql://postgres:pw@db.example.com/db") }, + kind: "db-url", + warning: "--db-url is deprecated and will be removed in v4. Use --remote instead", + }, + { + alias: "--local", + flags: { local: true }, + kind: "local", + warning: "--local isn't needed (local is the default) and will be removed in v4", + }, + ] satisfies ReadonlyArray<{ + alias: string; + flags: Partial<DbSchemaDeclarativeGenerateFlags>; + kind: string; + warning: string; + }>)("keeps $alias working and warns that it is deprecated", ({ flags, kind, warning }) => + Effect.gen(function* () { + const { target, warnings } = yield* resolvedTarget(flags); + expect(target?.kind).toBe(kind); + expect(warnings).toEqual([expect.stringContaining(warning)]); + }), + ); +}); 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 6aab8548e2..b99e3f462c 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 @@ -1001,9 +1001,7 @@ describe("db schema declarative sync integration", () => { }); expect(error).toMatchObject({ message: expect.stringContaining("uuid-ossp"), - suggestion: expect.stringContaining( - "supabase db schema declarative generate --local --overwrite", - ), + suggestion: expect.stringContaining("supabase db schema declarative generate --overwrite"), }); expect( yield* Schema.encodeUnknownEffect(Schema.fromJsonString(Schema.Unknown))(error), 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 08af568701..588625b524 100644 --- a/apps/cli/src/commands/db/test/test.integration.test.ts +++ b/apps/cli/src/commands/db/test/test.integration.test.ts @@ -286,12 +286,12 @@ describe("db test (alias) integration", () => { }).pipe(Effect.provide(layer)); }); - it.effect("mints a login role through the real DB config resolver for --linked", () => { + it.effect("mints a login role through the real DB config resolver for --remote=<ref>", () => { 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, projectRef: Option.some(REF) })); + yield* runTestDbCommand(flags({ remote: Option.some({ _tag: "Ref", ref: REF } as const) })); expect(requests.map(({ method, path }) => `${method} ${path}`)).toEqual([ `GET /v1/projects/${REF}/config/database/pooler`, `POST /v1/projects/${REF}/cli/login-role`, @@ -299,7 +299,7 @@ describe("db test (alias) integration", () => { expect(docker.lastOpts?.cmd[0]).toBe("pg_prove"); assertPermissionDrift({ command: "db test", - activeFlags: { linked: true, "project-ref": REF }, + activeFlags: { remote: REF }, requests: asRecordedRequests(requests), exact: true, }); @@ -326,33 +326,12 @@ describe("db test (alias) integration", () => { ); }); - it.effect("makes no Management API request for --local through the real resolver", () => { - const { layer, docker } = setup({ realDbConfig: true, workdir: tempRoot.current }); - const requests: Array<StubbedRequest> = []; - return Effect.gen(function* () { - yield* stubManagementApi(requests, () => undefined); - yield* runTestDbCommand(flags({ local: true })); - expect(docker.lastOpts?.cmd[0]).toBe("pg_prove"); - expect(requests).toEqual([]); - assertPermissionDrift({ - command: "db test", - activeFlags: { local: true }, - requests: [], - exact: true, - }); - }).pipe( - Effect.scoped, - Effect.provide(layer), - provideEnv({ SUPABASE_ACCESS_TOKEN: VALID_TOKEN }), - ); - }); - it.effect( "dispatches through the real `dbTestCommand` and records `db test`, not `test db`, as the telemetry command", () => { - // `--local --linked` fail inside `testDb` (mutual exclusivity) before any DB/docker IO, + // `--remote --local` fail inside `testDb` (mutual exclusivity) before any DB/docker IO, // so this can dispatch through the real command tree without a real Postgres or Docker. - const args = ["db", "test", "--local", "--linked"]; + const args = ["db", "test", "--remote", "--local"]; const analytics = mockContextualAnalytics(); const out = mockOutput({ format: "text" }); const layer = Layer.mergeAll( @@ -460,7 +439,7 @@ describeHostedConsent("db test", { invoke: () => { const connection = mockDbConnection(); return { - run: testDb(flags({ linked: true, projectRef: Option.some(REF) })).pipe( + run: testDb(flags({ remote: Option.some({ _tag: "Ref", ref: REF } as const) })).pipe( Effect.provide( Layer.mergeAll( mockResolver({ ...LOCAL_CONN, host: `db.${REF}.supabase.co` }), diff --git a/apps/cli/tests/helpers/db-remote-selection.ts b/apps/cli/tests/helpers/db-remote-selection.ts new file mode 100644 index 0000000000..a4f832a045 --- /dev/null +++ b/apps/cli/tests/helpers/db-remote-selection.ts @@ -0,0 +1,34 @@ +import { Effect } from "effect"; + +import { VALID_REF, jsonResponse, mockCommandPlatformApi } from "./command-mocks.ts"; +import type { mockOutput } from "./mocks.ts"; + +export const BRANCH_PROJECT_REF = "branchprojectrefabcd"; + +const branchRow = (name: string) => ({ + id: "11111111-2222-4333-8444-555555555555", + name, + project_ref: BRANCH_PROJECT_REF, + parent_project_ref: VALID_REF, + is_default: false, + persistent: false, + status: "MIGRATIONS_PASSED", + created_at: "2026-05-27T01:02:03Z", + updated_at: "2026-05-27T01:02:04Z", + with_data: true, +}); + +/** A platform API that only answers the linked project's branch list, with one branch named `name`. */ +export const branchListApi = (name = "feature-x") => + mockCommandPlatformApi({ + handler: (request) => + Effect.succeed( + request.url.endsWith(`/v1/projects/${VALID_REF}/branches`) + ? jsonResponse(request, 200, [branchRow(name)]) + : jsonResponse(request, 404, { message: `unexpected request ${request.url}` }), + ), + }); + +/** The text of every warning the run printed. */ +export const warningsOf = (out: Pick<ReturnType<typeof mockOutput>, "messages">) => + out.messages.filter((message) => message.type === "warn").map((message) => message.message); From 54b0e7d151b99d5d8459e6c64fc5d157c13533b8 Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Mon, 19 Oct 2026 12:17:14 +0100 Subject: [PATCH 132/291] docs(cli): describe --remote target selection in the db commands Rewrites each db command's SIDE_EFFECTS.md for --remote (bare, =<ref>, =<branch>, =<url>), the local default and its linked-checkout hint, the branch lookup route, and the deprecated alias warnings, and drops the workspace target wording. --- .../src/commands/db/advisors/SIDE_EFFECTS.md | 95 +++++++------ apps/cli/src/commands/db/diff/SIDE_EFFECTS.md | 129 ++++++++++-------- apps/cli/src/commands/db/dump/SIDE_EFFECTS.md | 84 +++++++----- apps/cli/src/commands/db/lint/SIDE_EFFECTS.md | 91 ++++++------ apps/cli/src/commands/db/pull/SIDE_EFFECTS.md | 87 ++++++------ apps/cli/src/commands/db/push/SIDE_EFFECTS.md | 91 ++++++------ .../cli/src/commands/db/query/SIDE_EFFECTS.md | 102 +++++++------- .../db/remote/changes/SIDE_EFFECTS.md | 2 +- .../cli/src/commands/db/reset/SIDE_EFFECTS.md | 84 ++++++------ .../declarative/generate/SIDE_EFFECTS.md | 78 ++++++----- apps/cli/src/commands/db/test/SIDE_EFFECTS.md | 13 +- apps/cli/src/commands/test/db/SIDE_EFFECTS.md | 121 ++++++++-------- 12 files changed, 545 insertions(+), 432 deletions(-) diff --git a/apps/cli/src/commands/db/advisors/SIDE_EFFECTS.md b/apps/cli/src/commands/db/advisors/SIDE_EFFECTS.md index 9544b09db8..c5b1a9bd31 100644 --- a/apps/cli/src/commands/db/advisors/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/db/advisors/SIDE_EFFECTS.md @@ -1,44 +1,47 @@ # `supabase db advisors` 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 +stack and `--remote=<url>` query the database directly; a 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)). +With no selector the command checks the local stack, and in a linked checkout the `Target:` +line adds `default; pass --remote for <project>`. `--remote` selects the linked project, +`--remote=<ref>` another project, `--remote=<branch>` a branch of the linked project (by name +or UUID) and `--remote=<url>` a Postgres connection string +(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 | 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 | +| Path | Format | When | +| -------------------------------------- | ---------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `<workdir>/supabase/config.toml` | TOML | local / `--remote=<url>` — to resolve the DB connection config | +| `~/.supabase/access-token` | plain text | a remote project or branch only, when `SUPABASE_ACCESS_TOKEN` unset (keyring → file) | +| `<workdir>/supabase/.temp/project-ref` | plain text | bare `--remote` — to resolve the project ref, skipped when `SUPABASE_PROJECT_ID` is set; with no selector — only to name the project in the `Target:` hint | ## Files Written -| 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` | +| Path | Format | When | +| ---------------------------------------------- | ------ | --------------------------------------------------------------- | +| `~/.supabase/telemetry.json` | JSON | always (PostHog state flush) | +| `<workdir>/supabase/.temp/linked-project.json` | JSON | a remote project or branch only, via `LinkedProjectCache.cache` | The local lint query runs inside a transaction that is **always rolled back**. -## API Routes (linked target only) +## API Routes (remote project or branch only) -| Method | Path | Auth | Request | Response (used fields) | -| ------ | ----------------------------------------- | ------ | ------- | ---------------------- | -| GET | `/v1/projects/{ref}/advisors/security` | Bearer | — | `{ lints: Lint[] }` | -| GET | `/v1/projects/{ref}/advisors/performance` | Bearer | — | `{ lints: Lint[] }` | +| Method | Path | Auth | Request | Response (used fields) | +| ------ | ----------------------------------------- | ------ | ------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| GET | `/v1/projects/{ref}/advisors/security` | Bearer | — | `{ lints: Lint[] }` | +| GET | `/v1/projects/{ref}/advisors/performance` | Bearer | — | `{ lints: Lint[] }` | +| GET | `/v1/projects/{parent}/branches` | Bearer | — | `[{id, name, project_ref}]` — only for `--remote=<branch>`, and for a bare `--remote` followed by a word that could name a branch; `{parent}` is `SUPABASE_PROJECT_ID`, else the linked project | Issued through the typed client; the generated schema keeps advisor `name` / `metadata.type` open, so values the API can add do not fail decoding. `--type` selects which endpoints run: `security` → security only, `performance` → performance only, `all` → both. -## Database (local / `--db-url`) +## Database (local / `--remote=<url>`) One connection. Within one transaction: `BEGIN` → `set local search_path = ''` (setup half of `templates/lints.sql`) → the multi-CTE lints query → `ROLLBACK`. @@ -46,35 +49,37 @@ One connection. Within one transaction: `BEGIN` → `set local search_path = ''` ## Environment Variables -| 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 | +| Variable | Purpose | Required? | +| ----------------------- | ------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------- | +| `SUPABASE_ACCESS_TOKEN` | auth token for a remote project or branch | no (keyring → `~/.supabase/access-token`) | +| `SUPABASE_PROJECT_ID` | linked project ref override, and the parent that `--remote=<branch>` is resolved against (superseded by `--remote=<ref>`) | no | +| `SUPABASE_PROFILE` | API profile (built-in name or YAML path) | no | +| `PGHOST` / `PGPORT` / … | connection overrides (local / `--remote=<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` | 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`) | +| Code | Condition | +| ---- | -------------------------------------------------------------------------------------------------- | +| `0` | success — no issues at or above `--fail-on` (empty result is still `0`) | +| `1` | more than one of `--remote` / `--linked` / `--db-url` / `--local` (`TargetSelectorConflictError`) | +| `1` | a remote project or branch with no access token (suggests `supabase login`) | +| `1` | `--remote=<branch>` naming no branch of the parent project | +| `1` | connection / `BEGIN` / setup / query failure (local, `--remote=<url>`) | +| `1` | advisors API non-200 (remote project or branch) | +| `1` | a lint's level is at or above `--fail-on` | +| `1` | `--project-ref` with `--local`, `--db-url` or `--remote=<value>` (`TargetProjectRefConflictError`) | ## Output ### `--output-format text` -Diagnostics on **stderr**: the `Target: …` line (linked project `Target: <name> (<ref>)`, -`Target: local stack (<host>:<port>)`, or `Target: custom Postgres (<host>:<port>)`) and +Diagnostics on **stderr**: the `Target: …` line (remote project `Target: <name> (<ref>)`, branch +`Target: project <parent> · branch <name> (<ref>)`, `Target: local stack (<host>:<port>)` with +` · default; pass --remote for <project>` appended in a linked checkout when no selector was given, +or `Target: custom Postgres (<host>:<port>)`), alias deprecation warnings, 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). @@ -93,12 +98,18 @@ the process exits non-zero (no error envelope is written over the payload). ## Notes -- `--type` (`all` default): `security` / `performance` select endpoints (linked) or filter categories (local). +- `--type` (`all` default): `security` / `performance` select endpoints (remote) or filter categories (local, `--remote=<url>`). - `--level` (`warn` default) sets the minimum issue level to display. - `--fail-on` (`none` default) sets the level that forces a non-zero exit. -- **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. +- **Target selection.** `--remote` is read as the linked project (bare), a project ref (20 + lowercase letters), a Postgres URL, or else a branch of the linked project by name or UUID; + `--remote=false` and `--remote=` fail with `Leave out --remote to target the local database.` A bare + `--remote` followed by a word that is a branch of the linked project fails with a + `Did you mean --remote=<word>?` hint, since the word may have been meant as the flag's value. +- **Deprecated aliases.** `--linked`, `--project-ref=<ref>`, `--db-url=<url>` and `--local` still + work and print a deprecation warning on stderr naming the `--remote` replacement (`--local` + warns that local is the default). They are mutually exclusive with each other and with + `--remote`; a `false` switch counts as absent. `--project-ref` with `--local`, `--db-url` or + `--remote=<value>` 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 5d80282bdb..c470fe10d6 100644 --- a/apps/cli/src/commands/db/diff/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/db/diff/SIDE_EFFECTS.md @@ -1,18 +1,19 @@ # `supabase db diff` Diffs the local project's expected schema (a throwaway shadow -database) against a target database (local / linked / `--db-url`), using one of +database) against a target database (local / a remote project or branch / `--remote=<url>`), using one of three native engines: bundled in-process pg-delta, migra (edge-runtime), or pgAdmin (CLI-1968 — a native `docker run` of the differ container, no 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 +With no selector the command diffs the local stack, and in a linked checkout the `Target:` line +adds `default; pass --remote for <project>`. `--remote` selects the linked project, +`--remote=<ref>` another project, `--remote=<branch>` a branch of the linked project (by name or +UUID) and `--remote=<url>` a custom database (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. +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, @@ -45,8 +46,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 | 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 | +| `~/.supabase/access-token` | plain text | a remote project or branch with no `SUPABASE_ACCESS_TOKEN` | +| `<workdir>/supabase/.temp/project-ref` | plain text | bare `--remote`, ref resolution — skipped when `SUPABASE_PROJECT_ID` is set; with no selector, only to name the project in the `Target:` hint | ## Files Written @@ -58,7 +59,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 | a linked target (post-run cache) | +| `~/.supabase/<workdir-hash>/linked-project.json` | JSON | a remote project or branch (post-run cache) | | `~/.supabase/telemetry.json` | JSON | every invocation (post-run) | ## Docker @@ -80,7 +81,7 @@ it, and JSON `null` disables formatting without disabling safe compaction. narrower composition — `createShadowDatabase` -> health-wait -> `migrateShadowDatabase` directly (`diff.handler.ts`'s pgadmin branch) — with no declarative-schema-override branch and no `targetUrlOverride`. -- When `[experimental].stack` / `SUPABASE_EXPERIMENTAL_STACK=1` is on, `--local` inspects the +- When `[experimental].stack` / `SUPABASE_EXPERIMENTAL_STACK=1` is on, the local target inspects the project stack (`findStack` + `status`, database ready) instead of `supabase_db_<projectId>`, and shadows are temporary databases owned by the stack runtime (slim-init baseline, cache `--use-pg-schema`. @@ -94,36 +95,36 @@ it, and JSON `null` disables formatting without disabling safe compaction. `com.supabase.cli.project`/`com.docker.compose.project` labels — no env vars, bind mounts, or working-directory override. -## API Routes (linked path, via the db-config resolver) +## API Routes (remote project or branch, via the db-config resolver) -| Method | Path | 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) | +| Method | Path | 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) | +| GET | `/v1/projects/{parent}/branches` | Bearer | Branch lookup — only for `--remote=<branch>`, a bare `--remote` followed by a word that could name a branch, and a `--from`/`--to` value naming a branch; `{parent}` is `SUPABASE_PROJECT_ID`, else the linked project | -`--use-pgadmin` with a linked target performs every one of these calls natively (CLI-1968), as part +`--use-pgadmin` with a remote 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 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 | -| `SUPABASE_PROJECT_ID` | overrides the shadow container's project id/labels, same as `db start`/`db reset` (`utils.DbId`); ALSO the linked-ref resolution fallback `--project-ref` supersedes — see Notes for the narrower scope of the flag | no | -| `SUPABASE_NETWORK_ID` (`--network-id`) | forces the shadow container/network onto an existing Docker network | no | -| `SUPABASE_HOME` | overrides the `~/.supabase` root used for the shadow baseline cache (and other CLI state) | no | -| `SUPABASE_SHADOW_CACHE` | shadow baseline cache; on by default, opt-out (`0`/`false`); the shadow's post-baseline state is saved under a managed snapshot key and restored into the next run's fresh stack database (see Notes) | no | -| `SUPABASE_EXPERIMENTAL_PGDELTA_ENABLED` | overrides `[experimental.pgdelta].enabled` (default true; `false` selects migra); a value that is not a boolean fails the command | no | -| `PGDELTA_DEBUG` | pg-delta debug capture | no | -| `SUPABASE_SSL_DEBUG` | migra SSL debug logging | no | -| `SUPABASE_INTERNAL_IMAGE_REGISTRY` | overrides the differ's / shadow's image registry (shell **or** project `.env`, passed to each image operation) | no | +| Variable | Purpose | Required? | +| ------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------- | +| `SUPABASE_ACCESS_TOKEN` | auth for a remote project or branch | no | +| `SUPABASE_DB_PASSWORD` | remote DB password (a remote project or branch); ignored for a target other than the linked project (see Notes) | no | +| `SUPABASE_DB_SHADOW_PORT` | shadow container's host port (`db.shadow_port`) — NOT `SUPABASE_DB_PORT`, which the shadow never reads | no | +| `SUPABASE_DB_MAJOR_VERSION` / `SUPABASE_DB_HEALTH_TIMEOUT` / `SUPABASE_DB_SETTINGS_*` | shadow container-config overrides, same as `db start`/`db reset` | no | +| `SUPABASE_PROJECT_ID` | overrides the shadow container's project id/labels, same as `db start`/`db reset` (`utils.DbId`); ALSO the linked-ref resolution fallback and the parent project `--remote=<branch>` resolves against; `--remote=<ref>` supersedes it for the ref — see Notes for the narrower scope of the flag | no | +| `SUPABASE_NETWORK_ID` (`--network-id`) | forces the shadow container/network onto an existing Docker network | no | +| `SUPABASE_HOME` | overrides the `~/.supabase` root used for the shadow baseline cache (and other CLI state) | no | +| `SUPABASE_SHADOW_CACHE` | shadow baseline cache; on by default, opt-out (`0`/`false`); the shadow's post-baseline state is saved under a managed snapshot key and restored into the next run's fresh stack database (see Notes) | no | +| `SUPABASE_EXPERIMENTAL_PGDELTA_ENABLED` | overrides `[experimental.pgdelta].enabled` (default true; `false` selects migra); a value that is not a boolean fails the command | no | +| `PGDELTA_DEBUG` | pg-delta debug capture | no | +| `SUPABASE_SSL_DEBUG` | migra SSL debug logging | no | +| `SUPABASE_INTERNAL_IMAGE_REGISTRY` | overrides the differ's / shadow's image registry (shell **or** project `.env`, passed to each image operation) | no | `SUPABASE_DB_SHADOW_PORT`/`SUPABASE_NETWORK_ID`/`--network-id`/`SUPABASE_PROJECT_ID`/ `SUPABASE_DB_HEALTH_TIMEOUT` all apply to `--use-pgadmin` too — its shadow is provisioned @@ -144,20 +145,22 @@ migra/pg-delta-engine-specific). ## Exit Codes -| 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 | +| 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 `--remote`/`--linked`/`--db-url`/`--local` (`TargetSelectorConflictError`); `--project-ref` with `--local`, `--db-url` or `--remote=<value>` (`TargetProjectRefConflictError`); `--remote=<branch>`, or a `--from`/`--to` branch, naming no branch of the parent project | +| `1` | `--use-pg-schema` passed at all (any value, including `=false`) — removed, see Notes | ## Output ### `--output-format text` -Progress to stderr (the `Target: …` line — linked project `Target: <name> (<ref>)`, -`Target: local stack (<host>:<port>)` or `Target: custom Postgres (<host>:<port>)`, printed once per -distinct target — then `Creating shadow database...`, `Diffing schemas[: <list>]`, +Progress to stderr (alias deprecation warnings, then the `Target: …` line — remote project +`Target: <name> (<ref>)`, branch `Target: project <parent> · branch <name> (<ref>)`, +`Target: local stack (<host>:<port>)` (with ` · default; pass --remote for <project>` appended in a +linked checkout when no selector was given) 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 that it no longer changes the migrations baseline. The SQL diff prints to stdout @@ -220,10 +223,11 @@ 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.*]` - block. The linked-database password env is withheld when the target differs from + invalid value fails the command. A remote project or branch 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`. + temporary login role instead. `--password` is rejected with `--remote=<url>` and for the local + default. - **pg-delta selection**: `--use-pg-delta` sets `[experimental.pgdelta].enabled` at the flag tier, so `--use-pg-delta=false` selects migra over env and config. The default is pg-delta when the stack backend is active or `[experimental.pgdelta].enabled` resolves true. An explicit @@ -232,16 +236,29 @@ transaction metadata. (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); +- **Target selection.** `--remote` is read as the linked project (bare), a project ref (20 + lowercase letters), a Postgres URL, or else a branch of the linked project by name or UUID; + `--remote=false` and `--remote=` fail with `Leave out --remote to target the local database.` A + bare `--remote` followed by a word that is a branch of the linked project fails with a + `Did you mean --remote=<word>?` hint, since the word may have been meant as the flag's value. + `--remote=<ref>` supplies only the ref; unlike `SUPABASE_PROJECT_ID`, it does not affect the + shadow container's project id/labels. +- **Deprecated aliases.** `--linked`, `--project-ref=<ref>`, `--db-url=<url>` and `--local` still + work and print a deprecation warning on stderr naming the `--remote` replacement (`--local` + warns that local is the default). They are mutually exclusive with each other and with + `--remote`; a `false` switch counts as absent. `--project-ref` with `--local`, `--db-url` or + `--remote=<value>` is a hard error. +- **Explicit `--from`/`--to`.** Each endpoint is one of `local`, `migrations`, `remote` (the + linked project), a project ref, a Postgres URL, or a branch name or UUID of the linked project + (classified like a `--remote` value, so a branch named like a keyword is reached by its ref or + UUID). `--from linked` / `--to linked` still work and warn `--from linked is deprecated and will +be removed in v4. Use --from remote instead.` A `--remote` (or alias) selector given + alongside only validates the selection and merges the linked project's `[remotes.<ref>]` + override; the endpoints themselves come from the two values, and with no selector the default + `Target:` hint is not printed. The linked project resolves once, so `--from remote --to remote` + announces it once, and a branch or ref used on both sides shares one connection. A branch + endpoint looks the name up in the branch list of `SUPABASE_PROJECT_ID`, else the linked + project. `--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 @@ -287,7 +304,7 @@ the plain create/remove lifecycle. - The shadow `target` URL is a hardcoded template string (`postgresql://postgres:postgres@127.0.0.1:<port>/postgres`), not built via `toPostgresURL`, so it ignores `SUPABASE_SERVICES_HOSTNAME`/`[db] password`. -- `AssertSupabaseDbIsRunning` runs for `--linked`/`--db-url` too, and AFTER config load + +- `AssertSupabaseDbIsRunning` runs for a remote target too, and AFTER config load + target resolution — every other engine on this command never runs this check at all. - The `NOTE: …DESKTOP mode.` prefix (`supabase/pgadmin4#24`) is trimmed from the front of EACH run's own stdout independently (each run is parsed on its own — see "Output parsing" diff --git a/apps/cli/src/commands/db/dump/SIDE_EFFECTS.md b/apps/cli/src/commands/db/dump/SIDE_EFFECTS.md index 63ef4feffd..febaa15fe2 100644 --- a/apps/cli/src/commands/db/dump/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/db/dump/SIDE_EFFECTS.md @@ -5,22 +5,23 @@ 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 +With no selector the command dumps the local stack, and in a linked checkout the `Target:` line +adds `default; pass --remote for <project>`. `--remote` selects the linked project, +`--remote=<ref>` another project, `--remote=<branch>` a branch of the linked project (by name or +UUID) and `--remote=<url>` a custom database (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. +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 | 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) | +| 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 remote project or branch when the direct host is unreachable (pooler URL) | +| `~/.supabase/access-token` | plain text | a remote project or branch when `SUPABASE_ACCESS_TOKEN` unset | +| `supabase/.temp/project-ref` | plain text | bare `--remote` — ref resolution, skipped when `SUPABASE_PROJECT_ID`/config.toml `project_id` is set; with no selector, only to name the project in the `Target:` hint | +| `supabase/.env*` | dotenv | always (project env, feeds `SUPABASE_DB_PASSWORD` / `PG*`; shell values win) | ## Files Written @@ -30,21 +31,22 @@ a custom database. It only reads, so it never asks for confirmation, on any targ ## API Routes -| 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) | +| Method | Path | Auth | When | +| ------ | ------------------------------------------ | ------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| POST | `/v1/projects/{ref}/cli/login-role` | Bearer | remote target with no `DB_PASSWORD` (mint a temp postgres role) | +| POST | `/v1/projects/{ref}/network-bans/retrieve` | Bearer | remote pooler temp-role retry (list bans) | +| DELETE | `/v1/projects/{ref}/network-bans` | Bearer | remote pooler temp-role retry (clear self ban) | +| GET | `/v1/projects/{parent}/branches` | Bearer | Branch lookup — only for `--remote=<branch>`, and for a bare `--remote` followed by a word that could name a branch; `{parent}` is `SUPABASE_PROJECT_ID`, else the linked project | -(All via the shared `DbConfigResolver` linked path.) +(The login-role and network-ban calls go through 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-target auth | -| `SUPABASE_TARGET` | workspace target when no `--local`/`--linked`/`--db-url` is given | +| `SUPABASE_ACCESS_TOKEN` | auth for a remote project or branch | +| `SUPABASE_PROJECT_ID` | linked project ref override and the parent that `--remote=<branch>` is resolved against (superseded by `--remote=<ref>`) | | `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 | @@ -57,23 +59,26 @@ a custom database. It only reads, so it never asks for confirmation, on any targ | ---- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `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` | 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`) | +| `1` | more than one of `--remote`/`--linked`/`--db-url`/`--local` (`TargetSelectorConflictError`) | +| `1` | `--project-ref` with `--local`, `--db-url` or `--remote=<value>` (`TargetProjectRefConflictError`) | +| `1` | `--password` with `--remote=<url>` or the local default (`TargetPasswordConflictError`) | +| `1` | `--remote=<branch>` naming no branch of the parent project | ## 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**: the `Target: …` line (linked project `Target: <name> (<ref>)`, -`Target: local stack (<host>:<port>)`, or `Target: custom Postgres (<host>:<port>)`), +to **stderr**: alias deprecation warnings, the `Target: …` line (remote project `Target: <name> (<ref>)`, +branch `Target: project <parent> · branch <name> (<ref>)`, `Target: local stack (<host>:<port>)` +with ` · default; pass --remote for <project>` appended in a linked checkout when no selector was +given, 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 running a container; with `--file` it still prints the `Dumped schema to <abs>.` confirmation on a successful dry-run but does **not** create or truncate the file. -On a linked dump whose container fails with an IPv6 connectivity error (no IPv4 +On a remote dump whose container fails with an IPv6 connectivity error (no IPv4 pooler retry available, or the retry also fails), the error is followed on stderr by the IPv4 transaction-pooler suggestion. @@ -95,17 +100,22 @@ shell inherits the suppressing variables and is missed. - **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`) > default. The linked-database password env is withheld when the target differs + a remote project or branch) > 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. `--password` is rejected with - `--db-url` or `--local`, which carry their own credentials. + `--remote=<url>` and for the local default, which carry their own credentials. - `--data-only` XOR `--role-only`; `--keep-comments` XOR `--data-only`; `--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 +- **Target selection.** `--remote` is read as the linked project (bare), a project ref (20 + lowercase letters), a Postgres URL, or else a branch of the linked project by name or UUID; + `--remote=false` and `--remote=` fail with `Leave out --remote to target the local database.` A + bare `--remote` followed by a word that is a branch of the linked project fails with a + `Did you mean --remote=<word>?` hint, since the word may have been meant as the flag's value. +- **Deprecated aliases.** `--linked`, `--project-ref=<ref>`, `--db-url=<url>` and `--local` still + work and print a deprecation warning on stderr naming the `--remote` replacement (`--local` + warns that local is the default). They are mutually exclusive with each other and with + `--remote`; a `false` switch counts as absent. `--project-ref` with `--local`, `--db-url` or + `--remote=<value>` is a hard error. +- **Container-level pooler fallback.** When a remote 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 retried once through the project's IPv4 transaction pooler @@ -116,9 +126,9 @@ shell inherits the suppressing variables and is missed. prefills the project's specific pooler connection string. Surfacing that exact URL needs the pooler string exposed at this seam. - **Stack backend host rewrite.** Stack dumps always use catalog `pg_dump` (native - artifact or a one-shot of the same image). `--local` native rewrites loopback to - `127.0.0.1`. Native `--linked` / `--db-url` keep - the resolved host. Managed `--local` dumps run through the stack's own command runtime + artifact or a one-shot of the same image). A native local dump rewrites loopback to + `127.0.0.1`; a native remote or `--remote=<url>` dump keeps + the resolved host. Managed local dumps run through the stack's own command runtime against its endpoint without a rewrite. Other container dumps rewrite any loopback host (whatever its port) to `host.docker.internal` unless the tool container shares the Linux host network, and always run on the host network unless `--network-id` is set. Legacy diff --git a/apps/cli/src/commands/db/lint/SIDE_EFFECTS.md b/apps/cli/src/commands/db/lint/SIDE_EFFECTS.md index 20f7679053..853b19af21 100644 --- a/apps/cli/src/commands/db/lint/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/db/lint/SIDE_EFFECTS.md @@ -2,42 +2,48 @@ 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 +With no selector the command lints the local stack, and in a linked checkout the `Target:` line +adds `default; pass --remote for <project>`. `--remote` selects the linked project, +`--remote=<ref>` another project, `--remote=<branch>` a branch of the linked project (by name or +UUID) and `--remote=<url>` a custom database (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 | 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 | +| Path | Format | When | +| -------------------------------------- | ---------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `<workdir>/supabase/config.toml` | TOML | always — to resolve the local / remote DB connection config | +| `~/.supabase/access-token` | plain text | a remote project or branch only, when `SUPABASE_ACCESS_TOKEN` unset (keyring → file) | +| `<workdir>/supabase/.temp/project-ref` | plain text | bare `--remote` only, to resolve the project ref — skipped when `SUPABASE_PROJECT_ID` is set; with no selector, only to name the project in the `Target:` hint | ## Files Written -| 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) | +| Path | Format | When | +| ---------------------------------------------- | ------ | --------------------------------------------------------------- | +| `<workdir>/supabase/.temp/linked-project.json` | JSON | a remote project or branch 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 it targets the linked project; the local and -`--db-url` paths never populate a project ref, so no cache write occurs there. +linked-project cache when it targets a remote project or branch; the local and +`--remote=<url>` paths never populate a project ref, so no cache write occurs there. ## API Routes -None called directly. A linked target resolves the project's direct-DB connection +None called directly. A remote project or branch 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. +`--remote=<branch>`, and a bare `--remote` followed by a word that could name a branch, also call +`GET /v1/projects/{parent}/branches` (Bearer) to find the branch's project; `{parent}` is +`SUPABASE_PROJECT_ID`, else the linked project. + ## Database -One connection (local / `--db-url` / linked-direct). Within one transaction: +One connection (local / `--remote=<url>` / remote-direct). Within one transaction: 1. `BEGIN` 2. (when `--schema` is omitted) `ListUserSchemas` — `... not nspname like any($1)` with the managed-schemas array bound as `$1` @@ -45,38 +51,41 @@ One connection (local / `--db-url` / linked-direct). Within one transaction: 4. per schema: `SELECT p.proname, plpgsql_check_function(p.oid, format:='json') …` (`templates/check.sql`) 5. `ROLLBACK` (always — lint has no committed effects) -Requires `plpgsql_check` to be installable; a bare vanilla `--db-url` without +Requires `plpgsql_check` to be installable; a bare vanilla `--remote=<url>` without the extension fails at step 3. Stack catalog Postgres (native artifact and the -digest-pinned image) ships the extension, so `--local` lint on native and +digest-pinned image) ships the extension, so a local lint on native and container stacks can enable it. Compose uses the Compose Postgres image. ## Environment Variables -| 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 | +| Variable | Purpose | Required? | +| ----------------------- | ------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------- | +| `SUPABASE_ACCESS_TOKEN` | auth token for a remote project or branch | no (keyring → `~/.supabase/access-token`) | +| `SUPABASE_PROJECT_ID` | linked project ref override and the parent that `--remote=<branch>` is resolved against (superseded by `--remote=<ref>`) | no | +| `SUPABASE_DB_PASSWORD` | remote 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` | 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`) | +| Code | Condition | +| ---- | -------------------------------------------------------------------------------------------------- | +| `0` | success — no issues at or above `--fail-on` (an empty result is still `0`) | +| `1` | more than one of `--remote` / `--linked` / `--db-url` / `--local` (`TargetSelectorConflictError`) | +| `1` | connection / `BEGIN` / list-schemas / enable-extension / query failure | +| `1` | `--remote=<branch>` naming no branch of the parent project | +| `1` | malformed `plpgsql_check` JSON | +| `1` | an issue's level is at or above `--fail-on` | +| `1` | `--project-ref` with `--local`, `--db-url` or `--remote=<value>` (`TargetProjectRefConflictError`) | ## Output ### `--output-format text` -Diagnostics on **stderr**: the `Target: …` line (linked project `Target: <name> (<ref>)`, -`Target: local stack (<host>:<port>)`, or `Target: custom Postgres (<host>:<port>)`), +Diagnostics on **stderr**: alias deprecation warnings, the `Target: …` line (remote project +`Target: <name> (<ref>)`, branch `Target: project <parent> · branch <name> (<ref>)`, +`Target: local stack (<host>:<port>)` with ` · default; pass --remote for <project>` appended in a +linked checkout when no selector was given, 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 @@ -99,8 +108,14 @@ 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. -- **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. +- **Target selection.** `--remote` is read as the linked project (bare), a project ref (20 + lowercase letters), a Postgres URL, or else a branch of the linked project by name or UUID; + `--remote=false` and `--remote=` fail with `Leave out --remote to target the local database.` A + bare `--remote` followed by a word that is a branch of the linked project fails with a + `Did you mean --remote=<word>?` hint, since the word may have been meant as the flag's value. +- **Deprecated aliases.** `--linked`, `--project-ref=<ref>`, `--db-url=<url>` and `--local` still + work and print a deprecation warning on stderr naming the `--remote` replacement (`--local` + warns that local is the default). They are mutually exclusive with each other and with + `--remote`; a `false` switch counts as absent. `--project-ref` with `--local`, `--db-url` or + `--remote=<value>` is a hard error. - Telemetry: only the standard `cli_command_executed` event (no custom events). diff --git a/apps/cli/src/commands/db/pull/SIDE_EFFECTS.md b/apps/cli/src/commands/db/pull/SIDE_EFFECTS.md index d4e764c6f6..1a54d8adce 100644 --- a/apps/cli/src/commands/db/pull/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/db/pull/SIDE_EFFECTS.md @@ -10,11 +10,13 @@ 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. +The default target is the local database, and in a linked checkout the `Target:` line adds +`default; pass --remote for <project>`. `--remote` selects the linked project, `--remote=<ref>` +another project, `--remote=<branch>` a branch of the linked project (by name or UUID) and +`--remote=<url>` another database. One `Target: …` line on stderr names the resolved database. In +migration mode a hosted target (a remote project or branch, or a non-local `--remote=<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, @@ -25,9 +27,9 @@ cache described below. Native artifacts are shared through `$SUPABASE_HOME/cache/stack`; shadow state and data use the normal stack registry, so `stack list` and `stack destroy` can find a shadow left by an abrupt CLI exit. Each shadow owns a unique temporary project root and uses an automatically assigned port; `db.shadow_port` applies only to the legacy backend. Migra (`--diff-engine migra`) is rejected in stack mode. -A `--db-url` whose host and port match the running project stack's SQL endpoint (the `DB_URL` -from `stack status --env`) is a local target and connects in plaintext, like `--local`; a remote -or multi-host `--db-url` without an explicit `sslmode` still requires TLS. +A `--remote=<url>` whose host and port match the running project stack's SQL endpoint (the `DB_URL` +from `stack status --env`) is a local target and connects in plaintext, like the default local +target; a remote or multi-host URL without an explicit `sslmode` still requires TLS. Pg-delta runs in-process. Coverage gaps warn; `--strict-coverage` makes them fatal, while `PGDELTA_DEBUG` writes diagnostic JSON under @@ -48,8 +50,8 @@ disables formatting without disabling safe compaction. | `~/.supabase/cache/shadow-baseline/shadow-baseline-<key>.tar` | tar | warm shadow-cache hit (migration-style pull) — the matching snapshot is streamed into the fresh shadow; every cache-eligible acquire (warm hit and successful cold export) also enumerates and `stat`s every `shadow-baseline-*.tar` for LRU keep-3 + 2-day mtime TTL and may delete other keys (`SUPABASE_HOME` overrides the `~/.supabase` root) | | managed snapshot key `<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) | -| `~/.supabase/access-token` | plain text | linked target 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 remote project or branch with no `SUPABASE_ACCESS_TOKEN` | +| `<workdir>/supabase/.temp/project-ref` | plain text | bare `--remote`, ref resolution — skipped when `SUPABASE_PROJECT_ID` is set; with no selector, only to name the project in the `Target:` hint | ## Files Written @@ -63,7 +65,7 @@ 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, migration-style pull only (never a declarative export, which provisions no shadow); 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 remote project or branch (post-run cache) | | `~/.supabase/telemetry.json` | JSON | every invocation (post-run) | ## Docker @@ -117,25 +119,26 @@ 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 (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) | +| 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) | +| GET | `/v1/projects/{parent}/branches` | Bearer | Branch lookup — only for `--remote=<branch>`, and for a bare `--remote` followed by a word that could name a branch; `{parent}` is `SUPABASE_PROJECT_ID`, else the linked project | +| SQL | `SELECT version FROM …schema_migrations` | — | history reconciliation (remote) | +| SQL | `INSERT … ON CONFLICT … schema_migrations` (UPSERT) | — | history update (after consent) | ## Environment Variables | Variable | Purpose | Required? | | ------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------- | -| `SUPABASE_ACCESS_TOKEN` | auth for the linked target | no | +| `SUPABASE_ACCESS_TOKEN` | auth for a remote project or branch | no | | `SUPABASE_DB_PASSWORD` | remote DB password (overridden by `-p`); ignored for a target other than the linked project (see Notes) | no | | `SUPABASE_DB_SHADOW_PORT` | shadow container's host port (`db.shadow_port`) — NOT `SUPABASE_DB_PORT`, which the shadow never reads | no | | `SUPABASE_DB_MAJOR_VERSION` / `SUPABASE_DB_HEALTH_TIMEOUT` / `SUPABASE_DB_SETTINGS_*` | shadow container-config overrides, same as `db start`/`db reset` | no | -| `SUPABASE_PROJECT_ID` | overrides the shadow container's project id/labels, same as `db start`/`db reset` (`utils.DbId`); ALSO the linked-ref resolution fallback `--project-ref` supersedes — see Notes for the narrower scope of the flag | no | +| `SUPABASE_PROJECT_ID` | overrides the shadow container's project id/labels, same as `db start`/`db reset` (`utils.DbId`); ALSO the linked-ref resolution fallback and the parent project `--remote=<branch>` resolves against; `--remote=<ref>` supersedes it for the ref — see Notes for the narrower scope of the flag | no | | `SUPABASE_NETWORK_ID` (`--network-id`) | forces the shadow and initial-migra `pg_dump` containers onto an existing Docker network | no | | `SUPABASE_USE_SLIM_IMAGES` | resolves the current-pin shadow Postgres, `pg_dump`, PG15+ realtime/storage/auth migrate-job images (migration-style cold shadow), and (for migra) the edge-runtime image from the slim `ghcr.io/supabase/cli` builds (`true`/`1` enable); majors 13/15 use `15.14.1.167` when the flag is on; historical pins, PG14, OrioleDB tags the catalog does not pin, flag-off `15.8.1.085`, and `deno_version = 1` stay on docker.io | no | | `SUPABASE_HOME` | overrides the `~/.supabase` root used for the shadow baseline cache (and other CLI state) | no | @@ -145,13 +148,13 @@ at all, so nothing is cached for it. ## Exit Codes -| 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` | +| Code | Condition | +| ---- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `0` | success (migration written and recorded in the history; declarative export) | +| `1` | conflicting target flags (`--remote`/`--linked`/`--db-url`/`--local`); `--declarative` with `--diff-engine`; migration-history conflict; **no schema changes ("No schema changes found")**; `--remote=<branch>` naming no branch of the parent project; 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`, `--db-url` or `--remote=<value>` | +| `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 @@ -166,7 +169,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>`. On a hosted target the confirmation question precedes any file write. On success the PostRun line `Finished supabase db pull.` is printed to +written to <dir>`. Alias deprecation warnings print first. 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 @@ -181,7 +184,7 @@ resolved `target` object is merged into the payload. ## Consent -- Declarative pulls and pulls against the local stack ask nothing. +- Declarative pulls and pulls against the local stack (the default) 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 @@ -199,19 +202,25 @@ resolved `target` object is merged into the payload. - **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 remote project or branch 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`. -- `--declarative` is mutually exclusive with `--diff-engine`; `--db-url`, `--linked` and `--local` - are mutually exclusive, and the default target is the linked project. + temporary login role instead. `--password` is rejected with `--remote=<url>` and for the local default. +- `--declarative` is mutually exclusive with `--diff-engine`. - `--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`** 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. +- **Target selection.** `--remote` is read as the linked project (bare), a project ref (20 + lowercase letters), a Postgres URL, or else a branch of the linked project by name or UUID; + `--remote=false` and `--remote=` fail with `Leave out --remote to target the local database.` A + bare `--remote` followed by a word that is a branch of the linked project fails with a + `Did you mean --remote=<word>?` hint, since the word may have been meant as the flag's value. +- **Deprecated aliases.** `--linked`, `--project-ref=<ref>`, `--db-url=<url>` and `--local` still + work and print a deprecation warning on stderr naming the `--remote` replacement (`--local` + warns that local is the default). They are mutually exclusive with each other and with + `--remote`; a `false` switch counts as absent. `--project-ref` with `--local`, `--db-url` or + `--remote=<value>` is a hard error. Unlike `SUPABASE_PROJECT_ID`, `--remote=<ref>` and + `--project-ref` do not affect the shadow container's project id/labels. - 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/push/SIDE_EFFECTS.md b/apps/cli/src/commands/db/push/SIDE_EFFECTS.md index 4cf48e6c00..134b9e3e21 100644 --- a/apps/cli/src/commands/db/push/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/db/push/SIDE_EFFECTS.md @@ -1,29 +1,31 @@ # `supabase db push` Applies pending local migrations (and optionally seed data and custom roles) to -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. +the local database by default, or to the database `--remote` selects (the linked project, +`--remote=<ref>`, a branch via `--remote=<branch>`, or a database via `--remote=<url>`), +updating configured Vault secrets before migrations unless `--skip-vault` +is set. A hosted target asks one combined confirmation before the first write. With no +selector in a linked checkout, the `Target:` line adds `default; pass --remote for <project>`. ## Files Read -| Path | Format | When | -| ------------------------------------------------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------ | -| `<workdir>/supabase/config.json` or `config.toml` | JSON / TOML | always; `config.json` is preferred when both exist (embedded defaults used when neither exists) | -| `<workdir>/.env*`, `<workdir>/supabase/.env*` | dotenv | project env files, resolved with `SUPABASE_ENV` (default `development`); a variable the shell sets is never taken from a file | -| `~/.supabase/<hash>/project-ref` | plain text | on the `--linked` path (and the default target), to resolve the ref — skipped when `--project-ref` (or `SUPABASE_PROJECT_ID`) is set | -| `~/.supabase/access-token` | plain text | when `SUPABASE_ACCESS_TOKEN` unset and a linked temp-role is minted | -| `<workdir>/supabase/migrations/` | directory | when `[db.migrations].enabled` (default true), to list local files | -| `<workdir>/supabase/migrations/*.sql` | SQL | for each pending migration, when applied (and not `--dry-run`) | -| seed files from `[db.seed].sql_paths` | SQL | when `--include-seed` and `[db.seed].enabled` (paths under `supabase/`) | -| `<workdir>/supabase/roles.sql` | SQL | when `--include-roles` (existence check + apply) | +| Path | Format | When | +| ------------------------------------------------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | +| `<workdir>/supabase/config.json` or `config.toml` | JSON / TOML | always; `config.json` is preferred when both exist (embedded defaults used when neither exists) | +| `<workdir>/.env*`, `<workdir>/supabase/.env*` | dotenv | project env files, resolved with `SUPABASE_ENV` (default `development`); a variable the shell sets is never taken from a file | +| `~/.supabase/<hash>/project-ref` | plain text | bare `--remote`, to resolve the ref — skipped when `SUPABASE_PROJECT_ID` is set; with no selector, only to name the project in the `Target:` hint | +| `~/.supabase/access-token` | plain text | when `SUPABASE_ACCESS_TOKEN` unset and a remote temp-role is minted | +| `<workdir>/supabase/migrations/` | directory | when `[db.migrations].enabled` (default true), to list local files | +| `<workdir>/supabase/migrations/*.sql` | SQL | for each pending migration, when applied (and not `--dry-run`) | +| seed files from `[db.seed].sql_paths` | SQL | when `--include-seed` and `[db.seed].enabled` (paths under `supabase/`) | +| `<workdir>/supabase/roles.sql` | SQL | when `--include-roles` (existence check + apply) | ## Files Written -| Path | Format | When | -| ------------------------------------------------ | ------ | --------------------------------------- | -| `~/.supabase/<workdir-hash>/linked-project.json` | JSON | on the `--linked` path (post-run cache) | -| `~/.supabase/telemetry.json` | JSON | always (post-run telemetry flush) | +| Path | Format | When | +| ------------------------------------------------ | ------ | ---------------------------------------------- | +| `~/.supabase/<workdir-hash>/linked-project.json` | JSON | on a remote project or branch (post-run cache) | +| `~/.supabase/telemetry.json` | JSON | always (post-run telemetry flush) | ## Database Mutations @@ -38,40 +40,41 @@ is set. A hosted target asks one combined confirmation before the first write. ## API Routes -| Method | Path | Auth | Request body | Response (used fields) | -| ------ | ---- | ---- | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| — | — | — | — | The native handler connects to Postgres directly. On the `--linked` path the db-config resolver may call the Management API to mint a temporary login role (inherited from the shared resolver). | +| Method | Path | Auth | Request body | Response (used fields) | +| ------ | ---- | ---- | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| — | — | — | — | The native handler connects to Postgres directly. On a remote project or branch the db-config resolver may call the Management API to mint a temporary login role (inherited from the shared resolver), and `--remote=<branch>` (or a bare `--remote` followed by a word that could name a branch) calls `GET /v1/projects/{parent}/branches` to find the branch's project (`{parent}` is `SUPABASE_PROJECT_ID`, else the linked project). | ## Environment Variables -| Variable | Purpose | Required? | -| ----------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------- | -| `SUPABASE_ACCESS_TOKEN` | auth token for the `--linked` resolver path | no (falls back to keyring → `~/.supabase/access-token`) | -| `SUPABASE_DB_PASSWORD` | password for the linked/remote connection; 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` | 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 | +| Variable | Purpose | Required? | +| ----------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------- | +| `SUPABASE_ACCESS_TOKEN` | auth token for a remote project or branch | no (falls back to keyring → `~/.supabase/access-token`) | +| `SUPABASE_DB_PASSWORD` | password for the 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` | 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 `--remote=<ref>` when set, and the parent project that `--remote=<branch>` resolves against — 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` | more than one of `--local`, `--linked`, `--db-url`, or `--project-ref` with `--local`/`--db-url` (target selector conflict) | +| `1` | more than one of `--remote`, `--linked`, `--db-url` and `--local`, or `--project-ref` with `--local`/`--db-url`/`--remote=<value>` (target selector conflict) | +| `1` | `--remote=<branch>` naming no branch of the parent project | | `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` | `--password` with `--remote=<url>` or the local default (`--password can't be used with --remote=<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 (the `Target: …` banner, "Applying migration…", "Seeding…", "Updating vault +Diagnostics (alias deprecation warnings, 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 @@ -97,17 +100,23 @@ stdout is payload-only. A single `result` object is emitted: } ``` -`target` is the announced target (linked, local, or `db-url`); the linked shape carries the +`target` is the announced target (a remote project or branch as `linked`, `local`, or `db-url`); the linked shape carries the project ref and its environment. ## Notes -- **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. +- **Targets**: with no selector the target is the local database. The target is announced as a + `Target:` banner on stderr before anything is connected. +- **Target selection.** `--remote` is read as the linked project (bare), a project ref (20 + lowercase letters), a Postgres URL, or else a branch of the linked project by name or UUID; + `--remote=false` and `--remote=` fail with `Leave out --remote to target the local database.` A + bare `--remote` followed by a word that is a branch of the linked project fails with a + `Did you mean --remote=<word>?` hint, since the word may have been meant as the flag's value. +- **Deprecated aliases.** `--linked`, `--project-ref=<ref>`, `--db-url=<url>` and `--local` still + work and print a deprecation warning on stderr naming the `--remote` replacement (`--local` + warns that local is the default). They are mutually exclusive with each other and with + `--remote`; a `false` switch counts as absent. `--project-ref` with `--local`, `--db-url` or + `--remote=<value>` is a hard error. - **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 @@ -116,12 +125,12 @@ project ref and its environment. is withheld when the target project differs from the one in `.temp/project-ref`. The command prints `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>.` to stderr and mints a temporary login role instead. Unlinked workdirs use the env value. -- **`--password`** is rejected with `--db-url` or `--local`, because those +- **`--password`** is rejected with `--remote=<url>` and for the local default, because those targets carry their own credentials. - **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 + item. The default is No; declining exits 1 with nothing written. The local default 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 diff --git a/apps/cli/src/commands/db/query/SIDE_EFFECTS.md b/apps/cli/src/commands/db/query/SIDE_EFFECTS.md index e36656a8b4..667e1069e8 100644 --- a/apps/cli/src/commands/db/query/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/db/query/SIDE_EFFECTS.md @@ -1,69 +1,73 @@ # `supabase db query` Implemented in `query.handler.ts`. Executes SQL against the local -database (direct connection), a `--db-url` database (direct connection) or the linked project -(Management API), then renders the result as a table or JSON. +database (direct connection), a `--remote=<url>` database (direct connection) or a remote project +or branch (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 +With no selector the command runs against the local stack, and in a linked checkout the `Target:` +line adds `default; pass --remote for <project>`. `--remote` selects the linked project, +`--remote=<ref>` another project, `--remote=<branch>` a branch of the linked project (by name or +UUID) and `--remote=<url>` a custom database (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 +Any statement sent to a hosted target (remote project, branch or remote `--remote=<url>`) asks for one confirmation first, because the command cannot tell a read from a write. `--read-only` skips that confirmation and accepts only read statements in a read-only transaction (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 | 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) | +| 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 / `--remote=<url>` connection resolution | +| `~/.supabase/access-token` | plain text | a remote project or branch when `SUPABASE_ACCESS_TOKEN` unset | +| `supabase/.temp/project-ref` | plain text | bare `--remote`, ref resolution — skipped when `SUPABASE_PROJECT_ID` is set; with no selector, only to name the project in the `Target:` hint | +| `supabase/.temp/linked-project.json` | JSON | a remote project or branch, existence check before the cache write (see below) | ## Files Written -| 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 | +| Path | Format | When | +| ------------------------------------ | ------ | ----------------------------------------------------------------------------------------------------------------------------- | +| `supabase/.temp/linked-project.json` | JSON | remote project or branch, 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>"}`, plus `"read_only":true` with `--read-only` | 201, JSON array of row objects (read via `executeRaw`, since the typed operation voids the body). | -| 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 (read via `executeRaw`, since the typed operation voids the body). | +| GET | `/v1/projects/{ref}` | Bearer | — | 200 → linked-project cache write; any other status → no write. Fired after the query on a remote project or branch. | +| GET | `/v1/projects/{parent}/branches` | Bearer | — | 200 → `[{id, name, project_ref}]`. Branch lookup — only for `--remote=<branch>`, and for a bare `--remote` followed by a word that could name a branch; `{parent}` is `SUPABASE_PROJECT_ID`, else the linked project. | ## Environment Variables -| 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` | +| Variable | Purpose | +| ----------------------- | ------------------------------------------------------------------------------------------------------------------------ | +| `SUPABASE_ACCESS_TOKEN` | auth for a remote project or branch | +| `SUPABASE_PROJECT_ID` | linked project ref override and the parent that `--remote=<branch>` is resolved against (superseded by `--remote=<ref>`) | +| `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` | 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 | +| Code | Condition | +| ---- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `0` | success | +| `1` | more than one of `--remote`/`--linked`/`--db-url`/`--local` (`TargetSelectorConflictError`); no SQL provided; empty stdin; unreadable `--file`; remote project or branch without login; `--remote=<branch>` naming no branch of the parent project; query exec failure; non-201 remote status | +| `1` | `--project-ref` with `--local`, `--db-url` or `--remote=<value>` (`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 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>:<port>)`) +payload). Diagnostics go to **stderr**: alias deprecation warnings, the `Target: …` line (remote +project `Target: <name> (<ref>)`, branch `Target: project <parent> · branch <name> (<ref>)`, +`Target: local stack (<host>:<port>)` with ` · default; pass --remote for <project>` appended in a +linked checkout when no selector was given, `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. @@ -98,11 +102,17 @@ 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). -- **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 +- **Target selection.** `--remote` is read as the linked project (bare), a project ref (20 + lowercase letters), a Postgres URL, or else a branch of the linked project by name or UUID; + `--remote=false` and `--remote=` fail with `Leave out --remote to target the local database.` A + bare `--remote` followed by a word that is a branch of the linked project fails with a + `Did you mean --remote=<word>?` hint, since the word may have been meant as the flag's value. +- **Deprecated aliases.** `--linked`, `--project-ref=<ref>`, `--db-url=<url>` and `--local` still + work and print a deprecation warning on stderr naming the `--remote` replacement (`--local` + warns that local is the default). They are mutually exclusive with each other and with + `--remote`; a `false` switch counts as absent. `--project-ref` with `--local`, `--db-url` or + `--remote=<value>` is a hard error. +- **Hosted consent.** A remote project, branch or remote `--remote=<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 @@ -116,17 +126,17 @@ from the environment. Agent mode defaults the format to JSON (table for humans). `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 + keyword check. A remote project or branch sends `"read_only": true` in the Management API request, and the + API enforces it. The local stack and a `--remote=<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, +- **Linked-project cache.** On a remote project or branch, after the query runs — whether it succeeds or fails — the handler issues `GET /v1/projects/{ref}` and writes `supabase/.temp/linked-project.json`. The write is skipped when the file already exists (`supabase link` is authoritative), the access token is missing, or the GET is non-200 — so an auth-failing query - still fires the GET but writes nothing. `--local` / `--db-url` never resolve a + still fires the GET but writes nothing. The local default and `--remote=<url>` never resolve a project ref and so never trigger this request or write. Shared with `backups` via `LinkedProjectCache`. diff --git a/apps/cli/src/commands/db/remote/changes/SIDE_EFFECTS.md b/apps/cli/src/commands/db/remote/changes/SIDE_EFFECTS.md index 1141be8dc2..1bb5d5a060 100644 --- a/apps/cli/src/commands/db/remote/changes/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/db/remote/changes/SIDE_EFFECTS.md @@ -42,7 +42,7 @@ regardless of `-o`/`--output`: ``` supabase db remote changes was removed. -Use `supabase db diff --linked` instead. +Use `supabase db diff --remote` instead. ``` ### `--output-format json` / `stream-json` diff --git a/apps/cli/src/commands/db/reset/SIDE_EFFECTS.md b/apps/cli/src/commands/db/reset/SIDE_EFFECTS.md index 347d973c55..588cd2fe56 100644 --- a/apps/cli/src/commands/db/reset/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/db/reset/SIDE_EFFECTS.md @@ -1,13 +1,13 @@ # `supabase db reset` Reinitialises a database from local migrations (plus seed). Both targets are -fully native. The **remote** path (`--linked`, or a remote `--db-url`) +fully native. The **remote** path (`--remote` for a project or branch, or a remote `--remote=<url>`) drops all user schemas, upserts vault secrets, then either re-applies migrations (the default) or, on a versionless `--experimental`/`SUPABASE_EXPERIMENTAL` reset with pg-delta explicitly disabled (`[experimental.pgdelta] enabled = false`; it defaults to true), applies the declarative `[db.migrations].schema_paths` files instead (the `MigrateAndSeed` EXPERIMENTAL branch, CLI-1958), then -seeds. The **local** path (`--local`/default, or a `--db-url` pointing at the local +seeds. The **local** path (the default, or a `--remote=<url>` pointing at the local stack) is ALSO fully native: the running check, the PG14/PG15 container-recreate composition (`command-internal/db-bootstrap/recreate-local-database.ts`, reusing the same container-bootstrap primitives `db start` uses — see that command's own @@ -18,11 +18,11 @@ 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 +With no selector the command resets the local stack, and in a linked checkout the `Target:` line +adds `default; pass --remote for <project>`. `--remote` selects the linked project, +`--remote=<ref>` another project, `--remote=<branch>` a branch of the linked project (by name or +UUID) and `--remote=<url>` a custom database (see +[ADR 0033](../../../../../../docs/adr/0033-target-selection-and-hosted-mutation-consent.md)). A hosted target (a remote project or branch, or a remote `--remote=<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/ @@ -49,9 +49,9 @@ errored Storage is skipped. The gateway handles lazy activation. The command nev reset for a Storage problem: an unavailable service, missing credentials, a stack-gateway activation failure, or an invalid bucket config all print `WARNING: skipped seeding storage buckets: <reason> <next step>` to stderr and exit `0`, -where the next step is `Run supabase seed buckets --local once Storage is available.` (or, for +where the next step is `Run supabase seed buckets once Storage is available.` (or, for disabled Storage, to enable `[storage]`, run `supabase stack stop` then `supabase stack start`, -then `supabase seed buckets --local`), unless the underlying stack-storage error carries its own +then `supabase seed buckets`), unless the underlying stack-storage error carries its own suggestion (a missing API gateway endpoint, Auth disabled, or a gateway 502/503), in which case that suggestion is printed as the next step instead. The warning is omitted when the project configures no `[storage.buckets]` or vector buckets, since there is nothing to seed. The @@ -64,7 +64,7 @@ responsibility — the runtime never creates buckets itself. Durable stack state `$SUPABASE_HOME/stacks/<stackId>/`. A missing stack reports "The local stack is not running." The database and stack config are validated before the wipe. If catalog preparation or migrations fail, companion services remain stopped. Fix the reported error and rerun -`supabase db reset --local` to complete the reset and resume them. +`supabase db reset` to complete the reset and resume them. ## Files Read @@ -74,8 +74,8 @@ 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 | 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 | +| `~/.supabase/<hash>/project-ref` | plain text | bare `--remote`, to resolve the ref — skipped when `SUPABASE_PROJECT_ID` is set; with no selector, only to name the project in the `Target:` hint | +| `~/.supabase/access-token` | plain text | a remote project or branch, 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) | | `<workdir>/supabase/buckets/` | files | local path, when storage is up and `[storage.buckets]` configure objects | @@ -84,10 +84,10 @@ or migrations fail, companion services remain stopped. Fix the reported error an ## Files Written -| Path | Format | When | -| ------------------------------------------------ | ------ | --------------------------------- | -| `~/.supabase/<workdir-hash>/linked-project.json` | JSON | `--linked` (post-run cache) | -| `~/.supabase/telemetry.json` | JSON | always (post-run telemetry flush) | +| Path | Format | When | +| ------------------------------------------------ | ------ | ------------------------------------------- | +| `~/.supabase/<workdir-hash>/linked-project.json` | JSON | a remote project or branch (post-run cache) | +| `~/.supabase/telemetry.json` | JSON | always (post-run telemetry flush) | On the local path, the native recreate additionally recreates the `supabase_db_<project>` container/volume (PG15) or the `postgres`/`_supabase` @@ -158,25 +158,24 @@ the whole reset** (not just "skip buckets"). ## API Routes -| Method | Path | Auth | Request body | Response (used fields) | -| ------ | ---------------------------------------- | ---- | ------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| — | — | — | — | Connects to Postgres directly. The `--linked` resolver may call the Management API to mint a temporary login role; local bucket seeding calls the Storage gateway. | -| GET | `/containers/supabase_db_<project>/json` | — | — | local path — the running probe's Docker Engine API request over the active context's local socket/named pipe (`Api-Version`/`Server` identity headers + status; a 200 body must parse as a JSON object; identity-gated 200 → running, 404 → absent; anything else → the spawned-CLI fallback) | +| Method | Path | Auth | Request body | Response (used fields) | +| ------ | ---------------------------------------- | ---- | ------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| — | — | — | — | Connects to Postgres directly. A remote project or branch may call the Management API to mint a temporary login role (and `--remote=<branch>`, or a bare `--remote` followed by a word that could name a branch, calls `GET /v1/projects/{parent}/branches` to find the branch's project; `{parent}` is `SUPABASE_PROJECT_ID`, else the linked project); local bucket seeding calls the Storage gateway. | +| GET | `/containers/supabase_db_<project>/json` | — | — | local path — the running probe's Docker Engine API request over the active context's local socket/named pipe (`Api-Version`/`Server` identity headers + status; a 200 body must parse as a JSON object; identity-gated 200 → running, 404 → absent; anything else → the spawned-CLI fallback) | ## Environment Variables | Variable | Purpose | Required? | | -------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------- | | `DOCKER_HOST` / `DOCKER_CONTEXT` / `DOCKER_TLS_VERIFY` / `DOCKER_CERT_PATH` / `DOCKER_API_VERSION` / `DOCKER_CONFIG` | local path: ambient shell environment only (project dotenv files deliberately never override Docker client keys) — resolves the daemon endpoint for the running probe (in-process) and steers the spawned `docker`/`podman` CLI itself | no | -| `SUPABASE_ACCESS_TOKEN` | auth token for the `--linked` resolver path | no (falls back to keyring → `~/.supabase/access-token`) | -| `SUPABASE_DB_PASSWORD` | password for the linked/remote connection; ignored for a target other than the linked project (see Notes) | no | +| `SUPABASE_ACCESS_TOKEN` | auth token for a remote project or branch | no (falls back to keyring → `~/.supabase/access-token`) | +| `SUPABASE_DB_PASSWORD` | password for the 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` | 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) | -| `SUPABASE_PROJECT_ID` | overrides the local container id; ALSO the linked-ref resolution fallback `--project-ref` supersedes — see Notes for the narrower scope of the flag | no | +| `SUPABASE_PROJECT_ID` | overrides the local container id; ALSO the linked-ref resolution fallback and the parent project `--remote=<branch>` resolves against; `--remote=<ref>` supersedes it for the ref — see Notes for the narrower scope of the flag | no | | `SUPABASE_INTERNAL_IMAGE_REGISTRY` | overrides the image registry used to resolve the local path's container images (project `.env` or shell) | no (project `.env` or shell) | | `SUPABASE_USE_SLIM_IMAGES` | resolves the local-reset Postgres image and the realtime/storage/auth migrate-job images from slim `ghcr.io/supabase/cli` builds (`true`/`1` enable); majors 13/15 use `15.14.1.167` when the flag is on; historical pins, PG14, OrioleDB tags the catalog does not pin, and flag-off `15.8.1.085` stay on docker.io | no (ambient shell only) | | `SUPABASE_DB_PORT` / `SUPABASE_DB_MAJOR_VERSION` / `SUPABASE_DB_HEALTH_TIMEOUT` / `SUPABASE_DB_SETTINGS_*` | local-path container-recreate config overrides, same as `db start` | no | @@ -200,7 +199,7 @@ echoed, because those may genuinely have reached the server. | Code | Condition | | ---- | ------------------------------------------------------------------------------------------------------------------------------------------ | | `0` | success | -| `1` | more than one of `--db-url`, `--linked`, `--local` (`TargetSelectorConflictError`) | +| `1` | more than one of `--remote`, `--linked`, `--db-url`, `--local` (`TargetSelectorConflictError`) | | `1` | `--version` + `--last` together (`[last version]`) | | `1` | `--version` not an integer (`invalid version number`) | | `1` | `--version` has no matching migration file | @@ -212,16 +211,19 @@ echoed, because those may genuinely have reached the server. | `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` with `--local` or `--db-url` (`TargetProjectRefConflictError`) | +| `1` | `--project-ref` with `--local`, `--db-url` or `--remote=<value>` (`TargetProjectRefConflictError`) | +| `1` | `--remote=<branch>` naming no branch of the parent project | Neither target delegates to a child process — every failure is a native, typed error surfaced as `1`. ## Output -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>:<port>)`. There is no `Connecting to … database…` line. +Alias deprecation warnings print first. Every target prints a `Target: …` line to **stderr** +before the first database change: a remote project (`Target: <name> (<ref>)`), a branch +(`Target: project <parent> · branch <name> (<ref>)`), `Target: local stack (<host>:<port>)` with +` · default; pass --remote for <project>` appended in a linked checkout when no selector was given, +or `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 @@ -270,15 +272,21 @@ 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 selection.** `--remote` is read as the linked project (bare), a project ref (20 + lowercase letters), a Postgres URL, or else a branch of the linked project by name or UUID; + `--remote=false` and `--remote=` fail with `Leave out --remote to target the local database.` A + bare `--remote` followed by a word that is a branch of the linked project fails with a + `Did you mean --remote=<word>?` hint, since the word may have been meant as the flag's value. +- **Deprecated aliases.** `--linked`, `--project-ref=<ref>`, `--db-url=<url>` and `--local` still + work and print a deprecation warning on stderr naming the `--remote` replacement (`--local` + warns that local is the default). They are mutually exclusive with each other and with + `--remote`; a `false` switch counts as absent. `--project-ref` with `--local`, `--db-url` or + `--remote=<value>` is a hard error. Unlike `SUPABASE_PROJECT_ID`, `--remote=<ref>` and + `--project-ref` do not affect the local container id. - **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 + stack, not the flag name: a `--remote=<url>` pointing at the local stack is treated as a local reset and never prompts. -- **Hosted consent.** A linked or remote `--db-url` reset asks once, after the plan is known +- **Hosted consent.** A remote project, branch or remote `--remote=<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` @@ -293,7 +301,7 @@ to those defaults (the usual outcome for an interactive terminal). 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).` 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 + a remote project or branch 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. @@ -306,7 +314,7 @@ to those defaults (the usual outcome for an interactive terminal). patterns (supabase-relative). Mutually exclusive with `--no-seed`. On the local path it is applied the same way as `--no-seed` above; on the remote path it seeds the selected database after migrations (a warning is printed when paired with - `--linked` / `--db-url`). + a remote target). - `--last n` reverts the most recent `n` migrations; if `n ≥ total`, the reset target version becomes `-` (revert everything). Mutually exclusive with `--version`. - **`--experimental` schema-files apply** (the `MigrateAndSeed` EXPERIMENTAL 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 230954142d..de4943bd4e 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,10 +6,11 @@ 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>/`. -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, +The command is `selectable` and never asks for hosted-mutation consent: the target is `--remote` +(the linked project), `--remote=<ref>`, `--remote=<branch>` (a branch of the linked project, by name +or UUID) or `--remote=<url>`, else the local database; in a linked checkout the default `Target:` +line adds `default; pass --remote for <project>`. 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. @@ -22,13 +23,13 @@ formatting without disabling safe compaction. ## Files Read -| 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) | +| Path | Format | When | +| ---------------------------------------------- | ---------- | --------------------------------------------------------------------------------------------------------------- | +| `<workdir>/supabase/config.toml` | TOML | always — pg-delta gate, 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 | bare `--remote` with no `SUPABASE_PROJECT_ID`; with no selector, only to name the project in the `Target:` hint | +| `<workdir>/supabase/.temp/linked-project.json` | JSON | a remote project, for the `Target:` banner (no API call) | +| `~/.supabase/access-token` | plain text | a remote project or branch (token resolution) | ## Files Written @@ -48,10 +49,9 @@ formatting without disabling safe compaction. | Variable | Purpose | Required? | | ---------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------- | -| `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_ACCESS_TOKEN` | auth token for a remote project or branch | no | +| `SUPABASE_DB_PASSWORD` | remote database password for a remote project or branch (`--password`/`-p` overrides) | +| `SUPABASE_PROJECT_ID` | the project ref of a bare `--remote` target, and the parent that `--remote=<branch>` is resolved against | | `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 a local target | no | @@ -60,13 +60,14 @@ formatting without disabling safe compaction. ## 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 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 | +| 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 (`--remote`, `--linked`, `--db-url`, `--local`), or `--project-ref` combined with `--local`/`--db-url`/`--remote=<value>` | +| `1` | `--password` combined with `--remote=<url>` or the local default | +| `1` | `--remote=<branch>` naming no branch of the linked project | +| `1` | local-database bring-up / pg-delta engine / export failure | 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 @@ -76,7 +77,7 @@ local Postgres image check and database start. ## Output -Diagnostics (the `Target: …` banner, prompts, `Declarative schema written to <dir>`) +Diagnostics (alias deprecation warnings, 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 @@ -90,20 +91,27 @@ 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. -- 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. +- The target comes from `resolveTarget`: `--remote` or one of its forms wins, else local. With no + selector the command exports the local database; it neither prompts for a target nor fails in + non-interactive mode. +- **Target selection.** `--remote` is read as the linked project (bare), a project ref (20 + lowercase letters), a Postgres URL, or else a branch of the linked project by name or UUID; + `--remote=false` and `--remote=` fail with `Leave out --remote to target the local database.` A + bare `--remote` followed by a word that is a branch of the linked project fails with a + `Did you mean --remote=<word>?` hint, since the word may have been meant as the flag's value. +- **Deprecated aliases.** `--linked`, `--project-ref=<ref>`, `--db-url=<url>` and `--local` still + work and print a deprecation warning on stderr naming the `--remote` replacement (`--local` + warns that local is the default). They are mutually exclusive with each other and with + `--remote`; a `false` switch counts as absent. `--project-ref` with `--local`, `--db-url` or + `--remote=<value>` is a hard error. - `--reset` recreates the local database from the migrations before exporting; it has no effect - on a linked or `--db-url` target. + on a remote project, branch or `--remote=<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. +- A remote project or branch caches the project in `supabase/.temp/linked-project.json` on success and + failure, like other commands that target a remote project. - `--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). @@ -112,7 +120,11 @@ deleted.` (default no); a decline, or no answer, prints `Skipped writing declara or an export manifest, a WARNING on stderr explains the default move and how to keep the existing tree. Read-only probe; never changes behavior or exit codes. -- Remote Supabase targets use the shared connection/TLS behavior. +- Remote Supabase targets use the shared connection/TLS behavior. `--remote=<branch>`, and a bare + `--remote` followed by a word that could name a branch, call + `GET /v1/projects/{parent}/branches` to find the branch's project (`{parent}` is + `SUPABASE_PROJECT_ID`, else the linked project); the connection may also mint a temporary login + role through the Management API. - **Architecture:** the engine extracts and renders the target in-process. - **Stale local-container guard.** A local target inspects the running local `db` container's actual image and compares it against the diff --git a/apps/cli/src/commands/db/test/SIDE_EFFECTS.md b/apps/cli/src/commands/db/test/SIDE_EFFECTS.md index c6fc61b52e..fd22a7b2ed 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 target only) +## API Routes (remote project or branch only) Identical to `test db`. See -[`../../test/db/SIDE_EFFECTS.md`](../../test/db/SIDE_EFFECTS.md#api-routes-linked-target-only). +[`../../test/db/SIDE_EFFECTS.md`](../../test/db/SIDE_EFFECTS.md#api-routes-remote-project-or-branch-only). ## Environment Variables @@ -74,8 +74,11 @@ Identical to `test db`. See - Hidden command — registered with `.pipe(Command.unlisted)` in `../db.command.ts`. -- 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. +- Target selection and the hosted-target confirmation are identical to `test db`: with no selector, + bare `supabase db test` runs against the local stack (a linked checkout adds + `default; pass --remote for <project>` to the `Target:` line), `--remote` and its forms select a + remote project, branch or database, and a remote project, branch or remote `--remote=<url>` run + asks for confirmation (or needs `--yes`) before pgTAP is enabled. The deprecated `--linked`, + `--project-ref`, `--db-url` and `--local` aliases warn on stderr. - 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 1972f3742f..a6455f1854 100644 --- a/apps/cli/src/commands/test/db/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/test/db/SIDE_EFFECTS.md @@ -1,23 +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. +Runs pgTAP tests against a database. With no selector the command runs against the local stack, +and in a linked checkout the `Target:` line adds `default; pass --remote for <project>`. `--remote` +selects the linked project, `--remote=<ref>` another project, `--remote=<branch>` a branch of the +linked project (by name or UUID) and `--remote=<url>` a custom database (see +[ADR 0033](../../../../../../docs/adr/0033-target-selection-and-hosted-mutation-consent.md)). The run enables pgTAP and drops it again, so a hosted target (a remote project, branch or +remote `--remote=<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-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 | +| 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 | remote-project 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 remote project or branch only, when `SUPABASE_ACCESS_TOKEN` unset | +| `<workdir>/supabase/.temp/project-ref` | text | bare `--remote` only, to resolve the ref — skipped when `SUPABASE_PROJECT_ID` is set; with no selector, only to name the project in the `Target:` hint | +| `<supabaseHome>/stacks/*/state.json` | JSON | managed local stack: discover the project stack and open its saved definition | ## Files Written @@ -30,55 +30,56 @@ remote `--db-url`) asks for one confirmation before anything runs; the local sta | 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) | +| `set session role postgres` | after connect when the user is `supabase_admin` / `cli_login_*` (remote 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 -Compose `--local` / `--linked` / `--db-url` run one-shot `docker run --rm <pg_prove image>`, where the image is `supabase/pg_prove:3.36` resolved through the registry (`getRegistryImageUrl`): `SUPABASE_INTERNAL_IMAGE_REGISTRY` overrides the registry, `docker.io` pulls from Docker Hub unchanged, and the default is `public.ecr.aws/supabase/pg_prove:3.36`. +Compose local, remote-project and `--remote=<url>` runs use one-shot `docker run --rm <pg_prove image>`, where the image is `supabase/pg_prove:3.36` resolved through the registry (`getRegistryImageUrl`): `SUPABASE_INTERNAL_IMAGE_REGISTRY` overrides the registry, `docker.io` pulls from Docker Hub unchanged, and the default is `public.ecr.aws/supabase/pg_prove:3.36`. -Managed stack `--local` prove runs the catalog `pg_prove` tool through the owning stack runtime with runtime-facing host and port, preserving the CLI resolver’s database user, password, and database. External `--db-url`/`--linked` prove uses the catalog Postgres client: native prepends `artifact/bin`; container or no-native-artifact platforms run a one-shot of the digest-pinned catalog Postgres image. That pin is not rewritten through `SUPABASE_INTERNAL_IMAGE_REGISTRY`. Missing `pg_prove` fails closed. There is no PATH fallback. +Managed local stack prove runs the catalog `pg_prove` tool through the owning stack runtime with runtime-facing host and port, preserving the CLI resolver’s database user, password, and database. External `--remote` prove (a remote project, branch or URL) uses the catalog Postgres client: native prepends `artifact/bin`; container or no-native-artifact platforms run a one-shot of the digest-pinned catalog Postgres image. That pin is not rewritten through `SUPABASE_INTERNAL_IMAGE_REGISTRY`. Missing `pg_prove` fails closed. There is no PATH fallback. - `-v <hostpath>:<dockerpath>:ro` for each test path on Compose and on catalog container prove. A path that is a **file** is mounted via its **containing directory** (not the lone file) so that psql `\ir`/`\i` includes — which resolve relative to the test file's own directory — find their sibling files inside the container (CLI-1139). Directory paths are mounted as-is. Mounts are deduped by container target, so multiple files in the same directory produce a single `-v`. The full file path is still passed to `pg_prove`, so only the requested file runs. Native prove reads host paths directly (no bind). - `--security-opt label:disable` on Compose and catalog container prove (omitted in Bitbucket Pipelines) -- `--network supabase_network_<project_id>` (Compose `--local`) with env `PGHOST=db PGPORT=5432`. External `--db-url`/`--linked` runs use host networking with the resolved host and port. Managed stack proves use the owner-managed tool with runtime credentials and its runtime-specific attachment; they do not use the Compose network or `PGHOST=db`. `<project_id>` is sanitized (`sanitizeProjectId`), so an invalid configured value (e.g. `"my project"`) joins the same network the local stack created +- `--network supabase_network_<project_id>` (Compose local) with env `PGHOST=db PGPORT=5432`. External `--remote` runs use host networking with the resolved host and port. Managed stack proves use the owner-managed tool with runtime credentials and its runtime-specific attachment; they do not use the Compose network or `PGHOST=db`. `<project_id>` is sanitized (`sanitizeProjectId`), so an invalid configured value (e.g. `"my project"`) joins the same network the local stack created - `-e PGHOST/PGPORT/PGUSER/PGPASSWORD/PGDATABASE` - cmd `pg_prove --ext .pg --ext .sql -r <paths> [--verbose]` (`--verbose` when `--debug`) -## API Routes (linked target only) +## API Routes (remote project or branch only) -| Method | Path | Auth | Request body | Response (used fields) | -| ------ | ------------------------------------------ | ------------ | ----------------------------------------- | --------------------------- | -| POST | `/v1/projects/{ref}/cli/login-role` | access token | `{ read_only: false }` | `{ role, password }` | -| POST | `/v1/projects/{ref}/network-bans/retrieve` | access token | — | `{ banned_ipv4_addresses }` | -| DELETE | `/v1/projects/{ref}/network-bans` | access token | `{ ipv4_addresses, requester_ip: false }` | — | +| Method | Path | Auth | Request body | Response (used fields) | +| ------ | ------------------------------------------ | ------------ | ----------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| POST | `/v1/projects/{ref}/cli/login-role` | access token | `{ read_only: false }` | `{ role, password }` | +| POST | `/v1/projects/{ref}/network-bans/retrieve` | access token | — | `{ banned_ipv4_addresses }` | +| DELETE | `/v1/projects/{ref}/network-bans` | access token | `{ ipv4_addresses, requester_ip: false }` | — | +| GET | `/v1/projects/{parent}/branches` | access token | — | `[{id, name, project_ref}]` — only for `--remote=<branch>`, and for a bare `--remote` followed by a word that could name a branch; `{parent}` is `SUPABASE_PROJECT_ID`, else the linked project | ## Environment Variables -| 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 | +| Variable | Purpose | Required? | +| ---------------------------- | ------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------- | +| `SUPABASE_DB_PASSWORD` | remote project or branch: skip temporary login-role creation | no | +| `SUPABASE_ACCESS_TOKEN` | remote project or branch: Management API auth | no (falls back to keyring/file) | +| `SUPABASE_PROJECT_ID` | linked project ref override and the parent that `--remote=<branch>` is resolved against (superseded by `--remote=<ref>`) | no | +| `SUPABASE_YES` | grants the hosted-target confirmation (shell only; `supabase/.env` is ignored) | no (also `--yes`) | +| `SUPABASE_SERVICES_HOSTNAME` | local stack: overrides the local DB host (dev-container/remote Docker) | no (defaults via `DOCKER_HOST` → `127.0.0.1`) | +| `DOCKER_HOST` | local stack: 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 -| Code | Condition | -| ---- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `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` | 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 | +| Code | Condition | +| ---- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `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` | more than one of `--remote` / `--linked` / `--db-url` / `--local` (`TargetSelectorConflictError`) | +| `1` | database connection failure / pgTAP enable failure / docker failure / remote-project auth or IPv6 errors; `--remote=<branch>` naming no branch of the parent project | +| `1` | `--project-ref` with `--local`, `--db-url` or `--remote=<value>` (`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 @@ -102,8 +103,10 @@ 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>:<port>)`) and the hosted +TAP streams to stdout. The alias deprecation warnings, the `Target: …` line (remote project `Target: <name> (<ref>)`, +branch `Target: project <parent> · branch <name> (<ref>)`, `Target: local stack (<host>:<port>)` +with ` · default; pass --remote for <project>` appended in a linked checkout when no selector was +given, 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. @@ -118,23 +121,29 @@ 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. -- **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 +- **Target selection** (shared by `db test` via `testDbConfig`). `--remote` is read as the linked project (bare), a project ref (20 + lowercase letters), a Postgres URL, or else a branch of the linked project by name or UUID; + `--remote=false` and `--remote=` fail with `Leave out --remote to target the local database.` A + bare `--remote` followed by a word that is a branch of the linked project fails with a + `Did you mean --remote=<word>?` hint, since the word may have been meant as the flag's value. +- **Deprecated aliases.** `--linked`, `--project-ref=<ref>`, `--db-url=<url>` and `--local` still + work and print a deprecation warning on stderr naming the `--remote` replacement (`--local` + warns that local is the default). They are mutually exclusive with each other and with + `--remote`; a `false` switch counts as absent. `--project-ref` with `--local`, `--db-url` or + `--remote=<value>` is a hard error. +- **Hosted consent.** A remote project, branch or remote `--remote=<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` + `supabase/.env` and a piped answer do not. Stdin is never read for the answer. A `--remote=<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`) + (`ConnectLocalPostgres` sets `cc.TLSConfig = nil`); remote (`--remote`) connections honor the URL's `sslmode` — `disable` → plaintext, `verify-ca` / `verify-full` → TLS **with** certificate verification, and everything else (`prefer` / `require` / unset) → TLS **without** verification (non-TLS fallbacks stripped). -- `--db-url` accepts both the WHATWG `postgres(ql)://…` URL form and the libpq +- `--remote=<url>` (and the deprecated `--db-url`) accepts both the WHATWG `postgres(ql)://…` URL form and the libpq keyword/value DSN form (`host=… dbname=… user=…`, incl. unix-socket paths), following libpq parsing. The `sslmode` and libpq `options` (Supavisor `?options=reference=<ref>`) parameters are preserved on both forms. A malformed URL or percent escape surfaces as a redacted `failed to parse connection string` error, never @@ -157,7 +166,7 @@ command (exit 1). code 42710 `duplicate_object`), so "pgTAP already installed" is detected by checking `pg_extension` by extension name (any schema) before enabling — including when the user pre-installed pgTAP in a non-`extensions` schema such as `public`. -- The linked connection pooler URL is read from `supabase/.temp/pooler-url` (written by +- The remote connection pooler URL is read from `supabase/.temp/pooler-url` (written by `supabase link`) rather than from config.toml — the `[db.pooler]` config.toml field is intentionally ignored. The pooler's `?options=reference=<ref>` startup param is carried through to the connection for the legacy pooler-URL format. From 760cf9e9a73a0021158434bb9d4eac3e9a0023bf Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Mon, 19 Oct 2026 12:18:20 +0100 Subject: [PATCH 133/291] docs(cli): format migration list side effects --- .../commands/migration/list/SIDE_EFFECTS.md | 48 +++++++++---------- 1 file changed, 24 insertions(+), 24 deletions(-) diff --git a/apps/cli/src/commands/migration/list/SIDE_EFFECTS.md b/apps/cli/src/commands/migration/list/SIDE_EFFECTS.md index 08f5a32d15..82e984eb53 100644 --- a/apps/cli/src/commands/migration/list/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/migration/list/SIDE_EFFECTS.md @@ -2,13 +2,13 @@ ## Files Read -| Path | Format | When | -| ---- | ------ | ---- | -| `<workdir>/supabase/config.toml` | TOML | always, for the `[db]` settings, with a matched `[remotes.<ref>]` block over the base for a remote project (flag > shell env > project `.env*` > config) | -| `~/.supabase/access-token` | plain text | when `SUPABASE_ACCESS_TOKEN` unset and the target is a remote project (or a branch is looked up) | -| `<workdir>/supabase/.temp/project-ref` | plain text | a remote project: resolves the ref (bare `--remote` with no `SUPABASE_PROJECT_ID`) and tells whether it is the linked project; with no selector, only to word the linked-checkout hint | -| `<workdir>/supabase/.temp/linked-project.json` | JSON | to name the project in the `Target:` banner and the linked-checkout hint, and as the parent project for `--remote=<branch>` | -| `<workdir>/supabase/migrations/` | directory | always, to list local migration files | +| Path | Format | When | +| ---------------------------------------------- | ---------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `<workdir>/supabase/config.toml` | TOML | always, for the `[db]` settings, with a matched `[remotes.<ref>]` block over the base for a remote project (flag > shell env > project `.env*` > config) | +| `~/.supabase/access-token` | plain text | when `SUPABASE_ACCESS_TOKEN` unset and the target is a remote project (or a branch is looked up) | +| `<workdir>/supabase/.temp/project-ref` | plain text | a remote project: resolves the ref (bare `--remote` with no `SUPABASE_PROJECT_ID`) and tells whether it is the linked project; with no selector, only to word the linked-checkout hint | +| `<workdir>/supabase/.temp/linked-project.json` | JSON | to name the project in the `Target:` banner and the linked-checkout hint, and as the parent project for `--remote=<branch>` | +| `<workdir>/supabase/migrations/` | directory | always, to list local migration files | ## Files Written @@ -20,33 +20,33 @@ Local targets and database URLs make none. -| Method | Path | Auth | Request body | Response (used fields) | -| ------ | ---- | ---- | ------------ | ---------------------- | -| `GET` | `/v1/projects` | Bearer token | none | `[{id, name, organization_slug, region}]` — project picker: bare `--remote` with no ref from `SUPABASE_PROJECT_ID` or `.temp/project-ref`, with stdin and stdout TTYs and a non-JSON output format | -| `GET` | `/v1/projects/{parent}/branches` | Bearer token | none | `[{id, name, project_ref, is_default}]` — only for `--remote=<branch>` (or the deprecated `--project-ref=<branch>`), and for a bare `--remote` followed by a word that could name a branch; `{parent}` is `SUPABASE_PROJECT_ID`, else the linked project | -| `POST` | `/v1/projects/{ref}/cli/login-role` | Bearer token | `{ read_only: false }` | temporary `role` and `password` — remote project with no database password | -| `GET` | `/v1/projects/{ref}/config/database/pooler` | Bearer token | none | primary pooler `connection_string` — remote project on an IPv4-only network (direct host unreachable), only when the ref came from `--remote=<ref>` or `--remote=<branch>`, or differs from the linked project; a differing ref ignores the saved `.temp/pooler-url` | -| `POST` | `/v1/projects/{ref}/network-bans/retrieve` | Bearer token | none | `banned_ipv4_addresses` — remote project, from the 3rd failed temporary-role connect | -| `DELETE` | `/v1/projects/{ref}/network-bans` | Bearer token | `{ ipv4_addresses, requester_ip: false }` | none — remote project, only when bans are listed and `--yes` or a shell `SUPABASE_YES` is set (clears every listed address); otherwise the connect keeps retrying and fails with `LinkedDbNetworkBannedError` once the retries run out | -| `GET` | `/v1/projects/{ref}` | Bearer token | none | `{ref, name, organization_id, organization_slug}` — linked-project cache miss only (best-effort, post-run): written only when `.temp/linked-project.json` doesn't exist, `.temp/project-ref` doesn't name another ref, an access token exists and the lookup returns 200 | +| Method | Path | Auth | Request body | Response (used fields) | +| -------- | ------------------------------------------- | ------------ | ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `GET` | `/v1/projects` | Bearer token | none | `[{id, name, organization_slug, region}]` — project picker: bare `--remote` with no ref from `SUPABASE_PROJECT_ID` or `.temp/project-ref`, with stdin and stdout TTYs and a non-JSON output format | +| `GET` | `/v1/projects/{parent}/branches` | Bearer token | none | `[{id, name, project_ref, is_default}]` — only for `--remote=<branch>` (or the deprecated `--project-ref=<branch>`), and for a bare `--remote` followed by a word that could name a branch; `{parent}` is `SUPABASE_PROJECT_ID`, else the linked project | +| `POST` | `/v1/projects/{ref}/cli/login-role` | Bearer token | `{ read_only: false }` | temporary `role` and `password` — remote project with no database password | +| `GET` | `/v1/projects/{ref}/config/database/pooler` | Bearer token | none | primary pooler `connection_string` — remote project on an IPv4-only network (direct host unreachable), only when the ref came from `--remote=<ref>` or `--remote=<branch>`, or differs from the linked project; a differing ref ignores the saved `.temp/pooler-url` | +| `POST` | `/v1/projects/{ref}/network-bans/retrieve` | Bearer token | none | `banned_ipv4_addresses` — remote project, from the 3rd failed temporary-role connect | +| `DELETE` | `/v1/projects/{ref}/network-bans` | Bearer token | `{ ipv4_addresses, requester_ip: false }` | none — remote project, only when bans are listed and `--yes` or a shell `SUPABASE_YES` is set (clears every listed address); otherwise the connect keeps retrying and fails with `LinkedDbNetworkBannedError` once the retries run out | +| `GET` | `/v1/projects/{ref}` | Bearer token | none | `{ref, name, organization_id, organization_slug}` — linked-project cache miss only (best-effort, post-run): written only when `.temp/linked-project.json` doesn't exist, `.temp/project-ref` doesn't name another ref, an access token exists and the lookup returns 200 | ## Environment Variables | Variable | Purpose | Required? | | ----------------------- | --------------------------------------------------------------------------------- | ------------------------------------------------------- | | `SUPABASE_ACCESS_TOKEN` | auth token for a remote project | no (falls back to keyring → `~/.supabase/access-token`) | -| `SUPABASE_DB_PASSWORD` | password for the remote project's database connection (`--password`/`-p` wins) | no | +| `SUPABASE_DB_PASSWORD` | password for the remote project's database connection (`--password`/`-p` wins) | no | | `SUPABASE_PROJECT_ID` | project ref for a bare `--remote`, and the parent project for `--remote=<branch>` | no | ## Exit Codes -| Code | Condition | -| ---- | -------------------------------------------- | -| `0` | success | -| `1` | database connection failure | -| `1` | `--password` with a local target or `--remote=<db-url>` | -| `1` | failed to open migrations directory | -| `1` | conflicting target selectors (see Notes) | +| Code | Condition | +| ---- | ---------------------------------------------------------------------------------------------------------------------- | +| `0` | success | +| `1` | database connection failure | +| `1` | `--password` with a local target or `--remote=<db-url>` | +| `1` | failed to open migrations directory | +| `1` | conflicting target selectors (see Notes) | | `1` | `--remote=<branch>` matches no branch of the linked project (`LinkBranchNotFoundError`, lists the branches that exist) | ## Output From 9869f03576266a502524bc7f2ec67e53ecfa6f7b Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Mon, 19 Oct 2026 13:04:04 +0100 Subject: [PATCH 134/291] fix(cli): keep branch-lookup progress and target warnings off stdout Branch lookups only draw a spinner on a terminal, so a piped text run no longer mixes task steps into the command's payload. The db diff `--from/--to linked` deprecation joins the other target warnings on stderr. --- .../src/command-internal/target/branch-resolution.ts | 9 +++++++-- .../src/command-internal/target/remote-resolution.ts | 11 ++++++++--- apps/cli/src/commands/db/diff/diff.handler.ts | 3 ++- 3 files changed, 17 insertions(+), 6 deletions(-) diff --git a/apps/cli/src/command-internal/target/branch-resolution.ts b/apps/cli/src/command-internal/target/branch-resolution.ts index 3a2981ece1..9dc72139f5 100644 --- a/apps/cli/src/command-internal/target/branch-resolution.ts +++ b/apps/cli/src/command-internal/target/branch-resolution.ts @@ -113,9 +113,14 @@ const acquireApi = Effect.fnUntraced(function* () { return yield* factory.value.make; }); +/** A branch-lookup spinner, shown only on a terminal: a non-terminal text run writes task steps to stdout, which may carry the command's payload. */ +export const startBranchLookupTask = Effect.fnUntraced(function* (label: string) { + const output = yield* Output; + return output.format === "text" && output.interactive ? yield* output.task(label) : undefined; +}); + /** `GET /v1/projects/{ref}/branches`, shared by every branch-name lookup. */ export const listParentBranches = Effect.fnUntraced(function* (parentRef: string, label: string) { - const output = yield* Output; const api = yield* acquireApi(); const mapBranchListError = mapHttpError({ @@ -128,7 +133,7 @@ export const listParentBranches = Effect.fnUntraced(function* (parentRef: string : `unexpected list branches status ${status}: ${body}`, }); - const task = output.format === "text" ? yield* output.task(label) : undefined; + const task = yield* startBranchLookupTask(label); const branches: Branches = yield* api.v1.listAllBranches({ ref: parentRef }).pipe( Effect.tapError(() => task?.fail() ?? Effect.void), Effect.catch(mapBranchListError), diff --git a/apps/cli/src/command-internal/target/remote-resolution.ts b/apps/cli/src/command-internal/target/remote-resolution.ts index c912b51267..d9c3f56758 100644 --- a/apps/cli/src/command-internal/target/remote-resolution.ts +++ b/apps/cli/src/command-internal/target/remote-resolution.ts @@ -19,18 +19,23 @@ import { renderBranchTarget } from "./target-identity.ts"; import type { TargetDeprecation } from "./target-selector.ts"; import { TargetRemoteAmbiguousError } from "./target.errors.ts"; -/** Prints one warning per deprecated selector flag the run used on stderr in every output format, rebuilt from the real command line when it is known. */ +/** Prints a target warning on stderr in every output format, since stdout may carry the payload. */ +export const warnTarget = Effect.fnUntraced(function* (message: string) { + const output = yield* Output; + yield* output.raw(`${yellow(`Warning: ${message}`)}\n`, "stderr"); +}); + +/** Prints one warning per deprecated selector flag the run used, rebuilt from the real command line when it is known. */ export const warnTargetDeprecations = Effect.fnUntraced(function* ( deprecations: ReadonlyArray<TargetDeprecation>, ) { if (deprecations.length === 0) return; - const output = yield* Output; const { projectHost } = yield* CommandSettings; const argv = Option.getOrUndefined( Option.map(yield* Effect.serviceOption(CliArgs), (cli) => cli.args), ); for (const message of deprecationMessages(deprecations, { argv, projectHost })) { - yield* output.raw(`${yellow(`Warning: ${message}`)}\n`, "stderr"); + yield* warnTarget(message); } }); diff --git a/apps/cli/src/commands/db/diff/diff.handler.ts b/apps/cli/src/commands/db/diff/diff.handler.ts index 3bcf14abf2..28f4a689fe 100644 --- a/apps/cli/src/commands/db/diff/diff.handler.ts +++ b/apps/cli/src/commands/db/diff/diff.handler.ts @@ -27,6 +27,7 @@ import { type LinkedTarget, } from "../../../command-internal/target/resolve-target.ts"; import type { RemoteValue } from "../../../command-internal/target/remote-value.ts"; +import { warnTarget } from "../../../command-internal/target/remote-resolution.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"; @@ -311,7 +312,7 @@ export const dbDiff = Effect.fn("db.diff")(function* (flags: DbDiffFlags) { } case "Remote": if (explicit.viaDeprecatedLinked) { - yield* output.warn( + yield* warnTarget( `${flag} linked is deprecated and will be removed in v4. Use ${flag} remote instead.`, ); } From 81df1882b3da640265fc63ad271d4caa34061b1b Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Mon, 19 Oct 2026 13:04:15 +0100 Subject: [PATCH 135/291] refactor(cli): fold project selection into resolveProjectTarget `resolveProjectTarget` takes options for a parent-only `--remote` (branches) and for a command-owned lookup (config family), so the separate `selectProject` and `requestedConfigTarget` go away. Conditional project-ref/project-id telemetry safety is one `projectRefSafeFlags` helper, and the `remote` flag name has a single source shared by telemetry and flag parsing. --- .../src/command-internal/project-target.ts | 28 +---- .../target/project-ref-safe-flags.ts | 14 ++- .../project-ref-safe-flags.unit.test.ts | 35 ++++++ .../target/project-selection.ts | 31 ------ .../command-internal/target/remote-value.ts | 2 + ...resolve-project-target.integration.test.ts | 32 +++++- .../target/resolve-project-target.ts | 102 +++++++++++++++--- .../command-internal/target/target-flags.ts | 11 +- .../src/commands/branches/branches.target.ts | 40 ------- .../branches/branches.target.unit.test.ts | 20 ---- .../branches/create/create.command.ts | 5 +- .../branches/create/create.handler.ts | 7 +- .../branches/delete/delete.command.ts | 5 +- .../branches/delete/delete.handler.ts | 7 +- .../branches/disable/disable.command.ts | 5 +- .../branches/disable/disable.handler.ts | 7 +- .../src/commands/branches/get/get.command.ts | 5 +- .../src/commands/branches/get/get.handler.ts | 7 +- .../commands/branches/list/list.command.ts | 5 +- .../commands/branches/list/list.handler.ts | 7 +- .../commands/branches/pause/pause.command.ts | 5 +- .../commands/branches/pause/pause.handler.ts | 7 +- .../branches/unpause/unpause.command.ts | 5 +- .../branches/unpause/unpause.handler.ts | 7 +- .../branches/update/update.command.ts | 5 +- .../branches/update/update.handler.ts | 7 +- .../src/commands/config/diff/diff.command.ts | 8 +- .../src/commands/config/diff/diff.handler.ts | 16 ++- .../src/commands/config/pull/pull.command.ts | 9 +- .../src/commands/config/pull/pull.handler.ts | 16 ++- .../src/commands/config/push/push.command.ts | 8 +- .../src/commands/config/push/push.handler.ts | 16 ++- .../functions/delete/delete.command.ts | 9 +- .../functions/deploy/deploy.command.ts | 9 +- .../functions/download/download.command.ts | 8 +- .../commands/functions/list/list.command.ts | 9 +- .../src/commands/gen/types/types.command.ts | 32 +++--- apps/cli/src/commands/link/link.command.ts | 7 +- .../commands/notebooks/pull/pull.command.ts | 2 +- .../commands/notebooks/push/push.command.ts | 2 +- .../projects/api-keys/api-keys.command.ts | 2 +- apps/cli/src/commands/pull/pull.command.ts | 8 +- apps/cli/src/commands/pull/pull.handler.ts | 14 +-- 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 +- .../src/shared/functions/functions.shared.ts | 2 - apps/cli/src/telemetry/command-telemetry.ts | 8 +- 51 files changed, 315 insertions(+), 293 deletions(-) create mode 100644 apps/cli/src/command-internal/target/project-ref-safe-flags.unit.test.ts delete mode 100644 apps/cli/src/command-internal/target/project-selection.ts delete mode 100644 apps/cli/src/commands/branches/branches.target.unit.test.ts diff --git a/apps/cli/src/command-internal/project-target.ts b/apps/cli/src/command-internal/project-target.ts index 1eab188f26..32e4062822 100644 --- a/apps/cli/src/command-internal/project-target.ts +++ b/apps/cli/src/command-internal/project-target.ts @@ -12,14 +12,12 @@ import { resolveParentScopedProjectRef, } from "./parent-project-ref.ts"; import { announceTarget } from "../shared/output/command-target.service.ts"; -import { Output } from "../shared/output/output.service.ts"; import { projectRefPicker } from "../config/project-ref.layer.ts"; 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 { selectProject } from "./target/project-selection.ts"; -import type { ProjectTargetInput } from "./target/resolve-project-target.ts"; +import { startBranchLookupTask } from "./target/branch-resolution.ts"; import { type LinkedTarget, linkedTarget } from "./target/resolve-target.ts"; import { renderBranchTarget } from "./target/target-identity.ts"; import { @@ -169,26 +167,6 @@ export const configTarget = compose( projectRefPicker, ); -/** The ref, branch name or UUID that `--remote` (or the deprecated `--project-ref`) names; none means the linked project. */ -export const requestedConfigTarget = Effect.fn("ProjectTarget.requested")(function* ( - input: ProjectTargetInput, -) { - const { selection } = yield* selectProject(input); - switch (selection.explicit) { - case "branch": - return Option.some(selection.name); - case "linked": - return selection.ref; - case undefined: - return Option.none<string>(); - case "local": - case "db-url": - return yield* Effect.die( - new Error(`a project-only selector cannot select "${selection.explicit}"`), - ); - } -}); - /** * Resolves the requested `--remote` value to a {@link ConfigTarget}: a project ref, or the name/UUID * of a branch on the linked project. A ref-shaped value (20 lowercase letters) is always a project ref. @@ -205,7 +183,6 @@ export const resolveConfigTarget = Effect.fn("ProjectTarget.resolve")(function* /** Maps a branch-lookup (`GET`-by-UUID or `FIND`-by-name) transport/status failure. */ mapResolveError: (cause: SupabaseApiError) => Effect.Effect<never, EResolve>, ) { - const output = yield* Output; const resolver = yield* ProjectRefResolver; let ref: string; @@ -233,8 +210,7 @@ export const resolveConfigTarget = Effect.fn("ProjectTarget.resolve")(function* parentProjectRef = Option.some(parent.ref); } - const resolving = - output.format === "text" ? yield* output.task("Resolving branch...") : undefined; + const resolving = yield* startBranchLookupTask("Resolving branch..."); ref = yield* resolveBranchProjectRef(target, parentRef, { mapGetError: mapResolveError, mapFindError: mapResolveError, diff --git a/apps/cli/src/command-internal/target/project-ref-safe-flags.ts b/apps/cli/src/command-internal/target/project-ref-safe-flags.ts index c49fb759d2..c02e744889 100644 --- a/apps/cli/src/command-internal/target/project-ref-safe-flags.ts +++ b/apps/cli/src/command-internal/target/project-ref-safe-flags.ts @@ -2,6 +2,14 @@ import { Option } from "effect"; import { PROJECT_REF_PATTERN } from "../../config/project-ref.service.ts"; -/** `--project-ref` can also hold a branch name, so only a ref-shaped value is safe to log. */ -export const projectRefSafeFlags = (projectRef: Option.Option<string>): ReadonlyArray<string> => - Option.isSome(projectRef) && PROJECT_REF_PATTERN.test(projectRef.value) ? ["project-ref"] : []; +const isRefShaped = (value: Option.Option<string> | undefined): boolean => + value !== undefined && Option.isSome(value) && PROJECT_REF_PATTERN.test(value.value); + +/** `--project-ref` and `--project-id` can also hold a branch name, so only a ref-shaped value is safe to log. */ +export const projectRefSafeFlags = (flags: { + readonly projectRef: Option.Option<string>; + readonly projectId?: Option.Option<string>; +}): ReadonlyArray<string> => [ + ...(isRefShaped(flags.projectRef) ? ["project-ref"] : []), + ...(isRefShaped(flags.projectId) ? ["project-id"] : []), +]; diff --git a/apps/cli/src/command-internal/target/project-ref-safe-flags.unit.test.ts b/apps/cli/src/command-internal/target/project-ref-safe-flags.unit.test.ts new file mode 100644 index 0000000000..7e4bbb5738 --- /dev/null +++ b/apps/cli/src/command-internal/target/project-ref-safe-flags.unit.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from "@effect/vitest"; +import { Option } from "effect"; + +import { projectRefSafeFlags } from "./project-ref-safe-flags.ts"; + +const REF = "abcdefghijklmnopqrst"; + +describe("projectRefSafeFlags", () => { + it("reports --project-ref when it is a project ref", () => { + expect(projectRefSafeFlags({ projectRef: Option.some(REF) })).toEqual(["project-ref"]); + }); + + it("withholds a --project-ref value that is a branch name", () => { + expect(projectRefSafeFlags({ projectRef: Option.some("feature-x") })).toEqual([]); + }); + + it("reports nothing without --project-ref", () => { + expect(projectRefSafeFlags({ projectRef: Option.none() })).toEqual([]); + }); + + it("applies the same rule to --project-id", () => { + expect(projectRefSafeFlags({ projectRef: Option.none(), projectId: Option.some(REF) })).toEqual( + ["project-id"], + ); + expect( + projectRefSafeFlags({ projectRef: Option.none(), projectId: Option.some("feature-x") }), + ).toEqual([]); + }); + + it("reports both flags when both are ref-shaped", () => { + expect( + projectRefSafeFlags({ projectRef: Option.some(REF), projectId: Option.some(REF) }), + ).toEqual(["project-ref", "project-id"]); + }); +}); diff --git a/apps/cli/src/command-internal/target/project-selection.ts b/apps/cli/src/command-internal/target/project-selection.ts deleted file mode 100644 index 32fcc938cf..0000000000 --- a/apps/cli/src/command-internal/target/project-selection.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { Effect, Option, Result } from "effect"; - -import { - CommandRuntime, - getCommandRuntimeCommand, -} from "../../shared/runtime/command-runtime.service.ts"; -import { warnTargetDeprecations } from "./remote-resolution.ts"; -import type { ProjectTargetInput } from "./resolve-project-target.ts"; -import { parseTargetSelector } from "./target-selector.ts"; - -/** - * Parses the `--remote` and deprecated `--project-ref` of a hosted command and prints the alias - * warnings, leaving the lookup to the caller; a database URL fails as a selector error. - */ -export const selectProject = Effect.fn("Target.selectProject")(function* ( - input: ProjectTargetInput, -) { - const runtime = yield* Effect.serviceOption(CommandRuntime); - const commandName = Option.isSome(runtime) - ? `\`supabase ${getCommandRuntimeCommand(runtime.value)}\`` - : undefined; - const { selection, deprecations } = yield* Result.match( - parseTargetSelector( - { ...input, local: false, linked: false }, - commandName === undefined ? {} : { commandName }, - ), - { onFailure: Effect.fail, onSuccess: Effect.succeed }, - ); - yield* warnTargetDeprecations(deprecations); - return { selection, commandName: commandName ?? "This command" }; -}); diff --git a/apps/cli/src/command-internal/target/remote-value.ts b/apps/cli/src/command-internal/target/remote-value.ts index 84fad7e6da..9b44c669e8 100644 --- a/apps/cli/src/command-internal/target/remote-value.ts +++ b/apps/cli/src/command-internal/target/remote-value.ts @@ -2,6 +2,8 @@ import { Predicate } from "effect"; import { BRANCH_PROJECT_REF_PATTERN } from "../ref-patterns.ts"; +export const REMOTE_FLAG_NAME = "remote"; + /** What `--remote` (or a `db diff --from/--to` value) names. */ export type RemoteValue = | { readonly _tag: "Linked" } diff --git a/apps/cli/src/command-internal/target/resolve-project-target.integration.test.ts b/apps/cli/src/command-internal/target/resolve-project-target.integration.test.ts index 832652ef97..fe37f351a5 100644 --- a/apps/cli/src/command-internal/target/resolve-project-target.integration.test.ts +++ b/apps/cli/src/command-internal/target/resolve-project-target.integration.test.ts @@ -41,6 +41,7 @@ const run = ( readonly savedRef?: string; readonly args?: ReadonlyArray<string>; readonly branches?: ReadonlyArray<ReturnType<typeof branchRow>>; + readonly interactive?: boolean; } = {}, ) => Effect.gen(function* () { @@ -72,7 +73,9 @@ const run = ( resolveOptional: () => Effect.die("resolveOptional is not used by resolveProjectTarget"), promptProjectRef: () => Effect.die("promptProjectRef is not used by resolveProjectTarget"), }); - const out = mockOutput(); + const out = mockOutput( + options.interactive === undefined ? {} : { interactive: options.interactive }, + ); const api = mockCommandPlatformApi({ handler: (request) => Effect.succeed(jsonResponse(request, 200, options.branches ?? [])), }); @@ -102,6 +105,7 @@ const run = ( .filter((line) => line.startsWith("Warning: ")) .map((line) => line.slice("Warning: ".length)), outputWarnCalls: out.messages.filter((m) => m.type === "warn"), + progressEvents: out.progressEvents, refFlags, branchRequests: api.requests.length, }; @@ -165,6 +169,32 @@ describe("resolveProjectTarget", () => { }), ); + it.effect("shows the branch lookup spinner on a terminal", () => + Effect.gen(function* () { + const result = yield* run( + { remote: { _tag: "Branch", name: "feature-x" } }, + { savedRef: FILE_REF, branches: [branchRow("feature-x")], interactive: true }, + ); + expect(refOf(result.exit)).toBe(BRANCH_REF); + expect(result.progressEvents).toContainEqual({ + type: "start", + message: "Resolving branch...", + }); + }), + ); + + it.effect("keeps the branch lookup off stdout when it is not a terminal", () => + Effect.gen(function* () { + const result = yield* run( + { remote: { _tag: "Branch", name: "feature-x" } }, + { savedRef: FILE_REF, branches: [branchRow("feature-x")], interactive: false }, + ); + expect(refOf(result.exit)).toBe(BRANCH_REF); + expect(result.progressEvents).toEqual([]); + expect(result.stdout).toBe(""); + }), + ); + it.effect("treats a --project-ref that is not a ref as a branch name", () => Effect.gen(function* () { const result = yield* run( diff --git a/apps/cli/src/command-internal/target/resolve-project-target.ts b/apps/cli/src/command-internal/target/resolve-project-target.ts index cef3e76cce..7fe176e630 100644 --- a/apps/cli/src/command-internal/target/resolve-project-target.ts +++ b/apps/cli/src/command-internal/target/resolve-project-target.ts @@ -4,36 +4,49 @@ import { CommandRuntime, getCommandRuntimeCommand, } from "../../shared/runtime/command-runtime.service.ts"; +import { resolveParentScopedProjectRef } from "../parent-project-ref.ts"; import { resolveRemoteTarget } from "./resolve-target.ts"; import { rejectAmbiguousRemote, warnTargetDeprecations } from "./remote-resolution.ts"; import type { RemoteValue } from "./remote-value.ts"; import { parseTargetSelector } from "./target-selector.ts"; +import { TargetNeedsProjectError } from "./target.errors.ts"; export interface ProjectTargetInput { readonly remote: Option.Option<RemoteValue>; readonly projectRef: Option.Option<string>; } -/** - * Resolves the project a hosted-only command acts on: `--remote=<ref|branch>` names one, and a - * bare `--remote` or no selector falls through to the project-ref chain (`SUPABASE_PROJECT_ID`, - * the link file, the picker). - */ -export const resolveProjectTarget = Effect.fn("Target.resolveProject")(function* ( - input: ProjectTargetInput, -) { +interface ParentProjectTargetOptions { + /** A command that acts on a parent project, such as `branches *`: its `--remote` takes a ref, never a branch name. */ + readonly parent: true; +} + +interface RequestedProjectTargetOptions<A, E, R> { + /** Resolves the ref or branch name `--remote` names with the caller's own lookup, for a command with no operands. */ + readonly resolveRequested: (requested: Option.Option<string>) => Effect.Effect<A, E, R>; +} + +const selectProject = Effect.fnUntraced(function* (input: ProjectTargetInput) { const runtime = yield* Effect.serviceOption(CommandRuntime); + const commandName = Option.isSome(runtime) + ? `\`supabase ${getCommandRuntimeCommand(runtime.value)}\`` + : undefined; const { selection, deprecations } = yield* Result.match( parseTargetSelector( { ...input, local: false, linked: false }, - Option.isSome(runtime) - ? { commandName: `\`supabase ${getCommandRuntimeCommand(runtime.value)}\`` } - : {}, + commandName === undefined ? {} : { commandName }, ), { onFailure: Effect.fail, onSuccess: Effect.succeed }, ); yield* warnTargetDeprecations(deprecations); + return { selection, commandName: commandName ?? "This command" }; +}); + +const unreachableSelection = (explicit: "local" | "db-url") => + Effect.die(new Error(`a project-only selector cannot select "${explicit}"`)); +const resolveHosted = Effect.fn("Target.resolveProject")(function* (input: ProjectTargetInput) { + const { selection } = yield* selectProject(input); switch (selection.explicit) { case "branch": return (yield* resolveRemoteTarget({ _tag: "Branch", name: selection.name })).ref; @@ -48,8 +61,69 @@ export const resolveProjectTarget = Effect.fn("Target.resolveProject")(function* return (yield* resolveRemoteTarget({ _tag: "Linked" })).ref; case "local": case "db-url": - return yield* Effect.die( - new Error(`a project-only selector cannot select "${selection.explicit}"`), - ); + return yield* unreachableSelection(selection.explicit); + } +}); + +const resolveParent = Effect.fn("Target.resolveProject")(function* (input: ProjectTargetInput) { + const { selection, commandName } = yield* selectProject(input); + switch (selection.explicit) { + case "branch": + return yield* new TargetNeedsProjectError({ + message: `${commandName} acts on a parent project, so --remote takes its project ref, not a branch name. Use --remote=<ref>.`, + }); + case "linked": + return yield* resolveParentScopedProjectRef(selection.ref); + case undefined: + return yield* resolveParentScopedProjectRef(Option.none()); + case "local": + case "db-url": + return yield* unreachableSelection(selection.explicit); } }); + +const resolveRequested = Effect.fn("Target.resolveProject")(function* <A, E, R>( + input: ProjectTargetInput, + resolve: (requested: Option.Option<string>) => Effect.Effect<A, E, R>, +) { + const { selection } = yield* selectProject(input); + switch (selection.explicit) { + case "branch": + return yield* resolve(Option.some(selection.name)); + case "linked": + return yield* resolve(selection.ref); + case undefined: + return yield* resolve(Option.none()); + case "local": + case "db-url": + return yield* unreachableSelection(selection.explicit); + } +}); + +/** + * Resolves the project a hosted-only command acts on: `--remote=<ref|branch>` names one, and a + * bare `--remote` or no selector falls through to the project-ref chain (`SUPABASE_PROJECT_ID`, + * the link file, the picker). + */ +export function resolveProjectTarget(input: ProjectTargetInput): ReturnType<typeof resolveHosted>; +export function resolveProjectTarget( + input: ProjectTargetInput, + options: ParentProjectTargetOptions, +): ReturnType<typeof resolveParent>; +export function resolveProjectTarget<A, E, R>( + input: ProjectTargetInput, + options: RequestedProjectTargetOptions<A, E, R>, +): Effect.Effect< + A, + E | Effect.Error<ReturnType<typeof selectProject>>, + R | Effect.Services<ReturnType<typeof selectProject>> +>; +export function resolveProjectTarget<A, E, R>( + input: ProjectTargetInput, + options?: ParentProjectTargetOptions | RequestedProjectTargetOptions<A, E, R>, +) { + if (options === undefined) return resolveHosted(input); + return "resolveRequested" in options + ? resolveRequested(input, options.resolveRequested) + : resolveParent(input); +} diff --git a/apps/cli/src/command-internal/target/target-flags.ts b/apps/cli/src/command-internal/target/target-flags.ts index 90256ba382..d2f159c570 100644 --- a/apps/cli/src/command-internal/target/target-flags.ts +++ b/apps/cli/src/command-internal/target/target-flags.ts @@ -1,7 +1,12 @@ import { Effect, Option } from "effect"; import { Flag, GlobalFlag, Param, Primitive } from "effect/cli"; -import { classifyRemoteValue, LEAVE_OUT_REMOTE_PREFIX, type RemoteValue } from "./remote-value.ts"; +import { + classifyRemoteValue, + LEAVE_OUT_REMOTE_PREFIX, + REMOTE_FLAG_NAME, + type RemoteValue, +} from "./remote-value.ts"; import type { ResolvedTarget } from "./resolve-target.ts"; type TargetSubject = "database" | "Storage API"; @@ -44,7 +49,7 @@ const remotePrimitive = (variant: RemoteFlagVariant): Primitive.Primitive<Remote const remoteFlag = (variant: RemoteFlagVariant) => Param.makeSingle({ kind: Param.flagKind, - name: "remote", + name: REMOTE_FLAG_NAME, primitiveType: remotePrimitive(variant), }).pipe(Flag.withDescription(remoteDescription(variant)), Flag.optional); @@ -130,7 +135,7 @@ export const flagsForTarget = (target: ResolvedTarget): string => { * share their definitions with {@link targetFlags}. */ export const seedTargetGlobalFlags = { - remote: GlobalFlag.Setting("remote")({ + remote: GlobalFlag.Setting(REMOTE_FLAG_NAME)({ flag: remoteFlag({ kind: "selectable", subject: "Storage API", dbUrl: false }), }), linked: GlobalFlag.Setting("linked")({ flag: linkedFlag() }), diff --git a/apps/cli/src/commands/branches/branches.target.ts b/apps/cli/src/commands/branches/branches.target.ts index d55d06b866..7f7f3de945 100644 --- a/apps/cli/src/commands/branches/branches.target.ts +++ b/apps/cli/src/commands/branches/branches.target.ts @@ -1,44 +1,4 @@ -import { Effect, Option } from "effect"; - -import { PROJECT_REF_PATTERN } from "../../config/project-ref.service.ts"; - import { block } from "../../command-internal/command-permissions/model.ts"; -import { resolveParentScopedProjectRef } from "../../command-internal/parent-project-ref.ts"; -import { selectProject } from "../../command-internal/target/project-selection.ts"; -import type { ProjectTargetInput } from "../../command-internal/target/resolve-project-target.ts"; -import { TargetNeedsProjectError } from "../../command-internal/target/target.errors.ts"; - -/** - * The parent project a `branches` command acts on: `--remote=<ref>` names one, and a bare - * `--remote` or no selector falls through to the linked parent. The branch itself is an argument, - * so a branch name is no valid `--remote` value here. - */ -export const resolveBranchesParent = Effect.fn("branches.resolveParent")(function* ( - input: ProjectTargetInput, -) { - const { selection, commandName } = yield* selectProject(input); - switch (selection.explicit) { - case "branch": - return yield* new TargetNeedsProjectError({ - message: `${commandName} acts on a parent project, so --remote takes its project ref, not a branch name. Use --remote=<ref>.`, - }); - case "linked": - return yield* resolveParentScopedProjectRef(selection.ref); - case undefined: - return yield* resolveParentScopedProjectRef(Option.none()); - case "local": - case "db-url": - return yield* Effect.die( - new Error(`a project-only selector cannot select "${selection.explicit}"`), - ); - } -}); - -/** `--project-ref` is safe to report only when ref-shaped; a branch-shaped value is a user-chosen name. */ -export const branchesSafeFlags = (flags: { readonly projectRef: Option.Option<string> }) => - Option.isSome(flags.projectRef) && PROJECT_REF_PATTERN.test(flags.projectRef.value) - ? ["project-ref"] - : []; /** `--remote` only picks the parent project; it never changes which operations a branches command calls. */ export const branchesParentTarget = block("branches-parent-target", [], ["remote"]); diff --git a/apps/cli/src/commands/branches/branches.target.unit.test.ts b/apps/cli/src/commands/branches/branches.target.unit.test.ts deleted file mode 100644 index b50b180df8..0000000000 --- a/apps/cli/src/commands/branches/branches.target.unit.test.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { describe, expect, it } from "@effect/vitest"; -import { Option } from "effect"; - -import { branchesSafeFlags } from "./branches.target.ts"; - -describe("branchesSafeFlags", () => { - it("reports --project-ref when it is a project ref", () => { - expect(branchesSafeFlags({ projectRef: Option.some("abcdefghijklmnopqrst") })).toEqual([ - "project-ref", - ]); - }); - - it("withholds a --project-ref value that is a branch name", () => { - expect(branchesSafeFlags({ projectRef: Option.some("feature-x") })).toEqual([]); - }); - - it("reports nothing without --project-ref", () => { - expect(branchesSafeFlags({ projectRef: Option.none() })).toEqual([]); - }); -}); diff --git a/apps/cli/src/commands/branches/create/create.command.ts b/apps/cli/src/commands/branches/create/create.command.ts index 29514bf977..48a17c8034 100644 --- a/apps/cli/src/commands/branches/create/create.command.ts +++ b/apps/cli/src/commands/branches/create/create.command.ts @@ -12,7 +12,8 @@ import { withCommandTelemetry } from "../../../telemetry/command-telemetry.ts"; import { branchesCreate } from "./create.handler.ts"; import { withTargetPolicy } from "../../../command-internal/target/target-policy.ts"; import { projectTargetFlags } from "../../../command-internal/target/target-flags.ts"; -import { branchesParentTarget, branchesSafeFlags } from "../branches.target.ts"; +import { projectRefSafeFlags } from "../../../command-internal/target/project-ref-safe-flags.ts"; +import { branchesParentTarget } from "../branches.target.ts"; const BRANCH_REGIONS = [ "ap-east-1", @@ -109,7 +110,7 @@ export const branchesCreateCommand = Command.make("create", config).pipe( ), Command.withHandler((flags) => branchesCreate(flags).pipe( - withCommandTelemetry({ flags, safeFlags: branchesSafeFlags(flags), config }), + withCommandTelemetry({ flags, safeFlags: projectRefSafeFlags(flags), config }), withJsonErrorHandling, ), ), diff --git a/apps/cli/src/commands/branches/create/create.handler.ts b/apps/cli/src/commands/branches/create/create.handler.ts index e96096e6c7..660bb81c10 100644 --- a/apps/cli/src/commands/branches/create/create.handler.ts +++ b/apps/cli/src/commands/branches/create/create.handler.ts @@ -14,7 +14,7 @@ import { encodeStructYaml, } from "../../../command-internal/struct-output.encoders.ts"; import { mapHttpError } from "../../../command-internal/http-errors.ts"; -import { resolveBranchesParent } from "../branches.target.ts"; +import { resolveProjectTarget } from "../../../command-internal/target/resolve-project-target.ts"; import { gateMapError } from "../../../command-internal/upgrade-suggest.ts"; import { BRANCH_RESPONSE_SHAPE } from "../branches.response-shape.ts"; import { @@ -67,7 +67,10 @@ export const branchesCreate = Effect.fn("branches.create")(function* (flags: Bra // `branches` is parent-scoped: after `supabase link <branch>`, `supabase/.temp/project-ref` // holds the branch's own ref, and the platform 403s on that ref for every branches-management // endpoint. - const ref = yield* resolveBranchesParent({ remote: flags.remote, projectRef: flags.projectRef }); + const ref = yield* resolveProjectTarget( + { remote: flags.remote, projectRef: flags.projectRef }, + { parent: true }, + ); yield* Effect.annotateCurrentSpan("project.ref", ref); yield* Effect.gen(function* () { diff --git a/apps/cli/src/commands/branches/delete/delete.command.ts b/apps/cli/src/commands/branches/delete/delete.command.ts index 72741fba1a..95df3caa51 100644 --- a/apps/cli/src/commands/branches/delete/delete.command.ts +++ b/apps/cli/src/commands/branches/delete/delete.command.ts @@ -13,7 +13,8 @@ import { withCommandTelemetry } from "../../../telemetry/command-telemetry.ts"; import { branchesDelete } from "./delete.handler.ts"; import { withTargetPolicy } from "../../../command-internal/target/target-policy.ts"; import { projectTargetFlags } from "../../../command-internal/target/target-flags.ts"; -import { branchesParentTarget, branchesSafeFlags } from "../branches.target.ts"; +import { projectRefSafeFlags } from "../../../command-internal/target/project-ref-safe-flags.ts"; +import { branchesParentTarget } from "../branches.target.ts"; const config = { name: Argument.String("name").pipe( @@ -39,7 +40,7 @@ export const branchesDeleteCommand = Command.make("delete", config).pipe( ), Command.withHandler((flags) => branchesDelete(flags).pipe( - withCommandTelemetry({ flags, safeFlags: branchesSafeFlags(flags) }), + withCommandTelemetry({ flags, safeFlags: projectRefSafeFlags(flags) }), withJsonErrorHandling, ), ), diff --git a/apps/cli/src/commands/branches/delete/delete.handler.ts b/apps/cli/src/commands/branches/delete/delete.handler.ts index 9203078103..894eff4aa3 100644 --- a/apps/cli/src/commands/branches/delete/delete.handler.ts +++ b/apps/cli/src/commands/branches/delete/delete.handler.ts @@ -6,7 +6,7 @@ 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 { resolveBranchesParent } from "../branches.target.ts"; +import { resolveProjectTarget } from "../../../command-internal/target/resolve-project-target.ts"; import { BranchesDeleteNetworkError, BranchesDeleteUnexpectedStatusError, @@ -31,7 +31,10 @@ export const branchesDelete = Effect.fn("branches.delete")(function* (flags: Bra // `branches` is parent-scoped: after `supabase link <branch>`, `supabase/.temp/project-ref` // holds the branch's own ref, and the platform 403s on that ref for every branches-management // endpoint. - const ref = yield* resolveBranchesParent({ remote: flags.remote, projectRef: flags.projectRef }); + const ref = yield* resolveProjectTarget( + { remote: flags.remote, projectRef: flags.projectRef }, + { parent: true }, + ); yield* Effect.annotateCurrentSpan("project.ref", ref); yield* Effect.gen(function* () { diff --git a/apps/cli/src/commands/branches/disable/disable.command.ts b/apps/cli/src/commands/branches/disable/disable.command.ts index 8753e8a22f..03ed3494f2 100644 --- a/apps/cli/src/commands/branches/disable/disable.command.ts +++ b/apps/cli/src/commands/branches/disable/disable.command.ts @@ -11,7 +11,8 @@ import { withCommandTelemetry } from "../../../telemetry/command-telemetry.ts"; import { branchesDisable } from "./disable.handler.ts"; import { withTargetPolicy } from "../../../command-internal/target/target-policy.ts"; import { projectTargetFlags } from "../../../command-internal/target/target-flags.ts"; -import { branchesParentTarget, branchesSafeFlags } from "../branches.target.ts"; +import { projectRefSafeFlags } from "../../../command-internal/target/project-ref-safe-flags.ts"; +import { branchesParentTarget } from "../branches.target.ts"; const config = { ...projectTargetFlags(), @@ -32,7 +33,7 @@ export const branchesDisableCommand = Command.make("disable", config).pipe( ), Command.withHandler((flags) => branchesDisable(flags).pipe( - withCommandTelemetry({ flags, safeFlags: branchesSafeFlags(flags) }), + withCommandTelemetry({ flags, safeFlags: projectRefSafeFlags(flags) }), withJsonErrorHandling, ), ), diff --git a/apps/cli/src/commands/branches/disable/disable.handler.ts b/apps/cli/src/commands/branches/disable/disable.handler.ts index 0049af559b..69e913da2a 100644 --- a/apps/cli/src/commands/branches/disable/disable.handler.ts +++ b/apps/cli/src/commands/branches/disable/disable.handler.ts @@ -6,7 +6,7 @@ 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 { resolveBranchesParent } from "../branches.target.ts"; +import { resolveProjectTarget } from "../../../command-internal/target/resolve-project-target.ts"; import { BranchesDisableNetworkError, BranchesDisableUnexpectedStatusError, @@ -31,7 +31,10 @@ export const branchesDisable = Effect.fn("branches.disable")(function* ( // `branches` is PARENT-scoped: after `supabase link <branch>`, // `supabase/.temp/project-ref` holds the branch's own ref, and the platform // 403s on that ref for every branches-management endpoint. - const ref = yield* resolveBranchesParent({ remote: flags.remote, projectRef: flags.projectRef }); + const ref = yield* resolveProjectTarget( + { remote: flags.remote, projectRef: flags.projectRef }, + { parent: true }, + ); yield* Effect.annotateCurrentSpan("project.ref", ref); yield* Effect.gen(function* () { diff --git a/apps/cli/src/commands/branches/get/get.command.ts b/apps/cli/src/commands/branches/get/get.command.ts index 7031ebba6a..106006d6e1 100644 --- a/apps/cli/src/commands/branches/get/get.command.ts +++ b/apps/cli/src/commands/branches/get/get.command.ts @@ -15,7 +15,8 @@ import { withCommandTelemetry } from "../../../telemetry/command-telemetry.ts"; import { branchesGet } from "./get.handler.ts"; import { withTargetPolicy } from "../../../command-internal/target/target-policy.ts"; import { projectTargetFlags } from "../../../command-internal/target/target-flags.ts"; -import { branchesParentTarget, branchesSafeFlags } from "../branches.target.ts"; +import { projectRefSafeFlags } from "../../../command-internal/target/project-ref-safe-flags.ts"; +import { branchesParentTarget } from "../branches.target.ts"; // `get.handler.ts` skips the api-keys and pooler calls for `-o pretty`, and with no `-o` when the output format is text. const MACHINE_OUTPUT: ReadonlyArray<{ @@ -90,7 +91,7 @@ export const branchesGetCommand = Command.make("get", config).pipe( ), Command.withHandler((flags) => branchesGet(flags).pipe( - withCommandTelemetry({ flags, safeFlags: branchesSafeFlags(flags) }), + withCommandTelemetry({ flags, safeFlags: projectRefSafeFlags(flags) }), withJsonErrorHandling, ), ), diff --git a/apps/cli/src/commands/branches/get/get.handler.ts b/apps/cli/src/commands/branches/get/get.handler.ts index 24dbeda809..4a7d637aa2 100644 --- a/apps/cli/src/commands/branches/get/get.handler.ts +++ b/apps/cli/src/commands/branches/get/get.handler.ts @@ -20,7 +20,7 @@ import { encodeYaml, } from "../../../command-internal/output.encoders.ts"; import { mapHttpError } from "../../../command-internal/http-errors.ts"; -import { resolveBranchesParent } from "../branches.target.ts"; +import { resolveProjectTarget } from "../../../command-internal/target/resolve-project-target.ts"; import { BranchesApiKeysNetworkError, BranchesApiKeysUnexpectedStatusError, @@ -83,7 +83,10 @@ export const branchesGet = Effect.fn("branches.get")(function* (flags: BranchesG // `branches` is parent-scoped: after `supabase link <branch>`, `supabase/.temp/project-ref` // holds the branch's own ref, which the platform 403s on for every branches-management endpoint. - const ref = yield* resolveBranchesParent({ remote: flags.remote, projectRef: flags.projectRef }); + const ref = yield* resolveProjectTarget( + { remote: flags.remote, projectRef: flags.projectRef }, + { parent: true }, + ); yield* Effect.annotateCurrentSpan("project.ref", ref); yield* Effect.gen(function* () { diff --git a/apps/cli/src/commands/branches/list/list.command.ts b/apps/cli/src/commands/branches/list/list.command.ts index 932908bfae..ffbb284afd 100644 --- a/apps/cli/src/commands/branches/list/list.command.ts +++ b/apps/cli/src/commands/branches/list/list.command.ts @@ -11,7 +11,8 @@ import { withCommandTelemetry } from "../../../telemetry/command-telemetry.ts"; import { branchesList } from "./list.handler.ts"; import { withTargetPolicy } from "../../../command-internal/target/target-policy.ts"; import { projectTargetFlags } from "../../../command-internal/target/target-flags.ts"; -import { branchesParentTarget, branchesSafeFlags } from "../branches.target.ts"; +import { projectRefSafeFlags } from "../../../command-internal/target/project-ref-safe-flags.ts"; +import { branchesParentTarget } from "../branches.target.ts"; const config = { ...projectTargetFlags(), @@ -32,7 +33,7 @@ export const branchesListCommand = Command.make("list", config).pipe( ), Command.withHandler((flags) => branchesList(flags).pipe( - withCommandTelemetry({ flags, safeFlags: branchesSafeFlags(flags) }), + withCommandTelemetry({ flags, safeFlags: projectRefSafeFlags(flags) }), withJsonErrorHandling, ), ), diff --git a/apps/cli/src/commands/branches/list/list.handler.ts b/apps/cli/src/commands/branches/list/list.handler.ts index af58afbdb1..3b61d57bb5 100644 --- a/apps/cli/src/commands/branches/list/list.handler.ts +++ b/apps/cli/src/commands/branches/list/list.handler.ts @@ -13,7 +13,7 @@ import { encodeStructYaml, } from "../../../command-internal/struct-output.encoders.ts"; import { mapHttpError } from "../../../command-internal/http-errors.ts"; -import { resolveBranchesParent } from "../branches.target.ts"; +import { resolveProjectTarget } from "../../../command-internal/target/resolve-project-target.ts"; import { BRANCHES_LIST_SHAPE, BRANCHES_TOML_WRAPPER_SHAPE } from "../branches.response-shape.ts"; import { BranchesEnvNotSupportedError, @@ -42,7 +42,10 @@ export const branchesList = Effect.fn("branches.list")(function* (flags: Branche // `branches` is parent-scoped: after `supabase link <branch>`, `supabase/.temp/project-ref` // holds the branch's own ref, which the platform 403s on for every branches-management endpoint. - const ref = yield* resolveBranchesParent({ remote: flags.remote, projectRef: flags.projectRef }); + const ref = yield* resolveProjectTarget( + { remote: flags.remote, projectRef: flags.projectRef }, + { parent: true }, + ); yield* Effect.annotateCurrentSpan("project.ref", ref); yield* Effect.gen(function* () { diff --git a/apps/cli/src/commands/branches/pause/pause.command.ts b/apps/cli/src/commands/branches/pause/pause.command.ts index 0d324d9ecf..eef2995e32 100644 --- a/apps/cli/src/commands/branches/pause/pause.command.ts +++ b/apps/cli/src/commands/branches/pause/pause.command.ts @@ -13,7 +13,8 @@ import { withCommandTelemetry } from "../../../telemetry/command-telemetry.ts"; import { branchesPause } from "./pause.handler.ts"; import { withTargetPolicy } from "../../../command-internal/target/target-policy.ts"; import { projectTargetFlags } from "../../../command-internal/target/target-flags.ts"; -import { branchesParentTarget, branchesSafeFlags } from "../branches.target.ts"; +import { projectRefSafeFlags } from "../../../command-internal/target/project-ref-safe-flags.ts"; +import { branchesParentTarget } from "../branches.target.ts"; const config = { name: Argument.String("name").pipe( @@ -40,7 +41,7 @@ export const branchesPauseCommand = Command.make("pause", config).pipe( ), Command.withHandler((flags) => branchesPause(flags).pipe( - withCommandTelemetry({ flags, safeFlags: branchesSafeFlags(flags) }), + withCommandTelemetry({ flags, safeFlags: projectRefSafeFlags(flags) }), withJsonErrorHandling, ), ), diff --git a/apps/cli/src/commands/branches/pause/pause.handler.ts b/apps/cli/src/commands/branches/pause/pause.handler.ts index 8e22aeb17c..b84ec3e993 100644 --- a/apps/cli/src/commands/branches/pause/pause.handler.ts +++ b/apps/cli/src/commands/branches/pause/pause.handler.ts @@ -6,7 +6,7 @@ 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 { resolveBranchesParent } from "../branches.target.ts"; +import { resolveProjectTarget } from "../../../command-internal/target/resolve-project-target.ts"; import { BranchesPauseNetworkError, BranchesPauseUnexpectedStatusError, @@ -30,7 +30,10 @@ export const branchesPause = Effect.fn("branches.pause")(function* (flags: Branc // `branches` is parent-scoped: after `supabase link <branch>`, `supabase/.temp/project-ref` // holds the branch's own ref, which the platform 403s on for every branches-management endpoint. - const ref = yield* resolveBranchesParent({ remote: flags.remote, projectRef: flags.projectRef }); + const ref = yield* resolveProjectTarget( + { remote: flags.remote, projectRef: flags.projectRef }, + { parent: true }, + ); yield* Effect.annotateCurrentSpan("project.ref", ref); yield* Effect.gen(function* () { diff --git a/apps/cli/src/commands/branches/unpause/unpause.command.ts b/apps/cli/src/commands/branches/unpause/unpause.command.ts index 0fb8dc7d56..518d2251d8 100644 --- a/apps/cli/src/commands/branches/unpause/unpause.command.ts +++ b/apps/cli/src/commands/branches/unpause/unpause.command.ts @@ -13,7 +13,8 @@ import { withCommandTelemetry } from "../../../telemetry/command-telemetry.ts"; import { branchesUnpause } from "./unpause.handler.ts"; import { withTargetPolicy } from "../../../command-internal/target/target-policy.ts"; import { projectTargetFlags } from "../../../command-internal/target/target-flags.ts"; -import { branchesParentTarget, branchesSafeFlags } from "../branches.target.ts"; +import { projectRefSafeFlags } from "../../../command-internal/target/project-ref-safe-flags.ts"; +import { branchesParentTarget } from "../branches.target.ts"; const config = { name: Argument.String("name").pipe( @@ -40,7 +41,7 @@ export const branchesUnpauseCommand = Command.make("unpause", config).pipe( ), Command.withHandler((flags) => branchesUnpause(flags).pipe( - withCommandTelemetry({ flags, safeFlags: branchesSafeFlags(flags) }), + withCommandTelemetry({ flags, safeFlags: projectRefSafeFlags(flags) }), withJsonErrorHandling, ), ), diff --git a/apps/cli/src/commands/branches/unpause/unpause.handler.ts b/apps/cli/src/commands/branches/unpause/unpause.handler.ts index 46fc125cd2..cc8697e05c 100644 --- a/apps/cli/src/commands/branches/unpause/unpause.handler.ts +++ b/apps/cli/src/commands/branches/unpause/unpause.handler.ts @@ -6,7 +6,7 @@ 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 { resolveBranchesParent } from "../branches.target.ts"; +import { resolveProjectTarget } from "../../../command-internal/target/resolve-project-target.ts"; import { BranchesUnpauseNetworkError, BranchesUnpauseUnexpectedStatusError, @@ -32,7 +32,10 @@ export const branchesUnpause = Effect.fn("branches.unpause")(function* ( // `branches` is parent-scoped: after `supabase link <branch>`, `supabase/.temp/project-ref` // holds the branch's own ref, which the platform 403s on for every branches-management endpoint. - const ref = yield* resolveBranchesParent({ remote: flags.remote, projectRef: flags.projectRef }); + const ref = yield* resolveProjectTarget( + { remote: flags.remote, projectRef: flags.projectRef }, + { parent: true }, + ); yield* Effect.annotateCurrentSpan("project.ref", ref); yield* Effect.gen(function* () { diff --git a/apps/cli/src/commands/branches/update/update.command.ts b/apps/cli/src/commands/branches/update/update.command.ts index 729c6d2ce3..56010afcc5 100644 --- a/apps/cli/src/commands/branches/update/update.command.ts +++ b/apps/cli/src/commands/branches/update/update.command.ts @@ -14,7 +14,8 @@ import { withCommandTelemetry } from "../../../telemetry/command-telemetry.ts"; import { branchesUpdate } from "./update.handler.ts"; import { withTargetPolicy } from "../../../command-internal/target/target-policy.ts"; import { projectTargetFlags } from "../../../command-internal/target/target-flags.ts"; -import { branchesParentTarget, branchesSafeFlags } from "../branches.target.ts"; +import { projectRefSafeFlags } from "../../../command-internal/target/project-ref-safe-flags.ts"; +import { branchesParentTarget } from "../branches.target.ts"; const BRANCH_STATUSES = [ "RUNNING_MIGRATIONS", @@ -72,7 +73,7 @@ export const branchesUpdateCommand = Command.make("update", config).pipe( ), Command.withHandler((flags) => branchesUpdate(flags).pipe( - withCommandTelemetry({ flags, safeFlags: branchesSafeFlags(flags), config }), + withCommandTelemetry({ flags, safeFlags: projectRefSafeFlags(flags), config }), withJsonErrorHandling, ), ), diff --git a/apps/cli/src/commands/branches/update/update.handler.ts b/apps/cli/src/commands/branches/update/update.handler.ts index caa21755aa..602a32106f 100644 --- a/apps/cli/src/commands/branches/update/update.handler.ts +++ b/apps/cli/src/commands/branches/update/update.handler.ts @@ -13,7 +13,7 @@ import { } 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 { resolveBranchesParent } from "../branches.target.ts"; +import { resolveProjectTarget } from "../../../command-internal/target/resolve-project-target.ts"; import { gateMapError } from "../../../command-internal/upgrade-suggest.ts"; import { BRANCH_RESPONSE_SHAPE } from "../branches.response-shape.ts"; import { @@ -43,7 +43,10 @@ export const branchesUpdate = Effect.fn("branches.update")(function* (flags: Bra // `branches` is parent-scoped: after `supabase link <branch>`, `supabase/.temp/project-ref` // holds the branch's own ref, which the platform 403s on for every branches-management endpoint. - const ref = yield* resolveBranchesParent({ remote: flags.remote, projectRef: flags.projectRef }); + const ref = yield* resolveProjectTarget( + { remote: flags.remote, projectRef: flags.projectRef }, + { parent: true }, + ); yield* Effect.annotateCurrentSpan("project.ref", ref); yield* Effect.gen(function* () { diff --git a/apps/cli/src/commands/config/diff/diff.command.ts b/apps/cli/src/commands/config/diff/diff.command.ts index 6bda3f2859..e71c562ed2 100644 --- a/apps/cli/src/commands/config/diff/diff.command.ts +++ b/apps/cli/src/commands/config/diff/diff.command.ts @@ -1,10 +1,9 @@ -import { Option } from "effect"; import type * as CliCommand from "effect/cli/Command"; import { Command, Flag } from "effect/cli"; import { withCliConfigFlags } from "../../../config/cli-config-flags.ts"; import { cliConfigValuesLayer } from "../../../config/cli-config-values.layer.ts"; -import { PROJECT_REF_PATTERN } from "../../../config/project-ref.service.ts"; +import { projectRefSafeFlags } from "../../../command-internal/target/project-ref-safe-flags.ts"; import { withPermissions } from "../../../command-internal/command-permissions/command-permissions.annotation.ts"; import { compose } from "../../../command-internal/command-permissions/model.ts"; import { configTarget } from "../../../command-internal/project-target.ts"; @@ -37,10 +36,7 @@ export const configDiffHandler = (flags: ConfigDiffFlags) => // actually ref-shaped — a user-created branch name must never reach PostHog. withCommandTelemetry({ flags, - safeFlags: - Option.isSome(flags.projectRef) && PROJECT_REF_PATTERN.test(flags.projectRef.value) - ? ["project-ref"] - : [], + safeFlags: projectRefSafeFlags(flags), // This command rejects every `-o/--output` value itself with a message pointing at // `--output-format`, so the wrapper must allow the full choice set through — otherwise its // own generic enum-check message would fire first and the handler would never run. diff --git a/apps/cli/src/commands/config/diff/diff.handler.ts b/apps/cli/src/commands/config/diff/diff.handler.ts index 6aecb81594..daf6d47741 100644 --- a/apps/cli/src/commands/config/diff/diff.handler.ts +++ b/apps/cli/src/commands/config/diff/diff.handler.ts @@ -22,9 +22,9 @@ import { } from "../../../command-internal/http-errors.ts"; import { configTargetErrorsFor, - requestedConfigTarget, resolveConfigTarget, } from "../../../command-internal/project-target.ts"; +import { resolveProjectTarget } from "../../../command-internal/target/resolve-project-target.ts"; import { configIsRecord } from "../config.paths.ts"; import { loadResolvedConfig, @@ -113,14 +113,12 @@ export const configDiff = Effect.fn("config.diff")(function* (flags: ConfigDiffF ); // See resolveConfigTarget's doc comment for the target-resolution rules this preserves. - const requested = yield* requestedConfigTarget({ - remote: flags.remote, - projectRef: flags.projectRef, - }); - const { ref, branch } = yield* resolveConfigTarget( - requested, - configTargetErrors, - mapBranchResolveError, + const { ref, branch } = yield* resolveProjectTarget( + { remote: flags.remote, projectRef: flags.projectRef }, + { + resolveRequested: (requested) => + resolveConfigTarget(requested, configTargetErrors, mapBranchResolveError), + }, ); yield* Ref.set(resolvedRef, Option.some(ref)); yield* Effect.annotateCurrentSpan({ diff --git a/apps/cli/src/commands/config/pull/pull.command.ts b/apps/cli/src/commands/config/pull/pull.command.ts index f014a79477..17a0815ff5 100644 --- a/apps/cli/src/commands/config/pull/pull.command.ts +++ b/apps/cli/src/commands/config/pull/pull.command.ts @@ -1,10 +1,10 @@ -import { Layer, Option } from "effect"; +import { Layer } from "effect"; import type * as CliCommand from "effect/cli/Command"; import { Command, Flag } from "effect/cli"; import { withCliConfigFlags } from "../../../config/cli-config-flags.ts"; import { cliConfigValuesLayer } from "../../../config/cli-config-values.layer.ts"; -import { PROJECT_REF_PATTERN } from "../../../config/project-ref.service.ts"; +import { projectRefSafeFlags } from "../../../command-internal/target/project-ref-safe-flags.ts"; import { withPermissions } from "../../../command-internal/command-permissions/command-permissions.annotation.ts"; import { compose } from "../../../command-internal/command-permissions/model.ts"; import { configPullRun } from "../../../command-internal/config-pull-run.ts"; @@ -47,10 +47,7 @@ const configPullHandler = (flags: ConfigPullFlags) => // and is never safe to log. withCommandTelemetry({ flags, - safeFlags: - Option.isSome(flags.projectRef) && PROJECT_REF_PATTERN.test(flags.projectRef.value) - ? ["project-ref"] - : [], + safeFlags: projectRefSafeFlags(flags), // The handler rejects every -o/--output value itself, so the full global choice set is // declared "allowed" here rather than gated by this wrapper. outputFormats: GLOBAL_OUTPUT_FORMATS, diff --git a/apps/cli/src/commands/config/pull/pull.handler.ts b/apps/cli/src/commands/config/pull/pull.handler.ts index 454d1f5e38..b4fc9cf4af 100644 --- a/apps/cli/src/commands/config/pull/pull.handler.ts +++ b/apps/cli/src/commands/config/pull/pull.handler.ts @@ -9,10 +9,10 @@ import { } from "../../../command-internal/http-errors.ts"; import { configTargetErrorsFor, - requestedConfigTarget, resolveConfigTarget, type ConfigTarget, } from "../../../command-internal/project-target.ts"; +import { resolveProjectTarget } from "../../../command-internal/target/resolve-project-target.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"; @@ -256,14 +256,12 @@ export const configPull = Effect.fn("config.pull")(function* (flags: ConfigPullF const source = yield* openConfigPullSource(); // Resolves the pull target via resolveConfigTarget, shared with config diff/config push. - const requested = yield* requestedConfigTarget({ - remote: flags.remote, - projectRef: flags.projectRef, - }); - const { ref, branch } = yield* resolveConfigTarget( - requested, - configTargetErrors, - mapBranchResolveError, + const { ref, branch } = yield* resolveProjectTarget( + { remote: flags.remote, projectRef: flags.projectRef }, + { + resolveRequested: (requested) => + resolveConfigTarget(requested, configTargetErrors, mapBranchResolveError), + }, ); yield* Ref.set(resolvedRef, Option.some(ref)); yield* Effect.annotateCurrentSpan("config.target_is_branch", branch !== undefined); diff --git a/apps/cli/src/commands/config/push/push.command.ts b/apps/cli/src/commands/config/push/push.command.ts index 5b0fea7e7f..1730d01775 100644 --- a/apps/cli/src/commands/config/push/push.command.ts +++ b/apps/cli/src/commands/config/push/push.command.ts @@ -1,9 +1,8 @@ -import { Option } from "effect"; import { Command } from "effect/cli"; import { withCliConfigFlags } from "../../../config/cli-config-flags.ts"; import { cliConfigValuesLayer } from "../../../config/cli-config-values.layer.ts"; -import { PROJECT_REF_PATTERN } from "../../../config/project-ref.service.ts"; +import { projectRefSafeFlags } from "../../../command-internal/target/project-ref-safe-flags.ts"; import type * as CliCommand from "effect/cli/Command"; import { withPermissions } from "../../../command-internal/command-permissions/command-permissions.annotation.ts"; @@ -32,10 +31,7 @@ export const configPushHandler = (flags: ConfigPushFlags) => // a bad paste) must reach PostHog as "<redacted>". withCommandTelemetry({ flags, - safeFlags: - Option.isSome(flags.projectRef) && PROJECT_REF_PATTERN.test(flags.projectRef.value) - ? ["project-ref"] - : [], + safeFlags: projectRefSafeFlags(flags), }), withJsonErrorHandling, ); diff --git a/apps/cli/src/commands/config/push/push.handler.ts b/apps/cli/src/commands/config/push/push.handler.ts index 043e12bf3d..9d9ba65fa6 100644 --- a/apps/cli/src/commands/config/push/push.handler.ts +++ b/apps/cli/src/commands/config/push/push.handler.ts @@ -23,9 +23,9 @@ import { } from "../../../command-internal/http-errors.ts"; import { configTargetErrorsFor, - requestedConfigTarget, resolveConfigTarget, } from "../../../command-internal/project-target.ts"; +import { resolveProjectTarget } from "../../../command-internal/target/resolve-project-target.ts"; import { missingProjectConfigMessageEffect, relativeConfigPath, @@ -252,14 +252,12 @@ export const configPush = Effect.fn("config.push")(function* (flags: ConfigPushF // the resolved config load before its one schema decode, so a base document that's invalid without its // overlay must never be decoded on its own — this can cost a network round trip before a // malformed `config.toml` is caught. - const requestedRef = yield* requestedConfigTarget({ - remote: flags.remote, - projectRef: flags.projectRef, - }); - const { ref, branch } = yield* resolveConfigTarget( - requestedRef, - configTargetErrors, - mapPushBranchResolveError, + const { ref, branch } = yield* resolveProjectTarget( + { remote: flags.remote, projectRef: flags.projectRef }, + { + resolveRequested: (requested) => + resolveConfigTarget(requested, configTargetErrors, mapPushBranchResolveError), + }, ); yield* Ref.set(resolvedRef, Option.some(ref)); yield* Effect.annotateCurrentSpan({ diff --git a/apps/cli/src/commands/functions/delete/delete.command.ts b/apps/cli/src/commands/functions/delete/delete.command.ts index a43bcfde6f..f673abd6bb 100644 --- a/apps/cli/src/commands/functions/delete/delete.command.ts +++ b/apps/cli/src/commands/functions/delete/delete.command.ts @@ -1,12 +1,10 @@ -import { Option } from "effect"; import { Argument, Command } from "effect/cli"; import type * as CliCommand from "effect/cli/Command"; import { withPermissions } from "../../../command-internal/command-permissions/command-permissions.annotation.ts"; import { compose } from "../../../command-internal/command-permissions/model.ts"; -import { PROJECT_REF_PATTERN } from "../../../config/project-ref.service.ts"; +import { projectRefSafeFlags } from "../../../command-internal/target/project-ref-safe-flags.ts"; import { projectRefPicker } from "../../../config/project-ref.layer.ts"; import { linkedProjectCache } from "../../../telemetry/linked-project-cache.layer.ts"; -import { FUNCTIONS_PROJECT_REF_SAFE_FLAGS } from "../../../shared/functions/functions.shared.ts"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; import { managementApiRuntimeLayer } from "../../../command-internal/management-api-runtime.layer.ts"; import { withCommandTelemetry } from "../../../telemetry/command-telemetry.ts"; @@ -30,10 +28,7 @@ export const functionsDeleteHandler = (flags: FunctionsDeleteFlags) => functionsDelete(flags).pipe( withCommandTelemetry({ flags, - safeFlags: - Option.isSome(flags.projectRef) && PROJECT_REF_PATTERN.test(flags.projectRef.value) - ? FUNCTIONS_PROJECT_REF_SAFE_FLAGS - : [], + safeFlags: projectRefSafeFlags(flags), }), withJsonErrorHandling, ); diff --git a/apps/cli/src/commands/functions/deploy/deploy.command.ts b/apps/cli/src/commands/functions/deploy/deploy.command.ts index 64f0a27241..0a8beb18c0 100644 --- a/apps/cli/src/commands/functions/deploy/deploy.command.ts +++ b/apps/cli/src/commands/functions/deploy/deploy.command.ts @@ -1,16 +1,14 @@ -import { Option } from "effect"; import { Argument, 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 } from "../../../command-internal/command-permissions/model.ts"; import { branchResolution } from "../../../command-internal/target/branch-resolution.ts"; import { projectTargetFlags } from "../../../command-internal/target/target-flags.ts"; -import { PROJECT_REF_PATTERN } from "../../../config/project-ref.service.ts"; +import { projectRefSafeFlags } from "../../../command-internal/target/project-ref-safe-flags.ts"; import { projectRefPicker } from "../../../config/project-ref.layer.ts"; import { linkedProjectCache } from "../../../telemetry/linked-project-cache.layer.ts"; 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 { managementApiRuntimeLayer } from "../../../command-internal/management-api-runtime.layer.ts"; import { withCommandTelemetry } from "../../../telemetry/command-telemetry.ts"; @@ -156,10 +154,7 @@ export const functionsDeployCommand = Command.make("deploy", config).pipe( functionsDeploy(flags).pipe( withCommandTelemetry({ flags, - safeFlags: - Option.isSome(flags.projectRef) && PROJECT_REF_PATTERN.test(flags.projectRef.value) - ? FUNCTIONS_PROJECT_REF_SAFE_FLAGS - : [], + safeFlags: projectRefSafeFlags(flags), }), withJsonErrorHandling, ), diff --git a/apps/cli/src/commands/functions/download/download.command.ts b/apps/cli/src/commands/functions/download/download.command.ts index 75bc3ba119..d47d016cd0 100644 --- a/apps/cli/src/commands/functions/download/download.command.ts +++ b/apps/cli/src/commands/functions/download/download.command.ts @@ -5,11 +5,10 @@ import { withPermissions } from "../../../command-internal/command-permissions/c import { compose } from "../../../command-internal/command-permissions/model.ts"; import { withCliConfigFlags } from "../../../config/cli-config-flags.ts"; import { cliConfigValuesLayer } from "../../../config/cli-config-values.layer.ts"; -import { PROJECT_REF_PATTERN } from "../../../config/project-ref.service.ts"; +import { projectRefSafeFlags } from "../../../command-internal/target/project-ref-safe-flags.ts"; import { projectRefPicker } from "../../../config/project-ref.layer.ts"; import { linkedProjectCache } from "../../../telemetry/linked-project-cache.layer.ts"; import { functionsDownload as functionsDownloadCalls } from "../../../shared/functions/download.ts"; -import { FUNCTIONS_PROJECT_REF_SAFE_FLAGS } from "../../../shared/functions/functions.shared.ts"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; import { commandRuntimeLayer } from "../../../shared/runtime/command-runtime.layer.ts"; import { managementApiRuntimeLayer } from "../../../command-internal/management-api-runtime.layer.ts"; @@ -49,11 +48,6 @@ export type FunctionsDownloadFlags = CliCommand.Command.Config.Infer<typeof conf // Exported so integration tests can drive the exact wiring `Command.withHandler` // uses below, instead of re-asserting the generic instrumentation mechanism. -const projectRefSafeFlags = (flags: FunctionsDownloadFlags) => - Option.isSome(flags.projectRef) && PROJECT_REF_PATTERN.test(flags.projectRef.value) - ? FUNCTIONS_PROJECT_REF_SAFE_FLAGS - : []; - export const functionsDownloadHandler = (flags: FunctionsDownloadFlags) => functionsDownload(flags).pipe( withCommandTelemetry({ flags, safeFlags: projectRefSafeFlags(flags) }), diff --git a/apps/cli/src/commands/functions/list/list.command.ts b/apps/cli/src/commands/functions/list/list.command.ts index 4faa7d1668..79603ab28e 100644 --- a/apps/cli/src/commands/functions/list/list.command.ts +++ b/apps/cli/src/commands/functions/list/list.command.ts @@ -1,12 +1,10 @@ -import { Option } from "effect"; import { Command } from "effect/cli"; import type * as CliCommand from "effect/cli/Command"; import { withPermissions } from "../../../command-internal/command-permissions/command-permissions.annotation.ts"; import { compose } from "../../../command-internal/command-permissions/model.ts"; -import { PROJECT_REF_PATTERN } from "../../../config/project-ref.service.ts"; +import { projectRefSafeFlags } from "../../../command-internal/target/project-ref-safe-flags.ts"; import { projectRefPicker } from "../../../config/project-ref.layer.ts"; import { linkedProjectCache } from "../../../telemetry/linked-project-cache.layer.ts"; -import { FUNCTIONS_PROJECT_REF_SAFE_FLAGS } from "../../../shared/functions/functions.shared.ts"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; import { managementApiRuntimeLayer } from "../../../command-internal/management-api-runtime.layer.ts"; import { withCommandTelemetry } from "../../../telemetry/command-telemetry.ts"; @@ -46,10 +44,7 @@ export const functionsListCommand = Command.make("list", config).pipe( functionsList(flags).pipe( withCommandTelemetry({ flags, - safeFlags: - Option.isSome(flags.projectRef) && PROJECT_REF_PATTERN.test(flags.projectRef.value) - ? FUNCTIONS_PROJECT_REF_SAFE_FLAGS - : [], + safeFlags: projectRefSafeFlags(flags), }), withJsonErrorHandling, ), diff --git a/apps/cli/src/commands/gen/types/types.command.ts b/apps/cli/src/commands/gen/types/types.command.ts index 6bc55794d5..a7b371f26f 100644 --- a/apps/cli/src/commands/gen/types/types.command.ts +++ b/apps/cli/src/commands/gen/types/types.command.ts @@ -1,4 +1,3 @@ -import { Option } from "effect"; import { Argument, Command, Flag, Param } from "effect/cli"; import type * as CliCommand from "effect/cli/Command"; import { withPermissions } from "../../../command-internal/command-permissions/command-permissions.annotation.ts"; @@ -38,7 +37,7 @@ import { 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"; +import { projectRefSafeFlags } from "../../../command-internal/target/project-ref-safe-flags.ts"; const config = { ...targetFlags({ subject: "database", dbUrl: true }), @@ -113,6 +112,17 @@ const remoteTarget: ReadonlyArray<EntryCondition> = [ { flag: "postgrest-v9-compat", is: false }, ]; +// Exported so integration tests can drive the exact wiring `Command.withHandler` uses below. +export const genTypesHandler = (flags: CliCommand.Command.Config.Infer<typeof commandConfig>) => + genTypes(flags).pipe( + withCommandTelemetry({ + flags, + safeFlags: projectRefSafeFlags(flags), + config: flagsConfig, + }), + withJsonErrorHandling, + ); + export const genTypesCommand = Command.make("types", commandConfig).pipe( Command.withDescription("Generate types from Postgres schema."), Command.withShortDescription("Generate types from Postgres schema"), @@ -186,23 +196,7 @@ export const genTypesCommand = Command.make("types", commandConfig).pipe( { default: "local" }, ), ), - Command.withHandler((flags) => - genTypes(flags).pipe( - withCommandTelemetry({ - flags, - safeFlags: [ - ...(Option.isSome(flags.projectId) && PROJECT_REF_PATTERN.test(flags.projectId.value) - ? ["project-id"] - : []), - ...(Option.isSome(flags.projectRef) && PROJECT_REF_PATTERN.test(flags.projectRef.value) - ? ["project-ref"] - : []), - ], - config: flagsConfig, - }), - withJsonErrorHandling, - ), - ), + Command.withHandler(genTypesHandler), Command.provide(genTypesRuntimeLayer), Command.provide(cliConfigValuesLayer), withCliConfigFlags(commandConfig), diff --git a/apps/cli/src/commands/link/link.command.ts b/apps/cli/src/commands/link/link.command.ts index a18e647621..496dad6c41 100644 --- a/apps/cli/src/commands/link/link.command.ts +++ b/apps/cli/src/commands/link/link.command.ts @@ -2,7 +2,7 @@ import { Effect, Option } from "effect"; import { Argument, Command, Flag } from "effect/cli"; import type * as CliCommand from "effect/cli/Command"; -import { PROJECT_REF_PATTERN } from "../../config/project-ref.service.ts"; +import { projectRefSafeFlags } from "../../command-internal/target/project-ref-safe-flags.ts"; import { withPermissions } from "../../command-internal/command-permissions/command-permissions.annotation.ts"; import { compose, withContext } from "../../command-internal/command-permissions/model.ts"; import { linkServices } from "../../command-internal/link-services-core.ts"; @@ -51,10 +51,7 @@ export const linkHandler = (flags: LinkFlags) => // branch name, which is user data. `--skip-pooler` is always safe; `--password` stays redacted. withCommandTelemetry({ flags, - safeFlags: - Option.isSome(flags.projectRef) && PROJECT_REF_PATTERN.test(flags.projectRef.value) - ? ["project-ref"] - : [], + safeFlags: projectRefSafeFlags(flags), }), withJsonErrorHandling, ); diff --git a/apps/cli/src/commands/notebooks/pull/pull.command.ts b/apps/cli/src/commands/notebooks/pull/pull.command.ts index 44a9581dd3..40656e067a 100644 --- a/apps/cli/src/commands/notebooks/pull/pull.command.ts +++ b/apps/cli/src/commands/notebooks/pull/pull.command.ts @@ -30,7 +30,7 @@ export type NotebooksPullFlags = CliCommand.Command.Config.Infer<typeof config>; // uses below, instead of re-asserting the generic instrumentation mechanism. export const notebooksPullHandler = (flags: NotebooksPullFlags) => notebooksPull(flags).pipe( - withCommandTelemetry({ flags, safeFlags: projectRefSafeFlags(flags.projectRef) }), + withCommandTelemetry({ flags, safeFlags: projectRefSafeFlags(flags) }), withJsonErrorHandling, ); diff --git a/apps/cli/src/commands/notebooks/push/push.command.ts b/apps/cli/src/commands/notebooks/push/push.command.ts index 45f0ca5b47..2b04d0ec4c 100644 --- a/apps/cli/src/commands/notebooks/push/push.command.ts +++ b/apps/cli/src/commands/notebooks/push/push.command.ts @@ -28,7 +28,7 @@ export type NotebooksPushFlags = CliCommand.Command.Config.Infer<typeof config>; // uses below, instead of re-asserting the generic instrumentation mechanism. export const notebooksPushHandler = (flags: NotebooksPushFlags) => notebooksPush(flags).pipe( - withCommandTelemetry({ flags, safeFlags: projectRefSafeFlags(flags.projectRef) }), + withCommandTelemetry({ flags, safeFlags: projectRefSafeFlags(flags) }), withJsonErrorHandling, ); 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 dd8a68e46b..05ff76b4af 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 @@ -59,7 +59,7 @@ export const projectsApiKeysCommand = Command.make("api-keys", config).pipe( // `reveal` is intentionally not in `safeFlags`: it is a boolean flag, and // boolean values are always logged verbatim by the instrumentation. Only // string flags that are telemetry-safe belong in `safeFlags`. - withCommandTelemetry({ flags, safeFlags: projectRefSafeFlags(flags.projectRef) }), + withCommandTelemetry({ flags, safeFlags: projectRefSafeFlags(flags) }), withJsonErrorHandling, ), ), diff --git a/apps/cli/src/commands/pull/pull.command.ts b/apps/cli/src/commands/pull/pull.command.ts index 724b682140..d7d21d5678 100644 --- a/apps/cli/src/commands/pull/pull.command.ts +++ b/apps/cli/src/commands/pull/pull.command.ts @@ -1,4 +1,3 @@ -import { Option } from "effect"; import type * as CliCommand from "effect/cli/Command"; import { Command, Flag } from "effect/cli"; @@ -10,7 +9,7 @@ import { dbConfigPoolerFallbackLinked, } from "../../command-internal/db-config.layer.ts"; import { configTarget } from "../../command-internal/project-target.ts"; -import { PROJECT_REF_PATTERN } from "../../config/project-ref.service.ts"; +import { projectRefSafeFlags } from "../../command-internal/target/project-ref-safe-flags.ts"; import { functionsDownload } from "../../shared/functions/download.ts"; import { linkedProjectCache } from "../../telemetry/linked-project-cache.layer.ts"; import { withCliConfigFlags } from "../../config/cli-config-flags.ts"; @@ -64,10 +63,7 @@ export const pullHandler = (flags: PullFlags) => // `--remote-label` is a free-form, user-chosen string and is never safe to log verbatim. withCommandTelemetry({ flags, - safeFlags: - Option.isSome(flags.projectRef) && PROJECT_REF_PATTERN.test(flags.projectRef.value) - ? ["project-ref"] - : [], + safeFlags: projectRefSafeFlags(flags), // The handler rejects every `-o`/`--output` value with a message pointing at // `--output-format`, so the full global choice set is declared "allowed" here. outputFormats: GLOBAL_OUTPUT_FORMATS, diff --git a/apps/cli/src/commands/pull/pull.handler.ts b/apps/cli/src/commands/pull/pull.handler.ts index afc1331cc3..209a111186 100644 --- a/apps/cli/src/commands/pull/pull.handler.ts +++ b/apps/cli/src/commands/pull/pull.handler.ts @@ -16,9 +16,9 @@ import { LinkedProjectCache } from "../../telemetry/linked-project-cache.service import { TelemetryState } from "../../telemetry/telemetry-state.service.ts"; import { configTargetErrorsFor, - requestedConfigTarget, resolveConfigTarget, } from "../../command-internal/project-target.ts"; +import { resolveProjectTarget } from "../../command-internal/target/resolve-project-target.ts"; import { unexpectedStatusMessage } from "../../command-internal/http-errors.ts"; import { configPullPayload, @@ -227,11 +227,13 @@ export const pull = Effect.fn("pull")(function* (flags: PullFlags) { // instead of re-resolving (ADR 0024). const { ref, branch, linked, runPlan, source } = yield* Effect.gen(function* () { const pullSource = yield* openConfigPullSource(); - const requested = yield* requestedConfigTarget({ - remote: flags.remote, - projectRef: flags.projectRef, - }); - const target = yield* resolveConfigTarget(requested, pullTargetErrors, mapBranchResolveError); + const target = yield* resolveProjectTarget( + { remote: flags.remote, projectRef: flags.projectRef }, + { + resolveRequested: (requested) => + resolveConfigTarget(requested, pullTargetErrors, mapBranchResolveError), + }, + ); yield* Ref.set(resolvedRef, Option.some(target.ref)); // `planConfigPullRun` does everything up through the schema-validation gate: no git diff --git a/apps/cli/src/commands/sso/add/add.command.ts b/apps/cli/src/commands/sso/add/add.command.ts index be67367bb3..1642c9d2aa 100644 --- a/apps/cli/src/commands/sso/add/add.command.ts +++ b/apps/cli/src/commands/sso/add/add.command.ts @@ -100,7 +100,7 @@ export const ssoAddCommand = Command.make("add", config).pipe( ssoAdd(flags).pipe( withCommandTelemetry({ flags, - safeFlags: projectRefSafeFlags(flags.projectRef), + safeFlags: projectRefSafeFlags(flags), config, // Maps the `-t` alias back to `type` so telemetry records it as changed. aliases: { t: "type" }, diff --git a/apps/cli/src/commands/sso/info/info.command.ts b/apps/cli/src/commands/sso/info/info.command.ts index e5f47c31ed..72f7b50141 100644 --- a/apps/cli/src/commands/sso/info/info.command.ts +++ b/apps/cli/src/commands/sso/info/info.command.ts @@ -35,7 +35,7 @@ export const ssoInfoCommand = Command.make("info", config).pipe( ), Command.withHandler((flags) => ssoInfo(flags).pipe( - withCommandTelemetry({ flags, safeFlags: projectRefSafeFlags(flags.projectRef) }), + withCommandTelemetry({ flags, safeFlags: projectRefSafeFlags(flags) }), withJsonErrorHandling, ), ), diff --git a/apps/cli/src/commands/sso/list/list.command.ts b/apps/cli/src/commands/sso/list/list.command.ts index 6b2e08703f..dc533f9f77 100644 --- a/apps/cli/src/commands/sso/list/list.command.ts +++ b/apps/cli/src/commands/sso/list/list.command.ts @@ -40,7 +40,7 @@ export const ssoListCommand = Command.make("list", config).pipe( ), Command.withHandler((flags) => ssoList(flags).pipe( - withCommandTelemetry({ flags, safeFlags: projectRefSafeFlags(flags.projectRef) }), + withCommandTelemetry({ flags, safeFlags: projectRefSafeFlags(flags) }), withJsonErrorHandling, ), ), diff --git a/apps/cli/src/commands/sso/remove/remove.command.ts b/apps/cli/src/commands/sso/remove/remove.command.ts index e963a42418..27cf99c978 100644 --- a/apps/cli/src/commands/sso/remove/remove.command.ts +++ b/apps/cli/src/commands/sso/remove/remove.command.ts @@ -46,7 +46,7 @@ export const ssoRemoveCommand = Command.make("remove", config).pipe( ), Command.withHandler((flags) => ssoRemove(flags).pipe( - withCommandTelemetry({ flags, safeFlags: projectRefSafeFlags(flags.projectRef) }), + withCommandTelemetry({ flags, safeFlags: projectRefSafeFlags(flags) }), withJsonErrorHandling, ), ), diff --git a/apps/cli/src/commands/sso/show/show.command.ts b/apps/cli/src/commands/sso/show/show.command.ts index dd2e5b2ed6..92e8711bc6 100644 --- a/apps/cli/src/commands/sso/show/show.command.ts +++ b/apps/cli/src/commands/sso/show/show.command.ts @@ -51,7 +51,7 @@ export const ssoShowCommand = Command.make("show", config).pipe( ), Command.withHandler((flags) => ssoShow(flags).pipe( - withCommandTelemetry({ flags, safeFlags: projectRefSafeFlags(flags.projectRef) }), + withCommandTelemetry({ flags, safeFlags: projectRefSafeFlags(flags) }), withJsonErrorHandling, ), ), diff --git a/apps/cli/src/commands/sso/update/update.command.ts b/apps/cli/src/commands/sso/update/update.command.ts index dca213b9cc..f17b0f260d 100644 --- a/apps/cli/src/commands/sso/update/update.command.ts +++ b/apps/cli/src/commands/sso/update/update.command.ts @@ -115,7 +115,7 @@ export const ssoUpdateCommand = Command.make("update", config).pipe( ), Command.withHandler((flags) => ssoUpdate(flags).pipe( - withCommandTelemetry({ flags, safeFlags: projectRefSafeFlags(flags.projectRef), config }), + withCommandTelemetry({ flags, safeFlags: projectRefSafeFlags(flags), config }), withJsonErrorHandling, ), ), diff --git a/apps/cli/src/shared/functions/functions.shared.ts b/apps/cli/src/shared/functions/functions.shared.ts index a7d8b65247..34393a20f5 100644 --- a/apps/cli/src/shared/functions/functions.shared.ts +++ b/apps/cli/src/shared/functions/functions.shared.ts @@ -11,8 +11,6 @@ export function validateFunctionSlugMessage(slug: string): string | undefined { return functionSlugPattern.test(slug) ? undefined : invalidFunctionSlugDetail; } -export const FUNCTIONS_PROJECT_REF_SAFE_FLAGS = ["project-ref"] as const; - // Order is rendered verbatim in the mutually-exclusive-flags error message. export const FUNCTIONS_DEPLOY_BUNDLER_MUTEX_GROUP = [ "use-api", diff --git a/apps/cli/src/telemetry/command-telemetry.ts b/apps/cli/src/telemetry/command-telemetry.ts index bebfce1358..812c2293a2 100644 --- a/apps/cli/src/telemetry/command-telemetry.ts +++ b/apps/cli/src/telemetry/command-telemetry.ts @@ -48,7 +48,11 @@ import { } from "../shared/cli/value-consuming-flags.ts"; import { unwrapToSingleParam } from "../command-internal/param-introspection.ts"; import { StackBackendContext } from "../command-internal/stack-backend.ts"; -import { isRemoteValue, type RemoteValue } from "../command-internal/target/remote-value.ts"; +import { + isRemoteValue, + REMOTE_FLAG_NAME, + type RemoteValue, +} from "../command-internal/target/remote-value.ts"; import { CommandTelemetryAttributes, type CommandTelemetryAttributeValues, @@ -120,8 +124,6 @@ const REDACTED_VALUE = "<redacted>"; const MACHINE_OUTPUT_FLAG_FORMATS = new Set(["env", "json", "toml", "yaml", "csv"]); const OUTPUT_FLAG_FORMATS = new Set([...MACHINE_OUTPUT_FLAG_FORMATS, "pretty"]); -const REMOTE_FLAG_NAME = "remote"; - // A project ref is telemetry-safe; a branch name or database URL is user data. function remoteTelemetryValue(remote: RemoteValue): string | boolean { switch (remote._tag) { From 35f164f7f4bd54ed9d742426ac48ab7b82d1bc75 Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Mon, 19 Oct 2026 13:04:16 +0100 Subject: [PATCH 136/291] fix(cli): recognise hidden aliases and --remote when suggesting flag placement Value-taking classification now reads every declared flag, including hidden deprecated aliases such as `--project-ref` and `--db-url`, and treats `--remote` as taking a value only through `=`. --- apps/cli/src/shared/cli/run.unit.test.ts | 4 +- .../shared/cli/subcommand-flag-suggestions.ts | 76 +++++++++++---- .../subcommand-flag-suggestions.unit.test.ts | 96 +++++++++++++++---- 3 files changed, 133 insertions(+), 43 deletions(-) diff --git a/apps/cli/src/shared/cli/run.unit.test.ts b/apps/cli/src/shared/cli/run.unit.test.ts index e1b8850906..79514ea6bf 100644 --- a/apps/cli/src/shared/cli/run.unit.test.ts +++ b/apps/cli/src/shared/cli/run.unit.test.ts @@ -248,7 +248,7 @@ describe("classifyParseErrorConsoleOutput", () => { expect( classifyParseErrorConsoleOutput(cause, { rootCommand: testRoot, - args: ["sso", "add", "--metadata-url", "--type"], + args: ["sso", "add", "--project-ref", "--type"], }), ).toBe("drop"); }); @@ -278,7 +278,7 @@ describe("classifyParseErrorConsoleOutput", () => { expect( classifyParseErrorConsoleOutput(cause, { rootCommand: testRoot, - args: ["sso", "add", "--metadata-url", "--", "--type"], + args: ["sso", "add", "--project-ref", "--", "--type"], }), ).toBe("flush-help-doc-to-stderr"); }); diff --git a/apps/cli/src/shared/cli/subcommand-flag-suggestions.ts b/apps/cli/src/shared/cli/subcommand-flag-suggestions.ts index d55364353e..f3e8f64da3 100644 --- a/apps/cli/src/shared/cli/subcommand-flag-suggestions.ts +++ b/apps/cli/src/shared/cli/subcommand-flag-suggestions.ts @@ -1,5 +1,8 @@ import { Predicate } from "effect"; -import type { CliError, Command, HelpDoc } from "effect/cli"; +import type { CliError, Command, HelpDoc, Param } from "effect/cli"; +import { unwrapToSingleParam } from "../../command-internal/param-introspection.ts"; +import { REMOTE_FLAG_NAME } from "../../command-internal/target/remote-value.ts"; +import { commandInternals } from "../../docs/docs-introspection.ts"; import { formatInvalidValueMessage } from "./invalid-value-message.ts"; export interface CliErrorSuggestionContext { @@ -27,10 +30,19 @@ interface CommandWithHelpDoc extends Command.Command.Any { readonly buildHelpDoc: (path: ReadonlyArray<string>) => HelpDoc.HelpDoc; } +/** `"optional-value"` is a flag that is a switch bare and takes its value only through `=` (`--remote`). */ +type FlagArity = "switch" | "value" | "optional-value"; + +interface CommandFlag { + readonly name: string; + readonly aliases: ReadonlyArray<string>; + readonly arity: FlagArity; +} + interface MatchingCommand { readonly command: Command.Command.Any; readonly commandPath: ReadonlyArray<string>; - readonly flag: HelpDoc.FlagDoc; + readonly flag: CommandFlag; } function hasHelpDoc(command: Command.Command.Any): command is CommandWithHelpDoc { @@ -44,6 +56,26 @@ export function helpDocFor( return hasHelpDoc(command) ? command.buildHelpDoc(commandPath) : undefined; } +const flagArityOf = (single: Param.Single<Param.ParamKind, unknown>): FlagArity => { + if (single.primitiveType._tag !== "Boolean") return "value"; + return single.name === REMOTE_FLAG_NAME ? "optional-value" : "switch"; +}; + +/** Every flag the command parses, hidden aliases included: parsing consumes their values like any other flag's. */ +function commandFlagsFor(command: Command.Command.Any): ReadonlyArray<CommandFlag> { + return commandInternals(command).config.flags.flatMap((param) => { + const single = unwrapToSingleParam(param); + if (single === undefined) return []; + return [ + { + name: single.name, + aliases: single.aliases.map((alias) => (alias.length === 1 ? `-${alias}` : `--${alias}`)), + arity: flagArityOf(single), + }, + ]; + }); +} + function findCommand( root: Command.Command.Any, pathWithoutRoot: ReadonlyArray<string>, @@ -100,11 +132,8 @@ function collectDescendants( if (child.unlisted) continue; const childPath = [...path, child.name]; - const helpDoc = helpDocFor(child, childPath); - if (helpDoc) { - for (const flag of helpDoc.flags) { - matches.push({ command: child, commandPath: childPath, flag }); - } + for (const flag of commandFlagsFor(child)) { + matches.push({ command: child, commandPath: childPath, flag }); } visit(child, childPath); } @@ -126,10 +155,10 @@ function normalizeOption(option: string): string { return withoutValue; } -function flagMatchesOption(flag: HelpDoc.FlagDoc, option: string): boolean { +function flagMatchesOption(flag: CommandFlag, option: string): boolean { const optionName = normalizeOption(option); if (flag.name === optionName) return true; - if (flag.type === "boolean" && optionName === `no-${flag.name}`) return true; + if (flag.arity !== "value" && optionName === `no-${flag.name}`) return true; return flag.aliases.includes(option); } @@ -146,16 +175,15 @@ export function flagAliasesFor( ): ReadonlyArray<string> { const command = findCommand(rootCommand, commandPath.slice(1)); if (!command) return []; - const flag = helpDocFor(command, commandPath)?.flags.find( - (candidate) => candidate.name === option, - ); + const flag = commandFlagsFor(command).find((candidate) => candidate.name === option); return flag?.aliases ?? []; } /** * Builds a lookup for whether an argv token (a canonical `--name` or an alias) at `commandPath` - * belongs to a value-taking flag (any flag whose type isn't `"boolean"`), answering `false` for - * every token when `commandPath` doesn't resolve to a real command. Used by `run.ts`'s + * belongs to a flag that takes the next token as its value, hidden deprecated aliases included + * and `--remote` excluded (its value comes only through `=`). Answers `false` for every token + * when `commandPath` doesn't resolve to a real command. Used by `run.ts`'s * `isMissingFlagTokenPresent` to detect when the token would have been consumed as a preceding * flag's value instead of treating it as its own occurrence. */ @@ -164,11 +192,10 @@ export function isValueTakingFlagTokenFor( commandPath: ReadonlyArray<string>, ): (token: string) => boolean { const command = findCommand(rootCommand, commandPath.slice(1)); - const flags = command && helpDocFor(command, commandPath)?.flags; - if (!flags) return () => false; + if (!command) return () => false; const valueTakingTokens = new Set<string>(); - for (const flag of flags) { - if (flag.type === "boolean") continue; + for (const flag of commandFlagsFor(command)) { + if (flag.arity !== "value") continue; valueTakingTokens.add(`--${flag.name}`); for (const alias of flag.aliases) valueTakingTokens.add(alias); } @@ -218,9 +245,16 @@ function formatCommandList(matches: ReadonlyArray<MatchingCommand>): string { return `${commands.slice(0, -1).join(", ")}, and ${commands[commands.length - 1]}`; } -function formatFlagUsage(option: string, flag: HelpDoc.FlagDoc): string { +function formatFlagUsage(option: string, flag: CommandFlag): string { const flagToken = optionToken(option); - return flag.type === "boolean" ? flagToken : `${flagToken} <value>`; + switch (flag.arity) { + case "switch": + return flagToken; + case "optional-value": + return `${flagToken}[=<value>]`; + case "value": + return `${flagToken} <value>`; + } } function findValueAfterOption(args: ReadonlyArray<string>, option: string): string | undefined { @@ -263,7 +297,7 @@ function buildSubcommandFlagHint( ? `, for example:\n ${attempted.commandPath.join(" ")} ${formatFlagUsage(error.option, attempted.flag)}` : "."; const consumedValue = - attempted && attempted.flag.type !== "boolean" + attempted && attempted.flag.arity === "value" ? findValueAfterOption(context.args, error.option) : undefined; diff --git a/apps/cli/src/shared/cli/subcommand-flag-suggestions.unit.test.ts b/apps/cli/src/shared/cli/subcommand-flag-suggestions.unit.test.ts index fb73008c8f..87ab3be84e 100644 --- a/apps/cli/src/shared/cli/subcommand-flag-suggestions.unit.test.ts +++ b/apps/cli/src/shared/cli/subcommand-flag-suggestions.unit.test.ts @@ -2,11 +2,10 @@ import { CliError, Command } from "effect/cli"; import { describe, expect, it } from "vitest"; import { branchesCommand } from "../../commands/branches/branches.command.ts"; import { networkRestrictionsCommand } from "../../commands/network-restrictions/network-restrictions.command.ts"; -import { ssoCommand } from "../../commands/sso/sso.command.ts"; import { formatCliErrorsForDisplay } from "./subcommand-flag-suggestions.ts"; const testRoot = Command.make("supabase").pipe( - Command.withSubcommands([branchesCommand, networkRestrictionsCommand, ssoCommand]), + Command.withSubcommands([branchesCommand, networkRestrictionsCommand]), ); describe("subcommand flag placement suggestions", () => { @@ -14,32 +13,38 @@ describe("subcommand flag placement suggestions", () => { const errors = formatCliErrorsForDisplay( [ new CliError.UnrecognizedOption({ - option: "--attribute-mapping-file", - command: ["supabase", "sso"], + option: "--project-ref", + command: ["supabase", "network-restrictions"], suggestions: [], }), new CliError.UnknownSubcommand({ - subcommand: "mapping.json", - parent: ["supabase", "sso"], + subcommand: "jacraenyzrorgjhsdvvf", + parent: ["supabase", "network-restrictions"], suggestions: [], }), ], { rootCommand: testRoot, - args: ["sso", "--attribute-mapping-file", "mapping.json", "add", "--experimental"], + args: [ + "network-restrictions", + "--project-ref", + "jacraenyzrorgjhsdvvf", + "get", + "--experimental", + ], }, ); expect(errors.changed).toBe(true); expect(errors.errors).toHaveLength(1); expect(errors.errors[0]?.message).toContain( - "Unrecognized flag: --attribute-mapping-file in command supabase sso", + "Unrecognized flag: --project-ref in command supabase network-restrictions", ); expect(errors.errors[0]?.message).toContain( - "Hint: --attribute-mapping-file is available on `supabase sso add` and `supabase sso update`.", + "Hint: --project-ref is available on `supabase network-restrictions get` and `supabase network-restrictions update`.", ); expect(errors.errors[0]?.message).toContain( - "supabase sso add --attribute-mapping-file <value>", + "supabase network-restrictions get --project-ref <value>", ); expect(errors.errors[0]?.message).not.toContain("Unknown subcommand"); }); @@ -71,14 +76,19 @@ describe("subcommand flag placement suggestions", () => { const errors = formatCliErrorsForDisplay( [ new CliError.UnrecognizedOption({ - option: "--remote=abcdefghijklmnopqrst", + option: "--project-ref", command: ["supabase", "branches"], suggestions: [], }), + new CliError.UnknownSubcommand({ + subcommand: "abcdefghijklmnopqrst", + parent: ["supabase", "branches"], + suggestions: [], + }), ], { rootCommand: testRoot, - args: ["branches", "--remote=abcdefghijklmnopqrst", "get"], + args: ["branches", "--project-ref", "abcdefghijklmnopqrst", "get"], }, ); @@ -88,31 +98,77 @@ describe("subcommand flag placement suggestions", () => { expect(errors.errors[0]?.message).not.toContain("branches disable"); }); - it("normalizes assigned flags in placement examples", () => { + it("shows --remote as taking its value only through =", () => { const errors = formatCliErrorsForDisplay( [ new CliError.UnrecognizedOption({ - option: "--attribute-mapping-file=mapping.json", - command: ["supabase", "sso"], + option: "--remote", + command: ["supabase", "network-restrictions"], suggestions: [], }), ], { rootCommand: testRoot, - args: ["sso", "--attribute-mapping-file=mapping.json", "add"], + args: ["network-restrictions", "--remote", "get"], }, ); expect(errors.changed).toBe(true); expect(errors.errors[0]?.message).toContain( - "Hint: --attribute-mapping-file is available on `supabase sso add` and `supabase sso update`.", + "Hint: --remote is available on `supabase network-restrictions get` and `supabase network-restrictions update`.", + ); + expect(errors.errors[0]?.message).toContain( + "supabase network-restrictions get --remote[=<value>]", + ); + }); + + it("keeps the subcommand after a misplaced --remote instead of reading it as the flag's value", () => { + const errors = formatCliErrorsForDisplay( + [ + new CliError.UnrecognizedOption({ + option: "--remote", + command: ["supabase", "network-restrictions"], + suggestions: [], + }), + new CliError.UnknownSubcommand({ + subcommand: "get", + parent: ["supabase", "network-restrictions"], + suggestions: [], + }), + ], + { + rootCommand: testRoot, + args: ["network-restrictions", "--remote", "get"], + }, ); + + expect(errors.errors).toHaveLength(2); + expect(errors.errors[1]?.message).toContain("Unknown subcommand"); + }); + + it("normalizes assigned flags in placement examples", () => { + const errors = formatCliErrorsForDisplay( + [ + new CliError.UnrecognizedOption({ + option: "--project-ref=jacraenyzrorgjhsdvvf", + command: ["supabase", "network-restrictions"], + suggestions: [], + }), + ], + { + rootCommand: testRoot, + args: ["network-restrictions", "--project-ref=jacraenyzrorgjhsdvvf", "get"], + }, + ); + + expect(errors.changed).toBe(true); expect(errors.errors[0]?.message).toContain( - "supabase sso add --attribute-mapping-file <value>", + "Hint: --project-ref is available on `supabase network-restrictions get` and `supabase network-restrictions update`.", ); - expect(errors.errors[0]?.message).not.toContain( - "--attribute-mapping-file=mapping.json <value>", + expect(errors.errors[0]?.message).toContain( + "supabase network-restrictions get --project-ref <value>", ); + expect(errors.errors[0]?.message).not.toContain("--project-ref=jacraenyzrorgjhsdvvf <value>"); }); it("collapses the doubled 'Expected: Expected' prefix for an invalid choice flag value", () => { From 64bb35c1a820612794e4d0ed9da6b27f66b86551 Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Mon, 19 Oct 2026 13:04:22 +0100 Subject: [PATCH 137/291] test(cli): move shared live helpers to --remote --- apps/cli/tests/helpers/branches-live.integration.test.ts | 4 +--- apps/cli/tests/helpers/branches-live.ts | 8 ++++---- apps/cli/tests/helpers/declarative-sync-e2e.ts | 2 +- apps/cli/tests/helpers/live.ts | 7 +++---- apps/cli/tests/helpers/secrets-live.ts | 2 +- 5 files changed, 10 insertions(+), 13 deletions(-) diff --git a/apps/cli/tests/helpers/branches-live.integration.test.ts b/apps/cli/tests/helpers/branches-live.integration.test.ts index 25565991cd..7bde7ef27e 100644 --- a/apps/cli/tests/helpers/branches-live.integration.test.ts +++ b/apps/cli/tests/helpers/branches-live.integration.test.ts @@ -219,9 +219,7 @@ describe("live branch lifecycle helpers", () => { const exit = yield* Effect.exit(createLiveBranchEffect(cli, project, branch.name)); if (!Exit.isFailure(exit)) throw new Error("expected create failure"); expect(String(exit.cause)).toContain("branch quota exceeded"); - expect(state.calls).toContain( - `branches delete ${branch.name} --project-ref ${project.ref} --yes`, - ); + expect(state.calls).toContain(`branches delete ${branch.name} --remote=${project.ref} --yes`); expect(state.listed).toBe(false); }), ); diff --git a/apps/cli/tests/helpers/branches-live.ts b/apps/cli/tests/helpers/branches-live.ts index bb8aebe79c..6190f51e29 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", "--yes"], + ["branches", "create", name, `--remote=${project.ref}`, "--output-format", "json", "--yes"], "branches create", undefined, ); @@ -171,7 +171,7 @@ function getBranchReady<E>( name: string, ): Effect.Effect<true, BranchError<E>, never> { const response: Effect.Effect<BranchCliResult, E, never> = cli( - ["branches", "get", name, "--project-ref", project.ref], + ["branches", "get", name, `--remote=${project.ref}`], { exitTimeoutMs: COMMAND_TIMEOUT, }, @@ -212,7 +212,7 @@ function listBranches<E>( ): Effect.Effect<ReadonlyArray<Schema.Schema.Type<typeof BranchListItem>>, BranchError<E>, never> { return command( cli, - ["branches", "list", "--output", "json", "--project-ref", project.ref], + ["branches", "list", "--output", "json", `--remote=${project.ref}`], phase, COMMAND_TIMEOUT, ).pipe( @@ -251,7 +251,7 @@ function deleteBranch<E>( phase: string, ): Effect.Effect<true, BranchError<E>, never> { const response: Effect.Effect<BranchCliResult, E, never> = cli( - ["branches", "delete", target, "--project-ref", project.ref, "--yes"], + ["branches", "delete", target, `--remote=${project.ref}`, "--yes"], { exitTimeoutMs: COMMAND_TIMEOUT, }, diff --git a/apps/cli/tests/helpers/declarative-sync-e2e.ts b/apps/cli/tests/helpers/declarative-sync-e2e.ts index bc09358ff2..8e91996b20 100644 --- a/apps/cli/tests/helpers/declarative-sync-e2e.ts +++ b/apps/cli/tests/helpers/declarative-sync-e2e.ts @@ -164,7 +164,7 @@ export const convergeInitialSchema = (projectDir: string) => ); requireCliSuccess(sync, "initial declarative sync"); const reset = yield* runSupabaseEffect( - ["db", "reset", "--local", "--no-seed"], + ["db", "reset", "--no-seed"], scenarioCommandOptions(projectDir), ); requireCliSuccess(reset, "db reset setup"); diff --git a/apps/cli/tests/helpers/live.ts b/apps/cli/tests/helpers/live.ts index 25757f42b7..f41a504057 100644 --- a/apps/cli/tests/helpers/live.ts +++ b/apps/cli/tests/helpers/live.ts @@ -158,7 +158,7 @@ export function requireLiveJson( /** Flags every storage live test passes: the suite links the shared project * and the storage command family is experimental-gated. */ -export const storageLiveFlags: ReadonlyArray<string> = ["--linked", "--experimental"]; +export const storageLiveFlags: ReadonlyArray<string> = ["--remote", "--experimental"]; /** * Best-effort exact-object cleanup for storage live tests: removes one owned @@ -181,7 +181,7 @@ export async function removeStorageLiveObject( /** Flags for experimental-gated live tests that address the shared project by * ref rather than linking it (contrast `storageLiveFlags`). */ export function experimentalProjectLiveFlags(project: LiveProject): ReadonlyArray<string> { - return ["--project-ref", project.ref, "--experimental"]; + return [`--remote=${project.ref}`, "--experimental"]; } /** @@ -217,8 +217,7 @@ export async function removeLiveMigration( version, "--status", "reverted", - "--db-url", - project.dbUrl, + `--remote=${project.dbUrl}`, "--yes", ]); requireCliSuccess(reverted, `migration repair cleanup for ${version}`); diff --git a/apps/cli/tests/helpers/secrets-live.ts b/apps/cli/tests/helpers/secrets-live.ts index 55007cf53f..9d38c45afe 100644 --- a/apps/cli/tests/helpers/secrets-live.ts +++ b/apps/cli/tests/helpers/secrets-live.ts @@ -27,7 +27,7 @@ export function requireSuccess(result: LiveRun, label: string) { /** Exact-name cleanup; unsetting an already-removed secret is tolerated. */ export function unsetSecret(cliEffect: LiveCliEffect, name: string, ref: string) { return Effect.gen(function* () { - const cleanup = yield* cliEffect(["secrets", "unset", name, "--project-ref", ref, "--yes"]); + const cleanup = yield* cliEffect(["secrets", "unset", name, `--remote=${ref}`, "--yes"]); if ( cleanup.exitCode !== 0 && !/not found|does not exist/iu.test(`${cleanup.stdout}\n${cleanup.stderr}`) From 4d4366367124008ca4e3e5e5726c7032653adf45 Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Mon, 19 Oct 2026 13:04:23 +0100 Subject: [PATCH 138/291] docs(cli): drop stale target wording and note empty-selector errors Side-effects docs state that an empty `--db-url` or `--project-ref` fails with `TargetEmptyValueError`, and the declarative sync note, release-notes example and CLI agent instructions use the `--remote` forms. --- apps/cli/AGENTS.md | 2 +- apps/cli/src/commands/db/advisors/SIDE_EFFECTS.md | 3 ++- apps/cli/src/commands/db/dump/SIDE_EFFECTS.md | 3 ++- apps/cli/src/commands/db/lint/SIDE_EFFECTS.md | 3 ++- apps/cli/src/commands/db/pull/SIDE_EFFECTS.md | 3 ++- apps/cli/src/commands/db/push/SIDE_EFFECTS.md | 3 ++- apps/cli/src/commands/db/reset/SIDE_EFFECTS.md | 3 ++- .../commands/db/schema/declarative/generate/SIDE_EFFECTS.md | 3 ++- .../src/commands/db/schema/declarative/sync/SIDE_EFFECTS.md | 4 ++-- tools/release/release-notes-prompt.md | 2 +- 10 files changed, 18 insertions(+), 11 deletions(-) diff --git a/apps/cli/AGENTS.md b/apps/cli/AGENTS.md index 73567581ab..002126c535 100644 --- a/apps/cli/AGENTS.md +++ b/apps/cli/AGENTS.md @@ -243,7 +243,7 @@ without one. Declare the target flags only through `targetFlags` in - Never read stdin for consent. JSON, piped, and agent runs need `--yes` or a shell `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`. +- A `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/commands/db/advisors/SIDE_EFFECTS.md b/apps/cli/src/commands/db/advisors/SIDE_EFFECTS.md index c5b1a9bd31..471414ab24 100644 --- a/apps/cli/src/commands/db/advisors/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/db/advisors/SIDE_EFFECTS.md @@ -110,6 +110,7 @@ the process exits non-zero (no error envelope is written over the payload). work and print a deprecation warning on stderr naming the `--remote` replacement (`--local` warns that local is the default). They are mutually exclusive with each other and with `--remote`; a `false` switch counts as absent. `--project-ref` with `--local`, `--db-url` or - `--remote=<value>` is a hard error. + `--remote=<value>` is a hard error, and an empty `--db-url` or `--project-ref` fails with + `TargetEmptyValueError` instead of reading as absent. - 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/dump/SIDE_EFFECTS.md b/apps/cli/src/commands/db/dump/SIDE_EFFECTS.md index febaa15fe2..61c10b0f8a 100644 --- a/apps/cli/src/commands/db/dump/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/db/dump/SIDE_EFFECTS.md @@ -114,7 +114,8 @@ shell inherits the suppressing variables and is missed. work and print a deprecation warning on stderr naming the `--remote` replacement (`--local` warns that local is the default). They are mutually exclusive with each other and with `--remote`; a `false` switch counts as absent. `--project-ref` with `--local`, `--db-url` or - `--remote=<value>` is a hard error. + `--remote=<value>` is a hard error, and an empty `--db-url` or `--project-ref` fails with + `TargetEmptyValueError` instead of reading as absent. - **Container-level pooler fallback.** When a remote 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 853b19af21..84253c1fe7 100644 --- a/apps/cli/src/commands/db/lint/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/db/lint/SIDE_EFFECTS.md @@ -117,5 +117,6 @@ the process exits non-zero (no error envelope is written over the payload). work and print a deprecation warning on stderr naming the `--remote` replacement (`--local` warns that local is the default). They are mutually exclusive with each other and with `--remote`; a `false` switch counts as absent. `--project-ref` with `--local`, `--db-url` or - `--remote=<value>` is a hard error. + `--remote=<value>` is a hard error, and an empty `--db-url` or `--project-ref` fails with + `TargetEmptyValueError` instead of reading as absent. - Telemetry: only the standard `cli_command_executed` event (no custom events). diff --git a/apps/cli/src/commands/db/pull/SIDE_EFFECTS.md b/apps/cli/src/commands/db/pull/SIDE_EFFECTS.md index 1a54d8adce..48faed7607 100644 --- a/apps/cli/src/commands/db/pull/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/db/pull/SIDE_EFFECTS.md @@ -219,7 +219,8 @@ resolved `target` object is merged into the payload. work and print a deprecation warning on stderr naming the `--remote` replacement (`--local` warns that local is the default). They are mutually exclusive with each other and with `--remote`; a `false` switch counts as absent. `--project-ref` with `--local`, `--db-url` or - `--remote=<value>` is a hard error. Unlike `SUPABASE_PROJECT_ID`, `--remote=<ref>` and + `--remote=<value>` is a hard error, and an empty `--db-url` or `--project-ref` fails with + `TargetEmptyValueError` instead of reading as absent. Unlike `SUPABASE_PROJECT_ID`, `--remote=<ref>` and `--project-ref` do not affect the shadow container's project id/labels. - Migration-style pulls always compare migrations with the live target; declarative files and `schema_paths` do not replace that baseline. diff --git a/apps/cli/src/commands/db/push/SIDE_EFFECTS.md b/apps/cli/src/commands/db/push/SIDE_EFFECTS.md index 134b9e3e21..58360beec7 100644 --- a/apps/cli/src/commands/db/push/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/db/push/SIDE_EFFECTS.md @@ -116,7 +116,8 @@ project ref and its environment. work and print a deprecation warning on stderr naming the `--remote` replacement (`--local` warns that local is the default). They are mutually exclusive with each other and with `--remote`; a `false` switch counts as absent. `--project-ref` with `--local`, `--db-url` or - `--remote=<value>` is a hard error. + `--remote=<value>` is a hard error, and an empty `--db-url` or `--project-ref` fails with + `TargetEmptyValueError` instead of reading as absent. - **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 diff --git a/apps/cli/src/commands/db/reset/SIDE_EFFECTS.md b/apps/cli/src/commands/db/reset/SIDE_EFFECTS.md index 588cd2fe56..ebfaafaa3d 100644 --- a/apps/cli/src/commands/db/reset/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/db/reset/SIDE_EFFECTS.md @@ -281,7 +281,8 @@ to those defaults (the usual outcome for an interactive terminal). work and print a deprecation warning on stderr naming the `--remote` replacement (`--local` warns that local is the default). They are mutually exclusive with each other and with `--remote`; a `false` switch counts as absent. `--project-ref` with `--local`, `--db-url` or - `--remote=<value>` is a hard error. Unlike `SUPABASE_PROJECT_ID`, `--remote=<ref>` and + `--remote=<value>` is a hard error, and an empty `--db-url` or `--project-ref` fails with + `TargetEmptyValueError` instead of reading as absent. Unlike `SUPABASE_PROJECT_ID`, `--remote=<ref>` and `--project-ref` do not affect the local container id. - **Target/local split** follows whether the resolved config points at the local stack, not the flag name: a `--remote=<url>` pointing at the local stack is treated 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 de4943bd4e..764317d64b 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 @@ -103,7 +103,8 @@ always go to stderr, in every `--output-format`. On success: work and print a deprecation warning on stderr naming the `--remote` replacement (`--local` warns that local is the default). They are mutually exclusive with each other and with `--remote`; a `false` switch counts as absent. `--project-ref` with `--local`, `--db-url` or - `--remote=<value>` is a hard error. + `--remote=<value>` is a hard error, and an empty `--db-url` or `--project-ref` fails with + `TargetEmptyValueError` instead of reading as absent. - `--reset` recreates the local database from the migrations before exporting; it has no effect on a remote project, branch or `--remote=<url>` target. - Existing declarative files are overwritten only after the export succeeds: without 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 caf5fba70f..af31017904 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 @@ -118,8 +118,8 @@ existing SQL or creates an export manifest. 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. + `supabase db schema declarative generate --remote` (or `--remote=<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. diff --git a/tools/release/release-notes-prompt.md b/tools/release/release-notes-prompt.md index 5bcf2223cb..8d548107c4 100644 --- a/tools/release/release-notes-prompt.md +++ b/tools/release/release-notes-prompt.md @@ -187,7 +187,7 @@ Omit empty sections. | Case | ❌ | ✅ | | --------------------------------------- | ------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------- | -| Feature `feat(db): --linked on db diff` | Added `--linked` flag (#4567) | **`db diff` against your linked project, no Docker** — pass `--linked` to diff remote without a local stack; handy in CI (#4567) | +| Feature `feat(db): --remote on db diff` | Added `--remote` flag (#4567) | **`db diff` against your linked project, no Docker** — pass `--remote` to diff remote without a local stack; handy in CI (#4567) | | Bug + issue | Fixed nil pointer in config parser (#5012) | `supabase start` no longer crashes when optional sections like `[db.pooler]` are missing (#5012) | | 3 PRs, one feature | Three `db lint --json` bullets | **`db lint` machine-readable output** — `--json` for CI; empty array when clean (#4801, #4815, #4823) | | Port only | New native `db diff` implementation | Under **TypeScript port progress** only — `db diff`; behavior unchanged (#5314) | From fea78956ba3a53efd586889d2a348bb2e4245576 Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Mon, 19 Oct 2026 13:04:28 +0100 Subject: [PATCH 139/291] test(cli): share one hosted and one db remote-selection helper The hosted selection matrix (with a bare `--remote` case) and the db helpers live in `tests/helpers/remote-selection.ts` and `hosted-target-selection.ts`; the duplicate hosted and compute helpers are gone. Deprecation and target warnings are read from stderr, and gen types gets telemetry wiring tests. --- .../test-db.integration.test.ts | 2 +- .../config/diff/diff.integration.test.ts | 5 +- .../config/pull/pull.integration.test.ts | 5 +- .../config/push/push.integration.test.ts | 5 +- .../db/advisors/advisors.integration.test.ts | 2 +- .../commands/db/diff/diff.integration.test.ts | 2 +- .../commands/db/dump/dump.integration.test.ts | 2 +- .../commands/db/lint/lint.integration.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 +- .../generate/generate.integration.test.ts | 2 +- .../compute/delete/delete.integration.test.ts | 3 +- .../deploy/deploy.integration.test.ts | 16 +- .../gen/types/types.integration.test.ts | 109 +++++++--- .../db/inspect-query.integration.test.ts | 24 +-- .../inspect/report/report.integration.test.ts | 24 +-- .../migration/down/down.integration.test.ts | 33 +-- .../migration/fetch/fetch.integration.test.ts | 26 +-- .../migration/list/list.integration.test.ts | 29 +-- .../repair/repair.integration.test.ts | 29 +-- .../squash/squash.integration.test.ts | 29 +-- .../migration/up/up.integration.test.ts | 29 +-- .../network-bans/get/get.integration.test.ts | 10 +- .../remove/remove.integration.test.ts | 13 +- .../get/get.integration.test.ts | 10 +- .../update/update.integration.test.ts | 16 +- .../notebooks/notebooks.integration.test.ts | 5 +- .../postgres-config.integration.test.ts | 42 ++-- .../api-keys/api-keys.integration.test.ts | 11 +- .../commands/pull/pull.integration.test.ts | 5 +- .../secrets/list/list.integration.test.ts | 10 +- .../secrets/set/set.integration.test.ts | 18 +- .../secrets/unset/unset.integration.test.ts | 15 +- .../seed/buckets/buckets.integration.test.ts | 9 +- .../download/download.integration.test.ts | 13 +- .../snippets/list/list.integration.test.ts | 10 +- .../get/get.integration.test.ts | 10 +- .../update/update.integration.test.ts | 16 +- .../commands/sso/add/add.integration.test.ts | 12 +- .../sso/info/info.integration.test.ts | 10 +- .../sso/list/list.integration.test.ts | 10 +- .../sso/remove/remove.integration.test.ts | 13 +- .../sso/show/show.integration.test.ts | 15 +- .../sso/update/update.integration.test.ts | 14 +- .../storage/cp/cp.integration.test.ts | 11 +- .../storage/ls/ls.integration.test.ts | 11 +- .../storage/mv/mv.integration.test.ts | 11 +- .../storage/rm/rm.integration.test.ts | 11 +- .../vanity-subdomains.integration.test.ts | 46 ++-- .../tests/helpers/compute-target-selection.ts | 10 +- .../tests/helpers/hosted-remote-selection.ts | 202 ------------------ .../tests/helpers/hosted-target-selection.ts | 118 +++++----- ...emote-selection.ts => remote-selection.ts} | 13 +- 55 files changed, 403 insertions(+), 703 deletions(-) delete mode 100644 apps/cli/tests/helpers/hosted-remote-selection.ts rename apps/cli/tests/helpers/{db-remote-selection.ts => remote-selection.ts} (70%) 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 d8683e41d5..cb354f7050 100644 --- a/apps/cli/src/command-internal/test-db.integration.test.ts +++ b/apps/cli/src/command-internal/test-db.integration.test.ts @@ -32,7 +32,7 @@ import { BRANCH_PROJECT_REF, branchListApi, warningsOf, -} from "../../tests/helpers/db-remote-selection.ts"; +} from "../../tests/helpers/remote-selection.ts"; import { assertPermissionDrift } from "../../tests/helpers/permission-drift.ts"; import { asRecordedRequests, 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 1b2220cea6..1bad927269 100644 --- a/apps/cli/src/commands/config/diff/diff.integration.test.ts +++ b/apps/cli/src/commands/config/diff/diff.integration.test.ts @@ -34,6 +34,7 @@ import { commandRuntimeLayer } from "../../../shared/runtime/command-runtime.lay import { configDiffHandler } from "./diff.command.ts"; import { CONFIG_DIFF_PAYLOAD_VERSION } from "./diff.format.ts"; import { configDiff } from "./diff.handler.ts"; +import { warningsOf } from "../../../../tests/helpers/remote-selection.ts"; const tempRoot = useTempWorkdir("supabase-config-diff-int-"); @@ -699,9 +700,7 @@ describe("config diff integration", () => { }); return Effect.gen(function* () { yield* configDiff({ ...noFlags, projectRef: Option.some("staging") }); - expect( - out.messages.filter((message) => message.type === "warn").map(({ message }) => message), - ).toEqual([ + expect(warningsOf(out)).toEqual([ "--project-ref is deprecated and will be removed in v4. Use --remote instead: supabase config diff --remote=staging", ]); expect(out.stderrText).toContain( 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 e822a6032c..02ae6b2653 100644 --- a/apps/cli/src/commands/config/pull/pull.integration.test.ts +++ b/apps/cli/src/commands/config/pull/pull.integration.test.ts @@ -56,6 +56,7 @@ import { type ConfigPullSource, } from "./pull.handler.ts"; import type { ConfigPullFlags } from "./pull.command.ts"; +import { warningsOf } from "../../../../tests/helpers/remote-selection.ts"; // Setup mirrors ../diff/diff.integration.test.ts: same temp workdir helper, mocked // services, and v2Response() fixture. @@ -1779,9 +1780,7 @@ describe("config pull integration", () => { }); return Effect.gen(function* () { yield* configPull({ ...noFlags, projectRef: Option.some("staging") }); - expect( - out.messages.filter((message) => message.type === "warn").map(({ message }) => message), - ).toEqual([ + expect(warningsOf(out)).toEqual([ "--project-ref is deprecated and will be removed in v4. Use --remote instead: supabase config pull --remote=staging", ]); expect(out.stderrText).toContain(`Pulling config from 'staging' (branch ${BRANCH_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 f8e2f76486..7ebb260894 100644 --- a/apps/cli/src/commands/config/push/push.integration.test.ts +++ b/apps/cli/src/commands/config/push/push.integration.test.ts @@ -56,6 +56,7 @@ import { secretDigestHex } from "./push.secret.ts"; import { configDiff } from "../diff/diff.handler.ts"; import { configPush } from "./push.handler.ts"; import { configPushHandler } from "./push.command.ts"; +import { warningsOf } from "../../../../tests/helpers/remote-selection.ts"; const tempRoot = useTempWorkdir("supabase-config-push-int-"); @@ -3549,9 +3550,7 @@ describe("config push --remote branch name/UUID resolution (CLI-2289)", () => { }); return Effect.gen(function* () { yield* configPush({ remote: Option.none(), projectRef: Option.some("staging") }); - expect( - out.messages.filter((message) => message.type === "warn").map(({ message }) => message), - ).toEqual([ + expect(warningsOf(out)).toEqual([ "--project-ref is deprecated and will be removed in v4. Use --remote instead: supabase config push --remote=staging", ]); expect(out.stderrText).toContain(`Target: project ${REF} · branch staging (${BRANCH_REF})\n`); 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 5e4eaeaa9b..0f862e7fa2 100644 --- a/apps/cli/src/commands/db/advisors/advisors.integration.test.ts +++ b/apps/cli/src/commands/db/advisors/advisors.integration.test.ts @@ -15,7 +15,7 @@ import { BRANCH_PROJECT_REF, branchListApi, warningsOf, -} from "../../../../tests/helpers/db-remote-selection.ts"; +} from "../../../../tests/helpers/remote-selection.ts"; import { mockOutput, mockProcessControl } from "../../../../tests/helpers/mocks.ts"; import { VALID_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 18c9e2e35d..fdfaf25b35 100644 --- a/apps/cli/src/commands/db/diff/diff.integration.test.ts +++ b/apps/cli/src/commands/db/diff/diff.integration.test.ts @@ -32,7 +32,7 @@ import { BRANCH_PROJECT_REF, branchListApi, warningsOf, -} from "../../../../tests/helpers/db-remote-selection.ts"; +} from "../../../../tests/helpers/remote-selection.ts"; import { assertPermissionDrift } from "../../../../tests/helpers/permission-drift.ts"; import { asRecordedRequests, 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 58fdfb7753..5a25d6d137 100644 --- a/apps/cli/src/commands/db/dump/dump.integration.test.ts +++ b/apps/cli/src/commands/db/dump/dump.integration.test.ts @@ -31,7 +31,7 @@ import { BRANCH_PROJECT_REF, branchListApi, warningsOf, -} from "../../../../tests/helpers/db-remote-selection.ts"; +} from "../../../../tests/helpers/remote-selection.ts"; import { mockOutput, mockTty, processEnvLayer } from "../../../../tests/helpers/mocks.ts"; import { unusedGateway } from "../../../../tests/helpers/unused-stack.ts"; import { withReachableDirectHost } from "../../../../tests/helpers/refuse-invalid-db-hosts.ts"; 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 31f6001bb7..0cab06c8f0 100644 --- a/apps/cli/src/commands/db/lint/lint.integration.test.ts +++ b/apps/cli/src/commands/db/lint/lint.integration.test.ts @@ -15,7 +15,7 @@ import { BRANCH_PROJECT_REF, branchListApi, warningsOf, -} from "../../../../tests/helpers/db-remote-selection.ts"; +} from "../../../../tests/helpers/remote-selection.ts"; import { mockOutput, mockProcessControl } from "../../../../tests/helpers/mocks.ts"; import { VALID_REF, 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 9e72cd0c8d..803f651a99 100644 --- a/apps/cli/src/commands/db/pull/pull.integration.test.ts +++ b/apps/cli/src/commands/db/pull/pull.integration.test.ts @@ -32,7 +32,7 @@ import { BRANCH_PROJECT_REF, branchListApi, warningsOf, -} from "../../../../tests/helpers/db-remote-selection.ts"; +} from "../../../../tests/helpers/remote-selection.ts"; import { describeHostedConsent } from "../../../../tests/helpers/hosted-consent.ts"; import { withReachableDirectHost } from "../../../../tests/helpers/refuse-invalid-db-hosts.ts"; import { 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 7d7df1a165..90c9e3de81 100644 --- a/apps/cli/src/commands/db/push/push.integration.test.ts +++ b/apps/cli/src/commands/db/push/push.integration.test.ts @@ -18,7 +18,7 @@ import { BRANCH_PROJECT_REF, branchListApi, warningsOf, -} from "../../../../tests/helpers/db-remote-selection.ts"; +} from "../../../../tests/helpers/remote-selection.ts"; import { assertPermissionDrift } from "../../../../tests/helpers/permission-drift.ts"; import { asRecordedRequests, 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 a5b61e6864..aa2cd493c6 100644 --- a/apps/cli/src/commands/db/query/query.integration.test.ts +++ b/apps/cli/src/commands/db/query/query.integration.test.ts @@ -36,7 +36,7 @@ import { BRANCH_PROJECT_REF, branchListApi, warningsOf, -} from "../../../../tests/helpers/db-remote-selection.ts"; +} from "../../../../tests/helpers/remote-selection.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"; 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 487d49ccfb..f155e3412f 100644 --- a/apps/cli/src/commands/db/reset/reset.integration.test.ts +++ b/apps/cli/src/commands/db/reset/reset.integration.test.ts @@ -54,7 +54,7 @@ import { BRANCH_PROJECT_REF, branchListApi, warningsOf, -} from "../../../../tests/helpers/db-remote-selection.ts"; +} from "../../../../tests/helpers/remote-selection.ts"; import { assertPermissionDrift } from "../../../../tests/helpers/permission-drift.ts"; import { asRecordedRequests, 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 4b1a08f603..26478f286a 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 @@ -20,7 +20,7 @@ import { BRANCH_PROJECT_REF, branchListApi, warningsOf, -} from "../../../../../../tests/helpers/db-remote-selection.ts"; +} from "../../../../../../tests/helpers/remote-selection.ts"; import { assertPermissionDrift } from "../../../../../../tests/helpers/permission-drift.ts"; import { asRecordedRequests, 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 a5c51f3e4e..d7f9cc1640 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 @@ -24,6 +24,7 @@ import { configValuesLayer } from "../../../../../tests/helpers/config-values-la import { ComputeEnvNotSupportedError } from "../compute.errors.ts"; import { computeDeleteCommand } from "./delete.command.ts"; import { computeDelete } from "./delete.handler.ts"; +import { warningsOf } from "../../../../../tests/helpers/remote-selection.ts"; const CONFIG = `project_id = "demo"\n\n[compute.api]\nruntime = "node"\nsize = "2gb"\n`; @@ -311,7 +312,7 @@ describe("compute delete", () => { const suggestion = error instanceof ComputeNotDeployedError ? error.suggestion : ""; expect(suggestion).toContain("supabase compute list"); expect(suggestion).not.toContain("compute push"); - expect(out.messages.filter((message) => message.type === "warn")).toHaveLength(0); + expect(warningsOf(out)).toEqual([]); }).pipe(Effect.provide(layer)); }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), ); 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 561ca07b40..5bb12679eb 100644 --- a/apps/cli/src/commands/functions/deploy/deploy.integration.test.ts +++ b/apps/cli/src/commands/functions/deploy/deploy.integration.test.ts @@ -66,6 +66,7 @@ import { withJsonErrorHandling } from "../../../shared/output/json-error-handlin import { CommandPlatformApi } from "../../../auth/command-platform-api.service.ts"; import { functionsDeploy } from "./deploy.handler.ts"; import type { FunctionsDeployFlags } from "./deploy.command.ts"; +import { BRANCH_PROJECT_REF, branchRow } from "../../../../tests/helpers/remote-selection.ts"; const decodeJson = Schema.decodeEffect(Schema.fromJsonString(Schema.Unknown)); @@ -449,19 +450,6 @@ describe("functions deploy", () => { describe("project selection", () => { const OTHER_REF = "qrstuvwxyzabcdefghij"; - const BRANCH_REF = "branchprojectrefabcd"; - const branchRow = (name: string) => ({ - id: "11111111-2222-4333-8444-555555555555", - name, - project_ref: BRANCH_REF, - parent_project_ref: VALID_REF, - is_default: false, - persistent: false, - status: "MIGRATIONS_PASSED", - created_at: "2026-05-27T01:02:03Z", - updated_at: "2026-05-27T01:02:04Z", - with_data: true, - }); const selectionHarness = (args: ReadonlyArray<string>) => { const out = mockOutput({ format: "text" }); @@ -602,7 +590,7 @@ describe("functions deploy", () => { `GET https://api.supabase.com/v1/projects/${VALID_REF}/branches`, ); expect(deployedTo()).toEqual([ - `https://api.supabase.com/v1/projects/${BRANCH_REF}/functions/deploy`, + `https://api.supabase.com/v1/projects/${BRANCH_PROJECT_REF}/functions/deploy`, ]); assertPermissionDrift({ command: "functions deploy", 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 43e8614137..d76279cfc0 100644 --- a/apps/cli/src/commands/gen/types/types.integration.test.ts +++ b/apps/cli/src/commands/gen/types/types.integration.test.ts @@ -1,7 +1,7 @@ import { tmpdir } from "node:os"; import { describe, expect, it } from "@effect/vitest"; import { vi } from "vitest"; -import { BunPath, BunServices } from "@effect/platform-bun"; +import { BunCrypto, BunPath, BunServices } from "@effect/platform-bun"; import type { V1CreateLoginRoleOutput, V1GetABranchConfigOutput, @@ -31,10 +31,11 @@ import { } from "effect"; import { resolveWorkdir } from "../../../config/command-settings.layer.ts"; import { DnsResolverFlag, OutputFlag, YesFlag } from "../../../command-internal/global-flags.ts"; +import { commandRuntimeLayer } from "../../../shared/runtime/command-runtime.layer.ts"; import { CommandPlatformApiFactory } from "../../../auth/command-platform-api-factory.service.ts"; import { CommandPlatformApi } from "../../../auth/command-platform-api.service.ts"; import { stripAnsi } from "../../../../tests/helpers/ansi.ts"; -import { mockOutput, mockTty } from "../../../../tests/helpers/mocks.ts"; +import { mockContextualAnalytics, mockOutput, mockTty } from "../../../../tests/helpers/mocks.ts"; import { assertPermissionDrift } from "../../../../tests/helpers/permission-drift.ts"; import { asRecordedRequests, @@ -74,7 +75,7 @@ import type { ResolvedDbConfig, ResolvedDbConnection, } from "../../../command-internal/db-config.types.ts"; -import type { GenTypesFlags } from "./types.command.ts"; +import { genTypesHandler, 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"; @@ -87,6 +88,11 @@ import { type GenTypesGenerateInput, GenTypesToolFailedError, } from "./types.generator.service.ts"; +import { + BRANCH_PROJECT_REF, + branchRow, + warningsOf, +} from "../../../../tests/helpers/remote-selection.ts"; const fixtureNet = vi.hoisted(() => ({ directHostReachable: false })); @@ -162,21 +168,6 @@ function defaultFlags(overrides: Partial<GenTypesFlags> = {}): GenTypesFlags { }; } -const BRANCH_PROJECT_REF = "branchprojectrefabcd"; - -const branchRow = (name: string): Record<string, unknown> => ({ - id: "11111111-2222-4333-8444-555555555555", - name, - project_ref: BRANCH_PROJECT_REF, - parent_project_ref: VALID_REF, - is_default: false, - persistent: false, - status: "MIGRATIONS_PASSED", - created_at: "2026-05-27T01:02:03Z", - updated_at: "2026-05-27T01:02:04Z", - with_data: true, -}); - function statusApiError(status: number, body: string) { const request = HttpClientRequest.get("https://api.supabase.test/v1/projects/ref"); const response = HttpClientResponse.fromWeb( @@ -1060,9 +1051,10 @@ describe("gen types", () => { expect(alias.out.stdoutText).toBe("export type Database = {}\n"); expect(alias.out.stdoutText).toBe(canonical.out.stdoutText); - expect(alias.out.messages.filter((message) => message.type === "warn")).toEqual([]); - expect(stripAnsi(alias.out.stderrText)).toContain("Warning: --project-id is deprecated"); - expect(canonical.out.stderrText).not.toContain("deprecated"); + expect(warningsOf(alias.out)).toEqual([ + `--project-id is deprecated and will be removed in v4. Use --remote instead: supabase gen types --remote=${VALID_REF}`, + ]); + expect(warningsOf(canonical.out)).toEqual([]); }).pipe(Effect.provide(BunServices.layer)), ); @@ -1187,7 +1179,7 @@ describe("gen types", () => { expect(dbConfig.resolves.map(({ target }) => target.kind)).toEqual(["db-url"]); expect(api.requests).toEqual([]); - expect(out.messages.filter((message) => message.type === "warn")).toEqual([]); + expect(warningsOf(out)).toEqual([]); }).pipe(Effect.provide(BunServices.layer)), ); @@ -1255,11 +1247,7 @@ describe("gen types", () => { yield* genTypes(defaultFlags(testCase.flags)).pipe(Effect.provide(context.layer)); testCase.reached(context); - expect( - context.out.messages - .filter((message) => message.type === "warn") - .map((message) => message.message), - ).toEqual([testCase.warning]); + expect(warningsOf(context.out)).toEqual([testCase.warning]); }).pipe(Effect.provide(BunServices.layer)), ); } @@ -3516,6 +3504,7 @@ const setupRecorded = Effect.fnUntraced(function* (opts: { readonly branchOf?: string; readonly pickRef?: string; readonly generator?: ReturnType<typeof mockGenTypesGenerator>; + readonly analytics?: ReturnType<typeof mockContextualAnalytics>; }) { const workdir = yield* makeWorkdir("supabase-gen-types-recorded-"); yield* ensureDefaultConfig(workdir); @@ -3584,6 +3573,7 @@ const setupRecorded = Effect.fnUntraced(function* (opts: { api, cliSettings, telemetry: telemetry.layer, + ...(opts.analytics === undefined ? {} : { analytics: opts.analytics }), tty: mockTty({ stdinIsTty: opts.pickRef !== undefined }), cliArgs: opts.args, linkedProjectCache: realLinkedProjectCache({ @@ -3686,9 +3676,7 @@ describe("gen types permissions through the real layers", () => { expect(methodPaths(ctx.api.requests)).toEqual([ `GET /v1/projects/${VALID_REF}/types/typescript`, ]); - expect( - ctx.out.messages.filter((message) => message.type === "warn").map(({ message }) => message), - ).toEqual([ + expect(warningsOf(ctx.out)).toEqual([ `--project-id is deprecated and will be removed in v4. Use --remote instead: supabase gen types --remote=${VALID_REF}`, ]); assertPermissionDrift({ @@ -3700,6 +3688,67 @@ describe("gen types permissions through the real layers", () => { }).pipe(Effect.provide(BunServices.layer)), ); + describe("telemetry wiring", () => { + const reportedFlags = ( + args: ReadonlyArray<string>, + overrides: Partial<GenTypesFlags>, + setup: { readonly projectRefFile?: string; readonly cacheRef?: string } = {}, + ) => + Effect.gen(function* () { + const analytics = mockContextualAnalytics(); + const ctx = yield* setupRecorded({ args, analytics, ...setup }); + yield* ctx.run( + genTypesHandler({ ...defaultFlags(overrides), language: Option.none() }).pipe( + Effect.provide( + commandRuntimeLayer(["gen", "types"]).pipe(Layer.provide(BunCrypto.layer)), + ), + ), + ); + return analytics.captured.find((c) => c.event === "cli_command_executed")?.properties.flags; + }).pipe(Effect.provide(BunServices.layer)); + + it.effect("reports a ref-shaped --project-id verbatim", () => + Effect.gen(function* () { + expect( + yield* reportedFlags(["gen", "types", "--project-id", VALID_REF], { + projectId: Option.some(VALID_REF), + }), + ).toEqual({ "project-id": VALID_REF }); + }), + ); + + it.effect("redacts a --project-id that names a branch", () => + Effect.gen(function* () { + const flags = yield* reportedFlags( + ["gen", "types", "--project-id", "feature-x"], + { projectId: Option.some("feature-x") }, + { projectRefFile: VALID_REF, cacheRef: BRANCH_PROJECT_REF }, + ); + expect(flags).toEqual({ "project-id": "<redacted>" }); + }), + ); + + it.effect("reports a ref-shaped --project-ref verbatim", () => + Effect.gen(function* () { + expect( + yield* reportedFlags(["gen", "types", "--project-ref", VALID_REF], { + projectRef: Option.some(VALID_REF), + }), + ).toEqual({ "project-ref": VALID_REF }); + }), + ); + + it.effect("reports --remote=<ref> through the central remote handling", () => + Effect.gen(function* () { + expect( + yield* reportedFlags(["gen", "types", `--remote=${VALID_REF}`], { + remote: Option.some({ _tag: "Ref", ref: VALID_REF } as const), + }), + ).toEqual({ remote: VALID_REF }); + }), + ); + }); + it.effect( "looks the branch --remote=<branch> names up in the linked project, then generates", () => 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 d05e337e57..f5985e481b 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 @@ -55,22 +55,13 @@ 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 type { InspectConnectionFlags } from "./inspect-query.ts"; +import { + BRANCH_PROJECT_REF, + branchRow, + warningsOf, +} from "../../../../tests/helpers/remote-selection.ts"; const FLAG_REF = "flagflagflagflagflag"; -const BRANCH_REF = "branchprojectrefabcd"; - -const branchRow = (name: string) => ({ - id: "11111111-2222-4333-8444-555555555555", - name, - project_ref: BRANCH_REF, - parent_project_ref: VALID_REF, - is_default: false, - persistent: false, - status: "MIGRATIONS_PASSED", - created_at: "2026-05-27T01:02:03Z", - updated_at: "2026-05-27T01:02:04Z", - with_data: true, -}); const LOCAL_CONN: PgConnInput = { host: "127.0.0.1", @@ -256,8 +247,7 @@ function setup(opts: SetupOpts = {}) { connection, telemetry, branchRequests: api.requests, - warnings: () => - out.messages.filter((message) => message.type === "warn").map((message) => message.message), + warnings: () => warningsOf(out), }; } @@ -413,7 +403,7 @@ describe("inspect db query runner", () => { ]); expect(resolver.connectionInput?.target).toMatchObject({ kind: "linked", - ref: BRANCH_REF, + ref: BRANCH_PROJECT_REF, }); expect(warnings()).toEqual([]); }).pipe(Effect.provide(layer)); 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 14b5890331..769eb505e8 100644 --- a/apps/cli/src/commands/inspect/report/report.integration.test.ts +++ b/apps/cli/src/commands/inspect/report/report.integration.test.ts @@ -61,22 +61,13 @@ 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"; +import { + BRANCH_PROJECT_REF, + branchRow, + warningsOf, +} from "../../../../tests/helpers/remote-selection.ts"; const FLAG_REF = "flagflagflagflagflag"; -const BRANCH_REF = "branchprojectrefabcd"; - -const branchRow = (name: string) => ({ - id: "11111111-2222-4333-8444-555555555555", - name, - project_ref: BRANCH_REF, - parent_project_ref: VALID_REF, - is_default: false, - persistent: false, - status: "MIGRATIONS_PASSED", - created_at: "2026-05-27T01:02:03Z", - updated_at: "2026-05-27T01:02:04Z", - with_data: true, -}); const LOCAL_CONN: PgConnInput = { host: "127.0.0.1", @@ -253,8 +244,7 @@ function setupReport(opts: SetupOpts = {}) { connection, telemetry, branchRequests: api.requests, - warnings: () => - out.messages.filter((message) => message.type === "warn").map((message) => message.message), + warnings: () => warningsOf(out), }; } @@ -411,7 +401,7 @@ describe("inspect report", () => { ]); expect(resolver.connectionInput?.target).toMatchObject({ kind: "linked", - ref: BRANCH_REF, + ref: BRANCH_PROJECT_REF, }); expect(warnings()).toEqual([]); }).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 20e8fcc5fe..4a45b44697 100644 --- a/apps/cli/src/commands/migration/down/down.integration.test.ts +++ b/apps/cli/src/commands/migration/down/down.integration.test.ts @@ -59,25 +59,16 @@ import { import { MigrationLastTooLargeError, MigrationLastZeroError } from "./down.errors.ts"; import { migrationDown } from "./down.handler.ts"; import type { MigrationDownFlags } from "./down.command.ts"; +import { + BRANCH_PROJECT_REF, + branchRow, + warningsOf, +} from "../../../../tests/helpers/remote-selection.ts"; const LIST_SQL = "SELECT version FROM supabase_migrations.schema_migrations ORDER BY version"; const FLAG_REF = "flagflagflagflagflag"; -const BRANCH_REF = "branchprojectrefabcd"; const DB_URL = "postgresql://postgres:postgres@127.0.0.1:54322/postgres"; -const branchRow = (name: string) => ({ - id: "11111111-2222-4333-8444-555555555555", - name, - project_ref: BRANCH_REF, - parent_project_ref: VALID_REF, - is_default: false, - persistent: false, - status: "MIGRATIONS_PASSED", - created_at: "2026-05-27T01:02:03Z", - updated_at: "2026-05-27T01:02:04Z", - with_data: true, -}); - interface SetupOpts { /** Runs the real `DbConfigResolver` and linked-project cache against `workdir`. */ readonly realLayers?: boolean; @@ -232,12 +223,7 @@ function setup(workdir: string, opts: SetupOpts = {}) { cache, resolverCalls, branchRequests: branchApi.requests, - // A missing seed.sql also warns on a database reset; only selector warnings matter here. - warnings: () => - out.messages - .filter((message) => message.type === "warn") - .map((message) => message.message) - .filter((message) => !message.startsWith("no files matched pattern")), + warnings: () => warningsOf(out), }; } @@ -364,8 +350,11 @@ describe("migration down", () => { expect(ctx.branchRequests.map(({ method, url }) => `${method} ${url}`)).toEqual([ `GET https://api.supabase.com/v1/projects/${VALID_REF}/branches`, ]); - expect(ctx.resolverCalls[0]?.target).toMatchObject({ kind: "linked", ref: BRANCH_REF }); - expect(ctx.cache.cachedRef).toBe(BRANCH_REF); + expect(ctx.resolverCalls[0]?.target).toMatchObject({ + kind: "linked", + ref: BRANCH_PROJECT_REF, + }); + expect(ctx.cache.cachedRef).toBe(BRANCH_PROJECT_REF); expect(ctx.warnings()).toEqual([]); }).pipe(Effect.provide(ctx.layer)); }); 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 666926ddc7..045a9d332f 100644 --- a/apps/cli/src/commands/migration/fetch/fetch.integration.test.ts +++ b/apps/cli/src/commands/migration/fetch/fetch.integration.test.ts @@ -54,26 +54,17 @@ 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"; +import { + BRANCH_PROJECT_REF, + branchRow, + warningsOf, +} from "../../../../tests/helpers/remote-selection.ts"; const SELECT_SQL = "SELECT version, coalesce(name, '') as name, statements FROM supabase_migrations.schema_migrations"; const FLAG_REF = "flagflagflagflagflag"; -const BRANCH_REF = "branchprojectrefabcd"; const DB_URL = "postgresql://u:p@db.example.com/postgres"; -const branchRow = (name: string) => ({ - id: "11111111-2222-4333-8444-555555555555", - name, - project_ref: BRANCH_REF, - parent_project_ref: VALID_REF, - is_default: false, - persistent: false, - status: "MIGRATIONS_PASSED", - created_at: "2026-05-27T01:02:03Z", - updated_at: "2026-05-27T01:02:04Z", - with_data: true, -}); - interface MigrationRow { readonly version: string; readonly name: string; @@ -199,8 +190,7 @@ function setup(workdir: string, opts: SetupOpts = {}) { ), BunServices.layer, ); - const warnings = () => - out.messages.filter((message) => message.type === "warn").map((message) => message.message); + const warnings = () => warningsOf(out); return { layer, http, @@ -572,8 +562,8 @@ describe("migration fetch", () => { expect(branchRequests.map(({ method, url }) => `${method} ${url}`)).toEqual([ `GET https://api.supabase.com/v1/projects/${VALID_REF}/branches`, ]); - expect(resolveCalls[0]?.target).toMatchObject({ kind: "linked", ref: BRANCH_REF }); - expect(cache.cachedRef).toBe(BRANCH_REF); + expect(resolveCalls[0]?.target).toMatchObject({ kind: "linked", ref: BRANCH_PROJECT_REF }); + expect(cache.cachedRef).toBe(BRANCH_PROJECT_REF); expect(warnings()).toEqual([]); }).pipe(Effect.provide(layer)); }); 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 cfd78a5499..1d12c67d0e 100644 --- a/apps/cli/src/commands/migration/list/list.integration.test.ts +++ b/apps/cli/src/commands/migration/list/list.integration.test.ts @@ -54,25 +54,16 @@ import { } from "../../../command-internal/target/target.errors.ts"; import { migrationList } from "./list.handler.ts"; import type { MigrationListFlags } from "./list.command.ts"; +import { + BRANCH_PROJECT_REF, + branchRow, + warningsOf, +} from "../../../../tests/helpers/remote-selection.ts"; const LIST_SQL = "SELECT version FROM supabase_migrations.schema_migrations ORDER BY version"; const FLAG_REF = "flagflagflagflagflag"; -const BRANCH_REF = "branchprojectrefabcd"; const DB_URL = "postgresql://postgres:postgres@127.0.0.1:54322/postgres"; -const branchRow = (name: string) => ({ - id: "11111111-2222-4333-8444-555555555555", - name, - project_ref: BRANCH_REF, - parent_project_ref: VALID_REF, - is_default: false, - persistent: false, - status: "MIGRATIONS_PASSED", - created_at: "2026-05-27T01:02:03Z", - updated_at: "2026-05-27T01:02:04Z", - with_data: true, -}); - interface SetupOpts { readonly format?: OutputFormat; readonly isLocal?: boolean; @@ -181,8 +172,7 @@ function setup(workdir: string, opts: SetupOpts = {}) { http, resolverCalls, branchRequests: branchApi.requests, - warnings: () => - out.messages.filter((message) => message.type === "warn").map((message) => message.message), + warnings: () => warningsOf(out), }; } @@ -301,8 +291,11 @@ describe("migration list", () => { expect(ctx.branchRequests.map(({ method, url }) => `${method} ${url}`)).toEqual([ `GET https://api.supabase.com/v1/projects/${VALID_REF}/branches`, ]); - expect(ctx.resolverCalls[0]?.target).toMatchObject({ kind: "linked", ref: BRANCH_REF }); - expect(ctx.cache.cachedRef).toBe(BRANCH_REF); + expect(ctx.resolverCalls[0]?.target).toMatchObject({ + kind: "linked", + ref: BRANCH_PROJECT_REF, + }); + expect(ctx.cache.cachedRef).toBe(BRANCH_PROJECT_REF); expect(ctx.warnings()).toEqual([]); }).pipe(Effect.provide(ctx.layer)); }); 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 c0db85c14a..42a91b4fb6 100644 --- a/apps/cli/src/commands/migration/repair/repair.integration.test.ts +++ b/apps/cli/src/commands/migration/repair/repair.integration.test.ts @@ -58,24 +58,15 @@ import { import { MigrationFileNotFoundError, MigrationInvalidVersionError } from "../migration.errors.ts"; import { MigrationRepairUpdateError } from "./repair.errors.ts"; import { migrationRepair, type MigrationRepairInput } from "./repair.handler.ts"; +import { + BRANCH_PROJECT_REF, + branchRow, + warningsOf, +} from "../../../../tests/helpers/remote-selection.ts"; const FLAG_REF = "flagflagflagflagflag"; -const BRANCH_REF = "branchprojectrefabcd"; const DB_URL = "postgresql://postgres:postgres@127.0.0.1:54322/postgres"; -const branchRow = (name: string) => ({ - id: "11111111-2222-4333-8444-555555555555", - name, - project_ref: BRANCH_REF, - parent_project_ref: VALID_REF, - is_default: false, - persistent: false, - status: "MIGRATIONS_PASSED", - created_at: "2026-05-27T01:02:03Z", - updated_at: "2026-05-27T01:02:04Z", - with_data: true, -}); - interface SetupOpts { /** Runs the real `DbConfigResolver` and linked-project cache against `workdir`. */ readonly realLayers?: boolean; @@ -220,8 +211,7 @@ function setup(workdir: string, opts: SetupOpts = {}) { cache, resolverCalls, branchRequests: branchApi.requests, - warnings: () => - out.messages.filter((message) => message.type === "warn").map((message) => message.message), + warnings: () => warningsOf(out), }; } @@ -482,8 +472,11 @@ describe("migration repair", () => { expect(ctx.branchRequests.map(({ method, url }) => `${method} ${url}`)).toEqual([ `GET https://api.supabase.com/v1/projects/${VALID_REF}/branches`, ]); - expect(ctx.resolverCalls[0]?.target).toMatchObject({ kind: "linked", ref: BRANCH_REF }); - expect(ctx.cache.cachedRef).toBe(BRANCH_REF); + expect(ctx.resolverCalls[0]?.target).toMatchObject({ + kind: "linked", + ref: BRANCH_PROJECT_REF, + }); + expect(ctx.cache.cachedRef).toBe(BRANCH_PROJECT_REF); expect(ctx.warnings()).toEqual([]); }).pipe(Effect.provide(ctx.layer)); }); 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 940a3fae8f..7ea9d3d95a 100644 --- a/apps/cli/src/commands/migration/squash/squash.integration.test.ts +++ b/apps/cli/src/commands/migration/squash/squash.integration.test.ts @@ -96,6 +96,11 @@ import { MigrationSquashWriteError, } from "./squash.errors.ts"; import { migrationSquash } from "./squash.handler.ts"; +import { + BRANCH_PROJECT_REF, + branchRow, + warningsOf, +} from "../../../../tests/helpers/remote-selection.ts"; // Distinguishes squash's three pg_dump containers from the shadow's setup jobs by env: // only a pg_dump invocation carries PGDATABASE. Among dump calls, the first two sharing @@ -235,22 +240,8 @@ const alwaysReadyHttpClientLayer = Layer.succeed( ); const FLAG_REF = "flagflagflagflagflag"; -const BRANCH_REF = "branchprojectrefabcd"; const DB_URL = "postgresql://postgres:postgres@127.0.0.1:54322/postgres"; -const branchRow = (name: string) => ({ - id: "11111111-2222-4333-8444-555555555555", - name, - project_ref: BRANCH_REF, - parent_project_ref: VALID_REF, - is_default: false, - persistent: false, - status: "MIGRATIONS_PASSED", - created_at: "2026-05-27T01:02:03Z", - updated_at: "2026-05-27T01:02:04Z", - with_data: true, -}); - interface SetupOpts { /** Runs the real `DbConfigResolver` and linked-project cache against `workdir`. */ readonly realLayers?: boolean; @@ -472,8 +463,7 @@ function setup(workdir: string, opts: SetupOpts = {}) { telemetry, cache, branchRequests: branchApi.requests, - warnings: () => - out.messages.filter((message) => message.type === "warn").map((message) => message.message), + warnings: () => warningsOf(out), ...fake, }; } @@ -1404,8 +1394,11 @@ describe("migration squash", () => { expect(s.branchRequests.map(({ method, url }) => `${method} ${url}`)).toEqual([ `GET https://api.supabase.com/v1/projects/${VALID_REF}/branches`, ]); - expect(s.resolverCalls[0]?.target).toMatchObject({ kind: "linked", ref: BRANCH_REF }); - expect(s.cache.cachedRef).toBe(BRANCH_REF); + expect(s.resolverCalls[0]?.target).toMatchObject({ + kind: "linked", + ref: BRANCH_PROJECT_REF, + }); + expect(s.cache.cachedRef).toBe(BRANCH_PROJECT_REF); expect(s.warnings()).toEqual([]); }).pipe(Effect.provide(s.layer)); }, 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 90073cdf3d..f36a41b787 100644 --- a/apps/cli/src/commands/migration/up/up.integration.test.ts +++ b/apps/cli/src/commands/migration/up/up.integration.test.ts @@ -57,26 +57,17 @@ import { import { MigrationMissingLocalError, MigrationMissingRemoteError } from "./up.errors.ts"; import { migrationUp } from "./up.handler.ts"; import type { MigrationUpFlags } from "./up.command.ts"; +import { + BRANCH_PROJECT_REF, + branchRow, + warningsOf, +} from "../../../../tests/helpers/remote-selection.ts"; const LIST_SQL = "SELECT version FROM supabase_migrations.schema_migrations ORDER BY version"; const READ_VAULT = "SELECT id, name FROM vault.secrets WHERE name = ANY($1)"; const FLAG_REF = "flagflagflagflagflag"; -const BRANCH_REF = "branchprojectrefabcd"; const DB_URL = "postgresql://postgres:postgres@127.0.0.1:54322/postgres"; -const branchRow = (name: string) => ({ - id: "11111111-2222-4333-8444-555555555555", - name, - project_ref: BRANCH_REF, - parent_project_ref: VALID_REF, - is_default: false, - persistent: false, - status: "MIGRATIONS_PASSED", - created_at: "2026-05-27T01:02:03Z", - updated_at: "2026-05-27T01:02:04Z", - with_data: true, -}); - interface SetupOpts { /** Runs the real `DbConfigResolver` and linked-project cache against `workdir`. */ readonly realLayers?: boolean; @@ -212,8 +203,7 @@ function setup(workdir: string, opts: SetupOpts = {}) { cache, resolverCalls, branchRequests: branchApi.requests, - warnings: () => - out.messages.filter((message) => message.type === "warn").map((message) => message.message), + warnings: () => warningsOf(out), }; } @@ -377,8 +367,11 @@ describe("migration up", () => { expect(ctx.branchRequests.map(({ method, url }) => `${method} ${url}`)).toEqual([ `GET https://api.supabase.com/v1/projects/${VALID_REF}/branches`, ]); - expect(ctx.resolverCalls[0]?.target).toMatchObject({ kind: "linked", ref: BRANCH_REF }); - expect(ctx.cache.cachedRef).toBe(BRANCH_REF); + expect(ctx.resolverCalls[0]?.target).toMatchObject({ + kind: "linked", + ref: BRANCH_PROJECT_REF, + }); + expect(ctx.cache.cachedRef).toBe(BRANCH_PROJECT_REF); expect(ctx.warnings()).toEqual([]); }).pipe(Effect.provide(ctx.layer)); }); 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 a9f04665d3..b0f8091bbf 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 @@ -15,7 +15,7 @@ import { useTempWorkdir, unverifiedTargetBanner, } from "../../../../tests/helpers/command-mocks.ts"; -import { describeHostedRemoteSelection } from "../../../../tests/helpers/hosted-remote-selection.ts"; +import { describeHostedTargetSelection } from "../../../../tests/helpers/hosted-target-selection.ts"; import { assertPermissionDrift } from "../../../../tests/helpers/permission-drift.ts"; import { projectResponse, @@ -347,10 +347,8 @@ describe("network-bans get integration", () => { }); }); - describeHostedRemoteSelection({ - command: ["network-bans", "get"], - workdir: () => tempRoot.current, - respond: (request) => jsonResponse(request, 201, SAMPLE_BANS), - run: (selector, layer) => networkBansGet(selector).pipe(Effect.provide(layer)), + describeHostedTargetSelection("network-bans get", { + respond: (request) => Effect.succeed(jsonResponse(request, 201, SAMPLE_BANS)), + run: (flags) => networkBansGet(flags), }); }); 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 d54fbea580..dbf2132caf 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 @@ -3,7 +3,7 @@ import { describe, expect, it } from "@effect/vitest"; import { Cause, Effect, Exit, Layer, Option, FileSystem, Schema } from "effect"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; import { describeHostedConsent } from "../../../../tests/helpers/hosted-consent.ts"; -import { describeHostedRemoteSelection } from "../../../../tests/helpers/hosted-remote-selection.ts"; +import { describeHostedTargetSelection } from "../../../../tests/helpers/hosted-target-selection.ts"; import { mockOutput, mockTty } from "../../../../tests/helpers/mocks.ts"; import { VALID_REF, @@ -504,13 +504,10 @@ describe("network-bans remove integration", () => { }); }); - describeHostedRemoteSelection({ - command: ["network-bans", "remove"], - args: ["--db-unban-ip", "1.2.3.4"], - workdir: () => tempRoot.current, - respond: (request) => jsonResponse(request, 200, null), - run: (selector, layer) => - networkBansRemove({ ...selector, dbUnbanIp: ["1.2.3.4"] }).pipe(Effect.provide(layer)), + describeHostedTargetSelection("network-bans remove", { + operands: ["--db-unban-ip", "1.2.3.4"], + respond: (request) => Effect.succeed(jsonResponse(request, 200, null)), + run: (flags) => networkBansRemove({ ...flags, dbUnbanIp: ["1.2.3.4"] }), }); }); diff --git a/apps/cli/src/commands/network-restrictions/get/get.integration.test.ts b/apps/cli/src/commands/network-restrictions/get/get.integration.test.ts index 9b54cf8bf3..61f7dfc4c9 100644 --- a/apps/cli/src/commands/network-restrictions/get/get.integration.test.ts +++ b/apps/cli/src/commands/network-restrictions/get/get.integration.test.ts @@ -14,7 +14,7 @@ import { mockTelemetryStateTracked, useTempWorkdir, } from "../../../../tests/helpers/command-mocks.ts"; -import { describeHostedRemoteSelection } from "../../../../tests/helpers/hosted-remote-selection.ts"; +import { describeHostedTargetSelection } from "../../../../tests/helpers/hosted-target-selection.ts"; import { assertPermissionDrift } from "../../../../tests/helpers/permission-drift.ts"; import { projectResponse, @@ -375,10 +375,8 @@ describe("network-restrictions get integration", () => { }); }); - describeHostedRemoteSelection({ - command: ["network-restrictions", "get"], - workdir: () => tempRoot.current, - respond: (request) => jsonResponse(request, 200, APPLIED_WITH_CIDRS), - run: (selector, layer) => networkRestrictionsGet(selector).pipe(Effect.provide(layer)), + describeHostedTargetSelection("network-restrictions get", { + respond: (request) => Effect.succeed(jsonResponse(request, 200, APPLIED_WITH_CIDRS)), + run: (flags) => networkRestrictionsGet(flags), }); }); 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 fc64f9e63e..1fd5a35379 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 @@ -7,7 +7,7 @@ import { describe, expect, it } from "@effect/vitest"; import { Cause, Effect, Exit, Layer, Option, FileSystem, Schema } from "effect"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; import { describeHostedConsent } from "../../../../tests/helpers/hosted-consent.ts"; -import { describeHostedRemoteSelection } from "../../../../tests/helpers/hosted-remote-selection.ts"; +import { describeHostedTargetSelection } from "../../../../tests/helpers/hosted-target-selection.ts"; import { mockOutput, mockTty } from "../../../../tests/helpers/mocks.ts"; import { VALID_REF, @@ -826,16 +826,12 @@ describe("network-restrictions update integration", () => { }); }); -describeHostedRemoteSelection({ - command: ["network-restrictions", "update"], - args: ["--db-allow-cidr", "12.3.4.5/32"], - workdir: () => tempRoot.current, - respond: (request) => jsonResponse(request, 201, POST_APPLIED), +describeHostedTargetSelection("network-restrictions update", { + operands: ["--db-allow-cidr", "12.3.4.5/32"], + respond: (request) => Effect.succeed(jsonResponse(request, 201, POST_APPLIED)), activeFlags: { append: false }, - run: (selector, layer) => - networkRestrictionsUpdate({ ...baseFlags, ...selector, dbAllowCidr: ["12.3.4.5/32"] }).pipe( - Effect.provide(layer), - ), + run: (flags) => + networkRestrictionsUpdate({ ...baseFlags, ...flags, dbAllowCidr: ["12.3.4.5/32"] }), }); describeHostedConsent("network-restrictions update", { diff --git a/apps/cli/src/commands/notebooks/notebooks.integration.test.ts b/apps/cli/src/commands/notebooks/notebooks.integration.test.ts index e441a01a08..c8e0abdc3d 100644 --- a/apps/cli/src/commands/notebooks/notebooks.integration.test.ts +++ b/apps/cli/src/commands/notebooks/notebooks.integration.test.ts @@ -24,6 +24,7 @@ import { } from "./notebooks.errors.ts"; import { notebooksPullHandler } from "./pull/pull.command.ts"; import { notebooksPushHandler } from "./push/push.command.ts"; +import { warningsOf } from "../../../tests/helpers/remote-selection.ts"; const temp = useTempWorkdir("supabase-notebooks-regression-"); const ID = "44444444-4444-4444-8444-444444444444"; @@ -699,9 +700,7 @@ describe.each(commands)("notebooks %s project selection", (command) => { return Effect.gen(function* () { yield* runWith(command, { ...none, projectRef: Option.some(NOTEBOOKS_PROJECT_REF) }); expect(http.routeKeys).toEqual([`GET ${notebooksRoute()}`]); - expect( - out.messages.filter((message) => message.type === "warn").map((message) => message.message), - ).toEqual([ + expect(warningsOf(out)).toEqual([ `--project-ref is deprecated and will be removed in v4. Use --remote instead: supabase notebooks ${command} --remote=${NOTEBOOKS_PROJECT_REF}`, ]); }).pipe(Effect.provide(Layer.mergeAll(layer, Layer.succeed(CliArgs, { args: argv })))); 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 4a90db6a0d..6dd18e5492 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,7 +1,7 @@ import { describe, expect, it } from "@effect/vitest"; import { Cause, Effect, Exit, Layer, Option, FileSystem, Schema } from "effect"; import { describeHostedConsent } from "../../../tests/helpers/hosted-consent.ts"; -import { describeHostedRemoteSelection } from "../../../tests/helpers/hosted-remote-selection.ts"; +import { describeHostedTargetSelection } from "../../../tests/helpers/hosted-target-selection.ts"; import { mockOutput, mockTty } from "../../../tests/helpers/mocks.ts"; import { makeApiClient } from "@supabase/api/effect"; import * as HttpClientResponse from "effect/http/HttpClientResponse"; @@ -1188,42 +1188,40 @@ describe("postgres-config delete", () => { }); }); -describeHostedRemoteSelection({ - command: ["postgres-config", "get"], - workdir: () => tempRoot.current, - respond: (request) => jsonResponse(request, 200, { max_connections: 100 }), - run: (selector, layer) => postgresConfigGet(selector).pipe(Effect.provide(layer)), +describeHostedTargetSelection("postgres-config get", { + respond: (request) => Effect.succeed(jsonResponse(request, 200, { max_connections: 100 })), + run: (flags) => postgresConfigGet(flags), }); -describeHostedRemoteSelection({ - command: ["postgres-config", "update"], - args: ["--config", "max_connections=100"], - workdir: () => tempRoot.current, +describeHostedTargetSelection("postgres-config update", { + operands: ["--config", "max_connections=100"], activeFlags: { "replace-existing-overrides": false }, respond: (request) => - jsonResponse(request, 200, request.method === "PUT" ? { max_connections: 100 } : {}), - run: (selector, layer) => + Effect.succeed( + jsonResponse(request, 200, request.method === "PUT" ? { max_connections: 100 } : {}), + ), + run: (flags) => postgresConfigUpdate({ - ...selector, + ...flags, config: ["max_connections=100"], replaceExistingOverrides: false, noRestart: false, - }).pipe(Effect.provide(layer)), + }), }); -describeHostedRemoteSelection({ - command: ["postgres-config", "delete"], - args: ["--config", "max_connections"], - workdir: () => tempRoot.current, +describeHostedTargetSelection("postgres-config delete", { + operands: ["--config", "max_connections"], activeFlags: { config: true, "no-restart": false }, respond: (request) => - jsonResponse(request, 200, request.method === "PUT" ? {} : { max_connections: 100 }), - run: (selector, layer) => + Effect.succeed( + jsonResponse(request, 200, request.method === "PUT" ? {} : { max_connections: 100 }), + ), + run: (flags) => postgresConfigDelete({ - ...selector, + ...flags, config: ["max_connections"], noRestart: false, - }).pipe(Effect.provide(layer)), + }), }); describeHostedConsent("postgres-config update", { diff --git a/apps/cli/src/commands/projects/api-keys/api-keys.integration.test.ts b/apps/cli/src/commands/projects/api-keys/api-keys.integration.test.ts index 3b93fba047..90c8913970 100644 --- a/apps/cli/src/commands/projects/api-keys/api-keys.integration.test.ts +++ b/apps/cli/src/commands/projects/api-keys/api-keys.integration.test.ts @@ -11,7 +11,7 @@ import { mockCommandPlatformApi, useTempWorkdir, } from "../../../../tests/helpers/command-mocks.ts"; -import { describeHostedRemoteSelection } from "../../../../tests/helpers/hosted-remote-selection.ts"; +import { describeHostedTargetSelection } from "../../../../tests/helpers/hosted-target-selection.ts"; import { assertPermissionDrift } from "../../../../tests/helpers/permission-drift.ts"; import { realLinkedProjectCache } from "../../../../tests/helpers/permission-real-layers.ts"; import { projectsApiKeys } from "./api-keys.handler.ts"; @@ -321,12 +321,9 @@ describe("projects api-keys integration", () => { }).pipe(Effect.provide(layer)); }); - describeHostedRemoteSelection({ - command: ["projects", "api-keys"], - workdir: () => tempRoot.current, + describeHostedTargetSelection("projects api-keys", { activeFlags: { reveal: false }, - respond: (request) => jsonResponse(request, 200, SAMPLE_KEYS), - run: (selector, layer) => - projectsApiKeys({ ...selector, reveal: false }).pipe(Effect.provide(layer)), + respond: (request) => Effect.succeed(jsonResponse(request, 200, SAMPLE_KEYS)), + run: (flags) => projectsApiKeys({ ...flags, reveal: false }), }); }); diff --git a/apps/cli/src/commands/pull/pull.integration.test.ts b/apps/cli/src/commands/pull/pull.integration.test.ts index 8d8d62c9db..9a114d4167 100644 --- a/apps/cli/src/commands/pull/pull.integration.test.ts +++ b/apps/cli/src/commands/pull/pull.integration.test.ts @@ -104,6 +104,7 @@ 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"; +import { warningsOf } from "../../../tests/helpers/remote-selection.ts"; /** * Scenario-oriented integration coverage for `supabase pull` (ADR 0024), driving the real @@ -991,9 +992,7 @@ describe("pull integration", () => { return Effect.gen(function* () { yield* writeConfig(); yield* runPull(pullFlags({ projectRef: Option.some("staging"), dryRun: true })); - expect( - out.messages.filter((message) => message.type === "warn").map(({ message }) => message), - ).toEqual([ + expect(warningsOf(out)).toEqual([ "--project-ref is deprecated and will be removed in v4. Use --remote instead: supabase pull --remote=staging", ]); }).pipe(Effect.provide(layer)); diff --git a/apps/cli/src/commands/secrets/list/list.integration.test.ts b/apps/cli/src/commands/secrets/list/list.integration.test.ts index 27a9ca1eed..5d49bba54a 100644 --- a/apps/cli/src/commands/secrets/list/list.integration.test.ts +++ b/apps/cli/src/commands/secrets/list/list.integration.test.ts @@ -12,7 +12,7 @@ import { mockCommandPlatformApi, useTempWorkdir, } from "../../../../tests/helpers/command-mocks.ts"; -import { describeHostedRemoteSelection } from "../../../../tests/helpers/hosted-remote-selection.ts"; +import { describeHostedTargetSelection } from "../../../../tests/helpers/hosted-target-selection.ts"; import { assertPermissionDrift } from "../../../../tests/helpers/permission-drift.ts"; import { realLinkedProjectCache } from "../../../../tests/helpers/permission-real-layers.ts"; import { secretsList } from "./list.handler.ts"; @@ -343,10 +343,8 @@ describe("secrets list integration", () => { }).pipe(Effect.provide(layer)); }); - describeHostedRemoteSelection({ - command: ["secrets", "list"], - workdir: () => tempRoot.current, - respond: (request) => jsonResponse(request, 200, SAMPLE_SECRETS), - run: (selector, layer) => secretsList(selector).pipe(Effect.provide(layer)), + describeHostedTargetSelection("secrets list", { + respond: (request) => Effect.succeed(jsonResponse(request, 200, SAMPLE_SECRETS)), + run: (flags) => secretsList(flags), }); }); 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 2d1591e576..c269ec1713 100644 --- a/apps/cli/src/commands/secrets/set/set.integration.test.ts +++ b/apps/cli/src/commands/secrets/set/set.integration.test.ts @@ -17,7 +17,7 @@ import { HostedMutationDeclinedError } from "../../../command-internal/hosted-co import { TargetPolicy } from "../../../command-internal/target/target-policy.ts"; import { cliConfigValuesTestLayer } from "../../../../tests/helpers/config-values-layer.ts"; import { describeHostedConsent } from "../../../../tests/helpers/hosted-consent.ts"; -import { describeHostedRemoteSelection } from "../../../../tests/helpers/hosted-remote-selection.ts"; +import { describeHostedTargetSelection } from "../../../../tests/helpers/hosted-target-selection.ts"; import { mockOutput, mockRuntimeInfo, @@ -1177,16 +1177,12 @@ FROM_CONFIG = "config-value" ); }); -describeHostedRemoteSelection({ - command: ["secrets", "set"], - args: ["FOO=bar"], - workdir: () => tempRoot.current, - respond: (request) => jsonResponse(request, 201, null), - run: (selector, layer) => - secretsSet({ ...selector, envFile: Option.none(), secrets: ["FOO=bar"] }).pipe( - Effect.provide( - Layer.mergeAll(layer, cliConfigValuesTestLayer, mockDebugLoggerTracked().layer), - ), +describeHostedTargetSelection("secrets set", { + operands: ["FOO=bar"], + respond: (request) => Effect.succeed(jsonResponse(request, 201, null)), + run: (flags) => + secretsSet({ ...flags, envFile: Option.none(), secrets: ["FOO=bar"] }).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 fe39c3118b..995d53f752 100644 --- a/apps/cli/src/commands/secrets/unset/unset.integration.test.ts +++ b/apps/cli/src/commands/secrets/unset/unset.integration.test.ts @@ -10,7 +10,7 @@ import { 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 { describeHostedRemoteSelection } from "../../../../tests/helpers/hosted-remote-selection.ts"; +import { describeHostedTargetSelection } from "../../../../tests/helpers/hosted-target-selection.ts"; import { mockOutput, mockStdin, mockTty } from "../../../../tests/helpers/mocks.ts"; import { VALID_REF, @@ -532,17 +532,14 @@ describe("secrets unset integration", () => { }); }); -describeHostedRemoteSelection({ - command: ["secrets", "unset"], - args: ["FOO"], - workdir: () => tempRoot.current, +describeHostedTargetSelection("secrets unset", { + operands: ["FOO"], activeFlags: { all: false, "dry-run": false }, respond: (request) => - jsonResponse(request, 200, request.method === "GET" ? [{ name: "FOO", value: "d1" }] : null), - run: (selector, layer) => - secretsUnset({ ...selector, names: ["FOO"], all: false, dryRun: false }).pipe( - Effect.provide(layer), + Effect.succeed( + jsonResponse(request, 200, request.method === "GET" ? [{ name: "FOO", value: "d1" }] : null), ), + run: (flags) => secretsUnset({ ...flags, names: ["FOO"], all: false, dryRun: false }), }); describeHostedConsent("secrets unset", { 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 a87d613421..297379ceb0 100644 --- a/apps/cli/src/commands/seed/buckets/buckets.integration.test.ts +++ b/apps/cli/src/commands/seed/buckets/buckets.integration.test.ts @@ -67,6 +67,7 @@ import { realLinkedProjectCache, } from "../../../../tests/helpers/permission-real-layers.ts"; import { cliConfigValuesTestLayer } from "../../../../tests/helpers/config-values-layer.ts"; +import { warningsOf } from "../../../../tests/helpers/remote-selection.ts"; interface MockRoute { readonly method: string; @@ -2316,7 +2317,7 @@ describe("seed buckets", () => { requests.some((r) => r.url.startsWith(`https://${STORAGE_TEST_BRANCH_REF}.supabase.co`)), ).toBe(true); expect(linkedCache.cachedRef).toBe(STORAGE_TEST_BRANCH_REF); - expect(out.messages.filter((message) => message.type === "warn")).toEqual([]); + expect(warningsOf(out)).toEqual([]); }).pipe(seedScenario), ); @@ -2404,11 +2405,7 @@ describe("seed buckets", () => { expect(Exit.isSuccess(exit)).toBe(true); expect(requests.length).toBeGreaterThan(0); expect(requests.every((r) => r.url.startsWith(testCase.host))).toBe(true); - expect( - out.messages - .filter((message) => message.type === "warn") - .map((message) => message.message), - ).toEqual([testCase.warning]); + expect(warningsOf(out)).toEqual([testCase.warning]); }).pipe(seedScenario), ); } diff --git a/apps/cli/src/commands/snippets/download/download.integration.test.ts b/apps/cli/src/commands/snippets/download/download.integration.test.ts index c6ac3a689f..d9aaaecb93 100644 --- a/apps/cli/src/commands/snippets/download/download.integration.test.ts +++ b/apps/cli/src/commands/snippets/download/download.integration.test.ts @@ -13,7 +13,7 @@ import { mockTelemetryStateTracked, useTempWorkdir, } from "../../../../tests/helpers/command-mocks.ts"; -import { describeHostedRemoteSelection } from "../../../../tests/helpers/hosted-remote-selection.ts"; +import { describeHostedTargetSelection } from "../../../../tests/helpers/hosted-target-selection.ts"; import { assertPermissionDrift } from "../../../../tests/helpers/permission-drift.ts"; import { projectResponse, @@ -437,12 +437,9 @@ describe("snippets download integration", () => { }).pipe(Effect.provide(layer)); }); - describeHostedRemoteSelection({ - command: ["snippets", "download"], - args: [VALID_ID], - workdir: () => tempRoot.current, - respond: (request) => jsonResponse(request, 200, SNIPPET_RESPONSE), - run: (selector, layer) => - snippetsDownload({ ...selector, snippetId: VALID_ID }).pipe(Effect.provide(layer)), + describeHostedTargetSelection("snippets download", { + operands: [VALID_ID], + respond: (request) => Effect.succeed(jsonResponse(request, 200, SNIPPET_RESPONSE)), + run: (flags) => snippetsDownload({ ...flags, snippetId: VALID_ID }), }); }); diff --git a/apps/cli/src/commands/snippets/list/list.integration.test.ts b/apps/cli/src/commands/snippets/list/list.integration.test.ts index aee6da721d..c6e9c9d2c0 100644 --- a/apps/cli/src/commands/snippets/list/list.integration.test.ts +++ b/apps/cli/src/commands/snippets/list/list.integration.test.ts @@ -14,7 +14,7 @@ import { useTempWorkdir, } from "../../../../tests/helpers/command-mocks.ts"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; -import { describeHostedRemoteSelection } from "../../../../tests/helpers/hosted-remote-selection.ts"; +import { describeHostedTargetSelection } from "../../../../tests/helpers/hosted-target-selection.ts"; import { assertPermissionDrift } from "../../../../tests/helpers/permission-drift.ts"; import { realLinkedProjectCache } from "../../../../tests/helpers/permission-real-layers.ts"; import { snippetsList } from "./list.handler.ts"; @@ -464,10 +464,8 @@ describe("snippets list integration", () => { }).pipe(Effect.provide(layer)); }); - describeHostedRemoteSelection({ - command: ["snippets", "list"], - workdir: () => tempRoot.current, - respond: (request) => jsonResponse(request, 200, SINGLE_RESPONSE), - run: (selector, layer) => snippetsList(selector).pipe(Effect.provide(layer)), + describeHostedTargetSelection("snippets list", { + respond: (request) => Effect.succeed(jsonResponse(request, 200, SINGLE_RESPONSE)), + run: (flags) => snippetsList(flags), }); }); diff --git a/apps/cli/src/commands/ssl-enforcement/get/get.integration.test.ts b/apps/cli/src/commands/ssl-enforcement/get/get.integration.test.ts index 202c962614..cde36a03b3 100644 --- a/apps/cli/src/commands/ssl-enforcement/get/get.integration.test.ts +++ b/apps/cli/src/commands/ssl-enforcement/get/get.integration.test.ts @@ -15,7 +15,7 @@ import { mockTelemetryStateTracked, useTempWorkdir, } from "../../../../tests/helpers/command-mocks.ts"; -import { describeHostedRemoteSelection } from "../../../../tests/helpers/hosted-remote-selection.ts"; +import { describeHostedTargetSelection } from "../../../../tests/helpers/hosted-target-selection.ts"; import { assertPermissionDrift } from "../../../../tests/helpers/permission-drift.ts"; import { projectResponse, @@ -438,10 +438,8 @@ describe("ssl-enforcement get integration", () => { }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), ); - describeHostedRemoteSelection({ - command: ["ssl-enforcement", "get"], - workdir: () => tempRoot.current, - respond: (request) => jsonResponse(request, 200, SSL_ENFORCED), - run: (selector, layer) => sslEnforcementGet(selector).pipe(Effect.provide(layer)), + describeHostedTargetSelection("ssl-enforcement get", { + respond: (request) => Effect.succeed(jsonResponse(request, 200, SSL_ENFORCED)), + run: (flags) => sslEnforcementGet(flags), }); }); 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 b07c5a66b9..6cd5f58f9c 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 @@ -3,7 +3,7 @@ import { type V1GetSslEnforcementConfigOutput } from "@supabase/api/effect"; import { describe, expect, it } from "@effect/vitest"; import { Cause, Effect, Exit, FileSystem, Layer, Option, Path, Schema } from "effect"; import { describeHostedConsent } from "../../../../tests/helpers/hosted-consent.ts"; -import { describeHostedRemoteSelection } from "../../../../tests/helpers/hosted-remote-selection.ts"; +import { describeHostedTargetSelection } from "../../../../tests/helpers/hosted-target-selection.ts"; import { mockOutput, mockTty } from "../../../../tests/helpers/mocks.ts"; import { VALID_REF, @@ -574,18 +574,16 @@ describe("ssl-enforcement update integration", () => { }).pipe(Effect.provide(layer)); }); - describeHostedRemoteSelection({ - command: ["ssl-enforcement", "update"], - args: ["--enable-db-ssl-enforcement"], - workdir: () => tempRoot.current, + describeHostedTargetSelection("ssl-enforcement update", { + operands: ["--enable-db-ssl-enforcement"], activeFlags: { "enable-db-ssl-enforcement": true, "disable-db-ssl-enforcement": false }, - respond: (request) => jsonResponse(request, 200, SSL_ENFORCED), - run: (selector, layer) => + respond: (request) => Effect.succeed(jsonResponse(request, 200, SSL_ENFORCED)), + run: (flags) => sslEnforcementUpdate({ - ...selector, + ...flags, enableDbSslEnforcement: true, disableDbSslEnforcement: false, - }).pipe(Effect.provide(layer)), + }), }); }); 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 4061a9ea47..bbdbb03318 100644 --- a/apps/cli/src/commands/sso/add/add.integration.test.ts +++ b/apps/cli/src/commands/sso/add/add.integration.test.ts @@ -25,7 +25,7 @@ import { useTempWorkdir, } from "../../../../tests/helpers/command-mocks.ts"; import { describeHostedConsent } from "../../../../tests/helpers/hosted-consent.ts"; -import { describeHostedRemoteSelection } from "../../../../tests/helpers/hosted-remote-selection.ts"; +import { describeHostedTargetSelection } from "../../../../tests/helpers/hosted-target-selection.ts"; import { mockAnalytics, mockOutput, mockTty } from "../../../../tests/helpers/mocks.ts"; import { assertPermissionDrift } from "../../../../tests/helpers/permission-drift.ts"; import { @@ -1343,10 +1343,8 @@ describeHostedConsent("sso add", { }), }); -describeHostedRemoteSelection({ - command: ["sso", "add"], - args: ["--type", "saml"], - workdir: () => tempRoot.current, - respond: (request) => jsonResponse(request, 201, RESPONSE_PROVIDER), - run: (selector, layer) => ssoAdd({ ...defaultFlags, ...selector }).pipe(Effect.provide(layer)), +describeHostedTargetSelection("sso add", { + operands: ["--type", "saml"], + respond: (request) => Effect.succeed(jsonResponse(request, 201, RESPONSE_PROVIDER)), + run: (flags) => ssoAdd({ ...defaultFlags, ...flags }), }); diff --git a/apps/cli/src/commands/sso/info/info.integration.test.ts b/apps/cli/src/commands/sso/info/info.integration.test.ts index 62f31b878e..2d4dbec844 100644 --- a/apps/cli/src/commands/sso/info/info.integration.test.ts +++ b/apps/cli/src/commands/sso/info/info.integration.test.ts @@ -12,7 +12,7 @@ import { mockTelemetryStateTracked, useTempWorkdir, } from "../../../../tests/helpers/command-mocks.ts"; -import { describeHostedRemoteSelection } from "../../../../tests/helpers/hosted-remote-selection.ts"; +import { describeHostedTargetSelection } from "../../../../tests/helpers/hosted-target-selection.ts"; import { assertPermissionDrift } from "../../../../tests/helpers/permission-drift.ts"; import { projectResponse, @@ -205,9 +205,7 @@ describe("sso info integration", () => { }); }); -describeHostedRemoteSelection({ - command: ["sso", "info"], - workdir: () => tempRoot.current, - respond: (request) => jsonResponse(request, 200, {}), - run: (selector, layer) => ssoInfo(selector).pipe(Effect.provide(layer)), +describeHostedTargetSelection("sso info", { + respond: (request) => Effect.succeed(jsonResponse(request, 200, {})), + run: (flags) => ssoInfo(flags), }); diff --git a/apps/cli/src/commands/sso/list/list.integration.test.ts b/apps/cli/src/commands/sso/list/list.integration.test.ts index 0bb0af4c42..0ea2e51daa 100644 --- a/apps/cli/src/commands/sso/list/list.integration.test.ts +++ b/apps/cli/src/commands/sso/list/list.integration.test.ts @@ -12,7 +12,7 @@ import { mockTelemetryStateTracked, useTempWorkdir, } from "../../../../tests/helpers/command-mocks.ts"; -import { describeHostedRemoteSelection } from "../../../../tests/helpers/hosted-remote-selection.ts"; +import { describeHostedTargetSelection } from "../../../../tests/helpers/hosted-target-selection.ts"; import { assertPermissionDrift } from "../../../../tests/helpers/permission-drift.ts"; import { projectResponse, @@ -499,9 +499,7 @@ describe("sso list integration", () => { }); }); -describeHostedRemoteSelection({ - command: ["sso", "list"], - workdir: () => tempRoot.current, - respond: (request) => jsonResponse(request, 200, { items: [PROVIDER_ITEM] }), - run: (selector, layer) => ssoList(selector).pipe(Effect.provide(layer)), +describeHostedTargetSelection("sso list", { + respond: (request) => Effect.succeed(jsonResponse(request, 200, { items: [PROVIDER_ITEM] })), + run: (flags) => ssoList(flags), }); 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 6517086057..71587c9616 100644 --- a/apps/cli/src/commands/sso/remove/remove.integration.test.ts +++ b/apps/cli/src/commands/sso/remove/remove.integration.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from "@effect/vitest"; import { Cause, Effect, Exit, FileSystem, Layer, Option, Schema } from "effect"; import * as HttpClientResponse from "effect/http/HttpClientResponse"; import { describeHostedConsent } from "../../../../tests/helpers/hosted-consent.ts"; -import { describeHostedRemoteSelection } from "../../../../tests/helpers/hosted-remote-selection.ts"; +import { describeHostedTargetSelection } from "../../../../tests/helpers/hosted-target-selection.ts"; import { mockAnalytics, mockOutput, mockTty } from "../../../../tests/helpers/mocks.ts"; import { buildTestRuntime, @@ -431,11 +431,8 @@ describeHostedConsent("sso remove", { }), }); -describeHostedRemoteSelection({ - command: ["sso", "remove"], - args: [VALID_PROVIDER_ID], - workdir: () => tempRoot.current, - respond: (request) => jsonResponse(request, 200, PROVIDER), - run: (selector, layer) => - ssoRemove({ ...selector, providerId: VALID_PROVIDER_ID }).pipe(Effect.provide(layer)), +describeHostedTargetSelection("sso remove", { + operands: [VALID_PROVIDER_ID], + respond: (request) => Effect.succeed(jsonResponse(request, 200, PROVIDER)), + run: (flags) => ssoRemove({ ...flags, providerId: VALID_PROVIDER_ID }), }); diff --git a/apps/cli/src/commands/sso/show/show.integration.test.ts b/apps/cli/src/commands/sso/show/show.integration.test.ts index 2ce142150b..55ea714579 100644 --- a/apps/cli/src/commands/sso/show/show.integration.test.ts +++ b/apps/cli/src/commands/sso/show/show.integration.test.ts @@ -12,7 +12,7 @@ import { mockTelemetryStateTracked, useTempWorkdir, } from "../../../../tests/helpers/command-mocks.ts"; -import { describeHostedRemoteSelection } from "../../../../tests/helpers/hosted-remote-selection.ts"; +import { describeHostedTargetSelection } from "../../../../tests/helpers/hosted-target-selection.ts"; import { assertPermissionDrift } from "../../../../tests/helpers/permission-drift.ts"; import { projectResponse, @@ -410,13 +410,8 @@ UpdatedAt = "2023-03-28T13:50:14.464Z" }); }); -describeHostedRemoteSelection({ - command: ["sso", "show"], - args: [VALID_PROVIDER_ID], - workdir: () => tempRoot.current, - respond: (request) => jsonResponse(request, 200, PROVIDER), - run: (selector, layer) => - ssoShow({ ...selector, providerId: VALID_PROVIDER_ID, metadata: false }).pipe( - Effect.provide(layer), - ), +describeHostedTargetSelection("sso show", { + operands: [VALID_PROVIDER_ID], + respond: (request) => Effect.succeed(jsonResponse(request, 200, PROVIDER)), + run: (flags) => ssoShow({ ...flags, providerId: VALID_PROVIDER_ID, metadata: false }), }); 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 194958ad2f..2971500cef 100644 --- a/apps/cli/src/commands/sso/update/update.integration.test.ts +++ b/apps/cli/src/commands/sso/update/update.integration.test.ts @@ -17,7 +17,7 @@ import { import * as HttpClientResponse from "effect/http/HttpClientResponse"; import { describeHostedConsent } from "../../../../tests/helpers/hosted-consent.ts"; -import { describeHostedRemoteSelection } from "../../../../tests/helpers/hosted-remote-selection.ts"; +import { describeHostedTargetSelection } from "../../../../tests/helpers/hosted-target-selection.ts"; import { mockAnalytics, mockOutput, @@ -1697,11 +1697,11 @@ describeHostedConsent("sso update", { }), }); -describeHostedRemoteSelection({ - command: ["sso", "update"], - args: [VALID_PROVIDER_ID], - workdir: () => tempRoot.current, +describeHostedTargetSelection("sso update", { + operands: [VALID_PROVIDER_ID], respond: (request) => - jsonResponse(request, 200, request.method === "GET" ? EXISTING_PROVIDER : RESPONSE_PROVIDER), - run: (selector, layer) => ssoUpdate({ ...defaultFlags, ...selector }).pipe(Effect.provide(layer)), + Effect.succeed( + jsonResponse(request, 200, request.method === "GET" ? EXISTING_PROVIDER : RESPONSE_PROVIDER), + ), + run: (flags) => ssoUpdate({ ...defaultFlags, ...flags }), }); 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 259e0e7e1c..d3b93b1c65 100644 --- a/apps/cli/src/commands/storage/cp/cp.integration.test.ts +++ b/apps/cli/src/commands/storage/cp/cp.integration.test.ts @@ -17,6 +17,7 @@ import { generateLocalJwt } from "../../../command-internal/local-jwt.ts"; import { StorageUnsupportedOperationError } from "../storage.errors.ts"; import { storageCp } from "./cp.handler.ts"; import type { StorageCpFlags } from "./cp.command.ts"; +import { warningsOf } from "../../../../tests/helpers/remote-selection.ts"; const FLAG_REF = "flagflagflagflagflag"; const DB_URL = "postgresql://postgres:postgres@127.0.0.1:54322/postgres"; @@ -610,9 +611,7 @@ describe("storage cp", () => { requests.some((r) => r.url.startsWith(`https://${STORAGE_TEST_BRANCH_REF}.supabase.co`)), ).toBe(true); expect(linkedCache.cachedRef).toBe(STORAGE_TEST_BRANCH_REF); - expect( - out.messages.filter((message) => message.type === "warn").map((message) => message.message), - ).toEqual([]); + expect(warningsOf(out)).toEqual([]); }).pipe(Effect.provide(layer)); }); @@ -699,11 +698,7 @@ describe("storage cp", () => { }).pipe(Effect.exit); expect(Exit.isSuccess(exit)).toBe(true); expect(requests[0]?.url.startsWith(testCase.host)).toBe(true); - expect( - out.messages - .filter((message) => message.type === "warn") - .map((message) => message.message), - ).toEqual([testCase.warning]); + expect(warningsOf(out)).toEqual([testCase.warning]); }).pipe(Effect.provide(layer)); }); } 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 30dba8d0e6..2be38a2e3e 100644 --- a/apps/cli/src/commands/storage/ls/ls.integration.test.ts +++ b/apps/cli/src/commands/storage/ls/ls.integration.test.ts @@ -18,6 +18,7 @@ import { StorageGatewayStatusError } from "../../../command-internal/storage-gat import { storageLs } from "./ls.handler.ts"; import type { StorageLsFlags } from "./ls.command.ts"; import { generateLocalJwt } from "../../../command-internal/local-jwt.ts"; +import { warningsOf } from "../../../../tests/helpers/remote-selection.ts"; const FLAG_REF = "flagflagflagflagflag"; const DB_URL = "postgresql://postgres:postgres@127.0.0.1:54322/postgres"; @@ -314,9 +315,7 @@ project_id = "env(STAGING_REF)" requests.some((r) => r.url.startsWith(`https://${STORAGE_TEST_BRANCH_REF}.supabase.co`)), ).toBe(true); expect(linkedCache.cachedRef).toBe(STORAGE_TEST_BRANCH_REF); - expect( - out.messages.filter((message) => message.type === "warn").map((message) => message.message), - ).toEqual([]); + expect(warningsOf(out)).toEqual([]); }); }); @@ -396,11 +395,7 @@ project_id = "env(STAGING_REF)" ); expect(Exit.isSuccess(exit)).toBe(true); expect(requests[0]?.url.startsWith(testCase.host)).toBe(true); - expect( - out.messages - .filter((message) => message.type === "warn") - .map((message) => message.message), - ).toEqual([testCase.warning]); + expect(warningsOf(out)).toEqual([testCase.warning]); }); }); } 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 da9332f61f..4576922142 100644 --- a/apps/cli/src/commands/storage/mv/mv.integration.test.ts +++ b/apps/cli/src/commands/storage/mv/mv.integration.test.ts @@ -16,6 +16,7 @@ import { VALID_REF, useTempWorkdir, withEnvVar } from "../../../../tests/helpers import { assertPermissionDrift } from "../../../../tests/helpers/permission-drift.ts"; import { storageMv } from "./mv.handler.ts"; import type { StorageMvFlags } from "./mv.command.ts"; +import { warningsOf } from "../../../../tests/helpers/remote-selection.ts"; const FLAG_REF = "flagflagflagflagflag"; const DB_URL = "postgresql://postgres:postgres@127.0.0.1:54322/postgres"; @@ -334,9 +335,7 @@ describe("storage mv", () => { requests.some((r) => r.url.startsWith(`https://${STORAGE_TEST_BRANCH_REF}.supabase.co`)), ).toBe(true); expect(linkedCache.cachedRef).toBe(STORAGE_TEST_BRANCH_REF); - expect( - out.messages.filter((message) => message.type === "warn").map((message) => message.message), - ).toEqual([]); + expect(warningsOf(out)).toEqual([]); }).pipe(Effect.provide(layer)); }); @@ -419,11 +418,7 @@ describe("storage mv", () => { }).pipe(Effect.exit); expect(Exit.isSuccess(exit)).toBe(true); expect(requests[0]?.url.startsWith(testCase.host)).toBe(true); - expect( - out.messages - .filter((message) => message.type === "warn") - .map((message) => message.message), - ).toEqual([testCase.warning]); + expect(warningsOf(out)).toEqual([testCase.warning]); }).pipe(Effect.provide(layer)); }); } 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 a8d2eaa259..932fec1083 100644 --- a/apps/cli/src/commands/storage/rm/rm.integration.test.ts +++ b/apps/cli/src/commands/storage/rm/rm.integration.test.ts @@ -24,6 +24,7 @@ import { } from "../../../../tests/helpers/command-mocks.ts"; import { assertPermissionDrift } from "../../../../tests/helpers/permission-drift.ts"; import { storageRm } from "./rm.handler.ts"; +import { warningsOf } from "../../../../tests/helpers/remote-selection.ts"; const writeAncestorConfig = Effect.fnUntraced(function* (root: string, toml: string) { const fs = yield* FileSystem.FileSystem; @@ -803,9 +804,7 @@ describe("storage rm", () => { requests.some((r) => r.url.startsWith(`https://${STORAGE_TEST_BRANCH_REF}.supabase.co`)), ).toBe(true); expect(linkedCache.cachedRef).toBe(STORAGE_TEST_BRANCH_REF); - expect( - out.messages.filter((message) => message.type === "warn").map((message) => message.message), - ).toEqual([]); + expect(warningsOf(out)).toEqual([]); }).pipe(Effect.provide(layer)); }); @@ -887,11 +886,7 @@ describe("storage rm", () => { const exit = yield* storageRm({ ...RM_ARGS, ...testCase.flags }).pipe(Effect.exit); expect(Exit.isSuccess(exit)).toBe(true); expect(requests[0]?.url.startsWith(testCase.host)).toBe(true); - expect( - out.messages - .filter((message) => message.type === "warn") - .map((message) => message.message), - ).toEqual([testCase.warning]); + expect(warningsOf(out)).toEqual([testCase.warning]); }).pipe(Effect.provide(layer)); }); } 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 4e8b33e81f..0e13f8e8c2 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 @@ -20,7 +20,7 @@ import { unverifiedTargetBanner, } from "../../../tests/helpers/command-mocks.ts"; import { describeHostedConsent } from "../../../tests/helpers/hosted-consent.ts"; -import { describeHostedRemoteSelection } from "../../../tests/helpers/hosted-remote-selection.ts"; +import { describeHostedTargetSelection } from "../../../tests/helpers/hosted-target-selection.ts"; import { assertPermissionDrift } from "../../../tests/helpers/permission-drift.ts"; import { projectResponse, @@ -1084,42 +1084,34 @@ describeHostedConsent("vanity-subdomains delete", { }), }); -describeHostedRemoteSelection({ - command: ["vanity-subdomains", "get"], - workdir: () => tempRoot.current, - respond: (request) => jsonResponse(request, 200, SAMPLE_GET), - run: (selector, layer) => vanitySubdomainsGet(selector).pipe(Effect.provide(layer)), +describeHostedTargetSelection("vanity-subdomains get", { + respond: (request) => Effect.succeed(jsonResponse(request, 200, SAMPLE_GET)), + run: (flags) => vanitySubdomainsGet(flags), }); -describeHostedRemoteSelection({ - command: ["vanity-subdomains", "check-availability"], - args: ["--desired-subdomain", "example.com"], - workdir: () => tempRoot.current, +describeHostedTargetSelection("vanity-subdomains check-availability", { + operands: ["--desired-subdomain", "example.com"], activeFlags: { "desired-subdomain": "example.com" }, - respond: (request) => jsonResponse(request, 201, SAMPLE_CHECK), - run: (selector, layer) => + respond: (request) => Effect.succeed(jsonResponse(request, 201, SAMPLE_CHECK)), + run: (flags) => vanitySubdomainsCheckAvailability({ - ...selector, + ...flags, desiredSubdomain: Option.some("example.com"), - }).pipe(Effect.provide(layer)), + }), }); -describeHostedRemoteSelection({ - command: ["vanity-subdomains", "activate"], - args: ["--desired-subdomain", "example.com"], - workdir: () => tempRoot.current, +describeHostedTargetSelection("vanity-subdomains activate", { + operands: ["--desired-subdomain", "example.com"], activeFlags: { "desired-subdomain": "example.com" }, - respond: (request) => jsonResponse(request, 201, SAMPLE_ACTIVATE), - run: (selector, layer) => + respond: (request) => Effect.succeed(jsonResponse(request, 201, SAMPLE_ACTIVATE)), + run: (flags) => vanitySubdomainsActivate({ - ...selector, + ...flags, desiredSubdomain: Option.some("example.com"), - }).pipe(Effect.provide(layer)), + }), }); -describeHostedRemoteSelection({ - command: ["vanity-subdomains", "delete"], - workdir: () => tempRoot.current, - respond: (request) => jsonResponse(request, 200, null), - run: (selector, layer) => vanitySubdomainsDelete(selector).pipe(Effect.provide(layer)), +describeHostedTargetSelection("vanity-subdomains delete", { + respond: (request) => Effect.succeed(jsonResponse(request, 200, null)), + run: (flags) => vanitySubdomainsDelete(flags), }); diff --git a/apps/cli/tests/helpers/compute-target-selection.ts b/apps/cli/tests/helpers/compute-target-selection.ts index ba01e10f84..7325f08a21 100644 --- a/apps/cli/tests/helpers/compute-target-selection.ts +++ b/apps/cli/tests/helpers/compute-target-selection.ts @@ -4,9 +4,10 @@ import { Effect, FileSystem, Layer, Path } from "effect"; import { commandRuntimeLayer } from "../../src/shared/runtime/command-runtime.layer.ts"; import { COMPUTE_PROJECT_REF, setupCompute } from "./compute.ts"; import type { ComputeHttpRoutes } from "./compute.ts"; -import { describeTargetSelectionMatrix, branchListRow } from "./hosted-target-selection.ts"; +import { describeTargetSelectionMatrix } from "./hosted-target-selection.ts"; import type { TargetSelectionFlags } from "./hosted-target-selection.ts"; import type { ActiveFlags } from "./permission-registry.ts"; +import { branchRow, warningsOf } from "./remote-selection.ts"; type ComputeSetup = ReturnType<typeof setupCompute>; @@ -59,7 +60,7 @@ export function describeComputeTargetSelection( ...scenario.routes(expectedRef), [`GET /v1/projects/${COMPUTE_PROJECT_REF}/branches`]: { status: 200, - body: [branchListRow("feature-x")], + body: [branchRow("feature-x")], }, }, }); @@ -69,10 +70,7 @@ export function describeComputeTargetSelection( commandRuntimeLayer(path.split(" ")).pipe(Layer.provide(BunServices.layer)), ), requests: () => http.driftRequests, - warnings: () => - out.messages - .filter((message) => message.type === "warn") - .map((message) => message.message), + warnings: () => warningsOf(out), }; }, }); diff --git a/apps/cli/tests/helpers/hosted-remote-selection.ts b/apps/cli/tests/helpers/hosted-remote-selection.ts deleted file mode 100644 index 3b98a87445..0000000000 --- a/apps/cli/tests/helpers/hosted-remote-selection.ts +++ /dev/null @@ -1,202 +0,0 @@ -import { BunServices } from "@effect/platform-bun"; -import { describe, expect, it } from "@effect/vitest"; -import { Effect, FileSystem, Layer, Option, Stdio } from "effect"; -import type * as HttpClientRequest from "effect/http/HttpClientRequest"; -import type * as HttpClientResponse from "effect/http/HttpClientResponse"; - -import { YesFlag } from "../../src/command-internal/global-flags.ts"; -import type { RemoteValue } from "../../src/command-internal/target/remote-value.ts"; -import { TargetPolicy } from "../../src/command-internal/target/target-policy.ts"; -import { commandRuntimeLayer } from "../../src/shared/runtime/command-runtime.layer.ts"; -import { - VALID_REF, - buildTestRuntime, - jsonResponse, - mockCommandPlatformApi, - mockCommandSettings, - mockLinkedProjectCacheTracked, -} from "./command-mocks.ts"; -import { mockOutput } from "./mocks.ts"; -import { assertPermissionDrift } from "./permission-drift.ts"; -import type { ActiveFlags } from "./permission-registry.ts"; - -const OTHER_REF = "qrstuvwxyzabcdefghij"; -const BRANCH_REF = "branchprojectrefabcd"; -const DB_URL = "postgresql://postgres:pw@db.example.com:5432/postgres"; - -const branchRow = (name: string) => ({ - id: "11111111-2222-4333-8444-555555555555", - name, - project_ref: BRANCH_REF, - parent_project_ref: VALID_REF, - is_default: false, - persistent: false, - status: "MIGRATIONS_PASSED", - created_at: "2026-05-27T01:02:03Z", - updated_at: "2026-05-27T01:02:04Z", - with_data: true, -}); - -const hostedSelectionLayer = (options: { - readonly command: ReadonlyArray<string>; - readonly argv: ReadonlyArray<string>; - readonly workdir: string; - readonly respond: ( - request: HttpClientRequest.HttpClientRequest, - ) => HttpClientResponse.HttpClientResponse; -}) => { - const out = mockOutput({ format: "text" }); - const api = mockCommandPlatformApi({ - handler: (request) => - Effect.succeed( - request.url.endsWith("/branches") - ? jsonResponse(request, 200, [branchRow("feature-x")]) - : options.respond(request), - ), - }); - const cache = mockLinkedProjectCacheTracked(); - const layer = Layer.mergeAll( - buildTestRuntime({ - out, - api, - cliSettings: mockCommandSettings({ workdir: options.workdir, projectId: Option.none() }), - cliArgs: options.argv, - linkedProjectCache: cache.layer, - }), - commandRuntimeLayer(options.command).pipe(Layer.provide(BunServices.layer)), - Layer.succeed(YesFlag, true), - Layer.succeed(TargetPolicy, { group: "hosted", consent: "no" }), - Stdio.layerTest({ args: Effect.succeed(options.argv) }), - ); - return { layer, out, api, cache }; -}; - -type HostedSelectionLayer = ReturnType<typeof hostedSelectionLayer>["layer"]; - -interface HostedSelector { - readonly remote: Option.Option<RemoteValue>; - readonly projectRef: Option.Option<string>; -} - -interface HostedRemoteSelectionOptions { - /** The command path, e.g. `["network-bans", "get"]`. */ - readonly command: ReadonlyArray<string>; - /** Argv between the command path and the selector; plain tokens only, so the deprecation hint echoes them verbatim. */ - readonly args?: ReadonlyArray<string>; - readonly workdir: () => string; - /** The command's own API answer; the branch listing is answered by the helper. */ - readonly respond: ( - request: HttpClientRequest.HttpClientRequest, - ) => HttpClientResponse.HttpClientResponse; - /** Flags that decide which operations the command declares, for the permission drift check. */ - readonly activeFlags?: ActiveFlags; - /** Runs the handler with `selector` and the flags `args` stands for. */ - readonly run: ( - selector: HostedSelector, - layer: HostedSelectionLayer, - ) => Effect.Effect<unknown, unknown, never>; -} - -const none: HostedSelector = { remote: Option.none(), projectRef: Option.none() }; - -/** - * Registers the project-selection cases of a hosted-only command: `--remote=<ref>`, a bare - * `--remote`, `--remote=<branch>`, a rejected `--remote=<url>` and the deprecated `--project-ref`. - */ -export const describeHostedRemoteSelection = (options: HostedRemoteSelectionOptions): void => { - const commandText = options.command.join(" "); - const args = options.args ?? []; - - const harness = (selectorArgs: ReadonlyArray<string>) => { - const argv = [...options.command, ...args, ...selectorArgs]; - return hostedSelectionLayer({ - command: options.command, - argv, - workdir: options.workdir(), - respond: options.respond, - }); - }; - const linkParent = Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - yield* fs.makeDirectory(`${options.workdir()}/supabase/.temp`, { recursive: true }); - yield* fs.writeFileString(`${options.workdir()}/supabase/.temp/project-ref`, VALID_REF); - }).pipe(Effect.provide(BunServices.layer)); - const expectActedOn = (run: ReturnType<typeof harness>, ref: string) => { - expect(run.cache.cachedRef).toBe(ref); - const pathRefs = run.api.requests - .filter((request) => !request.url.endsWith("/branches")) - .flatMap((request) => /\/v1\/projects\/([^/?]+)\//.exec(request.url)?.[1] ?? []); - expect(pathRefs).toEqual(pathRefs.map(() => ref)); - }; - const warnings = (out: ReturnType<typeof harness>["out"]) => - out.messages.filter((message) => message.type === "warn").map((message) => message.message); - - describe(`${commandText} remote selection`, () => { - it.effect("acts on the project --remote=<ref> names, with no deprecation warning", () => { - const run = harness([`--remote=${OTHER_REF}`]); - return Effect.gen(function* () { - yield* options.run( - { ...none, remote: Option.some({ _tag: "Ref", ref: OTHER_REF }) }, - run.layer, - ); - expectActedOn(run, OTHER_REF); - expect(warnings(run.out)).toEqual([]); - }); - }); - - it.effect("acts on the linked project for a bare --remote", () => { - const run = harness(["--remote"]); - return Effect.gen(function* () { - yield* linkParent; - yield* options.run({ ...none, remote: Option.some({ _tag: "Linked" }) }, run.layer); - expectActedOn(run, VALID_REF); - expect(warnings(run.out)).toEqual([]); - }); - }); - - it.effect("acts on the project of the branch --remote=<branch> names", () => { - const run = harness(["--remote=feature-x"]); - return Effect.gen(function* () { - yield* linkParent; - yield* options.run( - { ...none, remote: Option.some({ _tag: "Branch", name: "feature-x" }) }, - run.layer, - ); - expect(`${run.api.requests[0]?.method} ${run.api.requests[0]?.url}`).toBe( - `GET https://api.supabase.com/v1/projects/${VALID_REF}/branches`, - ); - expectActedOn(run, BRANCH_REF); - assertPermissionDrift({ - command: commandText, - activeFlags: { ...options.activeFlags, remote: "feature-x" }, - requests: run.api.requests, - }); - }); - }); - - it.effect("rejects --remote=<url> before any request", () => { - const { layer, api } = harness([`--remote=${DB_URL}`]); - return Effect.gen(function* () { - const error = yield* options - .run({ ...none, remote: Option.some({ _tag: "DbUrl", url: DB_URL }) }, layer) - .pipe(Effect.flip); - expect(error).toMatchObject({ - _tag: "TargetNeedsProjectError", - message: `\`supabase ${commandText}\` needs a project, not a database URL.`, - }); - expect(api.requests).toEqual([]); - }); - }); - - it.effect("keeps --project-ref working and points at --remote=<ref>", () => { - const run = harness(["--project-ref", OTHER_REF]); - return Effect.gen(function* () { - yield* options.run({ ...none, projectRef: Option.some(OTHER_REF) }, run.layer); - expectActedOn(run, OTHER_REF); - expect(warnings(run.out)).toEqual([ - `--project-ref is deprecated and will be removed in v4. Use --remote instead: ${["supabase", ...options.command, ...args].join(" ")} --remote=${OTHER_REF}`, - ]); - }); - }); - }); -}; diff --git a/apps/cli/tests/helpers/hosted-target-selection.ts b/apps/cli/tests/helpers/hosted-target-selection.ts index b6626ba2f5..ebdd9d1905 100644 --- a/apps/cli/tests/helpers/hosted-target-selection.ts +++ b/apps/cli/tests/helpers/hosted-target-selection.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, Path, Stdio } from "effect"; import { YesFlag } from "../../src/command-internal/global-flags.ts"; import type { RemoteValue } from "../../src/command-internal/target/remote-value.ts"; @@ -12,33 +12,20 @@ import { jsonResponse, mockCommandPlatformApi, mockCommandSettings, + mockLinkedProjectCacheTracked, useTempWorkdir, } from "./command-mocks.ts"; import type { ApiHandler } from "./command-mocks.ts"; import { mockOutput } from "./mocks.ts"; import { assertPermissionDrift } from "./permission-drift.ts"; import type { ActiveFlags, PermissionVariant } from "./permission-registry.ts"; +import { BRANCH_PROJECT_REF, branchRow, warningsOf } from "./remote-selection.ts"; const OTHER_REF = "qrstuvwxyzabcdefghij"; -const BRANCH_REF = "branchprojectrefabcd"; const DB_URL = "postgresql://postgres:pw@db.example.com:5432/postgres"; const API_URL = "https://api.supabase.com"; const PROJECT_REF_IN_URL = /\/v[12]\/projects\/([a-z]{20})(?:[/?]|$)/; -/** The branch list the harness answers a `--remote=feature-x` lookup with. */ -export const branchListRow = (name: string) => ({ - id: "11111111-2222-4333-8444-555555555555", - name, - project_ref: BRANCH_REF, - parent_project_ref: VALID_REF, - is_default: false, - persistent: false, - status: "MIGRATIONS_PASSED", - created_at: "2026-05-27T01:02:03Z", - updated_at: "2026-05-27T01:02:04Z", - with_data: true, -}); - export interface TargetSelectionFlags { readonly remote: Option.Option<RemoteValue>; readonly projectRef: Option.Option<string>; @@ -54,6 +41,8 @@ interface TargetSelectionHarness<R> { readonly layer: Layer.Layer<R, unknown>; readonly requests: () => ReadonlyArray<RecordedCall>; readonly warnings: () => ReadonlyArray<string>; + /** For a leaf whose requests never name its project (`sso info`, `snippets list`): the ref it cached as its target. */ + readonly cachedRef?: () => string | undefined; } export interface TargetSelectionMatrix<R> { @@ -92,9 +81,9 @@ const projectRefsOf = (requests: ReadonlyArray<RecordedCall>): ReadonlyArray<str /** * Registers the `--remote` selection matrix for the leaf `path`: `--remote=<ref>` with no - * warning, the `--project-ref` alias with its deprecation warning, a database URL rejected before - * any request, and a branch name looked up under the linked project (or rejected, for a - * parent-scoped command). + * warning, a bare `--remote` on the linked project, the `--project-ref` alias with its + * deprecation warning, a database URL rejected before any request, and a branch name looked up + * under the linked project (or rejected, for a parent-scoped command). */ export function describeTargetSelectionMatrix<R>( path: string, @@ -106,8 +95,10 @@ export function describeTargetSelectionMatrix<R>( const extraFlags = matrix.activeFlags ?? {}; const driftVariant = matrix.variant === undefined ? {} : { variant: matrix.variant }; // A parent-scoped command goes on to touch the branch's own project, so only the first project it reaches is the parent. - const expectActedOn = (requests: ReadonlyArray<RecordedCall>, ref: string) => { - const refs = projectRefsOf(requests); + const expectActedOn = (run: TargetSelectionHarness<R>, ref: string) => { + const refs = projectRefsOf(run.requests()); + if (run.cachedRef !== undefined) expect(run.cachedRef()).toBe(ref); + if (run.cachedRef !== undefined && refs.length === 0) return; if (matrix.mode === "parent") expect(refs[0]).toBe(ref); else expect(refs).toEqual([ref]); }; @@ -125,7 +116,7 @@ export function describeTargetSelectionMatrix<R>( }); it.effect("uses the project --remote=<ref> names, with no deprecation warning", () => { - const { layer, requests, warnings } = harness([`--remote=${OTHER_REF}`], OTHER_REF); + const run = harness([`--remote=${OTHER_REF}`], OTHER_REF); return Effect.gen(function* () { yield* prepare; yield* matrix.run({ @@ -133,32 +124,44 @@ export function describeTargetSelectionMatrix<R>( projectRef: Option.none(), }); - expectActedOn(requests(), OTHER_REF); - expect(warnings()).toEqual([]); + expectActedOn(run, OTHER_REF); + expect(run.warnings()).toEqual([]); assertPermissionDrift({ command: path, ...driftVariant, activeFlags: { ...extraFlags, remote: OTHER_REF }, - requests: requests(), + requests: run.requests(), }); - }).pipe(Effect.provide(layer)); + }).pipe(Effect.provide(run.layer)); + }); + + it.effect("acts on the linked project for a bare --remote, with no deprecation warning", () => { + const run = harness(["--remote"], VALID_REF); + return Effect.gen(function* () { + yield* prepare; + yield* matrix.linkParent(tempRoot.current); + yield* matrix.run({ remote: Option.some({ _tag: "Linked" }), projectRef: Option.none() }); + + expectActedOn(run, VALID_REF); + expect(run.warnings()).toEqual([]); + }).pipe(Effect.provide(run.layer)); }); it.effect("keeps --project-ref working and points at --remote=<ref>", () => { - const { layer, requests, warnings } = harness(["--project-ref", OTHER_REF], OTHER_REF); + const run = harness(["--project-ref", OTHER_REF], OTHER_REF); return Effect.gen(function* () { yield* prepare; yield* matrix.run({ remote: Option.none(), projectRef: Option.some(OTHER_REF) }); - expectActedOn(requests(), OTHER_REF); - expect(warnings()).toEqual([ + expectActedOn(run, OTHER_REF); + expect(run.warnings()).toEqual([ `--project-ref is deprecated and will be removed in v4. Use --remote instead: supabase ${commandLine} --remote=${OTHER_REF}`, ]); - }).pipe(Effect.provide(layer)); + }).pipe(Effect.provide(run.layer)); }); it.effect("rejects a database URL before any request", () => { - const { layer, requests } = harness([`--remote=${DB_URL}`], VALID_REF); + const run = harness([`--remote=${DB_URL}`], VALID_REF); return Effect.gen(function* () { yield* prepare; const error = yield* matrix @@ -169,13 +172,13 @@ export function describeTargetSelectionMatrix<R>( _tag: "TargetNeedsProjectError", message: `${commandName} needs a project, not a database URL.`, }); - expect(requests()).toEqual([]); - }).pipe(Effect.provide(layer)); + expect(run.requests()).toEqual([]); + }).pipe(Effect.provide(run.layer)); }); if (matrix.mode === "parent") { it.effect("rejects a branch name and points at --remote=<ref>", () => { - const { layer, requests } = harness(["--remote=feature-x"], VALID_REF); + const run = harness(["--remote=feature-x"], VALID_REF); return Effect.gen(function* () { yield* prepare; const error = yield* matrix @@ -189,14 +192,14 @@ export function describeTargetSelectionMatrix<R>( _tag: "TargetNeedsProjectError", message: `${commandName} acts on a parent project, so --remote takes its project ref, not a branch name. Use --remote=<ref>.`, }); - expect(requests()).toEqual([]); - }).pipe(Effect.provide(layer)); + expect(run.requests()).toEqual([]); + }).pipe(Effect.provide(run.layer)); }); return; } it.effect("acts on the project of the branch --remote=<branch> names", () => { - const { layer, requests } = harness(["--remote=feature-x"], BRANCH_REF); + const run = harness(["--remote=feature-x"], BRANCH_PROJECT_REF); return Effect.gen(function* () { yield* prepare; yield* matrix.linkParent(tempRoot.current); @@ -206,18 +209,23 @@ export function describeTargetSelectionMatrix<R>( projectRef: Option.none(), }); - expect(requests()[0]).toMatchObject({ + expect(run.requests()[0]).toMatchObject({ method: "GET", url: `${API_URL}/v1/projects/${VALID_REF}/branches`, }); - expect(projectRefsOf(requests())).toEqual([VALID_REF, BRANCH_REF]); + const refs = projectRefsOf(run.requests()); + if (run.cachedRef !== undefined) expect(run.cachedRef()).toBe(BRANCH_PROJECT_REF); + expect(refs[0]).toBe(VALID_REF); + if (run.cachedRef === undefined || refs.length > 1) { + expect(refs).toEqual([VALID_REF, BRANCH_PROJECT_REF]); + } assertPermissionDrift({ command: path, ...driftVariant, activeFlags: { ...extraFlags, remote: "feature-x" }, - requests: requests(), + requests: run.requests(), }); - }).pipe(Effect.provide(layer)); + }).pipe(Effect.provide(run.layer)); }); }); } @@ -225,12 +233,14 @@ export function describeTargetSelectionMatrix<R>( const hostedLayer = ( words: ReadonlyArray<string>, runtime: Parameters<typeof buildTestRuntime>[0], + argv: ReadonlyArray<string>, ) => Layer.mergeAll( buildTestRuntime(runtime), commandRuntimeLayer(words).pipe(Layer.provide(BunServices.layer)), Layer.succeed(YesFlag, true), Layer.succeed(TargetPolicy, { group: "hosted", consent: "yes" }), + Stdio.layerTest({ args: Effect.succeed(argv) }), ); /** Everything a hosted leaf's handler can require while `describeHostedTargetSelection` runs it. */ @@ -269,21 +279,25 @@ export function describeHostedTargetSelection( const api = mockCommandPlatformApi({ handler: (request) => mode === "project" && request.method === "GET" && request.url.endsWith("/branches") - ? Effect.succeed(jsonResponse(request, 200, [branchListRow("feature-x")])) + ? Effect.succeed(jsonResponse(request, 200, [branchRow("feature-x")])) : scenario.respond(request, expectedRef), }); + const cache = mockLinkedProjectCacheTracked(); return { - layer: hostedLayer(path.split(" "), { - out, - api, - cliSettings: mockCommandSettings({ workdir, projectId: Option.none() }), - cliArgs: selectorArgs, - }), + layer: hostedLayer( + path.split(" "), + { + out, + api, + cliSettings: mockCommandSettings({ workdir, projectId: Option.none() }), + cliArgs: selectorArgs, + linkedProjectCache: cache.layer, + }, + selectorArgs, + ), requests: () => api.requests, - warnings: () => - out.messages - .filter((message) => message.type === "warn") - .map((message) => message.message), + warnings: () => warningsOf(out), + cachedRef: () => cache.cachedRef, }; }, linkParent: (workdir) => diff --git a/apps/cli/tests/helpers/db-remote-selection.ts b/apps/cli/tests/helpers/remote-selection.ts similarity index 70% rename from apps/cli/tests/helpers/db-remote-selection.ts rename to apps/cli/tests/helpers/remote-selection.ts index a4f832a045..4d5234c209 100644 --- a/apps/cli/tests/helpers/db-remote-selection.ts +++ b/apps/cli/tests/helpers/remote-selection.ts @@ -1,11 +1,13 @@ import { Effect } from "effect"; +import { stripAnsi } from "./ansi.ts"; import { VALID_REF, jsonResponse, mockCommandPlatformApi } from "./command-mocks.ts"; import type { mockOutput } from "./mocks.ts"; export const BRANCH_PROJECT_REF = "branchprojectrefabcd"; -const branchRow = (name: string) => ({ +/** A branch of the project {@link VALID_REF} whose own project is {@link BRANCH_PROJECT_REF}. */ +export const branchRow = (name: string) => ({ id: "11111111-2222-4333-8444-555555555555", name, project_ref: BRANCH_PROJECT_REF, @@ -29,6 +31,9 @@ export const branchListApi = (name = "feature-x") => ), }); -/** The text of every warning the run printed. */ -export const warningsOf = (out: Pick<ReturnType<typeof mockOutput>, "messages">) => - out.messages.filter((message) => message.type === "warn").map((message) => message.message); +/** The text of every warning the run printed on stderr, without the `Warning: ` prefix. */ +export const warningsOf = (out: Pick<ReturnType<typeof mockOutput>, "stderrText">) => + stripAnsi(out.stderrText) + .split("\n") + .filter((line) => line.startsWith("Warning: ")) + .map((line) => line.slice("Warning: ".length)); From 15b4e3cdc1faeb5c28007903a60ed60ebd9abcbe Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Mon, 19 Oct 2026 13:47:55 +0100 Subject: [PATCH 140/291] docs(cli): align ADR 0033 and the target docs with the single branch resolver Take link out of the hosted group that takes --remote, describe one branch resolver, the selector mixing rules, the default branch as production and the shape of deprecation warnings, and move the other ADRs off --project-ref. --- apps/cli/docs/command-target.md | 25 ++++--- apps/cli/docs/supabase/db/diff.md | 2 +- ...diff-classification-and-managed-surface.md | 2 +- ...ull-write-strategy-and-scope-resolution.md | 4 +- docs/adr/0024-top-level-pull-orchestration.md | 4 +- ...t-selection-and-hosted-mutation-consent.md | 74 ++++++++++++------- 6 files changed, 67 insertions(+), 44 deletions(-) diff --git a/apps/cli/docs/command-target.md b/apps/cli/docs/command-target.md index a6d14d9ec5..d37bb5c936 100644 --- a/apps/cli/docs/command-target.md +++ b/apps/cli/docs/command-target.md @@ -2,23 +2,24 @@ 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. -Database commands run against the local stack unless `--remote` is passed: bare `--remote` is the linked project, and `--remote=<ref|branch|url>` is another project, a branch of the linked project, or a database URL. Hosted-only commands, which have no local side, use the linked project by default and take `--remote=<ref|branch>` for another. `--linked`, `--project-ref`, `--db-url` and `--local` still work in v3 but are deprecated and removed in v4; use `--remote`. +Database commands run against the local stack unless `--remote` is passed: bare `--remote` is the linked project, and `--remote=<ref|branch|url>` is another project, a branch of the linked project, or a database URL. Hosted-only commands, which have no local side, use the linked project by default and take `--remote=<ref|branch>` for another. Every command resolves a branch name or UUID the same way `supabase link` does, and a branch UUID also works without a linked project. `--linked`, `--project-ref`, `--db-url` and `--local` still work in v3 but are deprecated and removed in v4; use `--remote`. Their warning shows the command path, its operand placeholders and the `--remote` replacement, never your operands or other flags. ## 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 --remote for <name> (<ref>)` | -| `--remote=<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 | +| Target | Text | +| ------------------------------------- | -------------------------------------------------------------------------------------------- | +| Local stack | `local stack (<host>:<port>)` | +| Local stack, nothing selected, linked | `local stack (<host>:<port>) · default; pass --remote for <name> (<ref>)` | +| `--remote=<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 name> (<parent ref>) · branch <name> (<ref>)`, or `branch <ref>` for a UUID | +| The project's default branch | `project <name> (<ref>) · production` | -The `default; pass --remote` hint appears only when the checkout is linked and no `--remote` was passed. "Branch" is claimed only on positive evidence: a branch lookup by name or UUID, or a live probe. A cached link never proves one. +The `default; pass --remote` hint appears only when the checkout is linked and no `--remote` was passed. "Branch" is claimed only on positive evidence: a branch lookup by name or UUID, or a live probe. A cached link never proves one. A branch's parent shows as its bare ref when the cache doesn't know its name. ## JSON `target` @@ -30,7 +31,7 @@ The `default; pass --remote` hint appears only when the checkout is linked and n | `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`. +`environment` is `production` when the linked-project cache names the resolved ref or the resolved branch is the project's default branch, `branch` on positive evidence of any other branch, 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. diff --git a/apps/cli/docs/supabase/db/diff.md b/apps/cli/docs/supabase/db/diff.md index 86ff32ba0d..30a411e3a8 100644 --- a/apps/cli/docs/supabase/db/diff.md +++ b/apps/cli/docs/supabase/db/diff.md @@ -8,7 +8,7 @@ Diffs the local database by default, which requires the local development stack 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. -Explicit `--from`/`--to` mode always uses pg-delta. In this mode, `-f` is ignored and stdout (or `--output`) is a flattened representation for review, not a portable apply script. Do not apply it directly with plain `psql -f`: transactional units can contain `SET LOCAL` preambles that only take effect inside a transaction, while plans that mix transactional and non-transactional units cannot safely be wrapped in one transaction. To create an applicable migration, use normal target mode with `supabase db diff -f <name>`, then apply it through `supabase db reset` locally or `supabase db push --remote` against the linked project. These paths preserve the plan's per-unit transaction semantics. +In `--from`/`--to`, `remote` always means the linked project; passing `--remote` or one of its aliases together with `--from` or `--to` is an error. Explicit `--from`/`--to` mode always uses pg-delta. In this mode, `-f` is ignored and stdout (or `--output`) is a flattened representation for review, not a portable apply script. Do not apply it directly with plain `psql -f`: transactional units can contain `SET LOCAL` preambles that only take effect inside a transaction, while plans that mix transactional and non-transactional units cannot safely be wrapped in one transaction. To create an applicable migration, use normal target mode with `supabase db diff -f <name>`, then apply it through `supabase db reset` locally or `supabase db push --remote` against the linked project. These paths preserve the plan's per-unit transaction semantics. By default, all schemas in the target database are diffed. Use the `--schema public,extensions` flag to restrict diffing to a subset of schemas. diff --git a/docs/adr/0022-config-diff-classification-and-managed-surface.md b/docs/adr/0022-config-diff-classification-and-managed-surface.md index 25fc6a6937..c0e79ae6e8 100644 --- a/docs/adr/0022-config-diff-classification-and-managed-surface.md +++ b/docs/adr/0022-config-diff-classification-and-managed-surface.md @@ -24,7 +24,7 @@ This ADR was first accepted with a self-contained translation table inside `conf - **Equality is meaning-based**: the normalizers canonicalize representations (durations, byte sizes, comma-joins) per ADR 0021, and the classifier's residual equality tolerates string/number and string/boolean scalar skew. Whether an array is a SET or a SEQUENCE is per-field wire knowledge and lives on the registry row (`arrayEquality`), defaulting to sequence — `api.schemas`' first entry is PostgREST's default schema and `api.extra_search_path` is a literal `search_path`, so reordering is drift; `auth.additional_redirect_urls` opts into set semantics. - **Secrets are "present, unknown".** Both normalizers omit secret leaves (the platform only reports HMAC digests), so secrets can never classify; the registry's `isSecret` rows define the masked surface, and locally-declared ones are surfaced in `ConfigChangeSet.masked` so a clean change list is visibly a partial claim. The same visibility rule covers `ConfigChangeSet.unmanaged`: a declared comparable path the local projection drops can never classify either, and silently vanishing would print a false "no differences" over a real disagreement. This family is no longer whole-container omissions predicting what a push mapper happened to send (that mechanism, `applyPushUnmanagedOmissions`, was retired on CLI-2314 — see ADR 0021's 2026-09-04 addendum for the full re-derivation); it is now `DISABLED_SENTINEL_PRUNES`'s surviving sibling-only rules, each re-derived from a platform data-model fact independent of any actor's write path — e.g. the Data-API-off `api.{schemas,extra_search_path,max_rows}` siblings (`api.enabled` is itself derived from `db_schema` via `remoteDataApiDisabled`, the same fact `schemas` carries), `auth.email.smtp`'s non-host fields (`smtp.enabled` is likewise derived from `smtp_host.length > 0`, not an independent field), `auth.captcha.provider`/`secret` (a disabled captcha's provider choice is genuinely retained-but-inert on the platform), `auth.oauth_server`'s two non-`enabled` siblings, `storage.analytics`/`storage.vector`'s quota fields, and `db.network_restrictions`'s CIDR fields (a document-only management opt-out with no v2 API counterpart at all). The command layer separately echoes which response blocks were carried — a block absent from the response, or present but EMPTY (the plausible permission-truncated shape), reports as not-returned — so partially-populated responses degrade to `local_only` + an explicit scope note instead of an error or silent omission. This resource is read via `api.executeRaw` rather than the generated client's `execute`, per ADR 0019 rule 2: a strict `Schema.Struct` decode would drop excess properties and reject unknown enum members (a new `pooler.pool_mode` value, say) before the lenient config mirror ever ran on the body, leaving nothing left for it to be lenient about — `diff.integration.test.ts`'s "an unknown enum value in the response degrades instead of failing" case pins the resulting degrade-not-fail behavior. Any future typed consumer of this resource, `config pull` included, must read it the same way, or deliberately reintroduce contract overrides for it. - **Change entries carry what a consumer needs verbatim**: `path` is a SEGMENT ARRAY end to end (an `sms.test_otp` phone-number key may itself contain a `.`; joining is display-only), `declared` distinguishes file-written values from schema-materialized defaults, and a `remote_only` entry keeps the materialized local default so "what would `config push` change?" is answerable from the change alone — the primary dashboard-drift use case, and what `config pull` will need to write back. -- **Command surface (review revision, PR #6295)**: the target flag is `link`'s settled vocabulary — a single `--project-ref` accepting a project ref or a branch name/UUID (ref-shaped values always read as refs; a UUID resolves without a linked parent) — not a bespoke `--target`. The global `-o/--output` flag is rejected outright — every value, `pretty` included — with a message pointing at `--output-format` (the CLI-2156 ruling: no Go-compat contract for net-new commands, recorded as the scoped exception to Legacy Shell Invariant #6). `--exit-code` gives drift its own exit code `2`, keeping `1` for failures, so scripts can tell "drifted" from "token expired". The machine payload versions its own contract (`schema_version: 1`, an integer) separately from the user-controlled `$schema` document reference (`config_schema`). +- **Command surface (review revision, PR #6295)**: the target flag is the shared `--remote` — a single `--remote=<ref|branch>` accepting a project ref or a branch name/UUID, resolved by `link`'s rule (ref-shaped values always read as refs; a UUID resolves without a linked parent) — not a bespoke `--target`; the deprecated `--project-ref` is an alias. The global `-o/--output` flag is rejected outright — every value, `pretty` included — with a message pointing at `--output-format` (the CLI-2156 ruling: no Go-compat contract for net-new commands, recorded as the scoped exception to Legacy Shell Invariant #6). `--exit-code` gives drift its own exit code `2`, keeping `1` for failures, so scripts can tell "drifted" from "token expired". The machine payload versions its own contract (`schema_version: 1`, an integer) separately from the user-controlled `$schema` document reference (`config_schema`). - The interpolation pipeline records the resolving env var name on `"environment"` value origins, so a change on an `env()`-fed property can name the variable involved. The command layer (`apps/cli/src/legacy/commands/config/diff/`) only resolves the target, fetches, projects, and renders. Per ADR 0021's rendering rule, reported "local" values are the convergence projection — what pushing the file would produce hosted — not the file's literal spelling. diff --git a/docs/adr/0023-config-pull-write-strategy-and-scope-resolution.md b/docs/adr/0023-config-pull-write-strategy-and-scope-resolution.md index 5d3af9c481..044a4cdde1 100644 --- a/docs/adr/0023-config-pull-write-strategy-and-scope-resolution.md +++ b/docs/adr/0023-config-pull-write-strategy-and-scope-resolution.md @@ -29,7 +29,7 @@ JSON configs are handled differently: they are re-serialized rather than span-ed 1. **`--remote-label`, when passed, is resolved FIRST** — even for a ref-shaped target — so its own remedy ("pass --remote-label") is never dead against the env() hard error described below. Its FINAL, SANITIZED label is checked against every existing `[remotes.*]` block's name: a match whose `project_id` equals the resolved ref is reused; a match whose `project_id` merely _resolves_ there via `env(...)` is that same hard error; any other match is a collision (the label already tracks a different project). A label matching nothing existing creates a fresh block, UNLESS some OTHER block's raw `project_id` already tracks this exact ref (block reuse's own primary signal, rule 2) — that is a collision too, naming the block reuse would otherwise have selected. 2. **Else, reuse an existing `[remotes.*]` block** whose RAW, pre-`env()`-interpolation `project_id` literal matches the resolved ref, via `remoteNameForProjectRef` (`packages/config/src/io.ts`) — the exact rule the config loader's own overlay selection uses (ADR 0018's `applyRemoteOverride`). Block reuse, not whether the target was named as a branch, is the primary signal: however the user named the target (ref, branch UUID, branch name), if a block already claims that project, pull writes there. This makes pull and the loader's own remote selection exact inverses of each other — the block pull picks is provably the block that would apply on the next `supabase start`/`config push`. 3. **Else, if the target was named as a branch**, apply the SAME rule 1 logic to its own sanitized, branch-derived label: create `[remotes.<label>]` when nothing existing shares that name; a branch name that collides with an EXISTING block tracking a _different_ project is an error, never a silent overwrite of that block's `project_id` (rule 2 has already ruled out "a different block tracks this exact ref" by the time this rule runs, so a branch-derived label can only ever collide on same-name-different-project, never on "reuse elsewhere"). -4. **Else** (a ref-shaped `--project-ref`, or the linked-project fallback with no branch involved) — the config root, with `dual_scope`-flagged properties (the registry's `dualScope` metadata) additionally surfaced as warnings, since some root-scoped settings (`auth.site_url`, `db.settings.*`, `db.major_version`, `db.pooler.*`, …) also govern the local stack and pulling a hosted value into them silently reconfigures `supabase start`. +4. **Else** (a `--remote=<ref>` naming a project directly, or the linked project with no branch involved) — the config root, with `dual_scope`-flagged properties (the registry's `dualScope` metadata) additionally surfaced as warnings, since some root-scoped settings (`auth.site_url`, `db.settings.*`, `db.major_version`, `db.pooler.*`, …) also govern the local stack and pulling a hosted value into them silently reconfigures `supabase start`. A binding constraint governs rule 2's boundary, checked between rules 2 and 3 (only when no `--remote-label` was given — rule 1 already settled the destination for that case): a `[remotes.*]` block whose `project_id` is an `env(...)` reference that _resolves_ to the target ref is a hard error — never reused, never rewritten. The loader matches on the raw literal, never the resolved value (ADR 0018), so a value pull wrote into that block would never actually apply on the next load; pull would report success but never converge. Fixing this required exporting the single matching rule (`remoteNameForProjectRef`) from `@supabase/config` rather than reimplementing it, which also closed CLI-2287: `config diff`'s own precheck (`diff.handler.ts`) previously re-derived this match locally and diverged from the loader's raw-literal rule; it is now repointed onto the same exported function pull uses, so the two commands can never disagree about which block a ref matches. @@ -56,7 +56,7 @@ The dirty guard, confirmation prompt, and `hasWork` short-circuit all run AFTER - **Regeneration was never viable for pull specifically.** `saveCliConfig`'s full-document `smol-toml` stringify is correct for `supabase init`'s scaffold and any caller that owns the whole document, but CLI-2064's acceptance criteria are explicit that a user's comments, ordering, and quoting survive a pull unrelated to them. A regenerate-and-diff-the-bytes approach cannot satisfy that; only touching exactly the spans that changed can. - **No maintained format-preserving TOML AST library exists** to adopt in its place, and pulling one in would make it the package's second TOML dependency alongside `smol-toml` for a comparatively narrow write surface. Writing the surgical editor directly keeps `smol-toml` as the only TOML dependency and keeps the write surface exactly as large as `config pull` actually needs — scalars, arrays of scalars, and table creation — rather than a general-purpose editor's larger, harder-to-verify surface. - **Verification against an independently computed expectation, not against the splice logic's own output**, is what makes refusal trustworthy: `deepSet` over the original parse is a different code path from the scanner/placement logic, so a bug in one is unlikely to also fool the other into agreeing on a wrong result. -- **Block reuse over branch-ness** as the primary scope signal is what keeps pull and the loader in lockstep: a user can rename a `[remotes.*]` block's label freely, or reach the same project via a bare `--project-ref`, and pull must still find the block that actually governs that project rather than creating a second one. +- **Block reuse over branch-ness** as the primary scope signal is what keeps pull and the loader in lockstep: a user can rename a `[remotes.*]` block's label freely, or reach the same project via `--remote=<ref>`, and pull must still find the block that actually governs that project rather than creating a second one. ## Consequences diff --git a/docs/adr/0024-top-level-pull-orchestration.md b/docs/adr/0024-top-level-pull-orchestration.md index f5c649aab1..6af95e1cc0 100644 --- a/docs/adr/0024-top-level-pull-orchestration.md +++ b/docs/adr/0024-top-level-pull-orchestration.md @@ -42,7 +42,7 @@ implementations rather than assuming a thin composition layer would do: `resolveConfigTarget` (`command-internal/project-target.ts`) is called exactly once, in `pull.handler.ts`, producing `{ ref, branch }`. That `ref` is passed directly into every sub-step's -existing `--project-ref`-shaped input (each step already accepts one) rather than reusing the +existing `--remote=<ref>`-shaped input (each step already accepts one) rather than reusing the sub-step's own resolver end to end. This is safe because the resolved `ref` is always already the API-addressable ref, including for a branch target: `resolveBranchProjectRef` returns `detail.ref`/`branch.project_ref` and re-asserts it against the branch-project-ref pattern before @@ -255,7 +255,7 @@ review, extend section 4 and 5 above: always runs. `--force` now bypasses all three checks. See `pull.format.ts`'s `pullDirtyWarningMessage` and `pull.handler.ts`'s three independently-tracked dirty locations. - **Failed steps now carry a "retry just this step" hint** naming the exact standalone command - (`supabase <command> --project-ref <resolved-ref>`) — added because a failure inside an orchestrator + (`supabase <command> --remote=<resolved-ref>`) — added because a failure inside an orchestrator is easy to mis-diagnose as "re-run the whole orchestrator" when re-running just the one failed sub-command is both correct and far cheaper. Appended to `failure.suggestion` after any step-specific remedy (e.g. the `db` step's migration-conflict hint), surfaced in both the JSON 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 bc1b76ba0d..1c1f269e22 100644 --- a/docs/adr/0033-target-selection-and-hosted-mutation-consent.md +++ b/docs/adr/0033-target-selection-and-hosted-mutation-consent.md @@ -38,15 +38,14 @@ truth for the default target and for the consent default. the checkout and have no target flags. - `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 have no local side, so they act on the linked project - by default and take `--remote=<ref|branch>` for another project. +- `hosted`: `functions deploy`, `secrets *`, `config *`, `branches *`, `projects api-keys` and the + other platform resource commands. They have no local side, so they act on the linked project by + default and take `--remote=<ref|branch>` for another project. `link` is the one hosted command + without `--remote`: its target is the `[ref-or-branch]` argument, and `--project-ref` still works. - `selectable`: `db push`, `db pull`, `migration fetch`, `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 run locally unless `--remote` is passed. This group - absorbs the former `sync` group (`db push`, `db pull`, `migration fetch`), which defaulted to the - linked project. + `db schema declarative generate`. They run locally unless `--remote` is passed. - `removed`: `db branch *`, `db remote changes`, `db remote commit`. They exit 1 with replacement guidance. @@ -56,9 +55,9 @@ truth for the default target and for the consent default. ### One flag: `--remote` -`--remote` selects the target on every command that can act on a hosted project. It is bare or -takes a value, and there is no workspace-level switch: the `target` config key and -`SUPABASE_TARGET` do not exist, so the intent is on the command line and nowhere else. +`--remote` selects the target on every command that can act on a hosted project, except `link`. It +is bare or takes a value, and there is no workspace-level switch, so the intent is on the command +line and nowhere else. | You pass | Target | | ------------------- | ------------------------------------------------------------------------ | @@ -68,8 +67,9 @@ takes a value, and there is no workspace-level switch: the `target` config key a | `--remote=<url>` | That database: a `postgres://` or `postgresql://` URL | | `--remote=<branch>` | A branch of the linked project, by name or UUID | -The value rule is the one `supabase link` uses, through its resolver; a value that matches no branch -fails with link's error, which lists the branches. A URL applies only where the remote side is +Every command resolves a `--remote=<branch|uuid>` value with one resolver, the one `supabase link` +uses. A value that matches no branch fails with link's error, which lists the branches. A branch +UUID also resolves in a checkout with no linked project. A URL applies only where the remote side is Postgres: `storage`, `seed buckets` and `hosted` commands need a project and reject one. `hosted` commands accept `--remote=<ref|branch>`, and bare `--remote` changes nothing for them. The linked project comes from `SUPABASE_PROJECT_ID`, then the link file. `SUPABASE_PROJECT_ID` alone never @@ -80,8 +80,10 @@ unresolvable remote target fails. The CLI never falls back between local and rem parsing when the command takes no positional, when the token is a ref or URL, or when the command already has more bare tokens than positionals. The one ambiguous shape, a bare `--remote` directly followed by a token the command took as a positional, is checked against the linked project's -branches before anything runs: a match fails with a hint to use `--remote=<branch>`, and no match -runs the command. +branches before anything runs. A token that exactly matches a branch, is within a small edit +distance of one, or can't be checked because the branch listing failed stops the run with an error +that offers both readings: `--remote=<word>` for the branch, `<word> --remote` for the operand. Any +other token runs as the command's operand. ### Deprecated aliases @@ -91,14 +93,27 @@ that names that form, and says v4 removes it. `--local` is still accepted and do local is already the default; it warns the same way. v4 removes all of them, and `db diff`'s `linked` value for `--from` and `--to`, which `remote` replaces. +A warning shows the command path, the command's operand placeholders and the `--remote` +replacement, last. It never repeats the user's operands or other flags, because they can be secrets +that a CI log would then keep: + +```text +Warning: --project-ref is deprecated and will be removed in v4. Use --remote=<ref> instead: supabase secrets set <NAME=VALUE>... --remote=<ref> +``` + ### Selector rules One parser (`parseTargetSelector`) backs every `selectable` command and maps the deprecated aliases onto `--remote`: -- `--remote` and the aliases are mutually exclusive. They are rejected together, never resolved by - precedence. -- `--no-remote` and `--remote=false` fail with "omit `--remote` to target the local database". +- `--remote` and the aliases name one target and are never resolved by precedence. + `--remote=<value>` together with an alias is an error, and so are two of `--remote`, `--linked`, + `--db-url` and `--local`, or `--project-ref` with `--local` or `--db-url`. +- A bare `--remote` together with `--project-ref=<x>` is accepted, as `--linked --project-ref=<x>` + is: the alias's value wins and its warning prints. +- `db diff` with `--remote` or an alias together with `--from` or `--to` is an error. In `--from` + and `--to`, `remote` always means the linked project. +- `--no-remote` and `--remote=false` fail and tell the user to leave `--remote` out. `--remote yes`, `--remote 1`, `--remote true` and `--remote on` mean bare `--remote`. - An empty `--remote=` value is rejected. - `--password` is rejected unless the target is a project, meaning bare `--remote`, a ref or a @@ -109,9 +124,9 @@ onto `--remote`: `ProjectRefResolver.resolveRef` is the only chain: `--remote=<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. +differs: `link` skips the ref file, `branches` derives the parent project, `projects delete` prompts +for the project, and `projects list` and `services` read the ref for display only. Branch names and +UUIDs go through the one branch resolver on every command, the `config` family included. ### The target banner and JSON `target` @@ -124,7 +139,7 @@ Target: local stack (<host>:<port>) Target: local stack (<host>:<port>) · default; pass --remote for <name> (<ref>) Target: custom Postgres (<host>:<port>) Target: project <name> (<ref>) · production -Target: project <parent ref> · branch <name> (<ref>) +Target: project <parent name> (<parent ref>) · branch <name> (<ref>) Target: project <ref> Target: project <ref> (not the linked project) ``` @@ -136,8 +151,14 @@ linked checkout runs locally and prints the `default; pass --remote` hint. 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). +UUID, or a live probe), and anything else is the bare ref, reported `unverified` in JSON. The branch +line names the parent project when the cache knows its name, and shows the parent ref otherwise. + +A project's default branch is production, not a preview branch. `--remote=<default branch>`, by name +or UUID, resolves to the production project: the banner, the consent prompt and JSON +`environment: "production"` all say so, so a script that checks `environment` to stay away from +production sees it. 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`: @@ -158,9 +179,9 @@ first in JSON. Help states each command's target from the same policy declaration, so labels cannot drift from behaviour. A group's help labels each subcommand `[local or remote]` (`selectable`), `[local only]` or `[remote only]` (`hosted`); account commands carry no label. A command's own help has a `TARGET` -section above `USAGE`, and its `remote` flag row reads `--remote[=<ref|branch|url>]` on `selectable` -commands and `--remote[=<ref|branch>]` on `hosted` ones. JSON help carries the same information as -`target`. Docs, examples, help and the deprecation warnings put `--remote` last, in the `=` form +section above `USAGE`, and its `remote` flag row reads `--remote[=<ref|branch|url>]` on a command +that takes a database URL and `--remote[=<ref|branch>]` on one that does not (`hosted` commands, +`storage`, `seed buckets`). JSON help carries the same information as `target`. Docs, examples, help and the deprecation warnings put `--remote` last, in the `=` form when it has a value (`db pull add_profiles --remote`, `db push --remote=<ref>`), so it cannot be read as anything else, and shell completion never offers a space-separated value for `--remote` on a command that has a positional. @@ -243,7 +264,8 @@ to No makes the Enter key safe. - 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. +- One selector parser, one ref chain, one branch resolver 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. From 530a3b9c6d9d3f8e42facabf36e0cf34b685c8f4 Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Mon, 19 Oct 2026 13:48:03 +0100 Subject: [PATCH 141/291] docs(cli): use --remote and the link argument in docs and examples Link examples use the positional ref, config pull names --remote=<ref>, the self-hosted examples use a non-loopback host, and the README no longer shows a bare db push as reaching the linked project. --- README.md | 4 +++- apps/cli/docs/supabase/config/pull.md | 2 +- apps/cli/docs/supabase/link.md | 4 ++-- apps/cli/docs/templates/examples.yaml | 10 +++++----- apps/cli/src/SIDE_EFFECTS_TEMPLATE.md | 2 +- apps/cli/src/commands/link/SIDE_EFFECTS.md | 7 ++++--- apps/cli/src/commands/link/link.command.ts | 2 +- 7 files changed, 17 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 6f73c589c3..f9515f796d 100644 --- a/README.md +++ b/README.md @@ -77,12 +77,14 @@ supabase link ## Manage Your Database -Create migrations, compare schemas, and apply changes locally or to your linked project: +Create migrations, compare schemas, and apply changes to your local database. Add `--remote` to act +on your linked project instead: ```sh supabase migration new create_profiles supabase db diff supabase db push +supabase db push --remote supabase db reset ``` diff --git a/apps/cli/docs/supabase/config/pull.md b/apps/cli/docs/supabase/config/pull.md index 1874e6aa6c..0b7878c0b5 100644 --- a/apps/cli/docs/supabase/config/pull.md +++ b/apps/cli/docs/supabase/config/pull.md @@ -4,7 +4,7 @@ Writes the effective configuration of a remote project or branch into the local Pass `--remote=<ref|branch>` to pull from 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. `--project-ref` still works in v3 but is deprecated and removed in v4; use `--remote`. -Where a pulled value lands depends on whether the target is already tracked by a `[remotes.*]` block, not on how you named it: if any block's `project_id` already matches the resolved ref, that block is reused — whatever its own label — and every value lands there. Otherwise, if the target was named as a branch, a new `[remotes.<branch-name>]` block is created (a branch resolved by UUID falls back to the project ref itself as its label). Only when neither applies — a bare `--project-ref` naming a project directly, or the linked project with no branch involved — do values land at the config root. `--remote-label` overrides the block config pull would otherwise reuse or create; naming a block that already tracks a different project, or naming nothing while a different block already tracks this exact ref, is an error rather than a silent overwrite. A `[remotes.*]` block whose `project_id` is an `env(...)` reference that merely _resolves_ to the target ref is never reused or rewritten — that is a hard error naming the variable, since a value written there would never actually take effect on the next load. Creating a new block always writes its `project_id`, even when every value it would otherwise carry already matches the local defaults — otherwise the block could never be reused on a later pull. +Where a pulled value lands depends on whether the target is already tracked by a `[remotes.*]` block, not on how you named it: if any block's `project_id` already matches the resolved ref, that block is reused — whatever its own label — and every value lands there. Otherwise, if the target was named as a branch, a new `[remotes.<branch-name>]` block is created (a branch resolved by UUID falls back to the project ref itself as its label). Only when neither applies — `--remote=<ref>` naming a project directly, or the linked project (a bare `--remote` or no flag) with no branch involved — do values land at the config root. `--remote-label` overrides the block config pull would otherwise reuse or create; naming a block that already tracks a different project, or naming nothing while a different block already tracks this exact ref, is an error rather than a silent overwrite. A `[remotes.*]` block whose `project_id` is an `env(...)` reference that merely _resolves_ to the target ref is never reused or rewritten — that is a hard error naming the variable, since a value written there would never actually take effect on the next load. Creating a new block always writes its `project_id`, even when every value it would otherwise carry already matches the local defaults — otherwise the block could never be reused on a later pull. Writing to the config root can also affect `supabase start`: a handful of root-scoped settings — `auth.site_url`, `db.settings.*`, `db.major_version` (which changes what `supabase start` boots), `db.pooler.*`, and similar — also govern the local stack, so pulling a hosted value into them is a real change to local dev behavior, not just a record of the hosted project's own setting. `config pull` warns about this rather than refusing; writing the same value into a `[remotes.*]` block is unaffected. Passing `--remote-label` is the escape hatch: it diverts what would otherwise be a root-bound pull into a `[remotes.<label>]` block instead, leaving local dev untouched. diff --git a/apps/cli/docs/supabase/link.md b/apps/cli/docs/supabase/link.md index bba1291ae7..b3113c3723 100644 --- a/apps/cli/docs/supabase/link.md +++ b/apps/cli/docs/supabase/link.md @@ -1,6 +1,6 @@ ## supabase-link -Link your local development project to a hosted Supabase project. +Link your local development project to a hosted Supabase project. Pass the project ref, or the name of one of its branches, as the argument: `supabase link <ref>`. The `--project-ref` flag still works. PostgREST configurations are fetched from the Supabase platform and validated against your local configuration file. @@ -8,4 +8,4 @@ Optionally, database settings can be validated if you provide a password. Your d > If you do not want to be prompted for the database password, such as in a CI environment, you may specify it explicitly via the `SUPABASE_DB_PASSWORD` environment variable. -Some commands like `db dump`, `db push`, and `db pull` require your project to be linked first. +Passing `--remote` to commands like `db dump`, `db push`, and `db pull` targets the linked project, so it must be linked first. diff --git a/apps/cli/docs/templates/examples.yaml b/apps/cli/docs/templates/examples.yaml index e8fa528a8c..e899ab4355 100644 --- a/apps/cli/docs/templates/examples.yaml +++ b/apps/cli/docs/templates/examples.yaml @@ -22,19 +22,19 @@ supabase-login: supabase-link: - id: basic-usage name: Basic usage - code: supabase link --project-ref ******************** + code: supabase link ******************** response: | Enter your database password (or leave blank to skip): ******** Finished supabase link. - id: without-password name: Link without database password - code: supabase link --project-ref ******************** <<< "" + code: supabase link ******************** <<< "" response: | Enter your database password (or leave blank to skip): Finished supabase link. - id: using-alternate-dns name: Link using DNS-over-HTTPS resolver - code: supabase link --project-ref ******************** --dns-resolver https + code: supabase link ******************** --dns-resolver https response: | Enter your database password (or leave blank to skip): Finished supabase link. @@ -120,7 +120,7 @@ supabase-migration-list: 20230222032233 │ │ 2023-02-22 03:22:33 - id: with-db-url name: Connect to self-hosted database - code: supabase migration list --remote='postgres://postgres[:percent_encoded_password]@127.0.0.1[:port]/postgres' + code: supabase migration list --remote='postgres://postgres[:percent_encoded_password]@db.example.com[:port]/postgres' response: |2 LOCAL │ REMOTE │ TIME (UTC) ─────────────────┼────────────────┼────────────────────── @@ -280,7 +280,7 @@ supabase-db-push: Linked project is up to date. - id: self-hosted name: Self hosted - code: supabase db push --remote="postgres://user:pass@127.0.0.1:5432/postgres" + code: supabase db push --remote="postgres://user:pass@db.example.com:5432/postgres" response: | Pushing migration 20230410135622_create_employees_table.sql... Finished supabase db push. diff --git a/apps/cli/src/SIDE_EFFECTS_TEMPLATE.md b/apps/cli/src/SIDE_EFFECTS_TEMPLATE.md index b2b4bb1a51..1dec99b0f8 100644 --- a/apps/cli/src/SIDE_EFFECTS_TEMPLATE.md +++ b/apps/cli/src/SIDE_EFFECTS_TEMPLATE.md @@ -118,7 +118,7 @@ ## Notes <!-- Anything else an implementer or reviewer needs to know: - - Behaviour differences between --local / --linked / --project-ref modes + - Behaviour differences between local and --remote targets - Idempotency characteristics - Intentional output quirks - Side effects of retries or partial failures --> diff --git a/apps/cli/src/commands/link/SIDE_EFFECTS.md b/apps/cli/src/commands/link/SIDE_EFFECTS.md index 2bfdda8eed..8a1c3d38c7 100644 --- a/apps/cli/src/commands/link/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/link/SIDE_EFFECTS.md @@ -3,9 +3,10 @@ Writes flat state files under `<workdir>/supabase/.temp/`. `link` accepts an optional `[ref-or-branch]` positional -argument, and `--project-ref` also accepts a branch name instead of a project ref. A value is -treated as a ref when it matches `PROJECT_REF_PATTERN` (20 lowercase letters); any other -non-empty value is looked up as a branch name of the currently-linked parent project. +argument (`supabase link <ref>`), and `--project-ref` still works and also accepts a branch name +instead of a project ref. A value is treated as a ref when it matches `PROJECT_REF_PATTERN` (20 +lowercase letters); any other non-empty value is looked up as a branch name of the +currently-linked parent project. `link` takes no `--remote`: the argument is its target. ## Files Read diff --git a/apps/cli/src/commands/link/link.command.ts b/apps/cli/src/commands/link/link.command.ts index 496dad6c41..d4eb7fbcde 100644 --- a/apps/cli/src/commands/link/link.command.ts +++ b/apps/cli/src/commands/link/link.command.ts @@ -67,7 +67,7 @@ export const linkCommand = Command.make("link", config).pipe( Command.withShortDescription("Link to a Supabase project"), Command.withExamples([ { - command: "supabase link --project-ref abcdefghijklmnopqrst", + command: "supabase link abcdefghijklmnopqrst", description: "Link to a project by ref", }, { From 74dc98df65bfadc3595705a40727b297180619d3 Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Mon, 19 Oct 2026 13:48:20 +0100 Subject: [PATCH 142/291] docs(cli): describe central target-flag telemetry and --remote=<value> mixing AGENTS.md states that withCommandTelemetry reports the target flags for every command, and the per-command SIDE_EFFECTS rows follow: --project-ref is logged only when ref-shaped, and only --remote=<value> conflicts with --project-ref. The storage SIDE_EFFECTS name --remote for a hosted target. --- apps/cli/AGENTS.md | 10 +++-- .../src/commands/backups/list/SIDE_EFFECTS.md | 6 +-- .../commands/backups/restore/SIDE_EFFECTS.md | 6 +-- .../src/commands/config/pull/SIDE_EFFECTS.md | 6 +-- .../src/commands/encryption/SIDE_EFFECTS.md | 6 +-- .../experimental/compute/logs/SIDE_EFFECTS.md | 2 +- .../commands/network-bans/get/SIDE_EFFECTS.md | 24 +++++------ .../network-bans/remove/SIDE_EFFECTS.md | 8 ++-- .../network-restrictions/get/SIDE_EFFECTS.md | 22 +++++----- .../update/SIDE_EFFECTS.md | 8 ++-- .../commands/notebooks/pull/SIDE_EFFECTS.md | 26 ++++++------ .../commands/notebooks/push/SIDE_EFFECTS.md | 28 ++++++------- .../postgres-config/delete/SIDE_EFFECTS.md | 8 ++-- .../postgres-config/get/SIDE_EFFECTS.md | 24 +++++------ .../postgres-config/update/SIDE_EFFECTS.md | 8 ++-- .../cli/src/commands/projects/SIDE_EFFECTS.md | 4 +- .../projects/api-keys/SIDE_EFFECTS.md | 16 +++---- apps/cli/src/commands/pull/SIDE_EFFECTS.md | 6 +-- .../src/commands/secrets/list/SIDE_EFFECTS.md | 28 ++++++------- .../src/commands/secrets/set/SIDE_EFFECTS.md | 42 +++++++++---------- .../commands/secrets/unset/SIDE_EFFECTS.md | 8 ++-- .../snippets/download/SIDE_EFFECTS.md | 22 +++++----- .../commands/snippets/list/SIDE_EFFECTS.md | 22 +++++----- .../ssl-enforcement/get/SIDE_EFFECTS.md | 22 +++++----- .../ssl-enforcement/update/SIDE_EFFECTS.md | 30 ++++++------- apps/cli/src/commands/sso/add/SIDE_EFFECTS.md | 2 +- .../cli/src/commands/sso/info/SIDE_EFFECTS.md | 12 +++--- .../cli/src/commands/sso/list/SIDE_EFFECTS.md | 14 +++---- .../src/commands/sso/remove/SIDE_EFFECTS.md | 20 ++++----- .../cli/src/commands/sso/show/SIDE_EFFECTS.md | 18 ++++---- .../src/commands/sso/update/SIDE_EFFECTS.md | 2 +- .../src/commands/storage/cp/SIDE_EFFECTS.md | 18 ++++---- .../src/commands/storage/ls/SIDE_EFFECTS.md | 16 +++---- .../src/commands/storage/mv/SIDE_EFFECTS.md | 16 +++---- .../src/commands/storage/rm/SIDE_EFFECTS.md | 16 +++---- .../activate/SIDE_EFFECTS.md | 22 +++++----- .../check-availability/SIDE_EFFECTS.md | 18 ++++---- .../vanity-subdomains/delete/SIDE_EFFECTS.md | 20 ++++----- .../vanity-subdomains/get/SIDE_EFFECTS.md | 16 +++---- 39 files changed, 302 insertions(+), 300 deletions(-) diff --git a/apps/cli/AGENTS.md b/apps/cli/AGENTS.md index 002126c535..2cda6e1f5d 100644 --- a/apps/cli/AGENTS.md +++ b/apps/cli/AGENTS.md @@ -186,10 +186,12 @@ property shape (`flags`, `is_agent`, and `env_signals`), use canonical names and [event catalog](src/shared/telemetry/event-catalog.ts), and redact sensitive values. `safeFlags` may include only values that carry no user data. Keep the established safe project, -project-id, org-id, and version cases. When `--project-ref` also accepts branch names, whitelist -only values matching `PROJECT_REF_PATTERN`; never log a user-created branch name. Global boolean -and choice flags are resolved by the wrapper from `globalFlagValues`; a command-local flag wins, -so a local path-valued `--output` remains redacted. Keep free-form user content redacted. +project-id, org-id, and version cases. `withCommandTelemetry` reports the target flags itself for +every command, so never list them in a command's `safeFlags`: `--remote`, `--linked`, `--local`, +`--project-ref`/`--project-id` (the value only when it matches `PROJECT_REF_PATTERN`, since it can +be a user-created branch name) and `--db-url` (always redacted). Global boolean and choice flags +are resolved by the wrapper from `globalFlagValues`; a command-local flag wins, so a local +path-valued `--output` remains redacted. Keep free-form user content redacted. Do not remove, rename, or reshape custom events. Preserve login aliasing after token persistence, link project and organization identity after the link write (including branch-link metadata), diff --git a/apps/cli/src/commands/backups/list/SIDE_EFFECTS.md b/apps/cli/src/commands/backups/list/SIDE_EFFECTS.md index 6160d35021..cc9cd10a7b 100644 --- a/apps/cli/src/commands/backups/list/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/backups/list/SIDE_EFFECTS.md @@ -49,9 +49,9 @@ ## Telemetry Events Fired -| Event | When | Notable properties / groups | -| ---------------------- | ------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `cli_command_executed` | post-run, success or failure (via wrapper) | `exit_code`, `duration_ms`, `flags` (`--remote=<ref>` → the ref, a bare `--remote` → `true`, any other `--remote` value → `<redacted>`; `--project-ref` → `<redacted>`) | +| Event | When | Notable properties / groups | +| ---------------------- | ------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `cli_command_executed` | post-run, success or failure (via wrapper) | `exit_code`, `duration_ms`, `flags` (`--remote=<ref>` → the ref, a bare `--remote` → `true`, any other `--remote` value → `<redacted>`; `--project-ref` → the ref when ref-shaped, else `<redacted>`) | ## Output diff --git a/apps/cli/src/commands/backups/restore/SIDE_EFFECTS.md b/apps/cli/src/commands/backups/restore/SIDE_EFFECTS.md index 0e271ecb71..fc622f2044 100644 --- a/apps/cli/src/commands/backups/restore/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/backups/restore/SIDE_EFFECTS.md @@ -52,9 +52,9 @@ ## Telemetry Events Fired -| Event | When | Notable properties / groups | -| ---------------------- | ------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `cli_command_executed` | post-run, success or failure (via wrapper) | `exit_code`, `duration_ms`, `flags` (`--remote=<ref>` → the ref, a bare `--remote` → `true`, any other `--remote` value → `<redacted>`; `--project-ref` / `--timestamp` → `<redacted>`) | +| Event | When | Notable properties / groups | +| ---------------------- | ------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `cli_command_executed` | post-run, success or failure (via wrapper) | `exit_code`, `duration_ms`, `flags` (`--remote=<ref>` → the ref, a bare `--remote` → `true`, any other `--remote` value → `<redacted>`; `--project-ref` → the ref when ref-shaped, else `<redacted>`; `--timestamp` → `<redacted>`) | ## Output diff --git a/apps/cli/src/commands/config/pull/SIDE_EFFECTS.md b/apps/cli/src/commands/config/pull/SIDE_EFFECTS.md index d7d99ad9b7..4c810cb491 100644 --- a/apps/cli/src/commands/config/pull/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/config/pull/SIDE_EFFECTS.md @@ -101,9 +101,9 @@ exit `0`: neither is a failure. ## Telemetry Events Fired -| Event | When | Notable properties / groups | -| ---------------------- | --------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `cli_command_executed` | post-run, success or failure (via `withCommandTelemetry`) | `exit_code`, `duration_ms`, `flags`; `--remote=<ref>` is logged as the ref, a bare `--remote` as `true`, and a branch name or UUID is redacted; the deprecated `--project-ref`'s value is only in `safeFlags` (logged verbatim) when it is ref-shaped (`PROJECT_REF_PATTERN`) — a user-created branch name is redacted; `--remote-label` is never in `safeFlags` | +| Event | When | Notable properties / groups | +| ---------------------- | --------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `cli_command_executed` | post-run, success or failure (via `withCommandTelemetry`) | `exit_code`, `duration_ms`, `flags`; `--remote=<ref>` is logged as the ref, a bare `--remote` as `true`, and a branch name or UUID is redacted; the deprecated `--project-ref`'s value is logged only when it is ref-shaped (`PROJECT_REF_PATTERN`) — a user-created branch name is redacted; `--remote-label` is never in `safeFlags` | ## Output diff --git a/apps/cli/src/commands/encryption/SIDE_EFFECTS.md b/apps/cli/src/commands/encryption/SIDE_EFFECTS.md index 9834a37dac..f1ad2d558f 100644 --- a/apps/cli/src/commands/encryption/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/encryption/SIDE_EFFECTS.md @@ -54,9 +54,9 @@ additionally reads the new key from stdin. ## Telemetry Events Fired -| Event | When | Notable properties / groups | -| ---------------------- | ------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `cli_command_executed` | post-run, success or failure (via wrapper) | `exit_code`, `duration_ms`, `flags` (`--remote=<ref>` → the ref, a bare `--remote` → `true`, any other `--remote` value → `<redacted>`; `--project-ref` redacted — not telemetry-safe) | +| Event | When | Notable properties / groups | +| ---------------------- | ------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `cli_command_executed` | post-run, success or failure (via wrapper) | `exit_code`, `duration_ms`, `flags` (`--remote=<ref>` → the ref, a bare `--remote` → `true`, any other `--remote` value → `<redacted>`; `--project-ref` → the ref when ref-shaped, else `<redacted>`) | No custom `phtelemetry.*` events in `internal/encryption/`. diff --git a/apps/cli/src/commands/experimental/compute/logs/SIDE_EFFECTS.md b/apps/cli/src/commands/experimental/compute/logs/SIDE_EFFECTS.md index 482ac36937..fa156da3ff 100644 --- a/apps/cli/src/commands/experimental/compute/logs/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/experimental/compute/logs/SIDE_EFFECTS.md @@ -125,7 +125,7 @@ guaranteed and is deduplicated on the Logflare-minted `id`. `--kind` is a choice flag, so its value is logged verbatim (a closed enum carries no user data). `--remote=<ref>` is logged as the ref and a bare `--remote` as `true`; any other -`--remote` value, and `--project-ref`, is redacted. No custom events. +`--remote` value is redacted, and `--project-ref` is logged only when it is ref-shaped. No custom events. ## Output Formats 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 81089dd9e0..f7e48b53ad 100644 --- a/apps/cli/src/commands/network-bans/get/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/network-bans/get/SIDE_EFFECTS.md @@ -37,21 +37,21 @@ The Management API exposes this read operation as `POST .../network-bans/retriev ## Exit Codes -| Code | Condition | -| ---- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `0` | success — network bans printed to stdout | -| `1` | `--experimental` not passed and `SUPABASE_EXPERIMENTAL` unset (`ExperimentalRequiredError`) — checked before ref resolution/API/telemetry | -| `1` | project ref unresolved (`ProjectRefNotLinkedError`; `InvalidProjectRefError` for a malformed `SUPABASE_PROJECT_ID` or link-file ref) | -| `1` | invalid `--remote` selector — `--remote=<url>` (`TargetNeedsProjectError`), an empty value, `--remote` combined with `--project-ref`, or a branch the linked project does not have (`LinkBranchNotFoundError`) | -| `1` | API non-2xx (`NetworkBansGetUnexpectedStatusError`) | -| `1` | transport failure (`NetworkBansGetNetworkError`) | -| `1` | `--output env` requested (`NetworkBansEnvNotSupportedError`) | +| Code | Condition | +| ---- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `0` | success — network bans printed to stdout | +| `1` | `--experimental` not passed and `SUPABASE_EXPERIMENTAL` unset (`ExperimentalRequiredError`) — checked before ref resolution/API/telemetry | +| `1` | project ref unresolved (`ProjectRefNotLinkedError`; `InvalidProjectRefError` for a malformed `SUPABASE_PROJECT_ID` or link-file ref) | +| `1` | invalid `--remote` selector — `--remote=<url>` (`TargetNeedsProjectError`), an empty value, `--remote=<value>` combined with `--project-ref`, or a branch the linked project does not have (`LinkBranchNotFoundError`) | +| `1` | API non-2xx (`NetworkBansGetUnexpectedStatusError`) | +| `1` | transport failure (`NetworkBansGetNetworkError`) | +| `1` | `--output env` requested (`NetworkBansEnvNotSupportedError`) | ## Telemetry Events Fired -| Event | When | Notable properties / groups | -| ---------------------- | ------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------- | -| `cli_command_executed` | post-run, success or failure (via wrapper) | `exit_code`, `duration_ms`, `flags` (`--remote` → `true` (bare), the ref, or `<redacted>`; `--project-ref` → `<redacted>`) | +| Event | When | Notable properties / groups | +| ---------------------- | ------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `cli_command_executed` | post-run, success or failure (via wrapper) | `exit_code`, `duration_ms`, `flags` (`--remote` → `true` (bare), the ref, or `<redacted>`; `--project-ref` → the ref when ref-shaped, else `<redacted>`) | ## Output 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 1279813b29..47523dc204 100644 --- a/apps/cli/src/commands/network-bans/remove/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/network-bans/remove/SIDE_EFFECTS.md @@ -45,7 +45,7 @@ | `1` | `--experimental` not passed and `SUPABASE_EXPERIMENTAL` unset (`ExperimentalRequiredError`) — checked before ref resolution/API/telemetry | | `1` | invalid IP supplied via `--db-unban-ip` (`NetworkBansInvalidIpError`) | | `1` | project ref unresolved (`ProjectRefNotLinkedError`; `InvalidProjectRefError` for a malformed `SUPABASE_PROJECT_ID` or link-file ref) | -| `1` | invalid `--remote` selector — `--remote=<url>` (`TargetNeedsProjectError`), an empty value, `--remote` combined with `--project-ref`, or a branch the linked project does not have (`LinkBranchNotFoundError`) | +| `1` | invalid `--remote` selector — `--remote=<url>` (`TargetNeedsProjectError`), an empty value, `--remote=<value>` combined with `--project-ref`, or a branch the linked project does not have (`LinkBranchNotFoundError`) | | `1` | API non-2xx (`NetworkBansRemoveUnexpectedStatusError`) | | `1` | transport failure (`NetworkBansRemoveNetworkError`) | | `1` | declined at the confirmation prompt (`HostedMutationDeclinedError`, `context canceled`) — nothing is changed | @@ -53,9 +53,9 @@ ## Telemetry Events Fired -| Event | When | Notable properties / groups | -| ---------------------- | ------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `cli_command_executed` | post-run, success or failure (via wrapper) | `exit_code`, `duration_ms`, `flags` (`--remote` → `true` (bare), the ref, or `<redacted>`; `--project-ref` → `<redacted>`, `--db-unban-ip` → `<redacted>`) | +| Event | When | Notable properties / groups | +| ---------------------- | ------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `cli_command_executed` | post-run, success or failure (via wrapper) | `exit_code`, `duration_ms`, `flags` (`--remote` → `true` (bare), the ref, or `<redacted>`; `--project-ref` → the ref when ref-shaped, else `<redacted>`, `--db-unban-ip` → `<redacted>`) | ## Output 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 29392f673f..47a12a14ae 100644 --- a/apps/cli/src/commands/network-restrictions/get/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/network-restrictions/get/SIDE_EFFECTS.md @@ -35,20 +35,20 @@ ## Exit Codes -| Code | Condition | -| ---- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `0` | success — network-restrictions status printed to stdout | -| `1` | `--experimental` not passed and `SUPABASE_EXPERIMENTAL` unset (`ExperimentalRequiredError`) — checked before ref resolution/API/telemetry | -| `1` | project ref unresolved (`ProjectRefNotLinkedError`; `InvalidProjectRefError` for a malformed `SUPABASE_PROJECT_ID` or link-file ref) | -| `1` | invalid `--remote` selector — `--remote=<url>` (`TargetNeedsProjectError`), an empty value, `--remote` combined with `--project-ref`, or a branch the linked project does not have (`LinkBranchNotFoundError`) | -| `1` | API non-200 (`NetworkRestrictionsGetUnexpectedStatusError`) | -| `1` | transport failure (`NetworkRestrictionsGetNetworkError`) | +| Code | Condition | +| ---- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `0` | success — network-restrictions status printed to stdout | +| `1` | `--experimental` not passed and `SUPABASE_EXPERIMENTAL` unset (`ExperimentalRequiredError`) — checked before ref resolution/API/telemetry | +| `1` | project ref unresolved (`ProjectRefNotLinkedError`; `InvalidProjectRefError` for a malformed `SUPABASE_PROJECT_ID` or link-file ref) | +| `1` | invalid `--remote` selector — `--remote=<url>` (`TargetNeedsProjectError`), an empty value, `--remote=<value>` combined with `--project-ref`, or a branch the linked project does not have (`LinkBranchNotFoundError`) | +| `1` | API non-200 (`NetworkRestrictionsGetUnexpectedStatusError`) | +| `1` | transport failure (`NetworkRestrictionsGetNetworkError`) | ## Telemetry Events Fired -| Event | When | Notable properties / groups | -| ---------------------- | ---------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- | -| `cli_command_executed` | post-run, success or failure (via wrapper); not fired when the `--experimental` gate is closed | `exit_code`, `duration_ms`, `flags` (`--remote` → `true` (bare), the ref, or `<redacted>`; `--project-ref` → `<redacted>`) | +| Event | When | Notable properties / groups | +| ---------------------- | ---------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `cli_command_executed` | post-run, success or failure (via wrapper); not fired when the `--experimental` gate is closed | `exit_code`, `duration_ms`, `flags` (`--remote` → `true` (bare), the ref, or `<redacted>`; `--project-ref` → the ref when ref-shaped, else `<redacted>`) | ## Output 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 d051bd4c93..28ab2cc542 100644 --- a/apps/cli/src/commands/network-restrictions/update/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/network-restrictions/update/SIDE_EFFECTS.md @@ -50,7 +50,7 @@ when no `--db-allow-cidr` was supplied). | `1` | CIDR parse failure — `NetworkRestrictionsInvalidCidrError` (`failed to parse IP: <input>`) | | `1` | private-IP rejection — `NetworkRestrictionsPrivateIpError` (`private IP provided: <input>`) | | `1` | project ref unresolved (`ProjectRefNotLinkedError`; `InvalidProjectRefError` for a malformed `SUPABASE_PROJECT_ID` or link-file ref) | -| `1` | invalid `--remote` selector — `--remote=<url>` (`TargetNeedsProjectError`), an empty value, `--remote` combined with `--project-ref`, or a branch the linked project does not have (`LinkBranchNotFoundError`) | +| `1` | invalid `--remote` selector — `--remote=<url>` (`TargetNeedsProjectError`), an empty value, `--remote=<value>` combined with `--project-ref`, or a branch the linked project does not have (`LinkBranchNotFoundError`) | | `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 | @@ -58,9 +58,9 @@ when no `--db-allow-cidr` was supplied). ## Telemetry Events Fired -| Event | When | Notable properties / groups | -| ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- | -| `cli_command_executed` | post-run, success or failure (via wrapper); not fired when the `--experimental` gate is closed or when flag parsing fails (malformed `--db-allow-cidr` CSV) | `exit_code`, `duration_ms`, `flags` (`--remote` → `true` (bare), the ref, or `<redacted>`; `--project-ref` → `<redacted>`) | +| Event | When | Notable properties / groups | +| ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `cli_command_executed` | post-run, success or failure (via wrapper); not fired when the `--experimental` gate is closed or when flag parsing fails (malformed `--db-allow-cidr` CSV) | `exit_code`, `duration_ms`, `flags` (`--remote` → `true` (bare), the ref, or `<redacted>`; `--project-ref` → the ref when ref-shaped, else `<redacted>`) | ## CIDR Validation (runs locally before any HTTP call) diff --git a/apps/cli/src/commands/notebooks/pull/SIDE_EFFECTS.md b/apps/cli/src/commands/notebooks/pull/SIDE_EFFECTS.md index 40e13b1833..20da2aa502 100644 --- a/apps/cli/src/commands/notebooks/pull/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/notebooks/pull/SIDE_EFFECTS.md @@ -72,19 +72,19 @@ size. ## Exit Codes -| Code | Condition | -| ---- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `0` | success | -| `1` | no project ref — not linked and no `--remote` | -| `1` | the supplied notebook id is not a UUID | -| `1` | the project holds two notebooks of one name during a broad pull | -| `1` | the notebooks path exists but cannot be read as a directory | -| `1` | an explicitly requested notebook name cannot be stored safely as a local file | -| `1` | a Management API call failed (transport, unexpected status, or undecodable body) | -| `1` | a local file the user chose to create in the project is not a readable notebook | -| `1` | a local notebook file cannot be written | -| `1` | `-o env`, which cannot represent the payload | -| `1` | invalid `--remote` selector — `--remote=<url>` (`TargetNeedsProjectError`), an empty value, `--remote` combined with `--project-ref`, or a branch the linked project does not have (`LinkBranchNotFoundError`) | +| Code | Condition | +| ---- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `0` | success | +| `1` | no project ref — not linked and no `--remote` | +| `1` | the supplied notebook id is not a UUID | +| `1` | the project holds two notebooks of one name during a broad pull | +| `1` | the notebooks path exists but cannot be read as a directory | +| `1` | an explicitly requested notebook name cannot be stored safely as a local file | +| `1` | a Management API call failed (transport, unexpected status, or undecodable body) | +| `1` | a local file the user chose to create in the project is not a readable notebook | +| `1` | a local notebook file cannot be written | +| `1` | `-o env`, which cannot represent the payload | +| `1` | invalid `--remote` selector — `--remote=<url>` (`TargetNeedsProjectError`), an empty value, `--remote=<value>` combined with `--project-ref`, or a branch the linked project does not have (`LinkBranchNotFoundError`) | ## Environment Variables diff --git a/apps/cli/src/commands/notebooks/push/SIDE_EFFECTS.md b/apps/cli/src/commands/notebooks/push/SIDE_EFFECTS.md index c6c22ce698..23d995ff09 100644 --- a/apps/cli/src/commands/notebooks/push/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/notebooks/push/SIDE_EFFECTS.md @@ -83,20 +83,20 @@ never mentions `favorite` keeps whatever the dashboard set. ## Exit Codes -| Code | Condition | -| ---- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `0` | success | -| `1` | no project ref — not linked and no `--remote` | -| `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 | -| `1` | invalid `--remote` selector — `--remote=<url>` (`TargetNeedsProjectError`), an empty value, `--remote` combined with `--project-ref`, or a branch the linked project does not have (`LinkBranchNotFoundError`) | +| Code | Condition | +| ---- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `0` | success | +| `1` | no project ref — not linked and no `--remote` | +| `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 | +| `1` | invalid `--remote` selector — `--remote=<url>` (`TargetNeedsProjectError`), an empty value, `--remote=<value>` combined with `--project-ref`, or a branch the linked project does not have (`LinkBranchNotFoundError`) | ## Environment Variables 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 be3b1b07f0..53380605a7 100644 --- a/apps/cli/src/commands/postgres-config/delete/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/postgres-config/delete/SIDE_EFFECTS.md @@ -47,7 +47,7 @@ The remaining stored overrides are forwarded unvalidated, so a stored key this C | `1` | malformed CSV in a `--config` value — fails during flag parsing, before the `--experimental` gate, the handler, and telemetry, with the exact diagnostic text on stderr (e.g. `invalid argument "\"max_connections" for "--config" flag: parse error on line 1, column 17: extraneous or missing " in quoted-field`; a blank-only value fails with `EOF`) — CLI-2005 | | `1` | `--experimental` not passed and `SUPABASE_EXPERIMENTAL` unset (`ExperimentalRequiredError`) - checked before ref resolution/API/telemetry | | `1` | project ref unresolved (`ProjectRefNotLinkedError`; `InvalidProjectRefError` for a malformed `SUPABASE_PROJECT_ID` or link-file ref) | -| `1` | invalid `--remote` selector — `--remote=<url>` (`TargetNeedsProjectError`), an empty value, `--remote` combined with `--project-ref`, or a branch the linked project does not have (`LinkBranchNotFoundError`) | +| `1` | invalid `--remote` selector — `--remote=<url>` (`TargetNeedsProjectError`), an empty value, `--remote=<value>` combined with `--project-ref`, or a branch the linked project does not have (`LinkBranchNotFoundError`) | | `1` | initial GET non-2xx (`PostgresConfigGetUnexpectedStatusError`) | | `1` | initial GET transport failure (`PostgresConfigGetNetworkError`) | | `1` | PUT non-2xx (`PostgresConfigDeleteUnexpectedStatusError`) | @@ -58,9 +58,9 @@ The remaining stored overrides are forwarded unvalidated, so a stored key this C ## Telemetry Events Fired -| Event | When | Notable properties / groups | -| ---------------------- | ------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------- | -| `cli_command_executed` | post-run, success or failure (via wrapper) | `exit_code`, `duration_ms`, `flags` (`--remote` → `true` (bare), the ref, or `<redacted>`; `--project-ref` → `<redacted>`) | +| Event | When | Notable properties / groups | +| ---------------------- | ------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `cli_command_executed` | post-run, success or failure (via wrapper) | `exit_code`, `duration_ms`, `flags` (`--remote` → `true` (bare), the ref, or `<redacted>`; `--project-ref` → the ref when ref-shaped, else `<redacted>`) | ## Output 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 737095f893..0d99ee9841 100644 --- a/apps/cli/src/commands/postgres-config/get/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/postgres-config/get/SIDE_EFFECTS.md @@ -35,21 +35,21 @@ ## Exit Codes -| Code | Condition | -| ---- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `0` | success - Postgres config printed | -| `1` | `--experimental` not passed and `SUPABASE_EXPERIMENTAL` unset (`ExperimentalRequiredError`) - checked before ref resolution/API/telemetry | -| `1` | project ref unresolved (`ProjectRefNotLinkedError`; `InvalidProjectRefError` for a malformed `SUPABASE_PROJECT_ID` or link-file ref) | -| `1` | invalid `--remote` selector — `--remote=<url>` (`TargetNeedsProjectError`), an empty value, `--remote` combined with `--project-ref`, or a branch the linked project does not have (`LinkBranchNotFoundError`) | -| `1` | API non-2xx (`PostgresConfigGetUnexpectedStatusError`) | -| `1` | transport failure (`PostgresConfigGetNetworkError`); the GET retries on 5xx and transport errors first | -| `1` | response body is not a JSON object (`PostgresConfigGetUnmarshalError`) | +| Code | Condition | +| ---- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `0` | success - Postgres config printed | +| `1` | `--experimental` not passed and `SUPABASE_EXPERIMENTAL` unset (`ExperimentalRequiredError`) - checked before ref resolution/API/telemetry | +| `1` | project ref unresolved (`ProjectRefNotLinkedError`; `InvalidProjectRefError` for a malformed `SUPABASE_PROJECT_ID` or link-file ref) | +| `1` | invalid `--remote` selector — `--remote=<url>` (`TargetNeedsProjectError`), an empty value, `--remote=<value>` combined with `--project-ref`, or a branch the linked project does not have (`LinkBranchNotFoundError`) | +| `1` | API non-2xx (`PostgresConfigGetUnexpectedStatusError`) | +| `1` | transport failure (`PostgresConfigGetNetworkError`); the GET retries on 5xx and transport errors first | +| `1` | response body is not a JSON object (`PostgresConfigGetUnmarshalError`) | ## Telemetry Events Fired -| Event | When | Notable properties / groups | -| ---------------------- | ------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------- | -| `cli_command_executed` | post-run, success or failure (via wrapper) | `exit_code`, `duration_ms`, `flags` (`--remote` → `true` (bare), the ref, or `<redacted>`; `--project-ref` → `<redacted>`) | +| Event | When | Notable properties / groups | +| ---------------------- | ------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `cli_command_executed` | post-run, success or failure (via wrapper) | `exit_code`, `duration_ms`, `flags` (`--remote` → `true` (bare), the ref, or `<redacted>`; `--project-ref` → the ref when ref-shaped, else `<redacted>`) | ## Output 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 44829fae22..d12cf195e3 100644 --- a/apps/cli/src/commands/postgres-config/update/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/postgres-config/update/SIDE_EFFECTS.md @@ -48,7 +48,7 @@ The keys passed with `--config` (and `restart_database`) are validated locally a | `1` | `--experimental` not passed and `SUPABASE_EXPERIMENTAL` unset (`ExperimentalRequiredError`) - checked before ref resolution/API/telemetry | | `1` | malformed `--config` (`PostgresConfigInvalidConfigValueError`) | | `1` | project ref unresolved (`ProjectRefNotLinkedError`; `InvalidProjectRefError` for a malformed `SUPABASE_PROJECT_ID` or link-file ref) | -| `1` | invalid `--remote` selector — `--remote=<url>` (`TargetNeedsProjectError`), an empty value, `--remote` combined with `--project-ref`, or a branch the linked project does not have (`LinkBranchNotFoundError`) | +| `1` | invalid `--remote` selector — `--remote=<url>` (`TargetNeedsProjectError`), an empty value, `--remote=<value>` combined with `--project-ref`, or a branch the linked project does not have (`LinkBranchNotFoundError`) | | `1` | initial GET non-2xx (`PostgresConfigGetUnexpectedStatusError`) | | `1` | initial GET transport failure (`PostgresConfigGetNetworkError`) | | `1` | PUT non-2xx (`PostgresConfigUpdateUnexpectedStatusError`) | @@ -60,9 +60,9 @@ The keys passed with `--config` (and `restart_database`) are validated locally a ## Telemetry Events Fired -| Event | When | Notable properties / groups | -| ---------------------- | ------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------- | -| `cli_command_executed` | post-run, success or failure (via wrapper) | `exit_code`, `duration_ms`, `flags` (`--remote` → `true` (bare), the ref, or `<redacted>`; `--project-ref` → `<redacted>`) | +| Event | When | Notable properties / groups | +| ---------------------- | ------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `cli_command_executed` | post-run, success or failure (via wrapper) | `exit_code`, `duration_ms`, `flags` (`--remote` → `true` (bare), the ref, or `<redacted>`; `--project-ref` → the ref when ref-shaped, else `<redacted>`) | ## Output diff --git a/apps/cli/src/commands/projects/SIDE_EFFECTS.md b/apps/cli/src/commands/projects/SIDE_EFFECTS.md index f404e61fda..5409cc9c78 100644 --- a/apps/cli/src/commands/projects/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/projects/SIDE_EFFECTS.md @@ -64,8 +64,8 @@ subcommand's own `SIDE_EFFECTS.md`. | ---------------------- | ------------------------------------------ | ----------------------------------- | | `cli_command_executed` | post-run, success or failure (via wrapper) | `exit_code`, `duration_ms`, `flags` | -`safeFlags`: `--org-id` (`create`), a ref-shaped `--project-ref` (`api-keys`); the shared -wrapper reports `--remote` as `true` (bare), the ref, or `<redacted>`. No custom events beyond `cli_command_executed`. +`safeFlags`: `--org-id` (`create`). The shared wrapper reports the target flags: `--remote` as +`true` (bare), the ref, or `<redacted>`, and `--project-ref` only when it is ref-shaped. No custom events beyond `cli_command_executed`. ## Output (two-axis: `--output` × `--output-format`) diff --git a/apps/cli/src/commands/projects/api-keys/SIDE_EFFECTS.md b/apps/cli/src/commands/projects/api-keys/SIDE_EFFECTS.md index f98669702e..3661c91496 100644 --- a/apps/cli/src/commands/projects/api-keys/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/projects/api-keys/SIDE_EFFECTS.md @@ -41,14 +41,14 @@ Management API to return the full secret keys (`sb_secret_...`) in `api_key` ins ## Exit Codes -| Code | Condition | -| ---- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `0` | success — API keys printed to stdout | -| `1` | authentication error — no valid token found | -| `1` | API error — non-2xx response from `/v1/projects/{ref}/api-keys` | -| `1` | network / connection failure | -| `1` | project ref not provided and no linked project found | -| `1` | invalid `--remote` selector — `--remote=<url>` (`TargetNeedsProjectError`), an empty value, `--remote` combined with `--project-ref`, or a branch the linked project does not have (`LinkBranchNotFoundError`) | +| Code | Condition | +| ---- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `0` | success — API keys printed to stdout | +| `1` | authentication error — no valid token found | +| `1` | API error — non-2xx response from `/v1/projects/{ref}/api-keys` | +| `1` | network / connection failure | +| `1` | project ref not provided and no linked project found | +| `1` | invalid `--remote` selector — `--remote=<url>` (`TargetNeedsProjectError`), an empty value, `--remote=<value>` combined with `--project-ref`, or a branch the linked project does not have (`LinkBranchNotFoundError`) | ## Telemetry Events Fired diff --git a/apps/cli/src/commands/pull/SIDE_EFFECTS.md b/apps/cli/src/commands/pull/SIDE_EFFECTS.md index 24a379fbe8..77ca76317a 100644 --- a/apps/cli/src/commands/pull/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/pull/SIDE_EFFECTS.md @@ -186,9 +186,9 @@ condition. Every other step's failure still counts as a `pull` failure. ## Telemetry Events Fired -| Event | When | Notable properties / groups | -| ---------------------- | --------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `cli_command_executed` | post-run, success or failure (via `withCommandTelemetry`) | `exit_code`, `duration_ms`, `flags`; `--remote=<ref>` is logged as the ref, a bare `--remote` as `true`, and a branch name or UUID is redacted; the deprecated `--project-ref`'s value is only in `safeFlags` (logged verbatim) when it is ref-shaped (`PROJECT_REF_PATTERN`) — a user-created branch name is redacted | +| Event | When | Notable properties / groups | +| ---------------------- | --------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `cli_command_executed` | post-run, success or failure (via `withCommandTelemetry`) | `exit_code`, `duration_ms`, `flags`; `--remote=<ref>` is logged as the ref, a bare `--remote` as `true`, and a branch name or UUID is redacted; the deprecated `--project-ref`'s value is logged only when it is ref-shaped (`PROJECT_REF_PATTERN`) — a user-created branch name is redacted | Exactly one `cli_command_executed` fires per `pull` invocation. None of the four reused steps fire their own `cli_command_executed` (or any other event) when run through `pull` — each is called as a diff --git a/apps/cli/src/commands/secrets/list/SIDE_EFFECTS.md b/apps/cli/src/commands/secrets/list/SIDE_EFFECTS.md index b198db2b25..da44011c73 100644 --- a/apps/cli/src/commands/secrets/list/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/secrets/list/SIDE_EFFECTS.md @@ -36,23 +36,23 @@ ## Exit Codes -| Code | Condition | -| ---- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `0` | success — secrets printed to stdout | -| `1` | `AccessTokenRequiredError` — no token in env/keyring/file | -| `1` | `InvalidAccessTokenError` — token violates `^sbp_(oauth_\|v0_)?[a-f0-9]{40}$` | -| `1` | `ProjectRefNotLinkedError` — `--remote=<ref>` unset, env/file empty, and stdin not a TTY | -| `1` | invalid `--remote` selector — `--remote=<url>` (`TargetNeedsProjectError`), an empty value, `--remote` combined with `--project-ref`, or a branch the linked project does not have (`LinkBranchNotFoundError`) | -| `1` | `InvalidProjectRefError` — a `SUPABASE_PROJECT_ID` or link-file ref violates `^[a-z]{20}$` | -| `1` | `SecretsListUnexpectedStatusError` — non-2xx response from the secrets endpoint | -| `1` | `SecretsListNetworkError` — transport-level network failure | -| `1` | `SecretsEnvNotSupportedError` — `--output env` flag is rejected | +| Code | Condition | +| ---- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `0` | success — secrets printed to stdout | +| `1` | `AccessTokenRequiredError` — no token in env/keyring/file | +| `1` | `InvalidAccessTokenError` — token violates `^sbp_(oauth_\|v0_)?[a-f0-9]{40}$` | +| `1` | `ProjectRefNotLinkedError` — `--remote=<ref>` unset, env/file empty, and stdin not a TTY | +| `1` | invalid `--remote` selector — `--remote=<url>` (`TargetNeedsProjectError`), an empty value, `--remote=<value>` combined with `--project-ref`, or a branch the linked project does not have (`LinkBranchNotFoundError`) | +| `1` | `InvalidProjectRefError` — a `SUPABASE_PROJECT_ID` or link-file ref violates `^[a-z]{20}$` | +| `1` | `SecretsListUnexpectedStatusError` — non-2xx response from the secrets endpoint | +| `1` | `SecretsListNetworkError` — transport-level network failure | +| `1` | `SecretsEnvNotSupportedError` — `--output env` flag is rejected | ## Telemetry Events Fired -| Event | When | Notable properties / groups | -| ---------------------- | ------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------- | -| `cli_command_executed` | post-run, success or failure (via wrapper) | `exit_code`, `duration_ms`, `flags` (`--remote` → `true` (bare), the ref, or `<redacted>`; `--project-ref` → `<redacted>`) | +| Event | When | Notable properties / groups | +| ---------------------- | ------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `cli_command_executed` | post-run, success or failure (via wrapper) | `exit_code`, `duration_ms`, `flags` (`--remote` → `true` (bare), the ref, or `<redacted>`; `--project-ref` → the ref when ref-shaped, else `<redacted>`) | ## Output diff --git a/apps/cli/src/commands/secrets/set/SIDE_EFFECTS.md b/apps/cli/src/commands/secrets/set/SIDE_EFFECTS.md index dd5411f6ea..800d608c6b 100644 --- a/apps/cli/src/commands/secrets/set/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/secrets/set/SIDE_EFFECTS.md @@ -43,30 +43,30 @@ ## 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` — `--remote=<ref>` unset, env/file empty, and stdin not a TTY | -| `1` | invalid `--remote` selector — `--remote=<url>` (`TargetNeedsProjectError`), an empty value, `--remote` combined with `--project-ref`, or a branch the linked project does not have (`LinkBranchNotFoundError`) | -| `1` | `InvalidProjectRefError` — a `SUPABASE_PROJECT_ID` or link-file 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 | +| 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` — `--remote=<ref>` unset, env/file empty, and stdin not a TTY | +| `1` | invalid `--remote` selector — `--remote=<url>` (`TargetNeedsProjectError`), an empty value, `--remote=<value>` combined with `--project-ref`, or a branch the linked project does not have (`LinkBranchNotFoundError`) | +| `1` | `InvalidProjectRefError` — a `SUPABASE_PROJECT_ID` or link-file 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 -| Event | When | Notable properties / groups | -| ---------------------- | ------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------- | -| `cli_command_executed` | post-run, success or failure (via wrapper) | `exit_code`, `duration_ms`, `flags` (`--remote` → `true` (bare), the ref, or `<redacted>`; `--project-ref` / `--env-file` → `<redacted>`) | +| Event | When | Notable properties / groups | +| ---------------------- | ------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `cli_command_executed` | post-run, success or failure (via wrapper) | `exit_code`, `duration_ms`, `flags` (`--remote` → `true` (bare), the ref, or `<redacted>`; `--project-ref` → the ref when ref-shaped, else `<redacted>`; `--env-file` → `<redacted>`) | ## Output diff --git a/apps/cli/src/commands/secrets/unset/SIDE_EFFECTS.md b/apps/cli/src/commands/secrets/unset/SIDE_EFFECTS.md index 64b2e7ad59..be9f41e963 100644 --- a/apps/cli/src/commands/secrets/unset/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/secrets/unset/SIDE_EFFECTS.md @@ -50,7 +50,7 @@ | `1` | `AccessTokenRequiredError` — no token in env/keyring/file | | `1` | `InvalidAccessTokenError` — token violates `^sbp_(oauth_\|v0_)?[a-f0-9]{40}$` | | `1` | `ProjectRefNotLinkedError` — `--remote=<ref>` unset, env/file empty, and stdin not a TTY | -| `1` | invalid `--remote` selector — `--remote=<url>` (`TargetNeedsProjectError`), an empty value, `--remote` combined with `--project-ref`, or a branch the linked project does not have (`LinkBranchNotFoundError`) | +| `1` | invalid `--remote` selector — `--remote=<url>` (`TargetNeedsProjectError`), an empty value, `--remote=<value>` combined with `--project-ref`, or a branch the linked project does not have (`LinkBranchNotFoundError`) | | `1` | `InvalidProjectRefError` — a `SUPABASE_PROJECT_ID` or link-file 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) | @@ -61,9 +61,9 @@ ## Telemetry Events Fired -| Event | When | Notable properties / groups | -| ---------------------- | ------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------- | -| `cli_command_executed` | post-run, success or failure (via wrapper) | `exit_code`, `duration_ms`, `flags` (`--remote` → `true` (bare), the ref, or `<redacted>`; `--project-ref` → `<redacted>`) | +| Event | When | Notable properties / groups | +| ---------------------- | ------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `cli_command_executed` | post-run, success or failure (via wrapper) | `exit_code`, `duration_ms`, `flags` (`--remote` → `true` (bare), the ref, or `<redacted>`; `--project-ref` → the ref when ref-shaped, else `<redacted>`) | ## Output diff --git a/apps/cli/src/commands/snippets/download/SIDE_EFFECTS.md b/apps/cli/src/commands/snippets/download/SIDE_EFFECTS.md index 0083d49764..bbb083d927 100644 --- a/apps/cli/src/commands/snippets/download/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/snippets/download/SIDE_EFFECTS.md @@ -40,20 +40,20 @@ The snippet request carries no project ref and no query. The ref is resolved onl ## Exit Codes -| Code | Condition | -| ---- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `0` | success — SQL written to stdout | -| `1` | `SnippetsInvalidIdError` — `<snippet-id>` is not a valid UUID | -| `1` | `ProjectRefNotLinkedError`; `InvalidProjectRefError` for a malformed `SUPABASE_PROJECT_ID` or link-file ref | -| `1` | invalid `--remote` selector — `--remote=<url>` (`TargetNeedsProjectError`), an empty value, `--remote` combined with `--project-ref`, or a branch the linked project does not have (`LinkBranchNotFoundError`) | -| `1` | `SnippetsDownloadUnexpectedStatusError` — non-2xx response | -| `1` | `SnippetsDownloadNetworkError` — transport-level failure | +| Code | Condition | +| ---- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `0` | success — SQL written to stdout | +| `1` | `SnippetsInvalidIdError` — `<snippet-id>` is not a valid UUID | +| `1` | `ProjectRefNotLinkedError`; `InvalidProjectRefError` for a malformed `SUPABASE_PROJECT_ID` or link-file ref | +| `1` | invalid `--remote` selector — `--remote=<url>` (`TargetNeedsProjectError`), an empty value, `--remote=<value>` combined with `--project-ref`, or a branch the linked project does not have (`LinkBranchNotFoundError`) | +| `1` | `SnippetsDownloadUnexpectedStatusError` — non-2xx response | +| `1` | `SnippetsDownloadNetworkError` — transport-level failure | ## Telemetry Events Fired -| Event | When | Notable properties | -| ---------------------- | ------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------- | -| `cli_command_executed` | post-run, success or failure (via wrapper) | `exit_code`, `duration_ms`, `flags` (`--remote` → `true` (bare), the ref, or `<redacted>`; `--project-ref` → `<redacted>`) | +| Event | When | Notable properties | +| ---------------------- | ------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `cli_command_executed` | post-run, success or failure (via wrapper) | `exit_code`, `duration_ms`, `flags` (`--remote` → `true` (bare), the ref, or `<redacted>`; `--project-ref` → the ref when ref-shaped, else `<redacted>`) | ## Output diff --git a/apps/cli/src/commands/snippets/list/SIDE_EFFECTS.md b/apps/cli/src/commands/snippets/list/SIDE_EFFECTS.md index 727c39928d..cca001b45b 100644 --- a/apps/cli/src/commands/snippets/list/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/snippets/list/SIDE_EFFECTS.md @@ -34,20 +34,20 @@ ## Exit Codes -| Code | Condition | -| ---- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `0` | success | -| `1` | `SnippetsEnvNotSupportedError` — `--output env` was requested | -| `1` | `ProjectRefNotLinkedError`; `InvalidProjectRefError` for a malformed `SUPABASE_PROJECT_ID` or link-file ref | -| `1` | invalid `--remote` selector — `--remote=<url>` (`TargetNeedsProjectError`), an empty value, `--remote` combined with `--project-ref`, or a branch the linked project does not have (`LinkBranchNotFoundError`) | -| `1` | `SnippetsListUnexpectedStatusError` — non-2xx response | -| `1` | `SnippetsListNetworkError` — transport-level failure | +| Code | Condition | +| ---- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `0` | success | +| `1` | `SnippetsEnvNotSupportedError` — `--output env` was requested | +| `1` | `ProjectRefNotLinkedError`; `InvalidProjectRefError` for a malformed `SUPABASE_PROJECT_ID` or link-file ref | +| `1` | invalid `--remote` selector — `--remote=<url>` (`TargetNeedsProjectError`), an empty value, `--remote=<value>` combined with `--project-ref`, or a branch the linked project does not have (`LinkBranchNotFoundError`) | +| `1` | `SnippetsListUnexpectedStatusError` — non-2xx response | +| `1` | `SnippetsListNetworkError` — transport-level failure | ## Telemetry Events Fired -| Event | When | Notable properties | -| ---------------------- | ------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------- | -| `cli_command_executed` | post-run, success or failure (via wrapper) | `exit_code`, `duration_ms`, `flags` (`--remote` → `true` (bare), the ref, or `<redacted>`; `--project-ref` → `<redacted>`) | +| Event | When | Notable properties | +| ---------------------- | ------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `cli_command_executed` | post-run, success or failure (via wrapper) | `exit_code`, `duration_ms`, `flags` (`--remote` → `true` (bare), the ref, or `<redacted>`; `--project-ref` → the ref when ref-shaped, else `<redacted>`) | ## Output 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 bc52d0ff7a..3b70947839 100644 --- a/apps/cli/src/commands/ssl-enforcement/get/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/ssl-enforcement/get/SIDE_EFFECTS.md @@ -35,20 +35,20 @@ ## Exit Codes -| Code | Condition | -| ---- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `0` | success — SSL enforcement status printed to stdout | -| `1` | `--experimental` not passed and `SUPABASE_EXPERIMENTAL` unset (`ExperimentalRequiredError`) — checked before ref resolution/API/telemetry | -| `1` | project ref unresolved (`ProjectRefNotLinkedError`; `InvalidProjectRefError` for a malformed `SUPABASE_PROJECT_ID` or link-file ref) | -| `1` | invalid `--remote` selector — `--remote=<url>` (`TargetNeedsProjectError`), an empty value, `--remote` combined with `--project-ref`, or a branch the linked project does not have (`LinkBranchNotFoundError`) | -| `1` | API non-200 (`SslEnforcementGetUnexpectedStatusError`) | -| `1` | transport failure (`SslEnforcementGetNetworkError`) | +| Code | Condition | +| ---- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `0` | success — SSL enforcement status printed to stdout | +| `1` | `--experimental` not passed and `SUPABASE_EXPERIMENTAL` unset (`ExperimentalRequiredError`) — checked before ref resolution/API/telemetry | +| `1` | project ref unresolved (`ProjectRefNotLinkedError`; `InvalidProjectRefError` for a malformed `SUPABASE_PROJECT_ID` or link-file ref) | +| `1` | invalid `--remote` selector — `--remote=<url>` (`TargetNeedsProjectError`), an empty value, `--remote=<value>` combined with `--project-ref`, or a branch the linked project does not have (`LinkBranchNotFoundError`) | +| `1` | API non-200 (`SslEnforcementGetUnexpectedStatusError`) | +| `1` | transport failure (`SslEnforcementGetNetworkError`) | ## Telemetry Events Fired -| Event | When | Notable properties / groups | -| ---------------------- | ------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------- | -| `cli_command_executed` | post-run, success or failure (via wrapper) | `exit_code`, `duration_ms`, `flags` (`--remote` → `true` (bare), the ref, or `<redacted>`; `--project-ref` → `<redacted>`) | +| Event | When | Notable properties / groups | +| ---------------------- | ------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `cli_command_executed` | post-run, success or failure (via wrapper) | `exit_code`, `duration_ms`, `flags` (`--remote` → `true` (bare), the ref, or `<redacted>`; `--project-ref` → the ref when ref-shaped, else `<redacted>`) | ## Output 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 3e3bd01d7d..dbaa92e599 100644 --- a/apps/cli/src/commands/ssl-enforcement/update/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/ssl-enforcement/update/SIDE_EFFECTS.md @@ -36,24 +36,24 @@ ## Exit Codes -| Code | Condition | -| ---- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `0` | success — SSL enforcement status (post-update) printed to stdout | -| `1` | `--experimental` not passed and `SUPABASE_EXPERIMENTAL` unset (`ExperimentalRequiredError`) — checked in `.command.ts`, before the handler (and its flag validation/telemetry) ever runs | -| `1` | neither `--enable-db-ssl-enforcement` nor `--disable-db-ssl-enforcement` set (`SslEnforcementNoEnableDisableFlagError`) | -| `1` | both `--enable-db-ssl-enforcement` and `--disable-db-ssl-enforcement` set (`SslEnforcementMutuallyExclusiveFlagsError`) | -| `1` | project ref unresolved (`ProjectRefNotLinkedError`; `InvalidProjectRefError` for a malformed `SUPABASE_PROJECT_ID` or link-file ref) | -| `1` | invalid `--remote` selector — `--remote=<url>` (`TargetNeedsProjectError`), an empty value, `--remote` combined with `--project-ref`, or a branch the linked project does not have (`LinkBranchNotFoundError`) | -| `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 | +| Code | Condition | +| ---- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `0` | success — SSL enforcement status (post-update) printed to stdout | +| `1` | `--experimental` not passed and `SUPABASE_EXPERIMENTAL` unset (`ExperimentalRequiredError`) — checked in `.command.ts`, before the handler (and its flag validation/telemetry) ever runs | +| `1` | neither `--enable-db-ssl-enforcement` nor `--disable-db-ssl-enforcement` set (`SslEnforcementNoEnableDisableFlagError`) | +| `1` | both `--enable-db-ssl-enforcement` and `--disable-db-ssl-enforcement` set (`SslEnforcementMutuallyExclusiveFlagsError`) | +| `1` | project ref unresolved (`ProjectRefNotLinkedError`; `InvalidProjectRefError` for a malformed `SUPABASE_PROJECT_ID` or link-file ref) | +| `1` | invalid `--remote` selector — `--remote=<url>` (`TargetNeedsProjectError`), an empty value, `--remote=<value>` combined with `--project-ref`, or a branch the linked project does not have (`LinkBranchNotFoundError`) | +| `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 -| Event | When | Notable properties / groups | -| ---------------------- | ------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `cli_command_executed` | post-run, success or failure (via wrapper) | `exit_code`, `duration_ms`, `flags` (`--remote` → `true` (bare), the ref, or `<redacted>`; `--project-ref` → `<redacted>`; `--enable-db-ssl-enforcement` / `--disable-db-ssl-enforcement` booleans pass through) | +| Event | When | Notable properties / groups | +| ---------------------- | ------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `cli_command_executed` | post-run, success or failure (via wrapper) | `exit_code`, `duration_ms`, `flags` (`--remote` → `true` (bare), the ref, or `<redacted>`; `--project-ref` → the ref when ref-shaped, else `<redacted>`; `--enable-db-ssl-enforcement` / `--disable-db-ssl-enforcement` booleans pass through) | ## Output diff --git a/apps/cli/src/commands/sso/add/SIDE_EFFECTS.md b/apps/cli/src/commands/sso/add/SIDE_EFFECTS.md index b73897cb49..6930cff200 100644 --- a/apps/cli/src/commands/sso/add/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/sso/add/SIDE_EFFECTS.md @@ -65,7 +65,7 @@ is sent. A 2xx response whose body does not match the provider schema fails as | `1` | `SsoAddNetworkError` — transport failure or undecodable 2xx body | | `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 | -| `1` | invalid `--remote` selector — `--remote=<url>` (`TargetNeedsProjectError`), an empty value, `--remote` combined with `--project-ref`, or a branch the linked project does not have (`LinkBranchNotFoundError`) | +| `1` | invalid `--remote` selector — `--remote=<url>` (`TargetNeedsProjectError`), an empty value, `--remote=<value>` combined with `--project-ref`, or a branch the linked project does not have (`LinkBranchNotFoundError`) | ## Telemetry Events Fired diff --git a/apps/cli/src/commands/sso/info/SIDE_EFFECTS.md b/apps/cli/src/commands/sso/info/SIDE_EFFECTS.md index 7da36b0655..78c5d6f57e 100644 --- a/apps/cli/src/commands/sso/info/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/sso/info/SIDE_EFFECTS.md @@ -40,12 +40,12 @@ own. The ref resolution and the cache fill still call the API: ## Exit Codes -| Code | Condition | -| ---- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `0` | success | -| `1` | `InvalidProjectRefError` — `SUPABASE_PROJECT_ID` or the link file holds a ref that doesn't match `^[a-z]{20}$` | -| `1` | `ProjectRefNotLinkedError` — no ref source available | -| `1` | invalid `--remote` selector — `--remote=<url>` (`TargetNeedsProjectError`), an empty value, `--remote` combined with `--project-ref`, or a branch the linked project does not have (`LinkBranchNotFoundError`) | +| Code | Condition | +| ---- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `0` | success | +| `1` | `InvalidProjectRefError` — `SUPABASE_PROJECT_ID` or the link file holds a ref that doesn't match `^[a-z]{20}$` | +| `1` | `ProjectRefNotLinkedError` — no ref source available | +| `1` | invalid `--remote` selector — `--remote=<url>` (`TargetNeedsProjectError`), an empty value, `--remote=<value>` combined with `--project-ref`, or a branch the linked project does not have (`LinkBranchNotFoundError`) | ## Telemetry Events Fired diff --git a/apps/cli/src/commands/sso/list/SIDE_EFFECTS.md b/apps/cli/src/commands/sso/list/SIDE_EFFECTS.md index c360e34beb..3be5c86aa8 100644 --- a/apps/cli/src/commands/sso/list/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/sso/list/SIDE_EFFECTS.md @@ -37,13 +37,13 @@ ## Exit Codes -| Code | Condition | -| ---- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `0` | success | -| `1` | `SsoListSamlDisabledError` — 404 from list endpoint | -| `1` | `SsoListUnexpectedStatusError` — other non-2xx | -| `1` | `SsoListNetworkError` — transport-level failure | -| `1` | invalid `--remote` selector — `--remote=<url>` (`TargetNeedsProjectError`), an empty value, `--remote` combined with `--project-ref`, or a branch the linked project does not have (`LinkBranchNotFoundError`) | +| Code | Condition | +| ---- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `0` | success | +| `1` | `SsoListSamlDisabledError` — 404 from list endpoint | +| `1` | `SsoListUnexpectedStatusError` — other non-2xx | +| `1` | `SsoListNetworkError` — transport-level failure | +| `1` | invalid `--remote` selector — `--remote=<url>` (`TargetNeedsProjectError`), an empty value, `--remote=<value>` combined with `--project-ref`, or a branch the linked project does not have (`LinkBranchNotFoundError`) | ## Telemetry Events Fired diff --git a/apps/cli/src/commands/sso/remove/SIDE_EFFECTS.md b/apps/cli/src/commands/sso/remove/SIDE_EFFECTS.md index 728c98de48..246d2b2a7a 100644 --- a/apps/cli/src/commands/sso/remove/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/sso/remove/SIDE_EFFECTS.md @@ -38,16 +38,16 @@ ## 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 | -| `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 | -| `1` | invalid `--remote` selector — `--remote=<url>` (`TargetNeedsProjectError`), an empty value, `--remote` combined with `--project-ref`, or a branch the linked project does not have (`LinkBranchNotFoundError`) | +| 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 | +| `1` | invalid `--remote` selector — `--remote=<url>` (`TargetNeedsProjectError`), an empty value, `--remote=<value>` combined with `--project-ref`, or a branch the linked project does not have (`LinkBranchNotFoundError`) | ## Telemetry Events Fired diff --git a/apps/cli/src/commands/sso/show/SIDE_EFFECTS.md b/apps/cli/src/commands/sso/show/SIDE_EFFECTS.md index e144a1ea80..83b3b5f425 100644 --- a/apps/cli/src/commands/sso/show/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/sso/show/SIDE_EFFECTS.md @@ -38,15 +38,15 @@ side-calls. ## Exit Codes -| Code | Condition | -| ---- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `0` | success | -| `1` | `SsoInvalidUuidError` — provider ID is not a canonical UUID | -| `1` | `SsoShowNotFoundError` — 404 from get endpoint | -| `1` | `SsoShowUnexpectedStatusError` — other non-2xx | -| `1` | `SsoShowEnvNotSupportedError` — `--output env` with show | -| `1` | `SsoShowNetworkError` — transport-level failure | -| `1` | invalid `--remote` selector — `--remote=<url>` (`TargetNeedsProjectError`), an empty value, `--remote` combined with `--project-ref`, or a branch the linked project does not have (`LinkBranchNotFoundError`) | +| Code | Condition | +| ---- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `0` | success | +| `1` | `SsoInvalidUuidError` — provider ID is not a canonical UUID | +| `1` | `SsoShowNotFoundError` — 404 from get endpoint | +| `1` | `SsoShowUnexpectedStatusError` — other non-2xx | +| `1` | `SsoShowEnvNotSupportedError` — `--output env` with show | +| `1` | `SsoShowNetworkError` — transport-level failure | +| `1` | invalid `--remote` selector — `--remote=<url>` (`TargetNeedsProjectError`), an empty value, `--remote=<value>` combined with `--project-ref`, or a branch the linked project does not have (`LinkBranchNotFoundError`) | ## Telemetry Events Fired diff --git a/apps/cli/src/commands/sso/update/SIDE_EFFECTS.md b/apps/cli/src/commands/sso/update/SIDE_EFFECTS.md index 776d1eed06..09a3b06f17 100644 --- a/apps/cli/src/commands/sso/update/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/sso/update/SIDE_EFFECTS.md @@ -69,7 +69,7 @@ classification), for the GET before any PUT. | `1` | `SsoUpdateNetworkError` — transport failure or undecodable 2xx body | | `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 | -| `1` | invalid `--remote` selector — `--remote=<url>` (`TargetNeedsProjectError`), an empty value, `--remote` combined with `--project-ref`, or a branch the linked project does not have (`LinkBranchNotFoundError`) | +| `1` | invalid `--remote` selector — `--remote=<url>` (`TargetNeedsProjectError`), an empty value, `--remote=<value>` combined with `--project-ref`, or a branch the linked project does not have (`LinkBranchNotFoundError`) | ## Telemetry Events Fired diff --git a/apps/cli/src/commands/storage/cp/SIDE_EFFECTS.md b/apps/cli/src/commands/storage/cp/SIDE_EFFECTS.md index 11e95ebfcf..29a563ca59 100644 --- a/apps/cli/src/commands/storage/cp/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/storage/cp/SIDE_EFFECTS.md @@ -13,15 +13,15 @@ stack-backend endpoint, credential, capability-state, and error behavior. ## Files Read -| Path | Format | When | -| ---------------------------------------------- | ---------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `<workdir>/supabase/config.toml` | TOML | always (local creds; `[storage.buckets.*]` for bucket auto-create). With an explicit `--workdir`/`SUPABASE_WORKDIR` on a LOCAL target, a missing project config is now a hard failure (`StorageMissingProjectConfigError`) rather than a fall-back to embedded defaults — the default `api.port` would otherwise point the operation at a different local stack; a REMOTE (`--project-ref`/`--linked`) target never hard-fails on this, since credential resolution there reads the Management API, not local config | -| `~/.supabase/access-token` | plain text | remote project or branch lookup, when `SUPABASE_ACCESS_TOKEN` unset | -| `<workdir>/supabase/.temp/project-ref` | plain text | a remote project: resolves the ref (bare `--remote` with no `SUPABASE_PROJECT_ID`) and tells whether it is the linked project; with no selector, only to word the linked-checkout hint | -| `<workdir>/supabase/.temp/linked-project.json` | JSON | the `Target:` banner and the linked-checkout hint, the parent project for `--remote=<branch>`, and the existence check before the cache fill | -| local Kong TLS cert/key | PEM | legacy backend only, local + `api.enabled` + `api.tls.enabled` | -| `<workdir>/supabase/.env*`, `<workdir>/.env*` | dotenv | local path: read on both backends for `env(VAR)` interpolation while loading `config.toml`; on the legacy backend also to resolve the `SUPABASE_API_*` overrides for the gateway URL/TLS (#6452) and `SUPABASE_AUTH_{JWT_SECRET,SERVICE_ROLE_KEY}` for the service-role key | -| upload source files | bytes | upload: sniff (≤512 bytes) + streamed body | +| Path | Format | When | +| ---------------------------------------------- | ---------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `<workdir>/supabase/config.toml` | TOML | always (local creds; `[storage.buckets.*]` for bucket auto-create). With an explicit `--workdir`/`SUPABASE_WORKDIR` on a LOCAL target, a missing project config is now a hard failure (`StorageMissingProjectConfigError`) rather than a fall-back to embedded defaults — the default `api.port` would otherwise point the operation at a different local stack; a REMOTE (`--remote`) target never hard-fails on this, since credential resolution there reads the Management API, not local config | +| `~/.supabase/access-token` | plain text | remote project or branch lookup, when `SUPABASE_ACCESS_TOKEN` unset | +| `<workdir>/supabase/.temp/project-ref` | plain text | a remote project: resolves the ref (bare `--remote` with no `SUPABASE_PROJECT_ID`) and tells whether it is the linked project; with no selector, only to word the linked-checkout hint | +| `<workdir>/supabase/.temp/linked-project.json` | JSON | the `Target:` banner and the linked-checkout hint, the parent project for `--remote=<branch>`, and the existence check before the cache fill | +| local Kong TLS cert/key | PEM | legacy backend only, local + `api.enabled` + `api.tls.enabled` | +| `<workdir>/supabase/.env*`, `<workdir>/.env*` | dotenv | local path: read on both backends for `env(VAR)` interpolation while loading `config.toml`; on the legacy backend also to resolve the `SUPABASE_API_*` overrides for the gateway URL/TLS (#6452) and `SUPABASE_AUTH_{JWT_SECRET,SERVICE_ROLE_KEY}` for the service-role key | +| upload source files | bytes | upload: sniff (≤512 bytes) + streamed body | ## Files Written diff --git a/apps/cli/src/commands/storage/ls/SIDE_EFFECTS.md b/apps/cli/src/commands/storage/ls/SIDE_EFFECTS.md index f9757d17fa..b84d1e959d 100644 --- a/apps/cli/src/commands/storage/ls/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/storage/ls/SIDE_EFFECTS.md @@ -12,14 +12,14 @@ stack-backend endpoint, credential, capability-state, and error behavior. ## Files Read -| Path | Format | When | -| ---------------------------------------------- | ---------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `<workdir>/supabase/config.toml` | TOML | always (local creds/baseUrl; `[remotes.*]` merge when linked). With an explicit `--workdir`/`SUPABASE_WORKDIR` on a LOCAL target, a missing project config is now a hard failure (`StorageMissingProjectConfigError`) rather than a fall-back to embedded defaults — the default `api.port` would otherwise point the operation at a different local stack; a REMOTE (`--project-ref`/`--linked`) target never hard-fails on this, since credential resolution there reads the Management API, not local config | -| `~/.supabase/access-token` | plain text | remote project or branch lookup, when `SUPABASE_ACCESS_TOKEN` unset | -| `<workdir>/supabase/.temp/project-ref` | plain text | a remote project: resolves the ref (bare `--remote` with no `SUPABASE_PROJECT_ID`) and tells whether it is the linked project; with no selector, only to word the linked-checkout hint | -| `<workdir>/supabase/.temp/linked-project.json` | JSON | the `Target:` banner and the linked-checkout hint, the parent project for `--remote=<branch>`, and the existence check before the cache fill | -| local Kong TLS cert/key | PEM | legacy backend only, local + `api.enabled` + `api.tls.enabled` | -| `<workdir>/supabase/.env*`, `<workdir>/.env*` | dotenv | local path: read on both backends for `env(VAR)` interpolation while loading `config.toml`; on the legacy backend also to resolve the `SUPABASE_API_*` overrides for the gateway URL/TLS (#6452) and `SUPABASE_AUTH_{JWT_SECRET,SERVICE_ROLE_KEY}` for the service-role key | +| Path | Format | When | +| ---------------------------------------------- | ---------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `<workdir>/supabase/config.toml` | TOML | always (local creds/baseUrl; `[remotes.*]` merge when linked). With an explicit `--workdir`/`SUPABASE_WORKDIR` on a LOCAL target, a missing project config is now a hard failure (`StorageMissingProjectConfigError`) rather than a fall-back to embedded defaults — the default `api.port` would otherwise point the operation at a different local stack; a REMOTE (`--remote`) target never hard-fails on this, since credential resolution there reads the Management API, not local config | +| `~/.supabase/access-token` | plain text | remote project or branch lookup, when `SUPABASE_ACCESS_TOKEN` unset | +| `<workdir>/supabase/.temp/project-ref` | plain text | a remote project: resolves the ref (bare `--remote` with no `SUPABASE_PROJECT_ID`) and tells whether it is the linked project; with no selector, only to word the linked-checkout hint | +| `<workdir>/supabase/.temp/linked-project.json` | JSON | the `Target:` banner and the linked-checkout hint, the parent project for `--remote=<branch>`, and the existence check before the cache fill | +| local Kong TLS cert/key | PEM | legacy backend only, local + `api.enabled` + `api.tls.enabled` | +| `<workdir>/supabase/.env*`, `<workdir>/.env*` | dotenv | local path: read on both backends for `env(VAR)` interpolation while loading `config.toml`; on the legacy backend also to resolve the `SUPABASE_API_*` overrides for the gateway URL/TLS (#6452) and `SUPABASE_AUTH_{JWT_SECRET,SERVICE_ROLE_KEY}` for the service-role key | ## Files Written diff --git a/apps/cli/src/commands/storage/mv/SIDE_EFFECTS.md b/apps/cli/src/commands/storage/mv/SIDE_EFFECTS.md index c122b825ce..014428de14 100644 --- a/apps/cli/src/commands/storage/mv/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/storage/mv/SIDE_EFFECTS.md @@ -14,14 +14,14 @@ stack-backend endpoint, credential, capability-state, and error behavior. ## Files Read -| Path | Format | When | -| ---------------------------------------------- | ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `<workdir>/supabase/config.toml` | TOML | always (local creds; `[remotes.*]` merge when linked). With an explicit `--workdir`/`SUPABASE_WORKDIR` on a LOCAL target, a missing project config is now a hard failure (`StorageMissingProjectConfigError`) rather than a fall-back to embedded defaults — the default `api.port` would otherwise point the operation at a different local stack; a REMOTE (`--project-ref`/`--linked`) target never hard-fails on this, since credential resolution there reads the Management API, not local config | -| `~/.supabase/access-token` | plain text | remote project or branch lookup, when `SUPABASE_ACCESS_TOKEN` unset | -| `<workdir>/supabase/.temp/project-ref` | plain text | a remote project: resolves the ref (bare `--remote` with no `SUPABASE_PROJECT_ID`) and tells whether it is the linked project; with no selector, only to word the linked-checkout hint | -| `<workdir>/supabase/.temp/linked-project.json` | JSON | the `Target:` banner and the linked-checkout hint, the parent project for `--remote=<branch>`, and the existence check before the cache fill | -| local Kong TLS cert/key | PEM | legacy backend only, local + `api.enabled` + `api.tls.enabled` | -| `<workdir>/supabase/.env*`, `<workdir>/.env*` | dotenv | local path: read on both backends for `env(VAR)` interpolation while loading `config.toml`; on the legacy backend also to resolve the `SUPABASE_API_*` overrides for the gateway URL/TLS (#6452) and `SUPABASE_AUTH_{JWT_SECRET,SERVICE_ROLE_KEY}` for the service-role key | +| Path | Format | When | +| ---------------------------------------------- | ---------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `<workdir>/supabase/config.toml` | TOML | always (local creds; `[remotes.*]` merge when linked). With an explicit `--workdir`/`SUPABASE_WORKDIR` on a LOCAL target, a missing project config is now a hard failure (`StorageMissingProjectConfigError`) rather than a fall-back to embedded defaults — the default `api.port` would otherwise point the operation at a different local stack; a REMOTE (`--remote`) target never hard-fails on this, since credential resolution there reads the Management API, not local config | +| `~/.supabase/access-token` | plain text | remote project or branch lookup, when `SUPABASE_ACCESS_TOKEN` unset | +| `<workdir>/supabase/.temp/project-ref` | plain text | a remote project: resolves the ref (bare `--remote` with no `SUPABASE_PROJECT_ID`) and tells whether it is the linked project; with no selector, only to word the linked-checkout hint | +| `<workdir>/supabase/.temp/linked-project.json` | JSON | the `Target:` banner and the linked-checkout hint, the parent project for `--remote=<branch>`, and the existence check before the cache fill | +| local Kong TLS cert/key | PEM | legacy backend only, local + `api.enabled` + `api.tls.enabled` | +| `<workdir>/supabase/.env*`, `<workdir>/.env*` | dotenv | local path: read on both backends for `env(VAR)` interpolation while loading `config.toml`; on the legacy backend also to resolve the `SUPABASE_API_*` overrides for the gateway URL/TLS (#6452) and `SUPABASE_AUTH_{JWT_SECRET,SERVICE_ROLE_KEY}` for the service-role key | ## Files Written diff --git a/apps/cli/src/commands/storage/rm/SIDE_EFFECTS.md b/apps/cli/src/commands/storage/rm/SIDE_EFFECTS.md index 4df81ab87a..da9589df07 100644 --- a/apps/cli/src/commands/storage/rm/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/storage/rm/SIDE_EFFECTS.md @@ -15,14 +15,14 @@ stack-backend endpoint, credential, capability-state, and error behavior. ## Files Read -| Path | Format | When | -| ---------------------------------------------- | ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `<workdir>/supabase/config.toml` | TOML | always (local creds; `[remotes.*]` merge when linked). With an explicit `--workdir`/`SUPABASE_WORKDIR` on a LOCAL target, a missing project config is now a hard failure (`StorageMissingProjectConfigError`) rather than a fall-back to embedded defaults — the default `api.port` would otherwise point the operation at a different local stack; a REMOTE (`--project-ref`/`--linked`) target never hard-fails on this, since credential resolution there reads the Management API, not local config | -| `~/.supabase/access-token` | plain text | remote project or branch lookup, when `SUPABASE_ACCESS_TOKEN` unset | -| `<workdir>/supabase/.temp/project-ref` | plain text | a remote project: resolves the ref (bare `--remote` with no `SUPABASE_PROJECT_ID`) and tells whether it is the linked project; with no selector, only to word the linked-checkout hint | -| `<workdir>/supabase/.temp/linked-project.json` | JSON | the `Target:` banner and the linked-checkout hint, the parent project for `--remote=<branch>`, and the existence check before the cache fill | -| local Kong TLS cert/key | PEM | legacy backend only, local + `api.enabled` + `api.tls.enabled` | -| `<workdir>/supabase/.env*`, `<workdir>/.env*` | dotenv | always, to resolve `SUPABASE_YES` (CLI-1878) and, on both backends, for `env(VAR)` interpolation while loading `config.toml`; on the local, legacy-backend path also the `SUPABASE_API_*` overrides for the gateway URL/TLS (#6452) and `SUPABASE_AUTH_{JWT_SECRET,SERVICE_ROLE_KEY}` for the service-role key | +| Path | Format | When | +| ---------------------------------------------- | ---------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `<workdir>/supabase/config.toml` | TOML | always (local creds; `[remotes.*]` merge when linked). With an explicit `--workdir`/`SUPABASE_WORKDIR` on a LOCAL target, a missing project config is now a hard failure (`StorageMissingProjectConfigError`) rather than a fall-back to embedded defaults — the default `api.port` would otherwise point the operation at a different local stack; a REMOTE (`--remote`) target never hard-fails on this, since credential resolution there reads the Management API, not local config | +| `~/.supabase/access-token` | plain text | remote project or branch lookup, when `SUPABASE_ACCESS_TOKEN` unset | +| `<workdir>/supabase/.temp/project-ref` | plain text | a remote project: resolves the ref (bare `--remote` with no `SUPABASE_PROJECT_ID`) and tells whether it is the linked project; with no selector, only to word the linked-checkout hint | +| `<workdir>/supabase/.temp/linked-project.json` | JSON | the `Target:` banner and the linked-checkout hint, the parent project for `--remote=<branch>`, and the existence check before the cache fill | +| local Kong TLS cert/key | PEM | legacy backend only, local + `api.enabled` + `api.tls.enabled` | +| `<workdir>/supabase/.env*`, `<workdir>/.env*` | dotenv | always, to resolve `SUPABASE_YES` (CLI-1878) and, on both backends, for `env(VAR)` interpolation while loading `config.toml`; on the local, legacy-backend path also the `SUPABASE_API_*` overrides for the gateway URL/TLS (#6452) and `SUPABASE_AUTH_{JWT_SECRET,SERVICE_ROLE_KEY}` for the service-role key | ## Files Written 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 5daaa8b107..65287581aa 100644 --- a/apps/cli/src/commands/vanity-subdomains/activate/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/vanity-subdomains/activate/SIDE_EFFECTS.md @@ -38,17 +38,17 @@ ## Exit Codes -| Code | Condition | -| ---- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `0` | success | -| `1` | `--experimental` not passed and `SUPABASE_EXPERIMENTAL` unset (`ExperimentalRequiredError`) — checked before ref resolution/API/telemetry | -| `1` | project ref unresolved (`ProjectRefNotLinkedError`; `InvalidProjectRefError` for a malformed `SUPABASE_PROJECT_ID` or link-file ref) | -| `1` | invalid `--remote` selector — `--remote=<url>` (`TargetNeedsProjectError`), an empty value, `--remote` combined with `--project-ref`, or a branch the linked project does not have (`LinkBranchNotFoundError`) | -| `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 | +| Code | Condition | +| ---- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `0` | success | +| `1` | `--experimental` not passed and `SUPABASE_EXPERIMENTAL` unset (`ExperimentalRequiredError`) — checked before ref resolution/API/telemetry | +| `1` | project ref unresolved (`ProjectRefNotLinkedError`; `InvalidProjectRefError` for a malformed `SUPABASE_PROJECT_ID` or link-file ref) | +| `1` | invalid `--remote` selector — `--remote=<url>` (`TargetNeedsProjectError`), an empty value, `--remote=<value>` combined with `--project-ref`, or a branch the linked project does not have (`LinkBranchNotFoundError`) | +| `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 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 19722d0926..3dabd24bc2 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 @@ -37,15 +37,15 @@ ## Exit Codes -| Code | Condition | -| ---- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `0` | success | -| `1` | `--experimental` not passed and `SUPABASE_EXPERIMENTAL` unset (`ExperimentalRequiredError`) — checked before ref resolution/API/telemetry | -| `1` | project ref unresolved (`ProjectRefNotLinkedError`; `InvalidProjectRefError` for a malformed `SUPABASE_PROJECT_ID` or link-file ref) | -| `1` | invalid `--remote` selector — `--remote=<url>` (`TargetNeedsProjectError`), an empty value, `--remote` combined with `--project-ref`, or a branch the linked project does not have (`LinkBranchNotFoundError`) | -| `1` | `--desired-subdomain` omitted (`DesiredSubdomainRequiredError`) — checked after gate/login/ref resolution; telemetry still fires | -| `1` | API non-2xx (`VanitySubdomainsCheckUnexpectedStatusError`) | -| `1` | transport failure (`VanitySubdomainsCheckNetworkError`) | +| Code | Condition | +| ---- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `0` | success | +| `1` | `--experimental` not passed and `SUPABASE_EXPERIMENTAL` unset (`ExperimentalRequiredError`) — checked before ref resolution/API/telemetry | +| `1` | project ref unresolved (`ProjectRefNotLinkedError`; `InvalidProjectRefError` for a malformed `SUPABASE_PROJECT_ID` or link-file ref) | +| `1` | invalid `--remote` selector — `--remote=<url>` (`TargetNeedsProjectError`), an empty value, `--remote=<value>` combined with `--project-ref`, or a branch the linked project does not have (`LinkBranchNotFoundError`) | +| `1` | `--desired-subdomain` omitted (`DesiredSubdomainRequiredError`) — checked after gate/login/ref resolution; telemetry still fires | +| `1` | API non-2xx (`VanitySubdomainsCheckUnexpectedStatusError`) | +| `1` | transport failure (`VanitySubdomainsCheckNetworkError`) | ## Telemetry Events Fired 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 7316e40983..5d89e4c47b 100644 --- a/apps/cli/src/commands/vanity-subdomains/delete/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/vanity-subdomains/delete/SIDE_EFFECTS.md @@ -36,16 +36,16 @@ ## Exit Codes -| Code | Condition | -| ---- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `0` | success | -| `1` | `--experimental` not passed and `SUPABASE_EXPERIMENTAL` unset (`ExperimentalRequiredError`) — checked before ref resolution/API/telemetry | -| `1` | project ref unresolved (`ProjectRefNotLinkedError`; `InvalidProjectRefError` for a malformed `SUPABASE_PROJECT_ID` or link-file ref) | -| `1` | invalid `--remote` selector — `--remote=<url>` (`TargetNeedsProjectError`), an empty value, `--remote` combined with `--project-ref`, or a branch the linked project does not have (`LinkBranchNotFoundError`) | -| `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 | +| Code | Condition | +| ---- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `0` | success | +| `1` | `--experimental` not passed and `SUPABASE_EXPERIMENTAL` unset (`ExperimentalRequiredError`) — checked before ref resolution/API/telemetry | +| `1` | project ref unresolved (`ProjectRefNotLinkedError`; `InvalidProjectRefError` for a malformed `SUPABASE_PROJECT_ID` or link-file ref) | +| `1` | invalid `--remote` selector — `--remote=<url>` (`TargetNeedsProjectError`), an empty value, `--remote=<value>` combined with `--project-ref`, or a branch the linked project does not have (`LinkBranchNotFoundError`) | +| `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 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 626f59c8b5..636c280417 100644 --- a/apps/cli/src/commands/vanity-subdomains/get/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/vanity-subdomains/get/SIDE_EFFECTS.md @@ -35,14 +35,14 @@ ## Exit Codes -| Code | Condition | -| ---- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `0` | success | -| `1` | `--experimental` not passed and `SUPABASE_EXPERIMENTAL` unset (`ExperimentalRequiredError`) — checked before ref resolution/API/telemetry | -| `1` | project ref unresolved (`ProjectRefNotLinkedError`; `InvalidProjectRefError` for a malformed `SUPABASE_PROJECT_ID` or link-file ref) | -| `1` | invalid `--remote` selector — `--remote=<url>` (`TargetNeedsProjectError`), an empty value, `--remote` combined with `--project-ref`, or a branch the linked project does not have (`LinkBranchNotFoundError`) | -| `1` | API non-2xx (`VanitySubdomainsGetUnexpectedStatusError`) | -| `1` | transport failure (`VanitySubdomainsGetNetworkError`) | +| Code | Condition | +| ---- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `0` | success | +| `1` | `--experimental` not passed and `SUPABASE_EXPERIMENTAL` unset (`ExperimentalRequiredError`) — checked before ref resolution/API/telemetry | +| `1` | project ref unresolved (`ProjectRefNotLinkedError`; `InvalidProjectRefError` for a malformed `SUPABASE_PROJECT_ID` or link-file ref) | +| `1` | invalid `--remote` selector — `--remote=<url>` (`TargetNeedsProjectError`), an empty value, `--remote=<value>` combined with `--project-ref`, or a branch the linked project does not have (`LinkBranchNotFoundError`) | +| `1` | API non-2xx (`VanitySubdomainsGetUnexpectedStatusError`) | +| `1` | transport failure (`VanitySubdomainsGetNetworkError`) | ## Telemetry Events Fired From a7152c1237d82db9c7da8c2096c1f5ac95cf6a85 Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Mon, 19 Oct 2026 13:48:38 +0100 Subject: [PATCH 143/291] docs(cli): describe the bare --remote operand check in SIDE_EFFECTS A word after a bare --remote that matches a branch, nearly matches one or can't be checked stops the run with both readings. Commands that take no operands read the word as the flag's value. --- apps/cli/src/commands/backups/list/SIDE_EFFECTS.md | 2 +- apps/cli/src/commands/backups/restore/SIDE_EFFECTS.md | 2 +- apps/cli/src/commands/db/advisors/SIDE_EFFECTS.md | 6 +++--- apps/cli/src/commands/db/diff/SIDE_EFFECTS.md | 4 ++-- apps/cli/src/commands/db/dump/SIDE_EFFECTS.md | 4 ++-- apps/cli/src/commands/db/lint/SIDE_EFFECTS.md | 4 ++-- apps/cli/src/commands/db/pull/SIDE_EFFECTS.md | 6 ++++-- apps/cli/src/commands/db/push/SIDE_EFFECTS.md | 4 ++-- apps/cli/src/commands/db/query/SIDE_EFFECTS.md | 6 ++++-- apps/cli/src/commands/db/reset/SIDE_EFFECTS.md | 4 ++-- .../db/schema/declarative/generate/SIDE_EFFECTS.md | 4 ++-- apps/cli/src/commands/domains/SIDE_EFFECTS.md | 2 +- apps/cli/src/commands/encryption/SIDE_EFFECTS.md | 2 +- .../commands/experimental/compute/delete/SIDE_EFFECTS.md | 2 +- .../commands/experimental/compute/list/SIDE_EFFECTS.md | 2 +- .../commands/experimental/compute/logs/SIDE_EFFECTS.md | 2 +- .../commands/experimental/compute/push/SIDE_EFFECTS.md | 2 +- .../commands/experimental/compute/status/SIDE_EFFECTS.md | 2 +- apps/cli/src/commands/functions/delete/SIDE_EFFECTS.md | 2 +- apps/cli/src/commands/functions/deploy/SIDE_EFFECTS.md | 7 ++++--- apps/cli/src/commands/functions/download/SIDE_EFFECTS.md | 2 +- apps/cli/src/commands/functions/list/SIDE_EFFECTS.md | 2 +- apps/cli/src/commands/gen/types/SIDE_EFFECTS.md | 6 ++++-- apps/cli/src/commands/inspect/db/SIDE_EFFECTS.md | 5 ++--- apps/cli/src/commands/inspect/report/SIDE_EFFECTS.md | 5 ++--- apps/cli/src/commands/migration/down/SIDE_EFFECTS.md | 5 ++--- apps/cli/src/commands/migration/fetch/SIDE_EFFECTS.md | 5 ++--- apps/cli/src/commands/migration/list/SIDE_EFFECTS.md | 5 ++--- apps/cli/src/commands/migration/repair/SIDE_EFFECTS.md | 6 ++++-- apps/cli/src/commands/migration/squash/SIDE_EFFECTS.md | 5 ++--- apps/cli/src/commands/migration/up/SIDE_EFFECTS.md | 5 ++--- apps/cli/src/commands/network-bans/get/SIDE_EFFECTS.md | 5 ++--- apps/cli/src/commands/network-bans/remove/SIDE_EFFECTS.md | 5 ++--- .../src/commands/network-restrictions/get/SIDE_EFFECTS.md | 5 ++--- .../commands/network-restrictions/update/SIDE_EFFECTS.md | 5 ++--- apps/cli/src/commands/notebooks/pull/SIDE_EFFECTS.md | 7 ++++--- apps/cli/src/commands/notebooks/push/SIDE_EFFECTS.md | 7 ++++--- .../src/commands/postgres-config/delete/SIDE_EFFECTS.md | 5 ++--- apps/cli/src/commands/postgres-config/get/SIDE_EFFECTS.md | 5 ++--- .../src/commands/postgres-config/update/SIDE_EFFECTS.md | 5 ++--- apps/cli/src/commands/projects/api-keys/SIDE_EFFECTS.md | 5 ++--- apps/cli/src/commands/secrets/list/SIDE_EFFECTS.md | 5 ++--- apps/cli/src/commands/secrets/set/SIDE_EFFECTS.md | 7 ++++--- apps/cli/src/commands/secrets/unset/SIDE_EFFECTS.md | 7 ++++--- apps/cli/src/commands/seed/buckets/SIDE_EFFECTS.md | 6 +++--- apps/cli/src/commands/snippets/download/SIDE_EFFECTS.md | 7 ++++--- apps/cli/src/commands/snippets/list/SIDE_EFFECTS.md | 5 ++--- apps/cli/src/commands/ssl-enforcement/get/SIDE_EFFECTS.md | 5 ++--- .../src/commands/ssl-enforcement/update/SIDE_EFFECTS.md | 5 ++--- apps/cli/src/commands/sso/add/SIDE_EFFECTS.md | 5 ++--- apps/cli/src/commands/sso/info/SIDE_EFFECTS.md | 5 ++--- apps/cli/src/commands/sso/list/SIDE_EFFECTS.md | 5 ++--- apps/cli/src/commands/sso/remove/SIDE_EFFECTS.md | 7 ++++--- apps/cli/src/commands/sso/show/SIDE_EFFECTS.md | 7 ++++--- apps/cli/src/commands/sso/update/SIDE_EFFECTS.md | 7 ++++--- apps/cli/src/commands/storage/cp/SIDE_EFFECTS.md | 8 +++++--- apps/cli/src/commands/storage/ls/SIDE_EFFECTS.md | 8 +++++--- apps/cli/src/commands/storage/mv/SIDE_EFFECTS.md | 8 +++++--- apps/cli/src/commands/storage/rm/SIDE_EFFECTS.md | 8 +++++--- apps/cli/src/commands/test/db/SIDE_EFFECTS.md | 6 ++++-- .../commands/vanity-subdomains/activate/SIDE_EFFECTS.md | 5 ++--- .../vanity-subdomains/check-availability/SIDE_EFFECTS.md | 5 ++--- .../src/commands/vanity-subdomains/delete/SIDE_EFFECTS.md | 5 ++--- .../src/commands/vanity-subdomains/get/SIDE_EFFECTS.md | 5 ++--- 64 files changed, 158 insertions(+), 157 deletions(-) diff --git a/apps/cli/src/commands/backups/list/SIDE_EFFECTS.md b/apps/cli/src/commands/backups/list/SIDE_EFFECTS.md index cc9cd10a7b..9b09b76259 100644 --- a/apps/cli/src/commands/backups/list/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/backups/list/SIDE_EFFECTS.md @@ -93,7 +93,7 @@ One `result` NDJSON event on success containing the backup response object. ## Notes - Targets the linked project unless `--remote=<ref>` is given; a bare `--remote` also targets the linked project. `--remote=<value>` is read as a project ref (20 lowercase letters), a branch of the linked project (by name or UUID, resolved through `GET /v1/projects/{parent}/branches`), or `true|yes|on|1` for the linked project. A database URL is rejected (`supabase backups list` takes a project, not a database); `false|no|off|0`, `--no-remote` and an empty value fail with `Leave out --remote to use the linked project.` -- A bare `--remote` followed by a word that is a branch of the linked project fails before any other work with a `Did you mean --remote=<word>?` hint, since the word may have been meant as the flag's value. +- A word after a bare `--remote` is read as its value, `--remote=<word>`, because the command takes no operands. - The deprecated `--project-ref` is an alias of `--remote=<ref>` and prints a deprecation warning on stderr that names the replacement; it cannot be combined with `--remote=<value>`. An empty `--project-ref` is rejected. - Requires `--remote=<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. diff --git a/apps/cli/src/commands/backups/restore/SIDE_EFFECTS.md b/apps/cli/src/commands/backups/restore/SIDE_EFFECTS.md index fc622f2044..d607d94168 100644 --- a/apps/cli/src/commands/backups/restore/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/backups/restore/SIDE_EFFECTS.md @@ -81,7 +81,7 @@ One `result` NDJSON event on success with the project ref payload. ## Notes - Targets the linked project unless `--remote=<ref>` is given; a bare `--remote` also targets the linked project. `--remote=<value>` is read as a project ref (20 lowercase letters), a branch of the linked project (by name or UUID, resolved through `GET /v1/projects/{parent}/branches`), or `true|yes|on|1` for the linked project. A database URL is rejected (`supabase backups restore` takes a project, not a database); `false|no|off|0`, `--no-remote` and an empty value fail with `Leave out --remote to use the linked project.` -- A bare `--remote` followed by a word that is a branch of the linked project fails before any other work with a `Did you mean --remote=<word>?` hint, since the word may have been meant as the flag's value. +- A word after a bare `--remote` is read as its value, `--remote=<word>`, because the command takes no operands. - The deprecated `--project-ref` is an alias of `--remote=<ref>` and prints a deprecation warning on stderr that names the replacement; it cannot be combined with `--remote=<value>`. An empty `--project-ref` is rejected. - `--timestamp` / `-t` accepts seconds since Unix epoch (int64). Defaults to `0`, which the API interprets as "now". - Known gap: the generated `V1RestorePitrBackupInput` schema enforces `recovery_time_target_unix >= 0`, so a negative value is rejected locally with a schema decode error instead of being forwarded to the API (the underlying field has no lower bound). Resolving this requires an upstream OpenAPI spec change. diff --git a/apps/cli/src/commands/db/advisors/SIDE_EFFECTS.md b/apps/cli/src/commands/db/advisors/SIDE_EFFECTS.md index 471414ab24..2cabd472db 100644 --- a/apps/cli/src/commands/db/advisors/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/db/advisors/SIDE_EFFECTS.md @@ -103,9 +103,9 @@ the process exits non-zero (no error envelope is written over the payload). - `--fail-on` (`none` default) sets the level that forces a non-zero exit. - **Target selection.** `--remote` is read as the linked project (bare), a project ref (20 lowercase letters), a Postgres URL, or else a branch of the linked project by name or UUID; - `--remote=false` and `--remote=` fail with `Leave out --remote to target the local database.` A bare - `--remote` followed by a word that is a branch of the linked project fails with a - `Did you mean --remote=<word>?` hint, since the word may have been meant as the flag's value. + `--remote=false` and `--remote=` fail with `Leave out --remote to target the local database.` A + word after a bare `--remote` is read as its value, `--remote=<word>`, because the command takes no + operands. - **Deprecated aliases.** `--linked`, `--project-ref=<ref>`, `--db-url=<url>` and `--local` still work and print a deprecation warning on stderr naming the `--remote` replacement (`--local` warns that local is the default). They are mutually exclusive with each other and with diff --git a/apps/cli/src/commands/db/diff/SIDE_EFFECTS.md b/apps/cli/src/commands/db/diff/SIDE_EFFECTS.md index c470fe10d6..1a960a3aad 100644 --- a/apps/cli/src/commands/db/diff/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/db/diff/SIDE_EFFECTS.md @@ -239,8 +239,8 @@ transaction metadata. - **Target selection.** `--remote` is read as the linked project (bare), a project ref (20 lowercase letters), a Postgres URL, or else a branch of the linked project by name or UUID; `--remote=false` and `--remote=` fail with `Leave out --remote to target the local database.` A - bare `--remote` followed by a word that is a branch of the linked project fails with a - `Did you mean --remote=<word>?` hint, since the word may have been meant as the flag's value. + word after a bare `--remote` is read as its value, `--remote=<word>`, because the command takes no + operands. `--remote=<ref>` supplies only the ref; unlike `SUPABASE_PROJECT_ID`, it does not affect the shadow container's project id/labels. - **Deprecated aliases.** `--linked`, `--project-ref=<ref>`, `--db-url=<url>` and `--local` still diff --git a/apps/cli/src/commands/db/dump/SIDE_EFFECTS.md b/apps/cli/src/commands/db/dump/SIDE_EFFECTS.md index 61c10b0f8a..60e89e5296 100644 --- a/apps/cli/src/commands/db/dump/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/db/dump/SIDE_EFFECTS.md @@ -108,8 +108,8 @@ shell inherits the suppressing variables and is missed. - **Target selection.** `--remote` is read as the linked project (bare), a project ref (20 lowercase letters), a Postgres URL, or else a branch of the linked project by name or UUID; `--remote=false` and `--remote=` fail with `Leave out --remote to target the local database.` A - bare `--remote` followed by a word that is a branch of the linked project fails with a - `Did you mean --remote=<word>?` hint, since the word may have been meant as the flag's value. + word after a bare `--remote` is read as its value, `--remote=<word>`, because the command takes no + operands. - **Deprecated aliases.** `--linked`, `--project-ref=<ref>`, `--db-url=<url>` and `--local` still work and print a deprecation warning on stderr naming the `--remote` replacement (`--local` warns that local is the default). They are mutually exclusive with each other and with diff --git a/apps/cli/src/commands/db/lint/SIDE_EFFECTS.md b/apps/cli/src/commands/db/lint/SIDE_EFFECTS.md index 84253c1fe7..6d1b983463 100644 --- a/apps/cli/src/commands/db/lint/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/db/lint/SIDE_EFFECTS.md @@ -111,8 +111,8 @@ the process exits non-zero (no error envelope is written over the payload). - **Target selection.** `--remote` is read as the linked project (bare), a project ref (20 lowercase letters), a Postgres URL, or else a branch of the linked project by name or UUID; `--remote=false` and `--remote=` fail with `Leave out --remote to target the local database.` A - bare `--remote` followed by a word that is a branch of the linked project fails with a - `Did you mean --remote=<word>?` hint, since the word may have been meant as the flag's value. + word after a bare `--remote` is read as its value, `--remote=<word>`, because the command takes no + operands. - **Deprecated aliases.** `--linked`, `--project-ref=<ref>`, `--db-url=<url>` and `--local` still work and print a deprecation warning on stderr naming the `--remote` replacement (`--local` warns that local is the default). They are mutually exclusive with each other and with diff --git a/apps/cli/src/commands/db/pull/SIDE_EFFECTS.md b/apps/cli/src/commands/db/pull/SIDE_EFFECTS.md index 48faed7607..33240565e5 100644 --- a/apps/cli/src/commands/db/pull/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/db/pull/SIDE_EFFECTS.md @@ -213,8 +213,10 @@ resolved `target` object is merged into the payload. - **Target selection.** `--remote` is read as the linked project (bare), a project ref (20 lowercase letters), a Postgres URL, or else a branch of the linked project by name or UUID; `--remote=false` and `--remote=` fail with `Leave out --remote to target the local database.` A - bare `--remote` followed by a word that is a branch of the linked project fails with a - `Did you mean --remote=<word>?` hint, since the word may have been meant as the flag's value. + bare `--remote` followed by a word that exactly matches a branch of the linked project, is within + a small edit distance of one, or can't be checked because the branch listing failed stops the run + with an error that offers both readings: `--remote=<word>` for the branch, `<word> --remote` for + the operand. Any other word runs as the operand. - **Deprecated aliases.** `--linked`, `--project-ref=<ref>`, `--db-url=<url>` and `--local` still work and print a deprecation warning on stderr naming the `--remote` replacement (`--local` warns that local is the default). They are mutually exclusive with each other and with diff --git a/apps/cli/src/commands/db/push/SIDE_EFFECTS.md b/apps/cli/src/commands/db/push/SIDE_EFFECTS.md index 58360beec7..e24542a2eb 100644 --- a/apps/cli/src/commands/db/push/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/db/push/SIDE_EFFECTS.md @@ -110,8 +110,8 @@ project ref and its environment. - **Target selection.** `--remote` is read as the linked project (bare), a project ref (20 lowercase letters), a Postgres URL, or else a branch of the linked project by name or UUID; `--remote=false` and `--remote=` fail with `Leave out --remote to target the local database.` A - bare `--remote` followed by a word that is a branch of the linked project fails with a - `Did you mean --remote=<word>?` hint, since the word may have been meant as the flag's value. + word after a bare `--remote` is read as its value, `--remote=<word>`, because the command takes no + operands. - **Deprecated aliases.** `--linked`, `--project-ref=<ref>`, `--db-url=<url>` and `--local` still work and print a deprecation warning on stderr naming the `--remote` replacement (`--local` warns that local is the default). They are mutually exclusive with each other and with diff --git a/apps/cli/src/commands/db/query/SIDE_EFFECTS.md b/apps/cli/src/commands/db/query/SIDE_EFFECTS.md index 667e1069e8..f6abf48ca7 100644 --- a/apps/cli/src/commands/db/query/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/db/query/SIDE_EFFECTS.md @@ -105,8 +105,10 @@ from the environment. Agent mode defaults the format to JSON (table for humans). - **Target selection.** `--remote` is read as the linked project (bare), a project ref (20 lowercase letters), a Postgres URL, or else a branch of the linked project by name or UUID; `--remote=false` and `--remote=` fail with `Leave out --remote to target the local database.` A - bare `--remote` followed by a word that is a branch of the linked project fails with a - `Did you mean --remote=<word>?` hint, since the word may have been meant as the flag's value. + bare `--remote` followed by a word that exactly matches a branch of the linked project, is within + a small edit distance of one, or can't be checked because the branch listing failed stops the run + with an error that offers both readings: `--remote=<word>` for the branch, `<word> --remote` for + the operand. Any other word runs as the operand. - **Deprecated aliases.** `--linked`, `--project-ref=<ref>`, `--db-url=<url>` and `--local` still work and print a deprecation warning on stderr naming the `--remote` replacement (`--local` warns that local is the default). They are mutually exclusive with each other and with diff --git a/apps/cli/src/commands/db/reset/SIDE_EFFECTS.md b/apps/cli/src/commands/db/reset/SIDE_EFFECTS.md index ebfaafaa3d..79d5693c4d 100644 --- a/apps/cli/src/commands/db/reset/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/db/reset/SIDE_EFFECTS.md @@ -275,8 +275,8 @@ to those defaults (the usual outcome for an interactive terminal). - **Target selection.** `--remote` is read as the linked project (bare), a project ref (20 lowercase letters), a Postgres URL, or else a branch of the linked project by name or UUID; `--remote=false` and `--remote=` fail with `Leave out --remote to target the local database.` A - bare `--remote` followed by a word that is a branch of the linked project fails with a - `Did you mean --remote=<word>?` hint, since the word may have been meant as the flag's value. + word after a bare `--remote` is read as its value, `--remote=<word>`, because the command takes no + operands. - **Deprecated aliases.** `--linked`, `--project-ref=<ref>`, `--db-url=<url>` and `--local` still work and print a deprecation warning on stderr naming the `--remote` replacement (`--local` warns that local is the default). They are mutually exclusive with each other and with 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 764317d64b..fefebbf091 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 @@ -97,8 +97,8 @@ always go to stderr, in every `--output-format`. On success: - **Target selection.** `--remote` is read as the linked project (bare), a project ref (20 lowercase letters), a Postgres URL, or else a branch of the linked project by name or UUID; `--remote=false` and `--remote=` fail with `Leave out --remote to target the local database.` A - bare `--remote` followed by a word that is a branch of the linked project fails with a - `Did you mean --remote=<word>?` hint, since the word may have been meant as the flag's value. + word after a bare `--remote` is read as its value, `--remote=<word>`, because the command takes no + operands. - **Deprecated aliases.** `--linked`, `--project-ref=<ref>`, `--db-url=<url>` and `--local` still work and print a deprecation warning on stderr naming the `--remote` replacement (`--local` warns that local is the default). They are mutually exclusive with each other and with diff --git a/apps/cli/src/commands/domains/SIDE_EFFECTS.md b/apps/cli/src/commands/domains/SIDE_EFFECTS.md index f80fc1abf5..d4430ae982 100644 --- a/apps/cli/src/commands/domains/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/domains/SIDE_EFFECTS.md @@ -98,7 +98,7 @@ suppressed on stderr. `delete` ignores `-o`. ## Notes - Targets the linked project unless `--remote=<ref>` is given; a bare `--remote` also targets the linked project. `--remote=<value>` is read as a project ref (20 lowercase letters), a branch of the linked project (by name or UUID, resolved through `GET /v1/projects/{parent}/branches`), or `true|yes|on|1` for the linked project. A database URL is rejected (these commands take a project, not a database); `false|no|off|0`, `--no-remote` and an empty value fail with `Leave out --remote to use the linked project.` -- A bare `--remote` followed by a word that is a branch of the linked project fails before any other work with a `Did you mean --remote=<word>?` hint, since the word may have been meant as the flag's value. +- A word after a bare `--remote` is read as its value, `--remote=<word>`, because the command takes no operands. - The deprecated `--project-ref` is an alias of `--remote=<ref>` and prints a deprecation warning on stderr that names the replacement; it cannot be combined with `--remote=<value>`. An empty `--project-ref` is rejected. - `--custom-hostname` is required for `create`. - `create` validates the CNAME via Cloudflare DNS-over-HTTPS (`https://1.1.1.1`, 10s timeout) before initializing; on failure it short-circuits before any POST. diff --git a/apps/cli/src/commands/encryption/SIDE_EFFECTS.md b/apps/cli/src/commands/encryption/SIDE_EFFECTS.md index f1ad2d558f..4d85113f94 100644 --- a/apps/cli/src/commands/encryption/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/encryption/SIDE_EFFECTS.md @@ -83,7 +83,7 @@ One `result` event carrying `{root_key}` (both subcommands). ## Notes - Targets the linked project unless `--remote=<ref>` is given; a bare `--remote` also targets the linked project. `--remote=<value>` is read as a project ref (20 lowercase letters), a branch of the linked project (by name or UUID, resolved through `GET /v1/projects/{parent}/branches`), or `true|yes|on|1` for the linked project. A database URL is rejected (these commands take a project, not a database); `false|no|off|0`, `--no-remote` and an empty value fail with `Leave out --remote to use the linked project.` -- A bare `--remote` followed by a word that is a branch of the linked project fails before any other work with a `Did you mean --remote=<word>?` hint, since the word may have been meant as the flag's value. +- A word after a bare `--remote` is read as its value, `--remote=<word>`, because the command takes no operands. - The deprecated `--project-ref` is an alias of `--remote=<ref>` and prints a deprecation warning on stderr that names the replacement; it cannot be combined with `--remote=<value>`. An empty `--project-ref` is rejected. - Requires `--remote=<ref>`, `SUPABASE_PROJECT_ID`, or a linked project. - `update-root-key` reads the key from stdin: a real TTY is read with a masked 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 2c0c5a6030..40e8cde6ce 100644 --- a/apps/cli/src/commands/experimental/compute/delete/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/experimental/compute/delete/SIDE_EFFECTS.md @@ -110,5 +110,5 @@ fall through to that same text branch. ## Notes - Targets the linked project unless `--remote=<ref>` is given; a bare `--remote` also targets the linked project. `--remote=<value>` is read as a project ref (20 lowercase letters), a branch of the linked project (by name or UUID, resolved through `GET /v1/projects/{parent}/branches`), or `true|yes|on|1` for the linked project. A database URL is rejected (`supabase experimental compute delete` takes a project, not a database); `false|no|off|0`, `--no-remote` and an empty value fail with `Leave out --remote to use the linked project.` -- A bare `--remote` followed by a word that is a branch of the linked project fails before any other work with a `Did you mean --remote=<word>?` hint, since the word may have been meant as the flag's value. +- A bare `--remote` followed by a word that exactly matches a branch of the linked project, is within a small edit distance of one, or can't be checked because the branch listing failed stops the run before any other work with an error that offers both readings: `--remote=<word>` for the branch, `<word> --remote` for the operand. Any other word runs as the operand. - The deprecated `--project-ref` is an alias of `--remote=<ref>` and prints a deprecation warning on stderr that names the replacement; it cannot be combined with `--remote=<value>`. An empty `--project-ref` is rejected. diff --git a/apps/cli/src/commands/experimental/compute/list/SIDE_EFFECTS.md b/apps/cli/src/commands/experimental/compute/list/SIDE_EFFECTS.md index 8d40a388a9..885d125ec6 100644 --- a/apps/cli/src/commands/experimental/compute/list/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/experimental/compute/list/SIDE_EFFECTS.md @@ -87,5 +87,5 @@ format still carries `url` per compute, and `compute status` renders it. ## Notes - Targets the linked project unless `--remote=<ref>` is given; a bare `--remote` also targets the linked project. `--remote=<value>` is read as a project ref (20 lowercase letters), a branch of the linked project (by name or UUID, resolved through `GET /v1/projects/{parent}/branches`), or `true|yes|on|1` for the linked project. A database URL is rejected (`supabase experimental compute list` takes a project, not a database); `false|no|off|0`, `--no-remote` and an empty value fail with `Leave out --remote to use the linked project.` -- A bare `--remote` followed by a word that is a branch of the linked project fails before any other work with a `Did you mean --remote=<word>?` hint, since the word may have been meant as the flag's value. +- A word after a bare `--remote` is read as its value, `--remote=<word>`, because the command takes no operands. - The deprecated `--project-ref` is an alias of `--remote=<ref>` and prints a deprecation warning on stderr that names the replacement; it cannot be combined with `--remote=<value>`. An empty `--project-ref` is rejected. diff --git a/apps/cli/src/commands/experimental/compute/logs/SIDE_EFFECTS.md b/apps/cli/src/commands/experimental/compute/logs/SIDE_EFFECTS.md index fa156da3ff..8a753331e1 100644 --- a/apps/cli/src/commands/experimental/compute/logs/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/experimental/compute/logs/SIDE_EFFECTS.md @@ -170,5 +170,5 @@ preserved so a stack trace survives intact. ## Notes - Targets the linked project unless `--remote=<ref>` is given; a bare `--remote` also targets the linked project. `--remote=<value>` is read as a project ref (20 lowercase letters), a branch of the linked project (by name or UUID, resolved through `GET /v1/projects/{parent}/branches`), or `true|yes|on|1` for the linked project. A database URL is rejected (`supabase experimental compute logs` takes a project, not a database); `false|no|off|0`, `--no-remote` and an empty value fail with `Leave out --remote to use the linked project.` -- A bare `--remote` followed by a word that is a branch of the linked project fails before any other work with a `Did you mean --remote=<word>?` hint, since the word may have been meant as the flag's value. +- A bare `--remote` followed by a word that exactly matches a branch of the linked project, is within a small edit distance of one, or can't be checked because the branch listing failed stops the run before any other work with an error that offers both readings: `--remote=<word>` for the branch, `<word> --remote` for the operand. Any other word runs as the operand. - The deprecated `--project-ref` is an alias of `--remote=<ref>` and prints a deprecation warning on stderr that names the replacement; it cannot be combined with `--remote=<value>`. An empty `--project-ref` is rejected. 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 9112585af4..5d69a62e9d 100644 --- a/apps/cli/src/commands/experimental/compute/push/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/experimental/compute/push/SIDE_EFFECTS.md @@ -194,5 +194,5 @@ strings that carry a signature. ## Notes - Targets the linked project unless `--remote=<ref>` is given; a bare `--remote` also targets the linked project. `--remote=<value>` is read as a project ref (20 lowercase letters), a branch of the linked project (by name or UUID, resolved through `GET /v1/projects/{parent}/branches`), or `true|yes|on|1` for the linked project. A database URL is rejected (`supabase experimental compute push` takes a project, not a database); `false|no|off|0`, `--no-remote` and an empty value fail with `Leave out --remote to use the linked project.` -- A bare `--remote` followed by a word that is a branch of the linked project fails before any other work with a `Did you mean --remote=<word>?` hint, since the word may have been meant as the flag's value. +- A bare `--remote` followed by a word that exactly matches a branch of the linked project, is within a small edit distance of one, or can't be checked because the branch listing failed stops the run before any other work with an error that offers both readings: `--remote=<word>` for the branch, `<word> --remote` for the operand. Any other word runs as the operand. - The deprecated `--project-ref` is an alias of `--remote=<ref>` and prints a deprecation warning on stderr that names the replacement; it cannot be combined with `--remote=<value>`. An empty `--project-ref` is rejected. diff --git a/apps/cli/src/commands/experimental/compute/status/SIDE_EFFECTS.md b/apps/cli/src/commands/experimental/compute/status/SIDE_EFFECTS.md index 05e66b0d21..77aa1c874a 100644 --- a/apps/cli/src/commands/experimental/compute/status/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/experimental/compute/status/SIDE_EFFECTS.md @@ -91,5 +91,5 @@ fall through to that same text branch. ## Notes - Targets the linked project unless `--remote=<ref>` is given; a bare `--remote` also targets the linked project. `--remote=<value>` is read as a project ref (20 lowercase letters), a branch of the linked project (by name or UUID, resolved through `GET /v1/projects/{parent}/branches`), or `true|yes|on|1` for the linked project. A database URL is rejected (`supabase experimental compute status` takes a project, not a database); `false|no|off|0`, `--no-remote` and an empty value fail with `Leave out --remote to use the linked project.` -- A bare `--remote` followed by a word that is a branch of the linked project fails before any other work with a `Did you mean --remote=<word>?` hint, since the word may have been meant as the flag's value. +- A bare `--remote` followed by a word that exactly matches a branch of the linked project, is within a small edit distance of one, or can't be checked because the branch listing failed stops the run before any other work with an error that offers both readings: `--remote=<word>` for the branch, `<word> --remote` for the operand. Any other word runs as the operand. - The deprecated `--project-ref` is an alias of `--remote=<ref>` and prints a deprecation warning on stderr that names the replacement; it cannot be combined with `--remote=<value>`. An empty `--project-ref` is rejected. diff --git a/apps/cli/src/commands/functions/delete/SIDE_EFFECTS.md b/apps/cli/src/commands/functions/delete/SIDE_EFFECTS.md index d7d7ede8d7..2fdf824ad6 100644 --- a/apps/cli/src/commands/functions/delete/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/functions/delete/SIDE_EFFECTS.md @@ -72,7 +72,7 @@ Prints the same structured success result, including `target`, and needs `--yes` ## Notes - Targets the linked project unless `--remote=<ref>` is given; a bare `--remote` also targets the linked project. `--remote=<value>` is read as a project ref (20 lowercase letters), a branch of the linked project (by name or UUID, resolved through `GET /v1/projects/{parent}/branches`), or `true|yes|on|1` for the linked project. A database URL is rejected (`supabase functions delete` takes a project, not a database); `false|no|off|0`, `--no-remote` and an empty value fail with `Leave out --remote to use the linked project.` -- A bare `--remote` followed by a word that is a branch of the linked project fails before any other work with a `Did you mean --remote=<word>?` hint, since the word may have been meant as the flag's value. +- A bare `--remote` followed by a word that exactly matches a branch of the linked project, is within a small edit distance of one, or can't be checked because the branch listing failed stops the run before any other work with an error that offers both readings: `--remote=<word>` for the branch, `<word> --remote` for the operand. Any other word runs as the operand. - The deprecated `--project-ref` is an alias of `--remote=<ref>` and prints a deprecation warning on stderr that names the replacement; it cannot be combined with `--remote=<value>`. An empty `--project-ref` is rejected. - Requires exactly one argument: the function slug/name. - Does NOT remove the function from the local filesystem. diff --git a/apps/cli/src/commands/functions/deploy/SIDE_EFFECTS.md b/apps/cli/src/commands/functions/deploy/SIDE_EFFECTS.md index 1708b2d35c..773ec6be6f 100644 --- a/apps/cli/src/commands/functions/deploy/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/functions/deploy/SIDE_EFFECTS.md @@ -118,9 +118,10 @@ The `--output`/`-o` flag does not change deploy output. project (by name or UUID), or `true|yes|on|1` for the linked project. A database URL is rejected (`functions deploy` takes a project, not a database); `false|no|off|0`, `--no-remote` and an empty value fail with `Leave out --remote to use the linked project.` -- A bare `--remote` followed by a word that is a branch of the linked project fails before any - other work with a `Did you mean --remote=<word>?` hint, since the word may have been meant as the - flag's value. +- A bare `--remote` followed by a word that exactly matches a branch of the linked project, is + within a small edit distance of one, or can't be checked because the branch listing failed stops + the run before any other work with an error that offers both readings: `--remote=<word>` for the + branch, `<word> --remote` for the operand. Any other word runs as the operand. - The deprecated `--project-ref` is an alias of `--remote=<ref>` and prints a deprecation warning on stderr that names the replacement; it cannot be combined with `--remote=<value>`. - 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`. diff --git a/apps/cli/src/commands/functions/download/SIDE_EFFECTS.md b/apps/cli/src/commands/functions/download/SIDE_EFFECTS.md index 4c3c8de9b8..5fc68d2473 100644 --- a/apps/cli/src/commands/functions/download/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/functions/download/SIDE_EFFECTS.md @@ -104,7 +104,7 @@ Same envelope as `json` above (including on the Docker-unbundle path). ## Notes - Targets the linked project unless `--remote=<ref>` is given; a bare `--remote` also targets the linked project. `--remote=<value>` is read as a project ref (20 lowercase letters), a branch of the linked project (by name or UUID, resolved through `GET /v1/projects/{parent}/branches`), or `true|yes|on|1` for the linked project. A database URL is rejected (`supabase functions download` takes a project, not a database); `false|no|off|0`, `--no-remote` and an empty value fail with `Leave out --remote to use the linked project.` -- A bare `--remote` followed by a word that is a branch of the linked project fails before any other work with a `Did you mean --remote=<word>?` hint, since the word may have been meant as the flag's value. +- A bare `--remote` followed by a word that exactly matches a branch of the linked project, is within a small edit distance of one, or can't be checked because the branch listing failed stops the run before any other work with an error that offers both readings: `--remote=<word>` for the branch, `<word> --remote` for the operand. Any other word runs as the operand. - The deprecated `--project-ref` is an alias of `--remote=<ref>` and prints a deprecation warning on stderr that names the replacement; it cannot be combined with `--remote=<value>`. An empty `--project-ref` is rejected. - **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 diff --git a/apps/cli/src/commands/functions/list/SIDE_EFFECTS.md b/apps/cli/src/commands/functions/list/SIDE_EFFECTS.md index a86d60f043..8ddf62648a 100644 --- a/apps/cli/src/commands/functions/list/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/functions/list/SIDE_EFFECTS.md @@ -69,7 +69,7 @@ Prints the same structured success result, including `target`. ## Notes - Targets the linked project unless `--remote=<ref>` is given; a bare `--remote` also targets the linked project. `--remote=<value>` is read as a project ref (20 lowercase letters), a branch of the linked project (by name or UUID, resolved through `GET /v1/projects/{parent}/branches`), or `true|yes|on|1` for the linked project. A database URL is rejected (`supabase functions list` takes a project, not a database); `false|no|off|0`, `--no-remote` and an empty value fail with `Leave out --remote to use the linked project.` -- A bare `--remote` followed by a word that is a branch of the linked project fails before any other work with a `Did you mean --remote=<word>?` hint, since the word may have been meant as the flag's value. +- A word after a bare `--remote` is read as its value, `--remote=<word>`, because the command takes no operands. - The deprecated `--project-ref` is an alias of `--remote=<ref>` and prints a deprecation warning on stderr that names the replacement; it cannot be combined with `--remote=<value>`. An empty `--project-ref` is rejected. - Requires a linked project (`--remote=<ref>`, `SUPABASE_PROJECT_ID`, or `<workdir>/supabase/.temp/project-ref`). - Uses the Management API. diff --git a/apps/cli/src/commands/gen/types/SIDE_EFFECTS.md b/apps/cli/src/commands/gen/types/SIDE_EFFECTS.md index b5f378c9ae..a6304ca11e 100644 --- a/apps/cli/src/commands/gen/types/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/gen/types/SIDE_EFFECTS.md @@ -155,8 +155,10 @@ Not applicable. `postgresql://`), `true|yes|on|1` (the linked project), or otherwise the name of a branch of the linked project, which resolves to that branch's own project. `false|no|off|0`, `--no-remote` and an empty value fail with `Leave out --remote to target the local database.` A bare `--remote` - followed by a word that is a branch of the linked project fails with a - `Did you mean --remote=<word>?` hint, since the word may have been meant as the flag's value. The + followed by a word that exactly matches a branch of the linked project, is within a small edit + distance of one, or can't be checked because the branch listing failed stops the run with an error + that offers both readings: `--remote=<word>` for the branch, `<word> --remote` for the operand. + Any other word runs as the operand. The resolved target is announced on stderr (`Target: …`) in every output format. - **Deprecated selectors.** `--linked`, `--project-ref`, `--project-id`, `--db-url` and `--local` are hidden aliases that still select their target and print a warning on stderr naming the diff --git a/apps/cli/src/commands/inspect/db/SIDE_EFFECTS.md b/apps/cli/src/commands/inspect/db/SIDE_EFFECTS.md index 1a547836a1..318152412a 100644 --- a/apps/cli/src/commands/inspect/db/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/inspect/db/SIDE_EFFECTS.md @@ -135,9 +135,8 @@ Emit one extra stderr line before the table: is read as a project ref (20 lowercase letters), a database URL (`postgres://` or `postgresql://`), `true|yes|on|1` (the linked project), or otherwise the name of a branch of the linked project, which resolves to that branch's own project. `false|no|off|0`, `--no-remote` and - an empty value fail with `Leave out --remote to target the local database.` A bare `--remote` - followed by a word that is a branch of the linked project fails with a - `Did you mean --remote=<word>?` hint, since the word may have been meant as the flag's value. The + an empty value fail with `Leave out --remote to target the local database.` A word after a bare + `--remote` is read as its value, `--remote=<word>`, because the command takes no operands. The resolved target is announced on stderr (`Target: …`) in every output format and, in JSON results, as a `target` object. - **Deprecated selectors.** `--linked`, `--project-ref`, `--db-url` and `--local` are hidden aliases diff --git a/apps/cli/src/commands/inspect/report/SIDE_EFFECTS.md b/apps/cli/src/commands/inspect/report/SIDE_EFFECTS.md index 264e5b4a32..211bbcc31b 100644 --- a/apps/cli/src/commands/inspect/report/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/inspect/report/SIDE_EFFECTS.md @@ -138,9 +138,8 @@ instead a structured result is emitted. The stderr `Target:` banner is still pri is read as a project ref (20 lowercase letters), a database URL (`postgres://` or `postgresql://`), `true|yes|on|1` (the linked project), or otherwise the name of a branch of the linked project, which resolves to that branch's own project. `false|no|off|0`, `--no-remote` and - an empty value fail with `Leave out --remote to target the local database.` A bare `--remote` - followed by a word that is a branch of the linked project fails with a - `Did you mean --remote=<word>?` hint, since the word may have been meant as the flag's value. The + an empty value fail with `Leave out --remote to target the local database.` A word after a bare + `--remote` is read as its value, `--remote=<word>`, because the command takes no operands. The resolved target is announced on stderr (`Target: …`) in every output format and, in JSON results, as a `target` object. - **Deprecated selectors.** `--linked`, `--project-ref`, `--db-url` and `--local` are hidden aliases diff --git a/apps/cli/src/commands/migration/down/SIDE_EFFECTS.md b/apps/cli/src/commands/migration/down/SIDE_EFFECTS.md index fc5a18031a..391049662f 100644 --- a/apps/cli/src/commands/migration/down/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/migration/down/SIDE_EFFECTS.md @@ -86,9 +86,8 @@ Same structured result delivered as an NDJSON `result` event. is read as a project ref (20 lowercase letters), a database URL (`postgres://` or `postgresql://`), `true|yes|on|1` (the linked project), or otherwise the name of a branch of the linked project, which resolves to that branch's own project. `false|no|off|0`, `--no-remote` and - an empty value fail with `Leave out --remote to target the local database.` A bare `--remote` - followed by a word that is a branch of the linked project fails with a - `Did you mean --remote=<word>?` hint, since the word may have been meant as the flag's value. The + an empty value fail with `Leave out --remote to target the local database.` A word after a bare + `--remote` is read as its value, `--remote=<word>`, because the command takes no operands. The resolved target is announced on stderr (`Target: …`) in every output format and, in JSON results, as a `target` object. - **Deprecated selectors.** `--linked`, `--project-ref`, `--db-url` and `--local` are hidden aliases diff --git a/apps/cli/src/commands/migration/fetch/SIDE_EFFECTS.md b/apps/cli/src/commands/migration/fetch/SIDE_EFFECTS.md index 608d3ee592..031120bcf4 100644 --- a/apps/cli/src/commands/migration/fetch/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/migration/fetch/SIDE_EFFECTS.md @@ -87,9 +87,8 @@ Same structured `files` result delivered as an NDJSON `result` event. is read as a project ref (20 lowercase letters), a database URL (`postgres://` or `postgresql://`), `true|yes|on|1` (the linked project), or otherwise the name of a branch of the linked project, which resolves to that branch's own project. `false|no|off|0`, `--no-remote` and - an empty value fail with `Leave out --remote to target the local database.` A bare `--remote` - followed by a word that is a branch of the linked project fails with a - `Did you mean --remote=<word>?` hint, since the word may have been meant as the flag's value. The + an empty value fail with `Leave out --remote to target the local database.` A word after a bare + `--remote` is read as its value, `--remote=<word>`, because the command takes no operands. The resolved target is announced on stderr (`Target: …`) in every output format and, in JSON results, as a `target` object. - **Deprecated selectors.** `--linked`, `--project-ref`, `--db-url` and `--local` are hidden aliases diff --git a/apps/cli/src/commands/migration/list/SIDE_EFFECTS.md b/apps/cli/src/commands/migration/list/SIDE_EFFECTS.md index 82e984eb53..e6613505b5 100644 --- a/apps/cli/src/commands/migration/list/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/migration/list/SIDE_EFFECTS.md @@ -74,9 +74,8 @@ Same structured `migrations` result delivered as an NDJSON `result` event. is read as a project ref (20 lowercase letters), a database URL (`postgres://` or `postgresql://`), `true|yes|on|1` (the linked project), or otherwise the name of a branch of the linked project, which resolves to that branch's own project. `false|no|off|0`, `--no-remote` and - an empty value fail with `Leave out --remote to target the local database.` A bare `--remote` - followed by a word that is a branch of the linked project fails with a - `Did you mean --remote=<word>?` hint, since the word may have been meant as the flag's value. The + an empty value fail with `Leave out --remote to target the local database.` A word after a bare + `--remote` is read as its value, `--remote=<word>`, because the command takes no operands. The resolved target is announced on stderr (`Target: …`) in every output format and, in JSON results, as a `target` object. - **Deprecated selectors.** `--linked`, `--project-ref`, `--db-url` and `--local` are hidden aliases diff --git a/apps/cli/src/commands/migration/repair/SIDE_EFFECTS.md b/apps/cli/src/commands/migration/repair/SIDE_EFFECTS.md index eb66b3ed1f..1d07339ec4 100644 --- a/apps/cli/src/commands/migration/repair/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/migration/repair/SIDE_EFFECTS.md @@ -98,8 +98,10 @@ Same structured result delivered as an NDJSON `result` event. `postgresql://`), `true|yes|on|1` (the linked project), or otherwise the name of a branch of the linked project, which resolves to that branch's own project. `false|no|off|0`, `--no-remote` and an empty value fail with `Leave out --remote to target the local database.` A bare `--remote` - followed by a word that is a branch of the linked project fails with a - `Did you mean --remote=<word>?` hint, since the word may have been meant as the flag's value. The + followed by a word that exactly matches a branch of the linked project, is within a small edit + distance of one, or can't be checked because the branch listing failed stops the run with an error + that offers both readings: `--remote=<word>` for the branch, `<word> --remote` for the operand. + Any other word runs as the operand. The resolved target is announced on stderr (`Target: …`) in every output format and, in JSON results, as a `target` object. - **Deprecated selectors.** `--linked`, `--project-ref`, `--db-url` and `--local` are hidden aliases diff --git a/apps/cli/src/commands/migration/squash/SIDE_EFFECTS.md b/apps/cli/src/commands/migration/squash/SIDE_EFFECTS.md index 5159f2981f..a2695c12ba 100644 --- a/apps/cli/src/commands/migration/squash/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/migration/squash/SIDE_EFFECTS.md @@ -156,9 +156,8 @@ code or the rest of the payload. is read as a project ref (20 lowercase letters), a database URL (`postgres://` or `postgresql://`), `true|yes|on|1` (the linked project), or otherwise the name of a branch of the linked project, which resolves to that branch's own project. `false|no|off|0`, `--no-remote` and - an empty value fail with `Leave out --remote to target the local database.` A bare `--remote` - followed by a word that is a branch of the linked project fails with a - `Did you mean --remote=<word>?` hint, since the word may have been meant as the flag's value. The + an empty value fail with `Leave out --remote to target the local database.` A word after a bare + `--remote` is read as its value, `--remote=<word>`, because the command takes no operands. 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. diff --git a/apps/cli/src/commands/migration/up/SIDE_EFFECTS.md b/apps/cli/src/commands/migration/up/SIDE_EFFECTS.md index 074c1f1769..e021be1587 100644 --- a/apps/cli/src/commands/migration/up/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/migration/up/SIDE_EFFECTS.md @@ -86,9 +86,8 @@ Same structured `applied` result delivered as an NDJSON `result` event. is read as a project ref (20 lowercase letters), a database URL (`postgres://` or `postgresql://`), `true|yes|on|1` (the linked project), or otherwise the name of a branch of the linked project, which resolves to that branch's own project. `false|no|off|0`, `--no-remote` and - an empty value fail with `Leave out --remote to target the local database.` A bare `--remote` - followed by a word that is a branch of the linked project fails with a - `Did you mean --remote=<word>?` hint, since the word may have been meant as the flag's value. The + an empty value fail with `Leave out --remote to target the local database.` A word after a bare + `--remote` is read as its value, `--remote=<word>`, because the command takes no operands. The resolved target is announced on stderr (`Target: …`) in every output format and, in JSON results, as a `target` object. - **Deprecated selectors.** `--linked`, `--project-ref`, `--db-url` and `--local` are hidden aliases 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 f7e48b53ad..cd53a06b9e 100644 --- a/apps/cli/src/commands/network-bans/get/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/network-bans/get/SIDE_EFFECTS.md @@ -96,9 +96,8 @@ One `result` event whose `data` is the full response object. project (by name or UUID), or `true|yes|on|1` for the linked project. A database URL is rejected (`supabase network-bans get` takes a project, not a database); `false|no|off|0`, `--no-remote` and an empty value fail with `Leave out --remote to use the linked project.` -- A bare `--remote` followed by a word that is a branch of the linked project fails before any - other work with a `Did you mean --remote=<word>?` hint, since the word may have been meant as - the flag's value. +- A word after a bare `--remote` is read as its value, `--remote=<word>`, because the command takes + no operands. - The deprecated `--project-ref` is an alias of `--remote=<ref>` (a non-ref value is read as a branch name) and prints a deprecation warning on stderr that names the replacement; it cannot be combined with `--remote=<value>`. 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 47523dc204..8da52baf38 100644 --- a/apps/cli/src/commands/network-bans/remove/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/network-bans/remove/SIDE_EFFECTS.md @@ -100,9 +100,8 @@ One `result` event on success when the `--output` flag is unset. project (by name or UUID), or `true|yes|on|1` for the linked project. A database URL is rejected (`supabase network-bans remove` takes a project, not a database); `false|no|off|0`, `--no-remote` and an empty value fail with `Leave out --remote to use the linked project.` -- A bare `--remote` followed by a word that is a branch of the linked project fails before any - other work with a `Did you mean --remote=<word>?` hint, since the word may have been meant as - the flag's value. +- A word after a bare `--remote` is read as its value, `--remote=<word>`, because the command takes + no operands. - The deprecated `--project-ref` is an alias of `--remote=<ref>` (a non-ref value is read as a branch name) and prints a deprecation warning on stderr that names the replacement; it cannot be combined with `--remote=<value>`. 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 47a12a14ae..429eca01be 100644 --- a/apps/cli/src/commands/network-restrictions/get/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/network-restrictions/get/SIDE_EFFECTS.md @@ -107,9 +107,8 @@ One `result` event whose `data` is the full response object. project (by name or UUID), or `true|yes|on|1` for the linked project. A database URL is rejected (`supabase network-restrictions get` takes a project, not a database); `false|no|off|0`, `--no-remote` and an empty value fail with `Leave out --remote to use the linked project.` -- A bare `--remote` followed by a word that is a branch of the linked project fails before any - other work with a `Did you mean --remote=<word>?` hint, since the word may have been meant as - the flag's value. +- A word after a bare `--remote` is read as its value, `--remote=<word>`, because the command takes + no operands. - The deprecated `--project-ref` is an alias of `--remote=<ref>` (a non-ref value is read as a branch name) and prints a deprecation warning on stderr that names the replacement; it cannot be combined with `--remote=<value>`. 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 28ab2cc542..4ec008b797 100644 --- a/apps/cli/src/commands/network-restrictions/update/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/network-restrictions/update/SIDE_EFFECTS.md @@ -134,9 +134,8 @@ One `result` event whose `data` is the full response object. project (by name or UUID), or `true|yes|on|1` for the linked project. A database URL is rejected (`supabase network-restrictions update` takes a project, not a database); `false|no|off|0`, `--no-remote` and an empty value fail with `Leave out --remote to use the linked project.` -- A bare `--remote` followed by a word that is a branch of the linked project fails before any - other work with a `Did you mean --remote=<word>?` hint, since the word may have been meant as - the flag's value. +- A word after a bare `--remote` is read as its value, `--remote=<word>`, because the command takes + no operands. - The deprecated `--project-ref` is an alias of `--remote=<ref>` (a non-ref value is read as a branch name) and prints a deprecation warning on stderr that names the replacement; it cannot be combined with `--remote=<value>`. diff --git a/apps/cli/src/commands/notebooks/pull/SIDE_EFFECTS.md b/apps/cli/src/commands/notebooks/pull/SIDE_EFFECTS.md index 20da2aa502..af308c470f 100644 --- a/apps/cli/src/commands/notebooks/pull/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/notebooks/pull/SIDE_EFFECTS.md @@ -138,9 +138,10 @@ After the project ref resolves, one `Target: …` banner naming the resolved pro project (by name or UUID), or `true|yes|on|1` for the linked project. A database URL is rejected (`supabase notebooks pull` takes a project, not a database); `false|no|off|0`, `--no-remote` and an empty value fail with `Leave out --remote to use the linked project.` -- A bare `--remote` followed by a word that is a branch of the linked project fails before any - other work with a `Did you mean --remote=<word>?` hint, since the word may have been meant as - the flag's value. +- A bare `--remote` followed by a word that exactly matches a branch of the linked project, is + within a small edit distance of one, or can't be checked because the branch listing failed stops + the run before any other work with an error that offers both readings: `--remote=<word>` for the + branch, `<word> --remote` for the operand. Any other word runs as the operand. - The deprecated `--project-ref` is an alias of `--remote=<ref>` (a non-ref value is read as a branch name) and prints a deprecation warning on stderr that names the replacement; it cannot be combined with `--remote=<value>`. diff --git a/apps/cli/src/commands/notebooks/push/SIDE_EFFECTS.md b/apps/cli/src/commands/notebooks/push/SIDE_EFFECTS.md index 23d995ff09..afe3f1294e 100644 --- a/apps/cli/src/commands/notebooks/push/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/notebooks/push/SIDE_EFFECTS.md @@ -149,9 +149,10 @@ runs. `-o env` is rejected by the handler before resolving the project. project (by name or UUID), or `true|yes|on|1` for the linked project. A database URL is rejected (`supabase notebooks push` takes a project, not a database); `false|no|off|0`, `--no-remote` and an empty value fail with `Leave out --remote to use the linked project.` -- A bare `--remote` followed by a word that is a branch of the linked project fails before any - other work with a `Did you mean --remote=<word>?` hint, since the word may have been meant as - the flag's value. +- A bare `--remote` followed by a word that exactly matches a branch of the linked project, is + within a small edit distance of one, or can't be checked because the branch listing failed stops + the run before any other work with an error that offers both readings: `--remote=<word>` for the + branch, `<word> --remote` for the operand. Any other word runs as the operand. - The deprecated `--project-ref` is an alias of `--remote=<ref>` (a non-ref value is read as a branch name) and prints a deprecation warning on stderr that names the replacement; it cannot be combined with `--remote=<value>`. 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 53380605a7..2181780abd 100644 --- a/apps/cli/src/commands/postgres-config/delete/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/postgres-config/delete/SIDE_EFFECTS.md @@ -123,9 +123,8 @@ One `result` event on success. project (by name or UUID), or `true|yes|on|1` for the linked project. A database URL is rejected (`supabase postgres-config delete` takes a project, not a database); `false|no|off|0`, `--no-remote` and an empty value fail with `Leave out --remote to use the linked project.` -- A bare `--remote` followed by a word that is a branch of the linked project fails before any - other work with a `Did you mean --remote=<word>?` hint, since the word may have been meant as - the flag's value. +- A word after a bare `--remote` is read as its value, `--remote=<word>`, because the command takes + no operands. - The deprecated `--project-ref` is an alias of `--remote=<ref>` (a non-ref value is read as a branch name) and prints a deprecation warning on stderr that names the replacement; it cannot be combined with `--remote=<value>`. 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 0d99ee9841..9b6b9516b1 100644 --- a/apps/cli/src/commands/postgres-config/get/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/postgres-config/get/SIDE_EFFECTS.md @@ -107,9 +107,8 @@ One `result` event on success. project (by name or UUID), or `true|yes|on|1` for the linked project. A database URL is rejected (`supabase postgres-config get` takes a project, not a database); `false|no|off|0`, `--no-remote` and an empty value fail with `Leave out --remote to use the linked project.` -- A bare `--remote` followed by a word that is a branch of the linked project fails before any - other work with a `Did you mean --remote=<word>?` hint, since the word may have been meant as - the flag's value. +- A word after a bare `--remote` is read as its value, `--remote=<word>`, because the command takes + no operands. - The deprecated `--project-ref` is an alias of `--remote=<ref>` (a non-ref value is read as a branch name) and prints a deprecation warning on stderr that names the replacement; it cannot be combined with `--remote=<value>`. 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 d12cf195e3..eab38ca3bb 100644 --- a/apps/cli/src/commands/postgres-config/update/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/postgres-config/update/SIDE_EFFECTS.md @@ -126,9 +126,8 @@ One `result` event on success. project (by name or UUID), or `true|yes|on|1` for the linked project. A database URL is rejected (`supabase postgres-config update` takes a project, not a database); `false|no|off|0`, `--no-remote` and an empty value fail with `Leave out --remote to use the linked project.` -- A bare `--remote` followed by a word that is a branch of the linked project fails before any - other work with a `Did you mean --remote=<word>?` hint, since the word may have been meant as - the flag's value. +- A word after a bare `--remote` is read as its value, `--remote=<word>`, because the command takes + no operands. - The deprecated `--project-ref` is an alias of `--remote=<ref>` (a non-ref value is read as a branch name) and prints a deprecation warning on stderr that names the replacement; it cannot be combined with `--remote=<value>`. diff --git a/apps/cli/src/commands/projects/api-keys/SIDE_EFFECTS.md b/apps/cli/src/commands/projects/api-keys/SIDE_EFFECTS.md index 3661c91496..ab2dce27c7 100644 --- a/apps/cli/src/commands/projects/api-keys/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/projects/api-keys/SIDE_EFFECTS.md @@ -113,9 +113,8 @@ On failure, an `error` event is emitted instead: project (by name or UUID), or `true|yes|on|1` for the linked project. A database URL is rejected (`supabase projects api-keys` takes a project, not a database); `false|no|off|0`, `--no-remote` and an empty value fail with `Leave out --remote to use the linked project.` -- A bare `--remote` followed by a word that is a branch of the linked project fails before any - other work with a `Did you mean --remote=<word>?` hint, since the word may have been meant as - the flag's value. +- A word after a bare `--remote` is read as its value, `--remote=<word>`, because the command takes + no operands. - The deprecated `--project-ref` is an alias of `--remote=<ref>` (a non-ref value is read as a branch name) and prints a deprecation warning on stderr that names the replacement; it cannot be combined with `--remote=<value>`. diff --git a/apps/cli/src/commands/secrets/list/SIDE_EFFECTS.md b/apps/cli/src/commands/secrets/list/SIDE_EFFECTS.md index da44011c73..a819db1f89 100644 --- a/apps/cli/src/commands/secrets/list/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/secrets/list/SIDE_EFFECTS.md @@ -99,9 +99,8 @@ One `result` NDJSON event on success containing `{secrets: [...]}` and `target`. project (by name or UUID), or `true|yes|on|1` for the linked project. A database URL is rejected (`supabase secrets list` takes a project, not a database); `false|no|off|0`, `--no-remote` and an empty value fail with `Leave out --remote to use the linked project.` -- A bare `--remote` followed by a word that is a branch of the linked project fails before any - other work with a `Did you mean --remote=<word>?` hint, since the word may have been meant as - the flag's value. +- A word after a bare `--remote` is read as its value, `--remote=<word>`, because the command takes + no operands. - The deprecated `--project-ref` is an alias of `--remote=<ref>` (a non-ref value is read as a branch name) and prints a deprecation warning on stderr that names the replacement; it cannot be combined with `--remote=<value>`. diff --git a/apps/cli/src/commands/secrets/set/SIDE_EFFECTS.md b/apps/cli/src/commands/secrets/set/SIDE_EFFECTS.md index 800d608c6b..f1a4dcfe03 100644 --- a/apps/cli/src/commands/secrets/set/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/secrets/set/SIDE_EFFECTS.md @@ -101,9 +101,10 @@ One `result` NDJSON event on success containing `{project_ref, count}` and `targ project (by name or UUID), or `true|yes|on|1` for the linked project. A database URL is rejected (`supabase secrets set` takes a project, not a database); `false|no|off|0`, `--no-remote` and an empty value fail with `Leave out --remote to use the linked project.` -- A bare `--remote` followed by a word that is a branch of the linked project fails before any - other work with a `Did you mean --remote=<word>?` hint, since the word may have been meant as - the flag's value. +- A bare `--remote` followed by a word that exactly matches a branch of the linked project, is + within a small edit distance of one, or can't be checked because the branch listing failed stops + the run before any other work with an error that offers both readings: `--remote=<word>` for the + branch, `<word> --remote` for the operand. Any other word runs as the operand. - The deprecated `--project-ref` is an alias of `--remote=<ref>` (a non-ref value is read as a branch name) and prints a deprecation warning on stderr that names the replacement; it cannot be combined with `--remote=<value>`. diff --git a/apps/cli/src/commands/secrets/unset/SIDE_EFFECTS.md b/apps/cli/src/commands/secrets/unset/SIDE_EFFECTS.md index be9f41e963..de44a4f384 100644 --- a/apps/cli/src/commands/secrets/unset/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/secrets/unset/SIDE_EFFECTS.md @@ -110,9 +110,10 @@ One `result` NDJSON event on success containing `{project_ref, count, names, dry project (by name or UUID), or `true|yes|on|1` for the linked project. A database URL is rejected (`supabase secrets unset` takes a project, not a database); `false|no|off|0`, `--no-remote` and an empty value fail with `Leave out --remote to use the linked project.` -- A bare `--remote` followed by a word that is a branch of the linked project fails before any - other work with a `Did you mean --remote=<word>?` hint, since the word may have been meant as - the flag's value. +- A bare `--remote` followed by a word that exactly matches a branch of the linked project, is + within a small edit distance of one, or can't be checked because the branch listing failed stops + the run before any other work with an error that offers both readings: `--remote=<word>` for the + branch, `<word> --remote` for the operand. Any other word runs as the operand. - The deprecated `--project-ref` is an alias of `--remote=<ref>` (a non-ref value is read as a branch name) and prints a deprecation warning on stderr that names the replacement; it cannot be combined with `--remote=<value>`. diff --git a/apps/cli/src/commands/seed/buckets/SIDE_EFFECTS.md b/apps/cli/src/commands/seed/buckets/SIDE_EFFECTS.md index f4a1d88827..1131f8799e 100644 --- a/apps/cli/src/commands/seed/buckets/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/seed/buckets/SIDE_EFFECTS.md @@ -204,9 +204,9 @@ stdout and a terminal `result`/`error` event is emitted. otherwise the name of a branch of the linked project, which resolves to that branch's own project. A database URL (`--remote=<db-url>`) fails with a `needs a project, not a database URL` error and sends no request. `false|no|off|0`, `--no-remote` and an empty value fail with - `Leave out --remote to target the local Storage API.` A bare `--remote` followed by a word that is - a branch of the linked project fails with a `Did you mean --remote=<word>?` hint, since the word - may have been meant as the flag's value. The resolved target is announced on stderr (`Target: …`) + `Leave out --remote to target the local Storage API.` A word after a bare `--remote` is read as + its value, `--remote=<word>`, because the command takes no operands. The resolved target is + announced on stderr (`Target: …`) in every output format and, in JSON results, as a `target` object. `--remote`, `--linked` and `--local` are scoped globals on the `seed` group, so they are also accepted before the subcommand token. diff --git a/apps/cli/src/commands/snippets/download/SIDE_EFFECTS.md b/apps/cli/src/commands/snippets/download/SIDE_EFFECTS.md index bbb083d927..9a1f568a28 100644 --- a/apps/cli/src/commands/snippets/download/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/snippets/download/SIDE_EFFECTS.md @@ -95,9 +95,10 @@ NDJSON `success` event with the same full payload as `--output-format json`. project (by name or UUID), or `true|yes|on|1` for the linked project. A database URL is rejected (`supabase snippets download` takes a project, not a database); `false|no|off|0`, `--no-remote` and an empty value fail with `Leave out --remote to use the linked project.` -- A bare `--remote` followed by a word that is a branch of the linked project fails before any - other work with a `Did you mean --remote=<word>?` hint, since the word may have been meant as - the flag's value. +- A bare `--remote` followed by a word that exactly matches a branch of the linked project, is + within a small edit distance of one, or can't be checked because the branch listing failed stops + the run before any other work with an error that offers both readings: `--remote=<word>` for the + branch, `<word> --remote` for the operand. Any other word runs as the operand. - The deprecated `--project-ref` is an alias of `--remote=<ref>` (a non-ref value is read as a branch name) and prints a deprecation warning on stderr that names the replacement; it cannot be combined with `--remote=<value>`. diff --git a/apps/cli/src/commands/snippets/list/SIDE_EFFECTS.md b/apps/cli/src/commands/snippets/list/SIDE_EFFECTS.md index cca001b45b..4504cd421b 100644 --- a/apps/cli/src/commands/snippets/list/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/snippets/list/SIDE_EFFECTS.md @@ -107,9 +107,8 @@ NDJSON `success` event with the full response as `data`. project (by name or UUID), or `true|yes|on|1` for the linked project. A database URL is rejected (`supabase snippets list` takes a project, not a database); `false|no|off|0`, `--no-remote` and an empty value fail with `Leave out --remote to use the linked project.` -- A bare `--remote` followed by a word that is a branch of the linked project fails before any - other work with a `Did you mean --remote=<word>?` hint, since the word may have been meant as - the flag's value. +- A word after a bare `--remote` is read as its value, `--remote=<word>`, because the command takes + no operands. - The deprecated `--project-ref` is an alias of `--remote=<ref>` (a non-ref value is read as a branch name) and prints a deprecation warning on stderr that names the replacement; it cannot be combined with `--remote=<value>`. 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 3b70947839..aa8b1eeccb 100644 --- a/apps/cli/src/commands/ssl-enforcement/get/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/ssl-enforcement/get/SIDE_EFFECTS.md @@ -114,9 +114,8 @@ One `result` event: project (by name or UUID), or `true|yes|on|1` for the linked project. A database URL is rejected (`supabase ssl-enforcement get` takes a project, not a database); `false|no|off|0`, `--no-remote` and an empty value fail with `Leave out --remote to use the linked project.` -- A bare `--remote` followed by a word that is a branch of the linked project fails before any - other work with a `Did you mean --remote=<word>?` hint, since the word may have been meant as - the flag's value. +- A word after a bare `--remote` is read as its value, `--remote=<word>`, because the command takes + no operands. - The deprecated `--project-ref` is an alias of `--remote=<ref>` (a non-ref value is read as a branch name) and prints a deprecation warning on stderr that names the replacement; it cannot be combined with `--remote=<value>`. 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 dbaa92e599..f3365b96bd 100644 --- a/apps/cli/src/commands/ssl-enforcement/update/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/ssl-enforcement/update/SIDE_EFFECTS.md @@ -117,9 +117,8 @@ One `result` event: project (by name or UUID), or `true|yes|on|1` for the linked project. A database URL is rejected (`supabase ssl-enforcement update` takes a project, not a database); `false|no|off|0`, `--no-remote` and an empty value fail with `Leave out --remote to use the linked project.` -- A bare `--remote` followed by a word that is a branch of the linked project fails before any - other work with a `Did you mean --remote=<word>?` hint, since the word may have been meant as - the flag's value. +- A word after a bare `--remote` is read as its value, `--remote=<word>`, because the command takes + no operands. - The deprecated `--project-ref` is an alias of `--remote=<ref>` (a non-ref value is read as a branch name) and prints a deprecation warning on stderr that names the replacement; it cannot be combined with `--remote=<value>`. diff --git a/apps/cli/src/commands/sso/add/SIDE_EFFECTS.md b/apps/cli/src/commands/sso/add/SIDE_EFFECTS.md index 6930cff200..e13ee1e078 100644 --- a/apps/cli/src/commands/sso/add/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/sso/add/SIDE_EFFECTS.md @@ -113,9 +113,8 @@ Single `success` event with the parsed response as data. project (by name or UUID), or `true|yes|on|1` for the linked project. A database URL is rejected (`supabase sso add` takes a project, not a database); `false|no|off|0`, `--no-remote` and an empty value fail with `Leave out --remote to use the linked project.` -- A bare `--remote` followed by a word that is a branch of the linked project fails before any - other work with a `Did you mean --remote=<word>?` hint, since the word may have been meant as - the flag's value. +- A word after a bare `--remote` is read as its value, `--remote=<word>`, because the command takes + no operands. - The deprecated `--project-ref` is an alias of `--remote=<ref>` (a non-ref value is read as a branch name) and prints a deprecation warning on stderr that names the replacement; it cannot be combined with `--remote=<value>`. diff --git a/apps/cli/src/commands/sso/info/SIDE_EFFECTS.md b/apps/cli/src/commands/sso/info/SIDE_EFFECTS.md index 78c5d6f57e..a620107ec9 100644 --- a/apps/cli/src/commands/sso/info/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/sso/info/SIDE_EFFECTS.md @@ -86,9 +86,8 @@ Single `success` event with the payload as data. project (by name or UUID), or `true|yes|on|1` for the linked project. A database URL is rejected (`supabase sso info` takes a project, not a database); `false|no|off|0`, `--no-remote` and an empty value fail with `Leave out --remote to use the linked project.` -- A bare `--remote` followed by a word that is a branch of the linked project fails before any - other work with a `Did you mean --remote=<word>?` hint, since the word may have been meant as - the flag's value. +- A word after a bare `--remote` is read as its value, `--remote=<word>`, because the command takes + no operands. - The deprecated `--project-ref` is an alias of `--remote=<ref>` (a non-ref value is read as a branch name) and prints a deprecation warning on stderr that names the replacement; it cannot be combined with `--remote=<value>`. diff --git a/apps/cli/src/commands/sso/list/SIDE_EFFECTS.md b/apps/cli/src/commands/sso/list/SIDE_EFFECTS.md index 3be5c86aa8..94ecacb266 100644 --- a/apps/cli/src/commands/sso/list/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/sso/list/SIDE_EFFECTS.md @@ -81,9 +81,8 @@ Single `success` event with `{providers: items}` as data. project (by name or UUID), or `true|yes|on|1` for the linked project. A database URL is rejected (`supabase sso list` takes a project, not a database); `false|no|off|0`, `--no-remote` and an empty value fail with `Leave out --remote to use the linked project.` -- A bare `--remote` followed by a word that is a branch of the linked project fails before any - other work with a `Did you mean --remote=<word>?` hint, since the word may have been meant as - the flag's value. +- A word after a bare `--remote` is read as its value, `--remote=<word>`, because the command takes + no operands. - The deprecated `--project-ref` is an alias of `--remote=<ref>` (a non-ref value is read as a branch name) and prints a deprecation warning on stderr that names the replacement; it cannot be combined with `--remote=<value>`. diff --git a/apps/cli/src/commands/sso/remove/SIDE_EFFECTS.md b/apps/cli/src/commands/sso/remove/SIDE_EFFECTS.md index 246d2b2a7a..f676e8c178 100644 --- a/apps/cli/src/commands/sso/remove/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/sso/remove/SIDE_EFFECTS.md @@ -86,9 +86,10 @@ Single `success` event with the removed provider's details as data. project (by name or UUID), or `true|yes|on|1` for the linked project. A database URL is rejected (`supabase sso remove` takes a project, not a database); `false|no|off|0`, `--no-remote` and an empty value fail with `Leave out --remote to use the linked project.` -- A bare `--remote` followed by a word that is a branch of the linked project fails before any - other work with a `Did you mean --remote=<word>?` hint, since the word may have been meant as - the flag's value. +- A bare `--remote` followed by a word that exactly matches a branch of the linked project, is + within a small edit distance of one, or can't be checked because the branch listing failed stops + the run before any other work with an error that offers both readings: `--remote=<word>` for the + branch, `<word> --remote` for the operand. Any other word runs as the operand. - The deprecated `--project-ref` is an alias of `--remote=<ref>` (a non-ref value is read as a branch name) and prints a deprecation warning on stderr that names the replacement; it cannot be combined with `--remote=<value>`. diff --git a/apps/cli/src/commands/sso/show/SIDE_EFFECTS.md b/apps/cli/src/commands/sso/show/SIDE_EFFECTS.md index 83b3b5f425..e903ad7bac 100644 --- a/apps/cli/src/commands/sso/show/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/sso/show/SIDE_EFFECTS.md @@ -88,9 +88,10 @@ Single `success` event with the response as data. project (by name or UUID), or `true|yes|on|1` for the linked project. A database URL is rejected (`supabase sso show` takes a project, not a database); `false|no|off|0`, `--no-remote` and an empty value fail with `Leave out --remote to use the linked project.` -- A bare `--remote` followed by a word that is a branch of the linked project fails before any - other work with a `Did you mean --remote=<word>?` hint, since the word may have been meant as - the flag's value. +- A bare `--remote` followed by a word that exactly matches a branch of the linked project, is + within a small edit distance of one, or can't be checked because the branch listing failed stops + the run before any other work with an error that offers both readings: `--remote=<word>` for the + branch, `<word> --remote` for the operand. Any other word runs as the operand. - The deprecated `--project-ref` is an alias of `--remote=<ref>` (a non-ref value is read as a branch name) and prints a deprecation warning on stderr that names the replacement; it cannot be combined with `--remote=<value>`. diff --git a/apps/cli/src/commands/sso/update/SIDE_EFFECTS.md b/apps/cli/src/commands/sso/update/SIDE_EFFECTS.md index 09a3b06f17..98163e19c5 100644 --- a/apps/cli/src/commands/sso/update/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/sso/update/SIDE_EFFECTS.md @@ -120,9 +120,10 @@ Single `success` event with the parsed response as data. project (by name or UUID), or `true|yes|on|1` for the linked project. A database URL is rejected (`supabase sso update` takes a project, not a database); `false|no|off|0`, `--no-remote` and an empty value fail with `Leave out --remote to use the linked project.` -- A bare `--remote` followed by a word that is a branch of the linked project fails before any - other work with a `Did you mean --remote=<word>?` hint, since the word may have been meant as - the flag's value. +- A bare `--remote` followed by a word that exactly matches a branch of the linked project, is + within a small edit distance of one, or can't be checked because the branch listing failed stops + the run before any other work with an error that offers both readings: `--remote=<word>` for the + branch, `<word> --remote` for the operand. Any other word runs as the operand. - The deprecated `--project-ref` is an alias of `--remote=<ref>` (a non-ref value is read as a branch name) and prints a deprecation warning on stderr that names the replacement; it cannot be combined with `--remote=<value>`. diff --git a/apps/cli/src/commands/storage/cp/SIDE_EFFECTS.md b/apps/cli/src/commands/storage/cp/SIDE_EFFECTS.md index 29a563ca59..fe0d7adb96 100644 --- a/apps/cli/src/commands/storage/cp/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/storage/cp/SIDE_EFFECTS.md @@ -113,9 +113,11 @@ backend — same roles as `storage ls`. otherwise the name of a branch of the linked project, which resolves to that branch's own project. A database URL (`--remote=<db-url>`) fails with a `needs a project, not a database URL` error and sends no request. `false|no|off|0`, `--no-remote` and an empty value fail with - `Leave out --remote to target the local Storage API.` A bare `--remote` followed by a word that is - a branch of the linked project fails with a `Did you mean --remote=<word>?` hint, since the word - may have been meant as the flag's value. The resolved target is announced on stderr (`Target: …`) + `Leave out --remote to target the local Storage API.` A bare `--remote` followed by a word that + exactly matches a branch of the linked project, is within a small edit distance of one, or can't + be checked because the branch listing failed stops the run with an error that offers both + readings: `--remote=<word>` for the branch, `<word> --remote` for the operand. Any other word runs + as the operand. The resolved target is announced on stderr (`Target: …`) in every output format and, in JSON results, as a `target` object. - **Deprecated selectors.** `--linked`, `--project-ref` and `--local` are hidden aliases that still select their target and print a warning on stderr naming the replacement, for example diff --git a/apps/cli/src/commands/storage/ls/SIDE_EFFECTS.md b/apps/cli/src/commands/storage/ls/SIDE_EFFECTS.md index b84d1e959d..be2d29f52a 100644 --- a/apps/cli/src/commands/storage/ls/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/storage/ls/SIDE_EFFECTS.md @@ -111,9 +111,11 @@ No custom storage telemetry events. otherwise the name of a branch of the linked project, which resolves to that branch's own project. A database URL (`--remote=<db-url>`) fails with a `needs a project, not a database URL` error and sends no request. `false|no|off|0`, `--no-remote` and an empty value fail with - `Leave out --remote to target the local Storage API.` A bare `--remote` followed by a word that is - a branch of the linked project fails with a `Did you mean --remote=<word>?` hint, since the word - may have been meant as the flag's value. The resolved target is announced on stderr (`Target: …`) + `Leave out --remote to target the local Storage API.` A bare `--remote` followed by a word that + exactly matches a branch of the linked project, is within a small edit distance of one, or can't + be checked because the branch listing failed stops the run with an error that offers both + readings: `--remote=<word>` for the branch, `<word> --remote` for the operand. Any other word runs + as the operand. The resolved target is announced on stderr (`Target: …`) in every output format and, in JSON results, as a `target` object. - **Deprecated selectors.** `--linked`, `--project-ref` and `--local` are hidden aliases that still select their target and print a warning on stderr naming the replacement, for example diff --git a/apps/cli/src/commands/storage/mv/SIDE_EFFECTS.md b/apps/cli/src/commands/storage/mv/SIDE_EFFECTS.md index 014428de14..63df62bf9b 100644 --- a/apps/cli/src/commands/storage/mv/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/storage/mv/SIDE_EFFECTS.md @@ -108,9 +108,11 @@ backend — same roles as `storage ls`. otherwise the name of a branch of the linked project, which resolves to that branch's own project. A database URL (`--remote=<db-url>`) fails with a `needs a project, not a database URL` error and sends no request. `false|no|off|0`, `--no-remote` and an empty value fail with - `Leave out --remote to target the local Storage API.` A bare `--remote` followed by a word that is - a branch of the linked project fails with a `Did you mean --remote=<word>?` hint, since the word - may have been meant as the flag's value. The resolved target is announced on stderr (`Target: …`) + `Leave out --remote to target the local Storage API.` A bare `--remote` followed by a word that + exactly matches a branch of the linked project, is within a small edit distance of one, or can't + be checked because the branch listing failed stops the run with an error that offers both + readings: `--remote=<word>` for the branch, `<word> --remote` for the operand. Any other word runs + as the operand. The resolved target is announced on stderr (`Target: …`) in every output format and, in JSON results, as a `target` object. - **Deprecated selectors.** `--linked`, `--project-ref` and `--local` are hidden aliases that still select their target and print a warning on stderr naming the replacement, for example diff --git a/apps/cli/src/commands/storage/rm/SIDE_EFFECTS.md b/apps/cli/src/commands/storage/rm/SIDE_EFFECTS.md index da9589df07..07eed8302f 100644 --- a/apps/cli/src/commands/storage/rm/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/storage/rm/SIDE_EFFECTS.md @@ -126,9 +126,11 @@ Requires `--yes`/`SUPABASE_YES` on the same terms as `json`. otherwise the name of a branch of the linked project, which resolves to that branch's own project. A database URL (`--remote=<db-url>`) fails with a `needs a project, not a database URL` error and sends no request. `false|no|off|0`, `--no-remote` and an empty value fail with - `Leave out --remote to target the local Storage API.` A bare `--remote` followed by a word that is - a branch of the linked project fails with a `Did you mean --remote=<word>?` hint, since the word - may have been meant as the flag's value. The resolved target is announced on stderr (`Target: …`) + `Leave out --remote to target the local Storage API.` A bare `--remote` followed by a word that + exactly matches a branch of the linked project, is within a small edit distance of one, or can't + be checked because the branch listing failed stops the run with an error that offers both + readings: `--remote=<word>` for the branch, `<word> --remote` for the operand. Any other word runs + as the operand. The resolved target is announced on stderr (`Target: …`) in every output format and, in JSON results, as a `target` object. - **Deprecated selectors.** `--linked`, `--project-ref` and `--local` are hidden aliases that still select their target and print a warning on stderr naming the replacement, for example diff --git a/apps/cli/src/commands/test/db/SIDE_EFFECTS.md b/apps/cli/src/commands/test/db/SIDE_EFFECTS.md index a6455f1854..b582ad51cb 100644 --- a/apps/cli/src/commands/test/db/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/test/db/SIDE_EFFECTS.md @@ -124,8 +124,10 @@ command (exit 1). - **Target selection** (shared by `db test` via `testDbConfig`). `--remote` is read as the linked project (bare), a project ref (20 lowercase letters), a Postgres URL, or else a branch of the linked project by name or UUID; `--remote=false` and `--remote=` fail with `Leave out --remote to target the local database.` A - bare `--remote` followed by a word that is a branch of the linked project fails with a - `Did you mean --remote=<word>?` hint, since the word may have been meant as the flag's value. + bare `--remote` followed by a word that exactly matches a branch of the linked project, is within + a small edit distance of one, or can't be checked because the branch listing failed stops the run + with an error that offers both readings: `--remote=<word>` for the branch, `<word> --remote` for + the operand. Any other word runs as the operand. - **Deprecated aliases.** `--linked`, `--project-ref=<ref>`, `--db-url=<url>` and `--local` still work and print a deprecation warning on stderr naming the `--remote` replacement (`--local` warns that local is the default). They are mutually exclusive with each other and with 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 65287581aa..ef64406820 100644 --- a/apps/cli/src/commands/vanity-subdomains/activate/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/vanity-subdomains/activate/SIDE_EFFECTS.md @@ -102,9 +102,8 @@ One `result` event with the full response object. project (by name or UUID), or `true|yes|on|1` for the linked project. A database URL is rejected (`supabase vanity-subdomains activate` takes a project, not a database); `false|no|off|0`, `--no-remote` and an empty value fail with `Leave out --remote to use the linked project.` -- A bare `--remote` followed by a word that is a branch of the linked project fails before any - other work with a `Did you mean --remote=<word>?` hint, since the word may have been meant as - the flag's value. +- A word after a bare `--remote` is read as its value, `--remote=<word>`, because the command takes + no operands. - The deprecated `--project-ref` is an alias of `--remote=<ref>` (a non-ref value is read as a branch name) and prints a deprecation warning on stderr that names the replacement; it cannot be combined with `--remote=<value>`. 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 3dabd24bc2..02e77965ef 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 @@ -99,9 +99,8 @@ One `result` event with the full response object. project (by name or UUID), or `true|yes|on|1` for the linked project. A database URL is rejected (`supabase vanity-subdomains check-availability` takes a project, not a database); `false|no|off|0`, `--no-remote` and an empty value fail with `Leave out --remote to use the linked project.` -- A bare `--remote` followed by a word that is a branch of the linked project fails before any - other work with a `Did you mean --remote=<word>?` hint, since the word may have been meant as - the flag's value. +- A word after a bare `--remote` is read as its value, `--remote=<word>`, because the command takes + no operands. - The deprecated `--project-ref` is an alias of `--remote=<ref>` (a non-ref value is read as a branch name) and prints a deprecation warning on stderr that names the replacement; it cannot be combined with `--remote=<value>`. 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 5d89e4c47b..49ab4dc661 100644 --- a/apps/cli/src/commands/vanity-subdomains/delete/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/vanity-subdomains/delete/SIDE_EFFECTS.md @@ -91,9 +91,8 @@ One `result` event when the `--output` flag is unset. project (by name or UUID), or `true|yes|on|1` for the linked project. A database URL is rejected (`supabase vanity-subdomains delete` takes a project, not a database); `false|no|off|0`, `--no-remote` and an empty value fail with `Leave out --remote to use the linked project.` -- A bare `--remote` followed by a word that is a branch of the linked project fails before any - other work with a `Did you mean --remote=<word>?` hint, since the word may have been meant as - the flag's value. +- A word after a bare `--remote` is read as its value, `--remote=<word>`, because the command takes + no operands. - The deprecated `--project-ref` is an alias of `--remote=<ref>` (a non-ref value is read as a branch name) and prints a deprecation warning on stderr that names the replacement; it cannot be combined with `--remote=<value>`. 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 636c280417..ab4b2c24ae 100644 --- a/apps/cli/src/commands/vanity-subdomains/get/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/vanity-subdomains/get/SIDE_EFFECTS.md @@ -91,9 +91,8 @@ One `result` event with the full response object. project (by name or UUID), or `true|yes|on|1` for the linked project. A database URL is rejected (`supabase vanity-subdomains get` takes a project, not a database); `false|no|off|0`, `--no-remote` and an empty value fail with `Leave out --remote to use the linked project.` -- A bare `--remote` followed by a word that is a branch of the linked project fails before any - other work with a `Did you mean --remote=<word>?` hint, since the word may have been meant as - the flag's value. +- A word after a bare `--remote` is read as its value, `--remote=<word>`, because the command takes + no operands. - The deprecated `--project-ref` is an alias of `--remote=<ref>` (a non-ref value is read as a branch name) and prints a deprecation warning on stderr that names the replacement; it cannot be combined with `--remote=<value>`. From 13bb978710c71a27c6eda0496a2abe0fb75f7807 Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Mon, 19 Oct 2026 13:54:26 +0100 Subject: [PATCH 144/291] refactor(cli): share one --remote vocabulary and recognise the flag by its primitive --- .../command-internal/target/remote-value.ts | 18 +--------- .../target/remote-vocabulary.ts | 26 ++++++++++++++ .../target/target-deprecation.ts | 21 ++--------- .../command-internal/target/target-flags.ts | 9 ++++- .../target/target-flags.unit.test.ts | 26 +++++++++++++- .../target/target-selector.ts | 3 +- apps/cli/src/docs/docs-spec.ts | 3 +- apps/cli/src/shared/cli/remote-argv.ts | 35 ++++++++++--------- .../src/shared/cli/remote-argv.unit.test.ts | 6 ++++ .../shared/cli/subcommand-flag-suggestions.ts | 4 +-- apps/cli/src/shared/output/target-help.ts | 10 +++--- apps/cli/src/telemetry/command-telemetry.ts | 7 ++-- apps/cli/tests/helpers/permission-registry.ts | 5 ++- 13 files changed, 101 insertions(+), 72 deletions(-) create mode 100644 apps/cli/src/command-internal/target/remote-vocabulary.ts diff --git a/apps/cli/src/command-internal/target/remote-value.ts b/apps/cli/src/command-internal/target/remote-value.ts index 9b44c669e8..6b337e843a 100644 --- a/apps/cli/src/command-internal/target/remote-value.ts +++ b/apps/cli/src/command-internal/target/remote-value.ts @@ -1,8 +1,7 @@ import { Predicate } from "effect"; import { BRANCH_PROJECT_REF_PATTERN } from "../ref-patterns.ts"; - -export const REMOTE_FLAG_NAME = "remote"; +import { DB_URL_PATTERN, LINKED_LITERAL, OFF_LITERAL } from "./remote-vocabulary.ts"; /** What `--remote` (or a `db diff --from/--to` value) names. */ export type RemoteValue = @@ -23,10 +22,6 @@ export type NamedRemoteValue = Exclude<RemoteValue, { readonly _tag: "Linked" }> /** Starts every failure for an off or empty `--remote` value, which the CLI prints without its usual `Invalid value` framing. */ export const LEAVE_OUT_REMOTE_PREFIX = "Leave out --remote "; -const LINKED_LITERAL = /^(true|yes|on|1)$/i; -const OFF_LITERAL = /^(false|no|off|0)$/i; -const DB_URL_PATTERN = /^postgres(ql)?:\/\//; - /** 20 lowercase letters is a project ref, a postgres URL is a database, anything else names a branch. */ export const classifyNamedRemote = (raw: string): NamedRemoteValue => { if (BRANCH_PROJECT_REF_PATTERN.test(raw)) return { _tag: "Ref", ref: raw }; @@ -34,17 +29,6 @@ export const classifyNamedRemote = (raw: string): NamedRemoteValue => { return { _tag: "Branch", name: raw }; }; -const NOT_A_BRANCH_NAME: ReadonlyArray<RegExp> = [ - /\s/, - /^[a-z][a-z0-9+.-]*:\/\//i, - /^\d+$/, - /^[./~]/, -]; - -/** Whether a word reads as a branch name rather than a path, URL, number or sentence. */ -export const couldNameBranch = (word: string): boolean => - word !== "" && !NOT_A_BRANCH_NAME.some((pattern) => pattern.test(word)); - /** Classifies a `--remote` value; `undefined` for an empty value or an off switch (`false|no|off|0`). */ export const classifyRemoteValue = (raw: string): RemoteValue | undefined => { if (LINKED_LITERAL.test(raw)) return { _tag: "Linked" }; diff --git a/apps/cli/src/command-internal/target/remote-vocabulary.ts b/apps/cli/src/command-internal/target/remote-vocabulary.ts new file mode 100644 index 0000000000..c6a6866de3 --- /dev/null +++ b/apps/cli/src/command-internal/target/remote-vocabulary.ts @@ -0,0 +1,26 @@ +/** What `--remote` values look like, shared by the argv rewrite, the value parser and the permission evaluators. */ + +export const REMOTE_FLAG_NAME = "remote"; + +export const DB_URL_PATTERN = /^postgres(ql)?:\/\//; + +/** The spellings of a bare `--remote`, in any case. */ +export const LINKED_LITERAL = /^(true|yes|on|1)$/i; + +/** The spellings of an off switch, in any case. */ +export const OFF_LITERAL = /^(false|no|off|0)$/i; + +/** Whether a word is a boolean literal in either direction, in any case. */ +export const isBooleanLiteral = (word: string): boolean => + LINKED_LITERAL.test(word) || OFF_LITERAL.test(word); + +const NOT_A_BRANCH_NAME: ReadonlyArray<RegExp> = [ + /\s/, + /^[a-z][a-z0-9+.-]*:\/\//i, + /^\d+$/, + /^[./~]/, +]; + +/** Whether a word reads as a branch name rather than a path, URL, number or sentence. */ +export const couldNameBranch = (word: string): boolean => + word !== "" && !NOT_A_BRANCH_NAME.some((pattern) => pattern.test(word)); diff --git a/apps/cli/src/command-internal/target/target-deprecation.ts b/apps/cli/src/command-internal/target/target-deprecation.ts index 5673e3a984..20d71c56c6 100644 --- a/apps/cli/src/command-internal/target/target-deprecation.ts +++ b/apps/cli/src/command-internal/target/target-deprecation.ts @@ -1,6 +1,7 @@ import { BRANCH_PROJECT_REF_PATTERN } from "../ref-patterns.ts"; import type { RemoteValue } from "./remote-value.ts"; -import type { RemoteAliasFlag, TargetDeprecation } from "./target-selector.ts"; +import { DB_URL_PATTERN, isBooleanLiteral } from "./remote-vocabulary.ts"; +import { joinFlags, type TargetDeprecation } from "./target-selector.ts"; const ALIAS_VALUE_FLAGS: ReadonlySet<string> = new Set([ "--db-url", @@ -9,17 +10,6 @@ const ALIAS_VALUE_FLAGS: ReadonlySet<string> = new Set([ ]); const ALIAS_SWITCH_FLAGS: ReadonlySet<string> = new Set(["--linked", "--local"]); const SECRET_VALUE_FLAGS: ReadonlySet<string> = new Set(["--password", "-p", "--db-password"]); -const BOOLEAN_LITERALS: ReadonlySet<string> = new Set([ - "true", - "false", - "yes", - "no", - "on", - "off", - "1", - "0", -]); -const DB_URL_PATTERN = /^postgres(ql)?:\/\//; const SAFE_TOKEN = /^[\w@%+=:,./-]+$/; const shellQuote = (token: string): string => @@ -66,7 +56,7 @@ export const rebuildCommand = (argv: ReadonlyArray<string>, remote: string | und const consumesNext = !token.includes("=") && ((ALIAS_VALUE_FLAGS.has(alias) && next !== undefined) || - (ALIAS_SWITCH_FLAGS.has(alias) && next !== undefined && BOOLEAN_LITERALS.has(next))); + (ALIAS_SWITCH_FLAGS.has(alias) && next !== undefined && isBooleanLiteral(next))); if (consumesNext) index += 1; continue; } @@ -101,11 +91,6 @@ export const refFromDbUrl = (url: string, projectHost: string): string | undefin } }; -const joinFlags = (flags: ReadonlyArray<RemoteAliasFlag>): string => - flags.length <= 2 - ? flags.join(" and ") - : `${flags.slice(0, -1).join(", ")} and ${flags[flags.length - 1]}`; - /** * The warning for each deprecated selector the run used. `argv` is the real command line, so the * replacement shows the command the user ran; a database URL is never echoed. diff --git a/apps/cli/src/command-internal/target/target-flags.ts b/apps/cli/src/command-internal/target/target-flags.ts index d2f159c570..b2e5194d20 100644 --- a/apps/cli/src/command-internal/target/target-flags.ts +++ b/apps/cli/src/command-internal/target/target-flags.ts @@ -4,9 +4,9 @@ import { Flag, GlobalFlag, Param, Primitive } from "effect/cli"; import { classifyRemoteValue, LEAVE_OUT_REMOTE_PREFIX, - REMOTE_FLAG_NAME, type RemoteValue, } from "./remote-value.ts"; +import { REMOTE_FLAG_NAME } from "./remote-vocabulary.ts"; import type { ResolvedTarget } from "./resolve-target.ts"; type TargetSubject = "database" | "Storage API"; @@ -33,18 +33,25 @@ const leaveOutMessage = (variant: RemoteFlagVariant): string => ? `${LEAVE_OUT_REMOTE_PREFIX}to use the linked project.` : `${LEAVE_OUT_REMOTE_PREFIX}to target the local ${variant.subject}.`; +const REMOTE_PRIMITIVE = Symbol.for("supabase/cli/RemotePrimitive"); + /** * Keeps the `Boolean` primitive tag so the parser accepts a bare `--remote` and never takes the * next token as its value; only `--remote=<value>` (or a boolean literal) reaches `parse`. */ const remotePrimitive = (variant: RemoteFlagVariant): Primitive.Primitive<RemoteValue> => Object.assign(Object.create(Primitive.Boolean), { + [REMOTE_PRIMITIVE]: true, parse: (raw: string) => { const value = classifyRemoteValue(raw); return value === undefined ? Effect.fail(leaveOutMessage(variant)) : Effect.succeed(value); }, }); +/** Whether a flag was declared by {@link targetFlags}, {@link projectTargetFlags} or the `seed` globals as `--remote`. */ +export const isRemoteFlag = (single: Param.Single<Param.ParamKind, unknown>): boolean => + REMOTE_PRIMITIVE in single.primitiveType; + /** `--remote`: the linked project when bare, else the project, branch or database named by its value. */ const remoteFlag = (variant: RemoteFlagVariant) => Param.makeSingle({ 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 af28d9e9af..1a2687028e 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,7 +1,7 @@ import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; import { Effect, Layer, Option, Redacted } from "effect"; -import { CliError, CliOutput, Command, type Flag } from "effect/cli"; +import { CliError, CliOutput, Command, Flag } from "effect/cli"; import type * as CliCommand from "effect/cli/Command"; import { formatInvalidValueMessage } from "../../shared/cli/invalid-value-message.ts"; @@ -9,6 +9,7 @@ import { unwrapParam } from "../param-introspection.ts"; import { deprecatedProjectIdFlag, flagsForTarget, + isRemoteFlag, projectTargetFlags, seedTargetGlobalFlags, targetFlags, @@ -218,6 +219,29 @@ describe("deprecatedProjectIdFlag", () => { ); }); +describe("isRemoteFlag", () => { + const singleOf = (flag: Flag.Flag<unknown>) => { + const single = unwrapParam(flag)?.single; + if (single === undefined) throw new Error("expected a single flag"); + return single; + }; + + it("recognises every declaration of --remote by its primitive, not its name", () => { + expect(isRemoteFlag(singleOf(targetFlags({ subject: "database", dbUrl: true }).remote))).toBe( + true, + ); + expect(isRemoteFlag(singleOf(projectTargetFlags().remote))).toBe(true); + expect(isRemoteFlag(singleOf(seedTargetGlobalFlags.remote.flag))).toBe(true); + }); + + it("does not take a boolean flag for --remote", () => { + expect(isRemoteFlag(singleOf(targetFlags({ subject: "database", dbUrl: true }).linked))).toBe( + false, + ); + expect(isRemoteFlag(singleOf(Flag.Boolean("remote")))).toBe(false); + }); +}); + describe("seedTargetGlobalFlags", () => { it("shares the selector flag names with targetFlags", () => { expect(describeFlag(seedTargetGlobalFlags.remote.flag).name).toBe("remote"); diff --git a/apps/cli/src/command-internal/target/target-selector.ts b/apps/cli/src/command-internal/target/target-selector.ts index 119d11283b..fab7abb766 100644 --- a/apps/cli/src/command-internal/target/target-selector.ts +++ b/apps/cli/src/command-internal/target/target-selector.ts @@ -48,7 +48,8 @@ export interface TargetSelectorOptions { readonly commandName?: string; } -const joinFlags = (flags: ReadonlyArray<string>): string => +/** Lists flag names as `a`, `a and b` or `a, b and c`. */ +export const joinFlags = (flags: ReadonlyArray<string>): string => flags.length <= 2 ? flags.join(" and ") : `${flags.slice(0, -1).join(", ")} and ${flags[flags.length - 1]}`; diff --git a/apps/cli/src/docs/docs-spec.ts b/apps/cli/src/docs/docs-spec.ts index 6d5d4312f3..5f8ecea691 100644 --- a/apps/cli/src/docs/docs-spec.ts +++ b/apps/cli/src/docs/docs-spec.ts @@ -9,6 +9,7 @@ import { userGlobalFlagParams, } from "./docs-introspection.ts"; import { unwrapParam } from "../command-internal/param-introspection.ts"; +import { isRemoteFlag } from "../command-internal/target/target-flags.ts"; import { remoteFlagName, remoteValueHint } from "../shared/output/target-help.ts"; import { DOCS_ARG_OVERRIDES, @@ -210,7 +211,7 @@ export function docsFlagDoc( const shorthand = single.aliases.find((alias) => alias.length === 1); const longName = - single.name === "remote" && remoteHint !== undefined + isRemoteFlag(single) && remoteHint !== undefined ? remoteFlagName(remoteHint) : `${single.name}${varname === undefined ? "" : ` <${varname}>`}`; const name = `${shorthand === undefined ? "" : `-${shorthand}, `}--${longName}`; diff --git a/apps/cli/src/shared/cli/remote-argv.ts b/apps/cli/src/shared/cli/remote-argv.ts index 02ede7a248..430985908e 100644 --- a/apps/cli/src/shared/cli/remote-argv.ts +++ b/apps/cli/src/shared/cli/remote-argv.ts @@ -1,8 +1,14 @@ import type { Command } from "effect/cli"; +import { unwrapToSingleParam } from "../../command-internal/param-introspection.ts"; import { BRANCH_PROJECT_REF_PATTERN } from "../../command-internal/ref-patterns.ts"; -import { couldNameBranch } from "../../command-internal/target/remote-value.ts"; -import { commandInternals } from "../../docs/docs-introspection.ts"; +import { + couldNameBranch, + DB_URL_PATTERN, + LINKED_LITERAL, +} from "../../command-internal/target/remote-vocabulary.ts"; +import { isRemoteFlag } from "../../command-internal/target/target-flags.ts"; +import { commandInternals, userGlobalFlagParams } from "../../docs/docs-introspection.ts"; import { BOOLEAN_FLAG_VALUES } from "./agent-output.ts"; import { helpDocFor } from "./subcommand-flag-suggestions.ts"; @@ -15,9 +21,6 @@ export interface RemoteArgvLeaf { readonly consumesNextToken: (token: string) => boolean; } -const LINKED_LITERALS: ReadonlySet<string> = new Set(["true", "yes", "on", "1"]); -const DB_URL_PATTERN = /^postgres(ql)?:\/\//; - interface RemoteArgvScan { /** Indexes of the bare (non-flag, non-flag-value) tokens ahead of any `--`. */ readonly bare: ReadonlyArray<number>; @@ -45,7 +48,7 @@ const scanRemoteArgv = (args: ReadonlyArray<string>, leaf: RemoteArgvLeaf): Remo const next = args[index + 1]; const isLiteral = next !== undefined && BOOLEAN_FLAG_VALUES.has(next); if (arg === "--remote") { - if (!isLiteral || LINKED_LITERALS.has(next)) remotes.push(index); + if (!isLiteral || LINKED_LITERAL.test(next)) remotes.push(index); if (isLiteral) index += 1; } else if (leaf.consumesNextToken(arg) || (isLiteral && !arg.includes("="))) { index += 1; @@ -74,7 +77,7 @@ export function joinRemoteOperands( const ordinal = bare.indexOf(index + 1); if (ordinal !== -1 && ordinal < leaf.pathLength) continue; if ( - LINKED_LITERALS.has(word) || + LINKED_LITERAL.test(word) || leaf.operandCapacity === 0 || BRANCH_PROJECT_REF_PATTERN.test(word) || DB_URL_PATTERN.test(word) || @@ -116,6 +119,12 @@ export function bareRemoteOperand( return undefined; } +const declaresRemoteFlag = (command: Command.Command.Any): boolean => + [...commandInternals(command).config.flags, ...userGlobalFlagParams(command)].some((param) => { + const single = unwrapToSingleParam(param); + return single !== undefined && isRemoteFlag(single); + }); + /** * The operand capacity of the command `path` resolves to, or `undefined` when it does not accept * `--remote` (as its own flag, or as a persistent flag of an ancestor such as `seed`). @@ -135,15 +144,7 @@ export function remoteOperandCapacity( } const leaf = commands[commands.length - 1]; if (leaf === undefined) return undefined; - const doc = helpDocFor(leaf, path); - const acceptsRemote = - doc?.flags.some((flag) => flag.name === "remote") === true || - commands.some((command) => - commandInternals(command).globalFlags.some( - (flag) => flag._tag === "Setting" && flag.id === "remote", - ), - ); - if (!acceptsRemote) return undefined; - const operands = doc?.args ?? []; + if (!commands.some(declaresRemoteFlag)) return undefined; + const operands = helpDocFor(leaf, path)?.args ?? []; return operands.some((operand) => operand.variadic) ? Infinity : operands.length; } diff --git a/apps/cli/src/shared/cli/remote-argv.unit.test.ts b/apps/cli/src/shared/cli/remote-argv.unit.test.ts index 835b86d50c..1a253a337e 100644 --- a/apps/cli/src/shared/cli/remote-argv.unit.test.ts +++ b/apps/cli/src/shared/cli/remote-argv.unit.test.ts @@ -46,6 +46,12 @@ describe("joinRemoteOperands", () => { args: ["functions", "deploy", "--remote", "yes", "--remote", "on", "--remote", "1"], expected: ["functions", "deploy", "--remote=yes", "--remote=on", "--remote=1"], }, + { + name: "joins a linked boolean literal in any case, as --remote=<literal> reads it", + leaf: oneOperand, + args: ["storage", "ls", "--remote", "YES"], + expected: ["storage", "ls", "--remote=YES"], + }, { name: "leaves an off literal for the parser to reject", leaf: noOperands, diff --git a/apps/cli/src/shared/cli/subcommand-flag-suggestions.ts b/apps/cli/src/shared/cli/subcommand-flag-suggestions.ts index f3e8f64da3..297c94df81 100644 --- a/apps/cli/src/shared/cli/subcommand-flag-suggestions.ts +++ b/apps/cli/src/shared/cli/subcommand-flag-suggestions.ts @@ -1,7 +1,7 @@ import { Predicate } from "effect"; import type { CliError, Command, HelpDoc, Param } from "effect/cli"; import { unwrapToSingleParam } from "../../command-internal/param-introspection.ts"; -import { REMOTE_FLAG_NAME } from "../../command-internal/target/remote-value.ts"; +import { isRemoteFlag } from "../../command-internal/target/target-flags.ts"; import { commandInternals } from "../../docs/docs-introspection.ts"; import { formatInvalidValueMessage } from "./invalid-value-message.ts"; @@ -58,7 +58,7 @@ export function helpDocFor( const flagArityOf = (single: Param.Single<Param.ParamKind, unknown>): FlagArity => { if (single.primitiveType._tag !== "Boolean") return "value"; - return single.name === REMOTE_FLAG_NAME ? "optional-value" : "switch"; + return isRemoteFlag(single) ? "optional-value" : "switch"; }; /** Every flag the command parses, hidden aliases included: parsing consumes their values like any other flag's. */ diff --git a/apps/cli/src/shared/output/target-help.ts b/apps/cli/src/shared/output/target-help.ts index cb9ac80c47..503f432571 100644 --- a/apps/cli/src/shared/output/target-help.ts +++ b/apps/cli/src/shared/output/target-help.ts @@ -1,5 +1,6 @@ import { Array as Arr, Context, Option } from "effect"; import type { Command, HelpDoc } from "effect/cli"; +import { REMOTE_FLAG_NAME } from "../../command-internal/target/remote-vocabulary.ts"; import { TargetPolicyAnnotation } from "../../command-internal/target/target-policy.ts"; type TargetKind = "selectable" | "hosted" | "local-only"; @@ -32,15 +33,12 @@ const TARGET_COPY: Readonly<Record<TargetKind, TargetCopy>> = { const KIND_BY_GROUP: Readonly<Record<string, TargetKind | undefined>> = { selectable: "selectable", - sync: "selectable", hosted: "hosted", "local-only": "local-only", }; -const REMOTE_FLAG = "remote"; - /** The flag's display name once its value hint is known: `remote[=<ref|branch>]`. */ -export const remoteFlagName = (hint: string): string => `${REMOTE_FLAG}[=${hint}]`; +export const remoteFlagName = (hint: string): string => `${REMOTE_FLAG_NAME}[=${hint}]`; export interface TargetHelp { readonly scope: string; @@ -54,7 +52,7 @@ const kindOf = (annotations: Context.Context<never>): TargetKind | undefined => ); const declaresRemoteFlag = (doc: HelpDoc.HelpDoc): boolean => - [...doc.flags, ...(doc.globalFlags ?? [])].some((flag) => flag.name === REMOTE_FLAG); + [...doc.flags, ...(doc.globalFlags ?? [])].some((flag) => flag.name === REMOTE_FLAG_NAME); /** What a leaf's help says about its target; `undefined` for account, removed and group commands. */ export function targetHelpFor(doc: HelpDoc.HelpDoc): TargetHelp | undefined { @@ -78,7 +76,7 @@ export function withRemoteFlagUsage(doc: HelpDoc.HelpDoc): HelpDoc.HelpDoc { const hint = remoteValueHint(doc.annotations); if (hint === undefined || !declaresRemoteFlag(doc)) return doc; const rename = (flag: HelpDoc.FlagDoc): HelpDoc.FlagDoc => - flag.name === REMOTE_FLAG ? { ...flag, name: remoteFlagName(hint) } : flag; + flag.name === REMOTE_FLAG_NAME ? { ...flag, name: remoteFlagName(hint) } : flag; return { ...doc, flags: doc.flags.map(rename), diff --git a/apps/cli/src/telemetry/command-telemetry.ts b/apps/cli/src/telemetry/command-telemetry.ts index 812c2293a2..7458d6a2a9 100644 --- a/apps/cli/src/telemetry/command-telemetry.ts +++ b/apps/cli/src/telemetry/command-telemetry.ts @@ -48,11 +48,8 @@ import { } from "../shared/cli/value-consuming-flags.ts"; import { unwrapToSingleParam } from "../command-internal/param-introspection.ts"; import { StackBackendContext } from "../command-internal/stack-backend.ts"; -import { - isRemoteValue, - REMOTE_FLAG_NAME, - type RemoteValue, -} from "../command-internal/target/remote-value.ts"; +import { isRemoteValue, type RemoteValue } from "../command-internal/target/remote-value.ts"; +import { REMOTE_FLAG_NAME } from "../command-internal/target/remote-vocabulary.ts"; import { CommandTelemetryAttributes, type CommandTelemetryAttributeValues, diff --git a/apps/cli/tests/helpers/permission-registry.ts b/apps/cli/tests/helpers/permission-registry.ts index c53e9ad8f0..ae163c1368 100644 --- a/apps/cli/tests/helpers/permission-registry.ts +++ b/apps/cli/tests/helpers/permission-registry.ts @@ -8,6 +8,7 @@ import type { EntryCondition, OperationEntry, } from "../../src/command-internal/command-permissions/model.ts"; +import { DB_URL_PATTERN } from "../../src/command-internal/target/remote-vocabulary.ts"; import { findCommand } from "../../src/shared/cli/command-docs.ts"; /** Which command tree to resolve against: `start`/`status`/`stop` differ by stack backend. */ @@ -25,8 +26,6 @@ export type ActiveFlags = Readonly<Record<string, boolean | string>>; /** The flags that select a database target, in precedence order. */ export const DB_TARGET_FLAGS = ["db-url", "local", "linked", "remote"] as const; -const DB_URL_PREFIX = /^postgres(ql)?:\/\//; - /** The target `activeFlags` selects (`db-url`, `local`, `linked`, `remote`, or `project-ref`, which imply linked unless `remote` is a database URL), else `fallback`. */ function dbTargetOf( activeFlags: ActiveFlags, @@ -37,7 +36,7 @@ function dbTargetOf( } if (Object.hasOwn(activeFlags, "remote")) { const remote = activeFlags["remote"]; - return typeof remote === "string" && DB_URL_PREFIX.test(remote) ? "db-url" : "linked"; + return typeof remote === "string" && DB_URL_PATTERN.test(remote) ? "db-url" : "linked"; } if (Object.hasOwn(activeFlags, "project-ref")) return "linked"; return fallback; From 7a78d7bc5905c0e3c5481f1773dc3af4b002df70 Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Mon, 19 Oct 2026 14:30:12 +0100 Subject: [PATCH 145/291] refactor(cli): resolve every --remote target through one resolver and one argv Every hosted `--remote` value now goes through one branch resolver and one dispatch. The config family drops its minted branch errors and uses the same list-based lookup as the other commands, and a branch UUID with no linked project resolves through `GET /v1/branches/{id}`. Resolvers take the decoded flag record, `CliArgs` carries the rewritten argv and the bare `--remote` operand computed once, and the permission model gains a selector-shape condition next to `dbTarget`. --- .../command-permissions.unit.test.ts | 21 +- .../command-permissions/model.ts | 18 +- .../src/command-internal/config-pull-run.ts | 4 +- .../project-target.integration.test.ts | 111 -------- .../src/command-internal/project-target.ts | 255 ------------------ .../project-target.unit.test.ts | 88 ------ .../target/branch-resolution.ts | 109 +++++--- .../target/remote-resolution.ts | 117 ++++++-- ...resolve-project-target.integration.test.ts | 194 ++++++++++++- .../target/resolve-project-target.ts | 154 ++++------- .../target/resolve-target.integration.test.ts | 32 +-- .../command-internal/target/resolve-target.ts | 137 +++------- .../command-internal/target/target-flags.ts | 111 ++++++-- .../target/target-selector.ts | 66 ++--- .../target/target-selector.unit.test.ts | 55 ++-- .../src/command-internal/test-db.handler.ts | 8 +- .../src/commands/backups/list/SIDE_EFFECTS.md | 3 +- .../src/commands/backups/list/list.handler.ts | 2 +- .../commands/backups/restore/SIDE_EFFECTS.md | 3 +- .../backups/restore/restore.handler.ts | 2 +- .../src/commands/config/diff/SIDE_EFFECTS.md | 20 +- .../src/commands/config/diff/diff.command.ts | 6 +- .../src/commands/config/diff/diff.errors.ts | 47 ---- .../src/commands/config/diff/diff.handler.ts | 40 +-- .../config/diff/diff.integration.test.ts | 55 ++-- .../src/commands/config/pull/SIDE_EFFECTS.md | 20 +- .../src/commands/config/pull/pull.command.ts | 6 +- .../src/commands/config/pull/pull.errors.ts | 22 -- .../src/commands/config/pull/pull.handler.ts | 44 +-- .../config/pull/pull.integration.test.ts | 59 ++-- .../src/commands/config/push/SIDE_EFFECTS.md | 8 +- .../src/commands/config/push/push.command.ts | 6 +- .../src/commands/config/push/push.errors.ts | 50 ---- .../src/commands/config/push/push.handler.ts | 59 +--- .../config/push/push.integration.test.ts | 59 ++-- .../src/commands/db/advisors/SIDE_EFFECTS.md | 1 + .../commands/db/advisors/advisors.handler.ts | 8 +- apps/cli/src/commands/db/diff/SIDE_EFFECTS.md | 1 + apps/cli/src/commands/db/diff/diff.handler.ts | 31 +-- apps/cli/src/commands/db/dump/SIDE_EFFECTS.md | 1 + apps/cli/src/commands/db/dump/dump.handler.ts | 8 +- apps/cli/src/commands/db/lint/SIDE_EFFECTS.md | 3 +- apps/cli/src/commands/db/lint/lint.handler.ts | 8 +- apps/cli/src/commands/db/pull/SIDE_EFFECTS.md | 1 + apps/cli/src/commands/db/push/SIDE_EFFECTS.md | 6 +- apps/cli/src/commands/db/push/push.handler.ts | 8 +- .../cli/src/commands/db/query/SIDE_EFFECTS.md | 1 + .../src/commands/db/query/query.handler.ts | 8 +- .../cli/src/commands/db/reset/SIDE_EFFECTS.md | 8 +- .../src/commands/db/reset/reset.handler.ts | 8 +- .../declarative/generate/SIDE_EFFECTS.md | 5 +- apps/cli/src/commands/domains/SIDE_EFFECTS.md | 3 +- .../domains/activate/activate.handler.ts | 2 +- .../commands/domains/create/create.handler.ts | 2 +- .../commands/domains/delete/delete.handler.ts | 2 +- .../src/commands/domains/get/get.handler.ts | 2 +- .../domains/reverify/reverify.handler.ts | 2 +- .../src/commands/encryption/SIDE_EFFECTS.md | 3 +- .../get-root-key/get-root-key.handler.ts | 2 +- .../update-root-key.handler.ts | 2 +- .../compute/delete/SIDE_EFFECTS.md | 3 +- .../compute/delete/delete.handler.ts | 5 +- .../experimental/compute/list/SIDE_EFFECTS.md | 3 +- .../experimental/compute/list/list.handler.ts | 5 +- .../experimental/compute/logs/SIDE_EFFECTS.md | 3 +- .../experimental/compute/logs/logs.handler.ts | 5 +- .../experimental/compute/push/SIDE_EFFECTS.md | 3 +- .../experimental/compute/push/push.handler.ts | 5 +- .../compute/status/SIDE_EFFECTS.md | 3 +- .../compute/status/status.handler.ts | 5 +- .../commands/functions/delete/SIDE_EFFECTS.md | 3 +- .../functions/delete/delete.handler.ts | 7 +- .../commands/functions/deploy/SIDE_EFFECTS.md | 1 + .../functions/deploy/deploy.handler.ts | 2 +- .../deploy/deploy.integration.test.ts | 7 +- .../functions/download/SIDE_EFFECTS.md | 3 +- .../functions/download/download.handler.ts | 7 +- .../commands/functions/list/SIDE_EFFECTS.md | 3 +- .../commands/functions/list/list.handler.ts | 7 +- .../src/commands/gen/types/SIDE_EFFECTS.md | 1 + .../src/commands/inspect/db/SIDE_EFFECTS.md | 1 + .../commands/inspect/report/SIDE_EFFECTS.md | 1 + .../commands/migration/down/SIDE_EFFECTS.md | 1 + .../commands/migration/fetch/SIDE_EFFECTS.md | 1 + .../commands/migration/list/SIDE_EFFECTS.md | 1 + .../commands/migration/repair/SIDE_EFFECTS.md | 1 + .../commands/migration/squash/SIDE_EFFECTS.md | 1 + .../src/commands/migration/up/SIDE_EFFECTS.md | 1 + .../commands/network-bans/get/SIDE_EFFECTS.md | 1 + .../commands/network-bans/get/get.handler.ts | 2 +- .../network-bans/remove/SIDE_EFFECTS.md | 1 + .../network-bans/remove/remove.handler.ts | 2 +- .../network-restrictions/get/SIDE_EFFECTS.md | 1 + .../network-restrictions/get/get.handler.ts | 2 +- .../update/SIDE_EFFECTS.md | 1 + .../update/update.handler.ts | 2 +- .../commands/notebooks/pull/SIDE_EFFECTS.md | 1 + .../commands/notebooks/pull/pull.handler.ts | 2 +- .../commands/notebooks/push/SIDE_EFFECTS.md | 1 + .../commands/notebooks/push/push.handler.ts | 2 +- .../postgres-config/delete/SIDE_EFFECTS.md | 1 + .../postgres-config/delete/delete.handler.ts | 2 +- .../postgres-config/get/SIDE_EFFECTS.md | 1 + .../postgres-config/get/get.handler.ts | 2 +- .../postgres-config/update/SIDE_EFFECTS.md | 1 + .../postgres-config/update/update.handler.ts | 2 +- .../cli/src/commands/projects/SIDE_EFFECTS.md | 22 +- .../projects/api-keys/SIDE_EFFECTS.md | 1 + .../projects/api-keys/api-keys.handler.ts | 2 +- apps/cli/src/commands/pull/SIDE_EFFECTS.md | 14 +- apps/cli/src/commands/pull/pull.command.ts | 6 +- apps/cli/src/commands/pull/pull.errors.ts | 63 ----- apps/cli/src/commands/pull/pull.handler.ts | 37 +-- .../commands/pull/pull.integration.test.ts | 8 +- .../src/commands/secrets/list/SIDE_EFFECTS.md | 1 + .../src/commands/secrets/list/list.handler.ts | 2 +- .../src/commands/secrets/set/SIDE_EFFECTS.md | 1 + .../src/commands/secrets/set/set.handler.ts | 2 +- .../commands/secrets/unset/SIDE_EFFECTS.md | 1 + .../commands/secrets/unset/unset.handler.ts | 2 +- .../src/commands/seed/buckets/SIDE_EFFECTS.md | 1 + .../commands/seed/buckets/buckets.handler.ts | 7 +- .../snippets/download/SIDE_EFFECTS.md | 1 + .../snippets/download/download.handler.ts | 2 +- .../commands/snippets/list/SIDE_EFFECTS.md | 1 + .../commands/snippets/list/list.handler.ts | 2 +- .../ssl-enforcement/get/SIDE_EFFECTS.md | 1 + .../ssl-enforcement/get/get.handler.ts | 2 +- .../ssl-enforcement/update/SIDE_EFFECTS.md | 1 + .../ssl-enforcement/update/update.handler.ts | 2 +- apps/cli/src/commands/sso/add/SIDE_EFFECTS.md | 1 + .../cli/src/commands/sso/info/SIDE_EFFECTS.md | 1 + .../cli/src/commands/sso/info/info.handler.ts | 2 +- .../cli/src/commands/sso/list/SIDE_EFFECTS.md | 1 + .../cli/src/commands/sso/list/list.handler.ts | 2 +- .../src/commands/sso/remove/SIDE_EFFECTS.md | 1 + .../src/commands/sso/remove/remove.handler.ts | 2 +- .../cli/src/commands/sso/show/SIDE_EFFECTS.md | 1 + .../cli/src/commands/sso/show/show.handler.ts | 2 +- .../src/commands/sso/update/SIDE_EFFECTS.md | 1 + .../src/commands/storage/cp/SIDE_EFFECTS.md | 1 + .../src/commands/storage/ls/SIDE_EFFECTS.md | 1 + .../src/commands/storage/mv/SIDE_EFFECTS.md | 1 + .../src/commands/storage/rm/SIDE_EFFECTS.md | 1 + .../cli/src/commands/storage/storage.frame.ts | 7 +- apps/cli/src/commands/test/db/SIDE_EFFECTS.md | 1 + .../activate/SIDE_EFFECTS.md | 1 + .../activate/activate.handler.ts | 2 +- .../check-availability/SIDE_EFFECTS.md | 1 + .../check-availability.handler.ts | 5 +- .../vanity-subdomains/delete/SIDE_EFFECTS.md | 1 + .../delete/delete.handler.ts | 2 +- .../vanity-subdomains/get/SIDE_EFFECTS.md | 1 + .../vanity-subdomains/get/get.handler.ts | 2 +- apps/cli/src/config/project-ref.layer.ts | 2 +- apps/cli/src/shared/cli/cli-args.service.ts | 7 +- .../src/shared/cli/remote-argv.unit.test.ts | 13 +- apps/cli/src/shared/cli/run.ts | 52 ++-- .../telemetry/__fixtures__/error-tags.txt | 22 -- .../error-tag-stability.unit.test.ts | 68 +---- apps/cli/tests/helpers/permission-drift.ts | 6 +- .../helpers/permission-drift.unit.test.ts | 41 +++ apps/cli/tests/helpers/permission-registry.ts | 44 ++- .../helpers/permission-registry.unit.test.ts | 47 ++++ apps/cli/tests/helpers/remote-flag-fixture.ts | 14 +- 165 files changed, 1165 insertions(+), 1607 deletions(-) delete mode 100644 apps/cli/src/command-internal/project-target.integration.test.ts delete mode 100644 apps/cli/src/command-internal/project-target.ts delete mode 100644 apps/cli/src/command-internal/project-target.unit.test.ts diff --git a/apps/cli/src/command-internal/command-permissions/command-permissions.unit.test.ts b/apps/cli/src/command-internal/command-permissions/command-permissions.unit.test.ts index 75dea30bc8..489b74a5a6 100644 --- a/apps/cli/src/command-internal/command-permissions/command-permissions.unit.test.ts +++ b/apps/cli/src/command-internal/command-permissions/command-permissions.unit.test.ts @@ -9,7 +9,7 @@ import { OPERATIONS } from "../../../tests/helpers/operation-table.ts"; import { readPermissions } from "./command-permissions.annotation.ts"; import { GLOBAL_NO_API_EFFECT_FLAGS } from "../../../tests/helpers/permission-global-flags.ts"; import type { CommandPermissions, EntryCondition, OperationEntry } from "./model.ts"; -import { DB_TARGET_FLAGS } from "../../../tests/helpers/permission-registry.ts"; +import { DB_TARGET_FLAGS, SELECTOR_FLAGS } from "../../../tests/helpers/permission-registry.ts"; import type { PermissionVariant } from "../../../tests/helpers/permission-registry.ts"; /** @@ -48,9 +48,11 @@ function rootGlobalFlagIds(): ReadonlySet<string> { function conditionFlagNames(entries: ReadonlyArray<OperationEntry>): ReadonlyArray<string> { return entries.flatMap((entry) => - (entry.when ?? []).flatMap((cond: EntryCondition) => - cond.dbTarget === undefined ? [cond.flag] : DB_TARGET_FLAGS, - ), + (entry.when ?? []).flatMap((cond: EntryCondition) => { + if (cond.dbTarget !== undefined) return DB_TARGET_FLAGS; + if (cond.selector !== undefined) return SELECTOR_FLAGS; + return [cond.flag]; + }), ); } @@ -129,6 +131,17 @@ describe("command permission mapping completeness", () => { } continue; } + if (cond.selector !== undefined) { + expect( + cond.selector.length, + `"${path}"'s \`when\` condition on "${entry.operationId}" lists no selector shapes`, + ).toBeGreaterThan(0); + expect( + cond.source !== undefined || SELECTOR_FLAGS.some((flag) => node.flags.has(flag)), + `"${path}"'s \`when\` condition on "${entry.operationId}" checks the target selector, but the command accepts no selector flag`, + ).toBe(true); + continue; + } // A condition a building block wrote (`cond.source` set) only has to name a real flag // somewhere in the CLI: a command can include a block without accepting every flag // its conditions mention. On such a command a presence condition is false and an diff --git a/apps/cli/src/command-internal/command-permissions/model.ts b/apps/cli/src/command-internal/command-permissions/model.ts index 2dadf7200c..74d924b7a4 100644 --- a/apps/cli/src/command-internal/command-permissions/model.ts +++ b/apps/cli/src/command-internal/command-permissions/model.ts @@ -15,6 +15,7 @@ interface FlagConditionBase { */ readonly source?: string; readonly dbTarget?: never; + readonly selector?: never; } /** @@ -63,13 +64,28 @@ export type DbTarget = "local" | "db-url" | "linked"; interface DbTargetCondition { readonly dbTarget: ReadonlyArray<DbTarget>; readonly source?: string; + readonly selector?: never; readonly flag?: never; readonly present?: never; readonly is?: never; readonly oneOf?: never; } -export type EntryCondition = FlagCondition | DbTargetCondition; +/** What the target selector carries once the deprecated aliases are folded in; `bare` is `--remote` or `--linked` with no value. */ +export type SelectorShape = "none" | "bare" | "ref" | "branch" | "url"; + +/** Holds when the selector's shape is one of `selector`, whichever flag spelled it. */ +interface SelectorCondition { + readonly selector: ReadonlyArray<SelectorShape>; + readonly source?: string; + readonly dbTarget?: never; + readonly flag?: never; + readonly present?: never; + readonly is?: never; + readonly oneOf?: never; +} + +export type EntryCondition = FlagCondition | DbTargetCondition | SelectorCondition; export interface OperationEntry { /** The spec's `operationId`, e.g. `"v1-list-all-secrets"`. */ diff --git a/apps/cli/src/command-internal/config-pull-run.ts b/apps/cli/src/command-internal/config-pull-run.ts index 20400f5763..d974eb447f 100644 --- a/apps/cli/src/command-internal/config-pull-run.ts +++ b/apps/cli/src/command-internal/config-pull-run.ts @@ -27,7 +27,7 @@ import { } from "../config/cli-config-validation.ts"; import { block } from "./command-permissions/model.ts"; import { sanitizeErrorBody, sanitizeInlineName } from "./http-errors.ts"; -import type { ConfigTarget } from "./project-target.ts"; +import type { LinkedTarget } from "./target/resolve-target.ts"; import { BRANCH_UUID_PATTERN } from "./ref-patterns.ts"; import { configDeepSetAtPath, @@ -92,7 +92,7 @@ import { */ export interface ConfigPullPlanRequest { - readonly target: ConfigTarget; + readonly target: Pick<LinkedTarget, "ref" | "branch">; readonly remoteLabel: string | undefined; readonly source: ConfigPullSource; } diff --git a/apps/cli/src/command-internal/project-target.integration.test.ts b/apps/cli/src/command-internal/project-target.integration.test.ts deleted file mode 100644 index 2ebd4e2de3..0000000000 --- a/apps/cli/src/command-internal/project-target.integration.test.ts +++ /dev/null @@ -1,111 +0,0 @@ -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/project-target.ts b/apps/cli/src/command-internal/project-target.ts deleted file mode 100644 index 32e4062822..0000000000 --- a/apps/cli/src/command-internal/project-target.ts +++ /dev/null @@ -1,255 +0,0 @@ -import type { SupabaseApiError } from "@supabase/api/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, - parentRefTypoHint, - resolveLinkedParentRef, - resolveParentScopedProjectRef, -} from "./parent-project-ref.ts"; -import { announceTarget } from "../shared/output/command-target.service.ts"; -import { projectRefPicker } from "../config/project-ref.layer.ts"; -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 { startBranchLookupTask } from "./target/branch-resolution.ts"; -import { type LinkedTarget, linkedTarget } from "./target/resolve-target.ts"; -import { renderBranchTarget } from "./target/target-identity.ts"; -import { - actionability, - type CliErrorActionabilityDeclaration, - ErrorActionabilityId, -} from "../shared/telemetry/error-actionability.ts"; - -/** - * The resolved comparison/pull target for a command that accepts `--remote` - * (`config diff`, `config pull`, `config push`, `pull`): a project ref, plus the branch - * name/UUID it was given, if any. - */ -export interface ConfigTarget { - readonly ref: string; - readonly branch: string | undefined; -} - -/** A {@link ConfigTarget} with the same ref as the linked target connection resolution takes. */ -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 - * the four minted classes into one type. - */ -export interface ConfigTargetErrors<TError> { - /** - * `target` was named as a branch, but no project is linked to search for - * branches under — none of `SUPABASE_PROJECT_ID`, - * `supabase/.temp/linked-project.json`, or `supabase/.temp/project-ref` - * yielded a candidate. - */ - readonly notLinked: (target: string) => TError; - /** - * `target` was named as a branch, and a parent-project candidate exists - * but is not ref-shaped — corrupt or stale linked state. - */ - readonly parentRefInvalid: (target: string) => TError; - /** `target` named a branch the parent project does not have. */ - readonly branchNotFound: (target: string) => TError; - /** The resolved branch has no project ref yet (still provisioning). */ - readonly branchNotReady: (target: string) => TError; -} - -/** The constructor shape every minted target-error class has. */ -type ConfigTargetErrorClass<E> = new (args: { readonly message: string }) => E; - -/** - * Mints one calling command family's four target-resolution error classes from its name - * prefix, keeping `_tag`, fingerprint, and actionability family-owned from one definition. - * - * Every caller must re-export each minted class from its own `*.errors.ts` under the - * family-prefixed name; `error-tag-stability.unit.test.ts`'s static scan only sees the - * template-interpolated tags there. - */ -export function mintConfigTargetErrors<Prefix extends string>(prefix: Prefix) { - class BranchNotFoundError extends Data.TaggedError(`${prefix}BranchNotFoundError`)<{ - readonly message: string; - }> { - get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { - return actionability.invalidInput; - } - } - class BranchNotLinkedError extends Data.TaggedError(`${prefix}BranchNotLinkedError`)<{ - readonly message: string; - }> { - get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { - return actionability.projectNotLinked; - } - } - class ParentRefInvalidError extends Data.TaggedError(`${prefix}ParentRefInvalidError`)<{ - readonly message: string; - }> { - get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { - return actionability.relinkProject; - } - } - class BranchNotReadyError extends Data.TaggedError(`${prefix}BranchNotReadyError`)<{ - readonly message: string; - }> { - get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { - return { ...actionability.apiStatus, fingerprint_suffix: "branch_not_ready" }; - } - } - return { BranchNotFoundError, BranchNotLinkedError, ParentRefInvalidError, BranchNotReadyError }; -} - -/** - * Wraps a family's four minted classes as the `errors` bundle {@link resolveConfigTarget} - * takes. The message text is identical across every family, so it lives here instead of - * being restated per handler. - */ -export function configTargetErrorsFor< - TNotLinked, - TParentRefInvalid, - TBranchNotFound, - TBranchNotReady, ->(classes: { - readonly notLinked: ConfigTargetErrorClass<TNotLinked>; - readonly parentRefInvalid: ConfigTargetErrorClass<TParentRefInvalid>; - readonly branchNotFound: ConfigTargetErrorClass<TBranchNotFound>; - readonly branchNotReady: ConfigTargetErrorClass<TBranchNotReady>; -}): ConfigTargetErrors<TNotLinked | TParentRefInvalid | TBranchNotFound | TBranchNotReady> { - return { - notLinked: (target) => new classes.notLinked({ message: parentNotLinkedMessage(target) }), - parentRefInvalid: (target) => - new classes.parentRefInvalid({ message: parentRefInvalidMessage(target) }), - branchNotFound: (target) => - new classes.branchNotFound({ - message: `Branch "${sanitizeInlineName(target)}" not found. Run \`supabase branches list\` to see available branches.${parentRefTypoHint(target)}`, - }), - branchNotReady: (target) => - new classes.branchNotReady({ - message: `Branch "${sanitizeInlineName(target)}" has no project ref yet. Wait for it to finish provisioning, then retry.`, - }), - }; -} - -/** - * What {@link resolveConfigTarget} needs to know about a branch-lookup failure to decide - * whether it was a 404. `status` is optional because only one arm of the `mapHttpError` - * failure union carries an HTTP status; every other producer has none. `_tag` is required - * so the interface isn't a TypeScript weak type. - */ -export interface ConfigTargetResolveFailure { - readonly _tag: string; - readonly status?: number | undefined; -} - -// Not folded into `mapResolveError`: `config pull` shares one mapper across the branch -// lookup and the config read, so treating every 404 as "branch not found" here would -// misclassify a config-read 404. -function reclassifyBranchNotFoundError<E extends ConfigTargetResolveFailure, C>( - cause: E, - notFoundError: C, -): Effect.Effect<never, C | E> { - return cause.status === 404 ? Effect.fail(notFoundError) : Effect.fail(cause); -} - -/** The calls `resolveConfigTarget` makes: a branch lookup for a non-ref `--remote`, the project picker when no ref is given or linked. */ -export const configTarget = compose( - withCondition(branchRef, { flag: "remote", present: true }), - withCondition(branchRef, { flag: "project-ref", present: true }), - projectRefPicker, -); - -/** - * Resolves the requested `--remote` value to a {@link ConfigTarget}: a project ref, or the name/UUID - * of a branch on the linked project. A ref-shaped value (20 lowercase letters) is always a project ref. - * - * A UUID branch needs no parent ref, so it works unlinked; a name branch resolves the parent - * ref eagerly, before any spinner starts, so an unlinked or stale link fails immediately. - */ -export const resolveConfigTarget = Effect.fn("ProjectTarget.resolve")(function* < - TError, - EResolve extends ConfigTargetResolveFailure, ->( - requested: Option.Option<string>, - errors: ConfigTargetErrors<TError>, - /** Maps a branch-lookup (`GET`-by-UUID or `FIND`-by-name) transport/status failure. */ - mapResolveError: (cause: SupabaseApiError) => Effect.Effect<never, EResolve>, -) { - const resolver = yield* ProjectRefResolver; - - 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; - const byId = BRANCH_UUID_PATTERN.test(target); - yield* Effect.annotateCurrentSpan("project_target.kind", byId ? "branch_id" : "branch_name"); - - let parentRef: ReturnType<typeof resolveParentScopedProjectRef>; - let parentProjectRef = Option.none<string>(); - if (byId) { - parentRef = resolveParentScopedProjectRef(Option.none()); - } else { - const parent = yield* resolveLinkedParentRef(); - if (parent.kind === "absent") { - return yield* Effect.fail(errors.notLinked(target)); - } - if (parent.kind === "invalid") { - return yield* Effect.fail(errors.parentRefInvalid(target)); - } - parentRef = Effect.succeed(parent.ref); - parentProjectRef = Option.some(parent.ref); - } - - const resolving = yield* startBranchLookupTask("Resolving branch..."); - ref = yield* resolveBranchProjectRef(target, parentRef, { - mapGetError: mapResolveError, - mapFindError: mapResolveError, - }).pipe( - Effect.tapError(() => resolving?.fail() ?? Effect.void), - Effect.catch((cause) => reclassifyBranchNotFoundError(cause, errors.branchNotFound(target))), - ); - yield* resolving?.clear ?? Effect.void; - - // The resolved branch might not have a project ref yet (still provisioning); don't - // let an empty ref reach the config-read call. - 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, - }), - ); - 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"); - const resolved = yield* resolver.resolveRef(requested); - ref = resolved.ref; - linked = linkedTarget(resolved); - } - - yield* Effect.annotateCurrentSpan("project.ref", ref); - return { ref, branch, linked } satisfies ResolvedConfigTarget; -}); diff --git a/apps/cli/src/command-internal/project-target.unit.test.ts b/apps/cli/src/command-internal/project-target.unit.test.ts deleted file mode 100644 index 74db5949b4..0000000000 --- a/apps/cli/src/command-internal/project-target.unit.test.ts +++ /dev/null @@ -1,88 +0,0 @@ -/** - * Unit tests for the `mintConfigTargetErrors` factory and `configTargetErrorsFor` - * builder, independent of any family's own `*.errors.ts` file. - */ - -import { describe, expect, it } from "vitest"; - -import { actionability, ErrorActionabilityId } from "../shared/telemetry/error-actionability.ts"; -import { configTargetErrorsFor, mintConfigTargetErrors } from "./project-target.ts"; - -const PREFIX = "ConfigTestMint"; - -describe("mintConfigTargetErrors", () => { - const classes = mintConfigTargetErrors(PREFIX); - - it("tags BranchNotFoundError as `${prefix}BranchNotFoundError`", () => { - expect(new classes.BranchNotFoundError({ message: "x" })._tag).toBe( - "ConfigTestMintBranchNotFoundError", - ); - }); - - it("tags BranchNotLinkedError as `${prefix}BranchNotLinkedError`", () => { - expect(new classes.BranchNotLinkedError({ message: "x" })._tag).toBe( - "ConfigTestMintBranchNotLinkedError", - ); - }); - - it("tags ParentRefInvalidError as `${prefix}ParentRefInvalidError`", () => { - expect(new classes.ParentRefInvalidError({ message: "x" })._tag).toBe( - "ConfigTestMintParentRefInvalidError", - ); - }); - - it("tags BranchNotReadyError as `${prefix}BranchNotReadyError`", () => { - expect(new classes.BranchNotReadyError({ message: "x" })._tag).toBe( - "ConfigTestMintBranchNotReadyError", - ); - }); - - it("declares BranchNotFoundError as actionability.invalidInput", () => { - const instance = new classes.BranchNotFoundError({ message: "x" }); - expect(instance[ErrorActionabilityId]).toEqual(actionability.invalidInput); - }); - - it("declares BranchNotLinkedError as actionability.projectNotLinked", () => { - const instance = new classes.BranchNotLinkedError({ message: "x" }); - expect(instance[ErrorActionabilityId]).toEqual(actionability.projectNotLinked); - }); - - it("declares ParentRefInvalidError as actionability.relinkProject", () => { - const instance = new classes.ParentRefInvalidError({ message: "x" }); - expect(instance[ErrorActionabilityId]).toEqual(actionability.relinkProject); - }); - - it("declares BranchNotReadyError as actionability.apiStatus with a branch_not_ready fingerprint suffix", () => { - const instance = new classes.BranchNotReadyError({ message: "x" }); - expect(instance[ErrorActionabilityId]).toEqual({ - ...actionability.apiStatus, - fingerprint_suffix: "branch_not_ready", - }); - }); - - it("mints genuinely per-call classes, not a cached/shared set, across different prefixes", () => { - const a = mintConfigTargetErrors("ConfigTestMintA"); - const b = mintConfigTargetErrors("ConfigTestMintB"); - const aTag = new a.BranchNotFoundError({ message: "x" })._tag; - const bTag = new b.BranchNotFoundError({ message: "x" })._tag; - expect(aTag).toBe("ConfigTestMintABranchNotFoundError"); - expect(bTag).toBe("ConfigTestMintBBranchNotFoundError"); - expect(aTag).not.toBe(bTag); - }); -}); - -describe("configTargetErrorsFor", () => { - it("wraps a minted class set into constructors that produce working, correctly tagged instances", () => { - const classes = mintConfigTargetErrors(PREFIX); - const errors = configTargetErrorsFor({ - notLinked: classes.BranchNotLinkedError, - parentRefInvalid: classes.ParentRefInvalidError, - branchNotFound: classes.BranchNotFoundError, - branchNotReady: classes.BranchNotReadyError, - }); - - const error = errors.branchNotFound("some-target"); - expect(error._tag).toBe("ConfigTestMintBranchNotFoundError"); - expect(error.message).toContain("some-target"); - }); -}); diff --git a/apps/cli/src/command-internal/target/branch-resolution.ts b/apps/cli/src/command-internal/target/branch-resolution.ts index 9dc72139f5..8e15597807 100644 --- a/apps/cli/src/command-internal/target/branch-resolution.ts +++ b/apps/cli/src/command-internal/target/branch-resolution.ts @@ -12,7 +12,9 @@ import { parentRefInvalidMessage, parentRefTypoHint, resolveLinkedParentRef, + type ParentRefResolution, } from "../parent-project-ref.ts"; +import { BRANCH_UUID_PATTERN } from "../ref-patterns.ts"; import { LinkBranchListNetworkError, LinkBranchListStatusError, @@ -27,22 +29,25 @@ export type ParentBranch = Branches[number]; const MAX_LISTED_BRANCHES = 20; -const BRANCH_LOOKUP_CONTEXT = - "the value is neither a 20-letter project ref nor a database URL (a branch name or UUID), or a bare --remote is followed by a word that could name a branch"; - -/** The branch listing every branch-name lookup makes against the linked project. */ +/** The branch lookups every `--remote` value that names a branch makes. */ export const branchResolution = block("branch-resolution", [ { operationId: "v1-list-all-branches", kind: "required", - when: [{ flag: "remote", present: true }], - context: BRANCH_LOOKUP_CONTEXT, + when: [{ selector: ["branch"] }], + context: "a project is linked, or the value is a branch name", }, { - operationId: "v1-list-all-branches", + operationId: "v1-get-a-branch-config", kind: "required", - when: [{ flag: "project-ref", present: true }], - context: BRANCH_LOOKUP_CONTEXT, + when: [{ selector: ["branch"] }], + context: "the value is a branch UUID and no project is linked", + }, + { + operationId: "v1-list-all-branches", + kind: "best-effort", + when: [{ selector: ["bare"] }], + context: "a word after the bare --remote could name a branch of the linked project", }, ]); @@ -88,16 +93,13 @@ export const findBranch = (branches: Branches, value: string): ParentBranch | un (branch) => branch.name === value || branch.id.toLowerCase() === value.toLowerCase(), ); -/** The linked parent project's ref, searched for a branch named `value`. */ -const resolveBranchParentRef = Effect.fnUntraced(function* (value: string) { - const parent = yield* resolveLinkedParentRef(); - if (parent.kind === "absent") { - return yield* new LinkBranchNotLinkedError({ message: parentNotLinkedMessage(value) }); - } - if (parent.kind === "invalid") { - return yield* new LinkParentRefInvalidError({ message: parentRefInvalidMessage(value) }); - } - return parent.ref; +const unresolvedParent = Effect.fnUntraced(function* ( + parent: Exclude<ParentRefResolution, { readonly kind: "resolved" }>, + value: string, +) { + return parent.kind === "absent" + ? yield* new LinkBranchNotLinkedError({ message: parentNotLinkedMessage(value) }) + : yield* new LinkParentRefInvalidError({ message: parentRefInvalidMessage(value) }); }); /** The eager client when the command built one, else the lazy factory's, so a bare run never resolves a token. */ @@ -114,7 +116,7 @@ const acquireApi = Effect.fnUntraced(function* () { }); /** A branch-lookup spinner, shown only on a terminal: a non-terminal text run writes task steps to stdout, which may carry the command's payload. */ -export const startBranchLookupTask = Effect.fnUntraced(function* (label: string) { +const startBranchLookupTask = Effect.fnUntraced(function* (label: string) { const output = yield* Output; return output.format === "text" && output.interactive ? yield* output.task(label) : undefined; }); @@ -142,16 +144,7 @@ export const listParentBranches = Effect.fnUntraced(function* (parentRef: string return branches; }); -/** - * Resolves a non-ref-shaped value to the branch's project ref by looking it up (by name or UUID) - * against the linked parent project's branches. A ref-shaped value (20 lowercase letters) is - * always a ref and never looked up as a branch name. - * - * Uses the list endpoint rather than a single-branch lookup so the full list can power the - * available-branches error. - */ -export const resolveLinkBranchRef = Effect.fn("Target.resolveBranch")(function* (value: string) { - const parentRef = yield* resolveBranchParentRef(value); +const resolveInParent = Effect.fnUntraced(function* (value: string, parentRef: string) { const branches = yield* listParentBranches(parentRef, "Resolving branch..."); yield* Effect.annotateCurrentSpan({ "project.parent_ref": parentRef, @@ -179,3 +172,59 @@ export const resolveLinkBranchRef = Effect.fn("Target.resolveBranch")(function* parentRef, } satisfies BranchResolution; }); + +/** + * Resolves a non-ref-shaped value to the branch's project ref by looking it up (by name or UUID) + * against the linked parent project's branches. A ref-shaped value (20 lowercase letters) is + * always a ref and never looked up as a branch name. + * + * Uses the list endpoint rather than a single-branch lookup so the full list can power the + * available-branches error. + */ +export const resolveLinkBranchRef = Effect.fn("Target.resolveBranch")(function* (value: string) { + const parent = yield* resolveLinkedParentRef(); + if (parent.kind !== "resolved") return yield* unresolvedParent(parent, value); + return yield* resolveInParent(value, parent.ref); +}); + +const resolveBranchById = Effect.fnUntraced(function* (id: string) { + const api = yield* acquireApi(); + const mapBranchGetError = mapHttpError({ + networkError: LinkBranchListNetworkError, + statusError: LinkBranchListStatusError, + networkMessage: (cause) => `failed to look up branch: ${cause}`, + statusMessage: (status, body) => + status === 404 + ? `Branch "${sanitizeInlineName(id)}" not found. Run \`supabase branches list\` to see available branches.` + : `unexpected get branch status ${status}: ${body}`, + }); + + const task = yield* startBranchLookupTask("Resolving branch..."); + const detail = yield* api.v1.getABranchConfig({ branch_id_or_ref: id }).pipe( + Effect.tapError(() => task?.fail() ?? Effect.void), + Effect.catch(mapBranchGetError), + ); + yield* task?.clear ?? Effect.void; + + if (!PROJECT_REF_PATTERN.test(detail.ref)) { + return yield* new LinkBranchNotReadyError({ + branch: id, + status: detail.status, + message: `Branch "${sanitizeInlineName(id)}" has no project ref yet (status: ${detail.status}). Wait for it to finish provisioning, then retry.`, + }); + } + return { projectRef: detail.ref, branchName: undefined, parentRef: undefined }; +}); + +/** + * Resolves the branch a `--remote` value names: against the linked project's branches when a + * project is linked, and a UUID with no usable link through `GET /v1/branches/{id}`. + */ +export const resolveRemoteBranchRef = Effect.fn("Target.resolveRemoteBranch")(function* ( + value: string, +) { + const parent = yield* resolveLinkedParentRef(); + if (parent.kind === "resolved") return yield* resolveInParent(value, parent.ref); + if (BRANCH_UUID_PATTERN.test(value)) return yield* resolveBranchById(value); + return yield* unresolvedParent(parent, value); +}); diff --git a/apps/cli/src/command-internal/target/remote-resolution.ts b/apps/cli/src/command-internal/target/remote-resolution.ts index d9c3f56758..ff6770f941 100644 --- a/apps/cli/src/command-internal/target/remote-resolution.ts +++ b/apps/cli/src/command-internal/target/remote-resolution.ts @@ -1,24 +1,32 @@ -import { Effect, Option } from "effect"; +import { Effect, FileSystem, Option, Path, Result } from "effect"; import { CommandSettings } from "../../config/command-settings.service.ts"; +import { ProjectRefResolver, type ResolvedProjectRef } from "../../config/project-ref.service.ts"; import { CliArgs } from "../../shared/cli/cli-args.service.ts"; -import { bareRemoteOperand } from "../../shared/cli/remote-argv.ts"; +import { readProjectRefFile } from "../../shared/config/temp-paths.ts"; import { CommandTarget } from "../../shared/output/command-target.service.ts"; import { Output } from "../../shared/output/output.service.ts"; +import { + CommandRuntime, + getCommandRuntimeCommand, +} from "../../shared/runtime/command-runtime.service.ts"; import { yellow } from "../colors.ts"; import { resolveLinkedParentRef } from "../parent-project-ref.ts"; -import { - findBranch, - listParentBranches, - resolveLinkBranchRef, - type BranchResolution, -} from "./branch-resolution.ts"; +import { findBranch, listParentBranches, resolveRemoteBranchRef } from "./branch-resolution.ts"; import type { RemoteValue } from "./remote-value.ts"; +import type { LinkedTarget } from "./resolve-target.ts"; import { deprecationMessages, remoteFlagText } from "./target-deprecation.ts"; import { renderBranchTarget } from "./target-identity.ts"; -import type { TargetDeprecation } from "./target-selector.ts"; +import { + parseTargetSelector, + type TargetDeprecation, + type TargetSelectorInput, +} from "./target-selector.ts"; import { TargetRemoteAmbiguousError } from "./target.errors.ts"; +/** A `--remote` value that names a hosted project, as opposed to a database URL. */ +export type HostedRemote = Exclude<RemoteValue, { readonly _tag: "DbUrl" }>; + /** Prints a target warning on stderr in every output format, since stdout may carry the payload. */ export const warnTarget = Effect.fnUntraced(function* (message: string) { const output = yield* Output; @@ -40,18 +48,23 @@ export const warnTargetDeprecations = Effect.fnUntraced(function* ( }); /** - * Fails a bare `--remote` whose next word is, or may be, a branch of the linked project, before - * the command prompts, connects or writes. + * Parses the selector flags, prints the deprecation warnings, and names the command for the + * errors that need it. */ -export const rejectAmbiguousRemote = Effect.fn("Target.checkRemoteOperand")(function* ( - remote: Option.Option<RemoteValue>, -) { - if (!Option.exists(remote, (value) => value._tag === "Linked")) return; - const cliArgs = yield* Effect.serviceOption(CliArgs); - const operand = Option.isSome(cliArgs) - ? bareRemoteOperand(cliArgs.value.args, cliArgs.value.remoteLeaf) - : undefined; - if (operand === undefined) return; +export const selectTarget = Effect.fnUntraced(function* (flags: TargetSelectorInput) { + const runtime = yield* Effect.serviceOption(CommandRuntime); + const commandName = Option.isSome(runtime) + ? `\`supabase ${getCommandRuntimeCommand(runtime.value)}\`` + : "This command"; + const { selection, deprecations } = yield* Result.match( + parseTargetSelector(flags, { commandName }), + { onFailure: Effect.fail, onSuccess: Effect.succeed }, + ); + yield* warnTargetDeprecations(deprecations); + return { selection, commandName }; +}); + +const checkRemoteOperand = Effect.fn("Target.checkRemoteOperand")(function* (operand: string) { const parent = yield* resolveLinkedParentRef(); if (parent.kind !== "resolved") return; @@ -71,18 +84,72 @@ export const rejectAmbiguousRemote = Effect.fn("Target.checkRemoteOperand")(func } }); -/** Resolves a branch name or UUID to its project ref and announces it as the run's target. */ -export const resolveRemoteBranch = Effect.fnUntraced(function* (name: string) { - const found: BranchResolution = yield* resolveLinkBranchRef(name); +/** + * Fails a bare `--remote` whose next word is, or may be, a branch of the linked project, before + * the command prompts, connects or writes. + */ +const rejectAmbiguousRemote = Effect.fnUntraced(function* () { + const cliArgs = yield* Effect.serviceOption(CliArgs); + const operand = Option.getOrUndefined(cliArgs)?.bareRemoteOperand; + if (operand !== undefined) yield* checkRemoteOperand(operand); +}); + +/** The linked target for a ref the ref chain has already resolved and announced. */ +const linkedTarget = (resolved: ResolvedProjectRef): LinkedTarget => ({ + kind: "linked", + ref: resolved.ref, + source: resolved.source, + matchesLinkedRef: resolved.matchesLinkedRef, +}); + +const resolveRemoteBranch = Effect.fnUntraced(function* (name: string) { + const found = yield* resolveRemoteBranchRef(name); const cell = yield* Effect.serviceOption(CommandTarget); if (Option.isSome(cell)) { yield* cell.value.announce( renderBranchTarget({ - branch: Option.some(found.branchName), + branch: Option.fromNullishOr(found.branchName), ref: found.projectRef, - parentRef: Option.some(found.parentRef), + parentRef: Option.fromNullishOr(found.parentRef), }), ); } return found; }); + +/** The linked target for one hosted `--remote` value; announces it, as a branch when the value names one. */ +export const resolveRemoteTarget = Effect.fn("Target.resolveRemote")(function* ( + remote: HostedRemote, +) { + if (remote._tag === "Branch") { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const { workdir } = yield* CommandSettings; + const found = yield* resolveRemoteBranch(remote.name); + const linkedRef = yield* readProjectRefFile(fs, path, workdir).pipe( + Effect.orElseSucceed(() => Option.none<string>()), + ); + return { + ...linkedTarget({ + ref: found.projectRef, + source: "flag", + matchesLinkedRef: Option.contains(linkedRef, found.projectRef), + }), + branch: remote.name, + ...(found.parentRef === undefined ? {} : { parentRef: found.parentRef }), + } satisfies LinkedTarget; + } + const projectRef = yield* ProjectRefResolver; + return linkedTarget( + yield* projectRef.resolveRef(remote._tag === "Ref" ? Option.some(remote.ref) : Option.none()), + ); +}); + +/** + * Resolves a hosted selection to its project: a bare `--remote` first checks that the word after + * it does not name a branch, then the value's own lookup runs. + */ +export const resolveHostedRemote = Effect.fnUntraced(function* (remote: HostedRemote) { + if (remote._tag === "Linked") yield* rejectAmbiguousRemote(); + return yield* resolveRemoteTarget(remote); +}); diff --git a/apps/cli/src/command-internal/target/resolve-project-target.integration.test.ts b/apps/cli/src/command-internal/target/resolve-project-target.integration.test.ts index fe37f351a5..00915cffc5 100644 --- a/apps/cli/src/command-internal/target/resolve-project-target.integration.test.ts +++ b/apps/cli/src/command-internal/target/resolve-project-target.integration.test.ts @@ -11,11 +11,14 @@ import { stripAnsi } from "../../../tests/helpers/ansi.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 { rootCommandForFeatures } from "../../cli/root.ts"; import { CliArgs } from "../../shared/cli/cli-args.service.ts"; +import { prepareCliArgs } from "../../shared/cli/run.ts"; import { commandTargetLayer } from "../../shared/output/command-target.layer.ts"; import { commandRuntimeLayer } from "../../shared/runtime/command-runtime.layer.ts"; import type { RemoteValue } from "./remote-value.ts"; import { resolveProjectTarget } from "./resolve-project-target.ts"; +import type { ProjectTargetFlagValues } from "./target-flags.ts"; const FILE_REF = "filefilefilefilefile"; const FLAG_REF = "flagflagflagflagflag"; @@ -34,15 +37,38 @@ const branchRow = (name: string) => ({ with_data: true, }); -const run = ( - input: { readonly remote?: RemoteValue; readonly projectRef?: string }, - options: { - readonly linked?: boolean; - readonly savedRef?: string; - readonly args?: ReadonlyArray<string>; - readonly branches?: ReadonlyArray<ReturnType<typeof branchRow>>; - readonly interactive?: boolean; - } = {}, +const BRANCH_UUID = "11111111-2222-4333-8444-555555555555"; + +const branchConfig = (ref: string, status: string) => ({ + ref, + status, + postgres_version: "15", + postgres_engine: "15", + release_channel: "ga", + db_host: "h", + db_port: 5432, +}); + +const root = rootCommandForFeatures(); + +interface RunOptions { + readonly linked?: boolean; + readonly savedRef?: string; + readonly args?: ReadonlyArray<string>; + readonly branches?: ReadonlyArray<ReturnType<typeof branchRow>>; + readonly branchById?: { readonly status: number; readonly body: unknown }; + readonly interactive?: boolean; +} + +interface RunInput { + readonly remote?: RemoteValue; + readonly projectRef?: string; +} + +const runWith = <A, E, R>( + input: RunInput, + options: RunOptions, + resolve: (flags: ProjectTargetFlagValues) => Effect.Effect<A, E, R>, ) => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; @@ -77,9 +103,15 @@ const run = ( options.interactive === undefined ? {} : { interactive: options.interactive }, ); const api = mockCommandPlatformApi({ - handler: (request) => Effect.succeed(jsonResponse(request, 200, options.branches ?? [])), + handler: (request) => { + if (request.url.includes("/v1/branches/")) { + const byId = options.branchById ?? { status: 404, body: { message: "not found" } }; + return Effect.succeed(jsonResponse(request, byId.status, byId.body)); + } + return Effect.succeed(jsonResponse(request, 200, options.branches ?? [])); + }, }); - const exit = yield* resolveProjectTarget({ + const exit = yield* resolve({ remote: Option.fromNullishOr(input.remote), projectRef: Option.fromNullishOr(input.projectRef), }).pipe( @@ -91,7 +123,9 @@ const run = ( api.layer, mockCommandSettings({ workdir: dir, projectId: Option.none() }), commandRuntimeLayer(["functions", "deploy"]).pipe(Layer.provide(BunServices.layer)), - options.args === undefined ? Layer.empty : Layer.succeed(CliArgs, { args: options.args }), + options.args === undefined + ? Layer.empty + : Layer.succeed(CliArgs, prepareCliArgs(root, options.args)), ), ), Effect.exit, @@ -108,14 +142,23 @@ const run = ( progressEvents: out.progressEvents, refFlags, branchRequests: api.requests.length, + requestedUrls: api.requests.map((request) => request.url), }; }).pipe(Effect.scoped, Effect.provide(BunServices.layer)); -const refOf = (exit: Exit.Exit<string, unknown>) => { +const run = (input: RunInput, options: RunOptions = {}) => + runWith(input, options, (flags) => resolveProjectTarget(flags)); + +const runDetailed = (input: RunInput, options: RunOptions = {}) => + runWith(input, options, (flags) => resolveProjectTarget(flags, { detail: true })); + +const successOf = <A>(exit: Exit.Exit<A, unknown>): A => { if (!Exit.isSuccess(exit)) throw new Error(`expected success, got ${Cause.pretty(exit.cause)}`); return exit.value; }; +const refOf = (exit: Exit.Exit<string, unknown>) => successOf(exit); + const failureOf = (exit: Exit.Exit<unknown, unknown>) => Exit.isFailure(exit) ? Cause.squash(exit.cause) : undefined; @@ -241,4 +284,129 @@ describe("resolveProjectTarget", () => { expect(failureOf(result.exit)).toMatchObject({ _tag: "ProjectRefNotLinkedError" }); }), ); + + it.effect( + "reads the branch list once, matching a branch UUID by id under the linked project", + () => + Effect.gen(function* () { + const result = yield* run( + { remote: { _tag: "Branch", name: BRANCH_UUID } }, + { savedRef: FILE_REF, branches: [{ ...branchRow("feature-x"), id: BRANCH_UUID }] }, + ); + expect(refOf(result.exit)).toBe(BRANCH_REF); + expect(result.requestedUrls).toEqual([ + `https://api.supabase.com/v1/projects/${FILE_REF}/branches`, + ]); + }), + ); + + it.effect("resolves a branch UUID through GET /v1/branches/{id} when no project is linked", () => + Effect.gen(function* () { + const result = yield* run( + { remote: { _tag: "Branch", name: BRANCH_UUID } }, + { branchById: { status: 200, body: branchConfig(BRANCH_REF, "ACTIVE_HEALTHY") } }, + ); + expect(refOf(result.exit)).toBe(BRANCH_REF); + expect(result.requestedUrls).toEqual([`https://api.supabase.com/v1/branches/${BRANCH_UUID}`]); + expect(result.stderr).toContain(`branch ${BRANCH_REF}`); + }), + ); + + it.effect("reports an unknown branch UUID without a linked project", () => + Effect.gen(function* () { + const result = yield* run( + { remote: { _tag: "Branch", name: BRANCH_UUID } }, + { branchById: { status: 404, body: { message: "not found" } } }, + ); + expect(failureOf(result.exit)).toMatchObject({ + _tag: "LinkBranchListStatusError", + message: expect.stringContaining(`Branch "${BRANCH_UUID}" not found`), + }); + }), + ); + + it.effect("reports a branch UUID whose project is still provisioning", () => + Effect.gen(function* () { + const result = yield* run( + { remote: { _tag: "Branch", name: BRANCH_UUID } }, + { branchById: { status: 200, body: branchConfig("", "CREATING_PROJECT") } }, + ); + expect(failureOf(result.exit)).toMatchObject({ + _tag: "LinkBranchNotReadyError", + message: expect.stringContaining("has no project ref yet"), + }); + }), + ); + + it.effect("asks for a linked project before looking a branch name up", () => + Effect.gen(function* () { + const result = yield* run({ remote: { _tag: "Branch", name: "feature-x" } }); + expect(failureOf(result.exit)).toMatchObject({ _tag: "LinkBranchNotLinkedError" }); + expect(result.branchRequests).toBe(0); + }), + ); +}); + +describe("resolveProjectTarget with detail", () => { + it.effect("carries the source and linked-ref match the ref chain resolved", () => + Effect.gen(function* () { + const flag = successOf((yield* runDetailed({ remote: { _tag: "Ref", ref: FLAG_REF } })).exit); + expect(flag).toEqual({ + kind: "linked", + ref: FLAG_REF, + source: "flag", + matchesLinkedRef: false, + }); + const file = successOf((yield* runDetailed({})).exit); + expect(file).toEqual({ + kind: "linked", + ref: FILE_REF, + source: "file", + matchesLinkedRef: true, + }); + }), + ); + + it.effect("names the branch and the project it was found under", () => + Effect.gen(function* () { + const result = yield* runDetailed( + { remote: { _tag: "Branch", name: "feature-x" } }, + { savedRef: FILE_REF, branches: [branchRow("feature-x")] }, + ); + expect(successOf(result.exit)).toEqual({ + kind: "linked", + ref: BRANCH_REF, + source: "flag", + matchesLinkedRef: false, + branch: "feature-x", + parentRef: FILE_REF, + }); + }), + ); + + it.effect("matches the linked ref when the branch's own ref is the saved one", () => + Effect.gen(function* () { + const result = yield* runDetailed( + { remote: { _tag: "Branch", name: "feature-x" } }, + { savedRef: BRANCH_REF, branches: [branchRow("feature-x")] }, + ); + expect(successOf(result.exit)).toMatchObject({ ref: BRANCH_REF, matchesLinkedRef: true }); + }), + ); + + it.effect("names a branch resolved from a UUID by the UUID, with no parent", () => + Effect.gen(function* () { + const result = yield* runDetailed( + { remote: { _tag: "Branch", name: BRANCH_UUID } }, + { branchById: { status: 200, body: branchConfig(BRANCH_REF, "ACTIVE_HEALTHY") } }, + ); + expect(successOf(result.exit)).toEqual({ + kind: "linked", + ref: BRANCH_REF, + source: "flag", + matchesLinkedRef: false, + branch: BRANCH_UUID, + }); + }), + ); }); diff --git a/apps/cli/src/command-internal/target/resolve-project-target.ts b/apps/cli/src/command-internal/target/resolve-project-target.ts index 7fe176e630..86e58e4465 100644 --- a/apps/cli/src/command-internal/target/resolve-project-target.ts +++ b/apps/cli/src/command-internal/target/resolve-project-target.ts @@ -1,102 +1,66 @@ -import { Effect, Option, Result } from "effect"; +import { Effect, Option } from "effect"; -import { - CommandRuntime, - getCommandRuntimeCommand, -} from "../../shared/runtime/command-runtime.service.ts"; import { resolveParentScopedProjectRef } from "../parent-project-ref.ts"; -import { resolveRemoteTarget } from "./resolve-target.ts"; -import { rejectAmbiguousRemote, warnTargetDeprecations } from "./remote-resolution.ts"; -import type { RemoteValue } from "./remote-value.ts"; -import { parseTargetSelector } from "./target-selector.ts"; +import { resolveHostedRemote, selectTarget } from "./remote-resolution.ts"; +import type { ProjectTargetFlagValues } from "./target-flags.ts"; import { TargetNeedsProjectError } from "./target.errors.ts"; -export interface ProjectTargetInput { - readonly remote: Option.Option<RemoteValue>; - readonly projectRef: Option.Option<string>; -} - interface ParentProjectTargetOptions { /** A command that acts on a parent project, such as `branches *`: its `--remote` takes a ref, never a branch name. */ readonly parent: true; } -interface RequestedProjectTargetOptions<A, E, R> { - /** Resolves the ref or branch name `--remote` names with the caller's own lookup, for a command with no operands. */ - readonly resolveRequested: (requested: Option.Option<string>) => Effect.Effect<A, E, R>; +interface DetailedProjectTargetOptions { + /** Returns the resolved linked target, with the branch it was found through, instead of just its ref. */ + readonly detail: true; } -const selectProject = Effect.fnUntraced(function* (input: ProjectTargetInput) { - const runtime = yield* Effect.serviceOption(CommandRuntime); - const commandName = Option.isSome(runtime) - ? `\`supabase ${getCommandRuntimeCommand(runtime.value)}\`` - : undefined; - const { selection, deprecations } = yield* Result.match( - parseTargetSelector( - { ...input, local: false, linked: false }, - commandName === undefined ? {} : { commandName }, - ), - { onFailure: Effect.fail, onSuccess: Effect.succeed }, - ); - yield* warnTargetDeprecations(deprecations); - return { selection, commandName: commandName ?? "This command" }; +const selectProject = Effect.fnUntraced(function* (flags: ProjectTargetFlagValues) { + return yield* selectTarget({ + remote: flags.remote, + projectRef: flags.projectRef, + local: false, + linked: false, + }); }); -const unreachableSelection = (explicit: "local" | "db-url") => - Effect.die(new Error(`a project-only selector cannot select "${explicit}"`)); +const unreachableSelection = (tag: "Local" | "DbUrl") => + Effect.die(new Error(`a project-only selector cannot select "${tag}"`)); -const resolveHosted = Effect.fn("Target.resolveProject")(function* (input: ProjectTargetInput) { - const { selection } = yield* selectProject(input); - switch (selection.explicit) { - case "branch": - return (yield* resolveRemoteTarget({ _tag: "Branch", name: selection.name })).ref; - case "linked": { - if (Option.isNone(selection.ref)) yield* rejectAmbiguousRemote(input.remote); - const remote: RemoteValue = Option.isSome(selection.ref) - ? { _tag: "Ref", ref: selection.ref.value } - : { _tag: "Linked" }; - return (yield* resolveRemoteTarget(remote)).ref; - } - case undefined: - return (yield* resolveRemoteTarget({ _tag: "Linked" })).ref; - case "local": - case "db-url": - return yield* unreachableSelection(selection.explicit); +const resolveHosted = Effect.fn("Target.resolveProject")(function* ( + flags: ProjectTargetFlagValues, +) { + const { selection } = yield* selectProject(flags); + switch (selection._tag) { + case "Local": + case "DbUrl": + return yield* unreachableSelection(selection._tag); + case "Default": + return yield* resolveHostedRemote({ _tag: "Linked" }); + case "Linked": + case "Ref": + case "Branch": + return yield* resolveHostedRemote(selection); } }); -const resolveParent = Effect.fn("Target.resolveProject")(function* (input: ProjectTargetInput) { - const { selection, commandName } = yield* selectProject(input); - switch (selection.explicit) { - case "branch": +const resolveParent = Effect.fn("Target.resolveProject")(function* ( + flags: ProjectTargetFlagValues, +) { + const { selection, commandName } = yield* selectProject(flags); + switch (selection._tag) { + case "Branch": return yield* new TargetNeedsProjectError({ message: `${commandName} acts on a parent project, so --remote takes its project ref, not a branch name. Use --remote=<ref>.`, }); - case "linked": - return yield* resolveParentScopedProjectRef(selection.ref); - case undefined: + case "Ref": + return yield* resolveParentScopedProjectRef(Option.some(selection.ref)); + case "Linked": + case "Default": return yield* resolveParentScopedProjectRef(Option.none()); - case "local": - case "db-url": - return yield* unreachableSelection(selection.explicit); - } -}); - -const resolveRequested = Effect.fn("Target.resolveProject")(function* <A, E, R>( - input: ProjectTargetInput, - resolve: (requested: Option.Option<string>) => Effect.Effect<A, E, R>, -) { - const { selection } = yield* selectProject(input); - switch (selection.explicit) { - case "branch": - return yield* resolve(Option.some(selection.name)); - case "linked": - return yield* resolve(selection.ref); - case undefined: - return yield* resolve(Option.none()); - case "local": - case "db-url": - return yield* unreachableSelection(selection.explicit); + case "Local": + case "DbUrl": + return yield* unreachableSelection(selection._tag); } }); @@ -105,25 +69,25 @@ const resolveRequested = Effect.fn("Target.resolveProject")(function* <A, E, R>( * bare `--remote` or no selector falls through to the project-ref chain (`SUPABASE_PROJECT_ID`, * the link file, the picker). */ -export function resolveProjectTarget(input: ProjectTargetInput): ReturnType<typeof resolveHosted>; export function resolveProjectTarget( - input: ProjectTargetInput, - options: ParentProjectTargetOptions, -): ReturnType<typeof resolveParent>; -export function resolveProjectTarget<A, E, R>( - input: ProjectTargetInput, - options: RequestedProjectTargetOptions<A, E, R>, + flags: ProjectTargetFlagValues, ): Effect.Effect< - A, - E | Effect.Error<ReturnType<typeof selectProject>>, - R | Effect.Services<ReturnType<typeof selectProject>> + string, + Effect.Error<ReturnType<typeof resolveHosted>>, + Effect.Services<ReturnType<typeof resolveHosted>> >; -export function resolveProjectTarget<A, E, R>( - input: ProjectTargetInput, - options?: ParentProjectTargetOptions | RequestedProjectTargetOptions<A, E, R>, +export function resolveProjectTarget( + flags: ProjectTargetFlagValues, + options: ParentProjectTargetOptions, +): ReturnType<typeof resolveParent>; +export function resolveProjectTarget( + flags: ProjectTargetFlagValues, + options: DetailedProjectTargetOptions, +): ReturnType<typeof resolveHosted>; +export function resolveProjectTarget( + flags: ProjectTargetFlagValues, + options?: ParentProjectTargetOptions | DetailedProjectTargetOptions, ) { - if (options === undefined) return resolveHosted(input); - return "resolveRequested" in options - ? resolveRequested(input, options.resolveRequested) - : resolveParent(input); + if (options === undefined) return Effect.map(resolveHosted(flags), (target) => target.ref); + return "parent" in options ? resolveParent(flags) : resolveHosted(flags); } 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 e8583d0f91..07ae831412 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 @@ -15,7 +15,7 @@ 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 { CliArgs } from "../../shared/cli/cli-args.service.ts"; -import { remoteArgvLeafFor, rewriteRemoteArgv } from "../../shared/cli/run.ts"; +import { prepareCliArgs } from "../../shared/cli/run.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"; @@ -24,9 +24,9 @@ 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"; +import type { TargetFlagValuesWithDbUrl } from "./target-flags.ts"; -const none: TargetSelectorInput = { +const none: TargetFlagValuesWithDbUrl = { remote: Option.none(), local: false, linked: false, @@ -34,7 +34,7 @@ const none: TargetSelectorInput = { projectRef: Option.none(), }; -const remote = (value: RemoteValue): Partial<TargetSelectorInput> => ({ +const remote = (value: RemoteValue): Partial<TargetFlagValuesWithDbUrl> => ({ remote: Option.some(value), }); @@ -59,11 +59,6 @@ const branchRow = (name: string, projectRef = BRANCH_REF) => ({ const root = rootCommandForFeatures(); -const cliArgsFor = (raw: ReadonlyArray<string>) => { - const args = rewriteRemoteArgv(root, raw); - return { args, remoteLeaf: remoteArgvLeafFor(root, args) }; -}; - const projectRefResolver = (options: { readonly linked: boolean }) => { const flags: Array<Option.Option<string>> = []; const layer = Layer.succeed(ProjectRefResolver, { @@ -89,14 +84,13 @@ const projectRefResolver = (options: { readonly linked: boolean }) => { const run = ( group: TargetGroup, - input: TargetSelectorInput, + input: TargetFlagValuesWithDbUrl, options: { readonly linked?: boolean; readonly projectId?: Option.Option<string>; readonly savedRef?: string; readonly cache?: { readonly ref: string; readonly name?: string }; readonly args?: ReadonlyArray<string>; - readonly cliArgs?: ReturnType<typeof cliArgsFor>; readonly format?: OutputFormat; readonly branches?: ReadonlyArray<ReturnType<typeof branchRow>> | "unavailable"; } = {}, @@ -149,11 +143,9 @@ const run = ( workdir: dir, projectId: options.projectId ?? Option.none(), }), - options.cliArgs !== undefined - ? Layer.succeed(CliArgs, options.cliArgs) - : options.args === undefined - ? Layer.empty - : Layer.succeed(CliArgs, { args: options.args }), + options.args === undefined + ? Layer.empty + : Layer.succeed(CliArgs, prepareCliArgs(root, options.args)), ), ), Effect.withTracer(tracer), @@ -265,6 +257,8 @@ describe("resolveTarget", () => { ref: BRANCH_REF, source: "flag", matchesLinkedRef: false, + branch: "feature-x", + parentRef: FILE_REF, }); expect(result.apiRequests).toEqual([`GET /v1/projects/${FILE_REF}/branches`]); expect(result.refFlags).toEqual([]); @@ -310,7 +304,7 @@ describe("resolveTarget", () => { }); describe("a bare --remote followed by a word", () => { - const args = ["db", "push", "--remote", "feature-x"]; + const args = ["db", "pull", "--remote", "feature-x"]; const bare = { ...none, ...remote({ _tag: "Linked" }) }; it.effect("fails when the word is a branch of the linked project", () => @@ -376,7 +370,7 @@ describe("resolveTarget", () => { ])("does not look up the $name subcommand as a branch", ({ raw }) => Effect.gen(function* () { const result = yield* run("selectable", bare, { - cliArgs: cliArgsFor(raw), + args: raw, savedRef: FILE_REF, branches: "unavailable", }); @@ -388,7 +382,7 @@ describe("resolveTarget", () => { it.effect("still fails for a branch the command would otherwise take as its operand", () => Effect.gen(function* () { const result = yield* run("selectable", bare, { - cliArgs: cliArgsFor(["db", "pull", "--remote", "feature-x"]), + args: ["db", "pull", "--remote", "feature-x"], savedRef: FILE_REF, branches: [branchRow("feature-x")], }); diff --git a/apps/cli/src/command-internal/target/resolve-target.ts b/apps/cli/src/command-internal/target/resolve-target.ts index 0f8c237a2d..229947d667 100644 --- a/apps/cli/src/command-internal/target/resolve-target.ts +++ b/apps/cli/src/command-internal/target/resolve-target.ts @@ -1,28 +1,15 @@ -import { Effect, FileSystem, Option, Path, Redacted, Result } from "effect"; +import { Effect, FileSystem, Option, Path, Redacted } from "effect"; import { CommandSettings } from "../../config/command-settings.service.ts"; -import { - PROJECT_REF_PATTERN, - ProjectRefResolver, - type ProjectRefSource, - type ResolvedProjectRef, -} from "../../config/project-ref.service.ts"; +import { PROJECT_REF_PATTERN, type ProjectRefSource } from "../../config/project-ref.service.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 { - CommandRuntime, - getCommandRuntimeCommand, -} from "../../shared/runtime/command-runtime.service.ts"; -import { - rejectAmbiguousRemote, - resolveRemoteBranch, - warnTargetDeprecations, -} from "./remote-resolution.ts"; -import type { RemoteValue } from "./remote-value.ts"; +import { resolveHostedRemote, selectTarget } from "./remote-resolution.ts"; +import type { TargetFlagValuesWithDbUrl, TargetFlagValuesWithoutDbUrl } from "./target-flags.ts"; import { linkedAlternativeHint } from "./target-identity.ts"; -import { parseTargetSelector, type TargetSelectorInput } from "./target-selector.ts"; import { TargetPolicy } from "./target-policy.ts"; +import type { TargetSelectorInput } from "./target-selector.ts"; export type ResolvedTarget = | { readonly kind: "local" } @@ -31,6 +18,10 @@ export type ResolvedTarget = readonly ref: string; readonly source: ProjectRefSource; readonly matchesLinkedRef: boolean; + /** The branch name or UUID `--remote` was given, when it named one. */ + readonly branch?: string | undefined; + /** The linked project the branch was found under, when it was looked up through one. */ + readonly parentRef?: string | undefined; } | { readonly kind: "db-url"; readonly url: Redacted.Redacted<string> }; @@ -38,60 +29,29 @@ export type LinkedTarget = Extract<ResolvedTarget, { readonly kind: "linked" }>; type SelectedBy = "flag" | "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, -}); - -/** The linked target for one hosted `--remote` value; announces it, as a branch when the value names one. */ -export const resolveRemoteTarget = Effect.fn("Target.resolveRemote")(function* ( - remote: Exclude<RemoteValue, { readonly _tag: "DbUrl" }>, -) { - if (remote._tag === "Branch") { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const { workdir } = yield* CommandSettings; - const found = yield* resolveRemoteBranch(remote.name); - const linkedRef = yield* readProjectRefFile(fs, path, workdir).pipe( - Effect.orElseSucceed(() => Option.none<string>()), - ); - return linkedTarget({ - ref: found.projectRef, - source: "flag", - matchesLinkedRef: Option.isSome(linkedRef) && linkedRef.value === found.projectRef, - }); - } - const projectRef = yield* ProjectRefResolver; - return linkedTarget( - yield* projectRef.resolveRef(remote._tag === "Ref" ? Option.some(remote.ref) : Option.none()), +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 })); }); -/** - * Resolves the target a `selectable` command acts on: `--remote` (or a deprecated alias) picks a - * linked project, a branch or a database URL, and no selector means the local stack. - */ -export const resolveTarget = Effect.fn("Target.resolve")(function* (input: TargetSelectorInput) { +const resolveSelected = Effect.fn("Target.resolve")(function* (flags: TargetSelectorInput) { const policy = yield* TargetPolicy; if (policy.group !== "selectable") { return yield* Effect.die( new Error(`resolveTarget needs a selectable command, not "${policy.group}"`), ); } - const runtime = yield* Effect.serviceOption(CommandRuntime); - const { selection, deprecations } = yield* Result.match( - parseTargetSelector( - input, - Option.isSome(runtime) - ? { commandName: `\`supabase ${getCommandRuntimeCommand(runtime.value)}\`` } - : {}, - ), - { onFailure: Effect.fail, onSuccess: Effect.succeed }, - ); - yield* warnTargetDeprecations(deprecations); + const { selection } = yield* selectTarget(flags); const finish = Effect.fnUntraced(function* (target: ResolvedTarget, selectedBy: SelectedBy) { yield* Effect.annotateCurrentSpan({ @@ -100,40 +60,31 @@ export const resolveTarget = Effect.fn("Target.resolve")(function* (input: Targe }); return target; }); - 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) { - case "local": + switch (selection._tag) { + case "Local": return yield* finish({ kind: "local" }, "flag"); - case "db-url": + case "DbUrl": return yield* finish({ kind: "db-url", url: Redacted.make(selection.url) }, "flag"); - case "linked": { - if (Option.isNone(selection.ref)) yield* rejectAmbiguousRemote(input.remote); - const remote: RemoteValue = Option.isSome(selection.ref) - ? { _tag: "Ref", ref: selection.ref.value } - : { _tag: "Linked" }; - return yield* finish(yield* resolveRemoteTarget(remote), "flag"); - } - case "branch": - return yield* finish( - yield* resolveRemoteTarget({ _tag: "Branch", name: selection.name }), - "flag", - ); - case undefined: + case "Default": yield* hintLinkedAlternative(); return yield* finish({ kind: "local" }, "default"); + case "Linked": + case "Ref": + case "Branch": + return yield* finish(yield* resolveHostedRemote(selection), "flag"); } }); + +/** + * Resolves the target a `selectable` command acts on: `--remote` (or a deprecated alias) picks a + * linked project, a branch or a database URL, and no selector means the local stack. A command + * whose flags came from `targetFlags({ dbUrl: false })` cannot pass a database URL here. + */ +export function resolveTarget(flags: TargetFlagValuesWithDbUrl): ReturnType<typeof resolveSelected>; +export function resolveTarget( + flags: TargetFlagValuesWithoutDbUrl, +): ReturnType<typeof resolveSelected>; +export function resolveTarget(flags: TargetSelectorInput) { + return resolveSelected(flags); +} diff --git a/apps/cli/src/command-internal/target/target-flags.ts b/apps/cli/src/command-internal/target/target-flags.ts index b2e5194d20..bcbf3f4b57 100644 --- a/apps/cli/src/command-internal/target/target-flags.ts +++ b/apps/cli/src/command-internal/target/target-flags.ts @@ -1,13 +1,10 @@ -import { Effect, Option } from "effect"; +import { Effect, Option, Redacted } from "effect"; import { Flag, GlobalFlag, Param, Primitive } from "effect/cli"; -import { - classifyRemoteValue, - LEAVE_OUT_REMOTE_PREFIX, - type RemoteValue, -} from "./remote-value.ts"; +import { classifyRemoteValue, LEAVE_OUT_REMOTE_PREFIX, type RemoteValue } from "./remote-value.ts"; import { REMOTE_FLAG_NAME } from "./remote-vocabulary.ts"; import type { ResolvedTarget } from "./resolve-target.ts"; +import { remoteFlagText } from "./target-deprecation.ts"; type TargetSubject = "database" | "Storage API"; @@ -15,9 +12,25 @@ interface TargetFlagOptions { readonly subject: TargetSubject; } -type RemoteFlagVariant = - | { readonly kind: "selectable"; readonly subject: TargetSubject; readonly dbUrl: boolean } - | { readonly kind: "hosted" }; +interface SelectableRemoteFlagVariant<DbUrl extends boolean> { + readonly kind: "selectable"; + readonly subject: TargetSubject; + readonly dbUrl: DbUrl; +} + +interface HostedRemoteFlagVariant { + readonly kind: "hosted"; +} + +type RemoteFlagVariant = SelectableRemoteFlagVariant<boolean> | HostedRemoteFlagVariant; + +declare const urlAcceptance: unique symbol; + +/** A `--remote` value on a command that takes a database URL; the brand exists only in the type. */ +type RemoteValueWithUrl = RemoteValue & { readonly [urlAcceptance]?: "accepted" }; + +/** A `--remote` value on a command with no database connection; the brand exists only in the type. */ +type RemoteValueWithoutUrl = RemoteValue & { readonly [urlAcceptance]?: "rejected" }; const remoteDescription = (variant: RemoteFlagVariant): string => { if (variant.kind === "hosted") { @@ -53,12 +66,20 @@ export const isRemoteFlag = (single: Param.Single<Param.ParamKind, unknown>): bo REMOTE_PRIMITIVE in single.primitiveType; /** `--remote`: the linked project when bare, else the project, branch or database named by its value. */ -const remoteFlag = (variant: RemoteFlagVariant) => - Param.makeSingle({ +function remoteFlag( + variant: SelectableRemoteFlagVariant<true>, +): Flag.Flag<Option.Option<RemoteValueWithUrl>>; +function remoteFlag( + variant: SelectableRemoteFlagVariant<false>, +): Flag.Flag<Option.Option<RemoteValueWithoutUrl>>; +function remoteFlag(variant: HostedRemoteFlagVariant): Flag.Flag<Option.Option<RemoteValue>>; +function remoteFlag(variant: RemoteFlagVariant): Flag.Flag<Option.Option<RemoteValue>> { + return Param.makeSingle({ kind: Param.flagKind, name: REMOTE_FLAG_NAME, primitiveType: remotePrimitive(variant), }).pipe(Flag.withDescription(remoteDescription(variant)), Flag.optional); +} const localFlag = () => Flag.Boolean("local").pipe( @@ -91,14 +112,45 @@ const projectRefFlag = () => ); export type TargetFlagsWithDbUrl = { - readonly remote: Flag.Flag<Option.Option<RemoteValue>>; + readonly remote: Flag.Flag<Option.Option<RemoteValueWithUrl>>; readonly local: Flag.Flag<boolean>; readonly linked: Flag.Flag<boolean>; readonly dbUrl: Flag.Flag<Option.Option<string>>; readonly projectRef: Flag.Flag<Option.Option<string>>; }; -export type TargetFlagsWithoutDbUrl = Omit<TargetFlagsWithDbUrl, "dbUrl">; +export type TargetFlagsWithoutDbUrl = { + readonly remote: Flag.Flag<Option.Option<RemoteValueWithoutUrl>>; + readonly local: Flag.Flag<boolean>; + readonly linked: Flag.Flag<boolean>; + readonly projectRef: Flag.Flag<Option.Option<string>>; +}; + +/** The decoded flags every hosted command's selector is read from. */ +export interface ProjectTargetFlagValues { + readonly remote: Option.Option<RemoteValue>; + readonly projectRef: Option.Option<string>; +} + +/** The decoded selector flags of a command that can also act on the local stack. */ +export interface TargetFlagValues extends ProjectTargetFlagValues { + readonly local: boolean; + readonly linked: boolean; + /** `gen types` only: the deprecated alias of `--project-ref`. */ + readonly projectId?: Option.Option<string>; +} + +/** The decoded flags of a command whose `targetFlags` declared `dbUrl: true`. */ +export interface TargetFlagValuesWithDbUrl extends TargetFlagValues { + readonly remote: Option.Option<RemoteValueWithUrl>; + readonly dbUrl: Option.Option<string>; +} + +/** The decoded flags of a command whose `targetFlags` declared `dbUrl: false`. */ +export interface TargetFlagValuesWithoutDbUrl extends TargetFlagValues { + readonly remote: Option.Option<RemoteValueWithoutUrl>; + readonly dbUrl?: undefined; +} /** The only declaration of `--remote` and its deprecated `--local`, `--linked`, `--db-url` and `--project-ref` aliases for selectable commands. */ export function targetFlags( @@ -110,13 +162,17 @@ export function targetFlags( export function targetFlags( options: TargetFlagOptions & { readonly dbUrl: boolean }, ): TargetFlagsWithDbUrl | TargetFlagsWithoutDbUrl { - const base = { - remote: remoteFlag({ kind: "selectable", subject: options.subject, dbUrl: options.dbUrl }), - local: localFlag(), - linked: linkedFlag(), - projectRef: projectRefFlag(), - }; - return options.dbUrl ? { ...base, dbUrl: dbUrlFlag() } : base; + const base = { local: localFlag(), linked: linkedFlag(), projectRef: projectRefFlag() }; + return options.dbUrl + ? { + ...base, + remote: remoteFlag({ kind: "selectable", subject: options.subject, dbUrl: true }), + dbUrl: dbUrlFlag(), + } + : { + ...base, + remote: remoteFlag({ kind: "selectable", subject: options.subject, dbUrl: false }), + }; } /** The flags of a hosted-only command: `--remote=<ref|branch>` and the deprecated `--project-ref` alias. */ @@ -125,18 +181,23 @@ export const projectTargetFlags = () => ({ projectRef: projectRefFlag(), }); -/** The flags that point a suggested follow-up command at `target`; a database URL is never echoed. */ -export const flagsForTarget = (target: ResolvedTarget): string => { +const remoteValueOfTarget = (target: ResolvedTarget): RemoteValue | undefined => { switch (target.kind) { case "local": - return ""; + return undefined; case "db-url": - return "--remote=<db-url>"; + return { _tag: "DbUrl", url: Redacted.value(target.url) }; case "linked": - return target.matchesLinkedRef ? "--remote" : `--remote=${target.ref}`; + return target.matchesLinkedRef ? { _tag: "Linked" } : { _tag: "Ref", ref: target.ref }; } }; +/** The flags that point a suggested follow-up command at `target`; a database URL is never echoed. */ +export const flagsForTarget = (target: ResolvedTarget): string => { + const value = remoteValueOfTarget(target); + return value === undefined ? "" : remoteFlagText(value); +}; + /** * `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-selector.ts b/apps/cli/src/command-internal/target/target-selector.ts index fab7abb766..2b9734fc04 100644 --- a/apps/cli/src/command-internal/target/target-selector.ts +++ b/apps/cli/src/command-internal/target/target-selector.ts @@ -1,6 +1,7 @@ import { Option, Result } from "effect"; import { classifyNamedRemote, type RemoteValue } from "./remote-value.ts"; +import type { TargetFlagValues } from "./target-flags.ts"; import { TargetEmptyValueError, TargetNeedsProjectError, @@ -9,25 +10,24 @@ import { type TargetSelectorError, } from "./target.errors.ts"; -export interface TargetSelectorInput { - readonly remote: Option.Option<RemoteValue>; - readonly local: boolean; - readonly linked: boolean; - /** Absent for a command with no database connection, which rejects a database URL. */ - readonly dbUrl?: Option.Option<string>; - readonly projectRef: Option.Option<string>; - /** `gen types` only: the deprecated alias of `--project-ref`. */ - readonly projectId?: Option.Option<string>; -} +/** The decoded selector flags; `dbUrl` is absent for a command with no database connection, which rejects a database URL. */ +export type TargetSelectorInput = TargetFlagValues & { + readonly dbUrl?: Option.Option<string> | undefined; +}; -type TargetSelection = - | { readonly explicit: "local" } - | { readonly explicit: "db-url"; readonly url: string } - | { readonly explicit: "linked"; readonly ref: Option.Option<string> } - | { readonly explicit: "branch"; readonly name: string } - | { readonly explicit: undefined }; +/** What the flags select: the default, the local stack, or a remote project, branch or database. */ +type TargetSelection = { readonly _tag: "Default" } | { readonly _tag: "Local" } | RemoteValue; -export type RemoteAliasFlag = "--linked" | "--project-ref" | "--project-id" | "--db-url"; +/** Whether any selector flag, `--local` and the deprecated aliases included, was given. */ +export const hasExplicitSelector = (flags: TargetSelectorInput): boolean => + flags.local || + flags.linked || + Option.isSome(flags.remote) || + Option.isSome(flags.projectRef) || + Option.isSome(flags.projectId ?? Option.none()) || + Option.isSome(flags.dbUrl ?? Option.none()); + +type RemoteAliasFlag = "--linked" | "--project-ref" | "--project-id" | "--db-url"; /** A deprecated selector flag the run used, with the `--remote` form it maps to. */ export type TargetDeprecation = @@ -54,19 +54,6 @@ export const joinFlags = (flags: ReadonlyArray<string>): string => ? flags.join(" and ") : `${flags.slice(0, -1).join(", ")} and ${flags[flags.length - 1]}`; -const selectionOf = (remote: RemoteValue): TargetSelection => { - switch (remote._tag) { - case "Linked": - return { explicit: "linked", ref: Option.none() }; - case "Ref": - return { explicit: "linked", ref: Option.some(remote.ref) }; - case "DbUrl": - return { explicit: "db-url", url: remote.url }; - case "Branch": - return { explicit: "branch", name: remote.name }; - } -}; - const emptyValueError = (flag: "--db-url" | "--project-ref" | "--project-id") => new TargetEmptyValueError({ flag, @@ -145,19 +132,18 @@ export const parseTargetSelector = ( readonly selection: TargetSelection; readonly deprecations: ReadonlyArray<TargetDeprecation>; } => { - if (input.local) return { selection: { explicit: "local" }, deprecations: [{ kind: "local" }] }; + if (input.local) return { selection: { _tag: "Local" }, deprecations: [{ kind: "local" }] }; if (dbUrl !== undefined) { + const value: RemoteValue = { _tag: "DbUrl", url: dbUrl }; return { - selection: { explicit: "db-url", url: dbUrl }, - deprecations: [ - { kind: "remote-alias", flags: ["--db-url"], remote: { _tag: "DbUrl", url: dbUrl } }, - ], + selection: value, + deprecations: [{ kind: "remote-alias", flags: ["--db-url"], remote: value }], }; } if (aliasValue !== undefined) { const value = classifyNamedRemote(aliasValue); return { - selection: selectionOf(value), + selection: value, deprecations: [ { kind: "remote-alias", @@ -170,15 +156,15 @@ export const parseTargetSelector = ( if (input.linked) { const value: RemoteValue = { _tag: "Linked" }; return { - selection: selectionOf(value), + selection: value, deprecations: [{ kind: "remote-alias", flags: ["--linked"], remote: value }], }; } - if (remote !== undefined) return { selection: selectionOf(remote), deprecations: [] }; - return { selection: { explicit: undefined }, deprecations: [] }; + if (remote !== undefined) return { selection: remote, deprecations: [] }; + return { selection: { _tag: "Default" }, deprecations: [] }; })(); - if (input.dbUrl === undefined && outcome.selection.explicit === "db-url") { + if (input.dbUrl === undefined && outcome.selection._tag === "DbUrl") { return Result.fail( new TargetNeedsProjectError({ message: `${options.commandName ?? "This command"} needs a project, not a database URL.`, 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 59eabad1f5..e6e3521c30 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 @@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest"; import type { RemoteValue } from "./remote-value.ts"; import { + hasExplicitSelector, parseTargetSelector, type TargetSelectorInput, type TargetSelectorOptions, @@ -39,18 +40,18 @@ const failure = (overrides: Partial<TargetSelectorInput>, options?: TargetSelect describe("parseTargetSelector", () => { it("reports no explicit target and no deprecation when nothing is set", () => { - expect(success({})).toEqual({ selection: { explicit: undefined }, deprecations: [] }); + expect(success({})).toEqual({ selection: { _tag: "Default" }, deprecations: [] }); }); describe("--remote", () => { it.each([ - ["a bare --remote", { _tag: "Linked" }, { explicit: "linked", ref: Option.none() }], - ["a project ref", { _tag: "Ref", ref: REF }, { explicit: "linked", ref: Option.some(REF) }], - ["a database URL", { _tag: "DbUrl", url: URL }, { explicit: "db-url", url: URL }], + ["a bare --remote", { _tag: "Linked" }, { _tag: "Linked" }], + ["a project ref", { _tag: "Ref", ref: REF }, { _tag: "Ref", ref: REF }], + ["a database URL", { _tag: "DbUrl", url: URL }, { _tag: "DbUrl", url: URL }], [ "a branch name", { _tag: "Branch", name: "feature-x" }, - { explicit: "branch", name: "feature-x" }, + { _tag: "Branch", name: "feature-x" }, ], ] as const)( "selects the target named by %s without a deprecation", @@ -63,21 +64,21 @@ describe("parseTargetSelector", () => { describe("deprecated aliases", () => { it("treats --local as the default and reports it", () => { expect(success({ local: true })).toEqual({ - selection: { explicit: "local" }, + selection: { _tag: "Local" }, deprecations: [{ kind: "local" }], }); }); it("maps --linked to a bare --remote", () => { expect(success({ linked: true })).toEqual({ - selection: { explicit: "linked", ref: Option.none() }, + selection: { _tag: "Linked" }, deprecations: [{ kind: "remote-alias", flags: ["--linked"], remote: { _tag: "Linked" } }], }); }); it("maps --db-url to --remote=<url>", () => { expect(success({ dbUrl: Option.some(URL) })).toEqual({ - selection: { explicit: "db-url", url: URL }, + selection: { _tag: "DbUrl", url: URL }, deprecations: [ { kind: "remote-alias", flags: ["--db-url"], remote: { _tag: "DbUrl", url: URL } }, ], @@ -92,7 +93,7 @@ describe("parseTargetSelector", () => { ? { projectRef: Option.some(REF) } : { projectId: Option.some(REF) }; expect(success(overrides)).toEqual({ - selection: { explicit: "linked", ref: Option.some(REF) }, + selection: { _tag: "Ref", ref: REF }, deprecations: [ { kind: "remote-alias", flags: [flag], remote: { _tag: "Ref", ref: REF } }, ], @@ -102,7 +103,7 @@ describe("parseTargetSelector", () => { it("maps --project-ref with a branch name to --remote=<branch>", () => { expect(success({ projectRef: Option.some("feature-x") })).toEqual({ - selection: { explicit: "branch", name: "feature-x" }, + selection: { _tag: "Branch", name: "feature-x" }, deprecations: [ { kind: "remote-alias", @@ -115,7 +116,7 @@ describe("parseTargetSelector", () => { it("folds --linked and --project-ref into one deprecation", () => { expect(success({ linked: true, projectRef: Option.some(REF) })).toEqual({ - selection: { explicit: "linked", ref: Option.some(REF) }, + selection: { _tag: "Ref", ref: REF }, deprecations: [ { kind: "remote-alias", @@ -130,17 +131,17 @@ describe("parseTargetSelector", () => { expect( success({ ...remote({ _tag: "Linked" }), projectRef: Option.some(REF) }), ).toMatchObject({ - selection: { explicit: "linked", ref: Option.some(REF) }, + selection: { _tag: "Ref", ref: REF }, }); }); it("treats false switches as absent", () => { expect(success({ local: false, linked: false })).toEqual({ - selection: { explicit: undefined }, + selection: { _tag: "Default" }, deprecations: [], }); expect(success({ linked: false, dbUrl: Option.some(URL) })).toMatchObject({ - selection: { explicit: "db-url", url: URL }, + selection: { _tag: "DbUrl", url: URL }, }); }); }); @@ -268,12 +269,12 @@ describe("parseTargetSelector", () => { it("accepts a project ref and a branch", () => { expect(hosted(remote({ _tag: "Ref", ref: REF }))).toEqual( Result.succeed({ - selection: { explicit: "linked", ref: Option.some(REF) }, + selection: { _tag: "Ref", ref: REF }, deprecations: [], }), ); expect(hosted(remote({ _tag: "Branch", name: "feature-x" }))).toMatchObject({ - success: { selection: { explicit: "branch", name: "feature-x" } }, + success: { selection: { _tag: "Branch", name: "feature-x" } }, }); }); @@ -296,3 +297,25 @@ describe("parseTargetSelector", () => { }); }); }); + +describe("hasExplicitSelector", () => { + it("is false when no selector flag is given", () => { + expect(hasExplicitSelector(none)).toBe(false); + }); + + it.each<readonly [string, Partial<TargetSelectorInput>]>([ + ["--remote", { remote: Option.some({ _tag: "Linked" }) }], + ["--linked", { linked: true }], + ["--local", { local: true }], + ["--db-url", { dbUrl: Option.some(URL) }], + ["--project-ref", { projectRef: Option.some(REF) }], + ["--project-id", { projectId: Option.some(REF) }], + ])("is true for %s", (_flag, overrides) => { + expect(hasExplicitSelector({ ...none, ...overrides })).toBe(true); + }); + + it("ignores a database URL flag a command does not declare", () => { + const { dbUrl: _dbUrl, ...withoutDbUrl } = none; + expect(hasExplicitSelector(withoutDbUrl)).toBe(false); + }); +}); diff --git a/apps/cli/src/command-internal/test-db.handler.ts b/apps/cli/src/command-internal/test-db.handler.ts index 2a67ff910a..d30baa4345 100644 --- a/apps/cli/src/command-internal/test-db.handler.ts +++ b/apps/cli/src/command-internal/test-db.handler.ts @@ -116,13 +116,7 @@ export const testDb = Effect.fn("test.db")(function* (flags: TestDbFlags) { const dnsResolver = yield* DnsResolverFlag; yield* Effect.gen(function* () { - const target = yield* resolveTarget({ - remote: flags.remote, - local: flags.local, - linked: flags.linked, - dbUrl: flags.dbUrl, - projectRef: flags.projectRef, - }); + const target = yield* resolveTarget(flags); const resolved = yield* resolver.resolveConnection({ target, diff --git a/apps/cli/src/commands/backups/list/SIDE_EFFECTS.md b/apps/cli/src/commands/backups/list/SIDE_EFFECTS.md index 9b09b76259..eb2a269ecb 100644 --- a/apps/cli/src/commands/backups/list/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/backups/list/SIDE_EFFECTS.md @@ -25,6 +25,7 @@ | `GET` | `/v1/projects` | Bearer token | none | `[{id, ref, name, organization_slug, region, ...}]` — TTY-prompt fallback only | | `GET` | `/v1/projects/{ref}` | Bearer token | none | `{ref, name, organization_id, organization_slug}` — linked-project cache miss only (best-effort, after the command) | | `GET` | `/v1/projects/{parent}/branches` | Bearer token | none | `[{id, name, project_ref, is_default}]` — only for `--remote=<branch>` (or the deprecated `--project-ref=<branch>`), and for a bare `--remote` followed by a word that could name a branch; `{parent}` is `SUPABASE_PROJECT_ID`, else the linked project | +| `GET` | `/v1/branches/{id}` | Bearer token | none | `{ref, status, …}` — only for `--remote=<branch UUID>` when no project is linked | ## Environment Variables @@ -92,7 +93,7 @@ One `result` NDJSON event on success containing the backup response object. ## Notes -- Targets the linked project unless `--remote=<ref>` is given; a bare `--remote` also targets the linked project. `--remote=<value>` is read as a project ref (20 lowercase letters), a branch of the linked project (by name or UUID, resolved through `GET /v1/projects/{parent}/branches`), or `true|yes|on|1` for the linked project. A database URL is rejected (`supabase backups list` takes a project, not a database); `false|no|off|0`, `--no-remote` and an empty value fail with `Leave out --remote to use the linked project.` +- Targets the linked project unless `--remote=<ref>` is given; a bare `--remote` also targets the linked project. `--remote=<value>` is read as a project ref (20 lowercase letters), a branch of the linked project (by name or UUID, resolved through `GET /v1/projects/{parent}/branches`, or `GET /v1/branches/{id}` for a UUID when no project is linked), or `true|yes|on|1` for the linked project. A database URL is rejected (`supabase backups list` takes a project, not a database); `false|no|off|0`, `--no-remote` and an empty value fail with `Leave out --remote to use the linked project.` - A word after a bare `--remote` is read as its value, `--remote=<word>`, because the command takes no operands. - The deprecated `--project-ref` is an alias of `--remote=<ref>` and prints a deprecation warning on stderr that names the replacement; it cannot be combined with `--remote=<value>`. An empty `--project-ref` is rejected. - Requires `--remote=<ref>`, `SUPABASE_PROJECT_ID`, a populated `<workdir>/supabase/.temp/project-ref` file, or a TTY for the interactive project picker. diff --git a/apps/cli/src/commands/backups/list/list.handler.ts b/apps/cli/src/commands/backups/list/list.handler.ts index b3d944e38e..70089a15a1 100644 --- a/apps/cli/src/commands/backups/list/list.handler.ts +++ b/apps/cli/src/commands/backups/list/list.handler.ts @@ -96,7 +96,7 @@ export const backupsList = Effect.fn("backups.list")(function* (flags: BackupsLi const linkedProjectCache = yield* LinkedProjectCache; const telemetryState = yield* TelemetryState; - const ref = yield* resolveProjectTarget({ remote: flags.remote, projectRef: flags.projectRef }); + const ref = yield* resolveProjectTarget(flags); yield* Effect.gen(function* () { // Spinner is text-mode only; in JSON/stream-json it would leave a dangling `[task] start:` diff --git a/apps/cli/src/commands/backups/restore/SIDE_EFFECTS.md b/apps/cli/src/commands/backups/restore/SIDE_EFFECTS.md index d607d94168..6cc305b3bc 100644 --- a/apps/cli/src/commands/backups/restore/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/backups/restore/SIDE_EFFECTS.md @@ -25,6 +25,7 @@ | `GET` | `/v1/projects` | Bearer token | none | `[{id, ref, name, organization_slug, region, ...}]` — TTY-prompt fallback only | | `GET` | `/v1/projects/{ref}` | Bearer token | none | `{ref, name, organization_id, organization_slug}` — linked-project cache miss only (best-effort, after the command) | | `GET` | `/v1/projects/{parent}/branches` | Bearer token | none | `[{id, name, project_ref, is_default}]` — only for `--remote=<branch>` (or the deprecated `--project-ref=<branch>`), and for a bare `--remote` followed by a word that could name a branch; `{parent}` is `SUPABASE_PROJECT_ID`, else the linked project | +| `GET` | `/v1/branches/{id}` | Bearer token | none | `{ref, status, …}` — only for `--remote=<branch UUID>` when no project is linked | ## Environment Variables @@ -80,7 +81,7 @@ One `result` NDJSON event on success with the project ref payload. ## Notes -- Targets the linked project unless `--remote=<ref>` is given; a bare `--remote` also targets the linked project. `--remote=<value>` is read as a project ref (20 lowercase letters), a branch of the linked project (by name or UUID, resolved through `GET /v1/projects/{parent}/branches`), or `true|yes|on|1` for the linked project. A database URL is rejected (`supabase backups restore` takes a project, not a database); `false|no|off|0`, `--no-remote` and an empty value fail with `Leave out --remote to use the linked project.` +- Targets the linked project unless `--remote=<ref>` is given; a bare `--remote` also targets the linked project. `--remote=<value>` is read as a project ref (20 lowercase letters), a branch of the linked project (by name or UUID, resolved through `GET /v1/projects/{parent}/branches`, or `GET /v1/branches/{id}` for a UUID when no project is linked), or `true|yes|on|1` for the linked project. A database URL is rejected (`supabase backups restore` takes a project, not a database); `false|no|off|0`, `--no-remote` and an empty value fail with `Leave out --remote to use the linked project.` - A word after a bare `--remote` is read as its value, `--remote=<word>`, because the command takes no operands. - The deprecated `--project-ref` is an alias of `--remote=<ref>` and prints a deprecation warning on stderr that names the replacement; it cannot be combined with `--remote=<value>`. An empty `--project-ref` is rejected. - `--timestamp` / `-t` accepts seconds since Unix epoch (int64). Defaults to `0`, which the API interprets as "now". diff --git a/apps/cli/src/commands/backups/restore/restore.handler.ts b/apps/cli/src/commands/backups/restore/restore.handler.ts index a3ee883cae..50b8345073 100644 --- a/apps/cli/src/commands/backups/restore/restore.handler.ts +++ b/apps/cli/src/commands/backups/restore/restore.handler.ts @@ -29,7 +29,7 @@ export const backupsRestore = Effect.fn("backups.restore")(function* (flags: Bac const linkedProjectCache = yield* LinkedProjectCache; const telemetryState = yield* TelemetryState; - const ref = yield* resolveProjectTarget({ remote: flags.remote, projectRef: flags.projectRef }); + const ref = yield* resolveProjectTarget(flags); const recoveryTimeTargetUnix = Option.getOrElse(flags.timestamp, () => 0); yield* Effect.gen(function* () { diff --git a/apps/cli/src/commands/config/diff/SIDE_EFFECTS.md b/apps/cli/src/commands/config/diff/SIDE_EFFECTS.md index b3678dd425..fd5eef8449 100644 --- a/apps/cli/src/commands/config/diff/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/config/diff/SIDE_EFFECTS.md @@ -31,11 +31,11 @@ that finds differences. All Bearer-authenticated, all read-only. -| # | Purpose | Method | Path | Success | Notes | -| --- | ----------------------- | ------ | ------------------------------------ | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| 0a | branch by UUID | GET | `/v1/branches/{branch_id}` | 200 | only when `--remote` is a UUID; needs no linked project (no parent pre-check) | -| 0b | branch by name | GET | `/v1/projects/{ref}/branches/{name}` | 200 | only when `--remote` is a NAME (not a ref/UUID); the parent ref is resolved from local state BEFORE this call — an absent/invalid parent fails without making this request; 404 → "branch not found" | -| 1 | effective remote config | GET | `/v2/projects/{ref}/config` | 200 | always (after target resolution); 401/403/404 get purpose-written messages, other statuses the generic `unexpected status N: body` shape | +| # | Purpose | Method | Path | Success | Notes | +| --- | ----------------------- | ------ | -------------------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| 0a | branch list | GET | `/v1/projects/{parent}/branches` | 200 | only when `--remote` is a branch name or UUID and a project is linked; `{parent}` is `SUPABASE_PROJECT_ID`, else the linked project, resolved from local state BEFORE this call — an absent/invalid parent fails without making this request; no match → "branch not found", listing the branches that exist | +| 0b | branch by UUID | GET | `/v1/branches/{id}` | 200 | only when `--remote` is a branch UUID and no usable project is linked; the lookup needs no parent project | +| 1 | effective remote config | GET | `/v2/projects/{ref}/config` | 200 | always (after target resolution); 401/403/404 get purpose-written messages, other statuses the generic `unexpected status N: body` shape | ## Environment Variables @@ -73,10 +73,10 @@ same as any other non-zero exit. | `1` | resolved `--workdir`/`SUPABASE_WORKDIR` doesn't exist or isn't a directory (`ConfigDiffWorkdirError`) — beats the config read and every network call | | `1` | missing or malformed `supabase/config.toml`/`config.json` (`ConfigDiffLoadConfigError`) — a missing file suggests `supabase init` only for a DEFAULTED workdir; with an explicit workdir the message names the resolved path instead and never suggests `init` | | `1` | a flag, `SUPABASE_*` variable, project `.env*` file or config value fails to decode (`CliConfigValueError`) — names the source that supplied it; surfaced as-is, not wrapped in the command's load error | -| `1` | branch-name `--remote` with no linked parent project (`ConfigDiffBranchNotLinkedError`) | -| `1` | branch-name `--remote` with a corrupt/invalid linked parent ref (`ConfigDiffParentRefInvalidError`) | -| `1` | unknown branch (branch-name `--remote` 404, `ConfigDiffBranchNotFoundError`) | -| `1` | resolved branch has no project ref yet — still provisioning (`ConfigDiffBranchNotReadyError`) | +| `1` | branch-name `--remote` with no linked parent project (`LinkBranchNotLinkedError`) | +| `1` | branch-name `--remote` with a corrupt/invalid linked parent ref (`LinkParentRefInvalidError`) | +| `1` | unknown branch (no branch of the linked project matches the name or UUID, `LinkBranchNotFoundError`; or an unknown UUID with no linked project, `LinkBranchListStatusError`) | +| `1` | resolved branch has no project ref yet — still provisioning (`LinkBranchNotReadyError`) | | `1` | two `[remotes.*]` blocks declare the same `project_id` as the target ref | | `1` | remote config read failure (network, 401/403/404, or other unexpected status) | @@ -146,7 +146,7 @@ and emit nothing. ## Notes -- `--remote=<value>` selects the target: a project ref (20 lowercase letters), or the name or UUID of a branch of the linked project (resolved through `GET /v1/projects/{ref}/branches/{name}` or `GET /v1/branches/{id}`). A bare `--remote` and no selector both target the linked project. A database URL is rejected (`supabase config diff` takes a project, not a database); `false|no|off|0`, `--no-remote` and an empty value fail with `Leave out --remote to use the linked project.` +- `--remote=<value>` selects the target: a project ref (20 lowercase letters), or the name or UUID of a branch of the linked project (resolved through `GET /v1/projects/{parent}/branches`, or `GET /v1/branches/{id}` for a UUID when no project is linked). A bare `--remote` and no selector both target the linked project. A database URL is rejected (`supabase config diff` takes a project, not a database); `false|no|off|0`, `--no-remote` and an empty value fail with `Leave out --remote to use the linked project.` - The deprecated `--project-ref` is an alias of `--remote=<ref>` (it still accepts a branch name or UUID) and prints a deprecation warning on stderr that names the replacement; it cannot be combined with `--remote=<value>`. An empty `--project-ref` is rejected. - 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. diff --git a/apps/cli/src/commands/config/diff/diff.command.ts b/apps/cli/src/commands/config/diff/diff.command.ts index e71c562ed2..9a3042c7cd 100644 --- a/apps/cli/src/commands/config/diff/diff.command.ts +++ b/apps/cli/src/commands/config/diff/diff.command.ts @@ -6,7 +6,8 @@ import { cliConfigValuesLayer } from "../../../config/cli-config-values.layer.ts import { projectRefSafeFlags } from "../../../command-internal/target/project-ref-safe-flags.ts"; import { withPermissions } from "../../../command-internal/command-permissions/command-permissions.annotation.ts"; import { compose } from "../../../command-internal/command-permissions/model.ts"; -import { configTarget } from "../../../command-internal/project-target.ts"; +import { projectRefPicker } from "../../../config/project-ref.layer.ts"; +import { branchResolution } from "../../../command-internal/target/branch-resolution.ts"; import { linkedProjectCache } from "../../../telemetry/linked-project-cache.layer.ts"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; import { GLOBAL_OUTPUT_FORMATS } from "../../../command-internal/global-flags.ts"; @@ -62,7 +63,8 @@ export const configDiffCommand = Command.make("diff", config).pipe( ]), withPermissions( compose( - configTarget, + branchResolution, + projectRefPicker, { operations: [{ operationId: "v2-get-project-config", kind: "required" }], noApiEffectFlags: ["exit-code"], diff --git a/apps/cli/src/commands/config/diff/diff.errors.ts b/apps/cli/src/commands/config/diff/diff.errors.ts index b64f680c26..6f3366f4b9 100644 --- a/apps/cli/src/commands/config/diff/diff.errors.ts +++ b/apps/cli/src/commands/config/diff/diff.errors.ts @@ -5,7 +5,6 @@ import { ErrorActionabilityId, statusCodeActionability, } from "../../../shared/telemetry/error-actionability.ts"; -import { mintConfigTargetErrors } from "../../../command-internal/project-target.ts"; interface NetworkErrorArgs { readonly message: string; @@ -49,52 +48,6 @@ export class ConfigDiffOutputFlagUnsupportedError extends Data.TaggedError( } } -const targetErrors = mintConfigTargetErrors("ConfigDiff"); - -/** `--remote` named a branch the parent project does not have. */ -export const ConfigDiffBranchNotFoundError = targetErrors.BranchNotFoundError; -export type ConfigDiffBranchNotFoundError = InstanceType<typeof ConfigDiffBranchNotFoundError>; - -/** - * `--remote` named a branch by name, but no project is linked to search branches under — - * none of `SUPABASE_PROJECT_ID`, `supabase/.temp/linked-project.json`, or - * `supabase/.temp/project-ref` yielded a candidate. Mirrors `LinkBranchNotLinkedError`. - */ -export const ConfigDiffBranchNotLinkedError = targetErrors.BranchNotLinkedError; -export type ConfigDiffBranchNotLinkedError = InstanceType<typeof ConfigDiffBranchNotLinkedError>; - -/** - * `--remote` named a branch by name, and a parent-project candidate exists but isn't - * ref-shaped — corrupt or stale linked state. Mirrors `LinkParentRefInvalidError`. - */ -export const ConfigDiffParentRefInvalidError = targetErrors.ParentRefInvalidError; -export type ConfigDiffParentRefInvalidError = InstanceType<typeof ConfigDiffParentRefInvalidError>; - -/** - * The resolved branch has no project ref yet (still provisioning), guarding against an - * empty ref reaching `/v2/projects//config`. Mirrors `LinkBranchNotReadyError`. - */ -export const ConfigDiffBranchNotReadyError = targetErrors.BranchNotReadyError; -export type ConfigDiffBranchNotReadyError = InstanceType<typeof ConfigDiffBranchNotReadyError>; - -export class ConfigDiffBranchResolveNetworkError extends Data.TaggedError( - "ConfigDiffBranchResolveNetworkError", -)<NetworkErrorArgs> { - get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { - return this.decode === true - ? { ...actionability.apiStatus, fingerprint_suffix: "api_response" } - : actionability.externalNetwork; - } -} - -export class ConfigDiffBranchResolveStatusError extends Data.TaggedError( - "ConfigDiffBranchResolveStatusError", -)<StatusErrorArgs> { - get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { - return statusCodeActionability(this.status, { notFoundIsInvalidInput: true }); - } -} - export class ConfigDiffReadNetworkError extends Data.TaggedError( "ConfigDiffReadNetworkError", )<NetworkErrorArgs> { diff --git a/apps/cli/src/commands/config/diff/diff.handler.ts b/apps/cli/src/commands/config/diff/diff.handler.ts index daf6d47741..2a9be28d9d 100644 --- a/apps/cli/src/commands/config/diff/diff.handler.ts +++ b/apps/cli/src/commands/config/diff/diff.handler.ts @@ -15,15 +15,7 @@ import { OutputFlag } from "../../../command-internal/global-flags.ts"; import { unsupportedOutputFlagMessage } from "../../../command-internal/output-formats.ts"; import { Output } from "../../../shared/output/output.service.ts"; import { ProcessControl } from "../../../shared/runtime/process-control.service.ts"; -import { - mapHttpError, - sanitizeErrorBody, - unexpectedStatusMessage, -} from "../../../command-internal/http-errors.ts"; -import { - configTargetErrorsFor, - resolveConfigTarget, -} from "../../../command-internal/project-target.ts"; +import { sanitizeErrorBody } from "../../../command-internal/http-errors.ts"; import { resolveProjectTarget } from "../../../command-internal/target/resolve-project-target.ts"; import { configIsRecord } from "../config.paths.ts"; import { @@ -43,35 +35,14 @@ import { type ConfigDiffContext, } from "./diff.format.ts"; import { - ConfigDiffBranchNotFoundError, - ConfigDiffBranchNotLinkedError, - ConfigDiffBranchNotReadyError, - ConfigDiffBranchResolveNetworkError, - ConfigDiffBranchResolveStatusError, ConfigDiffLoadConfigError, ConfigDiffOutputFlagUnsupportedError, - ConfigDiffParentRefInvalidError, ConfigDiffReadNetworkError, ConfigDiffReadStatusError, ConfigDiffWorkdirError, } from "./diff.errors.ts"; import type { ConfigDiffFlags } from "./diff.command.ts"; -const mapBranchResolveError = mapHttpError({ - networkError: ConfigDiffBranchResolveNetworkError, - statusError: ConfigDiffBranchResolveStatusError, - networkMessage: (cause) => `failed to resolve branch: ${cause}`, - statusMessage: unexpectedStatusMessage, -}); - -// Maps resolveConfigTarget's generic errors onto config diff's own tagged error classes. -const configTargetErrors = configTargetErrorsFor({ - notLinked: ConfigDiffBranchNotLinkedError, - parentRefInvalid: ConfigDiffParentRefInvalidError, - branchNotFound: ConfigDiffBranchNotFoundError, - branchNotReady: ConfigDiffBranchNotReadyError, -}); - export const configDiff = Effect.fn("config.diff")(function* (flags: ConfigDiffFlags) { const output = yield* Output; const api = yield* CommandPlatformApi; @@ -112,14 +83,7 @@ export const configDiff = Effect.fn("config.diff")(function* (flags: ConfigDiffF makeLoadError, ); - // See resolveConfigTarget's doc comment for the target-resolution rules this preserves. - const { ref, branch } = yield* resolveProjectTarget( - { remote: flags.remote, projectRef: flags.projectRef }, - { - resolveRequested: (requested) => - resolveConfigTarget(requested, configTargetErrors, mapBranchResolveError), - }, - ); + const { ref, branch } = yield* resolveProjectTarget(flags, { detail: true }); yield* Ref.set(resolvedRef, Option.some(ref)); yield* Effect.annotateCurrentSpan({ "project.ref": ref, 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 1bad927269..b814819fd7 100644 --- a/apps/cli/src/commands/config/diff/diff.integration.test.ts +++ b/apps/cli/src/commands/config/diff/diff.integration.test.ts @@ -86,7 +86,7 @@ const PROJECT: (typeof V1ListAllProjectsOutput.Type)[number] = { }, }; -/** V1GetABranch body for the branch-name `--remote` lookup. */ +/** A branch row of the linked project's branch list, which every branch `--remote` is looked up in. */ const BRANCH_BY_NAME = { id: BRANCH_UUID, name: "staging", @@ -117,7 +117,7 @@ interface SetupOpts { readonly format?: "text" | "json" | "stream-json"; readonly outputFlag?: "env" | "pretty" | "json" | "toml" | "yaml" | "table" | "csv"; readonly v2?: { status: number; body: unknown } | "fail"; - readonly branchByName?: { status: number; body: unknown }; + readonly branchList?: { status: number; body: unknown }; readonly branchByUuid?: { status: number; body: unknown }; /** `false` simulates a directory with no linked project. */ readonly linked?: boolean; @@ -156,8 +156,8 @@ function setup(opts: SetupOpts = {}) { const b = opts.branchByUuid ?? { status: 200, body: BRANCH_CONFIG }; return Effect.succeed(jsonResponse(request, b.status, b.body)); } - if (url.includes("/branches/")) { - const b = opts.branchByName ?? { status: 200, body: BRANCH_BY_NAME }; + if (url.endsWith("/branches")) { + const b = opts.branchList ?? { status: 200, body: [BRANCH_BY_NAME] }; return Effect.succeed(jsonResponse(request, b.status, b.body)); } return Effect.succeed(jsonResponse(request, 200, {})); @@ -588,13 +588,11 @@ describe("config diff integration", () => { `Comparing against 'staging' (branch ${BRANCH_REF}) using base config`, ); const urls = api.requests.map((request) => request.url); - expect(urls.some((url) => url.includes(`/v1/projects/${VALID_REF}/branches/staging`))).toBe( - true, - ); + expect(urls.some((url) => url.endsWith(`/v1/projects/${VALID_REF}/branches`))).toBe(true); expect(urls.some((url) => url.includes(`/v2/projects/${BRANCH_REF}/config`))).toBe(true); assertPermissionDrift({ command: "config diff", - activeFlags: { remote: true }, + activeFlags: { remote: "staging" }, requests: api.requests, }); }).pipe(Effect.provide(layer)); @@ -622,13 +620,11 @@ describe("config diff integration", () => { ); yield* configDiff(remoteBranch("staging")); const urls = api.requests.map((request) => request.url); - expect(urls.some((url) => url.includes(`/v1/projects/${VALID_REF}/branches/staging`))).toBe( - true, - ); + expect(urls.some((url) => url.endsWith(`/v1/projects/${VALID_REF}/branches`))).toBe(true); expect(urls.some((url) => url.includes(`/v1/projects/${BRANCH_REF}/`))).toBe(false); assertPermissionDrift({ command: "config diff", - activeFlags: { remote: true }, + activeFlags: { remote: "staging" }, requests: api.requests, }); }).pipe(Effect.provide(layer)); @@ -652,7 +648,7 @@ describe("config diff integration", () => { ); assertPermissionDrift({ command: "config diff", - activeFlags: { remote: true }, + activeFlags: { remote: BRANCH_UUID }, requests: api.requests, }); }).pipe(Effect.provide(layer)); @@ -666,11 +662,11 @@ describe("config diff integration", () => { return Effect.gen(function* () { yield* configDiff(remoteRef(BRANCH_REF)); const urls = api.requests.map((request) => request.url); - expect(urls.some((url) => url.includes("/branches/"))).toBe(false); + expect(urls.some((url) => url.includes("/branches"))).toBe(false); expect(urls.some((url) => url.includes(`/v2/projects/${BRANCH_REF}/config`))).toBe(true); assertPermissionDrift({ command: "config diff", - activeFlags: { remote: true }, + activeFlags: { remote: BRANCH_REF }, requests: api.requests, exact: true, }); @@ -682,7 +678,7 @@ describe("config diff integration", () => { return Effect.gen(function* () { yield* configDiff({ ...noFlags, remote: Option.some<RemoteValue>({ _tag: "Linked" }) }); expect(out.stderrText).toContain(`Comparing against project ${VALID_REF} using base config`); - expect(api.requests.some((request) => request.url.includes("/branches/"))).toBe(false); + expect(api.requests.some((request) => request.url.includes("/branches"))).toBe(false); assertPermissionDrift({ command: "config diff", activeFlags: { remote: true }, @@ -707,9 +703,7 @@ describe("config diff integration", () => { `Comparing against 'staging' (branch ${BRANCH_REF}) using base config`, ); expect( - api.requests.some((request) => - request.url.includes(`/v1/projects/${VALID_REF}/branches/staging`), - ), + api.requests.some((request) => request.url.endsWith(`/v1/projects/${VALID_REF}/branches`)), ).toBe(true); }).pipe(Effect.provide(layer)); }); @@ -739,19 +733,20 @@ describe("config diff integration", () => { ); }); - it.effect("an unknown branch fails with a branches-list suggestion", () => { + it.effect("an unknown branch fails listing the available branches", () => { const { layer, telemetry, linkedProjectCache } = setup({ toml: 'project_id = "test"\n', - branchByName: { status: 404, body: { message: "not found" } }, + branchList: { status: 200, body: [BRANCH_BY_NAME] }, }); return Effect.gen(function* () { const exit = yield* configDiff(remoteBranch("ghost")).pipe(Effect.exit); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { const causeText = Cause.pretty(exit.cause); - expect(causeText).toContain("ConfigDiffBranchNotFoundError"); - expect(causeText).toContain('Branch "ghost" not found'); - expect(causeText).toContain("supabase branches list"); + expect(causeText).toContain("LinkBranchNotFoundError"); + expect(causeText).toContain( + `Branch "ghost" not found for project ${VALID_REF}. Available branches: staging`, + ); } // Telemetry still flushes on failure; the linked-project cache stays untouched since no // ref resolved. @@ -763,13 +758,13 @@ describe("config diff integration", () => { it.effect("a non-404 branch lookup failure keeps its status error", () => { const { layer } = setup({ toml: 'project_id = "test"\n', - branchByName: { status: 500, body: { message: "boom" } }, + branchList: { status: 500, body: { message: "boom" } }, }); return Effect.gen(function* () { const exit = yield* configDiff(remoteBranch("staging")).pipe(Effect.exit); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(Cause.pretty(exit.cause)).toContain("ConfigDiffBranchResolveStatusError"); + expect(Cause.pretty(exit.cause)).toContain("LinkBranchListStatusError"); } }).pipe(Effect.provide(layer)); }); @@ -1387,7 +1382,7 @@ describe("config diff integration", () => { expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { const causeText = Cause.pretty(exit.cause); - expect(causeText).toContain("ConfigDiffBranchNotLinkedError"); + expect(causeText).toContain("LinkBranchNotLinkedError"); expect(causeText).toContain('"somebranch"'); } expect(api.requests).toHaveLength(0); @@ -1406,7 +1401,7 @@ describe("config diff integration", () => { expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { const causeText = Cause.pretty(exit.cause); - expect(causeText).toContain("ConfigDiffParentRefInvalidError"); + expect(causeText).toContain("LinkParentRefInvalidError"); expect(causeText).toContain('"somebranch"'); expect(causeText).toContain("Relink the parent project"); } @@ -1417,14 +1412,14 @@ describe("config diff integration", () => { it.effect("a resolved branch with no project ref yet fails with a not-ready error", () => { const { layer, api } = setup({ toml: 'project_id = "test"\n', - branchByName: { status: 200, body: { ...BRANCH_BY_NAME, project_ref: "" } }, + branchList: { status: 200, body: [{ ...BRANCH_BY_NAME, project_ref: "" }] }, }); return Effect.gen(function* () { const exit = yield* configDiff(remoteBranch("staging")).pipe(Effect.exit); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { const causeText = Cause.pretty(exit.cause); - expect(causeText).toContain("ConfigDiffBranchNotReadyError"); + expect(causeText).toContain("LinkBranchNotReadyError"); expect(causeText).toContain("has no project ref yet"); } expect(api.requests.some((request) => request.url.includes("/v2/projects/"))).toBe(false); diff --git a/apps/cli/src/commands/config/pull/SIDE_EFFECTS.md b/apps/cli/src/commands/config/pull/SIDE_EFFECTS.md index 4c810cb491..4ca17517ca 100644 --- a/apps/cli/src/commands/config/pull/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/config/pull/SIDE_EFFECTS.md @@ -53,11 +53,11 @@ declining any other answer. Never writes on `--dry-run`, on a declined prompt, o All Bearer-authenticated, all read-only (pull never calls a write endpoint — every change lands locally via the file write above). -| # | Purpose | Method | Path | Success | Notes | -| --- | ----------------------- | ------ | ------------------------------------ | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| 0a | branch by UUID | GET | `/v1/branches/{branch_id}` | 200 | only when `--remote` is a UUID; needs no linked project (no parent pre-check) | -| 0b | branch by name | GET | `/v1/projects/{ref}/branches/{name}` | 200 | only when `--remote` is a NAME (not a ref/UUID); the parent ref is resolved from local state BEFORE this call — an absent/invalid parent fails without making this request; 404 → "branch not found" | -| 1 | effective remote config | GET | `/v2/projects/{ref}/config` | 200 | always (after target resolution); 401/403/404 get purpose-written messages, other statuses the generic `unexpected status N: body` shape | +| # | Purpose | Method | Path | Success | Notes | +| --- | ----------------------- | ------ | -------------------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| 0a | branch list | GET | `/v1/projects/{parent}/branches` | 200 | only when `--remote` is a branch name or UUID and a project is linked; `{parent}` is `SUPABASE_PROJECT_ID`, else the linked project, resolved from local state BEFORE this call — an absent/invalid parent fails without making this request; no match → "branch not found", listing the branches that exist | +| 0b | branch by UUID | GET | `/v1/branches/{id}` | 200 | only when `--remote` is a branch UUID and no usable project is linked; the lookup needs no parent project | +| 1 | effective remote config | GET | `/v2/projects/{ref}/config` | 200 | always (after target resolution); 401/403/404 get purpose-written messages, other statuses the generic `unexpected status N: body` shape | ## Environment Variables @@ -85,10 +85,10 @@ exit `0`: neither is a failure. | `1` | resolved `--workdir`/`SUPABASE_WORKDIR` doesn't exist or isn't a directory (`ConfigPullWorkdirError`) — beats the base config load and every network call | | `1` | missing or malformed `supabase/config.toml`/`config.json` (`ConfigPullLoadConfigError`) — a missing file suggests `supabase init` only for a DEFAULTED workdir; with an explicit workdir the message names the resolved path instead and never suggests `init` | | `1` | a flag, `SUPABASE_*` variable, project `.env*` file or config value fails to decode (`CliConfigValueError`) — names the source that supplied it; surfaced as-is, not wrapped in the command's load error | -| `1` | branch-name `--remote` with no linked parent project (`ConfigPullBranchNotLinkedError`) | -| `1` | branch-name `--remote` with a corrupt/invalid linked parent ref (`ConfigPullParentRefInvalidError`) | -| `1` | unknown branch (branch-name `--remote` 404, `ConfigPullBranchNotFoundError`) | -| `1` | resolved branch has no project ref yet — still provisioning (`ConfigPullBranchNotReadyError`) | +| `1` | branch-name `--remote` with no linked parent project (`LinkBranchNotLinkedError`) | +| `1` | branch-name `--remote` with a corrupt/invalid linked parent ref (`LinkParentRefInvalidError`) | +| `1` | unknown branch (no branch of the linked project matches the name or UUID, `LinkBranchNotFoundError`; or an unknown UUID with no linked project, `LinkBranchListStatusError`) | +| `1` | resolved branch has no project ref yet — still provisioning (`LinkBranchNotReadyError`) | | `1` | `--remote-label`, or a branch-derived label, names an existing block tracking a different project, or a `--remote-label` names nothing while a different block already tracks the target ref (`ConfigPullRemoteLabelCollisionError`) | | `1` | the target ref only matches a `[remotes.*]` block's `project_id` via `env(...)` resolution, never its raw literal (`ConfigPullRemoteEnvRefError`) | | `1` | remote config read failure — network, 401/403/404, or other unexpected status (`ConfigPullReadNetworkError`/`ConfigPullReadStatusError`) | @@ -182,7 +182,7 @@ the -o/--output flag is not supported by config pull; use --output-format json|s ## Notes -- `--remote=<value>` selects the target: a project ref (20 lowercase letters), or the name or UUID of a branch of the linked project (resolved through `GET /v1/projects/{ref}/branches/{name}` or `GET /v1/branches/{id}`). A bare `--remote` and no selector both target the linked project. A database URL is rejected (`supabase config pull` takes a project, not a database); `false|no|off|0`, `--no-remote` and an empty value fail with `Leave out --remote to use the linked project.` +- `--remote=<value>` selects the target: a project ref (20 lowercase letters), or the name or UUID of a branch of the linked project (resolved through `GET /v1/projects/{parent}/branches`, or `GET /v1/branches/{id}` for a UUID when no project is linked). A bare `--remote` and no selector both target the linked project. A database URL is rejected (`supabase config pull` takes a project, not a database); `false|no|off|0`, `--no-remote` and an empty value fail with `Leave out --remote to use the linked project.` - The deprecated `--project-ref` is an alias of `--remote=<ref>` (it still accepts a branch name or UUID) and prints a deprecation warning on stderr that names the replacement; it cannot be combined with `--remote=<value>`. An empty `--project-ref` is rejected. - **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 diff --git a/apps/cli/src/commands/config/pull/pull.command.ts b/apps/cli/src/commands/config/pull/pull.command.ts index 17a0815ff5..b1d80bd4ff 100644 --- a/apps/cli/src/commands/config/pull/pull.command.ts +++ b/apps/cli/src/commands/config/pull/pull.command.ts @@ -8,7 +8,8 @@ import { projectRefSafeFlags } from "../../../command-internal/target/project-re import { withPermissions } from "../../../command-internal/command-permissions/command-permissions.annotation.ts"; import { compose } from "../../../command-internal/command-permissions/model.ts"; import { configPullRun } from "../../../command-internal/config-pull-run.ts"; -import { configTarget } from "../../../command-internal/project-target.ts"; +import { projectRefPicker } from "../../../config/project-ref.layer.ts"; +import { branchResolution } from "../../../command-internal/target/branch-resolution.ts"; import { linkedProjectCache } from "../../../telemetry/linked-project-cache.layer.ts"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; import { stdinLayer } from "../../../shared/runtime/stdin.layer.ts"; @@ -76,7 +77,8 @@ export const configPullCommand = Command.make("pull", config).pipe( ]), withPermissions( compose( - configTarget, + branchResolution, + projectRefPicker, configPullRun, { operations: [], noApiEffectFlags: ["remote-label", "dry-run", "force"] }, linkedProjectCache, diff --git a/apps/cli/src/commands/config/pull/pull.errors.ts b/apps/cli/src/commands/config/pull/pull.errors.ts index 37b2800e5d..26cb599e36 100644 --- a/apps/cli/src/commands/config/pull/pull.errors.ts +++ b/apps/cli/src/commands/config/pull/pull.errors.ts @@ -5,7 +5,6 @@ import { ErrorActionabilityId, statusCodeActionability, } from "../../../shared/telemetry/error-actionability.ts"; -import { mintConfigTargetErrors } from "../../../command-internal/project-target.ts"; interface NetworkErrorArgs { readonly message: string; @@ -50,27 +49,6 @@ export class ConfigPullOutputFlagUnsupportedError extends Data.TaggedError( } } -const targetErrors = mintConfigTargetErrors("ConfigPull"); - -/** `--remote` named a branch the parent project does not have. */ -export const ConfigPullBranchNotFoundError = targetErrors.BranchNotFoundError; -export type ConfigPullBranchNotFoundError = InstanceType<typeof ConfigPullBranchNotFoundError>; - -/** `--remote` named a branch (by name), but no project is linked to search branches under. */ -export const ConfigPullBranchNotLinkedError = targetErrors.BranchNotLinkedError; -export type ConfigPullBranchNotLinkedError = InstanceType<typeof ConfigPullBranchNotLinkedError>; - -/** - * `--remote` named a branch (by name), and a parent-project candidate exists but is not - * ref-shaped — corrupt or stale linked state. - */ -export const ConfigPullParentRefInvalidError = targetErrors.ParentRefInvalidError; -export type ConfigPullParentRefInvalidError = InstanceType<typeof ConfigPullParentRefInvalidError>; - -/** The resolved branch has no project ref yet (still provisioning). */ -export const ConfigPullBranchNotReadyError = targetErrors.BranchNotReadyError; -export type ConfigPullBranchNotReadyError = InstanceType<typeof ConfigPullBranchNotReadyError>; - /** * A transport failure reading remote state over HTTP, shared by both the branch-lookup call and * the `/v2/projects/{ref}/config` read — the actionability is identical either way. The caller's diff --git a/apps/cli/src/commands/config/pull/pull.handler.ts b/apps/cli/src/commands/config/pull/pull.handler.ts index b4fc9cf4af..b72054aa32 100644 --- a/apps/cli/src/commands/config/pull/pull.handler.ts +++ b/apps/cli/src/commands/config/pull/pull.handler.ts @@ -2,17 +2,9 @@ import { Effect, FileSystem, Option, Ref } from "effect"; import { CommandSettings } from "../../../config/command-settings.service.ts"; import { pathHasUncommittedChanges } from "../../../command-internal/git-status.ts"; -import { - mapHttpError, - sanitizeInlineName, - unexpectedStatusMessage, -} from "../../../command-internal/http-errors.ts"; -import { - configTargetErrorsFor, - resolveConfigTarget, - type ConfigTarget, -} from "../../../command-internal/project-target.ts"; +import { sanitizeInlineName } from "../../../command-internal/http-errors.ts"; import { resolveProjectTarget } from "../../../command-internal/target/resolve-project-target.ts"; +import type { LinkedTarget } from "../../../command-internal/target/resolve-target.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"; @@ -34,13 +26,7 @@ import { withAnnouncedTarget } from "../../../shared/output/command-target.servi import { configPullCreatedBlockLabel, configPullSummaryMessage } from "./pull.format.ts"; import type { ConfigPullPlan } from "./pull.plan.ts"; import { - ConfigPullBranchNotFoundError, - ConfigPullBranchNotLinkedError, - ConfigPullBranchNotReadyError, ConfigPullOutputFlagUnsupportedError, - ConfigPullParentRefInvalidError, - ConfigPullReadNetworkError, - ConfigPullReadStatusError, ConfigPullUncommittedChangesError, ConfigPullWorkdirError, } from "./pull.errors.ts"; @@ -57,25 +43,10 @@ import type { ConfigPullFlags } from "./pull.command.ts"; export { openConfigPullSource }; export type { ConfigPullSource }; -const mapBranchResolveError = mapHttpError({ - networkError: ConfigPullReadNetworkError, - statusError: ConfigPullReadStatusError, - networkMessage: (cause) => `failed to resolve branch: ${cause}`, - statusMessage: unexpectedStatusMessage, -}); - -// Maps resolveConfigTarget's generic errors onto config pull's own tagged error classes. -const configTargetErrors = configTargetErrorsFor({ - notLinked: ConfigPullBranchNotLinkedError, - parentRefInvalid: ConfigPullParentRefInvalidError, - branchNotFound: ConfigPullBranchNotFoundError, - branchNotReady: ConfigPullBranchNotReadyError, -}); - /** Everything `config pull` does once the target is known, reusable independently of the CLI * flag surface. */ export interface ConfigPullInput { - readonly target: ConfigTarget; + readonly target: Pick<LinkedTarget, "ref" | "branch">; /** `--remote-label`, already filtered so an empty value reads as absent. */ readonly remoteLabel: string | undefined; readonly dryRun: boolean; @@ -255,14 +226,7 @@ export const configPull = Effect.fn("config.pull")(function* (flags: ConfigPullF // and a malformed document doesn't burn a branch-resolution round trip. const source = yield* openConfigPullSource(); - // Resolves the pull target via resolveConfigTarget, shared with config diff/config push. - const { ref, branch } = yield* resolveProjectTarget( - { remote: flags.remote, projectRef: flags.projectRef }, - { - resolveRequested: (requested) => - resolveConfigTarget(requested, configTargetErrors, mapBranchResolveError), - }, - ); + const { ref, branch } = yield* resolveProjectTarget(flags, { detail: true }); yield* Ref.set(resolvedRef, Option.some(ref)); yield* Effect.annotateCurrentSpan("config.target_is_branch", branch !== undefined); 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 02ae6b2653..b61b9a7ed8 100644 --- a/apps/cli/src/commands/config/pull/pull.integration.test.ts +++ b/apps/cli/src/commands/config/pull/pull.integration.test.ts @@ -258,7 +258,7 @@ function v2Response( }; } -/** V1FindBranch body for the branch-name `--remote` lookup. */ +/** A branch row of the linked project's branch list, which every branch `--remote` is looked up in. */ const BRANCH_BY_NAME = { id: "11111111-1111-4111-8111-111111111111", name: "staging", @@ -371,7 +371,7 @@ interface SetupOpts { readonly format?: "text" | "json" | "stream-json"; readonly outputFlag?: (typeof GLOBAL_OUTPUT_FORMATS)[number]; readonly v2?: { status: number; body: unknown } | "fail" | "decode-fail"; - readonly branchByName?: { status: number; body: unknown } | "fail"; + readonly branchList?: { status: number; body: unknown } | "fail"; readonly branchByUuid?: { status: number; body: unknown }; /** `false` simulates a directory with no linked project. */ readonly linked?: boolean; @@ -444,11 +444,11 @@ function setup(opts: SetupOpts = {}) { const b = opts.branchByUuid ?? { status: 200, body: BRANCH_CONFIG }; return Effect.succeed(jsonResponse(request, b.status, b.body)); } - if (url.includes("/branches/")) { - if (opts.branchByName === "fail") { + if (url.endsWith("/branches")) { + if (opts.branchList === "fail") { return Effect.fail(transportFailure(request)); } - const b = opts.branchByName ?? { status: 200, body: BRANCH_BY_NAME }; + const b = opts.branchList ?? { status: 200, body: [BRANCH_BY_NAME] }; return Effect.succeed(jsonResponse(request, b.status, b.body)); } return Effect.succeed(jsonResponse(request, 200, {})); @@ -1767,7 +1767,7 @@ describe("config pull integration", () => { return Effect.gen(function* () { yield* configPull({ ...noFlags, remote: refRemote(BRANCH_REF) }); expect(out.stderrText).toContain(`Pulling config from project ${BRANCH_REF}`); - expect(api.requests.some((request) => request.url.includes("/branches/"))).toBe(false); + expect(api.requests.some((request) => request.url.includes("/branches"))).toBe(false); }).pipe(Effect.provide(layer)); }); @@ -1822,13 +1822,11 @@ describe("config pull integration", () => { yield* configPull({ ...noFlags, remote: branchRemote("staging") }); expect(out.stderrText).toContain(`Pulling config from 'staging' (branch ${BRANCH_REF})`); const urls = api.requests.map((request) => request.url); - expect(urls.some((url) => url.includes(`/v1/projects/${VALID_REF}/branches/staging`))).toBe( - true, - ); + expect(urls.some((url) => url.endsWith(`/v1/projects/${VALID_REF}/branches`))).toBe(true); expect(urls.some((url) => url.includes(`/v2/projects/${BRANCH_REF}/config`))).toBe(true); assertPermissionDrift({ command: "config pull", - activeFlags: { remote: true }, + activeFlags: { remote: "staging" }, requests: api.requests, }); }).pipe(Effect.provide(layer)); @@ -1863,7 +1861,7 @@ describe("config pull integration", () => { expect(yield* readConfig).toContain(`[remotes.${BRANCH_REF}]`); assertPermissionDrift({ command: "config pull", - activeFlags: { remote: true }, + activeFlags: { remote: BRANCH_UUID }, requests: api.requests, }); }).pipe(Effect.provide(layer)); @@ -1878,11 +1876,11 @@ describe("config pull integration", () => { return Effect.gen(function* () { yield* configPull({ ...noFlags, remote: refRemote(BRANCH_REF) }); const urls = api.requests.map((request) => request.url); - expect(urls.some((url) => url.includes("/branches/"))).toBe(false); + expect(urls.some((url) => url.includes("/branches"))).toBe(false); expect(urls.some((url) => url.includes(`/v2/projects/${BRANCH_REF}/config`))).toBe(true); assertPermissionDrift({ command: "config pull", - activeFlags: { remote: true }, + activeFlags: { remote: BRANCH_REF }, requests: api.requests, exact: true, }); @@ -1904,7 +1902,7 @@ describe("config pull integration", () => { expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { const causeText = Cause.pretty(exit.cause); - expect(causeText).toContain("ConfigPullBranchNotLinkedError"); + expect(causeText).toContain("LinkBranchNotLinkedError"); expect(causeText).toContain('"somebranch"'); } expect(api.requests).toHaveLength(0); @@ -1927,7 +1925,7 @@ describe("config pull integration", () => { expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { const causeText = Cause.pretty(exit.cause); - expect(causeText).toContain("ConfigPullParentRefInvalidError"); + expect(causeText).toContain("LinkParentRefInvalidError"); expect(causeText).toContain('"somebranch"'); expect(causeText).toContain("Relink the parent project"); } @@ -1935,10 +1933,10 @@ describe("config pull integration", () => { }).pipe(Effect.provide(layer)); }); - it.effect("an unknown branch fails with a branches-list suggestion", () => { + it.effect("an unknown branch fails listing the available branches", () => { const { layer } = setup({ toml: 'project_id = "test"\n', - branchByName: { status: 404, body: { message: "not found" } }, + branchList: { status: 200, body: [BRANCH_BY_NAME] }, }); return Effect.gen(function* () { const exit = yield* configPull({ ...noFlags, remote: branchRemote("ghost") }).pipe( @@ -1947,9 +1945,10 @@ describe("config pull integration", () => { expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { const causeText = Cause.pretty(exit.cause); - expect(causeText).toContain("ConfigPullBranchNotFoundError"); - expect(causeText).toContain('Branch "ghost" not found'); - expect(causeText).toContain("supabase branches list"); + expect(causeText).toContain("LinkBranchNotFoundError"); + expect(causeText).toContain( + `Branch "ghost" not found for project ${VALID_REF}. Available branches: staging`, + ); } }).pipe(Effect.provide(layer)); }); @@ -1957,7 +1956,7 @@ describe("config pull integration", () => { it.effect("a resolved branch with no project ref yet fails with a not-ready error", () => { const { layer, api } = setup({ toml: 'project_id = "test"\n', - branchByName: { status: 200, body: { ...BRANCH_BY_NAME, project_ref: "" } }, + branchList: { status: 200, body: [{ ...BRANCH_BY_NAME, project_ref: "" }] }, }); return Effect.gen(function* () { const exit = yield* configPull({ @@ -1967,7 +1966,7 @@ describe("config pull integration", () => { expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { const causeText = Cause.pretty(exit.cause); - expect(causeText).toContain("ConfigPullBranchNotReadyError"); + expect(causeText).toContain("LinkBranchNotReadyError"); expect(causeText).toContain("has no project ref yet"); } expect(api.requests.some((request) => request.url.includes("/v2/projects/"))).toBe(false); @@ -1977,7 +1976,7 @@ describe("config pull integration", () => { it.effect("a non-404 branch lookup failure keeps its status error", () => { const { layer } = setup({ toml: 'project_id = "test"\n', - branchByName: { status: 500, body: { message: "boom" } }, + branchList: { status: 500, body: { message: "boom" } }, }); return Effect.gen(function* () { const exit = yield* configPull({ @@ -1986,7 +1985,7 @@ describe("config pull integration", () => { }).pipe(Effect.exit); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(Cause.pretty(exit.cause)).toContain("ConfigPullReadStatusError"); + expect(Cause.pretty(exit.cause)).toContain("LinkBranchListStatusError"); } }).pipe(Effect.provide(layer)); }); @@ -2135,8 +2134,8 @@ describe("config pull integration", () => { }, ); - it.effect("a branch-lookup transport failure maps to the read network error", () => { - const { layer } = setup({ toml: 'project_id = "test"\n', branchByName: "fail" }); + it.effect("a branch-lookup transport failure maps to the branch list network error", () => { + const { layer } = setup({ toml: 'project_id = "test"\n', branchList: "fail" }); return Effect.gen(function* () { const exit = yield* configPull({ ...noFlags, @@ -2145,8 +2144,8 @@ describe("config pull integration", () => { expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { const causeText = Cause.pretty(exit.cause); - expect(causeText).toContain("ConfigPullReadNetworkError"); - expect(causeText).toContain("failed to resolve branch"); + expect(causeText).toContain("LinkBranchListNetworkError"); + expect(causeText).toContain("failed to list branches"); } }).pipe(Effect.provide(layer)); }); @@ -2343,7 +2342,7 @@ describe("config pull integration", () => { const { layer } = setup({ toml: 'project_id = "test"\n', format: "json", - branchByName: { status: 404, body: { message: "not found" } }, + branchList: { status: 200, body: [BRANCH_BY_NAME] }, }); return Effect.gen(function* () { const exit = yield* configPull({ ...noFlags, remote: branchRemote("ghost") }).pipe( @@ -2351,7 +2350,7 @@ describe("config pull integration", () => { ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(Cause.pretty(exit.cause)).toContain("ConfigPullBranchNotFoundError"); + expect(Cause.pretty(exit.cause)).toContain("LinkBranchNotFoundError"); } }).pipe(Effect.provide(layer)); }); diff --git a/apps/cli/src/commands/config/push/SIDE_EFFECTS.md b/apps/cli/src/commands/config/push/SIDE_EFFECTS.md index f29448308f..b68cc4e84d 100644 --- a/apps/cli/src/commands/config/push/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/config/push/SIDE_EFFECTS.md @@ -41,8 +41,8 @@ question is declined (a decline writes nothing at all). | # | Resource | Method | Path | Success | Notes | | --- | ------------------------ | ------ | ----------------------------------------------- | ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| -2 | branch resolution (name) | GET | `/v1/projects/{parent_ref}/branches/{name}` | 200 | only when `--remote` names a branch by name (CLI-2289); `parent_ref` from the currently linked project | -| -2 | branch resolution (UUID) | GET | `/v1/branches/{id}` | 200 | only when `--remote` is a UUID (CLI-2289); needs no linked project | +| -2 | branch resolution (list) | GET | `/v1/projects/{parent}/branches` | 200 | only when `--remote` is a branch name or UUID and a project is linked; `{parent}` is `SUPABASE_PROJECT_ID`, else the linked project, resolved from local state BEFORE this call — an absent/invalid parent fails without making this request; no match → "branch not found", listing the branches that exist | +| -2 | branch resolution (UUID) | GET | `/v1/branches/{id}` | 200 | only when `--remote` is a branch UUID and no usable project is linked; the lookup needs no parent project | | -1 | target detection | GET | `/v1/projects/{ref}` | 200 or 404 | CLI-2168: 200 = plain project (its `name` is shown); 404 = `ref` is a preview branch. Wrapped in a `"Checking project..."` task and bounded at 5s. Entirely best-effort/diagnostic: a TIMEOUT, a transport failure, or any OTHER status degrades to an "unknown" target (never silently "project" — that would skip the confirmation gate for a real branch — and never a hard failure that aborts an otherwise-successful push, e.g. for a scoped token that can write service config but can't read the project record). Skipped entirely when `--remote` already named a branch by name/UUID above. | | -1 | branch name lookup | GET | `/v1/projects/{parent_ref}/branches` | 200 | only on the 404 path above, or a UUID-resolved target (never when the probe degrades to "unknown" — that never attempts recovery) — and only when `.temp/linked-project.json` names a different, ref-shaped project to treat as the parent (a branch-name `--remote` never triggers it); best-effort, 5s-bounded — a failure only omits the branch's name/parent from the echoed message, it never fails the command | | 0 | cost matrix | GET | `/v1/projects/{ref}/billing/addons` | 200 | typed client; cost map for 1-variant addons; fetched after the target probe and the branch confirmation gate | @@ -349,9 +349,9 @@ may itself contain a `.`. ## Notes -- `--remote=<value>` selects the target: a project ref (20 lowercase letters), or the name or UUID of a branch of the linked project (resolved through `GET /v1/projects/{ref}/branches/{name}` or `GET /v1/branches/{id}`). A bare `--remote` and no selector both target the linked project. A database URL is rejected (`supabase config push` takes a project, not a database); `false|no|off|0`, `--no-remote` and an empty value fail with `Leave out --remote to use the linked project.` +- `--remote=<value>` selects the target: a project ref (20 lowercase letters), or the name or UUID of a branch of the linked project (resolved through `GET /v1/projects/{parent}/branches`, or `GET /v1/branches/{id}` for a UUID when no project is linked). A bare `--remote` and no selector both target the linked project. A database URL is rejected (`supabase config push` takes a project, not a database); `false|no|off|0`, `--no-remote` and an empty value fail with `Leave out --remote to use the linked project.` - The deprecated `--project-ref` is an alias of `--remote=<ref>` (it still accepts a branch name or UUID) and prints a deprecation warning on stderr that names the replacement; it cannot be combined with `--remote=<value>`. An empty `--project-ref` is rejected. -- **`--remote` 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. +- **`--remote` 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 or UUID is resolved against the branches of the currently linked project (fails if none is linked, or if the linked ref is itself invalid); a UUID with no usable linked project resolves directly through `GET /v1/branches/{id}`. - **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 `--remote` 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 `--remote` 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 `--remote=<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. diff --git a/apps/cli/src/commands/config/push/push.command.ts b/apps/cli/src/commands/config/push/push.command.ts index 1730d01775..637186d619 100644 --- a/apps/cli/src/commands/config/push/push.command.ts +++ b/apps/cli/src/commands/config/push/push.command.ts @@ -8,7 +8,8 @@ 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 { branchTarget } from "../../../command-internal/branch-target.ts"; -import { configTarget } from "../../../command-internal/project-target.ts"; +import { projectRefPicker } from "../../../config/project-ref.layer.ts"; +import { branchResolution } from "../../../command-internal/target/branch-resolution.ts"; import { linkedProjectCache } from "../../../telemetry/linked-project-cache.layer.ts"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; import { managementApiRuntimeLayer } from "../../../command-internal/management-api-runtime.layer.ts"; @@ -57,7 +58,8 @@ export const configPushCommand = Command.make("push", config).pipe( ]), withPermissions( compose( - configTarget, + branchResolution, + projectRefPicker, { operations: [ { diff --git a/apps/cli/src/commands/config/push/push.errors.ts b/apps/cli/src/commands/config/push/push.errors.ts index 26beaf5c00..cb82848a25 100644 --- a/apps/cli/src/commands/config/push/push.errors.ts +++ b/apps/cli/src/commands/config/push/push.errors.ts @@ -5,7 +5,6 @@ import { ErrorActionabilityId, statusCodeActionability, } from "../../../shared/telemetry/error-actionability.ts"; -import { mintConfigTargetErrors } from "../../../command-internal/project-target.ts"; /** * Tagged errors for `supabase config push`. @@ -66,55 +65,6 @@ export class ConfigPushWorkdirError extends Data.TaggedError( } } -// --remote accepts a project ref, or the name (or UUID) of one of its branches; mirrors -// config diff's own error set under push's own names. -const targetErrors = mintConfigTargetErrors("ConfigPush"); - -/** `--remote` named a branch the parent project does not have. */ -export const ConfigPushBranchNotFoundError = targetErrors.BranchNotFoundError; -export type ConfigPushBranchNotFoundError = InstanceType<typeof ConfigPushBranchNotFoundError>; - -/** - * `--remote` named a branch (by name), but no project is linked to - * search for branches under — none of `SUPABASE_PROJECT_ID`, - * `supabase/.temp/linked-project.json`, or `supabase/.temp/project-ref` - * yielded a candidate. - */ -export const ConfigPushBranchNotLinkedError = targetErrors.BranchNotLinkedError; -export type ConfigPushBranchNotLinkedError = InstanceType<typeof ConfigPushBranchNotLinkedError>; - -/** - * `--remote` named a branch (by name), and a parent-project candidate - * exists but is not ref-shaped — corrupt or stale linked state. - */ -export const ConfigPushParentRefInvalidError = targetErrors.ParentRefInvalidError; -export type ConfigPushParentRefInvalidError = InstanceType<typeof ConfigPushParentRefInvalidError>; - -/** - * The resolved branch has no project ref yet (still provisioning) — guards - * against an empty/placeholder ref reaching a push target. - */ -export const ConfigPushBranchNotReadyError = targetErrors.BranchNotReadyError; -export type ConfigPushBranchNotReadyError = InstanceType<typeof ConfigPushBranchNotReadyError>; - -export class ConfigPushBranchResolveNetworkError extends Data.TaggedError( - "ConfigPushBranchResolveNetworkError", -)<DecodableNetworkErrorArgs> { - get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { - return this.decode === true - ? { ...actionability.apiStatus, fingerprint_suffix: "api_response" } - : actionability.externalNetwork; - } -} - -export class ConfigPushBranchResolveStatusError extends Data.TaggedError( - "ConfigPushBranchResolveStatusError", -)<StatusErrorArgs> { - get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { - return statusCodeActionability(this.status, { notFoundIsInvalidInput: true }); - } -} - // The getProject probe that tells the user whether ref is the linked project or a branch is // entirely best-effort: a 404 is the branch signal, and any other outcome (a timeout, a // transport failure, or an unexpected status) degrades to an uncertain target rather than diff --git a/apps/cli/src/commands/config/push/push.handler.ts b/apps/cli/src/commands/config/push/push.handler.ts index 9d9ba65fa6..c1b73782c4 100644 --- a/apps/cli/src/commands/config/push/push.handler.ts +++ b/apps/cli/src/commands/config/push/push.handler.ts @@ -13,7 +13,6 @@ import { requireHostedMutationConsent } from "../../../command-internal/hosted-c 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"; import { BRANCH_UUID_PATTERN } from "../../../command-internal/ref-patterns.ts"; import { sanitizeInlineName, @@ -21,10 +20,6 @@ import { sanitizeErrorBody, unexpectedStatusMessage, } from "../../../command-internal/http-errors.ts"; -import { - configTargetErrorsFor, - resolveConfigTarget, -} from "../../../command-internal/project-target.ts"; import { resolveProjectTarget } from "../../../command-internal/target/resolve-project-target.ts"; import { missingProjectConfigMessageEffect, @@ -58,11 +53,6 @@ import { ConfigPushApiUpdateStatusError, ConfigPushAuthUpdateNetworkError, ConfigPushAuthUpdateStatusError, - ConfigPushBranchNotFoundError, - ConfigPushBranchNotLinkedError, - ConfigPushBranchNotReadyError, - ConfigPushBranchResolveNetworkError, - ConfigPushBranchResolveStatusError, ConfigPushConfigEmptyError, ConfigPushConfigReadNetworkError, ConfigPushConfigReadStatusError, @@ -73,7 +63,6 @@ import { ConfigPushLoadConfigError, ConfigPushNetworkRestrictionsUpdateNetworkError, ConfigPushNetworkRestrictionsUpdateStatusError, - ConfigPushParentRefInvalidError, ConfigPushSslEnforcementUpdateNetworkError, ConfigPushSslEnforcementUpdateStatusError, ConfigPushStorageUpdateNetworkError, @@ -201,22 +190,6 @@ const loadPushConfig = Effect.fn("config.push.loadConfig")(function* ( }; }); -const mapPushBranchResolveError = mapHttpError({ - networkError: ConfigPushBranchResolveNetworkError, - statusError: ConfigPushBranchResolveStatusError, - networkMessage: (cause) => `failed to resolve branch: ${cause}`, - statusMessage: unexpectedStatusMessage, -}); - -/** Wires `resolveConfigTarget` (shared with `config diff`/`config pull`) to `config push`'s own - * tagged error classes. */ -const configTargetErrors = configTargetErrorsFor({ - notLinked: ConfigPushBranchNotLinkedError, - parentRefInvalid: ConfigPushParentRefInvalidError, - branchNotFound: ConfigPushBranchNotFoundError, - branchNotReady: ConfigPushBranchNotReadyError, -}); - export const configPush = Effect.fn("config.push")(function* (flags: ConfigPushFlags) { const output = yield* Output; const api = yield* CommandPlatformApi; @@ -252,13 +225,7 @@ export const configPush = Effect.fn("config.push")(function* (flags: ConfigPushF // the resolved config load before its one schema decode, so a base document that's invalid without its // overlay must never be decoded on its own — this can cost a network round trip before a // malformed `config.toml` is caught. - const { ref, branch } = yield* resolveProjectTarget( - { remote: flags.remote, projectRef: flags.projectRef }, - { - resolveRequested: (requested) => - resolveConfigTarget(requested, configTargetErrors, mapPushBranchResolveError), - }, - ); + const { ref, branch, parentRef } = yield* resolveProjectTarget(flags, { detail: true }); yield* Ref.set(resolvedRef, Option.some(ref)); yield* Effect.annotateCurrentSpan({ "project.ref": ref, @@ -298,22 +265,14 @@ 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 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)) { - knownBranch = { kind: "uuid" }; - } else { - const parent = yield* resolveLinkedParentRef(); - knownBranch = - parent.kind === "resolved" - ? { kind: "name", branchName: branch, parentRef: parent.ref } - : { kind: "uuid" }; - } - } + // 5. Determine the push target for the echo and the payload. A UUID carries no display name, + // so only a branch looked up by name under its parent is known well enough to skip the probe. + const knownBranch: ConfigPushKnownBranch | undefined = + branch === undefined + ? undefined + : !BRANCH_UUID_PATTERN.test(branch) && parentRef !== undefined + ? { kind: "name", branchName: branch, parentRef } + : { kind: "uuid" }; const target = yield* resolveConfigPushTarget(ref, { knownBranch }).pipe( Effect.withSpan("config.push.probeTarget"), ); 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 7ebb260894..6d8d938e49 100644 --- a/apps/cli/src/commands/config/push/push.integration.test.ts +++ b/apps/cli/src/commands/config/push/push.integration.test.ts @@ -280,8 +280,7 @@ interface SetupOptions { // unnamed project, and branch-lookup endpoints degrade to "not found"/empty rather than // hanging or decode-erroring. `"fail"` simulates a transport failure, distinct from a status. readonly project?: { status: number; body: unknown } | "fail"; - readonly branchList?: { status: number; body: unknown }; - readonly branchByName?: { status: number; body: unknown } | "fail"; + readonly branchList?: { status: number; body: unknown } | "fail"; readonly branchById?: { status: number; body: unknown }; /** * `cliSettings.projectId` override — defaults to `Option.some(REF)`. Pass `Option.none()` so @@ -308,7 +307,6 @@ type PushApiRoutes = Pick< | "authPatch" | "project" | "branchList" - | "branchByName" | "branchById" >; @@ -397,14 +395,10 @@ function pushApiHandler(opts: PushApiRoutes): MockCommandPlatformApiOpts["handle // 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") { + if (opts.branchList === "fail") { return Effect.fail(transportFailure(request)); } - const b = opts.branchByName ?? { status: 404, body: {} }; + const b = opts.branchList ?? { status: 200, body: [] }; return Effect.succeed(jsonResponse(request, b.status, b.body)); } if (/^\/v1\/branches\/[0-9a-f-]+$/.test(pathname)) { @@ -3445,7 +3439,7 @@ describe("config push --remote branch name/UUID resolution (CLI-2289)", () => { const { layer, api } = setup({ toml: BRANCH_PUSH_TOML, yes: true, - branchByName: { status: 200, body: BRANCH_BY_NAME }, + branchList: { status: 200, body: [BRANCH_BY_NAME] }, v2: { status: 200, body: v2Response({ ref: BRANCH_REF }) }, }); return Effect.gen(function* () { @@ -3459,7 +3453,7 @@ describe("config push --remote branch name/UUID resolution (CLI-2289)", () => { assertPermissionDrift({ command: "config push", requests: api.requests, - activeFlags: { remote: true }, + activeFlags: { remote: "staging" }, }); }).pipe(Effect.provide(layer)); }, @@ -3469,7 +3463,7 @@ describe("config push --remote branch name/UUID resolution (CLI-2289)", () => { const { layer, out } = setup({ toml: BRANCH_PUSH_TOML, yes: true, - branchByName: { status: 200, body: BRANCH_BY_NAME }, + branchList: { status: 200, body: [BRANCH_BY_NAME] }, v2: { status: 200, body: v2Response({ ref: BRANCH_REF }) }, }); return Effect.gen(function* () { @@ -3483,7 +3477,7 @@ describe("config push --remote branch name/UUID resolution (CLI-2289)", () => { toml: BRANCH_PUSH_TOML, yes: true, format: "json", - branchByName: { status: 200, body: BRANCH_BY_NAME }, + branchList: { status: 200, body: [BRANCH_BY_NAME] }, v2: { status: 200, body: v2Response({ ref: BRANCH_REF }) }, }); return Effect.gen(function* () { @@ -3517,7 +3511,7 @@ describe("config push --remote branch name/UUID resolution (CLI-2289)", () => { assertPermissionDrift({ command: "config push", requests: api.requests, - activeFlags: { remote: true }, + activeFlags: { remote: BRANCH_UUID }, }); }).pipe(Effect.provide(layer)); }); @@ -3544,7 +3538,7 @@ describe("config push --remote branch name/UUID resolution (CLI-2289)", () => { const { layer, out } = setup({ toml: BRANCH_PUSH_TOML, yes: true, - branchByName: { status: 200, body: BRANCH_BY_NAME }, + branchList: { status: 200, body: [BRANCH_BY_NAME] }, v2: { status: 200, body: v2Response({ ref: BRANCH_REF }) }, cliArgs: ["config", "push", "--project-ref", "staging"], }); @@ -3569,7 +3563,7 @@ describe("config push --remote branch name/UUID resolution (CLI-2289)", () => { remote: Option.some<RemoteValue>({ _tag: "Ref", ref: BRANCH_REF }), projectRef: Option.none(), }); - expect(api.requests.some((r) => r.url.includes("/branches/"))).toBe(false); + expect(api.requests.some((r) => r.url.includes("/branches"))).toBe(false); expect(api.requests.some((r) => r.url.includes(`/v2/projects/${BRANCH_REF}/config`))).toBe( true, ); @@ -3601,20 +3595,21 @@ describe("config push --remote branch name/UUID resolution (CLI-2289)", () => { ); }); - it.effect("--remote=<unknown-branch-name> fails with a branches-list suggestion", () => { + it.effect("--remote=<unknown-branch-name> fails listing the available branches", () => { const { layer, api, telemetry, linkedProjectCache } = setup({ toml: BRANCH_PUSH_TOML, yes: true, - branchByName: { status: 404, body: { message: "not found" } }, + branchList: { status: 200, body: [BRANCH_BY_NAME] }, }); return Effect.gen(function* () { const exit = yield* configPush(pushRemoteBranch("ghost")).pipe(Effect.exit); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { const causeText = Cause.pretty(exit.cause); - expect(causeText).toContain("ConfigPushBranchNotFoundError"); - expect(causeText).toContain('Branch "ghost" not found'); - expect(causeText).toContain("supabase branches list"); + expect(causeText).toContain("LinkBranchNotFoundError"); + expect(causeText).toContain( + `Branch "ghost" not found for project ${REF}. Available branches: staging`, + ); } expect(api.requests.some((r) => r.url.includes("/billing/addons"))).toBe(false); // Telemetry flushes even though ref resolution failed; the linked-project cache stays @@ -3631,13 +3626,13 @@ describe("config push --remote branch name/UUID resolution (CLI-2289)", () => { toml: BRANCH_PUSH_TOML, yes: true, format: "json", - branchByName: { status: 404, body: { message: "not found" } }, + branchList: { status: 200, body: [BRANCH_BY_NAME] }, }); return Effect.gen(function* () { const exit = yield* configPush(pushRemoteBranch("ghost")).pipe(Effect.exit); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(Cause.pretty(exit.cause)).toContain("ConfigPushBranchNotFoundError"); + expect(Cause.pretty(exit.cause)).toContain("LinkBranchNotFoundError"); } expect(api.requests.some((r) => r.url.includes("/billing/addons"))).toBe(false); }).pipe(Effect.provide(layer)); @@ -3655,7 +3650,7 @@ describe("config push --remote branch name/UUID resolution (CLI-2289)", () => { expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { const causeText = Cause.pretty(exit.cause); - expect(causeText).toContain("ConfigPushBranchNotLinkedError"); + expect(causeText).toContain("LinkBranchNotLinkedError"); expect(causeText).toContain('"somebranch"'); } expect(api.requests).toHaveLength(0); @@ -3675,7 +3670,7 @@ describe("config push --remote branch name/UUID resolution (CLI-2289)", () => { expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { const causeText = Cause.pretty(exit.cause); - expect(causeText).toContain("ConfigPushParentRefInvalidError"); + expect(causeText).toContain("LinkParentRefInvalidError"); expect(causeText).toContain('"somebranch"'); } expect(api.requests).toHaveLength(0); @@ -3688,14 +3683,14 @@ describe("config push --remote branch name/UUID resolution (CLI-2289)", () => { const { layer, api, telemetry, linkedProjectCache } = setup({ toml: BRANCH_PUSH_TOML, yes: true, - branchByName: { status: 200, body: { ...BRANCH_BY_NAME, project_ref: "" } }, + branchList: { status: 200, body: [{ ...BRANCH_BY_NAME, project_ref: "" }] }, }); return Effect.gen(function* () { const exit = yield* configPush(pushRemoteBranch("staging")).pipe(Effect.exit); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { const causeText = Cause.pretty(exit.cause); - expect(causeText).toContain("ConfigPushBranchNotReadyError"); + expect(causeText).toContain("LinkBranchNotReadyError"); expect(causeText).toContain("has no project ref yet"); } expect(api.requests.some((r) => r.url.includes("/billing/addons"))).toBe(false); @@ -3706,17 +3701,17 @@ describe("config push --remote branch name/UUID resolution (CLI-2289)", () => { }).pipe(Effect.provide(layer)); }); - it.effect("a transport failure resolving a branch name maps to the resolve network error", () => { + it.effect("a transport failure listing branches maps to the branch list network error", () => { const { layer, telemetry, linkedProjectCache } = setup({ toml: BRANCH_PUSH_TOML, yes: true, - branchByName: "fail", + branchList: "fail", }); return Effect.gen(function* () { const exit = yield* configPush(pushRemoteBranch("staging")).pipe(Effect.exit); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(Cause.pretty(exit.cause)).toContain("ConfigPushBranchResolveNetworkError"); + expect(Cause.pretty(exit.cause)).toContain("LinkBranchListNetworkError"); } expect(telemetry.flushed).toBe(true); expect(linkedProjectCache.cachedRef).toBeUndefined(); @@ -3727,13 +3722,13 @@ describe("config push --remote branch name/UUID resolution (CLI-2289)", () => { const { layer, telemetry, linkedProjectCache } = setup({ toml: BRANCH_PUSH_TOML, yes: true, - branchByName: { status: 500, body: { message: "boom" } }, + branchList: { status: 500, body: { message: "boom" } }, }); return Effect.gen(function* () { const exit = yield* configPush(pushRemoteBranch("staging")).pipe(Effect.exit); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(Cause.pretty(exit.cause)).toContain("ConfigPushBranchResolveStatusError"); + expect(Cause.pretty(exit.cause)).toContain("LinkBranchListStatusError"); } expect(telemetry.flushed).toBe(true); expect(linkedProjectCache.cachedRef).toBeUndefined(); diff --git a/apps/cli/src/commands/db/advisors/SIDE_EFFECTS.md b/apps/cli/src/commands/db/advisors/SIDE_EFFECTS.md index 2cabd472db..cd5c2d37e1 100644 --- a/apps/cli/src/commands/db/advisors/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/db/advisors/SIDE_EFFECTS.md @@ -35,6 +35,7 @@ The local lint query runs inside a transaction that is **always rolled back**. | GET | `/v1/projects/{ref}/advisors/security` | Bearer | — | `{ lints: Lint[] }` | | GET | `/v1/projects/{ref}/advisors/performance` | Bearer | — | `{ lints: Lint[] }` | | GET | `/v1/projects/{parent}/branches` | Bearer | — | `[{id, name, project_ref}]` — only for `--remote=<branch>`, and for a bare `--remote` followed by a word that could name a branch; `{parent}` is `SUPABASE_PROJECT_ID`, else the linked project | +| GET | `/v1/branches/{id}` | Bearer | — | `{ref, status, …}` — only for `--remote=<branch UUID>` when no project is linked | Issued through the typed client; the generated schema keeps advisor `name` / `metadata.type` open, so values the API can add do not fail decoding. diff --git a/apps/cli/src/commands/db/advisors/advisors.handler.ts b/apps/cli/src/commands/db/advisors/advisors.handler.ts index 7a501d937c..53158c599c 100644 --- a/apps/cli/src/commands/db/advisors/advisors.handler.ts +++ b/apps/cli/src/commands/db/advisors/advisors.handler.ts @@ -202,13 +202,7 @@ const runAdvisors = Effect.fnUntraced(function* ( flags: DbAdvisorsFlags, dnsResolver: "native" | "https", ) { - const target = yield* resolveTarget({ - remote: flags.remote, - local: flags.local, - linked: flags.linked, - dbUrl: flags.dbUrl, - projectRef: flags.projectRef, - }); + const target = yield* resolveTarget(flags); const advisorType = Option.getOrElse(flags.type, () => "all"); const level = Option.getOrElse(flags.level, () => "warn"); diff --git a/apps/cli/src/commands/db/diff/SIDE_EFFECTS.md b/apps/cli/src/commands/db/diff/SIDE_EFFECTS.md index 1a960a3aad..765c2057c2 100644 --- a/apps/cli/src/commands/db/diff/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/db/diff/SIDE_EFFECTS.md @@ -105,6 +105,7 @@ 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) | | GET | `/v1/projects/{parent}/branches` | Bearer | Branch lookup — only for `--remote=<branch>`, a bare `--remote` followed by a word that could name a branch, and a `--from`/`--to` value naming a branch; `{parent}` is `SUPABASE_PROJECT_ID`, else the linked project | +| GET | `/v1/branches/{id}` | Bearer | Branch lookup — only for `--remote=<branch UUID>` when no project is linked | `--use-pgadmin` with a remote target performs every one of these calls natively (CLI-1968), as part of this command's own target resolve, ahead of the differ container. diff --git a/apps/cli/src/commands/db/diff/diff.handler.ts b/apps/cli/src/commands/db/diff/diff.handler.ts index 28f4a689fe..f96340087d 100644 --- a/apps/cli/src/commands/db/diff/diff.handler.ts +++ b/apps/cli/src/commands/db/diff/diff.handler.ts @@ -22,12 +22,15 @@ import { import { DbConfigResolver } from "../../../command-internal/db-config.service.ts"; import { hostedMutationPreapproval } from "../../../command-internal/hosted-consent/hosted-consent.ts"; import { - resolveRemoteTarget, resolveTarget, type LinkedTarget, } from "../../../command-internal/target/resolve-target.ts"; import type { RemoteValue } from "../../../command-internal/target/remote-value.ts"; -import { warnTarget } from "../../../command-internal/target/remote-resolution.ts"; +import { + resolveRemoteTarget, + warnTarget, +} from "../../../command-internal/target/remote-resolution.ts"; +import { hasExplicitSelector } from "../../../command-internal/target/target-selector.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"; @@ -188,20 +191,8 @@ export const dbDiff = Effect.fn("db.diff")(function* (flags: DbDiffFlags) { const hostedEndpoints = new Map<string, PgDeltaDatabaseEndpoint>(); // A target flag validates the selection and, for a hosted project, merges // `[remotes.<ref>]` so the explicit `local`/`migrations` refs below see that override. - if ( - flags.local || - flags.linked || - Option.isSome(flags.remote) || - Option.isSome(flags.dbUrl) || - Option.isSome(flags.projectRef) - ) { - const preflight = yield* resolveTarget({ - remote: flags.remote, - local: flags.local, - linked: flags.linked, - dbUrl: flags.dbUrl, - projectRef: flags.projectRef, - }); + if (hasExplicitSelector(flags)) { + const preflight = yield* resolveTarget(flags); if (preflight.kind === "linked") { linkedTarget = preflight; yield* Ref.set(linkedRefForCache, Option.some(preflight.ref)); @@ -400,13 +391,7 @@ 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 diffTarget = yield* resolveTarget({ - remote: flags.remote, - local: flags.local, - linked: flags.linked, - dbUrl: flags.dbUrl, - projectRef: flags.projectRef, - }); + const diffTarget = yield* resolveTarget(flags); // The config is read before `resolver.resolveConnection()` below, so the "Loading config // override" print and merged-config validation happen before the actual connection work diff --git a/apps/cli/src/commands/db/dump/SIDE_EFFECTS.md b/apps/cli/src/commands/db/dump/SIDE_EFFECTS.md index 60e89e5296..ab6e70fbe8 100644 --- a/apps/cli/src/commands/db/dump/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/db/dump/SIDE_EFFECTS.md @@ -37,6 +37,7 @@ It only reads, so it never asks for confirmation, on any target. | POST | `/v1/projects/{ref}/network-bans/retrieve` | Bearer | remote pooler temp-role retry (list bans) | | DELETE | `/v1/projects/{ref}/network-bans` | Bearer | remote pooler temp-role retry (clear self ban) | | GET | `/v1/projects/{parent}/branches` | Bearer | Branch lookup — only for `--remote=<branch>`, and for a bare `--remote` followed by a word that could name a branch; `{parent}` is `SUPABASE_PROJECT_ID`, else the linked project | +| GET | `/v1/branches/{id}` | Bearer | Branch lookup — only for `--remote=<branch UUID>` when no project is linked | (The login-role and network-ban calls go through the shared `DbConfigResolver` linked path.) diff --git a/apps/cli/src/commands/db/dump/dump.handler.ts b/apps/cli/src/commands/db/dump/dump.handler.ts index 2ff73b6411..3f377be6cd 100644 --- a/apps/cli/src/commands/db/dump/dump.handler.ts +++ b/apps/cli/src/commands/db/dump/dump.handler.ts @@ -134,13 +134,7 @@ export const dbDump = Effect.fn("db.dump")(function* (flags: DbDumpFlags) { } // 3. Resolve the target (`--remote` or an alias, else local) and connect to it. - const target = yield* resolveTarget({ - remote: flags.remote, - local: flags.local, - linked: flags.linked, - dbUrl: flags.dbUrl, - projectRef: flags.projectRef, - }); + const target = yield* resolveTarget(flags); // The linked-project cache is refreshed unconditionally afterward, even on a // connection-resolution failure. if (target.kind === "linked") { diff --git a/apps/cli/src/commands/db/lint/SIDE_EFFECTS.md b/apps/cli/src/commands/db/lint/SIDE_EFFECTS.md index 6d1b983463..dedacb5dd2 100644 --- a/apps/cli/src/commands/db/lint/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/db/lint/SIDE_EFFECTS.md @@ -39,7 +39,8 @@ resolve credentials); the lint itself issues no advisor/API requests. `--remote=<branch>`, and a bare `--remote` followed by a word that could name a branch, also call `GET /v1/projects/{parent}/branches` (Bearer) to find the branch's project; `{parent}` is -`SUPABASE_PROJECT_ID`, else the linked project. +`SUPABASE_PROJECT_ID`, else the linked project. A branch UUID with no linked project is resolved +through `GET /v1/branches/{id}` instead. ## Database diff --git a/apps/cli/src/commands/db/lint/lint.handler.ts b/apps/cli/src/commands/db/lint/lint.handler.ts index 4ca5c41e98..8c9dc09c92 100644 --- a/apps/cli/src/commands/db/lint/lint.handler.ts +++ b/apps/cli/src/commands/db/lint/lint.handler.ts @@ -101,13 +101,7 @@ const runLint = Effect.fnUntraced(function* (flags: DbLintFlags, dnsResolver: "n const dbConn = yield* DbConnection; const processControl = yield* ProcessControl; - const target = yield* resolveTarget({ - remote: flags.remote, - local: flags.local, - linked: flags.linked, - dbUrl: flags.dbUrl, - projectRef: flags.projectRef, - }); + const target = yield* resolveTarget(flags); const level = Option.getOrElse(flags.level, () => "warning"); const failOn = Option.getOrElse(flags.failOn, () => "none"); diff --git a/apps/cli/src/commands/db/pull/SIDE_EFFECTS.md b/apps/cli/src/commands/db/pull/SIDE_EFFECTS.md index 33240565e5..2ceab421c5 100644 --- a/apps/cli/src/commands/db/pull/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/db/pull/SIDE_EFFECTS.md @@ -127,6 +127,7 @@ at all, so nothing is cached for it. | 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) | | GET | `/v1/projects/{parent}/branches` | Bearer | Branch lookup — only for `--remote=<branch>`, and for a bare `--remote` followed by a word that could name a branch; `{parent}` is `SUPABASE_PROJECT_ID`, else the linked project | +| GET | `/v1/branches/{id}` | Bearer | Branch lookup — only for `--remote=<branch UUID>` when no project is linked | | SQL | `SELECT version FROM …schema_migrations` | — | history reconciliation (remote) | | SQL | `INSERT … ON CONFLICT … schema_migrations` (UPSERT) | — | history update (after consent) | diff --git a/apps/cli/src/commands/db/push/SIDE_EFFECTS.md b/apps/cli/src/commands/db/push/SIDE_EFFECTS.md index e24542a2eb..6f81da7a6d 100644 --- a/apps/cli/src/commands/db/push/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/db/push/SIDE_EFFECTS.md @@ -40,9 +40,9 @@ selector in a linked checkout, the `Target:` line adds `default; pass --remote f ## API Routes -| Method | Path | Auth | Request body | Response (used fields) | -| ------ | ---- | ---- | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| — | — | — | — | The native handler connects to Postgres directly. On a remote project or branch the db-config resolver may call the Management API to mint a temporary login role (inherited from the shared resolver), and `--remote=<branch>` (or a bare `--remote` followed by a word that could name a branch) calls `GET /v1/projects/{parent}/branches` to find the branch's project (`{parent}` is `SUPABASE_PROJECT_ID`, else the linked project). | +| Method | Path | Auth | Request body | Response (used fields) | +| ------ | ---- | ---- | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| — | — | — | — | The native handler connects to Postgres directly. On a remote project or branch the db-config resolver may call the Management API to mint a temporary login role (inherited from the shared resolver), and `--remote=<branch>` (or a bare `--remote` followed by a word that could name a branch) calls `GET /v1/projects/{parent}/branches` to find the branch's project (`{parent}` is `SUPABASE_PROJECT_ID`, else the linked project; a branch UUID with no linked project uses `GET /v1/branches/{id}`). | ## Environment Variables diff --git a/apps/cli/src/commands/db/push/push.handler.ts b/apps/cli/src/commands/db/push/push.handler.ts index 550ee6254a..b7d2d4eb02 100644 --- a/apps/cli/src/commands/db/push/push.handler.ts +++ b/apps/cli/src/commands/db/push/push.handler.ts @@ -37,13 +37,7 @@ export const dbPush = Effect.fn("db.push")(function* (flags: DbPushFlags) { const linkedRefForCache = yield* Ref.make<string | undefined>(undefined); const body = Effect.gen(function* () { - const target = yield* resolveTarget({ - remote: flags.remote, - local: flags.local, - linked: flags.linked, - dbUrl: flags.dbUrl, - projectRef: flags.projectRef, - }); + const target = yield* resolveTarget(flags); const projectRef = target.kind === "linked" ? target.ref : ""; if (target.kind === "linked") yield* Ref.set(linkedRefForCache, projectRef); diff --git a/apps/cli/src/commands/db/query/SIDE_EFFECTS.md b/apps/cli/src/commands/db/query/SIDE_EFFECTS.md index f6abf48ca7..c3fe228c24 100644 --- a/apps/cli/src/commands/db/query/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/db/query/SIDE_EFFECTS.md @@ -39,6 +39,7 @@ stack never prompts. | POST | `/v1/projects/{ref}/database/query` | Bearer | `{"query":"<sql>"}`, plus `"read_only":true` with `--read-only` | 201, JSON array of row objects (read via `executeRaw`, since the typed operation voids the body). | | GET | `/v1/projects/{ref}` | Bearer | — | 200 → linked-project cache write; any other status → no write. Fired after the query on a remote project or branch. | | GET | `/v1/projects/{parent}/branches` | Bearer | — | 200 → `[{id, name, project_ref}]`. Branch lookup — only for `--remote=<branch>`, and for a bare `--remote` followed by a word that could name a branch; `{parent}` is `SUPABASE_PROJECT_ID`, else the linked project. | +| GET | `/v1/branches/{id}` | Bearer | — | 200 → `{ref, status, …}`. Branch lookup — only for `--remote=<branch UUID>` when no project is linked | ## Environment Variables diff --git a/apps/cli/src/commands/db/query/query.handler.ts b/apps/cli/src/commands/db/query/query.handler.ts index a3a82148cc..e0951cb791 100644 --- a/apps/cli/src/commands/db/query/query.handler.ts +++ b/apps/cli/src/commands/db/query/query.handler.ts @@ -268,13 +268,7 @@ export const dbQuery = Effect.fn("db.query")(function* (flags: DbQueryFlags) { }); yield* Effect.gen(function* () { - const target = yield* resolveTarget({ - remote: flags.remote, - local: flags.local, - linked: flags.linked, - dbUrl: flags.dbUrl, - projectRef: flags.projectRef, - }); + const target = yield* resolveTarget(flags); // Recorded now so the cache finalizer still fires if a later step fails. if (target.kind === "linked") yield* Ref.set(linkedRefForCache, target.ref); diff --git a/apps/cli/src/commands/db/reset/SIDE_EFFECTS.md b/apps/cli/src/commands/db/reset/SIDE_EFFECTS.md index 79d5693c4d..ce33194306 100644 --- a/apps/cli/src/commands/db/reset/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/db/reset/SIDE_EFFECTS.md @@ -158,10 +158,10 @@ the whole reset** (not just "skip buckets"). ## API Routes -| Method | Path | Auth | Request body | Response (used fields) | -| ------ | ---------------------------------------- | ---- | ------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| — | — | — | — | Connects to Postgres directly. A remote project or branch may call the Management API to mint a temporary login role (and `--remote=<branch>`, or a bare `--remote` followed by a word that could name a branch, calls `GET /v1/projects/{parent}/branches` to find the branch's project; `{parent}` is `SUPABASE_PROJECT_ID`, else the linked project); local bucket seeding calls the Storage gateway. | -| GET | `/containers/supabase_db_<project>/json` | — | — | local path — the running probe's Docker Engine API request over the active context's local socket/named pipe (`Api-Version`/`Server` identity headers + status; a 200 body must parse as a JSON object; identity-gated 200 → running, 404 → absent; anything else → the spawned-CLI fallback) | +| Method | Path | Auth | Request body | Response (used fields) | +| ------ | ---------------------------------------- | ---- | ------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| — | — | — | — | Connects to Postgres directly. A remote project or branch may call the Management API to mint a temporary login role (and `--remote=<branch>`, or a bare `--remote` followed by a word that could name a branch, calls `GET /v1/projects/{parent}/branches` to find the branch's project; `{parent}` is `SUPABASE_PROJECT_ID`, else the linked project; a branch UUID with no linked project uses `GET /v1/branches/{id}`); local bucket seeding calls the Storage gateway. | +| GET | `/containers/supabase_db_<project>/json` | — | — | local path — the running probe's Docker Engine API request over the active context's local socket/named pipe (`Api-Version`/`Server` identity headers + status; a 200 body must parse as a JSON object; identity-gated 200 → running, 404 → absent; anything else → the spawned-CLI fallback) | ## Environment Variables diff --git a/apps/cli/src/commands/db/reset/reset.handler.ts b/apps/cli/src/commands/db/reset/reset.handler.ts index 69e15cf7e8..882aa25f41 100644 --- a/apps/cli/src/commands/db/reset/reset.handler.ts +++ b/apps/cli/src/commands/db/reset/reset.handler.ts @@ -153,13 +153,7 @@ export const dbReset = Effect.fn("db.reset")(function* (flags: DbResetFlags) { resolvedVersion = versions[total - last - 1] ?? "-"; } - const target = yield* resolveTarget({ - remote: flags.remote, - local: flags.local, - linked: flags.linked, - dbUrl: flags.dbUrl, - projectRef: flags.projectRef, - }); + const target = yield* resolveTarget(flags); // 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). 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 fefebbf091..c85a3f2283 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 @@ -124,8 +124,9 @@ deleted.` (default no); a decline, or no answer, prints `Skipped writing declara - Remote Supabase targets use the shared connection/TLS behavior. `--remote=<branch>`, and a bare `--remote` followed by a word that could name a branch, call `GET /v1/projects/{parent}/branches` to find the branch's project (`{parent}` is - `SUPABASE_PROJECT_ID`, else the linked project); the connection may also mint a temporary login - role through the Management API. + `SUPABASE_PROJECT_ID`, else the linked project; a branch UUID with no linked project uses + `GET /v1/branches/{id}`); the connection may also mint a temporary login role through the + Management API. - **Architecture:** the engine extracts and renders the target in-process. - **Stale local-container guard.** A local target inspects the running local `db` container's actual image and compares it against the diff --git a/apps/cli/src/commands/domains/SIDE_EFFECTS.md b/apps/cli/src/commands/domains/SIDE_EFFECTS.md index d4430ae982..8b8756aba6 100644 --- a/apps/cli/src/commands/domains/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/domains/SIDE_EFFECTS.md @@ -32,6 +32,7 @@ Cloudflare DNS-over-HTTPS CNAME pre-check. | `DELETE` | `/v1/projects/{ref}/custom-hostname` | Bearer token | none | 200 → empty/void body (`delete`) | | `GET` | `/v1/projects/{ref}` | Bearer token | none | `{ref, name, organization_id, organization_slug}` — `linkedProjectCache` fill, best-effort, skipped when `.temp/linked-project.json` exists, `.temp/project-ref` names another ref, or no token is available | | `GET` | `/v1/projects/{parent}/branches` | Bearer token | none | `[{id, name, project_ref, is_default}]` — only for `--remote=<branch>` (or the deprecated `--project-ref=<branch>`), and for a bare `--remote` followed by a word that could name a branch; `{parent}` is `SUPABASE_PROJECT_ID`, else the linked project | +| `GET` | `/v1/branches/{id}` | Bearer token | none | `{ref, status, …}` — only for `--remote=<branch UUID>` when no project is linked | ## Environment Variables @@ -97,7 +98,7 @@ suppressed on stderr. `delete` ignores `-o`. ## Notes -- Targets the linked project unless `--remote=<ref>` is given; a bare `--remote` also targets the linked project. `--remote=<value>` is read as a project ref (20 lowercase letters), a branch of the linked project (by name or UUID, resolved through `GET /v1/projects/{parent}/branches`), or `true|yes|on|1` for the linked project. A database URL is rejected (these commands take a project, not a database); `false|no|off|0`, `--no-remote` and an empty value fail with `Leave out --remote to use the linked project.` +- Targets the linked project unless `--remote=<ref>` is given; a bare `--remote` also targets the linked project. `--remote=<value>` is read as a project ref (20 lowercase letters), a branch of the linked project (by name or UUID, resolved through `GET /v1/projects/{parent}/branches`, or `GET /v1/branches/{id}` for a UUID when no project is linked), or `true|yes|on|1` for the linked project. A database URL is rejected (these commands take a project, not a database); `false|no|off|0`, `--no-remote` and an empty value fail with `Leave out --remote to use the linked project.` - A word after a bare `--remote` is read as its value, `--remote=<word>`, because the command takes no operands. - The deprecated `--project-ref` is an alias of `--remote=<ref>` and prints a deprecation warning on stderr that names the replacement; it cannot be combined with `--remote=<value>`. An empty `--project-ref` is rejected. - `--custom-hostname` is required for `create`. diff --git a/apps/cli/src/commands/domains/activate/activate.handler.ts b/apps/cli/src/commands/domains/activate/activate.handler.ts index 2d34e86bc0..3ecc38ee71 100644 --- a/apps/cli/src/commands/domains/activate/activate.handler.ts +++ b/apps/cli/src/commands/domains/activate/activate.handler.ts @@ -21,7 +21,7 @@ export const domainsActivate = Effect.fn("domains.activate")(function* ( const linkedProjectCache = yield* LinkedProjectCache; const telemetryState = yield* TelemetryState; - const ref = yield* resolveProjectTarget({ remote: flags.remote, projectRef: flags.projectRef }); + const ref = yield* resolveProjectTarget(flags); yield* Effect.gen(function* () { yield* requireHostedMutationConsent({ diff --git a/apps/cli/src/commands/domains/create/create.handler.ts b/apps/cli/src/commands/domains/create/create.handler.ts index a8263ed726..8eaf665f15 100644 --- a/apps/cli/src/commands/domains/create/create.handler.ts +++ b/apps/cli/src/commands/domains/create/create.handler.ts @@ -24,7 +24,7 @@ export const domainsCreate = Effect.fn("domains.create")(function* (flags: Domai const linkedProjectCache = yield* LinkedProjectCache; const telemetryState = yield* TelemetryState; - const ref = yield* resolveProjectTarget({ remote: flags.remote, projectRef: flags.projectRef }); + const ref = yield* resolveProjectTarget(flags); // Write the linked-project cache and persist the telemetry state file on // success and failure. diff --git a/apps/cli/src/commands/domains/delete/delete.handler.ts b/apps/cli/src/commands/domains/delete/delete.handler.ts index 248e428e8c..bd37fa6e4d 100644 --- a/apps/cli/src/commands/domains/delete/delete.handler.ts +++ b/apps/cli/src/commands/domains/delete/delete.handler.ts @@ -19,7 +19,7 @@ export const domainsDelete = Effect.fn("domains.delete")(function* (flags: Domai const linkedProjectCache = yield* LinkedProjectCache; const telemetryState = yield* TelemetryState; - const ref = yield* resolveProjectTarget({ remote: flags.remote, projectRef: flags.projectRef }); + const ref = yield* resolveProjectTarget(flags); yield* Effect.gen(function* () { yield* requireHostedMutationConsent({ action: "Delete the custom hostname config" }); diff --git a/apps/cli/src/commands/domains/get/get.handler.ts b/apps/cli/src/commands/domains/get/get.handler.ts index 6fe34d7341..fb14b2e700 100644 --- a/apps/cli/src/commands/domains/get/get.handler.ts +++ b/apps/cli/src/commands/domains/get/get.handler.ts @@ -18,7 +18,7 @@ export const domainsGet = Effect.fn("domains.get")(function* (flags: DomainsGetF const linkedProjectCache = yield* LinkedProjectCache; const telemetryState = yield* TelemetryState; - const ref = yield* resolveProjectTarget({ remote: flags.remote, projectRef: flags.projectRef }); + const ref = yield* resolveProjectTarget(flags); // Write the linked-project cache and persist the telemetry state file on // success and failure. diff --git a/apps/cli/src/commands/domains/reverify/reverify.handler.ts b/apps/cli/src/commands/domains/reverify/reverify.handler.ts index 8058e2cc1f..6683caea67 100644 --- a/apps/cli/src/commands/domains/reverify/reverify.handler.ts +++ b/apps/cli/src/commands/domains/reverify/reverify.handler.ts @@ -20,7 +20,7 @@ export const domainsReverify = Effect.fn("domains.reverify")(function* ( const linkedProjectCache = yield* LinkedProjectCache; const telemetryState = yield* TelemetryState; - const ref = yield* resolveProjectTarget({ remote: flags.remote, projectRef: flags.projectRef }); + const ref = yield* resolveProjectTarget(flags); yield* Effect.gen(function* () { const reverifying = diff --git a/apps/cli/src/commands/encryption/SIDE_EFFECTS.md b/apps/cli/src/commands/encryption/SIDE_EFFECTS.md index 4d85113f94..1f97921da8 100644 --- a/apps/cli/src/commands/encryption/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/encryption/SIDE_EFFECTS.md @@ -29,6 +29,7 @@ additionally reads the new key from stdin. | `PUT` | `/v1/projects/{ref}/pgsodium` | Bearer token | `{root_key}` | `{root_key}` | | `GET` | `/v1/projects/{ref}` | Bearer token | none | `{ref, name, organization_id, organization_slug}` — `linkedProjectCache` fill, best-effort, skipped when `.temp/linked-project.json` exists, `.temp/project-ref` names another ref, or no token is available | | `GET` | `/v1/projects/{parent}/branches` | Bearer token | none | `[{id, name, project_ref, is_default}]` — only for `--remote=<branch>` (or the deprecated `--project-ref=<branch>`), and for a bare `--remote` followed by a word that could name a branch; `{parent}` is `SUPABASE_PROJECT_ID`, else the linked project | +| `GET` | `/v1/branches/{id}` | Bearer token | none | `{ref, status, …}` — only for `--remote=<branch UUID>` when no project is linked | `get-root-key` calls `GET`; `update-root-key` calls `PUT`. @@ -82,7 +83,7 @@ One `result` event carrying `{root_key}` (both subcommands). ## Notes -- Targets the linked project unless `--remote=<ref>` is given; a bare `--remote` also targets the linked project. `--remote=<value>` is read as a project ref (20 lowercase letters), a branch of the linked project (by name or UUID, resolved through `GET /v1/projects/{parent}/branches`), or `true|yes|on|1` for the linked project. A database URL is rejected (these commands take a project, not a database); `false|no|off|0`, `--no-remote` and an empty value fail with `Leave out --remote to use the linked project.` +- Targets the linked project unless `--remote=<ref>` is given; a bare `--remote` also targets the linked project. `--remote=<value>` is read as a project ref (20 lowercase letters), a branch of the linked project (by name or UUID, resolved through `GET /v1/projects/{parent}/branches`, or `GET /v1/branches/{id}` for a UUID when no project is linked), or `true|yes|on|1` for the linked project. A database URL is rejected (these commands take a project, not a database); `false|no|off|0`, `--no-remote` and an empty value fail with `Leave out --remote to use the linked project.` - A word after a bare `--remote` is read as its value, `--remote=<word>`, because the command takes no operands. - The deprecated `--project-ref` is an alias of `--remote=<ref>` and prints a deprecation warning on stderr that names the replacement; it cannot be combined with `--remote=<value>`. An empty `--project-ref` is rejected. - Requires `--remote=<ref>`, `SUPABASE_PROJECT_ID`, or a linked project. diff --git a/apps/cli/src/commands/encryption/get-root-key/get-root-key.handler.ts b/apps/cli/src/commands/encryption/get-root-key/get-root-key.handler.ts index 4abd82b5ca..20a8687f00 100644 --- a/apps/cli/src/commands/encryption/get-root-key/get-root-key.handler.ts +++ b/apps/cli/src/commands/encryption/get-root-key/get-root-key.handler.ts @@ -18,7 +18,7 @@ export const encryptionGetRootKey = Effect.fn("encryption.get-root-key")(functio const linkedProjectCache = yield* LinkedProjectCache; const telemetryState = yield* TelemetryState; - const ref = yield* resolveProjectTarget({ remote: flags.remote, projectRef: flags.projectRef }); + const ref = yield* resolveProjectTarget(flags); // Write the linked-project cache and persist the telemetry state file on // success and failure. 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 0ddea40858..0c1bddad67 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 @@ -25,7 +25,7 @@ export const encryptionUpdateRootKey = Effect.fn("encryption.update-root-key")(f const linkedProjectCache = yield* LinkedProjectCache; const telemetryState = yield* TelemetryState; - const ref = yield* resolveProjectTarget({ remote: flags.remote, projectRef: flags.projectRef }); + const ref = yield* resolveProjectTarget(flags); // In text mode, the piped-input path writes the prompt to stderr and echoes a // trailing newline to stdout after reading; on a TTY the masked prompt uses 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 40e8cde6ce..dbfebab416 100644 --- a/apps/cli/src/commands/experimental/compute/delete/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/experimental/compute/delete/SIDE_EFFECTS.md @@ -56,6 +56,7 @@ known) are written to stderr before the typed-name prompt. | `GET` | `/v1/projects` | Bearer token | none | `id`, `name`, `organization_slug`, `region` — only when no ref resolved and the session is interactive, to populate the project picker | | `GET` | `/v1/projects/{ref}` | Bearer token | none | `ref`, `name`, `organization_id`, `organization_slug` — linked-project cache miss only (best-effort, post-run): written only when `<workdir>/supabase/.temp/linked-project.json` doesn't exist, `.temp/project-ref` doesn't name another ref, an access token exists and the lookup returns 200 | | `GET` | `/v1/projects/{parent}/branches` | Bearer token | none | `[{id, name, project_ref, is_default}]` — only for `--remote=<branch>` (or the deprecated `--project-ref=<branch>`), and for a bare `--remote` followed by a word that could name a branch; `{parent}` is `SUPABASE_PROJECT_ID`, else the linked project | +| `GET` | `/v1/branches/{id}` | Bearer token | none | `{ref, status, …}` — only for `--remote=<branch UUID>` when no project is linked | ## Exit Codes @@ -109,6 +110,6 @@ fall through to that same text branch. ## Notes -- Targets the linked project unless `--remote=<ref>` is given; a bare `--remote` also targets the linked project. `--remote=<value>` is read as a project ref (20 lowercase letters), a branch of the linked project (by name or UUID, resolved through `GET /v1/projects/{parent}/branches`), or `true|yes|on|1` for the linked project. A database URL is rejected (`supabase experimental compute delete` takes a project, not a database); `false|no|off|0`, `--no-remote` and an empty value fail with `Leave out --remote to use the linked project.` +- Targets the linked project unless `--remote=<ref>` is given; a bare `--remote` also targets the linked project. `--remote=<value>` is read as a project ref (20 lowercase letters), a branch of the linked project (by name or UUID, resolved through `GET /v1/projects/{parent}/branches`, or `GET /v1/branches/{id}` for a UUID when no project is linked), or `true|yes|on|1` for the linked project. A database URL is rejected (`supabase experimental compute delete` takes a project, not a database); `false|no|off|0`, `--no-remote` and an empty value fail with `Leave out --remote to use the linked project.` - A bare `--remote` followed by a word that exactly matches a branch of the linked project, is within a small edit distance of one, or can't be checked because the branch listing failed stops the run before any other work with an error that offers both readings: `--remote=<word>` for the branch, `<word> --remote` for the operand. Any other word runs as the operand. - The deprecated `--project-ref` is an alias of `--remote=<ref>` and prints a deprecation warning on stderr that names the replacement; it cannot be combined with `--remote=<value>`. An empty `--project-ref` is rejected. 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 d324f1f6b2..f544f933e3 100644 --- a/apps/cli/src/commands/experimental/compute/delete/delete.handler.ts +++ b/apps/cli/src/commands/experimental/compute/delete/delete.handler.ts @@ -46,10 +46,7 @@ export const computeDelete = Effect.fn("compute.delete")(function* (flags: Compu // them; everything that can fail on its own — loading `config.toml`, // validating the name, resolving the compute — belongs inside, so those // failures still flush telemetry. Same shape as `config/push`. - const projectRef = yield* resolveProjectTarget({ - remote: flags.remote, - projectRef: flags.projectRef, - }); + const projectRef = yield* resolveProjectTarget(flags); // Every retry this command suggests is for a *destructive* re-run, so the ref // has to survive the copy-paste. const refSuffix = computeRemoteSuffix(flags, projectRef); diff --git a/apps/cli/src/commands/experimental/compute/list/SIDE_EFFECTS.md b/apps/cli/src/commands/experimental/compute/list/SIDE_EFFECTS.md index 885d125ec6..05b01b90e3 100644 --- a/apps/cli/src/commands/experimental/compute/list/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/experimental/compute/list/SIDE_EFFECTS.md @@ -41,6 +41,7 @@ and the command handler does not run. See the [Compute command guide](../../../. | `GET` | `/v1/projects` | Bearer token | none | `id`, `name`, `organization_slug`, `region` — only when no ref resolved and the session is interactive, to populate the project picker | | `GET` | `/v1/projects/{ref}` | Bearer token | none | `ref`, `name`, `organization_id`, `organization_slug` — linked-project cache miss only (best-effort, post-run): written only when `<workdir>/supabase/.temp/linked-project.json` doesn't exist, `.temp/project-ref` doesn't name another ref, an access token exists and the lookup returns 200 | | `GET` | `/v1/projects/{parent}/branches` | Bearer token | none | `[{id, name, project_ref, is_default}]` — only for `--remote=<branch>` (or the deprecated `--project-ref=<branch>`), and for a bare `--remote` followed by a word that could name a branch; `{parent}` is `SUPABASE_PROJECT_ID`, else the linked project | +| `GET` | `/v1/branches/{id}` | Bearer token | none | `{ref, status, …}` — only for `--remote=<branch UUID>` when no project is linked | ## Exit Codes @@ -86,6 +87,6 @@ format still carries `url` per compute, and `compute status` renders it. ## Notes -- Targets the linked project unless `--remote=<ref>` is given; a bare `--remote` also targets the linked project. `--remote=<value>` is read as a project ref (20 lowercase letters), a branch of the linked project (by name or UUID, resolved through `GET /v1/projects/{parent}/branches`), or `true|yes|on|1` for the linked project. A database URL is rejected (`supabase experimental compute list` takes a project, not a database); `false|no|off|0`, `--no-remote` and an empty value fail with `Leave out --remote to use the linked project.` +- Targets the linked project unless `--remote=<ref>` is given; a bare `--remote` also targets the linked project. `--remote=<value>` is read as a project ref (20 lowercase letters), a branch of the linked project (by name or UUID, resolved through `GET /v1/projects/{parent}/branches`, or `GET /v1/branches/{id}` for a UUID when no project is linked), or `true|yes|on|1` for the linked project. A database URL is rejected (`supabase experimental compute list` takes a project, not a database); `false|no|off|0`, `--no-remote` and an empty value fail with `Leave out --remote to use the linked project.` - A word after a bare `--remote` is read as its value, `--remote=<word>`, because the command takes no operands. - The deprecated `--project-ref` is an alias of `--remote=<ref>` and prints a deprecation warning on stderr that names the replacement; it cannot be combined with `--remote=<value>`. An empty `--project-ref` is rejected. diff --git a/apps/cli/src/commands/experimental/compute/list/list.handler.ts b/apps/cli/src/commands/experimental/compute/list/list.handler.ts index 760185f9cb..6b9f895e2c 100644 --- a/apps/cli/src/commands/experimental/compute/list/list.handler.ts +++ b/apps/cli/src/commands/experimental/compute/list/list.handler.ts @@ -103,10 +103,7 @@ export const computeList = Effect.fn("compute.list")(function* (flags: ComputeLi // Resolved here, outside the block below, since caching it is one of that // block's own finalizers — everything else that can fail belongs inside so // those failures still flush telemetry. - const projectRef = yield* resolveProjectTarget({ - remote: flags.remote, - projectRef: flags.projectRef, - }); + const projectRef = yield* resolveProjectTarget(flags); yield* Effect.gen(function* () { const project = yield* loadComputeProject; diff --git a/apps/cli/src/commands/experimental/compute/logs/SIDE_EFFECTS.md b/apps/cli/src/commands/experimental/compute/logs/SIDE_EFFECTS.md index 8a753331e1..a001cc798c 100644 --- a/apps/cli/src/commands/experimental/compute/logs/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/experimental/compute/logs/SIDE_EFFECTS.md @@ -44,6 +44,7 @@ command's output depends on local state — there is no source path to report | `GET` | `/v1/projects` | Bearer token | none — only when no ref resolved and the session is interactive | project picker | | `GET` | `/v1/projects/{ref}` | Bearer token | none | `ref`, `name`, `organization_id`, `organization_slug` — linked-project cache miss only (best-effort, post-run): written only when `<workdir>/supabase/.temp/linked-project.json` doesn't exist, `.temp/project-ref` doesn't name another ref, an access token exists and the lookup returns 200 | | `GET` | `/v1/projects/{parent}/branches` | Bearer token | none | `[{id, name, project_ref, is_default}]` — only for `--remote=<branch>` (or the deprecated `--project-ref=<branch>`), and for a bare `--remote` followed by a word that could name a branch; `{parent}` is `SUPABASE_PROJECT_ID`, else the linked project | +| `GET` | `/v1/branches/{id}` | Bearer token | none | `{ref, status, …}` — only for `--remote=<branch UUID>` when no project is linked | Requires the `analytics_logs_read` permission, and the project must be on the Compute private-alpha allow-list — an unenrolled project answers 404. @@ -169,6 +170,6 @@ preserved so a stack trace survives intact. ## Notes -- Targets the linked project unless `--remote=<ref>` is given; a bare `--remote` also targets the linked project. `--remote=<value>` is read as a project ref (20 lowercase letters), a branch of the linked project (by name or UUID, resolved through `GET /v1/projects/{parent}/branches`), or `true|yes|on|1` for the linked project. A database URL is rejected (`supabase experimental compute logs` takes a project, not a database); `false|no|off|0`, `--no-remote` and an empty value fail with `Leave out --remote to use the linked project.` +- Targets the linked project unless `--remote=<ref>` is given; a bare `--remote` also targets the linked project. `--remote=<value>` is read as a project ref (20 lowercase letters), a branch of the linked project (by name or UUID, resolved through `GET /v1/projects/{parent}/branches`, or `GET /v1/branches/{id}` for a UUID when no project is linked), or `true|yes|on|1` for the linked project. A database URL is rejected (`supabase experimental compute logs` takes a project, not a database); `false|no|off|0`, `--no-remote` and an empty value fail with `Leave out --remote to use the linked project.` - A bare `--remote` followed by a word that exactly matches a branch of the linked project, is within a small edit distance of one, or can't be checked because the branch listing failed stops the run before any other work with an error that offers both readings: `--remote=<word>` for the branch, `<word> --remote` for the operand. Any other word runs as the operand. - The deprecated `--project-ref` is an alias of `--remote=<ref>` and prints a deprecation warning on stderr that names the replacement; it cannot be combined with `--remote=<value>`. An empty `--project-ref` is rejected. diff --git a/apps/cli/src/commands/experimental/compute/logs/logs.handler.ts b/apps/cli/src/commands/experimental/compute/logs/logs.handler.ts index 3b66049618..9393a66d75 100644 --- a/apps/cli/src/commands/experimental/compute/logs/logs.handler.ts +++ b/apps/cli/src/commands/experimental/compute/logs/logs.handler.ts @@ -150,10 +150,7 @@ export const computeLogs = Effect.fn("compute.logs")(function* ( // checkout fails inside `resolve` after the command has already run. Only the // linked-project cache stays under the ref, since it has nothing to write without one. yield* Effect.gen(function* () { - const projectRef = yield* resolveProjectTarget({ - remote: flags.remote, - projectRef: flags.projectRef, - }); + const projectRef = yield* resolveProjectTarget(flags); const refSuffix = computeRemoteSuffix(flags, projectRef); yield* Effect.gen(function* () { 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 5d69a62e9d..83738c74b0 100644 --- a/apps/cli/src/commands/experimental/compute/push/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/experimental/compute/push/SIDE_EFFECTS.md @@ -53,6 +53,7 @@ deploying unasked. Stdin is never read for the answer. | `GET` | `/v1/projects` | Bearer token | none | `id`, `name`, `organization_slug`, `region` — project picker: only when no ref resolves from `--remote=<ref>`, `SUPABASE_PROJECT_ID` or `supabase/.temp/project-ref` and stdin and stdout are TTYs | | `GET` | `/v1/projects/{ref}` | Bearer token | none | `ref`, `name`, `organization_id`, `organization_slug` — linked-project cache miss only (best-effort, post-run): written only when `<workdir>/supabase/.temp/linked-project.json` doesn't exist, `.temp/project-ref` doesn't name another ref, an access token exists and the lookup returns 200 | | `GET` | `/v1/projects/{parent}/branches` | Bearer token | none | `[{id, name, project_ref, is_default}]` — only for `--remote=<branch>` (or the deprecated `--project-ref=<branch>`), and for a bare `--remote` followed by a word that could name a branch; `{parent}` is `SUPABASE_PROJECT_ID`, else the linked project | +| `GET` | `/v1/branches/{id}` | Bearer token | none | `{ref, status, …}` — only for `--remote=<branch UUID>` when no project is linked | `GET /v2/projects/{ref}/compute/{name}` is polled until `build_state` leaves `building`. It is skipped entirely in two cases: under `--no-wait`, and when @@ -193,6 +194,6 @@ strings that carry a signature. ## Notes -- Targets the linked project unless `--remote=<ref>` is given; a bare `--remote` also targets the linked project. `--remote=<value>` is read as a project ref (20 lowercase letters), a branch of the linked project (by name or UUID, resolved through `GET /v1/projects/{parent}/branches`), or `true|yes|on|1` for the linked project. A database URL is rejected (`supabase experimental compute push` takes a project, not a database); `false|no|off|0`, `--no-remote` and an empty value fail with `Leave out --remote to use the linked project.` +- Targets the linked project unless `--remote=<ref>` is given; a bare `--remote` also targets the linked project. `--remote=<value>` is read as a project ref (20 lowercase letters), a branch of the linked project (by name or UUID, resolved through `GET /v1/projects/{parent}/branches`, or `GET /v1/branches/{id}` for a UUID when no project is linked), or `true|yes|on|1` for the linked project. A database URL is rejected (`supabase experimental compute push` takes a project, not a database); `false|no|off|0`, `--no-remote` and an empty value fail with `Leave out --remote to use the linked project.` - A bare `--remote` followed by a word that exactly matches a branch of the linked project, is within a small edit distance of one, or can't be checked because the branch listing failed stops the run before any other work with an error that offers both readings: `--remote=<word>` for the branch, `<word> --remote` for the operand. Any other word runs as the operand. - The deprecated `--project-ref` is an alias of `--remote=<ref>` and prints a deprecation warning on stderr that names the replacement; it cannot be combined with `--remote=<value>`. An empty `--project-ref` is rejected. 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 64553a4dbf..833f581ac7 100644 --- a/apps/cli/src/commands/experimental/compute/push/push.handler.ts +++ b/apps/cli/src/commands/experimental/compute/push/push.handler.ts @@ -551,10 +551,7 @@ export const computePush = Effect.fn("compute.push")(function* ( // Resolved here, outside the block below, since caching it is one of that // block's own finalizers — everything else that can fail belongs inside so // those failures still flush telemetry. - const projectRef = yield* resolveProjectTarget({ - remote: flags.remote, - projectRef: flags.projectRef, - }); + const projectRef = yield* resolveProjectTarget(flags); yield* Effect.gen(function* () { const project = yield* loadComputeProject; diff --git a/apps/cli/src/commands/experimental/compute/status/SIDE_EFFECTS.md b/apps/cli/src/commands/experimental/compute/status/SIDE_EFFECTS.md index 77aa1c874a..a19b05fefe 100644 --- a/apps/cli/src/commands/experimental/compute/status/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/experimental/compute/status/SIDE_EFFECTS.md @@ -41,6 +41,7 @@ and the command handler does not run. See the [Compute command guide](../../../. | `GET` | `/v1/projects` | Bearer token | none | `id`, `name`, `organization_slug`, `region` — only when no ref resolved and the session is interactive, to populate the project picker | | `GET` | `/v1/projects/{ref}` | Bearer token | none | `ref`, `name`, `organization_id`, `organization_slug` — linked-project cache miss only (best-effort, post-run): written only when `<workdir>/supabase/.temp/linked-project.json` doesn't exist, `.temp/project-ref` doesn't name another ref, an access token exists and the lookup returns 200 | | `GET` | `/v1/projects/{parent}/branches` | Bearer token | none | `[{id, name, project_ref, is_default}]` — only for `--remote=<branch>` (or the deprecated `--project-ref=<branch>`), and for a bare `--remote` followed by a word that could name a branch; `{parent}` is `SUPABASE_PROJECT_ID`, else the linked project | +| `GET` | `/v1/branches/{id}` | Bearer token | none | `{ref, status, …}` — only for `--remote=<branch UUID>` when no project is linked | ## Exit Codes @@ -90,6 +91,6 @@ fall through to that same text branch. ## Notes -- Targets the linked project unless `--remote=<ref>` is given; a bare `--remote` also targets the linked project. `--remote=<value>` is read as a project ref (20 lowercase letters), a branch of the linked project (by name or UUID, resolved through `GET /v1/projects/{parent}/branches`), or `true|yes|on|1` for the linked project. A database URL is rejected (`supabase experimental compute status` takes a project, not a database); `false|no|off|0`, `--no-remote` and an empty value fail with `Leave out --remote to use the linked project.` +- Targets the linked project unless `--remote=<ref>` is given; a bare `--remote` also targets the linked project. `--remote=<value>` is read as a project ref (20 lowercase letters), a branch of the linked project (by name or UUID, resolved through `GET /v1/projects/{parent}/branches`, or `GET /v1/branches/{id}` for a UUID when no project is linked), or `true|yes|on|1` for the linked project. A database URL is rejected (`supabase experimental compute status` takes a project, not a database); `false|no|off|0`, `--no-remote` and an empty value fail with `Leave out --remote to use the linked project.` - A bare `--remote` followed by a word that exactly matches a branch of the linked project, is within a small edit distance of one, or can't be checked because the branch listing failed stops the run before any other work with an error that offers both readings: `--remote=<word>` for the branch, `<word> --remote` for the operand. Any other word runs as the operand. - The deprecated `--project-ref` is an alias of `--remote=<ref>` and prints a deprecation warning on stderr that names the replacement; it cannot be combined with `--remote=<value>`. An empty `--project-ref` is rejected. diff --git a/apps/cli/src/commands/experimental/compute/status/status.handler.ts b/apps/cli/src/commands/experimental/compute/status/status.handler.ts index ac6aae13ed..6114918d91 100644 --- a/apps/cli/src/commands/experimental/compute/status/status.handler.ts +++ b/apps/cli/src/commands/experimental/compute/status/status.handler.ts @@ -43,10 +43,7 @@ export const computeStatus = Effect.fn("compute.status")(function* (flags: Compu // Resolved here, outside the block below, since caching it is one of that // block's own finalizers — everything else that can fail belongs inside so // those failures still flush telemetry. - const projectRef = yield* resolveProjectTarget({ - remote: flags.remote, - projectRef: flags.projectRef, - }); + const projectRef = yield* resolveProjectTarget(flags); const refSuffix = computeRemoteSuffix(flags, projectRef); yield* Effect.gen(function* () { diff --git a/apps/cli/src/commands/functions/delete/SIDE_EFFECTS.md b/apps/cli/src/commands/functions/delete/SIDE_EFFECTS.md index 2fdf824ad6..573c22c02c 100644 --- a/apps/cli/src/commands/functions/delete/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/functions/delete/SIDE_EFFECTS.md @@ -25,6 +25,7 @@ | `GET` | `/v1/projects` | Bearer token | none | project picker options when no ref is supplied in TTY | | `GET` | `/v1/projects/{ref}` | Bearer token | none | linked project metadata used by the post-run cache | | `GET` | `/v1/projects/{parent}/branches` | Bearer token | none | `[{id, name, project_ref, is_default}]` — only for `--remote=<branch>` (or the deprecated `--project-ref=<branch>`), and for a bare `--remote` followed by a word that could name a branch; `{parent}` is `SUPABASE_PROJECT_ID`, else the linked project | +| `GET` | `/v1/branches/{id}` | Bearer token | none | `{ref, status, …}` — only for `--remote=<branch UUID>` when no project is linked | ## Environment Variables @@ -71,7 +72,7 @@ Prints the same structured success result, including `target`, and needs `--yes` ## Notes -- Targets the linked project unless `--remote=<ref>` is given; a bare `--remote` also targets the linked project. `--remote=<value>` is read as a project ref (20 lowercase letters), a branch of the linked project (by name or UUID, resolved through `GET /v1/projects/{parent}/branches`), or `true|yes|on|1` for the linked project. A database URL is rejected (`supabase functions delete` takes a project, not a database); `false|no|off|0`, `--no-remote` and an empty value fail with `Leave out --remote to use the linked project.` +- Targets the linked project unless `--remote=<ref>` is given; a bare `--remote` also targets the linked project. `--remote=<value>` is read as a project ref (20 lowercase letters), a branch of the linked project (by name or UUID, resolved through `GET /v1/projects/{parent}/branches`, or `GET /v1/branches/{id}` for a UUID when no project is linked), or `true|yes|on|1` for the linked project. A database URL is rejected (`supabase functions delete` takes a project, not a database); `false|no|off|0`, `--no-remote` and an empty value fail with `Leave out --remote to use the linked project.` - A bare `--remote` followed by a word that exactly matches a branch of the linked project, is within a small edit distance of one, or can't be checked because the branch listing failed stops the run before any other work with an error that offers both readings: `--remote=<word>` for the branch, `<word> --remote` for the operand. Any other word runs as the operand. - The deprecated `--project-ref` is an alias of `--remote=<ref>` and prints a deprecation warning on stderr that names the replacement; it cannot be combined with `--remote=<value>`. An empty `--project-ref` is rejected. - Requires exactly one argument: the function slug/name. diff --git a/apps/cli/src/commands/functions/delete/delete.handler.ts b/apps/cli/src/commands/functions/delete/delete.handler.ts index fa21795c81..32a9aedc66 100644 --- a/apps/cli/src/commands/functions/delete/delete.handler.ts +++ b/apps/cli/src/commands/functions/delete/delete.handler.ts @@ -19,10 +19,9 @@ export const functionsDelete = Effect.fn("functions.delete")(function* ( { slug: flags.functionName }, { api, - resolveProjectRef: resolveProjectTarget({ - remote: flags.remote, - projectRef: flags.projectRef, - }).pipe(Effect.tap((ref) => Ref.set(resolvedProjectRef, Option.some(ref)))), + resolveProjectRef: resolveProjectTarget(flags).pipe( + Effect.tap((ref) => Ref.set(resolvedProjectRef, Option.some(ref))), + ), // The "Deleted Function" line is stdout-bound, so the TTY gate must check stdout. styleIdentifier: (text) => aqua(text, process.stdout), }, diff --git a/apps/cli/src/commands/functions/deploy/SIDE_EFFECTS.md b/apps/cli/src/commands/functions/deploy/SIDE_EFFECTS.md index 773ec6be6f..2bccfd3d47 100644 --- a/apps/cli/src/commands/functions/deploy/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/functions/deploy/SIDE_EFFECTS.md @@ -48,6 +48,7 @@ Docker bundling may pull or run the configured edge-runtime image and uses the | `DELETE` | `/v1/projects/{ref}/functions/{slug}` | Bearer token | none | ignored on `200/404` | | `GET` | `/v1/projects` | Bearer token | none | `[{id, name, organization_slug, region}]` — project picker: no project ref from `--remote=<ref>`, `SUPABASE_PROJECT_ID` or `supabase/.temp/project-ref`, with stdin and stdout TTYs and a non-JSON output format | | `GET` | `/v1/projects/{parent}/branches` | Bearer token | none | `[{id, name, project_ref, is_default}]` — only for `--remote=<branch>` (or the deprecated `--project-ref=<branch>`), and for a bare `--remote` followed by a word that could name a branch; `{parent}` is `SUPABASE_PROJECT_ID`, else the linked project | +| `GET` | `/v1/branches/{id}` | Bearer token | none | `{ref, status, …}` — only for `--remote=<branch UUID>` when no project is linked | | `GET` | `/v1/projects/{ref}` | Bearer token | none | `{ref, name, organization_id, organization_slug}` — linked-project cache miss only (best-effort, post-run): written only when `<workdir>/supabase/.temp/linked-project.json` doesn't exist, `.temp/project-ref` doesn't name another ref, an access token exists and the lookup returns 200 | ## Environment Variables diff --git a/apps/cli/src/commands/functions/deploy/deploy.handler.ts b/apps/cli/src/commands/functions/deploy/deploy.handler.ts index e223bca8d1..ace488701b 100644 --- a/apps/cli/src/commands/functions/deploy/deploy.handler.ts +++ b/apps/cli/src/commands/functions/deploy/deploy.handler.ts @@ -39,7 +39,7 @@ export const functionsDeploy = Effect.fn("functions.deploy")(function* ( rawArgs, edgeRuntimeVersion, resolveProjectRef: () => - resolveProjectTarget({ remote: flags.remote, projectRef: flags.projectRef }).pipe( + resolveProjectTarget(flags).pipe( Effect.tap((ref) => Ref.set(resolvedProjectRef, Option.some(ref))), ), // Written to stdout, so the TTY color gate must check stdout. 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 5bb12679eb..f5498c6878 100644 --- a/apps/cli/src/commands/functions/deploy/deploy.integration.test.ts +++ b/apps/cli/src/commands/functions/deploy/deploy.integration.test.ts @@ -23,6 +23,8 @@ import { TestClock } from "effect/testing"; 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 { rootCommandForFeatures } from "../../../cli/root.ts"; +import { cliArgsLayer, prepareCliArgs } from "../../../shared/cli/run.ts"; import { stripControlSequences } from "../../../shared/output/strip-control-sequences.ts"; import { type ApiHandler, @@ -470,11 +472,12 @@ describe("functions deploy", () => { api, cliSettings: mockCommandSettings({ workdir: tempRoot.current, projectId: Option.none() }), runtimeInfo: mockRuntimeInfo({ cwd: tempRoot.current }), - cliArgs: args, }), commandRuntimeLayer(["functions", "deploy"]).pipe(Layer.provide(BunServices.layer)), approvedHostedMutation, - Stdio.layerTest({ args: Effect.succeed(args) }), + cliArgsLayer(prepareCliArgs(rootCommandForFeatures(), args)).pipe( + Layer.provideMerge(Stdio.layerTest({ args: Effect.succeed(args) })), + ), ); const deployedTo = () => api.requests diff --git a/apps/cli/src/commands/functions/download/SIDE_EFFECTS.md b/apps/cli/src/commands/functions/download/SIDE_EFFECTS.md index 5fc68d2473..d981418508 100644 --- a/apps/cli/src/commands/functions/download/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/functions/download/SIDE_EFFECTS.md @@ -33,6 +33,7 @@ | `GET` | `/v1/projects` | Bearer token | none | project picker options when no ref is supplied in TTY | | `GET` | `/v1/projects/{ref}` | Bearer token | none | linked project metadata used by the post-run cache | | `GET` | `/v1/projects/{parent}/branches` | Bearer token | none | `[{id, name, project_ref, is_default}]` — only for `--remote=<branch>` (or the deprecated `--project-ref=<branch>`), and for a bare `--remote` followed by a word that could name a branch; `{parent}` is `SUPABASE_PROJECT_ID`, else the linked project | +| `GET` | `/v1/branches/{id}` | Bearer token | none | `{ref, status, …}` — only for `--remote=<branch UUID>` when no project is linked | ## Subprocesses @@ -103,7 +104,7 @@ Same envelope as `json` above (including on the Docker-unbundle path). ## Notes -- Targets the linked project unless `--remote=<ref>` is given; a bare `--remote` also targets the linked project. `--remote=<value>` is read as a project ref (20 lowercase letters), a branch of the linked project (by name or UUID, resolved through `GET /v1/projects/{parent}/branches`), or `true|yes|on|1` for the linked project. A database URL is rejected (`supabase functions download` takes a project, not a database); `false|no|off|0`, `--no-remote` and an empty value fail with `Leave out --remote to use the linked project.` +- Targets the linked project unless `--remote=<ref>` is given; a bare `--remote` also targets the linked project. `--remote=<value>` is read as a project ref (20 lowercase letters), a branch of the linked project (by name or UUID, resolved through `GET /v1/projects/{parent}/branches`, or `GET /v1/branches/{id}` for a UUID when no project is linked), or `true|yes|on|1` for the linked project. A database URL is rejected (`supabase functions download` takes a project, not a database); `false|no|off|0`, `--no-remote` and an empty value fail with `Leave out --remote to use the linked project.` - A bare `--remote` followed by a word that exactly matches a branch of the linked project, is within a small edit distance of one, or can't be checked because the branch listing failed stops the run before any other work with an error that offers both readings: `--remote=<word>` for the branch, `<word> --remote` for the operand. Any other word runs as the operand. - The deprecated `--project-ref` is an alias of `--remote=<ref>` and prints a deprecation warning on stderr that names the replacement; it cannot be combined with `--remote=<value>`. An empty `--project-ref` is rejected. - **Config value precedence** (ADR 0032): explicit flag > shell env > project `.env*` > config diff --git a/apps/cli/src/commands/functions/download/download.handler.ts b/apps/cli/src/commands/functions/download/download.handler.ts index 345235b00e..73e8a660e4 100644 --- a/apps/cli/src/commands/functions/download/download.handler.ts +++ b/apps/cli/src/commands/functions/download/download.handler.ts @@ -40,10 +40,9 @@ export const functionsDownload = Effect.fn("functions.download")(function* ( styleAqua: (text) => aqua(text), // Written to stderr, matching `yellow`'s default TTY gate. styleWarning: (text) => yellow(text), - resolveProjectRef: resolveProjectTarget({ - remote: flags.remote, - projectRef: flags.projectRef, - }).pipe(Effect.tap((ref) => Ref.set(resolvedProjectRef, Option.some(ref)))), + resolveProjectRef: resolveProjectTarget(flags).pipe( + Effect.tap((ref) => Ref.set(resolvedProjectRef, Option.some(ref))), + ), }); yield* Effect.annotateCurrentSpan("function.count", result.slugs.length); diff --git a/apps/cli/src/commands/functions/list/SIDE_EFFECTS.md b/apps/cli/src/commands/functions/list/SIDE_EFFECTS.md index 8ddf62648a..3aef010f6b 100644 --- a/apps/cli/src/commands/functions/list/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/functions/list/SIDE_EFFECTS.md @@ -25,6 +25,7 @@ | `GET` | `/v1/projects` | Bearer token | none | project picker options when no ref is supplied in TTY | | `GET` | `/v1/projects/{ref}` | Bearer token | none | linked project metadata used by the post-run cache | | `GET` | `/v1/projects/{parent}/branches` | Bearer token | none | `[{id, name, project_ref, is_default}]` — only for `--remote=<branch>` (or the deprecated `--project-ref=<branch>`), and for a bare `--remote` followed by a word that could name a branch; `{parent}` is `SUPABASE_PROJECT_ID`, else the linked project | +| `GET` | `/v1/branches/{id}` | Bearer token | none | `{ref, status, …}` — only for `--remote=<branch UUID>` when no project is linked | ## Environment Variables @@ -68,7 +69,7 @@ Prints the same structured success result, including `target`. ## Notes -- Targets the linked project unless `--remote=<ref>` is given; a bare `--remote` also targets the linked project. `--remote=<value>` is read as a project ref (20 lowercase letters), a branch of the linked project (by name or UUID, resolved through `GET /v1/projects/{parent}/branches`), or `true|yes|on|1` for the linked project. A database URL is rejected (`supabase functions list` takes a project, not a database); `false|no|off|0`, `--no-remote` and an empty value fail with `Leave out --remote to use the linked project.` +- Targets the linked project unless `--remote=<ref>` is given; a bare `--remote` also targets the linked project. `--remote=<value>` is read as a project ref (20 lowercase letters), a branch of the linked project (by name or UUID, resolved through `GET /v1/projects/{parent}/branches`, or `GET /v1/branches/{id}` for a UUID when no project is linked), or `true|yes|on|1` for the linked project. A database URL is rejected (`supabase functions list` takes a project, not a database); `false|no|off|0`, `--no-remote` and an empty value fail with `Leave out --remote to use the linked project.` - A word after a bare `--remote` is read as its value, `--remote=<word>`, because the command takes no operands. - The deprecated `--project-ref` is an alias of `--remote=<ref>` and prints a deprecation warning on stderr that names the replacement; it cannot be combined with `--remote=<value>`. An empty `--project-ref` is rejected. - Requires a linked project (`--remote=<ref>`, `SUPABASE_PROJECT_ID`, or `<workdir>/supabase/.temp/project-ref`). diff --git a/apps/cli/src/commands/functions/list/list.handler.ts b/apps/cli/src/commands/functions/list/list.handler.ts index f88f912c66..a3ca3c9518 100644 --- a/apps/cli/src/commands/functions/list/list.handler.ts +++ b/apps/cli/src/commands/functions/list/list.handler.ts @@ -39,10 +39,9 @@ export const functionsList = Effect.fn("functions.list")(function* (flags: Funct const resolvedProjectRef = yield* Ref.make(Option.none<string>()); yield* Effect.gen(function* () { - const ref = yield* resolveProjectTarget({ - remote: flags.remote, - projectRef: flags.projectRef, - }).pipe(Effect.tap((projectRef) => Ref.set(resolvedProjectRef, Option.some(projectRef)))); + const ref = yield* resolveProjectTarget(flags).pipe( + Effect.tap((projectRef) => Ref.set(resolvedProjectRef, Option.some(projectRef))), + ); yield* Effect.annotateCurrentSpan("project.ref", ref); const fetching = diff --git a/apps/cli/src/commands/gen/types/SIDE_EFFECTS.md b/apps/cli/src/commands/gen/types/SIDE_EFFECTS.md index a6304ca11e..4fe1f88041 100644 --- a/apps/cli/src/commands/gen/types/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/gen/types/SIDE_EFFECTS.md @@ -50,6 +50,7 @@ The local target and `--remote=<db-url>` write no files. | `DELETE` | `/v1/projects/{ref}/network-bans` | Bearer token | `{ ipv4_addresses, requester_ip: false }` | none | | `GET` | `/v1/projects` | Bearer token | none | project list for the ref prompt: bare `--remote` with no ref from `SUPABASE_PROJECT_ID` or `.temp/project-ref`, with stdin and stdout TTYs and a non-JSON output format | | `GET` | `/v1/projects/{parent}/branches` | Bearer token | none | `[{id, name, project_ref, is_default}]` — only for `--remote=<branch>` (or an alias given a branch name), and for a bare `--remote` followed by a word that could name a branch; `{parent}` is `SUPABASE_PROJECT_ID`, else the linked project | +| `GET` | `/v1/branches/{id}` | Bearer token | none | `{ref, status, …}` — only for `--remote=<branch UUID>` when no project is linked | The TypeScript endpoint is called for a remote project (`--remote`, `--remote=<ref>`, `--remote=<branch>` or an alias) when `--lang=typescript`. For other languages on a remote project diff --git a/apps/cli/src/commands/inspect/db/SIDE_EFFECTS.md b/apps/cli/src/commands/inspect/db/SIDE_EFFECTS.md index 318152412a..20652a6482 100644 --- a/apps/cli/src/commands/inspect/db/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/inspect/db/SIDE_EFFECTS.md @@ -36,6 +36,7 @@ Local targets and database URLs make none. | -------- | ------------------------------------------- | ------------ | ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `GET` | `/v1/projects` | Bearer token | none | `[{id, name, organization_slug, region}]` — project picker: bare `--remote` with no ref from `SUPABASE_PROJECT_ID` or `.temp/project-ref`, with stdin and stdout TTYs and a non-JSON output format | | `GET` | `/v1/projects/{parent}/branches` | Bearer token | none | `[{id, name, project_ref, is_default}]` — only for `--remote=<branch>` (or the deprecated `--project-ref=<branch>`), and for a bare `--remote` followed by a word that could name a branch; `{parent}` is `SUPABASE_PROJECT_ID`, else the linked project | +| `GET` | `/v1/branches/{id}` | Bearer token | none | `{ref, status, …}` — only for `--remote=<branch UUID>` when no project is linked | | `POST` | `/v1/projects/{ref}/cli/login-role` | Bearer token | `{ read_only: false }` | temporary `role` and `password` — remote project with no database password | | `GET` | `/v1/projects/{ref}/config/database/pooler` | Bearer token | none | primary pooler `connection_string` — remote project on an IPv4-only network (direct host unreachable), only when the ref came from `--remote=<ref>` or `--remote=<branch>`, or differs from the linked project; a differing ref ignores the saved `.temp/pooler-url` | | `POST` | `/v1/projects/{ref}/network-bans/retrieve` | Bearer token | none | `banned_ipv4_addresses` — remote project, from the 3rd failed temporary-role connect | diff --git a/apps/cli/src/commands/inspect/report/SIDE_EFFECTS.md b/apps/cli/src/commands/inspect/report/SIDE_EFFECTS.md index 211bbcc31b..a651716343 100644 --- a/apps/cli/src/commands/inspect/report/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/inspect/report/SIDE_EFFECTS.md @@ -60,6 +60,7 @@ Local targets and database URLs make none. | -------- | ------------------------------------------- | ------------ | ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `GET` | `/v1/projects` | Bearer token | none | `[{id, name, organization_slug, region}]` — project picker: bare `--remote` with no ref from `SUPABASE_PROJECT_ID` or `.temp/project-ref`, with stdin and stdout TTYs and a non-JSON output format | | `GET` | `/v1/projects/{parent}/branches` | Bearer token | none | `[{id, name, project_ref, is_default}]` — only for `--remote=<branch>` (or the deprecated `--project-ref=<branch>`), and for a bare `--remote` followed by a word that could name a branch; `{parent}` is `SUPABASE_PROJECT_ID`, else the linked project | +| `GET` | `/v1/branches/{id}` | Bearer token | none | `{ref, status, …}` — only for `--remote=<branch UUID>` when no project is linked | | `POST` | `/v1/projects/{ref}/cli/login-role` | Bearer token | `{ read_only: false }` | temporary `role` and `password` — remote project with no database password | | `GET` | `/v1/projects/{ref}/config/database/pooler` | Bearer token | none | primary pooler `connection_string` — remote project on an IPv4-only network (direct host unreachable), only when the ref came from `--remote=<ref>` or `--remote=<branch>`, or differs from the linked project; a differing ref ignores the saved `.temp/pooler-url` | | `POST` | `/v1/projects/{ref}/network-bans/retrieve` | Bearer token | none | `banned_ipv4_addresses` — remote project, from the 3rd failed temporary-role connect | diff --git a/apps/cli/src/commands/migration/down/SIDE_EFFECTS.md b/apps/cli/src/commands/migration/down/SIDE_EFFECTS.md index 391049662f..74d9f6e76c 100644 --- a/apps/cli/src/commands/migration/down/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/migration/down/SIDE_EFFECTS.md @@ -24,6 +24,7 @@ Local targets and database URLs make none. | -------- | ------------------------------------------- | ------------ | ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `GET` | `/v1/projects` | Bearer token | none | `[{id, name, organization_slug, region}]` — project picker: bare `--remote` with no ref from `SUPABASE_PROJECT_ID` or `.temp/project-ref`, with stdin and stdout TTYs and a non-JSON output format | | `GET` | `/v1/projects/{parent}/branches` | Bearer token | none | `[{id, name, project_ref, is_default}]` — only for `--remote=<branch>` (or the deprecated `--project-ref=<branch>`), and for a bare `--remote` followed by a word that could name a branch; `{parent}` is `SUPABASE_PROJECT_ID`, else the linked project | +| `GET` | `/v1/branches/{id}` | Bearer token | none | `{ref, status, …}` — only for `--remote=<branch UUID>` when no project is linked | | `POST` | `/v1/projects/{ref}/cli/login-role` | Bearer token | `{ read_only: false }` | temporary `role` and `password` — remote project with no database password | | `GET` | `/v1/projects/{ref}/config/database/pooler` | Bearer token | none | primary pooler `connection_string` — remote project on an IPv4-only network (direct host unreachable), only when the ref came from `--remote=<ref>` or `--remote=<branch>`, or differs from the linked project; a differing ref ignores the saved `.temp/pooler-url` | | `POST` | `/v1/projects/{ref}/network-bans/retrieve` | Bearer token | none | `banned_ipv4_addresses` — remote project, from the 3rd failed temporary-role connect | diff --git a/apps/cli/src/commands/migration/fetch/SIDE_EFFECTS.md b/apps/cli/src/commands/migration/fetch/SIDE_EFFECTS.md index 031120bcf4..ba5f18eca9 100644 --- a/apps/cli/src/commands/migration/fetch/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/migration/fetch/SIDE_EFFECTS.md @@ -25,6 +25,7 @@ Local targets and database URLs make none. | -------- | ------------------------------------------- | ------------ | ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `GET` | `/v1/projects` | Bearer token | none | `[{id, name, organization_slug, region}]` — project picker: bare `--remote` with no ref from `SUPABASE_PROJECT_ID` or `.temp/project-ref`, with stdin and stdout TTYs and a non-JSON output format | | `GET` | `/v1/projects/{parent}/branches` | Bearer token | none | `[{id, name, project_ref, is_default}]` — only for `--remote=<branch>` (or the deprecated `--project-ref=<branch>`), and for a bare `--remote` followed by a word that could name a branch; `{parent}` is `SUPABASE_PROJECT_ID`, else the linked project | +| `GET` | `/v1/branches/{id}` | Bearer token | none | `{ref, status, …}` — only for `--remote=<branch UUID>` when no project is linked | | `POST` | `/v1/projects/{ref}/cli/login-role` | Bearer token | `{ read_only: false }` | temporary `role` and `password` — remote project with no database password | | `GET` | `/v1/projects/{ref}/config/database/pooler` | Bearer token | none | primary pooler `connection_string` — remote project on an IPv4-only network (direct host unreachable), only when the ref came from `--remote=<ref>` or `--remote=<branch>`, or differs from the linked project; a differing ref ignores the saved `.temp/pooler-url` | | `POST` | `/v1/projects/{ref}/network-bans/retrieve` | Bearer token | none | `banned_ipv4_addresses` — remote project, from the 3rd failed temporary-role connect | diff --git a/apps/cli/src/commands/migration/list/SIDE_EFFECTS.md b/apps/cli/src/commands/migration/list/SIDE_EFFECTS.md index e6613505b5..14922cb9f9 100644 --- a/apps/cli/src/commands/migration/list/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/migration/list/SIDE_EFFECTS.md @@ -24,6 +24,7 @@ Local targets and database URLs make none. | -------- | ------------------------------------------- | ------------ | ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `GET` | `/v1/projects` | Bearer token | none | `[{id, name, organization_slug, region}]` — project picker: bare `--remote` with no ref from `SUPABASE_PROJECT_ID` or `.temp/project-ref`, with stdin and stdout TTYs and a non-JSON output format | | `GET` | `/v1/projects/{parent}/branches` | Bearer token | none | `[{id, name, project_ref, is_default}]` — only for `--remote=<branch>` (or the deprecated `--project-ref=<branch>`), and for a bare `--remote` followed by a word that could name a branch; `{parent}` is `SUPABASE_PROJECT_ID`, else the linked project | +| `GET` | `/v1/branches/{id}` | Bearer token | none | `{ref, status, …}` — only for `--remote=<branch UUID>` when no project is linked | | `POST` | `/v1/projects/{ref}/cli/login-role` | Bearer token | `{ read_only: false }` | temporary `role` and `password` — remote project with no database password | | `GET` | `/v1/projects/{ref}/config/database/pooler` | Bearer token | none | primary pooler `connection_string` — remote project on an IPv4-only network (direct host unreachable), only when the ref came from `--remote=<ref>` or `--remote=<branch>`, or differs from the linked project; a differing ref ignores the saved `.temp/pooler-url` | | `POST` | `/v1/projects/{ref}/network-bans/retrieve` | Bearer token | none | `banned_ipv4_addresses` — remote project, from the 3rd failed temporary-role connect | diff --git a/apps/cli/src/commands/migration/repair/SIDE_EFFECTS.md b/apps/cli/src/commands/migration/repair/SIDE_EFFECTS.md index 1d07339ec4..9f07bb5280 100644 --- a/apps/cli/src/commands/migration/repair/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/migration/repair/SIDE_EFFECTS.md @@ -24,6 +24,7 @@ Local targets and database URLs make none. | -------- | ------------------------------------------- | ------------ | ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `GET` | `/v1/projects` | Bearer token | none | `[{id, name, organization_slug, region}]` — project picker: bare `--remote` with no ref from `SUPABASE_PROJECT_ID` or `.temp/project-ref`, with stdin and stdout TTYs and a non-JSON output format | | `GET` | `/v1/projects/{parent}/branches` | Bearer token | none | `[{id, name, project_ref, is_default}]` — only for `--remote=<branch>` (or the deprecated `--project-ref=<branch>`), and for a bare `--remote` followed by a word that could name a branch; `{parent}` is `SUPABASE_PROJECT_ID`, else the linked project | +| `GET` | `/v1/branches/{id}` | Bearer token | none | `{ref, status, …}` — only for `--remote=<branch UUID>` when no project is linked | | `POST` | `/v1/projects/{ref}/cli/login-role` | Bearer token | `{ read_only: false }` | temporary `role` and `password` — remote project with no database password | | `GET` | `/v1/projects/{ref}/config/database/pooler` | Bearer token | none | primary pooler `connection_string` — remote project on an IPv4-only network (direct host unreachable), only when the ref came from `--remote=<ref>` or `--remote=<branch>`, or differs from the linked project; a differing ref ignores the saved `.temp/pooler-url` | | `POST` | `/v1/projects/{ref}/network-bans/retrieve` | Bearer token | none | `banned_ipv4_addresses` — remote project, from the 3rd failed temporary-role connect | diff --git a/apps/cli/src/commands/migration/squash/SIDE_EFFECTS.md b/apps/cli/src/commands/migration/squash/SIDE_EFFECTS.md index a2695c12ba..02572b3eea 100644 --- a/apps/cli/src/commands/migration/squash/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/migration/squash/SIDE_EFFECTS.md @@ -81,6 +81,7 @@ live shadow available. | — | — | — | local target or database URL: none | | GET | `/v1/projects` | Bearer | project picker: bare `--remote` with no ref from `SUPABASE_PROJECT_ID` or `.temp/project-ref`, interactive terminal only | | GET | `/v1/projects/{parent}/branches` | Bearer | only for `--remote=<branch>` (or the deprecated `--project-ref=<branch>`), and for a bare `--remote` followed by a word that could name a branch; `{parent}` is `SUPABASE_PROJECT_ID`, else the linked project | +| GET | `/v1/branches/{id}` | Bearer | only for `--remote=<branch UUID>` when no project is linked | | POST | `/v1/projects/{ref}/cli/login-role` | Bearer | remote project: temp login role when no password | | GET | `/v1/projects/{ref}/config/database/pooler` | Bearer | remote project: IPv4 pooler fallback (IPv6-only network), only when the ref came from `--remote=<ref>` or `--remote=<branch>`, or differs from the linked project | | POST/DELETE | `/v1/projects/{ref}/network-bans/retrieve`, `/v1/projects/{ref}/network-bans` | Bearer | remote project: unban during pooler login retry, only under `--yes` | diff --git a/apps/cli/src/commands/migration/up/SIDE_EFFECTS.md b/apps/cli/src/commands/migration/up/SIDE_EFFECTS.md index e021be1587..359170d4af 100644 --- a/apps/cli/src/commands/migration/up/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/migration/up/SIDE_EFFECTS.md @@ -24,6 +24,7 @@ Local targets and database URLs make none. | -------- | ------------------------------------------- | ------------ | ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `GET` | `/v1/projects` | Bearer token | none | `[{id, name, organization_slug, region}]` — project picker: bare `--remote` with no ref from `SUPABASE_PROJECT_ID` or `.temp/project-ref`, with stdin and stdout TTYs and a non-JSON output format | | `GET` | `/v1/projects/{parent}/branches` | Bearer token | none | `[{id, name, project_ref, is_default}]` — only for `--remote=<branch>` (or the deprecated `--project-ref=<branch>`), and for a bare `--remote` followed by a word that could name a branch; `{parent}` is `SUPABASE_PROJECT_ID`, else the linked project | +| `GET` | `/v1/branches/{id}` | Bearer token | none | `{ref, status, …}` — only for `--remote=<branch UUID>` when no project is linked | | `POST` | `/v1/projects/{ref}/cli/login-role` | Bearer token | `{ read_only: false }` | temporary `role` and `password` — remote project with no database password | | `GET` | `/v1/projects/{ref}/config/database/pooler` | Bearer token | none | primary pooler `connection_string` — remote project on an IPv4-only network (direct host unreachable), only when the ref came from `--remote=<ref>` or `--remote=<branch>`, or differs from the linked project; a differing ref ignores the saved `.temp/pooler-url` | | `POST` | `/v1/projects/{ref}/network-bans/retrieve` | Bearer token | none | `banned_ipv4_addresses` — remote project, from the 3rd failed temporary-role connect | 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 cd53a06b9e..c3e6a95ea9 100644 --- a/apps/cli/src/commands/network-bans/get/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/network-bans/get/SIDE_EFFECTS.md @@ -21,6 +21,7 @@ | ------ | ------------------------------------------ | ------------ | ------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `GET` | `/v1/projects` | Bearer token | none | project list for the interactive picker — only when no ref comes from `--remote=<ref>`, `SUPABASE_PROJECT_ID` or `.temp/project-ref` and stdin is a TTY with interactive output | | `GET` | `/v1/projects/{parent}/branches` | Bearer token | none | `[{id, name, project_ref, is_default}]` — only for `--remote=<branch>` (or the deprecated `--project-ref=<branch>`), and for a bare `--remote` followed by a word that could name a branch; `{parent}` is `SUPABASE_PROJECT_ID`, else the linked project | +| `GET` | `/v1/branches/{id}` | Bearer token | none | `{ref, status, …}` — only for `--remote=<branch UUID>` when no project is linked | | `POST` | `/v1/projects/{ref}/network-bans/retrieve` | Bearer token | none | `{banned_ipv4_addresses}` | | `GET` | `/v1/projects/{ref}` | Bearer token | none | `{ref, name, organization_id, organization_slug}` — `linkedProjectCache` fill, best-effort, skipped when `.temp/linked-project.json` exists, `.temp/project-ref` names another ref, or no token is available | diff --git a/apps/cli/src/commands/network-bans/get/get.handler.ts b/apps/cli/src/commands/network-bans/get/get.handler.ts index 84cf311516..3e485a6ab2 100644 --- a/apps/cli/src/commands/network-bans/get/get.handler.ts +++ b/apps/cli/src/commands/network-bans/get/get.handler.ts @@ -31,7 +31,7 @@ export const networkBansGet = Effect.fn("network-bans.get")(function* (flags: Ne const telemetryState = yield* TelemetryState; yield* Effect.gen(function* () { - const ref = yield* resolveProjectTarget({ remote: flags.remote, projectRef: flags.projectRef }); + const ref = yield* resolveProjectTarget(flags); yield* Effect.gen(function* () { const fetching = 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 8da52baf38..740cea464f 100644 --- a/apps/cli/src/commands/network-bans/remove/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/network-bans/remove/SIDE_EFFECTS.md @@ -21,6 +21,7 @@ | -------- | --------------------------------- | ------------ | --------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `GET` | `/v1/projects` | Bearer token | none | project list for the interactive picker — only when no ref comes from `--remote=<ref>`, `SUPABASE_PROJECT_ID` or `.temp/project-ref` and stdin is a TTY with interactive output | | `GET` | `/v1/projects/{parent}/branches` | Bearer token | none | `[{id, name, project_ref, is_default}]` — only for `--remote=<branch>` (or the deprecated `--project-ref=<branch>`), and for a bare `--remote` followed by a word that could name a branch; `{parent}` is `SUPABASE_PROJECT_ID`, else the linked project | +| `GET` | `/v1/branches/{id}` | Bearer token | none | `{ref, status, …}` — only for `--remote=<branch UUID>` when no project is linked | | `DELETE` | `/v1/projects/{ref}/network-bans` | Bearer token | `{ipv4_addresses: string[], requester_ip: boolean}` | none | | `GET` | `/v1/projects/{ref}` | Bearer token | none | `{ref, name, organization_id, organization_slug}` — `linkedProjectCache` fill, best-effort, skipped when `.temp/linked-project.json` exists, `.temp/project-ref` names another ref, or no token is available | 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 3ce97a0096..e64baa0c08 100644 --- a/apps/cli/src/commands/network-bans/remove/remove.handler.ts +++ b/apps/cli/src/commands/network-bans/remove/remove.handler.ts @@ -34,7 +34,7 @@ export const networkBansRemove = Effect.fn("network-bans.remove")(function* ( yield* Effect.gen(function* () { // Resolves before validating --db-unban-ip, so a bad ref surfaces before a bad IP. - const ref = yield* resolveProjectTarget({ remote: flags.remote, projectRef: flags.projectRef }); + const ref = yield* resolveProjectTarget(flags); yield* Effect.gen(function* () { for (const ip of flags.dbUnbanIp) { 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 429eca01be..3970e97ced 100644 --- a/apps/cli/src/commands/network-restrictions/get/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/network-restrictions/get/SIDE_EFFECTS.md @@ -21,6 +21,7 @@ | ------ | ----------------------------------------- | ------------ | ------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `GET` | `/v1/projects` | Bearer token | none | project list for the interactive picker — only when no ref comes from `--remote=<ref>`, `SUPABASE_PROJECT_ID` or `.temp/project-ref` and stdin is a TTY with interactive output | | `GET` | `/v1/projects/{parent}/branches` | Bearer token | none | `[{id, name, project_ref, is_default}]` — only for `--remote=<branch>` (or the deprecated `--project-ref=<branch>`), and for a bare `--remote` followed by a word that could name a branch; `{parent}` is `SUPABASE_PROJECT_ID`, else the linked project | +| `GET` | `/v1/branches/{id}` | Bearer token | none | `{ref, status, …}` — only for `--remote=<branch UUID>` when no project is linked | | `GET` | `/v1/projects/{ref}/network-restrictions` | Bearer token | none | `{ config: { dbAllowedCidrs?: string[], dbAllowedCidrsV6?: string[] }, status: "stored" \| "applied" }` (see `V1GetNetworkRestrictionsOutput` in `packages/api`) | | `GET` | `/v1/projects/{ref}` | Bearer token | none | `{ref, name, organization_id, organization_slug}` — `linkedProjectCache` fill, best-effort, skipped when `.temp/linked-project.json` exists, `.temp/project-ref` names another ref, or no token is available | diff --git a/apps/cli/src/commands/network-restrictions/get/get.handler.ts b/apps/cli/src/commands/network-restrictions/get/get.handler.ts index 6ec7738fe8..68ae95d421 100644 --- a/apps/cli/src/commands/network-restrictions/get/get.handler.ts +++ b/apps/cli/src/commands/network-restrictions/get/get.handler.ts @@ -39,7 +39,7 @@ export const networkRestrictionsGet = Effect.fn("network-restrictions.get")(func const telemetryState = yield* TelemetryState; yield* Effect.gen(function* () { - const ref = yield* resolveProjectTarget({ remote: flags.remote, projectRef: flags.projectRef }); + const ref = yield* resolveProjectTarget(flags); yield* Effect.gen(function* () { const fetching = 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 4ec008b797..89620d3c81 100644 --- a/apps/cli/src/commands/network-restrictions/update/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/network-restrictions/update/SIDE_EFFECTS.md @@ -21,6 +21,7 @@ | ------- | ----------------------------------------------- | ------------ | ------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `GET` | `/v1/projects` | Bearer token | none | project list for the interactive picker — only when no ref comes from `--remote=<ref>`, `SUPABASE_PROJECT_ID` or `.temp/project-ref` and stdin is a TTY with interactive output | | `GET` | `/v1/projects/{parent}/branches` | Bearer token | none | `[{id, name, project_ref, is_default}]` — only for `--remote=<branch>` (or the deprecated `--project-ref=<branch>`), and for a bare `--remote` followed by a word that could name a branch; `{parent}` is `SUPABASE_PROJECT_ID`, else the linked project | +| `GET` | `/v1/branches/{id}` | Bearer token | none | `{ref, status, …}` — only for `--remote=<branch UUID>` when no project is linked | | `POST` | `/v1/projects/{ref}/network-restrictions/apply` | Bearer token | `{ dbAllowedCidrs: string[], dbAllowedCidrsV6: string[] }` | `{ config: { dbAllowedCidrs?, dbAllowedCidrsV6? }, status }` (`V1UpdateNetworkRestrictionsOutput`) | | `PATCH` | `/v1/projects/{ref}/network-restrictions` | Bearer token | `{ add: { dbAllowedCidrs: string[], dbAllowedCidrsV6: string[] } }` | `{ config: { dbAllowedCidrs?: Array<{address, type}> }, status }` (`V1PatchNetworkRestrictionsOutput`) | | `GET` | `/v1/projects/{ref}` | Bearer token | none | `{ref, name, organization_id, organization_slug}` — `linkedProjectCache` fill, best-effort, skipped when `.temp/linked-project.json` exists, `.temp/project-ref` names another ref, or no token is available | 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 c83e51c1d5..882bb22f5c 100644 --- a/apps/cli/src/commands/network-restrictions/update/update.handler.ts +++ b/apps/cli/src/commands/network-restrictions/update/update.handler.ts @@ -60,7 +60,7 @@ export const networkRestrictionsUpdate = Effect.fn("network-restrictions.update" } const { v4, v6 } = validation.success; - const ref = yield* resolveProjectTarget({ remote: flags.remote, projectRef: flags.projectRef }); + const ref = yield* resolveProjectTarget(flags); yield* Effect.gen(function* () { yield* requireHostedMutationConsent({ diff --git a/apps/cli/src/commands/notebooks/pull/SIDE_EFFECTS.md b/apps/cli/src/commands/notebooks/pull/SIDE_EFFECTS.md index af308c470f..fe36cc98e3 100644 --- a/apps/cli/src/commands/notebooks/pull/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/notebooks/pull/SIDE_EFFECTS.md @@ -60,6 +60,7 @@ nothing else is reconciled. | Method | Path | Auth | Request body | Response (used fields) | | ------ | ----------------------------------- | ------------ | ------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `GET` | `/v1/projects/{parent}/branches` | Bearer token | none | `[{id, name, project_ref, is_default}]` — only for `--remote=<branch>` (or the deprecated `--project-ref=<branch>`), and for a bare `--remote` followed by a word that could name a branch; `{parent}` is `SUPABASE_PROJECT_ID`, else the linked project | +| `GET` | `/v1/branches/{id}` | Bearer token | none | `{ref, status, …}` — only for `--remote=<branch UUID>` when no project is linked | | GET | `/v2/projects/{ref}/notebooks` | Bearer | — | `data[].id`, `data[].attributes.name`, `links.next` — broad pull only | | GET | `/v2/projects/{ref}/notebooks/{id}` | Bearer | — | `data.{id,attributes.{name,description,favorite,content}}` | | POST | `/v2/projects/{ref}/notebooks` | Bearer | `data.attributes` from the file | — (only for the create answer) | diff --git a/apps/cli/src/commands/notebooks/pull/pull.handler.ts b/apps/cli/src/commands/notebooks/pull/pull.handler.ts index 8ee3b4da48..a140b8d9b7 100644 --- a/apps/cli/src/commands/notebooks/pull/pull.handler.ts +++ b/apps/cli/src/commands/notebooks/pull/pull.handler.ts @@ -55,7 +55,7 @@ export const notebooksPull = Effect.fn("notebooks.pull")(function* (flags: Noteb // and failing at emit time would mean failing after the files are written. yield* rejectNotebooksEnvOutput(); - const ref = yield* resolveProjectTarget({ remote: flags.remote, projectRef: flags.projectRef }); + const ref = yield* resolveProjectTarget(flags); yield* Effect.gen(function* () { const pulled: Array<string> = []; diff --git a/apps/cli/src/commands/notebooks/push/SIDE_EFFECTS.md b/apps/cli/src/commands/notebooks/push/SIDE_EFFECTS.md index afe3f1294e..b8494a7c36 100644 --- a/apps/cli/src/commands/notebooks/push/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/notebooks/push/SIDE_EFFECTS.md @@ -66,6 +66,7 @@ entry are canonicalized and refused when they resolve outside the project root. | Method | Path | Auth | Request body | Response (used fields) | | ------ | ----------------------------------- | ------------ | -------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `GET` | `/v1/projects/{parent}/branches` | Bearer token | none | `[{id, name, project_ref, is_default}]` — only for `--remote=<branch>` (or the deprecated `--project-ref=<branch>`), and for a bare `--remote` followed by a word that could name a branch; `{parent}` is `SUPABASE_PROJECT_ID`, else the linked project | +| `GET` | `/v1/branches/{id}` | Bearer token | none | `{ref, status, …}` — only for `--remote=<branch UUID>` when no project is linked | | GET | `/v2/projects/{ref}/notebooks` | Bearer | — | `data[].id`, `data[].attributes.name`, `links.next` | | POST | `/v2/projects/{ref}/notebooks` | Bearer | `data.attributes` — name plus the file | — | | PATCH | `/v2/projects/{ref}/notebooks/{id}` | Bearer | `data.attributes` — name plus the file | — | diff --git a/apps/cli/src/commands/notebooks/push/push.handler.ts b/apps/cli/src/commands/notebooks/push/push.handler.ts index b0870af8cd..7156768c9b 100644 --- a/apps/cli/src/commands/notebooks/push/push.handler.ts +++ b/apps/cli/src/commands/notebooks/push/push.handler.ts @@ -54,7 +54,7 @@ export const notebooksPush = Effect.fn("notebooks.push")(function* (flags: Noteb // failing after the project has already changed. yield* rejectNotebooksEnvOutput(); - const ref = yield* resolveProjectTarget({ remote: flags.remote, projectRef: flags.projectRef }); + const ref = yield* resolveProjectTarget(flags); yield* Effect.gen(function* () { const local = yield* listLocalNotebooks(workdir); 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 2181780abd..c968828fb4 100644 --- a/apps/cli/src/commands/postgres-config/delete/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/postgres-config/delete/SIDE_EFFECTS.md @@ -21,6 +21,7 @@ | ------ | --------------------------------------------- | ------------ | --------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `GET` | `/v1/projects` | Bearer token | none | project list for the interactive picker — only when no ref comes from `--remote=<ref>`, `SUPABASE_PROJECT_ID` or `.temp/project-ref` and stdin is a TTY with interactive output | | `GET` | `/v1/projects/{parent}/branches` | Bearer token | none | `[{id, name, project_ref, is_default}]` — only for `--remote=<branch>` (or the deprecated `--project-ref=<branch>`), and for a bare `--remote` followed by a word that could name a branch; `{parent}` is `SUPABASE_PROJECT_ID`, else the linked project | +| `GET` | `/v1/branches/{id}` | Bearer token | none | `{ref, status, …}` — only for `--remote=<branch UUID>` when no project is linked | | `GET` | `/v1/projects/{ref}/config/database/postgres` | Bearer token | none | full JSON object | | `PUT` | `/v1/projects/{ref}/config/database/postgres` | Bearer token | current config minus deleted keys (`restart_database` optional) | full JSON object | | `GET` | `/v1/projects/{ref}` | Bearer token | none | `{ref, name, organization_id, organization_slug}` — `linkedProjectCache` fill, best-effort, skipped when `.temp/linked-project.json` exists, `.temp/project-ref` names another ref, or no token is available | 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 7a3c721179..f82eee3a6f 100644 --- a/apps/cli/src/commands/postgres-config/delete/delete.handler.ts +++ b/apps/cli/src/commands/postgres-config/delete/delete.handler.ts @@ -27,7 +27,7 @@ export const postgresConfigDelete = Effect.fn("postgres-config.delete")(function const telemetryState = yield* TelemetryState; yield* Effect.gen(function* () { - const ref = yield* resolveProjectTarget({ remote: flags.remote, projectRef: flags.projectRef }); + const ref = yield* resolveProjectTarget(flags); yield* Effect.gen(function* () { const startTask = Effect.suspend((): Effect.Effect<OutputTask | undefined> => 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 9b6b9516b1..0b83d7f406 100644 --- a/apps/cli/src/commands/postgres-config/get/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/postgres-config/get/SIDE_EFFECTS.md @@ -21,6 +21,7 @@ | ------ | --------------------------------------------- | ------------ | ------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `GET` | `/v1/projects` | Bearer token | none | project list for the interactive picker — only when no ref comes from `--remote=<ref>`, `SUPABASE_PROJECT_ID` or `.temp/project-ref` and stdin is a TTY with interactive output | | `GET` | `/v1/projects/{parent}/branches` | Bearer token | none | `[{id, name, project_ref, is_default}]` — only for `--remote=<branch>` (or the deprecated `--project-ref=<branch>`), and for a bare `--remote` followed by a word that could name a branch; `{parent}` is `SUPABASE_PROJECT_ID`, else the linked project | +| `GET` | `/v1/branches/{id}` | Bearer token | none | `{ref, status, …}` — only for `--remote=<branch UUID>` when no project is linked | | `GET` | `/v1/projects/{ref}/config/database/postgres` | Bearer token | none | full JSON object | | `GET` | `/v1/projects/{ref}` | Bearer token | none | `{ref, name, organization_id, organization_slug}` — `linkedProjectCache` fill, best-effort, skipped when `.temp/linked-project.json` exists, `.temp/project-ref` names another ref, or no token is available | diff --git a/apps/cli/src/commands/postgres-config/get/get.handler.ts b/apps/cli/src/commands/postgres-config/get/get.handler.ts index ba9bb3adea..03f0f5d260 100644 --- a/apps/cli/src/commands/postgres-config/get/get.handler.ts +++ b/apps/cli/src/commands/postgres-config/get/get.handler.ts @@ -18,7 +18,7 @@ export const postgresConfigGet = Effect.fn("postgres-config.get")(function* ( const telemetryState = yield* TelemetryState; yield* Effect.gen(function* () { - const ref = yield* resolveProjectTarget({ remote: flags.remote, projectRef: flags.projectRef }); + const ref = yield* resolveProjectTarget(flags); yield* Effect.gen(function* () { const fetching = 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 eab38ca3bb..a98fcf31d6 100644 --- a/apps/cli/src/commands/postgres-config/update/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/postgres-config/update/SIDE_EFFECTS.md @@ -21,6 +21,7 @@ | ------ | --------------------------------------------- | ------------ | -------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `GET` | `/v1/projects` | Bearer token | none | project list for the interactive picker — only when no ref comes from `--remote=<ref>`, `SUPABASE_PROJECT_ID` or `.temp/project-ref` and stdin is a TTY with interactive output | | `GET` | `/v1/projects/{parent}/branches` | Bearer token | none | `[{id, name, project_ref, is_default}]` — only for `--remote=<branch>` (or the deprecated `--project-ref=<branch>`), and for a bare `--remote` followed by a word that could name a branch; `{parent}` is `SUPABASE_PROJECT_ID`, else the linked project | +| `GET` | `/v1/branches/{id}` | Bearer token | none | `{ref, status, …}` — only for `--remote=<branch UUID>` when no project is linked | | `GET` | `/v1/projects/{ref}/config/database/postgres` | Bearer token | none | full JSON object | | `PUT` | `/v1/projects/{ref}/config/database/postgres` | Bearer token | full config object (conditional GET merge unless replace mode) | full JSON object | | `GET` | `/v1/projects/{ref}` | Bearer token | none | `{ref, name, organization_id, organization_slug}` — `linkedProjectCache` fill, best-effort, skipped when `.temp/linked-project.json` exists, `.temp/project-ref` names another ref, or no token is available | 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 be4846a88e..49c99c82cf 100644 --- a/apps/cli/src/commands/postgres-config/update/update.handler.ts +++ b/apps/cli/src/commands/postgres-config/update/update.handler.ts @@ -39,7 +39,7 @@ export const postgresConfigUpdate = Effect.fn("postgres-config.update")(function nextOverrides[splits[0] ?? ""] = splits[1] ?? ""; } - const ref = yield* resolveProjectTarget({ remote: flags.remote, projectRef: flags.projectRef }); + const ref = yield* resolveProjectTarget(flags); yield* Effect.gen(function* () { const startTask = Effect.suspend((): Effect.Effect<OutputTask | undefined> => diff --git a/apps/cli/src/commands/projects/SIDE_EFFECTS.md b/apps/cli/src/commands/projects/SIDE_EFFECTS.md index 5409cc9c78..b7c103f577 100644 --- a/apps/cli/src/commands/projects/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/projects/SIDE_EFFECTS.md @@ -27,15 +27,16 @@ subcommand's own `SIDE_EFFECTS.md`. ## API Routes -| Method | Path | Auth | Request body | Response (used fields) | -| -------- | -------------------------------- | ------------ | ------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------- | -| `GET` | `/v1/projects` | Bearer token | — | `[{id, ref, organization_slug, name, region, created_at, status, database}]` | -| `POST` | `/v1/projects` | Bearer token | `{name, organization_slug, db_pass, region?, desired_instance_size?, high_availability?, release_channel?, postgres_engine?}` (JSON) | `{id, ref, organization_slug, name, region, created_at, status}` | -| `DELETE` | `/v1/projects/{ref}` | Bearer token | — | `{id, ref, name}`; `404` → does-not-exist | -| `GET` | `/v1/projects/{parent}/branches` | Bearer token | — | `[{id, name, project_ref, is_default}]` — `api-keys` with `--remote=<branch>` only | -| `GET` | `/v1/projects/{ref}/api-keys` | Bearer token | — | `[{name, api_key?}]` | -| `GET` | `/v1/projects/{ref}` | Bearer token | — | linked-project cache fill, best-effort (`Effect.ensuring`) | -| `GET` | `/v1/organizations` | Bearer token | — | `[{id, slug, name}]` — `create` interactive org prompt only | +| Method | Path | Auth | Request body | Response (used fields) | +| -------- | -------------------------------- | ------------ | ------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------- | +| `GET` | `/v1/projects` | Bearer token | — | `[{id, ref, organization_slug, name, region, created_at, status, database}]` | +| `POST` | `/v1/projects` | Bearer token | `{name, organization_slug, db_pass, region?, desired_instance_size?, high_availability?, release_channel?, postgres_engine?}` (JSON) | `{id, ref, organization_slug, name, region, created_at, status}` | +| `DELETE` | `/v1/projects/{ref}` | Bearer token | — | `{id, ref, name}`; `404` → does-not-exist | +| `GET` | `/v1/projects/{parent}/branches` | Bearer token | — | `[{id, name, project_ref, is_default}]` — `api-keys` with `--remote=<branch>` only | +| `GET` | `/v1/branches/{id}` | Bearer token | — | `{ref, status, …}` — `api-keys` with `--remote=<branch UUID>` only, when no project is linked | +| `GET` | `/v1/projects/{ref}/api-keys` | Bearer token | — | `[{name, api_key?}]` | +| `GET` | `/v1/projects/{ref}` | Bearer token | — | linked-project cache fill, best-effort (`Effect.ensuring`) | +| `GET` | `/v1/organizations` | Bearer token | — | `[{id, slug, name}]` — `create` interactive org prompt only | ## Environment Variables @@ -101,4 +102,5 @@ subcommand's own `SIDE_EFFECTS.md`. - `api-keys` resolves `--remote=<ref|branch>` (or the deprecated `--project-ref` alias) via the shared resolver (flag → env → `.temp/project-ref` → prompt on a TTY → error when unlinked). A bare `--remote` targets the linked project, `--remote=<branch>` looks the branch up on the linked - project (`GET /v1/projects/{parent}/branches`), and `--remote=<url>` is rejected. + project (`GET /v1/projects/{parent}/branches`, or `GET /v1/branches/{id}` for a UUID when no + project is linked), and `--remote=<url>` is rejected. diff --git a/apps/cli/src/commands/projects/api-keys/SIDE_EFFECTS.md b/apps/cli/src/commands/projects/api-keys/SIDE_EFFECTS.md index ab2dce27c7..cde830c8fb 100644 --- a/apps/cli/src/commands/projects/api-keys/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/projects/api-keys/SIDE_EFFECTS.md @@ -18,6 +18,7 @@ | Method | Path | Auth | Request body | Response (used fields) | | ------ | ------------------------------------------- | ------------ | ------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `GET` | `/v1/projects/{parent}/branches` | Bearer token | none | `[{id, name, project_ref, is_default}]` — only for `--remote=<branch>` (or the deprecated `--project-ref=<branch>`), and for a bare `--remote` followed by a word that could name a branch; `{parent}` is `SUPABASE_PROJECT_ID`, else the linked project | +| `GET` | `/v1/branches/{id}` | Bearer token | none | `{ref, status, …}` — only for `--remote=<branch UUID>` when no project is linked | | `GET` | `/v1/projects/{ref}/api-keys[?reveal=true]` | Bearer token | none | `[{name: string, api_key: string \| null}]` | The `reveal=true` query param is sent only when `--reveal` is passed; it instructs the diff --git a/apps/cli/src/commands/projects/api-keys/api-keys.handler.ts b/apps/cli/src/commands/projects/api-keys/api-keys.handler.ts index d7a1cf1525..991766740b 100644 --- a/apps/cli/src/commands/projects/api-keys/api-keys.handler.ts +++ b/apps/cli/src/commands/projects/api-keys/api-keys.handler.ts @@ -57,7 +57,7 @@ export const projectsApiKeys = Effect.fn("projects.api-keys")(function* ( const telemetryState = yield* TelemetryState; // `--project-ref` resolution prompts on a TTY and fails when unlinked. - const ref = yield* resolveProjectTarget({ remote: flags.remote, projectRef: flags.projectRef }); + const ref = yield* resolveProjectTarget(flags); yield* Effect.gen(function* () { const fetching = diff --git a/apps/cli/src/commands/pull/SIDE_EFFECTS.md b/apps/cli/src/commands/pull/SIDE_EFFECTS.md index 77ca76317a..32d6ae489c 100644 --- a/apps/cli/src/commands/pull/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/pull/SIDE_EFFECTS.md @@ -95,11 +95,11 @@ each location's check is independent, so a spawn failure on one does not affect ## API Routes -| # | Purpose | Method | Path | When | -| --- | ----------------------- | ------ | ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------- | -| 1a | branch by UUID | GET | `/v1/branches/{branch_id}` | `--remote` is a branch UUID — resolved once, up front (ADR 0024 "resolve once"); needs no linked project | -| 1b | branch by name | GET | `/v1/projects/{ref}/branches/{name}` | `--remote` is a branch NAME (not a ref/UUID); the parent ref is resolved from local state first | -| 2 | effective remote config | GET | `/v2/projects/{ref}/config` | the config step's own fetch (`planConfigPullRun`) — guaranteed single by the plan/apply split, never repeated for a dry run or a real apply | +| # | Purpose | Method | Path | When | +| --- | ----------------------- | ------ | -------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 1a | branch list | GET | `/v1/projects/{parent}/branches` | `--remote` is a branch name or UUID and a project is linked — resolved once, up front (ADR 0024 "resolve once"); `{parent}` is `SUPABASE_PROJECT_ID`, else the linked project, resolved from local state first | +| 1b | branch by UUID | GET | `/v1/branches/{id}` | `--remote` is a branch UUID and no usable project is linked; needs no parent project | +| 2 | effective remote config | GET | `/v2/projects/{ref}/config` | the config step's own fetch (`planConfigPullRun`) — guaranteed single by the plan/apply split, never repeated for a dry run or a real apply | Every other route belongs to a reused sub-step, called with the already-resolved `ref` (no re-resolution): the db step's own routes (temp login role, IPv4 pooler config, linked-project @@ -175,7 +175,7 @@ functions) — `pull` does not read or override any of these itself, it only sup | `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 `--remote` target-resolution failure (`PullBranchNotLinkedError` / `PullParentRefInvalidError` / `PullBranchNotFoundError` / `PullBranchNotReadyError` / `PullBranchResolveNetworkError` / `PullBranchResolveStatusError`) | +| `1` | branch-name `--remote` target-resolution failure (`LinkBranchNotLinkedError` / `LinkParentRefInvalidError` / `LinkBranchNotFoundError` / `LinkBranchNotReadyError` / `LinkBranchListNetworkError` / `LinkBranchListStatusError`) | | `1` | `supabase/config.toml`/`.json`, `supabase/migrations`, and/or `supabase/functions` has uncommitted or untracked changes and no human will read the warning (`PullUncommittedChangesError`) — see Git above | | `1` | any one step fails (config, migration history, db, or functions) — every OTHER step still runs and is reported, but the process exits non-zero and re-fails with the FIRST original failure's own cause/classification | @@ -293,7 +293,7 @@ the -o/--output flag is not supported by pull; use --output-format json|stream-j ## Notes -- `--remote=<value>` selects the target: a project ref (20 lowercase letters), or the name or UUID of a branch of the linked project (resolved through `GET /v1/projects/{ref}/branches/{name}` or `GET /v1/branches/{id}`). A bare `--remote` and no selector both target the linked project. A database URL is rejected (`supabase pull` takes a project, not a database); `false|no|off|0`, `--no-remote` and an empty value fail with `Leave out --remote to use the linked project.` +- `--remote=<value>` selects the target: a project ref (20 lowercase letters), or the name or UUID of a branch of the linked project (resolved through `GET /v1/projects/{parent}/branches`, or `GET /v1/branches/{id}` for a UUID when no project is linked). A bare `--remote` and no selector both target the linked project. A database URL is rejected (`supabase pull` takes a project, not a database); `false|no|off|0`, `--no-remote` and an empty value fail with `Leave out --remote to use the linked project.` - The deprecated `--project-ref` is an alias of `--remote=<ref>` (it still accepts a branch name or UUID) and prints a deprecation warning on stderr that names the replacement; it cannot be combined with `--remote=<value>`. An empty `--project-ref` is rejected. - **Steps run sequentially, not in parallel**, refining ADR 0004's "runs all sub-syncs in parallel" aspiration for `pull` specifically — see [ADR diff --git a/apps/cli/src/commands/pull/pull.command.ts b/apps/cli/src/commands/pull/pull.command.ts index d7d21d5678..3a121ea912 100644 --- a/apps/cli/src/commands/pull/pull.command.ts +++ b/apps/cli/src/commands/pull/pull.command.ts @@ -8,7 +8,8 @@ import { dbConfigLinked, dbConfigPoolerFallbackLinked, } from "../../command-internal/db-config.layer.ts"; -import { configTarget } from "../../command-internal/project-target.ts"; +import { projectRefPicker } from "../../config/project-ref.layer.ts"; +import { branchResolution } from "../../command-internal/target/branch-resolution.ts"; import { projectRefSafeFlags } from "../../command-internal/target/project-ref-safe-flags.ts"; import { functionsDownload } from "../../shared/functions/download.ts"; import { linkedProjectCache } from "../../telemetry/linked-project-cache.layer.ts"; @@ -92,7 +93,8 @@ export const pullCommand = Command.make("pull", config).pipe( ]), withPermissions( compose( - configTarget, + branchResolution, + projectRefPicker, configPullRun, linkedProjectCache, { diff --git a/apps/cli/src/commands/pull/pull.errors.ts b/apps/cli/src/commands/pull/pull.errors.ts index ba838580c2..9a4aa62b8b 100644 --- a/apps/cli/src/commands/pull/pull.errors.ts +++ b/apps/cli/src/commands/pull/pull.errors.ts @@ -1,74 +1,11 @@ import { Data } from "effect"; -import { mintConfigTargetErrors } from "../../command-internal/project-target.ts"; import { actionability, type CliErrorActionabilityDeclaration, ErrorActionabilityId, - statusCodeActionability, } from "../../shared/telemetry/error-actionability.ts"; -interface PullNetworkErrorArgs { - readonly message: string; - readonly decode?: boolean; -} - -interface PullStatusErrorArgs { - readonly status: number; - readonly body: string; - readonly message: string; -} - -/** - * `--remote` target-resolution errors, minted via `mintConfigTargetErrors`. Its tags are - * template-interpolated, so `error-actionability-coverage.unit.test.ts`'s static scan can't see - * them there — each class is re-exported below under its own top-level export so the scan finds it. - */ -const pullTargetErrors = mintConfigTargetErrors("Pull"); - -/** `--remote` named a branch the parent project does not have. */ -export const PullBranchNotFoundError = pullTargetErrors.BranchNotFoundError; -export type PullBranchNotFoundError = InstanceType<typeof PullBranchNotFoundError>; - -/** - * `--remote` named a branch (by name), but no project is linked to - * search for branches under. - */ -export const PullBranchNotLinkedError = pullTargetErrors.BranchNotLinkedError; -export type PullBranchNotLinkedError = InstanceType<typeof PullBranchNotLinkedError>; - -/** - * `--remote` named a branch (by name), and a parent-project candidate - * exists but is not ref-shaped — corrupt or stale linked state. - */ -export const PullParentRefInvalidError = pullTargetErrors.ParentRefInvalidError; -export type PullParentRefInvalidError = InstanceType<typeof PullParentRefInvalidError>; - -/** The resolved branch has no project ref yet (still provisioning). */ -export const PullBranchNotReadyError = pullTargetErrors.BranchNotReadyError; -export type PullBranchNotReadyError = InstanceType<typeof PullBranchNotReadyError>; - -/** A transport/decode failure resolving a branch-shaped `--remote` (`GET`-by-UUID or - * `FIND`-by-name). */ -export class PullBranchResolveNetworkError extends Data.TaggedError( - "PullBranchResolveNetworkError", -)<PullNetworkErrorArgs> { - get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { - return this.decode === true - ? { ...actionability.apiStatus, fingerprint_suffix: "api_response" } - : actionability.externalNetwork; - } -} - -/** An unexpected HTTP status resolving a branch-shaped `--remote`. */ -export class PullBranchResolveStatusError extends Data.TaggedError( - "PullBranchResolveStatusError", -)<PullStatusErrorArgs> { - get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { - return statusCodeActionability(this.status, { notFoundIsInvalidInput: true }); - } -} - /** The global `-o`/`--output` flag was passed; `pull` only supports `--output-format` for * machine output. */ export class PullOutputFlagUnsupportedError extends Data.TaggedError( diff --git a/apps/cli/src/commands/pull/pull.handler.ts b/apps/cli/src/commands/pull/pull.handler.ts index 209a111186..e829d8210d 100644 --- a/apps/cli/src/commands/pull/pull.handler.ts +++ b/apps/cli/src/commands/pull/pull.handler.ts @@ -2,7 +2,6 @@ import { Cause, Effect, Exit, FileSystem, Option, Path, Ref } from "effect"; import { CommandSettings } from "../../config/command-settings.service.ts"; import { pathHasUncommittedChanges } from "../../command-internal/git-status.ts"; -import { mapHttpError } from "../../command-internal/http-errors.ts"; import { MigrationsReadError } from "../../command-internal/migration.errors.ts"; import { unsupportedOutputFlagMessage } from "../../command-internal/output-formats.ts"; import { validateWorkdirIsDirectory } from "../../command-internal/workdir-validation.ts"; @@ -14,12 +13,7 @@ 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, - resolveConfigTarget, -} from "../../command-internal/project-target.ts"; import { resolveProjectTarget } from "../../command-internal/target/resolve-project-target.ts"; -import { unexpectedStatusMessage } from "../../command-internal/http-errors.ts"; import { configPullPayload, openConfigPullSource, @@ -55,33 +49,12 @@ import { } from "./pull.format.ts"; import type { PullAggregate, PullStepContext, PullStepResult } from "./pull.types.ts"; import { - PullBranchNotFoundError, - PullBranchNotLinkedError, - PullBranchNotReadyError, - PullBranchResolveNetworkError, - PullBranchResolveStatusError, PullOutputFlagUnsupportedError, - PullParentRefInvalidError, PullUncommittedChangesError, PullWorkdirError, } from "./pull.errors.ts"; import type { PullFlags } from "./pull.command.ts"; -/** Wires `pull`'s own tagged error classes into `resolveConfigTarget`. */ -const pullTargetErrors = configTargetErrorsFor({ - notLinked: PullBranchNotLinkedError, - parentRefInvalid: PullParentRefInvalidError, - branchNotFound: PullBranchNotFoundError, - branchNotReady: PullBranchNotReadyError, -}); - -const mapBranchResolveError = mapHttpError({ - networkError: PullBranchResolveNetworkError, - statusError: PullBranchResolveStatusError, - networkMessage: (cause) => `failed to resolve branch: ${cause}`, - statusMessage: unexpectedStatusMessage, -}); - /** The union of every typed error the four step runners (`pull.steps.ts`) can fail with — keeps * `firstFailureCause` typed instead of erasing the error channel to `unknown`. */ type PullStepFailureCause = @@ -227,13 +200,7 @@ export const pull = Effect.fn("pull")(function* (flags: PullFlags) { // instead of re-resolving (ADR 0024). const { ref, branch, linked, runPlan, source } = yield* Effect.gen(function* () { const pullSource = yield* openConfigPullSource(); - const target = yield* resolveProjectTarget( - { remote: flags.remote, projectRef: flags.projectRef }, - { - resolveRequested: (requested) => - resolveConfigTarget(requested, pullTargetErrors, mapBranchResolveError), - }, - ); + const target = yield* resolveProjectTarget(flags, { detail: true }); yield* Ref.set(resolvedRef, Option.some(target.ref)); // `planConfigPullRun` does everything up through the schema-validation gate: no git @@ -246,7 +213,7 @@ export const pull = Effect.fn("pull")(function* (flags: PullFlags) { return { ref: target.ref, branch: target.branch, - linked: target.linked, + linked: target, runPlan: plan, source: pullSource, }; diff --git a/apps/cli/src/commands/pull/pull.integration.test.ts b/apps/cli/src/commands/pull/pull.integration.test.ts index 9a114d4167..698f8f3423 100644 --- a/apps/cli/src/commands/pull/pull.integration.test.ts +++ b/apps/cli/src/commands/pull/pull.integration.test.ts @@ -547,11 +547,11 @@ function apiHandlerFor(opts: ApiOpts): ApiHandler { if (url.includes("/v1/branches/")) { return Effect.succeed(jsonResponse(request, 200, {})); } - if (url.includes("/branches/")) { + if (url.endsWith("/branches")) { if (opts.branchNetworkFails === true) { return Effect.fail(transportFailure(request)); } - return Effect.succeed(jsonResponse(request, 200, BRANCH_BY_NAME)); + return Effect.succeed(jsonResponse(request, 200, [BRANCH_BY_NAME])); } if (url.endsWith("/body")) { if ( @@ -1036,7 +1036,7 @@ describe("pull integration", () => { yield* seedLocalMigration("20260101000000"); yield* runPull(pullFlags({ remote: branchRemote("staging") })); - const branchLookups = api.requests.filter((request) => request.url.includes("/branches/")); + const branchLookups = api.requests.filter((request) => request.url.endsWith("/branches")); expect(branchLookups).toHaveLength(1); const configLookups = api.requests.filter((request) => request.url.endsWith("/config")); expect(configLookups).toHaveLength(1); @@ -1698,7 +1698,7 @@ describe("pull integration", () => { const exit = yield* Effect.exit(runPull(pullFlags({ remote: branchRemote("staging") }))); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(Cause.pretty(exit.cause)).toContain("PullBranchResolveNetworkError"); + expect(Cause.pretty(exit.cause)).toContain("LinkBranchListNetworkError"); } }).pipe(Effect.provide(layer)); }, diff --git a/apps/cli/src/commands/secrets/list/SIDE_EFFECTS.md b/apps/cli/src/commands/secrets/list/SIDE_EFFECTS.md index a819db1f89..5a033ed12b 100644 --- a/apps/cli/src/commands/secrets/list/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/secrets/list/SIDE_EFFECTS.md @@ -24,6 +24,7 @@ | `GET` | `/v1/projects/{ref}/secrets` | Bearer token | none | `[{name, value, updated_at?}]` (value is digest) | | `GET` | `/v1/projects` | Bearer token | none | `[{id, ref, name, ...}]` — TTY-prompt fallback only | | `GET` | `/v1/projects/{parent}/branches` | Bearer token | none | `[{id, name, project_ref, is_default}]` — only for `--remote=<branch>` (or the deprecated `--project-ref=<branch>`), and for a bare `--remote` followed by a word that could name a branch; `{parent}` is `SUPABASE_PROJECT_ID`, else the linked project | +| `GET` | `/v1/branches/{id}` | Bearer token | none | `{ref, status, …}` — only for `--remote=<branch UUID>` when no project is linked | ## Environment Variables diff --git a/apps/cli/src/commands/secrets/list/list.handler.ts b/apps/cli/src/commands/secrets/list/list.handler.ts index 9325b3161c..4e70c63d74 100644 --- a/apps/cli/src/commands/secrets/list/list.handler.ts +++ b/apps/cli/src/commands/secrets/list/list.handler.ts @@ -57,7 +57,7 @@ export const secretsList = Effect.fn("secrets.list")(function* (flags: SecretsLi const linkedProjectCache = yield* LinkedProjectCache; const telemetryState = yield* TelemetryState; - const ref = yield* resolveProjectTarget({ remote: flags.remote, projectRef: flags.projectRef }); + const ref = yield* resolveProjectTarget(flags); yield* Effect.gen(function* () { const fetching = diff --git a/apps/cli/src/commands/secrets/set/SIDE_EFFECTS.md b/apps/cli/src/commands/secrets/set/SIDE_EFFECTS.md index f1a4dcfe03..f43418fde4 100644 --- a/apps/cli/src/commands/secrets/set/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/secrets/set/SIDE_EFFECTS.md @@ -28,6 +28,7 @@ | `POST` | `/v1/projects/{ref}/secrets` | Bearer token | `[{name: string, value: string}, ...]` | none (201 expected) | | `GET` | `/v1/projects` | Bearer token | none | project picker — only when no ref comes from `--remote=<ref>`, `SUPABASE_PROJECT_ID` or `.temp/project-ref`, stdin is a TTY and output is interactive | | `GET` | `/v1/projects/{parent}/branches` | Bearer token | none | `[{id, name, project_ref, is_default}]` — only for `--remote=<branch>` (or the deprecated `--project-ref=<branch>`), and for a bare `--remote` followed by a word that could name a branch; `{parent}` is `SUPABASE_PROJECT_ID`, else the linked project | +| `GET` | `/v1/branches/{id}` | Bearer token | none | `{ref, status, …}` — only for `--remote=<branch UUID>` when no project is linked | | `GET` | `/v1/projects/{ref}` | Bearer token | none | linked-project cache fill — best-effort, only when `.temp/linked-project.json` is absent, `.temp/project-ref` is absent or names the same ref, and a token is available; failures are ignored | ## Environment Variables diff --git a/apps/cli/src/commands/secrets/set/set.handler.ts b/apps/cli/src/commands/secrets/set/set.handler.ts index ea4b38170c..57be31b0c3 100644 --- a/apps/cli/src/commands/secrets/set/set.handler.ts +++ b/apps/cli/src/commands/secrets/set/set.handler.ts @@ -108,7 +108,7 @@ export const secretsSet = Effect.fn("secrets.set")(function* (flags: SecretsSetF const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const ref = yield* resolveProjectTarget({ remote: flags.remote, projectRef: flags.projectRef }); + const ref = yield* resolveProjectTarget(flags); yield* Effect.gen(function* () { // Source 1: `[edge_runtime.secrets]` from `supabase/config.toml`. Only resolved values are diff --git a/apps/cli/src/commands/secrets/unset/SIDE_EFFECTS.md b/apps/cli/src/commands/secrets/unset/SIDE_EFFECTS.md index de44a4f384..8def221b5d 100644 --- a/apps/cli/src/commands/secrets/unset/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/secrets/unset/SIDE_EFFECTS.md @@ -26,6 +26,7 @@ | `DELETE` | `/v1/projects/{ref}/secrets` | Bearer token | `["NAME", ...]` | none (200 expected; not called with `--dry-run`) | | `GET` | `/v1/projects` | Bearer token | none | project picker — only when no ref comes from `--remote=<ref>`, `SUPABASE_PROJECT_ID` or `.temp/project-ref`, stdin is a TTY and output is interactive | | `GET` | `/v1/projects/{parent}/branches` | Bearer token | none | `[{id, name, project_ref, is_default}]` — only for `--remote=<branch>` (or the deprecated `--project-ref=<branch>`), and for a bare `--remote` followed by a word that could name a branch; `{parent}` is `SUPABASE_PROJECT_ID`, else the linked project | +| `GET` | `/v1/branches/{id}` | Bearer token | none | `{ref, status, …}` — only for `--remote=<branch UUID>` when no project is linked | | `GET` | `/v1/projects/{ref}` | Bearer token | none | linked-project cache fill — best-effort, only when `.temp/linked-project.json` is absent, `.temp/project-ref` is absent or names the same ref, and a token is available; failures are ignored | ## Environment Variables diff --git a/apps/cli/src/commands/secrets/unset/unset.handler.ts b/apps/cli/src/commands/secrets/unset/unset.handler.ts index 28bdbaeece..46e4bc17cc 100644 --- a/apps/cli/src/commands/secrets/unset/unset.handler.ts +++ b/apps/cli/src/commands/secrets/unset/unset.handler.ts @@ -55,7 +55,7 @@ export const secretsUnset = Effect.fn("secrets.unset")(function* (flags: Secrets }); } - const ref = yield* resolveProjectTarget({ remote: flags.remote, projectRef: flags.projectRef }); + const ref = yield* resolveProjectTarget(flags); yield* Effect.gen(function* () { let names: ReadonlyArray<string> = flags.names; diff --git a/apps/cli/src/commands/seed/buckets/SIDE_EFFECTS.md b/apps/cli/src/commands/seed/buckets/SIDE_EFFECTS.md index 1131f8799e..c7ea943691 100644 --- a/apps/cli/src/commands/seed/buckets/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/seed/buckets/SIDE_EFFECTS.md @@ -88,6 +88,7 @@ Analytics bucket routes (`/storage/v1/iceberg/...`) are only reached when | ------ | ----------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------- | | `GET` | `/v1/projects` | project picker: bare `--remote` with no ref from `SUPABASE_PROJECT_ID` or `.temp/project-ref`, with stdin and stdout TTYs and a non-JSON output format | `[{id, name, organization_slug, region}]` | | `GET` | `/v1/projects/{parent}/branches` | only for `--remote=<branch>` (or the deprecated `--project-ref=<branch>`), and for a bare `--remote` followed by a word that could name a branch; `{parent}` is `SUPABASE_PROJECT_ID`, else the linked project | `[{id, name, project_ref, is_default}]` | +| `GET` | `/v1/branches/{id}` | only for `--remote=<branch UUID>` when no project is linked | `{ref, status, …}` | | `GET` | `/v1/projects/{ref}/api-keys?reveal=true` | `SUPABASE_AUTH_SERVICE_ROLE_KEY` is not set | `[{name, api_key, type, secret_jwt_template}]` | | `GET` | `/v1/projects/{ref}` | linked-project cache miss only (best-effort, post-run): written only when `.temp/linked-project.json` doesn't exist, `.temp/project-ref` doesn't name another ref, an access token exists and the lookup returns 200 | `{ref, name, organization_id, organization_slug}` | diff --git a/apps/cli/src/commands/seed/buckets/buckets.handler.ts b/apps/cli/src/commands/seed/buckets/buckets.handler.ts index 0c6a1742e0..9a9393280c 100644 --- a/apps/cli/src/commands/seed/buckets/buckets.handler.ts +++ b/apps/cli/src/commands/seed/buckets/buckets.handler.ts @@ -43,12 +43,7 @@ export const seedBuckets = Effect.fn("seed.buckets")(function* (flags: BucketsFl // Resolved before the config load so the matching `[remotes.<name>]` override // (whose `project_id == ref`) is merged over the base config. - const target = yield* resolveTarget({ - remote: flags.remote, - local: flags.local, - linked: flags.linked, - projectRef: flags.projectRef, - }); + const target = yield* resolveTarget(flags); const projectRef = target.kind === "linked" ? target.ref : ""; yield* Ref.set(linkedRef, projectRef); diff --git a/apps/cli/src/commands/snippets/download/SIDE_EFFECTS.md b/apps/cli/src/commands/snippets/download/SIDE_EFFECTS.md index 9a1f568a28..240144d18a 100644 --- a/apps/cli/src/commands/snippets/download/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/snippets/download/SIDE_EFFECTS.md @@ -24,6 +24,7 @@ | `GET` | `/v1/snippets/{id}` | Bearer token | none | `{content: {sql, schema_version, favorite?}, id, name, visibility, owner, project, inserted_at, updated_at}` | | `GET` | `/v1/projects` | Bearer token | none | project picker — only when no ref comes from `--remote=<ref>`, `SUPABASE_PROJECT_ID` or `.temp/project-ref`, stdin is a TTY and output is interactive | | `GET` | `/v1/projects/{parent}/branches` | Bearer token | none | `[{id, name, project_ref, is_default}]` — only for `--remote=<branch>` (or the deprecated `--project-ref=<branch>`), and for a bare `--remote` followed by a word that could name a branch; `{parent}` is `SUPABASE_PROJECT_ID`, else the linked project | +| `GET` | `/v1/branches/{id}` | Bearer token | none | `{ref, status, …}` — only for `--remote=<branch UUID>` when no project is linked | | `GET` | `/v1/projects/{ref}` | Bearer token | none | linked-project cache fill — best-effort, only when `.temp/linked-project.json` is absent, `.temp/project-ref` is absent or names the same ref, and a token is available; failures are ignored | Only `content.sql` is rendered in text mode. The full payload is exposed via `--output-format json`. diff --git a/apps/cli/src/commands/snippets/download/download.handler.ts b/apps/cli/src/commands/snippets/download/download.handler.ts index a3d38a3bca..5e50723765 100644 --- a/apps/cli/src/commands/snippets/download/download.handler.ts +++ b/apps/cli/src/commands/snippets/download/download.handler.ts @@ -125,7 +125,7 @@ export const snippetsDownload = Effect.fn("snippets.download")(function* ( const telemetryState = yield* TelemetryState; yield* Effect.gen(function* () { - const ref = yield* resolveProjectTarget({ remote: flags.remote, projectRef: flags.projectRef }); + const ref = yield* resolveProjectTarget(flags); yield* Effect.gen(function* () { const snippetId = yield* Effect.fromResult(parseSnippetUuid(flags.snippetId)).pipe( diff --git a/apps/cli/src/commands/snippets/list/SIDE_EFFECTS.md b/apps/cli/src/commands/snippets/list/SIDE_EFFECTS.md index 4504cd421b..3c92fa11c3 100644 --- a/apps/cli/src/commands/snippets/list/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/snippets/list/SIDE_EFFECTS.md @@ -22,6 +22,7 @@ | Method | Path | Auth | Request body | Response (used fields) | | ------ | -------------------------------- | ------------ | ------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `GET` | `/v1/projects/{parent}/branches` | Bearer token | none | `[{id, name, project_ref, is_default}]` — only for `--remote=<branch>` (or the deprecated `--project-ref=<branch>`), and for a bare `--remote` followed by a word that could name a branch; `{parent}` is `SUPABASE_PROJECT_ID`, else the linked project | +| `GET` | `/v1/branches/{id}` | Bearer token | none | `{ref, status, …}` — only for `--remote=<branch UUID>` when no project is linked | | `GET` | `/v1/snippets?project_ref=<ref>` | Bearer token | none | `{data: [{id, name, visibility, owner: {username}, inserted_at, updated_at, ...}], cursor?}` | ## Environment Variables diff --git a/apps/cli/src/commands/snippets/list/list.handler.ts b/apps/cli/src/commands/snippets/list/list.handler.ts index 7e9b5db9c9..52585bc7ea 100644 --- a/apps/cli/src/commands/snippets/list/list.handler.ts +++ b/apps/cli/src/commands/snippets/list/list.handler.ts @@ -102,7 +102,7 @@ export const snippetsList = Effect.fn("snippets.list")(function* (flags: Snippet // write linked-project cache (needs `ref`) // flush telemetry (no `ref` required) yield* Effect.gen(function* () { - const ref = yield* resolveProjectTarget({ remote: flags.remote, projectRef: flags.projectRef }); + const ref = yield* resolveProjectTarget(flags); yield* Effect.gen(function* () { if (Option.getOrUndefined(outputFlag) === "env") { 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 aa8b1eeccb..7a494b1b2c 100644 --- a/apps/cli/src/commands/ssl-enforcement/get/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/ssl-enforcement/get/SIDE_EFFECTS.md @@ -21,6 +21,7 @@ | ------ | ------------------------------------ | ------------ | ------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `GET` | `/v1/projects` | Bearer token | none | project list for the interactive picker — only when no ref comes from `--remote=<ref>`, `SUPABASE_PROJECT_ID` or `.temp/project-ref` and stdin is a TTY with interactive output | | `GET` | `/v1/projects/{parent}/branches` | Bearer token | none | `[{id, name, project_ref, is_default}]` — only for `--remote=<branch>` (or the deprecated `--project-ref=<branch>`), and for a bare `--remote` followed by a word that could name a branch; `{parent}` is `SUPABASE_PROJECT_ID`, else the linked project | +| `GET` | `/v1/branches/{id}` | Bearer token | none | `{ref, status, …}` — only for `--remote=<branch UUID>` when no project is linked | | `GET` | `/v1/projects/{ref}/ssl-enforcement` | Bearer token | none | `{currentConfig: {database: boolean}, appliedSuccessfully: boolean}` | | `GET` | `/v1/projects/{ref}` | Bearer token | none | `{ref, name, organization_id, organization_slug}` — `linkedProjectCache` fill, best-effort, skipped when `.temp/linked-project.json` exists, `.temp/project-ref` names another ref, or no token is available | diff --git a/apps/cli/src/commands/ssl-enforcement/get/get.handler.ts b/apps/cli/src/commands/ssl-enforcement/get/get.handler.ts index 334e6b0032..082b640b10 100644 --- a/apps/cli/src/commands/ssl-enforcement/get/get.handler.ts +++ b/apps/cli/src/commands/ssl-enforcement/get/get.handler.ts @@ -40,7 +40,7 @@ export const sslEnforcementGet = Effect.fn("ssl-enforcement.get")(function* ( // emission fails. `linkedProjectCache.cache` requires a resolved ref, so // it wraps the inner sub-effect only. yield* Effect.gen(function* () { - const ref = yield* resolveProjectTarget({ remote: flags.remote, projectRef: flags.projectRef }); + const ref = yield* resolveProjectTarget(flags); yield* Effect.gen(function* () { const fetching = 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 f3365b96bd..f60f9856fb 100644 --- a/apps/cli/src/commands/ssl-enforcement/update/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/ssl-enforcement/update/SIDE_EFFECTS.md @@ -21,6 +21,7 @@ | ------ | ------------------------------------ | ------------ | ---------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `GET` | `/v1/projects` | Bearer token | none | project list for the interactive picker — only when no ref comes from `--remote=<ref>`, `SUPABASE_PROJECT_ID` or `.temp/project-ref` and stdin is a TTY with interactive output | | `GET` | `/v1/projects/{parent}/branches` | Bearer token | none | `[{id, name, project_ref, is_default}]` — only for `--remote=<branch>` (or the deprecated `--project-ref=<branch>`), and for a bare `--remote` followed by a word that could name a branch; `{parent}` is `SUPABASE_PROJECT_ID`, else the linked project | +| `GET` | `/v1/branches/{id}` | Bearer token | none | `{ref, status, …}` — only for `--remote=<branch UUID>` when no project is linked | | `PUT` | `/v1/projects/{ref}/ssl-enforcement` | Bearer token | `{requestedConfig: {database: boolean}}` | `{currentConfig: {database: boolean}, appliedSuccessfully: boolean}` | | `GET` | `/v1/projects/{ref}` | Bearer token | none | `{ref, name, organization_id, organization_slug}` — `linkedProjectCache` fill, best-effort, skipped when `.temp/linked-project.json` exists, `.temp/project-ref` names another ref, or no token is available | 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 53d9595bfa..4537121667 100644 --- a/apps/cli/src/commands/ssl-enforcement/update/update.handler.ts +++ b/apps/cli/src/commands/ssl-enforcement/update/update.handler.ts @@ -51,7 +51,7 @@ export const sslEnforcementUpdate = Effect.fn("ssl-enforcement.update")(function return yield* new SslEnforcementNoEnableDisableFlagError(); } - const ref = yield* resolveProjectTarget({ remote: flags.remote, projectRef: flags.projectRef }); + const ref = yield* resolveProjectTarget(flags); yield* Effect.gen(function* () { yield* requireHostedMutationConsent({ diff --git a/apps/cli/src/commands/sso/add/SIDE_EFFECTS.md b/apps/cli/src/commands/sso/add/SIDE_EFFECTS.md index e13ee1e078..9980e10d35 100644 --- a/apps/cli/src/commands/sso/add/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/sso/add/SIDE_EFFECTS.md @@ -25,6 +25,7 @@ | ------ | ---------------------------------------------- | ------------ | ------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `GET` | `/v1/projects` | Bearer token | none | project list for the interactive picker — only when no ref comes from `--remote=<ref>`, `SUPABASE_PROJECT_ID` or `.temp/project-ref` and stdin is a TTY with interactive output | | `GET` | `/v1/projects/{parent}/branches` | Bearer token | none | `[{id, name, project_ref, is_default}]` — only for `--remote=<branch>` (or the deprecated `--project-ref=<branch>`), and for a bare `--remote` followed by a word that could name a branch; `{parent}` is `SUPABASE_PROJECT_ID`, else the linked project | +| `GET` | `/v1/branches/{id}` | Bearer token | none | `{ref, status, …}` — only for `--remote=<branch UUID>` when no project is linked | | `POST` | `/v1/projects/{ref}/config/auth/sso/providers` | Bearer token | `{type, metadata_xml?, metadata_url?, domains?, attribute_mapping?, name_id_format?}` | `{id, saml?, domains?, created_at?, updated_at?}` (typed schema) | | `GET` | `<metadata-url>` | none | `Accept: application/xml`, 10s timeout | XML body (UTF-8) — validation when `--skip-url-validation` not set | | `GET` | `/v1/projects/{ref}` | Bearer token | none | `{organization_slug}` — upgrade-gate side-call on a 4xx with no plan-gate error in the body | diff --git a/apps/cli/src/commands/sso/info/SIDE_EFFECTS.md b/apps/cli/src/commands/sso/info/SIDE_EFFECTS.md index a620107ec9..d078666c7a 100644 --- a/apps/cli/src/commands/sso/info/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/sso/info/SIDE_EFFECTS.md @@ -29,6 +29,7 @@ own. The ref resolution and the cache fill still call the API: | ------ | -------------------------------- | ------------ | ------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `GET` | `/v1/projects` | Bearer token | none | project list for the interactive picker — only when no ref comes from `--remote=<ref>`, `SUPABASE_PROJECT_ID` or `.temp/project-ref` and stdin is a TTY with interactive output | | `GET` | `/v1/projects/{parent}/branches` | Bearer token | none | `[{id, name, project_ref, is_default}]` — only for `--remote=<branch>` (or the deprecated `--project-ref=<branch>`), and for a bare `--remote` followed by a word that could name a branch; `{parent}` is `SUPABASE_PROJECT_ID`, else the linked project | +| `GET` | `/v1/branches/{id}` | Bearer token | none | `{ref, status, …}` — only for `--remote=<branch UUID>` when no project is linked | | `GET` | `/v1/projects/{ref}` | Bearer token | none | `{ref, name, organization_id, organization_slug}` — `linkedProjectCache` fill, best-effort, skipped when `.temp/linked-project.json` exists, `.temp/project-ref` names another ref, or no token is available | ## Environment Variables diff --git a/apps/cli/src/commands/sso/info/info.handler.ts b/apps/cli/src/commands/sso/info/info.handler.ts index 675eb2591c..3920a019a3 100644 --- a/apps/cli/src/commands/sso/info/info.handler.ts +++ b/apps/cli/src/commands/sso/info/info.handler.ts @@ -21,7 +21,7 @@ export const ssoInfo = Effect.fn("sso.info")(function* (flags: SsoInfoFlags) { const telemetryState = yield* TelemetryState; yield* Effect.gen(function* () { - const ref = yield* resolveProjectTarget({ remote: flags.remote, projectRef: flags.projectRef }); + const ref = yield* resolveProjectTarget(flags); yield* Effect.gen(function* () { const payload = buildInfoPayload(ref); diff --git a/apps/cli/src/commands/sso/list/SIDE_EFFECTS.md b/apps/cli/src/commands/sso/list/SIDE_EFFECTS.md index 94ecacb266..01e36aa66d 100644 --- a/apps/cli/src/commands/sso/list/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/sso/list/SIDE_EFFECTS.md @@ -23,6 +23,7 @@ | ------ | ---------------------------------------------- | ------------ | ------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `GET` | `/v1/projects` | Bearer token | none | project list for the interactive picker — only when no ref comes from `--remote=<ref>`, `SUPABASE_PROJECT_ID` or `.temp/project-ref` and stdin is a TTY with interactive output | | `GET` | `/v1/projects/{parent}/branches` | Bearer token | none | `[{id, name, project_ref, is_default}]` — only for `--remote=<branch>` (or the deprecated `--project-ref=<branch>`), and for a bare `--remote` followed by a word that could name a branch; `{parent}` is `SUPABASE_PROJECT_ID`, else the linked project | +| `GET` | `/v1/branches/{id}` | Bearer token | none | `{ref, status, …}` — only for `--remote=<branch UUID>` when no project is linked | | `GET` | `/v1/projects/{ref}/config/auth/sso/providers` | Bearer token | none | `{items: [{id, saml?, domains?, created_at?, updated_at?}]}` | | `GET` | `/v1/projects/{ref}` | Bearer token | none | `{organization_slug}` — upgrade-gate side-call on a 4xx with no plan-gate error in the body | | `GET` | `/v1/organizations/{slug}/entitlements` | Bearer token | none | `{entitlements[].feature.key, .hasAccess}` — upgrade-gate side-call on a 4xx with no plan-gate error in the body | diff --git a/apps/cli/src/commands/sso/list/list.handler.ts b/apps/cli/src/commands/sso/list/list.handler.ts index e13f996914..0c3e616d2d 100644 --- a/apps/cli/src/commands/sso/list/list.handler.ts +++ b/apps/cli/src/commands/sso/list/list.handler.ts @@ -68,7 +68,7 @@ export const ssoList = Effect.fn("sso.list")(function* (flags: SsoListFlags) { const telemetryState = yield* TelemetryState; yield* Effect.gen(function* () { - const ref = yield* resolveProjectTarget({ remote: flags.remote, projectRef: flags.projectRef }); + const ref = yield* resolveProjectTarget(flags); yield* Effect.gen(function* () { const fetching = diff --git a/apps/cli/src/commands/sso/remove/SIDE_EFFECTS.md b/apps/cli/src/commands/sso/remove/SIDE_EFFECTS.md index f676e8c178..fec33f037a 100644 --- a/apps/cli/src/commands/sso/remove/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/sso/remove/SIDE_EFFECTS.md @@ -23,6 +23,7 @@ | -------- | ------------------------------------------------------------ | ------------ | ------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `GET` | `/v1/projects` | Bearer token | none | project list for the interactive picker — only when no ref comes from `--remote=<ref>`, `SUPABASE_PROJECT_ID` or `.temp/project-ref` and stdin is a TTY with interactive output | | `GET` | `/v1/projects/{parent}/branches` | Bearer token | none | `[{id, name, project_ref, is_default}]` — only for `--remote=<branch>` (or the deprecated `--project-ref=<branch>`), and for a bare `--remote` followed by a word that could name a branch; `{parent}` is `SUPABASE_PROJECT_ID`, else the linked project | +| `GET` | `/v1/branches/{id}` | Bearer token | none | `{ref, status, …}` — only for `--remote=<branch UUID>` when no project is linked | | `DELETE` | `/v1/projects/{ref}/config/auth/sso/providers/{provider_id}` | Bearer token | none | `{id, saml?, domains?, created_at?, updated_at?}` | | `GET` | `/v1/projects/{ref}` | Bearer token | none | `{organization_slug}` — upgrade-gate side-call on a 4xx with no plan-gate error in the body | | `GET` | `/v1/organizations/{slug}/entitlements` | Bearer token | none | `{entitlements[].feature.key, .hasAccess}` — upgrade-gate side-call on a 4xx with no plan-gate error in the body | diff --git a/apps/cli/src/commands/sso/remove/remove.handler.ts b/apps/cli/src/commands/sso/remove/remove.handler.ts index f562ae5b8e..eab5670f1f 100644 --- a/apps/cli/src/commands/sso/remove/remove.handler.ts +++ b/apps/cli/src/commands/sso/remove/remove.handler.ts @@ -68,7 +68,7 @@ export const ssoRemove = Effect.fn("sso.remove")(function* (flags: SsoRemoveFlag yield* Effect.gen(function* () { const providerId = yield* Effect.fromResult(validateUuid(flags.providerId)); - const ref = yield* resolveProjectTarget({ remote: flags.remote, projectRef: flags.projectRef }); + const ref = yield* resolveProjectTarget(flags); yield* Effect.gen(function* () { yield* requireHostedMutationConsent({ action: `Remove SSO provider ${providerId}` }); diff --git a/apps/cli/src/commands/sso/show/SIDE_EFFECTS.md b/apps/cli/src/commands/sso/show/SIDE_EFFECTS.md index e903ad7bac..46d3ead02c 100644 --- a/apps/cli/src/commands/sso/show/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/sso/show/SIDE_EFFECTS.md @@ -23,6 +23,7 @@ | ------ | ------------------------------------------------------------ | ------------ | ------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `GET` | `/v1/projects` | Bearer token | none | project list for the interactive picker — only when no ref comes from `--remote=<ref>`, `SUPABASE_PROJECT_ID` or `.temp/project-ref` and stdin is a TTY with interactive output | | `GET` | `/v1/projects/{parent}/branches` | Bearer token | none | `[{id, name, project_ref, is_default}]` — only for `--remote=<branch>` (or the deprecated `--project-ref=<branch>`), and for a bare `--remote` followed by a word that could name a branch; `{parent}` is `SUPABASE_PROJECT_ID`, else the linked project | +| `GET` | `/v1/branches/{id}` | Bearer token | none | `{ref, status, …}` — only for `--remote=<branch UUID>` when no project is linked | | `GET` | `/v1/projects/{ref}/config/auth/sso/providers/{provider_id}` | Bearer token | none | `{id, saml?, domains?, created_at?, updated_at?}` | | `GET` | `/v1/projects/{ref}` | Bearer token | none | `{ref, name, organization_id, organization_slug}` — `linkedProjectCache` fill, best-effort, skipped when `.temp/linked-project.json` exists, `.temp/project-ref` names another ref, or no token is available | diff --git a/apps/cli/src/commands/sso/show/show.handler.ts b/apps/cli/src/commands/sso/show/show.handler.ts index 62f31f041e..08358b08f6 100644 --- a/apps/cli/src/commands/sso/show/show.handler.ts +++ b/apps/cli/src/commands/sso/show/show.handler.ts @@ -56,7 +56,7 @@ export const ssoShow = Effect.fn("sso.show")(function* (flags: SsoShowFlags) { yield* Effect.gen(function* () { const providerId = yield* Effect.fromResult(validateUuid(flags.providerId)); - const ref = yield* resolveProjectTarget({ remote: flags.remote, projectRef: flags.projectRef }); + const ref = yield* resolveProjectTarget(flags); yield* Effect.gen(function* () { const fetching = diff --git a/apps/cli/src/commands/sso/update/SIDE_EFFECTS.md b/apps/cli/src/commands/sso/update/SIDE_EFFECTS.md index 98163e19c5..f85c2fed7c 100644 --- a/apps/cli/src/commands/sso/update/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/sso/update/SIDE_EFFECTS.md @@ -25,6 +25,7 @@ | ------ | ------------------------------------------------------------ | ------------ | ------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `GET` | `/v1/projects` | Bearer token | none | project list for the interactive picker — only when no ref comes from `--remote=<ref>`, `SUPABASE_PROJECT_ID` or `.temp/project-ref` and stdin is a TTY with interactive output | | `GET` | `/v1/projects/{parent}/branches` | Bearer token | none | `[{id, name, project_ref, is_default}]` — only for `--remote=<branch>` (or the deprecated `--project-ref=<branch>`), and for a bare `--remote` followed by a word that could name a branch; `{parent}` is `SUPABASE_PROJECT_ID`, else the linked project | +| `GET` | `/v1/branches/{id}` | Bearer token | none | `{ref, status, …}` — only for `--remote=<branch UUID>` when no project is linked | | `GET` | `/v1/projects/{ref}/config/auth/sso/providers/{provider_id}` | Bearer token | none | `{id, saml?, domains?, created_at?, updated_at?}` | | `PUT` | `/v1/projects/{ref}/config/auth/sso/providers/{provider_id}` | Bearer token | `{metadata_xml?, metadata_url?, domains, attribute_mapping?, name_id_format?}` | `{id, saml?, domains?, ...}` (typed schema) | | `GET` | `<metadata-url>` | none | `Accept: application/xml`, 10s timeout | XML body (UTF-8) — validation when `--skip-url-validation` not set | diff --git a/apps/cli/src/commands/storage/cp/SIDE_EFFECTS.md b/apps/cli/src/commands/storage/cp/SIDE_EFFECTS.md index fe0d7adb96..50c7e53578 100644 --- a/apps/cli/src/commands/storage/cp/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/storage/cp/SIDE_EFFECTS.md @@ -45,6 +45,7 @@ Auth: `apikey` always; `Authorization: Bearer <key>` unless the key is `sb_`-pre | `POST` | `/storage/v1/bucket` | `{name, public?, file_size_limit?, allowed_mime_types?}` (auto-create on 404) | `{name}` | | `GET` | `/v1/projects` | — (Management API; project picker: bare `--remote` with no ref from `SUPABASE_PROJECT_ID` or `.temp/project-ref`, with stdin and stdout TTYs and a non-JSON output format) | `[{id, name, organization_slug, region}]` | | `GET` | `/v1/projects/{parent}/branches` | — (Management API; only for `--remote=<branch>` (or the deprecated `--project-ref=<branch>`), and for a bare `--remote` followed by a word that could name a branch; `{parent}` is `SUPABASE_PROJECT_ID`, else the linked project) | `[{id, name, project_ref, is_default}]` | +| `GET` | `/v1/branches/{id}` | — (Management API; only for `--remote=<branch UUID>` when no project is linked) | `{ref, status, …}` | | `GET` | `/v1/projects/{ref}/api-keys?reveal=true` | — (remote project, Management API; skipped when `SUPABASE_AUTH_SERVICE_ROLE_KEY` is set to a non-empty value in the shell env) | service-role key | | `GET` | `/v1/projects/{ref}` | — (remote project, Management API; best-effort cache fill after the ref resolves, only when `.temp/linked-project.json` is absent, `.temp/project-ref` is absent or names the same ref, and a token is available; failures are ignored) | `ref`, `name`, `organization_id`, `organization_slug` | diff --git a/apps/cli/src/commands/storage/ls/SIDE_EFFECTS.md b/apps/cli/src/commands/storage/ls/SIDE_EFFECTS.md index be2d29f52a..34ee58f672 100644 --- a/apps/cli/src/commands/storage/ls/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/storage/ls/SIDE_EFFECTS.md @@ -38,6 +38,7 @@ Auth: `apikey` header always; `Authorization: Bearer <key>` unless the key is `s | `GET` | `/storage/v1/bucket` | — | `[{name, id}]` | | `GET` | `/v1/projects` | — (Management API; project picker: bare `--remote` with no ref from `SUPABASE_PROJECT_ID` or `.temp/project-ref`, with stdin and stdout TTYs and a non-JSON output format) | `[{id, name, organization_slug, region}]` | | `GET` | `/v1/projects/{parent}/branches` | — (Management API; only for `--remote=<branch>` (or the deprecated `--project-ref=<branch>`), and for a bare `--remote` followed by a word that could name a branch; `{parent}` is `SUPABASE_PROJECT_ID`, else the linked project) | `[{id, name, project_ref, is_default}]` | +| `GET` | `/v1/branches/{id}` | — (Management API; only for `--remote=<branch UUID>` when no project is linked) | `{ref, status, …}` | | `GET` | `/v1/projects/{ref}/api-keys?reveal=true` | — (remote project, Management API; skipped when `SUPABASE_AUTH_SERVICE_ROLE_KEY` is set to a non-empty value in the shell env) | api-key list → service-role key | | `GET` | `/v1/projects/{ref}` | — (remote project, Management API; best-effort cache fill after the ref resolves, only when `.temp/linked-project.json` is absent, `.temp/project-ref` is absent or names the same ref, and a token is available; failures are ignored) | `ref`, `name`, `organization_id`, `organization_slug` | diff --git a/apps/cli/src/commands/storage/mv/SIDE_EFFECTS.md b/apps/cli/src/commands/storage/mv/SIDE_EFFECTS.md index 63df62bf9b..5bdd9e34bd 100644 --- a/apps/cli/src/commands/storage/mv/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/storage/mv/SIDE_EFFECTS.md @@ -40,6 +40,7 @@ Auth: `apikey` always; `Authorization: Bearer <key>` unless the key is `sb_`-pre | `POST` | `/storage/v1/object/list/{bucket}` | `{prefix, search?, limit:100, offset?}` (recursive fallback) | `[{name, id?}]` | | `GET` | `/v1/projects` | — (Management API; project picker: bare `--remote` with no ref from `SUPABASE_PROJECT_ID` or `.temp/project-ref`, with stdin and stdout TTYs and a non-JSON output format) | `[{id, name, organization_slug, region}]` | | `GET` | `/v1/projects/{parent}/branches` | — (Management API; only for `--remote=<branch>` (or the deprecated `--project-ref=<branch>`), and for a bare `--remote` followed by a word that could name a branch; `{parent}` is `SUPABASE_PROJECT_ID`, else the linked project) | `[{id, name, project_ref, is_default}]` | +| `GET` | `/v1/branches/{id}` | — (Management API; only for `--remote=<branch UUID>` when no project is linked) | `{ref, status, …}` | | `GET` | `/v1/projects/{ref}/api-keys?reveal=true` | — (remote project, Management API; skipped when `SUPABASE_AUTH_SERVICE_ROLE_KEY` is set to a non-empty value in the shell env) | service-role key | | `GET` | `/v1/projects/{ref}` | — (remote project, Management API; best-effort cache fill after the ref resolves, only when `.temp/linked-project.json` is absent, `.temp/project-ref` is absent or names the same ref, and a token is available; failures are ignored) | `ref`, `name`, `organization_id`, `organization_slug` | diff --git a/apps/cli/src/commands/storage/rm/SIDE_EFFECTS.md b/apps/cli/src/commands/storage/rm/SIDE_EFFECTS.md index 07eed8302f..2770f03942 100644 --- a/apps/cli/src/commands/storage/rm/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/storage/rm/SIDE_EFFECTS.md @@ -43,6 +43,7 @@ Auth: `apikey` always; `Authorization: Bearer <key>` unless the key is `sb_`-pre | `GET` | `/storage/v1/bucket` | — (no-args + `-r`: delete all buckets) | `[{name, id}]` | | `GET` | `/v1/projects` | — (Management API; project picker: bare `--remote` with no ref from `SUPABASE_PROJECT_ID` or `.temp/project-ref`, with stdin and stdout TTYs and a non-JSON output format) | `[{id, name, organization_slug, region}]` | | `GET` | `/v1/projects/{parent}/branches` | — (Management API; only for `--remote=<branch>` (or the deprecated `--project-ref=<branch>`), and for a bare `--remote` followed by a word that could name a branch; `{parent}` is `SUPABASE_PROJECT_ID`, else the linked project) | `[{id, name, project_ref, is_default}]` | +| `GET` | `/v1/branches/{id}` | — (Management API; only for `--remote=<branch UUID>` when no project is linked) | `{ref, status, …}` | | `GET` | `/v1/projects/{ref}/api-keys?reveal=true` | — (remote project, Management API; skipped when `SUPABASE_AUTH_SERVICE_ROLE_KEY` is set to a non-empty value in the shell env) | service-role key | | `GET` | `/v1/projects/{ref}` | — (remote project, Management API; best-effort cache fill after the ref resolves, only when `.temp/linked-project.json` is absent, `.temp/project-ref` is absent or names the same ref, and a token is available; failures are ignored) | `ref`, `name`, `organization_id`, `organization_slug` | diff --git a/apps/cli/src/commands/storage/storage.frame.ts b/apps/cli/src/commands/storage/storage.frame.ts index 64910d4b9e..5f22507828 100644 --- a/apps/cli/src/commands/storage/storage.frame.ts +++ b/apps/cli/src/commands/storage/storage.frame.ts @@ -44,12 +44,7 @@ interface StorageTargetFlags { /** The resolved target and its project ref, empty for the local stack. */ export const resolveStorageTarget = Effect.fnUntraced(function* (flags: StorageTargetFlags) { - const target = yield* resolveTarget({ - remote: flags.remote, - local: flags.local, - linked: flags.linked, - projectRef: flags.projectRef, - }); + const target = yield* resolveTarget(flags); return { target, projectRef: target.kind === "linked" ? target.ref : "" }; }); diff --git a/apps/cli/src/commands/test/db/SIDE_EFFECTS.md b/apps/cli/src/commands/test/db/SIDE_EFFECTS.md index b582ad51cb..047ac9f3eb 100644 --- a/apps/cli/src/commands/test/db/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/test/db/SIDE_EFFECTS.md @@ -54,6 +54,7 @@ Managed local stack prove runs the catalog `pg_prove` tool through the owning st | POST | `/v1/projects/{ref}/network-bans/retrieve` | access token | — | `{ banned_ipv4_addresses }` | | DELETE | `/v1/projects/{ref}/network-bans` | access token | `{ ipv4_addresses, requester_ip: false }` | — | | GET | `/v1/projects/{parent}/branches` | access token | — | `[{id, name, project_ref}]` — only for `--remote=<branch>`, and for a bare `--remote` followed by a word that could name a branch; `{parent}` is `SUPABASE_PROJECT_ID`, else the linked project | +| GET | `/v1/branches/{id}` | access token | — | `{ref, status, …}` — only for `--remote=<branch UUID>` when no project is linked | ## Environment Variables 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 ef64406820..630f8c1e79 100644 --- a/apps/cli/src/commands/vanity-subdomains/activate/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/vanity-subdomains/activate/SIDE_EFFECTS.md @@ -21,6 +21,7 @@ | ------ | ---------------------------------------------- | ------------ | ------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `GET` | `/v1/projects` | Bearer token | none | project list for the interactive picker — only when no ref comes from `--remote=<ref>`, `SUPABASE_PROJECT_ID` or `.temp/project-ref` and stdin is a TTY with interactive output | | `GET` | `/v1/projects/{parent}/branches` | Bearer token | none | `[{id, name, project_ref, is_default}]` — only for `--remote=<branch>` (or the deprecated `--project-ref=<branch>`), and for a bare `--remote` followed by a word that could name a branch; `{parent}` is `SUPABASE_PROJECT_ID`, else the linked project | +| `GET` | `/v1/branches/{id}` | Bearer token | none | `{ref, status, …}` — only for `--remote=<branch UUID>` when no project is linked | | `POST` | `/v1/projects/{ref}/vanity-subdomain/activate` | Bearer token | `{ vanity_subdomain: string }` | `{ custom_domain: string }` | | `GET` | `/v1/projects/{ref}` | Bearer token | none | `{organization_slug}` — upgrade-gate side-call on a 4xx with no plan-gate error in the body | | `GET` | `/v1/organizations/{slug}/entitlements` | Bearer token | none | `{entitlements[].feature.key, .hasAccess}` (key `vanity_subdomain`) — upgrade-gate side-call on a 4xx with no plan-gate error in the body | 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 d42defeab7..96b62b12b3 100644 --- a/apps/cli/src/commands/vanity-subdomains/activate/activate.handler.ts +++ b/apps/cli/src/commands/vanity-subdomains/activate/activate.handler.ts @@ -43,7 +43,7 @@ export const vanitySubdomainsActivate = Effect.fn("vanity-subdomains.activate")( const telemetryState = yield* TelemetryState; yield* Effect.gen(function* () { - const ref = yield* resolveProjectTarget({ remote: flags.remote, projectRef: flags.projectRef }); + const ref = yield* resolveProjectTarget(flags); yield* Effect.gen(function* () { // This check sits inside both `Effect.ensuring` wrappers so telemetry and the 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 02e77965ef..3cb40d52c4 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 @@ -21,6 +21,7 @@ | ------ | -------------------------------------------------------- | ------------ | ------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `GET` | `/v1/projects` | Bearer token | none | project list for the interactive picker — only when no ref comes from `--remote=<ref>`, `SUPABASE_PROJECT_ID` or `.temp/project-ref` and stdin is a TTY with interactive output | | `GET` | `/v1/projects/{parent}/branches` | Bearer token | none | `[{id, name, project_ref, is_default}]` — only for `--remote=<branch>` (or the deprecated `--project-ref=<branch>`), and for a bare `--remote` followed by a word that could name a branch; `{parent}` is `SUPABASE_PROJECT_ID`, else the linked project | +| `GET` | `/v1/branches/{id}` | Bearer token | none | `{ref, status, …}` — only for `--remote=<branch UUID>` when no project is linked | | `POST` | `/v1/projects/{ref}/vanity-subdomain/check-availability` | Bearer token | `{ vanity_subdomain: string }` | `{ available: boolean }` | | `GET` | `/v1/projects/{ref}` | Bearer token | none | `{organization_slug}` — upgrade-gate side-call on a 4xx with no plan-gate error in the body | | `GET` | `/v1/organizations/{slug}/entitlements` | Bearer token | none | `{entitlements[].feature.key, .hasAccess}` (key `vanity_subdomain`) — upgrade-gate side-call on a 4xx with no plan-gate error in the body | diff --git a/apps/cli/src/commands/vanity-subdomains/check-availability/check-availability.handler.ts b/apps/cli/src/commands/vanity-subdomains/check-availability/check-availability.handler.ts index a47c55eaa8..6cb5be70e8 100644 --- a/apps/cli/src/commands/vanity-subdomains/check-availability/check-availability.handler.ts +++ b/apps/cli/src/commands/vanity-subdomains/check-availability/check-availability.handler.ts @@ -41,10 +41,7 @@ export const vanitySubdomainsCheckAvailability = Effect.fn("vanity-subdomains.ch const telemetryState = yield* TelemetryState; yield* Effect.gen(function* () { - const ref = yield* resolveProjectTarget({ - remote: flags.remote, - projectRef: flags.projectRef, - }); + const ref = yield* resolveProjectTarget(flags); yield* Effect.gen(function* () { // This check sits inside both `Effect.ensuring` wrappers so telemetry and the 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 49ab4dc661..a7b32ba98f 100644 --- a/apps/cli/src/commands/vanity-subdomains/delete/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/vanity-subdomains/delete/SIDE_EFFECTS.md @@ -21,6 +21,7 @@ | -------- | ------------------------------------- | ------------ | ------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `GET` | `/v1/projects` | Bearer token | none | project list for the interactive picker — only when no ref comes from `--remote=<ref>`, `SUPABASE_PROJECT_ID` or `.temp/project-ref` and stdin is a TTY with interactive output | | `GET` | `/v1/projects/{parent}/branches` | Bearer token | none | `[{id, name, project_ref, is_default}]` — only for `--remote=<branch>` (or the deprecated `--project-ref=<branch>`), and for a bare `--remote` followed by a word that could name a branch; `{parent}` is `SUPABASE_PROJECT_ID`, else the linked project | +| `GET` | `/v1/branches/{id}` | Bearer token | none | `{ref, status, …}` — only for `--remote=<branch UUID>` when no project is linked | | `DELETE` | `/v1/projects/{ref}/vanity-subdomain` | Bearer token | none | none | | `GET` | `/v1/projects/{ref}` | Bearer token | none | `{ref, name, organization_id, organization_slug}` — `linkedProjectCache` fill, best-effort, skipped when `.temp/linked-project.json` exists, `.temp/project-ref` names another ref, or no token is available | 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 e5e8d31204..5001b9b6b1 100644 --- a/apps/cli/src/commands/vanity-subdomains/delete/delete.handler.ts +++ b/apps/cli/src/commands/vanity-subdomains/delete/delete.handler.ts @@ -31,7 +31,7 @@ export const vanitySubdomainsDelete = Effect.fn("vanity-subdomains.delete")(func const telemetryState = yield* TelemetryState; yield* Effect.gen(function* () { - const ref = yield* resolveProjectTarget({ remote: flags.remote, projectRef: flags.projectRef }); + const ref = yield* resolveProjectTarget(flags); yield* Effect.gen(function* () { yield* requireHostedMutationConsent({ 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 ab4b2c24ae..e480f1f2d0 100644 --- a/apps/cli/src/commands/vanity-subdomains/get/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/vanity-subdomains/get/SIDE_EFFECTS.md @@ -21,6 +21,7 @@ | ------ | ------------------------------------- | ------------ | ------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `GET` | `/v1/projects` | Bearer token | none | project list for the interactive picker — only when no ref comes from `--remote=<ref>`, `SUPABASE_PROJECT_ID` or `.temp/project-ref` and stdin is a TTY with interactive output | | `GET` | `/v1/projects/{parent}/branches` | Bearer token | none | `[{id, name, project_ref, is_default}]` — only for `--remote=<branch>` (or the deprecated `--project-ref=<branch>`), and for a bare `--remote` followed by a word that could name a branch; `{parent}` is `SUPABASE_PROJECT_ID`, else the linked project | +| `GET` | `/v1/branches/{id}` | Bearer token | none | `{ref, status, …}` — only for `--remote=<branch UUID>` when no project is linked | | `GET` | `/v1/projects/{ref}/vanity-subdomain` | Bearer token | none | `{ status: string, custom_domain?: string }` | | `GET` | `/v1/projects/{ref}` | Bearer token | none | `{ref, name, organization_id, organization_slug}` — `linkedProjectCache` fill, best-effort, skipped when `.temp/linked-project.json` exists, `.temp/project-ref` names another ref, or no token is available | diff --git a/apps/cli/src/commands/vanity-subdomains/get/get.handler.ts b/apps/cli/src/commands/vanity-subdomains/get/get.handler.ts index 1fd4d2c909..f6fd34b390 100644 --- a/apps/cli/src/commands/vanity-subdomains/get/get.handler.ts +++ b/apps/cli/src/commands/vanity-subdomains/get/get.handler.ts @@ -45,7 +45,7 @@ export const vanitySubdomainsGet = Effect.fn("vanity-subdomains.get")(function* const telemetryState = yield* TelemetryState; yield* Effect.gen(function* () { - const ref = yield* resolveProjectTarget({ remote: flags.remote, projectRef: flags.projectRef }); + const ref = yield* resolveProjectTarget(flags); yield* Effect.gen(function* () { const fetching = diff --git a/apps/cli/src/config/project-ref.layer.ts b/apps/cli/src/config/project-ref.layer.ts index aafe591c83..627ce7e9a4 100644 --- a/apps/cli/src/config/project-ref.layer.ts +++ b/apps/cli/src/config/project-ref.layer.ts @@ -28,7 +28,7 @@ export const projectRefPicker = block("project-ref", [ { operationId: "v1-list-all-projects", kind: "required", - when: [{ flag: "project-ref", present: false }], + when: [{ selector: ["none", "bare"] }], context: "an interactive terminal (stdin and stdout are TTYs, output format not json), no SUPABASE_PROJECT_ID and no supabase/.temp/project-ref (`link` ignores that file)", }, diff --git a/apps/cli/src/shared/cli/cli-args.service.ts b/apps/cli/src/shared/cli/cli-args.service.ts index 1fccd2add8..cac0908124 100644 --- a/apps/cli/src/shared/cli/cli-args.service.ts +++ b/apps/cli/src/shared/cli/cli-args.service.ts @@ -1,11 +1,10 @@ import { Context } from "effect"; -import type { RemoteArgvLeaf } from "./remote-argv.ts"; - export interface CliArgsShape { + /** The argv the command parses, with a bare `--remote <word>` already joined where that is the only reading. */ readonly args: ReadonlyArray<string>; - /** How the resolved command reads its operands; present only when argv holds a bare `--remote`. */ - readonly remoteLeaf?: RemoteArgvLeaf | undefined; + /** The word a bare `--remote` is followed by when it was taken as an operand and could name a branch. */ + readonly bareRemoteOperand?: string | undefined; } export class CliArgs extends Context.Service<CliArgs, CliArgsShape>()("supabase/cli/CliArgs") {} diff --git a/apps/cli/src/shared/cli/remote-argv.unit.test.ts b/apps/cli/src/shared/cli/remote-argv.unit.test.ts index 1a253a337e..60abcc25cf 100644 --- a/apps/cli/src/shared/cli/remote-argv.unit.test.ts +++ b/apps/cli/src/shared/cli/remote-argv.unit.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest"; import { rootCommandForFeatures } from "../../cli/root.ts"; import { bareRemoteOperand, joinRemoteOperands, type RemoteArgvLeaf } from "./remote-argv.ts"; -import { remoteArgvLeafFor, rewriteRemoteArgv } from "./run.ts"; +import { prepareCliArgs } from "./run.ts"; const REF = "abcdefghijklmnopqrst"; const URL = "postgresql://postgres:secret@db.example.com:5432/postgres"; @@ -199,7 +199,7 @@ describe("bareRemoteOperand", () => { }); }); -describe("rewriteRemoteArgv", () => { +describe("prepareCliArgs argv", () => { const root = rootCommandForFeatures(); const cases: ReadonlyArray<{ readonly name: string; @@ -259,16 +259,13 @@ describe("rewriteRemoteArgv", () => { ]; it.each(cases)("$name", ({ args, expected }) => { - expect(rewriteRemoteArgv(root, args)).toEqual(expected); + expect(prepareCliArgs(root, args).args).toEqual(expected); }); }); -describe("bareRemoteOperand on the real command tree", () => { +describe("prepareCliArgs bareRemoteOperand on the real command tree", () => { const root = rootCommandForFeatures(); - const operandOf = (raw: ReadonlyArray<string>) => { - const args = rewriteRemoteArgv(root, raw); - return bareRemoteOperand(args, remoteArgvLeafFor(root, args)); - }; + const operandOf = (raw: ReadonlyArray<string>) => prepareCliArgs(root, raw).bareRemoteOperand; it.each([ { name: "seed's subcommand", args: ["seed", "--remote", "buckets"] }, diff --git a/apps/cli/src/shared/cli/run.ts b/apps/cli/src/shared/cli/run.ts index dee701baa0..165abc4a95 100644 --- a/apps/cli/src/shared/cli/run.ts +++ b/apps/cli/src/shared/cli/run.ts @@ -52,14 +52,19 @@ import { withDebugConsole, withTraceExport, } from "../telemetry/trace-export.layer.ts"; -import { CliArgs } from "./cli-args.service.ts"; +import { CliArgs, type CliArgsShape } from "./cli-args.service.ts"; import { GLOBAL_VALUE_FLAG_TOKENS } from "./flag-groups.ts"; import { BOOLEAN_FLAG_VALUES, resolveAgentOutputFormatFromArgs, ROOT_BOOLEAN_FLAGS, } from "./agent-output.ts"; -import { joinRemoteOperands, remoteOperandCapacity, type RemoteArgvLeaf } from "./remote-argv.ts"; +import { + bareRemoteOperand, + joinRemoteOperands, + remoteOperandCapacity, + type RemoteArgvLeaf, +} from "./remote-argv.ts"; import { SuccessTrailer, successTrailerLayer } from "./success-trailer.ts"; import type { CliErrorSuggestionContext } from "./subcommand-flag-suggestions.ts"; import { @@ -189,7 +194,7 @@ function valueTakingFlagTokenPredicateForArgv( } /** How the command `args` resolves to reads its operands, or `undefined` without `--remote` or when the command takes no `--remote`. */ -export function remoteArgvLeafFor( +function remoteArgvLeafFor( rootCommand: Command.Command.Any, args: ReadonlyArray<string>, ): RemoteArgvLeaf | undefined { @@ -211,15 +216,32 @@ export function remoteArgvLeafFor( }; } -/** `args` with a bare `--remote <word>` joined to `--remote=<word>` wherever the command's operands leave no other reading. */ -export function rewriteRemoteArgv( +/** + * `rawArgs` with a bare `--remote <word>` joined to `--remote=<word>` wherever the command's + * operands leave no other reading, plus the word a bare `--remote` is still followed by. + */ +export function prepareCliArgs( rootCommand: Command.Command.Any, - args: ReadonlyArray<string>, -): ReadonlyArray<string> { - const leaf = remoteArgvLeafFor(rootCommand, args); - return leaf === undefined ? args : joinRemoteOperands(args, leaf); + rawArgs: ReadonlyArray<string>, +): CliArgsShape { + const leaf = remoteArgvLeafFor(rootCommand, rawArgs); + if (leaf === undefined) return { args: rawArgs }; + const args = joinRemoteOperands(rawArgs, leaf); + return { args, bareRemoteOperand: bareRemoteOperand(args, leaf) }; } +/** `CliArgs` and a `Stdio` whose `args` is the same argv, so every reader of either sees the rewritten command line. */ +export const cliArgsLayer = (cliArgs: CliArgsShape) => + Layer.mergeAll( + Layer.succeed(CliArgs, cliArgs), + Layer.effect( + Stdio.Stdio, + Effect.map(Stdio.Stdio, (stdio) => + Stdio.make({ ...stdio, args: Effect.succeed(cliArgs.args) }), + ), + ), + ); + /** Whether `token` is an occurrence of the long or exact-short boolean flag `name`, bare or valued (`--help`, `--help=false`). */ function isFlagOccurrence(token: string, name: string): boolean { return token === name || token.startsWith(`${name}=`); @@ -600,10 +622,11 @@ function cliProgramFor< BeforeParseError, >( rootCommand: Command.Command<Name, Input, ContextInput, E, R>, - args: ReadonlyArray<string>, + cliArgs: CliArgsShape, options: RunCliOptions<BeforeParseError>, outputFormat: OutputFormat, ) { + const { args } = cliArgs; const runtimeLayer = Layer.mergeAll(processControlLayer, runtimeInfoLayer, ttyLayer); const fallbackCommandLayer = Layer.mergeAll( // Root command env inference leaks some subcommand-provided services; these stand-ins die @@ -632,9 +655,7 @@ function cliProgramFor< Layer.provideMerge(projectLinkStateLayer), Layer.provideMerge(runtimeLayer), Layer.provideMerge(fallbackCommandLayer), - Layer.provideMerge( - Layer.succeed(CliArgs, { args, remoteLeaf: remoteArgvLeafFor(rootCommand, args) }), - ), + Layer.provideMerge(cliArgsLayer(cliArgs)), Layer.provideMerge(BunServices.layer), ); return withoutParseErrorHelpDump( @@ -674,7 +695,8 @@ export const runCli = Effect.fnUntraced(function* < const stdio = yield* Stdio.Stdio; return yield* stdio.args; }).pipe(Effect.provide(BunServices.layer)); - const args = rewriteRemoteArgv(rootCommand, rawArgs); + const cliArgs = prepareCliArgs(rootCommand, rawArgs); + const { args } = cliArgs; // Same shape `formatterLayerFor` builds below, so `normalizeCause`'s fallback path can reuse // `formatCliErrorsForDisplay` and surface the same subcommand-flag hint the formatters would. @@ -684,7 +706,7 @@ export const runCli = Effect.fnUntraced(function* < const aiTool = yield* AiTool; return resolveAgentOutputFormatFromArgs(args, aiTool.name, options.agentDefaultOutputFormat); }).pipe(Effect.provide(aiToolLayer)); - const cliProgram = cliProgramFor(rootCommand, args, options, outputFormat); + const cliProgram = cliProgramFor(rootCommand, cliArgs, options, outputFormat); const signalAwareLayer = processControlLayer.pipe( Layer.provideMerge(runtimeInfoLayer), diff --git a/apps/cli/src/shared/telemetry/__fixtures__/error-tags.txt b/apps/cli/src/shared/telemetry/__fixtures__/error-tags.txt index 4e23964bf2..1e07a91140 100644 --- a/apps/cli/src/shared/telemetry/__fixtures__/error-tags.txt +++ b/apps/cli/src/shared/telemetry/__fixtures__/error-tags.txt @@ -69,24 +69,14 @@ ComputeSourceMissingError ComputeStacksValidationError ComputeUnavailableError ComputeUploadFailedError -ConfigDiffBranchNotFoundError -ConfigDiffBranchNotLinkedError -ConfigDiffBranchNotReadyError -ConfigDiffBranchResolveNetworkError -ConfigDiffBranchResolveStatusError ConfigDiffLoadConfigError ConfigDiffOutputFlagUnsupportedError -ConfigDiffParentRefInvalidError ConfigDiffReadNetworkError ConfigDiffReadStatusError ConfigDiffWorkdirError -ConfigPullBranchNotFoundError -ConfigPullBranchNotLinkedError -ConfigPullBranchNotReadyError ConfigPullFileChangedError ConfigPullLoadConfigError ConfigPullOutputFlagUnsupportedError -ConfigPullParentRefInvalidError ConfigPullPlanDefectError ConfigPullReadNetworkError ConfigPullReadStatusError @@ -101,11 +91,6 @@ ConfigPushApiUpdateNetworkError ConfigPushApiUpdateStatusError ConfigPushAuthUpdateNetworkError ConfigPushAuthUpdateStatusError -ConfigPushBranchNotFoundError -ConfigPushBranchNotLinkedError -ConfigPushBranchNotReadyError -ConfigPushBranchResolveNetworkError -ConfigPushBranchResolveStatusError ConfigPushConfigEmptyError ConfigPushConfigReadNetworkError ConfigPushConfigReadStatusError @@ -118,7 +103,6 @@ ConfigPushListAddonsStatusError ConfigPushLoadConfigError ConfigPushNetworkRestrictionsUpdateNetworkError ConfigPushNetworkRestrictionsUpdateStatusError -ConfigPushParentRefInvalidError ConfigPushSslEnforcementUpdateNetworkError ConfigPushSslEnforcementUpdateStatusError ConfigPushStorageUpdateNetworkError @@ -426,13 +410,7 @@ ProjectsListNetworkError ProjectsListUnexpectedStatusError ProjectsOrgsListNetworkError ProjectsOrgsListUnexpectedStatusError -PullBranchNotFoundError -PullBranchNotLinkedError -PullBranchNotReadyError -PullBranchResolveNetworkError -PullBranchResolveStatusError PullOutputFlagUnsupportedError -PullParentRefInvalidError PullUncommittedChangesError PullWorkdirError RemoteJwksError diff --git a/apps/cli/src/shared/telemetry/error-tag-stability.unit.test.ts b/apps/cli/src/shared/telemetry/error-tag-stability.unit.test.ts index cf6761a714..479e673b21 100644 --- a/apps/cli/src/shared/telemetry/error-tag-stability.unit.test.ts +++ b/apps/cli/src/shared/telemetry/error-tag-stability.unit.test.ts @@ -1,7 +1,7 @@ import { BunServices } from "@effect/platform-bun"; import { beforeAll, describe, expect, it } from "@effect/vitest"; import { Config, Effect, FileSystem, Option, Path, PlatformError } from "effect"; -import { fileURLToPath, pathToFileURL } from "node:url"; +import { fileURLToPath } from "node:url"; /** * Guards the telemetry identity of every CLI error: the string passed to @@ -10,9 +10,7 @@ import { fileURLToPath, pathToFileURL } from "node:url"; * fingerprints, undetected until dashboards look wrong. * * Enumerates every production tagged-error tag under `apps/cli/src` and - * `packages/config/src`, resolving tags built via template interpolation - * (`mintConfigTargetErrors`) at runtime by constructing each class — see - * {@link collectComputedTagDeclarations}. Compares the set against the committed + * `packages/config/src` and compares the set against the committed * `__fixtures__/error-tags.txt` snapshot. * * Known gap: the snapshot is a set, not an owner-to-tag mapping, so two classes swapping tags @@ -41,19 +39,6 @@ const TEST_FILE_SUFFIXES = [ ".live.test.ts", ] as const; -/** - * Files whose tags `mintConfigTargetErrors` computes via template interpolation, resolved at - * runtime instead of by static regex. Also the list {@link collectStaticDeclarations} skips to - * avoid double-counting. Paths are relative to `cliSrcDir`. - */ -const COMPUTED_TAG_SOURCE_FILES: ReadonlyArray<string> = [ - "commands/config/pull/pull.errors.ts", - "commands/config/diff/diff.errors.ts", - "commands/config/push/push.errors.ts", - "commands/pull/pull.errors.ts", -]; -const computedTagSourceFileSet = new Set(COMPUTED_TAG_SOURCE_FILES); - interface TaggedErrorDeclaration { readonly className: string; readonly tag: string; @@ -83,19 +68,13 @@ function isProductionSourceFile(filePath: string): boolean { return !TEST_FILE_SUFFIXES.some((suffix) => filePath.endsWith(suffix)); } -/** - * Statically regexes every `Data.TaggedError("...")` string-literal declaration out of the - * given production files, skipping `COMPUTED_TAG_SOURCE_FILES` (those come from - * {@link collectComputedTagDeclarations} instead) so a file never contributes twice. - */ -const collectStaticDeclarations = (filePaths: ReadonlyArray<string>, rootDir: string) => +/** Regexes every `Data.TaggedError("...")` string-literal declaration out of the given production files. */ +const collectDeclarations = (filePaths: ReadonlyArray<string>) => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const declarations: Array<TaggedErrorDeclaration> = []; for (const filePath of filePaths) { - const relativeToRoot = path.relative(rootDir, filePath); - if (rootDir === cliSrcDir && computedTagSourceFileSet.has(relativeToRoot)) continue; const source = yield* fs.readFileString(filePath); for (const match of source.matchAll(TAGGED_ERROR_PATTERN)) { declarations.push({ @@ -108,35 +87,6 @@ const collectStaticDeclarations = (filePaths: ReadonlyArray<string>, rootDir: st return declarations; }); -/** - * Runtime half of the computed-tag guard: imports each `COMPUTED_TAG_SOURCE_FILES` module, - * constructs every exported class, and reads its real, fully-interpolated `_tag` off the - * instance — a source-text regex can't see `` Data.TaggedError(`${prefix}...`) ``. - * - * `new Export({})` is safe because every export here is a `Data.TaggedError`-derived class - * whose generated constructor assigns whatever properties are present without validating them. - */ -const collectComputedTagDeclarations = Effect.gen(function* () { - const path = yield* Path.Path; - const declarations: Array<TaggedErrorDeclaration> = []; - for (const relativeFile of COMPUTED_TAG_SOURCE_FILES) { - const moduleUrl = pathToFileURL(path.join(cliSrcDir, relativeFile)).href; - const module: Record<string, unknown> = yield* Effect.tryPromise(() => import(moduleUrl)); - for (const [exportName, exportValue] of Object.entries(module)) { - if (typeof exportValue !== "function") continue; - const constructed = yield* Effect.try(() => { - const Ctor = exportValue as new (args: Record<string, unknown>) => { _tag?: unknown }; - return new Ctor({})._tag; - }).pipe(Effect.option); - if (Option.isNone(constructed)) continue; // Not a constructible Data.TaggedError-shaped export. - const tag = constructed.value; - if (typeof tag !== "string") continue; - declarations.push({ className: exportName, tag, file: `apps/cli/src/${relativeFile}` }); - } - } - return declarations; -}); - /** Shared comparator for the fixture and the live scan, so a `localeCompare`-vs-default-sort * mismatch can't make the comparison lie. */ function compareTags(a: string, b: string): number { @@ -148,16 +98,14 @@ const readSnapshotTags = Effect.gen(function* () { return (yield* fs.readFileString(fixturePath)).split(/\r?\n/).filter((line) => line.length > 0); }); -/** Every production `Data.TaggedError` declaration, static and computed alike. */ +/** Every production `Data.TaggedError` declaration. */ const collectProductionDeclarations = Effect.gen(function* () { const cliFiles = (yield* walk(cliSrcDir)).filter(isProductionSourceFile); const externalFiles = (yield* walk(externalConfigSrcDir)).filter(isProductionSourceFile); - const staticDeclarations = [ - ...(yield* collectStaticDeclarations(cliFiles, cliSrcDir)), - ...(yield* collectStaticDeclarations(externalFiles, externalConfigSrcDir)), + return [ + ...(yield* collectDeclarations(cliFiles)), + ...(yield* collectDeclarations(externalFiles)), ]; - const computedDeclarations = yield* collectComputedTagDeclarations; - return [...staticDeclarations, ...computedDeclarations]; }); /** diff --git a/apps/cli/tests/helpers/permission-drift.ts b/apps/cli/tests/helpers/permission-drift.ts index 1faac0f4c7..a9b821d220 100644 --- a/apps/cli/tests/helpers/permission-drift.ts +++ b/apps/cli/tests/helpers/permission-drift.ts @@ -1,5 +1,6 @@ import { DB_TARGET_FLAGS, + SELECTOR_FLAGS, applicableEntries, applicableOperations, declaredPermissions, @@ -67,8 +68,9 @@ function knownFlagNames( for (const flag of permissions.noApiEffectFlags) names.add(flag); for (const entry of permissions.operations) { for (const cond of entry.when ?? []) { - if (cond.dbTarget === undefined) names.add(cond.flag); - else for (const flag of DB_TARGET_FLAGS) names.add(flag); + if (cond.dbTarget !== undefined) for (const flag of DB_TARGET_FLAGS) names.add(flag); + else if (cond.selector !== undefined) for (const flag of SELECTOR_FLAGS) names.add(flag); + else names.add(cond.flag); } } return names; diff --git a/apps/cli/tests/helpers/permission-drift.unit.test.ts b/apps/cli/tests/helpers/permission-drift.unit.test.ts index d8c4f0f750..29b6d11b3f 100644 --- a/apps/cli/tests/helpers/permission-drift.unit.test.ts +++ b/apps/cli/tests/helpers/permission-drift.unit.test.ts @@ -193,6 +193,47 @@ describe("assertDriftAgainst", () => { ).toThrow(/"v1-run-a-query", which is declared for this command but whose `when` condition/); }); + describe("selector conditions", () => { + const branchLookup = declared({ + status: "mapped", + operations: [ + { operationId: "v1-list-all-secrets", kind: "required" }, + { + operationId: "v1-list-all-branches", + kind: "required", + when: [{ selector: ["branch"] }], + }, + ], + noApiEffectFlags: [], + }); + const listBranches = { method: "GET", url: `${API}/v1/projects/${REF}/branches` }; + + it("accepts the branch listing only when a selector flag names a branch", () => { + const check = (activeFlags: Record<string, string | boolean>) => + assertDriftAgainst(branchLookup, { + command: "secrets list", + activeFlags, + requests: [listSecrets, listBranches], + }); + expect(() => check({ remote: "feature-x" })).not.toThrow(); + expect(() => check({ "project-ref": "feature-x" })).not.toThrow(); + expect(() => check({ remote: REF })).toThrow(/"v1-list-all-branches", which is declared/); + expect(() => check({})).toThrow(/"v1-list-all-branches", which is declared/); + }); + + it("classifies every flag that can carry the selector", () => { + for (const flag of ["remote", "linked", "project-ref", "project-id", "db-url", "local"]) { + expect(() => + assertDriftAgainst(branchLookup, { + command: "secrets list", + activeFlags: { [flag]: true }, + requests: [], + }), + ).not.toThrow(); + } + }); + }); + describe("request qualifiers", () => { const apiKeys = `${API}/v1/projects/${REF}/api-keys`; const withReveal = declared({ diff --git a/apps/cli/tests/helpers/permission-registry.ts b/apps/cli/tests/helpers/permission-registry.ts index ae163c1368..091675e532 100644 --- a/apps/cli/tests/helpers/permission-registry.ts +++ b/apps/cli/tests/helpers/permission-registry.ts @@ -7,8 +7,13 @@ import type { DbTarget, EntryCondition, OperationEntry, + SelectorShape, } from "../../src/command-internal/command-permissions/model.ts"; -import { DB_URL_PATTERN } from "../../src/command-internal/target/remote-vocabulary.ts"; +import { classifyNamedRemote } from "../../src/command-internal/target/remote-value.ts"; +import { + DB_URL_PATTERN, + LINKED_LITERAL, +} from "../../src/command-internal/target/remote-vocabulary.ts"; import { findCommand } from "../../src/shared/cli/command-docs.ts"; /** Which command tree to resolve against: `start`/`status`/`stop` differ by stack backend. */ @@ -26,6 +31,40 @@ export type ActiveFlags = Readonly<Record<string, boolean | string>>; /** The flags that select a database target, in precedence order. */ export const DB_TARGET_FLAGS = ["db-url", "local", "linked", "remote"] as const; +/** The flags that carry a target selector, whichever spelling the run used. */ +export const SELECTOR_FLAGS = [ + "remote", + "linked", + "project-ref", + "project-id", + "db-url", + "local", +] as const; + +const shapeOfNamedRemote = (value: string): SelectorShape => { + const named = classifyNamedRemote(value); + return named._tag === "Ref" ? "ref" : named._tag === "DbUrl" ? "url" : "branch"; +}; + +/** The shape of the target selector `activeFlags` carries, read in the order the CLI resolves them. */ +function selectorShapeOf(activeFlags: ActiveFlags): SelectorShape { + const given = (flag: string) => { + const value = Object.hasOwn(activeFlags, flag) ? activeFlags[flag] : undefined; + return value === "" ? undefined : value; + }; + if (given("local") !== undefined) return "none"; + const dbUrl = given("db-url"); + if (dbUrl !== undefined) return "url"; + const alias = given("project-ref") ?? given("project-id"); + if (typeof alias === "string") return shapeOfNamedRemote(alias); + if (given("linked") !== undefined) return "bare"; + const remote = given("remote"); + if (remote === undefined) return "none"; + return typeof remote === "string" && !LINKED_LITERAL.test(remote) + ? shapeOfNamedRemote(remote) + : "bare"; +} + /** The target `activeFlags` selects (`db-url`, `local`, `linked`, `remote`, or `project-ref`, which imply linked unless `remote` is a database URL), else `fallback`. */ function dbTargetOf( activeFlags: ActiveFlags, @@ -48,6 +87,9 @@ function conditionHolds( dbTarget: () => DbTarget, ): boolean { if (condition.dbTarget !== undefined) return condition.dbTarget.includes(dbTarget()); + if (condition.selector !== undefined) { + return condition.selector.includes(selectorShapeOf(activeFlags)); + } const value = Object.hasOwn(activeFlags, condition.flag) ? activeFlags[condition.flag] : undefined; diff --git a/apps/cli/tests/helpers/permission-registry.unit.test.ts b/apps/cli/tests/helpers/permission-registry.unit.test.ts index cba43c6be1..46a0a692e6 100644 --- a/apps/cli/tests/helpers/permission-registry.unit.test.ts +++ b/apps/cli/tests/helpers/permission-registry.unit.test.ts @@ -120,6 +120,53 @@ describe("applicableOperations", () => { }); }); + describe("selector shape", () => { + const REF = "abcdefghijklmnopqrst"; + const permissions = mapped([ + { operationId: "v1-list-all-branches", kind: "required", when: [{ selector: ["branch"] }] }, + { + operationId: "v1-get-pooler-config", + kind: "required", + when: [{ selector: ["ref", "branch", "url"] }], + }, + { + operationId: "v1-list-all-projects", + kind: "required", + when: [{ selector: ["none", "bare"] }], + }, + ]); + const applied = (activeFlags: Parameters<typeof applicableOperations>[1]) => + applicableOperations(permissions, activeFlags).map((e) => e.operationId); + + it("reads a --remote value by what it names", () => { + expect(applied({ remote: true })).toEqual(["v1-list-all-projects"]); + expect(applied({ remote: "yes" })).toEqual(["v1-list-all-projects"]); + expect(applied({ remote: REF })).toEqual(["v1-get-pooler-config"]); + expect(applied({ remote: "feature-x" })).toEqual([ + "v1-list-all-branches", + "v1-get-pooler-config", + ]); + expect(applied({ remote: "postgresql://postgres@localhost:5432/postgres" })).toEqual([ + "v1-get-pooler-config", + ]); + }); + + it("folds the deprecated aliases into the same shapes", () => { + expect(applied({ "project-ref": REF })).toEqual(applied({ remote: REF })); + expect(applied({ "project-id": "feature-x" })).toEqual(applied({ remote: "feature-x" })); + expect(applied({ "project-ref": "true" })).toEqual(applied({ remote: "feature-x" })); + expect(applied({ linked: true })).toEqual(applied({ remote: true })); + expect(applied({ "db-url": "postgresql://postgres@localhost:5432/postgres" })).toEqual( + applied({ remote: "postgresql://postgres@localhost:5432/postgres" }), + ); + }); + + it("reads no selector, and --local, as none", () => { + expect(applied({})).toEqual(["v1-list-all-projects"]); + expect(applied({ local: true })).toEqual(["v1-list-all-projects"]); + }); + }); + it("keeps a required entry with no context over a required entry that has one, for the same operationId", () => { const permissions = mapped([ { diff --git a/apps/cli/tests/helpers/remote-flag-fixture.ts b/apps/cli/tests/helpers/remote-flag-fixture.ts index 81220ef694..c2129bfd99 100644 --- a/apps/cli/tests/helpers/remote-flag-fixture.ts +++ b/apps/cli/tests/helpers/remote-flag-fixture.ts @@ -1,17 +1,13 @@ import { Effect } from "effect"; import type { Command } from "effect/cli"; -import { Argument, Command as CommandApi, Flag, Param, Primitive } from "effect/cli"; +import { Argument, Command as CommandApi, Flag } from "effect/cli"; +import { targetFlags } from "../../src/command-internal/target/target-flags.ts"; import { withTargetPolicy } from "../../src/command-internal/target/target-policy.ts"; import type { TargetPolicyDeclaration } from "../../src/command-internal/target/target-policy.ts"; -/** A bare-capable, string-valued flag shaped like `--remote`: the Boolean tag keeps the parser from taking the next token as its value. */ -export const remoteFlag = Param.makeSingle({ - kind: Param.flagKind, - name: "remote", - primitiveType: Object.assign(Object.create(Primitive.Boolean), { - parse: (value: string) => Effect.succeed(value), - }), -}).pipe(Flag.withDescription("Target a remote project."), Flag.optional); +export const remoteFlag = targetFlags({ subject: "database", dbUrl: true }).remote.pipe( + Flag.withDescription("Target a remote project."), +); const positional = (kind: "required" | "optional" | "variadic", index: number) => { const argument = Argument.String(`arg${index}`); From 44fa188ce3f784874b123e01ad7e107c797499f6 Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Mon, 19 Oct 2026 14:48:03 +0100 Subject: [PATCH 146/291] refactor(cli): report every target flag from withCommandTelemetry withCommandTelemetry logs --remote, --linked, --local and a ref-shaped --project-ref/--project-id for every hosted or selectable command, so the per-leaf projectRefSafeFlags list is gone. --- apps/cli/AGENTS.md | 2 +- .../target/project-ref-safe-flags.ts | 15 ---- .../project-ref-safe-flags.unit.test.ts | 35 --------- .../branches/create/create.command.ts | 6 +- .../branches/delete/delete.command.ts | 6 +- .../branches/disable/disable.command.ts | 6 +- .../src/commands/branches/get/get.command.ts | 6 +- .../commands/branches/list/list.command.ts | 6 +- .../commands/branches/pause/pause.command.ts | 6 +- .../branches/unpause/unpause.command.ts | 6 +- .../branches/update/update.command.ts | 6 +- .../src/commands/config/diff/diff.command.ts | 2 - .../config/diff/diff.integration.test.ts | 5 +- .../src/commands/config/pull/pull.command.ts | 2 - .../src/commands/config/push/push.command.ts | 2 - .../commands/db/advisors/advisors.command.ts | 4 +- apps/cli/src/commands/db/dump/dump.command.ts | 1 - apps/cli/src/commands/db/pull/pull.command.ts | 1 - apps/cli/src/commands/db/push/push.command.ts | 1 - .../get-root-key/get-root-key.command.ts | 6 +- .../update-root-key.command.ts | 6 +- .../functions/delete/delete.command.ts | 2 - .../functions/deploy/deploy.command.ts | 2 - .../functions/download/download.command.ts | 12 +-- .../download/download.integration.test.ts | 2 + .../commands/functions/list/list.command.ts | 2 - .../src/commands/gen/types/types.command.ts | 2 - .../commands/inspect/db/inspect-db-command.ts | 1 - .../commands/inspect/report/report.command.ts | 1 - apps/cli/src/commands/link/link.command.ts | 4 - .../commands/link/link.integration.test.ts | 3 + .../commands/notebooks/pull/pull.command.ts | 6 +- .../commands/notebooks/push/push.command.ts | 6 +- .../projects/api-keys/api-keys.command.ts | 3 +- apps/cli/src/commands/pull/pull.command.ts | 2 - .../snippets/download/download.command.ts | 7 +- .../commands/snippets/list/list.command.ts | 8 +- apps/cli/src/commands/sso/add/add.command.ts | 2 - .../cli/src/commands/sso/info/info.command.ts | 6 +- .../cli/src/commands/sso/list/list.command.ts | 6 +- .../src/commands/sso/remove/remove.command.ts | 6 +- .../cli/src/commands/sso/show/show.command.ts | 6 +- .../src/commands/sso/update/update.command.ts | 6 +- apps/cli/src/telemetry/command-telemetry.ts | 20 +++++ .../telemetry/command-telemetry.unit.test.ts | 78 +++++++++++++++++++ 45 files changed, 130 insertions(+), 193 deletions(-) delete mode 100644 apps/cli/src/command-internal/target/project-ref-safe-flags.ts delete mode 100644 apps/cli/src/command-internal/target/project-ref-safe-flags.unit.test.ts diff --git a/apps/cli/AGENTS.md b/apps/cli/AGENTS.md index 2cda6e1f5d..de9a596af8 100644 --- a/apps/cli/AGENTS.md +++ b/apps/cli/AGENTS.md @@ -187,7 +187,7 @@ property shape (`flags`, `is_agent`, and `env_signals`), use canonical names and `safeFlags` may include only values that carry no user data. Keep the established safe project, project-id, org-id, and version cases. `withCommandTelemetry` reports the target flags itself for -every command, so never list them in a command's `safeFlags`: `--remote`, `--linked`, `--local`, +every command with a `hosted` or `selectable` target policy, so never list them in a command's `safeFlags`: `--remote`, `--linked`, `--local`, `--project-ref`/`--project-id` (the value only when it matches `PROJECT_REF_PATTERN`, since it can be a user-created branch name) and `--db-url` (always redacted). Global boolean and choice flags are resolved by the wrapper from `globalFlagValues`; a command-local flag wins, so a local diff --git a/apps/cli/src/command-internal/target/project-ref-safe-flags.ts b/apps/cli/src/command-internal/target/project-ref-safe-flags.ts deleted file mode 100644 index c02e744889..0000000000 --- a/apps/cli/src/command-internal/target/project-ref-safe-flags.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { Option } from "effect"; - -import { PROJECT_REF_PATTERN } from "../../config/project-ref.service.ts"; - -const isRefShaped = (value: Option.Option<string> | undefined): boolean => - value !== undefined && Option.isSome(value) && PROJECT_REF_PATTERN.test(value.value); - -/** `--project-ref` and `--project-id` can also hold a branch name, so only a ref-shaped value is safe to log. */ -export const projectRefSafeFlags = (flags: { - readonly projectRef: Option.Option<string>; - readonly projectId?: Option.Option<string>; -}): ReadonlyArray<string> => [ - ...(isRefShaped(flags.projectRef) ? ["project-ref"] : []), - ...(isRefShaped(flags.projectId) ? ["project-id"] : []), -]; diff --git a/apps/cli/src/command-internal/target/project-ref-safe-flags.unit.test.ts b/apps/cli/src/command-internal/target/project-ref-safe-flags.unit.test.ts deleted file mode 100644 index 7e4bbb5738..0000000000 --- a/apps/cli/src/command-internal/target/project-ref-safe-flags.unit.test.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { describe, expect, it } from "@effect/vitest"; -import { Option } from "effect"; - -import { projectRefSafeFlags } from "./project-ref-safe-flags.ts"; - -const REF = "abcdefghijklmnopqrst"; - -describe("projectRefSafeFlags", () => { - it("reports --project-ref when it is a project ref", () => { - expect(projectRefSafeFlags({ projectRef: Option.some(REF) })).toEqual(["project-ref"]); - }); - - it("withholds a --project-ref value that is a branch name", () => { - expect(projectRefSafeFlags({ projectRef: Option.some("feature-x") })).toEqual([]); - }); - - it("reports nothing without --project-ref", () => { - expect(projectRefSafeFlags({ projectRef: Option.none() })).toEqual([]); - }); - - it("applies the same rule to --project-id", () => { - expect(projectRefSafeFlags({ projectRef: Option.none(), projectId: Option.some(REF) })).toEqual( - ["project-id"], - ); - expect( - projectRefSafeFlags({ projectRef: Option.none(), projectId: Option.some("feature-x") }), - ).toEqual([]); - }); - - it("reports both flags when both are ref-shaped", () => { - expect( - projectRefSafeFlags({ projectRef: Option.some(REF), projectId: Option.some(REF) }), - ).toEqual(["project-ref", "project-id"]); - }); -}); diff --git a/apps/cli/src/commands/branches/create/create.command.ts b/apps/cli/src/commands/branches/create/create.command.ts index 48a17c8034..4aac40124d 100644 --- a/apps/cli/src/commands/branches/create/create.command.ts +++ b/apps/cli/src/commands/branches/create/create.command.ts @@ -12,7 +12,6 @@ import { withCommandTelemetry } from "../../../telemetry/command-telemetry.ts"; import { branchesCreate } from "./create.handler.ts"; import { withTargetPolicy } from "../../../command-internal/target/target-policy.ts"; import { projectTargetFlags } from "../../../command-internal/target/target-flags.ts"; -import { projectRefSafeFlags } from "../../../command-internal/target/project-ref-safe-flags.ts"; import { branchesParentTarget } from "../branches.target.ts"; const BRANCH_REGIONS = [ @@ -109,10 +108,7 @@ export const branchesCreateCommand = Command.make("create", config).pipe( ), ), Command.withHandler((flags) => - branchesCreate(flags).pipe( - withCommandTelemetry({ flags, safeFlags: projectRefSafeFlags(flags), config }), - withJsonErrorHandling, - ), + branchesCreate(flags).pipe(withCommandTelemetry({ flags, config }), withJsonErrorHandling), ), Command.provide(managementApiRuntimeLayer(["branches", "create"])), 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 95df3caa51..d2edb49eb9 100644 --- a/apps/cli/src/commands/branches/delete/delete.command.ts +++ b/apps/cli/src/commands/branches/delete/delete.command.ts @@ -13,7 +13,6 @@ import { withCommandTelemetry } from "../../../telemetry/command-telemetry.ts"; import { branchesDelete } from "./delete.handler.ts"; import { withTargetPolicy } from "../../../command-internal/target/target-policy.ts"; import { projectTargetFlags } from "../../../command-internal/target/target-flags.ts"; -import { projectRefSafeFlags } from "../../../command-internal/target/project-ref-safe-flags.ts"; import { branchesParentTarget } from "../branches.target.ts"; const config = { @@ -39,10 +38,7 @@ export const branchesDeleteCommand = Command.make("delete", config).pipe( ), ), Command.withHandler((flags) => - branchesDelete(flags).pipe( - withCommandTelemetry({ flags, safeFlags: projectRefSafeFlags(flags) }), - withJsonErrorHandling, - ), + branchesDelete(flags).pipe(withCommandTelemetry({ flags }), withJsonErrorHandling), ), 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 03ed3494f2..6cfc6ce6eb 100644 --- a/apps/cli/src/commands/branches/disable/disable.command.ts +++ b/apps/cli/src/commands/branches/disable/disable.command.ts @@ -11,7 +11,6 @@ import { withCommandTelemetry } from "../../../telemetry/command-telemetry.ts"; import { branchesDisable } from "./disable.handler.ts"; import { withTargetPolicy } from "../../../command-internal/target/target-policy.ts"; import { projectTargetFlags } from "../../../command-internal/target/target-flags.ts"; -import { projectRefSafeFlags } from "../../../command-internal/target/project-ref-safe-flags.ts"; import { branchesParentTarget } from "../branches.target.ts"; const config = { @@ -32,10 +31,7 @@ export const branchesDisableCommand = Command.make("disable", config).pipe( ), ), Command.withHandler((flags) => - branchesDisable(flags).pipe( - withCommandTelemetry({ flags, safeFlags: projectRefSafeFlags(flags) }), - withJsonErrorHandling, - ), + branchesDisable(flags).pipe(withCommandTelemetry({ flags }), withJsonErrorHandling), ), 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 106006d6e1..a96bdb6166 100644 --- a/apps/cli/src/commands/branches/get/get.command.ts +++ b/apps/cli/src/commands/branches/get/get.command.ts @@ -15,7 +15,6 @@ import { withCommandTelemetry } from "../../../telemetry/command-telemetry.ts"; import { branchesGet } from "./get.handler.ts"; import { withTargetPolicy } from "../../../command-internal/target/target-policy.ts"; import { projectTargetFlags } from "../../../command-internal/target/target-flags.ts"; -import { projectRefSafeFlags } from "../../../command-internal/target/project-ref-safe-flags.ts"; import { branchesParentTarget } from "../branches.target.ts"; // `get.handler.ts` skips the api-keys and pooler calls for `-o pretty`, and with no `-o` when the output format is text. @@ -90,10 +89,7 @@ export const branchesGetCommand = Command.make("get", config).pipe( ), ), Command.withHandler((flags) => - branchesGet(flags).pipe( - withCommandTelemetry({ flags, safeFlags: projectRefSafeFlags(flags) }), - withJsonErrorHandling, - ), + branchesGet(flags).pipe(withCommandTelemetry({ flags }), withJsonErrorHandling), ), 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 ffbb284afd..f1a98ff02b 100644 --- a/apps/cli/src/commands/branches/list/list.command.ts +++ b/apps/cli/src/commands/branches/list/list.command.ts @@ -11,7 +11,6 @@ import { withCommandTelemetry } from "../../../telemetry/command-telemetry.ts"; import { branchesList } from "./list.handler.ts"; import { withTargetPolicy } from "../../../command-internal/target/target-policy.ts"; import { projectTargetFlags } from "../../../command-internal/target/target-flags.ts"; -import { projectRefSafeFlags } from "../../../command-internal/target/project-ref-safe-flags.ts"; import { branchesParentTarget } from "../branches.target.ts"; const config = { @@ -32,10 +31,7 @@ export const branchesListCommand = Command.make("list", config).pipe( ), ), Command.withHandler((flags) => - branchesList(flags).pipe( - withCommandTelemetry({ flags, safeFlags: projectRefSafeFlags(flags) }), - withJsonErrorHandling, - ), + branchesList(flags).pipe(withCommandTelemetry({ flags }), withJsonErrorHandling), ), 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 eef2995e32..92e90d0a40 100644 --- a/apps/cli/src/commands/branches/pause/pause.command.ts +++ b/apps/cli/src/commands/branches/pause/pause.command.ts @@ -13,7 +13,6 @@ import { withCommandTelemetry } from "../../../telemetry/command-telemetry.ts"; import { branchesPause } from "./pause.handler.ts"; import { withTargetPolicy } from "../../../command-internal/target/target-policy.ts"; import { projectTargetFlags } from "../../../command-internal/target/target-flags.ts"; -import { projectRefSafeFlags } from "../../../command-internal/target/project-ref-safe-flags.ts"; import { branchesParentTarget } from "../branches.target.ts"; const config = { @@ -40,10 +39,7 @@ export const branchesPauseCommand = Command.make("pause", config).pipe( ), ), Command.withHandler((flags) => - branchesPause(flags).pipe( - withCommandTelemetry({ flags, safeFlags: projectRefSafeFlags(flags) }), - withJsonErrorHandling, - ), + branchesPause(flags).pipe(withCommandTelemetry({ flags }), withJsonErrorHandling), ), 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 518d2251d8..11f9147b60 100644 --- a/apps/cli/src/commands/branches/unpause/unpause.command.ts +++ b/apps/cli/src/commands/branches/unpause/unpause.command.ts @@ -13,7 +13,6 @@ import { withCommandTelemetry } from "../../../telemetry/command-telemetry.ts"; import { branchesUnpause } from "./unpause.handler.ts"; import { withTargetPolicy } from "../../../command-internal/target/target-policy.ts"; import { projectTargetFlags } from "../../../command-internal/target/target-flags.ts"; -import { projectRefSafeFlags } from "../../../command-internal/target/project-ref-safe-flags.ts"; import { branchesParentTarget } from "../branches.target.ts"; const config = { @@ -40,10 +39,7 @@ export const branchesUnpauseCommand = Command.make("unpause", config).pipe( ), ), Command.withHandler((flags) => - branchesUnpause(flags).pipe( - withCommandTelemetry({ flags, safeFlags: projectRefSafeFlags(flags) }), - withJsonErrorHandling, - ), + branchesUnpause(flags).pipe(withCommandTelemetry({ flags }), withJsonErrorHandling), ), 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 56010afcc5..d60174812d 100644 --- a/apps/cli/src/commands/branches/update/update.command.ts +++ b/apps/cli/src/commands/branches/update/update.command.ts @@ -14,7 +14,6 @@ import { withCommandTelemetry } from "../../../telemetry/command-telemetry.ts"; import { branchesUpdate } from "./update.handler.ts"; import { withTargetPolicy } from "../../../command-internal/target/target-policy.ts"; import { projectTargetFlags } from "../../../command-internal/target/target-flags.ts"; -import { projectRefSafeFlags } from "../../../command-internal/target/project-ref-safe-flags.ts"; import { branchesParentTarget } from "../branches.target.ts"; const BRANCH_STATUSES = [ @@ -72,10 +71,7 @@ export const branchesUpdateCommand = Command.make("update", config).pipe( ), ), Command.withHandler((flags) => - branchesUpdate(flags).pipe( - withCommandTelemetry({ flags, safeFlags: projectRefSafeFlags(flags), config }), - withJsonErrorHandling, - ), + branchesUpdate(flags).pipe(withCommandTelemetry({ flags, config }), withJsonErrorHandling), ), Command.provide(managementApiRuntimeLayer(["branches", "update"])), withTargetPolicy({ group: "hosted", consent: "no" }), diff --git a/apps/cli/src/commands/config/diff/diff.command.ts b/apps/cli/src/commands/config/diff/diff.command.ts index 9a3042c7cd..871bfb9f27 100644 --- a/apps/cli/src/commands/config/diff/diff.command.ts +++ b/apps/cli/src/commands/config/diff/diff.command.ts @@ -3,7 +3,6 @@ import { Command, Flag } from "effect/cli"; import { withCliConfigFlags } from "../../../config/cli-config-flags.ts"; import { cliConfigValuesLayer } from "../../../config/cli-config-values.layer.ts"; -import { projectRefSafeFlags } from "../../../command-internal/target/project-ref-safe-flags.ts"; 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"; @@ -37,7 +36,6 @@ export const configDiffHandler = (flags: ConfigDiffFlags) => // actually ref-shaped — a user-created branch name must never reach PostHog. withCommandTelemetry({ flags, - safeFlags: projectRefSafeFlags(flags), // This command rejects every `-o/--output` value itself with a message pointing at // `--output-format`, so the wrapper must allow the full choice set through — otherwise its // own generic enum-check message would fire first and the handler would never run. 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 b814819fd7..3fe3fb487c 100644 --- a/apps/cli/src/commands/config/diff/diff.integration.test.ts +++ b/apps/cli/src/commands/config/diff/diff.integration.test.ts @@ -30,6 +30,7 @@ import { realLinkedProjectCache } from "../../../../tests/helpers/permission-rea import { resolveWorkdir } from "../../../config/command-settings.layer.ts"; import { GLOBAL_OUTPUT_FORMATS } from "../../../command-internal/global-flags.ts"; import type { RemoteValue } from "../../../command-internal/target/remote-value.ts"; +import { TargetPolicy } from "../../../command-internal/target/target-policy.ts"; import { commandRuntimeLayer } from "../../../shared/runtime/command-runtime.layer.ts"; import { configDiffHandler } from "./diff.command.ts"; import { CONFIG_DIFF_PAYLOAD_VERSION } from "./diff.format.ts"; @@ -1486,8 +1487,7 @@ describe("config diff integration", () => { }); describe("config diff telemetry wiring", () => { - // Uses configDiffHandler (not the bare handler) since the safeFlags guard lives in the command - // wiring, not the handler itself. + // Uses configDiffHandler (not the bare handler) since telemetry wraps the handler in the command wiring. const wiringLayer = ( analytics: ReturnType<typeof mockContextualAnalytics>, args: ReadonlyArray<string>, @@ -1496,6 +1496,7 @@ describe("config diff telemetry wiring", () => { setup({ toml: 'project_id = "test"\n', analytics }).layer, commandRuntimeLayer(["config", "diff"]).pipe(Layer.provide(BunServices.layer)), Stdio.layerTest({ args: Effect.succeed(["config", "diff", ...args]) }), + Layer.succeed(TargetPolicy, { group: "selectable", consent: "none" }), ); const flagsOf = (analytics: ReturnType<typeof mockContextualAnalytics>) => diff --git a/apps/cli/src/commands/config/pull/pull.command.ts b/apps/cli/src/commands/config/pull/pull.command.ts index b1d80bd4ff..e09d9d95d5 100644 --- a/apps/cli/src/commands/config/pull/pull.command.ts +++ b/apps/cli/src/commands/config/pull/pull.command.ts @@ -4,7 +4,6 @@ import { Command, Flag } from "effect/cli"; import { withCliConfigFlags } from "../../../config/cli-config-flags.ts"; import { cliConfigValuesLayer } from "../../../config/cli-config-values.layer.ts"; -import { projectRefSafeFlags } from "../../../command-internal/target/project-ref-safe-flags.ts"; import { withPermissions } from "../../../command-internal/command-permissions/command-permissions.annotation.ts"; import { compose } from "../../../command-internal/command-permissions/model.ts"; import { configPullRun } from "../../../command-internal/config-pull-run.ts"; @@ -48,7 +47,6 @@ const configPullHandler = (flags: ConfigPullFlags) => // and is never safe to log. withCommandTelemetry({ flags, - safeFlags: projectRefSafeFlags(flags), // The handler rejects every -o/--output value itself, so the full global choice set is // declared "allowed" here rather than gated by this wrapper. outputFormats: GLOBAL_OUTPUT_FORMATS, diff --git a/apps/cli/src/commands/config/push/push.command.ts b/apps/cli/src/commands/config/push/push.command.ts index 637186d619..bf7249bb30 100644 --- a/apps/cli/src/commands/config/push/push.command.ts +++ b/apps/cli/src/commands/config/push/push.command.ts @@ -2,7 +2,6 @@ import { Command } from "effect/cli"; import { withCliConfigFlags } from "../../../config/cli-config-flags.ts"; import { cliConfigValuesLayer } from "../../../config/cli-config-values.layer.ts"; -import { projectRefSafeFlags } from "../../../command-internal/target/project-ref-safe-flags.ts"; import type * as CliCommand from "effect/cli/Command"; import { withPermissions } from "../../../command-internal/command-permissions/command-permissions.annotation.ts"; @@ -32,7 +31,6 @@ export const configPushHandler = (flags: ConfigPushFlags) => // a bad paste) must reach PostHog as "<redacted>". withCommandTelemetry({ flags, - safeFlags: projectRefSafeFlags(flags), }), withJsonErrorHandling, ); diff --git a/apps/cli/src/commands/db/advisors/advisors.command.ts b/apps/cli/src/commands/db/advisors/advisors.command.ts index 6a513d086a..5ac9fe8670 100644 --- a/apps/cli/src/commands/db/advisors/advisors.command.ts +++ b/apps/cli/src/commands/db/advisors/advisors.command.ts @@ -87,9 +87,7 @@ export const dbAdvisorsCommand = Command.make("advisors", config).pipe( level: flags.level, "fail-on": flags.failOn, }, - // type/level/fail-on are auto-detected as safe via `config` below; --db-url stays - // redacted (may carry secrets), and --project-ref stays redacted too (no established - // telemetry-safety baseline). + // type/level/fail-on are auto-detected as safe via `config` below. config, }), withJsonErrorHandling, diff --git a/apps/cli/src/commands/db/dump/dump.command.ts b/apps/cli/src/commands/db/dump/dump.command.ts index 3cc49812db..84b7f42d9d 100644 --- a/apps/cli/src/commands/db/dump/dump.command.ts +++ b/apps/cli/src/commands/db/dump/dump.command.ts @@ -146,7 +146,6 @@ export const dbDumpCommand = Command.make("dump", config).pipe( password: flags.password, schema: flags.schema, }, - // Not on the established `--project-ref` safeFlags allowlist, so it stays redacted. aliases: { s: "schema", x: "exclude", f: "file", p: "password" }, }), Effect.catchTag("DbDumpRunError", onRunFailure), diff --git a/apps/cli/src/commands/db/pull/pull.command.ts b/apps/cli/src/commands/db/pull/pull.command.ts index 25487a848a..98d4c4cbf2 100644 --- a/apps/cli/src/commands/db/pull/pull.command.ts +++ b/apps/cli/src/commands/db/pull/pull.command.ts @@ -100,7 +100,6 @@ const telemetryOptions = (flags: DbPullFlags) => ({ // `password` is a credential — always reaches telemetry as `<redacted>`. password: flags.password, }, - // Not on the established `--project-ref` safeFlags allowlist, so it stays redacted. aliases: { s: "schema", p: "password" }, config, }); diff --git a/apps/cli/src/commands/db/push/push.command.ts b/apps/cli/src/commands/db/push/push.command.ts index bf672167d2..24cbba7255 100644 --- a/apps/cli/src/commands/db/push/push.command.ts +++ b/apps/cli/src/commands/db/push/push.command.ts @@ -96,7 +96,6 @@ export const dbPushCommand = Command.make("push", config).pipe( // `password` is a credential — always reaches telemetry as `<redacted>`. password: flags.password, }, - // Not on the established `--project-ref` safeFlags allowlist, so it stays redacted. aliases: { p: "password" }, }), withJsonErrorHandling, 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 128c22c098..05f186001d 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 @@ -33,11 +33,7 @@ export const encryptionGetRootKeyCommand = Command.make("get-root-key", config). ), ), Command.withHandler((flags) => - encryptionGetRootKey(flags).pipe( - // `--project-ref` is not telemetry-safe for encryption (no `markFlagTelemetrySafe`). - withCommandTelemetry({ flags }), - withJsonErrorHandling, - ), + encryptionGetRootKey(flags).pipe(withCommandTelemetry({ flags }), withJsonErrorHandling), ), 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 468a92ff86..9b50e9cd46 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 @@ -44,11 +44,7 @@ export const encryptionUpdateRootKeyCommand = Command.make("update-root-key", co ), ), Command.withHandler((flags) => - encryptionUpdateRootKey(flags).pipe( - // `--project-ref` is not telemetry-safe for encryption (no `markFlagTelemetrySafe`). - withCommandTelemetry({ flags }), - withJsonErrorHandling, - ), + encryptionUpdateRootKey(flags).pipe(withCommandTelemetry({ flags }), withJsonErrorHandling), ), Command.provide(updateRuntime), withTargetPolicy({ group: "hosted", 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 f673abd6bb..ff4bbf0880 100644 --- a/apps/cli/src/commands/functions/delete/delete.command.ts +++ b/apps/cli/src/commands/functions/delete/delete.command.ts @@ -2,7 +2,6 @@ import { Argument, Command } from "effect/cli"; import type * as CliCommand from "effect/cli/Command"; import { withPermissions } from "../../../command-internal/command-permissions/command-permissions.annotation.ts"; import { compose } from "../../../command-internal/command-permissions/model.ts"; -import { projectRefSafeFlags } from "../../../command-internal/target/project-ref-safe-flags.ts"; import { projectRefPicker } from "../../../config/project-ref.layer.ts"; import { linkedProjectCache } from "../../../telemetry/linked-project-cache.layer.ts"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; @@ -28,7 +27,6 @@ export const functionsDeleteHandler = (flags: FunctionsDeleteFlags) => functionsDelete(flags).pipe( withCommandTelemetry({ flags, - safeFlags: projectRefSafeFlags(flags), }), withJsonErrorHandling, ); diff --git a/apps/cli/src/commands/functions/deploy/deploy.command.ts b/apps/cli/src/commands/functions/deploy/deploy.command.ts index 0a8beb18c0..452fd53222 100644 --- a/apps/cli/src/commands/functions/deploy/deploy.command.ts +++ b/apps/cli/src/commands/functions/deploy/deploy.command.ts @@ -4,7 +4,6 @@ import { withPermissions } from "../../../command-internal/command-permissions/c import { compose } from "../../../command-internal/command-permissions/model.ts"; import { branchResolution } from "../../../command-internal/target/branch-resolution.ts"; import { projectTargetFlags } from "../../../command-internal/target/target-flags.ts"; -import { projectRefSafeFlags } from "../../../command-internal/target/project-ref-safe-flags.ts"; import { projectRefPicker } from "../../../config/project-ref.layer.ts"; import { linkedProjectCache } from "../../../telemetry/linked-project-cache.layer.ts"; import { withCliConfigFlags } from "../../../config/cli-config-flags.ts"; @@ -154,7 +153,6 @@ export const functionsDeployCommand = Command.make("deploy", config).pipe( functionsDeploy(flags).pipe( withCommandTelemetry({ flags, - safeFlags: projectRefSafeFlags(flags), }), withJsonErrorHandling, ), diff --git a/apps/cli/src/commands/functions/download/download.command.ts b/apps/cli/src/commands/functions/download/download.command.ts index d47d016cd0..2736a620aa 100644 --- a/apps/cli/src/commands/functions/download/download.command.ts +++ b/apps/cli/src/commands/functions/download/download.command.ts @@ -5,7 +5,6 @@ import { withPermissions } from "../../../command-internal/command-permissions/c import { compose } from "../../../command-internal/command-permissions/model.ts"; import { withCliConfigFlags } from "../../../config/cli-config-flags.ts"; import { cliConfigValuesLayer } from "../../../config/cli-config-values.layer.ts"; -import { projectRefSafeFlags } from "../../../command-internal/target/project-ref-safe-flags.ts"; import { projectRefPicker } from "../../../config/project-ref.layer.ts"; import { linkedProjectCache } from "../../../telemetry/linked-project-cache.layer.ts"; import { functionsDownload as functionsDownloadCalls } from "../../../shared/functions/download.ts"; @@ -49,10 +48,7 @@ export type FunctionsDownloadFlags = CliCommand.Command.Config.Infer<typeof conf // Exported so integration tests can drive the exact wiring `Command.withHandler` // uses below, instead of re-asserting the generic instrumentation mechanism. export const functionsDownloadHandler = (flags: FunctionsDownloadFlags) => - functionsDownload(flags).pipe( - withCommandTelemetry({ flags, safeFlags: projectRefSafeFlags(flags) }), - withJsonErrorHandling, - ); + functionsDownload(flags).pipe(withCommandTelemetry({ flags }), withJsonErrorHandling); const COMMAND_PATH = ["functions", "download"]; const managementApiLayer = managementApiRuntimeLayer(COMMAND_PATH); @@ -65,11 +61,7 @@ const functionsDownloadLegacyBundleHandler = (flags: FunctionsDownloadFlags) => return removedFlag( "--legacy-bundle", `Retry with \`supabase functions download --use-api ${slug}\` to unbundle server-side without Docker. If that also fails and the Function was deployed with a CLI older than 1.120.0, redeploy it with the current CLI.`, - ).pipe( - withCommandTelemetry({ flags, safeFlags: projectRefSafeFlags(flags) }), - withJsonErrorHandling, - Effect.provide(removedFlagLayer), - ); + ).pipe(withCommandTelemetry({ flags }), withJsonErrorHandling, Effect.provide(removedFlagLayer)); }; export const functionsDownloadCommand = Command.make("download", config).pipe( diff --git a/apps/cli/src/commands/functions/download/download.integration.test.ts b/apps/cli/src/commands/functions/download/download.integration.test.ts index a72bb93acb..c798c67ac1 100644 --- a/apps/cli/src/commands/functions/download/download.integration.test.ts +++ b/apps/cli/src/commands/functions/download/download.integration.test.ts @@ -48,6 +48,7 @@ import { import { mockChildProcessSpawner } from "../../../../tests/helpers/child-process-spawner.ts"; import { sanitizeProjectId } from "../../../shared/config/project-id.ts"; import { containerRuntimeNotFoundMessage } from "../../../command-internal/container-cli.ts"; +import { TargetPolicy } from "../../../command-internal/target/target-policy.ts"; import { downloadFunctions, functionsDownload as functionsDownloadCalls, @@ -1975,6 +1976,7 @@ describe("functions download", () => { "abcdefghijklmnopqrst", ]), }), + Layer.succeed(TargetPolicy, { group: "hosted", consent: "none" }), ); return Effect.gen(function* () { diff --git a/apps/cli/src/commands/functions/list/list.command.ts b/apps/cli/src/commands/functions/list/list.command.ts index 79603ab28e..05f744f644 100644 --- a/apps/cli/src/commands/functions/list/list.command.ts +++ b/apps/cli/src/commands/functions/list/list.command.ts @@ -2,7 +2,6 @@ import { Command } from "effect/cli"; import type * as CliCommand from "effect/cli/Command"; import { withPermissions } from "../../../command-internal/command-permissions/command-permissions.annotation.ts"; import { compose } from "../../../command-internal/command-permissions/model.ts"; -import { projectRefSafeFlags } from "../../../command-internal/target/project-ref-safe-flags.ts"; import { projectRefPicker } from "../../../config/project-ref.layer.ts"; import { linkedProjectCache } from "../../../telemetry/linked-project-cache.layer.ts"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; @@ -44,7 +43,6 @@ export const functionsListCommand = Command.make("list", config).pipe( functionsList(flags).pipe( withCommandTelemetry({ flags, - safeFlags: projectRefSafeFlags(flags), }), withJsonErrorHandling, ), diff --git a/apps/cli/src/commands/gen/types/types.command.ts b/apps/cli/src/commands/gen/types/types.command.ts index a7b371f26f..e7ef980cec 100644 --- a/apps/cli/src/commands/gen/types/types.command.ts +++ b/apps/cli/src/commands/gen/types/types.command.ts @@ -37,7 +37,6 @@ import { targetFlags, } from "../../../command-internal/target/target-flags.ts"; import { withTargetPolicy } from "../../../command-internal/target/target-policy.ts"; -import { projectRefSafeFlags } from "../../../command-internal/target/project-ref-safe-flags.ts"; const config = { ...targetFlags({ subject: "database", dbUrl: true }), @@ -117,7 +116,6 @@ export const genTypesHandler = (flags: CliCommand.Command.Config.Infer<typeof co genTypes(flags).pipe( withCommandTelemetry({ flags, - safeFlags: projectRefSafeFlags(flags), config: flagsConfig, }), withJsonErrorHandling, 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 8bb7a2f8bb..7f9d01fd1b 100644 --- a/apps/cli/src/commands/inspect/db/inspect-db-command.ts +++ b/apps/cli/src/commands/inspect/db/inspect-db-command.ts @@ -43,7 +43,6 @@ export function inspectDbCommandHandler<E, R>( local: flags.local, "project-ref": flags.projectRef, }, - // `project-ref` stays redacted; it carries no telemetry-safety allowlist entry. }), withJsonErrorHandling, ); diff --git a/apps/cli/src/commands/inspect/report/report.command.ts b/apps/cli/src/commands/inspect/report/report.command.ts index 1d4cce12df..a16f344aba 100644 --- a/apps/cli/src/commands/inspect/report/report.command.ts +++ b/apps/cli/src/commands/inspect/report/report.command.ts @@ -48,7 +48,6 @@ export const inspectReportCommand = Command.make("report", config).pipe( "project-ref": flags.projectRef, "output-dir": flags.outputDir, }, - // `--project-ref` has no telemetry-safety baseline, so it stays redacted. }), withJsonErrorHandling, ), diff --git a/apps/cli/src/commands/link/link.command.ts b/apps/cli/src/commands/link/link.command.ts index d4eb7fbcde..b2b5e5ec64 100644 --- a/apps/cli/src/commands/link/link.command.ts +++ b/apps/cli/src/commands/link/link.command.ts @@ -2,7 +2,6 @@ import { Effect, Option } from "effect"; import { Argument, Command, Flag } from "effect/cli"; import type * as CliCommand from "effect/cli/Command"; -import { projectRefSafeFlags } from "../../command-internal/target/project-ref-safe-flags.ts"; import { withPermissions } from "../../command-internal/command-permissions/command-permissions.annotation.ts"; import { compose, withContext } from "../../command-internal/command-permissions/model.ts"; import { linkServices } from "../../command-internal/link-services-core.ts"; @@ -47,11 +46,8 @@ export type LinkFlags = CliCommand.Command.Config.Infer<typeof config>; export const linkHandler = (flags: LinkFlags) => link(flags).pipe( - // `--project-ref` is only safe to log verbatim when it's ref-shaped — it can also be a - // branch name, which is user data. `--skip-pooler` is always safe; `--password` stays redacted. withCommandTelemetry({ flags, - safeFlags: projectRefSafeFlags(flags), }), withJsonErrorHandling, ); diff --git a/apps/cli/src/commands/link/link.integration.test.ts b/apps/cli/src/commands/link/link.integration.test.ts index f8ccb0add0..d740c54e26 100644 --- a/apps/cli/src/commands/link/link.integration.test.ts +++ b/apps/cli/src/commands/link/link.integration.test.ts @@ -18,6 +18,7 @@ import type * as HttpClientError from "effect/http/HttpClientError"; import * as HttpClientRequestModule from "effect/http/HttpClientRequest"; import * as HttpClientResponse from "effect/http/HttpClientResponse"; +import { TargetPolicy } from "../../command-internal/target/target-policy.ts"; import { commandRuntimeLayer } from "../../shared/runtime/command-runtime.layer.ts"; import { mockAnalytics, @@ -1493,6 +1494,7 @@ describe("link integration", () => { Stdio.layerTest({ args: Effect.succeed(["link", "--project-ref", VALID_REF]), }), + Layer.succeed(TargetPolicy, { group: "hosted", consent: "none" }), ); return Effect.gen(function* () { yield* linkHandler(flags({ projectRef: Option.some(VALID_REF) })); @@ -1523,6 +1525,7 @@ describe("link integration", () => { Stdio.layerTest({ args: Effect.succeed(["link", "--project-ref", "my-branch"]), }), + Layer.succeed(TargetPolicy, { group: "hosted", consent: "none" }), ); return Effect.gen(function* () { yield* Effect.exit(linkHandler(flags({ projectRef: Option.some("my-branch") }))); diff --git a/apps/cli/src/commands/notebooks/pull/pull.command.ts b/apps/cli/src/commands/notebooks/pull/pull.command.ts index 40656e067a..f79934c2cc 100644 --- a/apps/cli/src/commands/notebooks/pull/pull.command.ts +++ b/apps/cli/src/commands/notebooks/pull/pull.command.ts @@ -3,7 +3,6 @@ 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 { branchResolution } from "../../../command-internal/target/branch-resolution.ts"; -import { projectRefSafeFlags } from "../../../command-internal/target/project-ref-safe-flags.ts"; import { projectTargetFlags } from "../../../command-internal/target/target-flags.ts"; import { projectRefPicker } from "../../../config/project-ref.layer.ts"; import { linkedProjectCache } from "../../../telemetry/linked-project-cache.layer.ts"; @@ -29,10 +28,7 @@ export type NotebooksPullFlags = CliCommand.Command.Config.Infer<typeof config>; // Exported so integration tests can drive the exact wiring `Command.withHandler` // uses below, instead of re-asserting the generic instrumentation mechanism. export const notebooksPullHandler = (flags: NotebooksPullFlags) => - notebooksPull(flags).pipe( - withCommandTelemetry({ flags, safeFlags: projectRefSafeFlags(flags) }), - withJsonErrorHandling, - ); + notebooksPull(flags).pipe(withCommandTelemetry({ flags }), withJsonErrorHandling); export const notebooksPullCommand = Command.make("pull", config).pipe( Command.withDescription( diff --git a/apps/cli/src/commands/notebooks/push/push.command.ts b/apps/cli/src/commands/notebooks/push/push.command.ts index 2b04d0ec4c..5109b18f17 100644 --- a/apps/cli/src/commands/notebooks/push/push.command.ts +++ b/apps/cli/src/commands/notebooks/push/push.command.ts @@ -3,7 +3,6 @@ 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 { branchResolution } from "../../../command-internal/target/branch-resolution.ts"; -import { projectRefSafeFlags } from "../../../command-internal/target/project-ref-safe-flags.ts"; import { projectTargetFlags } from "../../../command-internal/target/target-flags.ts"; import { projectRefPicker } from "../../../config/project-ref.layer.ts"; import { linkedProjectCache } from "../../../telemetry/linked-project-cache.layer.ts"; @@ -27,10 +26,7 @@ export type NotebooksPushFlags = CliCommand.Command.Config.Infer<typeof config>; // Exported so integration tests can drive the exact wiring `Command.withHandler` // uses below, instead of re-asserting the generic instrumentation mechanism. export const notebooksPushHandler = (flags: NotebooksPushFlags) => - notebooksPush(flags).pipe( - withCommandTelemetry({ flags, safeFlags: projectRefSafeFlags(flags) }), - withJsonErrorHandling, - ); + notebooksPush(flags).pipe(withCommandTelemetry({ flags }), withJsonErrorHandling); export const notebooksPushCommand = Command.make("push", config).pipe( Command.withDescription( 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 05ff76b4af..12c6d99db1 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 @@ -3,7 +3,6 @@ import type * as CliCommand from "effect/cli/Command"; import { withPermissions } from "../../../command-internal/command-permissions/command-permissions.annotation.ts"; import { compose, withCondition } from "../../../command-internal/command-permissions/model.ts"; import { branchResolution } from "../../../command-internal/target/branch-resolution.ts"; -import { projectRefSafeFlags } from "../../../command-internal/target/project-ref-safe-flags.ts"; import { projectTargetFlags } from "../../../command-internal/target/target-flags.ts"; import { projectApiKeys } from "../../../command-internal/get-api-keys.ts"; import { projectRefPicker } from "../../../config/project-ref.layer.ts"; @@ -59,7 +58,7 @@ export const projectsApiKeysCommand = Command.make("api-keys", config).pipe( // `reveal` is intentionally not in `safeFlags`: it is a boolean flag, and // boolean values are always logged verbatim by the instrumentation. Only // string flags that are telemetry-safe belong in `safeFlags`. - withCommandTelemetry({ flags, safeFlags: projectRefSafeFlags(flags) }), + withCommandTelemetry({ flags }), withJsonErrorHandling, ), ), diff --git a/apps/cli/src/commands/pull/pull.command.ts b/apps/cli/src/commands/pull/pull.command.ts index 3a121ea912..6cbea04092 100644 --- a/apps/cli/src/commands/pull/pull.command.ts +++ b/apps/cli/src/commands/pull/pull.command.ts @@ -10,7 +10,6 @@ import { } from "../../command-internal/db-config.layer.ts"; import { projectRefPicker } from "../../config/project-ref.layer.ts"; import { branchResolution } from "../../command-internal/target/branch-resolution.ts"; -import { projectRefSafeFlags } from "../../command-internal/target/project-ref-safe-flags.ts"; import { functionsDownload } from "../../shared/functions/download.ts"; import { linkedProjectCache } from "../../telemetry/linked-project-cache.layer.ts"; import { withCliConfigFlags } from "../../config/cli-config-flags.ts"; @@ -64,7 +63,6 @@ export const pullHandler = (flags: PullFlags) => // `--remote-label` is a free-form, user-chosen string and is never safe to log verbatim. withCommandTelemetry({ flags, - safeFlags: projectRefSafeFlags(flags), // The handler rejects every `-o`/`--output` value with a message pointing at // `--output-format`, so the full global choice set is declared "allowed" here. outputFormats: GLOBAL_OUTPUT_FORMATS, diff --git a/apps/cli/src/commands/snippets/download/download.command.ts b/apps/cli/src/commands/snippets/download/download.command.ts index 70a9007d8a..b9ea46d607 100644 --- a/apps/cli/src/commands/snippets/download/download.command.ts +++ b/apps/cli/src/commands/snippets/download/download.command.ts @@ -39,12 +39,7 @@ export const snippetsDownloadCommand = Command.make("download", config).pipe( ), ), Command.withHandler((flags) => - snippetsDownload(flags).pipe( - // No `safeFlags` — `--project-ref` is not on the telemetry-safe list, - // so the telemetry payload redacts the value. - withCommandTelemetry({ flags }), - withJsonErrorHandling, - ), + snippetsDownload(flags).pipe(withCommandTelemetry({ flags }), withJsonErrorHandling), ), 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 896388af7c..cf4cf562bd 100644 --- a/apps/cli/src/commands/snippets/list/list.command.ts +++ b/apps/cli/src/commands/snippets/list/list.command.ts @@ -40,13 +40,7 @@ export const snippetsListCommand = Command.make("list", config).pipe( ), ), Command.withHandler((flags) => - snippetsList(flags).pipe( - // No `safeFlags` — `--project-ref` is not on the telemetry-safe list, - // so the telemetry payload redacts the value (the default behavior for - // unmarked flags). - withCommandTelemetry({ flags }), - withJsonErrorHandling, - ), + snippetsList(flags).pipe(withCommandTelemetry({ flags }), withJsonErrorHandling), ), Command.provide(managementApiRuntimeLayer(["snippets", "list"])), withTargetPolicy({ group: "hosted", consent: "none" }), diff --git a/apps/cli/src/commands/sso/add/add.command.ts b/apps/cli/src/commands/sso/add/add.command.ts index 1642c9d2aa..8bd2b54288 100644 --- a/apps/cli/src/commands/sso/add/add.command.ts +++ b/apps/cli/src/commands/sso/add/add.command.ts @@ -4,7 +4,6 @@ import type * as CliCommand from "effect/cli/Command"; import { withPermissions } from "../../../command-internal/command-permissions/command-permissions.annotation.ts"; import { compose } from "../../../command-internal/command-permissions/model.ts"; import { branchResolution } from "../../../command-internal/target/branch-resolution.ts"; -import { projectRefSafeFlags } from "../../../command-internal/target/project-ref-safe-flags.ts"; import { projectTargetFlags } from "../../../command-internal/target/target-flags.ts"; import { projectRefPicker } from "../../../config/project-ref.layer.ts"; import { linkedProjectCache } from "../../../telemetry/linked-project-cache.layer.ts"; @@ -100,7 +99,6 @@ export const ssoAddCommand = Command.make("add", config).pipe( ssoAdd(flags).pipe( withCommandTelemetry({ flags, - safeFlags: projectRefSafeFlags(flags), config, // Maps the `-t` alias back to `type` so telemetry records it as changed. aliases: { t: "type" }, diff --git a/apps/cli/src/commands/sso/info/info.command.ts b/apps/cli/src/commands/sso/info/info.command.ts index 72f7b50141..22ac1e044a 100644 --- a/apps/cli/src/commands/sso/info/info.command.ts +++ b/apps/cli/src/commands/sso/info/info.command.ts @@ -4,7 +4,6 @@ import type * as CliCommand from "effect/cli/Command"; import { withPermissions } from "../../../command-internal/command-permissions/command-permissions.annotation.ts"; import { compose } from "../../../command-internal/command-permissions/model.ts"; import { branchResolution } from "../../../command-internal/target/branch-resolution.ts"; -import { projectRefSafeFlags } from "../../../command-internal/target/project-ref-safe-flags.ts"; import { projectTargetFlags } from "../../../command-internal/target/target-flags.ts"; import { projectRefPicker } from "../../../config/project-ref.layer.ts"; import { linkedProjectCache } from "../../../telemetry/linked-project-cache.layer.ts"; @@ -34,10 +33,7 @@ export const ssoInfoCommand = Command.make("info", config).pipe( compose({ operations: [] }, projectRefPicker, linkedProjectCache, branchResolution), ), Command.withHandler((flags) => - ssoInfo(flags).pipe( - withCommandTelemetry({ flags, safeFlags: projectRefSafeFlags(flags) }), - withJsonErrorHandling, - ), + ssoInfo(flags).pipe(withCommandTelemetry({ flags }), withJsonErrorHandling), ), 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 dc533f9f77..30465c2b77 100644 --- a/apps/cli/src/commands/sso/list/list.command.ts +++ b/apps/cli/src/commands/sso/list/list.command.ts @@ -4,7 +4,6 @@ import type * as CliCommand from "effect/cli/Command"; import { withPermissions } from "../../../command-internal/command-permissions/command-permissions.annotation.ts"; import { compose } from "../../../command-internal/command-permissions/model.ts"; import { branchResolution } from "../../../command-internal/target/branch-resolution.ts"; -import { projectRefSafeFlags } from "../../../command-internal/target/project-ref-safe-flags.ts"; import { projectTargetFlags } from "../../../command-internal/target/target-flags.ts"; import { projectRefPicker } from "../../../config/project-ref.layer.ts"; import { linkedProjectCache } from "../../../telemetry/linked-project-cache.layer.ts"; @@ -39,10 +38,7 @@ export const ssoListCommand = Command.make("list", config).pipe( ), ), Command.withHandler((flags) => - ssoList(flags).pipe( - withCommandTelemetry({ flags, safeFlags: projectRefSafeFlags(flags) }), - withJsonErrorHandling, - ), + ssoList(flags).pipe(withCommandTelemetry({ flags }), withJsonErrorHandling), ), 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 27cf99c978..c51e096ca3 100644 --- a/apps/cli/src/commands/sso/remove/remove.command.ts +++ b/apps/cli/src/commands/sso/remove/remove.command.ts @@ -4,7 +4,6 @@ import type * as CliCommand from "effect/cli/Command"; import { withPermissions } from "../../../command-internal/command-permissions/command-permissions.annotation.ts"; import { compose } from "../../../command-internal/command-permissions/model.ts"; import { branchResolution } from "../../../command-internal/target/branch-resolution.ts"; -import { projectRefSafeFlags } from "../../../command-internal/target/project-ref-safe-flags.ts"; import { projectTargetFlags } from "../../../command-internal/target/target-flags.ts"; import { projectRefPicker } from "../../../config/project-ref.layer.ts"; import { linkedProjectCache } from "../../../telemetry/linked-project-cache.layer.ts"; @@ -45,10 +44,7 @@ export const ssoRemoveCommand = Command.make("remove", config).pipe( ), ), Command.withHandler((flags) => - ssoRemove(flags).pipe( - withCommandTelemetry({ flags, safeFlags: projectRefSafeFlags(flags) }), - withJsonErrorHandling, - ), + ssoRemove(flags).pipe(withCommandTelemetry({ flags }), withJsonErrorHandling), ), 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 92e8711bc6..13f7b81693 100644 --- a/apps/cli/src/commands/sso/show/show.command.ts +++ b/apps/cli/src/commands/sso/show/show.command.ts @@ -4,7 +4,6 @@ import type * as CliCommand from "effect/cli/Command"; import { withPermissions } from "../../../command-internal/command-permissions/command-permissions.annotation.ts"; import { compose } from "../../../command-internal/command-permissions/model.ts"; import { branchResolution } from "../../../command-internal/target/branch-resolution.ts"; -import { projectRefSafeFlags } from "../../../command-internal/target/project-ref-safe-flags.ts"; import { projectTargetFlags } from "../../../command-internal/target/target-flags.ts"; import { projectRefPicker } from "../../../config/project-ref.layer.ts"; import { linkedProjectCache } from "../../../telemetry/linked-project-cache.layer.ts"; @@ -50,10 +49,7 @@ export const ssoShowCommand = Command.make("show", config).pipe( ), ), Command.withHandler((flags) => - ssoShow(flags).pipe( - withCommandTelemetry({ flags, safeFlags: projectRefSafeFlags(flags) }), - withJsonErrorHandling, - ), + ssoShow(flags).pipe(withCommandTelemetry({ flags }), withJsonErrorHandling), ), 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 f17b0f260d..ebd9e8c32b 100644 --- a/apps/cli/src/commands/sso/update/update.command.ts +++ b/apps/cli/src/commands/sso/update/update.command.ts @@ -4,7 +4,6 @@ import type * as CliCommand from "effect/cli/Command"; import { withPermissions } from "../../../command-internal/command-permissions/command-permissions.annotation.ts"; import { compose } from "../../../command-internal/command-permissions/model.ts"; import { branchResolution } from "../../../command-internal/target/branch-resolution.ts"; -import { projectRefSafeFlags } from "../../../command-internal/target/project-ref-safe-flags.ts"; import { projectTargetFlags } from "../../../command-internal/target/target-flags.ts"; import { projectRefPicker } from "../../../config/project-ref.layer.ts"; import { linkedProjectCache } from "../../../telemetry/linked-project-cache.layer.ts"; @@ -114,10 +113,7 @@ export const ssoUpdateCommand = Command.make("update", config).pipe( ), ), Command.withHandler((flags) => - ssoUpdate(flags).pipe( - withCommandTelemetry({ flags, safeFlags: projectRefSafeFlags(flags), config }), - withJsonErrorHandling, - ), + ssoUpdate(flags).pipe(withCommandTelemetry({ flags, config }), withJsonErrorHandling), ), Command.provide(managementApiRuntimeLayer(["sso", "update"])), withTargetPolicy({ group: "hosted", consent: "no" }), diff --git a/apps/cli/src/telemetry/command-telemetry.ts b/apps/cli/src/telemetry/command-telemetry.ts index 7458d6a2a9..16d0200e3f 100644 --- a/apps/cli/src/telemetry/command-telemetry.ts +++ b/apps/cli/src/telemetry/command-telemetry.ts @@ -50,6 +50,8 @@ import { unwrapToSingleParam } from "../command-internal/param-introspection.ts" import { StackBackendContext } from "../command-internal/stack-backend.ts"; import { isRemoteValue, type RemoteValue } from "../command-internal/target/remote-value.ts"; import { REMOTE_FLAG_NAME } from "../command-internal/target/remote-vocabulary.ts"; +import { TargetPolicy } from "../command-internal/target/target-policy.ts"; +import { PROJECT_REF_PATTERN } from "../config/project-ref.service.ts"; import { CommandTelemetryAttributes, type CommandTelemetryAttributeValues, @@ -122,6 +124,8 @@ const MACHINE_OUTPUT_FLAG_FORMATS = new Set(["env", "json", "toml", "yaml", "csv const OUTPUT_FLAG_FORMATS = new Set([...MACHINE_OUTPUT_FLAG_FORMATS, "pretty"]); // A project ref is telemetry-safe; a branch name or database URL is user data. +const TARGET_ALIAS_FLAGS: ReadonlySet<string> = new Set(["project-ref", "project-id"]); + function remoteTelemetryValue(remote: RemoteValue): string | boolean { switch (remote._tag) { case "Linked": @@ -308,6 +312,8 @@ function buildFlagsMap<Flags extends Record<string, unknown>>(options: { readonly safeFlagSet: ReadonlySet<string>; readonly changedFlagNames: ReadonlyArray<string>; readonly choiceFlagNames: ReadonlySet<string>; + // Whether the command acts on a project, so its `--project-ref`/`--project-id` alias is a target. + readonly hasTarget: boolean; }): Record<string, unknown> | undefined { const { flags, @@ -315,6 +321,7 @@ function buildFlagsMap<Flags extends Record<string, unknown>>(options: { safeFlagSet, changedFlagNames, choiceFlagNames, + hasTarget, } = options; if (changedFlagNames.length === 0) return undefined; @@ -344,8 +351,16 @@ function buildFlagsMap<Flags extends Record<string, unknown>>(options: { // consults `GLOBAL_CHOICE_FLAG_NAMES`, the global flag's own choice-ness, so it can't inherit // a different command's per-flag safe/choice annotation just because the CLI name matches. // Boolean values are always safe, regardless of source. + // A target alias can also hold a branch name, so only a ref-shaped value is logged. + const isRefShapedTargetAlias = + hasTarget && + isFromHandler && + TARGET_ALIAS_FLAGS.has(cliName) && + typeof value === "string" && + PROJECT_REF_PATTERN.test(value); const isSafe = remote !== undefined || + isRefShapedTargetAlias || typeof value === "boolean" || (isFromHandler ? safeFlagSet.has(cliName) || choiceFlagNames.has(cliName) @@ -409,12 +424,17 @@ function withCommandAnalyticsImplementation<Flags extends Record<string, unknown ...options?.aliases, }); const globalFlags = yield* globalFlagValues; + const targetPolicy = yield* Effect.serviceOption(TargetPolicy); const flags = buildFlagsMap({ flags: options?.flags, globalFlagValues: globalFlags, safeFlagSet, changedFlagNames, choiceFlagNames, + hasTarget: Option.exists( + targetPolicy, + ({ group }) => group === "hosted" || group === "selectable", + ), }); const analyticsContext = { command_run_id: commandRuntime.commandRunId, diff --git a/apps/cli/src/telemetry/command-telemetry.unit.test.ts b/apps/cli/src/telemetry/command-telemetry.unit.test.ts index f9d103dbfe..61d3a570db 100644 --- a/apps/cli/src/telemetry/command-telemetry.unit.test.ts +++ b/apps/cli/src/telemetry/command-telemetry.unit.test.ts @@ -29,6 +29,7 @@ import { DbDumpRunError } from "../commands/db/dump/dump.errors.ts"; import { IdentityStitch } from "../command-internal/identity-stitch.ts"; import { RemovedSurfaceError } from "../command-internal/removed-command.ts"; import type { RemoteValue } from "../command-internal/target/remote-value.ts"; +import { TargetPolicy, type TargetGroup } from "../command-internal/target/target-policy.ts"; import { withCommandTelemetry } from "./command-telemetry.ts"; import { recordCommandTelemetry } from "./command-telemetry-attributes.ts"; import { stackBackendLayer } from "../command-internal/stack-backend.ts"; @@ -660,6 +661,83 @@ describe("withCommandTelemetry", () => { ); }); + describe("target aliases", () => { + const REF = "abcdefghijklmnopqrst"; + const DB_URL = "postgresql://postgres:secret@db.example.com:5432/postgres"; + + const recordedFlags = ( + group: TargetGroup, + flags: Record<string, unknown>, + args: ReadonlyArray<string>, + ) => { + const analytics = mockContextualAnalytics(); + return Effect.void.pipe( + withCommandTelemetry({ flags }), + Effect.provide(analytics.layer), + Effect.provide(mockProcessControl().layer), + Effect.provide(mockOutput({ format: "text" }).layer), + Effect.provide(Layer.succeed(TargetPolicy, { group, consent: "none" })), + Effect.provide(Stdio.layerTest({ args: Effect.succeed(["functions", "list", ...args]) })), + Effect.provide( + commandRuntimeLayer(["functions", "list"]).pipe(Layer.provide(BunCrypto.layer)), + ), + Effect.map(() => analytics.captured[0]?.properties.flags), + ); + }; + + it.live.each([ + { + name: "a ref-shaped --project-ref verbatim", + flags: { projectRef: Option.some(REF) }, + args: ["--project-ref", REF], + expected: { "project-ref": REF }, + }, + { + name: "a branch-shaped --project-ref as redacted", + flags: { projectRef: Option.some("customer-acme") }, + args: ["--project-ref", "customer-acme"], + expected: { "project-ref": "<redacted>" }, + }, + { + name: "a ref-shaped --project-id verbatim", + flags: { projectId: Option.some(REF) }, + args: ["--project-id", REF], + expected: { "project-id": REF }, + }, + { + name: "--db-url as redacted", + flags: { dbUrl: Option.some(DB_URL) }, + args: ["--db-url", DB_URL], + expected: { "db-url": "<redacted>" }, + }, + { + name: "--linked and --local as booleans", + flags: { linked: true, local: false }, + args: ["--linked", "--local=false"], + expected: { linked: true, local: false }, + }, + ] as const)("records $name on a hosted command", ({ flags, args, expected }) => + Effect.gen(function* () { + const recorded = yield* recordedFlags("hosted", flags, args); + expect(recorded).toEqual(expected); + expect(JSON.stringify(recorded)).not.toContain(DB_URL); + expect(JSON.stringify(recorded)).not.toContain("customer-acme"); + }), + ); + + it.live.each(["account", "local-only"] as const)( + "redacts a ref-shaped --project-ref on a %s command, where it is not a target", + (group) => + Effect.gen(function* () { + const recorded = yield* recordedFlags(group, { projectRef: Option.some(REF) }, [ + "--project-ref", + REF, + ]); + expect(recorded).toEqual({ "project-ref": "<redacted>" }); + }), + ); + }); + it.live("passes Flag.Literals values through verbatim", () => { const analytics = mockContextualAnalytics(); const config = { From 2bbfc06fb8a899a6d755ce698b87afbc0df8bc3c Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Mon, 19 Oct 2026 14:49:50 +0100 Subject: [PATCH 147/291] refactor(cli): type target flag inputs with the resolver's flag records Hand-written selector interfaces dropped the --db-url acceptance brand, so a database-URL leaf could lose --db-url, or a no-URL leaf pass one, without a compile error. --- .../target/target-flags.unit.test.ts | 24 +++++++++++++++++++ .../test-db.command-handler.ts | 10 ++------ .../experimental/compute/compute.output.ts | 10 ++------ .../src/commands/inspect/db/inspect-query.ts | 10 ++------ .../migration/repair/repair.handler.ts | 9 ++----- .../commands/seed/buckets/buckets.command.ts | 9 ++----- .../cli/src/commands/storage/rm/rm.handler.ts | 8 ++----- .../cli/src/commands/storage/storage.frame.ts | 13 ++++------ 8 files changed, 40 insertions(+), 53 deletions(-) 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 1a2687028e..0f3bfee471 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 @@ -14,6 +14,7 @@ import { seedTargetGlobalFlags, targetFlags, } from "./target-flags.ts"; +import { resolveTarget } from "./resolve-target.ts"; const silentFormatter: CliOutput.Formatter = { formatCliError: () => "", @@ -146,6 +147,29 @@ describe("targetFlags", () => { ]); }); + it("keeps a database-URL leaf from dropping --db-url and a no-URL leaf from passing one", () => { + const storage = targetFlags({ subject: "Storage API", dbUrl: false }); + const unselected = { + remote: Option.none(), + local: false, + linked: false, + projectRef: Option.none(), + }; + const withUrl: CliCommand.Command.Config.Infer<typeof withDbUrl> = { + ...unselected, + dbUrl: Option.none(), + }; + const withoutUrl: CliCommand.Command.Config.Infer<typeof storage> = unselected; + const { dbUrl: _dropped, ...droppedDbUrl } = withUrl; + + expect(Effect.isEffect(resolveTarget(withUrl))).toBe(true); + expect(Effect.isEffect(resolveTarget(withoutUrl))).toBe(true); + // @ts-expect-error a leaf that declared dbUrl: true cannot hand the resolver flags without it + resolveTarget(droppedDbUrl); + // @ts-expect-error a leaf that declared dbUrl: false cannot pass a database URL + resolveTarget({ ...withoutUrl, dbUrl: Option.some(URL) }); + }); + it("shows --remote and hides every deprecated alias", () => { expect(describeFlag(withDbUrl.remote).hidden).toBeFalsy(); for (const alias of [ 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 d4683d30c6..a14207cc95 100644 --- a/apps/cli/src/command-internal/test-db.command-handler.ts +++ b/apps/cli/src/command-internal/test-db.command-handler.ts @@ -7,8 +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 type { RemoteValue } from "./target/remote-value.ts"; -import { targetFlags } from "./target/target-flags.ts"; +import { targetFlags, type TargetFlagValuesWithDbUrl } from "./target/target-flags.ts"; import { testDb } from "./test-db.handler.ts"; /** Shared verbatim by both `test db` and its `db test` alias. */ @@ -47,13 +46,8 @@ export const testDbConfig = { ...targetFlags({ subject: "database", dbUrl: true }), } as const; -export interface TestDbFlags { +export interface TestDbFlags extends TargetFlagValuesWithDbUrl { readonly paths: ReadonlyArray<string>; - readonly remote: Option.Option<RemoteValue>; - readonly dbUrl: Option.Option<string>; - readonly linked: boolean; - readonly local: boolean; - readonly projectRef: Option.Option<string>; } /** diff --git a/apps/cli/src/commands/experimental/compute/compute.output.ts b/apps/cli/src/commands/experimental/compute/compute.output.ts index 0859da875c..e2cb8f9ded 100644 --- a/apps/cli/src/commands/experimental/compute/compute.output.ts +++ b/apps/cli/src/commands/experimental/compute/compute.output.ts @@ -1,6 +1,6 @@ import { Effect, Option } from "effect"; import { OutputFlag } from "../../../command-internal/global-flags.ts"; -import type { RemoteValue } from "../../../command-internal/target/remote-value.ts"; +import type { ProjectTargetFlagValues } from "../../../command-internal/target/target-flags.ts"; import { Output } from "../../../shared/output/output.service.ts"; import { encodeSortedJson, @@ -102,13 +102,7 @@ export const rejectComputeEnvOutput = Effect.fnUntraced(function* () { * prompt. Keyed off the flags, not the resolved ref, so a link-supplied ref isn't echoed back as * noise; the resolved ref also stands in for a branch name, which needs no second lookup. */ -export const computeRemoteSuffix = ( - flags: { - readonly remote: Option.Option<RemoteValue>; - readonly projectRef: Option.Option<string>; - }, - ref: string, -): string => +export const computeRemoteSuffix = (flags: ProjectTargetFlagValues, ref: string): string => Option.isSome(flags.projectRef) || Option.exists(flags.remote, (value) => value._tag !== "Linked") ? ` --remote=${ref}` : ""; diff --git a/apps/cli/src/commands/inspect/db/inspect-query.ts b/apps/cli/src/commands/inspect/db/inspect-query.ts index 0e3f677c0f..8020243eb0 100644 --- a/apps/cli/src/commands/inspect/db/inspect-query.ts +++ b/apps/cli/src/commands/inspect/db/inspect-query.ts @@ -7,18 +7,12 @@ 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 { hostedMutationPreapproval } from "../../../command-internal/hosted-consent/hosted-consent.ts"; -import type { RemoteValue } from "../../../command-internal/target/remote-value.ts"; +import type { TargetFlagValuesWithDbUrl } from "../../../command-internal/target/target-flags.ts"; import { resolveTarget } from "../../../command-internal/target/resolve-target.ts"; import { TelemetryState } from "../../../telemetry/telemetry-state.service.ts"; /** The target selector flags every `inspect db` subcommand inherits; see `targetFlags`. */ -export interface InspectConnectionFlags { - readonly remote: Option.Option<RemoteValue>; - readonly dbUrl: Option.Option<string>; - readonly linked: boolean; - readonly local: boolean; - readonly projectRef: Option.Option<string>; -} +export type InspectConnectionFlags = TargetFlagValuesWithDbUrl; /** * A single `inspect db` subcommand: the SQL it runs, the query parameters, table headers, and diff --git a/apps/cli/src/commands/migration/repair/repair.handler.ts b/apps/cli/src/commands/migration/repair/repair.handler.ts index eb50bcda82..9f781a5800 100644 --- a/apps/cli/src/commands/migration/repair/repair.handler.ts +++ b/apps/cli/src/commands/migration/repair/repair.handler.ts @@ -22,21 +22,16 @@ import { UPSERT_MIGRATION_VERSION, } from "../../../command-internal/migration-history.ts"; import { parseMigrationVersion } from "../../../command-internal/migration-timestamp.format.ts"; -import type { RemoteValue } from "../../../command-internal/target/remote-value.ts"; +import type { TargetFlagValuesWithDbUrl } from "../../../command-internal/target/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 { MigrationFileNotFoundError, MigrationInvalidVersionError } from "../migration.errors.ts"; import { MigrationRepairUpdateError } from "./repair.errors.ts"; -export interface MigrationRepairInput { +export interface MigrationRepairInput extends TargetFlagValuesWithDbUrl { readonly versions: ReadonlyArray<string>; readonly status: "applied" | "reverted"; - readonly remote: Option.Option<RemoteValue>; - readonly dbUrl: Option.Option<string>; - readonly linked: boolean; - readonly local: boolean; - readonly projectRef: Option.Option<string>; readonly password: Option.Option<string>; } diff --git a/apps/cli/src/commands/seed/buckets/buckets.command.ts b/apps/cli/src/commands/seed/buckets/buckets.command.ts index b3e148be4b..e12a382cfc 100644 --- a/apps/cli/src/commands/seed/buckets/buckets.command.ts +++ b/apps/cli/src/commands/seed/buckets/buckets.command.ts @@ -17,11 +17,11 @@ import { branchResolution } from "../../../command-internal/target/branch-resolu import { seedTargetGlobalFlags, targetFlags, + type TargetFlagValuesWithoutDbUrl, } 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 type { RemoteValue } from "../../../command-internal/target/remote-value.ts"; import { withTargetPolicy } from "../../../command-internal/target/target-policy.ts"; const config = { @@ -29,12 +29,7 @@ const config = { }; // `--remote`/`--linked`/`--local` are scoped globals on the `seed` group (`seedTargetGlobalFlags`). -export type BucketsFlags = { - readonly remote: Option.Option<RemoteValue>; - readonly linked: boolean; - readonly local: boolean; - readonly projectRef: Option.Option<string>; -}; +export type BucketsFlags = TargetFlagValuesWithoutDbUrl; export const bucketsCommand = Command.make("buckets", config).pipe( Command.withDescription("Seed buckets declared in [storage.buckets]."), diff --git a/apps/cli/src/commands/storage/rm/rm.handler.ts b/apps/cli/src/commands/storage/rm/rm.handler.ts index 7742e0349b..c88866d7a6 100644 --- a/apps/cli/src/commands/storage/rm/rm.handler.ts +++ b/apps/cli/src/commands/storage/rm/rm.handler.ts @@ -15,7 +15,7 @@ import { type StorageGateway, } from "../../../command-internal/storage-gateway.ts"; import { splitBucketPrefix, storageIsDir } from "../../../command-internal/storage-url.ts"; -import type { RemoteValue } from "../../../command-internal/target/remote-value.ts"; +import type { TargetFlagValuesWithoutDbUrl } from "../../../command-internal/target/target-flags.ts"; import { assertStorageWorkdir, connectStorageGateway, @@ -31,13 +31,9 @@ import { } from "../storage.errors.ts"; import { listStoragePaths } from "../storage.iterate.ts"; -export interface StorageRmFlags { +export interface StorageRmFlags extends TargetFlagValuesWithoutDbUrl { readonly files: ReadonlyArray<string>; readonly recursive: boolean; - readonly remote: Option.Option<RemoteValue>; - readonly linked: boolean; - readonly local: boolean; - readonly projectRef: Option.Option<string>; } interface RmSummary { diff --git a/apps/cli/src/commands/storage/storage.frame.ts b/apps/cli/src/commands/storage/storage.frame.ts index 5f22507828..7e7c767405 100644 --- a/apps/cli/src/commands/storage/storage.frame.ts +++ b/apps/cli/src/commands/storage/storage.frame.ts @@ -15,7 +15,7 @@ import { makeStorageGateway, type StorageGateway, } from "../../command-internal/storage-gateway.ts"; -import type { RemoteValue } from "../../command-internal/target/remote-value.ts"; +import type { TargetFlagValuesWithoutDbUrl } from "../../command-internal/target/target-flags.ts"; import { resolveTarget } from "../../command-internal/target/resolve-target.ts"; import { UrlParseError, @@ -35,15 +35,10 @@ import { StorageWorkdirError, } from "./storage.errors.ts"; -interface StorageTargetFlags { - readonly remote: Option.Option<RemoteValue>; - readonly local: boolean; - readonly linked: boolean; - readonly projectRef: Option.Option<string>; -} - /** The resolved target and its project ref, empty for the local stack. */ -export const resolveStorageTarget = Effect.fnUntraced(function* (flags: StorageTargetFlags) { +export const resolveStorageTarget = Effect.fnUntraced(function* ( + flags: TargetFlagValuesWithoutDbUrl, +) { const target = yield* resolveTarget(flags); return { target, projectRef: target.kind === "linked" ? target.ref : "" }; }); From 2611839cc9fefd66327a896d634b5cc91422d1a8 Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Mon, 19 Oct 2026 14:51:08 +0100 Subject: [PATCH 148/291] chore(cli): drop comments that say --project-ref stays redacted in telemetry --- apps/cli/src/command-internal/test-db.command-handler.ts | 1 - apps/cli/src/commands/db/diff/diff.command.ts | 1 - apps/cli/src/commands/db/query/query.command.ts | 1 - apps/cli/src/commands/db/reset/reset.command.ts | 2 -- apps/cli/src/commands/migration/down/down.command.ts | 1 - apps/cli/src/commands/migration/fetch/fetch.command.ts | 1 - apps/cli/src/commands/migration/squash/squash.command.ts | 2 -- apps/cli/src/commands/migration/up/up.command.ts | 1 - apps/cli/src/commands/seed/buckets/buckets.command.ts | 1 - apps/cli/src/commands/storage/cp/cp.command.ts | 5 +---- apps/cli/src/commands/storage/ls/ls.command.ts | 5 +---- apps/cli/src/commands/storage/mv/mv.command.ts | 5 +---- apps/cli/src/commands/storage/rm/rm.command.ts | 5 +---- 13 files changed, 4 insertions(+), 27 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 a14207cc95..9295a2a21c 100644 --- a/apps/cli/src/command-internal/test-db.command-handler.ts +++ b/apps/cli/src/command-internal/test-db.command-handler.ts @@ -72,7 +72,6 @@ export function runTestDbCommand(flags: CliCommand.Command.Config.Infer<typeof t local: flags.local, "project-ref": flags.projectRef, }, - // No safe-flag whitelist entry for --project-ref here, so it stays redacted. }), // Run failures must not corrupt the TAP stream in machine modes; see `onRunFailure`. Effect.catchTag(["TestDbRunError", "TestDbNoTestsError"], onRunFailure), diff --git a/apps/cli/src/commands/db/diff/diff.command.ts b/apps/cli/src/commands/db/diff/diff.command.ts index 1e754b25be..c2785952ab 100644 --- a/apps/cli/src/commands/db/diff/diff.command.ts +++ b/apps/cli/src/commands/db/diff/diff.command.ts @@ -153,7 +153,6 @@ export const dbDiffCommand = Command.make("diff", config).pipe( file: flags.file, schema: flags.schema, }, - // No established telemetry-safety baseline for --project-ref, so it stays redacted. aliases: { o: "output", f: "file", s: "schema" }, }), withJsonErrorHandling, diff --git a/apps/cli/src/commands/db/query/query.command.ts b/apps/cli/src/commands/db/query/query.command.ts index ed26a293f5..64fceea945 100644 --- a/apps/cli/src/commands/db/query/query.command.ts +++ b/apps/cli/src/commands/db/query/query.command.ts @@ -76,7 +76,6 @@ export const dbQueryCommand = Command.make("query", config).pipe( 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, // Telemetry reports changed flags by canonical name, so `-f query.sql` must log as `file`. aliases: { f: "file" }, diff --git a/apps/cli/src/commands/db/reset/reset.command.ts b/apps/cli/src/commands/db/reset/reset.command.ts index f77b0d0900..fc2a63745e 100644 --- a/apps/cli/src/commands/db/reset/reset.command.ts +++ b/apps/cli/src/commands/db/reset/reset.command.ts @@ -73,8 +73,6 @@ export const dbResetCommand = Command.make("reset", config).pipe( version: flags.version, last: flags.last, }, - // No safeFlags: telemetry-safe marking is per flag instance, and neither `--version` nor - // `--project-ref` is marked safe on this command, so both stay redacted. }), withJsonErrorHandling, ), diff --git a/apps/cli/src/commands/migration/down/down.command.ts b/apps/cli/src/commands/migration/down/down.command.ts index 0150c1652e..7cb85efd56 100644 --- a/apps/cli/src/commands/migration/down/down.command.ts +++ b/apps/cli/src/commands/migration/down/down.command.ts @@ -65,7 +65,6 @@ export const migrationDownCommand = Command.make("down", config).pipe( local: flags.local, "project-ref": flags.projectRef, }, - // `--project-ref` isn't marked safe here, so it stays redacted. }), withJsonErrorHandling, ), diff --git a/apps/cli/src/commands/migration/fetch/fetch.command.ts b/apps/cli/src/commands/migration/fetch/fetch.command.ts index 8ec3060426..1809a18643 100644 --- a/apps/cli/src/commands/migration/fetch/fetch.command.ts +++ b/apps/cli/src/commands/migration/fetch/fetch.command.ts @@ -47,7 +47,6 @@ export const migrationFetchCommand = Command.make("fetch", config).pipe( local: flags.local, "project-ref": flags.projectRef, }, - // `--project-ref` has no telemetry-safety baseline, so it stays redacted. }), withJsonErrorHandling, ), diff --git a/apps/cli/src/commands/migration/squash/squash.command.ts b/apps/cli/src/commands/migration/squash/squash.command.ts index 17de2bcca5..a312671bc6 100644 --- a/apps/cli/src/commands/migration/squash/squash.command.ts +++ b/apps/cli/src/commands/migration/squash/squash.command.ts @@ -63,8 +63,6 @@ export const migrationSquashCommand = Command.make("squash", config).pipe( password: flags.password, "project-ref": flags.projectRef, }, - // Only `--version`'s value is recorded verbatim. `--project-ref` has no - // established telemetry-safety baseline, so it stays redacted. safeFlags: ["version"], aliases: { p: "password" }, }), diff --git a/apps/cli/src/commands/migration/up/up.command.ts b/apps/cli/src/commands/migration/up/up.command.ts index 7276ce771e..1c76b32dfb 100644 --- a/apps/cli/src/commands/migration/up/up.command.ts +++ b/apps/cli/src/commands/migration/up/up.command.ts @@ -54,7 +54,6 @@ export const migrationUpCommand = Command.make("up", config).pipe( local: flags.local, "project-ref": flags.projectRef, }, - // `--project-ref` has no telemetry-safety baseline, so it stays redacted. }), withJsonErrorHandling, ), diff --git a/apps/cli/src/commands/seed/buckets/buckets.command.ts b/apps/cli/src/commands/seed/buckets/buckets.command.ts index e12a382cfc..b617feff44 100644 --- a/apps/cli/src/commands/seed/buckets/buckets.command.ts +++ b/apps/cli/src/commands/seed/buckets/buckets.command.ts @@ -60,7 +60,6 @@ export const bucketsCommand = Command.make("buckets", config).pipe( local: flags.local, "project-ref": flags.projectRef, }, - // Not in the safe-flags allowlist, so this stays redacted. }), ); }).pipe(withJsonErrorHandling), diff --git a/apps/cli/src/commands/storage/cp/cp.command.ts b/apps/cli/src/commands/storage/cp/cp.command.ts index 891289f984..f8e04745ea 100644 --- a/apps/cli/src/commands/storage/cp/cp.command.ts +++ b/apps/cli/src/commands/storage/cp/cp.command.ts @@ -102,10 +102,7 @@ export const storageCpCommand = Command.make("cp", config).pipe( local: flags.local, "project-ref": flags.projectRef, }; - return yield* storageCp(flags).pipe( - // Not in the safe-flags allowlist, so `--project-ref` stays redacted. - withCommandTelemetry({ flags: telemetryFlags }), - ); + return yield* storageCp(flags).pipe(withCommandTelemetry({ flags: telemetryFlags })); }).pipe(withJsonErrorHandling), ), Command.provide(storageGatewayRuntimeLayer(["storage", "cp"])), diff --git a/apps/cli/src/commands/storage/ls/ls.command.ts b/apps/cli/src/commands/storage/ls/ls.command.ts index 57ecb4d3a9..9733651ee9 100644 --- a/apps/cli/src/commands/storage/ls/ls.command.ts +++ b/apps/cli/src/commands/storage/ls/ls.command.ts @@ -48,10 +48,7 @@ export const storageLsCommand = Command.make("ls", config).pipe( local: flags.local, "project-ref": flags.projectRef, }; - return yield* storageLs(flags).pipe( - // Not in the safe-flags allowlist, so `--project-ref` stays redacted. - withCommandTelemetry({ flags: telemetryFlags }), - ); + return yield* storageLs(flags).pipe(withCommandTelemetry({ flags: telemetryFlags })); }).pipe(withJsonErrorHandling), ), Command.provide(storageGatewayRuntimeLayer(["storage", "ls"])), diff --git a/apps/cli/src/commands/storage/mv/mv.command.ts b/apps/cli/src/commands/storage/mv/mv.command.ts index ab275bff3e..3fa5305810 100644 --- a/apps/cli/src/commands/storage/mv/mv.command.ts +++ b/apps/cli/src/commands/storage/mv/mv.command.ts @@ -46,10 +46,7 @@ export const storageMvCommand = Command.make("mv", config).pipe( local: flags.local, "project-ref": flags.projectRef, }; - return yield* storageMv(flags).pipe( - // Not in the safe-flags allowlist, so `--project-ref` stays redacted. - withCommandTelemetry({ flags: telemetryFlags }), - ); + return yield* storageMv(flags).pipe(withCommandTelemetry({ flags: telemetryFlags })); }).pipe(withJsonErrorHandling), ), Command.provide(storageGatewayRuntimeLayer(["storage", "mv"])), diff --git a/apps/cli/src/commands/storage/rm/rm.command.ts b/apps/cli/src/commands/storage/rm/rm.command.ts index 45a10b62a2..9732f239e4 100644 --- a/apps/cli/src/commands/storage/rm/rm.command.ts +++ b/apps/cli/src/commands/storage/rm/rm.command.ts @@ -57,10 +57,7 @@ export const storageRmCommand = Command.make("rm", config).pipe( linked: flags.linked, local: flags.local, projectRef: flags.projectRef, - }).pipe( - // project-ref isn't on the safe-flags allowlist, so it stays redacted in telemetry. - withCommandTelemetry({ flags: telemetryFlags }), - ); + }).pipe(withCommandTelemetry({ flags: telemetryFlags })); }).pipe(withJsonErrorHandling), ), Command.provide(Layer.mergeAll(storageGatewayRuntimeLayer(["storage", "rm"]), stdinLayer)), From 180238624e1b8c3c0d1b8a55bcc7047b1586868b Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Mon, 19 Oct 2026 14:59:29 +0100 Subject: [PATCH 149/291] refactor(cli): declare the pooler lookup by selector shape and guard branch resolution tree-wide The pooler entry applies to a ref or branch selector only. A guard test over the command tree requires every --remote leaf to compose branchResolution. seed buckets reuses STORAGE_PERMISSIONS and reads all four selector flags from the seed group's scoped globals. --- .../command-permissions.unit.test.ts | 15 +++++ .../src/command-internal/db-config.layer.ts | 11 +--- .../command-internal/target/target-flags.ts | 11 ++++ .../gen/types/types.integration.test.ts | 9 ++- .../commands/link/link.integration.test.ts | 4 +- .../src/commands/seed/buckets/SIDE_EFFECTS.md | 6 +- .../commands/seed/buckets/buckets.command.ts | 47 +++----------- apps/cli/src/commands/seed/seed.command.ts | 5 +- .../commands/seed/seed.command.unit.test.ts | 64 +++++++++++++++++++ apps/cli/tests/helpers/permission-registry.ts | 5 ++ 10 files changed, 122 insertions(+), 55 deletions(-) create mode 100644 apps/cli/src/commands/seed/seed.command.unit.test.ts diff --git a/apps/cli/src/command-internal/command-permissions/command-permissions.unit.test.ts b/apps/cli/src/command-internal/command-permissions/command-permissions.unit.test.ts index 489b74a5a6..1c1f76a1c2 100644 --- a/apps/cli/src/command-internal/command-permissions/command-permissions.unit.test.ts +++ b/apps/cli/src/command-internal/command-permissions/command-permissions.unit.test.ts @@ -31,6 +31,8 @@ const OPERATIONS_WITHOUT_FGA: ReadonlyMap<string, string> = new Map<string, stri ], ]); +const BRANCH_RESOLUTION_SOURCE = "branch-resolution"; + const VARIANTS: ReadonlyArray<{ readonly name: string; readonly variant: PermissionVariant }> = [ { name: "default backend", variant: undefined }, { @@ -207,6 +209,19 @@ describe("command permission mapping completeness", () => { } }); + it("composes branch resolution on every leaf that accepts --remote, except the branches that act on a parent project", () => { + for (const [path, node] of leaves) { + const permissions = declarationOf(node); + if (permissions?.status !== "mapped") continue; + if (!node.flags.has("remote") || path.startsWith("branches ")) continue; + if (permissions.noApiEffectFlags.includes("remote")) continue; + expect( + permissions.operations.some((entry) => entry.source === BRANCH_RESOLUTION_SOURCE), + `"${path}" accepts --remote=<branch> but does not compose branchResolution`, + ).toBe(true); + } + }); + it("only marks a leaf unmapped when it's on the known go-delegation allowlist", () => { for (const [path, node] of leaves) { const permissions = declarationOf(node); diff --git a/apps/cli/src/command-internal/db-config.layer.ts b/apps/cli/src/command-internal/db-config.layer.ts index 51f2e67040..cc77bd65b9 100644 --- a/apps/cli/src/command-internal/db-config.layer.ts +++ b/apps/cli/src/command-internal/db-config.layer.ts @@ -61,16 +61,9 @@ const linkedEntries: ReadonlyArray<Omit<OperationEntry, "source">> = [ { operationId: "v1-get-pooler-config", kind: "required", - when: [{ flag: "project-ref" }], + when: [{ selector: ["ref", "branch"] }], 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: "remote" }], - context: - "--remote names a project or branch other than the linked one, the direct database host is unreachable (IPv4-only network) and there is no usable saved supabase/.temp/pooler-url", + "a project ref or branch names the target, 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", diff --git a/apps/cli/src/command-internal/target/target-flags.ts b/apps/cli/src/command-internal/target/target-flags.ts index bcbf3f4b57..1c513795b6 100644 --- a/apps/cli/src/command-internal/target/target-flags.ts +++ b/apps/cli/src/command-internal/target/target-flags.ts @@ -208,8 +208,19 @@ export const seedTargetGlobalFlags = { }), linked: GlobalFlag.Setting("linked")({ flag: linkedFlag() }), local: GlobalFlag.Setting("local")({ flag: localFlag() }), + projectRef: GlobalFlag.Setting("project-ref")({ flag: projectRefFlag() }), }; +/** The `seed` selector flags a leaf reads from the group's scoped globals. */ +export const readSeedTargetFlags = Effect.fnUntraced(function* () { + return { + remote: yield* seedTargetGlobalFlags.remote, + linked: yield* seedTargetGlobalFlags.linked, + local: yield* seedTargetGlobalFlags.local, + projectRef: yield* seedTargetGlobalFlags.projectRef, + } satisfies TargetFlagValuesWithoutDbUrl; +}); + /** Hidden alias of `--remote=<ref>`, accepted only by `gen types`. */ export const deprecatedProjectIdFlag = Flag.String("project-id").pipe( Flag.withDescription("Deprecated, removed in v4: use --remote=<ref>."), 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 d76279cfc0..4a434196b7 100644 --- a/apps/cli/src/commands/gen/types/types.integration.test.ts +++ b/apps/cli/src/commands/gen/types/types.integration.test.ts @@ -3681,7 +3681,12 @@ describe("gen types permissions through the real layers", () => { ]); assertPermissionDrift({ command: "gen types", - activeFlags: { ...DEFAULT_SWITCHES, linked: true, "project-id": true, lang: "typescript" }, + activeFlags: { + ...DEFAULT_SWITCHES, + linked: true, + "project-id": VALID_REF, + lang: "typescript", + }, requests: ctx.api.requests, exact: true, }); @@ -4003,7 +4008,7 @@ describe("gen types permissions through the real layers", () => { ...DEFAULT_SWITCHES, "postgrest-v9-compat": true, linked: true, - "project-id": true, + "project-id": VALID_REF, lang: "typescript", }, generates: false, diff --git a/apps/cli/src/commands/link/link.integration.test.ts b/apps/cli/src/commands/link/link.integration.test.ts index d740c54e26..43b3ef8bb3 100644 --- a/apps/cli/src/commands/link/link.integration.test.ts +++ b/apps/cli/src/commands/link/link.integration.test.ts @@ -1618,7 +1618,7 @@ describe("link permissions", () => { expect(yield* readTemp(workdir, "project-ref")).toBe(VALID_REF); assertPermissionDrift({ command: "link", - activeFlags: { "project-ref": true, "skip-pooler": false }, + activeFlags: { "project-ref": VALID_REF, "skip-pooler": false }, requests: api.requests, exact: true, }); @@ -1686,7 +1686,7 @@ describe("link permissions", () => { ]); assertPermissionDrift({ command: "link", - activeFlags: { "project-ref": true, "skip-pooler": false }, + activeFlags: { "project-ref": BRANCH_PROJECT_REF, "skip-pooler": false }, requests: api.requests, }); }).pipe(Effect.provide(layer)); diff --git a/apps/cli/src/commands/seed/buckets/SIDE_EFFECTS.md b/apps/cli/src/commands/seed/buckets/SIDE_EFFECTS.md index c7ea943691..29281614ac 100644 --- a/apps/cli/src/commands/seed/buckets/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/seed/buckets/SIDE_EFFECTS.md @@ -208,9 +208,9 @@ stdout and a terminal `result`/`error` event is emitted. `Leave out --remote to target the local Storage API.` A word after a bare `--remote` is read as its value, `--remote=<word>`, because the command takes no operands. The resolved target is announced on stderr (`Target: …`) - in every output format and, in JSON results, as a `target` object. `--remote`, `--linked` and - `--local` are scoped globals on the `seed` group, so they are also accepted before the subcommand - token. + in every output format and, in JSON results, as a `target` object. `--remote`, `--linked`, + `--local` and the deprecated `--project-ref` are scoped globals on the `seed` group, so they are + also accepted before the subcommand token. - **Deprecated selectors.** `--linked`, `--project-ref` and `--local` are hidden aliases that still select their target and print a warning on stderr naming the replacement, for example `--linked is deprecated and will be removed in v4. Use --remote instead: supabase seed buckets --remote` diff --git a/apps/cli/src/commands/seed/buckets/buckets.command.ts b/apps/cli/src/commands/seed/buckets/buckets.command.ts index b617feff44..d5a90dff68 100644 --- a/apps/cli/src/commands/seed/buckets/buckets.command.ts +++ b/apps/cli/src/commands/seed/buckets/buckets.command.ts @@ -1,57 +1,30 @@ -import { Effect, Layer, type Option } from "effect"; +import { Effect, Layer } from "effect"; import { Command } from "effect/cli"; import { withCliConfigFlags } from "../../../config/cli-config-flags.ts"; import { cliConfigValuesLayer } from "../../../config/cli-config-values.layer.ts"; import { withPermissions } from "../../../command-internal/command-permissions/command-permissions.annotation.ts"; import { - compose, - withCondition, - withDbTarget, -} from "../../../command-internal/command-permissions/model.ts"; -import { storageCredentials } from "../../../command-internal/storage-credentials.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"; -import { branchResolution } from "../../../command-internal/target/branch-resolution.ts"; -import { - seedTargetGlobalFlags, - targetFlags, + readSeedTargetFlags, type TargetFlagValuesWithoutDbUrl, } from "../../../command-internal/target/target-flags.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 { storageGatewayRuntimeLayer } from "../../../command-internal/storage-runtime.layer.ts"; +import { STORAGE_PERMISSIONS } from "../../storage/storage.flags.ts"; import { seedBuckets } from "./buckets.handler.ts"; import { withTargetPolicy } from "../../../command-internal/target/target-policy.ts"; -const config = { - projectRef: targetFlags({ subject: "Storage API", dbUrl: false }).projectRef, -}; - -// `--remote`/`--linked`/`--local` are scoped globals on the `seed` group (`seedTargetGlobalFlags`). export type BucketsFlags = TargetFlagValuesWithoutDbUrl; -export const bucketsCommand = Command.make("buckets", config).pipe( +export const bucketsCommand = Command.make("buckets").pipe( Command.withDescription("Seed buckets declared in [storage.buckets]."), Command.withShortDescription("Seed buckets declared in [storage.buckets]"), - withPermissions( - withDbTarget( - compose( - { operations: [], noApiEffectFlags: [] }, - withCondition(compose(storageCredentials, linkedProjectCache), { dbTarget: ["linked"] }), - branchResolution, - ), - { default: "local" }, - ), - ), - Command.withHandler((leafFlags) => + withPermissions(STORAGE_PERMISSIONS), + Command.withHandler(() => Effect.gen(function* () { - const flags: BucketsFlags = { - remote: yield* seedTargetGlobalFlags.remote, - linked: yield* seedTargetGlobalFlags.linked, - local: yield* seedTargetGlobalFlags.local, - projectRef: leafFlags.projectRef, - }; + const flags: BucketsFlags = yield* readSeedTargetFlags(); return yield* seedBuckets(flags).pipe( withCommandTelemetry({ flags: { @@ -66,6 +39,6 @@ export const bucketsCommand = Command.make("buckets", config).pipe( ), Command.provide(Layer.mergeAll(storageGatewayRuntimeLayer(["seed", "buckets"]), stdinLayer)), Command.provide(cliConfigValuesLayer), - withCliConfigFlags(config), + withCliConfigFlags({}), withTargetPolicy({ group: "selectable", consent: "no" }), ); diff --git a/apps/cli/src/commands/seed/seed.command.ts b/apps/cli/src/commands/seed/seed.command.ts index c22126181e..fdb701288d 100644 --- a/apps/cli/src/commands/seed/seed.command.ts +++ b/apps/cli/src/commands/seed/seed.command.ts @@ -7,11 +7,12 @@ 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 `--remote`/`--linked`/`--local`, accepted before or after the subcommand. Kept - // after `withSubcommands` so it discharges the subcommands' flag requirements. + // The selector flags are persistent, accepted before or after the subcommand. Kept after + // `withSubcommands` so it discharges the subcommands' flag requirements. Command.withGlobalFlags([ seedTargetGlobalFlags.remote, seedTargetGlobalFlags.linked, seedTargetGlobalFlags.local, + seedTargetGlobalFlags.projectRef, ]), ); diff --git a/apps/cli/src/commands/seed/seed.command.unit.test.ts b/apps/cli/src/commands/seed/seed.command.unit.test.ts new file mode 100644 index 0000000000..f7a923b0ad --- /dev/null +++ b/apps/cli/src/commands/seed/seed.command.unit.test.ts @@ -0,0 +1,64 @@ +import { BunServices } from "@effect/platform-bun"; +import { describe, expect, it } from "@effect/vitest"; +import { Effect, Layer, Option } from "effect"; +import { CliOutput, Command } from "effect/cli"; + +import { + readSeedTargetFlags, + seedTargetGlobalFlags, +} from "../../command-internal/target/target-flags.ts"; +import { commandInternals } from "../../docs/docs-introspection.ts"; +import { commandTreeFor } from "../../../tests/helpers/command-flags.ts"; + +const REF = "abcdefghijklmnopqrst"; + +const silentFormatter: CliOutput.Formatter = { + formatCliError: () => "", + formatError: () => "", + formatErrors: () => "", + formatHelpDoc: () => "", + formatVersion: () => "", +}; + +const parseProjectRef = (args: ReadonlyArray<string>) => { + const seen: Array<Option.Option<string>> = []; + const buckets = Command.make("buckets").pipe( + Command.withHandler(() => + readSeedTargetFlags().pipe(Effect.map((flags) => seen.push(flags.projectRef))), + ), + ); + const seed = Command.make("seed").pipe( + Command.withSubcommands([buckets]), + Command.withGlobalFlags([ + seedTargetGlobalFlags.remote, + seedTargetGlobalFlags.linked, + seedTargetGlobalFlags.local, + seedTargetGlobalFlags.projectRef, + ]), + ); + return Command.runWith(seed, { version: "0.0.0-test" })(args).pipe( + Effect.provide(Layer.mergeAll(BunServices.layer, CliOutput.layer(silentFormatter))), + Effect.map(() => seen[0]), + ); +}; + +describe("seed selector flags", () => { + it("registers every selector flag on the group, so the leaf declares none of its own", () => { + const leaf = commandTreeFor(undefined).get("seed buckets"); + expect(leaf).toBeDefined(); + if (leaf === undefined) return; + expect(commandInternals(leaf.command).config.flags).toEqual([]); + for (const flag of ["remote", "linked", "local", "project-ref"]) { + expect(leaf.flags.has(flag), `--${flag}`).toBe(true); + } + }); + + it.effect.each([ + { name: "before the subcommand", args: ["--project-ref", REF, "buckets"] }, + { name: "after the subcommand", args: ["buckets", "--project-ref", REF] }, + ])("reads --project-ref given $name", ({ args }) => + Effect.gen(function* () { + expect(yield* parseProjectRef(args)).toEqual(Option.some(REF)); + }), + ); +}); diff --git a/apps/cli/tests/helpers/permission-registry.ts b/apps/cli/tests/helpers/permission-registry.ts index 091675e532..8d75fa17a2 100644 --- a/apps/cli/tests/helpers/permission-registry.ts +++ b/apps/cli/tests/helpers/permission-registry.ts @@ -56,6 +56,11 @@ function selectorShapeOf(activeFlags: ActiveFlags): SelectorShape { const dbUrl = given("db-url"); if (dbUrl !== undefined) return "url"; const alias = given("project-ref") ?? given("project-id"); + if (alias === true) { + throw new Error( + "activeFlags gives --project-ref/--project-id as `true`; pass the value, since its shape picks the entries that apply", + ); + } if (typeof alias === "string") return shapeOfNamedRemote(alias); if (given("linked") !== undefined) return "bare"; const remote = given("remote"); From 930e64382086e0b00505bb6402b8d2cf4bdb4b4e Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Mon, 19 Oct 2026 15:03:00 +0100 Subject: [PATCH 150/291] fix(cli): resolve the gen types target before its flag guards gen types resolves the target first, so a bare --remote followed by a branch name reaches the ambiguity check instead of being read as the positional language, and --remote <ref> works on the rewritten command line. The remote-selector mutex groups collapse into one list of flags a hosted project excludes. --- .../src/commands/gen/types/SIDE_EFFECTS.md | 6 +- .../src/commands/gen/types/types.command.ts | 9 +- .../src/commands/gen/types/types.handler.ts | 54 +++---- .../gen/types/types.integration.test.ts | 149 +++++++++--------- 4 files changed, 98 insertions(+), 120 deletions(-) diff --git a/apps/cli/src/commands/gen/types/SIDE_EFFECTS.md b/apps/cli/src/commands/gen/types/SIDE_EFFECTS.md index 4fe1f88041..6d068c1b8c 100644 --- a/apps/cli/src/commands/gen/types/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/gen/types/SIDE_EFFECTS.md @@ -179,7 +179,11 @@ Not applicable. language flag (`swift-access-control`). Passing the selector together with any of them fails with `if any flags in the group [remote linked project-ref <flag>] are set none of the others can be; [<set flags, sorted>] were all set`, reporting the first violated group in that order. `--remote=<db-url>` and `--db-url` are not - members, so they accept all three. + members, so they accept all three. The target resolves first, ahead of these groups and the + language guards, so a selector error, a missing project or a bare `--remote <word>` that names a + branch fails before them. The command reads its own rewritten command line, so + `--remote <ref>` and `--remote <url>` mean `--remote=<ref>` and `--remote=<url>`, and the word is not + taken as the positional language. - With the local target, 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.command.ts b/apps/cli/src/commands/gen/types/types.command.ts index e7ef980cec..7d873a2f59 100644 --- a/apps/cli/src/commands/gen/types/types.command.ts +++ b/apps/cli/src/commands/gen/types/types.command.ts @@ -174,19 +174,12 @@ export const genTypesCommand = Command.make("types", commandConfig).pipe( context: "the ref names a preview branch and generating against its direct host fails with an IPv6 connectivity error", }, - { - operationId: "v1-list-all-branches", - kind: "required", - when: [{ flag: "project-id", present: true }], - context: - "--project-id names a branch (not a 20-letter project ref) of the linked project", - }, ], noApiEffectFlags: ["schema", "query-timeout", ...GEN_TYPES_LANGUAGE_FLAG_NAMES], }, withCondition(withContext(dbConfigLinked, notBranch), nonTypescript), withCondition(withContext(dbConfigPoolerFallbackLinked, notBranch), nonTypescript), - withCondition(projectRefPicker, { flag: "project-id", present: false }), + projectRefPicker, linkedProjectCache, branchResolution, ), diff --git a/apps/cli/src/commands/gen/types/types.handler.ts b/apps/cli/src/commands/gen/types/types.handler.ts index 7eaf7b1dc2..bbce90bbef 100644 --- a/apps/cli/src/commands/gen/types/types.handler.ts +++ b/apps/cli/src/commands/gen/types/types.handler.ts @@ -114,14 +114,11 @@ function isPoolerHost(host: string, poolerHost: string): boolean { const GEN_TYPES_COMMAND_PATH = ["gen", "types"] as const; -// The hosted-project selectors name one project, so they count as one member of a mutex group. -const REMOTE_TARGET_FLAGS: ReadonlySet<string> = new Set(["remote", "linked", "project-ref"]); - -// Validation reports only the first violated group, in this listed order. -const GEN_TYPES_MUTEX_GROUPS: ReadonlyArray<ReadonlyArray<string>> = [ - ["remote", "linked", "project-ref", "postgrest-v9-compat"], - ["remote", "linked", "project-ref", "query-timeout"], - ...GEN_TYPES_LANGUAGE_FLAG_NAMES.map((name) => ["remote", "linked", "project-ref", name]), +// A hosted project target excludes these flags; only the first one passed is reported, in this order. +const HOSTED_EXCLUDED_FLAGS: ReadonlyArray<string> = [ + "postgrest-v9-compat", + "query-timeout", + ...GEN_TYPES_LANGUAGE_FLAG_NAMES, ]; const POSTGREST_V9_COMPAT_DEPRECATION_LINE = @@ -573,6 +570,8 @@ export const genTypes = Effect.fn("gen.types")(function* (flags: GenTypesFlags) yield* output.raw(`${POSTGREST_V9_COMPAT_DEPRECATION_LINE}\n`, "stderr"); } + const target = yield* resolveTarget(flags); + const projectRefFlag = Option.orElse(flags.projectRef, () => flags.projectId); const remoteIsDatabase = Option.exists(flags.remote, (value) => value._tag === "DbUrl"); @@ -597,40 +596,25 @@ export const genTypes = Effect.fn("gen.types")(function* (flags: GenTypesFlags) }); } - // `--linked=false` counts as absent; every other flag counts once passed, whatever its value. - const changedMutexFlags: Record<string, boolean> = { - remote: Option.isSome(flags.remote) && !remoteIsDatabase, - linked: flags.linked, - "project-ref": Option.isSome(projectRefFlag), - "postgrest-v9-compat": occurrences.has("postgrest-v9-compat"), - "query-timeout": occurrences.has("query-timeout"), - ...Object.fromEntries( - GEN_TYPES_LANGUAGE_FLAG_NAMES.map((name) => [name, occurrences.has(name)]), - ), - }; - for (const group of GEN_TYPES_MUTEX_GROUPS) { - const set = group.filter((flagName) => changedMutexFlags[flagName]); - const members = new Set( - set.map((flagName) => (REMOTE_TARGET_FLAGS.has(flagName) ? "linked" : flagName)), - ); - if (members.size > 1) { + if (target.kind === "linked") { + const excluded = HOSTED_EXCLUDED_FLAGS.find((name) => occurrences.has(name)); + if (excluded !== undefined) { + const selectors = [ + ...(Option.isSome(flags.remote) ? ["remote"] : []), + ...(flags.linked ? ["linked"] : []), + ...(Option.isSome(projectRefFlag) ? ["project-ref"] : []), + ]; return yield* new GenTypesFlagUsageError({ - message: mutuallyExclusiveFlagsMessage(group, set), + message: mutuallyExclusiveFlagsMessage( + ["remote", "linked", "project-ref", excluded], + [...selectors, excluded], + ), }); } } yield* Effect.annotateCurrentSpan("typegen.lang", lang); - const target = yield* resolveTarget({ - remote: flags.remote, - local: flags.local, - linked: flags.linked, - dbUrl: flags.dbUrl, - projectRef: flags.projectRef, - projectId: flags.projectId, - }); - if (target.kind === "local") { yield* Effect.annotateCurrentSpan({ "typegen.source": "local", 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 4a434196b7..955bc5586c 100644 --- a/apps/cli/src/commands/gen/types/types.integration.test.ts +++ b/apps/cli/src/commands/gen/types/types.integration.test.ts @@ -31,6 +31,8 @@ import { } from "effect"; import { resolveWorkdir } from "../../../config/command-settings.layer.ts"; import { DnsResolverFlag, OutputFlag, YesFlag } from "../../../command-internal/global-flags.ts"; +import { rootCommandForFeatures } from "../../../cli/root.ts"; +import { cliArgsLayer, prepareCliArgs } from "../../../shared/cli/run.ts"; import { commandRuntimeLayer } from "../../../shared/runtime/command-runtime.layer.ts"; import { CommandPlatformApiFactory } from "../../../auth/command-platform-api-factory.service.ts"; import { CommandPlatformApi } from "../../../auth/command-platform-api.service.ts"; @@ -545,7 +547,9 @@ const setup = Effect.fnUntraced(function* ( runtime, BunServices.layer, child.layer, - Stdio.layerTest({ args: Effect.succeed(opts.args ?? ["gen", "types"]) }), + cliArgsLayer(prepareCliArgs(rootCommandForFeatures(), opts.args ?? ["gen", "types"])).pipe( + Layer.provideMerge(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), @@ -1148,6 +1152,45 @@ describe("gen types", () => { }).pipe(Effect.provide(BunServices.layer)), ); + it.effect("reads a bare --remote followed by a project ref as --remote=<ref>", () => + Effect.gen(function* () { + const { layer, api } = yield* setup({ + args: ["gen", "types", "--remote", BRANCH_PROJECT_REF], + projectTypes: "ok", + }); + yield* genTypes( + defaultFlags({ remote: Option.some({ _tag: "Ref", ref: BRANCH_PROJECT_REF } as const) }), + ).pipe(Effect.provide(layer)); + + expect(api.requests).toEqual([ + { + method: "generateTypescriptTypes", + input: { ref: BRANCH_PROJECT_REF, included_schemas: "public" }, + }, + ]); + }).pipe(Effect.provide(BunServices.layer)), + ); + + it.effect("fails a bare --remote followed by a branch name before the language guard", () => + Effect.gen(function* () { + const { layer, api, generator } = yield* setup({ + args: ["gen", "types", "--remote", "feature-x"], + projectId: Option.some(VALID_REF), + branches: [branchRow("feature-x")], + }); + const error = yield* genTypes( + defaultFlags({ remote: Option.some({ _tag: "Linked" } as const) }), + ).pipe(Effect.provide(layer), Effect.flip); + + expect(error).toMatchObject({ + _tag: "TargetRemoteAmbiguousError", + message: `feature-x is a branch of project ${VALID_REF}. Did you mean --remote=feature-x?`, + }); + expect(api.requests.map(({ method }) => method)).toEqual(["listAllBranches"]); + expect(generator.calls).toHaveLength(0); + }).pipe(Effect.provide(BunServices.layer)), + ); + it.effect("fails with the branches that exist when --remote=<branch> matches none", () => Effect.gen(function* () { const { layer, api, generator } = yield* setup({ @@ -1368,6 +1411,7 @@ describe("gen types", () => { Effect.gen(function* () { const { layer } = yield* setup({ args: ["gen", "types", "--remote", "--swift-access-control", "public", "--lang", "swift"], + projectId: Option.some(VALID_REF), }); const exit = yield* genTypes( defaultFlags({ @@ -1417,29 +1461,32 @@ describe("gen types", () => { }).pipe(Effect.provide(BunServices.layer)), ); - it.effect("rejects --postgrest-v9-compat without --db-url for project-ref generation", () => - Effect.gen(function* () { - const { layer } = yield* setup({ - args: ["gen", "types", "--project-ref", VALID_REF, "--postgrest-v9-compat"], - }); - const exit = yield* genTypes( - defaultFlags({ projectRef: Option.some(VALID_REF), postgrestV9Compat: true }), - ).pipe(Effect.provide(layer), Effect.exit); + it.effect( + "rejects --postgrest-v9-compat without --db-url for project-ref generation, ahead of the mutex group", + () => + Effect.gen(function* () { + const { layer } = yield* setup({ + args: ["gen", "types", "--project-ref", VALID_REF, "--postgrest-v9-compat"], + }); + const exit = yield* genTypes( + defaultFlags({ projectRef: Option.some(VALID_REF), postgrestV9Compat: true }), + ).pipe(Effect.provide(layer), Effect.exit); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - // Established guard, including its "must used" typo — do not "fix" the grammar. - expect(String(exit.cause)).toContain( - "--postgrest-v9-compat must used together with --remote=<db-url>", - ); - expect( - Option.exists( - Cause.findErrorOption(exit.cause), - Predicate.isTagged("GenTypesFlagUsageError"), - ), - ).toBe(true); - } - }).pipe(Effect.provide(BunServices.layer)), + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + // Established guard, including its "must used" typo — do not "fix" the grammar. + expect(String(exit.cause)).toContain( + "--postgrest-v9-compat must used together with --remote=<db-url>", + ); + expect(String(exit.cause)).not.toContain("if any flags in the group"); + expect( + Option.exists( + Cause.findErrorOption(exit.cause), + Predicate.isTagged("GenTypesFlagUsageError"), + ), + ).toBe(true); + } + }).pipe(Effect.provide(BunServices.layer)), ); it.effect("rejects --postgrest-v9-compat without --db-url for local generation", () => @@ -1599,34 +1646,15 @@ describe("gen types", () => { }).pipe(Effect.provide(BunServices.layer)), ); - it.effect("prefers the --postgrest-v9-compat guard over mutex group errors", () => + it.effect("prefers the positional language guard over mutex group errors", () => Effect.gen(function* () { const { layer } = yield* setup({ - args: ["gen", "types", "--local", "--linked", "--postgrest-v9-compat"], + args: ["gen", "types", "go", "--project-ref", VALID_REF, "--query-timeout", "20s"], }); const exit = yield* genTypes( - defaultFlags({ local: true, linked: true, postgrestV9Compat: true }), + 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( - "--postgrest-v9-compat must used together with --remote=<db-url>", - ); - } - }).pipe(Effect.provide(BunServices.layer)), - ); - - it.effect("prefers the positional language guard over mutex group errors", () => - Effect.gen(function* () { - const { layer } = yield* setup({ - args: ["gen", "types", "go", "--local", "--linked"], - }); - const exit = yield* genTypes(defaultFlags({ local: true, linked: true })).pipe( - Effect.provide(layer), - Effect.exit, - ); - expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { expect(String(exit.cause)).toContain("use --lang flag to specify the typegen language"); @@ -1640,37 +1668,6 @@ describe("gen types", () => { }).pipe(Effect.provide(BunServices.layer)), ); - it.effect("reports mutex groups in sorted group-key order", () => - Effect.gen(function* () { - const dbUrl = "postgresql://postgres:postgres@127.0.0.1:5432/postgres"; - const { layer } = yield* setup({ - args: [ - "gen", - "types", - "--db-url", - dbUrl, - "--postgrest-v9-compat", - "--project-ref", - VALID_REF, - ], - }); - const exit = yield* genTypes( - defaultFlags({ - dbUrl: Option.some(dbUrl), - projectRef: Option.some(VALID_REF), - postgrestV9Compat: true, - }), - ).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 [remote 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)), - ); - it.effect("rejects a non-typescript language passed after a -- separator", () => Effect.gen(function* () { const { layer } = yield* setup({ args: ["gen", "types", "--", "go"] }); From d3f47bbe00f288a6f799aa094fc748c5b79eb724 Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Mon, 19 Oct 2026 15:07:55 +0100 Subject: [PATCH 151/291] refactor(cli): complete --remote through the argv rewrite Completion asks the same rewrite the runtime uses whether the word after a bare --remote is its value, and offers --remote= (with no trailing space) instead of a bare switch. --- apps/cli/src/cli/complete.remote.unit.test.ts | 50 ++++++++++------ apps/cli/src/cli/complete.ts | 60 ++++++++++++------- apps/cli/src/cli/complete.unit.test.ts | 16 ++--- .../src/commands/completion/SIDE_EFFECTS.md | 6 +- apps/cli/src/shared/cli/run.ts | 2 +- 5 files changed, 84 insertions(+), 50 deletions(-) diff --git a/apps/cli/src/cli/complete.remote.unit.test.ts b/apps/cli/src/cli/complete.remote.unit.test.ts index ca4441a53b..abce18cf67 100644 --- a/apps/cli/src/cli/complete.remote.unit.test.ts +++ b/apps/cli/src/cli/complete.remote.unit.test.ts @@ -20,14 +20,27 @@ const names = (...words: ReadonlyArray<string>) => complete(...words)?.candidates.map((candidate) => candidate.name); const noValueCandidates = { candidates: [], directive: CompletionDirective.NoFileComp }; +const positionalCandidates = { candidates: [], directive: CompletionDirective.Default }; + +const PROJECT_REF = "abcdefghijklmnopqrst"; describe("--remote flag-name completion", () => { - it("offers --remote as a bare switch, never --remote=", () => { - expect(names("db", "push", "--rem")).toEqual(["--remote"]); + it("offers --remote= so the next word is typed as its value", () => { + expect(names("db", "push", "--rem")).toEqual(["--remote="]); + }); + + it("offers --remote= after positionals", () => { + expect(names("db", "pull", "add_profiles", "--rem")).toEqual(["--remote="]); + }); + + it("asks the shell not to add a space after --remote=", () => { + expect(complete("db", "push", "--rem")?.directive).toBe( + CompletionDirective.NoFileComp | CompletionDirective.NoSpace, + ); }); - it("offers --remote after positionals", () => { - expect(names("db", "pull", "add_profiles", "--rem")).toEqual(["--remote"]); + it("keeps the space after a flag name that takes no value", () => { + expect(complete("db", "push", "--ot")?.directive).toBe(CompletionDirective.NoFileComp); }); it("stops offering --remote once it is given", () => { @@ -56,14 +69,15 @@ describe("the token after a bare --remote", () => { }); it("is the positional while the command has a free positional slot", () => { - expect(complete("db", "pull", "--remote", "")).toEqual({ - candidates: [], - directive: CompletionDirective.Default, - }); - expect(complete("db", "query", "--remote", "sel")).toEqual({ - candidates: [], - directive: CompletionDirective.Default, - }); + expect(complete("db", "pull", "--remote", "")).toEqual(positionalCandidates); + expect(complete("db", "query", "--remote", "sel")).toEqual(positionalCandidates); + }); + + it("is the value when it is a project ref or a database URL, even with a free positional slot", () => { + expect(complete("db", "pull", "--remote", PROJECT_REF)).toEqual(noValueCandidates); + expect(complete("db", "query", "--remote", "postgresql://u:p@host:5432/db")).toEqual( + noValueCandidates, + ); }); it("is the value once the positionals are filled", () => { @@ -71,11 +85,13 @@ describe("the token after a bare --remote", () => { expect(complete("db", "query", "select 1", "--remote", "")).toEqual(noValueCandidates); }); - it("is always a positional when the command takes a variadic one", () => { - expect(complete("db", "cp", "a", "b", "--remote", "")).toEqual({ - candidates: [], - directive: CompletionDirective.Default, - }); + it("is a positional when the command takes a variadic one and the word could be an operand", () => { + expect(complete("db", "cp", "a", "b", "--remote", "")).toEqual(positionalCandidates); + expect(complete("db", "cp", "a", "--remote", "feat")).toEqual(positionalCandidates); + }); + + it("is the value on a variadic command once it is a project ref", () => { + expect(complete("db", "cp", "a", "--remote", PROJECT_REF)).toEqual(noValueCandidates); }); it("does not stop flag completion from running after the bare switch", () => { diff --git a/apps/cli/src/cli/complete.ts b/apps/cli/src/cli/complete.ts index fa66fd3b11..fe31a9851b 100644 --- a/apps/cli/src/cli/complete.ts +++ b/apps/cli/src/cli/complete.ts @@ -9,9 +9,11 @@ import { RESOURCE_OUTPUT_FORMATS, } from "../command-internal/output-formats.ts"; import { unwrapParam } from "../command-internal/param-introspection.ts"; -import { commandInternals } from "../docs/docs-introspection.ts"; +import { REMOTE_FLAG_NAME } from "../command-internal/target/remote-vocabulary.ts"; import { isValidBase0Int64, parseUintBase0 } from "../shared/config/parse-uint.ts"; import { parseStringSliceFlag } from "../command-internal/string-slice-flag.ts"; +import { joinRemoteOperands, type RemoteArgvLeaf } from "../shared/cli/remote-argv.ts"; +import { remoteArgvLeafFor } from "../shared/cli/run.ts"; import { withAnalyticsContext } from "../shared/telemetry/analytics-context.ts"; import { Analytics } from "../shared/telemetry/analytics.service.ts"; import { @@ -50,6 +52,7 @@ export interface CompletionResult { /** Bit flags for the shell completion protocol's directive value. */ export const CompletionDirective = { Default: 0, + NoSpace: 2, NoFileComp: 4, FilterFileExt: 8, } as const; @@ -80,6 +83,8 @@ export interface ClassifyCompletionInput { readonly trimmedArgs: ReadonlyArray<string>; readonly toComplete: string; readonly inScopeFlags: ReadonlyArray<FlagDescriptor>; + /** How the argv rewrite reads this command's operands; absent when the command takes no `--remote`. */ + readonly remoteLeaf?: RemoteArgvLeaf | undefined; } export interface CompleteDeps { @@ -348,12 +353,22 @@ const COMPLETION_NO_FILE_COMP_PATHS: ReadonlySet<string> = new Set([ "completion powershell", ]); +/** Offered instead of a bare `--remote`, so the next word is typed as its value rather than read as an operand. */ +const REMOTE_FLAG_CANDIDATE = `--${REMOTE_FLAG_NAME}=`; + +/** `--remote=` takes its value right after the `=`, so the shell must not add a space. */ +function flagNameDirective(candidates: ReadonlyArray<CompletionCandidate>): number { + return candidates.some((candidate) => candidate.name === REMOTE_FLAG_CANDIDATE) + ? CompletionDirective.NoFileComp | CompletionDirective.NoSpace + : CompletionDirective.NoFileComp; +} + function flagNameCandidates( flag: FlagDescriptor, toComplete: string, ): ReadonlyArray<CompletionCandidate> { const candidates: Array<CompletionCandidate> = []; - const long = `--${flag.name}`; + const long = flag.name === REMOTE_FLAG_NAME ? REMOTE_FLAG_CANDIDATE : `--${flag.name}`; if (long.startsWith(toComplete)) candidates.push({ name: long, description: flag.description }); for (const alias of flag.aliases) { if (alias.length !== 1) continue; @@ -656,23 +671,19 @@ function outputFlagChoiceKeys(matchedPath: ReadonlyArray<string>): ReadonlyArray } /** - * `--remote` is tagged as a boolean so a bare `--remote` parses, yet `--remote=<value>` takes any - * text; the runtime rejects `false` and the empty value with a clear error of its own. - */ -const REMOTE_FLAG = "remote"; - -/** - * Whether the token after a bare `--remote` is read as its value rather than a positional, - * mirroring the argv rewrite: always for a command with no positional, and for a token past the - * last positional slot. A variadic positional never runs out of slots. + * Whether the word being typed after a bare `--remote` would be joined into `--remote=<word>` by + * the argv rewrite, so it completes as the flag's value rather than as an operand. */ -function remoteTakesNextToken( - finalCommand: Command.Command.Any, - leftoverArgs: ReadonlyArray<string>, +function remoteTakesWord( + remoteLeaf: RemoteArgvLeaf | undefined, + trimmedArgs: ReadonlyArray<string>, + toComplete: string, ): boolean { - const positionals = commandInternals(finalCommand).config.arguments; - if (positionals.some((param) => unwrapParam(param)?.isVariadic === true)) return false; - return leftoverArgs.length >= positionals.length; + if (remoteLeaf === undefined) return false; + // An empty word has no shape yet, so it is read as the branch name it may become. + const word = toComplete === "" ? "branch" : toComplete; + const joined = joinRemoteOperands([...trimmedArgs, word], remoteLeaf); + return joined[joined.length - 1] === `--${REMOTE_FLAG_NAME}=${word}`; } /** @@ -687,7 +698,7 @@ function isValidFlagValue( flag: FlagDescriptor, value: string, ): boolean { - if (flag.name === REMOTE_FLAG) return true; + if (flag.name === REMOTE_FLAG_NAME) return true; const key = `${matchedPath.join(" ")}:${flag.name}`; if (COMPLETION_UINT_FLAGS.has(key)) { return "value" in parseUintBase0(value); @@ -823,7 +834,7 @@ function flagValueCompletion( matchedPath: ReadonlyArray<string>, flagName: string | undefined, ): CompletionResult { - if (flagName === REMOTE_FLAG) { + if (flagName === REMOTE_FLAG_NAME) { return { candidates: [], directive: CompletionDirective.NoFileComp }; } const key = flagName === undefined ? undefined : `${matchedPath.join(" ")}:${flagName}`; @@ -880,6 +891,7 @@ function helpArgumentCandidates( */ export function classifyCompletion(input: ClassifyCompletionInput): CompletionResult { const { finalCommand, matchedPath, leftoverArgs, trimmedArgs, toComplete, inScopeFlags } = input; + const { remoteLeaf } = input; const isAtRoot = matchedPath.length === 0; if (findUnresolvedFlagToken(trimmedArgs, toComplete, inScopeFlags, matchedPath) !== undefined) { @@ -922,12 +934,12 @@ export function classifyCompletion(input: ClassifyCompletionInput): CompletionRe ); // If any required flag is still unset, only required flags are offered. if (requiredCandidates.length > 0) { - return { candidates: requiredCandidates, directive: CompletionDirective.NoFileComp }; + return { candidates: requiredCandidates, directive: flagNameDirective(requiredCandidates) }; } const candidates = inScopeFlags .filter((flag) => !flag.hidden && (!changedFlagNames.has(flag.name) || flag.isVariadic)) .flatMap((flag) => flagNameCandidates(flag, toComplete)); - return { candidates, directive: CompletionDirective.NoFileComp }; + return { candidates, directive: flagNameDirective(candidates) }; } // Case 2: flag-VALUE completion. @@ -958,7 +970,10 @@ export function classifyCompletion(input: ClassifyCompletionInput): CompletionRe if (!resolved.isBoolean) { return flagValueCompletion(matchedPath, resolved.name); } - if (resolved.name === REMOTE_FLAG && remoteTakesNextToken(finalCommand, leftoverArgs)) { + if ( + resolved.name === REMOTE_FLAG_NAME && + remoteTakesWord(remoteLeaf, trimmedArgs, toComplete) + ) { return flagValueCompletion(matchedPath, resolved.name); } // A resolved boolean precedingToken falls through to Case 3, since it never @@ -1045,6 +1060,7 @@ export function respondToComplete( trimmedArgs, toComplete, inScopeFlags, + remoteLeaf: remoteArgvLeafFor(root, [...trimmedArgs, toComplete]), }); } diff --git a/apps/cli/src/cli/complete.unit.test.ts b/apps/cli/src/cli/complete.unit.test.ts index 4c9db33590..0ae77c01b6 100644 --- a/apps/cli/src/cli/complete.unit.test.ts +++ b/apps/cli/src/cli/complete.unit.test.ts @@ -93,10 +93,10 @@ describe("respondToComplete", () => { it("offers a non-root command's own declared global flags from a nested subcommand (Command.withGlobalFlags)", () => { const atGroup = respondToComplete(rootCommand, ["__complete", "seed", "--rem"]); - expect(atGroup?.candidates.map((c) => c.name)).toEqual(expect.arrayContaining(["--remote"])); + expect(atGroup?.candidates.map((c) => c.name)).toEqual(expect.arrayContaining(["--remote="])); const atLeaf = respondToComplete(rootCommand, ["__complete", "seed", "buckets", "--rem"]); - expect(atLeaf?.candidates.map((c) => c.name)).toEqual(expect.arrayContaining(["--remote"])); + expect(atLeaf?.candidates.map((c) => c.name)).toEqual(expect.arrayContaining(["--remote="])); }); it("does not duplicate a flag name that exists both globally and as a command's own local flag", () => { @@ -176,7 +176,7 @@ describe("respondToComplete", () => { "20240101000000", "--rem", ]); - expect(result?.candidates.map((c) => c.name)).toContain("--remote"); + expect(result?.candidates.map((c) => c.name)).toContain("--remote="); }); it("does not short-circuit on --help positioned after a genuine `--` terminator (it is positional, not a flag)", () => { @@ -555,7 +555,7 @@ describe("respondToComplete", () => { "table", "--rem", ]); - expect(result?.candidates.map((c) => c.name)).toContain("--remote"); + expect(result?.candidates.map((c) => c.name)).toContain("--remote="); }); it("accepts the resource-command values (env/pretty/toml/yaml) outside db query", () => { @@ -581,8 +581,8 @@ describe("respondToComplete", () => { "0x10", "--r", ]); - expect(result?.directive).toBe(CompletionDirective.NoFileComp); - expect(result?.candidates.map((c) => c.name)).toEqual(expect.arrayContaining(["--remote"])); + expect(result?.directive).toBe(CompletionDirective.NoFileComp | CompletionDirective.NoSpace); + expect(result?.candidates.map((c) => c.name)).toEqual(expect.arrayContaining(["--remote="])); }); it("rejects a value one past int64 max for a plain (non-uint) integer flag", () => { @@ -641,7 +641,7 @@ describe("respondToComplete", () => { "5s", "--rem", ]); - expect(queryTimeoutValid?.candidates.map((c) => c.name)).toContain("--remote"); + expect(queryTimeoutValid?.candidates.map((c) => c.name)).toContain("--remote="); const validFor = respondToComplete(rootCommand, [ "__complete", @@ -687,7 +687,7 @@ describe("respondToComplete", () => { "2562047h47m16.854775807s", "--rem", ]); - expect(max?.candidates.map((c) => c.name)).toContain("--remote"); + expect(max?.candidates.map((c) => c.name)).toContain("--remote="); }); it("rejects a malformed value for the time flag (gen bearer-jwt --exp, RFC3339 only)", () => { diff --git a/apps/cli/src/commands/completion/SIDE_EFFECTS.md b/apps/cli/src/commands/completion/SIDE_EFFECTS.md index 3742ea099c..1147902112 100644 --- a/apps/cli/src/commands/completion/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/completion/SIDE_EFFECTS.md @@ -96,8 +96,10 @@ for this doc — see that file's own doc comments — but its externally-visible format, env vars, and exit codes are documented here since it has no `SIDE_EFFECTS.md` of its own. The wire format written to stdout: one line per candidate (`name` or, when descriptions are enabled and present, `name\t<first line of description>`), followed by -a final `:<directive>` line (an integer — `0` default, `4` "no file completion", `8` -"filter by file extension", a subset of the `ShellCompDirective` bits). +a final `:<directive>` line (an integer — `0` default, `2` "no space after the completion", `4` +"no file completion", `8` "filter by file extension", a subset of the `ShellCompDirective` +bits). A flag-name completion that offers `--remote=` carries `2` so the shell leaves the cursor +right after the `=`. ## Notes diff --git a/apps/cli/src/shared/cli/run.ts b/apps/cli/src/shared/cli/run.ts index 165abc4a95..26bff6b3f2 100644 --- a/apps/cli/src/shared/cli/run.ts +++ b/apps/cli/src/shared/cli/run.ts @@ -194,7 +194,7 @@ function valueTakingFlagTokenPredicateForArgv( } /** How the command `args` resolves to reads its operands, or `undefined` without `--remote` or when the command takes no `--remote`. */ -function remoteArgvLeafFor( +export function remoteArgvLeafFor( rootCommand: Command.Command.Any, args: ReadonlyArray<string>, ): RemoteArgvLeaf | undefined { From 414410cff0b2e85182946b7737f4481e57b983c1 Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Mon, 19 Oct 2026 15:11:38 +0100 Subject: [PATCH 152/291] refactor(cli): unify the functions project resolver and read sso --remote from the argv scan --- .../command-internal/argv-flag-reconcile.ts | 11 +++++ .../argv-flag-reconcile.unit.test.ts | 33 ++++++++++++++ .../functions/deploy/deploy.handler.ts | 7 ++- .../deploy/deploy.integration.test.ts | 4 +- apps/cli/src/commands/sso/add/SIDE_EFFECTS.md | 2 +- apps/cli/src/commands/sso/add/add.handler.ts | 4 +- .../commands/sso/add/add.integration.test.ts | 35 ++++++++++++++- .../src/commands/sso/update/SIDE_EFFECTS.md | 2 +- .../src/commands/sso/update/update.handler.ts | 4 +- .../sso/update/update.integration.test.ts | 43 ++++++++++++++++++- apps/cli/src/shared/functions/deploy.ts | 4 +- 11 files changed, 135 insertions(+), 14 deletions(-) diff --git a/apps/cli/src/command-internal/argv-flag-reconcile.ts b/apps/cli/src/command-internal/argv-flag-reconcile.ts index 89f62d7d5c..f04e4897e9 100644 --- a/apps/cli/src/command-internal/argv-flag-reconcile.ts +++ b/apps/cli/src/command-internal/argv-flag-reconcile.ts @@ -11,6 +11,8 @@ import { import { CommandSettings } from "../config/command-settings.service.ts"; import { loadProfile, type LoadedProfile } from "./profile-load.ts"; import { parseStringSliceFlag } from "./string-slice-flag.ts"; +import { classifyRemoteValue, type RemoteValue } from "./target/remote-value.ts"; +import { REMOTE_FLAG_NAME } from "./target/remote-vocabulary.ts"; import { validateWorkdirIsDirectory } from "./workdir-validation.ts"; /** @@ -49,6 +51,15 @@ export function argvStringValue( return values === undefined ? Option.none() : Option.some(values[values.length - 1] ?? ""); } +/** The scan's last `--remote`, classified like the flag's own parser, so it reconciles with `--project-ref` read from the same scan. */ +export function argvRemoteValue( + occurrences: ReadonlyMap<string, ReadonlyArray<string>>, +): Option.Option<RemoteValue> { + return Option.flatMap(argvStringValue(occurrences, REMOTE_FLAG_NAME), (raw) => + Option.fromNullishOr(classifyRemoteValue(raw)), + ); +} + /** * Like `argvStringValue`, but for CSV-accumulating slice flags: every occurrence is * CSV-split and accumulated. An absent flag reconciles to `[]` even when the Effect parser diff --git a/apps/cli/src/command-internal/argv-flag-reconcile.unit.test.ts b/apps/cli/src/command-internal/argv-flag-reconcile.unit.test.ts index 708227c6e6..99aa404eed 100644 --- a/apps/cli/src/command-internal/argv-flag-reconcile.unit.test.ts +++ b/apps/cli/src/command-internal/argv-flag-reconcile.unit.test.ts @@ -9,6 +9,7 @@ import { argvBoolValue, argvEnumValue, argvProfileValue, + argvRemoteValue, argvWorkdirValue, resolveArgvProfile, validateArgvWorkdir, @@ -25,6 +26,38 @@ const NAME_ID_FORMATS = [ const occ = (entries: ReadonlyArray<readonly [string, ReadonlyArray<string>]>) => new Map(entries.map(([name, values]) => [name, [...values]])); +describe("argvRemoteValue", () => { + it("is none when --remote never occurs", () => { + expect(argvRemoteValue(occ([]))).toEqual(Option.none()); + }); + + it('reads a bare occurrence (recorded as "true") as the linked project', () => { + expect(argvRemoteValue(occ([["remote", ["true"]]]))).toEqual(Option.some({ _tag: "Linked" })); + }); + + it("classifies a ref, a database URL and a branch name like the flag's parser", () => { + expect(argvRemoteValue(occ([["remote", ["abcdefghijklmnopqrst"]]]))).toEqual( + Option.some({ _tag: "Ref", ref: "abcdefghijklmnopqrst" }), + ); + expect(argvRemoteValue(occ([["remote", ["postgresql://u:p@h:5432/db"]]]))).toEqual( + Option.some({ _tag: "DbUrl", url: "postgresql://u:p@h:5432/db" }), + ); + expect(argvRemoteValue(occ([["remote", ["feature-x"]]]))).toEqual( + Option.some({ _tag: "Branch", name: "feature-x" }), + ); + }); + + it("resolves repeats last-wins", () => { + expect(argvRemoteValue(occ([["remote", ["feature-x", "true"]]]))).toEqual( + Option.some({ _tag: "Linked" }), + ); + }); + + it("is none for an off switch", () => { + expect(argvRemoteValue(occ([["remote", ["false"]]]))).toEqual(Option.none()); + }); +}); + describe("argvBoolValue", () => { it("is false when the flag never occurs", () => { expect(argvBoolValue(occ([]), "skip-url-validation")).toEqual(Result.succeed(false)); diff --git a/apps/cli/src/commands/functions/deploy/deploy.handler.ts b/apps/cli/src/commands/functions/deploy/deploy.handler.ts index ace488701b..ef898c0a00 100644 --- a/apps/cli/src/commands/functions/deploy/deploy.handler.ts +++ b/apps/cli/src/commands/functions/deploy/deploy.handler.ts @@ -38,10 +38,9 @@ export const functionsDeploy = Effect.fn("functions.deploy")(function* ( localConfigLoader: functionsLocalConfigLoader, rawArgs, edgeRuntimeVersion, - resolveProjectRef: () => - resolveProjectTarget(flags).pipe( - Effect.tap((ref) => Ref.set(resolvedProjectRef, Option.some(ref))), - ), + resolveProjectRef: resolveProjectTarget(flags).pipe( + Effect.tap((ref) => Ref.set(resolvedProjectRef, Option.some(ref))), + ), // Written to stdout, so the TTY color gate must check stdout. styleIdentifier: (text) => aqua(text, process.stdout), // Written to stderr, matching `bold`'s default TTY gate. 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 f5498c6878..a21914e55c 100644 --- a/apps/cli/src/commands/functions/deploy/deploy.integration.test.ts +++ b/apps/cli/src/commands/functions/deploy/deploy.integration.test.ts @@ -2453,7 +2453,7 @@ describe("functions deploy", () => { localConfigLoader: functionsLocalConfigLoader, rawArgs: ["functions", "deploy"], edgeRuntimeVersion: "1.69.12", - resolveProjectRef: () => Effect.succeed("abcdefghijklmnopqrst"), + resolveProjectRef: Effect.succeed("abcdefghijklmnopqrst"), styleEmphasis: (text) => `<sgr>${text}</sgr>`, }, ); @@ -3063,7 +3063,7 @@ describe("functions deploy", () => { localConfigLoader: functionsLocalConfigLoader, rawArgs: ["functions", "deploy", "hello-world", "--use-api=false"], edgeRuntimeVersion: "1.69.12", - resolveProjectRef: () => Effect.succeed("abcdefghijklmnopqrst"), + resolveProjectRef: Effect.succeed("abcdefghijklmnopqrst"), styleWarning: (text) => `<warn>${text}</warn>`, }, ); diff --git a/apps/cli/src/commands/sso/add/SIDE_EFFECTS.md b/apps/cli/src/commands/sso/add/SIDE_EFFECTS.md index 9980e10d35..681dd9cfbd 100644 --- a/apps/cli/src/commands/sso/add/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/sso/add/SIDE_EFFECTS.md @@ -97,7 +97,7 @@ Single `success` event with the parsed response as data. - `--type saml` is **required**. - `--metadata-file` and `--metadata-url` are mutually exclusive. Violations emit the exact template: `if any flags in the group [metadata-file metadata-url] are set none of the others can be; [metadata-file metadata-url] were all set`. "Set" means explicitly passed — an explicit empty value (`--metadata-file=`) still counts. -- Flag values are resolved by a raw-argv scan rather than the TS parser: every value the handler acts on (the deprecated `--project-ref` alias, `--metadata-file`, `--metadata-url`, `--attribute-mapping-file`, `--domains`, `--name-id-format`, `--skip-url-validation`) is reconciled against that scan. E.g. `--project-ref --metadata-file x.xml --metadata-url u` hands `--metadata-file` to `--project-ref` as its value, which is then looked up as a branch name and fails before any POST — the metadata file is never read (CLI-1982). `--remote` is read from the parsed flag. Repeated flags resolve last-wins (the TS parser is first-wins), and an occurrence with an invalid value — `--type` outside `[ saml ]`, a boolean outside the accepted spellings (`--skip-url-validation=yes`), or a `--name-id-format` outside the enum — fails with the `invalid argument …` message before every validation and request. +- Flag values are resolved by a raw-argv scan rather than the TS parser: every value the handler acts on (`--remote`, the deprecated `--project-ref` alias, `--metadata-file`, `--metadata-url`, `--attribute-mapping-file`, `--domains`, `--name-id-format`, `--skip-url-validation`) is reconciled against that scan. E.g. `--project-ref --metadata-file x.xml --metadata-url u` hands `--metadata-file` to `--project-ref` as its value, which is then looked up as a branch name and fails before any POST — the metadata file is never read (CLI-1982). A `--remote` token another flag consumed as its value selects nothing. Repeated flags resolve last-wins (the TS parser is first-wins), and an occurrence with an invalid value — `--type` outside `[ saml ]`, a boolean outside the accepted spellings (`--skip-url-validation=yes`), or a `--name-id-format` outside the enum — fails with the `invalid argument …` message before every validation and request. - Required-ness uses the same scan: when the `--type` token is itself consumed as another flag's value (`--domains --type saml`), the command fails with the exact `required flag(s) "type" not set` before any request (required-flags validation runs before flag-group validation). `-t` shorthand occurrences are recognised by the scan and never trip this. - The workdir uses the same scan: the workdir-change step chdir's to the effective `--workdir` (last occurrence, even a flag-shaped consumed token like `--workdir --metadata-file`) or `SUPABASE_WORKDIR`, and a missing directory aborts with the exact `failed to change workdir: chdir …` before the required-flag check, the mutex check, and any request. A changed-but-empty `--workdir=` shadows the env var and falls back to the always-valid project-root walk-up. - The profile uses the same scan: whenever the effective `--profile`/`SUPABASE_PROFILE` token differs from the one the Effect parser gave the config layer (a `--profile` token consumed by another flag — `--domains --profile alternate.yml` targets the env/default profile, not `alternate.yml`; a flag-shaped consumed value — `--profile --metadata-url`; repeats, which resolve last-wins; an explicit `--profile supabase` shadowing the env; an untrimmed/empty persisted `~/.supabase/profile` file), the handler re-runs the profile loader on the effective token (`profile-load.ts`) — the POST targets that profile's `api_url`, and a token that can't be loaded aborts with the matching error (`failed to read profile: …` / `failed to parse profile: …` / `invalid profile: …`, byte-exact for the deterministic classes) before the workdir check and any request. Where the scan and the parser agree — every normal invocation — the config layer's resolution (including its pre-existing lenient missing/malformed-file fallback, which predates CLI-1982 and applies shell-wide) is used unchanged. The upgrade-gate fallback GETs and the linked-project cache fill also target the reconciled host. diff --git a/apps/cli/src/commands/sso/add/add.handler.ts b/apps/cli/src/commands/sso/add/add.handler.ts index 84b43c6d63..831c4fc6ba 100644 --- a/apps/cli/src/commands/sso/add/add.handler.ts +++ b/apps/cli/src/commands/sso/add/add.handler.ts @@ -29,6 +29,7 @@ import { gateResponse, suggestUpgrade } from "../../../command-internal/upgrade- import { argvBoolValue, argvEnumValue, + argvRemoteValue, argvSliceValue, argvStringValue, resolveArgvProfile, @@ -229,12 +230,13 @@ export const ssoAdd = Effect.fn("sso.add")(function* (flags: SsoAddFlags) { // than the TS-parsed flags, since the scan consumes flag-shaped tokens as // values where the parser doesn't. const projectRef = argvStringValue(occurrences, "project-ref"); + const remote = argvRemoteValue(occurrences); const metadataFile = argvStringValue(occurrences, "metadata-file"); const metadataUrl = argvStringValue(occurrences, "metadata-url"); const attributeMappingFile = argvStringValue(occurrences, "attribute-mapping-file"); const domains = argvSliceValue(occurrences, "domains", flags.domains); - const ref = yield* resolveProjectTarget({ remote: flags.remote, projectRef }); + const ref = yield* resolveProjectTarget({ remote, projectRef }); // Use the argv-reconciled profile's host when it disagreed with // `--profile`, otherwise the config layer's — applies to the POST and 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 bbdbb03318..7ccaac7443 100644 --- a/apps/cli/src/commands/sso/add/add.integration.test.ts +++ b/apps/cli/src/commands/sso/add/add.integration.test.ts @@ -39,6 +39,8 @@ import { classifyCliCauseActionability } from "../../../shared/telemetry/error-a import type { SsoAddFlags } from "./add.command.ts"; import { ssoAdd } from "./add.handler.ts"; +const OTHER_REF = "qrstuvwxyzabcdefghij"; + const RESPONSE_PROVIDER = { id: "b5ae62f9-ef1d-4f11-a02b-731c8bbb11e8", saml: { @@ -247,6 +249,33 @@ describe("sso add integration", () => { }).pipe(Effect.provide(layer)); }); + it.effect("selects the project from a --remote the argv scan sees", () => { + const { layer, api } = setup({ + cliArgs: ["sso", "add", "--type", "saml", `--remote=${OTHER_REF}`], + }); + return Effect.gen(function* () { + yield* ssoAdd(defaultFlags); + expect(api.requests.find((r) => r.method === "POST")?.url).toContain( + `/v1/projects/${OTHER_REF}/config/auth/sso/providers`, + ); + }).pipe(Effect.provide(layer)); + }); + + it.effect( + "ignores a --remote token a bare --domains consumed as its value, as it does for --project-ref", + () => { + const { layer, api } = setup({ + cliArgs: ["sso", "add", "--type", "saml", "--domains", `--remote=${OTHER_REF}`], + }); + return Effect.gen(function* () { + yield* ssoAdd({ ...defaultFlags, remote: Option.some({ _tag: "Ref", ref: OTHER_REF }) }); + expect(api.requests.find((r) => r.method === "POST")?.url).toContain( + `/v1/projects/${VALID_REF}/config/auth/sso/providers`, + ); + }).pipe(Effect.provide(layer)); + }, + ); + it.effect("lists the projects for the picker and fills the linked-project cache", () => { const out = mockOutput({ promptSelectResponses: [VALID_REF] }); const api = mockCommandPlatformApi({ @@ -1338,7 +1367,11 @@ describeHostedConsent("sso add", { api: { response: { status: 201, body: RESPONSE_PROVIDER } }, invoke: () => ({ run: ssoAdd({ ...defaultFlags, remote: Option.some({ _tag: "Ref", ref: VALID_REF }) }).pipe( - Effect.provide(Stdio.layerTest({ args: Effect.succeed(["sso", "add", "--type", "saml"]) })), + Effect.provide( + Stdio.layerTest({ + args: Effect.succeed(["sso", "add", "--type", "saml", `--remote=${VALID_REF}`]), + }), + ), ), }), }); diff --git a/apps/cli/src/commands/sso/update/SIDE_EFFECTS.md b/apps/cli/src/commands/sso/update/SIDE_EFFECTS.md index f85c2fed7c..6e11f3134a 100644 --- a/apps/cli/src/commands/sso/update/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/sso/update/SIDE_EFFECTS.md @@ -101,7 +101,7 @@ Single `success` event with the parsed response as data. - `--domains` is mutually exclusive with `--add-domains` and `--remove-domains`. - `--metadata-file` and `--metadata-url` are mutually exclusive. -- Flag values are resolved by a raw-argv scan rather than the TS parser: every value the handler acts on (the deprecated `--project-ref` alias, `--metadata-file`, `--metadata-url`, `--attribute-mapping-file`, the three domain slices, `--name-id-format`, `--skip-url-validation`) is reconciled against that scan — same mechanism as `sso add`; `--remote` is read from the parsed flag. Repeated flags resolve last-wins (the TS parser is first-wins), and an occurrence with an invalid value — a boolean outside the accepted spellings (`--skip-url-validation=yes`), or a `--name-id-format` outside the enum — fails with the `invalid argument …` message before any validation or request. +- Flag values are resolved by a raw-argv scan rather than the TS parser: every value the handler acts on (`--remote`, the deprecated `--project-ref` alias, `--metadata-file`, `--metadata-url`, `--attribute-mapping-file`, the three domain slices, `--name-id-format`, `--skip-url-validation`) is reconciled against that scan — same mechanism as `sso add`, so a `--remote` token another flag consumed as its value selects nothing. Repeated flags resolve last-wins (the TS parser is first-wins), and an occurrence with an invalid value — a boolean outside the accepted spellings (`--skip-url-validation=yes`), or a `--name-id-format` outside the enum — fails with the `invalid argument …` message before any validation or request. - Positional arity uses the same scan: the exact-one-argument rule is re-counted over the effective positionals (arity validation runs before every hook and flag validation), so a consumed flag token that orphans its parser-value (`--domains --metadata-url u <id>`) fails with the exact `accepts 1 arg(s), received 2` before the GET — and wins over both the mutex and invalid-UUID errors. - The workdir uses the same scan: the workdir-change step chdir's to the effective `--workdir` (last occurrence, even a flag-shaped consumed token like `--workdir --metadata-file`) or `SUPABASE_WORKDIR`, and a missing directory aborts with the exact `failed to change workdir: chdir …` after the arity check but before the mutex checks and any request. A changed-but-empty `--workdir=` shadows the env var and falls back to the always-valid project-root walk-up. - The profile uses the same scan: whenever the effective `--profile`/`SUPABASE_PROFILE` token differs from the one the Effect parser gave the config layer (repeats — the scan is last-wins where the parser is first-wins, so `--profile a.yml --profile b.yml` GETs and PUTs `b.yml`'s `api_url`; a flag-shaped consumed value — `--profile --add-domains`; an explicit `--profile supabase` shadowing the env; an untrimmed/empty persisted `~/.supabase/profile` file), the handler re-runs the profile loader on the effective token (`profile-load.ts`). Both the initial GET and the PUT then target that profile's `api_url` (a per-request base URL and token on the typed client), and a token that can't be loaded aborts with the matching error (`failed to read profile: …` / `failed to parse profile: …` / `invalid profile: …`, byte-exact for the deterministic classes) after the arity check, before the workdir and mutex checks and any request. The upgrade-gate fallback GETs and the linked-project cache fill also target the reconciled host. Where the scan and the parser agree — every normal invocation — the config layer's resolution (including its pre-existing lenient missing/malformed-file fallback, which predates CLI-1982 and applies shell-wide) is used unchanged. diff --git a/apps/cli/src/commands/sso/update/update.handler.ts b/apps/cli/src/commands/sso/update/update.handler.ts index 72b89e022e..85c4103a5c 100644 --- a/apps/cli/src/commands/sso/update/update.handler.ts +++ b/apps/cli/src/commands/sso/update/update.handler.ts @@ -30,6 +30,7 @@ import { gateResponse, suggestUpgrade } from "../../../command-internal/upgrade- import { argvBoolValue, argvEnumValue, + argvRemoteValue, argvSliceValue, argvStringValue, resolveArgvProfile, @@ -296,6 +297,7 @@ export const ssoUpdate = Effect.fn("sso.update")(function* (flags: SsoUpdateFlag // Everything below reads values from the scan rather // than the TS-parsed flags — see `add.handler.ts` and `argv-flag-reconcile.ts`. const projectRefFlag = argvStringValue(occurrences, "project-ref"); + const remote = argvRemoteValue(occurrences); const metadataFile = argvStringValue(occurrences, "metadata-file"); const metadataUrl = argvStringValue(occurrences, "metadata-url"); const attributeMappingFile = argvStringValue(occurrences, "attribute-mapping-file"); @@ -305,7 +307,7 @@ export const ssoUpdate = Effect.fn("sso.update")(function* (flags: SsoUpdateFlag const providerId = yield* Effect.fromResult(validateUuid(flags.providerId)); - const ref = yield* resolveProjectTarget({ remote: flags.remote, projectRef: projectRefFlag }); + const ref = yield* resolveProjectTarget({ remote, projectRef: projectRefFlag }); // Use the argv-reconciled profile's host when it disagreed with // `--profile`, otherwise the config layer's. 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 2971500cef..bef94810c2 100644 --- a/apps/cli/src/commands/sso/update/update.integration.test.ts +++ b/apps/cli/src/commands/sso/update/update.integration.test.ts @@ -64,6 +64,13 @@ const decodeJson = Schema.decodeEffect(Schema.fromJsonString(Schema.Unknown)); const tempRoot = useTempWorkdir("supabase-sso-update-int-"); +const OTHER_REF = "qrstuvwxyzabcdefghij"; + +const providerRequestUrls = (requests: ReadonlyArray<{ readonly url: string }>) => + requests + .map((request) => request.url) + .filter((url) => url.includes("/config/auth/sso/providers/")); + interface SetupOpts { format?: "text" | "json" | "stream-json"; outputFlag?: "env" | "pretty" | "json" | "toml" | "yaml"; @@ -577,6 +584,38 @@ describe("sso update integration", () => { }, ); + it.effect("selects the project from a --remote the argv scan sees", () => { + const { layer, api } = setup({ + cliArgs: ["sso", "update", VALID_PROVIDER_ID, `--remote=${OTHER_REF}`], + }); + return Effect.gen(function* () { + yield* ssoUpdate(defaultFlags); + expect(providerRequestUrls(api.requests)).toEqual([ + expect.stringContaining(`/v1/projects/${OTHER_REF}/`), + expect.stringContaining(`/v1/projects/${OTHER_REF}/`), + ]); + }).pipe(Effect.provide(layer)); + }); + + it.effect( + "ignores a --remote token a bare --domains consumed as its value, as it does for --project-ref", + () => { + const { layer, api } = setup({ + cliArgs: ["sso", "update", VALID_PROVIDER_ID, "--domains", `--remote=${OTHER_REF}`], + }); + return Effect.gen(function* () { + yield* ssoUpdate({ + ...defaultFlags, + remote: Option.some({ _tag: "Ref", ref: OTHER_REF }), + }); + expect(providerRequestUrls(api.requests)).toEqual([ + expect.stringContaining(`/v1/projects/${VALID_REF}/`), + expect.stringContaining(`/v1/projects/${VALID_REF}/`), + ]); + }).pipe(Effect.provide(layer)); + }, + ); + it.effect( "arity emulation: project-ref consuming --metadata-file orphans x.xml — fails the arity check, no API calls", () => { @@ -1691,7 +1730,9 @@ describeHostedConsent("sso update", { remote: Option.some({ _tag: "Ref", ref: VALID_REF }), }).pipe( Effect.provide( - Stdio.layerTest({ args: Effect.succeed(["sso", "update", VALID_PROVIDER_ID]) }), + Stdio.layerTest({ + args: Effect.succeed(["sso", "update", VALID_PROVIDER_ID, `--remote=${VALID_REF}`]), + }), ), ), }), diff --git a/apps/cli/src/shared/functions/deploy.ts b/apps/cli/src/shared/functions/deploy.ts index bb384dcaba..12401d50d2 100644 --- a/apps/cli/src/shared/functions/deploy.ts +++ b/apps/cli/src/shared/functions/deploy.ts @@ -107,7 +107,7 @@ interface DeployFunctionsDependencies<ResolveError, ResolveRequirements> { readonly localConfigLoader: FunctionsLocalConfigLoader; readonly rawArgs: ReadonlyArray<string>; readonly edgeRuntimeVersion: string; - readonly resolveProjectRef: () => Effect.Effect<string, ResolveError, ResolveRequirements>; + readonly resolveProjectRef: Effect.Effect<string, ResolveError, ResolveRequirements>; /** * Optional shell-specific styling hooks. All default to identity (plain text); keeping them * injected keeps this shared module free of CLI-specific rendering. @@ -2507,7 +2507,7 @@ export const deployFunctions = Effect.fn("functions.deploy")(function* < }); } - const projectRef = yield* dependencies.resolveProjectRef(); + const projectRef = yield* dependencies.resolveProjectRef; // `@supabase/config` merges the matching `[remotes.*]` block over the base config, so this // already reflects any remote function/edge_runtime overrides, through the same // `Config.Validate`/dotenv/env-override pipeline `start`/`stop`/`status` use (see From 416a9856cf6b6707164f79c2a708dd46aa5d9786 Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Mon, 19 Oct 2026 15:12:08 +0100 Subject: [PATCH 153/291] docs(cli): note the by-UUID branch lookup on the branch lookup errors --- .../src/command-internal/target/branch-resolution.errors.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/cli/src/command-internal/target/branch-resolution.errors.ts b/apps/cli/src/command-internal/target/branch-resolution.errors.ts index fcc5c85a91..14bb46ea4c 100644 --- a/apps/cli/src/command-internal/target/branch-resolution.errors.ts +++ b/apps/cli/src/command-internal/target/branch-resolution.errors.ts @@ -54,7 +54,7 @@ export class LinkBranchNotReadyError extends Data.TaggedError("LinkBranchNotRead } } -/** Transport (or response-decode) failure while listing branches for a branch-name lookup. */ +/** Transport (or response-decode) failure while listing branches, or fetching one by UUID, for a branch lookup. */ export class LinkBranchListNetworkError extends Data.TaggedError("LinkBranchListNetworkError")<{ readonly message: string; readonly decode?: boolean; @@ -66,7 +66,7 @@ export class LinkBranchListNetworkError extends Data.TaggedError("LinkBranchList } } -/** `GET /v1/projects/{ref}/branches` returned a non-200 status during a branch-name lookup. */ +/** `GET /v1/projects/{ref}/branches` or `GET /v1/branches/{id}` returned a non-200 status during a branch lookup. */ export class LinkBranchListStatusError extends Data.TaggedError("LinkBranchListStatusError")<{ readonly status: number; readonly body: string; From 7580520f6dc0623d0bf64a8de8e620fd1d8b9c71 Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Mon, 19 Oct 2026 15:12:32 +0100 Subject: [PATCH 154/291] chore(cli): trim comments in remote-vocabulary --- apps/cli/src/command-internal/target/remote-vocabulary.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/apps/cli/src/command-internal/target/remote-vocabulary.ts b/apps/cli/src/command-internal/target/remote-vocabulary.ts index c6a6866de3..8239eb9e9f 100644 --- a/apps/cli/src/command-internal/target/remote-vocabulary.ts +++ b/apps/cli/src/command-internal/target/remote-vocabulary.ts @@ -1,5 +1,3 @@ -/** What `--remote` values look like, shared by the argv rewrite, the value parser and the permission evaluators. */ - export const REMOTE_FLAG_NAME = "remote"; export const DB_URL_PATTERN = /^postgres(ql)?:\/\//; @@ -10,7 +8,6 @@ export const LINKED_LITERAL = /^(true|yes|on|1)$/i; /** The spellings of an off switch, in any case. */ export const OFF_LITERAL = /^(false|no|off|0)$/i; -/** Whether a word is a boolean literal in either direction, in any case. */ export const isBooleanLiteral = (word: string): boolean => LINKED_LITERAL.test(word) || OFF_LITERAL.test(word); From 0de53fdc30a880cec3aebcd75ce895f638963373 Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Mon, 19 Oct 2026 15:18:06 +0100 Subject: [PATCH 155/291] fix(cli): share the storage permissions across command boundaries Moves the declaration into command-internal so seed buckets no longer imports another command. The drift helper test now also covers --project-ref given as a boolean. --- .../command-internal/storage-permissions.ts | 15 +++++++++++++++ .../commands/seed/buckets/buckets.command.ts | 4 ++-- .../cli/src/commands/storage/storage.flags.ts | 19 ++----------------- .../helpers/permission-drift.unit.test.ts | 19 +++++++++++++++++-- 4 files changed, 36 insertions(+), 21 deletions(-) create mode 100644 apps/cli/src/command-internal/storage-permissions.ts diff --git a/apps/cli/src/command-internal/storage-permissions.ts b/apps/cli/src/command-internal/storage-permissions.ts new file mode 100644 index 0000000000..dd611f7cd7 --- /dev/null +++ b/apps/cli/src/command-internal/storage-permissions.ts @@ -0,0 +1,15 @@ +import { linkedProjectCache } from "../telemetry/linked-project-cache.layer.ts"; +import { compose, withCondition, withDbTarget } from "./command-permissions/model.ts"; +import { storageCredentials } from "./storage-credentials.ts"; +import { branchResolution } from "./target/branch-resolution.ts"; + +/** The Management API calls a Storage-API command makes on the linked project. */ +export const storagePermissions = (noApiEffectFlags: ReadonlyArray<string>) => + withDbTarget( + compose( + { operations: [], noApiEffectFlags }, + withCondition(compose(storageCredentials, linkedProjectCache), { dbTarget: ["linked"] }), + branchResolution, + ), + { default: "local" }, + ); diff --git a/apps/cli/src/commands/seed/buckets/buckets.command.ts b/apps/cli/src/commands/seed/buckets/buckets.command.ts index d5a90dff68..6b12a693c4 100644 --- a/apps/cli/src/commands/seed/buckets/buckets.command.ts +++ b/apps/cli/src/commands/seed/buckets/buckets.command.ts @@ -11,8 +11,8 @@ import { 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 { storagePermissions } from "../../../command-internal/storage-permissions.ts"; import { storageGatewayRuntimeLayer } from "../../../command-internal/storage-runtime.layer.ts"; -import { STORAGE_PERMISSIONS } from "../../storage/storage.flags.ts"; import { seedBuckets } from "./buckets.handler.ts"; import { withTargetPolicy } from "../../../command-internal/target/target-policy.ts"; @@ -21,7 +21,7 @@ export type BucketsFlags = TargetFlagValuesWithoutDbUrl; export const bucketsCommand = Command.make("buckets").pipe( Command.withDescription("Seed buckets declared in [storage.buckets]."), Command.withShortDescription("Seed buckets declared in [storage.buckets]"), - withPermissions(STORAGE_PERMISSIONS), + withPermissions(storagePermissions([])), Command.withHandler(() => Effect.gen(function* () { const flags: BucketsFlags = yield* readSeedTargetFlags(); diff --git a/apps/cli/src/commands/storage/storage.flags.ts b/apps/cli/src/commands/storage/storage.flags.ts index 6321acfb5b..c91e3e8352 100644 --- a/apps/cli/src/commands/storage/storage.flags.ts +++ b/apps/cli/src/commands/storage/storage.flags.ts @@ -1,21 +1,6 @@ -import { - compose, - withCondition, - withDbTarget, -} from "../../command-internal/command-permissions/model.ts"; -import { storageCredentials } from "../../command-internal/storage-credentials.ts"; -import { branchResolution } from "../../command-internal/target/branch-resolution.ts"; +import { storagePermissions } from "../../command-internal/storage-permissions.ts"; import { targetFlags } from "../../command-internal/target/target-flags.ts"; -import { linkedProjectCache } from "../../telemetry/linked-project-cache.layer.ts"; export const storageTargetFlags = targetFlags({ subject: "Storage API", dbUrl: false }); -/** The Management API calls every `storage` subcommand makes on the linked project. */ -export const STORAGE_PERMISSIONS = withDbTarget( - compose( - { operations: [], noApiEffectFlags: ["recursive"] }, - withCondition(compose(storageCredentials, linkedProjectCache), { dbTarget: ["linked"] }), - branchResolution, - ), - { default: "local" }, -); +export const STORAGE_PERMISSIONS = storagePermissions(["recursive"]); diff --git a/apps/cli/tests/helpers/permission-drift.unit.test.ts b/apps/cli/tests/helpers/permission-drift.unit.test.ts index 29b6d11b3f..9469dc4ccb 100644 --- a/apps/cli/tests/helpers/permission-drift.unit.test.ts +++ b/apps/cli/tests/helpers/permission-drift.unit.test.ts @@ -222,16 +222,31 @@ describe("assertDriftAgainst", () => { }); it("classifies every flag that can carry the selector", () => { - for (const flag of ["remote", "linked", "project-ref", "project-id", "db-url", "local"]) { + const carriers = { remote: true, linked: true, "db-url": true, local: true }; + for (const [flag, value] of Object.entries({ + ...carriers, + "project-ref": REF, + "project-id": REF, + })) { expect(() => assertDriftAgainst(branchLookup, { command: "secrets list", - activeFlags: { [flag]: true }, + activeFlags: { [flag]: value }, requests: [], }), ).not.toThrow(); } }); + + it("rejects --project-ref given as a boolean, since its value picks the entries that apply", () => { + expect(() => + assertDriftAgainst(branchLookup, { + command: "secrets list", + activeFlags: { "project-ref": true }, + requests: [], + }), + ).toThrow(/pass the value/); + }); }); describe("request qualifiers", () => { From 69e34784600ed9ce80df551d8eb9f08c133ef7e2 Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Mon, 19 Oct 2026 15:18:50 +0100 Subject: [PATCH 156/291] chore(cli): drop an unused import --- apps/cli/src/command-internal/test-db.command-handler.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 9295a2a21c..db215f8a14 100644 --- a/apps/cli/src/command-internal/test-db.command-handler.ts +++ b/apps/cli/src/command-internal/test-db.command-handler.ts @@ -1,4 +1,4 @@ -import { Effect, Option } from "effect"; +import { Effect } from "effect"; import { Argument } from "effect/cli"; import type * as CliCommand from "effect/cli/Command"; From 6aff9a38bb0bdcc868576095df84733e7ae5ab70 Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Mon, 19 Oct 2026 15:35:18 +0100 Subject: [PATCH 157/291] fix(cli): carry the target through follow-up hints and tighten db diff sides - repair, down, squash and the removed `db remote commit` hint name the target's flags so the suggested command reaches the same database - `db diff` rejects a target selector combined with --from/--to and documents that `remote` there always means the linked project - `db diff --from/--to=<ref|branch>` declares the linked-database permissions (login role, pooler config, network bans) - the IPv6 error after an ad-hoc --remote=<ref|branch> points at the pooler instead of suggesting `supabase link` - the `migration fetch` overwrite prompt names the target --- .../db-config.integration.test.ts | 49 +++ .../src/command-internal/db-config.layer.ts | 48 ++- .../hosted-consent/hosted-consent.ts | 3 +- .../command-internal/migration-fetch-run.ts | 8 +- .../src/command-internal/migration-history.ts | 2 +- apps/cli/src/commands/db/diff/SIDE_EFFECTS.md | 21 +- apps/cli/src/commands/db/diff/diff.command.ts | 22 +- apps/cli/src/commands/db/diff/diff.errors.ts | 4 +- apps/cli/src/commands/db/diff/diff.handler.ts | 53 ++-- .../commands/db/diff/diff.integration.test.ts | 286 +++++------------- .../commands/db/remote/commit/SIDE_EFFECTS.md | 2 +- .../db/remote/commit/commit.command.ts | 6 +- .../commands/migration/down/SIDE_EFFECTS.md | 3 +- .../commands/migration/down/down.handler.ts | 8 +- .../migration/down/down.integration.test.ts | 18 ++ .../commands/migration/fetch/SIDE_EFFECTS.md | 4 +- .../migration/fetch/fetch.integration.test.ts | 39 +++ .../commands/migration/repair/SIDE_EFFECTS.md | 3 +- .../migration/repair/repair.handler.ts | 8 +- .../repair/repair.integration.test.ts | 17 ++ .../commands/migration/squash/SIDE_EFFECTS.md | 2 +- .../migration/squash/squash.e2e.test.ts | 2 +- .../migration/squash/squash.handler.ts | 2 +- .../squash/squash.integration.test.ts | 4 +- .../src/shared/cli/hidden-flag.unit.test.ts | 4 +- 25 files changed, 332 insertions(+), 286 deletions(-) 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 78658534c0..75ef64bcee 100644 --- a/apps/cli/src/command-internal/db-config.integration.test.ts +++ b/apps/cli/src/command-internal/db-config.integration.test.ts @@ -1428,6 +1428,55 @@ describe("dbConfigResolver (an explicit --project-ref fetches the pooler config) }, ); + it.effect( + "an ad-hoc --remote=<ref> with no pooler configured suggests the pooler URL, not re-linking", + () => { + const ref = "adhocnopoolerrefabcd"; + const previousFetch = globalThis.fetch; + const dbConnection = Layer.succeed(DbConnection, { + connect: () => Effect.die("unexpected connect()"), + }); + globalThis.fetch = Object.assign( + (): Promise<Response> => + Promise.resolve( + new Response("[]", { status: 200, headers: { "content-type": "application/json" } }), + ), + { preconnect: previousFetch.preconnect }, + ); + + return Effect.gen(function* () { + const dir = yield* withWorkdir(); + return yield* resolveConnection( + dir, + inputFor(explicitRefTarget(ref, { matchesLinkedRef: false })), + { + projectHost: "invalid", + dbConnection, + configEnv: { SUPABASE_ACCESS_TOKEN: VALID_TOKEN }, + }, + ); + }).pipe( + Effect.flip, + Effect.tap((error) => + Effect.sync(() => { + expect(error).toMatchObject({ + _tag: "DbConfigIpv6Error", + suggestion: + "Pass the project's connection pooler URL with --remote=<pooler db-url> to connect over IPv4.", + }); + }), + ), + 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", () => { diff --git a/apps/cli/src/command-internal/db-config.layer.ts b/apps/cli/src/command-internal/db-config.layer.ts index cc77bd65b9..624542ed30 100644 --- a/apps/cli/src/command-internal/db-config.layer.ts +++ b/apps/cli/src/command-internal/db-config.layer.ts @@ -50,21 +50,22 @@ import { renderDbUrlTarget, renderLocalTarget } from "./target/target-identity.t import { isCheckoutLinked } from "../shared/config/temp-paths.ts"; import { rejectTargetPassword } from "./target/target-password.ts"; -const linkedEntries: ReadonlyArray<Omit<OperationEntry, "source">> = [ - { - operationId: "v1-create-login-role", - kind: "required", - when: [{ flag: "password", present: false }], - context: - "no database password supplied by the command (an empty one still mints a login role) or set in SUPABASE_DB_PASSWORD or the project .env (an ad-hoc project ref ignores the last two)", - }, - { - operationId: "v1-get-pooler-config", - kind: "required", - when: [{ selector: ["ref", "branch"] }], - context: - "a project ref or branch names the target, the direct database host is unreachable (IPv4-only network) and there is no usable saved supabase/.temp/pooler-url", - }, +const loginRoleEntry = { + operationId: "v1-create-login-role", + kind: "required", + when: [{ flag: "password", present: false }], + context: + "no database password supplied by the command (an empty one still mints a login role) or set in SUPABASE_DB_PASSWORD or the project .env (an ad-hoc project ref ignores the last two)", +} as const satisfies Omit<OperationEntry, "source">; + +const poolerConfigEntry = { + operationId: "v1-get-pooler-config", + kind: "required", + context: + "a project ref or branch names the target, the direct database host is unreachable (IPv4-only network) and there is no usable saved supabase/.temp/pooler-url", +} as const satisfies Omit<OperationEntry, "source">; + +const networkBanEntries = [ { operationId: "v1-list-all-network-bans", kind: "best-effort", @@ -79,6 +80,12 @@ const linkedEntries: ReadonlyArray<Omit<OperationEntry, "source">> = [ context: "after three failed attempts to connect through the pooler as the temporary login role", }, +] as const satisfies ReadonlyArray<Omit<OperationEntry, "source">>; + +const linkedEntries: ReadonlyArray<Omit<OperationEntry, "source">> = [ + loginRoleEntry, + { ...poolerConfigEntry, when: [{ selector: ["ref", "branch"] }] }, + ...networkBanEntries, ...projectRefPicker.operations, ]; @@ -88,6 +95,13 @@ const linkedEntries: ReadonlyArray<Omit<OperationEntry, "source">> = [ */ export const dbConfigLinked = block("db-config", linkedEntries); +/** The calls {@link dbConfigLinked} makes for a project a non-selector flag names, such as `db diff --from=<ref>`. */ +export const dbConfigNamedProject = block("db-config", [ + loginRoleEntry, + poolerConfigEntry, + ...networkBanEntries, +]); + /** * `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 @@ -565,7 +579,9 @@ export const resolveLinkedConn = Effect.fn("DbConfig.resolveLinkedConn")(functio if (Option.isNone(poolerConn)) { return yield* new Errors.DbConfigIpv6Error({ message: "IPv6 is not supported on your current network", - suggestion: `Run supabase link --project-ref ${ref} to setup IPv4 connection.`, + suggestion: adHocProjectRef + ? "Pass the project's connection pooler URL with --remote=<pooler db-url> to connect over IPv4." + : `Run supabase link --project-ref ${ref} to setup IPv4 connection.`, }); } return poolerConn.value; 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 ffc62f0863..f29e2e5a83 100644 --- a/apps/cli/src/command-internal/hosted-consent/hosted-consent.ts +++ b/apps/cli/src/command-internal/hosted-consent/hosted-consent.ts @@ -30,7 +30,8 @@ export interface HostedMutationRequest { const grant = (action: string, via: HostedMutationConsent["via"]): HostedMutationConsent => ({ action, via }) as HostedMutationConsent; -const announcedTarget = Effect.serviceOption(CommandTarget).pipe( +/** The first announced target's banner text, or `None` before any announcement. */ +export const announcedTarget = Effect.serviceOption(CommandTarget).pipe( Effect.flatMap( Option.match({ onNone: () => Effect.succeed(Option.none<string>()), diff --git a/apps/cli/src/command-internal/migration-fetch-run.ts b/apps/cli/src/command-internal/migration-fetch-run.ts index 6d948b0c7f..45bf6ec894 100644 --- a/apps/cli/src/command-internal/migration-fetch-run.ts +++ b/apps/cli/src/command-internal/migration-fetch-run.ts @@ -8,7 +8,7 @@ import { 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 { hostedMutationPreapproval } from "./hosted-consent/hosted-consent.ts"; +import { announcedTarget, 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"; @@ -77,7 +77,11 @@ export const runMigrationFetch = Effect.fn("MigrationFetch.run")(function* ( ), ); if (existing.length > 0) { - const title = `Do you want to overwrite existing files in ${bold("supabase/migrations")} directory?`; + const source = Option.match(yield* announcedTarget, { + onNone: () => "the target database", + onSome: bold, + }); + const title = `Overwrite local migrations with the history from ${source}?`; const overwrite = assumeYes !== undefined ? assumeYes diff --git a/apps/cli/src/command-internal/migration-history.ts b/apps/cli/src/command-internal/migration-history.ts index 6d9ec9bdd1..c815a079c1 100644 --- a/apps/cli/src/command-internal/migration-history.ts +++ b/apps/cli/src/command-internal/migration-history.ts @@ -225,7 +225,7 @@ export function reconcileMigrations( return { kind: "in-sync" }; } -const withTargetFlags = (command: string, targetFlags: string): string => +export const withTargetFlags = (command: string, targetFlags: string): string => targetFlags === "" ? command : `${command} ${targetFlags}`; /** `targetFlags` are the flags that select the resolved target (see `flagsForTarget`), appended last. */ diff --git a/apps/cli/src/commands/db/diff/SIDE_EFFECTS.md b/apps/cli/src/commands/db/diff/SIDE_EFFECTS.md index 765c2057c2..59cbafacb9 100644 --- a/apps/cli/src/commands/db/diff/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/db/diff/SIDE_EFFECTS.md @@ -99,8 +99,8 @@ it, and JSON `null` disables formatting without disabling safe compaction. | Method | Path | 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}/cli/login-role` | Bearer | Temp login role when no password; also for a `--from`/`--to` value naming a project ref or branch | +| GET | `/v1/projects/{ref}/config/database/pooler` | Bearer | IPv4 pooler fallback; also for a `--from`/`--to` value naming a project ref or branch | | 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) | @@ -149,7 +149,7 @@ migra/pg-delta-engine-specific). | 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` | `--from` without `--to`; `--remote` or an alias combined with `--from`/`--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 `--remote`/`--linked`/`--db-url`/`--local` (`TargetSelectorConflictError`); `--project-ref` with `--local`, `--db-url` or `--remote=<value>` (`TargetProjectRefConflictError`); `--remote=<branch>`, or a `--from`/`--to` branch, naming no branch of the parent project | | `1` | `--use-pg-schema` passed at all (any value, including `=false`) — removed, see Notes | @@ -252,12 +252,15 @@ transaction metadata. - **Explicit `--from`/`--to`.** Each endpoint is one of `local`, `migrations`, `remote` (the linked project), a project ref, a Postgres URL, or a branch name or UUID of the linked project (classified like a `--remote` value, so a branch named like a keyword is reached by its ref or - UUID). `--from linked` / `--to linked` still work and warn `--from linked is deprecated and will -be removed in v4. Use --from remote instead.` A `--remote` (or alias) selector given - alongside only validates the selection and merges the linked project's `[remotes.<ref>]` - override; the endpoints themselves come from the two values, and with no selector the default - `Target:` hint is not printed. The linked project resolves once, so `--from remote --to remote` - announces it once, and a branch or ref used on both sides shares one connection. A branch + UUID). In `--from`/`--to`, `remote` is always the linked project. `--from linked` / `--to linked` + still work and warn `--from linked is deprecated and will +be removed in v4. Use --from remote instead.` A `--remote` selector (or an alias: `--linked`, + `--project-ref`, `--db-url`, `--local`) given alongside fails with `--remote doesn't apply with +--from/--to; use --from=remote or --to=remote.` (the flag named is the one passed; `--db-url` + suggests `<db-url>` and `--local` suggests `local`), before any target resolves, and the default + `Target:` hint is not printed. The first hosted endpoint merges its `[remotes.<ref>]` override + into the config the later endpoints see. The linked project resolves once, so `--from remote +--to remote` announces it once, and a branch or ref used on both sides shares one connection. A branch endpoint looks the name up in the branch list of `SUPABASE_PROJECT_ID`, else the linked project. `--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 diff --git a/apps/cli/src/commands/db/diff/diff.command.ts b/apps/cli/src/commands/db/diff/diff.command.ts index c2785952ab..3beca2e430 100644 --- a/apps/cli/src/commands/db/diff/diff.command.ts +++ b/apps/cli/src/commands/db/diff/diff.command.ts @@ -5,9 +5,14 @@ import { withPermissions } from "../../../command-internal/command-permissions/c import { compose, withCondition, + withContext, withDbTarget, } from "../../../command-internal/command-permissions/model.ts"; -import { dbConfig, dbConfigLinked } from "../../../command-internal/db-config.layer.ts"; +import { + dbConfig, + dbConfigLinked, + dbConfigNamedProject, +} 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"; @@ -51,13 +56,13 @@ const config = { ), from: Flag.String("from").pipe( Flag.withDescription( - "Diff from local, migrations, remote, a project ref, a branch, or a Postgres URL.", + "Diff from local, migrations, remote (always the linked project), a project ref, a branch, or a Postgres URL.", ), Flag.optional, ), to: Flag.String("to").pipe( Flag.withDescription( - "Diff to local, migrations, remote, a project ref, a branch, or a Postgres URL.", + "Diff to local, migrations, remote (always the linked project), a project ref, a branch, or a Postgres URL.", ), Flag.optional, ), @@ -91,10 +96,17 @@ const config = { export type DbDiffFlags = CliCommand.Command.Config.Infer<typeof config>; -// An explicit `--from`/`--to` of `remote` or the deprecated `linked` resolves the linked project without `--remote`. A project ref, branch or URL side is not modelled: `oneOf` only matches fixed values. +// An explicit `--from`/`--to` of `remote` or the deprecated `linked` resolves the linked project without `--remote`. const explicitLinkedSide = (flag: "from" | "to") => withCondition(compose(dbConfigLinked, linkedProjectCache), { flag, oneOf: ["linked", "remote"] }); +// A condition can't tell a ref or branch value from `local`, `migrations` or a URL, so any value declares the block. +const explicitNamedProjectSide = (flag: "from" | "to") => + withContext( + withCondition(compose(dbConfigNamedProject, linkedProjectCache), { flag, present: true }), + `--${flag} is a project ref or branch name, not local, migrations, remote or a URL`, + ); + const explicitBranchSide = (flag: "from" | "to") => ({ operationId: "v1-list-all-branches", @@ -128,6 +140,8 @@ export const dbDiffCommand = Command.make("diff", config).pipe( withCondition(linkedProjectCache, { dbTarget: ["linked"] }), explicitLinkedSide("from"), explicitLinkedSide("to"), + explicitNamedProjectSide("from"), + explicitNamedProjectSide("to"), branchResolution, ), { default: "local" }, diff --git a/apps/cli/src/commands/db/diff/diff.errors.ts b/apps/cli/src/commands/db/diff/diff.errors.ts index 507a861337..b1fb373357 100644 --- a/apps/cli/src/commands/db/diff/diff.errors.ts +++ b/apps/cli/src/commands/db/diff/diff.errors.ts @@ -20,8 +20,8 @@ export class DbDiffEngineConflictError extends Data.TaggedError("DbDiffEngineCon } /** - * Only one of `--from` / `--to` was set in explicit diff mode; message text is - * an established output contract. + * Explicit diff mode got only one of `--from` / `--to`, or a target selector (`--remote` and its + * aliases), which it doesn't take; message text is an established output contract. */ export class DbDiffExplicitFlagsError extends Data.TaggedError("DbDiffExplicitFlagsError")<{ readonly message: string; diff --git a/apps/cli/src/commands/db/diff/diff.handler.ts b/apps/cli/src/commands/db/diff/diff.handler.ts index f96340087d..7f4d6cb442 100644 --- a/apps/cli/src/commands/db/diff/diff.handler.ts +++ b/apps/cli/src/commands/db/diff/diff.handler.ts @@ -30,7 +30,6 @@ import { resolveRemoteTarget, warnTarget, } from "../../../command-internal/target/remote-resolution.ts"; -import { hasExplicitSelector } from "../../../command-internal/target/target-selector.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"; @@ -115,6 +114,25 @@ const declarativeBaselineNote = (displayPath: string, nextSteps: string) => const declarativeFilesIgnoredNote = (displayPath: string, nextSteps: string) => `Note: db diff compares supabase/migrations with the target database; declarative schema files in ${displayPath} are not read. ${nextSteps}\n`; +const EXPLICIT_SIDE_FOR_SELECTOR = { + "--remote": "remote", + "--linked": "remote", + "--project-ref": "remote", + "--db-url": "<db-url>", + "--local": "local", +} as const; + +const givenSelectorFlag = ( + flags: DbDiffFlags, +): keyof typeof EXPLICIT_SIDE_FOR_SELECTOR | undefined => { + if (Option.isSome(flags.remote)) return "--remote"; + if (flags.linked) return "--linked"; + if (Option.isSome(flags.projectRef)) return "--project-ref"; + if (Option.isSome(flags.dbUrl)) return "--db-url"; + if (flags.local) return "--local"; + return undefined; +}; + export const dbDiff = Effect.fn("db.diff")(function* (flags: DbDiffFlags) { if (Option.isSome(flags.usePgSchema)) { return yield* removedFlag("--use-pg-schema", "Use the default pg-delta engine or --use-migra."); @@ -175,10 +193,17 @@ export const dbDiff = Effect.fn("db.diff")(function* (flags: DbDiffFlags) { message: "must set both --from and --to when using explicit diff 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.<ref>]` override. Undefined until a linked ref resolves, so a `migrations` ref - // resolved before any linked ref uses base. + const selectorFlag = givenSelectorFlag(flags); + if (selectorFlag !== undefined) { + const side = EXPLICIT_SIDE_FOR_SELECTOR[selectorFlag]; + return yield* new DbDiffExplicitFlagsError({ + message: `${selectorFlag} doesn't apply with --from/--to; use --from=${side} or --to=${side}.`, + }); + } + // `mergedLinkedRef` tracks the linked ref resolved so far so the config read below and a + // later `migrations` catalog export merge the matching `[remotes.<ref>]` override. + // Undefined until a linked ref resolves, so a `migrations` ref resolved before any linked + // ref uses base. let mergedLinkedRef: string | undefined; // The first migrations endpoint resolved wins: the engine provisions a single migrations // shadow/catalog, so only refs resolved before that point should influence it. @@ -189,23 +214,7 @@ export const dbDiff = Effect.fn("db.diff")(function* (flags: DbDiffFlags) { // Each hosted resolution mints a temporary login role that invalidates the previous one for // that project, so every ref to a project shares a single connection. const hostedEndpoints = new Map<string, PgDeltaDatabaseEndpoint>(); - // A target flag validates the selection and, for a hosted project, merges - // `[remotes.<ref>]` so the explicit `local`/`migrations` refs below see that override. - if (hasExplicitSelector(flags)) { - const preflight = yield* resolveTarget(flags); - 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.<ref>]`-merged config - // when a target flag resolved a ref (so base config isn't validated - // before the merge), else the base config. - let cfg = - mergedLinkedRef !== undefined - ? yield* readDbToml(fs, path, cliSettings.workdir, mergedLinkedRef) - : yield* readDbToml(fs, path, cliSettings.workdir); + let cfg = yield* readDbToml(fs, path, cliSettings.workdir); const hostedTarget = Effect.fnUntraced(function* ( remote: Exclude<RemoteValue, { readonly _tag: "DbUrl" }>, ) { 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 fdfaf25b35..94f0e6befb 100644 --- a/apps/cli/src/commands/db/diff/diff.integration.test.ts +++ b/apps/cli/src/commands/db/diff/diff.integration.test.ts @@ -1540,10 +1540,10 @@ describe("db diff", () => { }); it.effect( - "explicit --from remote --to migrations --remote=<ref> proceeds and uses the flag ref", + "explicit --from <ref> --to migrations applies the ref's remote override and caches the ref", () => { - // `[remotes.staging]`'s `project_id` matches the flag ref, not `opts.linkedRef` - // (left unset), so the override only applies if the flag resolved it. + // `[remotes.staging]`'s `project_id` matches the `--from` ref, not `opts.linkedRef` + // (left unset), so the override only applies if the ref's own project resolved it. const s = setup(tmp.current, { files: { "supabase/config.toml": [ @@ -1563,51 +1563,7 @@ describe("db diff", () => { }); return Effect.gen(function* () { yield* dbDiff( - flags({ - from: Option.some("remote"), - to: Option.some("migrations"), - remote: Option.some({ _tag: "Ref", ref: "flagflagflagflagflag" } as const), - }), - ); - expect(s.explicitDiffCalls[0]?.toml?.majorVersion).toBe(14); - expect(s.explicitDiffCalls[0]?.desired).toEqual({ - kind: "migrations", - projectRef: "flagflagflagflagflag", - }); - expect(s.cache.cachedRef).toBe("flagflagflagflagflag"); - }).pipe(Effect.provide(s.layer)); - }, - ); - - it.effect( - "explicit --from local --to migrations --remote=<ref> proceeds and applies the flag ref's remote override", - () => { - // Same `[remotes.staging]` fixture as the `--from remote` case above, but here - // `--remote=<ref>` alone selects the linked target instead of a literal `remote` side. - const s = setup(tmp.current, { - files: { - "supabase/config.toml": [ - "[db]", - "major_version = 17", - "", - "[remotes.staging]", - `project_id = "flagflagflagflagflag"`, - "", - "[remotes.staging.db]", - "major_version = 14", - "", - ].join("\n"), - }, - isLocal: false, - diffSql: "create table m ();\n", - }); - return Effect.gen(function* () { - yield* dbDiff( - flags({ - from: Option.some("local"), - to: Option.some("migrations"), - remote: Option.some({ _tag: "Ref", ref: "flagflagflagflagflag" } as const), - }), + flags({ from: Option.some("flagflagflagflagflag"), to: Option.some("migrations") }), ); expect(s.explicitDiffCalls[0]?.toml?.majorVersion).toBe(14); expect(s.explicitDiffCalls[0]?.desired).toEqual({ @@ -2265,75 +2221,6 @@ describe("db diff", () => { }).pipe(Effect.provide(s.layer)); }); - it.effect("explicit --from local --to migrations --remote seeds the merged config", () => { - // A changed --remote merges the matching [remotes.*] block before the explicit refs resolve, - // even though neither explicit ref is itself `remote`. - const s = setup(tmp.current, { - files: { - "supabase/config.toml": [ - "[db]", - "major_version = 17", - "", - "[remotes.staging]", - 'project_id = "abcdefghijklmnopqrst"', - "", - "[remotes.staging.db]", - "major_version = 14", - "", - ].join("\n"), - }, - isLocal: false, - linkedRef: "abcdefghijklmnopqrst", - diffSql: "create table m ();\n", - }); - return Effect.gen(function* () { - yield* dbDiff( - flags({ - from: Option.some("local"), - to: Option.some("migrations"), - remote: REMOTE_LINKED, - }), - ); - expect(s.explicitDiffCalls[0]?.desired).toEqual({ - kind: "migrations", - projectRef: "abcdefghijklmnopqrst", - }); - }).pipe(Effect.provide(s.layer)); - }); - - it.effect("explicit --from local --to migrations --remote validates the merged config", () => { - // The base config read is deferred until after the linked preflight, so a base - // config only valid after the remote merge doesn't fail early. - const s = setup(tmp.current, { - files: { - "supabase/config.toml": [ - "[db]", - "major_version = 16", - "", - "[remotes.staging]", - 'project_id = "abcdefghijklmnopqrst"', - "", - "[remotes.staging.db]", - "major_version = 15", - "", - ].join("\n"), - }, - isLocal: false, - linkedRef: "abcdefghijklmnopqrst", - diffSql: "create table m ();\n", - }); - return Effect.gen(function* () { - const exit = yield* dbDiff( - flags({ - from: Option.some("local"), - to: Option.some("migrations"), - remote: REMOTE_LINKED, - }), - ).pipe(Effect.exit); - expect(Exit.isSuccess(exit)).toBe(true); - }).pipe(Effect.provide(s.layer)); - }); - it.effect("empty --from/--to (shell vars) fall through to the normal diff", () => { // `--from "" --to ""` is unset and runs the normal local diff, not an // unknown-target error. @@ -2355,37 +2242,47 @@ describe("db diff", () => { }).pipe(Effect.provide(s.layer)); }); - it.effect("explicit mode accepts --remote=<db-url> without minting a connection", () => { - const s = setup(tmp.current, { diffSql: "create table p ();\n" }); - return Effect.gen(function* () { - yield* dbDiff( - flags({ - from: Option.some("local"), - to: Option.some("local"), - remote: Option.some({ _tag: "DbUrl", url: "postgresql://x" } as const), - }), - ); - 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)); + describe.each([ + { flag: "--remote", side: "remote", selector: { remote: REMOTE_LINKED } }, + { + flag: "--remote", + side: "remote", + selector: { remote: Option.some({ _tag: "Ref", ref: "flagflagflagflagflag" } as const) }, + }, + { + flag: "--remote", + side: "remote", + selector: { remote: Option.some({ _tag: "DbUrl", url: "postgresql://x" } as const) }, + }, + { flag: "--linked", side: "remote", selector: { linked: true } }, + { + flag: "--project-ref", + side: "remote", + selector: { projectRef: Option.some("flagflagflagflagflag") }, + }, + { flag: "--db-url", side: "<db-url>", selector: { dbUrl: Option.some("postgresql://x") } }, + { flag: "--local", side: "local", selector: { local: true } }, + ] satisfies ReadonlyArray<{ + flag: string; + side: string; + selector: Partial<DbDiffFlags>; + }>)("explicit --from/--to with $flag", ({ flag, side, selector }) => { + it.effect("is rejected before any target resolves", () => { + const s = setup(tmp.current); + return Effect.gen(function* () { + const error = yield* dbDiff( + flags({ from: Option.some("local"), to: Option.some("migrations"), ...selector }), + ).pipe(Effect.flip); + expect(error).toMatchObject({ + _tag: "DbDiffExplicitFlagsError", + message: `${flag} doesn't apply with --from/--to; use --from=${side} or --to=${side}.`, + }); + expect(s.resolverCalls).toEqual([]); + expect(s.explicitDiffCalls).toEqual([]); + expect(s.cache.cached).toBe(false); + expect(warningsOf(s.out)).toEqual([]); + }).pipe(Effect.provide(s.layer)); + }); }); it.effect("fails when --from is set without --to", () => { @@ -3229,29 +3126,25 @@ describe("db diff permissions through the real resolver and cache", () => { }); for (const side of ["from", "to"] as const) { - it.effect(`resolves the project --remote=<ref> names for --${side} remote`, () => { + it.effect(`resolves the project ref --${side} names`, () => { const s = setup(tmp.current, { realLayers: true, diffSql: "create table e ();\n" }); const requests: Array<StubbedRequest> = []; const refs = side === "from" - ? { from: "remote", to: "migrations" } - : { from: "migrations", to: "remote" }; + ? { from: VALID_REF, to: "migrations" } + : { from: "migrations", to: VALID_REF }; return Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; yield* stubManagementApi(requests, serveLinkedDatabase(VALID_REF)); - yield* dbDiff( - flags({ - from: Option.some(refs.from), - to: Option.some(refs.to), - remote: Option.some({ _tag: "Ref", ref: VALID_REF } as const), - }), - ).pipe(Effect.provide(s.layer)); + yield* dbDiff(flags({ from: Option.some(refs.from), to: Option.some(refs.to) })).pipe( + Effect.provide(s.layer), + ); expect(asLines(requests)).toEqual([poolerRequest, loginRoleRequest]); expect(cacheCalls(s.projectHttp.calls)).toEqual([cacheRequest]); expect(yield* fs.exists(`${tmp.current}/supabase/.temp/linked-project.json`)).toBe(true); assertPermissionDrift({ command: "db diff", - activeFlags: { from: refs.from, to: refs.to, remote: VALID_REF }, + activeFlags: { from: refs.from, to: refs.to }, requests: [...asRecordedRequests(requests), ...s.projectHttp.calls], exact: true, }); @@ -3286,55 +3179,28 @@ describe("db diff permissions through the real resolver and cache", () => { ); }); - it.effect("mints one login role shared by both sides when --from and --to are remote", () => { - const s = setup(tmp.current, { realLayers: true, diffSql: "create table e ();\n" }); - const requests: Array<StubbedRequest> = []; - return Effect.gen(function* () { - yield* stubManagementApi(requests, serveLinkedDatabase(VALID_REF)); - yield* dbDiff( - flags({ - from: Option.some("remote"), - to: Option.some("remote"), - remote: Option.some({ _tag: "Ref", ref: VALID_REF } as const), - }), - ).pipe(Effect.provide(s.layer)); - expect(asLines(requests)).toEqual([poolerRequest, loginRoleRequest]); - assertPermissionDrift({ - command: "db diff", - activeFlags: { from: "remote", to: "remote", remote: VALID_REF }, - requests: [...asRecordedRequests(requests), ...s.projectHttp.calls], - exact: true, - }); - }).pipe( - Effect.scoped, - Effect.provide(BunServices.layer), - provideEnv({ SUPABASE_ACCESS_TOKEN: VALID_TOKEN }), - ); - }); - - it.effect("mints one login role shared by the --remote preflight and both remote sides", () => { - const s = setup(tmp.current, { realLayers: true, diffSql: "create table e ();\n" }); - const requests: Array<StubbedRequest> = []; - return Effect.gen(function* () { - yield* stubManagementApi(requests, serveLinkedDatabase(VALID_REF)); - yield* dbDiff( - flags({ - from: Option.some("remote"), - to: Option.some("remote"), - remote: Option.some({ _tag: "Ref", ref: VALID_REF } as const), - }), - ).pipe(Effect.provide(s.layer)); - expect(asLines(requests)).toEqual([poolerRequest, loginRoleRequest]); - assertPermissionDrift({ - command: "db diff", - activeFlags: { remote: VALID_REF, from: "remote", to: "remote" }, - requests: [...asRecordedRequests(requests), ...s.projectHttp.calls], - exact: true, - }); - }).pipe( - Effect.scoped, - Effect.provide(BunServices.layer), - provideEnv({ SUPABASE_ACCESS_TOKEN: VALID_TOKEN }), - ); - }); + it.effect( + "mints one login role shared by both sides when --from and --to name one project", + () => { + const s = setup(tmp.current, { realLayers: true, diffSql: "create table e ();\n" }); + const requests: Array<StubbedRequest> = []; + return Effect.gen(function* () { + yield* stubManagementApi(requests, serveLinkedDatabase(VALID_REF)); + yield* dbDiff(flags({ from: Option.some(VALID_REF), to: Option.some(VALID_REF) })).pipe( + Effect.provide(s.layer), + ); + expect(asLines(requests)).toEqual([poolerRequest, loginRoleRequest]); + assertPermissionDrift({ + command: "db diff", + activeFlags: { from: VALID_REF, to: 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/remote/commit/SIDE_EFFECTS.md b/apps/cli/src/commands/db/remote/commit/SIDE_EFFECTS.md index 76108ad718..dddaa5ddba 100644 --- a/apps/cli/src/commands/db/remote/commit/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/db/remote/commit/SIDE_EFFECTS.md @@ -42,7 +42,7 @@ regardless of `-o`/`--output`: ``` supabase db remote commit was removed. -Use `supabase db pull` instead. +Use `supabase db pull --remote` instead. ``` ### `--output-format json` / `stream-json` 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 debcc0a695..25d9b3bc8c 100644 --- a/apps/cli/src/commands/db/remote/commit/commit.command.ts +++ b/apps/cli/src/commands/db/remote/commit/commit.command.ts @@ -32,15 +32,15 @@ const config = { } as const; export const dbRemoteCommitCommand = Command.make("commit", config).pipe( - Command.withDescription("Removed: use `supabase db pull` instead."), - Command.withShortDescription("Removed: use `db pull` instead"), + Command.withDescription("Removed: use `supabase db pull --remote` instead."), + Command.withShortDescription("Removed: use `db pull --remote` instead"), // Removed: fails before any Management API call, so no flag changes what it calls. withPermissions({ operations: [], noApiEffectFlags: ["db-url", "linked", "password", "schema"], }), Command.withHandler(() => - removedCommand("Use `supabase db pull` instead.").pipe( + removedCommand("Use `supabase db pull --remote` instead.").pipe( withCommandTelemetry(), withJsonErrorHandling, ), diff --git a/apps/cli/src/commands/migration/down/SIDE_EFFECTS.md b/apps/cli/src/commands/migration/down/SIDE_EFFECTS.md index 74d9f6e76c..3f366251d6 100644 --- a/apps/cli/src/commands/migration/down/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/migration/down/SIDE_EFFECTS.md @@ -101,7 +101,8 @@ Same structured result delivered as an NDJSON `result` event. be combined with `--local`, `--db-url` or `--remote=<value>`, an empty `--db-url` or `--project-ref` is rejected, and `--linked=false` selects nothing. - `--last` (default 1) resets up to the last n migration versions; must be `> 0` - and `<` the number of applied migrations. + and `<` the number of applied migrations. The error's `Try supabase db reset …` + suggestion carries the flags that select a hosted target (`--remote`, `--remote=<ref>`). - 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/down/down.handler.ts b/apps/cli/src/commands/migration/down/down.handler.ts index 9fcff7d826..46a22d98b1 100644 --- a/apps/cli/src/commands/migration/down/down.handler.ts +++ b/apps/cli/src/commands/migration/down/down.handler.ts @@ -13,8 +13,12 @@ import { 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 { + listRemoteMigrations, + withTargetFlags, +} 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"; @@ -62,7 +66,7 @@ const runDown = Effect.fnUntraced(function* (flags: MigrationDownFlags) { if (total <= flags.last || version === undefined) { return yield* new MigrationLastTooLargeError({ message: `--last must be smaller than total applied migrations: ${total}`, - suggestion: `Try ${aqua("supabase db reset")} if you want to revert all migrations.`, + suggestion: `Try ${aqua(withTargetFlags("supabase db reset", flagsForTarget(target)))} if you want to revert all migrations.`, }); } 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 4a45b44697..1fba538d21 100644 --- a/apps/cli/src/commands/migration/down/down.integration.test.ts +++ b/apps/cli/src/commands/migration/down/down.integration.test.ts @@ -284,6 +284,24 @@ describe("migration down", () => { }).pipe(Effect.provide(layer)); }); + it.effect("suggests the db reset of the same hosted target when --last is too large", () => { + const { layer } = setup(tmp.current, { remote: ["20240101000000"] }); + return Effect.gen(function* () { + const suggestionOf = (error: unknown) => + error instanceof MigrationLastTooLargeError ? stripAnsi(error.suggestion) : undefined; + const local = yield* migrationDown(flags({ last: 1 })).pipe(Effect.flip); + const hosted = yield* migrationDown( + flags({ last: 1, remote: Option.some({ _tag: "Linked" }) }), + ).pipe(Effect.flip); + expect(suggestionOf(local)).toBe( + "Try supabase db reset if you want to revert all migrations.", + ); + expect(suggestionOf(hosted)).toBe( + "Try supabase db reset --remote if you want to revert all migrations.", + ); + }).pipe(Effect.provide(layer)); + }); + it.effect("reverts the local database to the target version without prompting", () => { const { layer, out, execs, queries } = setup(tmp.current, { remote: ["20240101000000", "20240102000000"], diff --git a/apps/cli/src/commands/migration/fetch/SIDE_EFFECTS.md b/apps/cli/src/commands/migration/fetch/SIDE_EFFECTS.md index ba5f18eca9..45e6c17948 100644 --- a/apps/cli/src/commands/migration/fetch/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/migration/fetch/SIDE_EFFECTS.md @@ -71,8 +71,8 @@ Same structured `files` result delivered as an NDJSON `result` event. ## Prompts - When the migrations directory is non-empty, prompts - `Do you want to overwrite existing files in supabase/migrations directory?` - (default **YES**). Declining exits non-zero (`context canceled`). `--yes` or + `Overwrite local migrations with the history from <target>?`, where `<target>` is the + announced target (`Target: …`), e.g. `local stack (127.0.0.1:54322)` (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. This is a prompt about local files, so a `SUPABASE_YES` in the project `.env` still answers it. 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 045a9d332f..cf9e7c320f 100644 --- a/apps/cli/src/commands/migration/fetch/fetch.integration.test.ts +++ b/apps/cli/src/commands/migration/fetch/fetch.integration.test.ts @@ -20,6 +20,7 @@ import { mockTelemetryStateTracked, useTempWorkdir, } from "../../../../tests/helpers/command-mocks.ts"; +import { stripAnsi } from "../../../../tests/helpers/ansi.ts"; import { mockOutput, mockStdin, mockTty } from "../../../../tests/helpers/mocks.ts"; import { assertPermissionDrift } from "../../../../tests/helpers/permission-drift.ts"; import { @@ -46,6 +47,7 @@ import type { import { DbConnectError } 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 { CommandTarget } from "../../../shared/output/command-target.service.ts"; import { TargetProjectRefConflictError, TargetSelectorConflictError, @@ -367,6 +369,43 @@ describe("migration fetch", () => { }).pipe(Effect.provide(layer)); }); + it.effect("names the announced target in the overwrite question", () => { + const { layer, out } = setup(tmp.current, { + yes: true, + rows: [{ version: "20240101000000", name: "init", statements: ["create table a"] }], + }); + const announced = Layer.succeed( + CommandTarget, + CommandTarget.of({ + announce: () => Effect.void, + hintLocal: () => Effect.void, + json: Effect.succeed(Option.none()), + text: Effect.succeed(Option.some("project my-app (flagflagflagflagflag) · production")), + }), + ); + return Effect.gen(function* () { + yield* seedExistingMigration(tmp.current); + yield* migrationFetch(flags()); + expect(stripAnsi(out.stderrText)).toContain( + "Overwrite local migrations with the history from project my-app (flagflagflagflagflag) · production? [Y/n] y", + ); + }).pipe(Effect.provide(Layer.merge(layer, announced))); + }); + + it.effect("asks about the target database when no target was announced", () => { + const { layer, out } = setup(tmp.current, { + yes: true, + rows: [{ version: "20240101000000", name: "init", statements: ["create table a"] }], + }); + return Effect.gen(function* () { + yield* seedExistingMigration(tmp.current); + yield* migrationFetch(flags()); + expect(stripAnsi(out.stderrText)).toContain( + "Overwrite local migrations with the history from the target database? [Y/n] y", + ); + }).pipe(Effect.provide(layer)); + }); + it.effect("auto-confirms the overwrite prompt from SUPABASE_YES in the project .env", () => { // SUPABASE_YES lives only in supabase/.env; the project env loads before the // overwrite prompt. diff --git a/apps/cli/src/commands/migration/repair/SIDE_EFFECTS.md b/apps/cli/src/commands/migration/repair/SIDE_EFFECTS.md index 9f07bb5280..6d64b0b4d6 100644 --- a/apps/cli/src/commands/migration/repair/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/migration/repair/SIDE_EFFECTS.md @@ -61,7 +61,8 @@ Local targets and database URLs make none. 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, +to stderr; the suggestion carries the flags that select the repaired hosted target +(`--remote`, `--remote=<ref>`), so it lists the same database. The DB work is the history-table provisioning (its own transaction, skipped entirely when a read-only probe finds the ledger already provisioned, so a provisioned remote runs no provisioning DDL — supabase/cli#6393) followed by one repair transaction: (for repair-all) `TRUNCATE`, plus `applied` → per-version diff --git a/apps/cli/src/commands/migration/repair/repair.handler.ts b/apps/cli/src/commands/migration/repair/repair.handler.ts index 9f781a5800..93c636e9e2 100644 --- a/apps/cli/src/commands/migration/repair/repair.handler.ts +++ b/apps/cli/src/commands/migration/repair/repair.handler.ts @@ -20,9 +20,13 @@ import { resolveMigrationFile, TRUNCATE_VERSION_TABLE, UPSERT_MIGRATION_VERSION, + withTargetFlags, } from "../../../command-internal/migration-history.ts"; import { parseMigrationVersion } from "../../../command-internal/migration-timestamp.format.ts"; -import type { TargetFlagValuesWithDbUrl } from "../../../command-internal/target/target-flags.ts"; +import { + flagsForTarget, + type TargetFlagValuesWithDbUrl, +} from "../../../command-internal/target/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"; @@ -168,7 +172,7 @@ const runRepair = Effect.fnUntraced(function* (input: MigrationRepairInput) { // Success banner to stdout; follow-up suggestion to stderr. yield* output.raw(`Finished ${aqua("supabase migration repair")}.\n`); yield* emitSuccessTrailer( - `Run ${aqua("supabase migration list")} to show the updated migration history.\n`, + `Run ${aqua(withTargetFlags("supabase migration list", flagsForTarget(target)))} to show the updated migration history.\n`, ); } else { yield* output.success("Migration history repaired", { 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 42a91b4fb6..783d9b7855 100644 --- a/apps/cli/src/commands/migration/repair/repair.integration.test.ts +++ b/apps/cli/src/commands/migration/repair/repair.integration.test.ts @@ -382,6 +382,23 @@ describe("migration repair", () => { }).pipe(Effect.provide(layer)); }); + it.effect("suggests listing the history of the hosted project it repaired", () => { + const ctx = setup(tmp.current, { yes: true }); + return Effect.gen(function* () { + yield* seedMigration(tmp.current, "20240101000000_init.sql", "create table a;\n"); + yield* migrationRepair( + input({ + versions: ["20240101000000"], + status: "applied", + remote: Option.some({ _tag: "Ref", ref: FLAG_REF }), + }), + ); + expect(stripAnsi(ctx.out.stderrText)).toContain( + `Run supabase migration list --remote=${FLAG_REF} to show the updated migration history.`, + ); + }).pipe(Effect.provide(ctx.layer)); + }); + it.effect("prints multiple repaired versions using the `[a b]` slice format", () => { // The established format is space-separated and bracketed, with no commas; a // `.join(", ")` cleanup would silently change established output. diff --git a/apps/cli/src/commands/migration/squash/SIDE_EFFECTS.md b/apps/cli/src/commands/migration/squash/SIDE_EFFECTS.md index 02572b3eea..c87e45e299 100644 --- a/apps/cli/src/commands/migration/squash/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/migration/squash/SIDE_EFFECTS.md @@ -131,7 +131,7 @@ A hosted target asks (or is confirmed by `--yes`) right after the target, config 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 -supabase migration repair --status applied to update your remote migration +supabase migration repair --status applied --remote to update your remote migration history table.` to stderr, after the stdout line. ### `--output-format json` / `stream-json` diff --git a/apps/cli/src/commands/migration/squash/squash.e2e.test.ts b/apps/cli/src/commands/migration/squash/squash.e2e.test.ts index c76a169258..d4d0ec46a9 100644 --- a/apps/cli/src/commands/migration/squash/squash.e2e.test.ts +++ b/apps/cli/src/commands/migration/squash/squash.e2e.test.ts @@ -63,7 +63,7 @@ describe("supabase migration squash", () => { ); expect(stripAnsi(stdout)).toContain("Finished supabase migration squash."); expect(stripAnsi(stderr)).toContain( - "Run supabase migration repair --status applied to update your remote migration history table.", + "Run supabase migration repair --status applied --remote to update your remote migration history table.", ); }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), E2E_TIMEOUT_MS, diff --git a/apps/cli/src/commands/migration/squash/squash.handler.ts b/apps/cli/src/commands/migration/squash/squash.handler.ts index 8b1fb84138..ec0cf13ae9 100644 --- a/apps/cli/src/commands/migration/squash/squash.handler.ts +++ b/apps/cli/src/commands/migration/squash/squash.handler.ts @@ -525,7 +525,7 @@ const runSquash = Effect.fnUntraced(function* (flags: MigrationSquashFlags) { if (output.format === "text") { yield* output.raw(`Finished ${aqua("supabase migration squash")}.\n`); yield* output.raw( - `Run ${aqua("supabase migration repair --status applied")} to update your remote migration history table.\n`, + `Run ${aqua("supabase migration repair --status applied --remote")} to update your remote migration history table.\n`, "stderr", ); } else { 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 7ea9d3d95a..64832f6769 100644 --- a/apps/cli/src/commands/migration/squash/squash.integration.test.ts +++ b/apps/cli/src/commands/migration/squash/squash.integration.test.ts @@ -690,7 +690,7 @@ describe("migration squash", () => { // The Finished/repair-suggestion output still runs on the no-op path. expect(stdout(s.out)).toContain("Finished supabase migration squash."); expect(stderr(s.out)).toContain( - "Run supabase migration repair --status applied to update your remote migration history table.", + "Run supabase migration repair --status applied --remote to update your remote migration history table.", ); }).pipe(Effect.provide(s.layer)); }, @@ -1155,7 +1155,7 @@ describe("migration squash", () => { yield* migrationSquash(flags()); expect(stdout(s.out)).toContain("Finished supabase migration squash."); expect(stderr(s.out)).toContain( - "Run supabase migration repair --status applied to update your remote migration history table.", + "Run supabase migration repair --status applied --remote to update your remote migration history table.", ); expect(s.out.promptConfirmCalls).toEqual([]); }).pipe(Effect.provide(s.layer)); 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 417909c6b4..60bab2d9dc 100644 --- a/apps/cli/src/shared/cli/hidden-flag.unit.test.ts +++ b/apps/cli/src/shared/cli/hidden-flag.unit.test.ts @@ -263,11 +263,11 @@ describe("hidden subcommands", () => { [["db", "branch", "list"], [], undefined], [["db", "remote", "changes"], ["--schema", "public", "--linked"], undefined], [["gen", "keys"], ["--project-ref", "abcdefghijklmnopqrst"], undefined], - [["db", "remote", "commit"], [], "Use `supabase db pull` instead."], + [["db", "remote", "commit"], [], "Use `supabase db pull --remote` instead."], [ ["db", "remote", "commit"], ["--schema", "public", "--linked", "-p", "secret"], - "Use `supabase db pull` instead.", + "Use `supabase db pull --remote` instead.", ], [ ["inspect", "db", "total-index-size"], From b29d550507384663be5be5d8df3c58bc84d6ff4b Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Mon, 19 Oct 2026 16:20:11 +0100 Subject: [PATCH 158/291] fix(cli): reshape the --remote warnings, errors, banner and help - deprecation warnings show the command path, its operand placeholders and the `--remote` replacement last, and never echo operands, other flags, the `--` tail or URL userinfo - `--remote` values are trimmed and matched case-insensitively against `postgres(ql)://`; other URLs are rejected, and a project URL suggests its ref - keyword `--db-url` and boolean-literal `--project-ref` warnings say how to reach the same target through `--remote` - an empty or false `--remote` fails without a help dump and names the values that command accepts; `--remote` on `link` and on local-only commands says so - the default branch renders as the production project and a branch banner names its parent project - the bare `--remote` ambiguity check reports a match, a near miss or a failed listing with both readings, and keeps the cause and a login suggestion - branch lookups tell an expired token to log in and suggest the closest name - the linked-project hint reads `SUPABASE_PROJECT_ID` before the link file - `--remote` help rows, descriptions and JSON help follow each command's variant - conflict errors list only the selectors the command has and suggest `--remote` --- .../src/tests/target-selection.e2e.test.ts | 8 +- .../target/branch-resolution.ts | 46 ++-- .../command-internal/target/closest-branch.ts | 40 +++ .../target/closest-branch.unit.test.ts | 41 +++ .../target/remote-resolution.ts | 95 +++++-- .../target/remote-resolution.unit.test.ts | 115 +++++++- .../command-internal/target/remote-value.ts | 59 +++- .../target/remote-value.unit.test.ts | 54 ++++ .../target/remote-vocabulary.ts | 6 +- ...resolve-project-target.integration.test.ts | 4 +- .../target/resolve-project-target.ts | 22 +- .../target/resolve-target.integration.test.ts | 255 ++++++++++++++++-- .../command-internal/target/resolve-target.ts | 9 +- .../target/target-deprecation.ts | 137 +++++----- .../target/target-deprecation.unit.test.ts | 167 ++++++------ .../command-internal/target/target-flags.ts | 61 +++-- .../target/target-flags.unit.test.ts | 100 ++++++- .../target/target-identity.ts | 34 ++- .../target/target-identity.unit.test.ts | 35 +++ .../target/target-selector.ts | 57 +++- .../target/target-selector.unit.test.ts | 112 +++++++- .../command-internal/target/target.errors.ts | 27 +- .../target/target.errors.unit.test.ts | 22 +- .../test-db.integration.test.ts | 4 +- .../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 +- .../config/diff/diff.integration.test.ts | 10 +- .../config/pull/pull.integration.test.ts | 10 +- .../config/push/push.integration.test.ts | 10 +- .../db/advisors/advisors.integration.test.ts | 4 +- .../commands/db/diff/diff.integration.test.ts | 5 +- .../commands/db/dump/dump.integration.test.ts | 5 +- .../commands/db/lint/lint.integration.test.ts | 4 +- .../commands/db/pull/pull.integration.test.ts | 5 +- .../commands/db/push/push.integration.test.ts | 5 +- .../db/query/query.integration.test.ts | 4 +- .../db/reset/reset.integration.test.ts | 4 +- .../declarative/declarative.smart-target.ts | 4 +- .../generate/generate.integration.test.ts | 4 +- .../deploy/deploy.integration.test.ts | 4 +- .../gen/types/types.integration.test.ts | 18 +- .../db/inspect-query.integration.test.ts | 7 +- .../inspect/report/report.integration.test.ts | 7 +- .../migration/down/down.integration.test.ts | 6 +- .../migration/fetch/fetch.integration.test.ts | 6 +- .../migration/list/list.integration.test.ts | 6 +- .../repair/repair.integration.test.ts | 10 +- .../squash/squash.integration.test.ts | 6 +- .../migration/up/up.integration.test.ts | 6 +- .../notebooks/notebooks.integration.test.ts | 2 +- .../commands/pull/pull.integration.test.ts | 11 +- .../seed/buckets/buckets.integration.test.ts | 4 +- .../storage/cp/cp.integration.test.ts | 3 +- .../storage/ls/ls.integration.test.ts | 7 +- .../storage/mv/mv.integration.test.ts | 3 +- .../storage/rm/rm.integration.test.ts | 3 +- apps/cli/src/docs/docs-spec.ts | 15 +- apps/cli/src/docs/docs-spec.unit.test.ts | 12 +- apps/cli/src/shared/cli/cli-args.service.ts | 2 + .../src/shared/cli/invalid-value-message.ts | 14 +- apps/cli/src/shared/cli/run.ts | 29 +- apps/cli/src/shared/cli/run.unit.test.ts | 104 +++++++ .../shared/cli/subcommand-flag-suggestions.ts | 54 +++- .../subcommand-flag-suggestions.unit.test.ts | 46 ++++ apps/cli/src/shared/config/temp-paths.ts | 13 +- .../src/shared/config/temp-paths.unit.test.ts | 44 ++- apps/cli/src/shared/output/json-formatter.ts | 4 +- apps/cli/src/shared/output/target-help.ts | 68 ++--- .../shared/output/target-help.unit.test.ts | 68 ++++- .../telemetry/__fixtures__/error-tags.txt | 1 + apps/cli/tests/helpers/command-mocks.ts | 6 + .../tests/helpers/hosted-target-selection.ts | 3 +- apps/cli/tests/helpers/remote-flag-fixture.ts | 12 +- apps/cli/tests/helpers/storage.ts | 5 + 80 files changed, 1746 insertions(+), 463 deletions(-) create mode 100644 apps/cli/src/command-internal/target/closest-branch.ts create mode 100644 apps/cli/src/command-internal/target/closest-branch.unit.test.ts create mode 100644 apps/cli/src/command-internal/target/remote-value.unit.test.ts diff --git a/apps/cli-e2e/src/tests/target-selection.e2e.test.ts b/apps/cli-e2e/src/tests/target-selection.e2e.test.ts index d1127fefea..e4a7278f87 100644 --- a/apps/cli-e2e/src/tests/target-selection.e2e.test.ts +++ b/apps/cli-e2e/src/tests/target-selection.e2e.test.ts @@ -23,7 +23,7 @@ describe("deprecated target aliases", () => { const result = await run(["db", "advisors", "--type", "security", "--linked"]); expect(result.exitCode).toBe(0); expect(`${result.stdout}${result.stderr}`).toContain( - "--linked is deprecated and will be removed in v4. Use --remote instead: supabase db advisors --type security --remote", + "--linked is deprecated and will be removed in v4. Use --remote instead: supabase db advisors --remote", ); expect(result.stderr).toContain("No issues found"); }, @@ -45,7 +45,7 @@ describe("deprecated target aliases", () => { const result = await run(["inspect", "db", "db-stats", "--db-url", DEAD_DB_URL]); expect(result.exitCode).not.toBe(0); expect(`${result.stdout}${result.stderr}`).toContain( - "--db-url is deprecated and will be removed in v4. Use --remote instead: supabase inspect db db-stats --remote=<db-url>", + "--db-url is deprecated and will be removed in v4. Use --remote=<db-url> instead: supabase inspect db db-stats --remote=<db-url>", ); expect(`${result.stdout}${result.stderr}`).not.toContain(DEAD_DB_URL); expect(result.stderr).toContain("Target: custom Postgres (localhost:1)"); @@ -58,7 +58,7 @@ describe("deprecated target aliases", () => { const result = await run(["secrets", "list", "--project-ref", projectRef]); expect(result.exitCode).toBe(0); expect(`${result.stdout}${result.stderr}`).toContain( - `--project-ref is deprecated and will be removed in v4. Use --remote instead: supabase secrets list --remote=${projectRef}`, + `--project-ref is deprecated and will be removed in v4. Use --remote=<ref> instead: supabase secrets list --remote=${projectRef}`, ); }, ); @@ -70,7 +70,7 @@ describe("deprecated target aliases", () => { expect(result.exitCode).toBe(0); expect(result.stdout).toContain("export type Database"); expect(`${result.stdout}${result.stderr}`).toContain( - `--project-id is deprecated and will be removed in v4. Use --remote instead: supabase gen types --remote=${projectRef}`, + `--project-id is deprecated and will be removed in v4. Use --remote=<ref> instead: supabase gen types <language> --remote=${projectRef}`, ); }, ); diff --git a/apps/cli/src/command-internal/target/branch-resolution.ts b/apps/cli/src/command-internal/target/branch-resolution.ts index 8e15597807..49d8b800cf 100644 --- a/apps/cli/src/command-internal/target/branch-resolution.ts +++ b/apps/cli/src/command-internal/target/branch-resolution.ts @@ -6,7 +6,11 @@ import { CommandPlatformApi } from "../../auth/command-platform-api.service.ts"; import { PROJECT_REF_PATTERN } from "../../config/project-ref.service.ts"; import { Output } from "../../shared/output/output.service.ts"; import { block } from "../command-permissions/model.ts"; -import { mapHttpError, sanitizeInlineName } from "../http-errors.ts"; +import { + AUTHENTICATION_FAILED_STATUS_MESSAGE, + mapHttpError, + sanitizeInlineName, +} from "../http-errors.ts"; import { parentNotLinkedMessage, parentRefInvalidMessage, @@ -23,6 +27,8 @@ import { LinkBranchNotReadyError, LinkParentRefInvalidError, } from "./branch-resolution.errors.ts"; +import { closestByName } from "./closest-branch.ts"; +import { stripUserinfo } from "./remote-vocabulary.ts"; type Branches = typeof V1ListAllBranchesOutput.Type; export type ParentBranch = Branches[number]; @@ -56,6 +62,8 @@ export interface BranchResolution { readonly projectRef: string; readonly branchName: string; readonly parentRef: string; + /** The project's production branch, which is the linked project itself. */ + readonly isDefault: boolean; } function branchNotFoundMessage(value: string, parentRef: string, branches: Branches): string { @@ -74,15 +82,14 @@ function branchNotFoundMessage(value: string, parentRef: string, branches: Branc ? `${shownSanitized}, … (${remaining} more — run supabase branches list)` : shownSanitized; - const trimmedLower = value.trim().toLowerCase(); - const nearMiss = branches.find((branch) => branch.name.toLowerCase() === trimmedLower); + const closest = closestByName(branches, value); // Sanitized like the list above — an API-provided name must not be able to inject // ANSI/OSC/newline controls into the terminal. const didYouMean = - nearMiss !== undefined ? ` Did you mean "${sanitizeInlineName(nearMiss.name)}"?` : ""; + closest !== undefined ? ` Did you mean "${sanitizeInlineName(closest.name)}"?` : ""; return ( - `Branch "${value}" not found for project ${parentRef}. Available branches: ${namesList}` + + `Branch "${value}" not found for project ${parentRef}. Available branches: ${namesList}.` + `${didYouMean}${parentRefTypoHint(value)}` ); } @@ -98,8 +105,12 @@ const unresolvedParent = Effect.fnUntraced(function* ( value: string, ) { return parent.kind === "absent" - ? yield* new LinkBranchNotLinkedError({ message: parentNotLinkedMessage(value) }) - : yield* new LinkParentRefInvalidError({ message: parentRefInvalidMessage(value) }); + ? yield* new LinkBranchNotLinkedError({ + message: parentNotLinkedMessage(stripUserinfo(value)), + }) + : yield* new LinkParentRefInvalidError({ + message: parentRefInvalidMessage(stripUserinfo(value)), + }); }); /** The eager client when the command built one, else the lazy factory's, so a bare run never resolves a token. */ @@ -129,10 +140,12 @@ export const listParentBranches = Effect.fnUntraced(function* (parentRef: string networkError: LinkBranchListNetworkError, statusError: LinkBranchListStatusError, networkMessage: (cause) => `failed to list branches: ${cause}`, - statusMessage: (status, body) => - status === 404 + statusMessage: (status, body) => { + if (status === 401) return AUTHENTICATION_FAILED_STATUS_MESSAGE; + return status === 404 ? `Cannot list branches for project ${parentRef} (HTTP 404). If ${parentRef} is itself a preview branch, link its parent project first: supabase link --project-ref <parent-ref>` - : `unexpected list branches status ${status}: ${body}`, + : `unexpected list branches status ${status}: ${body}`; + }, }); const task = yield* startBranchLookupTask(label); @@ -154,7 +167,7 @@ const resolveInParent = Effect.fnUntraced(function* (value: string, parentRef: s const found = findBranch(branches, value); if (found === undefined) { return yield* new LinkBranchNotFoundError({ - message: branchNotFoundMessage(value, parentRef, branches), + message: branchNotFoundMessage(stripUserinfo(value), parentRef, branches), }); } @@ -170,6 +183,7 @@ const resolveInParent = Effect.fnUntraced(function* (value: string, parentRef: s projectRef: found.project_ref, branchName: found.name, parentRef, + isDefault: found.is_default || found.project_ref === parentRef, } satisfies BranchResolution; }); @@ -193,10 +207,12 @@ const resolveBranchById = Effect.fnUntraced(function* (id: string) { networkError: LinkBranchListNetworkError, statusError: LinkBranchListStatusError, networkMessage: (cause) => `failed to look up branch: ${cause}`, - statusMessage: (status, body) => - status === 404 + statusMessage: (status, body) => { + if (status === 401) return AUTHENTICATION_FAILED_STATUS_MESSAGE; + return status === 404 ? `Branch "${sanitizeInlineName(id)}" not found. Run \`supabase branches list\` to see available branches.` - : `unexpected get branch status ${status}: ${body}`, + : `unexpected get branch status ${status}: ${body}`; + }, }); const task = yield* startBranchLookupTask("Resolving branch..."); @@ -213,7 +229,7 @@ const resolveBranchById = Effect.fnUntraced(function* (id: string) { message: `Branch "${sanitizeInlineName(id)}" has no project ref yet (status: ${detail.status}). Wait for it to finish provisioning, then retry.`, }); } - return { projectRef: detail.ref, branchName: undefined, parentRef: undefined }; + return { projectRef: detail.ref, branchName: undefined, parentRef: undefined, isDefault: false }; }); /** diff --git a/apps/cli/src/command-internal/target/closest-branch.ts b/apps/cli/src/command-internal/target/closest-branch.ts new file mode 100644 index 0000000000..4489d5771c --- /dev/null +++ b/apps/cli/src/command-internal/target/closest-branch.ts @@ -0,0 +1,40 @@ +/** The Levenshtein distance between two strings. */ +const editDistance = (a: string, b: string): number => { + let previous = Array.from({ length: b.length + 1 }, (_, index) => index); + for (let row = 1; row <= a.length; row += 1) { + const current = [row]; + for (let column = 1; column <= b.length; column += 1) { + current[column] = Math.min( + (previous[column] ?? 0) + 1, + (current[column - 1] ?? 0) + 1, + (previous[column - 1] ?? 0) + (a[row - 1] === b[column - 1] ? 0 : 1), + ); + } + previous = current; + } + return previous[b.length] ?? 0; +}; + +/** Up to two edits, fewer for a short name, so two unrelated short words never match. */ +const allowedEdits = (a: string, b: string): number => + Math.min(2, Math.floor(Math.max(a.length, b.length) / 3)); + +/** The item whose name matches `word` ignoring case or within two edits of it; the nearest wins and a tie goes to the first name alphabetically. */ +export const closestByName = <T extends { readonly name: string }>( + items: ReadonlyArray<T>, + word: string, +): T | undefined => { + const target = word.trim().toLowerCase(); + let best: { readonly item: T; readonly distance: number } | undefined; + for (const item of items.toSorted((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0))) { + const name = item.name.toLowerCase(); + const distance = editDistance(target, name); + if ( + distance <= allowedEdits(target, name) && + (best === undefined || distance < best.distance) + ) { + best = { item, distance }; + } + } + return best?.item; +}; diff --git a/apps/cli/src/command-internal/target/closest-branch.unit.test.ts b/apps/cli/src/command-internal/target/closest-branch.unit.test.ts new file mode 100644 index 0000000000..bd75faf985 --- /dev/null +++ b/apps/cli/src/command-internal/target/closest-branch.unit.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from "vitest"; + +import { closestByName } from "./closest-branch.ts"; + +const branches = ["feature-x", "staging", "Main", "add_profiles", "db"].map((name) => ({ name })); + +describe("closestByName", () => { + it.each([ + { word: "feature-y", expected: "feature-x" }, + { word: "Feature-X", expected: "feature-x" }, + { word: "stagin", expected: "staging" }, + { word: "staging2", expected: "staging" }, + { word: "main", expected: "Main" }, + { word: "add_profile", expected: "add_profiles" }, + { word: " staging ", expected: "staging" }, + ])("finds $expected for $word", ({ word, expected }) => { + expect(closestByName(branches, word)?.name).toBe(expected); + }); + + it.each(["production", "feature-xyz-long", "x", "dx", "fi"])( + "finds nothing for %s, which is more than two edits from every name", + (word) => { + expect(closestByName(branches, word)).toBeUndefined(); + }, + ); + + it("lets a short word match only by case", () => { + expect(closestByName(branches, "DB")?.name).toBe("db"); + expect(closestByName(branches, "dc")).toBeUndefined(); + }); + + it("prefers the fewest edits, then the first name alphabetically", () => { + const names = [{ name: "release-b" }, { name: "release-a" }, { name: "release-ab" }]; + expect(closestByName(names, "release-c")?.name).toBe("release-a"); + expect(closestByName(names, "release-a")?.name).toBe("release-a"); + }); + + it("finds nothing among no branches", () => { + expect(closestByName([], "staging")).toBeUndefined(); + }); +}); diff --git a/apps/cli/src/command-internal/target/remote-resolution.ts b/apps/cli/src/command-internal/target/remote-resolution.ts index ff6770f941..74e9defa89 100644 --- a/apps/cli/src/command-internal/target/remote-resolution.ts +++ b/apps/cli/src/command-internal/target/remote-resolution.ts @@ -11,11 +11,20 @@ import { getCommandRuntimeCommand, } from "../../shared/runtime/command-runtime.service.ts"; import { yellow } from "../colors.ts"; -import { resolveLinkedParentRef } from "../parent-project-ref.ts"; +import { formatNamedRef, sanitizeInlineName } from "../http-errors.ts"; +import { readCachedLinkedProject, resolveLinkedParentRef } from "../parent-project-ref.ts"; import { findBranch, listParentBranches, resolveRemoteBranchRef } from "./branch-resolution.ts"; +import { LinkBranchListStatusError } from "./branch-resolution.errors.ts"; +import { closestByName } from "./closest-branch.ts"; import type { RemoteValue } from "./remote-value.ts"; +import { stripUserinfo } from "./remote-vocabulary.ts"; import type { LinkedTarget } from "./resolve-target.ts"; -import { deprecationMessages, remoteFlagText } from "./target-deprecation.ts"; +import { + type DeprecationCommand, + deprecationMessages, + remoteFlagText, + shellQuote, +} from "./target-deprecation.ts"; import { renderBranchTarget } from "./target-identity.ts"; import { parseTargetSelector, @@ -33,16 +42,24 @@ export const warnTarget = Effect.fnUntraced(function* (message: string) { yield* output.raw(`${yellow(`Warning: ${message}`)}\n`, "stderr"); }); -/** Prints one warning per deprecated selector flag the run used, rebuilt from the real command line when it is known. */ +const currentCommand = Effect.fnUntraced(function* () { + const runtime = yield* Effect.serviceOption(CommandRuntime); + if (Option.isNone(runtime)) return undefined; + const cliArgs = Option.getOrUndefined(yield* Effect.serviceOption(CliArgs)); + return { + path: getCommandRuntimeCommand(runtime.value), + operands: cliArgs?.operandPlaceholders?.() ?? [], + } satisfies DeprecationCommand; +}); + +/** Prints one warning per deprecated selector flag the run used, naming the command, its operand placeholders and the `--remote` replacement. */ export const warnTargetDeprecations = Effect.fnUntraced(function* ( deprecations: ReadonlyArray<TargetDeprecation>, ) { if (deprecations.length === 0) return; const { projectHost } = yield* CommandSettings; - const argv = Option.getOrUndefined( - Option.map(yield* Effect.serviceOption(CliArgs), (cli) => cli.args), - ); - for (const message of deprecationMessages(deprecations, { argv, projectHost })) { + const command = yield* currentCommand(); + for (const message of deprecationMessages(deprecations, { command, projectHost })) { yield* warnTarget(message); } }); @@ -51,35 +68,74 @@ export const warnTargetDeprecations = Effect.fnUntraced(function* ( * Parses the selector flags, prints the deprecation warnings, and names the command for the * errors that need it. */ -export const selectTarget = Effect.fnUntraced(function* (flags: TargetSelectorInput) { +export const selectTarget = Effect.fnUntraced(function* ( + flags: TargetSelectorInput, + options: { readonly valueHint?: string } = {}, +) { const runtime = yield* Effect.serviceOption(CommandRuntime); const commandName = Option.isSome(runtime) ? `\`supabase ${getCommandRuntimeCommand(runtime.value)}\`` : "This command"; const { selection, deprecations } = yield* Result.match( - parseTargetSelector(flags, { commandName }), + parseTargetSelector(flags, { commandName, ...options }), { onFailure: Effect.fail, onSuccess: Effect.succeed }, ); yield* warnTargetDeprecations(deprecations); return { selection, commandName }; }); -const checkRemoteOperand = Effect.fn("Target.checkRemoteOperand")(function* (operand: string) { +const linkedProjectName = Effect.fnUntraced(function* (ref: string) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const { workdir } = yield* CommandSettings; + const cache = Option.getOrUndefined(yield* readCachedLinkedProject(fs, path, workdir)); + return cache?.ref === ref ? cache.name : undefined; +}); + +const LOGIN_SUGGESTION = + "Run `supabase login` with an account that can list this project's branches."; + +const listingFailure = (error: unknown) => { + const status = error instanceof LinkBranchListStatusError ? error.status : undefined; + return { + status, + reason: status === undefined ? "" : ` with HTTP ${status}`, + suggestion: status === 401 || status === 403 ? LOGIN_SUGGESTION : undefined, + }; +}; + +const checkRemoteOperand = Effect.fn("Target.checkRemoteOperand")(function* (rawOperand: string) { const parent = yield* resolveLinkedParentRef(); if (parent.kind !== "resolved") return; - const hint = `Did you mean ${remoteFlagText({ _tag: "Branch", name: operand })}?`; - const branches = yield* listParentBranches(parent.ref, "Checking branches...").pipe( - Effect.option, - ); - if (Option.isNone(branches)) { + const operand = stripUserinfo(rawOperand); + const runtime = yield* Effect.serviceOption(CommandRuntime); + const usage = `supabase ${Option.isSome(runtime) ? getCommandRuntimeCommand(runtime.value) : "<command>"}`; + const options = `For the branch: ${usage} ${remoteFlagText({ _tag: "Branch", name: operand })}. For the linked project: ${usage} ${shellQuote(operand)} --remote`; + + const listed = yield* listParentBranches(parent.ref, "Checking branches...").pipe(Effect.result); + if (Result.isFailure(listed)) { + const { status, reason, suggestion } = listingFailure(listed.failure); + return yield* new TargetRemoteAmbiguousError({ + message: `Can't tell whether "${operand}" is a branch (listing the branches of project ${parent.ref} failed${reason}). ${options}`, + ...(suggestion === undefined ? {} : { suggestion }), + ...(status === undefined ? {} : { status }), + cause: listed.failure, + }); + } + const branches = listed.success; + if (findBranch(branches, operand) !== undefined) { + const name = yield* linkedProjectName(parent.ref); + const project = name === undefined ? `project ${parent.ref}` : formatNamedRef(name, parent.ref); return yield* new TargetRemoteAmbiguousError({ - message: `Could not list the branches of project ${parent.ref} to tell whether ${operand} names one. ${hint}`, + message: `"${operand}" is a branch of ${project}. ${options}`, }); } - if (findBranch(branches.value, operand) !== undefined) { + const closest = closestByName(branches, operand); + if (closest !== undefined) { + const closestName = sanitizeInlineName(closest.name); return yield* new TargetRemoteAmbiguousError({ - message: `${operand} is a branch of project ${parent.ref}. ${hint}`, + message: `"${operand}" isn't a branch, but "${closestName}" is. For the branch: ${usage} ${remoteFlagText({ _tag: "Branch", name: closestName })}. For the linked project: ${usage} ${shellQuote(operand)} --remote`, }); } }); @@ -106,11 +162,14 @@ const resolveRemoteBranch = Effect.fnUntraced(function* (name: string) { const found = yield* resolveRemoteBranchRef(name); const cell = yield* Effect.serviceOption(CommandTarget); if (Option.isSome(cell)) { + const nameOwner = found.isDefault ? found.projectRef : found.parentRef; yield* cell.value.announce( renderBranchTarget({ branch: Option.fromNullishOr(found.branchName), ref: found.projectRef, parentRef: Option.fromNullishOr(found.parentRef), + parentName: nameOwner === undefined ? undefined : yield* linkedProjectName(nameOwner), + isDefault: found.isDefault, }), ); } diff --git a/apps/cli/src/command-internal/target/remote-resolution.unit.test.ts b/apps/cli/src/command-internal/target/remote-resolution.unit.test.ts index 85177c9ae8..598bba33df 100644 --- a/apps/cli/src/command-internal/target/remote-resolution.unit.test.ts +++ b/apps/cli/src/command-internal/target/remote-resolution.unit.test.ts @@ -5,13 +5,16 @@ import { vi } from "vitest"; import { stripAnsi } from "../../../tests/helpers/ansi.ts"; import { mockCommandSettings } from "../../../tests/helpers/command-mocks.ts"; import { mockTty } from "../../../tests/helpers/mocks.ts"; -import { CliArgs } from "../../shared/cli/cli-args.service.ts"; +import { rootCommandForFeatures } from "../../cli/root.ts"; +import { CliArgs, type CliArgsShape } from "../../shared/cli/cli-args.service.ts"; +import { prepareCliArgs } from "../../shared/cli/run.ts"; import { jsonOutputLayer, streamJsonOutputLayer, textOutputLayer, } from "../../shared/output/output.layer.ts"; import { Output } from "../../shared/output/output.service.ts"; +import { CommandRuntime } from "../../shared/runtime/command-runtime.service.ts"; import type { Tty } from "../../shared/runtime/tty.service.ts"; import { warnTargetDeprecations } from "./remote-resolution.ts"; import type { TargetDeprecation } from "./target-selector.ts"; @@ -46,6 +49,16 @@ const linkedAlias: TargetDeprecation = { const runWith = ( outputLayer: Layer.Layer<Output, never, Stdio.Stdio | Tty>, deprecations: ReadonlyArray<TargetDeprecation>, + command: { + readonly path: ReadonlyArray<string>; + readonly cliArgs: CliArgsShape; + } = { + path: ["gen", "types"], + cliArgs: { + args: ["gen", "types", "--linked"], + operandPlaceholders: () => ["<NAME=VALUE>..."], + }, + }, ) => { const stdout: Array<string> = []; const stderr: Array<string> = []; @@ -67,7 +80,11 @@ const runWith = ( Layer.mergeAll( outputLayer, mockCommandSettings({ workdir: "/unused" }), - Layer.succeed(CliArgs, { args: ["gen", "types", "--linked"] }), + Layer.succeed(CliArgs, command.cliArgs), + Layer.succeed( + CommandRuntime, + CommandRuntime.of({ commandPath: command.path, commandRunId: "run" }), + ), ).pipe(Layer.provide(Layer.merge(stdio, mockTty()))), ), Effect.map(() => ({ stdout: stdout.join(""), stderr: stderr.join("") })), @@ -88,9 +105,101 @@ describe("warnTargetDeprecations", () => { expect(aliasRun.stdout).toBe(remoteRun.stdout); expect(remoteRun.stderr).toBe(""); expect(stripAnsi(aliasRun.stderr)).toBe( - "Warning: --linked is deprecated and will be removed in v4. Use --remote instead: supabase gen types --remote\n", + "Warning: --linked is deprecated and will be removed in v4. Use --remote instead: supabase gen types <NAME=VALUE>... --remote\n", ); expect(clack.log.warn).not.toHaveBeenCalled(); }), ); }); + +describe("warnTargetDeprecations on the raw command line", () => { + const root = rootCommandForFeatures(); + const REF = "abcdefghijklmnopqrst"; + + const warningFor = ( + argv: ReadonlyArray<string>, + path: ReadonlyArray<string>, + deprecation: TargetDeprecation, + ) => + Effect.gen(function* () { + const run = yield* runWith(textOutputLayer, [deprecation], { + path, + cliArgs: prepareCliArgs(root, argv), + }); + return stripAnsi(run.stderr); + }); + + it.effect("shows an operand placeholder in place of a secret value", () => + Effect.gen(function* () { + const stderr = yield* warningFor( + ["secrets", "set", "FOO=hunter2", `--project-ref=${REF}`], + ["secrets", "set"], + { kind: "remote-alias", flags: ["--project-ref"], remote: { _tag: "Ref", ref: REF } }, + ); + + expect(stderr).toBe( + `Warning: --project-ref is deprecated and will be removed in v4. Use --remote=<ref> instead: supabase secrets set <NAME=VALUE>... --remote=${REF}\n`, + ); + expect(stderr).not.toContain("hunter2"); + }), + ); + + it.effect("keeps a SQL operand out of the warning", () => + Effect.gen(function* () { + const stderr = yield* warningFor( + ["db", "query", "select 'hunter2' as pw", "--linked"], + ["db", "query"], + { kind: "remote-alias", flags: ["--linked"], remote: { _tag: "Linked" } }, + ); + + expect(stderr).toBe( + "Warning: --linked is deprecated and will be removed in v4. Use --remote instead: supabase db query <sql> --remote\n", + ); + expect(stderr).not.toContain("hunter2"); + }), + ); + + it.effect("keeps another flag's database URL and password out of the warning", () => + Effect.gen(function* () { + const stderr = yield* warningFor( + ["db", "diff", "--from=postgresql://u:hunter2@h/db", "--to=local", "--local"], + ["db", "diff"], + { kind: "local" }, + ); + + expect(stderr).toBe( + "Warning: --local isn't needed (local is the default) and will be removed in v4: supabase db diff\n", + ); + expect(stderr).not.toContain("hunter2"); + }), + ); + + it.effect("keeps the -- tail out of the warning", () => + Effect.gen(function* () { + const stderr = yield* warningFor( + ["functions", "deploy", "--linked", "--", "--token=hunter2"], + ["functions", "deploy"], + { kind: "remote-alias", flags: ["--linked"], remote: { _tag: "Linked" } }, + ); + + expect(stderr).toBe( + "Warning: --linked is deprecated and will be removed in v4. Use --remote instead: supabase functions deploy <Function name>... --remote\n", + ); + expect(stderr).not.toContain("hunter2"); + }), + ); + + it.effect("never echoes a database URL given to --db-url", () => + Effect.gen(function* () { + const url = "postgresql://u:hunter2@h/db"; + const stderr = yield* warningFor(["db", "push", `--db-url=${url}`], ["db", "push"], { + kind: "remote-alias", + flags: ["--db-url"], + remote: { _tag: "DbUrl", url }, + }); + + expect(stderr).toContain("supabase db push --remote=<db-url>"); + expect(stderr).not.toContain("hunter2"); + }), + ); +}); diff --git a/apps/cli/src/command-internal/target/remote-value.ts b/apps/cli/src/command-internal/target/remote-value.ts index 6b337e843a..1acdaa40a8 100644 --- a/apps/cli/src/command-internal/target/remote-value.ts +++ b/apps/cli/src/command-internal/target/remote-value.ts @@ -1,4 +1,4 @@ -import { Predicate } from "effect"; +import { Predicate, Result } from "effect"; import { BRANCH_PROJECT_REF_PATTERN } from "../ref-patterns.ts"; import { DB_URL_PATTERN, LINKED_LITERAL, OFF_LITERAL } from "./remote-vocabulary.ts"; @@ -19,19 +19,54 @@ export const isRemoteValue = (value: unknown): value is RemoteValue => export type NamedRemoteValue = Exclude<RemoteValue, { readonly _tag: "Linked" }>; -/** Starts every failure for an off or empty `--remote` value, which the CLI prints without its usual `Invalid value` framing. */ -export const LEAVE_OUT_REMOTE_PREFIX = "Leave out --remote "; +/** A value with `://` that is not a postgres URL; `ref` is the project it points at when it is a project's own URL. */ +export interface ForeignUrlRejection { + readonly _tag: "ForeignUrl"; + readonly ref: string | undefined; +} -/** 20 lowercase letters is a project ref, a postgres URL is a database, anything else names a branch. */ -export const classifyNamedRemote = (raw: string): NamedRemoteValue => { - if (BRANCH_PROJECT_REF_PATTERN.test(raw)) return { _tag: "Ref", ref: raw }; - if (DB_URL_PATTERN.test(raw)) return { _tag: "DbUrl", url: raw }; - return { _tag: "Branch", name: raw }; +export type RemoteValueRejection = + | { readonly _tag: "Empty" } + | { readonly _tag: "Off" } + | ForeignUrlRejection; + +const PROJECT_URL_REF = /^[a-z][a-z0-9+.-]*:\/\/([a-z]{20})\.supabase\.[a-z]+(?::\d+)?(?:[/?#]|$)/; + +const refOfProjectUrl = (value: string): string | undefined => + PROJECT_URL_REF.exec(value.toLowerCase())?.[1]; + +/** 20 lowercase letters is a project ref, a postgres URL is a database, any other URL is rejected, anything else names a branch. */ +export const parseNamedRemote = ( + raw: string, +): Result.Result<NamedRemoteValue, ForeignUrlRejection> => { + const value = raw.trim(); + if (BRANCH_PROJECT_REF_PATTERN.test(value)) return Result.succeed({ _tag: "Ref", ref: value }); + if (DB_URL_PATTERN.test(value)) return Result.succeed({ _tag: "DbUrl", url: value }); + if (value.includes("://")) { + return Result.fail({ _tag: "ForeignUrl", ref: refOfProjectUrl(value) }); + } + return Result.succeed({ _tag: "Branch", name: value }); }; -/** Classifies a `--remote` value; `undefined` for an empty value or an off switch (`false|no|off|0`). */ +/** Classifies a `--remote` value, or says why it is rejected: empty, an off switch (`false|no|off|0`) or a URL that is not a postgres URL. */ +export const parseRemoteValue = (raw: string): Result.Result<RemoteValue, RemoteValueRejection> => { + const value = raw.trim(); + if (LINKED_LITERAL.test(value)) return Result.succeed({ _tag: "Linked" }); + if (value === "") return Result.fail({ _tag: "Empty" }); + if (OFF_LITERAL.test(value)) return Result.fail({ _tag: "Off" }); + return parseNamedRemote(value); +}; + +/** {@link parseNamedRemote} for a caller that reads a value the flag's own parser already accepted: a foreign URL falls back to a branch name. */ +export const classifyNamedRemote = (raw: string): NamedRemoteValue => + Result.getOrElse(parseNamedRemote(raw), (): NamedRemoteValue => ({ + _tag: "Branch", + name: raw.trim(), + })); + +/** {@link parseRemoteValue} for the same caller; `undefined` for an empty value or an off switch. */ export const classifyRemoteValue = (raw: string): RemoteValue | undefined => { - if (LINKED_LITERAL.test(raw)) return { _tag: "Linked" }; - if (raw === "" || OFF_LITERAL.test(raw)) return undefined; - return classifyNamedRemote(raw); + const value = raw.trim(); + if (value === "" || OFF_LITERAL.test(value)) return undefined; + return Result.getOrUndefined(parseRemoteValue(value)) ?? classifyNamedRemote(value); }; diff --git a/apps/cli/src/command-internal/target/remote-value.unit.test.ts b/apps/cli/src/command-internal/target/remote-value.unit.test.ts new file mode 100644 index 0000000000..75f27c005b --- /dev/null +++ b/apps/cli/src/command-internal/target/remote-value.unit.test.ts @@ -0,0 +1,54 @@ +import { Result } from "effect"; +import { describe, expect, it } from "vitest"; + +import { parseNamedRemote, parseRemoteValue } from "./remote-value.ts"; +import { stripUserinfo } from "./remote-vocabulary.ts"; + +const REF = "abcdefghijklmnopqrst"; + +describe("parseRemoteValue", () => { + it.each([ + { raw: " true ", expected: { _tag: "Linked" } }, + { raw: "YES", expected: { _tag: "Linked" } }, + { raw: ` ${REF} `, expected: { _tag: "Ref", ref: REF } }, + { raw: "Postgres://h/db", expected: { _tag: "DbUrl", url: "Postgres://h/db" } }, + { raw: " staging ", expected: { _tag: "Branch", name: "staging" } }, + ])("reads $raw as $expected", ({ raw, expected }) => { + expect(parseRemoteValue(raw)).toEqual(Result.succeed(expected)); + }); + + it.each([ + { raw: "", expected: { _tag: "Empty" } }, + { raw: " ", expected: { _tag: "Empty" } }, + { raw: "False", expected: { _tag: "Off" } }, + { raw: " 0 ", expected: { _tag: "Off" } }, + { raw: `https://${REF}.supabase.co`, expected: { _tag: "ForeignUrl", ref: REF } }, + { raw: `HTTPS://${REF}.SUPABASE.CO:443/x`, expected: { _tag: "ForeignUrl", ref: REF } }, + { raw: `https://${REF}.supabase.com?x=1`, expected: { _tag: "ForeignUrl", ref: REF } }, + { raw: "https://db.supabase.co", expected: { _tag: "ForeignUrl", ref: undefined } }, + { raw: "ftp://example.com", expected: { _tag: "ForeignUrl", ref: undefined } }, + ])("rejects $raw as $expected", ({ raw, expected }) => { + expect(parseRemoteValue(raw)).toEqual(Result.fail(expected)); + }); +}); + +describe("parseNamedRemote", () => { + it("treats the words an off switch uses as branch names", () => { + expect(parseNamedRemote("false")).toEqual(Result.succeed({ _tag: "Branch", name: "false" })); + }); +}); + +describe("stripUserinfo", () => { + it.each([ + { value: "postgresql://u:pw@h/db", expected: "postgresql://h/db" }, + { value: "postgresql://u@h/db", expected: "postgresql://h/db" }, + { value: "u:pw@staging", expected: "staging" }, + { value: "staging", expected: "staging" }, + { + value: "a message with postgresql://u:pw@h and more", + expected: "a message with postgresql://h and more", + }, + ])("turns $value into $expected", ({ value, expected }) => { + expect(stripUserinfo(value)).toBe(expected); + }); +}); diff --git a/apps/cli/src/command-internal/target/remote-vocabulary.ts b/apps/cli/src/command-internal/target/remote-vocabulary.ts index 8239eb9e9f..95aff4337a 100644 --- a/apps/cli/src/command-internal/target/remote-vocabulary.ts +++ b/apps/cli/src/command-internal/target/remote-vocabulary.ts @@ -1,6 +1,6 @@ export const REMOTE_FLAG_NAME = "remote"; -export const DB_URL_PATTERN = /^postgres(ql)?:\/\//; +export const DB_URL_PATTERN = /^postgres(ql)?:\/\//i; /** The spellings of a bare `--remote`, in any case. */ export const LINKED_LITERAL = /^(true|yes|on|1)$/i; @@ -21,3 +21,7 @@ const NOT_A_BRANCH_NAME: ReadonlyArray<RegExp> = [ /** Whether a word reads as a branch name rather than a path, URL, number or sentence. */ export const couldNameBranch = (word: string): boolean => word !== "" && !NOT_A_BRANCH_NAME.some((pattern) => pattern.test(word)); + +/** Removes `user:pass@` from a value before it appears in an error or a warning. */ +export const stripUserinfo = (value: string): string => + value.replace(/\/\/[^\s/@]*@/g, "//").replace(/[^\s/@:]+:[^\s/@]*@/g, ""); diff --git a/apps/cli/src/command-internal/target/resolve-project-target.integration.test.ts b/apps/cli/src/command-internal/target/resolve-project-target.integration.test.ts index 00915cffc5..68abd7b358 100644 --- a/apps/cli/src/command-internal/target/resolve-project-target.integration.test.ts +++ b/apps/cli/src/command-internal/target/resolve-project-target.integration.test.ts @@ -192,7 +192,7 @@ describe("resolveProjectTarget", () => { ); expect(refOf(result.exit)).toBe(FLAG_REF); expect(result.warnings).toEqual([ - `--project-ref is deprecated and will be removed in v4. Use --remote instead: supabase functions deploy hello --remote=${FLAG_REF}`, + `--project-ref is deprecated and will be removed in v4. Use --remote=<ref> instead: supabase functions deploy <Function name>... --remote=${FLAG_REF}`, ]); expect(result.outputWarnCalls).toEqual([]); expect(result.stdout).toBe(""); @@ -273,7 +273,7 @@ describe("resolveProjectTarget", () => { ); expect(failureOf(result.exit)).toMatchObject({ _tag: "TargetRemoteAmbiguousError", - message: `feature-x is a branch of project ${FILE_REF}. Did you mean --remote=feature-x?`, + message: `"feature-x" is a branch of project ${FILE_REF}. For the branch: supabase functions deploy --remote=feature-x. For the linked project: supabase functions deploy feature-x --remote`, }); }), ); diff --git a/apps/cli/src/command-internal/target/resolve-project-target.ts b/apps/cli/src/command-internal/target/resolve-project-target.ts index 86e58e4465..63fe8709dc 100644 --- a/apps/cli/src/command-internal/target/resolve-project-target.ts +++ b/apps/cli/src/command-internal/target/resolve-project-target.ts @@ -15,13 +15,19 @@ interface DetailedProjectTargetOptions { readonly detail: true; } -const selectProject = Effect.fnUntraced(function* (flags: ProjectTargetFlagValues) { - return yield* selectTarget({ - remote: flags.remote, - projectRef: flags.projectRef, - local: false, - linked: false, - }); +const selectProject = Effect.fnUntraced(function* ( + flags: ProjectTargetFlagValues, + options: { readonly valueHint?: string } = {}, +) { + return yield* selectTarget( + { + remote: flags.remote, + projectRef: flags.projectRef, + local: false, + linked: false, + }, + options, + ); }); const unreachableSelection = (tag: "Local" | "DbUrl") => @@ -47,7 +53,7 @@ const resolveHosted = Effect.fn("Target.resolveProject")(function* ( const resolveParent = Effect.fn("Target.resolveProject")(function* ( flags: ProjectTargetFlagValues, ) { - const { selection, commandName } = yield* selectProject(flags); + const { selection, commandName } = yield* selectProject(flags, { valueHint: "<ref>" }); switch (selection._tag) { case "Branch": return yield* new TargetNeedsProjectError({ 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 07ae831412..aa80f3f65e 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 @@ -16,6 +16,7 @@ import { ProjectRefResolver } from "../../config/project-ref.service.ts"; import type { ProjectRefSource } from "../../config/project-ref.service.ts"; import { CliArgs } from "../../shared/cli/cli-args.service.ts"; import { prepareCliArgs } from "../../shared/cli/run.ts"; +import { commandRuntimeLayer } from "../../shared/runtime/command-runtime.layer.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"; @@ -44,12 +45,12 @@ const BRANCH_REF = "branchprojectrefabcd"; const SECRET_URL = "postgresql://postgres:hunter2@db.abcdefghijklmnopqrst.supabase.co:5432/postgres"; -const branchRow = (name: string, projectRef = BRANCH_REF) => ({ +const branchRow = (name: string, projectRef = BRANCH_REF, isDefault = false) => ({ id: "11111111-2222-4333-8444-555555555555", name, project_ref: projectRef, parent_project_ref: FILE_REF, - is_default: false, + is_default: isDefault, persistent: false, status: "MIGRATIONS_PASSED", created_at: "2026-05-27T01:02:03Z", @@ -91,8 +92,13 @@ const run = ( readonly savedRef?: string; readonly cache?: { readonly ref: string; readonly name?: string }; readonly args?: ReadonlyArray<string>; + readonly command?: ReadonlyArray<string>; readonly format?: OutputFormat; - readonly branches?: ReadonlyArray<ReturnType<typeof branchRow>> | "unavailable"; + readonly branches?: + | ReadonlyArray<ReturnType<typeof branchRow>> + | "unavailable" + | "unauthorized" + | "forbidden"; } = {}, ) => Effect.gen(function* () { @@ -119,7 +125,11 @@ const run = ( Effect.succeed( options.branches === "unavailable" ? jsonResponse(request, 500, { message: "boom" }) - : jsonResponse(request, 200, options.branches ?? []), + : options.branches === "unauthorized" + ? jsonResponse(request, 401, { message: "Unauthorized" }) + : options.branches === "forbidden" + ? jsonResponse(request, 403, { message: "Forbidden" }) + : jsonResponse(request, 200, options.branches ?? []), ), }); const spans: Array<Tracer.NativeSpan> = []; @@ -146,6 +156,7 @@ const run = ( options.args === undefined ? Layer.empty : Layer.succeed(CliArgs, prepareCliArgs(root, options.args)), + options.command === undefined ? Layer.empty : commandRuntimeLayer(options.command), ), ), Effect.withTracer(tracer), @@ -266,20 +277,94 @@ describe("resolveTarget", () => { }), ); + it.effect("names the cached parent project in the branch banner", () => + Effect.gen(function* () { + const result = yield* run( + "selectable", + { ...none, ...remote({ _tag: "Branch", name: "feature-x" }) }, + { + savedRef: FILE_REF, + cache: { ref: FILE_REF, name: "alpha" }, + branches: [branchRow("feature-x")], + }, + ); + expect(result.stderr).toContain( + `Target: project alpha (${FILE_REF}) · branch feature-x (${BRANCH_REF})\n`, + ); + }), + ); + + it.effect("announces the default branch as the production project", () => + Effect.gen(function* () { + const result = yield* run( + "selectable", + { ...none, ...remote({ _tag: "Branch", name: "main" }) }, + { + savedRef: FILE_REF, + cache: { ref: FILE_REF, name: "alpha" }, + branches: [branchRow("main", FILE_REF, true)], + }, + ); + expect(succeeded(result.exit)).toMatchObject({ kind: "linked", ref: FILE_REF }); + expect(result.stderr).toContain(`Target: project alpha (${FILE_REF}) · production\n`); + expect(result.stderr).not.toContain("branch main"); + }), + ); + + it.effect("announces a branch whose project is the parent as production", () => + Effect.gen(function* () { + const result = yield* run( + "selectable", + { ...none, ...remote({ _tag: "Branch", name: "main" }) }, + { savedRef: FILE_REF, branches: [branchRow("main", FILE_REF)] }, + ); + expect(result.stderr).toContain(`Target: project ${FILE_REF} · production\n`); + }), + ); + it.effect("fails with the available branches when none has that name", () => Effect.gen(function* () { const result = yield* run( "selectable", - { ...none, ...remote({ _tag: "Branch", name: "feature-y" }) }, + { ...none, ...remote({ _tag: "Branch", name: "other-name" }) }, { savedRef: FILE_REF, branches: [branchRow("feature-x")] }, ); expect(failureOf(result.exit)).toMatchObject({ _tag: "LinkBranchNotFoundError", - message: expect.stringContaining("Available branches: feature-x"), + message: `Branch "other-name" not found for project ${FILE_REF}. Available branches: feature-x.`, + }); + }), + ); + + it.effect("suggests the closest branch when the name is a near miss", () => + Effect.gen(function* () { + const result = yield* run( + "selectable", + { ...none, ...remote({ _tag: "Branch", name: "feature-y" }) }, + { savedRef: FILE_REF, branches: [branchRow("feature-x"), branchRow("main")] }, + ); + expect(failureOf(result.exit)).toMatchObject({ + _tag: "LinkBranchNotFoundError", + message: `Branch "feature-y" not found for project ${FILE_REF}. Available branches: feature-x, main. Did you mean "feature-x"?`, }); }), ); + it.effect("tells an expired token to log in instead of showing the response body", () => + Effect.gen(function* () { + const result = yield* run( + "selectable", + { ...none, ...remote({ _tag: "Branch", name: "feature-x" }) }, + { savedRef: FILE_REF, branches: "unauthorized" }, + ); + const error = failureOf(result.exit); + expect(error).toMatchObject({ _tag: "LinkBranchListStatusError", status: 401 }); + expect(String((error as Error).message)).toBe( + "Authentication failed: your access token is invalid or has expired. Run `supabase login` to re-authenticate.", + ); + }), + ); + it.effect("asks to link the parent project when no project is linked", () => Effect.gen(function* () { const result = yield* run("selectable", { @@ -305,18 +390,21 @@ describe("resolveTarget", () => { describe("a bare --remote followed by a word", () => { const args = ["db", "pull", "--remote", "feature-x"]; + const command = ["db", "pull"]; const bare = { ...none, ...remote({ _tag: "Linked" }) }; + const options = `For the branch: supabase db pull --remote=feature-x. For the linked project: supabase db pull feature-x --remote`; it.effect("fails when the word is a branch of the linked project", () => Effect.gen(function* () { const result = yield* run("selectable", bare, { args, + command, savedRef: FILE_REF, branches: [branchRow("feature-x")], }); expect(failureOf(result.exit)).toMatchObject({ _tag: "TargetRemoteAmbiguousError", - message: `feature-x is a branch of project ${FILE_REF}. Did you mean --remote=feature-x?`, + message: `"feature-x" is a branch of project ${FILE_REF}. ${options}`, }); expect(result.refFlags).toEqual([]); }), @@ -333,17 +421,101 @@ describe("resolveTarget", () => { }), ); - it.effect("fails when the branches cannot be listed to tell", () => + it.effect("names the branch of a linked, named project", () => + Effect.gen(function* () { + const result = yield* run("selectable", bare, { + args, + command, + savedRef: FILE_REF, + cache: { ref: FILE_REF, name: "alpha" }, + branches: [branchRow("feature-x")], + }); + expect(failureOf(result.exit)).toMatchObject({ + message: `"feature-x" is a branch of alpha (${FILE_REF}). ${options}`, + }); + }), + ); + + it.effect("fails when the branches cannot be listed to tell, keeping the cause", () => Effect.gen(function* () { const result = yield* run("selectable", bare, { args, + command, savedRef: FILE_REF, branches: "unavailable", }); + const error = failureOf(result.exit); + expect(error).toMatchObject({ + _tag: "TargetRemoteAmbiguousError", + message: `Can't tell whether "feature-x" is a branch (listing the branches of project ${FILE_REF} failed with HTTP 500). ${options}`, + status: 500, + cause: { _tag: "LinkBranchListStatusError", status: 500 }, + }); + expect(error).not.toHaveProperty("suggestion"); + }), + ); + + it.effect.each([ + { name: "unauthorized", status: 401 }, + { name: "forbidden", status: 403 }, + ] as const)("suggests logging in when the branch listing is $name", ({ name, status }) => + Effect.gen(function* () { + const result = yield* run("selectable", bare, { + args, + command, + savedRef: FILE_REF, + branches: name, + }); + expect(failureOf(result.exit)).toMatchObject({ + _tag: "TargetRemoteAmbiguousError", + message: `Can't tell whether "feature-x" is a branch (listing the branches of project ${FILE_REF} failed with HTTP ${status}). ${options}`, + suggestion: "Run `supabase login` with an account that can list this project's branches.", + status, + }); + }), + ); + + it.effect("fails for a word one edit from a branch, naming the closest branch", () => + Effect.gen(function* () { + const result = yield* run("selectable", bare, { + args: ["db", "pull", "--remote", "feature-y"], + command, + savedRef: FILE_REF, + branches: [branchRow("feature-x"), branchRow("main")], + }); expect(failureOf(result.exit)).toMatchObject({ _tag: "TargetRemoteAmbiguousError", - message: `Could not list the branches of project ${FILE_REF} to tell whether feature-x names one. Did you mean --remote=feature-x?`, + message: `"feature-y" isn't a branch, but "feature-x" is. For the branch: supabase db pull --remote=feature-x. For the linked project: supabase db pull feature-y --remote`, + }); + expect(result.refFlags).toEqual([]); + }), + ); + + it.effect("fails for a word that differs from a branch only by case", () => + Effect.gen(function* () { + const result = yield* run("selectable", bare, { + args: ["db", "pull", "--remote", "Feature-X"], + command, + savedRef: FILE_REF, + branches: [branchRow("feature-x")], + }); + expect(failureOf(result.exit)).toMatchObject({ + message: `"Feature-X" isn't a branch, but "feature-x" is. For the branch: supabase db pull --remote=feature-x. For the linked project: supabase db pull Feature-X --remote`, + }); + }), + ); + + it.effect("never echoes the userinfo of a word that carries credentials", () => + Effect.gen(function* () { + const result = yield* run("selectable", bare, { + args: ["db", "pull", "--remote", "hunter2:pw@feature-x"], + command, + savedRef: FILE_REF, + branches: [branchRow("feature-x")], }); + const message = String((failureOf(result.exit) as Error).message); + expect(message).toContain(`"feature-x" is a branch`); + expect(message).not.toContain("hunter2"); }), ); @@ -383,12 +555,13 @@ describe("resolveTarget", () => { Effect.gen(function* () { const result = yield* run("selectable", bare, { args: ["db", "pull", "--remote", "feature-x"], + command, savedRef: FILE_REF, branches: [branchRow("feature-x")], }); expect(failureOf(result.exit)).toMatchObject({ _tag: "TargetRemoteAmbiguousError", - message: `feature-x is a branch of project ${FILE_REF}. Did you mean --remote=feature-x?`, + message: `"feature-x" is a branch of project ${FILE_REF}. ${options}`, }); }), ); @@ -408,11 +581,11 @@ describe("resolveTarget", () => { const result = yield* run( "selectable", { ...none, linked: true }, - { args: ["db", "push", "--dry-run", "--linked"] }, + { args: ["db", "push", "--dry-run", "--linked"], command: ["db", "push"] }, ); expect(succeeded(result.exit)).toMatchObject({ kind: "linked", ref: FILE_REF }); expect(result.warnings).toEqual([ - "--linked is deprecated and will be removed in v4. Use --remote instead: supabase db push --dry-run --remote", + "--linked is deprecated and will be removed in v4. Use --remote instead: supabase db push --remote", ]); }), ); @@ -424,7 +597,7 @@ describe("resolveTarget", () => { const result = yield* run( "selectable", { ...none, linked: true }, - { args: ["db", "push", "--linked"], format }, + { args: ["db", "push", "--linked"], command: ["db", "push"], format }, ); expect(succeeded(result.exit)).toMatchObject({ kind: "linked" }); expect(result.warnings).toEqual([ @@ -440,11 +613,14 @@ describe("resolveTarget", () => { const result = yield* run( "selectable", { ...none, linked: true, projectRef: Option.some(FLAG_REF) }, - { args: ["db", "push", "--linked", "--project-ref", FLAG_REF, "--dry-run"] }, + { + args: ["db", "push", "--linked", "--project-ref", FLAG_REF, "--dry-run"], + command: ["db", "push"], + }, ); expect(succeeded(result.exit)).toMatchObject({ kind: "linked", ref: FLAG_REF }); expect(result.warnings).toEqual([ - `--linked and --project-ref are deprecated and will be removed in v4. Use --remote instead: supabase db push --dry-run --remote=${FLAG_REF}`, + `--linked and --project-ref are deprecated and will be removed in v4. Use --remote=<ref> instead: supabase db push --remote=${FLAG_REF}`, ]); }), ); @@ -454,11 +630,14 @@ describe("resolveTarget", () => { const result = yield* run( "selectable", { ...none, dbUrl: Option.some(SECRET_URL) }, - { args: ["db", "push", "--db-url", SECRET_URL, "--password", "hunter2"] }, + { + args: ["db", "push", "--db-url", SECRET_URL, "--password", "hunter2"], + command: ["db", "push"], + }, ); expect(succeeded(result.exit)).toMatchObject({ kind: "db-url" }); expect(result.warnings).toEqual([ - "--db-url is deprecated and will be removed in v4. Use --remote instead: supabase db push --password <redacted> --remote=<db-url>, or --remote=abcdefghijklmnopqrst with an access token set", + "--db-url is deprecated and will be removed in v4. Use --remote=<db-url> instead: supabase db push --remote=<db-url>, or --remote=abcdefghijklmnopqrst with an access token set", ]); expect(result.warnings.join("\n")).not.toContain("hunter2"); }), @@ -469,7 +648,7 @@ describe("resolveTarget", () => { const result = yield* run( "selectable", { ...none, local: true }, - { args: ["db", "push", "--local"] }, + { args: ["db", "push", "--local"], command: ["db", "push"] }, ); expect(succeeded(result.exit)).toEqual({ kind: "local" }); expect(result.warnings).toEqual([ @@ -543,6 +722,46 @@ describe("resolveTarget", () => { }), ); + it.effect("hints from SUPABASE_PROJECT_ID when nothing is linked on disk", () => + Effect.gen(function* () { + const result = yield* run("selectable", none, { projectId: Option.some(FILE_REF) }); + expect(result.stderr).toBe( + `Target: local stack (127.0.0.1:54322) · default; pass --remote for ${FILE_REF}\n`, + ); + }), + ); + + it.effect("names the project SUPABASE_PROJECT_ID points at when the cache knows it", () => + Effect.gen(function* () { + const result = yield* run("selectable", none, { + projectId: Option.some(FILE_REF), + cache: { ref: FILE_REF, name: "alpha" }, + }); + expect(result.stderr).toBe( + `Target: local stack (127.0.0.1:54322) · default; pass --remote for alpha (${FILE_REF})\n`, + ); + }), + ); + + it.effect("prefers SUPABASE_PROJECT_ID over the link file", () => + Effect.gen(function* () { + const result = yield* run("selectable", none, { + projectId: Option.some(FILE_REF), + savedRef: FLAG_REF, + }); + expect(result.stderr).toBe( + `Target: local stack (127.0.0.1:54322) · default; pass --remote for ${FILE_REF}\n`, + ); + }), + ); + + it.effect("adds no hint when SUPABASE_PROJECT_ID is not a project ref", () => + Effect.gen(function* () { + const result = yield* run("selectable", none, { projectId: Option.some("not-a-ref") }); + expect(result.stderr).toBe("Target: local stack (127.0.0.1:54322)\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" } }); diff --git a/apps/cli/src/command-internal/target/resolve-target.ts b/apps/cli/src/command-internal/target/resolve-target.ts index 229947d667..49905657cf 100644 --- a/apps/cli/src/command-internal/target/resolve-target.ts +++ b/apps/cli/src/command-internal/target/resolve-target.ts @@ -32,11 +32,14 @@ type SelectedBy = "flag" | "default"; 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))), + const { workdir, projectId } = yield* CommandSettings; + const fileRef = yield* readProjectRefFile(fs, path, workdir).pipe( Effect.orElseSucceed(() => Option.none<string>()), ); + const linkedRef = Option.filter( + Option.orElse(projectId, () => fileRef), + (ref) => PROJECT_REF_PATTERN.test(ref), + ); if (Option.isNone(linkedRef)) return; const cell = yield* Effect.serviceOption(CommandTarget); if (Option.isNone(cell)) return; diff --git a/apps/cli/src/command-internal/target/target-deprecation.ts b/apps/cli/src/command-internal/target/target-deprecation.ts index 20d71c56c6..9efdc4d9f0 100644 --- a/apps/cli/src/command-internal/target/target-deprecation.ts +++ b/apps/cli/src/command-internal/target/target-deprecation.ts @@ -1,21 +1,16 @@ import { BRANCH_PROJECT_REF_PATTERN } from "../ref-patterns.ts"; import type { RemoteValue } from "./remote-value.ts"; -import { DB_URL_PATTERN, isBooleanLiteral } from "./remote-vocabulary.ts"; +import { DB_URL_PATTERN, isBooleanLiteral, stripUserinfo } from "./remote-vocabulary.ts"; import { joinFlags, type TargetDeprecation } from "./target-selector.ts"; -const ALIAS_VALUE_FLAGS: ReadonlySet<string> = new Set([ - "--db-url", - "--project-ref", - "--project-id", -]); -const ALIAS_SWITCH_FLAGS: ReadonlySet<string> = new Set(["--linked", "--local"]); -const SECRET_VALUE_FLAGS: ReadonlySet<string> = new Set(["--password", "-p", "--db-password"]); const SAFE_TOKEN = /^[\w@%+=:,./-]+$/; -const shellQuote = (token: string): string => +export const shellQuote = (token: string): string => SAFE_TOKEN.test(token) ? token : `'${token.replaceAll("'", `'\\''`)}'`; -/** The `--remote` spelling for `value`; a database URL is never echoed. */ +const BRANCH_BY_REF = "--remote=<its ref or UUID>"; + +/** The `--remote` spelling for `value`; a database URL is never echoed, nor is a branch name's userinfo. */ export const remoteFlagText = (value: RemoteValue): string => { switch (value._tag) { case "Linked": @@ -23,56 +18,26 @@ export const remoteFlagText = (value: RemoteValue): string => { case "Ref": return `--remote=${value.ref}`; case "Branch": - return `--remote=${shellQuote(value.name)}`; + return isBooleanLiteral(value.name) + ? BRANCH_BY_REF + : `--remote=${shellQuote(stripUserinfo(value.name))}`; case "DbUrl": return "--remote=<db-url>"; } }; -const aliasNameOf = (token: string): string | undefined => { - const name = token.replace(/=.*$/, "").replace(/^--no-/, "--"); - return ALIAS_VALUE_FLAGS.has(name) || ALIAS_SWITCH_FLAGS.has(name) ? name : undefined; -}; - -/** - * `argv` with the deprecated selector tokens (and any `--remote` that `remote` replaces) removed, - * database URLs and passwords redacted, and `remote` last so it cannot be read as the value of a - * positional (ahead of a literal `--`). - */ -export const rebuildCommand = (argv: ReadonlyArray<string>, remote: string | undefined): string => { - const kept: Array<string> = []; - let tail: ReadonlyArray<string> = []; - for (let index = 0; index < argv.length; index += 1) { - const token = argv[index]; - if (token === undefined) continue; - if (token === "--") { - tail = argv.slice(index).map(shellQuote); - break; - } - if (remote !== undefined && (token === "--remote" || token.startsWith("--remote="))) continue; - const alias = aliasNameOf(token); - if (alias !== undefined) { - const next = argv[index + 1]; - const consumesNext = - !token.includes("=") && - ((ALIAS_VALUE_FLAGS.has(alias) && next !== undefined) || - (ALIAS_SWITCH_FLAGS.has(alias) && next !== undefined && isBooleanLiteral(next))); - if (consumesNext) index += 1; - continue; - } - if (SECRET_VALUE_FLAGS.has(token) && argv[index + 1] !== undefined) { - kept.push(token, "<redacted>"); - index += 1; - continue; - } - const secretFlag = [...SECRET_VALUE_FLAGS].find((flag) => token.startsWith(`${flag}=`)); - if (secretFlag !== undefined) { - kept.push(`${secretFlag}=<redacted>`); - continue; - } - kept.push(DB_URL_PATTERN.test(token) ? "<db-url>" : shellQuote(token)); +/** The `--remote` form a deprecated flag's value maps to, with a placeholder where the user's own value goes. */ +const replacementOf = (value: RemoteValue): string => { + switch (value._tag) { + case "Linked": + return "--remote"; + case "Ref": + return "--remote=<ref>"; + case "Branch": + return isBooleanLiteral(value.name) ? BRANCH_BY_REF : "--remote=<branch>"; + case "DbUrl": + return DB_URL_PATTERN.test(value.url) ? "--remote=<db-url>" : "--remote=<url>"; } - return ["supabase", ...kept, ...(remote === undefined ? [] : [remote]), ...tail].join(" "); }; /** The project ref a Supabase connection string already names: `db.<ref>.<host>` or a `postgres.<ref>` pooler user. */ @@ -91,27 +56,53 @@ export const refFromDbUrl = (url: string, projectHost: string): string | undefin } }; +/** The command the warning's example runs: its path and operand placeholders, never the user's own operands. */ +export interface DeprecationCommand { + /** The resolved command path, e.g. `db push`. */ + readonly path: string; + /** The operand placeholders from the command's help, e.g. `<NAME=VALUE>...`. */ + readonly operands: ReadonlyArray<string>; +} + +const exampleOf = (command: DeprecationCommand | undefined, remote: string | undefined): string => + command === undefined + ? "" + : `: ${["supabase", command.path, ...command.operands, ...(remote === undefined ? [] : [remote])].join(" ")}`; + +const aliasMessage = ( + deprecation: Extract<TargetDeprecation, { readonly kind: "remote-alias" }>, + context: { readonly command: DeprecationCommand | undefined; readonly projectHost: string }, +): string => { + const { flags, remote } = deprecation; + const verb = flags.length > 1 ? "are" : "is"; + const lead = `${joinFlags(flags)} ${verb} deprecated and will be removed in v4.`; + const example = exampleOf(context.command, remoteFlagText(remote)); + + if (remote._tag === "Branch" && isBooleanLiteral(remote.name)) { + return `${lead} Reach a branch named "${remote.name}" with ${BRANCH_BY_REF} instead${example}`; + } + if (remote._tag === "DbUrl" && !DB_URL_PATTERN.test(remote.url)) { + return `${lead} Convert the connection string to a postgresql:// URL and pass it as --remote=<url> instead${exampleOf(context.command, "--remote=<url>")}`; + } + const ref = remote._tag === "DbUrl" ? refFromDbUrl(remote.url, context.projectHost) : undefined; + const alternative = ref === undefined ? "" : `, or --remote=${ref} with an access token set`; + return `${lead} Use ${replacementOf(remote)} instead${example}${alternative}`; +}; + /** - * The warning for each deprecated selector the run used. `argv` is the real command line, so the - * replacement shows the command the user ran; a database URL is never echoed. + * The warning for each deprecated selector the run used. It shows the command path, its operand + * placeholders and the `--remote` replacement last; the user's own operands, other flags and + * database URLs are never echoed, because any of them can be a secret. */ export const deprecationMessages = ( deprecations: ReadonlyArray<TargetDeprecation>, - context: { readonly argv: ReadonlyArray<string> | undefined; readonly projectHost: string }, + context: { + readonly command: DeprecationCommand | undefined; + readonly projectHost: string; + }, ): ReadonlyArray<string> => - deprecations.map((deprecation) => { - const { argv } = context; - if (deprecation.kind === "local") { - const example = argv === undefined ? "" : `: ${rebuildCommand(argv, undefined)}`; - return `--local isn't needed (local is the default) and will be removed in v4${example}`; - } - const remote = remoteFlagText(deprecation.remote); - const example = argv === undefined ? "" : `: ${rebuildCommand(argv, remote)}`; - const ref = - deprecation.remote._tag === "DbUrl" - ? refFromDbUrl(deprecation.remote.url, context.projectHost) - : undefined; - const alternative = ref === undefined ? "" : `, or --remote=${ref} with an access token set`; - const verb = deprecation.flags.length > 1 ? "are" : "is"; - return `${joinFlags(deprecation.flags)} ${verb} deprecated and will be removed in v4. Use --remote instead${example}${alternative}`; - }); + deprecations.map((deprecation) => + deprecation.kind === "local" + ? `--local isn't needed (local is the default) and will be removed in v4${exampleOf(context.command, undefined)}` + : aliasMessage(deprecation, context), + ); diff --git a/apps/cli/src/command-internal/target/target-deprecation.unit.test.ts b/apps/cli/src/command-internal/target/target-deprecation.unit.test.ts index 72024384d3..bd4d638451 100644 --- a/apps/cli/src/command-internal/target/target-deprecation.unit.test.ts +++ b/apps/cli/src/command-internal/target/target-deprecation.unit.test.ts @@ -1,8 +1,8 @@ import { describe, expect, it } from "vitest"; import { + type DeprecationCommand, deprecationMessages, - rebuildCommand, refFromDbUrl, remoteFlagText, } from "./target-deprecation.ts"; @@ -17,73 +17,14 @@ describe("remoteFlagText", () => { { remote: { _tag: "Ref", ref: REF }, expected: `--remote=${REF}` }, { remote: { _tag: "Branch", name: "feature-x" }, expected: "--remote=feature-x" }, { remote: { _tag: "Branch", name: "my branch" }, expected: "--remote='my branch'" }, + { remote: { _tag: "Branch", name: "u:pw@staging" }, expected: "--remote=staging" }, + { remote: { _tag: "Branch", name: "TRUE" }, expected: "--remote=<its ref or UUID>" }, { remote: { _tag: "DbUrl", url: "postgresql://u:pw@h/db" }, expected: "--remote=<db-url>" }, ] as const)("spells $remote as $expected", ({ remote, expected }) => { expect(remoteFlagText(remote)).toBe(expected); }); }); -describe("rebuildCommand", () => { - it.each([ - { - name: "drops a deprecated switch and puts --remote last", - argv: ["db", "push", "--linked", "--dry-run"], - remote: "--remote", - expected: "supabase db push --dry-run --remote", - }, - { - name: "drops a deprecated value flag together with its value", - argv: ["db", "dump", "--project-ref", REF, "-f", "out.sql"], - remote: `--remote=${REF}`, - expected: `supabase db dump -f out.sql --remote=${REF}`, - }, - { - name: "drops an inline deprecated value", - argv: ["db", "dump", `--project-ref=${REF}`], - remote: `--remote=${REF}`, - expected: `supabase db dump --remote=${REF}`, - }, - { - name: "drops a boolean literal that follows a deprecated switch", - argv: ["db", "push", "--linked", "true"], - remote: "--remote", - expected: "supabase db push --remote", - }, - { - name: "replaces a --remote the alias already maps to", - argv: ["db", "push", "--remote", "--project-ref", REF], - remote: `--remote=${REF}`, - expected: `supabase db push --remote=${REF}`, - }, - { - name: "keeps --remote ahead of a literal --", - argv: ["functions", "deploy", "--linked", "--", "-weird"], - remote: "--remote", - expected: "supabase functions deploy --remote -- -weird", - }, - { - name: "redacts a database URL and a password", - argv: ["db", "push", "--db-url", "postgresql://u:pw@h/db", "--password", "hunter2"], - remote: "--remote=<db-url>", - expected: "supabase db push --password <redacted> --remote=<db-url>", - }, - { - name: "redacts an inline password and a stray URL operand", - argv: ["db", "push", "--password=hunter2", "postgresql://u:pw@h/db", "--local"], - remote: undefined, - expected: "supabase db push --password=<redacted> <db-url>", - }, - { - name: "quotes a token the shell would split", - argv: ["storage", "ls", "ss:///my bucket", "--linked"], - remote: "--remote", - expected: "supabase storage ls 'ss:///my bucket' --remote", - }, - ])("$name", ({ argv, remote, expected }) => { - expect(rebuildCommand(argv, remote)).toBe(expected); - }); -}); - describe("refFromDbUrl", () => { it.each([ { url: `postgresql://postgres:pw@db.${REF}.supabase.co:5432/postgres`, expected: REF }, @@ -100,17 +41,28 @@ describe("refFromDbUrl", () => { }); describe("deprecationMessages", () => { + const dbPush: DeprecationCommand = { path: "db push", operands: [] }; + const secretsSet: DeprecationCommand = { path: "secrets set", operands: ["<NAME=VALUE>..."] }; + const messages = ( deprecations: ReadonlyArray<TargetDeprecation>, - argv: ReadonlyArray<string> | undefined, - ) => deprecationMessages(deprecations, { argv, projectHost: HOST }); + command: DeprecationCommand | undefined = dbPush, + ) => deprecationMessages(deprecations, { command, projectHost: HOST }); - it("names the flag, the replacement and v4, with the command as the user ran it", () => { + it("shows the command path, its operand placeholders and the replacement last", () => { expect( messages( - [{ kind: "remote-alias", flags: ["--linked"], remote: { _tag: "Linked" } }], - ["db", "push", "--linked"], + [{ kind: "remote-alias", flags: ["--project-ref"], remote: { _tag: "Ref", ref: REF } }], + secretsSet, ), + ).toEqual([ + `--project-ref is deprecated and will be removed in v4. Use --remote=<ref> instead: supabase secrets set <NAME=VALUE>... --remote=${REF}`, + ]); + }); + + it("points a deprecated --linked at a bare --remote", () => { + expect( + messages([{ kind: "remote-alias", flags: ["--linked"], remote: { _tag: "Linked" } }]), ).toEqual([ "--linked is deprecated and will be removed in v4. Use --remote instead: supabase db push --remote", ]); @@ -118,45 +70,82 @@ describe("deprecationMessages", () => { it("joins every alias the run used into one warning", () => { expect( - messages( - [ - { - kind: "remote-alias", - flags: ["--linked", "--project-ref"], - remote: { _tag: "Ref", ref: REF }, - }, - ], - ["db", "push", "--linked", "--project-ref", REF], - ), + messages([ + { + kind: "remote-alias", + flags: ["--linked", "--project-ref"], + remote: { _tag: "Ref", ref: REF }, + }, + ]), + ).toEqual([ + `--linked and --project-ref are deprecated and will be removed in v4. Use --remote=<ref> instead: supabase db push --remote=${REF}`, + ]); + }); + + it("shows the branch an alias value names, without its userinfo", () => { + expect( + messages([ + { + kind: "remote-alias", + flags: ["--project-ref"], + remote: { _tag: "Branch", name: "staging" }, + }, + ]), ).toEqual([ - `--linked and --project-ref are deprecated and will be removed in v4. Use --remote instead: supabase db push --remote=${REF}`, + "--project-ref is deprecated and will be removed in v4. Use --remote=<branch> instead: supabase db push --remote=staging", ]); }); it("offers the project ref a Supabase database URL already names, without echoing the URL", () => { const url = `postgresql://postgres:hunter2@db.${REF}.supabase.co:5432/postgres`; - const [message] = messages( - [{ kind: "remote-alias", flags: ["--db-url"], remote: { _tag: "DbUrl", url } }], - ["db", "push", "--db-url", url], + const [message] = messages([ + { kind: "remote-alias", flags: ["--db-url"], remote: { _tag: "DbUrl", url } }, + ]); + expect(message).toBe( + `--db-url is deprecated and will be removed in v4. Use --remote=<db-url> instead: supabase db push --remote=<db-url>, or --remote=${REF} with an access token set`, ); + expect(message).not.toContain("hunter2"); + }); + + it("asks for a postgresql:// URL when --db-url held a keyword connection string", () => { + const [message] = messages([ + { + kind: "remote-alias", + flags: ["--db-url"], + remote: { _tag: "DbUrl", url: "host=db.example.com user=postgres password=hunter2" }, + }, + ]); expect(message).toBe( - `--db-url is deprecated and will be removed in v4. Use --remote instead: supabase db push --remote=<db-url>, or --remote=${REF} with an access token set`, + "--db-url is deprecated and will be removed in v4. Convert the connection string to a postgresql:// URL and pass it as --remote=<url> instead: supabase db push --remote=<url>", ); expect(message).not.toContain("hunter2"); }); - it("omits the command line when the argv is unknown", () => { + it.each(["true", "No", "OFF", "1"])( + "says a branch named %s is reached by its ref or UUID", + (name) => { + expect( + messages([ + { kind: "remote-alias", flags: ["--project-ref"], remote: { _tag: "Branch", name } }, + ]), + ).toEqual([ + `--project-ref is deprecated and will be removed in v4. Reach a branch named "${name}" with --remote=<its ref or UUID> instead: supabase db push --remote=<its ref or UUID>`, + ]); + }, + ); + + it("drops the example when the command is unknown", () => { expect( - messages( + deprecationMessages( [{ kind: "remote-alias", flags: ["--project-id"], remote: { _tag: "Ref", ref: REF } }], - undefined, + { command: undefined, projectHost: HOST }, ), - ).toEqual(["--project-id is deprecated and will be removed in v4. Use --remote instead"]); + ).toEqual(["--project-id is deprecated and will be removed in v4. Use --remote=<ref> instead"]); }); - it("says --local is not needed and drops it from the example", () => { - expect(messages([{ kind: "local" }], ["db", "push", "--local"])).toEqual([ - "--local isn't needed (local is the default) and will be removed in v4: supabase db push", + it("says --local is not needed and shows the command without it", () => { + expect(messages([{ kind: "local" }], secretsSet)).toEqual([ + "--local isn't needed (local is the default) and will be removed in v4: supabase secrets set <NAME=VALUE>...", ]); }); }); diff --git a/apps/cli/src/command-internal/target/target-flags.ts b/apps/cli/src/command-internal/target/target-flags.ts index 1c513795b6..a70f9c3e56 100644 --- a/apps/cli/src/command-internal/target/target-flags.ts +++ b/apps/cli/src/command-internal/target/target-flags.ts @@ -1,7 +1,7 @@ -import { Effect, Option, Redacted } from "effect"; +import { Effect, Option, Redacted, Result } from "effect"; import { Flag, GlobalFlag, Param, Primitive } from "effect/cli"; -import { classifyRemoteValue, LEAVE_OUT_REMOTE_PREFIX, type RemoteValue } from "./remote-value.ts"; +import { parseRemoteValue, type RemoteValue, type RemoteValueRejection } from "./remote-value.ts"; import { REMOTE_FLAG_NAME } from "./remote-vocabulary.ts"; import type { ResolvedTarget } from "./resolve-target.ts"; import { remoteFlagText } from "./target-deprecation.ts"; @@ -20,6 +20,8 @@ interface SelectableRemoteFlagVariant<DbUrl extends boolean> { interface HostedRemoteFlagVariant { readonly kind: "hosted"; + /** A command that acts on a parent project, such as `branches *`: its value is a ref, never a branch name. */ + readonly parent: boolean; } type RemoteFlagVariant = SelectableRemoteFlagVariant<boolean> | HostedRemoteFlagVariant; @@ -32,19 +34,44 @@ type RemoteValueWithUrl = RemoteValue & { readonly [urlAcceptance]?: "accepted" /** A `--remote` value on a command with no database connection; the brand exists only in the type. */ type RemoteValueWithoutUrl = RemoteValue & { readonly [urlAcceptance]?: "rejected" }; +/** What the variant's `--remote` takes as a value; the help rows, the flag description and the errors all read it. */ +const valueHintOf = (variant: RemoteFlagVariant): string => { + if (variant.kind === "hosted") return variant.parent ? "<ref>" : "<ref|branch>"; + return variant.dbUrl ? "<ref|branch|url>" : "<ref|branch>"; +}; + +const acceptedValuesOf = (variant: RemoteFlagVariant): string => { + if (variant.kind === "hosted" && variant.parent) return "a project ref"; + const url = variant.kind === "selectable" && variant.dbUrl ? "a postgres:// URL, " : ""; + return `${url}a project ref or a branch name`; +}; + const remoteDescription = (variant: RemoteFlagVariant): string => { + const hint = valueHintOf(variant); if (variant.kind === "hosted") { - return "Target another project: --remote=<ref|branch>. Defaults to the linked project."; + return `Target another project: --remote=${hint}. Defaults to the linked project.`; } return variant.dbUrl - ? "Target a remote database: the linked project, or --remote=<ref|branch|url>." - : `Target a remote ${variant.subject}: the linked project, or --remote=<ref|branch>.`; + ? `Target a remote database: the linked project, or --remote=${hint}.` + : `Target a remote ${variant.subject}: the linked project, or --remote=${hint}.`; }; -const leaveOutMessage = (variant: RemoteFlagVariant): string => - variant.kind === "hosted" - ? `${LEAVE_OUT_REMOTE_PREFIX}to use the linked project.` - : `${LEAVE_OUT_REMOTE_PREFIX}to target the local ${variant.subject}.`; +const rejectionMessage = (variant: RemoteFlagVariant, rejection: RemoteValueRejection): string => { + const fallback = + variant.kind === "hosted" ? "use the linked project" : `target the local ${variant.subject}`; + switch (rejection._tag) { + case "Empty": + return `--remote= has an empty value. Pass --remote for the linked project or --remote=${valueHintOf(variant)}; leave it out to ${fallback}.`; + case "Off": + return `--remote doesn't take false. Leave it out to ${fallback}.`; + case "ForeignUrl": + return `--remote takes ${acceptedValuesOf(variant)}${ + rejection.ref === undefined + ? "." + : `; to target that project, use --remote=${rejection.ref}.` + }`; + } +}; const REMOTE_PRIMITIVE = Symbol.for("supabase/cli/RemotePrimitive"); @@ -55,10 +82,11 @@ const REMOTE_PRIMITIVE = Symbol.for("supabase/cli/RemotePrimitive"); const remotePrimitive = (variant: RemoteFlagVariant): Primitive.Primitive<RemoteValue> => Object.assign(Object.create(Primitive.Boolean), { [REMOTE_PRIMITIVE]: true, - parse: (raw: string) => { - const value = classifyRemoteValue(raw); - return value === undefined ? Effect.fail(leaveOutMessage(variant)) : Effect.succeed(value); - }, + parse: (raw: string) => + Result.match(parseRemoteValue(raw), { + onFailure: (rejection) => Effect.fail(rejectionMessage(variant, rejection)), + onSuccess: Effect.succeed, + }), }); /** Whether a flag was declared by {@link targetFlags}, {@link projectTargetFlags} or the `seed` globals as `--remote`. */ @@ -78,6 +106,7 @@ function remoteFlag(variant: RemoteFlagVariant): Flag.Flag<Option.Option<RemoteV kind: Param.flagKind, name: REMOTE_FLAG_NAME, primitiveType: remotePrimitive(variant), + typeName: valueHintOf(variant), }).pipe(Flag.withDescription(remoteDescription(variant)), Flag.optional); } @@ -175,9 +204,9 @@ export function targetFlags( }; } -/** The flags of a hosted-only command: `--remote=<ref|branch>` and the deprecated `--project-ref` alias. */ -export const projectTargetFlags = () => ({ - remote: remoteFlag({ kind: "hosted" }), +/** The flags of a hosted-only command: `--remote=<ref|branch>` and the deprecated `--project-ref` alias; `parent` narrows the value to a ref. */ +export const projectTargetFlags = (options: { readonly parent?: true } = {}) => ({ + remote: remoteFlag({ kind: "hosted", parent: options.parent === true }), projectRef: projectRefFlag(), }); 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 0f3bfee471..3acf80e1f1 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 @@ -113,28 +113,84 @@ describe("targetFlags", () => { ); it.effect.each([ - { args: ["--remote=false"], value: "false" }, - { args: ["--remote=0"], value: "0" }, - { args: ["--remote", "off"], value: "off" }, - { args: ["--no-remote"], value: "false" }, - { args: ["--remote="], value: "" }, + { args: ["--remote=false"] }, + { args: ["--remote=0"] }, + { args: ["--remote", "off"] }, + { args: ["--no-remote"] }, ])("rejects $args by pointing at the local default", ({ args }) => Effect.gen(function* () { expect(yield* rejectedMessage(withDbUrl, args)).toBe( - "Leave out --remote to target the local database.", + "--remote doesn't take false. Leave it out to target the local database.", ); }), ); - it.effect("names the Storage API in its local-default message", () => + it.effect("rejects an empty value and offers every form the command takes", () => + Effect.gen(function* () { + expect(yield* rejectedMessage(withDbUrl, ["--remote="])).toBe( + "--remote= has an empty value. Pass --remote for the linked project or --remote=<ref|branch|url>; leave it out to target the local database.", + ); + }), + ); + + it.effect("names the Storage API in its local-default messages", () => Effect.gen(function* () { const storage = targetFlags({ subject: "Storage API", dbUrl: false }); expect(yield* rejectedMessage(storage, ["--no-remote"])).toBe( - "Leave out --remote to target the local Storage API.", + "--remote doesn't take false. Leave it out to target the local Storage API.", + ); + expect(yield* rejectedMessage(storage, ["--remote="])).toBe( + "--remote= has an empty value. Pass --remote for the linked project or --remote=<ref|branch>; leave it out to target the local Storage API.", ); }), ); + it.effect.each([ + { + value: `https://${REF}.supabase.co`, + message: `--remote takes a postgres:// URL, a project ref or a branch name; to target that project, use --remote=${REF}.`, + }, + { + value: `HTTPS://${REF}.supabase.co/dashboard`, + message: `--remote takes a postgres:// URL, a project ref or a branch name; to target that project, use --remote=${REF}.`, + }, + { + value: "https://example.com/db", + message: "--remote takes a postgres:// URL, a project ref or a branch name.", + }, + ])( + "rejects $value because a URL that is not postgres:// never names a branch", + ({ value, message }) => + Effect.gen(function* () { + expect(yield* rejectedMessage(withDbUrl, [`--remote=${value}`])).toBe(message); + }), + ); + + it.effect( + "leaves the postgres:// URL out of the foreign-URL message where none is accepted", + () => + Effect.gen(function* () { + const storage = targetFlags({ subject: "Storage API", dbUrl: false }); + expect(yield* rejectedMessage(storage, ["--remote=https://example.com"])).toBe( + "--remote takes a project ref or a branch name.", + ); + }), + ); + + it.effect.each([ + { value: ` ${URL} `, expected: { _tag: "DbUrl", url: URL } }, + { value: "POSTGRESQL://u:pw@h/db", expected: { _tag: "DbUrl", url: "POSTGRESQL://u:pw@h/db" } }, + { value: "Postgres://h/db", expected: { _tag: "DbUrl", url: "Postgres://h/db" } }, + { value: ` ${REF} `, expected: { _tag: "Ref", ref: REF } }, + { value: " staging ", expected: { _tag: "Branch", name: "staging" } }, + ])("trims and classifies the value %j", ({ value, expected }) => + Effect.gen(function* () { + expect(yield* parseWith(withDbUrl, [`--remote=${value}`])).toMatchObject({ + remote: Option.some(expected), + }); + }), + ); + it("omits --db-url for commands without a database connection", () => { const storage = targetFlags({ subject: "Storage API", dbUrl: false }); expect(Object.keys(storage).sort()).toEqual(["linked", "local", "projectRef", "remote"]); @@ -210,10 +266,28 @@ describe("projectTargetFlags", () => { }), ); - it.effect("rejects an off value by pointing at the linked project", () => + it.effect("rejects an off value and an empty value by pointing at the linked project", () => Effect.gen(function* () { expect(yield* rejectedMessage(hosted, ["--no-remote"])).toBe( - "Leave out --remote to use the linked project.", + "--remote doesn't take false. Leave it out to use the linked project.", + ); + expect(yield* rejectedMessage(hosted, ["--remote="])).toBe( + "--remote= has an empty value. Pass --remote for the linked project or --remote=<ref|branch>; leave it out to use the linked project.", + ); + }), + ); + + it.effect("takes only a project ref on a parent-scoped command", () => + Effect.gen(function* () { + const parent = projectTargetFlags({ parent: true }); + expect(yield* rejectedMessage(parent, ["--remote="])).toBe( + "--remote= has an empty value. Pass --remote for the linked project or --remote=<ref>; leave it out to use the linked project.", + ); + expect(yield* rejectedMessage(parent, ["--remote=https://example.com"])).toBe( + "--remote takes a project ref.", + ); + expect(describeFlag(parent.remote).description).toBe( + "Target another project: --remote=<ref>. Defaults to the linked project.", ); }), ); @@ -267,6 +341,12 @@ describe("isRemoteFlag", () => { }); describe("seedTargetGlobalFlags", () => { + it("takes a ref or a branch, never a database URL", () => { + expect(describeFlag(seedTargetGlobalFlags.remote.flag).description).toBe( + "Target a remote Storage API: the linked project, or --remote=<ref|branch>.", + ); + }); + it("shares the selector flag names with targetFlags", () => { expect(describeFlag(seedTargetGlobalFlags.remote.flag).name).toBe("remote"); expect(describeFlag(seedTargetGlobalFlags.linked.flag).name).toBe("linked"); diff --git a/apps/cli/src/command-internal/target/target-identity.ts b/apps/cli/src/command-internal/target/target-identity.ts index 498a9ca600..12374c59ac 100644 --- a/apps/cli/src/command-internal/target/target-identity.ts +++ b/apps/cli/src/command-internal/target/target-identity.ts @@ -23,6 +23,16 @@ const named = (name: string | undefined, ref: string): string => { return safeName.length === 0 ? ref : `${safeName} (${ref})`; }; +const productionTarget = (ref: string, name: string | undefined): RenderedTarget => ({ + text: `project ${named(name, ref)} · production`, + json: { + kind: "linked", + project_ref: ref, + ...(name === undefined ? {} : { project_name: name }), + environment: "production", + }, +}); + /** * 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 @@ -40,17 +50,7 @@ export const describeLinkedTarget = (input: { }): RenderedTarget => { const { ref } = 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?.ref === ref) return productionTarget(ref, cached.name); const differsFromLinked = Option.exists( input.linkedRef ?? Option.none(), (linked) => linked !== ref, @@ -76,12 +76,19 @@ export const linkedAlternativeHint = (input: { return `default; pass --remote 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. */ +/** + * A branch resolved to its own project ref; `branch` is absent for a UUID lookup and `parentRef` + * for anything but a name lookup. The default branch is the linked project itself, so it renders + * as production. + */ export const renderBranchTarget = (input: { readonly branch: Option.Option<string>; readonly ref: string; readonly parentRef: Option.Option<string>; + readonly parentName?: string | undefined; + readonly isDefault?: boolean; }): RenderedTarget => { + if (input.isDefault === true) return productionTarget(input.ref, input.parentName); const branch = Option.map(input.branch, sanitizeInlineName); const parentRef = Option.getOrUndefined(input.parentRef); const label = Option.match(branch, { @@ -89,7 +96,8 @@ export const renderBranchTarget = (input: { onSome: (name) => `branch ${name} (${input.ref})`, }); return { - text: parentRef === undefined ? label : `project ${parentRef} · ${label}`, + text: + parentRef === undefined ? label : `project ${named(input.parentName, parentRef)} · ${label}`, json: { kind: "linked", project_ref: input.ref, 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 41899d8654..6bd3620852 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 @@ -173,4 +173,39 @@ describe("renderBranchTarget", () => { json: { kind: "linked", project_ref: OTHER_REF, environment: "branch" }, }); }); + + it("names the parent project when its name is known", () => { + const target = renderBranchTarget({ + branch: Option.some("feature-x"), + ref: OTHER_REF, + parentRef: Option.some(REF), + parentName: "alpha", + }); + expect(target.text).toBe(`project alpha (${REF}) · branch feature-x (${OTHER_REF})`); + }); + + it("renders the default branch as the production project", () => { + const target = renderBranchTarget({ + branch: Option.some("main"), + ref: REF, + parentRef: Option.some(REF), + parentName: "alpha", + isDefault: true, + }); + expect(target).toEqual({ + text: `project alpha (${REF}) · production`, + json: { kind: "linked", project_ref: REF, project_name: "alpha", environment: "production" }, + }); + }); + + it("renders an unnamed default branch by its ref", () => { + const target = renderBranchTarget({ + branch: Option.some("main"), + ref: REF, + parentRef: Option.some(REF), + isDefault: true, + }); + expect(target.text).toBe(`project ${REF} · production`); + expect(target.json).toEqual({ kind: "linked", project_ref: REF, environment: "production" }); + }); }); diff --git a/apps/cli/src/command-internal/target/target-selector.ts b/apps/cli/src/command-internal/target/target-selector.ts index 2b9734fc04..ad4055994d 100644 --- a/apps/cli/src/command-internal/target/target-selector.ts +++ b/apps/cli/src/command-internal/target/target-selector.ts @@ -1,9 +1,10 @@ import { Option, Result } from "effect"; -import { classifyNamedRemote, type RemoteValue } from "./remote-value.ts"; +import { parseNamedRemote, type RemoteValue } from "./remote-value.ts"; import type { TargetFlagValues } from "./target-flags.ts"; import { TargetEmptyValueError, + TargetInvalidValueError, TargetNeedsProjectError, TargetProjectRefConflictError, TargetSelectorConflictError, @@ -46,6 +47,8 @@ export interface ParsedTargetSelector { export interface TargetSelectorOptions { /** Names the command in the error for a database URL it cannot use. */ readonly commandName?: string; + /** What `--remote` takes on this command, for the suggestions; a command with no `dbUrl` input defaults to `<ref|branch>`. */ + readonly valueHint?: string; } /** Lists flag names as `a`, `a and b` or `a, b and c`. */ @@ -60,10 +63,20 @@ const emptyValueError = (flag: "--db-url" | "--project-ref" | "--project-id") => message: `${flag} cannot be empty.`, suggestion: flag === "--db-url" - ? "Pass a connection string, for example --remote=postgresql://postgres:postgres@127.0.0.1:54322/postgres" + ? "Pass a connection string as --remote=<db-url>." : "Pass a project ref, for example --remote=abcdefghijklmnopqrst", }); +const PROJECT_REF_CONFLICT_SUGGESTION: Readonly< + Record<"--remote" | "--local" | "--db-url", string> +> = { + "--remote": "Use --remote=<ref> alone.", + "--local": "Use --remote=<ref> for the project, or leave both out for the local database.", + "--db-url": "Use --remote=<ref> for the project or --remote=<url> for the database, not both.", +}; + +const trimmed = (value: string): string => value.trim(); + /** * A `false` switch counts as absent, so `--linked=false` and `--no-linked` select nothing. * `--linked`, `--project-ref`, `--project-id` and `--db-url` resolve exactly like the `--remote` @@ -74,9 +87,11 @@ export const parseTargetSelector = ( options: TargetSelectorOptions = {}, ): Result.Result<ParsedTargetSelector, TargetSelectorError> => { const remote = Option.getOrUndefined(input.remote); - const dbUrl = Option.getOrUndefined(input.dbUrl ?? Option.none()); - const projectRef = Option.getOrUndefined(input.projectRef); - const projectId = Option.getOrUndefined(input.projectId ?? Option.none()); + const dbUrl = Option.getOrUndefined(Option.map(input.dbUrl ?? Option.none(), trimmed)); + const projectRef = Option.getOrUndefined(Option.map(input.projectRef, trimmed)); + const projectId = Option.getOrUndefined(Option.map(input.projectId ?? Option.none(), trimmed)); + const valueHint = + options.valueHint ?? (input.dbUrl === undefined ? "<ref|branch>" : "<ref|branch|url>"); if (dbUrl === "") return Result.fail(emptyValueError("--db-url")); if (projectRef === "") return Result.fail(emptyValueError("--project-ref")); @@ -88,11 +103,16 @@ export const parseTargetSelector = ( ...(dbUrl === undefined ? [] : ["--db-url"]), ...(input.local ? ["--local"] : []), ]; + const available = + input.dbUrl === undefined + ? ["--remote", "--linked", "--local"] + : ["--remote", "--linked", "--db-url", "--local"]; if (selected.length > 1) { return Result.fail( new TargetSelectorConflictError({ flags: selected, - message: `Only one of --remote, --linked, --db-url and --local can be set; got ${joinFlags(selected)}.`, + message: `Only one of ${joinFlags(available)} can be set; got ${joinFlags(selected)}.`, + suggestion: `Use --remote or --remote=${valueHint} on its own.`, }), ); } @@ -102,6 +122,7 @@ export const parseTargetSelector = ( new TargetSelectorConflictError({ flags: ["--project-id", "--project-ref"], message: "--project-id is a deprecated alias of --project-ref; pass only one of them.", + suggestion: "Use --remote=<ref> instead.", }), ); } @@ -121,13 +142,28 @@ export const parseTargetSelector = ( conflictsWith, message: conflictsWith === "--remote" - ? `${aliasFlag} cannot be combined with --remote=<value>; use --remote=<ref> alone.` + ? `${aliasFlag} cannot be combined with --remote=<value>.` : `${aliasFlag} selects a remote project and cannot be combined with ${conflictsWith}.`, + suggestion: PROJECT_REF_CONFLICT_SUGGESTION[conflictsWith], }), ); } } + const aliasRemote = aliasValue === undefined ? undefined : parseNamedRemote(aliasValue); + if (aliasRemote !== undefined && Result.isFailure(aliasRemote)) { + return Result.fail( + new TargetInvalidValueError({ + flag: aliasFlag, + message: `${aliasFlag} takes a project ref or a branch name, not a URL.`, + suggestion: + aliasRemote.failure.ref === undefined + ? "Pass a project ref or a branch name, for example --remote=abcdefghijklmnopqrst" + : `Use --remote=${aliasRemote.failure.ref}.`, + }), + ); + } + const outcome = ((): { readonly selection: TargetSelection; readonly deprecations: ReadonlyArray<TargetDeprecation>; @@ -140,15 +176,14 @@ export const parseTargetSelector = ( deprecations: [{ kind: "remote-alias", flags: ["--db-url"], remote: value }], }; } - if (aliasValue !== undefined) { - const value = classifyNamedRemote(aliasValue); + if (aliasRemote !== undefined && Result.isSuccess(aliasRemote)) { return { - selection: value, + selection: aliasRemote.success, deprecations: [ { kind: "remote-alias", flags: input.linked ? ["--linked", aliasFlag] : [aliasFlag], - remote: value, + remote: aliasRemote.success, }, ], }; 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 e6e3521c30..258a3c5391 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 @@ -146,6 +146,57 @@ describe("parseTargetSelector", () => { }); }); + describe("alias values", () => { + it.each([ + { flag: "--project-ref", overrides: { projectRef: Option.some("https://example.com") } }, + { flag: "--project-id", overrides: { projectId: Option.some("https://example.com") } }, + ] as const)("rejects a URL given to $flag", ({ flag, overrides }) => { + expect(failure(overrides)).toMatchObject({ + _tag: "TargetInvalidValueError", + flag, + message: `${flag} takes a project ref or a branch name, not a URL.`, + suggestion: + "Pass a project ref or a branch name, for example --remote=abcdefghijklmnopqrst", + }); + }); + + it("suggests the ref a project URL already names", () => { + expect(failure({ projectRef: Option.some(`https://${REF}.supabase.co`) })).toMatchObject({ + _tag: "TargetInvalidValueError", + suggestion: `Use --remote=${REF}.`, + }); + }); + + it("trims an alias value before it is classified", () => { + expect(success({ projectRef: Option.some(` ${REF} `) }).selection).toEqual({ + _tag: "Ref", + ref: REF, + }); + }); + + it.each(["true", "False", "YES", "off", "1", "0"])( + "keeps looking up a branch named %s through --project-ref", + (name) => { + expect(success({ projectRef: Option.some(name) }).selection).toEqual({ + _tag: "Branch", + name, + }); + }, + ); + + it("keeps a keyword connection string given to --db-url working", () => { + const dsn = "host=db.example.com user=postgres password=pw"; + expect(success({ dbUrl: Option.some(dsn) }).selection).toEqual({ _tag: "DbUrl", url: dsn }); + }); + + it("treats a postgres URL in any case as a database", () => { + expect(success({ projectRef: Option.some("POSTGRESQL://h/db") }).selection).toEqual({ + _tag: "DbUrl", + url: "POSTGRESQL://h/db", + }); + }); + }); + describe("conflicts", () => { it.each([ ["--linked", { linked: true }, ["--remote", "--linked"]], @@ -196,7 +247,7 @@ describe("parseTargetSelector", () => { [ "--remote=<ref>", remote({ _tag: "Ref", ref: REF }), - "--project-ref cannot be combined with --remote=<value>; use --remote=<ref> alone.", + "--project-ref cannot be combined with --remote=<value>.", ], ] as const)("rejects --project-ref with %s", (_flag, overrides, message) => { expect(failure({ ...overrides, projectRef: Option.some(REF) })).toMatchObject({ @@ -205,6 +256,59 @@ describe("parseTargetSelector", () => { }); }); + it.each([ + ["--local", { local: true }, "Use --remote=<ref> for the project, or leave both out"], + [ + "--db-url", + { dbUrl: Option.some(URL) }, + "Use --remote=<ref> for the project or --remote=<url> for the database, not both.", + ], + ["--remote=<ref>", remote({ _tag: "Ref", ref: REF }), "Use --remote=<ref> alone."], + ] as const)( + "suggests the --remote form when --project-ref meets %s", + (_flag, overrides, hint) => { + expect(failure({ ...overrides, projectRef: Option.some(REF) })).toMatchObject({ + suggestion: expect.stringContaining(hint), + }); + }, + ); + + it("suggests --remote=<ref> when --project-id meets --project-ref", () => { + expect(failure({ projectRef: Option.some(REF), projectId: Option.some(REF) })).toMatchObject({ + message: "--project-id is a deprecated alias of --project-ref; pass only one of them.", + suggestion: "Use --remote=<ref> instead.", + }); + }); + + it("suggests the --remote form for a selector conflict", () => { + expect(failure({ ...remote({ _tag: "Linked" }), linked: true })).toMatchObject({ + suggestion: "Use --remote or --remote=<ref|branch|url> on its own.", + }); + }); + + it("lists only the selectors a command without a database connection has", () => { + const result = parseTargetSelector({ + remote: Option.some({ _tag: "Linked" }), + local: true, + linked: false, + projectRef: Option.none(), + }); + expect(result).toMatchObject({ + failure: { + _tag: "TargetSelectorConflictError", + message: + "Only one of --remote, --linked and --local can be set; got --remote and --local.", + suggestion: "Use --remote or --remote=<ref|branch> on its own.", + }, + }); + }); + + it("suggests the parent-scoped hint when the command passes one", () => { + expect( + failure({ ...remote({ _tag: "Linked" }), linked: true }, { valueHint: "<ref>" }), + ).toMatchObject({ suggestion: "Use --remote or --remote=<ref> on its own." }); + }); + it("rejects --project-id with --project-ref", () => { expect(failure({ projectRef: Option.some(REF), projectId: Option.some(REF) })).toMatchObject({ _tag: "TargetSelectorConflictError", @@ -221,11 +325,7 @@ describe("parseTargetSelector", () => { describe("empty values", () => { it.each([ - [ - "--db-url", - { dbUrl: Option.some("") }, - "Pass a connection string, for example --remote=postgresql://postgres:postgres@127.0.0.1:54322/postgres", - ], + ["--db-url", { dbUrl: Option.some("") }, "Pass a connection string as --remote=<db-url>."], [ "--project-ref", { projectRef: Option.some("") }, diff --git a/apps/cli/src/command-internal/target/target.errors.ts b/apps/cli/src/command-internal/target/target.errors.ts index 628a6767ca..4c59b447b2 100644 --- a/apps/cli/src/command-internal/target/target.errors.ts +++ b/apps/cli/src/command-internal/target/target.errors.ts @@ -4,12 +4,14 @@ import { actionability, type CliErrorActionabilityDeclaration, ErrorActionabilityId, + statusCodeActionability, } from "../../shared/telemetry/error-actionability.ts"; /** More than one of `--remote`, `--linked`, `--db-url` and `--local` was set. */ export class TargetSelectorConflictError extends Data.TaggedError("TargetSelectorConflictError")<{ readonly flags: ReadonlyArray<string>; readonly message: string; + readonly suggestion: string; }> { get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { return actionability.provideFlags; @@ -22,6 +24,7 @@ export class TargetProjectRefConflictError extends Data.TaggedError( )<{ readonly conflictsWith: "--remote" | "--local" | "--db-url"; readonly message: string; + readonly suggestion: string; }> { get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { return actionability.provideFlags; @@ -39,6 +42,17 @@ export class TargetEmptyValueError extends Data.TaggedError("TargetEmptyValueErr } } +/** `--project-ref` or `--project-id` was given a URL, which names neither a project ref nor a branch. */ +export class TargetInvalidValueError extends Data.TaggedError("TargetInvalidValueError")<{ + readonly flag: "--project-ref" | "--project-id"; + readonly message: string; + readonly suggestion: string; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} + /** A command that acts on a project was given a database URL. */ export class TargetNeedsProjectError extends Data.TaggedError("TargetNeedsProjectError")<{ readonly message: string; @@ -48,12 +62,20 @@ export class TargetNeedsProjectError extends Data.TaggedError("TargetNeedsProjec } } -/** A bare `--remote` is followed by a word that names a branch (or whose branches could not be listed), so the word is probably the branch. */ +/** + * A bare `--remote` is followed by a word that names a branch, nearly names one, or could not be + * checked because the branch listing failed; `status` is that failure's HTTP status, when it had one. + */ export class TargetRemoteAmbiguousError extends Data.TaggedError("TargetRemoteAmbiguousError")<{ readonly message: string; + readonly suggestion?: string; + readonly status?: number; + readonly cause?: unknown; }> { get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { - return actionability.provideFlags; + return this.status === 401 || this.status === 403 + ? statusCodeActionability(this.status) + : actionability.provideFlags; } } @@ -71,4 +93,5 @@ export type TargetSelectorError = | TargetSelectorConflictError | TargetProjectRefConflictError | TargetEmptyValueError + | TargetInvalidValueError | TargetNeedsProjectError; 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 df1717c5db..c84b7b3549 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 @@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest"; import { classifyCliErrorActionability } from "../../shared/telemetry/error-actionability.ts"; import { TargetEmptyValueError, + TargetInvalidValueError, TargetNeedsProjectError, TargetPasswordConflictError, TargetProjectRefConflictError, @@ -12,9 +13,14 @@ import { describe("target errors", () => { it.each([ - new TargetSelectorConflictError({ flags: ["--local", "--linked"], message: "m" }), - new TargetProjectRefConflictError({ conflictsWith: "--local", message: "m" }), + new TargetSelectorConflictError({ + flags: ["--local", "--linked"], + message: "m", + suggestion: "s", + }), + new TargetProjectRefConflictError({ conflictsWith: "--local", message: "m", suggestion: "s" }), new TargetEmptyValueError({ flag: "--db-url", message: "m", suggestion: "s" }), + new TargetInvalidValueError({ flag: "--project-ref", message: "m", suggestion: "s" }), new TargetNeedsProjectError({ message: "m" }), new TargetRemoteAmbiguousError({ message: "m" }), new TargetPasswordConflictError({ target: "local", message: "m" }), @@ -24,4 +30,16 @@ describe("target errors", () => { error_category: "invalid_input", }); }); + + it.each([401, 403])( + "classifies a failed branch listing with HTTP %s as an auth failure", + (status) => { + expect( + classifyCliErrorActionability(new TargetRemoteAmbiguousError({ message: "m", status })), + ).toMatchObject({ + error_kind: "user_actionable", + error_category: expect.not.stringMatching(/^invalid_input$/), + }); + }, + ); }); 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 cb354f7050..0406e133e3 100644 --- a/apps/cli/src/command-internal/test-db.integration.test.ts +++ b/apps/cli/src/command-internal/test-db.integration.test.ts @@ -997,13 +997,13 @@ describe("test db target selection", () => { alias: "--project-ref", flags: { projectRef: Option.some(FLAG_PROJECT_REF) }, kind: "linked", - warning: "--project-ref is deprecated and will be removed in v4. Use --remote instead", + warning: "--project-ref is deprecated and will be removed in v4. Use --remote=<ref> instead", }, { alias: "--db-url", flags: { dbUrl: Option.some("postgresql://postgres:pw@db.example.com/db") }, kind: "db-url", - warning: "--db-url is deprecated and will be removed in v4. Use --remote instead", + warning: "--db-url is deprecated and will be removed in v4. Use --remote=<db-url> instead", }, { alias: "--local", diff --git a/apps/cli/src/commands/branches/create/create.command.ts b/apps/cli/src/commands/branches/create/create.command.ts index 4aac40124d..0f0fd79188 100644 --- a/apps/cli/src/commands/branches/create/create.command.ts +++ b/apps/cli/src/commands/branches/create/create.command.ts @@ -61,7 +61,7 @@ const config = { Argument.withDescription("Name for the new branch."), Argument.optional, ), - ...projectTargetFlags(), + ...projectTargetFlags({ parent: true }), region: Flag.Literals("region", BRANCH_REGIONS).pipe( Flag.withDescription("Select a region to deploy the branch database."), Flag.optional, diff --git a/apps/cli/src/commands/branches/delete/delete.command.ts b/apps/cli/src/commands/branches/delete/delete.command.ts index d2edb49eb9..4985220e98 100644 --- a/apps/cli/src/commands/branches/delete/delete.command.ts +++ b/apps/cli/src/commands/branches/delete/delete.command.ts @@ -20,7 +20,7 @@ const config = { Argument.withDescription("Branch name or ID to delete."), Argument.optional, ), - ...projectTargetFlags(), + ...projectTargetFlags({ parent: true }), } as const; export type BranchesDeleteFlags = CliCommand.Command.Config.Infer<typeof config>; diff --git a/apps/cli/src/commands/branches/disable/disable.command.ts b/apps/cli/src/commands/branches/disable/disable.command.ts index 6cfc6ce6eb..3ece02fa55 100644 --- a/apps/cli/src/commands/branches/disable/disable.command.ts +++ b/apps/cli/src/commands/branches/disable/disable.command.ts @@ -14,7 +14,7 @@ import { projectTargetFlags } from "../../../command-internal/target/target-flag import { branchesParentTarget } from "../branches.target.ts"; const config = { - ...projectTargetFlags(), + ...projectTargetFlags({ parent: true }), } as const; export type BranchesDisableFlags = CliCommand.Command.Config.Infer<typeof config>; diff --git a/apps/cli/src/commands/branches/get/get.command.ts b/apps/cli/src/commands/branches/get/get.command.ts index a96bdb6166..ce4d250329 100644 --- a/apps/cli/src/commands/branches/get/get.command.ts +++ b/apps/cli/src/commands/branches/get/get.command.ts @@ -51,7 +51,7 @@ const config = { Argument.withDescription("Branch name or ID."), Argument.optional, ), - ...projectTargetFlags(), + ...projectTargetFlags({ parent: true }), } as const; export type BranchesGetFlags = CliCommand.Command.Config.Infer<typeof config>; diff --git a/apps/cli/src/commands/branches/list/list.command.ts b/apps/cli/src/commands/branches/list/list.command.ts index f1a98ff02b..92c535487e 100644 --- a/apps/cli/src/commands/branches/list/list.command.ts +++ b/apps/cli/src/commands/branches/list/list.command.ts @@ -14,7 +14,7 @@ import { projectTargetFlags } from "../../../command-internal/target/target-flag import { branchesParentTarget } from "../branches.target.ts"; const config = { - ...projectTargetFlags(), + ...projectTargetFlags({ parent: true }), } as const; export type BranchesListFlags = CliCommand.Command.Config.Infer<typeof config>; diff --git a/apps/cli/src/commands/branches/pause/pause.command.ts b/apps/cli/src/commands/branches/pause/pause.command.ts index 92e90d0a40..f6db80faa6 100644 --- a/apps/cli/src/commands/branches/pause/pause.command.ts +++ b/apps/cli/src/commands/branches/pause/pause.command.ts @@ -20,7 +20,7 @@ const config = { Argument.withDescription("Branch name or ID to pause."), Argument.optional, ), - ...projectTargetFlags(), + ...projectTargetFlags({ parent: true }), } as const; export type BranchesPauseFlags = CliCommand.Command.Config.Infer<typeof config>; diff --git a/apps/cli/src/commands/branches/unpause/unpause.command.ts b/apps/cli/src/commands/branches/unpause/unpause.command.ts index 11f9147b60..bc93f52524 100644 --- a/apps/cli/src/commands/branches/unpause/unpause.command.ts +++ b/apps/cli/src/commands/branches/unpause/unpause.command.ts @@ -20,7 +20,7 @@ const config = { Argument.withDescription("Branch name or ID to unpause."), Argument.optional, ), - ...projectTargetFlags(), + ...projectTargetFlags({ parent: true }), } as const; export type BranchesUnpauseFlags = CliCommand.Command.Config.Infer<typeof config>; diff --git a/apps/cli/src/commands/branches/update/update.command.ts b/apps/cli/src/commands/branches/update/update.command.ts index d60174812d..e7dae7809a 100644 --- a/apps/cli/src/commands/branches/update/update.command.ts +++ b/apps/cli/src/commands/branches/update/update.command.ts @@ -29,7 +29,7 @@ const config = { Argument.withDescription("Branch name or ID to update."), Argument.optional, ), - ...projectTargetFlags(), + ...projectTargetFlags({ parent: true }), name: Flag.String("name").pipe(Flag.withDescription("Rename the preview branch."), Flag.optional), gitBranch: Flag.String("git-branch").pipe( Flag.withDescription("Change the associated git branch."), 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 3fe3fb487c..7696cee169 100644 --- a/apps/cli/src/commands/config/diff/diff.integration.test.ts +++ b/apps/cli/src/commands/config/diff/diff.integration.test.ts @@ -131,8 +131,8 @@ interface SetupOpts { /** cliSettings.explicitWorkdir override — true iff --workdir/SUPABASE_WORKDIR was set verbatim. */ readonly explicitWorkdir?: boolean; readonly analytics?: ReturnType<typeof mockContextualAnalytics>; - /** The argv the deprecation warnings render their replacement command from. */ - readonly cliArgs?: ReadonlyArray<string>; + /** The command path the deprecation warnings name in their example. */ + readonly commandPath?: ReadonlyArray<string>; } function setup(opts: SetupOpts = {}) { @@ -186,7 +186,7 @@ function setup(opts: SetupOpts = {}) { processControl, outputFlag: opts.outputFlag === undefined ? Option.none() : Option.some(opts.outputFlag), ...(opts.analytics === undefined ? {} : { analytics: opts.analytics }), - ...(opts.cliArgs === undefined ? {} : { cliArgs: opts.cliArgs }), + ...(opts.commandPath === undefined ? {} : { commandPath: opts.commandPath }), }), projectFiles, cliConfigValuesTestLayer, @@ -693,12 +693,12 @@ describe("config diff integration", () => { const { layer, out, api } = setup({ toml: 'project_id = "test"\n', v2: { status: 200, body: v2Response({ ref: BRANCH_REF }) }, - cliArgs: ["config", "diff", "--project-ref", "staging"], + commandPath: ["config", "diff"], }); return Effect.gen(function* () { yield* configDiff({ ...noFlags, projectRef: Option.some("staging") }); expect(warningsOf(out)).toEqual([ - "--project-ref is deprecated and will be removed in v4. Use --remote instead: supabase config diff --remote=staging", + "--project-ref is deprecated and will be removed in v4. Use --remote=<branch> instead: supabase config diff --remote=staging", ]); expect(out.stderrText).toContain( `Comparing against 'staging' (branch ${BRANCH_REF}) using base config`, diff --git a/apps/cli/src/commands/config/pull/pull.integration.test.ts b/apps/cli/src/commands/config/pull/pull.integration.test.ts index b61b9a7ed8..8ab8f1e161 100644 --- a/apps/cli/src/commands/config/pull/pull.integration.test.ts +++ b/apps/cli/src/commands/config/pull/pull.integration.test.ts @@ -380,8 +380,8 @@ interface SetupOpts { /** Serves the project list and project lookup, answers the project picker with `VALID_REF`, and * writes the linked-project cache through the real cache layer. */ readonly pickerAndRealCache?: boolean; - /** The argv the deprecation warnings render their replacement command from. */ - readonly cliArgs?: ReadonlyArray<string>; + /** The command path the deprecation warnings name in their example. */ + readonly commandPath?: ReadonlyArray<string>; readonly yes?: boolean; readonly stdinIsTty?: boolean; readonly confirm?: ReadonlyArray<boolean>; @@ -484,7 +484,7 @@ function setup(opts: SetupOpts = {}) { processControl, tty: mockTty({ stdinIsTty: opts.stdinIsTty ?? false, stdoutIsTty: false }), outputFlag: opts.outputFlag === undefined ? Option.none() : Option.some(opts.outputFlag), - ...(opts.cliArgs === undefined ? {} : { cliArgs: opts.cliArgs }), + ...(opts.commandPath === undefined ? {} : { commandPath: opts.commandPath }), }), mockStdin(opts.stdinIsTty ?? false), Layer.succeed(YesFlag, opts.yes ?? false), @@ -1776,12 +1776,12 @@ describe("config pull integration", () => { toml: 'project_id = "test"\n', yes: true, v2: { status: 200, body: v2Response({ ref: BRANCH_REF }) }, - cliArgs: ["config", "pull", "--project-ref", "staging"], + commandPath: ["config", "pull"], }); return Effect.gen(function* () { yield* configPull({ ...noFlags, projectRef: Option.some("staging") }); expect(warningsOf(out)).toEqual([ - "--project-ref is deprecated and will be removed in v4. Use --remote instead: supabase config pull --remote=staging", + "--project-ref is deprecated and will be removed in v4. Use --remote=<branch> instead: supabase config pull --remote=staging", ]); expect(out.stderrText).toContain(`Pulling config from 'staging' (branch ${BRANCH_REF})`); }).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 6d8d938e49..e9451c8442 100644 --- a/apps/cli/src/commands/config/push/push.integration.test.ts +++ b/apps/cli/src/commands/config/push/push.integration.test.ts @@ -293,8 +293,8 @@ interface SetupOptions { /** Answers the project picker with `REF` and writes the linked-project cache through the real * cache layer. */ readonly pickerAndRealCache?: boolean; - /** The argv the deprecation warnings render their replacement command from. */ - readonly cliArgs?: ReadonlyArray<string>; + /** The command path the deprecation warnings name in their example. */ + readonly commandPath?: ReadonlyArray<string>; } type PushApiRoutes = Pick< @@ -451,7 +451,7 @@ function setup(opts: SetupOptions) { (opts.interactive ?? true), }), ...(opts.analytics === undefined ? {} : { analytics: opts.analytics }), - ...(opts.cliArgs === undefined ? {} : { cliArgs: opts.cliArgs }), + ...(opts.commandPath === undefined ? {} : { commandPath: opts.commandPath }), }), mockStdin(opts.stdinIsTty ?? true), Layer.succeed(YesFlag, opts.yes ?? false), @@ -3540,12 +3540,12 @@ describe("config push --remote branch name/UUID resolution (CLI-2289)", () => { yes: true, branchList: { status: 200, body: [BRANCH_BY_NAME] }, v2: { status: 200, body: v2Response({ ref: BRANCH_REF }) }, - cliArgs: ["config", "push", "--project-ref", "staging"], + commandPath: ["config", "push"], }); return Effect.gen(function* () { yield* configPush({ remote: Option.none(), projectRef: Option.some("staging") }); expect(warningsOf(out)).toEqual([ - "--project-ref is deprecated and will be removed in v4. Use --remote instead: supabase config push --remote=staging", + "--project-ref is deprecated and will be removed in v4. Use --remote=<branch> instead: supabase config push --remote=staging", ]); expect(out.stderrText).toContain(`Target: project ${REF} · branch staging (${BRANCH_REF})\n`); }).pipe(Effect.provide(layer)); 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 0f862e7fa2..3e5b17a70c 100644 --- a/apps/cli/src/commands/db/advisors/advisors.integration.test.ts +++ b/apps/cli/src/commands/db/advisors/advisors.integration.test.ts @@ -937,13 +937,13 @@ describe("db advisors — target selection", () => { alias: "--project-ref", flags: { projectRef: Option.some(FLAG_PROJECT_REF) }, kind: "linked", - warning: "--project-ref is deprecated and will be removed in v4. Use --remote instead", + warning: "--project-ref is deprecated and will be removed in v4. Use --remote=<ref> instead", }, { alias: "--db-url", flags: { dbUrl: Option.some("postgresql://postgres:pw@db.example.com/db") }, kind: "db-url", - warning: "--db-url is deprecated and will be removed in v4. Use --remote instead", + warning: "--db-url is deprecated and will be removed in v4. Use --remote=<db-url> instead", }, { alias: "--local", 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 fdfaf25b35..da152de23f 100644 --- a/apps/cli/src/commands/db/diff/diff.integration.test.ts +++ b/apps/cli/src/commands/db/diff/diff.integration.test.ts @@ -1353,13 +1353,14 @@ describe("db diff", () => { alias: "--project-ref", flags: { projectRef: Option.some(FLAG_PROJECT_REF) }, kind: "linked", - warning: "--project-ref is deprecated and will be removed in v4. Use --remote instead", + warning: + "--project-ref is deprecated and will be removed in v4. Use --remote=<ref> instead", }, { alias: "--db-url", flags: { dbUrl: Option.some("postgresql://postgres:pw@db.example.com/db") }, kind: "db-url", - warning: "--db-url is deprecated and will be removed in v4. Use --remote instead", + warning: "--db-url is deprecated and will be removed in v4. Use --remote=<db-url> instead", }, { alias: "--local", 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 5a25d6d137..01dc84b7d2 100644 --- a/apps/cli/src/commands/db/dump/dump.integration.test.ts +++ b/apps/cli/src/commands/db/dump/dump.integration.test.ts @@ -1152,13 +1152,14 @@ describe("db dump integration", () => { alias: "--project-ref", flags: { projectRef: Option.some(FLAG_PROJECT_REF) }, kind: "linked", - warning: "--project-ref is deprecated and will be removed in v4. Use --remote instead", + warning: + "--project-ref is deprecated and will be removed in v4. Use --remote=<ref> instead", }, { alias: "--db-url", flags: { dbUrl: Option.some("postgresql://postgres:pw@db.example.com/db") }, kind: "db-url", - warning: "--db-url is deprecated and will be removed in v4. Use --remote instead", + warning: "--db-url is deprecated and will be removed in v4. Use --remote=<db-url> instead", }, { alias: "--local", 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 0cab06c8f0..46c2fa8d0a 100644 --- a/apps/cli/src/commands/db/lint/lint.integration.test.ts +++ b/apps/cli/src/commands/db/lint/lint.integration.test.ts @@ -796,13 +796,13 @@ describe("db lint target selection", () => { alias: "--project-ref", flags: { projectRef: Option.some(FLAG_PROJECT_REF) }, kind: "linked", - warning: "--project-ref is deprecated and will be removed in v4. Use --remote instead", + warning: "--project-ref is deprecated and will be removed in v4. Use --remote=<ref> instead", }, { alias: "--db-url", flags: { dbUrl: Option.some("postgresql://postgres:pw@db.example.com/db") }, kind: "db-url", - warning: "--db-url is deprecated and will be removed in v4. Use --remote instead", + warning: "--db-url is deprecated and will be removed in v4. Use --remote=<db-url> instead", }, { alias: "--local", 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 803f651a99..c9cce13190 100644 --- a/apps/cli/src/commands/db/pull/pull.integration.test.ts +++ b/apps/cli/src/commands/db/pull/pull.integration.test.ts @@ -840,13 +840,14 @@ describe("db pull", () => { alias: "--project-ref", flags: { projectRef: Option.some(FLAG_PROJECT_REF) }, kind: "linked", - warning: "--project-ref is deprecated and will be removed in v4. Use --remote instead", + warning: + "--project-ref is deprecated and will be removed in v4. Use --remote=<ref> instead", }, { alias: "--db-url", flags: { dbUrl: Option.some("postgresql://postgres:pw@db.example.com/db") }, kind: "db-url", - warning: "--db-url is deprecated and will be removed in v4. Use --remote instead", + warning: "--db-url is deprecated and will be removed in v4. Use --remote=<db-url> instead", }, { alias: "--local", 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 90c9e3de81..c0fa43a917 100644 --- a/apps/cli/src/commands/db/push/push.integration.test.ts +++ b/apps/cli/src/commands/db/push/push.integration.test.ts @@ -1173,13 +1173,14 @@ describe("db push", () => { alias: "--project-ref", flags: { projectRef: Option.some(FLAG_PROJECT_REF) }, kind: "linked", - warning: "--project-ref is deprecated and will be removed in v4. Use --remote instead", + warning: + "--project-ref is deprecated and will be removed in v4. Use --remote=<ref> instead", }, { alias: "--db-url", flags: { dbUrl: Option.some("postgresql://postgres:pw@db.example.com/db") }, kind: "db-url", - warning: "--db-url is deprecated and will be removed in v4. Use --remote instead", + warning: "--db-url is deprecated and will be removed in v4. Use --remote=<db-url> instead", }, { alias: "--local", 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 aa2cd493c6..7ac529ba4d 100644 --- a/apps/cli/src/commands/db/query/query.integration.test.ts +++ b/apps/cli/src/commands/db/query/query.integration.test.ts @@ -1433,13 +1433,13 @@ describe("db query target selection", () => { alias: "--project-ref", flags: { projectRef: Option.some(FLAG_PROJECT_REF) }, expected: { hostedRef: FLAG_PROJECT_REF }, - warning: "--project-ref is deprecated and will be removed in v4. Use --remote instead", + warning: "--project-ref is deprecated and will be removed in v4. Use --remote=<ref> instead", }, { alias: "--db-url", flags: { dbUrl: Option.some("postgresql://postgres:pw@db.example.com/db") }, expected: { target: expect.objectContaining({ kind: "db-url" }) }, - warning: "--db-url is deprecated and will be removed in v4. Use --remote instead", + warning: "--db-url is deprecated and will be removed in v4. Use --remote=<db-url> instead", }, { alias: "--local", 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 f155e3412f..8f36d9de32 100644 --- a/apps/cli/src/commands/db/reset/reset.integration.test.ts +++ b/apps/cli/src/commands/db/reset/reset.integration.test.ts @@ -3889,13 +3889,13 @@ describe("db reset target selection", () => { alias: "--project-ref", flags: { projectRef: Option.some(FLAG_PROJECT_REF) }, kind: "linked", - warning: "--project-ref is deprecated and will be removed in v4. Use --remote instead", + warning: "--project-ref is deprecated and will be removed in v4. Use --remote=<ref> instead", }, { alias: "--db-url", flags: { dbUrl: Option.some("postgresql://postgres:pw@db.example.com/db") }, kind: "db-url", - warning: "--db-url is deprecated and will be removed in v4. Use --remote instead", + warning: "--db-url is deprecated and will be removed in v4. Use --remote=<db-url> instead", }, { alias: "--local", 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 67a82c646f..e67b58de75 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 @@ -55,11 +55,11 @@ export const prepareLocalTargetEndpoint = Effect.fn("DeclarativeSchema.localTarg E, R, >(shouldReset: Effect.Effect<boolean, E, R>, password: Option.Option<string> = Option.none()) { - const { workdir } = yield* CommandSettings; + const { workdir, projectId } = yield* CommandSettings; yield* rejectTargetPassword( "local", password, - yield* isCheckoutLinked(yield* FileSystem.FileSystem, yield* Path.Path, workdir), + yield* isCheckoutLinked(yield* FileSystem.FileSystem, yield* Path.Path, workdir, projectId), ); const seam = yield* DeclarativeSeam; yield* seam.ensureLocalPostgresImageCurrent; 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 26478f286a..71ea24871e 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 @@ -1359,13 +1359,13 @@ describe("db schema declarative generate target selection", () => { alias: "--project-ref", flags: { projectRef: Option.some(FLAG_PROJECT_REF) }, kind: "linked", - warning: "--project-ref is deprecated and will be removed in v4. Use --remote instead", + warning: "--project-ref is deprecated and will be removed in v4. Use --remote=<ref> instead", }, { alias: "--db-url", flags: { dbUrl: Option.some("postgresql://postgres:pw@db.example.com/db") }, kind: "db-url", - warning: "--db-url is deprecated and will be removed in v4. Use --remote instead", + warning: "--db-url is deprecated and will be removed in v4. Use --remote=<db-url> instead", }, { alias: "--local", 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 a21914e55c..217327810e 100644 --- a/apps/cli/src/commands/functions/deploy/deploy.integration.test.ts +++ b/apps/cli/src/commands/functions/deploy/deploy.integration.test.ts @@ -540,7 +540,7 @@ describe("functions deploy", () => { `https://api.supabase.com/v1/projects/${OTHER_REF}/functions/deploy`, ]); expect(warnings()).toEqual([ - `--project-ref is deprecated and will be removed in v4. Use --remote instead: supabase functions deploy hello-world --remote=${OTHER_REF}`, + `--project-ref is deprecated and will be removed in v4. Use --remote=<ref> instead: supabase functions deploy <Function name>... --remote=${OTHER_REF}`, ]); }).pipe(Effect.provide(layer), Effect.ensuring(removeTempRoot)); }); @@ -648,7 +648,7 @@ describe("functions deploy", () => { expect(error).toMatchObject({ _tag: "TargetRemoteAmbiguousError", - message: `feature-x is a branch of project ${VALID_REF}. Did you mean --remote=feature-x?`, + message: `"feature-x" is a branch of project ${VALID_REF}. For the branch: supabase functions deploy --remote=feature-x. For the linked project: supabase functions deploy feature-x --remote`, }); expect(deployedTo()).toEqual([]); }).pipe(Effect.provide(layer), Effect.ensuring(removeTempRoot)); 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 955bc5586c..2fefc826ee 100644 --- a/apps/cli/src/commands/gen/types/types.integration.test.ts +++ b/apps/cli/src/commands/gen/types/types.integration.test.ts @@ -540,6 +540,7 @@ const setup = Effect.fnUntraced(function* ( telemetry: telemetry.layer, linkedProjectCache: linkedProjectCache.layer, cliArgs: opts.args ?? ["gen", "types"], + commandPath: ["gen", "types"], }); const layer = Layer.mergeAll( @@ -1022,7 +1023,7 @@ describe("gen types", () => { ); expect(stripAnsi(out.stderrText)).toContain( - `Warning: --project-id is deprecated and will be removed in v4. Use --remote instead: supabase gen types --remote=${VALID_REF}\n`, + `Warning: --project-id is deprecated and will be removed in v4. Use --remote=<ref> instead: supabase gen types <language> --remote=${VALID_REF}\n`, ); expect(out.stderrText).toContain(unverifiedTargetBanner()); expect(api.requests).toContainEqual({ @@ -1056,7 +1057,7 @@ describe("gen types", () => { expect(alias.out.stdoutText).toBe("export type Database = {}\n"); expect(alias.out.stdoutText).toBe(canonical.out.stdoutText); expect(warningsOf(alias.out)).toEqual([ - `--project-id is deprecated and will be removed in v4. Use --remote instead: supabase gen types --remote=${VALID_REF}`, + `--project-id is deprecated and will be removed in v4. Use --remote=<ref> instead: supabase gen types <language> --remote=${VALID_REF}`, ]); expect(warningsOf(canonical.out)).toEqual([]); }).pipe(Effect.provide(BunServices.layer)), @@ -1184,7 +1185,7 @@ describe("gen types", () => { expect(error).toMatchObject({ _tag: "TargetRemoteAmbiguousError", - message: `feature-x is a branch of project ${VALID_REF}. Did you mean --remote=feature-x?`, + message: `"feature-x" is a branch of project ${VALID_REF}. For the branch: supabase gen types --remote=feature-x. For the linked project: supabase gen types feature-x --remote`, }); expect(api.requests.map(({ method }) => method)).toEqual(["listAllBranches"]); expect(generator.calls).toHaveLength(0); @@ -1242,7 +1243,7 @@ describe("gen types", () => { args: ["gen", "types", "--linked"], flags: { linked: true }, warning: - "--linked is deprecated and will be removed in v4. Use --remote instead: supabase gen types --remote", + "--linked is deprecated and will be removed in v4. Use --remote instead: supabase gen types <language> --remote", reached: ({ api }) => expect(api.requests).toContainEqual({ method: "generateTypescriptTypes", @@ -1253,7 +1254,7 @@ describe("gen types", () => { alias: "--project-ref", args: ["gen", "types", "--project-ref", FLAG_REF], flags: { projectRef: Option.some(FLAG_REF) }, - warning: `--project-ref is deprecated and will be removed in v4. Use --remote instead: supabase gen types --remote=${FLAG_REF}`, + warning: `--project-ref is deprecated and will be removed in v4. Use --remote=<ref> instead: supabase gen types <language> --remote=${FLAG_REF}`, reached: ({ api }) => expect(api.requests).toContainEqual({ method: "generateTypescriptTypes", @@ -1265,7 +1266,7 @@ describe("gen types", () => { args: ["gen", "types", "--db-url", dbUrl], flags: { dbUrl: Option.some(dbUrl) }, warning: - "--db-url is deprecated and will be removed in v4. Use --remote instead: supabase gen types --remote=<db-url>", + "--db-url is deprecated and will be removed in v4. Use --remote=<db-url> instead: supabase gen types <language> --remote=<db-url>", reached: ({ dbConfig }) => expect(dbConfig.resolves.map(({ target }) => target.kind)).toEqual(["db-url"]), }, @@ -1274,7 +1275,7 @@ describe("gen types", () => { args: ["gen", "types", "--local"], flags: { local: true }, warning: - "--local isn't needed (local is the default) and will be removed in v4: supabase gen types", + "--local isn't needed (local is the default) and will be removed in v4: supabase gen types <language>", reached: ({ generator }) => expect(generator.calls[0]?.isLocal).toBe(true), }, ]; @@ -3573,6 +3574,7 @@ const setupRecorded = Effect.fnUntraced(function* (opts: { ...(opts.analytics === undefined ? {} : { analytics: opts.analytics }), tty: mockTty({ stdinIsTty: opts.pickRef !== undefined }), cliArgs: opts.args, + commandPath: ["gen", "types"], linkedProjectCache: realLinkedProjectCache({ httpClient: http.layer, cliSettings: mockCommandSettings({ workdir, projectId: Option.none() }), @@ -3674,7 +3676,7 @@ describe("gen types permissions through the real layers", () => { `GET /v1/projects/${VALID_REF}/types/typescript`, ]); expect(warningsOf(ctx.out)).toEqual([ - `--project-id is deprecated and will be removed in v4. Use --remote instead: supabase gen types --remote=${VALID_REF}`, + `--project-id is deprecated and will be removed in v4. Use --remote=<ref> instead: supabase gen types --remote=${VALID_REF}`, ]); assertPermissionDrift({ command: "gen types", 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 f5985e481b..3e51cbfe7e 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 @@ -187,6 +187,7 @@ interface SetupOpts { projectId?: string; /** Raw CLI args slice; drives explicitly-passed flag detection. */ cliArgs?: ReadonlyArray<string>; + commandPath?: ReadonlyArray<string>; /** Runs the real `DbConfigResolver` against the temp `workdir`. */ realLayers?: { readonly workdir: string }; workdir?: string; @@ -218,6 +219,7 @@ function setup(opts: SetupOpts = {}) { }), telemetry: telemetry.layer, cliArgs: opts.cliArgs, + commandPath: opts.commandPath, }); const layer = Layer.mergeAll( runtime, @@ -450,7 +452,7 @@ describe("inspect db query runner", () => { cliArgs: ["inspect", "db", "db-stats", "--project-ref", FLAG_REF], flags: { projectRef: Option.some(FLAG_REF) }, target: { kind: "linked", ref: FLAG_REF, source: "flag" }, - warning: `--project-ref is deprecated and will be removed in v4. Use --remote instead: supabase inspect db db-stats --remote=${FLAG_REF}`, + warning: `--project-ref is deprecated and will be removed in v4. Use --remote=<ref> instead: supabase inspect db db-stats --remote=${FLAG_REF}`, }, { alias: "--db-url", @@ -458,7 +460,7 @@ describe("inspect db query runner", () => { flags: { dbUrl: Option.some(DEPRECATED_DB_URL) }, target: { kind: "db-url" }, warning: - "--db-url is deprecated and will be removed in v4. Use --remote instead: supabase inspect db db-stats --remote=<db-url>", + "--db-url is deprecated and will be removed in v4. Use --remote=<db-url> instead: supabase inspect db db-stats --remote=<db-url>", }, { alias: "--local", @@ -482,6 +484,7 @@ describe("inspect db query runner", () => { rows: [DB_STATS_ROW], projectId: VALID_REF, cliArgs: testCase.cliArgs, + commandPath: ["inspect", "db", "db-stats"], }); return Effect.gen(function* () { yield* inspectDbDbStats(flags(testCase.flags)); 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 769eb505e8..62b17b67c9 100644 --- a/apps/cli/src/commands/inspect/report/report.integration.test.ts +++ b/apps/cli/src/commands/inspect/report/report.integration.test.ts @@ -175,6 +175,7 @@ interface SetupOpts { projectId?: string; /** Raw CLI args slice, used to detect which flags were explicitly passed. */ cliArgs?: ReadonlyArray<string>; + commandPath?: ReadonlyArray<string>; /** Runs the real `DbConfigResolver` against `workdir`. */ realLayers?: boolean; } @@ -217,6 +218,7 @@ function setupReport(opts: SetupOpts = {}) { runtimeInfo, telemetry: telemetry.layer, cliArgs: opts.cliArgs, + commandPath: opts.commandPath, }), opts.realLayers !== true || opts.workdir === undefined ? resolver.layer @@ -441,7 +443,7 @@ describe("inspect report", () => { cliArgs: ["inspect", "report", "--project-ref", FLAG_REF], flags: { projectRef: Option.some(FLAG_REF) }, target: { kind: "linked", ref: FLAG_REF, source: "flag" }, - warning: `--project-ref is deprecated and will be removed in v4. Use --remote instead: supabase inspect report --remote=${FLAG_REF}`, + warning: `--project-ref is deprecated and will be removed in v4. Use --remote=<ref> instead: supabase inspect report --remote=${FLAG_REF}`, }, { alias: "--db-url", @@ -449,7 +451,7 @@ describe("inspect report", () => { flags: { dbUrl: Option.some(DEPRECATED_DB_URL) }, target: { kind: "db-url" }, warning: - "--db-url is deprecated and will be removed in v4. Use --remote instead: supabase inspect report --remote=<db-url>", + "--db-url is deprecated and will be removed in v4. Use --remote=<db-url> instead: supabase inspect report --remote=<db-url>", }, { alias: "--local", @@ -473,6 +475,7 @@ describe("inspect report", () => { csvs: DEFAULT_RULE_CSVS, projectId: VALID_REF, cliArgs: testCase.cliArgs, + commandPath: ["inspect", "report"], }); return Effect.gen(function* () { const base = yield* tempDir("supabase-report-out-"); 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 4a45b44697..60fa8b6cc7 100644 --- a/apps/cli/src/commands/migration/down/down.integration.test.ts +++ b/apps/cli/src/commands/migration/down/down.integration.test.ts @@ -38,6 +38,7 @@ import { type StubbedRequest, } from "../../../../tests/helpers/permission-real-layers.ts"; import { CliArgs } from "../../../shared/cli/cli-args.service.ts"; +import { commandRuntimeLayer } from "../../../shared/runtime/command-runtime.layer.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"; @@ -209,6 +210,7 @@ function setup(workdir: string, opts: SetupOpts = {}) { Layer.succeed(YesFlag, opts.yes ?? false), Layer.succeed(OutputFlag, Option.none()), Layer.succeed(CliArgs, { args: opts.args ?? [] }), + commandRuntimeLayer(["migration", "down"]).pipe(Layer.provide(BunServices.layer)), mockTty({ stdinIsTty: interactive, stdoutIsTty: interactive }), branchApi.layer, BunServices.layer, @@ -422,7 +424,7 @@ describe("migration down", () => { args: ["migration", "down", "--project-ref", FLAG_REF], flags: { projectRef: Option.some(FLAG_REF) }, target: { kind: "linked", ref: FLAG_REF }, - warning: `--project-ref is deprecated and will be removed in v4. Use --remote instead: supabase migration down --remote=${FLAG_REF}`, + warning: `--project-ref is deprecated and will be removed in v4. Use --remote=<ref> instead: supabase migration down --remote=${FLAG_REF}`, }, { alias: "--db-url", @@ -430,7 +432,7 @@ describe("migration down", () => { flags: { dbUrl: Option.some(DB_URL) }, target: { kind: "db-url" }, warning: - "--db-url is deprecated and will be removed in v4. Use --remote instead: supabase migration down --remote=<db-url>", + "--db-url is deprecated and will be removed in v4. Use --remote=<db-url> instead: supabase migration down --remote=<db-url>", }, { alias: "--local", 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 045a9d332f..954306bfd7 100644 --- a/apps/cli/src/commands/migration/fetch/fetch.integration.test.ts +++ b/apps/cli/src/commands/migration/fetch/fetch.integration.test.ts @@ -34,6 +34,7 @@ import { } 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 { commandRuntimeLayer } from "../../../shared/runtime/command-runtime.layer.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"; @@ -179,6 +180,7 @@ function setup(workdir: string, opts: SetupOpts = {}) { Layer.succeed(DnsResolverFlag, "native"), Layer.succeed(YesFlag, opts.yes ?? false), Layer.succeed(CliArgs, { args: opts.args ?? ["migration", "fetch"] }), + commandRuntimeLayer(["migration", "fetch"]).pipe(Layer.provide(BunServices.layer)), branchApi.layer, Layer.succeed(OutputFlag, Option.none()), Layer.succeed(TargetPolicy, { group: "selectable", consent: "none" }), @@ -599,7 +601,7 @@ describe("migration fetch", () => { args: ["migration", "fetch", "--project-ref", FLAG_REF], flags: { projectRef: Option.some(FLAG_REF) }, target: { kind: "linked", ref: FLAG_REF }, - warning: `--project-ref is deprecated and will be removed in v4. Use --remote instead: supabase migration fetch --remote=${FLAG_REF}`, + warning: `--project-ref is deprecated and will be removed in v4. Use --remote=<ref> instead: supabase migration fetch --remote=${FLAG_REF}`, }, { alias: "--db-url", @@ -607,7 +609,7 @@ describe("migration fetch", () => { flags: { dbUrl: Option.some(DB_URL) }, target: { kind: "db-url" }, warning: - "--db-url is deprecated and will be removed in v4. Use --remote instead: supabase migration fetch --remote=<db-url>", + "--db-url is deprecated and will be removed in v4. Use --remote=<db-url> instead: supabase migration fetch --remote=<db-url>", }, { alias: "--local", 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 1d12c67d0e..e04c5b64bb 100644 --- a/apps/cli/src/commands/migration/list/list.integration.test.ts +++ b/apps/cli/src/commands/migration/list/list.integration.test.ts @@ -35,6 +35,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 { commandRuntimeLayer } from "../../../shared/runtime/command-runtime.layer.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"; @@ -161,6 +162,7 @@ function setup(workdir: string, opts: SetupOpts = {}) { mockCommandSettings({ workdir }), Layer.succeed(DnsResolverFlag, "native"), Layer.succeed(CliArgs, { args: opts.args ?? [] }), + commandRuntimeLayer(["migration", "list"]).pipe(Layer.provide(BunServices.layer)), branchApi.layer, BunServices.layer, ); @@ -341,7 +343,7 @@ describe("migration list", () => { args: ["migration", "list", "--project-ref", FLAG_REF], flags: { projectRef: Option.some(FLAG_REF) }, target: { kind: "linked", ref: FLAG_REF }, - warning: `--project-ref is deprecated and will be removed in v4. Use --remote instead: supabase migration list --remote=${FLAG_REF}`, + warning: `--project-ref is deprecated and will be removed in v4. Use --remote=<ref> instead: supabase migration list --remote=${FLAG_REF}`, }, { alias: "--db-url", @@ -349,7 +351,7 @@ describe("migration list", () => { flags: { dbUrl: Option.some(DB_URL) }, target: { kind: "db-url" }, warning: - "--db-url is deprecated and will be removed in v4. Use --remote instead: supabase migration list --remote=<db-url>", + "--db-url is deprecated and will be removed in v4. Use --remote=<db-url> instead: supabase migration list --remote=<db-url>", }, { alias: "--local", 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 42a91b4fb6..7f252cda41 100644 --- a/apps/cli/src/commands/migration/repair/repair.integration.test.ts +++ b/apps/cli/src/commands/migration/repair/repair.integration.test.ts @@ -37,6 +37,7 @@ import { type StubbedRequest, } from "../../../../tests/helpers/permission-real-layers.ts"; import { CliArgs } from "../../../shared/cli/cli-args.service.ts"; +import { commandRuntimeLayer } from "../../../shared/runtime/command-runtime.layer.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"; @@ -197,6 +198,7 @@ function setup(workdir: string, opts: SetupOpts = {}) { Layer.succeed(YesFlag, opts.yes ?? false), Layer.succeed(OutputFlag, Option.none()), Layer.succeed(CliArgs, { args: opts.args ?? [] }), + commandRuntimeLayer(["migration", "repair"]).pipe(Layer.provide(BunServices.layer)), mockTty({ stdinIsTty: interactive, stdoutIsTty: interactive }), branchApi.layer, BunServices.layer, @@ -527,28 +529,28 @@ describe("migration repair", () => { args: [...base, "--linked"], flags: { linked: true }, target: { kind: "linked", ref: VALID_REF }, - warning: `--linked is deprecated and will be removed in v4. Use --remote instead: supabase ${base.join(" ")} --remote`, + warning: `--linked is deprecated and will be removed in v4. Use --remote instead: supabase migration repair --remote`, }, { alias: "--project-ref", args: [...base, "--project-ref", FLAG_REF], flags: { projectRef: Option.some(FLAG_REF) }, target: { kind: "linked", ref: FLAG_REF }, - warning: `--project-ref is deprecated and will be removed in v4. Use --remote instead: supabase ${base.join(" ")} --remote=${FLAG_REF}`, + warning: `--project-ref is deprecated and will be removed in v4. Use --remote=<ref> instead: supabase migration repair --remote=${FLAG_REF}`, }, { alias: "--db-url", args: [...base, "--db-url", DB_URL], flags: { dbUrl: Option.some(DB_URL) }, target: { kind: "db-url" }, - warning: `--db-url is deprecated and will be removed in v4. Use --remote instead: supabase ${base.join(" ")} --remote=<db-url>`, + warning: `--db-url is deprecated and will be removed in v4. Use --remote=<db-url> instead: supabase migration repair --remote=<db-url>`, }, { alias: "--local", args: [...base, "--local"], flags: { local: true }, target: { kind: "local" }, - warning: `--local isn't needed (local is the default) and will be removed in v4: supabase ${base.join(" ")}`, + warning: `--local isn't needed (local is the default) and will be removed in v4: supabase migration repair`, }, ] as const satisfies ReadonlyArray<{ readonly alias: string; 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 7ea9d3d95a..f887e98dad 100644 --- a/apps/cli/src/commands/migration/squash/squash.integration.test.ts +++ b/apps/cli/src/commands/migration/squash/squash.integration.test.ts @@ -49,6 +49,7 @@ import { dockerfileServiceImage } from "../../../shared/services/dockerfile-imag import { slimImagesEnabled } from "../../../shared/services/slim-images.ts"; import { getRegistryImageUrl } from "../../../command-internal/docker-registry.ts"; import { CliArgs } from "../../../shared/cli/cli-args.service.ts"; +import { commandRuntimeLayer } from "../../../shared/runtime/command-runtime.layer.ts"; import { DebugFlag, DnsResolverFlag, @@ -453,6 +454,7 @@ function setup(workdir: string, opts: SetupOpts = {}) { Layer.succeed(OutputFlag, Option.none()), Layer.succeed(TargetPolicy, { group: "selectable", consent: "no" }), Layer.succeed(CliArgs, { args: opts.args ?? [] }), + commandRuntimeLayer(["migration", "squash"]).pipe(Layer.provide(BunServices.layer)), mockTty({ stdinIsTty: interactive, stdoutIsTty: interactive }), ); @@ -1445,7 +1447,7 @@ describe("migration squash", () => { args: ["migration", "squash", "--project-ref", FLAG_REF], flags: { projectRef: Option.some(FLAG_REF) }, target: { kind: "linked", ref: FLAG_REF }, - warning: `--project-ref is deprecated and will be removed in v4. Use --remote instead: supabase migration squash --remote=${FLAG_REF}`, + warning: `--project-ref is deprecated and will be removed in v4. Use --remote=<ref> instead: supabase migration squash --remote=${FLAG_REF}`, }, { alias: "--db-url", @@ -1453,7 +1455,7 @@ describe("migration squash", () => { flags: { dbUrl: Option.some(DB_URL) }, target: { kind: "db-url" }, warning: - "--db-url is deprecated and will be removed in v4. Use --remote instead: supabase migration squash --remote=<db-url>", + "--db-url is deprecated and will be removed in v4. Use --remote=<db-url> instead: supabase migration squash --remote=<db-url>", }, { alias: "--local", 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 f36a41b787..476fcd12bb 100644 --- a/apps/cli/src/commands/migration/up/up.integration.test.ts +++ b/apps/cli/src/commands/migration/up/up.integration.test.ts @@ -37,6 +37,7 @@ import { type StubbedRequest, } from "../../../../tests/helpers/permission-real-layers.ts"; import { CliArgs } from "../../../shared/cli/cli-args.service.ts"; +import { commandRuntimeLayer } from "../../../shared/runtime/command-runtime.layer.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"; @@ -190,6 +191,7 @@ function setup(workdir: string, opts: SetupOpts = {}) { mockCommandSettings({ workdir }), Layer.succeed(DnsResolverFlag, "native"), Layer.succeed(CliArgs, { args: opts.args ?? [] }), + commandRuntimeLayer(["migration", "up"]).pipe(Layer.provide(BunServices.layer)), branchApi.layer, BunServices.layer, ); @@ -421,7 +423,7 @@ describe("migration up", () => { args: ["migration", "up", "--project-ref", FLAG_REF], flags: { projectRef: Option.some(FLAG_REF) }, target: { kind: "linked", ref: FLAG_REF }, - warning: `--project-ref is deprecated and will be removed in v4. Use --remote instead: supabase migration up --remote=${FLAG_REF}`, + warning: `--project-ref is deprecated and will be removed in v4. Use --remote=<ref> instead: supabase migration up --remote=${FLAG_REF}`, }, { alias: "--db-url", @@ -429,7 +431,7 @@ describe("migration up", () => { flags: { dbUrl: Option.some(DB_URL) }, target: { kind: "db-url" }, warning: - "--db-url is deprecated and will be removed in v4. Use --remote instead: supabase migration up --remote=<db-url>", + "--db-url is deprecated and will be removed in v4. Use --remote=<db-url> instead: supabase migration up --remote=<db-url>", }, { alias: "--local", diff --git a/apps/cli/src/commands/notebooks/notebooks.integration.test.ts b/apps/cli/src/commands/notebooks/notebooks.integration.test.ts index c8e0abdc3d..c14e65df76 100644 --- a/apps/cli/src/commands/notebooks/notebooks.integration.test.ts +++ b/apps/cli/src/commands/notebooks/notebooks.integration.test.ts @@ -701,7 +701,7 @@ describe.each(commands)("notebooks %s project selection", (command) => { yield* runWith(command, { ...none, projectRef: Option.some(NOTEBOOKS_PROJECT_REF) }); expect(http.routeKeys).toEqual([`GET ${notebooksRoute()}`]); expect(warningsOf(out)).toEqual([ - `--project-ref is deprecated and will be removed in v4. Use --remote instead: supabase notebooks ${command} --remote=${NOTEBOOKS_PROJECT_REF}`, + `--project-ref is deprecated and will be removed in v4. Use --remote=<ref> instead: supabase notebooks ${command} --remote=${NOTEBOOKS_PROJECT_REF}`, ]); }).pipe(Effect.provide(Layer.mergeAll(layer, Layer.succeed(CliArgs, { args: argv })))); }); diff --git a/apps/cli/src/commands/pull/pull.integration.test.ts b/apps/cli/src/commands/pull/pull.integration.test.ts index 698f8f3423..321c160cb8 100644 --- a/apps/cli/src/commands/pull/pull.integration.test.ts +++ b/apps/cli/src/commands/pull/pull.integration.test.ts @@ -81,7 +81,6 @@ import { 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, @@ -667,8 +666,8 @@ interface SetupOpts { readonly projectId?: Option.Option<string>; /** Serves `GET /v1/projects` and answers the project picker with `VALID_REF`. */ readonly picksProject?: boolean; - /** The argv the deprecation warnings render their replacement command from. */ - readonly cliArgs?: ReadonlyArray<string>; + /** The command path the deprecation warnings name in their example. */ + readonly commandPath?: ReadonlyArray<string>; } interface MachineSetupOpts extends SetupOpts { @@ -809,10 +808,10 @@ function setupRuntime( processControl: { layer: processControl.layer }, analytics: { layer: analytics.layer }, outputFlag: opts.outputFlag ?? Option.none(), + commandPath: opts.commandPath, }), Layer.succeed(YesFlag, opts.yes ?? false), Layer.succeed(TargetPolicy, { group: "hosted", consent: "no" }), - Layer.succeed(CliArgs, { args: opts.cliArgs ?? [] }), // Listed after `buildTestRuntime` so its spawner overrides the real one `BunServices.layer` // provides (last-wins). substrate.layer, @@ -987,13 +986,13 @@ describe("pull integration", () => { it.effect("--project-ref still resolves a branch name and warns to use --remote", () => { const { layer, out } = setup({ yes: true, - cliArgs: ["pull", "--project-ref", "staging"], + commandPath: ["pull"], }); return Effect.gen(function* () { yield* writeConfig(); yield* runPull(pullFlags({ projectRef: Option.some("staging"), dryRun: true })); expect(warningsOf(out)).toEqual([ - "--project-ref is deprecated and will be removed in v4. Use --remote instead: supabase pull --remote=staging", + "--project-ref is deprecated and will be removed in v4. Use --remote=<branch> instead: supabase pull --remote=staging", ]); }).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 297379ceb0..4ed7bc41e2 100644 --- a/apps/cli/src/commands/seed/buckets/buckets.integration.test.ts +++ b/apps/cli/src/commands/seed/buckets/buckets.integration.test.ts @@ -31,6 +31,7 @@ import { useTempWorkdir, } from "../../../../tests/helpers/command-mocks.ts"; import { CliArgs } from "../../../shared/cli/cli-args.service.ts"; +import { commandRuntimeLayer } from "../../../shared/runtime/command-runtime.layer.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"; @@ -278,6 +279,7 @@ const setupSeedBuckets = Effect.fnUntraced(function* ( mockTty({ stdinIsTty: true, stdoutIsTty: false }), mockStdin(true, opts.pipedAnswers ? `${opts.pipedAnswers.join("\n")}\n` : undefined), Layer.succeed(CliArgs, { args: opts.args ?? ["seed", "buckets"] }), + commandRuntimeLayer(["seed", "buckets"]).pipe(Layer.provide(BunServices.layer)), Layer.succeed(YesFlag, opts.yes ?? false), projectRefLayer, recorded === undefined @@ -2375,7 +2377,7 @@ describe("seed buckets", () => { cliArgs: ["seed", "buckets", "--project-ref", FLAG_REF], flags: { projectRef: Option.some(FLAG_REF), local: false }, host: `https://${FLAG_REF}.supabase.co`, - warning: `--project-ref is deprecated and will be removed in v4. Use --remote instead: supabase seed buckets --remote=${FLAG_REF}`, + warning: `--project-ref is deprecated and will be removed in v4. Use --remote=<ref> instead: supabase seed buckets --remote=${FLAG_REF}`, }, { alias: "--local", 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 d3b93b1c65..b0bc811722 100644 --- a/apps/cli/src/commands/storage/cp/cp.integration.test.ts +++ b/apps/cli/src/commands/storage/cp/cp.integration.test.ts @@ -669,7 +669,7 @@ describe("storage cp", () => { cliArgs: ["storage", "cp", "--project-ref", FLAG_REF], flags: { projectRef: Option.some(FLAG_REF) }, host: `https://${FLAG_REF}.supabase.co`, - warning: `--project-ref is deprecated and will be removed in v4. Use --remote instead: supabase storage cp --remote=${FLAG_REF}`, + warning: `--project-ref is deprecated and will be removed in v4. Use --remote=<ref> instead: supabase storage cp --remote=${FLAG_REF}`, }, { alias: "--local", @@ -686,6 +686,7 @@ describe("storage cp", () => { const { layer, requests, out } = setupStorage(tmp.current, { toml: 'project_id = "test"\n[api]\nport = 65432\n', cliArgs: testCase.cliArgs, + commandPath: ["storage", "cp"], routes: [ { method: "GET", match: OBJECT("private/readme.md"), rawBody: "downloaded-bytes" }, ], 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 2be38a2e3e..1361ab8fcf 100644 --- a/apps/cli/src/commands/storage/ls/ls.integration.test.ts +++ b/apps/cli/src/commands/storage/ls/ls.integration.test.ts @@ -369,7 +369,7 @@ project_id = "env(STAGING_REF)" cliArgs: ["storage", "ls", "--project-ref", FLAG_REF], flags: { projectRef: Option.some(FLAG_REF) }, host: `https://${FLAG_REF}.supabase.co`, - warning: `--project-ref is deprecated and will be removed in v4. Use --remote instead: supabase storage ls --remote=${FLAG_REF}`, + warning: `--project-ref is deprecated and will be removed in v4. Use --remote=<ref> instead: supabase storage ls --remote=${FLAG_REF}`, }, { alias: "--local", @@ -386,6 +386,7 @@ project_id = "env(STAGING_REF)" const { layer, requests, out } = setupStorage(tmp.current, { toml: 'project_id = "test"\n[api]\nport = 65432\n', cliArgs: testCase.cliArgs, + commandPath: ["storage", "ls"], routes: [{ method: "GET", match: BUCKET, body: [{ name: "remote", id: "remote" }] }], }); return Effect.gen(function* () { @@ -687,7 +688,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(out.stderrText).toContain("Target: local stack (127.0.0.1:59999)"); 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"); @@ -1097,7 +1098,7 @@ 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"); + expect(out.stderrText).toContain("Target: local stack (127.0.0.1:65432)"); }); }); 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 4576922142..c84213db59 100644 --- a/apps/cli/src/commands/storage/mv/mv.integration.test.ts +++ b/apps/cli/src/commands/storage/mv/mv.integration.test.ts @@ -391,7 +391,7 @@ describe("storage mv", () => { cliArgs: ["storage", "mv", "--project-ref", FLAG_REF], flags: { projectRef: Option.some(FLAG_REF) }, host: `https://${FLAG_REF}.supabase.co`, - warning: `--project-ref is deprecated and will be removed in v4. Use --remote instead: supabase storage mv --remote=${FLAG_REF}`, + warning: `--project-ref is deprecated and will be removed in v4. Use --remote=<ref> instead: supabase storage mv --remote=${FLAG_REF}`, }, { alias: "--local", @@ -408,6 +408,7 @@ describe("storage mv", () => { const { layer, requests, out } = setupStorage(tmp.current, { toml: 'project_id = "test"\n[api]\nport = 65432\n', cliArgs: testCase.cliArgs, + commandPath: ["storage", "mv"], yes: true, routes: [{ method: "POST", match: MOVE, body: { message: "Successfully moved" } }], }); 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 932fec1083..f5e375348f 100644 --- a/apps/cli/src/commands/storage/rm/rm.integration.test.ts +++ b/apps/cli/src/commands/storage/rm/rm.integration.test.ts @@ -860,7 +860,7 @@ describe("storage rm", () => { cliArgs: ["storage", "rm", "--project-ref", FLAG_REF], flags: { projectRef: Option.some(FLAG_REF) }, host: `https://${FLAG_REF}.supabase.co`, - warning: `--project-ref is deprecated and will be removed in v4. Use --remote instead: supabase storage rm --remote=${FLAG_REF}`, + warning: `--project-ref is deprecated and will be removed in v4. Use --remote=<ref> instead: supabase storage rm --remote=${FLAG_REF}`, }, { alias: "--local", @@ -877,6 +877,7 @@ describe("storage rm", () => { const { layer, requests, out } = setupStorage(tmp.current, { toml: 'project_id = "test"\n[api]\nport = 65432\n', cliArgs: testCase.cliArgs, + commandPath: ["storage", "rm"], yes: true, routes: [ { method: "DELETE", match: DELETE_OBJECT("private"), body: [{ name: "a.pdf" }] }, diff --git a/apps/cli/src/docs/docs-spec.ts b/apps/cli/src/docs/docs-spec.ts index 5f8ecea691..6f7043ab5a 100644 --- a/apps/cli/src/docs/docs-spec.ts +++ b/apps/cli/src/docs/docs-spec.ts @@ -10,7 +10,7 @@ import { } from "./docs-introspection.ts"; import { unwrapParam } from "../command-internal/param-introspection.ts"; import { isRemoteFlag } from "../command-internal/target/target-flags.ts"; -import { remoteFlagName, remoteValueHint } from "../shared/output/target-help.ts"; +import { remoteFlagName } from "../shared/output/target-help.ts"; import { DOCS_ARG_OVERRIDES, DOCS_CHOICE_OVERRIDES, @@ -184,7 +184,6 @@ function docsNormalizeText(text: string): string { export function docsFlagDoc( commandPath: ReadonlyArray<string>, param: Param.AnyFlag, - remoteHint?: string, ): DocsFlag | undefined { const unwrapped = unwrapParam(param); if (unwrapped === undefined) { @@ -211,8 +210,8 @@ export function docsFlagDoc( const shorthand = single.aliases.find((alias) => alias.length === 1); const longName = - isRemoteFlag(single) && remoteHint !== undefined - ? remoteFlagName(remoteHint) + isRemoteFlag(single) && single.typeName !== undefined + ? remoteFlagName(single.typeName) : `${single.name}${varname === undefined ? "" : ` <${varname}>`}`; const name = `${shorthand === undefined ? "" : `-${shorthand}, `}--${longName}`; @@ -314,11 +313,10 @@ export function buildDocsSpec(input: DocsSpecInput): DocsSpec { const flagDocsFor = ( commandPath: ReadonlyArray<string>, params: ReadonlyArray<Param.AnyFlag>, - remoteHint?: string, ): ReadonlyArray<DocsFlag> => { const docId = commandPath.join("-"); return params - .map((param) => docsFlagDoc(commandPath, param, remoteHint)) + .map((param) => docsFlagDoc(commandPath, param)) .filter((flag) => flag !== undefined) .filter((flag) => { const key = `${docId} ${flag.id}`; @@ -351,8 +349,7 @@ export function buildDocsSpec(input: DocsSpecInput): DocsSpec { const flags: Array<DocsFlag> = []; if (isLeaf) { - const remoteHint = remoteValueHint(command.annotations); - const declared = flagDocsFor(commandPath, internals.config.flags, remoteHint); + const declared = flagDocsFor(commandPath, internals.config.flags); const declaredIds = new Set(declared.map((flag) => flag.id)); const extraFlags = DOCS_EXTRA_FLAGS[docId]; if (extraFlags !== undefined) { @@ -376,7 +373,7 @@ export function buildDocsSpec(input: DocsSpecInput): DocsSpec { flags.push(experimentalOptionalFlag); } const ownIds = new Set(own.map((flag) => flag.id)); - const inherited = flagDocsFor(commandPath, inheritedParams, remoteHint).filter( + const inherited = flagDocsFor(commandPath, inheritedParams).filter( (flag) => !ownIds.has(flag.id) && !rootFlagIds.has(flag.id), ); inherited.forEach((flag) => seenFlagKeys.add(`${docId} ${flag.id}`)); diff --git a/apps/cli/src/docs/docs-spec.unit.test.ts b/apps/cli/src/docs/docs-spec.unit.test.ts index 1309fec21f..ef3b2c1a48 100644 --- a/apps/cli/src/docs/docs-spec.unit.test.ts +++ b/apps/cli/src/docs/docs-spec.unit.test.ts @@ -5,7 +5,7 @@ import { Command, Flag } from "effect/cli"; import { describe, expect, it } from "vitest"; import { ExperimentalFlag } from "../command-internal/global-flags.ts"; -import { remoteFlag } from "../../tests/helpers/remote-flag-fixture.ts"; +import { hostedRemoteFlag, remoteFlag } from "../../tests/helpers/remote-flag-fixture.ts"; import { rootCommand } from "../cli/root.ts"; import { readDocsContent } from "./docs-spec.content.ts"; @@ -79,15 +79,17 @@ describe("docsStripOverlayHeading", () => { }); describe("docsFlagDoc", () => { - it("renders --remote with the value hint of the command's target group", () => { - const doc = docsFlagDoc(["supabase", "db", "push"], remoteFlag, "<ref|branch|url>"); + it("renders --remote with the value hint its declaration carries", () => { + const doc = docsFlagDoc(["supabase", "db", "push"], remoteFlag); expect(doc?.name).toBe("--remote[=<ref|branch|url>]"); expect(doc?.id).toBe("remote"); }); - it("renders --remote as a bare switch where the command takes no value hint", () => { - expect(docsFlagDoc(["supabase", "db", "push"], remoteFlag)?.name).toBe("--remote"); + it("renders the narrower hint of a hosted command's --remote", () => { + expect(docsFlagDoc(["supabase", "functions", "deploy"], hostedRemoteFlag)?.name).toBe( + "--remote[=<ref|branch>]", + ); }); }); diff --git a/apps/cli/src/shared/cli/cli-args.service.ts b/apps/cli/src/shared/cli/cli-args.service.ts index cac0908124..fbee2e8a4d 100644 --- a/apps/cli/src/shared/cli/cli-args.service.ts +++ b/apps/cli/src/shared/cli/cli-args.service.ts @@ -5,6 +5,8 @@ export interface CliArgsShape { readonly args: ReadonlyArray<string>; /** The word a bare `--remote` is followed by when it was taken as an operand and could name a branch. */ readonly bareRemoteOperand?: string | undefined; + /** The operand placeholders the resolved command's help lists, e.g. `<NAME=VALUE>...`; read lazily, only a warning needs them. */ + readonly operandPlaceholders?: (() => ReadonlyArray<string>) | undefined; } export class CliArgs extends Context.Service<CliArgs, CliArgsShape>()("supabase/cli/CliArgs") {} diff --git a/apps/cli/src/shared/cli/invalid-value-message.ts b/apps/cli/src/shared/cli/invalid-value-message.ts index f3eb048b96..bc43099c3c 100644 --- a/apps/cli/src/shared/cli/invalid-value-message.ts +++ b/apps/cli/src/shared/cli/invalid-value-message.ts @@ -1,4 +1,4 @@ -import { LEAVE_OUT_REMOTE_PREFIX } from "../../command-internal/target/remote-value.ts"; +import { REMOTE_FLAG_NAME } from "../../command-internal/target/remote-vocabulary.ts"; // Some of effect's own CLI primitive parsers (`choice`, and the schema-backed `integer`, `float`, // `boolean`, `date`) fail with an `expected` string that already starts with "Expected", which @@ -10,9 +10,9 @@ import { LEAVE_OUT_REMOTE_PREFIX } from "../../command-internal/target/remote-va const EXPECTED_PREFIX = "Expected "; // Some flags already fail with their own byte-exact diagnostic (e.g. malformed CSV in a -// string-slice flag, or `--remote` given an off value), which the CLI's stderr contract must -// reproduce verbatim. Wrapping it in `CliError.InvalidValue`'s own template would double-frame -// it, so render it as-is instead. +// string-slice flag, or any `--remote` value), which the CLI's stderr contract must reproduce +// verbatim. Wrapping it in `CliError.InvalidValue`'s own template would double-frame it, so +// render it as-is instead. const INVALID_ARGUMENT_PREFIX = "invalid argument "; export interface InvalidValueMessageFields { @@ -25,13 +25,13 @@ export interface InvalidValueMessageFields { /** * Rebuilds a `CliError.InvalidValue` message from its own template when `expected` carries the * doubled "Expected" prefix, or passes `expected` through verbatim when it's already a complete - * `invalid argument …` or `Leave out --remote …` diagnostic. Returns `undefined` when unaffected, - * so callers fall back to the error's own message. + * `invalid argument …` or `--remote` diagnostic. Returns `undefined` when unaffected, so callers + * fall back to the error's own message. */ export function formatInvalidValueMessage(error: InvalidValueMessageFields): string | undefined { if ( error.expected.startsWith(INVALID_ARGUMENT_PREFIX) || - error.expected.startsWith(LEAVE_OUT_REMOTE_PREFIX) + (error.kind === "flag" && error.option === REMOTE_FLAG_NAME) ) { return error.expected; } diff --git a/apps/cli/src/shared/cli/run.ts b/apps/cli/src/shared/cli/run.ts index 26bff6b3f2..5656256ddc 100644 --- a/apps/cli/src/shared/cli/run.ts +++ b/apps/cli/src/shared/cli/run.ts @@ -20,6 +20,7 @@ import { import { CliError, CliOutput, Command } from "effect/cli"; import { HttpClient } from "effect/http"; import { ChildProcessSpawner } from "effect/process"; +import { REMOTE_FLAG_NAME } from "../../command-internal/target/remote-vocabulary.ts"; import { CLI_VERSION } from "./version.ts"; import type { CliProjectHome } from "../config/cli-project-home.service.ts"; import type { CliSettings } from "../config/cli-settings.service.ts"; @@ -69,7 +70,9 @@ import { SuccessTrailer, successTrailerLayer } from "./success-trailer.ts"; import type { CliErrorSuggestionContext } from "./subcommand-flag-suggestions.ts"; import { flagAliasesFor, + isRemoteFlagMisuse, isValueTakingFlagTokenFor, + operandPlaceholdersAt, resolvedCommandPathForArgv, } from "./subcommand-flag-suggestions.ts"; @@ -225,11 +228,24 @@ export function prepareCliArgs( rawArgs: ReadonlyArray<string>, ): CliArgsShape { const leaf = remoteArgvLeafFor(rootCommand, rawArgs); - if (leaf === undefined) return { args: rawArgs }; + if (leaf === undefined) { + return { args: rawArgs, operandPlaceholders: operandPlaceholdersFor(rootCommand, rawArgs) }; + } const args = joinRemoteOperands(rawArgs, leaf); - return { args, bareRemoteOperand: bareRemoteOperand(args, leaf) }; + return { + args, + bareRemoteOperand: bareRemoteOperand(args, leaf), + operandPlaceholders: operandPlaceholdersFor(rootCommand, args), + }; } +const operandPlaceholdersFor = + (rootCommand: Command.Command.Any, args: ReadonlyArray<string>) => (): ReadonlyArray<string> => + operandPlaceholdersAt( + rootCommand, + resolvedCommandPathForArgv(rootCommand, extractCommandPath(args)), + ); + /** `CliArgs` and a `Stdio` whose `args` is the same argv, so every reader of either sees the rewritten command line. */ export const cliArgsLayer = (cliArgs: CliArgsShape) => Layer.mergeAll( @@ -523,7 +539,14 @@ export function classifyParseErrorConsoleOutput( flagAliasesFor(context.rootCommand, error.commandPath, inner.option), isValueTakingToken, ); - return error.errors.every(isSuppressedMissingFlag) ? "drop" : "flush-help-doc-to-stderr"; + const isRemoteFlagError = (inner: (typeof error.errors)[number]) => + (Predicate.isTagged(inner, "InvalidValue") && + inner.kind === "flag" && + inner.option === REMOTE_FLAG_NAME) || + isRemoteFlagMisuse(inner, context.rootCommand); + return error.errors.every((inner) => isSuppressedMissingFlag(inner) || isRemoteFlagError(inner)) + ? "drop" + : "flush-help-doc-to-stderr"; } /** diff --git a/apps/cli/src/shared/cli/run.unit.test.ts b/apps/cli/src/shared/cli/run.unit.test.ts index 79514ea6bf..459cc0fc47 100644 --- a/apps/cli/src/shared/cli/run.unit.test.ts +++ b/apps/cli/src/shared/cli/run.unit.test.ts @@ -2,6 +2,7 @@ import { Cause, Data, Runtime } from "effect"; import { CliError, Command } from "effect/cli"; import { describe, expect, it } from "vitest"; +import { rootCommandForFeatures } from "../../cli/root.ts"; import { branchesCommand } from "../../commands/branches/branches.command.ts"; import { migrationCommand } from "../../commands/migration/migration.command.ts"; import { ssoCommand } from "../../commands/sso/sso.command.ts"; @@ -399,6 +400,109 @@ describe("classifyParseErrorConsoleOutput", () => { }); }); +describe("classifyParseErrorConsoleOutput for --remote", () => { + const root = rootCommandForFeatures(); + const remoteInvalid = new CliError.InvalidValue({ + option: "remote", + value: "false", + expected: "--remote doesn't take false. Leave it out to target the local database.", + kind: "flag", + }); + + it("drops the help dump for an invalid --remote value", () => { + const cause = Cause.fail( + new CliError.ShowHelp({ commandPath: ["supabase", "db", "push"], errors: [remoteInvalid] }), + ); + expect( + classifyParseErrorConsoleOutput(cause, { + rootCommand: root, + args: ["db", "push", "--remote=false"], + }), + ).toBe("drop"); + }); + + it.each([ + { name: "link", path: ["supabase", "link"] }, + { name: "a local-only command", path: ["supabase", "start"] }, + ])("drops the help dump for --remote on $name", ({ path }) => { + const cause = Cause.fail( + new CliError.ShowHelp({ + commandPath: path, + errors: [ + new CliError.UnrecognizedOption({ option: "--remote", command: path, suggestions: [] }), + ], + }), + ); + expect( + classifyParseErrorConsoleOutput(cause, { + rootCommand: root, + args: [...path.slice(1), "--remote"], + }), + ).toBe("drop"); + }); + + it("keeps the help dump for --remote on a command that takes no such flag and has no message for it", () => { + const cause = Cause.fail( + new CliError.ShowHelp({ + commandPath: ["supabase", "projects", "list"], + errors: [ + new CliError.UnrecognizedOption({ + option: "--remote", + command: ["supabase", "projects", "list"], + suggestions: [], + }), + ], + }), + ); + expect( + classifyParseErrorConsoleOutput(cause, { + rootCommand: root, + args: ["projects", "list", "--remote"], + }), + ).toBe("flush-help-doc-to-stderr"); + }); + + it("keeps the help dump when a --remote error comes with an unrelated one", () => { + const cause = Cause.fail( + new CliError.ShowHelp({ + commandPath: ["supabase", "db", "push"], + errors: [ + remoteInvalid, + new CliError.UnrecognizedOption({ option: "--bogus", suggestions: [] }), + ], + }), + ); + expect( + classifyParseErrorConsoleOutput(cause, { + rootCommand: root, + args: ["db", "push", "--remote=false", "--bogus"], + }), + ).toBe("flush-help-doc-to-stderr"); + }); + + it("keeps the help dump for another flag's invalid value", () => { + const cause = Cause.fail( + new CliError.ShowHelp({ + commandPath: ["supabase", "db", "push"], + errors: [ + new CliError.InvalidValue({ + option: "output-format", + value: "xml", + expected: "Expected one of text, json", + kind: "flag", + }), + ], + }), + ); + expect( + classifyParseErrorConsoleOutput(cause, { + rootCommand: root, + args: ["db", "push", "--output-format=xml"], + }), + ).toBe("flush-help-doc-to-stderr"); + }); +}); + describe("hasRootVersionFlag", () => { it.each([ [["--version"], true], diff --git a/apps/cli/src/shared/cli/subcommand-flag-suggestions.ts b/apps/cli/src/shared/cli/subcommand-flag-suggestions.ts index 297c94df81..debfc6cf19 100644 --- a/apps/cli/src/shared/cli/subcommand-flag-suggestions.ts +++ b/apps/cli/src/shared/cli/subcommand-flag-suggestions.ts @@ -1,7 +1,9 @@ -import { Predicate } from "effect"; +import { Option, Predicate } from "effect"; import type { CliError, Command, HelpDoc, Param } from "effect/cli"; import { unwrapToSingleParam } from "../../command-internal/param-introspection.ts"; +import { REMOTE_FLAG_NAME } from "../../command-internal/target/remote-vocabulary.ts"; import { isRemoteFlag } from "../../command-internal/target/target-flags.ts"; +import { readTargetPolicy } from "../../command-internal/target/target-policy.ts"; import { commandInternals } from "../../docs/docs-introspection.ts"; import { formatInvalidValueMessage } from "./invalid-value-message.ts"; @@ -56,6 +58,16 @@ export function helpDocFor( return hasHelpDoc(command) ? command.buildHelpDoc(commandPath) : undefined; } +/** The operand placeholders the command at `path` lists in its help, e.g. `<NAME=VALUE>...`. */ +export function operandPlaceholdersAt( + rootCommand: Command.Command.Any, + path: ReadonlyArray<string>, +): ReadonlyArray<string> { + const command = findCommand(rootCommand, path.slice(1)); + const operands = (command === undefined ? undefined : helpDocFor(command, path))?.args ?? []; + return operands.map((operand) => `<${operand.name}>${operand.variadic ? "..." : ""}`); +} + const flagArityOf = (single: Param.Single<Param.ParamKind, unknown>): FlagArity => { if (single.primitiveType._tag !== "Boolean") return "value"; return isRemoteFlag(single) ? "optional-value" : "switch"; @@ -273,6 +285,35 @@ function findValueAfterOption(args: ReadonlyArray<string>, option: string): stri return undefined; } +/** What to say when `--remote` is passed to `link` or a local-only command, which take no such flag. */ +function remoteFlagMisuseMessage( + rootCommand: Command.Command.Any, + commandPath: ReadonlyArray<string>, +): string | undefined { + const command = findCommand(rootCommand, commandPath.slice(1)); + if (command === undefined) return undefined; + const usage = `supabase ${commandPath.slice(1).join(" ")}`; + if (command.name === "link" && commandPath.length === 2) { + return `${usage} takes the project ref or branch as its argument: ${usage} <ref-or-branch>`; + } + return Option.exists(readTargetPolicy(command), (policy) => policy.group === "local-only") + ? `${usage} is local only; --remote doesn't apply.` + : undefined; +} + +/** Whether `error` is `--remote` given to a command that takes no such flag and has its own message for it. */ +export function isRemoteFlagMisuse( + error: CliError.CliError, + rootCommand: Command.Command.Any, +): error is CliError.UnrecognizedOption { + return ( + Predicate.isTagged(error, "UnrecognizedOption") && + normalizeOption(error.option) === REMOTE_FLAG_NAME && + error.command !== undefined && + remoteFlagMisuseMessage(rootCommand, error.command) !== undefined + ); +} + function buildSubcommandFlagHint( error: CliError.UnrecognizedOption, context: CliErrorSuggestionContext, @@ -316,6 +357,17 @@ export function formatCliErrorsForDisplay( let changed = false; for (const error of errors) { + if (context && isRemoteFlagMisuse(error, context.rootCommand) && error.command) { + changed = true; + formatted.push({ + _tag: error._tag, + message: remoteFlagMisuseMessage(context.rootCommand, error.command) ?? error.message, + source: error, + changed: true, + }); + continue; + } + if (Predicate.isTagged(error, "UnrecognizedOption") && context) { const hint = buildSubcommandFlagHint(error, context); if (hint) { diff --git a/apps/cli/src/shared/cli/subcommand-flag-suggestions.unit.test.ts b/apps/cli/src/shared/cli/subcommand-flag-suggestions.unit.test.ts index 87ab3be84e..0f938d6b17 100644 --- a/apps/cli/src/shared/cli/subcommand-flag-suggestions.unit.test.ts +++ b/apps/cli/src/shared/cli/subcommand-flag-suggestions.unit.test.ts @@ -2,6 +2,7 @@ import { CliError, Command } from "effect/cli"; import { describe, expect, it } from "vitest"; import { branchesCommand } from "../../commands/branches/branches.command.ts"; import { networkRestrictionsCommand } from "../../commands/network-restrictions/network-restrictions.command.ts"; +import { rootCommandForFeatures } from "../../cli/root.ts"; import { formatCliErrorsForDisplay } from "./subcommand-flag-suggestions.ts"; const testRoot = Command.make("supabase").pipe( @@ -276,3 +277,48 @@ describe("subcommand flag placement suggestions", () => { ); }); }); + +describe("--remote where it does not apply", () => { + const root = rootCommandForFeatures(); + const messageFor = (path: ReadonlyArray<string>, option = "--remote") => + formatCliErrorsForDisplay( + [ + new CliError.UnrecognizedOption({ + option, + command: ["supabase", ...path], + suggestions: [], + }), + ], + { rootCommand: root, args: [...path, option] }, + ).errors[0]?.message; + + it("points link at its ref-or-branch argument", () => { + expect(messageFor(["link"])).toBe( + "supabase link takes the project ref or branch as its argument: supabase link <ref-or-branch>", + ); + }); + + it.each([["start"], ["stop"], ["db", "start"], ["init"]])("says %s is local only", (...path) => { + expect(messageFor(path)).toBe( + `supabase ${path.join(" ")} is local only; --remote doesn't apply.`, + ); + }); + + it("rewrites --remote=<value> the same way", () => { + expect(messageFor(["start"], "--remote=staging")).toBe( + "supabase start is local only; --remote doesn't apply.", + ); + }); + + it("leaves --remote on an account command with the library's message", () => { + expect(messageFor(["projects", "list"])).toBe( + "Unrecognized flag: --remote in command supabase projects list", + ); + }); + + it("leaves other unrecognized flags on link and local-only commands unchanged", () => { + expect(messageFor(["start"], "--bogus")).toBe( + "Unrecognized flag: --bogus in command supabase start", + ); + }); +}); diff --git a/apps/cli/src/shared/config/temp-paths.ts b/apps/cli/src/shared/config/temp-paths.ts index ca436ea791..acc7e78383 100644 --- a/apps/cli/src/shared/config/temp-paths.ts +++ b/apps/cli/src/shared/config/temp-paths.ts @@ -80,13 +80,16 @@ export const readProjectRefFile = ( 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. */ +/** Whether `SUPABASE_PROJECT_ID` is set or `<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, + projectId: Option.Option<string> = Option.none(), ): Effect.Effect<boolean> => - readProjectRefFile(fs, path, workdir).pipe( - Effect.map(Option.isSome), - Effect.orElseSucceed(() => false), - ); + Option.isSome(projectId) + ? Effect.succeed(true) + : readProjectRefFile(fs, path, workdir).pipe( + Effect.map(Option.isSome), + Effect.orElseSucceed(() => false), + ); diff --git a/apps/cli/src/shared/config/temp-paths.unit.test.ts b/apps/cli/src/shared/config/temp-paths.unit.test.ts index 327828f539..c93074df0c 100644 --- a/apps/cli/src/shared/config/temp-paths.unit.test.ts +++ b/apps/cli/src/shared/config/temp-paths.unit.test.ts @@ -3,7 +3,12 @@ import { describe, expect, it } from "@effect/vitest"; import { Effect, FileSystem, Option, Path } from "effect"; import { classifyCliErrorActionability } from "../telemetry/error-actionability.ts"; -import { ProjectRefReadError, readProjectRefFile, tempPaths } from "./temp-paths.ts"; +import { + isCheckoutLinked, + ProjectRefReadError, + readProjectRefFile, + tempPaths, +} from "./temp-paths.ts"; const readRef = (workdir: string) => Effect.gen(function* () { @@ -112,3 +117,40 @@ describe("readProjectRefFile", () => { expect(result.suggested_command).toBeUndefined(); }); }); + +describe("isCheckoutLinked", () => { + const linked = (dir: string, projectId?: Option.Option<string>) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + return yield* isCheckoutLinked(fs, path, dir, projectId); + }); + + it.effect("is linked when the project-ref file names a project", () => + withWorkdir(seedRef(REF), (dir) => linked(dir)).pipe( + Effect.tap((value) => Effect.sync(() => expect(value).toBe(true))), + ), + ); + + it.effect("is not linked without a project-ref file or a project id", () => + withWorkdir( + () => Effect.void, + (dir) => linked(dir, Option.none()), + ).pipe(Effect.tap((value) => Effect.sync(() => expect(value).toBe(false)))), + ); + + it.effect("is linked by SUPABASE_PROJECT_ID alone", () => + withWorkdir( + () => Effect.void, + (dir) => linked(dir, Option.some(REF)), + ).pipe(Effect.tap((value) => Effect.sync(() => expect(value).toBe(true)))), + ); + + it.effect("counts an unreadable project-ref file as unlinked", () => + withWorkdir( + (fs, path, dir) => + fs.makeDirectory(path.join(dir, "supabase", ".temp", "project-ref"), { recursive: true }), + (dir) => linked(dir), + ).pipe(Effect.tap((value) => Effect.sync(() => expect(value).toBe(false)))), + ); +}); diff --git a/apps/cli/src/shared/output/json-formatter.ts b/apps/cli/src/shared/output/json-formatter.ts index 0261fd7f1b..3301266486 100644 --- a/apps/cli/src/shared/output/json-formatter.ts +++ b/apps/cli/src/shared/output/json-formatter.ts @@ -1,14 +1,14 @@ import type { CliOutput, HelpDoc } from "effect/cli"; import type { CliErrorSuggestionContext } from "../cli/subcommand-flag-suggestions.ts"; import { cliErrorCode, formatCliErrorsForDisplay } from "../cli/subcommand-flag-suggestions.ts"; -import { subcommandScopes, targetHelpFor, withRemoteFlagUsage } from "./target-help.ts"; +import { subcommandScopes, targetHelpFor, withRemoteFlagValue } from "./target-help.ts"; export function jsonCliOutputFormatter(context?: CliErrorSuggestionContext): CliOutput.Formatter { return { formatHelpDoc: (doc: HelpDoc.HelpDoc) => { const scopes = subcommandScopes(doc, context?.rootCommand); const target = targetHelpFor(doc); - const { annotations: _annotations, ...serializable } = withRemoteFlagUsage(doc); + const { annotations: _annotations, ...serializable } = withRemoteFlagValue(doc); return JSON.stringify({ _tag: "Help", doc: { diff --git a/apps/cli/src/shared/output/target-help.ts b/apps/cli/src/shared/output/target-help.ts index 503f432571..898ea07c4b 100644 --- a/apps/cli/src/shared/output/target-help.ts +++ b/apps/cli/src/shared/output/target-help.ts @@ -7,27 +7,23 @@ type TargetKind = "selectable" | "hosted" | "local-only"; interface TargetCopy { readonly scope: string; - readonly summary: string; - /** The value `--remote` accepts on this kind of command; `undefined` when it takes none. */ - readonly remoteValue: string | undefined; + /** The TARGET summary, given the value hint of the command's `--remote` flag. */ + readonly summary: (hint: string) => string; } const TARGET_COPY: Readonly<Record<TargetKind, TargetCopy>> = { selectable: { scope: "local or remote", - summary: - "Local by default. Pass --remote for the linked project, or --remote=<ref|branch|url>.", - remoteValue: "<ref|branch|url>", + summary: (hint) => + `Local by default. Pass --remote for the linked project, or --remote=${hint}.`, }, hosted: { scope: "remote only", - summary: "Remote only. Uses the linked project; pass --remote=<ref|branch> for another.", - remoteValue: "<ref|branch>", + summary: (hint) => `Remote only. Uses the linked project; pass --remote=${hint} for another.`, }, "local-only": { scope: "local only", - summary: "Local only. Runs against the local stack.", - remoteValue: undefined, + summary: () => "Local only. Runs against the local stack.", }, }; @@ -51,37 +47,49 @@ const kindOf = (annotations: Context.Context<never>): TargetKind | undefined => Option.getOrUndefined, ); -const declaresRemoteFlag = (doc: HelpDoc.HelpDoc): boolean => - [...doc.flags, ...(doc.globalFlags ?? [])].some((flag) => flag.name === REMOTE_FLAG_NAME); +const remoteFlagOf = (doc: HelpDoc.HelpDoc): HelpDoc.FlagDoc | undefined => + [...doc.flags, ...(doc.globalFlags ?? [])].find((flag) => flag.name === REMOTE_FLAG_NAME); /** What a leaf's help says about its target; `undefined` for account, removed and group commands. */ export function targetHelpFor(doc: HelpDoc.HelpDoc): TargetHelp | undefined { const kind = kindOf(doc.annotations); if (kind === undefined) return undefined; const { scope, summary } = TARGET_COPY[kind]; - return { - scope, - summary: declaresRemoteFlag(doc) ? summary : summary.slice(0, summary.indexOf(".") + 1), - }; + const flag = remoteFlagOf(doc); + const full = summary(flag?.type ?? ""); + return { scope, summary: flag === undefined ? full.slice(0, full.indexOf(".") + 1) : full }; } -/** The value hint `--remote` takes on a command, e.g. `<ref|branch>`; `undefined` where it takes none. */ -export function remoteValueHint(annotations: Context.Context<never>): string | undefined { - const kind = kindOf(annotations); - return kind === undefined ? undefined : TARGET_COPY[kind].remoteValue; -} - -/** The `--remote` flag rows show the value `remote` takes on this command: `--remote[=<ref|branch>]`. */ -export function withRemoteFlagUsage(doc: HelpDoc.HelpDoc): HelpDoc.HelpDoc { - const hint = remoteValueHint(doc.annotations); - if (hint === undefined || !declaresRemoteFlag(doc)) return doc; - const rename = (flag: HelpDoc.FlagDoc): HelpDoc.FlagDoc => - flag.name === REMOTE_FLAG_NAME ? { ...flag, name: remoteFlagName(hint) } : flag; +const mapRemoteFlag = ( + doc: HelpDoc.HelpDoc, + transform: (flag: HelpDoc.FlagDoc) => HelpDoc.FlagDoc, +): HelpDoc.HelpDoc => { + const apply = (flag: HelpDoc.FlagDoc) => + flag.name === REMOTE_FLAG_NAME ? transform(flag) : flag; return { ...doc, - flags: doc.flags.map(rename), - ...(doc.globalFlags === undefined ? {} : { globalFlags: doc.globalFlags.map(rename) }), + flags: doc.flags.map(apply), + ...(doc.globalFlags === undefined ? {} : { globalFlags: doc.globalFlags.map(apply) }), }; +}; + +/** Text help shows the `--remote` row as `--remote[=<hint>]`, with no separate type column. */ +export function withRemoteFlagUsage(doc: HelpDoc.HelpDoc): HelpDoc.HelpDoc { + return mapRemoteFlag(doc, (flag) => ({ + ...flag, + name: remoteFlagName(flag.type), + type: "boolean", + })); +} + +/** JSON help keeps the flag's name and says it takes an optional value: `value` is its hint. */ +export function withRemoteFlagValue(doc: HelpDoc.HelpDoc): HelpDoc.HelpDoc { + return mapRemoteFlag(doc, (flag) => ({ + ...flag, + type: "string", + value: flag.type, + valueOptional: true, + })); } function findCommand( diff --git a/apps/cli/src/shared/output/target-help.unit.test.ts b/apps/cli/src/shared/output/target-help.unit.test.ts index a1b26b9f79..4e11a30fec 100644 --- a/apps/cli/src/shared/output/target-help.unit.test.ts +++ b/apps/cli/src/shared/output/target-help.unit.test.ts @@ -140,6 +140,49 @@ describe("help --remote flag row", () => { it("leaves other flags as the default formatter renders them", () => { expect(textHelp(tree, ["db", "push"])).toMatch(/--other\s+Another flag\./); }); + + it("shows the hint once, in the flag name", () => { + expect(textHelp(tree, ["db", "push"])).not.toMatch(/--remote\[=<ref\|branch\|url>\]\s+<ref/); + }); +}); + +describe("help value hints per command variant", () => { + const remoteRow = (text: string) => text.split("\n").find((line) => line.includes("--remote[=")); + + it.each([ + { + command: ["storage", "ls"], + hint: "<ref|branch>", + target: "Local by default. Pass --remote for the linked project, or --remote=<ref|branch>.", + }, + { + command: ["db", "push"], + hint: "<ref|branch|url>", + target: + "Local by default. Pass --remote for the linked project, or --remote=<ref|branch|url>.", + }, + { + command: ["functions", "deploy"], + hint: "<ref|branch>", + target: "Remote only. Uses the linked project; pass --remote=<ref|branch> for another.", + }, + { + command: ["branches", "list"], + hint: "<ref>", + target: "Remote only. Uses the linked project; pass --remote=<ref> for another.", + }, + ])( + "agrees on $hint for $command in the TARGET row, the flag row and the description", + ({ command, hint, target }) => { + const text = textHelp(rootCommand, command); + const row = remoteRow(text); + + expect(text).toContain(`TARGET\n ${target}\n`); + expect(row).toContain(`--remote[=${hint}]`); + expect(row).toContain(`--remote=${hint}`); + expect(row).not.toMatch(new RegExp(`\\]\\s+${hint.replace(/[|[\]]/g, "\\$&")}\\s`)); + }, + ); }); describe("JSON help", () => { @@ -168,10 +211,31 @@ describe("JSON help", () => { }); }); - it("names the --remote flag with its value hint", () => { + it("keeps the --remote flag's name and says it takes an optional value", () => { const flags: Array<{ name: string }> = jsonHelp(tree, ["db", "push"]).flags; - expect(flags.map((flag) => flag.name)).toContain("remote[=<ref|branch|url>]"); + expect(flags.find((flag) => flag.name === "remote")).toMatchObject({ + name: "remote", + type: "string", + value: "<ref|branch|url>", + valueOptional: true, + }); + }); + + it.each([ + { command: ["storage", "ls"], value: "<ref|branch>" }, + { command: ["functions", "deploy"], value: "<ref|branch>" }, + { command: ["branches", "list"], value: "<ref>" }, + ])("reports $value as the --remote value of $command", ({ command, value }) => { + const flags: Array<{ name: string; value?: string }> = jsonHelp(rootCommand, command).flags; + + expect(flags.find((flag) => flag.name === "remote")?.value).toBe(value); + }); + + it("leaves every other flag without a value hint", () => { + const flags: Array<{ name: string; value?: string }> = jsonHelp(tree, ["db", "push"]).flags; + + expect(flags.find((flag) => flag.name === "other")).not.toHaveProperty("value"); }); it("omits target from an account command", () => { diff --git a/apps/cli/src/shared/telemetry/__fixtures__/error-tags.txt b/apps/cli/src/shared/telemetry/__fixtures__/error-tags.txt index 1e07a91140..24035e46c6 100644 --- a/apps/cli/src/shared/telemetry/__fixtures__/error-tags.txt +++ b/apps/cli/src/shared/telemetry/__fixtures__/error-tags.txt @@ -536,6 +536,7 @@ StorageWorkdirError TarFieldOutOfRangeError TarPathTooLongError TargetEmptyValueError +TargetInvalidValueError TargetNeedsProjectError TargetPasswordConflictError TargetProjectRefConflictError diff --git a/apps/cli/tests/helpers/command-mocks.ts b/apps/cli/tests/helpers/command-mocks.ts index 17aac70c77..f9b62d1961 100644 --- a/apps/cli/tests/helpers/command-mocks.ts +++ b/apps/cli/tests/helpers/command-mocks.ts @@ -62,6 +62,7 @@ import { LocalDockerEngine } from "../../src/command-internal/db-bootstrap/local import { LinkedProjectCache } from "../../src/telemetry/linked-project-cache.service.ts"; import { TelemetryState } from "../../src/telemetry/telemetry-state.service.ts"; import { CliArgs } from "../../src/shared/cli/cli-args.service.ts"; +import { commandRuntimeLayer } from "../../src/shared/runtime/command-runtime.layer.ts"; import type { Stdin } from "../../src/shared/runtime/stdin.service.ts"; import { OutputFlag } from "../../src/command-internal/global-flags.ts"; import type { Output } from "../../src/shared/output/output.service.ts"; @@ -1454,6 +1455,8 @@ export interface BuildTestRuntimeOpts { readonly outputFlag?: Option.Option<OutputFlagValue>; /** Raw argv seen by the handler (e.g. to exercise an explicit `--yes=false`). */ readonly cliArgs?: ReadonlyArray<string>; + /** The command path the handler runs as, e.g. `["storage", "ls"]`; leave it out to run without a `CommandRuntime`. */ + readonly commandPath?: ReadonlyArray<string>; } export function buildTestRuntime(opts: BuildTestRuntimeOpts) { @@ -1508,6 +1511,9 @@ export function buildTestRuntime(opts: BuildTestRuntimeOpts) { BunServices.layer, Layer.succeed(OutputFlag, outputFlag), Layer.succeed(CliArgs, { args: opts.cliArgs ?? [] }), + opts.commandPath === undefined + ? Layer.empty + : commandRuntimeLayer(opts.commandPath).pipe(Layer.provide(BunServices.layer)), linkedProjectCache, telemetry, analytics, diff --git a/apps/cli/tests/helpers/hosted-target-selection.ts b/apps/cli/tests/helpers/hosted-target-selection.ts index ebdd9d1905..6a34f4460c 100644 --- a/apps/cli/tests/helpers/hosted-target-selection.ts +++ b/apps/cli/tests/helpers/hosted-target-selection.ts @@ -90,7 +90,6 @@ export function describeTargetSelectionMatrix<R>( matrix: TargetSelectionMatrix<R>, ): void { const words = path.split(" "); - const commandLine = [...words, ...matrix.operands].join(" "); const commandName = `\`supabase ${path}\``; const extraFlags = matrix.activeFlags ?? {}; const driftVariant = matrix.variant === undefined ? {} : { variant: matrix.variant }; @@ -155,7 +154,7 @@ export function describeTargetSelectionMatrix<R>( expectActedOn(run, OTHER_REF); expect(run.warnings()).toEqual([ - `--project-ref is deprecated and will be removed in v4. Use --remote instead: supabase ${commandLine} --remote=${OTHER_REF}`, + `--project-ref is deprecated and will be removed in v4. Use --remote=<ref> instead: supabase ${path} --remote=${OTHER_REF}`, ]); }).pipe(Effect.provide(run.layer)); }); diff --git a/apps/cli/tests/helpers/remote-flag-fixture.ts b/apps/cli/tests/helpers/remote-flag-fixture.ts index c2129bfd99..9efa6518e1 100644 --- a/apps/cli/tests/helpers/remote-flag-fixture.ts +++ b/apps/cli/tests/helpers/remote-flag-fixture.ts @@ -1,7 +1,7 @@ import { Effect } from "effect"; import type { Command } from "effect/cli"; import { Argument, Command as CommandApi, Flag } from "effect/cli"; -import { targetFlags } from "../../src/command-internal/target/target-flags.ts"; +import { projectTargetFlags, targetFlags } from "../../src/command-internal/target/target-flags.ts"; import { withTargetPolicy } from "../../src/command-internal/target/target-policy.ts"; import type { TargetPolicyDeclaration } from "../../src/command-internal/target/target-policy.ts"; @@ -9,6 +9,10 @@ export const remoteFlag = targetFlags({ subject: "database", dbUrl: true }).remo Flag.withDescription("Target a remote project."), ); +export const hostedRemoteFlag = projectTargetFlags().remote.pipe( + Flag.withDescription("Target a remote project."), +); + const positional = (kind: "required" | "optional" | "variadic", index: number) => { const argument = Argument.String(`arg${index}`); if (kind === "optional") return argument.pipe(Argument.optional); @@ -33,7 +37,7 @@ export interface FixtureLeafOptions { readonly description?: string; } -/** A leaf declaring `group`'s target policy, with a `--remote` flag unless `withRemote` is false. */ +/** A leaf declaring `group`'s target policy, with the `--remote` flag that group takes unless `withRemote` is false. */ export const fixtureLeaf = ( name: string, group: FixtureGroup, @@ -41,7 +45,9 @@ export const fixtureLeaf = ( ) => { const config = { other: Flag.Boolean("other").pipe(Flag.withDescription("Another flag.")), - ...(options.withRemote === false ? {} : { remote: remoteFlag }), + ...(options.withRemote === false + ? {} + : { remote: group === "hosted" ? hostedRemoteFlag : remoteFlag }), ...Object.fromEntries( (options.positionals ?? []).map((kind, index) => [`arg${index}`, positional(kind, index)]), ), diff --git a/apps/cli/tests/helpers/storage.ts b/apps/cli/tests/helpers/storage.ts index 29791c9f5a..8eb37abe70 100644 --- a/apps/cli/tests/helpers/storage.ts +++ b/apps/cli/tests/helpers/storage.ts @@ -15,6 +15,7 @@ import type { } from "@supabase/stack/effect"; import { CliArgs } from "../../src/shared/cli/cli-args.service.ts"; +import { commandRuntimeLayer } from "../../src/shared/runtime/command-runtime.layer.ts"; import { CommandPlatformApi } from "../../src/auth/command-platform-api.service.ts"; import { CommandPlatformApiFactory } from "../../src/auth/command-platform-api-factory.service.ts"; import { ProjectRefNotLinkedError } from "../../src/config/project-ref.errors.ts"; @@ -73,6 +74,7 @@ export interface SetupStorageOptions { readonly stdinIsTty?: boolean; readonly pipedAnswers?: ReadonlyArray<string>; readonly cliArgs?: ReadonlyArray<string>; + readonly commandPath?: ReadonlyArray<string>; readonly projectRef?: string; readonly apiKeys?: ReadonlyArray<{ name: string; @@ -526,6 +528,9 @@ export function setupStorage(workdir: string, opts: SetupStorageOptions) { mockRuntimeInfo({ cwd: workdir }), // `resolveYes` scans the raw argv for an explicit `--yes=false`. Layer.succeed(CliArgs, { args: opts.cliArgs ?? [] }), + opts.commandPath === undefined + ? Layer.empty + : commandRuntimeLayer(opts.commandPath).pipe(Layer.provide(BunServices.layer)), unusedStackServices, stackApi.layer, ...(opts.stackBackend === true ? [stackBackendLayer("stack")] : []), From db8d43b5d00f889fd3acb1d40e6ec0fe4d28e2ef Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Mon, 19 Oct 2026 16:20:12 +0100 Subject: [PATCH 159/291] docs(cli): update the --remote messages in SIDE_EFFECTS, ADR 0033 and command-target - empty and false `--remote` pins use the new messages and the command's value hint - deprecation warning examples show the operand placeholders - ADR 0033 and command-target describe the SUPABASE_PROJECT_ID hint, the JSON help fields and the `link` and local-only `--remote` errors --- apps/cli/docs/command-target.md | 2 +- apps/cli/src/commands/backups/list/SIDE_EFFECTS.md | 2 +- .../src/commands/backups/restore/SIDE_EFFECTS.md | 2 +- .../src/commands/branches/create/SIDE_EFFECTS.md | 2 +- .../src/commands/branches/delete/SIDE_EFFECTS.md | 2 +- .../src/commands/branches/disable/SIDE_EFFECTS.md | 2 +- apps/cli/src/commands/branches/get/SIDE_EFFECTS.md | 2 +- .../cli/src/commands/branches/list/SIDE_EFFECTS.md | 2 +- .../src/commands/branches/pause/SIDE_EFFECTS.md | 2 +- .../src/commands/branches/unpause/SIDE_EFFECTS.md | 2 +- .../src/commands/branches/update/SIDE_EFFECTS.md | 2 +- apps/cli/src/commands/config/diff/SIDE_EFFECTS.md | 2 +- apps/cli/src/commands/config/pull/SIDE_EFFECTS.md | 2 +- apps/cli/src/commands/config/push/SIDE_EFFECTS.md | 2 +- apps/cli/src/commands/db/advisors/SIDE_EFFECTS.md | 2 +- 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 +- apps/cli/src/commands/db/pull/SIDE_EFFECTS.md | 2 +- apps/cli/src/commands/db/push/SIDE_EFFECTS.md | 2 +- apps/cli/src/commands/db/query/SIDE_EFFECTS.md | 2 +- apps/cli/src/commands/db/reset/SIDE_EFFECTS.md | 2 +- .../db/schema/declarative/generate/SIDE_EFFECTS.md | 2 +- apps/cli/src/commands/domains/SIDE_EFFECTS.md | 2 +- apps/cli/src/commands/encryption/SIDE_EFFECTS.md | 2 +- .../experimental/compute/delete/SIDE_EFFECTS.md | 2 +- .../experimental/compute/list/SIDE_EFFECTS.md | 2 +- .../experimental/compute/logs/SIDE_EFFECTS.md | 2 +- .../experimental/compute/push/SIDE_EFFECTS.md | 2 +- .../experimental/compute/status/SIDE_EFFECTS.md | 2 +- .../src/commands/functions/delete/SIDE_EFFECTS.md | 2 +- .../src/commands/functions/deploy/SIDE_EFFECTS.md | 3 +-- .../commands/functions/download/SIDE_EFFECTS.md | 2 +- .../src/commands/functions/list/SIDE_EFFECTS.md | 2 +- apps/cli/src/commands/gen/types/SIDE_EFFECTS.md | 11 +++++------ apps/cli/src/commands/inspect/db/SIDE_EFFECTS.md | 3 +-- .../src/commands/inspect/report/SIDE_EFFECTS.md | 3 +-- .../src/commands/migration/down/SIDE_EFFECTS.md | 3 +-- .../src/commands/migration/fetch/SIDE_EFFECTS.md | 3 +-- .../src/commands/migration/list/SIDE_EFFECTS.md | 3 +-- .../src/commands/migration/repair/SIDE_EFFECTS.md | 7 +++---- .../src/commands/migration/squash/SIDE_EFFECTS.md | 3 +-- apps/cli/src/commands/migration/up/SIDE_EFFECTS.md | 3 +-- .../src/commands/network-bans/get/SIDE_EFFECTS.md | 3 +-- .../commands/network-bans/remove/SIDE_EFFECTS.md | 3 +-- .../network-restrictions/get/SIDE_EFFECTS.md | 3 +-- .../network-restrictions/update/SIDE_EFFECTS.md | 3 +-- .../src/commands/notebooks/pull/SIDE_EFFECTS.md | 3 +-- .../src/commands/notebooks/push/SIDE_EFFECTS.md | 3 +-- .../postgres-config/delete/SIDE_EFFECTS.md | 3 +-- .../commands/postgres-config/get/SIDE_EFFECTS.md | 3 +-- .../postgres-config/update/SIDE_EFFECTS.md | 3 +-- .../src/commands/projects/api-keys/SIDE_EFFECTS.md | 3 +-- apps/cli/src/commands/pull/SIDE_EFFECTS.md | 2 +- apps/cli/src/commands/secrets/list/SIDE_EFFECTS.md | 3 +-- apps/cli/src/commands/secrets/set/SIDE_EFFECTS.md | 3 +-- .../cli/src/commands/secrets/unset/SIDE_EFFECTS.md | 3 +-- apps/cli/src/commands/seed/buckets/SIDE_EFFECTS.md | 3 +-- .../src/commands/snippets/download/SIDE_EFFECTS.md | 3 +-- .../cli/src/commands/snippets/list/SIDE_EFFECTS.md | 3 +-- .../commands/ssl-enforcement/get/SIDE_EFFECTS.md | 3 +-- .../ssl-enforcement/update/SIDE_EFFECTS.md | 3 +-- apps/cli/src/commands/sso/add/SIDE_EFFECTS.md | 3 +-- apps/cli/src/commands/sso/info/SIDE_EFFECTS.md | 3 +-- apps/cli/src/commands/sso/list/SIDE_EFFECTS.md | 3 +-- apps/cli/src/commands/sso/remove/SIDE_EFFECTS.md | 3 +-- apps/cli/src/commands/sso/show/SIDE_EFFECTS.md | 3 +-- apps/cli/src/commands/sso/update/SIDE_EFFECTS.md | 3 +-- apps/cli/src/commands/storage/cp/SIDE_EFFECTS.md | 7 +++---- apps/cli/src/commands/storage/ls/SIDE_EFFECTS.md | 7 +++---- apps/cli/src/commands/storage/mv/SIDE_EFFECTS.md | 7 +++---- apps/cli/src/commands/storage/rm/SIDE_EFFECTS.md | 7 +++---- apps/cli/src/commands/test/db/SIDE_EFFECTS.md | 2 +- .../vanity-subdomains/activate/SIDE_EFFECTS.md | 3 +-- .../check-availability/SIDE_EFFECTS.md | 3 +-- .../vanity-subdomains/delete/SIDE_EFFECTS.md | 3 +-- .../commands/vanity-subdomains/get/SIDE_EFFECTS.md | 3 +-- ...target-selection-and-hosted-mutation-consent.md | 14 ++++++++++---- 78 files changed, 101 insertions(+), 137 deletions(-) diff --git a/apps/cli/docs/command-target.md b/apps/cli/docs/command-target.md index d37bb5c936..8e9a3bb50a 100644 --- a/apps/cli/docs/command-target.md +++ b/apps/cli/docs/command-target.md @@ -19,7 +19,7 @@ Database commands run against the local stack unless `--remote` is passed: bare | Branch found by name or UUID | `project <parent name> (<parent ref>) · branch <name> (<ref>)`, or `branch <ref>` for a UUID | | The project's default branch | `project <name> (<ref>) · production` | -The `default; pass --remote` hint appears only when the checkout is linked and no `--remote` was passed. "Branch" is claimed only on positive evidence: a branch lookup by name or UUID, or a live probe. A cached link never proves one. A branch's parent shows as its bare ref when the cache doesn't know its name. +The `default; pass --remote` hint appears only when a project is linked, by `SUPABASE_PROJECT_ID` or the link file, and no `--remote` was passed. "Branch" is claimed only on positive evidence: a branch lookup by name or UUID, or a live probe. A cached link never proves one. A branch's parent shows as its bare ref when the cache doesn't know its name. ## JSON `target` diff --git a/apps/cli/src/commands/backups/list/SIDE_EFFECTS.md b/apps/cli/src/commands/backups/list/SIDE_EFFECTS.md index eb2a269ecb..3177cb3c2e 100644 --- a/apps/cli/src/commands/backups/list/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/backups/list/SIDE_EFFECTS.md @@ -93,7 +93,7 @@ One `result` NDJSON event on success containing the backup response object. ## Notes -- Targets the linked project unless `--remote=<ref>` is given; a bare `--remote` also targets the linked project. `--remote=<value>` is read as a project ref (20 lowercase letters), a branch of the linked project (by name or UUID, resolved through `GET /v1/projects/{parent}/branches`, or `GET /v1/branches/{id}` for a UUID when no project is linked), or `true|yes|on|1` for the linked project. A database URL is rejected (`supabase backups list` takes a project, not a database); `false|no|off|0`, `--no-remote` and an empty value fail with `Leave out --remote to use the linked project.` +- Targets the linked project unless `--remote=<ref>` is given; a bare `--remote` also targets the linked project. `--remote=<value>` is read as a project ref (20 lowercase letters), a branch of the linked project (by name or UUID, resolved through `GET /v1/projects/{parent}/branches`, or `GET /v1/branches/{id}` for a UUID when no project is linked), or `true|yes|on|1` for the linked project. A database URL is rejected (`supabase backups list` takes a project, not a database); `false|no|off|0` and `--no-remote` fail with `--remote doesn't take false. Leave it out to use the linked project.`, and an empty value with `--remote= has an empty value. Pass --remote for the linked project or --remote=<ref|branch>; leave it out to use the linked project.` - A word after a bare `--remote` is read as its value, `--remote=<word>`, because the command takes no operands. - The deprecated `--project-ref` is an alias of `--remote=<ref>` and prints a deprecation warning on stderr that names the replacement; it cannot be combined with `--remote=<value>`. An empty `--project-ref` is rejected. - Requires `--remote=<ref>`, `SUPABASE_PROJECT_ID`, a populated `<workdir>/supabase/.temp/project-ref` file, or a TTY for the interactive project picker. diff --git a/apps/cli/src/commands/backups/restore/SIDE_EFFECTS.md b/apps/cli/src/commands/backups/restore/SIDE_EFFECTS.md index 6cc305b3bc..ae63c51ced 100644 --- a/apps/cli/src/commands/backups/restore/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/backups/restore/SIDE_EFFECTS.md @@ -81,7 +81,7 @@ One `result` NDJSON event on success with the project ref payload. ## Notes -- Targets the linked project unless `--remote=<ref>` is given; a bare `--remote` also targets the linked project. `--remote=<value>` is read as a project ref (20 lowercase letters), a branch of the linked project (by name or UUID, resolved through `GET /v1/projects/{parent}/branches`, or `GET /v1/branches/{id}` for a UUID when no project is linked), or `true|yes|on|1` for the linked project. A database URL is rejected (`supabase backups restore` takes a project, not a database); `false|no|off|0`, `--no-remote` and an empty value fail with `Leave out --remote to use the linked project.` +- Targets the linked project unless `--remote=<ref>` is given; a bare `--remote` also targets the linked project. `--remote=<value>` is read as a project ref (20 lowercase letters), a branch of the linked project (by name or UUID, resolved through `GET /v1/projects/{parent}/branches`, or `GET /v1/branches/{id}` for a UUID when no project is linked), or `true|yes|on|1` for the linked project. A database URL is rejected (`supabase backups restore` takes a project, not a database); `false|no|off|0` and `--no-remote` fail with `--remote doesn't take false. Leave it out to use the linked project.`, and an empty value with `--remote= has an empty value. Pass --remote for the linked project or --remote=<ref|branch>; leave it out to use the linked project.` - A word after a bare `--remote` is read as its value, `--remote=<word>`, because the command takes no operands. - The deprecated `--project-ref` is an alias of `--remote=<ref>` and prints a deprecation warning on stderr that names the replacement; it cannot be combined with `--remote=<value>`. An empty `--project-ref` is rejected. - `--timestamp` / `-t` accepts seconds since Unix epoch (int64). Defaults to `0`, which the API interprets as "now". diff --git a/apps/cli/src/commands/branches/create/SIDE_EFFECTS.md b/apps/cli/src/commands/branches/create/SIDE_EFFECTS.md index a4e40602e2..5bab3d8c03 100644 --- a/apps/cli/src/commands/branches/create/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/branches/create/SIDE_EFFECTS.md @@ -69,7 +69,7 @@ In `--output-format json` / `stream-json`, a `success` event carries the respons ## Notes -- `--remote=<ref>` names the parent project; with no selector or a bare `--remote` the parent comes from the chain above. A branch name or UUID is rejected with `` `supabase branches create` acts on a parent project, so --remote takes its project ref, not a branch name. Use --remote=<ref>. ``, and a database URL with `` `supabase branches create` needs a project, not a database URL. ``; `false|no|off|0`, `--no-remote` and an empty value fail with `Leave out --remote to use the linked project.` The branch itself stays a positional argument. +- `--remote=<ref>` names the parent project; with no selector or a bare `--remote` the parent comes from the chain above. A branch name or UUID is rejected with `` `supabase branches create` acts on a parent project, so --remote takes its project ref, not a branch name. Use --remote=<ref>. ``, and a database URL with `` `supabase branches create` needs a project, not a database URL. ``; `false|no|off|0` and `--no-remote` fail with `--remote doesn't take false. Leave it out to use the linked project.`, and an empty value with `--remote= has an empty value. Pass --remote for the linked project or --remote=<ref>; leave it out to use the linked project.` The branch itself stays a positional argument. - The deprecated `--project-ref` is an alias of `--remote=<ref>` and prints a deprecation warning on stderr that names the replacement; it cannot be combined with `--remote=<value>`. An empty `--project-ref` is rejected. - 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 3249829257..c7cd11621d 100644 --- a/apps/cli/src/commands/branches/delete/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/branches/delete/SIDE_EFFECTS.md @@ -57,7 +57,7 @@ Single `success` event carrying `{project_ref}`. ## Notes -- `--remote=<ref>` names the parent project; with no selector or a bare `--remote` the parent comes from the chain above. A branch name or UUID is rejected with `` `supabase branches delete` acts on a parent project, so --remote takes its project ref, not a branch name. Use --remote=<ref>. ``, and a database URL with `` `supabase branches delete` needs a project, not a database URL. ``; `false|no|off|0`, `--no-remote` and an empty value fail with `Leave out --remote to use the linked project.` The branch itself stays a positional argument. +- `--remote=<ref>` names the parent project; with no selector or a bare `--remote` the parent comes from the chain above. A branch name or UUID is rejected with `` `supabase branches delete` acts on a parent project, so --remote takes its project ref, not a branch name. Use --remote=<ref>. ``, and a database URL with `` `supabase branches delete` needs a project, not a database URL. ``; `false|no|off|0` and `--no-remote` fail with `--remote doesn't take false. Leave it out to use the linked project.`, and an empty value with `--remote= has an empty value. Pass --remote for the linked project or --remote=<ref>; leave it out to use the linked project.` The branch itself stays a positional argument. - The deprecated `--project-ref` is an alias of `--remote=<ref>` and prints a deprecation warning on stderr that names the replacement; it cannot be combined with `--remote=<value>`. An empty `--project-ref` is rejected. - 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 73b029484e..686ce917fd 100644 --- a/apps/cli/src/commands/branches/disable/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/branches/disable/SIDE_EFFECTS.md @@ -55,7 +55,7 @@ Single `success` event carrying `{project_ref}`. ## Notes -- `--remote=<ref>` names the parent project; with no selector or a bare `--remote` the parent comes from the chain above. A branch name or UUID is rejected with `` `supabase branches disable` acts on a parent project, so --remote takes its project ref, not a branch name. Use --remote=<ref>. ``, and a database URL with `` `supabase branches disable` needs a project, not a database URL. ``; `false|no|off|0`, `--no-remote` and an empty value fail with `Leave out --remote to use the linked project.` The branch itself stays a positional argument. +- `--remote=<ref>` names the parent project; with no selector or a bare `--remote` the parent comes from the chain above. A branch name or UUID is rejected with `` `supabase branches disable` acts on a parent project, so --remote takes its project ref, not a branch name. Use --remote=<ref>. ``, and a database URL with `` `supabase branches disable` needs a project, not a database URL. ``; `false|no|off|0` and `--no-remote` fail with `--remote doesn't take false. Leave it out to use the linked project.`, and an empty value with `--remote= has an empty value. Pass --remote for the linked project or --remote=<ref>; leave it out to use the linked project.` The branch itself stays a positional argument. - The deprecated `--project-ref` is an alias of `--remote=<ref>` and prints a deprecation warning on stderr that names the replacement; it cannot be combined with `--remote=<value>`. An empty `--project-ref` is rejected. - 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 473f09ab37..73b0a0cc82 100644 --- a/apps/cli/src/commands/branches/get/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/branches/get/SIDE_EFFECTS.md @@ -56,6 +56,6 @@ Emits the standard-env projection: `POSTGRES_URL` (pooled, falls back to direct ## Notes -- `--remote=<ref>` names the parent project; with no selector or a bare `--remote` the parent comes from the chain above. A branch name or UUID is rejected with `` `supabase branches get` acts on a parent project, so --remote takes its project ref, not a branch name. Use --remote=<ref>. ``, and a database URL with `` `supabase branches get` needs a project, not a database URL. ``; `false|no|off|0`, `--no-remote` and an empty value fail with `Leave out --remote to use the linked project.` The branch itself stays a positional argument. +- `--remote=<ref>` names the parent project; with no selector or a bare `--remote` the parent comes from the chain above. A branch name or UUID is rejected with `` `supabase branches get` acts on a parent project, so --remote takes its project ref, not a branch name. Use --remote=<ref>. ``, and a database URL with `` `supabase branches get` needs a project, not a database URL. ``; `false|no|off|0` and `--no-remote` fail with `--remote doesn't take false. Leave it out to use the linked project.`, and an empty value with `--remote= has an empty value. Pass --remote for the linked project or --remote=<ref>; leave it out to use the linked project.` The branch itself stays a positional argument. - The deprecated `--project-ref` is an alias of `--remote=<ref>` and prints a deprecation warning on stderr that names the replacement; it cannot be combined with `--remote=<value>`. An empty `--project-ref` is rejected. - 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 f6cd56304b..7a1846baa5 100644 --- a/apps/cli/src/commands/branches/list/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/branches/list/SIDE_EFFECTS.md @@ -107,7 +107,7 @@ One `result` NDJSON event with `{branches: [...]}`. ## Notes -- `--remote=<ref>` names the parent project; with no selector or a bare `--remote` the parent comes from the chain above. A branch name or UUID is rejected with `` `supabase branches list` acts on a parent project, so --remote takes its project ref, not a branch name. Use --remote=<ref>. ``, and a database URL with `` `supabase branches list` needs a project, not a database URL. ``; `false|no|off|0`, `--no-remote` and an empty value fail with `Leave out --remote to use the linked project.` The branch itself stays a positional argument. +- `--remote=<ref>` names the parent project; with no selector or a bare `--remote` the parent comes from the chain above. A branch name or UUID is rejected with `` `supabase branches list` acts on a parent project, so --remote takes its project ref, not a branch name. Use --remote=<ref>. ``, and a database URL with `` `supabase branches list` needs a project, not a database URL. ``; `false|no|off|0` and `--no-remote` fail with `--remote doesn't take false. Leave it out to use the linked project.`, and an empty value with `--remote= has an empty value. Pass --remote for the linked project or --remote=<ref>; leave it out to use the linked project.` The branch itself stays a positional argument. - The deprecated `--project-ref` is an alias of `--remote=<ref>` and prints a deprecation warning on stderr that names the replacement; it cannot be combined with `--remote=<value>`. An empty `--project-ref` is rejected. - Timestamps are formatted as UTC `YYYY-MM-DD HH:MM:SS`. - Sends `User-Agent: SupabaseCLI/<version>` and Bearer auth. No `X-Supabase-Command` headers. diff --git a/apps/cli/src/commands/branches/pause/SIDE_EFFECTS.md b/apps/cli/src/commands/branches/pause/SIDE_EFFECTS.md index 612157f332..27772c414b 100644 --- a/apps/cli/src/commands/branches/pause/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/branches/pause/SIDE_EFFECTS.md @@ -51,7 +51,7 @@ Silent on success in every mode (the handler writes nothing itself; the target b ## Notes -- `--remote=<ref>` names the parent project; with no selector or a bare `--remote` the parent comes from the chain above. A branch name or UUID is rejected with `` `supabase branches pause` acts on a parent project, so --remote takes its project ref, not a branch name. Use --remote=<ref>. ``, and a database URL with `` `supabase branches pause` needs a project, not a database URL. ``; `false|no|off|0`, `--no-remote` and an empty value fail with `Leave out --remote to use the linked project.` The branch itself stays a positional argument. +- `--remote=<ref>` names the parent project; with no selector or a bare `--remote` the parent comes from the chain above. A branch name or UUID is rejected with `` `supabase branches pause` acts on a parent project, so --remote takes its project ref, not a branch name. Use --remote=<ref>. ``, and a database URL with `` `supabase branches pause` needs a project, not a database URL. ``; `false|no|off|0` and `--no-remote` fail with `--remote doesn't take false. Leave it out to use the linked project.`, and an empty value with `--remote= has an empty value. Pass --remote for the linked project or --remote=<ref>; leave it out to use the linked project.` The branch itself stays a positional argument. - The deprecated `--project-ref` is an alias of `--remote=<ref>` and prints a deprecation warning on stderr that names the replacement; it cannot be combined with `--remote=<value>`. An empty `--project-ref` is rejected. - 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 bc7b51ca35..630ed1a5bb 100644 --- a/apps/cli/src/commands/branches/unpause/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/branches/unpause/SIDE_EFFECTS.md @@ -51,7 +51,7 @@ Silent on success in every mode (the handler writes nothing itself; the target b ## Notes -- `--remote=<ref>` names the parent project; with no selector or a bare `--remote` the parent comes from the chain above. A branch name or UUID is rejected with `` `supabase branches unpause` acts on a parent project, so --remote takes its project ref, not a branch name. Use --remote=<ref>. ``, and a database URL with `` `supabase branches unpause` needs a project, not a database URL. ``; `false|no|off|0`, `--no-remote` and an empty value fail with `Leave out --remote to use the linked project.` The branch itself stays a positional argument. +- `--remote=<ref>` names the parent project; with no selector or a bare `--remote` the parent comes from the chain above. A branch name or UUID is rejected with `` `supabase branches unpause` acts on a parent project, so --remote takes its project ref, not a branch name. Use --remote=<ref>. ``, and a database URL with `` `supabase branches unpause` needs a project, not a database URL. ``; `false|no|off|0` and `--no-remote` fail with `--remote doesn't take false. Leave it out to use the linked project.`, and an empty value with `--remote= has an empty value. Pass --remote for the linked project or --remote=<ref>; leave it out to use the linked project.` The branch itself stays a positional argument. - The deprecated `--project-ref` is an alias of `--remote=<ref>` and prints a deprecation warning on stderr that names the replacement; it cannot be combined with `--remote=<value>`. An empty `--project-ref` is rejected. - 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 6f17467eb5..fd8797fceb 100644 --- a/apps/cli/src/commands/branches/update/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/branches/update/SIDE_EFFECTS.md @@ -58,7 +58,7 @@ For `--output {json,yaml,toml,env}`, the header goes to stderr followed by the e ## Notes -- `--remote=<ref>` names the parent project; with no selector or a bare `--remote` the parent comes from the chain above. A branch name or UUID is rejected with `` `supabase branches update` acts on a parent project, so --remote takes its project ref, not a branch name. Use --remote=<ref>. ``, and a database URL with `` `supabase branches update` needs a project, not a database URL. ``; `false|no|off|0`, `--no-remote` and an empty value fail with `Leave out --remote to use the linked project.` The branch itself stays a positional argument. +- `--remote=<ref>` names the parent project; with no selector or a bare `--remote` the parent comes from the chain above. A branch name or UUID is rejected with `` `supabase branches update` acts on a parent project, so --remote takes its project ref, not a branch name. Use --remote=<ref>. ``, and a database URL with `` `supabase branches update` needs a project, not a database URL. ``; `false|no|off|0` and `--no-remote` fail with `--remote doesn't take false. Leave it out to use the linked project.`, and an empty value with `--remote= has an empty value. Pass --remote for the linked project or --remote=<ref>; leave it out to use the linked project.` The branch itself stays a positional argument. - The deprecated `--project-ref` is an alias of `--remote=<ref>` and prints a deprecation warning on stderr that names the replacement; it cannot be combined with `--remote=<value>`. An empty `--project-ref` is rejected. - The upgrade-suggest call uses the branch's own resolved project ref (`resolveBranchProjectRef`) — not the parent `--remote=<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. diff --git a/apps/cli/src/commands/config/diff/SIDE_EFFECTS.md b/apps/cli/src/commands/config/diff/SIDE_EFFECTS.md index fd5eef8449..2ec89cdc87 100644 --- a/apps/cli/src/commands/config/diff/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/config/diff/SIDE_EFFECTS.md @@ -146,7 +146,7 @@ and emit nothing. ## Notes -- `--remote=<value>` selects the target: a project ref (20 lowercase letters), or the name or UUID of a branch of the linked project (resolved through `GET /v1/projects/{parent}/branches`, or `GET /v1/branches/{id}` for a UUID when no project is linked). A bare `--remote` and no selector both target the linked project. A database URL is rejected (`supabase config diff` takes a project, not a database); `false|no|off|0`, `--no-remote` and an empty value fail with `Leave out --remote to use the linked project.` +- `--remote=<value>` selects the target: a project ref (20 lowercase letters), or the name or UUID of a branch of the linked project (resolved through `GET /v1/projects/{parent}/branches`, or `GET /v1/branches/{id}` for a UUID when no project is linked). A bare `--remote` and no selector both target the linked project. A database URL is rejected (`supabase config diff` takes a project, not a database); `false|no|off|0` and `--no-remote` fail with `--remote doesn't take false. Leave it out to use the linked project.`, and an empty value with `--remote= has an empty value. Pass --remote for the linked project or --remote=<ref|branch>; leave it out to use the linked project.` - The deprecated `--project-ref` is an alias of `--remote=<ref>` (it still accepts a branch name or UUID) and prints a deprecation warning on stderr that names the replacement; it cannot be combined with `--remote=<value>`. An empty `--project-ref` is rejected. - 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. diff --git a/apps/cli/src/commands/config/pull/SIDE_EFFECTS.md b/apps/cli/src/commands/config/pull/SIDE_EFFECTS.md index 4ca17517ca..cfb54115d8 100644 --- a/apps/cli/src/commands/config/pull/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/config/pull/SIDE_EFFECTS.md @@ -182,7 +182,7 @@ the -o/--output flag is not supported by config pull; use --output-format json|s ## Notes -- `--remote=<value>` selects the target: a project ref (20 lowercase letters), or the name or UUID of a branch of the linked project (resolved through `GET /v1/projects/{parent}/branches`, or `GET /v1/branches/{id}` for a UUID when no project is linked). A bare `--remote` and no selector both target the linked project. A database URL is rejected (`supabase config pull` takes a project, not a database); `false|no|off|0`, `--no-remote` and an empty value fail with `Leave out --remote to use the linked project.` +- `--remote=<value>` selects the target: a project ref (20 lowercase letters), or the name or UUID of a branch of the linked project (resolved through `GET /v1/projects/{parent}/branches`, or `GET /v1/branches/{id}` for a UUID when no project is linked). A bare `--remote` and no selector both target the linked project. A database URL is rejected (`supabase config pull` takes a project, not a database); `false|no|off|0` and `--no-remote` fail with `--remote doesn't take false. Leave it out to use the linked project.`, and an empty value with `--remote= has an empty value. Pass --remote for the linked project or --remote=<ref|branch>; leave it out to use the linked project.` - The deprecated `--project-ref` is an alias of `--remote=<ref>` (it still accepts a branch name or UUID) and prints a deprecation warning on stderr that names the replacement; it cannot be combined with `--remote=<value>`. An empty `--project-ref` is rejected. - **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 diff --git a/apps/cli/src/commands/config/push/SIDE_EFFECTS.md b/apps/cli/src/commands/config/push/SIDE_EFFECTS.md index b68cc4e84d..4f23003e0b 100644 --- a/apps/cli/src/commands/config/push/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/config/push/SIDE_EFFECTS.md @@ -349,7 +349,7 @@ may itself contain a `.`. ## Notes -- `--remote=<value>` selects the target: a project ref (20 lowercase letters), or the name or UUID of a branch of the linked project (resolved through `GET /v1/projects/{parent}/branches`, or `GET /v1/branches/{id}` for a UUID when no project is linked). A bare `--remote` and no selector both target the linked project. A database URL is rejected (`supabase config push` takes a project, not a database); `false|no|off|0`, `--no-remote` and an empty value fail with `Leave out --remote to use the linked project.` +- `--remote=<value>` selects the target: a project ref (20 lowercase letters), or the name or UUID of a branch of the linked project (resolved through `GET /v1/projects/{parent}/branches`, or `GET /v1/branches/{id}` for a UUID when no project is linked). A bare `--remote` and no selector both target the linked project. A database URL is rejected (`supabase config push` takes a project, not a database); `false|no|off|0` and `--no-remote` fail with `--remote doesn't take false. Leave it out to use the linked project.`, and an empty value with `--remote= has an empty value. Pass --remote for the linked project or --remote=<ref|branch>; leave it out to use the linked project.` - The deprecated `--project-ref` is an alias of `--remote=<ref>` (it still accepts a branch name or UUID) and prints a deprecation warning on stderr that names the replacement; it cannot be combined with `--remote=<value>`. An empty `--project-ref` is rejected. - **`--remote` 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 or UUID is resolved against the branches of the currently linked project (fails if none is linked, or if the linked ref is itself invalid); a UUID with no usable linked project resolves directly through `GET /v1/branches/{id}`. - **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 `--remote` 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 `--remote` are treated alike, and the question names the branch and its parent project. diff --git a/apps/cli/src/commands/db/advisors/SIDE_EFFECTS.md b/apps/cli/src/commands/db/advisors/SIDE_EFFECTS.md index cd5c2d37e1..9902e44ba2 100644 --- a/apps/cli/src/commands/db/advisors/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/db/advisors/SIDE_EFFECTS.md @@ -104,7 +104,7 @@ the process exits non-zero (no error envelope is written over the payload). - `--fail-on` (`none` default) sets the level that forces a non-zero exit. - **Target selection.** `--remote` is read as the linked project (bare), a project ref (20 lowercase letters), a Postgres URL, or else a branch of the linked project by name or UUID; - `--remote=false` and `--remote=` fail with `Leave out --remote to target the local database.` A + `--remote=false` fails with `--remote doesn't take false. Leave it out to target the local database.`, and `--remote=` with `--remote= has an empty value. Pass --remote for the linked project or --remote=<ref|branch|url>; leave it out to target the local database.` A word after a bare `--remote` is read as its value, `--remote=<word>`, because the command takes no operands. - **Deprecated aliases.** `--linked`, `--project-ref=<ref>`, `--db-url=<url>` and `--local` still diff --git a/apps/cli/src/commands/db/diff/SIDE_EFFECTS.md b/apps/cli/src/commands/db/diff/SIDE_EFFECTS.md index 765c2057c2..6f02601eab 100644 --- a/apps/cli/src/commands/db/diff/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/db/diff/SIDE_EFFECTS.md @@ -239,7 +239,7 @@ transaction metadata. live member of the group. - **Target selection.** `--remote` is read as the linked project (bare), a project ref (20 lowercase letters), a Postgres URL, or else a branch of the linked project by name or UUID; - `--remote=false` and `--remote=` fail with `Leave out --remote to target the local database.` A + `--remote=false` fails with `--remote doesn't take false. Leave it out to target the local database.`, and `--remote=` with `--remote= has an empty value. Pass --remote for the linked project or --remote=<ref|branch|url>; leave it out to target the local database.` A word after a bare `--remote` is read as its value, `--remote=<word>`, because the command takes no operands. `--remote=<ref>` supplies only the ref; unlike `SUPABASE_PROJECT_ID`, it does not affect the diff --git a/apps/cli/src/commands/db/dump/SIDE_EFFECTS.md b/apps/cli/src/commands/db/dump/SIDE_EFFECTS.md index ab6e70fbe8..0901877e1c 100644 --- a/apps/cli/src/commands/db/dump/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/db/dump/SIDE_EFFECTS.md @@ -108,7 +108,7 @@ shell inherits the suppressing variables and is missed. `--schema` XOR `--role-only`. `--use-copy` / `--exclude` require `--data-only`. - **Target selection.** `--remote` is read as the linked project (bare), a project ref (20 lowercase letters), a Postgres URL, or else a branch of the linked project by name or UUID; - `--remote=false` and `--remote=` fail with `Leave out --remote to target the local database.` A + `--remote=false` fails with `--remote doesn't take false. Leave it out to target the local database.`, and `--remote=` with `--remote= has an empty value. Pass --remote for the linked project or --remote=<ref|branch|url>; leave it out to target the local database.` A word after a bare `--remote` is read as its value, `--remote=<word>`, because the command takes no operands. - **Deprecated aliases.** `--linked`, `--project-ref=<ref>`, `--db-url=<url>` and `--local` still diff --git a/apps/cli/src/commands/db/lint/SIDE_EFFECTS.md b/apps/cli/src/commands/db/lint/SIDE_EFFECTS.md index dedacb5dd2..cba0c46786 100644 --- a/apps/cli/src/commands/db/lint/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/db/lint/SIDE_EFFECTS.md @@ -111,7 +111,7 @@ the process exits non-zero (no error envelope is written over the payload). - `--schema` / `-s` restricts linting to specific schemas; omitted ⇒ all user schemas. - **Target selection.** `--remote` is read as the linked project (bare), a project ref (20 lowercase letters), a Postgres URL, or else a branch of the linked project by name or UUID; - `--remote=false` and `--remote=` fail with `Leave out --remote to target the local database.` A + `--remote=false` fails with `--remote doesn't take false. Leave it out to target the local database.`, and `--remote=` with `--remote= has an empty value. Pass --remote for the linked project or --remote=<ref|branch|url>; leave it out to target the local database.` A word after a bare `--remote` is read as its value, `--remote=<word>`, because the command takes no operands. - **Deprecated aliases.** `--linked`, `--project-ref=<ref>`, `--db-url=<url>` and `--local` still diff --git a/apps/cli/src/commands/db/pull/SIDE_EFFECTS.md b/apps/cli/src/commands/db/pull/SIDE_EFFECTS.md index 2ceab421c5..c1c2c5cd59 100644 --- a/apps/cli/src/commands/db/pull/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/db/pull/SIDE_EFFECTS.md @@ -213,7 +213,7 @@ resolved `target` object is merged into the payload. `SUPABASE_EXPERIMENTAL_PG_DELTA` opt-in is not read. - **Target selection.** `--remote` is read as the linked project (bare), a project ref (20 lowercase letters), a Postgres URL, or else a branch of the linked project by name or UUID; - `--remote=false` and `--remote=` fail with `Leave out --remote to target the local database.` A + `--remote=false` fails with `--remote doesn't take false. Leave it out to target the local database.`, and `--remote=` with `--remote= has an empty value. Pass --remote for the linked project or --remote=<ref|branch|url>; leave it out to target the local database.` A bare `--remote` followed by a word that exactly matches a branch of the linked project, is within a small edit distance of one, or can't be checked because the branch listing failed stops the run with an error that offers both readings: `--remote=<word>` for the branch, `<word> --remote` for diff --git a/apps/cli/src/commands/db/push/SIDE_EFFECTS.md b/apps/cli/src/commands/db/push/SIDE_EFFECTS.md index 6f81da7a6d..9f2a6d5b7f 100644 --- a/apps/cli/src/commands/db/push/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/db/push/SIDE_EFFECTS.md @@ -109,7 +109,7 @@ project ref and its environment. `Target:` banner on stderr before anything is connected. - **Target selection.** `--remote` is read as the linked project (bare), a project ref (20 lowercase letters), a Postgres URL, or else a branch of the linked project by name or UUID; - `--remote=false` and `--remote=` fail with `Leave out --remote to target the local database.` A + `--remote=false` fails with `--remote doesn't take false. Leave it out to target the local database.`, and `--remote=` with `--remote= has an empty value. Pass --remote for the linked project or --remote=<ref|branch|url>; leave it out to target the local database.` A word after a bare `--remote` is read as its value, `--remote=<word>`, because the command takes no operands. - **Deprecated aliases.** `--linked`, `--project-ref=<ref>`, `--db-url=<url>` and `--local` still diff --git a/apps/cli/src/commands/db/query/SIDE_EFFECTS.md b/apps/cli/src/commands/db/query/SIDE_EFFECTS.md index c3fe228c24..47f7103da9 100644 --- a/apps/cli/src/commands/db/query/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/db/query/SIDE_EFFECTS.md @@ -105,7 +105,7 @@ from the environment. Agent mode defaults the format to JSON (table for humans). `CREATE TABLE` etc. survive node-postgres' first-word-only parse of the tag). - **Target selection.** `--remote` is read as the linked project (bare), a project ref (20 lowercase letters), a Postgres URL, or else a branch of the linked project by name or UUID; - `--remote=false` and `--remote=` fail with `Leave out --remote to target the local database.` A + `--remote=false` fails with `--remote doesn't take false. Leave it out to target the local database.`, and `--remote=` with `--remote= has an empty value. Pass --remote for the linked project or --remote=<ref|branch|url>; leave it out to target the local database.` A bare `--remote` followed by a word that exactly matches a branch of the linked project, is within a small edit distance of one, or can't be checked because the branch listing failed stops the run with an error that offers both readings: `--remote=<word>` for the branch, `<word> --remote` for diff --git a/apps/cli/src/commands/db/reset/SIDE_EFFECTS.md b/apps/cli/src/commands/db/reset/SIDE_EFFECTS.md index ce33194306..05d522777e 100644 --- a/apps/cli/src/commands/db/reset/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/db/reset/SIDE_EFFECTS.md @@ -274,7 +274,7 @@ to those defaults (the usual outcome for an interactive terminal). never printed or logged. - **Target selection.** `--remote` is read as the linked project (bare), a project ref (20 lowercase letters), a Postgres URL, or else a branch of the linked project by name or UUID; - `--remote=false` and `--remote=` fail with `Leave out --remote to target the local database.` A + `--remote=false` fails with `--remote doesn't take false. Leave it out to target the local database.`, and `--remote=` with `--remote= has an empty value. Pass --remote for the linked project or --remote=<ref|branch|url>; leave it out to target the local database.` A word after a bare `--remote` is read as its value, `--remote=<word>`, because the command takes no operands. - **Deprecated aliases.** `--linked`, `--project-ref=<ref>`, `--db-url=<url>` and `--local` still 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 c85a3f2283..14a53ced1a 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 @@ -96,7 +96,7 @@ always go to stderr, in every `--output-format`. On success: non-interactive mode. - **Target selection.** `--remote` is read as the linked project (bare), a project ref (20 lowercase letters), a Postgres URL, or else a branch of the linked project by name or UUID; - `--remote=false` and `--remote=` fail with `Leave out --remote to target the local database.` A + `--remote=false` fails with `--remote doesn't take false. Leave it out to target the local database.`, and `--remote=` with `--remote= has an empty value. Pass --remote for the linked project or --remote=<ref|branch|url>; leave it out to target the local database.` A word after a bare `--remote` is read as its value, `--remote=<word>`, because the command takes no operands. - **Deprecated aliases.** `--linked`, `--project-ref=<ref>`, `--db-url=<url>` and `--local` still diff --git a/apps/cli/src/commands/domains/SIDE_EFFECTS.md b/apps/cli/src/commands/domains/SIDE_EFFECTS.md index 8b8756aba6..06481bc7e9 100644 --- a/apps/cli/src/commands/domains/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/domains/SIDE_EFFECTS.md @@ -98,7 +98,7 @@ suppressed on stderr. `delete` ignores `-o`. ## Notes -- Targets the linked project unless `--remote=<ref>` is given; a bare `--remote` also targets the linked project. `--remote=<value>` is read as a project ref (20 lowercase letters), a branch of the linked project (by name or UUID, resolved through `GET /v1/projects/{parent}/branches`, or `GET /v1/branches/{id}` for a UUID when no project is linked), or `true|yes|on|1` for the linked project. A database URL is rejected (these commands take a project, not a database); `false|no|off|0`, `--no-remote` and an empty value fail with `Leave out --remote to use the linked project.` +- Targets the linked project unless `--remote=<ref>` is given; a bare `--remote` also targets the linked project. `--remote=<value>` is read as a project ref (20 lowercase letters), a branch of the linked project (by name or UUID, resolved through `GET /v1/projects/{parent}/branches`, or `GET /v1/branches/{id}` for a UUID when no project is linked), or `true|yes|on|1` for the linked project. A database URL is rejected (these commands take a project, not a database); `false|no|off|0` and `--no-remote` fail with `--remote doesn't take false. Leave it out to use the linked project.`, and an empty value with `--remote= has an empty value. Pass --remote for the linked project or --remote=<ref|branch>; leave it out to use the linked project.` - A word after a bare `--remote` is read as its value, `--remote=<word>`, because the command takes no operands. - The deprecated `--project-ref` is an alias of `--remote=<ref>` and prints a deprecation warning on stderr that names the replacement; it cannot be combined with `--remote=<value>`. An empty `--project-ref` is rejected. - `--custom-hostname` is required for `create`. diff --git a/apps/cli/src/commands/encryption/SIDE_EFFECTS.md b/apps/cli/src/commands/encryption/SIDE_EFFECTS.md index 1f97921da8..bef0e1d8e1 100644 --- a/apps/cli/src/commands/encryption/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/encryption/SIDE_EFFECTS.md @@ -83,7 +83,7 @@ One `result` event carrying `{root_key}` (both subcommands). ## Notes -- Targets the linked project unless `--remote=<ref>` is given; a bare `--remote` also targets the linked project. `--remote=<value>` is read as a project ref (20 lowercase letters), a branch of the linked project (by name or UUID, resolved through `GET /v1/projects/{parent}/branches`, or `GET /v1/branches/{id}` for a UUID when no project is linked), or `true|yes|on|1` for the linked project. A database URL is rejected (these commands take a project, not a database); `false|no|off|0`, `--no-remote` and an empty value fail with `Leave out --remote to use the linked project.` +- Targets the linked project unless `--remote=<ref>` is given; a bare `--remote` also targets the linked project. `--remote=<value>` is read as a project ref (20 lowercase letters), a branch of the linked project (by name or UUID, resolved through `GET /v1/projects/{parent}/branches`, or `GET /v1/branches/{id}` for a UUID when no project is linked), or `true|yes|on|1` for the linked project. A database URL is rejected (these commands take a project, not a database); `false|no|off|0` and `--no-remote` fail with `--remote doesn't take false. Leave it out to use the linked project.`, and an empty value with `--remote= has an empty value. Pass --remote for the linked project or --remote=<ref|branch>; leave it out to use the linked project.` - A word after a bare `--remote` is read as its value, `--remote=<word>`, because the command takes no operands. - The deprecated `--project-ref` is an alias of `--remote=<ref>` and prints a deprecation warning on stderr that names the replacement; it cannot be combined with `--remote=<value>`. An empty `--project-ref` is rejected. - Requires `--remote=<ref>`, `SUPABASE_PROJECT_ID`, or a linked project. 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 dbfebab416..8227a856ff 100644 --- a/apps/cli/src/commands/experimental/compute/delete/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/experimental/compute/delete/SIDE_EFFECTS.md @@ -110,6 +110,6 @@ fall through to that same text branch. ## Notes -- Targets the linked project unless `--remote=<ref>` is given; a bare `--remote` also targets the linked project. `--remote=<value>` is read as a project ref (20 lowercase letters), a branch of the linked project (by name or UUID, resolved through `GET /v1/projects/{parent}/branches`, or `GET /v1/branches/{id}` for a UUID when no project is linked), or `true|yes|on|1` for the linked project. A database URL is rejected (`supabase experimental compute delete` takes a project, not a database); `false|no|off|0`, `--no-remote` and an empty value fail with `Leave out --remote to use the linked project.` +- Targets the linked project unless `--remote=<ref>` is given; a bare `--remote` also targets the linked project. `--remote=<value>` is read as a project ref (20 lowercase letters), a branch of the linked project (by name or UUID, resolved through `GET /v1/projects/{parent}/branches`, or `GET /v1/branches/{id}` for a UUID when no project is linked), or `true|yes|on|1` for the linked project. A database URL is rejected (`supabase experimental compute delete` takes a project, not a database); `false|no|off|0` and `--no-remote` fail with `--remote doesn't take false. Leave it out to use the linked project.`, and an empty value with `--remote= has an empty value. Pass --remote for the linked project or --remote=<ref|branch>; leave it out to use the linked project.` - A bare `--remote` followed by a word that exactly matches a branch of the linked project, is within a small edit distance of one, or can't be checked because the branch listing failed stops the run before any other work with an error that offers both readings: `--remote=<word>` for the branch, `<word> --remote` for the operand. Any other word runs as the operand. - The deprecated `--project-ref` is an alias of `--remote=<ref>` and prints a deprecation warning on stderr that names the replacement; it cannot be combined with `--remote=<value>`. An empty `--project-ref` is rejected. diff --git a/apps/cli/src/commands/experimental/compute/list/SIDE_EFFECTS.md b/apps/cli/src/commands/experimental/compute/list/SIDE_EFFECTS.md index 05b01b90e3..dfe6c842dd 100644 --- a/apps/cli/src/commands/experimental/compute/list/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/experimental/compute/list/SIDE_EFFECTS.md @@ -87,6 +87,6 @@ format still carries `url` per compute, and `compute status` renders it. ## Notes -- Targets the linked project unless `--remote=<ref>` is given; a bare `--remote` also targets the linked project. `--remote=<value>` is read as a project ref (20 lowercase letters), a branch of the linked project (by name or UUID, resolved through `GET /v1/projects/{parent}/branches`, or `GET /v1/branches/{id}` for a UUID when no project is linked), or `true|yes|on|1` for the linked project. A database URL is rejected (`supabase experimental compute list` takes a project, not a database); `false|no|off|0`, `--no-remote` and an empty value fail with `Leave out --remote to use the linked project.` +- Targets the linked project unless `--remote=<ref>` is given; a bare `--remote` also targets the linked project. `--remote=<value>` is read as a project ref (20 lowercase letters), a branch of the linked project (by name or UUID, resolved through `GET /v1/projects/{parent}/branches`, or `GET /v1/branches/{id}` for a UUID when no project is linked), or `true|yes|on|1` for the linked project. A database URL is rejected (`supabase experimental compute list` takes a project, not a database); `false|no|off|0` and `--no-remote` fail with `--remote doesn't take false. Leave it out to use the linked project.`, and an empty value with `--remote= has an empty value. Pass --remote for the linked project or --remote=<ref|branch>; leave it out to use the linked project.` - A word after a bare `--remote` is read as its value, `--remote=<word>`, because the command takes no operands. - The deprecated `--project-ref` is an alias of `--remote=<ref>` and prints a deprecation warning on stderr that names the replacement; it cannot be combined with `--remote=<value>`. An empty `--project-ref` is rejected. diff --git a/apps/cli/src/commands/experimental/compute/logs/SIDE_EFFECTS.md b/apps/cli/src/commands/experimental/compute/logs/SIDE_EFFECTS.md index a001cc798c..052c0bad68 100644 --- a/apps/cli/src/commands/experimental/compute/logs/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/experimental/compute/logs/SIDE_EFFECTS.md @@ -170,6 +170,6 @@ preserved so a stack trace survives intact. ## Notes -- Targets the linked project unless `--remote=<ref>` is given; a bare `--remote` also targets the linked project. `--remote=<value>` is read as a project ref (20 lowercase letters), a branch of the linked project (by name or UUID, resolved through `GET /v1/projects/{parent}/branches`, or `GET /v1/branches/{id}` for a UUID when no project is linked), or `true|yes|on|1` for the linked project. A database URL is rejected (`supabase experimental compute logs` takes a project, not a database); `false|no|off|0`, `--no-remote` and an empty value fail with `Leave out --remote to use the linked project.` +- Targets the linked project unless `--remote=<ref>` is given; a bare `--remote` also targets the linked project. `--remote=<value>` is read as a project ref (20 lowercase letters), a branch of the linked project (by name or UUID, resolved through `GET /v1/projects/{parent}/branches`, or `GET /v1/branches/{id}` for a UUID when no project is linked), or `true|yes|on|1` for the linked project. A database URL is rejected (`supabase experimental compute logs` takes a project, not a database); `false|no|off|0` and `--no-remote` fail with `--remote doesn't take false. Leave it out to use the linked project.`, and an empty value with `--remote= has an empty value. Pass --remote for the linked project or --remote=<ref|branch>; leave it out to use the linked project.` - A bare `--remote` followed by a word that exactly matches a branch of the linked project, is within a small edit distance of one, or can't be checked because the branch listing failed stops the run before any other work with an error that offers both readings: `--remote=<word>` for the branch, `<word> --remote` for the operand. Any other word runs as the operand. - The deprecated `--project-ref` is an alias of `--remote=<ref>` and prints a deprecation warning on stderr that names the replacement; it cannot be combined with `--remote=<value>`. An empty `--project-ref` is rejected. 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 83738c74b0..238237fe1a 100644 --- a/apps/cli/src/commands/experimental/compute/push/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/experimental/compute/push/SIDE_EFFECTS.md @@ -194,6 +194,6 @@ strings that carry a signature. ## Notes -- Targets the linked project unless `--remote=<ref>` is given; a bare `--remote` also targets the linked project. `--remote=<value>` is read as a project ref (20 lowercase letters), a branch of the linked project (by name or UUID, resolved through `GET /v1/projects/{parent}/branches`, or `GET /v1/branches/{id}` for a UUID when no project is linked), or `true|yes|on|1` for the linked project. A database URL is rejected (`supabase experimental compute push` takes a project, not a database); `false|no|off|0`, `--no-remote` and an empty value fail with `Leave out --remote to use the linked project.` +- Targets the linked project unless `--remote=<ref>` is given; a bare `--remote` also targets the linked project. `--remote=<value>` is read as a project ref (20 lowercase letters), a branch of the linked project (by name or UUID, resolved through `GET /v1/projects/{parent}/branches`, or `GET /v1/branches/{id}` for a UUID when no project is linked), or `true|yes|on|1` for the linked project. A database URL is rejected (`supabase experimental compute push` takes a project, not a database); `false|no|off|0` and `--no-remote` fail with `--remote doesn't take false. Leave it out to use the linked project.`, and an empty value with `--remote= has an empty value. Pass --remote for the linked project or --remote=<ref|branch>; leave it out to use the linked project.` - A bare `--remote` followed by a word that exactly matches a branch of the linked project, is within a small edit distance of one, or can't be checked because the branch listing failed stops the run before any other work with an error that offers both readings: `--remote=<word>` for the branch, `<word> --remote` for the operand. Any other word runs as the operand. - The deprecated `--project-ref` is an alias of `--remote=<ref>` and prints a deprecation warning on stderr that names the replacement; it cannot be combined with `--remote=<value>`. An empty `--project-ref` is rejected. diff --git a/apps/cli/src/commands/experimental/compute/status/SIDE_EFFECTS.md b/apps/cli/src/commands/experimental/compute/status/SIDE_EFFECTS.md index a19b05fefe..d1cb573c4c 100644 --- a/apps/cli/src/commands/experimental/compute/status/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/experimental/compute/status/SIDE_EFFECTS.md @@ -91,6 +91,6 @@ fall through to that same text branch. ## Notes -- Targets the linked project unless `--remote=<ref>` is given; a bare `--remote` also targets the linked project. `--remote=<value>` is read as a project ref (20 lowercase letters), a branch of the linked project (by name or UUID, resolved through `GET /v1/projects/{parent}/branches`, or `GET /v1/branches/{id}` for a UUID when no project is linked), or `true|yes|on|1` for the linked project. A database URL is rejected (`supabase experimental compute status` takes a project, not a database); `false|no|off|0`, `--no-remote` and an empty value fail with `Leave out --remote to use the linked project.` +- Targets the linked project unless `--remote=<ref>` is given; a bare `--remote` also targets the linked project. `--remote=<value>` is read as a project ref (20 lowercase letters), a branch of the linked project (by name or UUID, resolved through `GET /v1/projects/{parent}/branches`, or `GET /v1/branches/{id}` for a UUID when no project is linked), or `true|yes|on|1` for the linked project. A database URL is rejected (`supabase experimental compute status` takes a project, not a database); `false|no|off|0` and `--no-remote` fail with `--remote doesn't take false. Leave it out to use the linked project.`, and an empty value with `--remote= has an empty value. Pass --remote for the linked project or --remote=<ref|branch>; leave it out to use the linked project.` - A bare `--remote` followed by a word that exactly matches a branch of the linked project, is within a small edit distance of one, or can't be checked because the branch listing failed stops the run before any other work with an error that offers both readings: `--remote=<word>` for the branch, `<word> --remote` for the operand. Any other word runs as the operand. - The deprecated `--project-ref` is an alias of `--remote=<ref>` and prints a deprecation warning on stderr that names the replacement; it cannot be combined with `--remote=<value>`. An empty `--project-ref` is rejected. diff --git a/apps/cli/src/commands/functions/delete/SIDE_EFFECTS.md b/apps/cli/src/commands/functions/delete/SIDE_EFFECTS.md index 573c22c02c..b2e3fa7e96 100644 --- a/apps/cli/src/commands/functions/delete/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/functions/delete/SIDE_EFFECTS.md @@ -72,7 +72,7 @@ Prints the same structured success result, including `target`, and needs `--yes` ## Notes -- Targets the linked project unless `--remote=<ref>` is given; a bare `--remote` also targets the linked project. `--remote=<value>` is read as a project ref (20 lowercase letters), a branch of the linked project (by name or UUID, resolved through `GET /v1/projects/{parent}/branches`, or `GET /v1/branches/{id}` for a UUID when no project is linked), or `true|yes|on|1` for the linked project. A database URL is rejected (`supabase functions delete` takes a project, not a database); `false|no|off|0`, `--no-remote` and an empty value fail with `Leave out --remote to use the linked project.` +- Targets the linked project unless `--remote=<ref>` is given; a bare `--remote` also targets the linked project. `--remote=<value>` is read as a project ref (20 lowercase letters), a branch of the linked project (by name or UUID, resolved through `GET /v1/projects/{parent}/branches`, or `GET /v1/branches/{id}` for a UUID when no project is linked), or `true|yes|on|1` for the linked project. A database URL is rejected (`supabase functions delete` takes a project, not a database); `false|no|off|0` and `--no-remote` fail with `--remote doesn't take false. Leave it out to use the linked project.`, and an empty value with `--remote= has an empty value. Pass --remote for the linked project or --remote=<ref|branch>; leave it out to use the linked project.` - A bare `--remote` followed by a word that exactly matches a branch of the linked project, is within a small edit distance of one, or can't be checked because the branch listing failed stops the run before any other work with an error that offers both readings: `--remote=<word>` for the branch, `<word> --remote` for the operand. Any other word runs as the operand. - The deprecated `--project-ref` is an alias of `--remote=<ref>` and prints a deprecation warning on stderr that names the replacement; it cannot be combined with `--remote=<value>`. An empty `--project-ref` is rejected. - Requires exactly one argument: the function slug/name. diff --git a/apps/cli/src/commands/functions/deploy/SIDE_EFFECTS.md b/apps/cli/src/commands/functions/deploy/SIDE_EFFECTS.md index 2bccfd3d47..3f29d7cc81 100644 --- a/apps/cli/src/commands/functions/deploy/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/functions/deploy/SIDE_EFFECTS.md @@ -117,8 +117,7 @@ The `--output`/`-o` flag does not change deploy output. linked project. - `--remote=<value>` is read as a project ref (20 lowercase letters), a branch of the linked project (by name or UUID), or `true|yes|on|1` for the linked project. A database URL is rejected - (`functions deploy` takes a project, not a database); `false|no|off|0`, `--no-remote` and an - empty value fail with `Leave out --remote to use the linked project.` + (`functions deploy` takes a project, not a database); `false|no|off|0` and `--no-remote` fail with `--remote doesn't take false. Leave it out to use the linked project.`, and an empty value with `--remote= has an empty value. Pass --remote for the linked project or --remote=<ref|branch>; leave it out to use the linked project.` - A bare `--remote` followed by a word that exactly matches a branch of the linked project, is within a small edit distance of one, or can't be checked because the branch listing failed stops the run before any other work with an error that offers both readings: `--remote=<word>` for the diff --git a/apps/cli/src/commands/functions/download/SIDE_EFFECTS.md b/apps/cli/src/commands/functions/download/SIDE_EFFECTS.md index d981418508..3f72e9d0c4 100644 --- a/apps/cli/src/commands/functions/download/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/functions/download/SIDE_EFFECTS.md @@ -104,7 +104,7 @@ Same envelope as `json` above (including on the Docker-unbundle path). ## Notes -- Targets the linked project unless `--remote=<ref>` is given; a bare `--remote` also targets the linked project. `--remote=<value>` is read as a project ref (20 lowercase letters), a branch of the linked project (by name or UUID, resolved through `GET /v1/projects/{parent}/branches`, or `GET /v1/branches/{id}` for a UUID when no project is linked), or `true|yes|on|1` for the linked project. A database URL is rejected (`supabase functions download` takes a project, not a database); `false|no|off|0`, `--no-remote` and an empty value fail with `Leave out --remote to use the linked project.` +- Targets the linked project unless `--remote=<ref>` is given; a bare `--remote` also targets the linked project. `--remote=<value>` is read as a project ref (20 lowercase letters), a branch of the linked project (by name or UUID, resolved through `GET /v1/projects/{parent}/branches`, or `GET /v1/branches/{id}` for a UUID when no project is linked), or `true|yes|on|1` for the linked project. A database URL is rejected (`supabase functions download` takes a project, not a database); `false|no|off|0` and `--no-remote` fail with `--remote doesn't take false. Leave it out to use the linked project.`, and an empty value with `--remote= has an empty value. Pass --remote for the linked project or --remote=<ref|branch>; leave it out to use the linked project.` - A bare `--remote` followed by a word that exactly matches a branch of the linked project, is within a small edit distance of one, or can't be checked because the branch listing failed stops the run before any other work with an error that offers both readings: `--remote=<word>` for the branch, `<word> --remote` for the operand. Any other word runs as the operand. - The deprecated `--project-ref` is an alias of `--remote=<ref>` and prints a deprecation warning on stderr that names the replacement; it cannot be combined with `--remote=<value>`. An empty `--project-ref` is rejected. - **Config value precedence** (ADR 0032): explicit flag > shell env > project `.env*` > config diff --git a/apps/cli/src/commands/functions/list/SIDE_EFFECTS.md b/apps/cli/src/commands/functions/list/SIDE_EFFECTS.md index 3aef010f6b..d7b2a06648 100644 --- a/apps/cli/src/commands/functions/list/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/functions/list/SIDE_EFFECTS.md @@ -69,7 +69,7 @@ Prints the same structured success result, including `target`. ## Notes -- Targets the linked project unless `--remote=<ref>` is given; a bare `--remote` also targets the linked project. `--remote=<value>` is read as a project ref (20 lowercase letters), a branch of the linked project (by name or UUID, resolved through `GET /v1/projects/{parent}/branches`, or `GET /v1/branches/{id}` for a UUID when no project is linked), or `true|yes|on|1` for the linked project. A database URL is rejected (`supabase functions list` takes a project, not a database); `false|no|off|0`, `--no-remote` and an empty value fail with `Leave out --remote to use the linked project.` +- Targets the linked project unless `--remote=<ref>` is given; a bare `--remote` also targets the linked project. `--remote=<value>` is read as a project ref (20 lowercase letters), a branch of the linked project (by name or UUID, resolved through `GET /v1/projects/{parent}/branches`, or `GET /v1/branches/{id}` for a UUID when no project is linked), or `true|yes|on|1` for the linked project. A database URL is rejected (`supabase functions list` takes a project, not a database); `false|no|off|0` and `--no-remote` fail with `--remote doesn't take false. Leave it out to use the linked project.`, and an empty value with `--remote= has an empty value. Pass --remote for the linked project or --remote=<ref|branch>; leave it out to use the linked project.` - A word after a bare `--remote` is read as its value, `--remote=<word>`, because the command takes no operands. - The deprecated `--project-ref` is an alias of `--remote=<ref>` and prints a deprecation warning on stderr that names the replacement; it cannot be combined with `--remote=<value>`. An empty `--project-ref` is rejected. - Requires a linked project (`--remote=<ref>`, `SUPABASE_PROJECT_ID`, or `<workdir>/supabase/.temp/project-ref`). diff --git a/apps/cli/src/commands/gen/types/SIDE_EFFECTS.md b/apps/cli/src/commands/gen/types/SIDE_EFFECTS.md index 6d068c1b8c..ea80e33c3e 100644 --- a/apps/cli/src/commands/gen/types/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/gen/types/SIDE_EFFECTS.md @@ -135,7 +135,7 @@ linked directory the banner ends with `· default; pass --remote for <project>`. 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. Each deprecated selector additionally prints its warning on stderr, for example -`--project-id is deprecated and will be removed in v4. Use --remote instead: supabase gen types --remote=<ref>`. +`--project-id is deprecated and will be removed in v4. Use --remote=<ref> instead: supabase gen types <language> --remote=<ref>`. ### `--output-format json` @@ -154,8 +154,7 @@ Not applicable. targets the linked project (`SUPABASE_PROJECT_ID`, else `.temp/project-ref`). `--remote=<value>` is read as a project ref (20 lowercase letters), a database URL (`postgres://` or `postgresql://`), `true|yes|on|1` (the linked project), or otherwise the name of a branch of the - linked project, which resolves to that branch's own project. `false|no|off|0`, `--no-remote` and - an empty value fail with `Leave out --remote to target the local database.` A bare `--remote` + linked project, which resolves to that branch's own project. `false|no|off|0` and `--no-remote` fail with `--remote doesn't take false. Leave it out to target the local database.`, and an empty value with `--remote= has an empty value. Pass --remote for the linked project or --remote=<ref|branch|url>; leave it out to target the local database.` A bare `--remote` followed by a word that exactly matches a branch of the linked project, is within a small edit distance of one, or can't be checked because the branch listing failed stops the run with an error that offers both readings: `--remote=<word>` for the branch, `<word> --remote` for the operand. @@ -164,9 +163,9 @@ Not applicable. - **Deprecated selectors.** `--linked`, `--project-ref`, `--project-id`, `--db-url` and `--local` are hidden aliases that still select their target and print a warning on stderr naming the replacement, for example - `--linked is deprecated and will be removed in v4. Use --remote instead: supabase gen types --remote`, - `--project-id is deprecated and will be removed in v4. Use --remote instead: supabase gen types --remote=<ref>` - and `--local isn't needed (local is the default) and will be removed in v4: supabase gen types`; a + `--linked is deprecated and will be removed in v4. Use --remote instead: supabase gen types <language> --remote`, + `--project-id is deprecated and will be removed in v4. Use --remote=<ref> instead: supabase gen types <language> --remote=<ref>` + and `--local isn't needed (local is the default) and will be removed in v4: supabase gen types <language>`; a database URL is never echoed. Two selectors together fail (`Only one of --remote, --linked, --db-url and --local can be set; got …`), `--project-ref` and `--project-id` cannot be combined with `--local`, `--db-url` or `--remote=<value>`, an empty diff --git a/apps/cli/src/commands/inspect/db/SIDE_EFFECTS.md b/apps/cli/src/commands/inspect/db/SIDE_EFFECTS.md index 20652a6482..ab9e333128 100644 --- a/apps/cli/src/commands/inspect/db/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/inspect/db/SIDE_EFFECTS.md @@ -135,8 +135,7 @@ Emit one extra stderr line before the table: targets the linked project (`SUPABASE_PROJECT_ID`, else `.temp/project-ref`). `--remote=<value>` is read as a project ref (20 lowercase letters), a database URL (`postgres://` or `postgresql://`), `true|yes|on|1` (the linked project), or otherwise the name of a branch of the - linked project, which resolves to that branch's own project. `false|no|off|0`, `--no-remote` and - an empty value fail with `Leave out --remote to target the local database.` A word after a bare + linked project, which resolves to that branch's own project. `false|no|off|0` and `--no-remote` fail with `--remote doesn't take false. Leave it out to target the local database.`, and an empty value with `--remote= has an empty value. Pass --remote for the linked project or --remote=<ref|branch|url>; leave it out to target the local database.` A word after a bare `--remote` is read as its value, `--remote=<word>`, because the command takes no operands. The resolved target is announced on stderr (`Target: …`) in every output format and, in JSON results, as a `target` object. diff --git a/apps/cli/src/commands/inspect/report/SIDE_EFFECTS.md b/apps/cli/src/commands/inspect/report/SIDE_EFFECTS.md index a651716343..08a2aad0bc 100644 --- a/apps/cli/src/commands/inspect/report/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/inspect/report/SIDE_EFFECTS.md @@ -138,8 +138,7 @@ instead a structured result is emitted. The stderr `Target:` banner is still pri targets the linked project (`SUPABASE_PROJECT_ID`, else `.temp/project-ref`). `--remote=<value>` is read as a project ref (20 lowercase letters), a database URL (`postgres://` or `postgresql://`), `true|yes|on|1` (the linked project), or otherwise the name of a branch of the - linked project, which resolves to that branch's own project. `false|no|off|0`, `--no-remote` and - an empty value fail with `Leave out --remote to target the local database.` A word after a bare + linked project, which resolves to that branch's own project. `false|no|off|0` and `--no-remote` fail with `--remote doesn't take false. Leave it out to target the local database.`, and an empty value with `--remote= has an empty value. Pass --remote for the linked project or --remote=<ref|branch|url>; leave it out to target the local database.` A word after a bare `--remote` is read as its value, `--remote=<word>`, because the command takes no operands. The resolved target is announced on stderr (`Target: …`) in every output format and, in JSON results, as a `target` object. diff --git a/apps/cli/src/commands/migration/down/SIDE_EFFECTS.md b/apps/cli/src/commands/migration/down/SIDE_EFFECTS.md index 74d9f6e76c..60e1f1db1b 100644 --- a/apps/cli/src/commands/migration/down/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/migration/down/SIDE_EFFECTS.md @@ -86,8 +86,7 @@ Same structured result delivered as an NDJSON `result` event. targets the linked project (`SUPABASE_PROJECT_ID`, else `.temp/project-ref`). `--remote=<value>` is read as a project ref (20 lowercase letters), a database URL (`postgres://` or `postgresql://`), `true|yes|on|1` (the linked project), or otherwise the name of a branch of the - linked project, which resolves to that branch's own project. `false|no|off|0`, `--no-remote` and - an empty value fail with `Leave out --remote to target the local database.` A word after a bare + linked project, which resolves to that branch's own project. `false|no|off|0` and `--no-remote` fail with `--remote doesn't take false. Leave it out to target the local database.`, and an empty value with `--remote= has an empty value. Pass --remote for the linked project or --remote=<ref|branch|url>; leave it out to target the local database.` A word after a bare `--remote` is read as its value, `--remote=<word>`, because the command takes no operands. The resolved target is announced on stderr (`Target: …`) in every output format and, in JSON results, as a `target` object. diff --git a/apps/cli/src/commands/migration/fetch/SIDE_EFFECTS.md b/apps/cli/src/commands/migration/fetch/SIDE_EFFECTS.md index ba5f18eca9..0521efbd14 100644 --- a/apps/cli/src/commands/migration/fetch/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/migration/fetch/SIDE_EFFECTS.md @@ -87,8 +87,7 @@ Same structured `files` result delivered as an NDJSON `result` event. targets the linked project (`SUPABASE_PROJECT_ID`, else `.temp/project-ref`). `--remote=<value>` is read as a project ref (20 lowercase letters), a database URL (`postgres://` or `postgresql://`), `true|yes|on|1` (the linked project), or otherwise the name of a branch of the - linked project, which resolves to that branch's own project. `false|no|off|0`, `--no-remote` and - an empty value fail with `Leave out --remote to target the local database.` A word after a bare + linked project, which resolves to that branch's own project. `false|no|off|0` and `--no-remote` fail with `--remote doesn't take false. Leave it out to target the local database.`, and an empty value with `--remote= has an empty value. Pass --remote for the linked project or --remote=<ref|branch|url>; leave it out to target the local database.` A word after a bare `--remote` is read as its value, `--remote=<word>`, because the command takes no operands. The resolved target is announced on stderr (`Target: …`) in every output format and, in JSON results, as a `target` object. diff --git a/apps/cli/src/commands/migration/list/SIDE_EFFECTS.md b/apps/cli/src/commands/migration/list/SIDE_EFFECTS.md index 14922cb9f9..252b3d3296 100644 --- a/apps/cli/src/commands/migration/list/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/migration/list/SIDE_EFFECTS.md @@ -74,8 +74,7 @@ Same structured `migrations` result delivered as an NDJSON `result` event. targets the linked project (`SUPABASE_PROJECT_ID`, else `.temp/project-ref`). `--remote=<value>` is read as a project ref (20 lowercase letters), a database URL (`postgres://` or `postgresql://`), `true|yes|on|1` (the linked project), or otherwise the name of a branch of the - linked project, which resolves to that branch's own project. `false|no|off|0`, `--no-remote` and - an empty value fail with `Leave out --remote to target the local database.` A word after a bare + linked project, which resolves to that branch's own project. `false|no|off|0` and `--no-remote` fail with `--remote doesn't take false. Leave it out to target the local database.`, and an empty value with `--remote= has an empty value. Pass --remote for the linked project or --remote=<ref|branch|url>; leave it out to target the local database.` A word after a bare `--remote` is read as its value, `--remote=<word>`, because the command takes no operands. The resolved target is announced on stderr (`Target: …`) in every output format and, in JSON results, as a `target` object. diff --git a/apps/cli/src/commands/migration/repair/SIDE_EFFECTS.md b/apps/cli/src/commands/migration/repair/SIDE_EFFECTS.md index 9f07bb5280..6912c8d13d 100644 --- a/apps/cli/src/commands/migration/repair/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/migration/repair/SIDE_EFFECTS.md @@ -97,8 +97,7 @@ Same structured result delivered as an NDJSON `result` event. targets the linked project (`SUPABASE_PROJECT_ID`, else `.temp/project-ref`). `--remote=<value>` is read as a project ref (20 lowercase letters), a database URL (`postgres://` or `postgresql://`), `true|yes|on|1` (the linked project), or otherwise the name of a branch of the - linked project, which resolves to that branch's own project. `false|no|off|0`, `--no-remote` and - an empty value fail with `Leave out --remote to target the local database.` A bare `--remote` + linked project, which resolves to that branch's own project. `false|no|off|0` and `--no-remote` fail with `--remote doesn't take false. Leave it out to target the local database.`, and an empty value with `--remote= has an empty value. Pass --remote for the linked project or --remote=<ref|branch|url>; leave it out to target the local database.` A bare `--remote` followed by a word that exactly matches a branch of the linked project, is within a small edit distance of one, or can't be checked because the branch listing failed stops the run with an error that offers both readings: `--remote=<word>` for the branch, `<word> --remote` for the operand. @@ -107,9 +106,9 @@ Same structured result delivered as an NDJSON `result` event. as a `target` object. - **Deprecated selectors.** `--linked`, `--project-ref`, `--db-url` and `--local` are hidden aliases that still select their target and print a warning on stderr naming the replacement, for example - `--linked is deprecated and will be removed in v4. Use --remote instead: supabase migration repair --remote` + `--linked is deprecated and will be removed in v4. Use --remote instead: supabase migration repair <version>... --remote` and - `--local isn't needed (local is the default) and will be removed in v4: supabase migration repair`; + `--local isn't needed (local is the default) and will be removed in v4: supabase migration repair <version>...`; a database URL is never echoed. Two selectors together fail (`Only one of --remote, --linked, --db-url and --local can be set; got …`), `--project-ref` cannot be combined with `--local`, `--db-url` or `--remote=<value>`, an empty `--db-url` or diff --git a/apps/cli/src/commands/migration/squash/SIDE_EFFECTS.md b/apps/cli/src/commands/migration/squash/SIDE_EFFECTS.md index 02572b3eea..c245904ca3 100644 --- a/apps/cli/src/commands/migration/squash/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/migration/squash/SIDE_EFFECTS.md @@ -156,8 +156,7 @@ code or the rest of the payload. targets the linked project (`SUPABASE_PROJECT_ID`, else `.temp/project-ref`). `--remote=<value>` is read as a project ref (20 lowercase letters), a database URL (`postgres://` or `postgresql://`), `true|yes|on|1` (the linked project), or otherwise the name of a branch of the - linked project, which resolves to that branch's own project. `false|no|off|0`, `--no-remote` and - an empty value fail with `Leave out --remote to target the local database.` A word after a bare + linked project, which resolves to that branch's own project. `false|no|off|0` and `--no-remote` fail with `--remote doesn't take false. Leave it out to target the local database.`, and an empty value with `--remote= has an empty value. Pass --remote for the linked project or --remote=<ref|branch|url>; leave it out to target the local database.` A word after a bare `--remote` is read as its value, `--remote=<word>`, because the command takes no operands. 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 diff --git a/apps/cli/src/commands/migration/up/SIDE_EFFECTS.md b/apps/cli/src/commands/migration/up/SIDE_EFFECTS.md index 359170d4af..88593d213a 100644 --- a/apps/cli/src/commands/migration/up/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/migration/up/SIDE_EFFECTS.md @@ -86,8 +86,7 @@ Same structured `applied` result delivered as an NDJSON `result` event. targets the linked project (`SUPABASE_PROJECT_ID`, else `.temp/project-ref`). `--remote=<value>` is read as a project ref (20 lowercase letters), a database URL (`postgres://` or `postgresql://`), `true|yes|on|1` (the linked project), or otherwise the name of a branch of the - linked project, which resolves to that branch's own project. `false|no|off|0`, `--no-remote` and - an empty value fail with `Leave out --remote to target the local database.` A word after a bare + linked project, which resolves to that branch's own project. `false|no|off|0` and `--no-remote` fail with `--remote doesn't take false. Leave it out to target the local database.`, and an empty value with `--remote= has an empty value. Pass --remote for the linked project or --remote=<ref|branch|url>; leave it out to target the local database.` A word after a bare `--remote` is read as its value, `--remote=<word>`, because the command takes no operands. The resolved target is announced on stderr (`Target: …`) in every output format and, in JSON results, as a `target` object. 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 c3e6a95ea9..115ccb2ec0 100644 --- a/apps/cli/src/commands/network-bans/get/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/network-bans/get/SIDE_EFFECTS.md @@ -95,8 +95,7 @@ One `result` event whose `data` is the full response object. or, on an interactive terminal, the project picker. A bare `--remote` targets the linked project. - `--remote=<value>` is read as a project ref (20 lowercase letters), a branch of the linked project (by name or UUID), or `true|yes|on|1` for the linked project. A database URL is rejected - (`supabase network-bans get` takes a project, not a database); `false|no|off|0`, `--no-remote` and an - empty value fail with `Leave out --remote to use the linked project.` + (`supabase network-bans get` takes a project, not a database); `false|no|off|0` and `--no-remote` fail with `--remote doesn't take false. Leave it out to use the linked project.`, and an empty value with `--remote= has an empty value. Pass --remote for the linked project or --remote=<ref|branch>; leave it out to use the linked project.` - A word after a bare `--remote` is read as its value, `--remote=<word>`, because the command takes no operands. - The deprecated `--project-ref` is an alias of `--remote=<ref>` (a non-ref value is read as a 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 740cea464f..22407dff5d 100644 --- a/apps/cli/src/commands/network-bans/remove/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/network-bans/remove/SIDE_EFFECTS.md @@ -99,8 +99,7 @@ One `result` event on success when the `--output` flag is unset. - 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. - `--remote=<value>` is read as a project ref (20 lowercase letters), a branch of the linked project (by name or UUID), or `true|yes|on|1` for the linked project. A database URL is rejected - (`supabase network-bans remove` takes a project, not a database); `false|no|off|0`, `--no-remote` and an - empty value fail with `Leave out --remote to use the linked project.` + (`supabase network-bans remove` takes a project, not a database); `false|no|off|0` and `--no-remote` fail with `--remote doesn't take false. Leave it out to use the linked project.`, and an empty value with `--remote= has an empty value. Pass --remote for the linked project or --remote=<ref|branch>; leave it out to use the linked project.` - A word after a bare `--remote` is read as its value, `--remote=<word>`, because the command takes no operands. - The deprecated `--project-ref` is an alias of `--remote=<ref>` (a non-ref value is read as a 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 3970e97ced..22599c9ecd 100644 --- a/apps/cli/src/commands/network-restrictions/get/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/network-restrictions/get/SIDE_EFFECTS.md @@ -106,8 +106,7 @@ One `result` event whose `data` is the full response object. or, on an interactive terminal, the project picker. A bare `--remote` targets the linked project. - `--remote=<value>` is read as a project ref (20 lowercase letters), a branch of the linked project (by name or UUID), or `true|yes|on|1` for the linked project. A database URL is rejected - (`supabase network-restrictions get` takes a project, not a database); `false|no|off|0`, `--no-remote` and an - empty value fail with `Leave out --remote to use the linked project.` + (`supabase network-restrictions get` takes a project, not a database); `false|no|off|0` and `--no-remote` fail with `--remote doesn't take false. Leave it out to use the linked project.`, and an empty value with `--remote= has an empty value. Pass --remote for the linked project or --remote=<ref|branch>; leave it out to use the linked project.` - A word after a bare `--remote` is read as its value, `--remote=<word>`, because the command takes no operands. - The deprecated `--project-ref` is an alias of `--remote=<ref>` (a non-ref value is read as a 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 89620d3c81..4e28f9342e 100644 --- a/apps/cli/src/commands/network-restrictions/update/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/network-restrictions/update/SIDE_EFFECTS.md @@ -133,8 +133,7 @@ One `result` event whose `data` is the full response object. or, on an interactive terminal, the project picker. A bare `--remote` targets the linked project. - `--remote=<value>` is read as a project ref (20 lowercase letters), a branch of the linked project (by name or UUID), or `true|yes|on|1` for the linked project. A database URL is rejected - (`supabase network-restrictions update` takes a project, not a database); `false|no|off|0`, `--no-remote` and an - empty value fail with `Leave out --remote to use the linked project.` + (`supabase network-restrictions update` takes a project, not a database); `false|no|off|0` and `--no-remote` fail with `--remote doesn't take false. Leave it out to use the linked project.`, and an empty value with `--remote= has an empty value. Pass --remote for the linked project or --remote=<ref|branch>; leave it out to use the linked project.` - A word after a bare `--remote` is read as its value, `--remote=<word>`, because the command takes no operands. - The deprecated `--project-ref` is an alias of `--remote=<ref>` (a non-ref value is read as a diff --git a/apps/cli/src/commands/notebooks/pull/SIDE_EFFECTS.md b/apps/cli/src/commands/notebooks/pull/SIDE_EFFECTS.md index fe36cc98e3..cebd8c28f8 100644 --- a/apps/cli/src/commands/notebooks/pull/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/notebooks/pull/SIDE_EFFECTS.md @@ -137,8 +137,7 @@ After the project ref resolves, one `Target: …` banner naming the resolved pro or, on an interactive terminal, the project picker. A bare `--remote` targets the linked project. - `--remote=<value>` is read as a project ref (20 lowercase letters), a branch of the linked project (by name or UUID), or `true|yes|on|1` for the linked project. A database URL is rejected - (`supabase notebooks pull` takes a project, not a database); `false|no|off|0`, `--no-remote` and an - empty value fail with `Leave out --remote to use the linked project.` + (`supabase notebooks pull` takes a project, not a database); `false|no|off|0` and `--no-remote` fail with `--remote doesn't take false. Leave it out to use the linked project.`, and an empty value with `--remote= has an empty value. Pass --remote for the linked project or --remote=<ref|branch>; leave it out to use the linked project.` - A bare `--remote` followed by a word that exactly matches a branch of the linked project, is within a small edit distance of one, or can't be checked because the branch listing failed stops the run before any other work with an error that offers both readings: `--remote=<word>` for the diff --git a/apps/cli/src/commands/notebooks/push/SIDE_EFFECTS.md b/apps/cli/src/commands/notebooks/push/SIDE_EFFECTS.md index b8494a7c36..d7bff31f81 100644 --- a/apps/cli/src/commands/notebooks/push/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/notebooks/push/SIDE_EFFECTS.md @@ -148,8 +148,7 @@ runs. `-o env` is rejected by the handler before resolving the project. or, on an interactive terminal, the project picker. A bare `--remote` targets the linked project. - `--remote=<value>` is read as a project ref (20 lowercase letters), a branch of the linked project (by name or UUID), or `true|yes|on|1` for the linked project. A database URL is rejected - (`supabase notebooks push` takes a project, not a database); `false|no|off|0`, `--no-remote` and an - empty value fail with `Leave out --remote to use the linked project.` + (`supabase notebooks push` takes a project, not a database); `false|no|off|0` and `--no-remote` fail with `--remote doesn't take false. Leave it out to use the linked project.`, and an empty value with `--remote= has an empty value. Pass --remote for the linked project or --remote=<ref|branch>; leave it out to use the linked project.` - A bare `--remote` followed by a word that exactly matches a branch of the linked project, is within a small edit distance of one, or can't be checked because the branch listing failed stops the run before any other work with an error that offers both readings: `--remote=<word>` for the 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 c968828fb4..34bee53947 100644 --- a/apps/cli/src/commands/postgres-config/delete/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/postgres-config/delete/SIDE_EFFECTS.md @@ -122,8 +122,7 @@ One `result` event on success. - The current overrides are fetched (a read) before the confirmation so the plan is known; the `PUT` happens only after it. - `--remote=<value>` is read as a project ref (20 lowercase letters), a branch of the linked project (by name or UUID), or `true|yes|on|1` for the linked project. A database URL is rejected - (`supabase postgres-config delete` takes a project, not a database); `false|no|off|0`, `--no-remote` and an - empty value fail with `Leave out --remote to use the linked project.` + (`supabase postgres-config delete` takes a project, not a database); `false|no|off|0` and `--no-remote` fail with `--remote doesn't take false. Leave it out to use the linked project.`, and an empty value with `--remote= has an empty value. Pass --remote for the linked project or --remote=<ref|branch>; leave it out to use the linked project.` - A word after a bare `--remote` is read as its value, `--remote=<word>`, because the command takes no operands. - The deprecated `--project-ref` is an alias of `--remote=<ref>` (a non-ref value is read as a 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 0b83d7f406..0d9b26d3be 100644 --- a/apps/cli/src/commands/postgres-config/get/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/postgres-config/get/SIDE_EFFECTS.md @@ -106,8 +106,7 @@ One `result` event on success. - 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). - `--remote=<value>` is read as a project ref (20 lowercase letters), a branch of the linked project (by name or UUID), or `true|yes|on|1` for the linked project. A database URL is rejected - (`supabase postgres-config get` takes a project, not a database); `false|no|off|0`, `--no-remote` and an - empty value fail with `Leave out --remote to use the linked project.` + (`supabase postgres-config get` takes a project, not a database); `false|no|off|0` and `--no-remote` fail with `--remote doesn't take false. Leave it out to use the linked project.`, and an empty value with `--remote= has an empty value. Pass --remote for the linked project or --remote=<ref|branch>; leave it out to use the linked project.` - A word after a bare `--remote` is read as its value, `--remote=<word>`, because the command takes no operands. - The deprecated `--project-ref` is an alias of `--remote=<ref>` (a non-ref value is read as a 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 a98fcf31d6..1e88eb38a8 100644 --- a/apps/cli/src/commands/postgres-config/update/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/postgres-config/update/SIDE_EFFECTS.md @@ -125,8 +125,7 @@ One `result` event on success. - The current overrides are fetched (a read) before the confirmation so the plan is known; the `PUT` happens only after it. - `--remote=<value>` is read as a project ref (20 lowercase letters), a branch of the linked project (by name or UUID), or `true|yes|on|1` for the linked project. A database URL is rejected - (`supabase postgres-config update` takes a project, not a database); `false|no|off|0`, `--no-remote` and an - empty value fail with `Leave out --remote to use the linked project.` + (`supabase postgres-config update` takes a project, not a database); `false|no|off|0` and `--no-remote` fail with `--remote doesn't take false. Leave it out to use the linked project.`, and an empty value with `--remote= has an empty value. Pass --remote for the linked project or --remote=<ref|branch>; leave it out to use the linked project.` - A word after a bare `--remote` is read as its value, `--remote=<word>`, because the command takes no operands. - The deprecated `--project-ref` is an alias of `--remote=<ref>` (a non-ref value is read as a diff --git a/apps/cli/src/commands/projects/api-keys/SIDE_EFFECTS.md b/apps/cli/src/commands/projects/api-keys/SIDE_EFFECTS.md index cde830c8fb..6534d16ff1 100644 --- a/apps/cli/src/commands/projects/api-keys/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/projects/api-keys/SIDE_EFFECTS.md @@ -112,8 +112,7 @@ On failure, an `error` event is emitted instead: bare `--remote` targets the linked project. - `--remote=<value>` is read as a project ref (20 lowercase letters), a branch of the linked project (by name or UUID), or `true|yes|on|1` for the linked project. A database URL is rejected - (`supabase projects api-keys` takes a project, not a database); `false|no|off|0`, `--no-remote` and an - empty value fail with `Leave out --remote to use the linked project.` + (`supabase projects api-keys` takes a project, not a database); `false|no|off|0` and `--no-remote` fail with `--remote doesn't take false. Leave it out to use the linked project.`, and an empty value with `--remote= has an empty value. Pass --remote for the linked project or --remote=<ref|branch>; leave it out to use the linked project.` - A word after a bare `--remote` is read as its value, `--remote=<word>`, because the command takes no operands. - The deprecated `--project-ref` is an alias of `--remote=<ref>` (a non-ref value is read as a diff --git a/apps/cli/src/commands/pull/SIDE_EFFECTS.md b/apps/cli/src/commands/pull/SIDE_EFFECTS.md index 32d6ae489c..ac030a9ed7 100644 --- a/apps/cli/src/commands/pull/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/pull/SIDE_EFFECTS.md @@ -293,7 +293,7 @@ the -o/--output flag is not supported by pull; use --output-format json|stream-j ## Notes -- `--remote=<value>` selects the target: a project ref (20 lowercase letters), or the name or UUID of a branch of the linked project (resolved through `GET /v1/projects/{parent}/branches`, or `GET /v1/branches/{id}` for a UUID when no project is linked). A bare `--remote` and no selector both target the linked project. A database URL is rejected (`supabase pull` takes a project, not a database); `false|no|off|0`, `--no-remote` and an empty value fail with `Leave out --remote to use the linked project.` +- `--remote=<value>` selects the target: a project ref (20 lowercase letters), or the name or UUID of a branch of the linked project (resolved through `GET /v1/projects/{parent}/branches`, or `GET /v1/branches/{id}` for a UUID when no project is linked). A bare `--remote` and no selector both target the linked project. A database URL is rejected (`supabase pull` takes a project, not a database); `false|no|off|0` and `--no-remote` fail with `--remote doesn't take false. Leave it out to use the linked project.`, and an empty value with `--remote= has an empty value. Pass --remote for the linked project or --remote=<ref|branch>; leave it out to use the linked project.` - The deprecated `--project-ref` is an alias of `--remote=<ref>` (it still accepts a branch name or UUID) and prints a deprecation warning on stderr that names the replacement; it cannot be combined with `--remote=<value>`. An empty `--project-ref` is rejected. - **Steps run sequentially, not in parallel**, refining ADR 0004's "runs all sub-syncs in parallel" aspiration for `pull` specifically — see [ADR diff --git a/apps/cli/src/commands/secrets/list/SIDE_EFFECTS.md b/apps/cli/src/commands/secrets/list/SIDE_EFFECTS.md index 5a033ed12b..fc74dbbe16 100644 --- a/apps/cli/src/commands/secrets/list/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/secrets/list/SIDE_EFFECTS.md @@ -98,8 +98,7 @@ One `result` NDJSON event on success containing `{secrets: [...]}` and `target`. or, on an interactive terminal, the project picker. A bare `--remote` targets the linked project. - `--remote=<value>` is read as a project ref (20 lowercase letters), a branch of the linked project (by name or UUID), or `true|yes|on|1` for the linked project. A database URL is rejected - (`supabase secrets list` takes a project, not a database); `false|no|off|0`, `--no-remote` and an - empty value fail with `Leave out --remote to use the linked project.` + (`supabase secrets list` takes a project, not a database); `false|no|off|0` and `--no-remote` fail with `--remote doesn't take false. Leave it out to use the linked project.`, and an empty value with `--remote= has an empty value. Pass --remote for the linked project or --remote=<ref|branch>; leave it out to use the linked project.` - A word after a bare `--remote` is read as its value, `--remote=<word>`, because the command takes no operands. - The deprecated `--project-ref` is an alias of `--remote=<ref>` (a non-ref value is read as a diff --git a/apps/cli/src/commands/secrets/set/SIDE_EFFECTS.md b/apps/cli/src/commands/secrets/set/SIDE_EFFECTS.md index f43418fde4..e702a04906 100644 --- a/apps/cli/src/commands/secrets/set/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/secrets/set/SIDE_EFFECTS.md @@ -100,8 +100,7 @@ One `result` NDJSON event on success containing `{project_ref, count}` and `targ or, on an interactive terminal, the project picker. A bare `--remote` targets the linked project. - `--remote=<value>` is read as a project ref (20 lowercase letters), a branch of the linked project (by name or UUID), or `true|yes|on|1` for the linked project. A database URL is rejected - (`supabase secrets set` takes a project, not a database); `false|no|off|0`, `--no-remote` and an - empty value fail with `Leave out --remote to use the linked project.` + (`supabase secrets set` takes a project, not a database); `false|no|off|0` and `--no-remote` fail with `--remote doesn't take false. Leave it out to use the linked project.`, and an empty value with `--remote= has an empty value. Pass --remote for the linked project or --remote=<ref|branch>; leave it out to use the linked project.` - A bare `--remote` followed by a word that exactly matches a branch of the linked project, is within a small edit distance of one, or can't be checked because the branch listing failed stops the run before any other work with an error that offers both readings: `--remote=<word>` for the diff --git a/apps/cli/src/commands/secrets/unset/SIDE_EFFECTS.md b/apps/cli/src/commands/secrets/unset/SIDE_EFFECTS.md index 8def221b5d..5311f528b6 100644 --- a/apps/cli/src/commands/secrets/unset/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/secrets/unset/SIDE_EFFECTS.md @@ -109,8 +109,7 @@ One `result` NDJSON event on success containing `{project_ref, count, names, dry or, on an interactive terminal, the project picker. A bare `--remote` targets the linked project. - `--remote=<value>` is read as a project ref (20 lowercase letters), a branch of the linked project (by name or UUID), or `true|yes|on|1` for the linked project. A database URL is rejected - (`supabase secrets unset` takes a project, not a database); `false|no|off|0`, `--no-remote` and an - empty value fail with `Leave out --remote to use the linked project.` + (`supabase secrets unset` takes a project, not a database); `false|no|off|0` and `--no-remote` fail with `--remote doesn't take false. Leave it out to use the linked project.`, and an empty value with `--remote= has an empty value. Pass --remote for the linked project or --remote=<ref|branch>; leave it out to use the linked project.` - A bare `--remote` followed by a word that exactly matches a branch of the linked project, is within a small edit distance of one, or can't be checked because the branch listing failed stops the run before any other work with an error that offers both readings: `--remote=<word>` for the diff --git a/apps/cli/src/commands/seed/buckets/SIDE_EFFECTS.md b/apps/cli/src/commands/seed/buckets/SIDE_EFFECTS.md index 29281614ac..1e6dc65924 100644 --- a/apps/cli/src/commands/seed/buckets/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/seed/buckets/SIDE_EFFECTS.md @@ -204,8 +204,7 @@ stdout and a terminal `result`/`error` event is emitted. is read as a project ref (20 lowercase letters), `true|yes|on|1` (the linked project), or otherwise the name of a branch of the linked project, which resolves to that branch's own project. A database URL (`--remote=<db-url>`) fails with a `needs a project, not a database URL` error and - sends no request. `false|no|off|0`, `--no-remote` and an empty value fail with - `Leave out --remote to target the local Storage API.` A word after a bare `--remote` is read as + sends no request. `false|no|off|0` and `--no-remote` fail with `--remote doesn't take false. Leave it out to target the local Storage API.`, and an empty value with `--remote= has an empty value. Pass --remote for the linked project or --remote=<ref|branch>; leave it out to target the local Storage API.` A word after a bare `--remote` is read as its value, `--remote=<word>`, because the command takes no operands. The resolved target is announced on stderr (`Target: …`) in every output format and, in JSON results, as a `target` object. `--remote`, `--linked`, diff --git a/apps/cli/src/commands/snippets/download/SIDE_EFFECTS.md b/apps/cli/src/commands/snippets/download/SIDE_EFFECTS.md index 240144d18a..1ad5ec6eb0 100644 --- a/apps/cli/src/commands/snippets/download/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/snippets/download/SIDE_EFFECTS.md @@ -94,8 +94,7 @@ NDJSON `success` event with the same full payload as `--output-format json`. or, on an interactive terminal, the project picker. A bare `--remote` targets the linked project. - `--remote=<value>` is read as a project ref (20 lowercase letters), a branch of the linked project (by name or UUID), or `true|yes|on|1` for the linked project. A database URL is rejected - (`supabase snippets download` takes a project, not a database); `false|no|off|0`, `--no-remote` and an - empty value fail with `Leave out --remote to use the linked project.` + (`supabase snippets download` takes a project, not a database); `false|no|off|0` and `--no-remote` fail with `--remote doesn't take false. Leave it out to use the linked project.`, and an empty value with `--remote= has an empty value. Pass --remote for the linked project or --remote=<ref|branch>; leave it out to use the linked project.` - A bare `--remote` followed by a word that exactly matches a branch of the linked project, is within a small edit distance of one, or can't be checked because the branch listing failed stops the run before any other work with an error that offers both readings: `--remote=<word>` for the diff --git a/apps/cli/src/commands/snippets/list/SIDE_EFFECTS.md b/apps/cli/src/commands/snippets/list/SIDE_EFFECTS.md index 3c92fa11c3..fa3faa0be9 100644 --- a/apps/cli/src/commands/snippets/list/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/snippets/list/SIDE_EFFECTS.md @@ -106,8 +106,7 @@ NDJSON `success` event with the full response as `data`. or, on an interactive terminal, the project picker. A bare `--remote` targets the linked project. - `--remote=<value>` is read as a project ref (20 lowercase letters), a branch of the linked project (by name or UUID), or `true|yes|on|1` for the linked project. A database URL is rejected - (`supabase snippets list` takes a project, not a database); `false|no|off|0`, `--no-remote` and an - empty value fail with `Leave out --remote to use the linked project.` + (`supabase snippets list` takes a project, not a database); `false|no|off|0` and `--no-remote` fail with `--remote doesn't take false. Leave it out to use the linked project.`, and an empty value with `--remote= has an empty value. Pass --remote for the linked project or --remote=<ref|branch>; leave it out to use the linked project.` - A word after a bare `--remote` is read as its value, `--remote=<word>`, because the command takes no operands. - The deprecated `--project-ref` is an alias of `--remote=<ref>` (a non-ref value is read as a 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 7a494b1b2c..305a21216c 100644 --- a/apps/cli/src/commands/ssl-enforcement/get/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/ssl-enforcement/get/SIDE_EFFECTS.md @@ -113,8 +113,7 @@ One `result` event: or, on an interactive terminal, the project picker. A bare `--remote` targets the linked project. - `--remote=<value>` is read as a project ref (20 lowercase letters), a branch of the linked project (by name or UUID), or `true|yes|on|1` for the linked project. A database URL is rejected - (`supabase ssl-enforcement get` takes a project, not a database); `false|no|off|0`, `--no-remote` and an - empty value fail with `Leave out --remote to use the linked project.` + (`supabase ssl-enforcement get` takes a project, not a database); `false|no|off|0` and `--no-remote` fail with `--remote doesn't take false. Leave it out to use the linked project.`, and an empty value with `--remote= has an empty value. Pass --remote for the linked project or --remote=<ref|branch>; leave it out to use the linked project.` - A word after a bare `--remote` is read as its value, `--remote=<word>`, because the command takes no operands. - The deprecated `--project-ref` is an alias of `--remote=<ref>` (a non-ref value is read as a 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 f60f9856fb..792fb36cfa 100644 --- a/apps/cli/src/commands/ssl-enforcement/update/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/ssl-enforcement/update/SIDE_EFFECTS.md @@ -116,8 +116,7 @@ One `result` event: or, on an interactive terminal, the project picker. A bare `--remote` targets the linked project. - `--remote=<value>` is read as a project ref (20 lowercase letters), a branch of the linked project (by name or UUID), or `true|yes|on|1` for the linked project. A database URL is rejected - (`supabase ssl-enforcement update` takes a project, not a database); `false|no|off|0`, `--no-remote` and an - empty value fail with `Leave out --remote to use the linked project.` + (`supabase ssl-enforcement update` takes a project, not a database); `false|no|off|0` and `--no-remote` fail with `--remote doesn't take false. Leave it out to use the linked project.`, and an empty value with `--remote= has an empty value. Pass --remote for the linked project or --remote=<ref|branch>; leave it out to use the linked project.` - A word after a bare `--remote` is read as its value, `--remote=<word>`, because the command takes no operands. - The deprecated `--project-ref` is an alias of `--remote=<ref>` (a non-ref value is read as a diff --git a/apps/cli/src/commands/sso/add/SIDE_EFFECTS.md b/apps/cli/src/commands/sso/add/SIDE_EFFECTS.md index 681dd9cfbd..d3ad644175 100644 --- a/apps/cli/src/commands/sso/add/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/sso/add/SIDE_EFFECTS.md @@ -112,8 +112,7 @@ Single `success` event with the parsed response as data. or, on an interactive terminal, the project picker. A bare `--remote` targets the linked project. - `--remote=<value>` is read as a project ref (20 lowercase letters), a branch of the linked project (by name or UUID), or `true|yes|on|1` for the linked project. A database URL is rejected - (`supabase sso add` takes a project, not a database); `false|no|off|0`, `--no-remote` and an - empty value fail with `Leave out --remote to use the linked project.` + (`supabase sso add` takes a project, not a database); `false|no|off|0` and `--no-remote` fail with `--remote doesn't take false. Leave it out to use the linked project.`, and an empty value with `--remote= has an empty value. Pass --remote for the linked project or --remote=<ref|branch>; leave it out to use the linked project.` - A word after a bare `--remote` is read as its value, `--remote=<word>`, because the command takes no operands. - The deprecated `--project-ref` is an alias of `--remote=<ref>` (a non-ref value is read as a diff --git a/apps/cli/src/commands/sso/info/SIDE_EFFECTS.md b/apps/cli/src/commands/sso/info/SIDE_EFFECTS.md index d078666c7a..404ef49c15 100644 --- a/apps/cli/src/commands/sso/info/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/sso/info/SIDE_EFFECTS.md @@ -85,8 +85,7 @@ Single `success` event with the payload as data. or, on an interactive terminal, the project picker. A bare `--remote` targets the linked project. - `--remote=<value>` is read as a project ref (20 lowercase letters), a branch of the linked project (by name or UUID), or `true|yes|on|1` for the linked project. A database URL is rejected - (`supabase sso info` takes a project, not a database); `false|no|off|0`, `--no-remote` and an - empty value fail with `Leave out --remote to use the linked project.` + (`supabase sso info` takes a project, not a database); `false|no|off|0` and `--no-remote` fail with `--remote doesn't take false. Leave it out to use the linked project.`, and an empty value with `--remote= has an empty value. Pass --remote for the linked project or --remote=<ref|branch>; leave it out to use the linked project.` - A word after a bare `--remote` is read as its value, `--remote=<word>`, because the command takes no operands. - The deprecated `--project-ref` is an alias of `--remote=<ref>` (a non-ref value is read as a diff --git a/apps/cli/src/commands/sso/list/SIDE_EFFECTS.md b/apps/cli/src/commands/sso/list/SIDE_EFFECTS.md index 01e36aa66d..18edece0be 100644 --- a/apps/cli/src/commands/sso/list/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/sso/list/SIDE_EFFECTS.md @@ -80,8 +80,7 @@ Single `success` event with `{providers: items}` as data. or, on an interactive terminal, the project picker. A bare `--remote` targets the linked project. - `--remote=<value>` is read as a project ref (20 lowercase letters), a branch of the linked project (by name or UUID), or `true|yes|on|1` for the linked project. A database URL is rejected - (`supabase sso list` takes a project, not a database); `false|no|off|0`, `--no-remote` and an - empty value fail with `Leave out --remote to use the linked project.` + (`supabase sso list` takes a project, not a database); `false|no|off|0` and `--no-remote` fail with `--remote doesn't take false. Leave it out to use the linked project.`, and an empty value with `--remote= has an empty value. Pass --remote for the linked project or --remote=<ref|branch>; leave it out to use the linked project.` - A word after a bare `--remote` is read as its value, `--remote=<word>`, because the command takes no operands. - The deprecated `--project-ref` is an alias of `--remote=<ref>` (a non-ref value is read as a diff --git a/apps/cli/src/commands/sso/remove/SIDE_EFFECTS.md b/apps/cli/src/commands/sso/remove/SIDE_EFFECTS.md index fec33f037a..bb01c32b4b 100644 --- a/apps/cli/src/commands/sso/remove/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/sso/remove/SIDE_EFFECTS.md @@ -85,8 +85,7 @@ Single `success` event with the removed provider's details as data. or, on an interactive terminal, the project picker. A bare `--remote` targets the linked project. - `--remote=<value>` is read as a project ref (20 lowercase letters), a branch of the linked project (by name or UUID), or `true|yes|on|1` for the linked project. A database URL is rejected - (`supabase sso remove` takes a project, not a database); `false|no|off|0`, `--no-remote` and an - empty value fail with `Leave out --remote to use the linked project.` + (`supabase sso remove` takes a project, not a database); `false|no|off|0` and `--no-remote` fail with `--remote doesn't take false. Leave it out to use the linked project.`, and an empty value with `--remote= has an empty value. Pass --remote for the linked project or --remote=<ref|branch>; leave it out to use the linked project.` - A bare `--remote` followed by a word that exactly matches a branch of the linked project, is within a small edit distance of one, or can't be checked because the branch listing failed stops the run before any other work with an error that offers both readings: `--remote=<word>` for the diff --git a/apps/cli/src/commands/sso/show/SIDE_EFFECTS.md b/apps/cli/src/commands/sso/show/SIDE_EFFECTS.md index 46d3ead02c..bc449d0c39 100644 --- a/apps/cli/src/commands/sso/show/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/sso/show/SIDE_EFFECTS.md @@ -87,8 +87,7 @@ Single `success` event with the response as data. or, on an interactive terminal, the project picker. A bare `--remote` targets the linked project. - `--remote=<value>` is read as a project ref (20 lowercase letters), a branch of the linked project (by name or UUID), or `true|yes|on|1` for the linked project. A database URL is rejected - (`supabase sso show` takes a project, not a database); `false|no|off|0`, `--no-remote` and an - empty value fail with `Leave out --remote to use the linked project.` + (`supabase sso show` takes a project, not a database); `false|no|off|0` and `--no-remote` fail with `--remote doesn't take false. Leave it out to use the linked project.`, and an empty value with `--remote= has an empty value. Pass --remote for the linked project or --remote=<ref|branch>; leave it out to use the linked project.` - A bare `--remote` followed by a word that exactly matches a branch of the linked project, is within a small edit distance of one, or can't be checked because the branch listing failed stops the run before any other work with an error that offers both readings: `--remote=<word>` for the diff --git a/apps/cli/src/commands/sso/update/SIDE_EFFECTS.md b/apps/cli/src/commands/sso/update/SIDE_EFFECTS.md index 6e11f3134a..a9f3e2a88a 100644 --- a/apps/cli/src/commands/sso/update/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/sso/update/SIDE_EFFECTS.md @@ -119,8 +119,7 @@ Single `success` event with the parsed response as data. or, on an interactive terminal, the project picker. A bare `--remote` targets the linked project. - `--remote=<value>` is read as a project ref (20 lowercase letters), a branch of the linked project (by name or UUID), or `true|yes|on|1` for the linked project. A database URL is rejected - (`supabase sso update` takes a project, not a database); `false|no|off|0`, `--no-remote` and an - empty value fail with `Leave out --remote to use the linked project.` + (`supabase sso update` takes a project, not a database); `false|no|off|0` and `--no-remote` fail with `--remote doesn't take false. Leave it out to use the linked project.`, and an empty value with `--remote= has an empty value. Pass --remote for the linked project or --remote=<ref|branch>; leave it out to use the linked project.` - A bare `--remote` followed by a word that exactly matches a branch of the linked project, is within a small edit distance of one, or can't be checked because the branch listing failed stops the run before any other work with an error that offers both readings: `--remote=<word>` for the diff --git a/apps/cli/src/commands/storage/cp/SIDE_EFFECTS.md b/apps/cli/src/commands/storage/cp/SIDE_EFFECTS.md index 50c7e53578..eda47d88df 100644 --- a/apps/cli/src/commands/storage/cp/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/storage/cp/SIDE_EFFECTS.md @@ -113,8 +113,7 @@ backend — same roles as `storage ls`. is read as a project ref (20 lowercase letters), `true|yes|on|1` (the linked project), or otherwise the name of a branch of the linked project, which resolves to that branch's own project. A database URL (`--remote=<db-url>`) fails with a `needs a project, not a database URL` error and - sends no request. `false|no|off|0`, `--no-remote` and an empty value fail with - `Leave out --remote to target the local Storage API.` A bare `--remote` followed by a word that + sends no request. `false|no|off|0` and `--no-remote` fail with `--remote doesn't take false. Leave it out to target the local Storage API.`, and an empty value with `--remote= has an empty value. Pass --remote for the linked project or --remote=<ref|branch>; leave it out to target the local Storage API.` A bare `--remote` followed by a word that exactly matches a branch of the linked project, is within a small edit distance of one, or can't be checked because the branch listing failed stops the run with an error that offers both readings: `--remote=<word>` for the branch, `<word> --remote` for the operand. Any other word runs @@ -122,8 +121,8 @@ backend — same roles as `storage ls`. in every output format and, in JSON results, as a `target` object. - **Deprecated selectors.** `--linked`, `--project-ref` and `--local` are hidden aliases that still select their target and print a warning on stderr naming the replacement, for example - `--linked is deprecated and will be removed in v4. Use --remote instead: supabase storage cp --remote` - and `--local isn't needed (local is the default) and will be removed in v4: supabase storage cp`. + `--linked is deprecated and will be removed in v4. Use --remote instead: supabase storage cp <src> <dst> --remote` + and `--local isn't needed (local is the default) and will be removed in v4: supabase storage cp <src> <dst>`. Two selectors together fail (`Only one of --remote, --linked, --db-url and --local can be set; got …`), `--project-ref` cannot be combined with `--local` or `--remote=<value>`, an empty `--project-ref` is rejected, and diff --git a/apps/cli/src/commands/storage/ls/SIDE_EFFECTS.md b/apps/cli/src/commands/storage/ls/SIDE_EFFECTS.md index 34ee58f672..e481a9e86d 100644 --- a/apps/cli/src/commands/storage/ls/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/storage/ls/SIDE_EFFECTS.md @@ -111,8 +111,7 @@ No custom storage telemetry events. is read as a project ref (20 lowercase letters), `true|yes|on|1` (the linked project), or otherwise the name of a branch of the linked project, which resolves to that branch's own project. A database URL (`--remote=<db-url>`) fails with a `needs a project, not a database URL` error and - sends no request. `false|no|off|0`, `--no-remote` and an empty value fail with - `Leave out --remote to target the local Storage API.` A bare `--remote` followed by a word that + sends no request. `false|no|off|0` and `--no-remote` fail with `--remote doesn't take false. Leave it out to target the local Storage API.`, and an empty value with `--remote= has an empty value. Pass --remote for the linked project or --remote=<ref|branch>; leave it out to target the local Storage API.` A bare `--remote` followed by a word that exactly matches a branch of the linked project, is within a small edit distance of one, or can't be checked because the branch listing failed stops the run with an error that offers both readings: `--remote=<word>` for the branch, `<word> --remote` for the operand. Any other word runs @@ -120,8 +119,8 @@ No custom storage telemetry events. in every output format and, in JSON results, as a `target` object. - **Deprecated selectors.** `--linked`, `--project-ref` and `--local` are hidden aliases that still select their target and print a warning on stderr naming the replacement, for example - `--linked is deprecated and will be removed in v4. Use --remote instead: supabase storage ls --remote` - and `--local isn't needed (local is the default) and will be removed in v4: supabase storage ls`. + `--linked is deprecated and will be removed in v4. Use --remote instead: supabase storage ls <path> --remote` + and `--local isn't needed (local is the default) and will be removed in v4: supabase storage ls <path>`. Two selectors together fail (`Only one of --remote, --linked, --db-url and --local can be set; got …`), `--project-ref` cannot be combined with `--local` or `--remote=<value>`, an empty `--project-ref` is rejected, and diff --git a/apps/cli/src/commands/storage/mv/SIDE_EFFECTS.md b/apps/cli/src/commands/storage/mv/SIDE_EFFECTS.md index 5bdd9e34bd..c63e663c51 100644 --- a/apps/cli/src/commands/storage/mv/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/storage/mv/SIDE_EFFECTS.md @@ -108,8 +108,7 @@ backend — same roles as `storage ls`. is read as a project ref (20 lowercase letters), `true|yes|on|1` (the linked project), or otherwise the name of a branch of the linked project, which resolves to that branch's own project. A database URL (`--remote=<db-url>`) fails with a `needs a project, not a database URL` error and - sends no request. `false|no|off|0`, `--no-remote` and an empty value fail with - `Leave out --remote to target the local Storage API.` A bare `--remote` followed by a word that + sends no request. `false|no|off|0` and `--no-remote` fail with `--remote doesn't take false. Leave it out to target the local Storage API.`, and an empty value with `--remote= has an empty value. Pass --remote for the linked project or --remote=<ref|branch>; leave it out to target the local Storage API.` A bare `--remote` followed by a word that exactly matches a branch of the linked project, is within a small edit distance of one, or can't be checked because the branch listing failed stops the run with an error that offers both readings: `--remote=<word>` for the branch, `<word> --remote` for the operand. Any other word runs @@ -117,8 +116,8 @@ backend — same roles as `storage ls`. in every output format and, in JSON results, as a `target` object. - **Deprecated selectors.** `--linked`, `--project-ref` and `--local` are hidden aliases that still select their target and print a warning on stderr naming the replacement, for example - `--linked is deprecated and will be removed in v4. Use --remote instead: supabase storage mv --remote` - and `--local isn't needed (local is the default) and will be removed in v4: supabase storage mv`. + `--linked is deprecated and will be removed in v4. Use --remote instead: supabase storage mv <src> <dst> --remote` + and `--local isn't needed (local is the default) and will be removed in v4: supabase storage mv <src> <dst>`. Two selectors together fail (`Only one of --remote, --linked, --db-url and --local can be set; got …`), `--project-ref` cannot be combined with `--local` or `--remote=<value>`, an empty `--project-ref` is rejected, and diff --git a/apps/cli/src/commands/storage/rm/SIDE_EFFECTS.md b/apps/cli/src/commands/storage/rm/SIDE_EFFECTS.md index 2770f03942..f00946d57f 100644 --- a/apps/cli/src/commands/storage/rm/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/storage/rm/SIDE_EFFECTS.md @@ -126,8 +126,7 @@ Requires `--yes`/`SUPABASE_YES` on the same terms as `json`. is read as a project ref (20 lowercase letters), `true|yes|on|1` (the linked project), or otherwise the name of a branch of the linked project, which resolves to that branch's own project. A database URL (`--remote=<db-url>`) fails with a `needs a project, not a database URL` error and - sends no request. `false|no|off|0`, `--no-remote` and an empty value fail with - `Leave out --remote to target the local Storage API.` A bare `--remote` followed by a word that + sends no request. `false|no|off|0` and `--no-remote` fail with `--remote doesn't take false. Leave it out to target the local Storage API.`, and an empty value with `--remote= has an empty value. Pass --remote for the linked project or --remote=<ref|branch>; leave it out to target the local Storage API.` A bare `--remote` followed by a word that exactly matches a branch of the linked project, is within a small edit distance of one, or can't be checked because the branch listing failed stops the run with an error that offers both readings: `--remote=<word>` for the branch, `<word> --remote` for the operand. Any other word runs @@ -135,8 +134,8 @@ Requires `--yes`/`SUPABASE_YES` on the same terms as `json`. in every output format and, in JSON results, as a `target` object. - **Deprecated selectors.** `--linked`, `--project-ref` and `--local` are hidden aliases that still select their target and print a warning on stderr naming the replacement, for example - `--linked is deprecated and will be removed in v4. Use --remote instead: supabase storage rm --remote` - and `--local isn't needed (local is the default) and will be removed in v4: supabase storage rm`. + `--linked is deprecated and will be removed in v4. Use --remote instead: supabase storage rm <file>... --remote` + and `--local isn't needed (local is the default) and will be removed in v4: supabase storage rm <file>...`. Two selectors together fail (`Only one of --remote, --linked, --db-url and --local can be set; got …`), `--project-ref` cannot be combined with `--local` or `--remote=<value>`, an empty `--project-ref` is rejected, and diff --git a/apps/cli/src/commands/test/db/SIDE_EFFECTS.md b/apps/cli/src/commands/test/db/SIDE_EFFECTS.md index 047ac9f3eb..f942a1679f 100644 --- a/apps/cli/src/commands/test/db/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/test/db/SIDE_EFFECTS.md @@ -124,7 +124,7 @@ command (exit 1). - 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. - **Target selection** (shared by `db test` via `testDbConfig`). `--remote` is read as the linked project (bare), a project ref (20 lowercase letters), a Postgres URL, or else a branch of the linked project by name or UUID; - `--remote=false` and `--remote=` fail with `Leave out --remote to target the local database.` A + `--remote=false` fails with `--remote doesn't take false. Leave it out to target the local database.`, and `--remote=` with `--remote= has an empty value. Pass --remote for the linked project or --remote=<ref|branch|url>; leave it out to target the local database.` A bare `--remote` followed by a word that exactly matches a branch of the linked project, is within a small edit distance of one, or can't be checked because the branch listing failed stops the run with an error that offers both readings: `--remote=<word>` for the branch, `<word> --remote` for 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 630f8c1e79..4c2ce8d83c 100644 --- a/apps/cli/src/commands/vanity-subdomains/activate/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/vanity-subdomains/activate/SIDE_EFFECTS.md @@ -101,8 +101,7 @@ One `result` event with the full response object. or, on an interactive terminal, the project picker. A bare `--remote` targets the linked project. - `--remote=<value>` is read as a project ref (20 lowercase letters), a branch of the linked project (by name or UUID), or `true|yes|on|1` for the linked project. A database URL is rejected - (`supabase vanity-subdomains activate` takes a project, not a database); `false|no|off|0`, `--no-remote` and an - empty value fail with `Leave out --remote to use the linked project.` + (`supabase vanity-subdomains activate` takes a project, not a database); `false|no|off|0` and `--no-remote` fail with `--remote doesn't take false. Leave it out to use the linked project.`, and an empty value with `--remote= has an empty value. Pass --remote for the linked project or --remote=<ref|branch>; leave it out to use the linked project.` - A word after a bare `--remote` is read as its value, `--remote=<word>`, because the command takes no operands. - The deprecated `--project-ref` is an alias of `--remote=<ref>` (a non-ref value is read as a 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 3cb40d52c4..07781daefa 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 @@ -98,8 +98,7 @@ One `result` event with the full response object. or, on an interactive terminal, the project picker. A bare `--remote` targets the linked project. - `--remote=<value>` is read as a project ref (20 lowercase letters), a branch of the linked project (by name or UUID), or `true|yes|on|1` for the linked project. A database URL is rejected - (`supabase vanity-subdomains check-availability` takes a project, not a database); `false|no|off|0`, `--no-remote` and an - empty value fail with `Leave out --remote to use the linked project.` + (`supabase vanity-subdomains check-availability` takes a project, not a database); `false|no|off|0` and `--no-remote` fail with `--remote doesn't take false. Leave it out to use the linked project.`, and an empty value with `--remote= has an empty value. Pass --remote for the linked project or --remote=<ref|branch>; leave it out to use the linked project.` - A word after a bare `--remote` is read as its value, `--remote=<word>`, because the command takes no operands. - The deprecated `--project-ref` is an alias of `--remote=<ref>` (a non-ref value is read as a 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 a7b32ba98f..92510c4235 100644 --- a/apps/cli/src/commands/vanity-subdomains/delete/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/vanity-subdomains/delete/SIDE_EFFECTS.md @@ -90,8 +90,7 @@ One `result` event when the `--output` flag is unset. or, on an interactive terminal, the project picker. A bare `--remote` targets the linked project. - `--remote=<value>` is read as a project ref (20 lowercase letters), a branch of the linked project (by name or UUID), or `true|yes|on|1` for the linked project. A database URL is rejected - (`supabase vanity-subdomains delete` takes a project, not a database); `false|no|off|0`, `--no-remote` and an - empty value fail with `Leave out --remote to use the linked project.` + (`supabase vanity-subdomains delete` takes a project, not a database); `false|no|off|0` and `--no-remote` fail with `--remote doesn't take false. Leave it out to use the linked project.`, and an empty value with `--remote= has an empty value. Pass --remote for the linked project or --remote=<ref|branch>; leave it out to use the linked project.` - A word after a bare `--remote` is read as its value, `--remote=<word>`, because the command takes no operands. - The deprecated `--project-ref` is an alias of `--remote=<ref>` (a non-ref value is read as a 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 e480f1f2d0..f4211ec45b 100644 --- a/apps/cli/src/commands/vanity-subdomains/get/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/vanity-subdomains/get/SIDE_EFFECTS.md @@ -90,8 +90,7 @@ One `result` event with the full response object. or, on an interactive terminal, the project picker. A bare `--remote` targets the linked project. - `--remote=<value>` is read as a project ref (20 lowercase letters), a branch of the linked project (by name or UUID), or `true|yes|on|1` for the linked project. A database URL is rejected - (`supabase vanity-subdomains get` takes a project, not a database); `false|no|off|0`, `--no-remote` and an - empty value fail with `Leave out --remote to use the linked project.` + (`supabase vanity-subdomains get` takes a project, not a database); `false|no|off|0` and `--no-remote` fail with `--remote doesn't take false. Leave it out to use the linked project.`, and an empty value with `--remote= has an empty value. Pass --remote for the linked project or --remote=<ref|branch>; leave it out to use the linked project.` - A word after a bare `--remote` is read as its value, `--remote=<word>`, because the command takes no operands. - The deprecated `--project-ref` is an alias of `--remote=<ref>` (a non-ref value is read as a 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 1c1f269e22..5c089fdb2c 100644 --- a/docs/adr/0033-target-selection-and-hosted-mutation-consent.md +++ b/docs/adr/0033-target-selection-and-hosted-mutation-consent.md @@ -73,8 +73,8 @@ UUID also resolves in a checkout with no linked project. A URL applies only wher Postgres: `storage`, `seed buckets` and `hosted` commands need a project and reject one. `hosted` commands accept `--remote=<ref|branch>`, and bare `--remote` changes nothing for them. The linked project comes from `SUPABASE_PROJECT_ID`, then the link file. `SUPABASE_PROJECT_ID` alone never -makes a `selectable` command remote; it is only a ref source once `--remote` is passed. An -unresolvable remote target fails. The CLI never falls back between local and remote. +makes a `selectable` command remote; it is only a ref source once `--remote` is passed, and it +alone is enough for the `default; pass --remote` hint. An unresolvable remote target fails. The CLI never falls back between local and remote. `--remote=<value>` always parses. `--remote <value>` is rewritten to `--remote=<value>` before parsing when the command takes no positional, when the token is a ref or URL, or when the command @@ -115,7 +115,11 @@ onto `--remote`: and `--to`, `remote` always means the linked project. - `--no-remote` and `--remote=false` fail and tell the user to leave `--remote` out. `--remote yes`, `--remote 1`, `--remote true` and `--remote on` mean bare `--remote`. -- An empty `--remote=` value is rejected. +- An empty `--remote=` value is rejected, and the message names the values that command accepts. + Neither error prints the command's help. +- A conflict error lists only the selector flags the command has and suggests the `--remote` form. +- `--remote` on `link`, which takes the project as its argument, and on a `local-only` command + fails with a message saying so instead of an unknown-flag error. - `--password` is rejected unless the target is a project, meaning bare `--remote`, a ref or a branch. @@ -181,7 +185,9 @@ behaviour. A group's help labels each subcommand `[local or remote]` (`selectabl or `[remote only]` (`hosted`); account commands carry no label. A command's own help has a `TARGET` section above `USAGE`, and its `remote` flag row reads `--remote[=<ref|branch|url>]` on a command that takes a database URL and `--remote[=<ref|branch>]` on one that does not (`hosted` commands, -`storage`, `seed buckets`). JSON help carries the same information as `target`. Docs, examples, help and the deprecation warnings put `--remote` last, in the `=` form +`storage`, `seed buckets`); `branches *` commands act on a parent project and take +`--remote[=<ref>]`. JSON help carries the same information as `target`, and gives the `remote` flag +`value` and `valueOptional` fields in place of the bracketed name. Docs, examples, help and the deprecation warnings put `--remote` last, in the `=` form when it has a value (`db pull add_profiles --remote`, `db push --remote=<ref>`), so it cannot be read as anything else, and shell completion never offers a space-separated value for `--remote` on a command that has a positional. From 1034b1e471dd77aba1152e4b7b165b62485e6e8e Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Mon, 19 Oct 2026 16:21:54 +0100 Subject: [PATCH 160/291] fix(cli): count SUPABASE_PROJECT_ID as linked for the local connect hint --- apps/cli/src/command-internal/db-config.layer.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/cli/src/command-internal/db-config.layer.ts b/apps/cli/src/command-internal/db-config.layer.ts index 624542ed30..444bdd4f5d 100644 --- a/apps/cli/src/command-internal/db-config.layer.ts +++ b/apps/cli/src/command-internal/db-config.layer.ts @@ -817,7 +817,8 @@ export const dbConfigResolverLayer = Layer.effect( Effect.gen(function* () { const { target } = input; const linkedCheckout = - target.kind === "local" && (yield* isCheckoutLinked(fs, path, cliSettings.workdir)); + target.kind === "local" && + (yield* isCheckoutLinked(fs, path, cliSettings.workdir, cliSettings.projectId)); if (target.kind !== "linked") { yield* rejectTargetPassword(target.kind, input.password, linkedCheckout); } From b595dd84073517f68b5bbe135f29c456f40bcfc2 Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Mon, 19 Oct 2026 16:23:39 +0100 Subject: [PATCH 161/291] test(cli): cover the env-only linked-checkout hint on local connect errors --- .../db-config.integration.test.ts | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) 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 75ef64bcee..0eb23c974b 100644 --- a/apps/cli/src/command-internal/db-config.integration.test.ts +++ b/apps/cli/src/command-internal/db-config.integration.test.ts @@ -106,6 +106,7 @@ function buildResolver( readonly configEnv?: Record<string, string | undefined>; readonly stackApi?: Layer.Layer<StackApi>; readonly flagPassword?: string; + readonly projectId?: string; readonly output?: ReturnType<typeof mockOutput>; } = {}, ) { @@ -123,7 +124,7 @@ function buildResolver( workdir, projectHost: opts.projectHost ?? "supabase.co", poolerHost: opts.poolerHost, - projectId: Option.none(), + projectId: Option.fromNullishOr(opts.projectId), }), opts.dbConnection ?? mockDbConnection, opts.debugLogger ?? mockDebugLogger, @@ -318,6 +319,18 @@ describe("dbConfigResolver (local + db-url)", () => { }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), ); + it.effect( + "local mode with SUPABASE_PROJECT_ID set and no project-ref file carries the --linked pointer in its connect hint", + () => + Effect.gen(function* () { + const dir = yield* withWorkdir(); + const r = yield* resolveConnection(dir, inputFor(localTarget), { + projectId: "abcdefghijklmnopqrst", + }); + 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(); From e326db3426601908deda5f933cfb4da225fb523e Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Mon, 19 Oct 2026 16:24:03 +0100 Subject: [PATCH 162/291] fix(cli): lower-case the scheme of a --remote postgres URL --- apps/cli/src/command-internal/target/remote-value.ts | 7 ++++++- .../src/command-internal/target/remote-value.unit.test.ts | 3 ++- .../src/command-internal/target/target-flags.unit.test.ts | 4 ++-- .../command-internal/target/target-selector.unit.test.ts | 2 +- 4 files changed, 11 insertions(+), 5 deletions(-) diff --git a/apps/cli/src/command-internal/target/remote-value.ts b/apps/cli/src/command-internal/target/remote-value.ts index 1acdaa40a8..9c724ca456 100644 --- a/apps/cli/src/command-internal/target/remote-value.ts +++ b/apps/cli/src/command-internal/target/remote-value.ts @@ -41,7 +41,12 @@ export const parseNamedRemote = ( ): Result.Result<NamedRemoteValue, ForeignUrlRejection> => { const value = raw.trim(); if (BRANCH_PROJECT_REF_PATTERN.test(value)) return Result.succeed({ _tag: "Ref", ref: value }); - if (DB_URL_PATTERN.test(value)) return Result.succeed({ _tag: "DbUrl", url: value }); + if (DB_URL_PATTERN.test(value)) { + return Result.succeed({ + _tag: "DbUrl", + url: value.replace(DB_URL_PATTERN, (scheme) => scheme.toLowerCase()), + }); + } if (value.includes("://")) { return Result.fail({ _tag: "ForeignUrl", ref: refOfProjectUrl(value) }); } diff --git a/apps/cli/src/command-internal/target/remote-value.unit.test.ts b/apps/cli/src/command-internal/target/remote-value.unit.test.ts index 75f27c005b..666d077b95 100644 --- a/apps/cli/src/command-internal/target/remote-value.unit.test.ts +++ b/apps/cli/src/command-internal/target/remote-value.unit.test.ts @@ -11,7 +11,8 @@ describe("parseRemoteValue", () => { { raw: " true ", expected: { _tag: "Linked" } }, { raw: "YES", expected: { _tag: "Linked" } }, { raw: ` ${REF} `, expected: { _tag: "Ref", ref: REF } }, - { raw: "Postgres://h/db", expected: { _tag: "DbUrl", url: "Postgres://h/db" } }, + { raw: "Postgres://h/db", expected: { _tag: "DbUrl", url: "postgres://h/db" } }, + { raw: " POSTGRESQL://u@h/db", expected: { _tag: "DbUrl", url: "postgresql://u@h/db" } }, { raw: " staging ", expected: { _tag: "Branch", name: "staging" } }, ])("reads $raw as $expected", ({ raw, expected }) => { expect(parseRemoteValue(raw)).toEqual(Result.succeed(expected)); 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 3acf80e1f1..deedf26d6f 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 @@ -179,8 +179,8 @@ describe("targetFlags", () => { it.effect.each([ { value: ` ${URL} `, expected: { _tag: "DbUrl", url: URL } }, - { value: "POSTGRESQL://u:pw@h/db", expected: { _tag: "DbUrl", url: "POSTGRESQL://u:pw@h/db" } }, - { value: "Postgres://h/db", expected: { _tag: "DbUrl", url: "Postgres://h/db" } }, + { value: "POSTGRESQL://u:pw@h/db", expected: { _tag: "DbUrl", url: "postgresql://u:pw@h/db" } }, + { value: "Postgres://h/db", expected: { _tag: "DbUrl", url: "postgres://h/db" } }, { value: ` ${REF} `, expected: { _tag: "Ref", ref: REF } }, { value: " staging ", expected: { _tag: "Branch", name: "staging" } }, ])("trims and classifies the value %j", ({ value, expected }) => 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 258a3c5391..d7f29a1566 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 @@ -192,7 +192,7 @@ describe("parseTargetSelector", () => { it("treats a postgres URL in any case as a database", () => { expect(success({ projectRef: Option.some("POSTGRESQL://h/db") }).selection).toEqual({ _tag: "DbUrl", - url: "POSTGRESQL://h/db", + url: "postgresql://h/db", }); }); }); From fc85dbfe37707706b6849428c6a6122b562b5813 Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Mon, 19 Oct 2026 16:29:35 +0100 Subject: [PATCH 163/291] test(cli): pin the storage and seed selector conflict without --db-url --- apps/cli/src/commands/seed/buckets/SIDE_EFFECTS.md | 2 +- apps/cli/src/commands/seed/buckets/buckets.e2e.test.ts | 4 ++-- apps/cli/src/commands/storage/cp/SIDE_EFFECTS.md | 2 +- apps/cli/src/commands/storage/ls/SIDE_EFFECTS.md | 2 +- apps/cli/src/commands/storage/mv/SIDE_EFFECTS.md | 2 +- apps/cli/src/commands/storage/rm/SIDE_EFFECTS.md | 2 +- apps/cli/src/commands/storage/storage.e2e.test.ts | 2 +- 7 files changed, 8 insertions(+), 8 deletions(-) diff --git a/apps/cli/src/commands/seed/buckets/SIDE_EFFECTS.md b/apps/cli/src/commands/seed/buckets/SIDE_EFFECTS.md index 1e6dc65924..82fea0d6d1 100644 --- a/apps/cli/src/commands/seed/buckets/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/seed/buckets/SIDE_EFFECTS.md @@ -216,7 +216,7 @@ stdout and a terminal `result`/`error` event is emitted. and `--local isn't needed (local is the default) and will be removed in v4: supabase seed buckets`. Two selectors together fail - (`Only one of --remote, --linked, --db-url and --local can be set; got …`), `--project-ref` cannot + (`Only one of --remote, --linked and --local can be set; got …`), `--project-ref` cannot be combined with `--local` or `--remote=<value>`, an empty `--project-ref` is rejected, and `--linked=false` selects nothing. - **Target banner.** A remote project prints `Target: …` to stderr once its ref resolves; the local 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 29b26e6122..939d45caa0 100644 --- a/apps/cli/src/commands/seed/buckets/buckets.e2e.test.ts +++ b/apps/cli/src/commands/seed/buckets/buckets.e2e.test.ts @@ -51,7 +51,7 @@ describe("supabase seed buckets", () => { ); expect(exitCode).toBe(1); expect(`${stdout}${stderr}`).toContain( - "Only one of --remote, --linked, --db-url and --local can be set; got --remote and --local.", + "Only one of --remote, --linked and --local can be set; got --remote and --local.", ); }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), E2E_TIMEOUT_MS, @@ -88,7 +88,7 @@ describe("supabase seed buckets", () => { ); expect(exitCode).toBe(1); expect(`${stdout}${stderr}`).toContain( - "Only one of --remote, --linked, --db-url and --local can be set; got --remote and --local.", + "Only one of --remote, --linked and --local can be set; got --remote and --local.", ); }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), E2E_TIMEOUT_MS, diff --git a/apps/cli/src/commands/storage/cp/SIDE_EFFECTS.md b/apps/cli/src/commands/storage/cp/SIDE_EFFECTS.md index eda47d88df..8a34709c87 100644 --- a/apps/cli/src/commands/storage/cp/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/storage/cp/SIDE_EFFECTS.md @@ -124,7 +124,7 @@ backend — same roles as `storage ls`. `--linked is deprecated and will be removed in v4. Use --remote instead: supabase storage cp <src> <dst> --remote` and `--local isn't needed (local is the default) and will be removed in v4: supabase storage cp <src> <dst>`. Two selectors together fail - (`Only one of --remote, --linked, --db-url and --local can be set; got …`), `--project-ref` cannot + (`Only one of --remote, --linked and --local can be set; got …`), `--project-ref` cannot be combined with `--local` or `--remote=<value>`, an empty `--project-ref` is rejected, and `--linked=false` selects nothing. - **Target banner.** A remote project prints `Target: …` to stderr once its ref resolves; the local diff --git a/apps/cli/src/commands/storage/ls/SIDE_EFFECTS.md b/apps/cli/src/commands/storage/ls/SIDE_EFFECTS.md index e481a9e86d..33855545aa 100644 --- a/apps/cli/src/commands/storage/ls/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/storage/ls/SIDE_EFFECTS.md @@ -122,7 +122,7 @@ No custom storage telemetry events. `--linked is deprecated and will be removed in v4. Use --remote instead: supabase storage ls <path> --remote` and `--local isn't needed (local is the default) and will be removed in v4: supabase storage ls <path>`. Two selectors together fail - (`Only one of --remote, --linked, --db-url and --local can be set; got …`), `--project-ref` cannot + (`Only one of --remote, --linked and --local can be set; got …`), `--project-ref` cannot be combined with `--local` or `--remote=<value>`, an empty `--project-ref` is rejected, and `--linked=false` selects nothing. - **Target banner.** A remote project prints `Target: …` to stderr once its ref resolves; the local diff --git a/apps/cli/src/commands/storage/mv/SIDE_EFFECTS.md b/apps/cli/src/commands/storage/mv/SIDE_EFFECTS.md index c63e663c51..9686e45c7c 100644 --- a/apps/cli/src/commands/storage/mv/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/storage/mv/SIDE_EFFECTS.md @@ -119,7 +119,7 @@ backend — same roles as `storage ls`. `--linked is deprecated and will be removed in v4. Use --remote instead: supabase storage mv <src> <dst> --remote` and `--local isn't needed (local is the default) and will be removed in v4: supabase storage mv <src> <dst>`. Two selectors together fail - (`Only one of --remote, --linked, --db-url and --local can be set; got …`), `--project-ref` cannot + (`Only one of --remote, --linked and --local can be set; got …`), `--project-ref` cannot be combined with `--local` or `--remote=<value>`, an empty `--project-ref` is rejected, and `--linked=false` selects nothing. - **Target banner.** A remote project prints `Target: …` to stderr once its ref resolves; the local diff --git a/apps/cli/src/commands/storage/rm/SIDE_EFFECTS.md b/apps/cli/src/commands/storage/rm/SIDE_EFFECTS.md index f00946d57f..13cdcfb90c 100644 --- a/apps/cli/src/commands/storage/rm/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/storage/rm/SIDE_EFFECTS.md @@ -137,7 +137,7 @@ Requires `--yes`/`SUPABASE_YES` on the same terms as `json`. `--linked is deprecated and will be removed in v4. Use --remote instead: supabase storage rm <file>... --remote` and `--local isn't needed (local is the default) and will be removed in v4: supabase storage rm <file>...`. Two selectors together fail - (`Only one of --remote, --linked, --db-url and --local can be set; got …`), `--project-ref` cannot + (`Only one of --remote, --linked and --local can be set; got …`), `--project-ref` cannot be combined with `--local` or `--remote=<value>`, an empty `--project-ref` is rejected, and `--linked=false` selects nothing. - **Target banner.** A remote project prints `Target: …` to stderr once its ref resolves; the local diff --git a/apps/cli/src/commands/storage/storage.e2e.test.ts b/apps/cli/src/commands/storage/storage.e2e.test.ts index 1bd0995dbc..2e1b5a18a4 100644 --- a/apps/cli/src/commands/storage/storage.e2e.test.ts +++ b/apps/cli/src/commands/storage/storage.e2e.test.ts @@ -58,7 +58,7 @@ describe("supabase storage", () => { ); expect(exitCode).toBe(1); expect(`${stdout}${stderr}`).toContain( - "Only one of --remote, --linked, --db-url and --local can be set; got --remote and --local.", + "Only one of --remote, --linked and --local can be set; got --remote and --local.", ); }), ), From e7381e583bac15785c42f13b1e8b103412cc79c7 Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Mon, 19 Oct 2026 17:09:58 +0100 Subject: [PATCH 164/291] fix(cli): reject keyword connection strings after --remote and cut userinfo at the last @ A word with whitespace or '=' is never read as a branch name, so a NAME=value pair or a keyword DSN no longer reaches the bare-remote ambiguity check or a branch lookup. --remote=<keyword dsn> is rejected with a pointer to --remote=<url>, and stripUserinfo drops everything up to the last '@' so a password containing '@' cannot leak. --- .../command-internal/target/remote-value.ts | 25 +++++++++++++----- .../target/remote-value.unit.test.ts | 26 ++++++++++++++++++- .../target/remote-vocabulary.ts | 9 ++++--- .../command-internal/target/target-flags.ts | 6 +++++ .../target/target-flags.unit.test.ts | 15 +++++++++++ .../target/target-selector.ts | 8 +++--- .../target/target-selector.unit.test.ts | 10 +++++++ 7 files changed, 85 insertions(+), 14 deletions(-) diff --git a/apps/cli/src/command-internal/target/remote-value.ts b/apps/cli/src/command-internal/target/remote-value.ts index 9c724ca456..b92958d6fd 100644 --- a/apps/cli/src/command-internal/target/remote-value.ts +++ b/apps/cli/src/command-internal/target/remote-value.ts @@ -1,7 +1,12 @@ import { Predicate, Result } from "effect"; import { BRANCH_PROJECT_REF_PATTERN } from "../ref-patterns.ts"; -import { DB_URL_PATTERN, LINKED_LITERAL, OFF_LITERAL } from "./remote-vocabulary.ts"; +import { + DB_URL_PATTERN, + KEYWORD_DSN_PATTERN, + LINKED_LITERAL, + OFF_LITERAL, +} from "./remote-vocabulary.ts"; /** What `--remote` (or a `db diff --from/--to` value) names. */ export type RemoteValue = @@ -20,25 +25,32 @@ export const isRemoteValue = (value: unknown): value is RemoteValue => export type NamedRemoteValue = Exclude<RemoteValue, { readonly _tag: "Linked" }>; /** A value with `://` that is not a postgres URL; `ref` is the project it points at when it is a project's own URL. */ -export interface ForeignUrlRejection { +interface ForeignUrlRejection { readonly _tag: "ForeignUrl"; readonly ref: string | undefined; } +/** A `host=… user=…` connection string, which `--remote` does not read as a branch name. */ +interface KeywordDsnRejection { + readonly _tag: "KeywordDsn"; +} + +export type NamedRemoteRejection = ForeignUrlRejection | KeywordDsnRejection; + export type RemoteValueRejection = | { readonly _tag: "Empty" } | { readonly _tag: "Off" } - | ForeignUrlRejection; + | NamedRemoteRejection; const PROJECT_URL_REF = /^[a-z][a-z0-9+.-]*:\/\/([a-z]{20})\.supabase\.[a-z]+(?::\d+)?(?:[/?#]|$)/; const refOfProjectUrl = (value: string): string | undefined => PROJECT_URL_REF.exec(value.toLowerCase())?.[1]; -/** 20 lowercase letters is a project ref, a postgres URL is a database, any other URL is rejected, anything else names a branch. */ +/** 20 lowercase letters is a project ref, a postgres URL is a database, any other URL or a keyword connection string is rejected, anything else names a branch. */ export const parseNamedRemote = ( raw: string, -): Result.Result<NamedRemoteValue, ForeignUrlRejection> => { +): Result.Result<NamedRemoteValue, NamedRemoteRejection> => { const value = raw.trim(); if (BRANCH_PROJECT_REF_PATTERN.test(value)) return Result.succeed({ _tag: "Ref", ref: value }); if (DB_URL_PATTERN.test(value)) { @@ -50,6 +62,7 @@ export const parseNamedRemote = ( if (value.includes("://")) { return Result.fail({ _tag: "ForeignUrl", ref: refOfProjectUrl(value) }); } + if (KEYWORD_DSN_PATTERN.test(value)) return Result.fail({ _tag: "KeywordDsn" }); return Result.succeed({ _tag: "Branch", name: value }); }; @@ -62,7 +75,7 @@ export const parseRemoteValue = (raw: string): Result.Result<RemoteValue, Remote return parseNamedRemote(value); }; -/** {@link parseNamedRemote} for a caller that reads a value the flag's own parser already accepted: a foreign URL falls back to a branch name. */ +/** {@link parseNamedRemote} for a caller that reads a value the flag's own parser already accepted: a rejected value falls back to a branch name. */ export const classifyNamedRemote = (raw: string): NamedRemoteValue => Result.getOrElse(parseNamedRemote(raw), (): NamedRemoteValue => ({ _tag: "Branch", diff --git a/apps/cli/src/command-internal/target/remote-value.unit.test.ts b/apps/cli/src/command-internal/target/remote-value.unit.test.ts index 666d077b95..eec23f90c0 100644 --- a/apps/cli/src/command-internal/target/remote-value.unit.test.ts +++ b/apps/cli/src/command-internal/target/remote-value.unit.test.ts @@ -2,7 +2,7 @@ import { Result } from "effect"; import { describe, expect, it } from "vitest"; import { parseNamedRemote, parseRemoteValue } from "./remote-value.ts"; -import { stripUserinfo } from "./remote-vocabulary.ts"; +import { couldNameBranch, stripUserinfo } from "./remote-vocabulary.ts"; const REF = "abcdefghijklmnopqrst"; @@ -28,6 +28,9 @@ describe("parseRemoteValue", () => { { raw: `https://${REF}.supabase.com?x=1`, expected: { _tag: "ForeignUrl", ref: REF } }, { raw: "https://db.supabase.co", expected: { _tag: "ForeignUrl", ref: undefined } }, { raw: "ftp://example.com", expected: { _tag: "ForeignUrl", ref: undefined } }, + { raw: "host=db.example.com user=postgres", expected: { _tag: "KeywordDsn" } }, + { raw: "dbname=postgres", expected: { _tag: "KeywordDsn" } }, + { raw: "my branch", expected: { _tag: "KeywordDsn" } }, ])("rejects $raw as $expected", ({ raw, expected }) => { expect(parseRemoteValue(raw)).toEqual(Result.fail(expected)); }); @@ -39,11 +42,32 @@ describe("parseNamedRemote", () => { }); }); +describe("couldNameBranch", () => { + it.each(["feature-x", "feature/x", "v1.2"])("reads %s as a branch name", (word) => { + expect(couldNameBranch(word)).toBe(true); + }); + + it.each([ + "", + "STRIPE_KEY=sk_live_123", + "host=db.example.com", + "two words", + "ss:///bucket", + "123", + "./path", + ])("reads %j as something else", (word) => { + expect(couldNameBranch(word)).toBe(false); + }); +}); + describe("stripUserinfo", () => { it.each([ { value: "postgresql://u:pw@h/db", expected: "postgresql://h/db" }, { value: "postgresql://u@h/db", expected: "postgresql://h/db" }, { value: "u:pw@staging", expected: "staging" }, + { value: "u:p@ss@h", expected: "h" }, + { value: "postgresql://u:p@ss@h/db", expected: "postgresql://h/db" }, + { value: "postgresql://u:pw@h/db?x=a@b", expected: "postgresql://h/db?x=a@b" }, { value: "staging", expected: "staging" }, { value: "a message with postgresql://u:pw@h and more", diff --git a/apps/cli/src/command-internal/target/remote-vocabulary.ts b/apps/cli/src/command-internal/target/remote-vocabulary.ts index 95aff4337a..0f9634a4d9 100644 --- a/apps/cli/src/command-internal/target/remote-vocabulary.ts +++ b/apps/cli/src/command-internal/target/remote-vocabulary.ts @@ -11,17 +11,20 @@ export const OFF_LITERAL = /^(false|no|off|0)$/i; export const isBooleanLiteral = (word: string): boolean => LINKED_LITERAL.test(word) || OFF_LITERAL.test(word); +/** Whitespace or `=`: a `host=… user=…` connection string or a `NAME=value` pair, never a branch name. */ +export const KEYWORD_DSN_PATTERN = /[\s=]/; + const NOT_A_BRANCH_NAME: ReadonlyArray<RegExp> = [ - /\s/, + KEYWORD_DSN_PATTERN, /^[a-z][a-z0-9+.-]*:\/\//i, /^\d+$/, /^[./~]/, ]; -/** Whether a word reads as a branch name rather than a path, URL, number or sentence. */ +/** Whether a word reads as a branch name rather than a path, URL, number, sentence or `NAME=value` pair. */ export const couldNameBranch = (word: string): boolean => word !== "" && !NOT_A_BRANCH_NAME.some((pattern) => pattern.test(word)); /** Removes `user:pass@` from a value before it appears in an error or a warning. */ export const stripUserinfo = (value: string): string => - value.replace(/\/\/[^\s/@]*@/g, "//").replace(/[^\s/@:]+:[^\s/@]*@/g, ""); + value.replace(/\/\/[^\s/]*@/g, "//").replace(/[^\s/@:]+:[^\s/]*@/g, ""); diff --git a/apps/cli/src/command-internal/target/target-flags.ts b/apps/cli/src/command-internal/target/target-flags.ts index a70f9c3e56..f7ebacb386 100644 --- a/apps/cli/src/command-internal/target/target-flags.ts +++ b/apps/cli/src/command-internal/target/target-flags.ts @@ -70,6 +70,12 @@ const rejectionMessage = (variant: RemoteFlagVariant, rejection: RemoteValueReje ? "." : `; to target that project, use --remote=${rejection.ref}.` }`; + case "KeywordDsn": + return `--remote takes ${acceptedValuesOf(variant)}${ + variant.kind === "selectable" && variant.dbUrl + ? "; convert a connection string to a postgresql:// URL and pass --remote=<url>." + : "." + }`; } }; 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 deedf26d6f..573769064d 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 @@ -166,6 +166,21 @@ describe("targetFlags", () => { }), ); + it.effect("rejects a keyword connection string without echoing it", () => + Effect.gen(function* () { + const message = yield* rejectedMessage(withDbUrl, [ + "--remote=host=db.example.com user=postgres password=hunter2", + ]); + expect(message).toBe( + "--remote takes a postgres:// URL, a project ref or a branch name; convert a connection string to a postgresql:// URL and pass --remote=<url>.", + ); + const storage = targetFlags({ subject: "Storage API", dbUrl: false }); + expect(yield* rejectedMessage(storage, ["--remote=host=db.example.com"])).toBe( + "--remote takes a project ref or a branch name.", + ); + }), + ); + it.effect( "leaves the postgres:// URL out of the foreign-URL message where none is accepted", () => diff --git a/apps/cli/src/command-internal/target/target-selector.ts b/apps/cli/src/command-internal/target/target-selector.ts index ad4055994d..00a7fd0f5c 100644 --- a/apps/cli/src/command-internal/target/target-selector.ts +++ b/apps/cli/src/command-internal/target/target-selector.ts @@ -155,11 +155,11 @@ export const parseTargetSelector = ( return Result.fail( new TargetInvalidValueError({ flag: aliasFlag, - message: `${aliasFlag} takes a project ref or a branch name, not a URL.`, + message: `${aliasFlag} takes a project ref or a branch name, not a ${aliasRemote.failure._tag === "ForeignUrl" ? "URL" : "connection string"}.`, suggestion: - aliasRemote.failure.ref === undefined - ? "Pass a project ref or a branch name, for example --remote=abcdefghijklmnopqrst" - : `Use --remote=${aliasRemote.failure.ref}.`, + aliasRemote.failure._tag === "ForeignUrl" && aliasRemote.failure.ref !== undefined + ? `Use --remote=${aliasRemote.failure.ref}.` + : "Pass a project ref or a branch name, for example --remote=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 d7f29a1566..1f13b4a36f 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 @@ -160,6 +160,16 @@ describe("parseTargetSelector", () => { }); }); + it("rejects a keyword connection string given to --project-ref without echoing it", () => { + const error = failure({ projectRef: Option.some("host=db.example.com password=hunter2") }); + expect(error).toMatchObject({ + _tag: "TargetInvalidValueError", + flag: "--project-ref", + message: "--project-ref takes a project ref or a branch name, not a connection string.", + }); + expect(JSON.stringify(error)).not.toContain("hunter2"); + }); + it("suggests the ref a project URL already names", () => { expect(failure({ projectRef: Option.some(`https://${REF}.supabase.co`) })).toMatchObject({ _tag: "TargetInvalidValueError", From 63abc7c3f040def07d107cb01559188ceaa4717a Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Mon, 19 Oct 2026 17:09:59 +0100 Subject: [PATCH 165/291] fix(cli): add the no-space completion directive only when every candidate ends with = --- apps/cli/src/cli/complete.remote.unit.test.ts | 6 ++++++ apps/cli/src/cli/complete.ts | 4 ++-- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/apps/cli/src/cli/complete.remote.unit.test.ts b/apps/cli/src/cli/complete.remote.unit.test.ts index abce18cf67..0cf37f7789 100644 --- a/apps/cli/src/cli/complete.remote.unit.test.ts +++ b/apps/cli/src/cli/complete.remote.unit.test.ts @@ -39,6 +39,12 @@ describe("--remote flag-name completion", () => { ); }); + it("keeps the space when other flags are offered next to --remote=", () => { + expect(names("db", "push", "--")).toContain("--remote="); + expect(names("db", "push", "--")?.length).toBeGreaterThan(1); + expect(complete("db", "push", "--")?.directive).toBe(CompletionDirective.NoFileComp); + }); + it("keeps the space after a flag name that takes no value", () => { expect(complete("db", "push", "--ot")?.directive).toBe(CompletionDirective.NoFileComp); }); diff --git a/apps/cli/src/cli/complete.ts b/apps/cli/src/cli/complete.ts index fe31a9851b..70973cd171 100644 --- a/apps/cli/src/cli/complete.ts +++ b/apps/cli/src/cli/complete.ts @@ -356,9 +356,9 @@ const COMPLETION_NO_FILE_COMP_PATHS: ReadonlySet<string> = new Set([ /** Offered instead of a bare `--remote`, so the next word is typed as its value rather than read as an operand. */ const REMOTE_FLAG_CANDIDATE = `--${REMOTE_FLAG_NAME}=`; -/** `--remote=` takes its value right after the `=`, so the shell must not add a space. */ +/** `--remote=` takes its value right after the `=`, so no space follows it; any other candidate in the list keeps the space. */ function flagNameDirective(candidates: ReadonlyArray<CompletionCandidate>): number { - return candidates.some((candidate) => candidate.name === REMOTE_FLAG_CANDIDATE) + return candidates.length > 0 && candidates.every((candidate) => candidate.name.endsWith("=")) ? CompletionDirective.NoFileComp | CompletionDirective.NoSpace : CompletionDirective.NoFileComp; } From d18141a80b5cfe5a522b01204886ecb08f34c79c Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Mon, 19 Oct 2026 17:10:00 +0100 Subject: [PATCH 166/291] test(cli): prove a handler reads the rewritten argv from Stdio.args --- .../src/shared/cli/run.integration.test.ts | 64 ++++++++++++++++++- apps/cli/src/shared/cli/run.ts | 2 +- 2 files changed, 62 insertions(+), 4 deletions(-) diff --git a/apps/cli/src/shared/cli/run.integration.test.ts b/apps/cli/src/shared/cli/run.integration.test.ts index bf4e7629a6..d85153968c 100644 --- a/apps/cli/src/shared/cli/run.integration.test.ts +++ b/apps/cli/src/shared/cli/run.integration.test.ts @@ -1,13 +1,21 @@ import { describe, expect, it } from "@effect/vitest"; -import { Console, Effect, Exit, Layer } from "effect"; +import { Console, Effect, Exit, Layer, Stdio } from "effect"; import { Argument, CliOutput, Command, Flag } from "effect/cli"; import { branchesCommand } from "../../commands/branches/branches.command.ts"; import { GLOBAL_FLAGS, OutputFormatFlag } from "../../command-internal/global-flags.ts"; import { textCliOutputFormatter } from "../output/text-formatter.ts"; -import { emptyEnv, fakeConsole, mockOutput } from "../../../tests/helpers/mocks.ts"; +import { useTempWorkdir, withEnvVar } from "../../../tests/helpers/command-mocks.ts"; +import { emptyEnv, fakeConsole, mockAnalytics, mockOutput } from "../../../tests/helpers/mocks.ts"; +import { targetFlags } from "../../command-internal/target/target-flags.ts"; +import { CliProjectHome } from "../config/cli-project-home.service.ts"; import { commandTargetLayer } from "../output/command-target.layer.ts"; import { CliArgs } from "./cli-args.service.ts"; -import { exitCodeForFailure, withoutParseErrorHelpDump } from "./run.ts"; +import { + cliProgramFor, + exitCodeForFailure, + prepareCliArgs, + withoutParseErrorHelpDump, +} from "./run.ts"; const textOutputLayer = () => { const out = mockOutput({ format: "text" }); @@ -267,3 +275,53 @@ describe("nested command parsing", () => { }), ); }); + +describe("argv rewrite reaches handlers", () => { + const tempHome = useTempWorkdir("supabase-run-argv-"); + + it.live("a handler reads the rewritten argv from Stdio.args, as it does from CliArgs", () => + Effect.gen(function* () { + const REF = "abcdefghijklmnopqrst"; + let seenByHandler: ReadonlyArray<string> = []; + const types = Command.make("types", targetFlags({ subject: "database", dbUrl: true }), () => + Effect.gen(function* () { + seenByHandler = yield* (yield* Stdio.Stdio).args; + }), + ); + const gen = Command.make("gen").pipe(Command.withSubcommands([types])); + const root = Command.make("supabase").pipe(Command.withSubcommands([gen])); + const cliArgs = prepareCliArgs(root, ["gen", "types", "--remote", REF]); + + const projectHomeDir = `${tempHome.current}/.supabase`; + const projectHome = Layer.succeed( + CliProjectHome, + CliProjectHome.of({ + projectRoot: tempHome.current, + supabaseDir: `${tempHome.current}/supabase`, + projectHomeDir, + projectLinkPath: `${projectHomeDir}/project.json`, + projectLocalVersionsPath: `${projectHomeDir}/local-versions.json`, + ensureCliProjectHomeDir: Effect.void, + }), + ); + + yield* withEnvVar( + "SUPABASE_HOME", + tempHome.current, + cliProgramFor( + root, + cliArgs, + { + beforeParse: Effect.void, + agentDefaultOutputFormat: "text", + analyticsLayer: mockAnalytics().layer, + }, + "text", + ), + ).pipe(Effect.provide(projectHome)); + + expect(cliArgs.args).toEqual(["gen", "types", `--remote=${REF}`]); + expect(seenByHandler).toEqual(cliArgs.args); + }), + ); +}); diff --git a/apps/cli/src/shared/cli/run.ts b/apps/cli/src/shared/cli/run.ts index 5656256ddc..82493704c7 100644 --- a/apps/cli/src/shared/cli/run.ts +++ b/apps/cli/src/shared/cli/run.ts @@ -636,7 +636,7 @@ export interface RunCliOptions<BeforeParseError = never> { ) => Effect.Effect<void, never, FileSystem.FileSystem | Path.Path | RuntimeInfo>; } -function cliProgramFor< +export function cliProgramFor< Name extends string, Input, ContextInput, From 0e3b8b8ba6afd46a6e3817fd396dd0ff8c87e9e4 Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Mon, 19 Oct 2026 17:10:00 +0100 Subject: [PATCH 167/291] fix(cli): run the gen types usage guards before resolving the target A usage error no longer costs a branch listing or prints a Target banner. A positional language that is also the word after a bare --remote still lets the branch check rule first. --- .../src/commands/gen/types/SIDE_EFFECTS.md | 11 ++- .../src/commands/gen/types/types.handler.ts | 34 ++++---- .../gen/types/types.integration.test.ts | 80 +++++++++++++++++++ .../src/shared/cli/remote-argv.unit.test.ts | 20 +++++ 4 files changed, 127 insertions(+), 18 deletions(-) diff --git a/apps/cli/src/commands/gen/types/SIDE_EFFECTS.md b/apps/cli/src/commands/gen/types/SIDE_EFFECTS.md index ea80e33c3e..f8d530753f 100644 --- a/apps/cli/src/commands/gen/types/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/gen/types/SIDE_EFFECTS.md @@ -157,7 +157,7 @@ Not applicable. linked project, which resolves to that branch's own project. `false|no|off|0` and `--no-remote` fail with `--remote doesn't take false. Leave it out to target the local database.`, and an empty value with `--remote= has an empty value. Pass --remote for the linked project or --remote=<ref|branch|url>; leave it out to target the local database.` A bare `--remote` followed by a word that exactly matches a branch of the linked project, is within a small edit distance of one, or can't be checked because the branch listing failed stops the run with an error - that offers both readings: `--remote=<word>` for the branch, `<word> --remote` for the operand. + that names the cause (an HTTP status, or a missing or invalid access token) and offers both readings without echoing the word: `--remote=<branch>` for the branch, the command's operands then `--remote` for the linked project. Any other word runs as the operand. The resolved target is announced on stderr (`Target: …`) in every output format. - **Deprecated selectors.** `--linked`, `--project-ref`, `--project-id`, `--db-url` and `--local` @@ -178,9 +178,12 @@ Not applicable. language flag (`swift-access-control`). Passing the selector together with any of them fails with `if any flags in the group [remote linked project-ref <flag>] are set none of the others can be; [<set flags, sorted>] were all set`, reporting the first violated group in that order. `--remote=<db-url>` and `--db-url` are not - members, so they accept all three. The target resolves first, ahead of these groups and the - language guards, so a selector error, a missing project or a bare `--remote <word>` that names a - branch fails before them. The command reads its own rewritten command line, so + members, so they accept all three. These groups and the other usage guards + (`--postgrest-v9-compat` without a database target, a positional language other than + `typescript` without `--lang`) run before the target resolves, so a usage error never costs a + network call or prints a `Target:` banner; the one exception is a positional language that is + also the word after a bare `--remote`, which the branch check rules on first. The command reads + its own rewritten command line, so `--remote <ref>` and `--remote <url>` mean `--remote=<ref>` and `--remote=<url>`, and the word is not taken as the positional language. - With the local target, a missing `supabase/config.toml` uses the embedded config defaults plus diff --git a/apps/cli/src/commands/gen/types/types.handler.ts b/apps/cli/src/commands/gen/types/types.handler.ts index bbce90bbef..4efbaf747d 100644 --- a/apps/cli/src/commands/gen/types/types.handler.ts +++ b/apps/cli/src/commands/gen/types/types.handler.ts @@ -1,9 +1,10 @@ import { ChildProcessSpawner } from "effect/process"; -import { Effect, FileSystem, Match, Option, Path, Predicate, Stdio, Stream } from "effect"; +import { Effect, FileSystem, Match, Option, Path, Predicate, Result, 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 { CliArgs } from "../../../shared/cli/cli-args.service.ts"; import { mutuallyExclusiveFlagsMessage, PERSISTENT_VALUE_FLAG_NAMES, @@ -30,6 +31,7 @@ import { type ResolvedTarget, } from "../../../command-internal/target/resolve-target.ts"; import { renderLocalTarget } from "../../../command-internal/target/target-identity.ts"; +import { parseTargetSelector } from "../../../command-internal/target/target-selector.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"; @@ -570,13 +572,11 @@ export const genTypes = Effect.fn("gen.types")(function* (flags: GenTypesFlags) yield* output.raw(`${POSTGREST_V9_COMPAT_DEPRECATION_LINE}\n`, "stderr"); } - const target = yield* resolveTarget(flags); - const projectRefFlag = Option.orElse(flags.projectRef, () => flags.projectId); const remoteIsDatabase = Option.exists(flags.remote, (value) => value._tag === "DbUrl"); - // 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 + // These guards run before the target resolves, so a usage error never costs a network call or + // prints a `Target:` banner. Every return here must stay inside the // `Effect.ensuring(telemetryState.flush)` below. if (flags.postgrestV9Compat && Option.isNone(flags.dbUrl) && !remoteIsDatabase) { // Established error text, including the "must used" typo — do not @@ -586,17 +586,20 @@ export const genTypes = Effect.fn("gen.types")(function* (flags: GenTypesFlags) }); } const positionalLang = findPositionalLanguage(rawArgs); - if ( + const unknownPositionalLang = Option.isSome(positionalLang) && positionalLang.value !== "typescript" && - !occurrences.has("lang") - ) { - return yield* new GenTypesFlagUsageError({ - message: "use --lang flag to specify the typegen language", - }); - } - - if (target.kind === "linked") { + !occurrences.has("lang"); + const positionalLangError = () => + new GenTypesFlagUsageError({ message: "use --lang flag to specify the typegen language" }); + // The word after a bare `--remote` may be a branch name; the target's check rules on it first. + const cliArgs = Option.getOrUndefined(yield* Effect.serviceOption(CliArgs)); + const deferPositionalLang = + unknownPositionalLang && positionalLang.value === cliArgs?.bareRemoteOperand; + if (unknownPositionalLang && !deferPositionalLang) return yield* positionalLangError(); + + const selection = Result.getOrUndefined(parseTargetSelector(flags))?.selection; + if (selection?._tag === "Linked" || selection?._tag === "Ref" || selection?._tag === "Branch") { const excluded = HOSTED_EXCLUDED_FLAGS.find((name) => occurrences.has(name)); if (excluded !== undefined) { const selectors = [ @@ -613,6 +616,9 @@ export const genTypes = Effect.fn("gen.types")(function* (flags: GenTypesFlags) } } + const target = yield* resolveTarget(flags); + if (deferPositionalLang) return yield* positionalLangError(); + yield* Effect.annotateCurrentSpan("typegen.lang", lang); if (target.kind === "local") { 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 2fefc826ee..9a625a1740 100644 --- a/apps/cli/src/commands/gen/types/types.integration.test.ts +++ b/apps/cli/src/commands/gen/types/types.integration.test.ts @@ -1647,6 +1647,86 @@ describe("gen types", () => { }).pipe(Effect.provide(BunServices.layer)), ); + it.effect("prefers the --postgrest-v9-compat guard over a selector conflict", () => + Effect.gen(function* () { + const { layer } = yield* setup({ + args: ["gen", "types", "--local", "--linked", "--postgrest-v9-compat"], + }); + const exit = yield* genTypes( + defaultFlags({ local: true, linked: true, postgrestV9Compat: true }), + ).pipe(Effect.provide(layer), Effect.exit); + + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(String(exit.cause)).toContain( + "--postgrest-v9-compat must used together with --remote=<db-url>", + ); + } + }).pipe(Effect.provide(BunServices.layer)), + ); + + it.effect("prefers the positional language guard over a selector conflict", () => + Effect.gen(function* () { + const { layer } = yield* setup({ args: ["gen", "types", "go", "--local", "--linked"] }); + const exit = yield* genTypes(defaultFlags({ local: true, linked: true })).pipe( + Effect.provide(layer), + Effect.exit, + ); + + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(String(exit.cause)).toContain("use --lang flag to specify the typegen language"); + } + }).pipe(Effect.provide(BunServices.layer)), + ); + + it.effect("rejects a hosted selector with --query-timeout before any request or banner", () => + Effect.gen(function* () { + const { layer, out, api, telemetry } = yield* setup({ + args: ["gen", "types", `--remote=${VALID_REF}`, "--query-timeout", "20s"], + projectTypes: "ok", + }); + const exit = yield* genTypes( + defaultFlags({ + remote: Option.some({ _tag: "Ref", ref: VALID_REF } as const), + 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 [remote linked project-ref query-timeout] are set none of the others can be; [query-timeout remote] were all set", + ); + } + expect(api.requests).toEqual([]); + expect(out.stderrText).not.toContain("Target:"); + expect(telemetry.flushed).toBe(true); + }).pipe(Effect.provide(BunServices.layer)), + ); + + it.effect( + "reads the word after a bare --remote as the positional language once it names no branch", + () => + Effect.gen(function* () { + const { layer, api, generator } = yield* setup({ + args: ["gen", "types", "--remote", "feature-x"], + projectId: Option.some(VALID_REF), + branches: [branchRow("other")], + }); + const exit = yield* genTypes( + defaultFlags({ remote: Option.some({ _tag: "Linked" } as const) }), + ).pipe(Effect.provide(layer), Effect.exit); + + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(String(exit.cause)).toContain("use --lang flag to specify the typegen language"); + } + expect(api.requests.map(({ method }) => method)).toEqual(["listAllBranches"]); + expect(generator.calls).toHaveLength(0); + }).pipe(Effect.provide(BunServices.layer)), + ); + it.effect("prefers the positional language guard over mutex group errors", () => Effect.gen(function* () { const { layer } = yield* setup({ diff --git a/apps/cli/src/shared/cli/remote-argv.unit.test.ts b/apps/cli/src/shared/cli/remote-argv.unit.test.ts index 60abcc25cf..121b1fa35a 100644 --- a/apps/cli/src/shared/cli/remote-argv.unit.test.ts +++ b/apps/cli/src/shared/cli/remote-argv.unit.test.ts @@ -246,6 +246,16 @@ describe("prepareCliArgs argv", () => { args: ["seed", "--remote", "buckets"], expected: ["seed", "--remote", "buckets"], }, + { + name: "leaves a branch-shaped word for gen types's legacy language operand", + args: ["gen", "types", "--remote", "feature-x"], + expected: ["gen", "types", "--remote", "feature-x"], + }, + { + name: "joins a ref for gen types despite its legacy language operand", + args: ["gen", "types", "--remote", REF], + expected: ["gen", "types", `--remote=${REF}`], + }, { name: "leaves commands without a remote flag alone", args: ["link", "--remote", "feature-x"], @@ -278,6 +288,16 @@ describe("prepareCliArgs bareRemoteOperand on the real command tree", () => { expect(operandOf(["db", "pull", "--remote", "feature-x"])).toBe("feature-x"); }); + it("reads gen types's legacy language operand, which may be a branch", () => { + expect(operandOf(["gen", "types", "--remote", "feature-x"])).toBe("feature-x"); + }); + + it("lists gen types's legacy language operand among its placeholders", () => { + expect(prepareCliArgs(root, ["gen", "types", "--remote"]).operandPlaceholders?.()).toEqual([ + "<language>", + ]); + }); + it("finds nothing once every word is joined into --remote=<word>", () => { expect(operandOf(["db", "push", "--remote", "feature-x"])).toBeUndefined(); }); From 3219fd4141253876c4a0dc806b13a501830b9749 Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Mon, 19 Oct 2026 17:10:01 +0100 Subject: [PATCH 168/291] fix(cli): name the listing failure's cause and report an unlinked branch UUID as unverified The bare --remote ambiguity message no longer echoes the word, names a missing or invalid access token, and the branch lookups declare required permissions that match when they run (a new uuid selector shape covers GET /v1/branches/{id}). A branch UUID resolved without a linked project is announced as the bare project, environment unverified, since the lookup carries no default-branch flag. --- apps/cli/docs/command-target.md | 23 ++--- .../command-permissions/model.ts | 4 +- .../src/command-internal/db-config.layer.ts | 2 +- .../target/branch-resolution.ts | 19 ++-- .../target/remote-resolution.ts | 64 ++++++++++--- ...resolve-project-target.integration.test.ts | 42 +++++++-- .../target/resolve-target.integration.test.ts | 94 ++++++++++++++++++- .../command-internal/target/target.errors.ts | 12 ++- .../config/push/push.integration.test.ts | 4 +- apps/cli/src/commands/db/pull/SIDE_EFFECTS.md | 3 +- .../cli/src/commands/db/query/SIDE_EFFECTS.md | 3 +- .../compute/delete/SIDE_EFFECTS.md | 2 +- .../experimental/compute/logs/SIDE_EFFECTS.md | 2 +- .../experimental/compute/push/SIDE_EFFECTS.md | 2 +- .../compute/status/SIDE_EFFECTS.md | 2 +- .../commands/functions/delete/SIDE_EFFECTS.md | 2 +- .../commands/functions/deploy/SIDE_EFFECTS.md | 3 +- .../functions/download/SIDE_EFFECTS.md | 2 +- .../commands/migration/repair/SIDE_EFFECTS.md | 2 +- .../commands/notebooks/pull/SIDE_EFFECTS.md | 3 +- .../commands/notebooks/push/SIDE_EFFECTS.md | 3 +- .../src/commands/secrets/set/SIDE_EFFECTS.md | 3 +- .../commands/secrets/unset/SIDE_EFFECTS.md | 3 +- .../snippets/download/SIDE_EFFECTS.md | 3 +- .../src/commands/sso/remove/SIDE_EFFECTS.md | 3 +- .../cli/src/commands/sso/show/SIDE_EFFECTS.md | 3 +- .../src/commands/sso/update/SIDE_EFFECTS.md | 3 +- .../src/commands/storage/cp/SIDE_EFFECTS.md | 3 +- .../src/commands/storage/ls/SIDE_EFFECTS.md | 3 +- .../src/commands/storage/mv/SIDE_EFFECTS.md | 3 +- .../src/commands/storage/rm/SIDE_EFFECTS.md | 3 +- apps/cli/src/commands/test/db/SIDE_EFFECTS.md | 3 +- apps/cli/tests/helpers/permission-registry.ts | 57 ++++++----- .../helpers/permission-registry.unit.test.ts | 7 ++ ...t-selection-and-hosted-mutation-consent.md | 10 +- 35 files changed, 284 insertions(+), 116 deletions(-) diff --git a/apps/cli/docs/command-target.md b/apps/cli/docs/command-target.md index 8e9a3bb50a..863ce00999 100644 --- a/apps/cli/docs/command-target.md +++ b/apps/cli/docs/command-target.md @@ -8,16 +8,17 @@ Database commands run against the local stack unless `--remote` is passed: bare `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 --remote for <name> (<ref>)` | -| `--remote=<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 name> (<parent ref>) · branch <name> (<ref>)`, or `branch <ref>` for a UUID | -| The project's default branch | `project <name> (<ref>) · production` | +| Target | Text | +| ------------------------------------- | ----------------------------------------------------------------------------------- | +| Local stack | `local stack (<host>:<port>)` | +| Local stack, nothing selected, linked | `local stack (<host>:<port>) · default; pass --remote for <name> (<ref>)` | +| `--remote=<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 name> (<parent ref>) · branch <name> (<ref>)` | +| The project's default branch | `project <name> (<ref>) · production` | +| Branch UUID, no linked project | `project <ref>`: the lookup does not say whether it is the default branch | The `default; pass --remote` hint appears only when a project is linked, by `SUPABASE_PROJECT_ID` or the link file, and no `--remote` was passed. "Branch" is claimed only on positive evidence: a branch lookup by name or UUID, or a live probe. A cached link never proves one. A branch's parent shows as its bare ref when the cache doesn't know its name. @@ -31,7 +32,7 @@ The `default; pass --remote` hint appears only when a project is linked, by `SUP | `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 or the resolved branch is the project's default branch, `branch` on positive evidence of any other branch, and `unverified` otherwise. A failed identity check is `unverified`, never `branch`. +`environment` is `production` when the linked-project cache names the resolved ref or the resolved branch is the project's default branch, `branch` on positive evidence of any other branch, and `unverified` otherwise, including a branch UUID looked up with no linked project. 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. diff --git a/apps/cli/src/command-internal/command-permissions/model.ts b/apps/cli/src/command-internal/command-permissions/model.ts index 74d924b7a4..882d31464c 100644 --- a/apps/cli/src/command-internal/command-permissions/model.ts +++ b/apps/cli/src/command-internal/command-permissions/model.ts @@ -71,8 +71,8 @@ interface DbTargetCondition { readonly oneOf?: never; } -/** What the target selector carries once the deprecated aliases are folded in; `bare` is `--remote` or `--linked` with no value. */ -export type SelectorShape = "none" | "bare" | "ref" | "branch" | "url"; +/** What the target selector carries once the deprecated aliases are folded in; `bare` is `--remote` or `--linked` with no value, `uuid` a branch's UUID. */ +export type SelectorShape = "none" | "bare" | "ref" | "branch" | "uuid" | "url"; /** Holds when the selector's shape is one of `selector`, whichever flag spelled it. */ interface SelectorCondition { diff --git a/apps/cli/src/command-internal/db-config.layer.ts b/apps/cli/src/command-internal/db-config.layer.ts index 444bdd4f5d..59b554a69a 100644 --- a/apps/cli/src/command-internal/db-config.layer.ts +++ b/apps/cli/src/command-internal/db-config.layer.ts @@ -84,7 +84,7 @@ const networkBanEntries = [ const linkedEntries: ReadonlyArray<Omit<OperationEntry, "source">> = [ loginRoleEntry, - { ...poolerConfigEntry, when: [{ selector: ["ref", "branch"] }] }, + { ...poolerConfigEntry, when: [{ selector: ["ref", "branch", "uuid"] }] }, ...networkBanEntries, ...projectRefPicker.operations, ]; diff --git a/apps/cli/src/command-internal/target/branch-resolution.ts b/apps/cli/src/command-internal/target/branch-resolution.ts index 49d8b800cf..2066a07e2e 100644 --- a/apps/cli/src/command-internal/target/branch-resolution.ts +++ b/apps/cli/src/command-internal/target/branch-resolution.ts @@ -40,20 +40,21 @@ export const branchResolution = block("branch-resolution", [ { operationId: "v1-list-all-branches", kind: "required", - when: [{ selector: ["branch"] }], + when: [{ selector: ["branch", "uuid"] }], context: "a project is linked, or the value is a branch name", }, { operationId: "v1-get-a-branch-config", kind: "required", - when: [{ selector: ["branch"] }], - context: "the value is a branch UUID and no project is linked", + when: [{ selector: ["uuid"] }], + context: "no linked project resolves, so the branch is looked up by its UUID", }, { operationId: "v1-list-all-branches", - kind: "best-effort", + kind: "required", when: [{ selector: ["bare"] }], - context: "a word after the bare --remote could name a branch of the linked project", + context: + "a bare --remote is followed by an operand that could name a branch of the linked project", }, ]); @@ -229,7 +230,13 @@ const resolveBranchById = Effect.fnUntraced(function* (id: string) { message: `Branch "${sanitizeInlineName(id)}" has no project ref yet (status: ${detail.status}). Wait for it to finish provisioning, then retry.`, }); } - return { projectRef: detail.ref, branchName: undefined, parentRef: undefined, isDefault: false }; + return { + projectRef: detail.ref, + branchName: undefined, + parentRef: undefined, + // `GET /v1/branches/{id}` does not say whether this is the production branch. + isDefault: undefined, + }; }); /** diff --git a/apps/cli/src/command-internal/target/remote-resolution.ts b/apps/cli/src/command-internal/target/remote-resolution.ts index 74e9defa89..be9313da02 100644 --- a/apps/cli/src/command-internal/target/remote-resolution.ts +++ b/apps/cli/src/command-internal/target/remote-resolution.ts @@ -1,5 +1,6 @@ import { Effect, FileSystem, Option, Path, Result } from "effect"; +import { AccessTokenRequiredError, InvalidAccessTokenError } from "../../auth/errors.ts"; import { CommandSettings } from "../../config/command-settings.service.ts"; import { ProjectRefResolver, type ResolvedProjectRef } from "../../config/project-ref.service.ts"; import { CliArgs } from "../../shared/cli/cli-args.service.ts"; @@ -25,7 +26,7 @@ import { remoteFlagText, shellQuote, } from "./target-deprecation.ts"; -import { renderBranchTarget } from "./target-identity.ts"; +import { describeLinkedTarget, renderBranchTarget } from "./target-identity.ts"; import { parseTargetSelector, type TargetDeprecation, @@ -95,13 +96,43 @@ const linkedProjectName = Effect.fnUntraced(function* (ref: string) { const LOGIN_SUGGESTION = "Run `supabase login` with an account that can list this project's branches."; -const listingFailure = (error: unknown) => { - const status = error instanceof LinkBranchListStatusError ? error.status : undefined; - return { - status, - reason: status === undefined ? "" : ` with HTTP ${status}`, - suggestion: status === 401 || status === 403 ? LOGIN_SUGGESTION : undefined, - }; +interface ListingFailure { + readonly reason: string; + readonly suggestion?: string | undefined; + readonly status?: number; + readonly authFailure?: "login" | "token"; +} + +const listingFailure = (error: unknown): ListingFailure => { + if (error instanceof LinkBranchListStatusError) { + const { status } = error; + return { + status, + reason: ` with HTTP ${status}`, + suggestion: status === 401 || status === 403 ? LOGIN_SUGGESTION : undefined, + }; + } + if (error instanceof AccessTokenRequiredError) { + return { + reason: " because no access token is set", + suggestion: LOGIN_SUGGESTION, + authFailure: "login", + }; + } + if (error instanceof InvalidAccessTokenError) { + return error.source === "env" + ? { + reason: " because SUPABASE_ACCESS_TOKEN is not a valid access token", + suggestion: "Set SUPABASE_ACCESS_TOKEN to a valid access token.", + authFailure: "token", + } + : { + reason: " because the stored access token is not valid", + suggestion: LOGIN_SUGGESTION, + authFailure: "login", + }; + } + return { reason: "" }; }; const checkRemoteOperand = Effect.fn("Target.checkRemoteOperand")(function* (rawOperand: string) { @@ -115,11 +146,14 @@ const checkRemoteOperand = Effect.fn("Target.checkRemoteOperand")(function* (raw const listed = yield* listParentBranches(parent.ref, "Checking branches...").pipe(Effect.result); if (Result.isFailure(listed)) { - const { status, reason, suggestion } = listingFailure(listed.failure); + const failure = listingFailure(listed.failure); + const cliArgs = Option.getOrUndefined(yield* Effect.serviceOption(CliArgs)); + const operands = cliArgs?.operandPlaceholders?.() ?? []; return yield* new TargetRemoteAmbiguousError({ - message: `Can't tell whether "${operand}" is a branch (listing the branches of project ${parent.ref} failed${reason}). ${options}`, - ...(suggestion === undefined ? {} : { suggestion }), - ...(status === undefined ? {} : { status }), + message: `Can't tell whether the word after --remote is a branch (listing the branches of project ${parent.ref} failed${failure.reason}). For the branch: ${usage} --remote=<branch>. For the linked project: ${[usage, ...operands, "--remote"].join(" ")}`, + ...(failure.suggestion === undefined ? {} : { suggestion: failure.suggestion }), + ...(failure.status === undefined ? {} : { status: failure.status }), + ...(failure.authFailure === undefined ? {} : { authFailure: failure.authFailure }), cause: listed.failure, }); } @@ -162,6 +196,12 @@ const resolveRemoteBranch = Effect.fnUntraced(function* (name: string) { const found = yield* resolveRemoteBranchRef(name); const cell = yield* Effect.serviceOption(CommandTarget); if (Option.isSome(cell)) { + if (found.isDefault === undefined) { + yield* cell.value.announce( + describeLinkedTarget({ ref: found.projectRef, cache: Option.none() }), + ); + return found; + } const nameOwner = found.isDefault ? found.projectRef : found.parentRef; yield* cell.value.announce( renderBranchTarget({ diff --git a/apps/cli/src/command-internal/target/resolve-project-target.integration.test.ts b/apps/cli/src/command-internal/target/resolve-project-target.integration.test.ts index 68abd7b358..0048cf2ec2 100644 --- a/apps/cli/src/command-internal/target/resolve-project-target.integration.test.ts +++ b/apps/cli/src/command-internal/target/resolve-project-target.integration.test.ts @@ -15,6 +15,10 @@ import { rootCommandForFeatures } from "../../cli/root.ts"; import { CliArgs } from "../../shared/cli/cli-args.service.ts"; import { prepareCliArgs } from "../../shared/cli/run.ts"; import { commandTargetLayer } from "../../shared/output/command-target.layer.ts"; +import { + CommandTarget, + type CommandTargetJson, +} from "../../shared/output/command-target.service.ts"; import { commandRuntimeLayer } from "../../shared/runtime/command-runtime.layer.ts"; import type { RemoteValue } from "./remote-value.ts"; import { resolveProjectTarget } from "./resolve-project-target.ts"; @@ -111,10 +115,20 @@ const runWith = <A, E, R>( return Effect.succeed(jsonResponse(request, 200, options.branches ?? [])); }, }); + let announced: Option.Option<CommandTargetJson> = Option.none(); const exit = yield* resolve({ remote: Option.fromNullishOr(input.remote), projectRef: Option.fromNullishOr(input.projectRef), }).pipe( + Effect.tap(() => + CommandTarget.use((cell) => cell.json).pipe( + Effect.tap((json) => + Effect.sync(() => { + announced = json; + }), + ), + ), + ), Effect.provide( Layer.mergeAll( commandTargetLayer.pipe(Layer.provide(out.layer)), @@ -134,6 +148,7 @@ const runWith = <A, E, R>( exit, stderr: out.stderrText, stdout: out.stdoutText, + target: announced, warnings: stripAnsi(out.stderrText) .split("\n") .filter((line) => line.startsWith("Warning: ")) @@ -300,16 +315,23 @@ describe("resolveProjectTarget", () => { }), ); - it.effect("resolves a branch UUID through GET /v1/branches/{id} when no project is linked", () => - Effect.gen(function* () { - const result = yield* run( - { remote: { _tag: "Branch", name: BRANCH_UUID } }, - { branchById: { status: 200, body: branchConfig(BRANCH_REF, "ACTIVE_HEALTHY") } }, - ); - expect(refOf(result.exit)).toBe(BRANCH_REF); - expect(result.requestedUrls).toEqual([`https://api.supabase.com/v1/branches/${BRANCH_UUID}`]); - expect(result.stderr).toContain(`branch ${BRANCH_REF}`); - }), + it.effect( + "resolves a branch UUID through GET /v1/branches/{id} when no project is linked, as an unverified project", + () => + Effect.gen(function* () { + const result = yield* run( + { remote: { _tag: "Branch", name: BRANCH_UUID } }, + { branchById: { status: 200, body: branchConfig(BRANCH_REF, "ACTIVE_HEALTHY") } }, + ); + expect(refOf(result.exit)).toBe(BRANCH_REF); + expect(result.requestedUrls).toEqual([ + `https://api.supabase.com/v1/branches/${BRANCH_UUID}`, + ]); + expect(result.stderr).toBe(`Target: project ${BRANCH_REF}\n`); + expect(result.target).toEqual( + Option.some({ kind: "linked", project_ref: BRANCH_REF, environment: "unverified" }), + ); + }), ); it.effect("reports an unknown branch UUID without a linked project", () => 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 aa80f3f65e..4e2fbedbbb 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 @@ -10,6 +10,8 @@ import { } from "../../../tests/helpers/command-mocks.ts"; import { stripAnsi } from "../../../tests/helpers/ansi.ts"; import { mockOutput } from "../../../tests/helpers/mocks.ts"; +import { AccessTokenRequiredError, InvalidAccessTokenError } from "../../auth/errors.ts"; +import { CommandPlatformApiFactory } from "../../auth/command-platform-api-factory.service.ts"; import { rootCommandForFeatures } from "../../cli/root.ts"; import { ProjectRefNotLinkedError } from "../../config/project-ref.errors.ts"; import { ProjectRefResolver } from "../../config/project-ref.service.ts"; @@ -20,6 +22,7 @@ import { commandRuntimeLayer } from "../../shared/runtime/command-runtime.layer. 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 { actionability, ErrorActionabilityId } from "../../shared/telemetry/error-actionability.ts"; import type { RemoteValue } from "./remote-value.ts"; import { resolveTarget } from "./resolve-target.ts"; import { renderLocalTarget } from "./target-identity.ts"; @@ -94,6 +97,7 @@ const run = ( readonly args?: ReadonlyArray<string>; readonly command?: ReadonlyArray<string>; readonly format?: OutputFormat; + readonly tokenFailure?: AccessTokenRequiredError | InvalidAccessTokenError; readonly branches?: | ReadonlyArray<ReturnType<typeof branchRow>> | "unavailable" @@ -148,7 +152,9 @@ const run = ( commandTargetLayer.pipe(Layer.provide(out.layer)), out.layer, resolver.layer, - api.layer, + options.tokenFailure === undefined + ? api.layer + : Layer.succeed(CommandPlatformApiFactory, { make: Effect.fail(options.tokenFailure) }), mockCommandSettings({ workdir: dir, projectId: options.projectId ?? Option.none(), @@ -393,6 +399,7 @@ describe("resolveTarget", () => { const command = ["db", "pull"]; const bare = { ...none, ...remote({ _tag: "Linked" }) }; const options = `For the branch: supabase db pull --remote=feature-x. For the linked project: supabase db pull feature-x --remote`; + const placeholderOptions = `For the branch: supabase db pull --remote=<branch>. For the linked project: supabase db pull <migration name> --remote`; it.effect("fails when the word is a branch of the linked project", () => Effect.gen(function* () { @@ -447,7 +454,7 @@ describe("resolveTarget", () => { const error = failureOf(result.exit); expect(error).toMatchObject({ _tag: "TargetRemoteAmbiguousError", - message: `Can't tell whether "feature-x" is a branch (listing the branches of project ${FILE_REF} failed with HTTP 500). ${options}`, + message: `Can't tell whether the word after --remote is a branch (listing the branches of project ${FILE_REF} failed with HTTP 500). ${placeholderOptions}`, status: 500, cause: { _tag: "LinkBranchListStatusError", status: 500 }, }); @@ -468,13 +475,94 @@ describe("resolveTarget", () => { }); expect(failureOf(result.exit)).toMatchObject({ _tag: "TargetRemoteAmbiguousError", - message: `Can't tell whether "feature-x" is a branch (listing the branches of project ${FILE_REF} failed with HTTP ${status}). ${options}`, + message: `Can't tell whether the word after --remote is a branch (listing the branches of project ${FILE_REF} failed with HTTP ${status}). ${placeholderOptions}`, suggestion: "Run `supabase login` with an account that can list this project's branches.", status, }); }), ); + it.effect.each([ + { + name: "no access token", + failure: new AccessTokenRequiredError({ message: "Access token not provided." }), + reason: "no access token is set", + suggestion: "Run `supabase login` with an account that can list this project's branches.", + actionability: actionability.authLogin, + }, + { + name: "a malformed stored access token", + failure: new InvalidAccessTokenError({ message: "Invalid access token format." }), + reason: "the stored access token is not valid", + suggestion: "Run `supabase login` with an account that can list this project's branches.", + actionability: actionability.authLogin, + }, + { + name: "a malformed SUPABASE_ACCESS_TOKEN", + failure: new InvalidAccessTokenError({ + message: "Invalid access token format.", + source: "env", + }), + reason: "SUPABASE_ACCESS_TOKEN is not a valid access token", + suggestion: "Set SUPABASE_ACCESS_TOKEN to a valid access token.", + actionability: actionability.authToken, + }, + ])("names the cause and how to fix it when the listing has $name", (testCase) => + Effect.gen(function* () { + const result = yield* run("selectable", bare, { + args, + command, + savedRef: FILE_REF, + tokenFailure: testCase.failure, + }); + const error = failureOf(result.exit); + expect(error).toMatchObject({ + _tag: "TargetRemoteAmbiguousError", + message: `Can't tell whether the word after --remote is a branch (listing the branches of project ${FILE_REF} failed because ${testCase.reason}). ${placeholderOptions}`, + suggestion: testCase.suggestion, + cause: testCase.failure, + }); + expect(error).not.toHaveProperty("status"); + expect((error as { [ErrorActionabilityId]: unknown })[ErrorActionabilityId]).toEqual( + testCase.actionability, + ); + }), + ); + + it.effect.each(["text", "json"] as const)( + "never prints a NAME=value pair after --remote when the listing is forbidden (%s)", + (format) => + Effect.gen(function* () { + const result = yield* run("selectable", bare, { + args: ["secrets", "set", "--remote", "STRIPE_KEY=sk_live_secret"], + command: ["secrets", "set"], + format, + savedRef: FILE_REF, + branches: "forbidden", + }); + expect(succeeded(result.exit)).toMatchObject({ kind: "linked", ref: FILE_REF }); + expect(result.apiRequests).toEqual([]); + expect(result.stdout + result.stderr).not.toContain("sk_live_secret"); + }), + ); + + it.effect("never echoes a word it could not check against the branches", () => + Effect.gen(function* () { + const result = yield* run("selectable", bare, { + args: ["secrets", "unset", "--remote", "sk_live_secret"], + command: ["secrets", "unset"], + savedRef: FILE_REF, + branches: "forbidden", + }); + const error = failureOf(result.exit); + expect(error).toMatchObject({ + _tag: "TargetRemoteAmbiguousError", + message: `Can't tell whether the word after --remote is a branch (listing the branches of project ${FILE_REF} failed with HTTP 403). For the branch: supabase secrets unset --remote=<branch>. For the linked project: supabase secrets unset <NAME>... --remote`, + }); + expect(JSON.stringify(error)).not.toContain("sk_live_secret"); + }), + ); + it.effect("fails for a word one edit from a branch, naming the closest branch", () => Effect.gen(function* () { const result = yield* run("selectable", bare, { diff --git a/apps/cli/src/command-internal/target/target.errors.ts b/apps/cli/src/command-internal/target/target.errors.ts index 4c59b447b2..567b867d18 100644 --- a/apps/cli/src/command-internal/target/target.errors.ts +++ b/apps/cli/src/command-internal/target/target.errors.ts @@ -64,18 +64,22 @@ export class TargetNeedsProjectError extends Data.TaggedError("TargetNeedsProjec /** * A bare `--remote` is followed by a word that names a branch, nearly names one, or could not be - * checked because the branch listing failed; `status` is that failure's HTTP status, when it had one. + * checked because the branch listing failed; `status` is that failure's HTTP status, when it had + * one, and `authFailure` says the access token was missing or invalid (`token` when only the + * environment variable can fix it). */ export class TargetRemoteAmbiguousError extends Data.TaggedError("TargetRemoteAmbiguousError")<{ readonly message: string; readonly suggestion?: string; readonly status?: number; + readonly authFailure?: "login" | "token"; readonly cause?: unknown; }> { get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { - return this.status === 401 || this.status === 403 - ? statusCodeActionability(this.status) - : actionability.provideFlags; + if (this.status === 401 || this.status === 403) return statusCodeActionability(this.status); + if (this.authFailure === "login") return actionability.authLogin; + if (this.authFailure === "token") return actionability.authToken; + return actionability.provideFlags; } } 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 e9451c8442..0a29bdd80f 100644 --- a/apps/cli/src/commands/config/push/push.integration.test.ts +++ b/apps/cli/src/commands/config/push/push.integration.test.ts @@ -3502,7 +3502,7 @@ describe("config push --remote branch name/UUID resolution (CLI-2289)", () => { return Effect.gen(function* () { const exit = yield* configPush(pushRemoteBranch(BRANCH_UUID)).pipe(Effect.exit); expect(Exit.isSuccess(exit)).toBe(true); - expect(out.stderrText).toContain(`Target: branch ${UUID_TARGET_REF}\n`); + expect(out.stderrText).toContain(`Target: project ${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); @@ -3529,7 +3529,7 @@ describe("config push --remote branch name/UUID resolution (CLI-2289)", () => { return Effect.gen(function* () { const error = yield* configPush(pushRemoteBranch(BRANCH_UUID)).pipe(Effect.flip); expect(error).toBeInstanceOf(HostedMutationConsentRequiredError); - expect(out.stderrText).toContain(`Target: branch ${UUID_TARGET_REF}`); + expect(out.stderrText).toContain(`Target: project ${UUID_TARGET_REF}`); expect(api.requests.some((r) => r.method === "PATCH")).toBe(false); }).pipe(Effect.provide(layer)); }); diff --git a/apps/cli/src/commands/db/pull/SIDE_EFFECTS.md b/apps/cli/src/commands/db/pull/SIDE_EFFECTS.md index c1c2c5cd59..cc749dc2cf 100644 --- a/apps/cli/src/commands/db/pull/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/db/pull/SIDE_EFFECTS.md @@ -216,8 +216,7 @@ resolved `target` object is merged into the payload. `--remote=false` fails with `--remote doesn't take false. Leave it out to target the local database.`, and `--remote=` with `--remote= has an empty value. Pass --remote for the linked project or --remote=<ref|branch|url>; leave it out to target the local database.` A bare `--remote` followed by a word that exactly matches a branch of the linked project, is within a small edit distance of one, or can't be checked because the branch listing failed stops the run - with an error that offers both readings: `--remote=<word>` for the branch, `<word> --remote` for - the operand. Any other word runs as the operand. + with an error that names the cause (an HTTP status, or a missing or invalid access token) and offers both readings without echoing the word: `--remote=<branch>` for the branch, the command's operands then `--remote` for the linked project. Any other word runs as the operand. - **Deprecated aliases.** `--linked`, `--project-ref=<ref>`, `--db-url=<url>` and `--local` still work and print a deprecation warning on stderr naming the `--remote` replacement (`--local` warns that local is the default). They are mutually exclusive with each other and with diff --git a/apps/cli/src/commands/db/query/SIDE_EFFECTS.md b/apps/cli/src/commands/db/query/SIDE_EFFECTS.md index 47f7103da9..3c892c94c3 100644 --- a/apps/cli/src/commands/db/query/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/db/query/SIDE_EFFECTS.md @@ -108,8 +108,7 @@ from the environment. Agent mode defaults the format to JSON (table for humans). `--remote=false` fails with `--remote doesn't take false. Leave it out to target the local database.`, and `--remote=` with `--remote= has an empty value. Pass --remote for the linked project or --remote=<ref|branch|url>; leave it out to target the local database.` A bare `--remote` followed by a word that exactly matches a branch of the linked project, is within a small edit distance of one, or can't be checked because the branch listing failed stops the run - with an error that offers both readings: `--remote=<word>` for the branch, `<word> --remote` for - the operand. Any other word runs as the operand. + with an error that names the cause (an HTTP status, or a missing or invalid access token) and offers both readings without echoing the word: `--remote=<branch>` for the branch, the command's operands then `--remote` for the linked project. Any other word runs as the operand. - **Deprecated aliases.** `--linked`, `--project-ref=<ref>`, `--db-url=<url>` and `--local` still work and print a deprecation warning on stderr naming the `--remote` replacement (`--local` warns that local is the default). They are mutually exclusive with each other and with 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 8227a856ff..6dbadb8706 100644 --- a/apps/cli/src/commands/experimental/compute/delete/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/experimental/compute/delete/SIDE_EFFECTS.md @@ -111,5 +111,5 @@ fall through to that same text branch. ## Notes - Targets the linked project unless `--remote=<ref>` is given; a bare `--remote` also targets the linked project. `--remote=<value>` is read as a project ref (20 lowercase letters), a branch of the linked project (by name or UUID, resolved through `GET /v1/projects/{parent}/branches`, or `GET /v1/branches/{id}` for a UUID when no project is linked), or `true|yes|on|1` for the linked project. A database URL is rejected (`supabase experimental compute delete` takes a project, not a database); `false|no|off|0` and `--no-remote` fail with `--remote doesn't take false. Leave it out to use the linked project.`, and an empty value with `--remote= has an empty value. Pass --remote for the linked project or --remote=<ref|branch>; leave it out to use the linked project.` -- A bare `--remote` followed by a word that exactly matches a branch of the linked project, is within a small edit distance of one, or can't be checked because the branch listing failed stops the run before any other work with an error that offers both readings: `--remote=<word>` for the branch, `<word> --remote` for the operand. Any other word runs as the operand. +- A bare `--remote` followed by a word that exactly matches a branch of the linked project, is within a small edit distance of one, or can't be checked because the branch listing failed stops the run before any other work with an error that names the cause (an HTTP status, or a missing or invalid access token) and offers both readings without echoing the word: `--remote=<branch>` for the branch, the command's operands then `--remote` for the linked project. Any other word runs as the operand. - The deprecated `--project-ref` is an alias of `--remote=<ref>` and prints a deprecation warning on stderr that names the replacement; it cannot be combined with `--remote=<value>`. An empty `--project-ref` is rejected. diff --git a/apps/cli/src/commands/experimental/compute/logs/SIDE_EFFECTS.md b/apps/cli/src/commands/experimental/compute/logs/SIDE_EFFECTS.md index 052c0bad68..eb76f09862 100644 --- a/apps/cli/src/commands/experimental/compute/logs/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/experimental/compute/logs/SIDE_EFFECTS.md @@ -171,5 +171,5 @@ preserved so a stack trace survives intact. ## Notes - Targets the linked project unless `--remote=<ref>` is given; a bare `--remote` also targets the linked project. `--remote=<value>` is read as a project ref (20 lowercase letters), a branch of the linked project (by name or UUID, resolved through `GET /v1/projects/{parent}/branches`, or `GET /v1/branches/{id}` for a UUID when no project is linked), or `true|yes|on|1` for the linked project. A database URL is rejected (`supabase experimental compute logs` takes a project, not a database); `false|no|off|0` and `--no-remote` fail with `--remote doesn't take false. Leave it out to use the linked project.`, and an empty value with `--remote= has an empty value. Pass --remote for the linked project or --remote=<ref|branch>; leave it out to use the linked project.` -- A bare `--remote` followed by a word that exactly matches a branch of the linked project, is within a small edit distance of one, or can't be checked because the branch listing failed stops the run before any other work with an error that offers both readings: `--remote=<word>` for the branch, `<word> --remote` for the operand. Any other word runs as the operand. +- A bare `--remote` followed by a word that exactly matches a branch of the linked project, is within a small edit distance of one, or can't be checked because the branch listing failed stops the run before any other work with an error that names the cause (an HTTP status, or a missing or invalid access token) and offers both readings without echoing the word: `--remote=<branch>` for the branch, the command's operands then `--remote` for the linked project. Any other word runs as the operand. - The deprecated `--project-ref` is an alias of `--remote=<ref>` and prints a deprecation warning on stderr that names the replacement; it cannot be combined with `--remote=<value>`. An empty `--project-ref` is rejected. 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 238237fe1a..a2a44a850c 100644 --- a/apps/cli/src/commands/experimental/compute/push/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/experimental/compute/push/SIDE_EFFECTS.md @@ -195,5 +195,5 @@ strings that carry a signature. ## Notes - Targets the linked project unless `--remote=<ref>` is given; a bare `--remote` also targets the linked project. `--remote=<value>` is read as a project ref (20 lowercase letters), a branch of the linked project (by name or UUID, resolved through `GET /v1/projects/{parent}/branches`, or `GET /v1/branches/{id}` for a UUID when no project is linked), or `true|yes|on|1` for the linked project. A database URL is rejected (`supabase experimental compute push` takes a project, not a database); `false|no|off|0` and `--no-remote` fail with `--remote doesn't take false. Leave it out to use the linked project.`, and an empty value with `--remote= has an empty value. Pass --remote for the linked project or --remote=<ref|branch>; leave it out to use the linked project.` -- A bare `--remote` followed by a word that exactly matches a branch of the linked project, is within a small edit distance of one, or can't be checked because the branch listing failed stops the run before any other work with an error that offers both readings: `--remote=<word>` for the branch, `<word> --remote` for the operand. Any other word runs as the operand. +- A bare `--remote` followed by a word that exactly matches a branch of the linked project, is within a small edit distance of one, or can't be checked because the branch listing failed stops the run before any other work with an error that names the cause (an HTTP status, or a missing or invalid access token) and offers both readings without echoing the word: `--remote=<branch>` for the branch, the command's operands then `--remote` for the linked project. Any other word runs as the operand. - The deprecated `--project-ref` is an alias of `--remote=<ref>` and prints a deprecation warning on stderr that names the replacement; it cannot be combined with `--remote=<value>`. An empty `--project-ref` is rejected. diff --git a/apps/cli/src/commands/experimental/compute/status/SIDE_EFFECTS.md b/apps/cli/src/commands/experimental/compute/status/SIDE_EFFECTS.md index d1cb573c4c..b0788d8306 100644 --- a/apps/cli/src/commands/experimental/compute/status/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/experimental/compute/status/SIDE_EFFECTS.md @@ -92,5 +92,5 @@ fall through to that same text branch. ## Notes - Targets the linked project unless `--remote=<ref>` is given; a bare `--remote` also targets the linked project. `--remote=<value>` is read as a project ref (20 lowercase letters), a branch of the linked project (by name or UUID, resolved through `GET /v1/projects/{parent}/branches`, or `GET /v1/branches/{id}` for a UUID when no project is linked), or `true|yes|on|1` for the linked project. A database URL is rejected (`supabase experimental compute status` takes a project, not a database); `false|no|off|0` and `--no-remote` fail with `--remote doesn't take false. Leave it out to use the linked project.`, and an empty value with `--remote= has an empty value. Pass --remote for the linked project or --remote=<ref|branch>; leave it out to use the linked project.` -- A bare `--remote` followed by a word that exactly matches a branch of the linked project, is within a small edit distance of one, or can't be checked because the branch listing failed stops the run before any other work with an error that offers both readings: `--remote=<word>` for the branch, `<word> --remote` for the operand. Any other word runs as the operand. +- A bare `--remote` followed by a word that exactly matches a branch of the linked project, is within a small edit distance of one, or can't be checked because the branch listing failed stops the run before any other work with an error that names the cause (an HTTP status, or a missing or invalid access token) and offers both readings without echoing the word: `--remote=<branch>` for the branch, the command's operands then `--remote` for the linked project. Any other word runs as the operand. - The deprecated `--project-ref` is an alias of `--remote=<ref>` and prints a deprecation warning on stderr that names the replacement; it cannot be combined with `--remote=<value>`. An empty `--project-ref` is rejected. diff --git a/apps/cli/src/commands/functions/delete/SIDE_EFFECTS.md b/apps/cli/src/commands/functions/delete/SIDE_EFFECTS.md index b2e3fa7e96..0a9b8dcc88 100644 --- a/apps/cli/src/commands/functions/delete/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/functions/delete/SIDE_EFFECTS.md @@ -73,7 +73,7 @@ Prints the same structured success result, including `target`, and needs `--yes` ## Notes - Targets the linked project unless `--remote=<ref>` is given; a bare `--remote` also targets the linked project. `--remote=<value>` is read as a project ref (20 lowercase letters), a branch of the linked project (by name or UUID, resolved through `GET /v1/projects/{parent}/branches`, or `GET /v1/branches/{id}` for a UUID when no project is linked), or `true|yes|on|1` for the linked project. A database URL is rejected (`supabase functions delete` takes a project, not a database); `false|no|off|0` and `--no-remote` fail with `--remote doesn't take false. Leave it out to use the linked project.`, and an empty value with `--remote= has an empty value. Pass --remote for the linked project or --remote=<ref|branch>; leave it out to use the linked project.` -- A bare `--remote` followed by a word that exactly matches a branch of the linked project, is within a small edit distance of one, or can't be checked because the branch listing failed stops the run before any other work with an error that offers both readings: `--remote=<word>` for the branch, `<word> --remote` for the operand. Any other word runs as the operand. +- A bare `--remote` followed by a word that exactly matches a branch of the linked project, is within a small edit distance of one, or can't be checked because the branch listing failed stops the run before any other work with an error that names the cause (an HTTP status, or a missing or invalid access token) and offers both readings without echoing the word: `--remote=<branch>` for the branch, the command's operands then `--remote` for the linked project. Any other word runs as the operand. - The deprecated `--project-ref` is an alias of `--remote=<ref>` and prints a deprecation warning on stderr that names the replacement; it cannot be combined with `--remote=<value>`. An empty `--project-ref` is rejected. - Requires exactly one argument: the function slug/name. - Does NOT remove the function from the local filesystem. diff --git a/apps/cli/src/commands/functions/deploy/SIDE_EFFECTS.md b/apps/cli/src/commands/functions/deploy/SIDE_EFFECTS.md index 3f29d7cc81..201f8f08ea 100644 --- a/apps/cli/src/commands/functions/deploy/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/functions/deploy/SIDE_EFFECTS.md @@ -120,8 +120,7 @@ The `--output`/`-o` flag does not change deploy output. (`functions deploy` takes a project, not a database); `false|no|off|0` and `--no-remote` fail with `--remote doesn't take false. Leave it out to use the linked project.`, and an empty value with `--remote= has an empty value. Pass --remote for the linked project or --remote=<ref|branch>; leave it out to use the linked project.` - A bare `--remote` followed by a word that exactly matches a branch of the linked project, is within a small edit distance of one, or can't be checked because the branch listing failed stops - the run before any other work with an error that offers both readings: `--remote=<word>` for the - branch, `<word> --remote` for the operand. Any other word runs as the operand. + the run before any other work with an error that names the cause (an HTTP status, or a missing or invalid access token) and offers both readings without echoing the word: `--remote=<branch>` for the branch, the command's operands then `--remote` for the linked project. Any other word runs as the operand. - The deprecated `--project-ref` is an alias of `--remote=<ref>` and prints a deprecation warning on stderr that names the replacement; it cannot be combined with `--remote=<value>`. - 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`. diff --git a/apps/cli/src/commands/functions/download/SIDE_EFFECTS.md b/apps/cli/src/commands/functions/download/SIDE_EFFECTS.md index 3f72e9d0c4..810ff6cc81 100644 --- a/apps/cli/src/commands/functions/download/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/functions/download/SIDE_EFFECTS.md @@ -105,7 +105,7 @@ Same envelope as `json` above (including on the Docker-unbundle path). ## Notes - Targets the linked project unless `--remote=<ref>` is given; a bare `--remote` also targets the linked project. `--remote=<value>` is read as a project ref (20 lowercase letters), a branch of the linked project (by name or UUID, resolved through `GET /v1/projects/{parent}/branches`, or `GET /v1/branches/{id}` for a UUID when no project is linked), or `true|yes|on|1` for the linked project. A database URL is rejected (`supabase functions download` takes a project, not a database); `false|no|off|0` and `--no-remote` fail with `--remote doesn't take false. Leave it out to use the linked project.`, and an empty value with `--remote= has an empty value. Pass --remote for the linked project or --remote=<ref|branch>; leave it out to use the linked project.` -- A bare `--remote` followed by a word that exactly matches a branch of the linked project, is within a small edit distance of one, or can't be checked because the branch listing failed stops the run before any other work with an error that offers both readings: `--remote=<word>` for the branch, `<word> --remote` for the operand. Any other word runs as the operand. +- A bare `--remote` followed by a word that exactly matches a branch of the linked project, is within a small edit distance of one, or can't be checked because the branch listing failed stops the run before any other work with an error that names the cause (an HTTP status, or a missing or invalid access token) and offers both readings without echoing the word: `--remote=<branch>` for the branch, the command's operands then `--remote` for the linked project. Any other word runs as the operand. - The deprecated `--project-ref` is an alias of `--remote=<ref>` and prints a deprecation warning on stderr that names the replacement; it cannot be combined with `--remote=<value>`. An empty `--project-ref` is rejected. - **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 diff --git a/apps/cli/src/commands/migration/repair/SIDE_EFFECTS.md b/apps/cli/src/commands/migration/repair/SIDE_EFFECTS.md index 136a9f6257..db7dd37b71 100644 --- a/apps/cli/src/commands/migration/repair/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/migration/repair/SIDE_EFFECTS.md @@ -101,7 +101,7 @@ Same structured result delivered as an NDJSON `result` event. linked project, which resolves to that branch's own project. `false|no|off|0` and `--no-remote` fail with `--remote doesn't take false. Leave it out to target the local database.`, and an empty value with `--remote= has an empty value. Pass --remote for the linked project or --remote=<ref|branch|url>; leave it out to target the local database.` A bare `--remote` followed by a word that exactly matches a branch of the linked project, is within a small edit distance of one, or can't be checked because the branch listing failed stops the run with an error - that offers both readings: `--remote=<word>` for the branch, `<word> --remote` for the operand. + that names the cause (an HTTP status, or a missing or invalid access token) and offers both readings without echoing the word: `--remote=<branch>` for the branch, the command's operands then `--remote` for the linked project. Any other word runs as the operand. The resolved target is announced on stderr (`Target: …`) in every output format and, in JSON results, as a `target` object. diff --git a/apps/cli/src/commands/notebooks/pull/SIDE_EFFECTS.md b/apps/cli/src/commands/notebooks/pull/SIDE_EFFECTS.md index cebd8c28f8..7f947dcc66 100644 --- a/apps/cli/src/commands/notebooks/pull/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/notebooks/pull/SIDE_EFFECTS.md @@ -140,8 +140,7 @@ After the project ref resolves, one `Target: …` banner naming the resolved pro (`supabase notebooks pull` takes a project, not a database); `false|no|off|0` and `--no-remote` fail with `--remote doesn't take false. Leave it out to use the linked project.`, and an empty value with `--remote= has an empty value. Pass --remote for the linked project or --remote=<ref|branch>; leave it out to use the linked project.` - A bare `--remote` followed by a word that exactly matches a branch of the linked project, is within a small edit distance of one, or can't be checked because the branch listing failed stops - the run before any other work with an error that offers both readings: `--remote=<word>` for the - branch, `<word> --remote` for the operand. Any other word runs as the operand. + the run before any other work with an error that names the cause (an HTTP status, or a missing or invalid access token) and offers both readings without echoing the word: `--remote=<branch>` for the branch, the command's operands then `--remote` for the linked project. Any other word runs as the operand. - The deprecated `--project-ref` is an alias of `--remote=<ref>` (a non-ref value is read as a branch name) and prints a deprecation warning on stderr that names the replacement; it cannot be combined with `--remote=<value>`. diff --git a/apps/cli/src/commands/notebooks/push/SIDE_EFFECTS.md b/apps/cli/src/commands/notebooks/push/SIDE_EFFECTS.md index d7bff31f81..37b7bb826c 100644 --- a/apps/cli/src/commands/notebooks/push/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/notebooks/push/SIDE_EFFECTS.md @@ -151,8 +151,7 @@ runs. `-o env` is rejected by the handler before resolving the project. (`supabase notebooks push` takes a project, not a database); `false|no|off|0` and `--no-remote` fail with `--remote doesn't take false. Leave it out to use the linked project.`, and an empty value with `--remote= has an empty value. Pass --remote for the linked project or --remote=<ref|branch>; leave it out to use the linked project.` - A bare `--remote` followed by a word that exactly matches a branch of the linked project, is within a small edit distance of one, or can't be checked because the branch listing failed stops - the run before any other work with an error that offers both readings: `--remote=<word>` for the - branch, `<word> --remote` for the operand. Any other word runs as the operand. + the run before any other work with an error that names the cause (an HTTP status, or a missing or invalid access token) and offers both readings without echoing the word: `--remote=<branch>` for the branch, the command's operands then `--remote` for the linked project. Any other word runs as the operand. - The deprecated `--project-ref` is an alias of `--remote=<ref>` (a non-ref value is read as a branch name) and prints a deprecation warning on stderr that names the replacement; it cannot be combined with `--remote=<value>`. diff --git a/apps/cli/src/commands/secrets/set/SIDE_EFFECTS.md b/apps/cli/src/commands/secrets/set/SIDE_EFFECTS.md index e702a04906..6901ea9a9e 100644 --- a/apps/cli/src/commands/secrets/set/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/secrets/set/SIDE_EFFECTS.md @@ -103,8 +103,7 @@ One `result` NDJSON event on success containing `{project_ref, count}` and `targ (`supabase secrets set` takes a project, not a database); `false|no|off|0` and `--no-remote` fail with `--remote doesn't take false. Leave it out to use the linked project.`, and an empty value with `--remote= has an empty value. Pass --remote for the linked project or --remote=<ref|branch>; leave it out to use the linked project.` - A bare `--remote` followed by a word that exactly matches a branch of the linked project, is within a small edit distance of one, or can't be checked because the branch listing failed stops - the run before any other work with an error that offers both readings: `--remote=<word>` for the - branch, `<word> --remote` for the operand. Any other word runs as the operand. + the run before any other work with an error that names the cause (an HTTP status, or a missing or invalid access token) and offers both readings without echoing the word: `--remote=<branch>` for the branch, the command's operands then `--remote` for the linked project. Any other word runs as the operand. - The deprecated `--project-ref` is an alias of `--remote=<ref>` (a non-ref value is read as a branch name) and prints a deprecation warning on stderr that names the replacement; it cannot be combined with `--remote=<value>`. diff --git a/apps/cli/src/commands/secrets/unset/SIDE_EFFECTS.md b/apps/cli/src/commands/secrets/unset/SIDE_EFFECTS.md index 5311f528b6..04e411a4a0 100644 --- a/apps/cli/src/commands/secrets/unset/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/secrets/unset/SIDE_EFFECTS.md @@ -112,8 +112,7 @@ One `result` NDJSON event on success containing `{project_ref, count, names, dry (`supabase secrets unset` takes a project, not a database); `false|no|off|0` and `--no-remote` fail with `--remote doesn't take false. Leave it out to use the linked project.`, and an empty value with `--remote= has an empty value. Pass --remote for the linked project or --remote=<ref|branch>; leave it out to use the linked project.` - A bare `--remote` followed by a word that exactly matches a branch of the linked project, is within a small edit distance of one, or can't be checked because the branch listing failed stops - the run before any other work with an error that offers both readings: `--remote=<word>` for the - branch, `<word> --remote` for the operand. Any other word runs as the operand. + the run before any other work with an error that names the cause (an HTTP status, or a missing or invalid access token) and offers both readings without echoing the word: `--remote=<branch>` for the branch, the command's operands then `--remote` for the linked project. Any other word runs as the operand. - The deprecated `--project-ref` is an alias of `--remote=<ref>` (a non-ref value is read as a branch name) and prints a deprecation warning on stderr that names the replacement; it cannot be combined with `--remote=<value>`. diff --git a/apps/cli/src/commands/snippets/download/SIDE_EFFECTS.md b/apps/cli/src/commands/snippets/download/SIDE_EFFECTS.md index 1ad5ec6eb0..d88ac8ec5a 100644 --- a/apps/cli/src/commands/snippets/download/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/snippets/download/SIDE_EFFECTS.md @@ -97,8 +97,7 @@ NDJSON `success` event with the same full payload as `--output-format json`. (`supabase snippets download` takes a project, not a database); `false|no|off|0` and `--no-remote` fail with `--remote doesn't take false. Leave it out to use the linked project.`, and an empty value with `--remote= has an empty value. Pass --remote for the linked project or --remote=<ref|branch>; leave it out to use the linked project.` - A bare `--remote` followed by a word that exactly matches a branch of the linked project, is within a small edit distance of one, or can't be checked because the branch listing failed stops - the run before any other work with an error that offers both readings: `--remote=<word>` for the - branch, `<word> --remote` for the operand. Any other word runs as the operand. + the run before any other work with an error that names the cause (an HTTP status, or a missing or invalid access token) and offers both readings without echoing the word: `--remote=<branch>` for the branch, the command's operands then `--remote` for the linked project. Any other word runs as the operand. - The deprecated `--project-ref` is an alias of `--remote=<ref>` (a non-ref value is read as a branch name) and prints a deprecation warning on stderr that names the replacement; it cannot be combined with `--remote=<value>`. diff --git a/apps/cli/src/commands/sso/remove/SIDE_EFFECTS.md b/apps/cli/src/commands/sso/remove/SIDE_EFFECTS.md index bb01c32b4b..698fc9e3e4 100644 --- a/apps/cli/src/commands/sso/remove/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/sso/remove/SIDE_EFFECTS.md @@ -88,8 +88,7 @@ Single `success` event with the removed provider's details as data. (`supabase sso remove` takes a project, not a database); `false|no|off|0` and `--no-remote` fail with `--remote doesn't take false. Leave it out to use the linked project.`, and an empty value with `--remote= has an empty value. Pass --remote for the linked project or --remote=<ref|branch>; leave it out to use the linked project.` - A bare `--remote` followed by a word that exactly matches a branch of the linked project, is within a small edit distance of one, or can't be checked because the branch listing failed stops - the run before any other work with an error that offers both readings: `--remote=<word>` for the - branch, `<word> --remote` for the operand. Any other word runs as the operand. + the run before any other work with an error that names the cause (an HTTP status, or a missing or invalid access token) and offers both readings without echoing the word: `--remote=<branch>` for the branch, the command's operands then `--remote` for the linked project. Any other word runs as the operand. - The deprecated `--project-ref` is an alias of `--remote=<ref>` (a non-ref value is read as a branch name) and prints a deprecation warning on stderr that names the replacement; it cannot be combined with `--remote=<value>`. diff --git a/apps/cli/src/commands/sso/show/SIDE_EFFECTS.md b/apps/cli/src/commands/sso/show/SIDE_EFFECTS.md index bc449d0c39..c3f6a375ef 100644 --- a/apps/cli/src/commands/sso/show/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/sso/show/SIDE_EFFECTS.md @@ -90,8 +90,7 @@ Single `success` event with the response as data. (`supabase sso show` takes a project, not a database); `false|no|off|0` and `--no-remote` fail with `--remote doesn't take false. Leave it out to use the linked project.`, and an empty value with `--remote= has an empty value. Pass --remote for the linked project or --remote=<ref|branch>; leave it out to use the linked project.` - A bare `--remote` followed by a word that exactly matches a branch of the linked project, is within a small edit distance of one, or can't be checked because the branch listing failed stops - the run before any other work with an error that offers both readings: `--remote=<word>` for the - branch, `<word> --remote` for the operand. Any other word runs as the operand. + the run before any other work with an error that names the cause (an HTTP status, or a missing or invalid access token) and offers both readings without echoing the word: `--remote=<branch>` for the branch, the command's operands then `--remote` for the linked project. Any other word runs as the operand. - The deprecated `--project-ref` is an alias of `--remote=<ref>` (a non-ref value is read as a branch name) and prints a deprecation warning on stderr that names the replacement; it cannot be combined with `--remote=<value>`. diff --git a/apps/cli/src/commands/sso/update/SIDE_EFFECTS.md b/apps/cli/src/commands/sso/update/SIDE_EFFECTS.md index a9f3e2a88a..05fbda51bd 100644 --- a/apps/cli/src/commands/sso/update/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/sso/update/SIDE_EFFECTS.md @@ -122,8 +122,7 @@ Single `success` event with the parsed response as data. (`supabase sso update` takes a project, not a database); `false|no|off|0` and `--no-remote` fail with `--remote doesn't take false. Leave it out to use the linked project.`, and an empty value with `--remote= has an empty value. Pass --remote for the linked project or --remote=<ref|branch>; leave it out to use the linked project.` - A bare `--remote` followed by a word that exactly matches a branch of the linked project, is within a small edit distance of one, or can't be checked because the branch listing failed stops - the run before any other work with an error that offers both readings: `--remote=<word>` for the - branch, `<word> --remote` for the operand. Any other word runs as the operand. + the run before any other work with an error that names the cause (an HTTP status, or a missing or invalid access token) and offers both readings without echoing the word: `--remote=<branch>` for the branch, the command's operands then `--remote` for the linked project. Any other word runs as the operand. - The deprecated `--project-ref` is an alias of `--remote=<ref>` (a non-ref value is read as a branch name) and prints a deprecation warning on stderr that names the replacement; it cannot be combined with `--remote=<value>`. diff --git a/apps/cli/src/commands/storage/cp/SIDE_EFFECTS.md b/apps/cli/src/commands/storage/cp/SIDE_EFFECTS.md index 8a34709c87..0ee678d27f 100644 --- a/apps/cli/src/commands/storage/cp/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/storage/cp/SIDE_EFFECTS.md @@ -115,8 +115,7 @@ backend — same roles as `storage ls`. A database URL (`--remote=<db-url>`) fails with a `needs a project, not a database URL` error and sends no request. `false|no|off|0` and `--no-remote` fail with `--remote doesn't take false. Leave it out to target the local Storage API.`, and an empty value with `--remote= has an empty value. Pass --remote for the linked project or --remote=<ref|branch>; leave it out to target the local Storage API.` A bare `--remote` followed by a word that exactly matches a branch of the linked project, is within a small edit distance of one, or can't - be checked because the branch listing failed stops the run with an error that offers both - readings: `--remote=<word>` for the branch, `<word> --remote` for the operand. Any other word runs + be checked because the branch listing failed stops the run with an error that names the cause (an HTTP status, or a missing or invalid access token) and offers both readings without echoing the word: `--remote=<branch>` for the branch, the command's operands then `--remote` for the linked project. Any other word runs as the operand. The resolved target is announced on stderr (`Target: …`) in every output format and, in JSON results, as a `target` object. - **Deprecated selectors.** `--linked`, `--project-ref` and `--local` are hidden aliases that still diff --git a/apps/cli/src/commands/storage/ls/SIDE_EFFECTS.md b/apps/cli/src/commands/storage/ls/SIDE_EFFECTS.md index 33855545aa..d8b91e44c1 100644 --- a/apps/cli/src/commands/storage/ls/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/storage/ls/SIDE_EFFECTS.md @@ -113,8 +113,7 @@ No custom storage telemetry events. A database URL (`--remote=<db-url>`) fails with a `needs a project, not a database URL` error and sends no request. `false|no|off|0` and `--no-remote` fail with `--remote doesn't take false. Leave it out to target the local Storage API.`, and an empty value with `--remote= has an empty value. Pass --remote for the linked project or --remote=<ref|branch>; leave it out to target the local Storage API.` A bare `--remote` followed by a word that exactly matches a branch of the linked project, is within a small edit distance of one, or can't - be checked because the branch listing failed stops the run with an error that offers both - readings: `--remote=<word>` for the branch, `<word> --remote` for the operand. Any other word runs + be checked because the branch listing failed stops the run with an error that names the cause (an HTTP status, or a missing or invalid access token) and offers both readings without echoing the word: `--remote=<branch>` for the branch, the command's operands then `--remote` for the linked project. Any other word runs as the operand. The resolved target is announced on stderr (`Target: …`) in every output format and, in JSON results, as a `target` object. - **Deprecated selectors.** `--linked`, `--project-ref` and `--local` are hidden aliases that still diff --git a/apps/cli/src/commands/storage/mv/SIDE_EFFECTS.md b/apps/cli/src/commands/storage/mv/SIDE_EFFECTS.md index 9686e45c7c..1864980069 100644 --- a/apps/cli/src/commands/storage/mv/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/storage/mv/SIDE_EFFECTS.md @@ -110,8 +110,7 @@ backend — same roles as `storage ls`. A database URL (`--remote=<db-url>`) fails with a `needs a project, not a database URL` error and sends no request. `false|no|off|0` and `--no-remote` fail with `--remote doesn't take false. Leave it out to target the local Storage API.`, and an empty value with `--remote= has an empty value. Pass --remote for the linked project or --remote=<ref|branch>; leave it out to target the local Storage API.` A bare `--remote` followed by a word that exactly matches a branch of the linked project, is within a small edit distance of one, or can't - be checked because the branch listing failed stops the run with an error that offers both - readings: `--remote=<word>` for the branch, `<word> --remote` for the operand. Any other word runs + be checked because the branch listing failed stops the run with an error that names the cause (an HTTP status, or a missing or invalid access token) and offers both readings without echoing the word: `--remote=<branch>` for the branch, the command's operands then `--remote` for the linked project. Any other word runs as the operand. The resolved target is announced on stderr (`Target: …`) in every output format and, in JSON results, as a `target` object. - **Deprecated selectors.** `--linked`, `--project-ref` and `--local` are hidden aliases that still diff --git a/apps/cli/src/commands/storage/rm/SIDE_EFFECTS.md b/apps/cli/src/commands/storage/rm/SIDE_EFFECTS.md index 13cdcfb90c..f93f8bee15 100644 --- a/apps/cli/src/commands/storage/rm/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/storage/rm/SIDE_EFFECTS.md @@ -128,8 +128,7 @@ Requires `--yes`/`SUPABASE_YES` on the same terms as `json`. A database URL (`--remote=<db-url>`) fails with a `needs a project, not a database URL` error and sends no request. `false|no|off|0` and `--no-remote` fail with `--remote doesn't take false. Leave it out to target the local Storage API.`, and an empty value with `--remote= has an empty value. Pass --remote for the linked project or --remote=<ref|branch>; leave it out to target the local Storage API.` A bare `--remote` followed by a word that exactly matches a branch of the linked project, is within a small edit distance of one, or can't - be checked because the branch listing failed stops the run with an error that offers both - readings: `--remote=<word>` for the branch, `<word> --remote` for the operand. Any other word runs + be checked because the branch listing failed stops the run with an error that names the cause (an HTTP status, or a missing or invalid access token) and offers both readings without echoing the word: `--remote=<branch>` for the branch, the command's operands then `--remote` for the linked project. Any other word runs as the operand. The resolved target is announced on stderr (`Target: …`) in every output format and, in JSON results, as a `target` object. - **Deprecated selectors.** `--linked`, `--project-ref` and `--local` are hidden aliases that still diff --git a/apps/cli/src/commands/test/db/SIDE_EFFECTS.md b/apps/cli/src/commands/test/db/SIDE_EFFECTS.md index f942a1679f..2ea08e6c2f 100644 --- a/apps/cli/src/commands/test/db/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/test/db/SIDE_EFFECTS.md @@ -127,8 +127,7 @@ command (exit 1). `--remote=false` fails with `--remote doesn't take false. Leave it out to target the local database.`, and `--remote=` with `--remote= has an empty value. Pass --remote for the linked project or --remote=<ref|branch|url>; leave it out to target the local database.` A bare `--remote` followed by a word that exactly matches a branch of the linked project, is within a small edit distance of one, or can't be checked because the branch listing failed stops the run - with an error that offers both readings: `--remote=<word>` for the branch, `<word> --remote` for - the operand. Any other word runs as the operand. + with an error that names the cause (an HTTP status, or a missing or invalid access token) and offers both readings without echoing the word: `--remote=<branch>` for the branch, the command's operands then `--remote` for the linked project. Any other word runs as the operand. - **Deprecated aliases.** `--linked`, `--project-ref=<ref>`, `--db-url=<url>` and `--local` still work and print a deprecation warning on stderr naming the `--remote` replacement (`--local` warns that local is the default). They are mutually exclusive with each other and with diff --git a/apps/cli/tests/helpers/permission-registry.ts b/apps/cli/tests/helpers/permission-registry.ts index 8d75fa17a2..8ef8340cd0 100644 --- a/apps/cli/tests/helpers/permission-registry.ts +++ b/apps/cli/tests/helpers/permission-registry.ts @@ -1,4 +1,4 @@ -import { Option } from "effect"; +import { Option, Result } from "effect"; import { rootCommandForFeatures } from "../../src/cli/root.ts"; import { readPermissions } from "../../src/command-internal/command-permissions/command-permissions.annotation.ts"; @@ -9,11 +9,10 @@ import type { OperationEntry, SelectorShape, } from "../../src/command-internal/command-permissions/model.ts"; -import { classifyNamedRemote } from "../../src/command-internal/target/remote-value.ts"; -import { - DB_URL_PATTERN, - LINKED_LITERAL, -} from "../../src/command-internal/target/remote-vocabulary.ts"; +import { BRANCH_UUID_PATTERN } from "../../src/command-internal/ref-patterns.ts"; +import { classifyRemoteValue } from "../../src/command-internal/target/remote-value.ts"; +import { DB_URL_PATTERN } from "../../src/command-internal/target/remote-vocabulary.ts"; +import { parseTargetSelector } from "../../src/command-internal/target/target-selector.ts"; import { findCommand } from "../../src/shared/cli/command-docs.ts"; /** Which command tree to resolve against: `start`/`status`/`stop` differ by stack backend. */ @@ -41,33 +40,47 @@ export const SELECTOR_FLAGS = [ "local", ] as const; -const shapeOfNamedRemote = (value: string): SelectorShape => { - const named = classifyNamedRemote(value); - return named._tag === "Ref" ? "ref" : named._tag === "DbUrl" ? "url" : "branch"; -}; +const optionalText = (value: boolean | string | undefined): Option.Option<string> => + typeof value === "string" ? Option.some(value) : Option.none(); -/** The shape of the target selector `activeFlags` carries, read in the order the CLI resolves them. */ +/** The shape of the target selector `activeFlags` carries, read the way the CLI parses it. */ function selectorShapeOf(activeFlags: ActiveFlags): SelectorShape { const given = (flag: string) => { const value = Object.hasOwn(activeFlags, flag) ? activeFlags[flag] : undefined; return value === "" ? undefined : value; }; - if (given("local") !== undefined) return "none"; - const dbUrl = given("db-url"); - if (dbUrl !== undefined) return "url"; - const alias = given("project-ref") ?? given("project-id"); - if (alias === true) { + if (given("project-ref") === true || given("project-id") === true) { throw new Error( "activeFlags gives --project-ref/--project-id as `true`; pass the value, since its shape picks the entries that apply", ); } - if (typeof alias === "string") return shapeOfNamedRemote(alias); - if (given("linked") !== undefined) return "bare"; const remote = given("remote"); - if (remote === undefined) return "none"; - return typeof remote === "string" && !LINKED_LITERAL.test(remote) - ? shapeOfNamedRemote(remote) - : "bare"; + const parsed = parseTargetSelector({ + local: given("local") !== undefined, + linked: given("linked") !== undefined, + remote: + typeof remote === "string" + ? Option.fromNullishOr(classifyRemoteValue(remote)) + : Option.fromNullishOr(remote === undefined ? undefined : ({ _tag: "Linked" } as const)), + projectRef: optionalText(given("project-ref")), + projectId: optionalText(given("project-id")), + dbUrl: optionalText(given("db-url")), + }); + if (Result.isFailure(parsed)) throw new Error(parsed.failure.message); + const { selection } = parsed.success; + switch (selection._tag) { + case "Default": + case "Local": + return "none"; + case "Linked": + return "bare"; + case "Ref": + return "ref"; + case "DbUrl": + return "url"; + case "Branch": + return BRANCH_UUID_PATTERN.test(selection.name) ? "uuid" : "branch"; + } } /** The target `activeFlags` selects (`db-url`, `local`, `linked`, `remote`, or `project-ref`, which imply linked unless `remote` is a database URL), else `fallback`. */ diff --git a/apps/cli/tests/helpers/permission-registry.unit.test.ts b/apps/cli/tests/helpers/permission-registry.unit.test.ts index 46a0a692e6..4a2af99da5 100644 --- a/apps/cli/tests/helpers/permission-registry.unit.test.ts +++ b/apps/cli/tests/helpers/permission-registry.unit.test.ts @@ -134,10 +134,17 @@ describe("applicableOperations", () => { kind: "required", when: [{ selector: ["none", "bare"] }], }, + { operationId: "v1-get-a-branch-config", kind: "required", when: [{ selector: ["uuid"] }] }, ]); const applied = (activeFlags: Parameters<typeof applicableOperations>[1]) => applicableOperations(permissions, activeFlags).map((e) => e.operationId); + it("reads a branch UUID as its own shape, apart from a branch name", () => { + expect(applied({ remote: "6f0b5d2e-3c1a-4f7e-9a58-2b1d4c7e8f90" })).toEqual([ + "v1-get-a-branch-config", + ]); + }); + it("reads a --remote value by what it names", () => { expect(applied({ remote: true })).toEqual(["v1-list-all-projects"]); expect(applied({ remote: "yes" })).toEqual(["v1-list-all-projects"]); 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 5c089fdb2c..6aac105ad5 100644 --- a/docs/adr/0033-target-selection-and-hosted-mutation-consent.md +++ b/docs/adr/0033-target-selection-and-hosted-mutation-consent.md @@ -69,7 +69,9 @@ line and nowhere else. Every command resolves a `--remote=<branch|uuid>` value with one resolver, the one `supabase link` uses. A value that matches no branch fails with link's error, which lists the branches. A branch -UUID also resolves in a checkout with no linked project. A URL applies only where the remote side is +UUID also resolves in a checkout with no linked project, where it is reported as the bare project +ref, `unverified` in JSON, because the lookup does not say whether it is the default branch. A keyword +connection string (`host=… user=…`) is rejected, not looked up as a branch. A URL applies only where the remote side is Postgres: `storage`, `seed buckets` and `hosted` commands need a project and reject one. `hosted` commands accept `--remote=<ref|branch>`, and bare `--remote` changes nothing for them. The linked project comes from `SUPABASE_PROJECT_ID`, then the link file. `SUPABASE_PROJECT_ID` alone never @@ -82,7 +84,7 @@ already has more bare tokens than positionals. The one ambiguous shape, a bare ` followed by a token the command took as a positional, is checked against the linked project's branches before anything runs. A token that exactly matches a branch, is within a small edit distance of one, or can't be checked because the branch listing failed stops the run with an error -that offers both readings: `--remote=<word>` for the branch, `<word> --remote` for the operand. Any +that names the cause (an HTTP status, or a missing or invalid access token) and offers both readings without echoing the word: `--remote=<branch>` for the branch, the command's operands then `--remote` for the linked project. Any other token runs as the command's operand. ### Deprecated aliases @@ -161,8 +163,8 @@ line names the parent project when the cache knows its name, and shows the paren A project's default branch is production, not a preview branch. `--remote=<default branch>`, by name or UUID, resolves to the production project: the banner, the consent prompt and JSON `environment: "production"` all say so, so a script that checks `environment` to stay away from -production sees it. The full table and the JSON shape live in -[the command target doc](../../apps/cli/docs/command-target.md). +production sees it. A UUID looked up with no linked project cannot tell, so it stays `unverified`. +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`: From 94cda3fb89d399c9ec8fa4ea0811622c1d8fd516 Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Mon, 19 Oct 2026 17:19:10 +0100 Subject: [PATCH 169/291] fix(cli): leave a hidden positional out of the --remote operand reading A positional declared with Param.withHidden still parses but counts for no operand capacity and no warning placeholder. gen types --remote <word> now joins to --remote=<word> and its deprecation warnings read 'supabase gen types --remote=<ref>'; a language written before --remote keeps working. The gen types positional-language guard no longer needs to wait for the bare-remote ambiguity check. --- .../src/tests/target-selection.e2e.test.ts | 2 +- .../src/commands/gen/types/SIDE_EFFECTS.md | 49 +++++++-------- .../src/commands/gen/types/types.handler.ts | 14 ++--- .../gen/types/types.integration.test.ts | 63 ++++++++++++------- apps/cli/src/shared/cli/remote-argv.ts | 9 +-- .../src/shared/cli/remote-argv.unit.test.ts | 26 +++++--- .../shared/cli/subcommand-flag-suggestions.ts | 16 ++++- ...t-selection-and-hosted-mutation-consent.md | 3 +- 8 files changed, 108 insertions(+), 74 deletions(-) diff --git a/apps/cli-e2e/src/tests/target-selection.e2e.test.ts b/apps/cli-e2e/src/tests/target-selection.e2e.test.ts index e4a7278f87..5d9b2f510a 100644 --- a/apps/cli-e2e/src/tests/target-selection.e2e.test.ts +++ b/apps/cli-e2e/src/tests/target-selection.e2e.test.ts @@ -70,7 +70,7 @@ describe("deprecated target aliases", () => { expect(result.exitCode).toBe(0); expect(result.stdout).toContain("export type Database"); expect(`${result.stdout}${result.stderr}`).toContain( - `--project-id is deprecated and will be removed in v4. Use --remote=<ref> instead: supabase gen types <language> --remote=${projectRef}`, + `--project-id is deprecated and will be removed in v4. Use --remote=<ref> instead: supabase gen types --remote=${projectRef}`, ); }, ); diff --git a/apps/cli/src/commands/gen/types/SIDE_EFFECTS.md b/apps/cli/src/commands/gen/types/SIDE_EFFECTS.md index f8d530753f..22562ab835 100644 --- a/apps/cli/src/commands/gen/types/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/gen/types/SIDE_EFFECTS.md @@ -39,18 +39,18 @@ The local target and `--remote=<db-url>` write no files. ## API Routes -| Method | Path | Auth | Request body | Response (used fields) | -| -------- | ------------------------------------------- | ------------ | ----------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `GET` | `/v1/projects/{ref}/types/typescript` | Bearer token | none | TypeScript type definitions text | -| `GET` | `/v1/projects/{ref}` | Bearer token | none | non-TypeScript lookup (`404` ⇒ branch ref); also the linked-project cache fill on every remote project | -| `GET` | `/v1/branches/{branch_id_or_ref}` | Bearer token | none | `db_host`, `db_port`, `db_user`, `db_pass` | -| `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` | -| `POST` | `/v1/projects/{ref}/network-bans/retrieve` | Bearer token | none | `banned_ipv4_addresses` | -| `DELETE` | `/v1/projects/{ref}/network-bans` | Bearer token | `{ ipv4_addresses, requester_ip: false }` | none | -| `GET` | `/v1/projects` | Bearer token | none | project list for the ref prompt: bare `--remote` with no ref from `SUPABASE_PROJECT_ID` or `.temp/project-ref`, with stdin and stdout TTYs and a non-JSON output format | -| `GET` | `/v1/projects/{parent}/branches` | Bearer token | none | `[{id, name, project_ref, is_default}]` — only for `--remote=<branch>` (or an alias given a branch name), and for a bare `--remote` followed by a word that could name a branch; `{parent}` is `SUPABASE_PROJECT_ID`, else the linked project | -| `GET` | `/v1/branches/{id}` | Bearer token | none | `{ref, status, …}` — only for `--remote=<branch UUID>` when no project is linked | +| Method | Path | Auth | Request body | Response (used fields) | +| -------- | ------------------------------------------- | ------------ | ----------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `GET` | `/v1/projects/{ref}/types/typescript` | Bearer token | none | TypeScript type definitions text | +| `GET` | `/v1/projects/{ref}` | Bearer token | none | non-TypeScript lookup (`404` ⇒ branch ref); also the linked-project cache fill on every remote project | +| `GET` | `/v1/branches/{branch_id_or_ref}` | Bearer token | none | `db_host`, `db_port`, `db_user`, `db_pass` | +| `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` | +| `POST` | `/v1/projects/{ref}/network-bans/retrieve` | Bearer token | none | `banned_ipv4_addresses` | +| `DELETE` | `/v1/projects/{ref}/network-bans` | Bearer token | `{ ipv4_addresses, requester_ip: false }` | none | +| `GET` | `/v1/projects` | Bearer token | none | project list for the ref prompt: bare `--remote` with no ref from `SUPABASE_PROJECT_ID` or `.temp/project-ref`, with stdin and stdout TTYs and a non-JSON output format | +| `GET` | `/v1/projects/{parent}/branches` | Bearer token | none | `[{id, name, project_ref, is_default}]` — only for `--remote=<branch>` (or an alias given a branch name); `{parent}` is `SUPABASE_PROJECT_ID`, else the linked project | +| `GET` | `/v1/branches/{id}` | Bearer token | none | `{ref, status, …}` — only for `--remote=<branch UUID>` when no project is linked | The TypeScript endpoint is called for a remote project (`--remote`, `--remote=<ref>`, `--remote=<branch>` or an alias) when `--lang=typescript`. For other languages on a remote project @@ -107,7 +107,7 @@ way `--remote=<db-url>` does. | ---- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `0` | success — types printed to stdout | | `1` | conflicting target flags (see Notes): two selectors, `--project-ref` or `--project-id` combined with `--local`, `--db-url` or `--remote=<value>`, or `--project-id` combined with `--project-ref` (`TargetSelectorConflictError`, `TargetProjectRefConflictError`) | -| `1` | an empty `--db-url`, `--project-ref` or `--project-id` (`TargetEmptyValueError`), `--remote` switched off or given an empty value, or a bare `--remote` followed by a word that is a branch of the linked project (`TargetRemoteAmbiguousError`) | +| `1` | an empty `--db-url`, `--project-ref` or `--project-id` (`TargetEmptyValueError`), `--remote` switched off or given an empty value | | `1` | `--remote=<branch>` matches no branch of the linked project (`LinkBranchNotFoundError`, lists the branches that exist) | | `1` | a remote project selector (`--remote`, `--remote=<ref>`, `--remote=<branch>`, `--linked`, `--project-ref` or `--project-id`) combined with `--postgrest-v9-compat`, `--query-timeout` or a language flag (`GenTypesFlagUsageError`); `--remote=<db-url>` is outside the group | | `1` | `--postgrest-v9-compat` used without `--remote=<db-url>` or `--db-url` (`GenTypesFlagUsageError`) | @@ -135,7 +135,7 @@ linked directory the banner ends with `· default; pass --remote for <project>`. 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. Each deprecated selector additionally prints its warning on stderr, for example -`--project-id is deprecated and will be removed in v4. Use --remote=<ref> instead: supabase gen types <language> --remote=<ref>`. +`--project-id is deprecated and will be removed in v4. Use --remote=<ref> instead: supabase gen types --remote=<ref>`. ### `--output-format json` @@ -154,18 +154,16 @@ Not applicable. targets the linked project (`SUPABASE_PROJECT_ID`, else `.temp/project-ref`). `--remote=<value>` is read as a project ref (20 lowercase letters), a database URL (`postgres://` or `postgresql://`), `true|yes|on|1` (the linked project), or otherwise the name of a branch of the - linked project, which resolves to that branch's own project. `false|no|off|0` and `--no-remote` fail with `--remote doesn't take false. Leave it out to target the local database.`, and an empty value with `--remote= has an empty value. Pass --remote for the linked project or --remote=<ref|branch|url>; leave it out to target the local database.` A bare `--remote` - followed by a word that exactly matches a branch of the linked project, is within a small edit - distance of one, or can't be checked because the branch listing failed stops the run with an error - that names the cause (an HTTP status, or a missing or invalid access token) and offers both readings without echoing the word: `--remote=<branch>` for the branch, the command's operands then `--remote` for the linked project. - Any other word runs as the operand. The + linked project, which resolves to that branch's own project. `false|no|off|0` and `--no-remote` fail with `--remote doesn't take false. Leave it out to target the local database.`, and an empty value with `--remote= has an empty value. Pass --remote for the linked project or --remote=<ref|branch|url>; leave it out to target the local database.` `--remote <word>` is always read as + `--remote=<word>`: the legacy positional language is hidden and takes no part in that reading, so a + language goes before `--remote` (`gen types typescript --remote`). The resolved target is announced on stderr (`Target: …`) in every output format. - **Deprecated selectors.** `--linked`, `--project-ref`, `--project-id`, `--db-url` and `--local` are hidden aliases that still select their target and print a warning on stderr naming the replacement, for example - `--linked is deprecated and will be removed in v4. Use --remote instead: supabase gen types <language> --remote`, - `--project-id is deprecated and will be removed in v4. Use --remote=<ref> instead: supabase gen types <language> --remote=<ref>` - and `--local isn't needed (local is the default) and will be removed in v4: supabase gen types <language>`; a + `--linked is deprecated and will be removed in v4. Use --remote instead: supabase gen types --remote`, + `--project-id is deprecated and will be removed in v4. Use --remote=<ref> instead: supabase gen types --remote=<ref>` + and `--local isn't needed (local is the default) and will be removed in v4: supabase gen types`; a database URL is never echoed. Two selectors together fail (`Only one of --remote, --linked, --db-url and --local can be set; got …`), `--project-ref` and `--project-id` cannot be combined with `--local`, `--db-url` or `--remote=<value>`, an empty @@ -181,11 +179,8 @@ Not applicable. members, so they accept all three. These groups and the other usage guards (`--postgrest-v9-compat` without a database target, a positional language other than `typescript` without `--lang`) run before the target resolves, so a usage error never costs a - network call or prints a `Target:` banner; the one exception is a positional language that is - also the word after a bare `--remote`, which the branch check rules on first. The command reads - its own rewritten command line, so - `--remote <ref>` and `--remote <url>` mean `--remote=<ref>` and `--remote=<url>`, and the word is not - taken as the positional language. + network call or prints a `Target:` banner. The command reads its own rewritten command line, so + `--remote <word>` means `--remote=<word>`, and the word is not taken as the positional language. - With the local target, 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 4efbaf747d..fa8bad0e4c 100644 --- a/apps/cli/src/commands/gen/types/types.handler.ts +++ b/apps/cli/src/commands/gen/types/types.handler.ts @@ -4,7 +4,6 @@ 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 { CliArgs } from "../../../shared/cli/cli-args.service.ts"; import { mutuallyExclusiveFlagsMessage, PERSISTENT_VALUE_FLAG_NAMES, @@ -590,13 +589,11 @@ export const genTypes = Effect.fn("gen.types")(function* (flags: GenTypesFlags) Option.isSome(positionalLang) && positionalLang.value !== "typescript" && !occurrences.has("lang"); - const positionalLangError = () => - new GenTypesFlagUsageError({ message: "use --lang flag to specify the typegen language" }); - // The word after a bare `--remote` may be a branch name; the target's check rules on it first. - const cliArgs = Option.getOrUndefined(yield* Effect.serviceOption(CliArgs)); - const deferPositionalLang = - unknownPositionalLang && positionalLang.value === cliArgs?.bareRemoteOperand; - if (unknownPositionalLang && !deferPositionalLang) return yield* positionalLangError(); + if (unknownPositionalLang) { + return yield* new GenTypesFlagUsageError({ + message: "use --lang flag to specify the typegen language", + }); + } const selection = Result.getOrUndefined(parseTargetSelector(flags))?.selection; if (selection?._tag === "Linked" || selection?._tag === "Ref" || selection?._tag === "Branch") { @@ -617,7 +614,6 @@ export const genTypes = Effect.fn("gen.types")(function* (flags: GenTypesFlags) } const target = yield* resolveTarget(flags); - if (deferPositionalLang) return yield* positionalLangError(); yield* Effect.annotateCurrentSpan("typegen.lang", lang); 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 9a625a1740..8a41cb2ec2 100644 --- a/apps/cli/src/commands/gen/types/types.integration.test.ts +++ b/apps/cli/src/commands/gen/types/types.integration.test.ts @@ -1023,7 +1023,7 @@ describe("gen types", () => { ); expect(stripAnsi(out.stderrText)).toContain( - `Warning: --project-id is deprecated and will be removed in v4. Use --remote=<ref> instead: supabase gen types <language> --remote=${VALID_REF}\n`, + `Warning: --project-id is deprecated and will be removed in v4. Use --remote=<ref> instead: supabase gen types --remote=${VALID_REF}\n`, ); expect(out.stderrText).toContain(unverifiedTargetBanner()); expect(api.requests).toContainEqual({ @@ -1057,7 +1057,7 @@ describe("gen types", () => { expect(alias.out.stdoutText).toBe("export type Database = {}\n"); expect(alias.out.stdoutText).toBe(canonical.out.stdoutText); expect(warningsOf(alias.out)).toEqual([ - `--project-id is deprecated and will be removed in v4. Use --remote=<ref> instead: supabase gen types <language> --remote=${VALID_REF}`, + `--project-id is deprecated and will be removed in v4. Use --remote=<ref> instead: supabase gen types --remote=${VALID_REF}`, ]); expect(warningsOf(canonical.out)).toEqual([]); }).pipe(Effect.provide(BunServices.layer)), @@ -1172,23 +1172,25 @@ describe("gen types", () => { }).pipe(Effect.provide(BunServices.layer)), ); - it.effect("fails a bare --remote followed by a branch name before the language guard", () => + it.effect("reads a bare --remote followed by a branch name as --remote=<branch>", () => Effect.gen(function* () { - const { layer, api, generator } = yield* setup({ + const { layer, api } = yield* setup({ args: ["gen", "types", "--remote", "feature-x"], projectId: Option.some(VALID_REF), branches: [branchRow("feature-x")], + projectTypes: "ok", }); - const error = yield* genTypes( - defaultFlags({ remote: Option.some({ _tag: "Linked" } as const) }), - ).pipe(Effect.provide(layer), Effect.flip); + yield* genTypes( + defaultFlags({ remote: Option.some({ _tag: "Branch", name: "feature-x" } as const) }), + ).pipe(Effect.provide(layer)); - expect(error).toMatchObject({ - _tag: "TargetRemoteAmbiguousError", - message: `"feature-x" is a branch of project ${VALID_REF}. For the branch: supabase gen types --remote=feature-x. For the linked project: supabase gen types feature-x --remote`, - }); - expect(api.requests.map(({ method }) => method)).toEqual(["listAllBranches"]); - expect(generator.calls).toHaveLength(0); + expect(api.requests).toEqual([ + { method: "listAllBranches", input: { ref: VALID_REF } }, + { + method: "generateTypescriptTypes", + input: { ref: BRANCH_PROJECT_REF, included_schemas: "public" }, + }, + ]); }).pipe(Effect.provide(BunServices.layer)), ); @@ -1243,7 +1245,7 @@ describe("gen types", () => { args: ["gen", "types", "--linked"], flags: { linked: true }, warning: - "--linked is deprecated and will be removed in v4. Use --remote instead: supabase gen types <language> --remote", + "--linked is deprecated and will be removed in v4. Use --remote instead: supabase gen types --remote", reached: ({ api }) => expect(api.requests).toContainEqual({ method: "generateTypescriptTypes", @@ -1254,7 +1256,7 @@ describe("gen types", () => { alias: "--project-ref", args: ["gen", "types", "--project-ref", FLAG_REF], flags: { projectRef: Option.some(FLAG_REF) }, - warning: `--project-ref is deprecated and will be removed in v4. Use --remote=<ref> instead: supabase gen types <language> --remote=${FLAG_REF}`, + warning: `--project-ref is deprecated and will be removed in v4. Use --remote=<ref> instead: supabase gen types --remote=${FLAG_REF}`, reached: ({ api }) => expect(api.requests).toContainEqual({ method: "generateTypescriptTypes", @@ -1266,7 +1268,7 @@ describe("gen types", () => { args: ["gen", "types", "--db-url", dbUrl], flags: { dbUrl: Option.some(dbUrl) }, warning: - "--db-url is deprecated and will be removed in v4. Use --remote=<db-url> instead: supabase gen types <language> --remote=<db-url>", + "--db-url is deprecated and will be removed in v4. Use --remote=<db-url> instead: supabase gen types --remote=<db-url>", reached: ({ dbConfig }) => expect(dbConfig.resolves.map(({ target }) => target.kind)).toEqual(["db-url"]), }, @@ -1275,7 +1277,7 @@ describe("gen types", () => { args: ["gen", "types", "--local"], flags: { local: true }, warning: - "--local isn't needed (local is the default) and will be removed in v4: supabase gen types <language>", + "--local isn't needed (local is the default) and will be removed in v4: supabase gen types", reached: ({ generator }) => expect(generator.calls[0]?.isLocal).toBe(true), }, ]; @@ -1706,13 +1708,12 @@ describe("gen types", () => { ); it.effect( - "reads the word after a bare --remote as the positional language once it names no branch", + "rejects a positional language written before a bare --remote without a request", () => Effect.gen(function* () { const { layer, api, generator } = yield* setup({ - args: ["gen", "types", "--remote", "feature-x"], + args: ["gen", "types", "go", "--remote"], projectId: Option.some(VALID_REF), - branches: [branchRow("other")], }); const exit = yield* genTypes( defaultFlags({ remote: Option.some({ _tag: "Linked" } as const) }), @@ -1722,11 +1723,31 @@ describe("gen types", () => { if (Exit.isFailure(exit)) { expect(String(exit.cause)).toContain("use --lang flag to specify the typegen language"); } - expect(api.requests.map(({ method }) => method)).toEqual(["listAllBranches"]); + expect(api.requests).toEqual([]); expect(generator.calls).toHaveLength(0); }).pipe(Effect.provide(BunServices.layer)), ); + it.effect("accepts the legacy typescript language written before a bare --remote", () => + Effect.gen(function* () { + const { layer, api } = yield* setup({ + args: ["gen", "types", "typescript", "--remote"], + projectId: Option.some(VALID_REF), + projectTypes: "ok", + }); + yield* genTypes(defaultFlags({ remote: Option.some({ _tag: "Linked" } as const) })).pipe( + Effect.provide(layer), + ); + + expect(api.requests).toEqual([ + { + method: "generateTypescriptTypes", + input: { ref: VALID_REF, included_schemas: "public" }, + }, + ]); + }).pipe(Effect.provide(BunServices.layer)), + ); + it.effect("prefers the positional language guard over mutex group errors", () => Effect.gen(function* () { const { layer } = yield* setup({ diff --git a/apps/cli/src/shared/cli/remote-argv.ts b/apps/cli/src/shared/cli/remote-argv.ts index 430985908e..ec9d5f07a9 100644 --- a/apps/cli/src/shared/cli/remote-argv.ts +++ b/apps/cli/src/shared/cli/remote-argv.ts @@ -10,7 +10,7 @@ import { import { isRemoteFlag } from "../../command-internal/target/target-flags.ts"; import { commandInternals, userGlobalFlagParams } from "../../docs/docs-introspection.ts"; import { BOOLEAN_FLAG_VALUES } from "./agent-output.ts"; -import { helpDocFor } from "./subcommand-flag-suggestions.ts"; +import { visibleOperandsOf } from "./subcommand-flag-suggestions.ts"; export interface RemoteArgvLeaf { /** Command-path segments argv names ahead of the leaf's own operands. */ @@ -126,8 +126,9 @@ const declaresRemoteFlag = (command: Command.Command.Any): boolean => }); /** - * The operand capacity of the command `path` resolves to, or `undefined` when it does not accept - * `--remote` (as its own flag, or as a persistent flag of an ancestor such as `seed`). + * The operand capacity of the command `path` resolves to (a hidden positional counts for nothing), + * or `undefined` when it does not accept `--remote` (as its own flag, or as a persistent flag of an + * ancestor such as `seed`). */ export function remoteOperandCapacity( rootCommand: Command.Command.Any, @@ -145,6 +146,6 @@ export function remoteOperandCapacity( const leaf = commands[commands.length - 1]; if (leaf === undefined) return undefined; if (!commands.some(declaresRemoteFlag)) return undefined; - const operands = helpDocFor(leaf, path)?.args ?? []; + const operands = visibleOperandsOf(leaf); return operands.some((operand) => operand.variadic) ? Infinity : operands.length; } diff --git a/apps/cli/src/shared/cli/remote-argv.unit.test.ts b/apps/cli/src/shared/cli/remote-argv.unit.test.ts index 121b1fa35a..77aae6c86e 100644 --- a/apps/cli/src/shared/cli/remote-argv.unit.test.ts +++ b/apps/cli/src/shared/cli/remote-argv.unit.test.ts @@ -247,15 +247,25 @@ describe("prepareCliArgs argv", () => { expected: ["seed", "--remote", "buckets"], }, { - name: "leaves a branch-shaped word for gen types's legacy language operand", + name: "joins a branch-shaped word for gen types, whose legacy language operand is hidden", args: ["gen", "types", "--remote", "feature-x"], - expected: ["gen", "types", "--remote", "feature-x"], + expected: ["gen", "types", "--remote=feature-x"], }, { - name: "joins a ref for gen types despite its legacy language operand", + name: "joins a ref for gen types", args: ["gen", "types", "--remote", REF], expected: ["gen", "types", `--remote=${REF}`], }, + { + name: "keeps gen types's legacy language written before a bare --remote", + args: ["gen", "types", "typescript", "--remote"], + expected: ["gen", "types", "typescript", "--remote"], + }, + { + name: "joins a branch-shaped word after gen types's legacy language", + args: ["gen", "types", "typescript", "--remote", "feature-x"], + expected: ["gen", "types", "typescript", "--remote=feature-x"], + }, { name: "leaves commands without a remote flag alone", args: ["link", "--remote", "feature-x"], @@ -288,14 +298,12 @@ describe("prepareCliArgs bareRemoteOperand on the real command tree", () => { expect(operandOf(["db", "pull", "--remote", "feature-x"])).toBe("feature-x"); }); - it("reads gen types's legacy language operand, which may be a branch", () => { - expect(operandOf(["gen", "types", "--remote", "feature-x"])).toBe("feature-x"); + it("reads no operand from gen types, whose legacy language operand is hidden", () => { + expect(operandOf(["gen", "types", "--remote", "feature-x"])).toBeUndefined(); }); - it("lists gen types's legacy language operand among its placeholders", () => { - expect(prepareCliArgs(root, ["gen", "types", "--remote"]).operandPlaceholders?.()).toEqual([ - "<language>", - ]); + it("leaves gen types's hidden legacy language operand out of its placeholders", () => { + expect(prepareCliArgs(root, ["gen", "types", "--remote"]).operandPlaceholders?.()).toEqual([]); }); it("finds nothing once every word is joined into --remote=<word>", () => { diff --git a/apps/cli/src/shared/cli/subcommand-flag-suggestions.ts b/apps/cli/src/shared/cli/subcommand-flag-suggestions.ts index debfc6cf19..a3ba680193 100644 --- a/apps/cli/src/shared/cli/subcommand-flag-suggestions.ts +++ b/apps/cli/src/shared/cli/subcommand-flag-suggestions.ts @@ -1,6 +1,6 @@ import { Option, Predicate } from "effect"; import type { CliError, Command, HelpDoc, Param } from "effect/cli"; -import { unwrapToSingleParam } from "../../command-internal/param-introspection.ts"; +import { unwrapParam, unwrapToSingleParam } from "../../command-internal/param-introspection.ts"; import { REMOTE_FLAG_NAME } from "../../command-internal/target/remote-vocabulary.ts"; import { isRemoteFlag } from "../../command-internal/target/target-flags.ts"; import { readTargetPolicy } from "../../command-internal/target/target-policy.ts"; @@ -58,13 +58,25 @@ export function helpDocFor( return hasHelpDoc(command) ? command.buildHelpDoc(commandPath) : undefined; } +/** The operands a command takes, leaving out a hidden positional: it still parses but is never offered or counted. */ +export function visibleOperandsOf( + command: Command.Command.Any, +): ReadonlyArray<{ readonly name: string; readonly variadic: boolean }> { + return commandInternals(command).config.arguments.flatMap((param) => { + const unwrapped = unwrapParam(param); + return unwrapped === undefined || unwrapped.single.hidden + ? [] + : [{ name: unwrapped.single.name, variadic: unwrapped.isVariadic }]; + }); +} + /** The operand placeholders the command at `path` lists in its help, e.g. `<NAME=VALUE>...`. */ export function operandPlaceholdersAt( rootCommand: Command.Command.Any, path: ReadonlyArray<string>, ): ReadonlyArray<string> { const command = findCommand(rootCommand, path.slice(1)); - const operands = (command === undefined ? undefined : helpDocFor(command, path))?.args ?? []; + const operands = command === undefined ? [] : visibleOperandsOf(command); return operands.map((operand) => `<${operand.name}>${operand.variadic ? "..." : ""}`); } 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 6aac105ad5..6fc63a1ae6 100644 --- a/docs/adr/0033-target-selection-and-hosted-mutation-consent.md +++ b/docs/adr/0033-target-selection-and-hosted-mutation-consent.md @@ -79,7 +79,8 @@ makes a `selectable` command remote; it is only a ref source once `--remote` is alone is enough for the `default; pass --remote` hint. An unresolvable remote target fails. The CLI never falls back between local and remote. `--remote=<value>` always parses. `--remote <value>` is rewritten to `--remote=<value>` before -parsing when the command takes no positional, when the token is a ref or URL, or when the command +parsing when the command takes no positional (a hidden one, such as `gen types`'s legacy language, +does not count), when the token is a ref or URL, or when the command already has more bare tokens than positionals. The one ambiguous shape, a bare `--remote` directly followed by a token the command took as a positional, is checked against the linked project's branches before anything runs. A token that exactly matches a branch, is within a small edit From dc1c292c472a6bc7e7c4f3a268b9ede4515f7842 Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Mon, 19 Oct 2026 17:21:47 +0100 Subject: [PATCH 170/291] fix(cli): reject a keyword connection string given to db diff --from or --to classifyExplicitRef no longer turns it into a branch name, so the value is never echoed in a branch-not-found or not-linked message. The error points at --from=<url> or --to=<url>. --- apps/cli/src/commands/db/diff/SIDE_EFFECTS.md | 4 ++- apps/cli/src/commands/db/diff/diff.errors.ts | 5 +-- .../cli/src/commands/db/diff/diff.explicit.ts | 14 ++++++-- .../db/diff/diff.explicit.unit.test.ts | 7 ++++ apps/cli/src/commands/db/diff/diff.handler.ts | 4 +++ .../commands/db/diff/diff.integration.test.ts | 35 +++++++++++++++++++ 6 files changed, 64 insertions(+), 5 deletions(-) diff --git a/apps/cli/src/commands/db/diff/SIDE_EFFECTS.md b/apps/cli/src/commands/db/diff/SIDE_EFFECTS.md index f1c6757171..7d39020e96 100644 --- a/apps/cli/src/commands/db/diff/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/db/diff/SIDE_EFFECTS.md @@ -252,7 +252,9 @@ transaction metadata. - **Explicit `--from`/`--to`.** Each endpoint is one of `local`, `migrations`, `remote` (the linked project), a project ref, a Postgres URL, or a branch name or UUID of the linked project (classified like a `--remote` value, so a branch named like a keyword is reached by its ref or - UUID). In `--from`/`--to`, `remote` is always the linked project. `--from linked` / `--to linked` + UUID). A keyword connection string (`host=… password=…`) fails with `--from takes local, migrations, +remote, a project ref, a branch name or a postgresql:// URL; convert a connection string to a +postgresql:// URL and pass --from=<url>.` (`--to` likewise) and is never echoed. In `--from`/`--to`, `remote` is always the linked project. `--from linked` / `--to linked` still work and warn `--from linked is deprecated and will be removed in v4. Use --from remote instead.` A `--remote` selector (or an alias: `--linked`, `--project-ref`, `--db-url`, `--local`) given alongside fails with `--remote doesn't apply with diff --git a/apps/cli/src/commands/db/diff/diff.errors.ts b/apps/cli/src/commands/db/diff/diff.errors.ts index b1fb373357..7847562e3e 100644 --- a/apps/cli/src/commands/db/diff/diff.errors.ts +++ b/apps/cli/src/commands/db/diff/diff.errors.ts @@ -20,8 +20,9 @@ export class DbDiffEngineConflictError extends Data.TaggedError("DbDiffEngineCon } /** - * Explicit diff mode got only one of `--from` / `--to`, or a target selector (`--remote` and its - * aliases), which it doesn't take; message text is an established output contract. + * Explicit diff mode got only one of `--from` / `--to`, a keyword connection string for one of + * them, or a target selector (`--remote` and its aliases), which it doesn't take; message text is + * an established output contract. */ export class DbDiffExplicitFlagsError extends Data.TaggedError("DbDiffExplicitFlagsError")<{ readonly message: string; diff --git a/apps/cli/src/commands/db/diff/diff.explicit.ts b/apps/cli/src/commands/db/diff/diff.explicit.ts index 4b318eac3a..317debb1f8 100644 --- a/apps/cli/src/commands/db/diff/diff.explicit.ts +++ b/apps/cli/src/commands/db/diff/diff.explicit.ts @@ -1,5 +1,8 @@ +import { Result } from "effect"; + import { classifyNamedRemote, + parseNamedRemote, type NamedRemoteValue, } from "../../../command-internal/target/remote-value.ts"; @@ -8,12 +11,14 @@ export type ExplicitRef = | { readonly _tag: "Local" } | { readonly _tag: "Migrations" } | { readonly _tag: "Remote"; readonly viaDeprecatedLinked: boolean } + | { readonly _tag: "KeywordDsn" } | NamedRemoteValue; /** * `local`, `migrations` and `remote` (the linked project) are keywords; `linked` is the * deprecated spelling of `remote`. Anything else is classified like a `--remote` value, so a - * branch that shares a keyword's name is reached by its ref or UUID. + * branch that shares a keyword's name is reached by its ref or UUID. A keyword connection string + * is kept apart so it is never echoed as a branch name. */ export function classifyExplicitRef(ref: string): ExplicitRef { switch (ref) { @@ -25,7 +30,12 @@ export function classifyExplicitRef(ref: string): ExplicitRef { return { _tag: "Remote", viaDeprecatedLinked: false }; case "linked": return { _tag: "Remote", viaDeprecatedLinked: true }; - default: + default: { + const parsed = parseNamedRemote(ref); + if (Result.isFailure(parsed) && parsed.failure._tag === "KeywordDsn") { + return { _tag: "KeywordDsn" }; + } return classifyNamedRemote(ref); + } } } diff --git a/apps/cli/src/commands/db/diff/diff.explicit.unit.test.ts b/apps/cli/src/commands/db/diff/diff.explicit.unit.test.ts index 980e173345..99bce9c247 100644 --- a/apps/cli/src/commands/db/diff/diff.explicit.unit.test.ts +++ b/apps/cli/src/commands/db/diff/diff.explicit.unit.test.ts @@ -28,6 +28,13 @@ describe("classifyExplicitRef", () => { expect(classifyExplicitRef(value)).toEqual(expected); }); + it.each(["host=h password=x", "dbname=postgres", "password=x"])( + "keeps the keyword connection string %s apart from a branch name", + (value) => { + expect(classifyExplicitRef(value)).toEqual({ _tag: "KeywordDsn" }); + }, + ); + it("reaches a branch named like a keyword through its UUID, not the keyword", () => { expect(classifyExplicitRef("Remote")).toEqual({ _tag: "Branch", name: "Remote" }); expect(classifyExplicitRef(UUID)).toEqual({ _tag: "Branch", name: UUID }); diff --git a/apps/cli/src/commands/db/diff/diff.handler.ts b/apps/cli/src/commands/db/diff/diff.handler.ts index 7f4d6cb442..6410c59454 100644 --- a/apps/cli/src/commands/db/diff/diff.handler.ts +++ b/apps/cli/src/commands/db/diff/diff.handler.ts @@ -320,6 +320,10 @@ export const dbDiff = Effect.fn("db.diff")(function* (flags: DbDiffFlags) { case "Ref": case "Branch": return yield* hostedEndpoint(explicit); + case "KeywordDsn": + return yield* new DbDiffExplicitFlagsError({ + message: `${flag} takes local, migrations, remote, a project ref, a branch name or a postgresql:// URL; convert a connection string to a postgresql:// URL and pass ${flag}=<url>.`, + }); case "Migrations": // Preserve resolution order: the migrations shadow/catalog config must reflect // only refs resolved before this endpoint. 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 3795591469..7abb53175a 100644 --- a/apps/cli/src/commands/db/diff/diff.integration.test.ts +++ b/apps/cli/src/commands/db/diff/diff.integration.test.ts @@ -1473,6 +1473,41 @@ describe("db diff", () => { }); }); + it.effect.each( + (["from", "to"] as const).map((side) => ({ + side, + other: side === "from" ? "to" : "from", + })), + )( + "--$side <keyword dsn> is rejected without echoing the connection string", + ({ side, other }) => { + const branchApi = branchListApi(); + const s = setup(tmp.current, { + isLocal: false, + projectId: Option.some(VALID_REF), + diffSql: "create table e ();\n", + }); + return Effect.gen(function* () { + const exit = yield* dbDiff( + flags({ [side]: Option.some("host=h password=x"), [other]: Option.some("migrations") }), + ).pipe(Effect.provide(Layer.merge(s.layer, branchApi.layer)), Effect.exit); + + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const error = Cause.squash(exit.cause); + expect(error).toMatchObject({ + _tag: "DbDiffExplicitFlagsError", + message: expect.stringContaining(`pass --${side}=<url>`), + }); + expect(Cause.pretty(exit.cause)).not.toContain("password=x"); + } + expect(`${s.out.stdoutText}${s.out.stderrText}`).not.toContain("password=x"); + expect(branchApi.requests).toEqual([]); + expect(s.resolverCalls).toEqual([]); + }); + }, + ); + it.effect("--from <branch> asks the linked project's branch list", () => { const branchApi = branchListApi(); const s = setup(tmp.current, { From 8abebd8bffa5a551ec94053adc71e187a1234ed1 Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Mon, 19 Oct 2026 17:23:24 +0100 Subject: [PATCH 171/291] fix(cli): reject a non-postgres URL given to db diff --from or --to classifyExplicitRef returns the rejection --remote gives a value instead of reading it as a branch name, so a URL with credentials is never echoed. A project's own https URL names --from=<ref> or --to=<ref>. --- .../command-internal/target/remote-value.ts | 2 +- apps/cli/src/commands/db/diff/SIDE_EFFECTS.md | 8 ++-- apps/cli/src/commands/db/diff/diff.errors.ts | 4 +- .../cli/src/commands/db/diff/diff.explicit.ts | 14 +++---- .../db/diff/diff.explicit.unit.test.ts | 10 +++++ apps/cli/src/commands/db/diff/diff.handler.ts | 13 +++++- .../commands/db/diff/diff.integration.test.ts | 40 +++++++++++++++++++ 7 files changed, 76 insertions(+), 15 deletions(-) diff --git a/apps/cli/src/command-internal/target/remote-value.ts b/apps/cli/src/command-internal/target/remote-value.ts index b92958d6fd..32214cc1ab 100644 --- a/apps/cli/src/command-internal/target/remote-value.ts +++ b/apps/cli/src/command-internal/target/remote-value.ts @@ -76,7 +76,7 @@ export const parseRemoteValue = (raw: string): Result.Result<RemoteValue, Remote }; /** {@link parseNamedRemote} for a caller that reads a value the flag's own parser already accepted: a rejected value falls back to a branch name. */ -export const classifyNamedRemote = (raw: string): NamedRemoteValue => +const classifyNamedRemote = (raw: string): NamedRemoteValue => Result.getOrElse(parseNamedRemote(raw), (): NamedRemoteValue => ({ _tag: "Branch", name: raw.trim(), diff --git a/apps/cli/src/commands/db/diff/SIDE_EFFECTS.md b/apps/cli/src/commands/db/diff/SIDE_EFFECTS.md index 7d39020e96..3e264fea09 100644 --- a/apps/cli/src/commands/db/diff/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/db/diff/SIDE_EFFECTS.md @@ -252,9 +252,11 @@ transaction metadata. - **Explicit `--from`/`--to`.** Each endpoint is one of `local`, `migrations`, `remote` (the linked project), a project ref, a Postgres URL, or a branch name or UUID of the linked project (classified like a `--remote` value, so a branch named like a keyword is reached by its ref or - UUID). A keyword connection string (`host=… password=…`) fails with `--from takes local, migrations, -remote, a project ref, a branch name or a postgresql:// URL; convert a connection string to a -postgresql:// URL and pass --from=<url>.` (`--to` likewise) and is never echoed. In `--from`/`--to`, `remote` is always the linked project. `--from linked` / `--to linked` + UUID). A value `--remote` rejects fails and is never echoed: a keyword connection string + (`host=… password=…`) with `--from takes local, migrations, remote, a project ref, a branch name or +a postgresql:// URL; convert a connection string to a postgresql:// URL and pass --from=<url>.`, and + a non-postgres URL with the same first sentence (naming `--from=<ref>` for a project's own + `https://<ref>.supabase.co` URL); `--to` likewise. In `--from`/`--to`, `remote` is always the linked project. `--from linked` / `--to linked` still work and warn `--from linked is deprecated and will be removed in v4. Use --from remote instead.` A `--remote` selector (or an alias: `--linked`, `--project-ref`, `--db-url`, `--local`) given alongside fails with `--remote doesn't apply with diff --git a/apps/cli/src/commands/db/diff/diff.errors.ts b/apps/cli/src/commands/db/diff/diff.errors.ts index 7847562e3e..bfdea3c114 100644 --- a/apps/cli/src/commands/db/diff/diff.errors.ts +++ b/apps/cli/src/commands/db/diff/diff.errors.ts @@ -20,8 +20,8 @@ export class DbDiffEngineConflictError extends Data.TaggedError("DbDiffEngineCon } /** - * Explicit diff mode got only one of `--from` / `--to`, a keyword connection string for one of - * them, or a target selector (`--remote` and its aliases), which it doesn't take; message text is + * Explicit diff mode got only one of `--from` / `--to`, a non-postgres URL or keyword connection + * string for one of them, or a target selector (`--remote` and its aliases), which it doesn't take; message text is * an established output contract. */ export class DbDiffExplicitFlagsError extends Data.TaggedError("DbDiffExplicitFlagsError")<{ diff --git a/apps/cli/src/commands/db/diff/diff.explicit.ts b/apps/cli/src/commands/db/diff/diff.explicit.ts index 317debb1f8..13b93aff75 100644 --- a/apps/cli/src/commands/db/diff/diff.explicit.ts +++ b/apps/cli/src/commands/db/diff/diff.explicit.ts @@ -1,8 +1,8 @@ import { Result } from "effect"; import { - classifyNamedRemote, parseNamedRemote, + type NamedRemoteRejection, type NamedRemoteValue, } from "../../../command-internal/target/remote-value.ts"; @@ -11,14 +11,15 @@ export type ExplicitRef = | { readonly _tag: "Local" } | { readonly _tag: "Migrations" } | { readonly _tag: "Remote"; readonly viaDeprecatedLinked: boolean } - | { readonly _tag: "KeywordDsn" } + | NamedRemoteRejection | NamedRemoteValue; /** * `local`, `migrations` and `remote` (the linked project) are keywords; `linked` is the * deprecated spelling of `remote`. Anything else is classified like a `--remote` value, so a - * branch that shares a keyword's name is reached by its ref or UUID. A keyword connection string - * is kept apart so it is never echoed as a branch name. + * branch that shares a keyword's name is reached by its ref or UUID. A value `--remote` rejects (a + * non-postgres URL, a keyword connection string) is returned as that rejection, never as a branch + * name, so it is not echoed. */ export function classifyExplicitRef(ref: string): ExplicitRef { switch (ref) { @@ -32,10 +33,7 @@ export function classifyExplicitRef(ref: string): ExplicitRef { return { _tag: "Remote", viaDeprecatedLinked: true }; default: { const parsed = parseNamedRemote(ref); - if (Result.isFailure(parsed) && parsed.failure._tag === "KeywordDsn") { - return { _tag: "KeywordDsn" }; - } - return classifyNamedRemote(ref); + return Result.isFailure(parsed) ? parsed.failure : parsed.success; } } } diff --git a/apps/cli/src/commands/db/diff/diff.explicit.unit.test.ts b/apps/cli/src/commands/db/diff/diff.explicit.unit.test.ts index 99bce9c247..9e0fd2cd6c 100644 --- a/apps/cli/src/commands/db/diff/diff.explicit.unit.test.ts +++ b/apps/cli/src/commands/db/diff/diff.explicit.unit.test.ts @@ -28,6 +28,16 @@ describe("classifyExplicitRef", () => { expect(classifyExplicitRef(value)).toEqual(expected); }); + it.each([ + { value: "https://u:p@db.example.com/x", expected: { _tag: "ForeignUrl", ref: undefined } }, + { + value: `https://${REF}.supabase.co`, + expected: { _tag: "ForeignUrl", ref: REF }, + }, + ])("keeps the non-postgres URL $value apart from a branch name", ({ value, expected }) => { + expect(classifyExplicitRef(value)).toEqual(expected); + }); + it.each(["host=h password=x", "dbname=postgres", "password=x"])( "keeps the keyword connection string %s apart from a branch name", (value) => { diff --git a/apps/cli/src/commands/db/diff/diff.handler.ts b/apps/cli/src/commands/db/diff/diff.handler.ts index 6410c59454..098b7de5db 100644 --- a/apps/cli/src/commands/db/diff/diff.handler.ts +++ b/apps/cli/src/commands/db/diff/diff.handler.ts @@ -114,6 +114,9 @@ const declarativeBaselineNote = (displayPath: string, nextSteps: string) => const declarativeFilesIgnoredNote = (displayPath: string, nextSteps: string) => `Note: db diff compares supabase/migrations with the target database; declarative schema files in ${displayPath} are not read. ${nextSteps}\n`; +const EXPLICIT_REF_VALUES_MESSAGE = (flag: "--from" | "--to") => + `${flag} takes local, migrations, remote, a project ref, a branch name or a postgresql:// URL`; + const EXPLICIT_SIDE_FOR_SELECTOR = { "--remote": "remote", "--linked": "remote", @@ -320,9 +323,17 @@ export const dbDiff = Effect.fn("db.diff")(function* (flags: DbDiffFlags) { case "Ref": case "Branch": return yield* hostedEndpoint(explicit); + case "ForeignUrl": + return yield* new DbDiffExplicitFlagsError({ + message: `${EXPLICIT_REF_VALUES_MESSAGE(flag)}${ + explicit.ref === undefined + ? "." + : `; to target that project, use ${flag}=${explicit.ref}.` + }`, + }); case "KeywordDsn": return yield* new DbDiffExplicitFlagsError({ - message: `${flag} takes local, migrations, remote, a project ref, a branch name or a postgresql:// URL; convert a connection string to a postgresql:// URL and pass ${flag}=<url>.`, + message: `${EXPLICIT_REF_VALUES_MESSAGE(flag)}; convert a connection string to a postgresql:// URL and pass ${flag}=<url>.`, }); case "Migrations": // Preserve resolution order: the migrations shadow/catalog config must reflect 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 7abb53175a..21e2f07cc2 100644 --- a/apps/cli/src/commands/db/diff/diff.integration.test.ts +++ b/apps/cli/src/commands/db/diff/diff.integration.test.ts @@ -1508,6 +1508,46 @@ describe("db diff", () => { }, ); + it.effect.each([ + { + value: "https://u:p@db.example.com/x", + message: + "--from takes local, migrations, remote, a project ref, a branch name or a postgresql:// URL.", + }, + { + value: "https://flagflagflagflagflag.supabase.co", + message: + "--from takes local, migrations, remote, a project ref, a branch name or a postgresql:// URL; to target that project, use --from=flagflagflagflagflag.", + }, + ])( + "--from $value is rejected as a non-postgres URL without echoing it", + ({ value, message }) => { + const branchApi = branchListApi(); + const s = setup(tmp.current, { + isLocal: false, + projectId: Option.some(VALID_REF), + diffSql: "create table e ();\n", + }); + return Effect.gen(function* () { + const exit = yield* dbDiff( + flags({ from: Option.some(value), to: Option.some("migrations") }), + ).pipe(Effect.provide(Layer.merge(s.layer, branchApi.layer)), Effect.exit); + + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(Cause.squash(exit.cause)).toMatchObject({ + _tag: "DbDiffExplicitFlagsError", + message, + }); + expect(Cause.pretty(exit.cause)).not.toContain("u:p"); + } + expect(`${s.out.stdoutText}${s.out.stderrText}`).not.toContain("u:p"); + expect(branchApi.requests).toEqual([]); + expect(s.resolverCalls).toEqual([]); + }); + }, + ); + it.effect("--from <branch> asks the linked project's branch list", () => { const branchApi = branchListApi(); const s = setup(tmp.current, { From cec0efa43c6322358e4b408d6159c74ff927d641 Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Tue, 20 Oct 2026 09:16:38 +0100 Subject: [PATCH 172/291] test(cli): escape every regex metacharacter in the target help hint pattern --- apps/cli/src/shared/output/target-help.unit.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/cli/src/shared/output/target-help.unit.test.ts b/apps/cli/src/shared/output/target-help.unit.test.ts index 4e11a30fec..e632fcec21 100644 --- a/apps/cli/src/shared/output/target-help.unit.test.ts +++ b/apps/cli/src/shared/output/target-help.unit.test.ts @@ -180,7 +180,7 @@ describe("help value hints per command variant", () => { expect(text).toContain(`TARGET\n ${target}\n`); expect(row).toContain(`--remote[=${hint}]`); expect(row).toContain(`--remote=${hint}`); - expect(row).not.toMatch(new RegExp(`\\]\\s+${hint.replace(/[|[\]]/g, "\\$&")}\\s`)); + expect(row).not.toMatch(new RegExp(`\\]\\s+${hint.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\s`)); }, ); }); From 92211145d4df7159b1e9a423cf12fe60fb020bde Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Tue, 20 Oct 2026 09:25:37 +0100 Subject: [PATCH 173/291] test(cli): port the resolveRef tests to the effect-lint helpers --- .../src/config/project-ref.layer.unit.test.ts | 68 +++++++++++-------- .../shared/output/target-help.unit.test.ts | 4 +- 2 files changed, 43 insertions(+), 29 deletions(-) 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 94debfe6ca..433b83d21e 100644 --- a/apps/cli/src/config/project-ref.layer.unit.test.ts +++ b/apps/cli/src/config/project-ref.layer.unit.test.ts @@ -336,15 +336,23 @@ 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 writeLinkedCache = Effect.fnUntraced(function* ( + workdir: string, + cache: { ref: string; name?: string }, + ) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = path.join(workdir, "supabase", ".temp"); + yield* fs.makeDirectory(tempDir, { recursive: true }); + yield* fs.writeFileString( + path.join(tempDir, "linked-project.json"), + yield* encodeJson(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 }); + const { layer, out } = makeLayer({ workdir: tempRoot.current }); return Effect.gen(function* () { const { resolveRef } = yield* ProjectRefResolver; const resolved = yield* resolveRef(Option.some(VALID_REF)); @@ -354,7 +362,7 @@ describe("projectRefLayer", () => { }); it.effect("reports SUPABASE_PROJECT_ID as the env source", () => { - const { layer } = makeLayer({ workdir: tempRoot, projectId: VALID_REF }); + const { layer } = makeLayer({ workdir: tempRoot.current, projectId: VALID_REF }); return Effect.gen(function* () { const { resolveRef } = yield* ProjectRefResolver; const resolved = yield* resolveRef(Option.none()); @@ -363,10 +371,10 @@ describe("projectRefLayer", () => { }); 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 }); + const { layer, out } = makeLayer({ workdir: tempRoot.current }); return Effect.gen(function* () { + yield* writeRefFile(tempRoot.current, VALID_REF); + yield* writeLinkedCache(tempRoot.current, { ref: VALID_REF, name: "alpha" }); const { resolveRef } = yield* ProjectRefResolver; const resolved = yield* resolveRef(Option.none()); expect(resolved).toEqual({ ref: VALID_REF, source: "file", matchesLinkedRef: true }); @@ -377,10 +385,10 @@ describe("projectRefLayer", () => { 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 }); + const { layer, out } = makeLayer({ workdir: tempRoot.current }); return Effect.gen(function* () { + yield* writeRefFile(tempRoot.current, ANOTHER_REF); + yield* writeLinkedCache(tempRoot.current, { ref: VALID_REF, name: "alpha" }); const { resolveRef } = yield* ProjectRefResolver; yield* resolveRef(Option.none()); expect(out.stderrText).toBe(banner(`project ${ANOTHER_REF}`)); @@ -389,10 +397,10 @@ describe("projectRefLayer", () => { ); 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 }); + const { layer, out } = makeLayer({ workdir: tempRoot.current }); return Effect.gen(function* () { + yield* writeRefFile(tempRoot.current, VALID_REF); + yield* writeLinkedCache(tempRoot.current, { ref: VALID_REF, name: "alpha" }); const { resolveRef } = yield* ProjectRefResolver; const resolved = yield* resolveRef(Option.some(ANOTHER_REF)); expect(resolved.matchesLinkedRef).toBe(false); @@ -401,9 +409,9 @@ describe("projectRefLayer", () => { }); 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 }); + const { layer, out } = makeLayer({ workdir: tempRoot.current, projectId: ANOTHER_REF }); return Effect.gen(function* () { + yield* writeRefFile(tempRoot.current, VALID_REF); const { resolveRef } = yield* ProjectRefResolver; yield* resolveRef(Option.none()); expect(out.stderrText).toBe(banner(`project ${ANOTHER_REF} (not the linked project)`)); @@ -411,9 +419,9 @@ describe("projectRefLayer", () => { }); 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 }); + const { layer } = makeLayer({ workdir: tempRoot.current }); return Effect.gen(function* () { + yield* writeRefFile(tempRoot.current, VALID_REF); const { resolveRef } = yield* ProjectRefResolver; const resolved = yield* resolveRef(Option.some(VALID_REF)); expect(resolved).toEqual({ ref: VALID_REF, source: "flag", matchesLinkedRef: true }); @@ -425,7 +433,7 @@ describe("projectRefLayer", () => { { id: ANOTHER_REF, name: "beta", organization_slug: "acme", region: "eu-west-1" }, ]; const { layer } = makeLayer({ - workdir: tempRoot, + workdir: tempRoot.current, stdinIsTty: true, projects, promptSelectResponses: [ANOTHER_REF], @@ -442,7 +450,7 @@ describe("projectRefLayer", () => { { id: ANOTHER_REF, name: "beta", organization_slug: "acme", region: "eu-west-1" }, ]; const { layer, out } = makeLayer({ - workdir: tempRoot, + workdir: tempRoot.current, stdinIsTty: true, projects, promptSelectResponses: [ANOTHER_REF], @@ -455,7 +463,7 @@ describe("projectRefLayer", () => { }); it.effect("announces once when the same command resolves twice", () => { - const { layer, out } = makeLayer({ workdir: tempRoot }); + const { layer, out } = makeLayer({ workdir: tempRoot.current }); return Effect.gen(function* () { const { resolve } = yield* ProjectRefResolver; yield* resolve(Option.some(VALID_REF)); @@ -465,20 +473,24 @@ describe("projectRefLayer", () => { }); 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 }); + const { layer } = makeLayer({ workdir: tempRoot.current }); return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* fs.makeDirectory(path.join(tempRoot.current, "supabase", ".temp", "project-ref"), { + recursive: true, + }); const { resolveRef } = yield* ProjectRefResolver; const exit = yield* Effect.exit(resolveRef(Option.none())); expect(Exit.isFailure(exit)).toBe(true); - const errorJson = JSON.stringify(exit); + const errorJson = yield* encodeJson(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 }); + const { layer, out } = makeLayer({ workdir: tempRoot.current }); return Effect.gen(function* () { const { resolveRef } = yield* ProjectRefResolver; const exit = yield* Effect.exit(resolveRef(Option.none())); @@ -488,7 +500,7 @@ describe("projectRefLayer", () => { }); it.effect("announces the target for resolveForLink", () => { - const { layer, out } = makeLayer({ workdir: tempRoot }); + const { layer, out } = makeLayer({ workdir: tempRoot.current }); return Effect.gen(function* () { const { resolveForLink } = yield* ProjectRefResolver; yield* resolveForLink(Option.some(VALID_REF)); diff --git a/apps/cli/src/shared/output/target-help.unit.test.ts b/apps/cli/src/shared/output/target-help.unit.test.ts index e632fcec21..31e04b149f 100644 --- a/apps/cli/src/shared/output/target-help.unit.test.ts +++ b/apps/cli/src/shared/output/target-help.unit.test.ts @@ -180,7 +180,9 @@ describe("help value hints per command variant", () => { expect(text).toContain(`TARGET\n ${target}\n`); expect(row).toContain(`--remote[=${hint}]`); expect(row).toContain(`--remote=${hint}`); - expect(row).not.toMatch(new RegExp(`\\]\\s+${hint.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\s`)); + expect(row).not.toMatch( + new RegExp(`\\]\\s+${hint.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\s`), + ); }, ); }); From 6438015339a255ef85a00372b5499b8152509e99 Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Tue, 20 Oct 2026 09:27:51 +0100 Subject: [PATCH 174/291] test(cli): bring the target-flag telemetry tests under effect lint --- .../telemetry/command-telemetry.unit.test.ts | 54 ++++++++++--------- 1 file changed, 30 insertions(+), 24 deletions(-) diff --git a/apps/cli/src/telemetry/command-telemetry.unit.test.ts b/apps/cli/src/telemetry/command-telemetry.unit.test.ts index c4be542c83..d123760b05 100644 --- a/apps/cli/src/telemetry/command-telemetry.unit.test.ts +++ b/apps/cli/src/telemetry/command-telemetry.unit.test.ts @@ -614,12 +614,14 @@ describe("withCommandTelemetry", () => { const analytics = mockContextualAnalytics(); return Effect.void.pipe( withCommandTelemetry({ flags: { remote }, safeFlags }), - Effect.provide(analytics.layer), - Effect.provide(mockProcessControl().layer), - Effect.provide(mockOutput({ format: "text" }).layer), - Effect.provide(Stdio.layerTest({ args: Effect.succeed(["functions", "deploy", arg]) })), Effect.provide( - commandRuntimeLayer(["functions", "deploy"]).pipe(Layer.provide(BunCrypto.layer)), + Layer.mergeAll( + analytics.layer, + mockProcessControl().layer, + mockOutput({ format: "text" }).layer, + Stdio.layerTest({ args: Effect.succeed(["functions", "deploy", arg]) }), + commandRuntimeLayer(["functions", "deploy"]).pipe(Layer.provide(BunCrypto.layer)), + ), ), Effect.map(() => analytics.captured[0]), ); @@ -654,8 +656,9 @@ describe("withCommandTelemetry", () => { Effect.gen(function* () { const event = yield* recordedFlags(Option.some(remote), arg); expect(event?.properties.flags).toEqual({ remote: expected }); - expect(JSON.stringify(event)).not.toContain(DB_URL); - expect(JSON.stringify(event)).not.toContain("customer-acme"); + const eventJson = yield* encodeJson(event); + expect(eventJson).not.toContain(DB_URL); + expect(eventJson).not.toContain("customer-acme"); }), ); @@ -667,16 +670,16 @@ describe("withCommandTelemetry", () => { flags: { remote: Option.some<RemoteValue>({ _tag: "Linked" }), projectRef: REF }, safeFlags: ["project-ref"], }), - Effect.provide(analytics.layer), - Effect.provide(mockProcessControl().layer), - Effect.provide(mockOutput({ format: "text" }).layer), - Effect.provide( - Stdio.layerTest({ - args: Effect.succeed(["functions", "deploy", "--remote", "--project-ref", REF]), - }), - ), Effect.provide( - commandRuntimeLayer(["functions", "deploy"]).pipe(Layer.provide(BunCrypto.layer)), + Layer.mergeAll( + analytics.layer, + mockProcessControl().layer, + mockOutput({ format: "text" }).layer, + Stdio.layerTest({ + args: Effect.succeed(["functions", "deploy", "--remote", "--project-ref", REF]), + }), + commandRuntimeLayer(["functions", "deploy"]).pipe(Layer.provide(BunCrypto.layer)), + ), ), Effect.map(() => analytics.captured[0]), ); @@ -704,13 +707,15 @@ describe("withCommandTelemetry", () => { const analytics = mockContextualAnalytics(); return Effect.void.pipe( withCommandTelemetry({ flags }), - Effect.provide(analytics.layer), - Effect.provide(mockProcessControl().layer), - Effect.provide(mockOutput({ format: "text" }).layer), - Effect.provide(Layer.succeed(TargetPolicy, { group, consent: "none" })), - Effect.provide(Stdio.layerTest({ args: Effect.succeed(["functions", "list", ...args]) })), Effect.provide( - commandRuntimeLayer(["functions", "list"]).pipe(Layer.provide(BunCrypto.layer)), + Layer.mergeAll( + analytics.layer, + mockProcessControl().layer, + mockOutput({ format: "text" }).layer, + Layer.succeed(TargetPolicy, { group, consent: "none" }), + Stdio.layerTest({ args: Effect.succeed(["functions", "list", ...args]) }), + commandRuntimeLayer(["functions", "list"]).pipe(Layer.provide(BunCrypto.layer)), + ), ), Effect.map(() => analytics.captured[0]?.properties.flags), ); @@ -751,8 +756,9 @@ describe("withCommandTelemetry", () => { Effect.gen(function* () { const recorded = yield* recordedFlags("hosted", flags, args); expect(recorded).toEqual(expected); - expect(JSON.stringify(recorded)).not.toContain(DB_URL); - expect(JSON.stringify(recorded)).not.toContain("customer-acme"); + const recordedJson = yield* encodeJson(recorded); + expect(recordedJson).not.toContain(DB_URL); + expect(recordedJson).not.toContain("customer-acme"); }), ); From c18f4d490ce75281b04c00a0a66a03b55331b6e7 Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Tue, 20 Oct 2026 11:31:35 +0100 Subject: [PATCH 175/291] fix(cli): name the failing project in the network-ban recovery command The suggested network-bans remove run resolved its own target, so after db pull --remote=<other-ref> it pointed at the linked project instead of the one whose bans blocked the connection. --- .../db-config.integration.test.ts | 54 ++++++++++--------- .../src/command-internal/db-config.layer.ts | 6 +-- 2 files changed, 31 insertions(+), 29 deletions(-) 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 0eb23c974b..84335ff581 100644 --- a/apps/cli/src/command-internal/db-config.integration.test.ts +++ b/apps/cli/src/command-internal/db-config.integration.test.ts @@ -2077,32 +2077,34 @@ describe("dbConfigResolver.resolveConnection", () => { return { fiber, advance }; }); - 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(Number.POSITIVE_INFINITY); - return Effect.gen(function* () { - 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(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, 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(9); - expect(requests.some((request) => request.method === "DELETE")).toBe(false); - }).pipe(Effect.scoped, Effect.provide(BunServices.layer)); - }); + it.effect.each([{ matchesLinkedRef: false }, { matchesLinkedRef: true }])( + "reports the listed bans and names the project once the retries run out and removes nothing (matchesLinkedRef: $matchesLinkedRef)", + ({ matchesLinkedRef }) => { + const requests: Array<{ readonly method: string; readonly path: string }> = []; + const connection = failingConnect(Number.POSITIVE_INFINITY); + return Effect.gen(function* () { + yield* bannedRequests(requests, ["203.0.113.7", "198.51.100.2"]); + const { fiber, advance } = yield* forkResolve( + inputFor(linkedTarget(ref, { source: "flag", matchesLinkedRef })), + connection.layer, + ); + 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, 198.51.100.2"), + suggestion: `Re-run with --yes to remove them, or run supabase network-bans remove --remote=${ref} --db-unban-ip 203.0.113.7,198.51.100.2 --experimental --yes.`, + }), + ), + ); + 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 }> = []; diff --git a/apps/cli/src/command-internal/db-config.layer.ts b/apps/cli/src/command-internal/db-config.layer.ts index 59b554a69a..b20ee669d8 100644 --- a/apps/cli/src/command-internal/db-config.layer.ts +++ b/apps/cli/src/command-internal/db-config.layer.ts @@ -267,10 +267,10 @@ const removeBans = Effect.fnUntraced(function* (ref: string, addrs: ReadonlyArra .pipe(Effect.catch(unbanErrorMapper)); }); -const networkBannedError = (addrs: ReadonlyArray<string>) => +const networkBannedError = (ref: string, 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.`, + suggestion: `Re-run with --yes to remove them, or run supabase network-bans remove --remote=${ref} --db-unban-ip ${addrs.join(",")} --experimental --yes.`, }); // Verify-connect with backoff while the pooler refreshes the temp password. From attempt 3 on, look @@ -298,7 +298,7 @@ const waitForTempRole = Effect.fn("DbConfig.waitForTempRole")(function* ( if (n > MAX_RETRIES) { return Effect.fail( listedBans.length > 0 - ? networkBannedError(listedBans) + ? networkBannedError(ref, listedBans) : new Errors.DbConfigConnectTempRoleError({ message: `failed to connect as temp role: ${cause.message}`, suggestion: SUGGEST_ENV_VAR, From 930e3130f0c5e746639b2b3cab86281a918057f0 Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Tue, 20 Oct 2026 11:36:54 +0100 Subject: [PATCH 176/291] refactor(cli): drop the paste-ready example from the --remote deprecation warnings The warnings name the replacement flag only. The listing-failure text points at putting --remote last for the linked project. --- .../src/tests/target-selection.e2e.test.ts | 10 +- .../target/remote-resolution.ts | 26 +--- .../target/remote-resolution.unit.test.ts | 120 +----------------- ...resolve-project-target.integration.test.ts | 2 +- .../target/resolve-target.integration.test.ts | 14 +- .../target/target-deprecation.ts | 39 ++---- .../target/target-deprecation.unit.test.ts | 58 +++------ .../config/diff/diff.integration.test.ts | 2 +- .../config/pull/pull.integration.test.ts | 2 +- .../config/push/push.integration.test.ts | 2 +- .../deploy/deploy.integration.test.ts | 2 +- .../gen/types/types.integration.test.ts | 16 +-- .../db/inspect-query.integration.test.ts | 11 +- .../inspect/report/report.integration.test.ts | 11 +- .../migration/down/down.integration.test.ts | 11 +- .../migration/fetch/fetch.integration.test.ts | 11 +- .../migration/list/list.integration.test.ts | 11 +- .../repair/repair.integration.test.ts | 8 +- .../squash/squash.integration.test.ts | 10 +- .../migration/up/up.integration.test.ts | 11 +- .../notebooks/notebooks.integration.test.ts | 2 +- .../commands/pull/pull.integration.test.ts | 2 +- .../commands/seed/buckets/buckets.e2e.test.ts | 2 +- .../seed/buckets/buckets.integration.test.ts | 8 +- .../storage/cp/cp.integration.test.ts | 8 +- .../storage/ls/ls.integration.test.ts | 8 +- .../storage/mv/mv.integration.test.ts | 8 +- .../storage/rm/rm.integration.test.ts | 8 +- apps/cli/src/shared/cli/cli-args.service.ts | 2 - .../src/shared/cli/remote-argv.unit.test.ts | 4 - apps/cli/src/shared/cli/run.ts | 18 +-- .../shared/cli/subcommand-flag-suggestions.ts | 10 -- .../tests/helpers/hosted-target-selection.ts | 2 +- 33 files changed, 112 insertions(+), 347 deletions(-) diff --git a/apps/cli-e2e/src/tests/target-selection.e2e.test.ts b/apps/cli-e2e/src/tests/target-selection.e2e.test.ts index 5d9b2f510a..d6cdb51719 100644 --- a/apps/cli-e2e/src/tests/target-selection.e2e.test.ts +++ b/apps/cli-e2e/src/tests/target-selection.e2e.test.ts @@ -23,7 +23,7 @@ describe("deprecated target aliases", () => { const result = await run(["db", "advisors", "--type", "security", "--linked"]); expect(result.exitCode).toBe(0); expect(`${result.stdout}${result.stderr}`).toContain( - "--linked is deprecated and will be removed in v4. Use --remote instead: supabase db advisors --remote", + "--linked is deprecated and will be removed in v4. Use --remote instead", ); expect(result.stderr).toContain("No issues found"); }, @@ -33,7 +33,7 @@ describe("deprecated target aliases", () => { const result = await run(["db", "push", "--local"]); expect(result.exitCode).not.toBe(0); expect(`${result.stdout}${result.stderr}`).toContain( - "--local isn't needed (local is the default) and will be removed in v4: supabase db push", + "--local isn't needed (local is the default) and will be removed in v4", ); expect(result.stderr).toContain("Target: local stack"); expect(result.stderr).toContain("failed to connect to postgres"); @@ -45,7 +45,7 @@ describe("deprecated target aliases", () => { const result = await run(["inspect", "db", "db-stats", "--db-url", DEAD_DB_URL]); expect(result.exitCode).not.toBe(0); expect(`${result.stdout}${result.stderr}`).toContain( - "--db-url is deprecated and will be removed in v4. Use --remote=<db-url> instead: supabase inspect db db-stats --remote=<db-url>", + "--db-url is deprecated and will be removed in v4. Use --remote=<db-url> instead", ); expect(`${result.stdout}${result.stderr}`).not.toContain(DEAD_DB_URL); expect(result.stderr).toContain("Target: custom Postgres (localhost:1)"); @@ -58,7 +58,7 @@ describe("deprecated target aliases", () => { const result = await run(["secrets", "list", "--project-ref", projectRef]); expect(result.exitCode).toBe(0); expect(`${result.stdout}${result.stderr}`).toContain( - `--project-ref is deprecated and will be removed in v4. Use --remote=<ref> instead: supabase secrets list --remote=${projectRef}`, + `--project-ref is deprecated and will be removed in v4. Use --remote=<ref> instead`, ); }, ); @@ -70,7 +70,7 @@ describe("deprecated target aliases", () => { expect(result.exitCode).toBe(0); expect(result.stdout).toContain("export type Database"); expect(`${result.stdout}${result.stderr}`).toContain( - `--project-id is deprecated and will be removed in v4. Use --remote=<ref> instead: supabase gen types --remote=${projectRef}`, + `--project-id is deprecated and will be removed in v4. Use --remote=<ref> instead`, ); }, ); diff --git a/apps/cli/src/command-internal/target/remote-resolution.ts b/apps/cli/src/command-internal/target/remote-resolution.ts index be9313da02..85bc4f6c0b 100644 --- a/apps/cli/src/command-internal/target/remote-resolution.ts +++ b/apps/cli/src/command-internal/target/remote-resolution.ts @@ -20,12 +20,7 @@ import { closestByName } from "./closest-branch.ts"; import type { RemoteValue } from "./remote-value.ts"; import { stripUserinfo } from "./remote-vocabulary.ts"; import type { LinkedTarget } from "./resolve-target.ts"; -import { - type DeprecationCommand, - deprecationMessages, - remoteFlagText, - shellQuote, -} from "./target-deprecation.ts"; +import { deprecationMessages, remoteFlagText, shellQuote } from "./target-deprecation.ts"; import { describeLinkedTarget, renderBranchTarget } from "./target-identity.ts"; import { parseTargetSelector, @@ -43,24 +38,13 @@ export const warnTarget = Effect.fnUntraced(function* (message: string) { yield* output.raw(`${yellow(`Warning: ${message}`)}\n`, "stderr"); }); -const currentCommand = Effect.fnUntraced(function* () { - const runtime = yield* Effect.serviceOption(CommandRuntime); - if (Option.isNone(runtime)) return undefined; - const cliArgs = Option.getOrUndefined(yield* Effect.serviceOption(CliArgs)); - return { - path: getCommandRuntimeCommand(runtime.value), - operands: cliArgs?.operandPlaceholders?.() ?? [], - } satisfies DeprecationCommand; -}); - -/** Prints one warning per deprecated selector flag the run used, naming the command, its operand placeholders and the `--remote` replacement. */ +/** Prints one warning per deprecated selector flag the run used, naming its `--remote` replacement. */ export const warnTargetDeprecations = Effect.fnUntraced(function* ( deprecations: ReadonlyArray<TargetDeprecation>, ) { if (deprecations.length === 0) return; const { projectHost } = yield* CommandSettings; - const command = yield* currentCommand(); - for (const message of deprecationMessages(deprecations, { command, projectHost })) { + for (const message of deprecationMessages(deprecations, projectHost)) { yield* warnTarget(message); } }); @@ -147,10 +131,8 @@ const checkRemoteOperand = Effect.fn("Target.checkRemoteOperand")(function* (raw const listed = yield* listParentBranches(parent.ref, "Checking branches...").pipe(Effect.result); if (Result.isFailure(listed)) { const failure = listingFailure(listed.failure); - const cliArgs = Option.getOrUndefined(yield* Effect.serviceOption(CliArgs)); - const operands = cliArgs?.operandPlaceholders?.() ?? []; return yield* new TargetRemoteAmbiguousError({ - message: `Can't tell whether the word after --remote is a branch (listing the branches of project ${parent.ref} failed${failure.reason}). For the branch: ${usage} --remote=<branch>. For the linked project: ${[usage, ...operands, "--remote"].join(" ")}`, + message: `Can't tell whether the word after --remote is a branch (listing the branches of project ${parent.ref} failed${failure.reason}). For the branch: ${usage} --remote=<branch>. For the linked project, put --remote last: ${usage} <operands> --remote`, ...(failure.suggestion === undefined ? {} : { suggestion: failure.suggestion }), ...(failure.status === undefined ? {} : { status: failure.status }), ...(failure.authFailure === undefined ? {} : { authFailure: failure.authFailure }), diff --git a/apps/cli/src/command-internal/target/remote-resolution.unit.test.ts b/apps/cli/src/command-internal/target/remote-resolution.unit.test.ts index 598bba33df..27544c1c25 100644 --- a/apps/cli/src/command-internal/target/remote-resolution.unit.test.ts +++ b/apps/cli/src/command-internal/target/remote-resolution.unit.test.ts @@ -5,16 +5,12 @@ import { vi } from "vitest"; import { stripAnsi } from "../../../tests/helpers/ansi.ts"; import { mockCommandSettings } from "../../../tests/helpers/command-mocks.ts"; import { mockTty } from "../../../tests/helpers/mocks.ts"; -import { rootCommandForFeatures } from "../../cli/root.ts"; -import { CliArgs, type CliArgsShape } from "../../shared/cli/cli-args.service.ts"; -import { prepareCliArgs } from "../../shared/cli/run.ts"; import { jsonOutputLayer, streamJsonOutputLayer, textOutputLayer, } from "../../shared/output/output.layer.ts"; import { Output } from "../../shared/output/output.service.ts"; -import { CommandRuntime } from "../../shared/runtime/command-runtime.service.ts"; import type { Tty } from "../../shared/runtime/tty.service.ts"; import { warnTargetDeprecations } from "./remote-resolution.ts"; import type { TargetDeprecation } from "./target-selector.ts"; @@ -49,16 +45,6 @@ const linkedAlias: TargetDeprecation = { const runWith = ( outputLayer: Layer.Layer<Output, never, Stdio.Stdio | Tty>, deprecations: ReadonlyArray<TargetDeprecation>, - command: { - readonly path: ReadonlyArray<string>; - readonly cliArgs: CliArgsShape; - } = { - path: ["gen", "types"], - cliArgs: { - args: ["gen", "types", "--linked"], - operandPlaceholders: () => ["<NAME=VALUE>..."], - }, - }, ) => { const stdout: Array<string> = []; const stderr: Array<string> = []; @@ -77,15 +63,9 @@ const runWith = ( yield* output.raw(PAYLOAD); }).pipe( Effect.provide( - Layer.mergeAll( - outputLayer, - mockCommandSettings({ workdir: "/unused" }), - Layer.succeed(CliArgs, command.cliArgs), - Layer.succeed( - CommandRuntime, - CommandRuntime.of({ commandPath: command.path, commandRunId: "run" }), - ), - ).pipe(Layer.provide(Layer.merge(stdio, mockTty()))), + Layer.mergeAll(outputLayer, mockCommandSettings({ workdir: "/unused" })).pipe( + Layer.provide(Layer.merge(stdio, mockTty())), + ), ), Effect.map(() => ({ stdout: stdout.join(""), stderr: stderr.join("") })), ); @@ -105,101 +85,9 @@ describe("warnTargetDeprecations", () => { expect(aliasRun.stdout).toBe(remoteRun.stdout); expect(remoteRun.stderr).toBe(""); expect(stripAnsi(aliasRun.stderr)).toBe( - "Warning: --linked is deprecated and will be removed in v4. Use --remote instead: supabase gen types <NAME=VALUE>... --remote\n", + "Warning: --linked is deprecated and will be removed in v4. Use --remote instead\n", ); expect(clack.log.warn).not.toHaveBeenCalled(); }), ); }); - -describe("warnTargetDeprecations on the raw command line", () => { - const root = rootCommandForFeatures(); - const REF = "abcdefghijklmnopqrst"; - - const warningFor = ( - argv: ReadonlyArray<string>, - path: ReadonlyArray<string>, - deprecation: TargetDeprecation, - ) => - Effect.gen(function* () { - const run = yield* runWith(textOutputLayer, [deprecation], { - path, - cliArgs: prepareCliArgs(root, argv), - }); - return stripAnsi(run.stderr); - }); - - it.effect("shows an operand placeholder in place of a secret value", () => - Effect.gen(function* () { - const stderr = yield* warningFor( - ["secrets", "set", "FOO=hunter2", `--project-ref=${REF}`], - ["secrets", "set"], - { kind: "remote-alias", flags: ["--project-ref"], remote: { _tag: "Ref", ref: REF } }, - ); - - expect(stderr).toBe( - `Warning: --project-ref is deprecated and will be removed in v4. Use --remote=<ref> instead: supabase secrets set <NAME=VALUE>... --remote=${REF}\n`, - ); - expect(stderr).not.toContain("hunter2"); - }), - ); - - it.effect("keeps a SQL operand out of the warning", () => - Effect.gen(function* () { - const stderr = yield* warningFor( - ["db", "query", "select 'hunter2' as pw", "--linked"], - ["db", "query"], - { kind: "remote-alias", flags: ["--linked"], remote: { _tag: "Linked" } }, - ); - - expect(stderr).toBe( - "Warning: --linked is deprecated and will be removed in v4. Use --remote instead: supabase db query <sql> --remote\n", - ); - expect(stderr).not.toContain("hunter2"); - }), - ); - - it.effect("keeps another flag's database URL and password out of the warning", () => - Effect.gen(function* () { - const stderr = yield* warningFor( - ["db", "diff", "--from=postgresql://u:hunter2@h/db", "--to=local", "--local"], - ["db", "diff"], - { kind: "local" }, - ); - - expect(stderr).toBe( - "Warning: --local isn't needed (local is the default) and will be removed in v4: supabase db diff\n", - ); - expect(stderr).not.toContain("hunter2"); - }), - ); - - it.effect("keeps the -- tail out of the warning", () => - Effect.gen(function* () { - const stderr = yield* warningFor( - ["functions", "deploy", "--linked", "--", "--token=hunter2"], - ["functions", "deploy"], - { kind: "remote-alias", flags: ["--linked"], remote: { _tag: "Linked" } }, - ); - - expect(stderr).toBe( - "Warning: --linked is deprecated and will be removed in v4. Use --remote instead: supabase functions deploy <Function name>... --remote\n", - ); - expect(stderr).not.toContain("hunter2"); - }), - ); - - it.effect("never echoes a database URL given to --db-url", () => - Effect.gen(function* () { - const url = "postgresql://u:hunter2@h/db"; - const stderr = yield* warningFor(["db", "push", `--db-url=${url}`], ["db", "push"], { - kind: "remote-alias", - flags: ["--db-url"], - remote: { _tag: "DbUrl", url }, - }); - - expect(stderr).toContain("supabase db push --remote=<db-url>"); - expect(stderr).not.toContain("hunter2"); - }), - ); -}); diff --git a/apps/cli/src/command-internal/target/resolve-project-target.integration.test.ts b/apps/cli/src/command-internal/target/resolve-project-target.integration.test.ts index 0048cf2ec2..43528d5256 100644 --- a/apps/cli/src/command-internal/target/resolve-project-target.integration.test.ts +++ b/apps/cli/src/command-internal/target/resolve-project-target.integration.test.ts @@ -207,7 +207,7 @@ describe("resolveProjectTarget", () => { ); expect(refOf(result.exit)).toBe(FLAG_REF); expect(result.warnings).toEqual([ - `--project-ref is deprecated and will be removed in v4. Use --remote=<ref> instead: supabase functions deploy <Function name>... --remote=${FLAG_REF}`, + `--project-ref is deprecated and will be removed in v4. Use --remote=<ref> instead`, ]); expect(result.outputWarnCalls).toEqual([]); expect(result.stdout).toBe(""); 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 4e2fbedbbb..0de8c323b0 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 @@ -399,7 +399,7 @@ describe("resolveTarget", () => { const command = ["db", "pull"]; const bare = { ...none, ...remote({ _tag: "Linked" }) }; const options = `For the branch: supabase db pull --remote=feature-x. For the linked project: supabase db pull feature-x --remote`; - const placeholderOptions = `For the branch: supabase db pull --remote=<branch>. For the linked project: supabase db pull <migration name> --remote`; + const placeholderOptions = `For the branch: supabase db pull --remote=<branch>. For the linked project, put --remote last: supabase db pull <operands> --remote`; it.effect("fails when the word is a branch of the linked project", () => Effect.gen(function* () { @@ -557,7 +557,7 @@ describe("resolveTarget", () => { const error = failureOf(result.exit); expect(error).toMatchObject({ _tag: "TargetRemoteAmbiguousError", - message: `Can't tell whether the word after --remote is a branch (listing the branches of project ${FILE_REF} failed with HTTP 403). For the branch: supabase secrets unset --remote=<branch>. For the linked project: supabase secrets unset <NAME>... --remote`, + message: `Can't tell whether the word after --remote is a branch (listing the branches of project ${FILE_REF} failed with HTTP 403). For the branch: supabase secrets unset --remote=<branch>. For the linked project, put --remote last: supabase secrets unset <operands> --remote`, }); expect(JSON.stringify(error)).not.toContain("sk_live_secret"); }), @@ -673,7 +673,7 @@ describe("resolveTarget", () => { ); expect(succeeded(result.exit)).toMatchObject({ kind: "linked", ref: FILE_REF }); expect(result.warnings).toEqual([ - "--linked is deprecated and will be removed in v4. Use --remote instead: supabase db push --remote", + "--linked is deprecated and will be removed in v4. Use --remote instead", ]); }), ); @@ -689,7 +689,7 @@ describe("resolveTarget", () => { ); expect(succeeded(result.exit)).toMatchObject({ kind: "linked" }); expect(result.warnings).toEqual([ - "--linked is deprecated and will be removed in v4. Use --remote instead: supabase db push --remote", + "--linked is deprecated and will be removed in v4. Use --remote instead", ]); expect(result.outputWarnCalls).toEqual([]); expect(result.stdout).toBe(""); @@ -708,7 +708,7 @@ describe("resolveTarget", () => { ); expect(succeeded(result.exit)).toMatchObject({ kind: "linked", ref: FLAG_REF }); expect(result.warnings).toEqual([ - `--linked and --project-ref are deprecated and will be removed in v4. Use --remote=<ref> instead: supabase db push --remote=${FLAG_REF}`, + `--linked and --project-ref are deprecated and will be removed in v4. Use --remote=<ref> instead`, ]); }), ); @@ -725,7 +725,7 @@ describe("resolveTarget", () => { ); expect(succeeded(result.exit)).toMatchObject({ kind: "db-url" }); expect(result.warnings).toEqual([ - "--db-url is deprecated and will be removed in v4. Use --remote=<db-url> instead: supabase db push --remote=<db-url>, or --remote=abcdefghijklmnopqrst with an access token set", + "--db-url is deprecated and will be removed in v4. Use --remote=<db-url> instead, or --remote=abcdefghijklmnopqrst with an access token set", ]); expect(result.warnings.join("\n")).not.toContain("hunter2"); }), @@ -740,7 +740,7 @@ describe("resolveTarget", () => { ); expect(succeeded(result.exit)).toEqual({ kind: "local" }); expect(result.warnings).toEqual([ - "--local isn't needed (local is the default) and will be removed in v4: supabase db push", + "--local isn't needed (local is the default) and will be removed in v4", ]); expect(result.refFlags).toEqual([]); }), diff --git a/apps/cli/src/command-internal/target/target-deprecation.ts b/apps/cli/src/command-internal/target/target-deprecation.ts index 9efdc4d9f0..be2624e0f3 100644 --- a/apps/cli/src/command-internal/target/target-deprecation.ts +++ b/apps/cli/src/command-internal/target/target-deprecation.ts @@ -56,53 +56,32 @@ export const refFromDbUrl = (url: string, projectHost: string): string | undefin } }; -/** The command the warning's example runs: its path and operand placeholders, never the user's own operands. */ -export interface DeprecationCommand { - /** The resolved command path, e.g. `db push`. */ - readonly path: string; - /** The operand placeholders from the command's help, e.g. `<NAME=VALUE>...`. */ - readonly operands: ReadonlyArray<string>; -} - -const exampleOf = (command: DeprecationCommand | undefined, remote: string | undefined): string => - command === undefined - ? "" - : `: ${["supabase", command.path, ...command.operands, ...(remote === undefined ? [] : [remote])].join(" ")}`; - const aliasMessage = ( deprecation: Extract<TargetDeprecation, { readonly kind: "remote-alias" }>, - context: { readonly command: DeprecationCommand | undefined; readonly projectHost: string }, + projectHost: string, ): string => { const { flags, remote } = deprecation; const verb = flags.length > 1 ? "are" : "is"; const lead = `${joinFlags(flags)} ${verb} deprecated and will be removed in v4.`; - const example = exampleOf(context.command, remoteFlagText(remote)); if (remote._tag === "Branch" && isBooleanLiteral(remote.name)) { - return `${lead} Reach a branch named "${remote.name}" with ${BRANCH_BY_REF} instead${example}`; + return `${lead} Reach a branch named "${remote.name}" with ${BRANCH_BY_REF} instead`; } if (remote._tag === "DbUrl" && !DB_URL_PATTERN.test(remote.url)) { - return `${lead} Convert the connection string to a postgresql:// URL and pass it as --remote=<url> instead${exampleOf(context.command, "--remote=<url>")}`; + return `${lead} Convert the connection string to a postgresql:// URL and pass it as --remote=<url> instead`; } - const ref = remote._tag === "DbUrl" ? refFromDbUrl(remote.url, context.projectHost) : undefined; + const ref = remote._tag === "DbUrl" ? refFromDbUrl(remote.url, projectHost) : undefined; const alternative = ref === undefined ? "" : `, or --remote=${ref} with an access token set`; - return `${lead} Use ${replacementOf(remote)} instead${example}${alternative}`; + return `${lead} Use ${replacementOf(remote)} instead${alternative}`; }; -/** - * The warning for each deprecated selector the run used. It shows the command path, its operand - * placeholders and the `--remote` replacement last; the user's own operands, other flags and - * database URLs are never echoed, because any of them can be a secret. - */ +/** The warning for each deprecated selector the run used, naming its `--remote` replacement; a database URL is never echoed. */ export const deprecationMessages = ( deprecations: ReadonlyArray<TargetDeprecation>, - context: { - readonly command: DeprecationCommand | undefined; - readonly projectHost: string; - }, + projectHost: string, ): ReadonlyArray<string> => deprecations.map((deprecation) => deprecation.kind === "local" - ? `--local isn't needed (local is the default) and will be removed in v4${exampleOf(context.command, undefined)}` - : aliasMessage(deprecation, context), + ? "--local isn't needed (local is the default) and will be removed in v4" + : aliasMessage(deprecation, projectHost), ); diff --git a/apps/cli/src/command-internal/target/target-deprecation.unit.test.ts b/apps/cli/src/command-internal/target/target-deprecation.unit.test.ts index bd4d638451..6c291ceb92 100644 --- a/apps/cli/src/command-internal/target/target-deprecation.unit.test.ts +++ b/apps/cli/src/command-internal/target/target-deprecation.unit.test.ts @@ -1,11 +1,6 @@ import { describe, expect, it } from "vitest"; -import { - type DeprecationCommand, - deprecationMessages, - refFromDbUrl, - remoteFlagText, -} from "./target-deprecation.ts"; +import { deprecationMessages, refFromDbUrl, remoteFlagText } from "./target-deprecation.ts"; import type { TargetDeprecation } from "./target-selector.ts"; const REF = "abcdefghijklmnopqrst"; @@ -41,31 +36,23 @@ describe("refFromDbUrl", () => { }); describe("deprecationMessages", () => { - const dbPush: DeprecationCommand = { path: "db push", operands: [] }; - const secretsSet: DeprecationCommand = { path: "secrets set", operands: ["<NAME=VALUE>..."] }; + const messages = (deprecations: ReadonlyArray<TargetDeprecation>) => + deprecationMessages(deprecations, HOST); - const messages = ( - deprecations: ReadonlyArray<TargetDeprecation>, - command: DeprecationCommand | undefined = dbPush, - ) => deprecationMessages(deprecations, { command, projectHost: HOST }); - - it("shows the command path, its operand placeholders and the replacement last", () => { + it("points a deprecated --project-ref at --remote=<ref>", () => { expect( - messages( - [{ kind: "remote-alias", flags: ["--project-ref"], remote: { _tag: "Ref", ref: REF } }], - secretsSet, - ), + messages([ + { kind: "remote-alias", flags: ["--project-ref"], remote: { _tag: "Ref", ref: REF } }, + ]), ).toEqual([ - `--project-ref is deprecated and will be removed in v4. Use --remote=<ref> instead: supabase secrets set <NAME=VALUE>... --remote=${REF}`, + "--project-ref is deprecated and will be removed in v4. Use --remote=<ref> instead", ]); }); it("points a deprecated --linked at a bare --remote", () => { expect( messages([{ kind: "remote-alias", flags: ["--linked"], remote: { _tag: "Linked" } }]), - ).toEqual([ - "--linked is deprecated and will be removed in v4. Use --remote instead: supabase db push --remote", - ]); + ).toEqual(["--linked is deprecated and will be removed in v4. Use --remote instead"]); }); it("joins every alias the run used into one warning", () => { @@ -78,11 +65,11 @@ describe("deprecationMessages", () => { }, ]), ).toEqual([ - `--linked and --project-ref are deprecated and will be removed in v4. Use --remote=<ref> instead: supabase db push --remote=${REF}`, + `--linked and --project-ref are deprecated and will be removed in v4. Use --remote=<ref> instead`, ]); }); - it("shows the branch an alias value names, without its userinfo", () => { + it("points a deprecated --project-ref branch name at --remote=<branch>", () => { expect( messages([ { @@ -92,7 +79,7 @@ describe("deprecationMessages", () => { }, ]), ).toEqual([ - "--project-ref is deprecated and will be removed in v4. Use --remote=<branch> instead: supabase db push --remote=staging", + "--project-ref is deprecated and will be removed in v4. Use --remote=<branch> instead", ]); }); @@ -102,7 +89,7 @@ describe("deprecationMessages", () => { { kind: "remote-alias", flags: ["--db-url"], remote: { _tag: "DbUrl", url } }, ]); expect(message).toBe( - `--db-url is deprecated and will be removed in v4. Use --remote=<db-url> instead: supabase db push --remote=<db-url>, or --remote=${REF} with an access token set`, + `--db-url is deprecated and will be removed in v4. Use --remote=<db-url> instead, or --remote=${REF} with an access token set`, ); expect(message).not.toContain("hunter2"); }); @@ -116,7 +103,7 @@ describe("deprecationMessages", () => { }, ]); expect(message).toBe( - "--db-url is deprecated and will be removed in v4. Convert the connection string to a postgresql:// URL and pass it as --remote=<url> instead: supabase db push --remote=<url>", + "--db-url is deprecated and will be removed in v4. Convert the connection string to a postgresql:// URL and pass it as --remote=<url> instead", ); expect(message).not.toContain("hunter2"); }); @@ -129,23 +116,14 @@ describe("deprecationMessages", () => { { kind: "remote-alias", flags: ["--project-ref"], remote: { _tag: "Branch", name } }, ]), ).toEqual([ - `--project-ref is deprecated and will be removed in v4. Reach a branch named "${name}" with --remote=<its ref or UUID> instead: supabase db push --remote=<its ref or UUID>`, + `--project-ref is deprecated and will be removed in v4. Reach a branch named "${name}" with --remote=<its ref or UUID> instead`, ]); }, ); - it("drops the example when the command is unknown", () => { - expect( - deprecationMessages( - [{ kind: "remote-alias", flags: ["--project-id"], remote: { _tag: "Ref", ref: REF } }], - { command: undefined, projectHost: HOST }, - ), - ).toEqual(["--project-id is deprecated and will be removed in v4. Use --remote=<ref> instead"]); - }); - - it("says --local is not needed and shows the command without it", () => { - expect(messages([{ kind: "local" }], secretsSet)).toEqual([ - "--local isn't needed (local is the default) and will be removed in v4: supabase secrets set <NAME=VALUE>...", + it("says --local is not needed", () => { + expect(messages([{ kind: "local" }])).toEqual([ + "--local isn't needed (local is the default) and will be removed in v4", ]); }); }); 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 7696cee169..e978641a92 100644 --- a/apps/cli/src/commands/config/diff/diff.integration.test.ts +++ b/apps/cli/src/commands/config/diff/diff.integration.test.ts @@ -698,7 +698,7 @@ describe("config diff integration", () => { return Effect.gen(function* () { yield* configDiff({ ...noFlags, projectRef: Option.some("staging") }); expect(warningsOf(out)).toEqual([ - "--project-ref is deprecated and will be removed in v4. Use --remote=<branch> instead: supabase config diff --remote=staging", + "--project-ref is deprecated and will be removed in v4. Use --remote=<branch> instead", ]); expect(out.stderrText).toContain( `Comparing against 'staging' (branch ${BRANCH_REF}) using base config`, diff --git a/apps/cli/src/commands/config/pull/pull.integration.test.ts b/apps/cli/src/commands/config/pull/pull.integration.test.ts index 5df0d424e0..e163d1d35a 100644 --- a/apps/cli/src/commands/config/pull/pull.integration.test.ts +++ b/apps/cli/src/commands/config/pull/pull.integration.test.ts @@ -1781,7 +1781,7 @@ describe("config pull integration", () => { return Effect.gen(function* () { yield* configPull({ ...noFlags, projectRef: Option.some("staging") }); expect(warningsOf(out)).toEqual([ - "--project-ref is deprecated and will be removed in v4. Use --remote=<branch> instead: supabase config pull --remote=staging", + "--project-ref is deprecated and will be removed in v4. Use --remote=<branch> instead", ]); expect(out.stderrText).toContain(`Pulling config from 'staging' (branch ${BRANCH_REF})`); }).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 0a29bdd80f..0768810b56 100644 --- a/apps/cli/src/commands/config/push/push.integration.test.ts +++ b/apps/cli/src/commands/config/push/push.integration.test.ts @@ -3545,7 +3545,7 @@ describe("config push --remote branch name/UUID resolution (CLI-2289)", () => { return Effect.gen(function* () { yield* configPush({ remote: Option.none(), projectRef: Option.some("staging") }); expect(warningsOf(out)).toEqual([ - "--project-ref is deprecated and will be removed in v4. Use --remote=<branch> instead: supabase config push --remote=staging", + "--project-ref is deprecated and will be removed in v4. Use --remote=<branch> instead", ]); expect(out.stderrText).toContain(`Target: project ${REF} · branch staging (${BRANCH_REF})\n`); }).pipe(Effect.provide(layer)); 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 217327810e..655f3daa63 100644 --- a/apps/cli/src/commands/functions/deploy/deploy.integration.test.ts +++ b/apps/cli/src/commands/functions/deploy/deploy.integration.test.ts @@ -540,7 +540,7 @@ describe("functions deploy", () => { `https://api.supabase.com/v1/projects/${OTHER_REF}/functions/deploy`, ]); expect(warnings()).toEqual([ - `--project-ref is deprecated and will be removed in v4. Use --remote=<ref> instead: supabase functions deploy <Function name>... --remote=${OTHER_REF}`, + `--project-ref is deprecated and will be removed in v4. Use --remote=<ref> instead`, ]); }).pipe(Effect.provide(layer), Effect.ensuring(removeTempRoot)); }); 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 8a41cb2ec2..6531344edf 100644 --- a/apps/cli/src/commands/gen/types/types.integration.test.ts +++ b/apps/cli/src/commands/gen/types/types.integration.test.ts @@ -1023,7 +1023,7 @@ describe("gen types", () => { ); expect(stripAnsi(out.stderrText)).toContain( - `Warning: --project-id is deprecated and will be removed in v4. Use --remote=<ref> instead: supabase gen types --remote=${VALID_REF}\n`, + `Warning: --project-id is deprecated and will be removed in v4. Use --remote=<ref> instead\n`, ); expect(out.stderrText).toContain(unverifiedTargetBanner()); expect(api.requests).toContainEqual({ @@ -1057,7 +1057,7 @@ describe("gen types", () => { expect(alias.out.stdoutText).toBe("export type Database = {}\n"); expect(alias.out.stdoutText).toBe(canonical.out.stdoutText); expect(warningsOf(alias.out)).toEqual([ - `--project-id is deprecated and will be removed in v4. Use --remote=<ref> instead: supabase gen types --remote=${VALID_REF}`, + `--project-id is deprecated and will be removed in v4. Use --remote=<ref> instead`, ]); expect(warningsOf(canonical.out)).toEqual([]); }).pipe(Effect.provide(BunServices.layer)), @@ -1244,8 +1244,7 @@ describe("gen types", () => { alias: "--linked", args: ["gen", "types", "--linked"], flags: { linked: true }, - warning: - "--linked is deprecated and will be removed in v4. Use --remote instead: supabase gen types --remote", + warning: "--linked is deprecated and will be removed in v4. Use --remote instead", reached: ({ api }) => expect(api.requests).toContainEqual({ method: "generateTypescriptTypes", @@ -1256,7 +1255,7 @@ describe("gen types", () => { alias: "--project-ref", args: ["gen", "types", "--project-ref", FLAG_REF], flags: { projectRef: Option.some(FLAG_REF) }, - warning: `--project-ref is deprecated and will be removed in v4. Use --remote=<ref> instead: supabase gen types --remote=${FLAG_REF}`, + warning: `--project-ref is deprecated and will be removed in v4. Use --remote=<ref> instead`, reached: ({ api }) => expect(api.requests).toContainEqual({ method: "generateTypescriptTypes", @@ -1268,7 +1267,7 @@ describe("gen types", () => { args: ["gen", "types", "--db-url", dbUrl], flags: { dbUrl: Option.some(dbUrl) }, warning: - "--db-url is deprecated and will be removed in v4. Use --remote=<db-url> instead: supabase gen types --remote=<db-url>", + "--db-url is deprecated and will be removed in v4. Use --remote=<db-url> instead", reached: ({ dbConfig }) => expect(dbConfig.resolves.map(({ target }) => target.kind)).toEqual(["db-url"]), }, @@ -1276,8 +1275,7 @@ describe("gen types", () => { alias: "--local", args: ["gen", "types", "--local"], flags: { local: true }, - warning: - "--local isn't needed (local is the default) and will be removed in v4: supabase gen types", + warning: "--local isn't needed (local is the default) and will be removed in v4", reached: ({ generator }) => expect(generator.calls[0]?.isLocal).toBe(true), }, ]; @@ -3777,7 +3775,7 @@ describe("gen types permissions through the real layers", () => { `GET /v1/projects/${VALID_REF}/types/typescript`, ]); expect(warningsOf(ctx.out)).toEqual([ - `--project-id is deprecated and will be removed in v4. Use --remote=<ref> instead: supabase gen types --remote=${VALID_REF}`, + `--project-id is deprecated and will be removed in v4. Use --remote=<ref> instead`, ]); assertPermissionDrift({ command: "gen types", 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 3e51cbfe7e..55afeab9a7 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 @@ -444,31 +444,28 @@ describe("inspect db query runner", () => { cliArgs: ["inspect", "db", "db-stats", "--linked"], flags: { linked: true }, target: { kind: "linked", ref: VALID_REF }, - warning: - "--linked is deprecated and will be removed in v4. Use --remote instead: supabase inspect db db-stats --remote", + warning: "--linked is deprecated and will be removed in v4. Use --remote instead", }, { alias: "--project-ref", cliArgs: ["inspect", "db", "db-stats", "--project-ref", FLAG_REF], flags: { projectRef: Option.some(FLAG_REF) }, target: { kind: "linked", ref: FLAG_REF, source: "flag" }, - warning: `--project-ref is deprecated and will be removed in v4. Use --remote=<ref> instead: supabase inspect db db-stats --remote=${FLAG_REF}`, + warning: `--project-ref is deprecated and will be removed in v4. Use --remote=<ref> instead`, }, { alias: "--db-url", cliArgs: ["inspect", "db", "db-stats", "--db-url", DEPRECATED_DB_URL], flags: { dbUrl: Option.some(DEPRECATED_DB_URL) }, target: { kind: "db-url" }, - warning: - "--db-url is deprecated and will be removed in v4. Use --remote=<db-url> instead: supabase inspect db db-stats --remote=<db-url>", + warning: "--db-url is deprecated and will be removed in v4. Use --remote=<db-url> instead", }, { alias: "--local", cliArgs: ["inspect", "db", "db-stats", "--local"], flags: { local: true }, target: { kind: "local" }, - warning: - "--local isn't needed (local is the default) and will be removed in v4: supabase inspect db db-stats", + warning: "--local isn't needed (local is the default) and will be removed in v4", }, ] as const satisfies ReadonlyArray<{ readonly alias: string; 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 62b17b67c9..bbaefebb2a 100644 --- a/apps/cli/src/commands/inspect/report/report.integration.test.ts +++ b/apps/cli/src/commands/inspect/report/report.integration.test.ts @@ -435,31 +435,28 @@ describe("inspect report", () => { cliArgs: ["inspect", "report", "--linked"], flags: { linked: true }, target: { kind: "linked", ref: VALID_REF }, - warning: - "--linked is deprecated and will be removed in v4. Use --remote instead: supabase inspect report --remote", + warning: "--linked is deprecated and will be removed in v4. Use --remote instead", }, { alias: "--project-ref", cliArgs: ["inspect", "report", "--project-ref", FLAG_REF], flags: { projectRef: Option.some(FLAG_REF) }, target: { kind: "linked", ref: FLAG_REF, source: "flag" }, - warning: `--project-ref is deprecated and will be removed in v4. Use --remote=<ref> instead: supabase inspect report --remote=${FLAG_REF}`, + warning: `--project-ref is deprecated and will be removed in v4. Use --remote=<ref> instead`, }, { alias: "--db-url", cliArgs: ["inspect", "report", "--db-url", DEPRECATED_DB_URL], flags: { dbUrl: Option.some(DEPRECATED_DB_URL) }, target: { kind: "db-url" }, - warning: - "--db-url is deprecated and will be removed in v4. Use --remote=<db-url> instead: supabase inspect report --remote=<db-url>", + warning: "--db-url is deprecated and will be removed in v4. Use --remote=<db-url> instead", }, { alias: "--local", cliArgs: ["inspect", "report", "--local"], flags: { local: true }, target: { kind: "local" }, - warning: - "--local isn't needed (local is the default) and will be removed in v4: supabase inspect report", + warning: "--local isn't needed (local is the default) and will be removed in v4", }, ] as const satisfies ReadonlyArray<{ readonly alias: string; 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 243740a72d..67a35103f4 100644 --- a/apps/cli/src/commands/migration/down/down.integration.test.ts +++ b/apps/cli/src/commands/migration/down/down.integration.test.ts @@ -434,31 +434,28 @@ describe("migration down", () => { args: ["migration", "down", "--linked"], flags: { linked: true }, target: { kind: "linked", ref: VALID_REF }, - warning: - "--linked is deprecated and will be removed in v4. Use --remote instead: supabase migration down --remote", + warning: "--linked is deprecated and will be removed in v4. Use --remote instead", }, { alias: "--project-ref", args: ["migration", "down", "--project-ref", FLAG_REF], flags: { projectRef: Option.some(FLAG_REF) }, target: { kind: "linked", ref: FLAG_REF }, - warning: `--project-ref is deprecated and will be removed in v4. Use --remote=<ref> instead: supabase migration down --remote=${FLAG_REF}`, + warning: `--project-ref is deprecated and will be removed in v4. Use --remote=<ref> instead`, }, { alias: "--db-url", args: ["migration", "down", "--db-url", DB_URL], flags: { dbUrl: Option.some(DB_URL) }, target: { kind: "db-url" }, - warning: - "--db-url is deprecated and will be removed in v4. Use --remote=<db-url> instead: supabase migration down --remote=<db-url>", + warning: "--db-url is deprecated and will be removed in v4. Use --remote=<db-url> instead", }, { alias: "--local", args: ["migration", "down", "--local"], flags: { local: true }, target: { kind: "local" }, - warning: - "--local isn't needed (local is the default) and will be removed in v4: supabase migration down", + warning: "--local isn't needed (local is the default) and will be removed in v4", }, ] as const satisfies ReadonlyArray<{ readonly alias: string; 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 df44832256..35fd2a9248 100644 --- a/apps/cli/src/commands/migration/fetch/fetch.integration.test.ts +++ b/apps/cli/src/commands/migration/fetch/fetch.integration.test.ts @@ -632,31 +632,28 @@ describe("migration fetch", () => { args: ["migration", "fetch", "--linked"], flags: { linked: true }, target: { kind: "linked", ref: VALID_REF }, - warning: - "--linked is deprecated and will be removed in v4. Use --remote instead: supabase migration fetch --remote", + warning: "--linked is deprecated and will be removed in v4. Use --remote instead", }, { alias: "--project-ref", args: ["migration", "fetch", "--project-ref", FLAG_REF], flags: { projectRef: Option.some(FLAG_REF) }, target: { kind: "linked", ref: FLAG_REF }, - warning: `--project-ref is deprecated and will be removed in v4. Use --remote=<ref> instead: supabase migration fetch --remote=${FLAG_REF}`, + warning: `--project-ref is deprecated and will be removed in v4. Use --remote=<ref> instead`, }, { alias: "--db-url", args: ["migration", "fetch", "--db-url", DB_URL], flags: { dbUrl: Option.some(DB_URL) }, target: { kind: "db-url" }, - warning: - "--db-url is deprecated and will be removed in v4. Use --remote=<db-url> instead: supabase migration fetch --remote=<db-url>", + warning: "--db-url is deprecated and will be removed in v4. Use --remote=<db-url> instead", }, { alias: "--local", args: ["migration", "fetch", "--local"], flags: { local: true }, target: { kind: "local" }, - warning: - "--local isn't needed (local is the default) and will be removed in v4: supabase migration fetch", + warning: "--local isn't needed (local is the default) and will be removed in v4", }, ] as const satisfies ReadonlyArray<{ readonly alias: string; 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 e04c5b64bb..b84741fe5f 100644 --- a/apps/cli/src/commands/migration/list/list.integration.test.ts +++ b/apps/cli/src/commands/migration/list/list.integration.test.ts @@ -335,31 +335,28 @@ describe("migration list", () => { args: ["migration", "list", "--linked"], flags: { linked: true }, target: { kind: "linked", ref: VALID_REF }, - warning: - "--linked is deprecated and will be removed in v4. Use --remote instead: supabase migration list --remote", + warning: "--linked is deprecated and will be removed in v4. Use --remote instead", }, { alias: "--project-ref", args: ["migration", "list", "--project-ref", FLAG_REF], flags: { projectRef: Option.some(FLAG_REF) }, target: { kind: "linked", ref: FLAG_REF }, - warning: `--project-ref is deprecated and will be removed in v4. Use --remote=<ref> instead: supabase migration list --remote=${FLAG_REF}`, + warning: `--project-ref is deprecated and will be removed in v4. Use --remote=<ref> instead`, }, { alias: "--db-url", args: ["migration", "list", "--db-url", DB_URL], flags: { dbUrl: Option.some(DB_URL) }, target: { kind: "db-url" }, - warning: - "--db-url is deprecated and will be removed in v4. Use --remote=<db-url> instead: supabase migration list --remote=<db-url>", + warning: "--db-url is deprecated and will be removed in v4. Use --remote=<db-url> instead", }, { alias: "--local", args: ["migration", "list", "--local"], flags: { local: true }, target: { kind: "local" }, - warning: - "--local isn't needed (local is the default) and will be removed in v4: supabase migration list", + warning: "--local isn't needed (local is the default) and will be removed in v4", }, ] as const satisfies ReadonlyArray<{ readonly alias: string; 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 49f9b45565..b1fd7c3706 100644 --- a/apps/cli/src/commands/migration/repair/repair.integration.test.ts +++ b/apps/cli/src/commands/migration/repair/repair.integration.test.ts @@ -546,28 +546,28 @@ describe("migration repair", () => { args: [...base, "--linked"], flags: { linked: true }, target: { kind: "linked", ref: VALID_REF }, - warning: `--linked is deprecated and will be removed in v4. Use --remote instead: supabase migration repair --remote`, + warning: `--linked is deprecated and will be removed in v4. Use --remote instead`, }, { alias: "--project-ref", args: [...base, "--project-ref", FLAG_REF], flags: { projectRef: Option.some(FLAG_REF) }, target: { kind: "linked", ref: FLAG_REF }, - warning: `--project-ref is deprecated and will be removed in v4. Use --remote=<ref> instead: supabase migration repair --remote=${FLAG_REF}`, + warning: `--project-ref is deprecated and will be removed in v4. Use --remote=<ref> instead`, }, { alias: "--db-url", args: [...base, "--db-url", DB_URL], flags: { dbUrl: Option.some(DB_URL) }, target: { kind: "db-url" }, - warning: `--db-url is deprecated and will be removed in v4. Use --remote=<db-url> instead: supabase migration repair --remote=<db-url>`, + warning: `--db-url is deprecated and will be removed in v4. Use --remote=<db-url> instead`, }, { alias: "--local", args: [...base, "--local"], flags: { local: true }, target: { kind: "local" }, - warning: `--local isn't needed (local is the default) and will be removed in v4: supabase migration repair`, + warning: `--local isn't needed (local is the default) and will be removed in v4`, }, ] as const satisfies ReadonlyArray<{ readonly alias: string; 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 f211d29a95..7e36cae6d6 100644 --- a/apps/cli/src/commands/migration/squash/squash.integration.test.ts +++ b/apps/cli/src/commands/migration/squash/squash.integration.test.ts @@ -1439,15 +1439,14 @@ describe("migration squash", () => { args: ["migration", "squash", "--linked"], flags: { linked: true }, target: { kind: "linked", ref: VALID_REF }, - warning: - "--linked is deprecated and will be removed in v4. Use --remote instead: supabase migration squash --remote", + warning: "--linked is deprecated and will be removed in v4. Use --remote instead", }, { alias: "--project-ref", args: ["migration", "squash", "--project-ref", FLAG_REF], flags: { projectRef: Option.some(FLAG_REF) }, target: { kind: "linked", ref: FLAG_REF }, - warning: `--project-ref is deprecated and will be removed in v4. Use --remote=<ref> instead: supabase migration squash --remote=${FLAG_REF}`, + warning: `--project-ref is deprecated and will be removed in v4. Use --remote=<ref> instead`, }, { alias: "--db-url", @@ -1455,15 +1454,14 @@ describe("migration squash", () => { flags: { dbUrl: Option.some(DB_URL) }, target: { kind: "db-url" }, warning: - "--db-url is deprecated and will be removed in v4. Use --remote=<db-url> instead: supabase migration squash --remote=<db-url>", + "--db-url is deprecated and will be removed in v4. Use --remote=<db-url> instead", }, { alias: "--local", args: ["migration", "squash", "--local"], flags: { local: true }, target: { kind: "local" }, - warning: - "--local isn't needed (local is the default) and will be removed in v4: supabase migration squash", + warning: "--local isn't needed (local is the default) and will be removed in v4", }, ] as const satisfies ReadonlyArray<{ readonly alias: string; 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 476fcd12bb..e54c6a7701 100644 --- a/apps/cli/src/commands/migration/up/up.integration.test.ts +++ b/apps/cli/src/commands/migration/up/up.integration.test.ts @@ -415,31 +415,28 @@ describe("migration up", () => { args: ["migration", "up", "--linked"], flags: { linked: true }, target: { kind: "linked", ref: VALID_REF }, - warning: - "--linked is deprecated and will be removed in v4. Use --remote instead: supabase migration up --remote", + warning: "--linked is deprecated and will be removed in v4. Use --remote instead", }, { alias: "--project-ref", args: ["migration", "up", "--project-ref", FLAG_REF], flags: { projectRef: Option.some(FLAG_REF) }, target: { kind: "linked", ref: FLAG_REF }, - warning: `--project-ref is deprecated and will be removed in v4. Use --remote=<ref> instead: supabase migration up --remote=${FLAG_REF}`, + warning: `--project-ref is deprecated and will be removed in v4. Use --remote=<ref> instead`, }, { alias: "--db-url", args: ["migration", "up", "--db-url", DB_URL], flags: { dbUrl: Option.some(DB_URL) }, target: { kind: "db-url" }, - warning: - "--db-url is deprecated and will be removed in v4. Use --remote=<db-url> instead: supabase migration up --remote=<db-url>", + warning: "--db-url is deprecated and will be removed in v4. Use --remote=<db-url> instead", }, { alias: "--local", args: ["migration", "up", "--local"], flags: { local: true }, target: { kind: "local" }, - warning: - "--local isn't needed (local is the default) and will be removed in v4: supabase migration up", + warning: "--local isn't needed (local is the default) and will be removed in v4", }, ] as const satisfies ReadonlyArray<{ readonly alias: string; diff --git a/apps/cli/src/commands/notebooks/notebooks.integration.test.ts b/apps/cli/src/commands/notebooks/notebooks.integration.test.ts index c14e65df76..3d7282dad8 100644 --- a/apps/cli/src/commands/notebooks/notebooks.integration.test.ts +++ b/apps/cli/src/commands/notebooks/notebooks.integration.test.ts @@ -701,7 +701,7 @@ describe.each(commands)("notebooks %s project selection", (command) => { yield* runWith(command, { ...none, projectRef: Option.some(NOTEBOOKS_PROJECT_REF) }); expect(http.routeKeys).toEqual([`GET ${notebooksRoute()}`]); expect(warningsOf(out)).toEqual([ - `--project-ref is deprecated and will be removed in v4. Use --remote=<ref> instead: supabase notebooks ${command} --remote=${NOTEBOOKS_PROJECT_REF}`, + `--project-ref is deprecated and will be removed in v4. Use --remote=<ref> instead`, ]); }).pipe(Effect.provide(Layer.mergeAll(layer, Layer.succeed(CliArgs, { args: argv })))); }); diff --git a/apps/cli/src/commands/pull/pull.integration.test.ts b/apps/cli/src/commands/pull/pull.integration.test.ts index 321c160cb8..d4d690e748 100644 --- a/apps/cli/src/commands/pull/pull.integration.test.ts +++ b/apps/cli/src/commands/pull/pull.integration.test.ts @@ -992,7 +992,7 @@ describe("pull integration", () => { yield* writeConfig(); yield* runPull(pullFlags({ projectRef: Option.some("staging"), dryRun: true })); expect(warningsOf(out)).toEqual([ - "--project-ref is deprecated and will be removed in v4. Use --remote=<branch> instead: supabase pull --remote=staging", + "--project-ref is deprecated and will be removed in v4. Use --remote=<branch> instead", ]); }).pipe(Effect.provide(layer)); }); 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 939d45caa0..67aba20b18 100644 --- a/apps/cli/src/commands/seed/buckets/buckets.e2e.test.ts +++ b/apps/cli/src/commands/seed/buckets/buckets.e2e.test.ts @@ -70,7 +70,7 @@ describe("supabase seed buckets", () => { ); expect(`${stdout}${stderr}`).not.toContain("Unrecognized flag"); expect(`${stdout}${stderr}`).toContain( - "--local isn't needed (local is the default) and will be removed in v4: supabase seed buckets", + "--local isn't needed (local is the default) and will be removed in v4", ); expect(exitCode).toBe(0); }).pipe(Effect.scoped, Effect.provide(BunServices.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 4ed7bc41e2..f76196f270 100644 --- a/apps/cli/src/commands/seed/buckets/buckets.integration.test.ts +++ b/apps/cli/src/commands/seed/buckets/buckets.integration.test.ts @@ -2369,23 +2369,21 @@ describe("seed buckets", () => { cliArgs: ["seed", "buckets", "--linked"], flags: { linked: true, local: false }, host: `https://${VALID_REF}.supabase.co`, - warning: - "--linked is deprecated and will be removed in v4. Use --remote instead: supabase seed buckets --remote", + warning: "--linked is deprecated and will be removed in v4. Use --remote instead", }, { alias: "--project-ref", cliArgs: ["seed", "buckets", "--project-ref", FLAG_REF], flags: { projectRef: Option.some(FLAG_REF), local: false }, host: `https://${FLAG_REF}.supabase.co`, - warning: `--project-ref is deprecated and will be removed in v4. Use --remote=<ref> instead: supabase seed buckets --remote=${FLAG_REF}`, + warning: `--project-ref is deprecated and will be removed in v4. Use --remote=<ref> instead`, }, { alias: "--local", cliArgs: ["seed", "buckets", "--local"], flags: { local: true }, host: "http://", - warning: - "--local isn't needed (local is the default) and will be removed in v4: supabase seed buckets", + warning: "--local isn't needed (local is the default) and will be removed in v4", }, ] as const; 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 b0bc811722..c89da113d2 100644 --- a/apps/cli/src/commands/storage/cp/cp.integration.test.ts +++ b/apps/cli/src/commands/storage/cp/cp.integration.test.ts @@ -661,23 +661,21 @@ describe("storage cp", () => { cliArgs: ["storage", "cp", "--linked"], flags: { linked: true }, host: `https://${VALID_REF}.supabase.co`, - warning: - "--linked is deprecated and will be removed in v4. Use --remote instead: supabase storage cp --remote", + warning: "--linked is deprecated and will be removed in v4. Use --remote instead", }, { alias: "--project-ref", cliArgs: ["storage", "cp", "--project-ref", FLAG_REF], flags: { projectRef: Option.some(FLAG_REF) }, host: `https://${FLAG_REF}.supabase.co`, - warning: `--project-ref is deprecated and will be removed in v4. Use --remote=<ref> instead: supabase storage cp --remote=${FLAG_REF}`, + warning: `--project-ref is deprecated and will be removed in v4. Use --remote=<ref> instead`, }, { alias: "--local", cliArgs: ["storage", "cp", "--local"], flags: { local: true }, host: "http://127.0.0.1:65432", - warning: - "--local isn't needed (local is the default) and will be removed in v4: supabase storage cp", + warning: "--local isn't needed (local is the default) and will be removed in v4", }, ] as const; 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 1361ab8fcf..183099bf63 100644 --- a/apps/cli/src/commands/storage/ls/ls.integration.test.ts +++ b/apps/cli/src/commands/storage/ls/ls.integration.test.ts @@ -361,23 +361,21 @@ project_id = "env(STAGING_REF)" cliArgs: ["storage", "ls", "--linked"], flags: { linked: true }, host: `https://${VALID_REF}.supabase.co`, - warning: - "--linked is deprecated and will be removed in v4. Use --remote instead: supabase storage ls --remote", + warning: "--linked is deprecated and will be removed in v4. Use --remote instead", }, { alias: "--project-ref", cliArgs: ["storage", "ls", "--project-ref", FLAG_REF], flags: { projectRef: Option.some(FLAG_REF) }, host: `https://${FLAG_REF}.supabase.co`, - warning: `--project-ref is deprecated and will be removed in v4. Use --remote=<ref> instead: supabase storage ls --remote=${FLAG_REF}`, + warning: `--project-ref is deprecated and will be removed in v4. Use --remote=<ref> instead`, }, { alias: "--local", cliArgs: ["storage", "ls", "--local"], flags: { local: true }, host: "http://127.0.0.1:65432", - warning: - "--local isn't needed (local is the default) and will be removed in v4: supabase storage ls", + warning: "--local isn't needed (local is the default) and will be removed in v4", }, ] as const; 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 c84213db59..864225675c 100644 --- a/apps/cli/src/commands/storage/mv/mv.integration.test.ts +++ b/apps/cli/src/commands/storage/mv/mv.integration.test.ts @@ -383,23 +383,21 @@ describe("storage mv", () => { cliArgs: ["storage", "mv", "--linked"], flags: { linked: true }, host: `https://${VALID_REF}.supabase.co`, - warning: - "--linked is deprecated and will be removed in v4. Use --remote instead: supabase storage mv --remote", + warning: "--linked is deprecated and will be removed in v4. Use --remote instead", }, { alias: "--project-ref", cliArgs: ["storage", "mv", "--project-ref", FLAG_REF], flags: { projectRef: Option.some(FLAG_REF) }, host: `https://${FLAG_REF}.supabase.co`, - warning: `--project-ref is deprecated and will be removed in v4. Use --remote=<ref> instead: supabase storage mv --remote=${FLAG_REF}`, + warning: `--project-ref is deprecated and will be removed in v4. Use --remote=<ref> instead`, }, { alias: "--local", cliArgs: ["storage", "mv", "--local"], flags: { local: true }, host: "http://127.0.0.1:65432", - warning: - "--local isn't needed (local is the default) and will be removed in v4: supabase storage mv", + warning: "--local isn't needed (local is the default) and will be removed in v4", }, ] as const; 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 f5e375348f..1232ca8a38 100644 --- a/apps/cli/src/commands/storage/rm/rm.integration.test.ts +++ b/apps/cli/src/commands/storage/rm/rm.integration.test.ts @@ -852,23 +852,21 @@ describe("storage rm", () => { cliArgs: ["storage", "rm", "--linked"], flags: { linked: true }, host: `https://${VALID_REF}.supabase.co`, - warning: - "--linked is deprecated and will be removed in v4. Use --remote instead: supabase storage rm --remote", + warning: "--linked is deprecated and will be removed in v4. Use --remote instead", }, { alias: "--project-ref", cliArgs: ["storage", "rm", "--project-ref", FLAG_REF], flags: { projectRef: Option.some(FLAG_REF) }, host: `https://${FLAG_REF}.supabase.co`, - warning: `--project-ref is deprecated and will be removed in v4. Use --remote=<ref> instead: supabase storage rm --remote=${FLAG_REF}`, + warning: `--project-ref is deprecated and will be removed in v4. Use --remote=<ref> instead`, }, { alias: "--local", cliArgs: ["storage", "rm", "--local"], flags: { local: true }, host: "http://127.0.0.1:65432", - warning: - "--local isn't needed (local is the default) and will be removed in v4: supabase storage rm", + warning: "--local isn't needed (local is the default) and will be removed in v4", }, ] as const; diff --git a/apps/cli/src/shared/cli/cli-args.service.ts b/apps/cli/src/shared/cli/cli-args.service.ts index fbee2e8a4d..cac0908124 100644 --- a/apps/cli/src/shared/cli/cli-args.service.ts +++ b/apps/cli/src/shared/cli/cli-args.service.ts @@ -5,8 +5,6 @@ export interface CliArgsShape { readonly args: ReadonlyArray<string>; /** The word a bare `--remote` is followed by when it was taken as an operand and could name a branch. */ readonly bareRemoteOperand?: string | undefined; - /** The operand placeholders the resolved command's help lists, e.g. `<NAME=VALUE>...`; read lazily, only a warning needs them. */ - readonly operandPlaceholders?: (() => ReadonlyArray<string>) | undefined; } export class CliArgs extends Context.Service<CliArgs, CliArgsShape>()("supabase/cli/CliArgs") {} diff --git a/apps/cli/src/shared/cli/remote-argv.unit.test.ts b/apps/cli/src/shared/cli/remote-argv.unit.test.ts index 77aae6c86e..63fcc9c410 100644 --- a/apps/cli/src/shared/cli/remote-argv.unit.test.ts +++ b/apps/cli/src/shared/cli/remote-argv.unit.test.ts @@ -302,10 +302,6 @@ describe("prepareCliArgs bareRemoteOperand on the real command tree", () => { expect(operandOf(["gen", "types", "--remote", "feature-x"])).toBeUndefined(); }); - it("leaves gen types's hidden legacy language operand out of its placeholders", () => { - expect(prepareCliArgs(root, ["gen", "types", "--remote"]).operandPlaceholders?.()).toEqual([]); - }); - it("finds nothing once every word is joined into --remote=<word>", () => { expect(operandOf(["db", "push", "--remote", "feature-x"])).toBeUndefined(); }); diff --git a/apps/cli/src/shared/cli/run.ts b/apps/cli/src/shared/cli/run.ts index bd073ee71e..940ba9a2a4 100644 --- a/apps/cli/src/shared/cli/run.ts +++ b/apps/cli/src/shared/cli/run.ts @@ -73,7 +73,6 @@ import { flagAliasesFor, isRemoteFlagMisuse, isValueTakingFlagTokenFor, - operandPlaceholdersAt, resolvedCommandPathForArgv, } from "./subcommand-flag-suggestions.ts"; @@ -229,24 +228,11 @@ export function prepareCliArgs( rawArgs: ReadonlyArray<string>, ): CliArgsShape { const leaf = remoteArgvLeafFor(rootCommand, rawArgs); - if (leaf === undefined) { - return { args: rawArgs, operandPlaceholders: operandPlaceholdersFor(rootCommand, rawArgs) }; - } + if (leaf === undefined) return { args: rawArgs }; const args = joinRemoteOperands(rawArgs, leaf); - return { - args, - bareRemoteOperand: bareRemoteOperand(args, leaf), - operandPlaceholders: operandPlaceholdersFor(rootCommand, args), - }; + return { args, bareRemoteOperand: bareRemoteOperand(args, leaf) }; } -const operandPlaceholdersFor = - (rootCommand: Command.Command.Any, args: ReadonlyArray<string>) => (): ReadonlyArray<string> => - operandPlaceholdersAt( - rootCommand, - resolvedCommandPathForArgv(rootCommand, extractCommandPath(args)), - ); - /** `CliArgs` and a `Stdio` whose `args` is the same argv, so every reader of either sees the rewritten command line. */ export const cliArgsLayer = (cliArgs: CliArgsShape) => Layer.mergeAll( diff --git a/apps/cli/src/shared/cli/subcommand-flag-suggestions.ts b/apps/cli/src/shared/cli/subcommand-flag-suggestions.ts index a3ba680193..7a9e39668a 100644 --- a/apps/cli/src/shared/cli/subcommand-flag-suggestions.ts +++ b/apps/cli/src/shared/cli/subcommand-flag-suggestions.ts @@ -70,16 +70,6 @@ export function visibleOperandsOf( }); } -/** The operand placeholders the command at `path` lists in its help, e.g. `<NAME=VALUE>...`. */ -export function operandPlaceholdersAt( - rootCommand: Command.Command.Any, - path: ReadonlyArray<string>, -): ReadonlyArray<string> { - const command = findCommand(rootCommand, path.slice(1)); - const operands = command === undefined ? [] : visibleOperandsOf(command); - return operands.map((operand) => `<${operand.name}>${operand.variadic ? "..." : ""}`); -} - const flagArityOf = (single: Param.Single<Param.ParamKind, unknown>): FlagArity => { if (single.primitiveType._tag !== "Boolean") return "value"; return isRemoteFlag(single) ? "optional-value" : "switch"; diff --git a/apps/cli/tests/helpers/hosted-target-selection.ts b/apps/cli/tests/helpers/hosted-target-selection.ts index 6a34f4460c..07298ef89e 100644 --- a/apps/cli/tests/helpers/hosted-target-selection.ts +++ b/apps/cli/tests/helpers/hosted-target-selection.ts @@ -154,7 +154,7 @@ export function describeTargetSelectionMatrix<R>( expectActedOn(run, OTHER_REF); expect(run.warnings()).toEqual([ - `--project-ref is deprecated and will be removed in v4. Use --remote=<ref> instead: supabase ${path} --remote=${OTHER_REF}`, + `--project-ref is deprecated and will be removed in v4. Use --remote=<ref> instead`, ]); }).pipe(Effect.provide(run.layer)); }); From 18ea7ae7ae7e4db7275b6ec62bfa7f8046ee855a Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Tue, 20 Oct 2026 11:39:33 +0100 Subject: [PATCH 177/291] refactor(cli): drop the database-target default from permission declarations Every database command declared the local stack as its default, and the target policy already owns that default. The permission registry now reads a run with no target flag as the local stack, so withDbTarget and the default it carried leave the model and every declaration. --- .../command-permissions.unit.test.ts | 9 ++- .../command-permissions/model.ts | 30 +------ .../src/command-internal/db-config.layer.ts | 5 +- .../command-internal/storage-permissions.ts | 13 ++- .../target/target-policy.unit.test.ts | 16 ---- .../commands/db/advisors/advisors.command.ts | 67 +++++++--------- apps/cli/src/commands/db/diff/diff.command.ts | 46 +++++------ apps/cli/src/commands/db/dump/dump.command.ts | 45 +++++------ apps/cli/src/commands/db/lint/lint.command.ts | 19 ++--- apps/cli/src/commands/db/pull/pull.command.ts | 39 ++++----- apps/cli/src/commands/db/push/push.command.ts | 31 +++---- .../src/commands/db/query/query.command.ts | 27 +++---- .../src/commands/db/reset/reset.command.ts | 19 ++--- .../declarative/generate/generate.command.ts | 34 ++++---- apps/cli/src/commands/db/test/test.command.ts | 4 +- .../src/commands/gen/types/types.command.ts | 80 +++++++++---------- .../commands/inspect/db/inspect-db-command.ts | 6 +- .../commands/inspect/report/report.command.ts | 7 +- .../commands/migration/down/down.command.ts | 19 ++--- .../commands/migration/fetch/fetch.command.ts | 19 ++--- .../commands/migration/list/list.command.ts | 19 ++--- .../migration/repair/repair.command.ts | 19 ++--- .../migration/squash/squash.command.ts | 19 ++--- .../src/commands/migration/up/up.command.ts | 19 ++--- apps/cli/src/commands/test/db/db.command.ts | 4 +- apps/cli/tests/helpers/permission-registry.ts | 23 ++---- .../helpers/permission-registry.unit.test.ts | 21 ++--- 27 files changed, 239 insertions(+), 420 deletions(-) diff --git a/apps/cli/src/command-internal/command-permissions/command-permissions.unit.test.ts b/apps/cli/src/command-internal/command-permissions/command-permissions.unit.test.ts index 1c1f76a1c2..6b4bc5c038 100644 --- a/apps/cli/src/command-internal/command-permissions/command-permissions.unit.test.ts +++ b/apps/cli/src/command-internal/command-permissions/command-permissions.unit.test.ts @@ -11,6 +11,7 @@ import { GLOBAL_NO_API_EFFECT_FLAGS } from "../../../tests/helpers/permission-gl import type { CommandPermissions, EntryCondition, OperationEntry } from "./model.ts"; import { DB_TARGET_FLAGS, SELECTOR_FLAGS } from "../../../tests/helpers/permission-registry.ts"; import type { PermissionVariant } from "../../../tests/helpers/permission-registry.ts"; +import { readTargetPolicy } from "../target/target-policy.ts"; /** * Whole-command Go delegation, per `docs/go-cli-porting-status.md`. Flag-gated delegation @@ -121,13 +122,13 @@ describe("command permission mapping completeness", () => { if (cond.dbTarget !== undefined) { const where = `"${path}"'s \`when\` condition on "${entry.operationId}"`; expect( - permissions.dbTargetDefault, - `${where} checks the database target, but the declaration sets no default — wrap it in withDbTarget`, - ).toBeDefined(); + Option.getOrUndefined(readTargetPolicy(node.command))?.group, + `${where} checks the database target, which only a selectable leaf can choose between`, + ).toBe("selectable"); // A block's condition may name a target the command can't select; a command's own must be reachable. for (const target of cond.source === undefined ? cond.dbTarget : []) { expect( - target === permissions.dbTargetDefault || node.flags.has(target), + target === "local" || node.flags.has(target), `${where} names the "${target}" target, which --${target} can't select and isn't the default`, ).toBe(true); } diff --git a/apps/cli/src/command-internal/command-permissions/model.ts b/apps/cli/src/command-internal/command-permissions/model.ts index 882d31464c..165d98103f 100644 --- a/apps/cli/src/command-internal/command-permissions/model.ts +++ b/apps/cli/src/command-internal/command-permissions/model.ts @@ -59,7 +59,8 @@ export type DbTarget = "local" | "db-url" | "linked"; /** * The run's database target must be one of `dbTarget` for an entry to apply. The target is * `--db-url` (or `--remote=<url>`) if given, else `--local`, else `--linked`, `--project-ref` or - * any other `--remote`, else the default set with {@link withDbTarget}. As with {@link FlagCondition}, `source` marks a building block's condition. + * any other `--remote`, else the local stack. As with {@link FlagCondition}, `source` marks a + * building block's condition. */ interface DbTargetCondition { readonly dbTarget: ReadonlyArray<DbTarget>; @@ -106,8 +107,6 @@ export interface PermissionFragment { readonly operations: ReadonlyArray<OperationEntry>; /** Flags this fragment contributes that never change which operations it calls. */ readonly noApiEffectFlags?: ReadonlyArray<string>; - /** Which database the command targets when no target flag is given; see {@link withDbTarget}. */ - readonly dbTargetDefault?: "local" | "linked"; } export type CommandPermissions = @@ -115,7 +114,6 @@ export type CommandPermissions = readonly status: "mapped"; readonly operations: ReadonlyArray<OperationEntry>; readonly noApiEffectFlags: ReadonlyArray<string>; - readonly dbTargetDefault?: "local" | "linked"; } | { readonly status: "unmapped"; readonly reason: "go-delegated" }; @@ -145,17 +143,9 @@ export function block( /** Merges fragments' operations and `noApiEffectFlags` into one, preserving entry order. */ export function compose(...fragments: ReadonlyArray<PermissionFragment>): PermissionFragment { - const defaults = new Set(fragments.flatMap((fragment) => fragment.dbTargetDefault ?? [])); - if (defaults.size > 1) { - throw new Error( - `compose: fragments disagree on the default database target (${[...defaults]})`, - ); - } - const [dbTargetDefault] = defaults; return { operations: fragments.flatMap((fragment) => fragment.operations), noApiEffectFlags: fragments.flatMap((fragment) => fragment.noApiEffectFlags ?? []), - ...(dbTargetDefault === undefined ? {} : { dbTargetDefault }), }; } @@ -208,27 +198,11 @@ export function withKind(fragment: PermissionFragment, kind: PermissionKind): Pe }; } -/** - * Sets the database a command targets when no `--db-url`, `--local` or `--linked` flag is given, - * for entries with a {@link DbTargetCondition}. - * - * @public - */ -export function withDbTarget( - fragment: PermissionFragment, - options: { readonly default: "local" | "linked" }, -): PermissionFragment { - return { ...fragment, dbTargetDefault: options.default }; -} - /** Builds a `"mapped"` {@link CommandPermissions} from a fragment, e.g. the result of {@link compose}. */ export function mapped(fragment: PermissionFragment): CommandPermissions { return { status: "mapped", operations: fragment.operations, noApiEffectFlags: fragment.noApiEffectFlags ?? [], - ...(fragment.dbTargetDefault === undefined - ? {} - : { dbTargetDefault: fragment.dbTargetDefault }), }; } diff --git a/apps/cli/src/command-internal/db-config.layer.ts b/apps/cli/src/command-internal/db-config.layer.ts index b20ee669d8..36d8995a61 100644 --- a/apps/cli/src/command-internal/db-config.layer.ts +++ b/apps/cli/src/command-internal/db-config.layer.ts @@ -103,9 +103,8 @@ export const dbConfigNamedProject = block("db-config", [ ]); /** - * `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}. + * `DbConfigResolver` on the linked path; `--local` and `--db-url` make no API calls. The container + * retry that `db dump` and `db pull` run is {@link dbConfigPoolerFallback}. */ export const dbConfig = block( "db-config", diff --git a/apps/cli/src/command-internal/storage-permissions.ts b/apps/cli/src/command-internal/storage-permissions.ts index dd611f7cd7..0391e20b4c 100644 --- a/apps/cli/src/command-internal/storage-permissions.ts +++ b/apps/cli/src/command-internal/storage-permissions.ts @@ -1,15 +1,12 @@ import { linkedProjectCache } from "../telemetry/linked-project-cache.layer.ts"; -import { compose, withCondition, withDbTarget } from "./command-permissions/model.ts"; +import { compose, withCondition } from "./command-permissions/model.ts"; import { storageCredentials } from "./storage-credentials.ts"; import { branchResolution } from "./target/branch-resolution.ts"; /** The Management API calls a Storage-API command makes on the linked project. */ export const storagePermissions = (noApiEffectFlags: ReadonlyArray<string>) => - withDbTarget( - compose( - { operations: [], noApiEffectFlags }, - withCondition(compose(storageCredentials, linkedProjectCache), { dbTarget: ["linked"] }), - branchResolution, - ), - { default: "local" }, + compose( + { operations: [], noApiEffectFlags }, + withCondition(compose(storageCredentials, linkedProjectCache), { dbTarget: ["linked"] }), + branchResolution, ); 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 b5ebeebb63..a9286f36c7 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 @@ -126,22 +126,6 @@ describe.each(leavesByVariant)( expect(offenders).toEqual([]); }); - it("matches a declared database-target default to the group", () => { - const expected: Partial<Record<TargetGroup, "linked" | "local">> = { - 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}`]; - }); - 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") diff --git a/apps/cli/src/commands/db/advisors/advisors.command.ts b/apps/cli/src/commands/db/advisors/advisors.command.ts index 5ac9fe8670..aa103d8b13 100644 --- a/apps/cli/src/commands/db/advisors/advisors.command.ts +++ b/apps/cli/src/commands/db/advisors/advisors.command.ts @@ -1,11 +1,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, - withCondition, - withDbTarget, -} from "../../../command-internal/command-permissions/model.ts"; +import { compose, withCondition } 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"; @@ -40,38 +36,35 @@ export const dbAdvisorsCommand = Command.make("advisors", config).pipe( Command.withDescription("Checks database for security and performance issues."), Command.withShortDescription("Checks database for security and performance issues"), withPermissions( - withDbTarget( - compose( - { - operations: [ - { - operationId: "v1-get-security-advisors", - kind: "required", - when: [{ dbTarget: ["linked"] }, { flag: "type", oneOf: ["all", "security"] }], - }, - { - operationId: "v1-get-security-advisors", - kind: "required", - when: [{ dbTarget: ["linked"] }, { flag: "type", present: false }], - }, - { - operationId: "v1-get-performance-advisors", - kind: "required", - when: [{ dbTarget: ["linked"] }, { flag: "type", oneOf: ["all", "performance"] }], - }, - { - operationId: "v1-get-performance-advisors", - kind: "required", - when: [{ dbTarget: ["linked"] }, { flag: "type", present: false }], - }, - ], - noApiEffectFlags: ["level", "fail-on"], - }, - dbConfig, - withCondition(linkedProjectCache, { dbTarget: ["linked"] }), - branchResolution, - ), - { default: "local" }, + compose( + { + operations: [ + { + operationId: "v1-get-security-advisors", + kind: "required", + when: [{ dbTarget: ["linked"] }, { flag: "type", oneOf: ["all", "security"] }], + }, + { + operationId: "v1-get-security-advisors", + kind: "required", + when: [{ dbTarget: ["linked"] }, { flag: "type", present: false }], + }, + { + operationId: "v1-get-performance-advisors", + kind: "required", + when: [{ dbTarget: ["linked"] }, { flag: "type", oneOf: ["all", "performance"] }], + }, + { + operationId: "v1-get-performance-advisors", + kind: "required", + when: [{ dbTarget: ["linked"] }, { flag: "type", present: false }], + }, + ], + noApiEffectFlags: ["level", "fail-on"], + }, + dbConfig, + withCondition(linkedProjectCache, { dbTarget: ["linked"] }), + branchResolution, ), ), Command.withHandler((flags) => diff --git a/apps/cli/src/commands/db/diff/diff.command.ts b/apps/cli/src/commands/db/diff/diff.command.ts index 3beca2e430..47810df315 100644 --- a/apps/cli/src/commands/db/diff/diff.command.ts +++ b/apps/cli/src/commands/db/diff/diff.command.ts @@ -6,7 +6,6 @@ import { compose, withCondition, withContext, - withDbTarget, } from "../../../command-internal/command-permissions/model.ts"; import { dbConfig, @@ -121,30 +120,27 @@ export const dbDiffCommand = Command.make("diff", config).pipe( ), Command.withShortDescription("Diffs a database for schema changes"), withPermissions( - withDbTarget( - compose( - { - operations: [explicitBranchSide("from"), explicitBranchSide("to")], - noApiEffectFlags: [ - "use-migra", - "use-pgadmin", - "use-pg-schema", - "use-pg-delta", - "strict-coverage", - "output", - "file", - "schema", - ], - }, - dbConfig, - withCondition(linkedProjectCache, { dbTarget: ["linked"] }), - explicitLinkedSide("from"), - explicitLinkedSide("to"), - explicitNamedProjectSide("from"), - explicitNamedProjectSide("to"), - branchResolution, - ), - { default: "local" }, + compose( + { + operations: [explicitBranchSide("from"), explicitBranchSide("to")], + noApiEffectFlags: [ + "use-migra", + "use-pgadmin", + "use-pg-schema", + "use-pg-delta", + "strict-coverage", + "output", + "file", + "schema", + ], + }, + dbConfig, + withCondition(linkedProjectCache, { dbTarget: ["linked"] }), + explicitLinkedSide("from"), + explicitLinkedSide("to"), + explicitNamedProjectSide("from"), + explicitNamedProjectSide("to"), + branchResolution, ), ), Command.withHandler((flags) => diff --git a/apps/cli/src/commands/db/dump/dump.command.ts b/apps/cli/src/commands/db/dump/dump.command.ts index 84b7f42d9d..64772ea1f1 100644 --- a/apps/cli/src/commands/db/dump/dump.command.ts +++ b/apps/cli/src/commands/db/dump/dump.command.ts @@ -3,11 +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, - withCondition, - withDbTarget, -} from "../../../command-internal/command-permissions/model.ts"; +import { compose, withCondition } from "../../../command-internal/command-permissions/model.ts"; import { dbConfig, dbConfigPoolerFallback } from "../../../command-internal/db-config.layer.ts"; import { linkedProjectCache } from "../../../telemetry/linked-project-cache.layer.ts"; import { withCliConfigFlags } from "../../../config/cli-config-flags.ts"; @@ -103,27 +99,24 @@ export const dbDumpCommand = Command.make("dump", config).pipe( Command.withDescription("Dumps data or schemas from a database."), Command.withShortDescription("Dumps data or schemas from a database"), withPermissions( - withDbTarget( - compose( - { - operations: [], - noApiEffectFlags: [ - "dry-run", - "data-only", - "use-copy", - "exclude", - "role-only", - "keep-comments", - "file", - "schema", - ], - }, - dbConfig, - dbConfigPoolerFallback, - withCondition(linkedProjectCache, { dbTarget: ["linked"] }), - branchResolution, - ), - { default: "local" }, + compose( + { + operations: [], + noApiEffectFlags: [ + "dry-run", + "data-only", + "use-copy", + "exclude", + "role-only", + "keep-comments", + "file", + "schema", + ], + }, + dbConfig, + dbConfigPoolerFallback, + withCondition(linkedProjectCache, { dbTarget: ["linked"] }), + branchResolution, ), ), Command.withHandler((flags) => diff --git a/apps/cli/src/commands/db/lint/lint.command.ts b/apps/cli/src/commands/db/lint/lint.command.ts index a4f7568f6e..bfda6bf94e 100644 --- a/apps/cli/src/commands/db/lint/lint.command.ts +++ b/apps/cli/src/commands/db/lint/lint.command.ts @@ -1,11 +1,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, - withCondition, - withDbTarget, -} from "../../../command-internal/command-permissions/model.ts"; +import { compose, withCondition } 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"; @@ -46,14 +42,11 @@ export const dbLintCommand = Command.make("lint", config).pipe( Command.withDescription("Checks a database for typing error."), Command.withShortDescription("Checks a database for typing error"), withPermissions( - withDbTarget( - compose( - { operations: [], noApiEffectFlags: ["schema", "level", "fail-on"] }, - dbConfig, - withCondition(linkedProjectCache, { dbTarget: ["linked"] }), - branchResolution, - ), - { default: "local" }, + compose( + { operations: [], noApiEffectFlags: ["schema", "level", "fail-on"] }, + dbConfig, + withCondition(linkedProjectCache, { dbTarget: ["linked"] }), + branchResolution, ), ), Command.withHandler((flags) => diff --git a/apps/cli/src/commands/db/pull/pull.command.ts b/apps/cli/src/commands/db/pull/pull.command.ts index 98d4c4cbf2..1f63c6fa4d 100644 --- a/apps/cli/src/commands/db/pull/pull.command.ts +++ b/apps/cli/src/commands/db/pull/pull.command.ts @@ -3,11 +3,7 @@ import { Argument, 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, - withCondition, - withDbTarget, -} from "../../../command-internal/command-permissions/model.ts"; +import { compose, withCondition } from "../../../command-internal/command-permissions/model.ts"; import { dbConfig, dbConfigPoolerFallback } from "../../../command-internal/db-config.layer.ts"; import { linkedProjectCache } from "../../../telemetry/linked-project-cache.layer.ts"; import { withCliConfigFlags } from "../../../config/cli-config-flags.ts"; @@ -110,24 +106,21 @@ export const dbPullCommand = Command.make("pull", config).pipe( ), Command.withShortDescription("Pull schema from a database"), withPermissions( - withDbTarget( - compose( - { - operations: [], - noApiEffectFlags: [ - "declarative", - "use-pg-delta", - "diff-engine", - "strict-coverage", - "schema", - ], - }, - dbConfig, - dbConfigPoolerFallback, - withCondition(linkedProjectCache, { dbTarget: ["linked"] }), - branchResolution, - ), - { default: "local" }, + compose( + { + operations: [], + noApiEffectFlags: [ + "declarative", + "use-pg-delta", + "diff-engine", + "strict-coverage", + "schema", + ], + }, + dbConfig, + dbConfigPoolerFallback, + withCondition(linkedProjectCache, { dbTarget: ["linked"] }), + branchResolution, ), ), Command.withHandler((flags) => { diff --git a/apps/cli/src/commands/db/push/push.command.ts b/apps/cli/src/commands/db/push/push.command.ts index 24cbba7255..57dc87cf4f 100644 --- a/apps/cli/src/commands/db/push/push.command.ts +++ b/apps/cli/src/commands/db/push/push.command.ts @@ -2,11 +2,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, - withCondition, - withDbTarget, -} from "../../../command-internal/command-permissions/model.ts"; +import { compose, withCondition } 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 { cliConfigValuesLayer } from "../../../config/cli-config-values.layer.ts"; @@ -60,23 +56,14 @@ export const dbPushCommand = Command.make("push", config).pipe( ), Command.withShortDescription("Push new migrations to a database"), withPermissions( - withDbTarget( - compose( - { - operations: [], - noApiEffectFlags: [ - "include-all", - "include-roles", - "include-seed", - "skip-vault", - "dry-run", - ], - }, - dbConfig, - withCondition(linkedProjectCache, { dbTarget: ["linked"] }), - branchResolution, - ), - { default: "local" }, + compose( + { + operations: [], + noApiEffectFlags: ["include-all", "include-roles", "include-seed", "skip-vault", "dry-run"], + }, + dbConfig, + withCondition(linkedProjectCache, { dbTarget: ["linked"] }), + branchResolution, ), ), Command.withHandler((flags) => diff --git a/apps/cli/src/commands/db/query/query.command.ts b/apps/cli/src/commands/db/query/query.command.ts index 64fceea945..f4c7fa4a47 100644 --- a/apps/cli/src/commands/db/query/query.command.ts +++ b/apps/cli/src/commands/db/query/query.command.ts @@ -2,11 +2,7 @@ import { Argument, 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, - withCondition, - withDbTarget, -} from "../../../command-internal/command-permissions/model.ts"; +import { compose, withCondition } from "../../../command-internal/command-permissions/model.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"; @@ -50,18 +46,15 @@ export const dbQueryCommand = Command.make("query", config).pipe( Command.withDescription("Execute a SQL query against the database."), Command.withShortDescription("Execute a SQL query against the database"), withPermissions( - withDbTarget( - compose( - { - operations: [ - { operationId: "v1-run-a-query", kind: "required", when: [{ dbTarget: ["linked"] }] }, - ], - noApiEffectFlags: ["file", "read-only"], - }, - withCondition(linkedProjectCache, { dbTarget: ["linked"] }), - branchResolution, - ), - { default: "local" }, + compose( + { + operations: [ + { operationId: "v1-run-a-query", kind: "required", when: [{ dbTarget: ["linked"] }] }, + ], + noApiEffectFlags: ["file", "read-only"], + }, + withCondition(linkedProjectCache, { dbTarget: ["linked"] }), + branchResolution, ), ), Command.withHandler((flags) => diff --git a/apps/cli/src/commands/db/reset/reset.command.ts b/apps/cli/src/commands/db/reset/reset.command.ts index fc2a63745e..9354862a30 100644 --- a/apps/cli/src/commands/db/reset/reset.command.ts +++ b/apps/cli/src/commands/db/reset/reset.command.ts @@ -2,11 +2,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, - withCondition, - withDbTarget, -} from "../../../command-internal/command-permissions/model.ts"; +import { compose, withCondition } 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 { withCliConfigFlags } from "../../../config/cli-config-flags.ts"; @@ -49,14 +45,11 @@ export const dbResetCommand = Command.make("reset", config).pipe( Command.withDescription("Resets a database to current migrations."), Command.withShortDescription("Resets a database to current migrations"), withPermissions( - withDbTarget( - compose( - { operations: [], noApiEffectFlags: ["no-seed", "sql-paths", "version", "last"] }, - dbConfig, - withCondition(linkedProjectCache, { dbTarget: ["linked"] }), - branchResolution, - ), - { default: "local" }, + compose( + { operations: [], noApiEffectFlags: ["no-seed", "sql-paths", "version", "last"] }, + dbConfig, + withCondition(linkedProjectCache, { dbTarget: ["linked"] }), + branchResolution, ), ), Command.withHandler((flags) => 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 1c77c54234..31c369c63e 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,7 +6,6 @@ import { withPermissions } from "../../../../../command-internal/command-permiss import { compose, 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"; @@ -76,24 +75,21 @@ export const dbSchemaDeclarativeGenerateCommand = Command.make("generate", confi ), Command.withShortDescription("Generate declarative schema from a database"), withPermissions( - withDbTarget( - compose( - { - operations: [], - noApiEffectFlags: [ - "overwrite", - "output-dir", - "reset", - "schema", - "no-cache", - "strict-coverage", - ], - }, - dbConfig, - withCondition(linkedProjectCache, { dbTarget: ["linked"] }), - branchResolution, - ), - { default: "local" }, + compose( + { + operations: [], + noApiEffectFlags: [ + "overwrite", + "output-dir", + "reset", + "schema", + "no-cache", + "strict-coverage", + ], + }, + dbConfig, + withCondition(linkedProjectCache, { dbTarget: ["linked"] }), + branchResolution, ), ), Command.withHandler((flags) => diff --git a/apps/cli/src/commands/db/test/test.command.ts b/apps/cli/src/commands/db/test/test.command.ts index 779dd118fb..cc12aeb193 100644 --- a/apps/cli/src/commands/db/test/test.command.ts +++ b/apps/cli/src/commands/db/test/test.command.ts @@ -1,7 +1,7 @@ import { Command } from "effect/cli"; import { withPermissions } from "../../../command-internal/command-permissions/command-permissions.annotation.ts"; -import { compose, withDbTarget } from "../../../command-internal/command-permissions/model.ts"; +import { compose } from "../../../command-internal/command-permissions/model.ts"; import { dbConfig } from "../../../command-internal/db-config.layer.ts"; import { TEST_DB_DESCRIPTION, @@ -24,7 +24,7 @@ import { withTargetPolicy } from "../../../command-internal/target/target-policy export const dbTestCommand = Command.make("test", testDbConfig).pipe( Command.withDescription(TEST_DB_DESCRIPTION), Command.withShortDescription(TEST_DB_SHORT), - withPermissions(withDbTarget(compose(dbConfig, branchResolution), { default: "local" })), + withPermissions(compose(dbConfig, branchResolution)), Command.withHandler(runTestDbCommand), // `["db", "test"]`, not `["test", "db"]`: the `cli_command_executed` // telemetry records the actual invoked command path, which differs by diff --git a/apps/cli/src/commands/gen/types/types.command.ts b/apps/cli/src/commands/gen/types/types.command.ts index 7d873a2f59..6f1665e9db 100644 --- a/apps/cli/src/commands/gen/types/types.command.ts +++ b/apps/cli/src/commands/gen/types/types.command.ts @@ -5,7 +5,6 @@ import { compose, withCondition, withContext, - withDbTarget, type EntryCondition, } from "../../../command-internal/command-permissions/model.ts"; import { @@ -143,48 +142,45 @@ export const genTypesCommand = Command.make("types", commandConfig).pipe( }, ]), withPermissions( - withDbTarget( - remoteTarget.reduce( - (fragment, condition) => withCondition(fragment, condition), - compose( - { - operations: [ - { - operationId: "v1-generate-typescript-types", - kind: "required", - when: [{ flag: "lang", oneOf: ["typescript"] }], - }, - { operationId: "v1-get-project", kind: "required", when: [nonTypescript] }, - { - operationId: "v1-get-a-branch-config", - kind: "required", - when: [nonTypescript], - context: "the project lookup returns 404, so the ref names a preview branch", - }, - { - operationId: "v1-get-pooler-config", - kind: "required", - when: [nonTypescript], - context: `${notBranch}, the direct database host is unreachable (IPv4-only network) and there is no usable saved supabase/.temp/pooler-url (--remote=<ref>, --project-ref and --project-id ignore the saved file)`, - }, - { - operationId: "v1-get-pooler-config", - kind: "required", - when: [nonTypescript], - context: - "the ref names a preview branch and generating against its direct host fails with an IPv6 connectivity error", - }, - ], - noApiEffectFlags: ["schema", "query-timeout", ...GEN_TYPES_LANGUAGE_FLAG_NAMES], - }, - withCondition(withContext(dbConfigLinked, notBranch), nonTypescript), - withCondition(withContext(dbConfigPoolerFallbackLinked, notBranch), nonTypescript), - projectRefPicker, - linkedProjectCache, - branchResolution, - ), + remoteTarget.reduce( + (fragment, condition) => withCondition(fragment, condition), + compose( + { + operations: [ + { + operationId: "v1-generate-typescript-types", + kind: "required", + when: [{ flag: "lang", oneOf: ["typescript"] }], + }, + { operationId: "v1-get-project", kind: "required", when: [nonTypescript] }, + { + operationId: "v1-get-a-branch-config", + kind: "required", + when: [nonTypescript], + context: "the project lookup returns 404, so the ref names a preview branch", + }, + { + operationId: "v1-get-pooler-config", + kind: "required", + when: [nonTypescript], + context: `${notBranch}, the direct database host is unreachable (IPv4-only network) and there is no usable saved supabase/.temp/pooler-url (--remote=<ref>, --project-ref and --project-id ignore the saved file)`, + }, + { + operationId: "v1-get-pooler-config", + kind: "required", + when: [nonTypescript], + context: + "the ref names a preview branch and generating against its direct host fails with an IPv6 connectivity error", + }, + ], + noApiEffectFlags: ["schema", "query-timeout", ...GEN_TYPES_LANGUAGE_FLAG_NAMES], + }, + withCondition(withContext(dbConfigLinked, notBranch), nonTypescript), + withCondition(withContext(dbConfigPoolerFallbackLinked, notBranch), nonTypescript), + projectRefPicker, + linkedProjectCache, + branchResolution, ), - { default: "local" }, ), ), Command.withHandler(genTypesHandler), 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 7f9d01fd1b..53fc6ce9f5 100644 --- a/apps/cli/src/commands/inspect/db/inspect-db-command.ts +++ b/apps/cli/src/commands/inspect/db/inspect-db-command.ts @@ -1,6 +1,6 @@ import { Effect } from "effect"; -import { compose, withDbTarget } from "../../../command-internal/command-permissions/model.ts"; +import { compose } from "../../../command-internal/command-permissions/model.ts"; import { dbConfig } from "../../../command-internal/db-config.layer.ts"; import { branchResolution } from "../../../command-internal/target/branch-resolution.ts"; import { targetFlags } from "../../../command-internal/target/target-flags.ts"; @@ -16,9 +16,7 @@ import type { InspectConnectionFlags } from "./inspect-query.ts"; export const INSPECT_DB_FLAGS = targetFlags({ 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(compose(dbConfig, branchResolution), { - default: "local", -}); +export const INSPECT_DB_PERMISSIONS = compose(dbConfig, branchResolution); /** Target policy shared by every `inspect db` command. */ export const withInspectDbTargetPolicy = withTargetPolicy({ group: "selectable", diff --git a/apps/cli/src/commands/inspect/report/report.command.ts b/apps/cli/src/commands/inspect/report/report.command.ts index a16f344aba..f7f100ebfe 100644 --- a/apps/cli/src/commands/inspect/report/report.command.ts +++ b/apps/cli/src/commands/inspect/report/report.command.ts @@ -3,7 +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, withDbTarget } from "../../../command-internal/command-permissions/model.ts"; +import { compose } from "../../../command-internal/command-permissions/model.ts"; import { dbConfig } from "../../../command-internal/db-config.layer.ts"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; import { commandRuntimeLayer } from "../../../shared/runtime/command-runtime.layer.ts"; @@ -32,10 +32,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, branchResolution), - { default: "local" }, - ), + compose({ operations: [], noApiEffectFlags: ["output-dir"] }, dbConfig, branchResolution), ), Command.withHandler((flags) => inspectReport(flags).pipe( diff --git a/apps/cli/src/commands/migration/down/down.command.ts b/apps/cli/src/commands/migration/down/down.command.ts index 7cb85efd56..1236a204f6 100644 --- a/apps/cli/src/commands/migration/down/down.command.ts +++ b/apps/cli/src/commands/migration/down/down.command.ts @@ -2,11 +2,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, - withCondition, - withDbTarget, -} from "../../../command-internal/command-permissions/model.ts"; +import { compose, withCondition } 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"; @@ -44,14 +40,11 @@ export const migrationDownCommand = Command.make("down", config).pipe( Command.withDescription("Resets applied migrations up to the last n versions."), Command.withShortDescription("Resets applied migrations up to the last n versions"), withPermissions( - withDbTarget( - compose( - { operations: [], noApiEffectFlags: ["last"] }, - dbConfig, - withCondition(linkedProjectCache, { dbTarget: ["linked"] }), - branchResolution, - ), - { default: "local" }, + compose( + { operations: [], noApiEffectFlags: ["last"] }, + dbConfig, + withCondition(linkedProjectCache, { dbTarget: ["linked"] }), + branchResolution, ), ), Command.withHandler((flags) => diff --git a/apps/cli/src/commands/migration/fetch/fetch.command.ts b/apps/cli/src/commands/migration/fetch/fetch.command.ts index 1809a18643..be2b3d6e5f 100644 --- a/apps/cli/src/commands/migration/fetch/fetch.command.ts +++ b/apps/cli/src/commands/migration/fetch/fetch.command.ts @@ -2,11 +2,7 @@ import { Command } from "effect/cli"; import type * as CliCommand from "effect/cli/Command"; import { withPermissions } from "../../../command-internal/command-permissions/command-permissions.annotation.ts"; -import { - compose, - withCondition, - withDbTarget, -} from "../../../command-internal/command-permissions/model.ts"; +import { compose, withCondition } 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"; @@ -27,14 +23,11 @@ export const migrationFetchCommand = Command.make("fetch", config).pipe( Command.withDescription("Fetch migration files from history table."), Command.withShortDescription("Fetch migration files from history table"), withPermissions( - withDbTarget( - compose( - { operations: [], noApiEffectFlags: [] }, - dbConfig, - withCondition(linkedProjectCache, { dbTarget: ["linked"] }), - branchResolution, - ), - { default: "local" }, + compose( + { operations: [], noApiEffectFlags: [] }, + dbConfig, + withCondition(linkedProjectCache, { dbTarget: ["linked"] }), + branchResolution, ), ), 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 044bc44c6a..bb3a7cacd6 100644 --- a/apps/cli/src/commands/migration/list/list.command.ts +++ b/apps/cli/src/commands/migration/list/list.command.ts @@ -2,11 +2,7 @@ import { Command } from "effect/cli"; import type * as CliCommand from "effect/cli/Command"; import { withPermissions } from "../../../command-internal/command-permissions/command-permissions.annotation.ts"; -import { - compose, - withCondition, - withDbTarget, -} from "../../../command-internal/command-permissions/model.ts"; +import { compose, withCondition } 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 { withCliConfigFlags } from "../../../config/cli-config-flags.ts"; @@ -38,14 +34,11 @@ export const migrationListCommand = Command.make("list", config).pipe( ), Command.withShortDescription("List local files and the target database's migration history"), withPermissions( - withDbTarget( - compose( - { operations: [], noApiEffectFlags: [] }, - dbConfig, - withCondition(linkedProjectCache, { dbTarget: ["linked"] }), - branchResolution, - ), - { default: "local" }, + compose( + { operations: [], noApiEffectFlags: [] }, + dbConfig, + withCondition(linkedProjectCache, { dbTarget: ["linked"] }), + branchResolution, ), ), 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 6eaf64e4d5..fd3b650c77 100644 --- a/apps/cli/src/commands/migration/repair/repair.command.ts +++ b/apps/cli/src/commands/migration/repair/repair.command.ts @@ -1,11 +1,7 @@ import { Argument, Command, Flag } from "effect/cli"; import { withPermissions } from "../../../command-internal/command-permissions/command-permissions.annotation.ts"; -import { - compose, - withCondition, - withDbTarget, -} from "../../../command-internal/command-permissions/model.ts"; +import { compose, withCondition } 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 { withCliConfigFlags } from "../../../config/cli-config-flags.ts"; @@ -40,14 +36,11 @@ export const migrationRepairCommand = Command.make("repair", config).pipe( Command.withDescription("Repair the migration history table."), Command.withShortDescription("Repair the migration history table"), withPermissions( - withDbTarget( - compose( - { operations: [], noApiEffectFlags: ["status"] }, - dbConfig, - withCondition(linkedProjectCache, { dbTarget: ["linked"] }), - branchResolution, - ), - { default: "local" }, + compose( + { operations: [], noApiEffectFlags: ["status"] }, + dbConfig, + withCondition(linkedProjectCache, { dbTarget: ["linked"] }), + branchResolution, ), ), Command.withHandler((flags) => diff --git a/apps/cli/src/commands/migration/squash/squash.command.ts b/apps/cli/src/commands/migration/squash/squash.command.ts index a312671bc6..89d229ddf8 100644 --- a/apps/cli/src/commands/migration/squash/squash.command.ts +++ b/apps/cli/src/commands/migration/squash/squash.command.ts @@ -2,11 +2,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, - withCondition, - withDbTarget, -} from "../../../command-internal/command-permissions/model.ts"; +import { compose, withCondition } 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 { withCliConfigFlags } from "../../../config/cli-config-flags.ts"; @@ -40,14 +36,11 @@ export const migrationSquashCommand = Command.make("squash", config).pipe( Command.withDescription("Squash migrations to a single file."), Command.withShortDescription("Squash migrations to a single file"), withPermissions( - withDbTarget( - compose( - { operations: [], noApiEffectFlags: ["version"] }, - dbConfig, - withCondition(linkedProjectCache, { dbTarget: ["linked"] }), - branchResolution, - ), - { default: "local" }, + compose( + { operations: [], noApiEffectFlags: ["version"] }, + dbConfig, + withCondition(linkedProjectCache, { dbTarget: ["linked"] }), + branchResolution, ), ), Command.withHandler((flags) => diff --git a/apps/cli/src/commands/migration/up/up.command.ts b/apps/cli/src/commands/migration/up/up.command.ts index 1c76b32dfb..02c3fdd67a 100644 --- a/apps/cli/src/commands/migration/up/up.command.ts +++ b/apps/cli/src/commands/migration/up/up.command.ts @@ -2,11 +2,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, - withCondition, - withDbTarget, -} from "../../../command-internal/command-permissions/model.ts"; +import { compose, withCondition } 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"; @@ -33,14 +29,11 @@ export const migrationUpCommand = Command.make("up", config).pipe( Command.withDescription("Apply pending migrations to a database."), Command.withShortDescription("Apply pending migrations to a database"), withPermissions( - withDbTarget( - compose( - { operations: [], noApiEffectFlags: ["include-all"] }, - dbConfig, - withCondition(linkedProjectCache, { dbTarget: ["linked"] }), - branchResolution, - ), - { default: "local" }, + compose( + { operations: [], noApiEffectFlags: ["include-all"] }, + dbConfig, + withCondition(linkedProjectCache, { dbTarget: ["linked"] }), + branchResolution, ), ), Command.withHandler((flags) => diff --git a/apps/cli/src/commands/test/db/db.command.ts b/apps/cli/src/commands/test/db/db.command.ts index b962266868..dfd290e8c7 100644 --- a/apps/cli/src/commands/test/db/db.command.ts +++ b/apps/cli/src/commands/test/db/db.command.ts @@ -1,7 +1,7 @@ import { Command } from "effect/cli"; import { withPermissions } from "../../../command-internal/command-permissions/command-permissions.annotation.ts"; -import { compose, withDbTarget } from "../../../command-internal/command-permissions/model.ts"; +import { compose } from "../../../command-internal/command-permissions/model.ts"; import { dbConfig } from "../../../command-internal/db-config.layer.ts"; import { TEST_DB_DESCRIPTION, @@ -22,7 +22,7 @@ import { withTargetPolicy } from "../../../command-internal/target/target-policy export const testDbCommand = Command.make("db", testDbConfig).pipe( Command.withDescription(TEST_DB_DESCRIPTION), Command.withShortDescription(TEST_DB_SHORT), - withPermissions(withDbTarget(compose(dbConfig, branchResolution), { default: "local" })), + withPermissions(compose(dbConfig, branchResolution)), Command.withHandler(runTestDbCommand), Command.provide(testDbRuntimeLayer(["test", "db"])), Command.provide(cliConfigValuesLayer), diff --git a/apps/cli/tests/helpers/permission-registry.ts b/apps/cli/tests/helpers/permission-registry.ts index 8ef8340cd0..60f77c5277 100644 --- a/apps/cli/tests/helpers/permission-registry.ts +++ b/apps/cli/tests/helpers/permission-registry.ts @@ -83,11 +83,8 @@ function selectorShapeOf(activeFlags: ActiveFlags): SelectorShape { } } -/** The target `activeFlags` selects (`db-url`, `local`, `linked`, `remote`, or `project-ref`, which imply linked unless `remote` is a database URL), else `fallback`. */ -function dbTargetOf( - activeFlags: ActiveFlags, - fallback: DbTarget | undefined, -): DbTarget | undefined { +/** The target `activeFlags` selects (`db-url`, `local`, `linked`, `remote`, or `project-ref`, which imply linked unless `remote` is a database URL), else the local stack. */ +function dbTargetOf(activeFlags: ActiveFlags): DbTarget { for (const flag of ["db-url", "local", "linked"] as const satisfies ReadonlyArray<DbTarget>) { if (Object.hasOwn(activeFlags, flag)) return flag; } @@ -96,15 +93,15 @@ function dbTargetOf( return typeof remote === "string" && DB_URL_PATTERN.test(remote) ? "db-url" : "linked"; } if (Object.hasOwn(activeFlags, "project-ref")) return "linked"; - return fallback; + return "local"; } function conditionHolds( condition: EntryCondition, activeFlags: ActiveFlags, - dbTarget: () => DbTarget, + dbTarget: DbTarget, ): boolean { - if (condition.dbTarget !== undefined) return condition.dbTarget.includes(dbTarget()); + if (condition.dbTarget !== undefined) return condition.dbTarget.includes(dbTarget); if (condition.selector !== undefined) { return condition.selector.includes(selectorShapeOf(activeFlags)); } @@ -145,15 +142,7 @@ export function applicableEntries( permissions: MappedCommandPermissions, activeFlags: ActiveFlags, ): ReadonlyArray<OperationEntry> { - const dbTarget = (): DbTarget => { - const target = dbTargetOf(activeFlags, permissions.dbTargetDefault); - if (target === undefined) { - throw new Error( - "an entry checks the database target, but activeFlags names none of --remote, --db-url, --local, --linked and the declaration sets no default (withDbTarget)", - ); - } - return target; - }; + const dbTarget = dbTargetOf(activeFlags); return permissions.operations.filter( (entry) => entry.when === undefined || diff --git a/apps/cli/tests/helpers/permission-registry.unit.test.ts b/apps/cli/tests/helpers/permission-registry.unit.test.ts index 4a2af99da5..97ffed196d 100644 --- a/apps/cli/tests/helpers/permission-registry.unit.test.ts +++ b/apps/cli/tests/helpers/permission-registry.unit.test.ts @@ -93,30 +93,19 @@ describe("applicableOperations", () => { when: [{ dbTarget: ["local", "db-url"] }], }, ] as const; - const applied = ( - activeFlags: Parameters<typeof applicableOperations>[1], - dbTargetDefault?: "local" | "linked", - ) => - applicableOperations( - { ...mapped(entries), ...(dbTargetDefault === undefined ? {} : { dbTargetDefault }) }, - activeFlags, - ).map((e) => e.operationId); + const applied = (activeFlags: Parameters<typeof applicableOperations>[1]) => + applicableOperations(mapped(entries), activeFlags).map((e) => e.operationId); it("takes --db-url over --local over --linked", () => { expect(applied({ linked: true, local: true, "db-url": true })).toEqual([ "v1-get-pooler-config", ]); expect(applied({ linked: true, local: true })).toEqual(["v1-get-pooler-config"]); - expect(applied({ linked: true }, "local")).toEqual(["v1-run-a-query"]); - }); - - it("falls back to the declared default when no target flag is given", () => { - expect(applied({}, "linked")).toEqual(["v1-run-a-query"]); - expect(applied({}, "local")).toEqual(["v1-get-pooler-config"]); + expect(applied({ linked: true })).toEqual(["v1-run-a-query"]); }); - it("rejects a run with no target flag and no default", () => { - expect(() => applied({})).toThrow(/sets no default/); + it("reads a run with no target flag as the local stack", () => { + expect(applied({})).toEqual(["v1-get-pooler-config"]); }); }); From 4bcfc6f26722170e28f09e8998b1bcba4070894d Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Tue, 20 Oct 2026 11:43:26 +0100 Subject: [PATCH 178/291] fix(cli): read the remote-merged config for explicit db diff endpoints Explicit --from/--to read the base config before any endpoint resolved, so a value that only the linked project's [remotes.<ref>] block makes valid failed before the run started. The config now loads lazily with the ref resolved so far, and a hosted endpoint loads it before minting its connection. --- apps/cli/src/commands/db/diff/diff.handler.ts | 37 +++++++++++-------- .../commands/db/diff/diff.integration.test.ts | 31 ++++++++++++++++ 2 files changed, 52 insertions(+), 16 deletions(-) diff --git a/apps/cli/src/commands/db/diff/diff.handler.ts b/apps/cli/src/commands/db/diff/diff.handler.ts index 098b7de5db..15d0a5e04a 100644 --- a/apps/cli/src/commands/db/diff/diff.handler.ts +++ b/apps/cli/src/commands/db/diff/diff.handler.ts @@ -203,10 +203,10 @@ export const dbDiff = Effect.fn("db.diff")(function* (flags: DbDiffFlags) { message: `${selectorFlag} doesn't apply with --from/--to; use --from=${side} or --to=${side}.`, }); } - // `mergedLinkedRef` tracks the linked ref resolved so far so the config read below and a - // later `migrations` catalog export merge the matching `[remotes.<ref>]` override. - // Undefined until a linked ref resolves, so a `migrations` ref resolved before any linked - // ref uses base. + // `mergedLinkedRef` tracks the linked ref resolved so far so `loadCfg` and a later + // `migrations` catalog export merge the matching `[remotes.<ref>]` override. Undefined until + // a linked ref resolves, so a `local` or `migrations` ref resolved before any linked ref + // uses base. let mergedLinkedRef: string | undefined; // The first migrations endpoint resolved wins: the engine provisions a single migrations // shadow/catalog, so only refs resolved before that point should influence it. @@ -217,7 +217,14 @@ export const dbDiff = Effect.fn("db.diff")(function* (flags: DbDiffFlags) { // Each hosted resolution mints a temporary login role that invalidates the previous one for // that project, so every ref to a project shares a single connection. const hostedEndpoints = new Map<string, PgDeltaDatabaseEndpoint>(); - let cfg = yield* readDbToml(fs, path, cliSettings.workdir); + let loadedCfg: { readonly ref: string | undefined; readonly toml: DbTomlValues } | undefined; + const loadCfg = Effect.fnUntraced(function* () { + const ref = mergedLinkedRef; + if (loadedCfg !== undefined && loadedCfg.ref === ref) return loadedCfg.toml; + const toml = yield* readDbToml(fs, path, cliSettings.workdir, ref); + loadedCfg = { ref, toml }; + return toml; + }); const hostedTarget = Effect.fnUntraced(function* ( remote: Exclude<RemoteValue, { readonly _tag: "DbUrl" }>, ) { @@ -238,20 +245,16 @@ export const dbDiff = Effect.fn("db.diff")(function* (flags: DbDiffFlags) { const target = yield* hostedTarget(remote); const existing = hostedEndpoints.get(target.ref); if (existing !== undefined) return existing; + yield* Ref.set(linkedRefForCache, Option.some(target.ref)); + mergedLinkedRef ??= target.ref; + // Loaded before the temp-role mint, so a config error surfaces before that side effect. + yield* loadCfg(); const resolved = yield* resolver.resolveConnection({ target, dnsResolver, password: Option.none(), networkBanRemoval: yield* hostedMutationPreapproval, }); - const ref2 = Option.getOrUndefined(resolved.ref ?? Option.none()); - if (ref2 !== undefined) { - yield* Ref.set(linkedRefForCache, Option.some(ref2)); - if (mergedLinkedRef === undefined) { - mergedLinkedRef = ref2; - cfg = yield* readDbToml(fs, path, cliSettings.workdir, ref2); - } - } const endpoint = { kind: "database", ref: toPostgresURL(resolved.conn), @@ -261,8 +264,8 @@ export const dbDiff = Effect.fn("db.diff")(function* (flags: DbDiffFlags) { hostedEndpoints.set(target.ref, endpoint); return endpoint; }); - // Each ref resolves in order; the first hosted project re-merges its `[remotes.<ref>]` block - // so a later `local` ref read and the trailing `pgDeltaFormatOptions()` see the override. + // Each ref resolves in order; the first hosted project's `[remotes.<ref>]` block applies to + // every config read after it, so a later `local` ref and the trailing context see the override. const resolveRef = (ref: string, flag: "--from" | "--to") => Effect.gen(function* () { const explicit = classifyExplicitRef(ref); @@ -270,6 +273,7 @@ export const dbDiff = Effect.fn("db.diff")(function* (flags: DbDiffFlags) { case "Local": { const backend = yield* currentStackBackend; if (backend.kind !== "stack") { + const cfg = yield* loadCfg(); const hostConfig = yield* configValues.load({ workdir: cliSettings.workdir, projectRef: Option.fromNullishOr(mergedLinkedRef), @@ -338,7 +342,7 @@ export const dbDiff = Effect.fn("db.diff")(function* (flags: DbDiffFlags) { case "Migrations": // Preserve resolution order: the migrations shadow/catalog config must reflect // only refs resolved before this endpoint. - migrationsToml ??= cfg; + migrationsToml ??= yield* loadCfg(); return { kind: "migrations", ...(mergedLinkedRef !== undefined ? { projectRef: mergedLinkedRef } : {}), @@ -355,6 +359,7 @@ export const dbDiff = Effect.fn("db.diff")(function* (flags: DbDiffFlags) { }); const source = yield* resolveRef(from, "--from"); const desired = yield* resolveRef(to, "--to"); + const cfg = yield* loadCfg(); const explicitResolvedConfig = yield* configValues.load({ workdir: cliSettings.workdir, projectRef: Option.fromNullishOr(mergedLinkedRef), 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 21e2f07cc2..8d12d74641 100644 --- a/apps/cli/src/commands/db/diff/diff.integration.test.ts +++ b/apps/cli/src/commands/db/diff/diff.integration.test.ts @@ -2297,6 +2297,37 @@ describe("db diff", () => { }).pipe(Effect.provide(s.layer)); }); + it.effect("explicit --from=remote --to=migrations validates the merged config", () => { + // 16 is not a supported major version: the base config only validates once the linked + // project's remote override replaces it, so reading it before the ref resolves would fail. + const s = setup(tmp.current, { + files: { + "supabase/config.toml": [ + "[db]", + "major_version = 16", + "", + "[remotes.staging]", + 'project_id = "abcdefghijklmnopqrst"', + "", + "[remotes.staging.db]", + "major_version = 15", + "", + ].join("\n"), + }, + isLocal: false, + linkedRef: "abcdefghijklmnopqrst", + diffSql: "create table m ();\n", + }); + return Effect.gen(function* () { + yield* dbDiff(flags({ from: Option.some("remote"), to: Option.some("migrations") })); + expect(s.explicitDiffCalls[0]?.desired).toEqual({ + kind: "migrations", + projectRef: "abcdefghijklmnopqrst", + }); + expect(s.explicitDiffCalls[0]?.toml?.majorVersion).toBe(15); + }).pipe(Effect.provide(s.layer)); + }); + it.effect("empty --from/--to (shell vars) fall through to the normal diff", () => { // `--from "" --to ""` is unset and runs the normal local diff, not an // unknown-target error. From 46c2f5302cda33aa9e91f96916dd9d3f2f8b4cbb Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Tue, 20 Oct 2026 11:43:43 +0100 Subject: [PATCH 179/291] fix(cli): name the cause and the fix when a bare --remote cannot list branches A transport failure names its cause, an HTTP failure reads `failed: HTTP <status>`, and the suggestion follows the status: a 401 depends on where the access token came from, a 403 asks for an access token that can read the branches, and a 404 points at the parent project. --- .../target/branch-resolution.ts | 18 ++- .../target/remote-resolution.ts | 62 ++++++-- .../target/resolve-target.integration.test.ts | 136 ++++++++++++++---- .../command-internal/target/target.errors.ts | 2 +- 4 files changed, 178 insertions(+), 40 deletions(-) diff --git a/apps/cli/src/command-internal/target/branch-resolution.ts b/apps/cli/src/command-internal/target/branch-resolution.ts index 2066a07e2e..fe657ed972 100644 --- a/apps/cli/src/command-internal/target/branch-resolution.ts +++ b/apps/cli/src/command-internal/target/branch-resolution.ts @@ -133,6 +133,20 @@ const startBranchLookupTask = Effect.fnUntraced(function* (label: string) { return output.format === "text" && output.interactive ? yield* output.task(label) : undefined; }); +const LIST_BRANCHES_FAILED = "failed to list branches: "; + +/** The transport failure behind a branch-list error, without the `failed to list branches: ` lead the error message carries. */ +export const listBranchesFailureCause = (error: LinkBranchListNetworkError): string => { + const [firstLine = ""] = error.message.split("\n"); + return firstLine.startsWith(LIST_BRANCHES_FAILED) + ? firstLine.slice(LIST_BRANCHES_FAILED.length) + : firstLine; +}; + +/** What to try when the project whose branches were listed is itself a preview branch, which has none. */ +export const previewBranchHint = (ref: string): string => + `If ${ref} is itself a preview branch, link its parent project first: supabase link --project-ref <parent-ref>`; + /** `GET /v1/projects/{ref}/branches`, shared by every branch-name lookup. */ export const listParentBranches = Effect.fnUntraced(function* (parentRef: string, label: string) { const api = yield* acquireApi(); @@ -140,11 +154,11 @@ export const listParentBranches = Effect.fnUntraced(function* (parentRef: string const mapBranchListError = mapHttpError({ networkError: LinkBranchListNetworkError, statusError: LinkBranchListStatusError, - networkMessage: (cause) => `failed to list branches: ${cause}`, + networkMessage: (cause) => `${LIST_BRANCHES_FAILED}${cause}`, statusMessage: (status, body) => { if (status === 401) return AUTHENTICATION_FAILED_STATUS_MESSAGE; return status === 404 - ? `Cannot list branches for project ${parentRef} (HTTP 404). If ${parentRef} is itself a preview branch, link its parent project first: supabase link --project-ref <parent-ref>` + ? `Cannot list branches for project ${parentRef} (HTTP 404). ${previewBranchHint(parentRef)}` : `unexpected list branches status ${status}: ${body}`; }, }); diff --git a/apps/cli/src/command-internal/target/remote-resolution.ts b/apps/cli/src/command-internal/target/remote-resolution.ts index 85bc4f6c0b..6680cca48e 100644 --- a/apps/cli/src/command-internal/target/remote-resolution.ts +++ b/apps/cli/src/command-internal/target/remote-resolution.ts @@ -14,8 +14,17 @@ import { import { yellow } from "../colors.ts"; import { formatNamedRef, sanitizeInlineName } from "../http-errors.ts"; import { readCachedLinkedProject, resolveLinkedParentRef } from "../parent-project-ref.ts"; -import { findBranch, listParentBranches, resolveRemoteBranchRef } from "./branch-resolution.ts"; -import { LinkBranchListStatusError } from "./branch-resolution.errors.ts"; +import { + findBranch, + listBranchesFailureCause, + listParentBranches, + previewBranchHint, + resolveRemoteBranchRef, +} from "./branch-resolution.ts"; +import { + LinkBranchListNetworkError, + LinkBranchListStatusError, +} from "./branch-resolution.errors.ts"; import { closestByName } from "./closest-branch.ts"; import type { RemoteValue } from "./remote-value.ts"; import { stripUserinfo } from "./remote-vocabulary.ts"; @@ -79,6 +88,10 @@ const linkedProjectName = Effect.fnUntraced(function* (ref: string) { const LOGIN_SUGGESTION = "Run `supabase login` with an account that can list this project's branches."; +const SET_TOKEN_SUGGESTION = "Set SUPABASE_ACCESS_TOKEN to a valid access token."; +const FORBIDDEN_SUGGESTION = "Use an access token that can read this project's branches."; + +type TokenSource = "env" | "stored"; interface ListingFailure { readonly reason: string; @@ -87,14 +100,36 @@ interface ListingFailure { readonly authFailure?: "login" | "token"; } -const listingFailure = (error: unknown): ListingFailure => { +const statusFailure = ( + status: number, + parentRef: string, + tokenSource: TokenSource, +): ListingFailure => { + const reason = `: HTTP ${status}`; + switch (status) { + case 401: + return tokenSource === "env" + ? { status, reason, suggestion: SET_TOKEN_SUGGESTION, authFailure: "token" } + : { status, reason, suggestion: LOGIN_SUGGESTION }; + case 403: + return { status, reason, suggestion: FORBIDDEN_SUGGESTION }; + case 404: + return { status, reason, suggestion: previewBranchHint(parentRef) }; + default: + return { status, reason }; + } +}; + +const listingFailure = ( + error: unknown, + parentRef: string, + tokenSource: TokenSource, +): ListingFailure => { if (error instanceof LinkBranchListStatusError) { - const { status } = error; - return { - status, - reason: ` with HTTP ${status}`, - suggestion: status === 401 || status === 403 ? LOGIN_SUGGESTION : undefined, - }; + return statusFailure(error.status, parentRef, tokenSource); + } + if (error instanceof LinkBranchListNetworkError) { + return { reason: `: ${sanitizeInlineName(stripUserinfo(listBranchesFailureCause(error)))}` }; } if (error instanceof AccessTokenRequiredError) { return { @@ -107,7 +142,7 @@ const listingFailure = (error: unknown): ListingFailure => { return error.source === "env" ? { reason: " because SUPABASE_ACCESS_TOKEN is not a valid access token", - suggestion: "Set SUPABASE_ACCESS_TOKEN to a valid access token.", + suggestion: SET_TOKEN_SUGGESTION, authFailure: "token", } : { @@ -130,7 +165,12 @@ const checkRemoteOperand = Effect.fn("Target.checkRemoteOperand")(function* (raw const listed = yield* listParentBranches(parent.ref, "Checking branches...").pipe(Effect.result); if (Result.isFailure(listed)) { - const failure = listingFailure(listed.failure); + const { accessToken } = yield* CommandSettings; + const failure = listingFailure( + listed.failure, + parent.ref, + Option.isSome(accessToken) ? "env" : "stored", + ); return yield* new TargetRemoteAmbiguousError({ message: `Can't tell whether the word after --remote is a branch (listing the branches of project ${parent.ref} failed${failure.reason}). For the branch: ${usage} --remote=<branch>. For the linked project, put --remote last: ${usage} <operands> --remote`, ...(failure.suggestion === undefined ? {} : { suggestion: failure.suggestion }), 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 0de8c323b0..4b2e3dd266 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 @@ -7,6 +7,7 @@ import { jsonResponse, mockCommandPlatformApi, mockCommandSettings, + transportFailure, } from "../../../tests/helpers/command-mocks.ts"; import { stripAnsi } from "../../../tests/helpers/ansi.ts"; import { mockOutput } from "../../../tests/helpers/mocks.ts"; @@ -22,7 +23,11 @@ import { commandRuntimeLayer } from "../../shared/runtime/command-runtime.layer. 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 { actionability, ErrorActionabilityId } from "../../shared/telemetry/error-actionability.ts"; +import { + actionability, + ErrorActionabilityId, + statusCodeActionability, +} from "../../shared/telemetry/error-actionability.ts"; import type { RemoteValue } from "./remote-value.ts"; import { resolveTarget } from "./resolve-target.ts"; import { renderLocalTarget } from "./target-identity.ts"; @@ -61,6 +66,13 @@ const branchRow = (name: string, projectRef = BRANCH_REF, isDefault = false) => with_data: true, }); +const failingListings = { + unavailable: { status: 500, message: "boom" }, + unauthorized: { status: 401, message: "Unauthorized" }, + forbidden: { status: 403, message: "Forbidden" }, + missing: { status: 404, message: "Not found" }, +} as const; + const root = rootCommandForFeatures(); const projectRefResolver = (options: { readonly linked: boolean }) => { @@ -98,11 +110,9 @@ const run = ( readonly command?: ReadonlyArray<string>; readonly format?: OutputFormat; readonly tokenFailure?: AccessTokenRequiredError | InvalidAccessTokenError; - readonly branches?: - | ReadonlyArray<ReturnType<typeof branchRow>> - | "unavailable" - | "unauthorized" - | "forbidden"; + readonly storedToken?: boolean; + readonly unreachable?: string; + readonly branches?: ReadonlyArray<ReturnType<typeof branchRow>> | keyof typeof failingListings; } = {}, ) => Effect.gen(function* () { @@ -125,16 +135,17 @@ const run = ( const resolver = projectRefResolver({ linked: options.linked ?? true }); const out = mockOutput({ format: options.format ?? "text" }); const api = mockCommandPlatformApi({ - handler: (request) => - Effect.succeed( - options.branches === "unavailable" - ? jsonResponse(request, 500, { message: "boom" }) - : options.branches === "unauthorized" - ? jsonResponse(request, 401, { message: "Unauthorized" }) - : options.branches === "forbidden" - ? jsonResponse(request, 403, { message: "Forbidden" }) - : jsonResponse(request, 200, options.branches ?? []), - ), + handler: (request) => { + if (options.unreachable !== undefined) { + return Effect.fail(transportFailure(request, options.unreachable)); + } + const { branches = [] } = options; + if (typeof branches === "string") { + const { status, message } = failingListings[branches]; + return Effect.succeed(jsonResponse(request, status, { message })); + } + return Effect.succeed(jsonResponse(request, 200, branches)); + }, }); const spans: Array<Tracer.NativeSpan> = []; const tracer = Tracer.make({ @@ -158,6 +169,7 @@ const run = ( mockCommandSettings({ workdir: dir, projectId: options.projectId ?? Option.none(), + ...(options.storedToken === true ? { accessToken: Option.none() } : {}), }), options.args === undefined ? Layer.empty @@ -454,7 +466,7 @@ describe("resolveTarget", () => { const error = failureOf(result.exit); expect(error).toMatchObject({ _tag: "TargetRemoteAmbiguousError", - message: `Can't tell whether the word after --remote is a branch (listing the branches of project ${FILE_REF} failed with HTTP 500). ${placeholderOptions}`, + message: `Can't tell whether the word after --remote is a branch (listing the branches of project ${FILE_REF} failed: HTTP 500). ${placeholderOptions}`, status: 500, cause: { _tag: "LinkBranchListStatusError", status: 500 }, }); @@ -462,23 +474,95 @@ describe("resolveTarget", () => { }), ); + it.effect("names the transport failure when the listing never gets a response", () => + Effect.gen(function* () { + const result = yield* run("selectable", bare, { + args, + command, + savedRef: FILE_REF, + unreachable: "ECONNREFUSED", + }); + const error = failureOf(result.exit); + expect(error).toMatchObject({ + _tag: "TargetRemoteAmbiguousError", + message: `Can't tell whether the word after --remote is a branch (listing the branches of project ${FILE_REF} failed: ECONNREFUSED). ${placeholderOptions}`, + cause: { _tag: "LinkBranchListNetworkError" }, + }); + expect(error).not.toHaveProperty("status"); + expect(error).not.toHaveProperty("suggestion"); + }), + ); + + it.effect("keeps URL credentials out of a transport failure it names", () => + Effect.gen(function* () { + const result = yield* run("selectable", bare, { + args, + command, + savedRef: FILE_REF, + unreachable: + "connect ECONNREFUSED https://proxy-user:hunter2@proxy.test:8080\nsecond line", + }); + const message = String((failureOf(result.exit) as Error).message); + expect(message).toContain( + `failed: connect ECONNREFUSED https://proxy.test:8080). ${placeholderOptions}`, + ); + expect(message).not.toContain("hunter2"); + expect(message).not.toContain("second line"); + }), + ); + it.effect.each([ - { name: "unauthorized", status: 401 }, - { name: "forbidden", status: 403 }, - ] as const)("suggests logging in when the branch listing is $name", ({ name, status }) => + { + name: "unauthorized with a token from SUPABASE_ACCESS_TOKEN", + branches: "unauthorized", + storedToken: false, + status: 401, + suggestion: "Set SUPABASE_ACCESS_TOKEN to a valid access token.", + actionability: actionability.authToken, + }, + { + name: "unauthorized with a stored token", + branches: "unauthorized", + storedToken: true, + status: 401, + suggestion: "Run `supabase login` with an account that can list this project's branches.", + actionability: statusCodeActionability(401), + }, + { + name: "forbidden", + branches: "forbidden", + storedToken: false, + status: 403, + suggestion: "Use an access token that can read this project's branches.", + actionability: statusCodeActionability(403), + }, + { + name: "not found", + branches: "missing", + storedToken: false, + status: 404, + suggestion: `If ${FILE_REF} is itself a preview branch, link its parent project first: supabase link --project-ref <parent-ref>`, + actionability: actionability.provideFlags, + }, + ] as const)("suggests the fix when the branch listing is $name", (testCase) => Effect.gen(function* () { const result = yield* run("selectable", bare, { args, command, savedRef: FILE_REF, - branches: name, + branches: testCase.branches, + storedToken: testCase.storedToken, }); - expect(failureOf(result.exit)).toMatchObject({ + const error = failureOf(result.exit); + expect(error).toMatchObject({ _tag: "TargetRemoteAmbiguousError", - message: `Can't tell whether the word after --remote is a branch (listing the branches of project ${FILE_REF} failed with HTTP ${status}). ${placeholderOptions}`, - suggestion: "Run `supabase login` with an account that can list this project's branches.", - status, + message: `Can't tell whether the word after --remote is a branch (listing the branches of project ${FILE_REF} failed: HTTP ${testCase.status}). ${placeholderOptions}`, + suggestion: testCase.suggestion, + status: testCase.status, }); + expect((error as { [ErrorActionabilityId]: unknown })[ErrorActionabilityId]).toEqual( + testCase.actionability, + ); }), ); @@ -557,7 +641,7 @@ describe("resolveTarget", () => { const error = failureOf(result.exit); expect(error).toMatchObject({ _tag: "TargetRemoteAmbiguousError", - message: `Can't tell whether the word after --remote is a branch (listing the branches of project ${FILE_REF} failed with HTTP 403). For the branch: supabase secrets unset --remote=<branch>. For the linked project, put --remote last: supabase secrets unset <operands> --remote`, + message: `Can't tell whether the word after --remote is a branch (listing the branches of project ${FILE_REF} failed: HTTP 403). For the branch: supabase secrets unset --remote=<branch>. For the linked project, put --remote last: supabase secrets unset <operands> --remote`, }); expect(JSON.stringify(error)).not.toContain("sk_live_secret"); }), diff --git a/apps/cli/src/command-internal/target/target.errors.ts b/apps/cli/src/command-internal/target/target.errors.ts index 567b867d18..73b2902da0 100644 --- a/apps/cli/src/command-internal/target/target.errors.ts +++ b/apps/cli/src/command-internal/target/target.errors.ts @@ -76,9 +76,9 @@ export class TargetRemoteAmbiguousError extends Data.TaggedError("TargetRemoteAm readonly cause?: unknown; }> { get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + if (this.authFailure === "token") return actionability.authToken; if (this.status === 401 || this.status === 403) return statusCodeActionability(this.status); if (this.authFailure === "login") return actionability.authLogin; - if (this.authFailure === "token") return actionability.authToken; return actionability.provideFlags; } } From eaa61ff4df710bd9ada67abcafac6f2a12f1f37c Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Tue, 20 Oct 2026 11:46:40 +0100 Subject: [PATCH 180/291] fix(cli): stop a bare --remote from retargeting on a project-ref-shaped operand A 20-letter lowercase word after a bare --remote joins into --remote=<ref> only when it cannot be an operand. With operand capacity left, the run fails before any lookup and names both readings. --- apps/cli/src/cli/complete.remote.unit.test.ts | 20 ++++++-- .../target/remote-resolution.ts | 24 ++++++++-- .../target/resolve-target.integration.test.ts | 22 +++++++++ apps/cli/src/shared/cli/remote-argv.ts | 6 +-- .../src/shared/cli/remote-argv.unit.test.ts | 46 +++++++++++++++++-- 5 files changed, 101 insertions(+), 17 deletions(-) diff --git a/apps/cli/src/cli/complete.remote.unit.test.ts b/apps/cli/src/cli/complete.remote.unit.test.ts index 0cf37f7789..d24c287eab 100644 --- a/apps/cli/src/cli/complete.remote.unit.test.ts +++ b/apps/cli/src/cli/complete.remote.unit.test.ts @@ -79,13 +79,19 @@ describe("the token after a bare --remote", () => { expect(complete("db", "query", "--remote", "sel")).toEqual(positionalCandidates); }); - it("is the value when it is a project ref or a database URL, even with a free positional slot", () => { - expect(complete("db", "pull", "--remote", PROJECT_REF)).toEqual(noValueCandidates); + it("is the value when it is a database URL, even with a free positional slot", () => { + expect(complete("db", "pull", "--remote", "postgresql://u:p@host:5432/db")).toEqual( + noValueCandidates, + ); expect(complete("db", "query", "--remote", "postgresql://u:p@host:5432/db")).toEqual( noValueCandidates, ); }); + it("is the positional when it is a project ref and the command has a free positional slot", () => { + expect(complete("db", "pull", "--remote", PROJECT_REF)).toEqual(positionalCandidates); + }); + it("is the value once the positionals are filled", () => { expect(complete("db", "pull", "add_profiles", "--remote", "")).toEqual(noValueCandidates); expect(complete("db", "query", "select 1", "--remote", "")).toEqual(noValueCandidates); @@ -96,8 +102,14 @@ describe("the token after a bare --remote", () => { expect(complete("db", "cp", "a", "--remote", "feat")).toEqual(positionalCandidates); }); - it("is the value on a variadic command once it is a project ref", () => { - expect(complete("db", "cp", "a", "--remote", PROJECT_REF)).toEqual(noValueCandidates); + it("is the value on a variadic command once it is a database URL", () => { + expect(complete("db", "cp", "a", "--remote", "postgresql://u:p@host:5432/db")).toEqual( + noValueCandidates, + ); + }); + + it("is a positional on a variadic command when it is a project ref", () => { + expect(complete("db", "cp", "a", "--remote", PROJECT_REF)).toEqual(positionalCandidates); }); it("does not stop flag completion from running after the bare switch", () => { diff --git a/apps/cli/src/command-internal/target/remote-resolution.ts b/apps/cli/src/command-internal/target/remote-resolution.ts index 6680cca48e..5825a94789 100644 --- a/apps/cli/src/command-internal/target/remote-resolution.ts +++ b/apps/cli/src/command-internal/target/remote-resolution.ts @@ -14,6 +14,7 @@ import { import { yellow } from "../colors.ts"; import { formatNamedRef, sanitizeInlineName } from "../http-errors.ts"; import { readCachedLinkedProject, resolveLinkedParentRef } from "../parent-project-ref.ts"; +import { BRANCH_PROJECT_REF_PATTERN } from "../ref-patterns.ts"; import { findBranch, listBranchesFailureCause, @@ -154,13 +155,17 @@ const listingFailure = ( return { reason: "" }; }; +const commandUsage = Effect.fnUntraced(function* () { + const runtime = yield* Effect.serviceOption(CommandRuntime); + return `supabase ${Option.isSome(runtime) ? getCommandRuntimeCommand(runtime.value) : "<command>"}`; +}); + const checkRemoteOperand = Effect.fn("Target.checkRemoteOperand")(function* (rawOperand: string) { const parent = yield* resolveLinkedParentRef(); if (parent.kind !== "resolved") return; const operand = stripUserinfo(rawOperand); - const runtime = yield* Effect.serviceOption(CommandRuntime); - const usage = `supabase ${Option.isSome(runtime) ? getCommandRuntimeCommand(runtime.value) : "<command>"}`; + const usage = yield* commandUsage(); const options = `For the branch: ${usage} ${remoteFlagText({ _tag: "Branch", name: operand })}. For the linked project: ${usage} ${shellQuote(operand)} --remote`; const listed = yield* listParentBranches(parent.ref, "Checking branches...").pipe(Effect.result); @@ -196,14 +201,23 @@ const checkRemoteOperand = Effect.fn("Target.checkRemoteOperand")(function* (raw } }); +const rejectRefShapedOperand = Effect.fnUntraced(function* (operand: string) { + const usage = yield* commandUsage(); + return yield* new TargetRemoteAmbiguousError({ + message: `"${operand}" after --remote looks like a project ref. For that project: ${usage} --remote=${operand}. To keep it as an operand, put --remote last: ${usage} <operands> --remote`, + }); +}); + /** - * Fails a bare `--remote` whose next word is, or may be, a branch of the linked project, before - * the command prompts, connects or writes. + * Fails a bare `--remote` whose next word is a project ref, or is, or may be, a branch of the + * linked project, before the command prompts, connects or writes. */ const rejectAmbiguousRemote = Effect.fnUntraced(function* () { const cliArgs = yield* Effect.serviceOption(CliArgs); const operand = Option.getOrUndefined(cliArgs)?.bareRemoteOperand; - if (operand !== undefined) yield* checkRemoteOperand(operand); + if (operand === undefined) return; + if (BRANCH_PROJECT_REF_PATTERN.test(operand)) return yield* rejectRefShapedOperand(operand); + yield* checkRemoteOperand(operand); }); /** The linked target for a ref the ref chain has already resolved and announced. */ 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 4b2e3dd266..5f36cc8fc5 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 @@ -440,6 +440,28 @@ describe("resolveTarget", () => { }), ); + it.effect.each([ + { name: "a project is linked", options: { savedRef: FILE_REF } }, + { name: "no project is linked", options: { linked: false } }, + ])( + "fails before any lookup when the word is shaped like a project ref and $name", + ({ options: linkState }) => + Effect.gen(function* () { + const result = yield* run("selectable", bare, { + ...linkState, + args: ["db", "pull", "--remote", FLAG_REF], + command, + branches: [branchRow("feature-x")], + }); + expect(failureOf(result.exit)).toMatchObject({ + _tag: "TargetRemoteAmbiguousError", + message: `"${FLAG_REF}" after --remote looks like a project ref. For that project: supabase db pull --remote=${FLAG_REF}. To keep it as an operand, put --remote last: supabase db pull <operands> --remote`, + }); + expect(result.apiRequests).toEqual([]); + expect(result.refFlags).toEqual([]); + }), + ); + it.effect("names the branch of a linked, named project", () => Effect.gen(function* () { const result = yield* run("selectable", bare, { diff --git a/apps/cli/src/shared/cli/remote-argv.ts b/apps/cli/src/shared/cli/remote-argv.ts index ec9d5f07a9..44dd4974fe 100644 --- a/apps/cli/src/shared/cli/remote-argv.ts +++ b/apps/cli/src/shared/cli/remote-argv.ts @@ -1,7 +1,6 @@ import type { Command } from "effect/cli"; import { unwrapToSingleParam } from "../../command-internal/param-introspection.ts"; -import { BRANCH_PROJECT_REF_PATTERN } from "../../command-internal/ref-patterns.ts"; import { couldNameBranch, DB_URL_PATTERN, @@ -59,8 +58,8 @@ const scanRemoteArgv = (args: ReadonlyArray<string>, leaf: RemoteArgvLeaf): Remo /** * Joins a bare `--remote <word>` into `--remote=<word>` when the word cannot be one of the - * command's operands: the command takes none, the word is a project ref, a database URL or a - * boolean literal, or the command already has more operands than it takes. + * command's operands: the command takes none, the word is a database URL or a boolean literal, or + * the command already has more operands than it takes. */ export function joinRemoteOperands( args: ReadonlyArray<string>, @@ -79,7 +78,6 @@ export function joinRemoteOperands( if ( LINKED_LITERAL.test(word) || leaf.operandCapacity === 0 || - BRANCH_PROJECT_REF_PATTERN.test(word) || DB_URL_PATTERN.test(word) || (couldNameBranch(word) && operandCount > leaf.operandCapacity) ) { diff --git a/apps/cli/src/shared/cli/remote-argv.unit.test.ts b/apps/cli/src/shared/cli/remote-argv.unit.test.ts index 63fcc9c410..53c721c904 100644 --- a/apps/cli/src/shared/cli/remote-argv.unit.test.ts +++ b/apps/cli/src/shared/cli/remote-argv.unit.test.ts @@ -29,10 +29,28 @@ describe("joinRemoteOperands", () => { expected: ["db", "push", `--remote=feature-x`], }, { - name: "joins a project ref", + name: "joins a project ref for a command with no operands", + leaf: noOperands, + args: ["db", "push", "--remote", REF], + expected: ["db", "push", `--remote=${REF}`], + }, + { + name: "keeps a project ref as the operand when the command takes one", leaf: oneOperand, args: ["storage", "ls", "--remote", REF], - expected: ["storage", "ls", `--remote=${REF}`], + expected: ["storage", "ls", "--remote", REF], + }, + { + name: "joins a project ref once the command has more operands than it takes", + leaf: oneOperand, + args: ["storage", "ls", "ss:///bucket", "--remote", REF], + expected: ["storage", "ls", "ss:///bucket", `--remote=${REF}`], + }, + { + name: "never joins a project ref for a command with a variable number of operands", + leaf: variadic, + args: ["functions", "deploy", "--remote", REF, "hello"], + expected: ["functions", "deploy", "--remote", REF, "hello"], }, { name: "joins a database URL", @@ -144,6 +162,12 @@ describe("bareRemoteOperand", () => { args: ["db", "pull", "--remote", "feature-x"], expected: "feature-x", }, + { + name: "returns a project-ref-shaped word the command took as its operand", + leaf: oneOperand, + args: ["db", "pull", "--remote", REF], + expected: REF, + }, { name: "skips a word that is a command-path segment", leaf: noOperands, @@ -227,9 +251,19 @@ describe("prepareCliArgs argv", () => { expected: ["storage", "ls", "--remote", "ss:///bucket"], }, { - name: "joins a branch for functions deploy only when it is a ref or URL", + name: "joins a database URL for functions deploy, whose operands are variable", + args: ["functions", "deploy", "--remote", URL, "hello"], + expected: ["functions", "deploy", `--remote=${URL}`, "hello"], + }, + { + name: "leaves a project-ref-shaped word for functions deploy, which may be a function name", args: ["functions", "deploy", "--remote", REF, "hello"], - expected: ["functions", "deploy", `--remote=${REF}`, "hello"], + expected: ["functions", "deploy", "--remote", REF, "hello"], + }, + { + name: "leaves a project-ref-shaped word for db pull, which may be a migration name", + args: ["db", "pull", "--remote", REF], + expected: ["db", "pull", "--remote", REF], }, { name: "joins for seed's persistent --remote after the subcommand", @@ -298,6 +332,10 @@ describe("prepareCliArgs bareRemoteOperand on the real command tree", () => { expect(operandOf(["db", "pull", "--remote", "feature-x"])).toBe("feature-x"); }); + it("reads a project-ref-shaped word as db pull's operand rather than joining it", () => { + expect(operandOf(["db", "pull", "--remote", REF])).toBe(REF); + }); + it("reads no operand from gen types, whose legacy language operand is hidden", () => { expect(operandOf(["gen", "types", "--remote", "feature-x"])).toBeUndefined(); }); From 6ea92d7f91409d10ff0e956d90199470c277abca Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Tue, 20 Oct 2026 11:49:09 +0100 Subject: [PATCH 181/291] fix(cli): keep an ordinary word after a bare --remote from reading as a mistyped branch The edit distance counts an adjacent swap as one edit. After a bare --remote, a word is taken for a branch only when it equals a branch name ignoring case or sits one edit from a non-default branch whose name has five or more characters. --- .../target/branch-resolution.ts | 6 +- .../command-internal/target/closest-branch.ts | 40 +++++++++++- .../target/closest-branch.unit.test.ts | 39 +++++++++++- .../target/remote-resolution.ts | 7 ++- .../target/resolve-target.integration.test.ts | 63 +++++++++++++++++++ 5 files changed, 148 insertions(+), 7 deletions(-) diff --git a/apps/cli/src/command-internal/target/branch-resolution.ts b/apps/cli/src/command-internal/target/branch-resolution.ts index fe657ed972..ff75d25f3a 100644 --- a/apps/cli/src/command-internal/target/branch-resolution.ts +++ b/apps/cli/src/command-internal/target/branch-resolution.ts @@ -95,6 +95,10 @@ function branchNotFoundMessage(value: string, parentRef: string, branches: Branc ); } +/** The production branch is the linked project itself. */ +export const isDefaultBranch = (branch: ParentBranch, parentRef: string): boolean => + branch.is_default || branch.project_ref === parentRef; + /** UUID matching is case-insensitive; name matching is exact, with the not-found hint covering near misses. */ export const findBranch = (branches: Branches, value: string): ParentBranch | undefined => branches.find( @@ -198,7 +202,7 @@ const resolveInParent = Effect.fnUntraced(function* (value: string, parentRef: s projectRef: found.project_ref, branchName: found.name, parentRef, - isDefault: found.is_default || found.project_ref === parentRef, + isDefault: isDefaultBranch(found, parentRef), } satisfies BranchResolution; }); diff --git a/apps/cli/src/command-internal/target/closest-branch.ts b/apps/cli/src/command-internal/target/closest-branch.ts index 4489d5771c..c6d31ad784 100644 --- a/apps/cli/src/command-internal/target/closest-branch.ts +++ b/apps/cli/src/command-internal/target/closest-branch.ts @@ -1,15 +1,21 @@ -/** The Levenshtein distance between two strings. */ +/** The optimal string alignment distance: an insertion, deletion, substitution or adjacent swap is one edit. */ const editDistance = (a: string, b: string): number => { + let beforePrevious: ReadonlyArray<number> = []; let previous = Array.from({ length: b.length + 1 }, (_, index) => index); for (let row = 1; row <= a.length; row += 1) { const current = [row]; for (let column = 1; column <= b.length; column += 1) { - current[column] = Math.min( + let distance = Math.min( (previous[column] ?? 0) + 1, (current[column - 1] ?? 0) + 1, (previous[column - 1] ?? 0) + (a[row - 1] === b[column - 1] ? 0 : 1), ); + if (row > 1 && column > 1 && a[row - 1] === b[column - 2] && a[row - 2] === b[column - 1]) { + distance = Math.min(distance, (beforePrevious[column - 2] ?? 0) + 1); + } + current[column] = distance; } + beforePrevious = previous; previous = current; } return previous[b.length] ?? 0; @@ -19,6 +25,9 @@ const editDistance = (a: string, b: string): number => { const allowedEdits = (a: string, b: string): number => Math.min(2, Math.floor(Math.max(a.length, b.length) / 3)); +const byName = (a: { readonly name: string }, b: { readonly name: string }): number => + a.name < b.name ? -1 : a.name > b.name ? 1 : 0; + /** The item whose name matches `word` ignoring case or within two edits of it; the nearest wins and a tie goes to the first name alphabetically. */ export const closestByName = <T extends { readonly name: string }>( items: ReadonlyArray<T>, @@ -26,7 +35,7 @@ export const closestByName = <T extends { readonly name: string }>( ): T | undefined => { const target = word.trim().toLowerCase(); let best: { readonly item: T; readonly distance: number } | undefined; - for (const item of items.toSorted((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0))) { + for (const item of items.toSorted(byName)) { const name = item.name.toLowerCase(); const distance = editDistance(target, name); if ( @@ -38,3 +47,28 @@ export const closestByName = <T extends { readonly name: string }>( } return best?.item; }; + +const MIN_NEAR_MISS_NAME_LENGTH = 5; + +/** + * The item a word most likely mistypes: one whose name matches ignoring case, else a non-default + * one of at least five characters a single edit away. A short or default name never claims a word + * that reads as an ordinary operand. + */ +export const nearMissByName = <T extends { readonly name: string }>( + items: ReadonlyArray<T>, + word: string, + isDefault: (item: T) => boolean, +): T | undefined => { + const target = word.toLowerCase(); + const sorted = items.toSorted(byName); + return ( + sorted.find((item) => item.name.toLowerCase() === target) ?? + sorted.find( + (item) => + !isDefault(item) && + item.name.length >= MIN_NEAR_MISS_NAME_LENGTH && + editDistance(target, item.name.toLowerCase()) <= 1, + ) + ); +}; diff --git a/apps/cli/src/command-internal/target/closest-branch.unit.test.ts b/apps/cli/src/command-internal/target/closest-branch.unit.test.ts index bd75faf985..aa7cb6cbfd 100644 --- a/apps/cli/src/command-internal/target/closest-branch.unit.test.ts +++ b/apps/cli/src/command-internal/target/closest-branch.unit.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { closestByName } from "./closest-branch.ts"; +import { closestByName, nearMissByName } from "./closest-branch.ts"; const branches = ["feature-x", "staging", "Main", "add_profiles", "db"].map((name) => ({ name })); @@ -9,6 +9,8 @@ describe("closestByName", () => { { word: "feature-y", expected: "feature-x" }, { word: "Feature-X", expected: "feature-x" }, { word: "stagin", expected: "staging" }, + { word: "stagign", expected: "staging" }, + { word: "mian", expected: "Main" }, { word: "staging2", expected: "staging" }, { word: "main", expected: "Main" }, { word: "add_profile", expected: "add_profiles" }, @@ -39,3 +41,38 @@ describe("closestByName", () => { expect(closestByName([], "staging")).toBeUndefined(); }); }); + +describe("nearMissByName", () => { + const projectBranches = [ + { name: "main", isDefault: true }, + ...["develop", "staging", "app", "dev", "prod"].map((name) => ({ name, isDefault: false })), + ]; + const nearMiss = (word: string) => + nearMissByName(projectBranches, word, (branch) => branch.isDefault)?.name; + + it.each(["mail", "man", "rain", "api", "pro", "devel"])( + "leaves %s an ordinary word, being close only to short names, the default branch or neither", + (word) => { + expect(nearMiss(word)).toBeUndefined(); + }, + ); + + it.each([ + { word: "stagin", expected: "staging" }, + { word: "stagign", expected: "staging" }, + { word: "Dev", expected: "dev" }, + { word: "dev", expected: "dev" }, + { word: "MAIN", expected: "main" }, + ])("takes $word for $expected", ({ word, expected }) => { + expect(nearMiss(word)).toBe(expected); + }); + + it("takes the first name alphabetically when several match ignoring case", () => { + const names = [{ name: "release-b" }, { name: "Release-A" }, { name: "release-a" }]; + expect(nearMissByName(names, "RELEASE-A", () => false)?.name).toBe("Release-A"); + }); + + it("finds nothing among no branches", () => { + expect(nearMissByName([], "staging", () => false)).toBeUndefined(); + }); +}); diff --git a/apps/cli/src/command-internal/target/remote-resolution.ts b/apps/cli/src/command-internal/target/remote-resolution.ts index 5825a94789..b2e7de77ca 100644 --- a/apps/cli/src/command-internal/target/remote-resolution.ts +++ b/apps/cli/src/command-internal/target/remote-resolution.ts @@ -17,6 +17,7 @@ import { readCachedLinkedProject, resolveLinkedParentRef } from "../parent-proje import { BRANCH_PROJECT_REF_PATTERN } from "../ref-patterns.ts"; import { findBranch, + isDefaultBranch, listBranchesFailureCause, listParentBranches, previewBranchHint, @@ -26,7 +27,7 @@ import { LinkBranchListNetworkError, LinkBranchListStatusError, } from "./branch-resolution.errors.ts"; -import { closestByName } from "./closest-branch.ts"; +import { nearMissByName } from "./closest-branch.ts"; import type { RemoteValue } from "./remote-value.ts"; import { stripUserinfo } from "./remote-vocabulary.ts"; import type { LinkedTarget } from "./resolve-target.ts"; @@ -192,7 +193,9 @@ const checkRemoteOperand = Effect.fn("Target.checkRemoteOperand")(function* (raw message: `"${operand}" is a branch of ${project}. ${options}`, }); } - const closest = closestByName(branches, operand); + const closest = nearMissByName(branches, operand, (branch) => + isDefaultBranch(branch, parent.ref), + ); if (closest !== undefined) { const closestName = sanitizeInlineName(closest.name); return yield* new TargetRemoteAmbiguousError({ 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 5f36cc8fc5..7a18793c27 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 @@ -368,6 +368,20 @@ describe("resolveTarget", () => { }), ); + it.effect("suggests the branch when two letters of the name are swapped", () => + Effect.gen(function* () { + const result = yield* run( + "selectable", + { ...none, ...remote({ _tag: "Branch", name: "mian" }) }, + { savedRef: FILE_REF, branches: [branchRow("main", FILE_REF, true)] }, + ); + expect(failureOf(result.exit)).toMatchObject({ _tag: "LinkBranchNotFoundError" }); + expect(String((failureOf(result.exit) as Error).message)).toContain( + 'Available branches: main. Did you mean "main"?', + ); + }), + ); + it.effect("tells an expired token to log in instead of showing the response body", () => Effect.gen(function* () { const result = yield* run( @@ -685,6 +699,55 @@ describe("resolveTarget", () => { }), ); + it.effect.each([ + { name: "two edits from a branch", word: "feature-yz", branches: [branchRow("feature-x")] }, + { name: "one edit from a short branch", word: "den", branches: [branchRow("dev")] }, + { + name: "one edit from the default branch", + word: "mail", + branches: [branchRow("main", FILE_REF, true)], + }, + ])("keeps a word that is $name as an operand", ({ word, branches }) => + Effect.gen(function* () { + const result = yield* run("selectable", bare, { + args: ["db", "pull", "--remote", word], + command, + savedRef: FILE_REF, + branches, + }); + expect(succeeded(result.exit)).toMatchObject({ kind: "linked", ref: FILE_REF }); + }), + ); + + it.effect("fails for a word with two letters swapped from a branch", () => + Effect.gen(function* () { + const result = yield* run("selectable", bare, { + args: ["db", "pull", "--remote", "stagign"], + command, + savedRef: FILE_REF, + branches: [branchRow("staging")], + }); + expect(failureOf(result.exit)).toMatchObject({ + _tag: "TargetRemoteAmbiguousError", + message: `"stagign" isn't a branch, but "staging" is. For the branch: supabase db pull --remote=staging. For the linked project: supabase db pull stagign --remote`, + }); + }), + ); + + it.effect("fails for a word that differs from the default branch only by case", () => + Effect.gen(function* () { + const result = yield* run("selectable", bare, { + args: ["db", "pull", "--remote", "Main"], + command, + savedRef: FILE_REF, + branches: [branchRow("main", FILE_REF, true)], + }); + expect(failureOf(result.exit)).toMatchObject({ + message: `"Main" isn't a branch, but "main" is. For the branch: supabase db pull --remote=main. For the linked project: supabase db pull Main --remote`, + }); + }), + ); + it.effect("fails for a word that differs from a branch only by case", () => Effect.gen(function* () { const result = yield* run("selectable", bare, { From 831dc9480d742cff791625a2dff8bc55f99e4685 Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Tue, 20 Oct 2026 11:57:41 +0100 Subject: [PATCH 182/291] fix(cli): announce every database endpoint in explicit db diff Explicit --from/--to mode announced only hosted endpoints, so a --from <db-url> --to remote run printed just the project line and reported the project as the JSON target. Each database endpoint now announces its own target in resolution order: a URL as `custom Postgres (<host>:<port>)` and both local branches as `local stack (<host>:<port>)`. `migrations` announces nothing. The cell prints a repeated target once. A URL endpoint is parsed in the handler with the project env and carries the parsed connection, so a malformed URL fails before a later hosted endpoint mints its login role. --- apps/cli/src/commands/db/diff/diff.handler.ts | 23 +- .../commands/db/diff/diff.integration.test.ts | 252 ++++++++++++++++-- 2 files changed, 254 insertions(+), 21 deletions(-) diff --git a/apps/cli/src/commands/db/diff/diff.handler.ts b/apps/cli/src/commands/db/diff/diff.handler.ts index 15d0a5e04a..092579c406 100644 --- a/apps/cli/src/commands/db/diff/diff.handler.ts +++ b/apps/cli/src/commands/db/diff/diff.handler.ts @@ -8,6 +8,7 @@ import { } from "../../../command-internal/global-flags.ts"; import { removedFlag } from "../../../command-internal/removed-command.ts"; import { branchClause, detectGitBranch } from "../../../shared/git/git-branch.ts"; +import { announceTarget } 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 { CommandSettings } from "../../../config/command-settings.service.ts"; @@ -30,6 +31,10 @@ import { resolveRemoteTarget, warnTarget, } from "../../../command-internal/target/remote-resolution.ts"; +import { + renderDbUrlTarget, + renderLocalTarget, +} from "../../../command-internal/target/target-identity.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"; @@ -77,6 +82,7 @@ import { type PgDeltaEndpoint, type PgDeltaRenderedFile, } from "../shared/pgdelta-engine.service.ts"; +import { parsePgDeltaNextEndpoint } from "../shared/pgdelta-engine.next.layer.ts"; import { LoadPgDeltaSqlFiles } from "../shared/pgdelta-files.ts"; import { writePgDeltaMigrations } from "../shared/pgdelta-migrations.write.ts"; import { type PgDeltaContext, isPgDeltaDebugEnabled } from "../../../command-internal/pgdelta.ts"; @@ -285,6 +291,7 @@ export const dbDiff = Effect.fn("db.diff")(function* (flags: DbDiffFlags) { password: cfg.password, database: "postgres", }; + yield* announceTarget(renderLocalTarget(connection.host, connection.port)); return { kind: "database", ref: toPostgresURL(connection), @@ -310,6 +317,7 @@ export const dbDiff = Effect.fn("db.diff")(function* (flags: DbDiffFlags) { }), ), ); + yield* announceTarget(renderLocalTarget(connection.host, connection.port)); return { kind: "database", ref: toPostgresURL(connection), @@ -347,14 +355,21 @@ export const dbDiff = Effect.fn("db.diff")(function* (flags: DbDiffFlags) { kind: "migrations", ...(mergedLinkedRef !== undefined ? { projectRef: mergedLinkedRef } : {}), } satisfies PgDeltaEndpoint; - case "DbUrl": - return { + case "DbUrl": { + const { projectEnvValues } = yield* configValues.load({ + workdir: cliSettings.workdir, + projectRef: Option.fromNullishOr(mergedLinkedRef), + }); + const endpoint = { kind: "database", ref: explicit.url, - // The next engine parses arbitrary explicit URLs itself; they connect as remote - // by default, so TLS is used. + // Explicit URLs connect as remote, so TLS is used. connectOptions: { isLocal: false, dnsResolver }, } satisfies PgDeltaDatabaseEndpoint; + const connection = yield* parsePgDeltaNextEndpoint(endpoint, projectEnvValues); + yield* announceTarget(renderDbUrlTarget(connection.host, connection.port)); + return { ...endpoint, connection } satisfies PgDeltaDatabaseEndpoint; + } } }); const source = yield* resolveRef(from, "--from"); 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 8d12d74641..0cb4386c00 100644 --- a/apps/cli/src/commands/db/diff/diff.integration.test.ts +++ b/apps/cli/src/commands/db/diff/diff.integration.test.ts @@ -1,4 +1,4 @@ -import { unusedStackServices } from "../../../../tests/helpers/unused-stack.ts"; +import { unusedGateway, unusedStackServices } from "../../../../tests/helpers/unused-stack.ts"; import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; import { @@ -13,11 +13,14 @@ import { Layer, Option, Path, + Redacted, Schema, + Stream, } from "effect"; import * as HttpClient from "effect/http/HttpClient"; import * as HttpClientResponse from "effect/http/HttpClientResponse"; import { ChildProcess, ChildProcessSpawner } from "effect/process"; +import type { DatabaseInstance, Stack } from "@supabase/stack/effect"; import { vi } from "vitest"; vi.mock("node:net", (importOriginal) => @@ -73,6 +76,8 @@ import { NetworkIdFlag, YesFlag, } from "../../../command-internal/global-flags.ts"; +import { commandTargetLayer } from "../../../shared/output/command-target.layer.ts"; +import { CommandTarget } from "../../../shared/output/command-target.service.ts"; import type { OutputFormat } from "../../../shared/output/types.ts"; import { ProjectRefNotLinkedError } from "../../../config/project-ref.errors.ts"; import { @@ -85,6 +90,7 @@ import type { DbConnectionInput, ResolvedDbConnection, } from "../../../command-internal/db-config.types.ts"; +import { describeLinkedTarget } from "../../../command-internal/target/target-identity.ts"; import { TargetPolicy } from "../../../command-internal/target/target-policy.ts"; import { TargetProjectRefConflictError, @@ -114,6 +120,7 @@ import { RemovedSurfaceError } from "../../../command-internal/removed-command.t import type { DbDiffFlags } from "./diff.command.ts"; import { dbDiff } from "./diff.handler.ts"; import { stackBackendLayer } from "../../../command-internal/stack-backend.ts"; +import { StackApi } from "../../../command-internal/stack-api.ts"; import { StackNativeEngineError } from "../../../command-internal/stack-local-database.ts"; import { PGADMIN_DESKTOP_NOTE_PREFIX, PGADMIN_DIFF_HEADER } from "./pgadmin-diff.ts"; @@ -231,6 +238,91 @@ function fakeShadowDbConnection(opts: { readonly neverConnectableShadow?: boolea return { layer, connectedDatabases, execCalls, shadowConnectRefused }; } +const STACK_SQL_PORT = 54329; + +/** A registered project stack whose healthy database serves SQL on `STACK_SQL_PORT`. */ +const runningStackApi = (projectRoot: string) => { + const unused = Effect.die("unused by db diff"); + const database: DatabaseInstance = { + id: "database-primary", + service: "database", + start: unused, + ready: unused, + stop: unused, + restart: () => unused, + destroy: unused, + prepare: unused, + status: Effect.succeed({ + id: "database-primary", + endpoints: [{ name: "sql", protocol: "tcp", host: "127.0.0.1", port: STACK_SQL_PORT }], + config: { + service: "database", + config: { + version: "17.6.1.173", + databasePassword: Redacted.make("stack-password"), + jwtSecret: Redacted.make("secret"), + jwtExpiry: 3600, + }, + endpoints: {}, + }, + lifecycle: "running", + health: "healthy", + error: undefined, + exit: undefined, + currentOperation: undefined, + wakeEnabled: true, + }), + followStatus: Stream.empty, + readLogs: () => Stream.empty, + credentials: () => unused, + saveSnapshot: () => unused, + restoreSnapshot: () => unused, + resetData: unused, + }; + const stack: Stack = { + id: "b".repeat(64), + services: { create: () => unused, get: () => Effect.succeed(database), list: unused }, + credentials: { get: unused }, + composition: { + plan: () => unused, + describe: Effect.succeed({ + members: [{ id: database.id, activation: "eager" }], + dependencies: [], + }), + supabase: () => unused, + configure: () => unused, + start: unused, + stop: unused, + restart: unused, + }, + startupEndpointChanges: unused, + stop: unused, + destroy: unused, + gateway: unusedGateway, + commands: { run: () => unused }, + }; + return Layer.succeed(StackApi, { + create: () => unused, + open: () => Effect.succeed(stack), + discover: () => unused, + find: () => + Effect.succeed( + Option.some({ + definition: { + id: stack.id, + identity: { projectRoot, branchContext: "main", stackName: "default" }, + runtime: "native" as const, + instances: [], + lifetime: "detached" as const, + composition: { members: [], dependencies: [] }, + }, + host: undefined, + }), + ), + findDeleted: () => unused, + }); +}; + function setup(workdir: string, opts: SetupOpts = {}) { const out = mockOutput({ format: opts.format ?? "text" }); const telemetry = mockTelemetryStateTracked(); @@ -432,22 +524,36 @@ function setup(workdir: string, opts: SetupOpts = {}) { resolvePoolerFallback: () => Effect.succeed(Option.none()), }); - const projectRefResolver = Layer.succeed(ProjectRefResolver, { - resolve: () => Effect.die("resolve is not used by this test"), - resolveForLink: () => Effect.succeed(opts.linkedRef ?? VALID_REF), - resolveRef: (flagValue: Option.Option<string>) => - Option.isSome(flagValue) && flagValue.value.length > 0 - ? Effect.succeed({ ref: flagValue.value, source: "flag", matchesLinkedRef: false }) - : opts.linkedFails === true - ? Effect.fail(new ProjectRefNotLinkedError({ message: PROJECT_NOT_LINKED_MESSAGE })) - : Effect.succeed({ - ref: opts.linkedRef ?? VALID_REF, - source: "file", - matchesLinkedRef: true, - }), - resolveOptional: () => Effect.succeed(Option.some(opts.linkedRef ?? VALID_REF)), - promptProjectRef: () => Effect.succeed(opts.linkedRef ?? VALID_REF), - }); + const commandTarget = commandTargetLayer.pipe(Layer.provide(out.layer)); + const projectRefResolver = Layer.effect( + ProjectRefResolver, + Effect.gen(function* () { + const target = yield* CommandTarget; + return ProjectRefResolver.of({ + resolve: () => Effect.die("resolve is not used by this test"), + resolveForLink: () => Effect.succeed(opts.linkedRef ?? VALID_REF), + resolveRef: (flagValue: Option.Option<string>) => + Effect.gen(function* () { + const resolved = + Option.isSome(flagValue) && flagValue.value.length > 0 + ? { ref: flagValue.value, source: "flag" as const, matchesLinkedRef: false } + : opts.linkedFails === true + ? yield* new ProjectRefNotLinkedError({ message: PROJECT_NOT_LINKED_MESSAGE }) + : { + ref: opts.linkedRef ?? VALID_REF, + source: "file" as const, + matchesLinkedRef: true, + }; + yield* target.announce( + describeLinkedTarget({ ref: resolved.ref, cache: Option.none() }), + ); + return resolved; + }), + resolveOptional: () => Effect.succeed(Option.some(opts.linkedRef ?? VALID_REF)), + promptProjectRef: () => Effect.succeed(opts.linkedRef ?? VALID_REF), + }); + }), + ).pipe(Layer.provide(commandTarget)); const workdirFiles = Layer.effectDiscard( Effect.gen(function* () { @@ -500,6 +606,7 @@ function setup(workdir: string, opts: SetupOpts = {}) { }) : resolver, projectRefResolver, + commandTarget, mockCommandSettings({ workdir, projectId: opts.projectId ?? Option.some("test"), @@ -592,6 +699,11 @@ const stderr = (out: ReturnType<typeof mockOutput>) => .join(""), ); +const targetLines = (out: ReturnType<typeof mockOutput>) => + stderr(out) + .split("\n") + .filter((line) => line.startsWith("Target: ")); + const readFileText = (file: string) => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; @@ -1385,6 +1497,10 @@ describe("db diff", () => { describe("explicit --from and --to values", () => { const FLAG_PROJECT_REF = "flagflagflagflagflag"; const DB_URL = "postgresql://postgres:pw@db.example.com/db"; + const CREDENTIALED_URL = "postgresql://u:secret@db.example.com:6543/postgres"; + const LOCAL_LINE = "Target: local stack (127.0.0.1:54322)"; + const PROJECT_LINE = `Target: project ${VALID_REF}`; + const DB_URL_LINE = "Target: custom Postgres (db.example.com:6543)"; const sides = (["from", "to"] as const).flatMap((side) => [ @@ -1453,6 +1569,12 @@ describe("db diff", () => { expect(endpoint).toEqual({ kind: "database", ref: DB_URL, + connection: expect.objectContaining({ + host: "db.example.com", + user: "postgres", + password: "pw", + database: "db", + }), connectOptions: { isLocal: false, dnsResolver: "native" }, }); expect(s.resolverCalls).toEqual([]); @@ -1460,6 +1582,100 @@ describe("db diff", () => { }, ); + it.effect.each([ + { + label: "--from <db-url> --to remote", + from: CREDENTIALED_URL, + to: "remote", + lines: [DB_URL_LINE, PROJECT_LINE], + first: { kind: "db-url", host: "db.example.com", port: 6543 }, + }, + { + label: "--from remote --to <db-url>", + from: "remote", + to: CREDENTIALED_URL, + lines: [PROJECT_LINE, DB_URL_LINE], + first: { kind: "linked", project_ref: VALID_REF, environment: "unverified" }, + }, + { + label: "--from local --to remote", + from: "local", + to: "remote", + lines: [LOCAL_LINE, PROJECT_LINE], + first: { kind: "local", host: "127.0.0.1", port: 54322 }, + }, + { + label: "--from local --to <db-url>", + from: "local", + to: CREDENTIALED_URL, + lines: [LOCAL_LINE, DB_URL_LINE], + first: { kind: "local", host: "127.0.0.1", port: 54322 }, + }, + ])("$label announces each database in resolution order, without credentials", (row) => { + const s = setup(tmp.current, { isLocal: false, diffSql: "create table o ();\n" }); + return Effect.gen(function* () { + yield* dbDiff(flags({ from: Option.some(row.from), to: Option.some(row.to) })); + expect(targetLines(s.out)).toEqual(row.lines); + expect(yield* CommandTarget.use((cell) => cell.json)).toEqual(Option.some(row.first)); + expect(`${s.out.stdoutText}${s.out.stderrText}`).not.toContain("secret"); + }).pipe(Effect.provide(s.layer)); + }); + + it.effect.each([ + { label: "--from local --to local", value: "local", line: LOCAL_LINE }, + { + label: "--from <db-url> --to <the same db-url>", + value: CREDENTIALED_URL, + line: DB_URL_LINE, + }, + ])("$label announces the database once", ({ value, line }) => { + const s = setup(tmp.current, { diffSql: "create table o ();\n" }); + return Effect.gen(function* () { + yield* dbDiff(flags({ from: Option.some(value), to: Option.some(value) })); + expect(targetLines(s.out)).toEqual([line]); + }).pipe(Effect.provide(s.layer)); + }); + + it.effect("--from local on the stack backend announces the stack's database", () => { + const s = setup(tmp.current, { diffSql: "create table k ();\n" }); + return Effect.gen(function* () { + yield* dbDiff(flags({ from: Option.some("local"), to: Option.some("migrations") })); + expect(targetLines(s.out)).toEqual([`Target: local stack (127.0.0.1:${STACK_SQL_PORT})`]); + expect(yield* CommandTarget.use((cell) => cell.json)).toEqual( + Option.some({ kind: "local", host: "127.0.0.1", port: STACK_SQL_PORT }), + ); + }).pipe( + Effect.provide( + Layer.mergeAll(s.layer, stackBackendLayer("stack"), runningStackApi(tmp.current)), + ), + ); + }); + + it.effect("--from <malformed db-url> fails before the hosted side resolves, unechoed", () => { + const s = setup(tmp.current, { isLocal: false }); + return Effect.gen(function* () { + const exit = yield* dbDiff( + flags({ + from: Option.some(`${CREDENTIALED_URL}?sslmode=bogus`), + to: Option.some("remote"), + }), + ).pipe(Effect.exit); + + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(Cause.squash(exit.cause)).toMatchObject({ + _tag: "PgDeltaEngineError", + message: "failed to parse Postgres connection string for pg-delta", + }); + expect(Cause.pretty(exit.cause)).not.toContain("secret"); + } + expect(`${s.out.stdoutText}${s.out.stderrText}`).not.toContain("secret"); + expect(targetLines(s.out)).toEqual([]); + expect(s.resolverCalls).toEqual([]); + expect(s.explicitDiffCalls).toEqual([]); + }).pipe(Effect.provide(s.layer)); + }); + it.effect("--from local --to migrations reaches no hosted project", () => { const s = setup(tmp.current, { diffSql: "create table l ();\n" }); return Effect.gen(function* () { @@ -2161,11 +2377,13 @@ describe("db diff", () => { expect(s.explicitDiffCalls[0]?.source).toEqual({ kind: "database", ref: "postgresql://source.example/postgres", + connection: expect.objectContaining({ host: "source.example", database: "postgres" }), connectOptions: { isLocal: false, dnsResolver: "native" }, }); expect(s.explicitDiffCalls[0]?.desired).toEqual({ kind: "database", ref: "postgresql://desired.example/postgres", + connection: expect.objectContaining({ host: "desired.example", database: "postgres" }), connectOptions: { isLocal: false, dnsResolver: "native" }, }); }).pipe(Effect.provide(s.layer)); From cbed12dd7bfbcc20b442a180da381a4370bfd88c Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Tue, 20 Oct 2026 11:59:16 +0100 Subject: [PATCH 183/291] fix(cli): fail a --remote given twice with different values before anything runs A repeated --remote with differing values (bare, inline or mixed) fails with TargetSelectorConflictError before any lookup. The same value given twice is one selection. --- .../target/remote-resolution.ts | 4 +- ...resolve-project-target.integration.test.ts | 21 +++ .../target/resolve-target.integration.test.ts | 42 ++++++ .../target/target-selector.ts | 12 ++ .../target/target-selector.unit.test.ts | 14 ++ .../command-internal/target/target.errors.ts | 2 +- .../sso/update/update.integration.test.ts | 22 ++++ apps/cli/src/shared/cli/cli-args.service.ts | 1 + apps/cli/src/shared/cli/remote-argv.ts | 42 +++++- .../src/shared/cli/remote-argv.unit.test.ts | 120 +++++++++++++++++- apps/cli/src/shared/cli/run.ts | 12 +- 11 files changed, 285 insertions(+), 7 deletions(-) diff --git a/apps/cli/src/command-internal/target/remote-resolution.ts b/apps/cli/src/command-internal/target/remote-resolution.ts index b2e7de77ca..6eaf4a0de0 100644 --- a/apps/cli/src/command-internal/target/remote-resolution.ts +++ b/apps/cli/src/command-internal/target/remote-resolution.ts @@ -72,8 +72,10 @@ export const selectTarget = Effect.fnUntraced(function* ( const commandName = Option.isSome(runtime) ? `\`supabase ${getCommandRuntimeCommand(runtime.value)}\`` : "This command"; + const cliArgs = yield* Effect.serviceOption(CliArgs); + const remoteConflict = Option.getOrUndefined(cliArgs)?.remoteConflict === true; const { selection, deprecations } = yield* Result.match( - parseTargetSelector(flags, { commandName, ...options }), + parseTargetSelector(flags, { commandName, remoteConflict, ...options }), { onFailure: Effect.fail, onSuccess: Effect.succeed }, ); yield* warnTargetDeprecations(deprecations); diff --git a/apps/cli/src/command-internal/target/resolve-project-target.integration.test.ts b/apps/cli/src/command-internal/target/resolve-project-target.integration.test.ts index 43528d5256..e0a3e0e938 100644 --- a/apps/cli/src/command-internal/target/resolve-project-target.integration.test.ts +++ b/apps/cli/src/command-internal/target/resolve-project-target.integration.test.ts @@ -190,6 +190,27 @@ describe("resolveProjectTarget", () => { }), ); + it.effect("fails before any lookup when --remote is given twice with different values", () => + Effect.gen(function* () { + const result = yield* run( + { remote: { _tag: "Branch", name: "feature-x" } }, + { + savedRef: FILE_REF, + args: ["functions", "deploy", "--remote=feature-x", `--remote=${FLAG_REF}`], + branches: [branchRow("feature-x")], + }, + ); + expect(failureOf(result.exit)).toMatchObject({ + _tag: "TargetSelectorConflictError", + flags: ["--remote"], + message: "--remote is given more than once with different values; pass it once.", + suggestion: "Pass --remote once, as --remote or --remote=<ref|branch>.", + }); + expect(result.branchRequests).toBe(0); + expect(result.refFlags).toEqual([]); + }), + ); + it.effect("uses the project --remote=<ref> names, with no deprecation warning", () => Effect.gen(function* () { const result = yield* run({ remote: { _tag: "Ref", ref: FLAG_REF } }); 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 7a18793c27..231abd61a9 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 @@ -832,6 +832,48 @@ describe("resolveTarget", () => { ); }); + describe("a repeated --remote", () => { + const command = ["db", "push"]; + const bare = { ...none, ...remote({ _tag: "Linked" }) }; + + it.effect("fails before anything runs when the values differ", () => + Effect.gen(function* () { + const result = yield* run( + "selectable", + { ...none, ...remote({ _tag: "Branch", name: "feature-x" }) }, + { + args: ["db", "push", "--remote=feature-x", "--remote"], + command, + savedRef: FILE_REF, + branches: [branchRow("feature-x")], + }, + ); + const error = failureOf(result.exit); + expect(error).toMatchObject({ + _tag: "TargetSelectorConflictError", + flags: ["--remote"], + message: "--remote is given more than once with different values; pass it once.", + suggestion: "Pass --remote once, as --remote or --remote=<ref|branch|url>.", + }); + expect((error as { [ErrorActionabilityId]: unknown })[ErrorActionabilityId]).toEqual( + actionability.provideFlags, + ); + expect(result.apiRequests).toEqual([]); + expect(result.refFlags).toEqual([]); + }), + ); + + it.effect.each([ + { name: "the same value", args: ["db", "push", "--remote=feature-x", "--remote=feature-x"] }, + { name: "a bare switch", args: ["db", "push", "--remote", "--remote"] }, + ])("accepts $name given twice", ({ args }) => + Effect.gen(function* () { + const result = yield* run("selectable", bare, { args, command, savedRef: FILE_REF }); + expect(succeeded(result.exit)).toMatchObject({ kind: "linked", ref: FILE_REF }); + }), + ); + }); + describe("deprecated selectors", () => { it.effect("warns that --linked is replaced by --remote, with the command as run", () => Effect.gen(function* () { diff --git a/apps/cli/src/command-internal/target/target-selector.ts b/apps/cli/src/command-internal/target/target-selector.ts index 00a7fd0f5c..112995b14e 100644 --- a/apps/cli/src/command-internal/target/target-selector.ts +++ b/apps/cli/src/command-internal/target/target-selector.ts @@ -49,6 +49,8 @@ export interface TargetSelectorOptions { readonly commandName?: string; /** What `--remote` takes on this command, for the suggestions; a command with no `dbUrl` input defaults to `<ref|branch>`. */ readonly valueHint?: string; + /** `--remote` is given more than once with values that differ. */ + readonly remoteConflict?: boolean; } /** Lists flag names as `a`, `a and b` or `a, b and c`. */ @@ -97,6 +99,16 @@ export const parseTargetSelector = ( if (projectRef === "") return Result.fail(emptyValueError("--project-ref")); if (projectId === "") return Result.fail(emptyValueError("--project-id")); + if (options.remoteConflict === true) { + return Result.fail( + new TargetSelectorConflictError({ + flags: ["--remote"], + message: "--remote is given more than once with different values; pass it once.", + suggestion: `Pass --remote once, as --remote or --remote=${valueHint}.`, + }), + ); + } + const selected = [ ...(remote === undefined ? [] : ["--remote"]), ...(input.linked ? ["--linked"] : []), 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 1f13b4a36f..846783b723 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 @@ -235,6 +235,20 @@ describe("parseTargetSelector", () => { }); }); + it.each([ + { name: "with a database input", overrides: {}, hint: "<ref|branch|url>" }, + { name: "without one", overrides: { dbUrl: undefined }, hint: "<ref|branch>" }, + ])("rejects a --remote given twice with different values, $name", ({ overrides, hint }) => { + expect( + failure({ ...remote({ _tag: "Linked" }), ...overrides }, { remoteConflict: true }), + ).toMatchObject({ + _tag: "TargetSelectorConflictError", + flags: ["--remote"], + message: "--remote is given more than once with different values; pass it once.", + suggestion: `Pass --remote once, as --remote or --remote=${hint}.`, + }); + }); + it("rejects --local with --linked", () => { expect(failure({ local: true, linked: true })).toMatchObject({ flags: ["--linked", "--local"], diff --git a/apps/cli/src/command-internal/target/target.errors.ts b/apps/cli/src/command-internal/target/target.errors.ts index 73b2902da0..12a5823402 100644 --- a/apps/cli/src/command-internal/target/target.errors.ts +++ b/apps/cli/src/command-internal/target/target.errors.ts @@ -7,7 +7,7 @@ import { statusCodeActionability, } from "../../shared/telemetry/error-actionability.ts"; -/** More than one of `--remote`, `--linked`, `--db-url` and `--local` was set. */ +/** More than one of `--remote`, `--linked`, `--db-url` and `--local` was set, or `--remote` was given twice with different values. */ export class TargetSelectorConflictError extends Data.TaggedError("TargetSelectorConflictError")<{ readonly flags: ReadonlyArray<string>; readonly message: string; 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 bef94810c2..239caeb68c 100644 --- a/apps/cli/src/commands/sso/update/update.integration.test.ts +++ b/apps/cli/src/commands/sso/update/update.integration.test.ts @@ -39,8 +39,11 @@ import { projectResponse, realLinkedProjectCache, } from "../../../../tests/helpers/permission-real-layers.ts"; +import { rootCommandForFeatures } from "../../../cli/root.ts"; import { ProfileFlag, YesFlag } from "../../../command-internal/global-flags.ts"; import { TargetPolicy } from "../../../command-internal/target/target-policy.ts"; +import { CliArgs } from "../../../shared/cli/cli-args.service.ts"; +import { prepareCliArgs } from "../../../shared/cli/run.ts"; import { EventUpgradeSuggested } from "../../../shared/telemetry/event-catalog.ts"; import { classifyCliCauseActionability } from "../../../shared/telemetry/error-actionability.ts"; import type { SsoUpdateFlags } from "./update.command.ts"; @@ -597,6 +600,25 @@ describe("sso update integration", () => { }).pipe(Effect.provide(layer)); }); + it.effect("fails before any API call when --remote is given twice with different values", () => { + const cliArgs = ["sso", "update", VALID_PROVIDER_ID, `--remote=${OTHER_REF}`, "--remote"]; + const { layer, api } = setup({ cliArgs }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(ssoUpdate(defaultFlags)); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(Cause.squash(exit.cause)).toMatchObject({ + _tag: "TargetSelectorConflictError", + message: "--remote is given more than once with different values; pass it once.", + }); + } + expect(api.requests).toEqual([]); + }).pipe( + Effect.provideService(CliArgs, prepareCliArgs(rootCommandForFeatures(), cliArgs)), + Effect.provide(layer), + ); + }); + it.effect( "ignores a --remote token a bare --domains consumed as its value, as it does for --project-ref", () => { diff --git a/apps/cli/src/shared/cli/cli-args.service.ts b/apps/cli/src/shared/cli/cli-args.service.ts index cac0908124..8f618232d0 100644 --- a/apps/cli/src/shared/cli/cli-args.service.ts +++ b/apps/cli/src/shared/cli/cli-args.service.ts @@ -5,6 +5,7 @@ export interface CliArgsShape { readonly args: ReadonlyArray<string>; /** The word a bare `--remote` is followed by when it was taken as an operand and could name a branch. */ readonly bareRemoteOperand?: string | undefined; + readonly remoteConflict?: boolean; } export class CliArgs extends Context.Service<CliArgs, CliArgsShape>()("supabase/cli/CliArgs") {} diff --git a/apps/cli/src/shared/cli/remote-argv.ts b/apps/cli/src/shared/cli/remote-argv.ts index 44dd4974fe..51dbc0b172 100644 --- a/apps/cli/src/shared/cli/remote-argv.ts +++ b/apps/cli/src/shared/cli/remote-argv.ts @@ -1,6 +1,8 @@ +import { Result } from "effect"; import type { Command } from "effect/cli"; import { unwrapToSingleParam } from "../../command-internal/param-introspection.ts"; +import { parseRemoteValue, type RemoteValue } from "../../command-internal/target/remote-value.ts"; import { couldNameBranch, DB_URL_PATTERN, @@ -20,11 +22,15 @@ export interface RemoteArgvLeaf { readonly consumesNextToken: (token: string) => boolean; } +const INLINE_REMOTE_PREFIX = "--remote="; + interface RemoteArgvScan { /** Indexes of the bare (non-flag, non-flag-value) tokens ahead of any `--`. */ readonly bare: ReadonlyArray<number>; /** Indexes of the `--remote` tokens that carry no inline value. */ readonly remotes: ReadonlyArray<number>; + /** Indexes of the `--remote=<value>` tokens. */ + readonly inline: ReadonlyArray<number>; /** Index of the first `--`, or `args.length`. */ readonly end: number; } @@ -32,6 +38,7 @@ interface RemoteArgvScan { const scanRemoteArgv = (args: ReadonlyArray<string>, leaf: RemoteArgvLeaf): RemoteArgvScan => { const bare: Array<number> = []; const remotes: Array<number> = []; + const inline: Array<number> = []; let end = args.length; for (let index = 0; index < args.length; index += 1) { const arg = args[index]; @@ -44,6 +51,10 @@ const scanRemoteArgv = (args: ReadonlyArray<string>, leaf: RemoteArgvLeaf): Remo bare.push(index); continue; } + if (arg.startsWith(INLINE_REMOTE_PREFIX)) { + inline.push(index); + continue; + } const next = args[index + 1]; const isLiteral = next !== undefined && BOOLEAN_FLAG_VALUES.has(next); if (arg === "--remote") { @@ -53,7 +64,7 @@ const scanRemoteArgv = (args: ReadonlyArray<string>, leaf: RemoteArgvLeaf): Remo index += 1; } } - return { bare, remotes, end }; + return { bare, remotes, inline, end }; }; /** @@ -91,6 +102,35 @@ export function joinRemoteOperands( }); } +const selectionKey = (value: RemoteValue): string => { + switch (value._tag) { + case "Linked": + return "linked"; + case "Ref": + return `ref:${value.ref}`; + case "DbUrl": + return `db-url:${value.url}`; + case "Branch": + return `branch:${value.name}`; + } +}; + +/** + * Whether `args`, with bare `--remote <word>` already joined, give `--remote` more than once with + * values that differ. A bare `--remote` selects the linked project, even when an operand follows + * it; a value the flag rejects is left for its parser to report. + */ +export function hasConflictingRemotes(args: ReadonlyArray<string>, leaf: RemoteArgvLeaf): boolean { + const { remotes, inline } = scanRemoteArgv(args, leaf); + const selections = new Set<string>(); + if (remotes.length > 0) selections.add(selectionKey({ _tag: "Linked" })); + for (const index of inline) { + const parsed = parseRemoteValue((args[index] ?? "").slice(INLINE_REMOTE_PREFIX.length)); + if (Result.isSuccess(parsed)) selections.add(selectionKey(parsed.success)); + } + return selections.size > 1; +} + const UNKNOWN_LEAF: RemoteArgvLeaf = { pathLength: 0, operandCapacity: Infinity, diff --git a/apps/cli/src/shared/cli/remote-argv.unit.test.ts b/apps/cli/src/shared/cli/remote-argv.unit.test.ts index 53c721c904..0003f663a6 100644 --- a/apps/cli/src/shared/cli/remote-argv.unit.test.ts +++ b/apps/cli/src/shared/cli/remote-argv.unit.test.ts @@ -1,7 +1,12 @@ import { describe, expect, it } from "vitest"; import { rootCommandForFeatures } from "../../cli/root.ts"; -import { bareRemoteOperand, joinRemoteOperands, type RemoteArgvLeaf } from "./remote-argv.ts"; +import { + bareRemoteOperand, + hasConflictingRemotes, + joinRemoteOperands, + type RemoteArgvLeaf, +} from "./remote-argv.ts"; import { prepareCliArgs } from "./run.ts"; const REF = "abcdefghijklmnopqrst"; @@ -223,6 +228,92 @@ describe("bareRemoteOperand", () => { }); }); +describe("hasConflictingRemotes", () => { + const cases: ReadonlyArray<{ + readonly name: string; + readonly leaf: RemoteArgvLeaf; + readonly args: ReadonlyArray<string>; + readonly expected: boolean; + }> = [ + { + name: "is false for a single --remote", + leaf: noOperands, + args: ["db", "push", "--remote=feature-x"], + expected: false, + }, + { + name: "is false for identical values", + leaf: noOperands, + args: ["db", "push", "--remote=feature-x", "--remote=feature-x"], + expected: false, + }, + { + name: "is false for a repeated bare --remote", + leaf: noOperands, + args: ["db", "push", "--remote", "--remote"], + expected: false, + }, + { + name: "is true for two different branch names", + leaf: noOperands, + args: ["db", "push", "--remote=feature-x", "--remote=feature-y"], + expected: true, + }, + { + name: "is true for a project ref and a database URL", + leaf: noOperands, + args: ["db", "push", `--remote=${REF}`, `--remote=${URL}`], + expected: true, + }, + { + name: "is true for a bare --remote and a value", + leaf: noOperands, + args: ["db", "push", "--remote", `--remote=${REF}`], + expected: true, + }, + { + name: "reads a linked boolean literal as the bare --remote", + leaf: variadic, + args: ["functions", "deploy", "--remote=YES", "--remote", "--remote=on"], + expected: false, + }, + { + name: "reads a space-form word joined to --remote against a bare --remote", + leaf: noOperands, + args: ["db", "push", "--remote", "feature-x", "--remote"], + expected: true, + }, + { + name: "counts a bare --remote followed by an operand as the linked project", + leaf: oneOperand, + args: ["storage", "ls", "--remote", "feature-x", "--remote=feature-x"], + expected: true, + }, + { + name: "ignores a value --remote rejects, which its parser reports", + leaf: noOperands, + args: ["db", "push", "--remote=false", "--remote=feature-x"], + expected: false, + }, + { + name: "ignores a --remote after --", + leaf: variadic, + args: ["functions", "deploy", "--remote=feature-x", "--", "--remote=feature-y"], + expected: false, + }, + { + name: "ignores a --remote another flag takes as its value", + leaf: noOperands, + args: ["db", "push", "--password", "--remote=feature-y", "--remote=feature-x"], + expected: false, + }, + ]; + + it.each(cases)("$name", ({ leaf, args, expected }) => { + expect(hasConflictingRemotes(joinRemoteOperands(args, leaf), leaf)).toBe(expected); + }); +}); + describe("prepareCliArgs argv", () => { const root = rootCommandForFeatures(); const cases: ReadonlyArray<{ @@ -344,3 +435,30 @@ describe("prepareCliArgs bareRemoteOperand on the real command tree", () => { expect(operandOf(["db", "push", "--remote", "feature-x"])).toBeUndefined(); }); }); + +describe("prepareCliArgs remoteConflict on the real command tree", () => { + const root = rootCommandForFeatures(); + const conflictOf = (raw: ReadonlyArray<string>) => prepareCliArgs(root, raw).remoteConflict; + + it.each([ + { name: "a leaf's own --remote", args: ["db", "push", "--remote=feature-x", "--remote"] }, + { + name: "seed's persistent --remote", + args: ["seed", "buckets", `--remote=${REF}`, "--remote=feature-x"], + }, + { + name: "a space-form word joined into --remote", + args: ["db", "push", "--remote", "feature-x", "--remote=feature-y"], + }, + ])("is set for $name given twice with different values", ({ args }) => { + expect(conflictOf(args)).toBe(true); + }); + + it("is not set when the repeated values are the same", () => { + expect(conflictOf(["db", "push", "--remote=feature-x", "--remote=feature-x"])).toBe(false); + }); + + it("is not set when db pull's operand follows a bare --remote that is repeated bare", () => { + expect(conflictOf(["db", "pull", "--remote", "feature-x", "--remote"])).toBe(false); + }); +}); diff --git a/apps/cli/src/shared/cli/run.ts b/apps/cli/src/shared/cli/run.ts index 940ba9a2a4..1e8400f2f6 100644 --- a/apps/cli/src/shared/cli/run.ts +++ b/apps/cli/src/shared/cli/run.ts @@ -63,6 +63,7 @@ import { } from "./agent-output.ts"; import { bareRemoteOperand, + hasConflictingRemotes, joinRemoteOperands, remoteOperandCapacity, type RemoteArgvLeaf, @@ -201,7 +202,7 @@ export function remoteArgvLeafFor( rootCommand: Command.Command.Any, args: ReadonlyArray<string>, ): RemoteArgvLeaf | undefined { - if (!args.includes("--remote")) return undefined; + if (!args.some((arg) => arg === "--remote" || arg.startsWith("--remote="))) return undefined; const path = resolvedCommandPathForArgv(rootCommand, extractCommandPath(args)); const operandCapacity = remoteOperandCapacity(rootCommand, path); if (operandCapacity === undefined) return undefined; @@ -221,7 +222,8 @@ export function remoteArgvLeafFor( /** * `rawArgs` with a bare `--remote <word>` joined to `--remote=<word>` wherever the command's - * operands leave no other reading, plus the word a bare `--remote` is still followed by. + * operands leave no other reading, plus the word a bare `--remote` is still followed by and + * whether `--remote` is given more than once with different values. */ export function prepareCliArgs( rootCommand: Command.Command.Any, @@ -230,7 +232,11 @@ export function prepareCliArgs( const leaf = remoteArgvLeafFor(rootCommand, rawArgs); if (leaf === undefined) return { args: rawArgs }; const args = joinRemoteOperands(rawArgs, leaf); - return { args, bareRemoteOperand: bareRemoteOperand(args, leaf) }; + return { + args, + bareRemoteOperand: bareRemoteOperand(args, leaf), + remoteConflict: hasConflictingRemotes(args, leaf), + }; } /** `CliArgs` and a `Stdio` whose `args` is the same argv, so every reader of either sees the rewritten command line. */ From 818441df32982779b15876aca4749417ddeae1ef Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Tue, 20 Oct 2026 12:00:10 +0100 Subject: [PATCH 184/291] fix(cli): keep the remote reset question honest about migrations The hosted reset question listed `apply local migrations` whenever schema files were not in use, but the apply step only runs migrations when `[db.migrations] enabled` is true. With migrations disabled the question promised work the run never did. The question now names the declarative schema files, the local migrations, or no apply step, matching what the run applies. --- .../src/commands/db/reset/reset.handler.ts | 9 ++-- .../db/reset/reset.integration.test.ts | 51 +++++++++++++++++++ 2 files changed, 57 insertions(+), 3 deletions(-) diff --git a/apps/cli/src/commands/db/reset/reset.handler.ts b/apps/cli/src/commands/db/reset/reset.handler.ts index 882aa25f41..b6767629a7 100644 --- a/apps/cli/src/commands/db/reset/reset.handler.ts +++ b/apps/cli/src/commands/db/reset/reset.handler.ts @@ -228,6 +228,11 @@ export const dbReset = Effect.fn("db.reset")(function* (flags: DbResetFlags) { ? (yield* sqlFilesGlob(fs, path, seedSqlPaths, workdir)).files : []; const vaultCount = vaultSecrets.filter((secret) => secret.resolved).length; + const applyDetail = useSchemaFiles + ? "apply the declarative schema files" + : toml.migrationsEnabled + ? `apply local migrations${resolvedVersion === "" ? "" : ` up to version ${resolvedVersion}`}` + : undefined; yield* requireHostedMutationConsent({ action: `Reset the remote database${resolvedVersion === "" ? "" : ` to version ${resolvedVersion}`}`, details: [ @@ -235,9 +240,7 @@ export const dbReset = Effect.fn("db.reset")(function* (flags: DbResetFlags) { ...(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(applyDetail)), ...Option.toArray(Option.fromNullishOr(seedPlan.detail(seedFiles))), ], }); 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 8f36d9de32..fb63db89b8 100644 --- a/apps/cli/src/commands/db/reset/reset.integration.test.ts +++ b/apps/cli/src/commands/db/reset/reset.integration.test.ts @@ -3672,6 +3672,57 @@ describe("db reset", () => { }); }); + describe("the apply step in the single reset question", () => { + const remoteReset = (toml: string, extra: { experimental?: boolean } = {}) => + setup(tmp.current, { + toml, + files: { + ...migrationFile("20240101000000"), + "supabase/schemas/01_users.sql": "create table schema_users ();", + }, + confirm: [true], + ref: VALID_REF, + ...extra, + }); + + it.effect("names the local migrations when migrations are enabled", () => { + const { layer, out } = remoteReset('project_id = "test"\n'); + return Effect.gen(function* () { + yield* dbReset({ ...DEFAULT_FLAGS, remote: REMOTE_LINKED }).pipe(Effect.provide(layer)); + expect(out.stderrText).toContain("drop every user schema\napply local migrations\n"); + expect(out.stderrText).toContain("Applying migration 20240101000000_test.sql..."); + }); + }); + + it.effect("names no apply step when migrations are disabled", () => { + const { layer, out } = remoteReset( + 'project_id = "test"\n\n[db.migrations]\nenabled = false\n', + ); + return Effect.gen(function* () { + yield* dbReset({ ...DEFAULT_FLAGS, remote: REMOTE_LINKED }).pipe(Effect.provide(layer)); + expect(out.promptConfirmCalls).toHaveLength(1); + expect(out.stderrText).toContain("drop every user schema"); + expect(out.stderrText).not.toContain("apply local migrations"); + expect(out.stderrText).not.toContain("apply the declarative schema files"); + expect(out.stderrText).not.toContain("Applying migration"); + }); + }); + + it.effect("names the schema files instead of the migrations when they apply", () => { + const { layer, out } = remoteReset( + 'project_id = "test"\n\n[db.migrations]\nschema_paths = ["schemas/*.sql"]\n\n[experimental.pgdelta]\nenabled = false\n', + { experimental: true }, + ); + return Effect.gen(function* () { + yield* dbReset({ ...DEFAULT_FLAGS, remote: REMOTE_LINKED }).pipe(Effect.provide(layer)); + expect(out.stderrText).toContain( + "drop every user schema\napply the declarative schema files\n", + ); + expect(out.stderrText).not.toContain("apply local migrations"); + }); + }); + }); + it.effect("rejects --no-seed together with --sql-paths", () => { const { layer } = setup(tmp.current, { toml: 'project_id = "test"\n' }); return Effect.gen(function* () { From 172a17c71be262c50a1a7aabc1f53ff4bb3964c3 Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Tue, 20 Oct 2026 12:00:12 +0100 Subject: [PATCH 185/291] refactor(cli): derive target-flag telemetry from the invoked arguments withCommandTelemetry reads --remote, --linked, --local, --project-ref, --project-id and --db-url from the rewritten argv for every hosted or selectable command, so leaves no longer list them in their telemetry flags and a leaf that forgets them can no longer report <redacted>. The recorded values are unchanged: a bare --remote or a linked literal is true, a project ref is recorded verbatim, a branch name or URL is redacted, --linked and --local record their boolean, --project-ref and --project-id record only a ref-shaped value, and --db-url is always redacted. A repeated flag takes its first occurrence, as the flag parser does. The target cases move to command-telemetry.unit.test.ts, which covers a leaf that passes no target entries. Leaf tests that only re-checked these values are removed, and the handler exports that only they used are no longer exported. --- apps/cli/AGENTS.md | 6 +- .../command-internal/target/remote-value.ts | 9 +- .../test-db.command-handler.ts | 25 +- .../src/commands/config/diff/diff.command.ts | 2 - .../config/diff/diff.integration.test.ts | 51 ---- .../src/commands/config/pull/pull.command.ts | 4 +- .../src/commands/config/push/push.command.ts | 6 +- .../config/push/push.integration.test.ts | 64 +---- .../commands/db/advisors/advisors.command.ts | 5 - apps/cli/src/commands/db/diff/diff.command.ts | 5 - apps/cli/src/commands/db/dump/dump.command.ts | 5 - apps/cli/src/commands/db/lint/lint.command.ts | 9 +- apps/cli/src/commands/db/pull/pull.command.ts | 5 - apps/cli/src/commands/db/push/push.command.ts | 5 - .../src/commands/db/query/query.command.ts | 5 - .../src/commands/db/reset/reset.command.ts | 5 - .../declarative/generate/generate.command.ts | 5 - .../functions/delete/delete.command.ts | 4 +- .../delete/delete.integration.test.ts | 43 +-- .../functions/download/download.command.ts | 4 +- .../download/download.integration.test.ts | 87 +----- .../src/commands/gen/types/types.command.ts | 3 +- .../gen/types/types.integration.test.ts | 66 +---- .../commands/inspect/db/inspect-db-command.ts | 18 +- .../commands/inspect/report/report.command.ts | 5 - apps/cli/src/commands/link/link.command.ts | 2 +- .../commands/link/link.integration.test.ts | 87 +----- .../commands/migration/down/down.command.ts | 5 - .../commands/migration/fetch/fetch.command.ts | 13 +- .../commands/migration/list/list.command.ts | 5 - .../migration/repair/repair.command.ts | 8 +- .../migration/squash/squash.command.ts | 5 - .../src/commands/migration/up/up.command.ts | 5 - apps/cli/src/commands/pull/pull.command.ts | 2 - .../commands/pull/pull.integration.test.ts | 49 ---- .../commands/seed/buckets/buckets.command.ts | 11 +- .../cli/src/commands/storage/cp/cp.command.ts | 4 - .../cli/src/commands/storage/ls/ls.command.ts | 4 - .../cli/src/commands/storage/mv/mv.command.ts | 4 - .../cli/src/commands/storage/rm/rm.command.ts | 4 - apps/cli/src/telemetry/command-telemetry.ts | 126 ++++++--- .../telemetry/command-telemetry.unit.test.ts | 254 ++++++++---------- 42 files changed, 238 insertions(+), 796 deletions(-) diff --git a/apps/cli/AGENTS.md b/apps/cli/AGENTS.md index de9a596af8..57d76de73b 100644 --- a/apps/cli/AGENTS.md +++ b/apps/cli/AGENTS.md @@ -186,8 +186,10 @@ property shape (`flags`, `is_agent`, and `env_signals`), use canonical names and [event catalog](src/shared/telemetry/event-catalog.ts), and redact sensitive values. `safeFlags` may include only values that carry no user data. Keep the established safe project, -project-id, org-id, and version cases. `withCommandTelemetry` reports the target flags itself for -every command with a `hosted` or `selectable` target policy, so never list them in a command's `safeFlags`: `--remote`, `--linked`, `--local`, +project-id, org-id, and version cases. For every command with a `hosted` or `selectable` target +policy, `withCommandTelemetry` reads the target flags from the invoked arguments, so they need no +entry in a command's `flags` or `safeFlags`: `--remote` (`true` when bare or linked, the project +ref when it is one, otherwise redacted), `--linked` and `--local` (their boolean), `--project-ref`/`--project-id` (the value only when it matches `PROJECT_REF_PATTERN`, since it can be a user-created branch name) and `--db-url` (always redacted). Global boolean and choice flags are resolved by the wrapper from `globalFlagValues`; a command-local flag wins, so a local diff --git a/apps/cli/src/command-internal/target/remote-value.ts b/apps/cli/src/command-internal/target/remote-value.ts index 32214cc1ab..011c292b95 100644 --- a/apps/cli/src/command-internal/target/remote-value.ts +++ b/apps/cli/src/command-internal/target/remote-value.ts @@ -1,4 +1,4 @@ -import { Predicate, Result } from "effect"; +import { Result } from "effect"; import { BRANCH_PROJECT_REF_PATTERN } from "../ref-patterns.ts"; import { @@ -15,13 +15,6 @@ export type RemoteValue = | { readonly _tag: "DbUrl"; readonly url: string } | { readonly _tag: "Branch"; readonly name: string }; -/** Whether a value is a `RemoteValue`, told apart by its `_tag`. */ -export const isRemoteValue = (value: unknown): value is RemoteValue => - Predicate.isTagged(value, "Linked") || - Predicate.isTagged(value, "Ref") || - Predicate.isTagged(value, "DbUrl") || - Predicate.isTagged(value, "Branch"); - export type NamedRemoteValue = Exclude<RemoteValue, { readonly _tag: "Linked" }>; /** A value with `://` that is not a postgres URL; `ref` is the project it points at when it is a project's own URL. */ 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 db215f8a14..a9cc071ee4 100644 --- a/apps/cli/src/command-internal/test-db.command-handler.ts +++ b/apps/cli/src/command-internal/test-db.command-handler.ts @@ -30,14 +30,7 @@ const onRunFailure = (error: TestDbRunError | TestDbNoTestsError) => yield* processControl.setExitCode(1); }); -/** - * Flag config shared verbatim by `supabase test db` and its hidden alias - * `supabase db test`. - * - * Lives in `command-internal/` because it (with `runTestDbCommand` and - * `testDbRuntimeLayer`) is shared across the `db` and `test` command - * families — see "Hoist Before You Duplicate" in `apps/cli/CLAUDE.md`. - */ +/** Flag config shared by `supabase test db` and its hidden `supabase db test` alias. */ export const testDbConfig = { paths: Argument.String("path").pipe( Argument.withDescription("Paths to test files or directories."), @@ -50,11 +43,7 @@ export interface TestDbFlags extends TargetFlagValuesWithDbUrl { readonly paths: ReadonlyArray<string>; } -/** - * Assembled `test db` / `db test` handler: telemetry, run-failure routing - * (`onRunFailure`), and JSON-error-handling wiring shared by both entry - * points. - */ +/** The `test db` / `db test` handler: telemetry, run-failure routing and JSON error handling. */ export function runTestDbCommand(flags: CliCommand.Command.Config.Infer<typeof testDbConfig>) { return testDb({ paths: flags.paths, @@ -64,15 +53,7 @@ export function runTestDbCommand(flags: CliCommand.Command.Config.Infer<typeof t local: flags.local, projectRef: flags.projectRef, }).pipe( - withCommandTelemetry({ - flags: { - remote: flags.remote, - "db-url": flags.dbUrl, - linked: flags.linked, - local: flags.local, - "project-ref": flags.projectRef, - }, - }), + withCommandTelemetry(), // Run failures must not corrupt the TAP stream in machine modes; see `onRunFailure`. Effect.catchTag(["TestDbRunError", "TestDbNoTestsError"], onRunFailure), withJsonErrorHandling, diff --git a/apps/cli/src/commands/config/diff/diff.command.ts b/apps/cli/src/commands/config/diff/diff.command.ts index 871bfb9f27..6f3477d127 100644 --- a/apps/cli/src/commands/config/diff/diff.command.ts +++ b/apps/cli/src/commands/config/diff/diff.command.ts @@ -32,8 +32,6 @@ export type ConfigDiffFlags = CliCommand.Command.Config.Infer<typeof config>; export const configDiffHandler = (flags: ConfigDiffFlags) => configDiff(flags).pipe( - // `--project-ref` can also carry a branch name, so its value is safe to log only when it's - // actually ref-shaped — a user-created branch name must never reach PostHog. withCommandTelemetry({ flags, // This command rejects every `-o/--output` value itself with a message pointing at 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 7696cee169..8bca5d6887 100644 --- a/apps/cli/src/commands/config/diff/diff.integration.test.ts +++ b/apps/cli/src/commands/config/diff/diff.integration.test.ts @@ -30,7 +30,6 @@ import { realLinkedProjectCache } from "../../../../tests/helpers/permission-rea import { resolveWorkdir } from "../../../config/command-settings.layer.ts"; import { GLOBAL_OUTPUT_FORMATS } from "../../../command-internal/global-flags.ts"; import type { RemoteValue } from "../../../command-internal/target/remote-value.ts"; -import { TargetPolicy } from "../../../command-internal/target/target-policy.ts"; import { commandRuntimeLayer } from "../../../shared/runtime/command-runtime.layer.ts"; import { configDiffHandler } from "./diff.command.ts"; import { CONFIG_DIFF_PAYLOAD_VERSION } from "./diff.format.ts"; @@ -1486,56 +1485,6 @@ describe("config diff integration", () => { ); }); -describe("config diff telemetry wiring", () => { - // Uses configDiffHandler (not the bare handler) since telemetry wraps the handler in the command wiring. - const wiringLayer = ( - analytics: ReturnType<typeof mockContextualAnalytics>, - args: ReadonlyArray<string>, - ) => - Layer.mergeAll( - setup({ toml: 'project_id = "test"\n', analytics }).layer, - commandRuntimeLayer(["config", "diff"]).pipe(Layer.provide(BunServices.layer)), - Stdio.layerTest({ args: Effect.succeed(["config", "diff", ...args]) }), - Layer.succeed(TargetPolicy, { group: "selectable", consent: "none" }), - ); - - const flagsOf = (analytics: ReturnType<typeof mockContextualAnalytics>) => - analytics.captured.find((c) => c.event === "cli_command_executed")?.properties["flags"]; - - it.effect("logs a ref-shaped --remote verbatim in cli_command_executed", () => { - const analytics = mockContextualAnalytics(); - return Effect.gen(function* () { - yield* Effect.exit(configDiffHandler(remoteRef(VALID_REF))); - expect(flagsOf(analytics)).toEqual({ remote: VALID_REF }); - }).pipe(Effect.provide(wiringLayer(analytics, [`--remote=${VALID_REF}`]))); - }); - - it.effect("redacts a branch-name --remote", () => { - const analytics = mockContextualAnalytics(); - return Effect.gen(function* () { - yield* Effect.exit(configDiffHandler(remoteBranch("staging"))); - expect(flagsOf(analytics)).toEqual({ remote: "<redacted>" }); - }).pipe(Effect.provide(wiringLayer(analytics, ["--remote=staging"]))); - }); - - it.effect("logs a ref-shaped --project-ref verbatim in cli_command_executed", () => { - const analytics = mockContextualAnalytics(); - return Effect.gen(function* () { - yield* Effect.exit(configDiffHandler({ ...noFlags, projectRef: Option.some(VALID_REF) })); - expect(flagsOf(analytics)).toEqual({ "project-ref": VALID_REF }); - }).pipe(Effect.provide(wiringLayer(analytics, ["--project-ref", VALID_REF]))); - }); - - it.effect("redacts a branch-name-shaped --project-ref", () => { - // A user-created branch name must never reach PostHog verbatim. - const analytics = mockContextualAnalytics(); - return Effect.gen(function* () { - yield* Effect.exit(configDiffHandler({ ...noFlags, projectRef: Option.some("staging") })); - expect(flagsOf(analytics)).toEqual({ "project-ref": "<redacted>" }); - }).pipe(Effect.provide(wiringLayer(analytics, ["--project-ref", "staging"]))); - }); -}); - describe("config diff -o/--output wrapper wiring", () => { // diff.command.ts's `outputFormats` override widens the flag's enum to the full choice list; // without it, `-o table` would hit the wrapper's own generic rejection before reaching this diff --git a/apps/cli/src/commands/config/pull/pull.command.ts b/apps/cli/src/commands/config/pull/pull.command.ts index e09d9d95d5..b87239f104 100644 --- a/apps/cli/src/commands/config/pull/pull.command.ts +++ b/apps/cli/src/commands/config/pull/pull.command.ts @@ -42,9 +42,7 @@ export type ConfigPullFlags = CliCommand.Command.Config.Infer<typeof config>; const configPullHandler = (flags: ConfigPullFlags) => configPull(flags).pipe( - // --project-ref accepts branch names too, so it's only safe to log verbatim when - // ref-shaped; a branch name must never reach PostHog. --remote-label is free-form user text - // and is never safe to log. + // --remote-label is free-form user text and is never safe to log. withCommandTelemetry({ flags, // The handler rejects every -o/--output value itself, so the full global choice set is diff --git a/apps/cli/src/commands/config/push/push.command.ts b/apps/cli/src/commands/config/push/push.command.ts index bf7249bb30..4710b75177 100644 --- a/apps/cli/src/commands/config/push/push.command.ts +++ b/apps/cli/src/commands/config/push/push.command.ts @@ -23,12 +23,8 @@ const config = { export type ConfigPushFlags = CliCommand.Command.Config.Infer<typeof config>; -// Exported so integration tests can drive the exact wiring -// `Command.withHandler` uses below (same precedent as `linkHandler`). -export const configPushHandler = (flags: ConfigPushFlags) => +const configPushHandler = (flags: ConfigPushFlags) => configPush(flags).pipe( - // --project-ref is only safe to log verbatim when ref-shaped; an arbitrary string (a typo, - // a bad paste) must reach PostHog as "<redacted>". withCommandTelemetry({ flags, }), 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 0a29bdd80f..12c140207a 100644 --- a/apps/cli/src/commands/config/push/push.integration.test.ts +++ b/apps/cli/src/commands/config/push/push.integration.test.ts @@ -11,17 +11,12 @@ import { Path, PlatformError, Schema, - Stdio, } from "effect"; import * as HttpClientRequest from "effect/http/HttpClientRequest"; import * as HttpClientResponse from "effect/http/HttpClientResponse"; import { cliConfigValuesTestLayer } from "../../../../tests/helpers/config-values-layer.ts"; -import { - mockAnalytics, - mockContextualAnalytics, - mockOutput, -} from "../../../../tests/helpers/mocks.ts"; +import { mockAnalytics, mockOutput } from "../../../../tests/helpers/mocks.ts"; import { buildTestRuntime, DEFAULT_API_URL, @@ -55,7 +50,6 @@ import { commandRuntimeLayer } from "../../../shared/runtime/command-runtime.lay import { secretDigestHex } from "./push.secret.ts"; import { configDiff } from "../diff/diff.handler.ts"; import { configPush } from "./push.handler.ts"; -import { configPushHandler } from "./push.command.ts"; import { warningsOf } from "../../../../tests/helpers/remote-selection.ts"; const tempRoot = useTempWorkdir("supabase-config-push-int-"); @@ -3736,62 +3730,6 @@ describe("config push --remote branch name/UUID resolution (CLI-2289)", () => { }); }); -describe("config push telemetry wiring", () => { - // Drives the `Command.withHandler` wiring (`configPushHandler`), not the bare handler: the - // safeFlags guard lives in the wiring, and nothing validates `--project-ref` before it fires. - const wiringLayer = (analytics: ReturnType<typeof mockAnalytics>, args: ReadonlyArray<string>) => - Layer.mergeAll( - setup({ toml: `project_id = "test"\n`, yes: true, analytics }).layer, - commandRuntimeLayer(["config", "push"]).pipe(Layer.provide(BunServices.layer)), - Stdio.layerTest({ args: Effect.succeed(["config", "push", ...args]) }), - ); - - const flagsOf = (analytics: ReturnType<typeof mockAnalytics>) => - analytics.captured.find((c) => c.event === "cli_command_executed")?.properties["flags"]; - - it.effect("logs a ref-shaped --remote verbatim in cli_command_executed", () => { - const analytics = mockContextualAnalytics(); - return Effect.gen(function* () { - yield* Effect.exit( - configPushHandler({ - remote: Option.some<RemoteValue>({ _tag: "Ref", ref: REF }), - projectRef: Option.none(), - }), - ); - expect(flagsOf(analytics)).toEqual({ remote: REF }); - }).pipe(Effect.provide(wiringLayer(analytics, [`--remote=${REF}`]))); - }); - - it.effect("redacts a branch-name --remote", () => { - const analytics = mockContextualAnalytics(); - return Effect.gen(function* () { - yield* Effect.exit(configPushHandler(pushRemoteBranch("staging"))); - expect(flagsOf(analytics)).toEqual({ remote: "<redacted>" }); - }).pipe(Effect.provide(wiringLayer(analytics, ["--remote=staging"]))); - }); - - it.effect("logs a ref-shaped --project-ref verbatim in cli_command_executed", () => { - const analytics = mockContextualAnalytics(); - return Effect.gen(function* () { - yield* Effect.exit( - configPushHandler({ remote: Option.none(), projectRef: Option.some(REF) }), - ); - expect(flagsOf(analytics)).toEqual({ "project-ref": REF }); - }).pipe(Effect.provide(wiringLayer(analytics, ["--project-ref", REF]))); - }); - - it.effect("redacts a --project-ref value that is not ref-shaped", () => { - const analytics = mockContextualAnalytics(); - const value = "s3cret-paste-mistake"; - return Effect.gen(function* () { - yield* Effect.exit( - configPushHandler({ remote: Option.none(), projectRef: Option.some(value) }), - ); - expect(flagsOf(analytics)).toEqual({ "project-ref": "<redacted>" }); - }).pipe(Effect.provide(wiringLayer(analytics, ["--project-ref", value]))); - }); -}); - describeHostedConsent("config push", { api: { handler: pushApiHandler({}) }, setup: (workdir) => diff --git a/apps/cli/src/commands/db/advisors/advisors.command.ts b/apps/cli/src/commands/db/advisors/advisors.command.ts index aa103d8b13..d1ad2c6b80 100644 --- a/apps/cli/src/commands/db/advisors/advisors.command.ts +++ b/apps/cli/src/commands/db/advisors/advisors.command.ts @@ -71,11 +71,6 @@ export const dbAdvisorsCommand = Command.make("advisors", config).pipe( dbAdvisors(flags).pipe( withCommandTelemetry({ flags: { - remote: flags.remote, - "db-url": flags.dbUrl, - linked: flags.linked, - local: flags.local, - "project-ref": flags.projectRef, type: flags.type, level: flags.level, "fail-on": flags.failOn, diff --git a/apps/cli/src/commands/db/diff/diff.command.ts b/apps/cli/src/commands/db/diff/diff.command.ts index 47810df315..a0f2bc641e 100644 --- a/apps/cli/src/commands/db/diff/diff.command.ts +++ b/apps/cli/src/commands/db/diff/diff.command.ts @@ -155,11 +155,6 @@ export const dbDiffCommand = Command.make("diff", config).pipe( from: flags.from, to: flags.to, output: flags.output, - remote: flags.remote, - "db-url": flags.dbUrl, - linked: flags.linked, - local: flags.local, - "project-ref": flags.projectRef, file: flags.file, schema: flags.schema, }, diff --git a/apps/cli/src/commands/db/dump/dump.command.ts b/apps/cli/src/commands/db/dump/dump.command.ts index 64772ea1f1..7f507a78df 100644 --- a/apps/cli/src/commands/db/dump/dump.command.ts +++ b/apps/cli/src/commands/db/dump/dump.command.ts @@ -130,11 +130,6 @@ export const dbDumpCommand = Command.make("dump", config).pipe( "role-only": flags.roleOnly, "keep-comments": flags.keepComments, file: flags.file, - remote: flags.remote, - "db-url": flags.dbUrl, - linked: flags.linked, - local: flags.local, - "project-ref": flags.projectRef, // Never add `password` to `safeFlags`; it must stay `<redacted>` in telemetry. password: flags.password, schema: flags.schema, diff --git a/apps/cli/src/commands/db/lint/lint.command.ts b/apps/cli/src/commands/db/lint/lint.command.ts index bfda6bf94e..c64f9b52d4 100644 --- a/apps/cli/src/commands/db/lint/lint.command.ts +++ b/apps/cli/src/commands/db/lint/lint.command.ts @@ -53,17 +53,12 @@ export const dbLintCommand = Command.make("lint", config).pipe( dbLint(flags).pipe( withCommandTelemetry({ flags: { - remote: flags.remote, - "db-url": flags.dbUrl, - linked: flags.linked, - local: flags.local, - "project-ref": flags.projectRef, schema: flags.schema, level: flags.level, "fail-on": flags.failOn, }, - // level/fail-on are auto-detected as safe (Flag.Literals); --schema and - // --project-ref stay redacted (no established safelist). + // level/fail-on are auto-detected as safe (Flag.Literals); --schema stays redacted (no + // established safelist). config, aliases: { s: "schema" }, }), diff --git a/apps/cli/src/commands/db/pull/pull.command.ts b/apps/cli/src/commands/db/pull/pull.command.ts index 1f63c6fa4d..4a40b06b7c 100644 --- a/apps/cli/src/commands/db/pull/pull.command.ts +++ b/apps/cli/src/commands/db/pull/pull.command.ts @@ -88,11 +88,6 @@ const telemetryOptions = (flags: DbPullFlags) => ({ "diff-engine": flags.diffEngine, "strict-coverage": flags.strictCoverage, schema: flags.schema, - remote: flags.remote, - "db-url": flags.dbUrl, - linked: flags.linked, - local: flags.local, - "project-ref": flags.projectRef, // `password` is a credential — always reaches telemetry as `<redacted>`. password: flags.password, }, diff --git a/apps/cli/src/commands/db/push/push.command.ts b/apps/cli/src/commands/db/push/push.command.ts index 57dc87cf4f..81d91978ac 100644 --- a/apps/cli/src/commands/db/push/push.command.ts +++ b/apps/cli/src/commands/db/push/push.command.ts @@ -75,11 +75,6 @@ export const dbPushCommand = Command.make("push", config).pipe( "include-seed": flags.includeSeed, "skip-vault": flags.skipVault, "dry-run": flags.dryRun, - remote: flags.remote, - "db-url": flags.dbUrl, - linked: flags.linked, - local: flags.local, - "project-ref": flags.projectRef, // `password` is a credential — always reaches telemetry as `<redacted>`. password: flags.password, }, diff --git a/apps/cli/src/commands/db/query/query.command.ts b/apps/cli/src/commands/db/query/query.command.ts index f4c7fa4a47..022cd2983b 100644 --- a/apps/cli/src/commands/db/query/query.command.ts +++ b/apps/cli/src/commands/db/query/query.command.ts @@ -61,11 +61,6 @@ export const dbQueryCommand = Command.make("query", config).pipe( dbQuery(flags).pipe( withCommandTelemetry({ flags: { - remote: flags.remote, - "db-url": flags.dbUrl, - linked: flags.linked, - local: flags.local, - "project-ref": flags.projectRef, file: flags.file, "read-only": flags.readOnly, }, diff --git a/apps/cli/src/commands/db/reset/reset.command.ts b/apps/cli/src/commands/db/reset/reset.command.ts index 9354862a30..27cd073b01 100644 --- a/apps/cli/src/commands/db/reset/reset.command.ts +++ b/apps/cli/src/commands/db/reset/reset.command.ts @@ -56,11 +56,6 @@ export const dbResetCommand = Command.make("reset", config).pipe( dbReset(flags).pipe( withCommandTelemetry({ flags: { - remote: flags.remote, - "db-url": flags.dbUrl, - linked: flags.linked, - local: flags.local, - "project-ref": flags.projectRef, "no-seed": flags.noSeed, "sql-paths": flags.sqlPaths, version: flags.version, 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 31c369c63e..d51c7aea4f 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 @@ -122,11 +122,6 @@ export const dbSchemaDeclarativeGenerateCommand = Command.make("generate", confi "output-dir": merged.outputDir, reset: merged.reset, schema: merged.schema, - remote: merged.remote, - "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/functions/delete/delete.command.ts b/apps/cli/src/commands/functions/delete/delete.command.ts index ff4bbf0880..a7cec3e18a 100644 --- a/apps/cli/src/commands/functions/delete/delete.command.ts +++ b/apps/cli/src/commands/functions/delete/delete.command.ts @@ -21,9 +21,7 @@ const config = { export type FunctionsDeleteFlags = CliCommand.Command.Config.Infer<typeof config>; -// Exported so integration tests can drive the exact wiring `Command.withHandler` -// uses below, instead of re-asserting the generic instrumentation mechanism. -export const functionsDeleteHandler = (flags: FunctionsDeleteFlags) => +const functionsDeleteHandler = (flags: FunctionsDeleteFlags) => functionsDelete(flags).pipe( withCommandTelemetry({ flags, 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 c2d14b0747..806a9cdc85 100644 --- a/apps/cli/src/commands/functions/delete/delete.integration.test.ts +++ b/apps/cli/src/commands/functions/delete/delete.integration.test.ts @@ -1,10 +1,8 @@ import { describe, expect, it } from "@effect/vitest"; -import { BunCrypto } from "@effect/platform-bun"; -import { Cause, Effect, Exit, FileSystem, Layer, Option, Schema, Stdio } from "effect"; +import { Cause, Effect, Exit, FileSystem, Layer, Option, Schema } 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 { VALID_REF, @@ -18,13 +16,12 @@ import { } from "../../../../tests/helpers/command-mocks.ts"; import { describeHostedConsent } from "../../../../tests/helpers/hosted-consent.ts"; import { describeHostedTargetSelection } from "../../../../tests/helpers/hosted-target-selection.ts"; -import { mockContextualAnalytics, mockOutput, mockTty } from "../../../../tests/helpers/mocks.ts"; +import { mockOutput, mockTty } from "../../../../tests/helpers/mocks.ts"; import { assertPermissionDrift } from "../../../../tests/helpers/permission-drift.ts"; import { projectResponse, realLinkedProjectCache, } from "../../../../tests/helpers/permission-real-layers.ts"; -import { functionsDeleteHandler } from "./delete.command.ts"; import { functionsDelete } from "./delete.handler.ts"; const decodeJson = Schema.decodeEffect(Schema.fromJsonString(Schema.Unknown)); @@ -196,42 +193,6 @@ describe("functions delete", () => { }).pipe(Effect.provide(layer)); }, ); - - it.effect("does not redact --project-ref in cli_command_executed", () => { - const out = mockOutput({ format: "text" }); - const api = mockCommandPlatformApi({ response: { status: 200, body: null } }); - const analytics = mockContextualAnalytics(); - const layer = Layer.mergeAll( - buildTestRuntime({ - out, - api, - cliSettings: mockCommandSettings({ workdir: tempRoot.current }), - analytics, - }), - commandRuntimeLayer(["functions", "delete"]).pipe(Layer.provide(BunCrypto.layer)), - approvedHostedMutation, - Stdio.layerTest({ - args: Effect.succeed([ - "functions", - "delete", - "hello-world", - "--project-ref", - "abcdefghijklmnopqrst", - ]), - }), - ); - - return Effect.gen(function* () { - yield* functionsDeleteHandler({ - functionName: "hello-world", - remote: Option.none(), - projectRef: Option.some("abcdefghijklmnopqrst"), - }); - - const event = analytics.captured.find((c) => c.event === "cli_command_executed"); - expect(event?.properties.flags).toEqual({ "project-ref": "abcdefghijklmnopqrst" }); - }).pipe(Effect.provide(layer)); - }); }); describeHostedConsent("functions delete", { diff --git a/apps/cli/src/commands/functions/download/download.command.ts b/apps/cli/src/commands/functions/download/download.command.ts index 2736a620aa..694ec359c1 100644 --- a/apps/cli/src/commands/functions/download/download.command.ts +++ b/apps/cli/src/commands/functions/download/download.command.ts @@ -45,9 +45,7 @@ const config = { export type FunctionsDownloadFlags = CliCommand.Command.Config.Infer<typeof config>; -// Exported so integration tests can drive the exact wiring `Command.withHandler` -// uses below, instead of re-asserting the generic instrumentation mechanism. -export const functionsDownloadHandler = (flags: FunctionsDownloadFlags) => +const functionsDownloadHandler = (flags: FunctionsDownloadFlags) => functionsDownload(flags).pipe(withCommandTelemetry({ flags }), withJsonErrorHandling); const COMMAND_PATH = ["functions", "download"]; diff --git a/apps/cli/src/commands/functions/download/download.integration.test.ts b/apps/cli/src/commands/functions/download/download.integration.test.ts index c798c67ac1..8cce306351 100644 --- a/apps/cli/src/commands/functions/download/download.integration.test.ts +++ b/apps/cli/src/commands/functions/download/download.integration.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "@effect/vitest"; -import { BunCrypto, BunServices } from "@effect/platform-bun"; +import { BunServices } from "@effect/platform-bun"; import { dockerfileServiceImage } from "../../../shared/services/dockerfile-images.ts"; import { slimImagesEnabled } from "../../../shared/services/slim-images.ts"; import { @@ -21,7 +21,6 @@ import { import { ChildProcess, ChildProcessSpawner } from "effect/process"; import * as HttpClientResponse from "effect/http/HttpClientResponse"; -import { commandRuntimeLayer } from "../../../shared/runtime/command-runtime.layer.ts"; import { type ApiHandler, buildTestRuntime, @@ -36,7 +35,7 @@ import { } from "../../../../tests/helpers/command-mocks.ts"; import { cliConfigValuesTestLayer } from "../../../../tests/helpers/config-values-layer.ts"; import { describeHostedTargetSelection } from "../../../../tests/helpers/hosted-target-selection.ts"; -import { mockContextualAnalytics, mockOutput, mockTty } from "../../../../tests/helpers/mocks.ts"; +import { mockOutput, mockTty } from "../../../../tests/helpers/mocks.ts"; import { assertBlockDrift, assertPermissionDrift, @@ -48,7 +47,6 @@ import { import { mockChildProcessSpawner } from "../../../../tests/helpers/child-process-spawner.ts"; import { sanitizeProjectId } from "../../../shared/config/project-id.ts"; import { containerRuntimeNotFoundMessage } from "../../../command-internal/container-cli.ts"; -import { TargetPolicy } from "../../../command-internal/target/target-policy.ts"; import { downloadFunctions, functionsDownload as functionsDownloadCalls, @@ -57,7 +55,6 @@ import { functionsLocalConfigLoader } from "../../../command-internal/functions- import { CommandPlatformApi } from "../../../auth/command-platform-api.service.ts"; import { ConflictingFunctionDownloadFlagsError } from "../../../shared/functions/download.errors.ts"; import { FunctionsApiStatusError } from "../../../shared/functions/functions-api.errors.ts"; -import { functionsDownloadHandler } from "./download.command.ts"; import type { FunctionsDownloadFlags } from "./download.command.ts"; import { functionsDownload } from "./download.handler.ts"; @@ -1910,86 +1907,6 @@ describe("functions download", () => { }).pipe(Effect.provide(layer)); }); - it.effect("does not redact a --remote=<ref> in cli_command_executed", () => { - const out = mockOutput({ format: "text" }); - const api = mockCommandPlatformApi({ - handler: (request) => - request.url.endsWith("/body") - ? Effect.succeed(multipartResponse(request)) - : Effect.succeed(jsonResponse(request, 200, {})), - }); - const analytics = mockContextualAnalytics(); - const layer = Layer.mergeAll( - cliConfigValuesTestLayer, - buildTestRuntime({ - out, - api, - cliSettings: mockCommandSettings({ workdir: tempRoot.current }), - analytics, - }), - commandRuntimeLayer(["functions", "download"]).pipe(Layer.provide(BunCrypto.layer)), - Stdio.layerTest({ - args: Effect.succeed([ - "functions", - "download", - "hello-world", - "--remote=abcdefghijklmnopqrst", - ]), - }), - ); - - return Effect.gen(function* () { - yield* functionsDownloadHandler({ - ...baseFlags, - remote: Option.some({ _tag: "Ref", ref: "abcdefghijklmnopqrst" }), - }); - - const event = analytics.captured.find((c) => c.event === "cli_command_executed"); - expect(event?.properties.flags).toEqual({ remote: "abcdefghijklmnopqrst" }); - }).pipe(Effect.provide(layer)); - }); - - it.effect("does not redact --project-ref in cli_command_executed", () => { - const out = mockOutput({ format: "text" }); - const api = mockCommandPlatformApi({ - handler: (request) => - request.url.endsWith("/body") - ? Effect.succeed(multipartResponse(request)) - : Effect.succeed(jsonResponse(request, 200, {})), - }); - const analytics = mockContextualAnalytics(); - const layer = Layer.mergeAll( - cliConfigValuesTestLayer, - buildTestRuntime({ - out, - api, - cliSettings: mockCommandSettings({ workdir: tempRoot.current }), - analytics, - }), - commandRuntimeLayer(["functions", "download"]).pipe(Layer.provide(BunCrypto.layer)), - Stdio.layerTest({ - args: Effect.succeed([ - "functions", - "download", - "hello-world", - "--project-ref", - "abcdefghijklmnopqrst", - ]), - }), - Layer.succeed(TargetPolicy, { group: "hosted", consent: "none" }), - ); - - return Effect.gen(function* () { - yield* functionsDownloadHandler({ - ...baseFlags, - projectRef: Option.some("abcdefghijklmnopqrst"), - }); - - const event = analytics.captured.find((c) => c.event === "cli_command_executed"); - expect(event?.properties.flags).toEqual({ "project-ref": "abcdefghijklmnopqrst" }); - }).pipe(Effect.provide(layer)); - }); - it.effect("rejects the bundler mutex with the exact error text", () => { const out = mockOutput({ format: "text" }); const api = mockCommandPlatformApi(); diff --git a/apps/cli/src/commands/gen/types/types.command.ts b/apps/cli/src/commands/gen/types/types.command.ts index 6f1665e9db..7bb6b2df02 100644 --- a/apps/cli/src/commands/gen/types/types.command.ts +++ b/apps/cli/src/commands/gen/types/types.command.ts @@ -110,8 +110,7 @@ const remoteTarget: ReadonlyArray<EntryCondition> = [ { flag: "postgrest-v9-compat", is: false }, ]; -// Exported so integration tests can drive the exact wiring `Command.withHandler` uses below. -export const genTypesHandler = (flags: CliCommand.Command.Config.Infer<typeof commandConfig>) => +const genTypesHandler = (flags: CliCommand.Command.Config.Infer<typeof commandConfig>) => genTypes(flags).pipe( withCommandTelemetry({ flags, 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 8a41cb2ec2..312a89c84e 100644 --- a/apps/cli/src/commands/gen/types/types.integration.test.ts +++ b/apps/cli/src/commands/gen/types/types.integration.test.ts @@ -1,7 +1,7 @@ import { tmpdir } from "node:os"; import { describe, expect, it } from "@effect/vitest"; import { vi } from "vitest"; -import { BunCrypto, BunPath, BunServices } from "@effect/platform-bun"; +import { BunPath, BunServices } from "@effect/platform-bun"; import type { V1CreateLoginRoleOutput, V1GetABranchConfigOutput, @@ -33,7 +33,6 @@ import { resolveWorkdir } from "../../../config/command-settings.layer.ts"; import { DnsResolverFlag, OutputFlag, YesFlag } from "../../../command-internal/global-flags.ts"; import { rootCommandForFeatures } from "../../../cli/root.ts"; import { cliArgsLayer, prepareCliArgs } from "../../../shared/cli/run.ts"; -import { commandRuntimeLayer } from "../../../shared/runtime/command-runtime.layer.ts"; import { CommandPlatformApiFactory } from "../../../auth/command-platform-api-factory.service.ts"; import { CommandPlatformApi } from "../../../auth/command-platform-api.service.ts"; import { stripAnsi } from "../../../../tests/helpers/ansi.ts"; @@ -77,7 +76,7 @@ import type { ResolvedDbConfig, ResolvedDbConnection, } from "../../../command-internal/db-config.types.ts"; -import { genTypesHandler, type GenTypesFlags } from "./types.command.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"; @@ -3793,67 +3792,6 @@ describe("gen types permissions through the real layers", () => { }).pipe(Effect.provide(BunServices.layer)), ); - describe("telemetry wiring", () => { - const reportedFlags = ( - args: ReadonlyArray<string>, - overrides: Partial<GenTypesFlags>, - setup: { readonly projectRefFile?: string; readonly cacheRef?: string } = {}, - ) => - Effect.gen(function* () { - const analytics = mockContextualAnalytics(); - const ctx = yield* setupRecorded({ args, analytics, ...setup }); - yield* ctx.run( - genTypesHandler({ ...defaultFlags(overrides), language: Option.none() }).pipe( - Effect.provide( - commandRuntimeLayer(["gen", "types"]).pipe(Layer.provide(BunCrypto.layer)), - ), - ), - ); - return analytics.captured.find((c) => c.event === "cli_command_executed")?.properties.flags; - }).pipe(Effect.provide(BunServices.layer)); - - it.effect("reports a ref-shaped --project-id verbatim", () => - Effect.gen(function* () { - expect( - yield* reportedFlags(["gen", "types", "--project-id", VALID_REF], { - projectId: Option.some(VALID_REF), - }), - ).toEqual({ "project-id": VALID_REF }); - }), - ); - - it.effect("redacts a --project-id that names a branch", () => - Effect.gen(function* () { - const flags = yield* reportedFlags( - ["gen", "types", "--project-id", "feature-x"], - { projectId: Option.some("feature-x") }, - { projectRefFile: VALID_REF, cacheRef: BRANCH_PROJECT_REF }, - ); - expect(flags).toEqual({ "project-id": "<redacted>" }); - }), - ); - - it.effect("reports a ref-shaped --project-ref verbatim", () => - Effect.gen(function* () { - expect( - yield* reportedFlags(["gen", "types", "--project-ref", VALID_REF], { - projectRef: Option.some(VALID_REF), - }), - ).toEqual({ "project-ref": VALID_REF }); - }), - ); - - it.effect("reports --remote=<ref> through the central remote handling", () => - Effect.gen(function* () { - expect( - yield* reportedFlags(["gen", "types", `--remote=${VALID_REF}`], { - remote: Option.some({ _tag: "Ref", ref: VALID_REF } as const), - }), - ).toEqual({ remote: VALID_REF }); - }), - ); - }); - it.effect( "looks the branch --remote=<branch> names up in the linked project, then generates", () => 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 53fc6ce9f5..e8d2b22c50 100644 --- a/apps/cli/src/commands/inspect/db/inspect-db-command.ts +++ b/apps/cli/src/commands/inspect/db/inspect-db-command.ts @@ -24,24 +24,10 @@ export const withInspectDbTargetPolicy = withTargetPolicy({ consentExemptReason: "read-only", }); -/** - * Wraps an `inspect db` handler with the standard command-level pipeline: telemetry - * instrumentation with the three connection flags, and the machine-format JSON error envelope. - */ +/** Wraps an `inspect db` handler with telemetry and the machine-format JSON error envelope. */ export function inspectDbCommandHandler<E, R>( handler: (flags: InspectConnectionFlags) => Effect.Effect<void, E, R>, ) { return (flags: InspectConnectionFlags) => - handler(flags).pipe( - withCommandTelemetry({ - flags: { - remote: flags.remote, - "db-url": flags.dbUrl, - linked: flags.linked, - local: flags.local, - "project-ref": flags.projectRef, - }, - }), - withJsonErrorHandling, - ); + handler(flags).pipe(withCommandTelemetry(), withJsonErrorHandling); } diff --git a/apps/cli/src/commands/inspect/report/report.command.ts b/apps/cli/src/commands/inspect/report/report.command.ts index f7f100ebfe..c4cf2c5c00 100644 --- a/apps/cli/src/commands/inspect/report/report.command.ts +++ b/apps/cli/src/commands/inspect/report/report.command.ts @@ -38,11 +38,6 @@ export const inspectReportCommand = Command.make("report", config).pipe( inspectReport(flags).pipe( withCommandTelemetry({ flags: { - remote: flags.remote, - "db-url": flags.dbUrl, - linked: flags.linked, - local: flags.local, - "project-ref": flags.projectRef, "output-dir": flags.outputDir, }, }), diff --git a/apps/cli/src/commands/link/link.command.ts b/apps/cli/src/commands/link/link.command.ts index b2b5e5ec64..cc7b57e830 100644 --- a/apps/cli/src/commands/link/link.command.ts +++ b/apps/cli/src/commands/link/link.command.ts @@ -44,7 +44,7 @@ const config = { export type LinkFlags = CliCommand.Command.Config.Infer<typeof config>; -export const linkHandler = (flags: LinkFlags) => +const linkHandler = (flags: LinkFlags) => link(flags).pipe( withCommandTelemetry({ flags, diff --git a/apps/cli/src/commands/link/link.integration.test.ts b/apps/cli/src/commands/link/link.integration.test.ts index 43b3ef8bb3..8f70e39c84 100644 --- a/apps/cli/src/commands/link/link.integration.test.ts +++ b/apps/cli/src/commands/link/link.integration.test.ts @@ -1,25 +1,11 @@ import { describe, expect, it } from "@effect/vitest"; -import { BunCrypto } from "@effect/platform-bun"; import type { V1ListAllBranchesOutput } from "@supabase/api/effect"; -import { - Cause, - Effect, - Exit, - FileSystem, - Layer, - Option, - Path, - Predicate, - Schema, - Stdio, -} from "effect"; +import { Cause, Effect, Exit, FileSystem, Layer, Option, Path, Predicate, Schema } from "effect"; import * as HttpClient from "effect/http/HttpClient"; import type * as HttpClientError from "effect/http/HttpClientError"; import * as HttpClientRequestModule from "effect/http/HttpClientRequest"; import * as HttpClientResponse from "effect/http/HttpClientResponse"; -import { TargetPolicy } from "../../command-internal/target/target-policy.ts"; -import { commandRuntimeLayer } from "../../shared/runtime/command-runtime.layer.ts"; import { mockAnalytics, mockContextualAnalytics, @@ -43,7 +29,6 @@ import { } from "../../../tests/helpers/command-mocks.ts"; import { assertPermissionDrift } from "../../../tests/helpers/permission-drift.ts"; import { link } from "./link.handler.ts"; -import { linkHandler } from "./link.command.ts"; import type { LinkFlags } from "./link.command.ts"; const HEALTHY_PROJECT = { @@ -1465,76 +1450,6 @@ describe("link integration", () => { }, ); }); - - describe("telemetry: --project-ref redaction (CLI-2167)", () => { - it.effect("does not redact --project-ref in cli_command_executed when it is ref-shaped", () => { - const out = mockOutput({ format: "text" }); - const analytics = mockContextualAnalytics(); - const apiMock = mockCommandPlatformApiService({ - v1: { - getProject: () => Effect.succeed(HEALTHY_PROJECT), - getProjectApiKeys: () => Effect.succeed(SERVICE_KEYS), - getStorageConfig: () => Effect.succeed({ migrationVersion: "m" }), - getPoolerConfig: () => Effect.succeed(POOLER_PRIMARY), - }, - }); - const cliSettings = mockCommandSettings({ - workdir: tempRoot.current, - projectId: Option.none(), - }); - const layer = Layer.mergeAll( - buildTestRuntime({ - out, - api: { layer: apiMock.layer, httpClientLayer: tenantHttpLayer({}) }, - cliSettings, - analytics, - }), - cliConfigValuesTestLayer, - commandRuntimeLayer(["link"]).pipe(Layer.provide(BunCrypto.layer)), - Stdio.layerTest({ - args: Effect.succeed(["link", "--project-ref", VALID_REF]), - }), - Layer.succeed(TargetPolicy, { group: "hosted", consent: "none" }), - ); - return Effect.gen(function* () { - yield* linkHandler(flags({ projectRef: Option.some(VALID_REF) })); - const event = analytics.captured.find((c) => c.event === "cli_command_executed"); - expect(event?.properties.flags).toEqual({ "project-ref": VALID_REF }); - }).pipe(Effect.provide(layer)); - }); - - it.effect( - "redacts --project-ref in cli_command_executed when it is a branch name, not a ref", - () => { - const out = mockOutput({ format: "text" }); - const analytics = mockContextualAnalytics(); - const apiMock = mockCommandPlatformApiService({ v1: {} }); - const cliSettings = mockCommandSettings({ - workdir: tempRoot.current, - projectId: Option.none(), - }); - const layer = Layer.mergeAll( - buildTestRuntime({ - out, - api: { layer: apiMock.layer, httpClientLayer: tenantHttpLayer({}) }, - cliSettings, - analytics, - }), - cliConfigValuesTestLayer, - commandRuntimeLayer(["link"]).pipe(Layer.provide(BunCrypto.layer)), - Stdio.layerTest({ - args: Effect.succeed(["link", "--project-ref", "my-branch"]), - }), - Layer.succeed(TargetPolicy, { group: "hosted", consent: "none" }), - ); - return Effect.gen(function* () { - yield* Effect.exit(linkHandler(flags({ projectRef: Option.some("my-branch") }))); - const event = analytics.captured.find((c) => c.event === "cli_command_executed"); - expect(event?.properties.flags).toEqual({ "project-ref": "<redacted>" }); - }).pipe(Effect.provide(layer)); - }, - ); - }); }); // Records the Management API requests `link` makes, which the service-level mock above cannot. diff --git a/apps/cli/src/commands/migration/down/down.command.ts b/apps/cli/src/commands/migration/down/down.command.ts index 1236a204f6..3f9b3db95f 100644 --- a/apps/cli/src/commands/migration/down/down.command.ts +++ b/apps/cli/src/commands/migration/down/down.command.ts @@ -52,11 +52,6 @@ export const migrationDownCommand = Command.make("down", config).pipe( withCommandTelemetry({ flags: { last: flags.last, - remote: flags.remote, - "db-url": flags.dbUrl, - linked: flags.linked, - local: flags.local, - "project-ref": flags.projectRef, }, }), withJsonErrorHandling, diff --git a/apps/cli/src/commands/migration/fetch/fetch.command.ts b/apps/cli/src/commands/migration/fetch/fetch.command.ts index be2b3d6e5f..cfc25ac359 100644 --- a/apps/cli/src/commands/migration/fetch/fetch.command.ts +++ b/apps/cli/src/commands/migration/fetch/fetch.command.ts @@ -31,18 +31,7 @@ export const migrationFetchCommand = Command.make("fetch", config).pipe( ), ), Command.withHandler((flags) => - migrationFetch(flags).pipe( - withCommandTelemetry({ - flags: { - remote: flags.remote, - "db-url": flags.dbUrl, - linked: flags.linked, - local: flags.local, - "project-ref": flags.projectRef, - }, - }), - withJsonErrorHandling, - ), + migrationFetch(flags).pipe(withCommandTelemetry(), withJsonErrorHandling), ), Command.provide(migrationDbRuntimeLayer(["migration", "fetch"])), Command.provide(cliConfigValuesLayer), diff --git a/apps/cli/src/commands/migration/list/list.command.ts b/apps/cli/src/commands/migration/list/list.command.ts index bb3a7cacd6..1d603c5d36 100644 --- a/apps/cli/src/commands/migration/list/list.command.ts +++ b/apps/cli/src/commands/migration/list/list.command.ts @@ -45,11 +45,6 @@ export const migrationListCommand = Command.make("list", config).pipe( migrationList(flags).pipe( withCommandTelemetry({ flags: { - remote: flags.remote, - "db-url": flags.dbUrl, - linked: flags.linked, - local: flags.local, - "project-ref": flags.projectRef, // `password` is a credential — always reaches telemetry as `<redacted>`. password: flags.password, }, diff --git a/apps/cli/src/commands/migration/repair/repair.command.ts b/apps/cli/src/commands/migration/repair/repair.command.ts index fd3b650c77..863a834ad5 100644 --- a/apps/cli/src/commands/migration/repair/repair.command.ts +++ b/apps/cli/src/commands/migration/repair/repair.command.ts @@ -57,16 +57,10 @@ export const migrationRepairCommand = Command.make("repair", config).pipe( withCommandTelemetry({ flags: { status: flags.status, - remote: flags.remote, - "db-url": flags.dbUrl, - linked: flags.linked, - local: flags.local, - "project-ref": flags.projectRef, // `password` is a credential — always reaches telemetry as `<redacted>`. password: flags.password, }, - // --status is auto-detected as safe via config below; password and - // --project-ref stay redacted. + // --status is auto-detected as safe via config below; password stays redacted. config, aliases: { p: "password" }, }), diff --git a/apps/cli/src/commands/migration/squash/squash.command.ts b/apps/cli/src/commands/migration/squash/squash.command.ts index 89d229ddf8..e614ffc57e 100644 --- a/apps/cli/src/commands/migration/squash/squash.command.ts +++ b/apps/cli/src/commands/migration/squash/squash.command.ts @@ -48,13 +48,8 @@ export const migrationSquashCommand = Command.make("squash", config).pipe( withCommandTelemetry({ flags: { version: flags.version, - remote: flags.remote, - "db-url": flags.dbUrl, - linked: flags.linked, - local: flags.local, // `password` is a credential — always reaches telemetry as `<redacted>`. password: flags.password, - "project-ref": flags.projectRef, }, safeFlags: ["version"], aliases: { p: "password" }, diff --git a/apps/cli/src/commands/migration/up/up.command.ts b/apps/cli/src/commands/migration/up/up.command.ts index 02c3fdd67a..be4e1c000a 100644 --- a/apps/cli/src/commands/migration/up/up.command.ts +++ b/apps/cli/src/commands/migration/up/up.command.ts @@ -41,11 +41,6 @@ export const migrationUpCommand = Command.make("up", config).pipe( withCommandTelemetry({ flags: { "include-all": flags.includeAll, - remote: flags.remote, - "db-url": flags.dbUrl, - linked: flags.linked, - local: flags.local, - "project-ref": flags.projectRef, }, }), withJsonErrorHandling, diff --git a/apps/cli/src/commands/pull/pull.command.ts b/apps/cli/src/commands/pull/pull.command.ts index 6cbea04092..7c2b7708ab 100644 --- a/apps/cli/src/commands/pull/pull.command.ts +++ b/apps/cli/src/commands/pull/pull.command.ts @@ -58,8 +58,6 @@ export type PullFlags = CliCommand.Command.Config.Infer<typeof config>; // uses below, instead of re-implementing the `safeFlags`/telemetry wrapper inline. export const pullHandler = (flags: PullFlags) => pull(flags).pipe( - // `--project-ref` can carry a branch name, so its value is only safe to log verbatim when it's - // actually ref-shaped — a user-created branch name must never reach PostHog. // `--remote-label` is a free-form, user-chosen string and is never safe to log verbatim. withCommandTelemetry({ flags, diff --git a/apps/cli/src/commands/pull/pull.integration.test.ts b/apps/cli/src/commands/pull/pull.integration.test.ts index 321c160cb8..d7caaf90f5 100644 --- a/apps/cli/src/commands/pull/pull.integration.test.ts +++ b/apps/cli/src/commands/pull/pull.integration.test.ts @@ -2210,55 +2210,6 @@ describe("pull integration", () => { ); }); -// The raw `--remote`/`--project-ref` value in `cli_command_executed`'s `flags` map is read from the -// literal argv (`Stdio.args`), a separate source from the `PullFlags` object the handler runs -// on, so each case below overrides `Stdio.args` on top of `setup()`'s default. -describe("pull telemetry wiring", () => { - const withArgs = (base: ReturnType<typeof setup>["layer"], args: ReadonlyArray<string>) => - Layer.mergeAll(base, Stdio.layerTest({ args: Effect.succeed(["pull", ...args]) })); - - it.effect("logs a ref-shaped --remote verbatim in cli_command_executed", () => { - const { layer, analytics } = setup({ yes: true }); - return Effect.gen(function* () { - yield* writeConfig(); - yield* runPull(pullFlags({ remote: refRemote(VALID_REF), dryRun: true })); - const event = analytics.captured.find((c) => c.event === "cli_command_executed"); - expect(event?.properties["flags"]).toEqual({ remote: VALID_REF }); - }).pipe(Effect.provide(withArgs(layer, [`--remote=${VALID_REF}`]))); - }); - - it.effect("redacts a branch-name --remote in cli_command_executed", () => { - // A user-created branch name must never reach PostHog verbatim. - const { layer, analytics } = setup({ yes: true }); - return Effect.gen(function* () { - yield* writeConfig(); - yield* runPull(pullFlags({ remote: branchRemote("staging"), dryRun: true })); - const event = analytics.captured.find((c) => c.event === "cli_command_executed"); - expect(event?.properties["flags"]).toEqual({ remote: "<redacted>" }); - }).pipe(Effect.provide(withArgs(layer, ["--remote=staging"]))); - }); - - it.effect("logs a ref-shaped --project-ref verbatim in cli_command_executed", () => { - const { layer, analytics } = setup({ yes: true }); - return Effect.gen(function* () { - yield* writeConfig(); - yield* runPull(pullFlags({ projectRef: Option.some(VALID_REF), dryRun: true })); - const event = analytics.captured.find((c) => c.event === "cli_command_executed"); - expect(event?.properties["flags"]).toEqual({ "project-ref": VALID_REF }); - }).pipe(Effect.provide(withArgs(layer, ["--project-ref", VALID_REF]))); - }); - - it.effect("redacts a branch-name-shaped --project-ref in cli_command_executed", () => { - const { layer, analytics } = setup({ yes: true }); - return Effect.gen(function* () { - yield* writeConfig(); - yield* runPull(pullFlags({ projectRef: Option.some("staging"), dryRun: true })); - const event = analytics.captured.find((c) => c.event === "cli_command_executed"); - expect(event?.properties["flags"]).toEqual({ "project-ref": "<redacted>" }); - }).pipe(Effect.provide(withArgs(layer, ["--project-ref", "staging"]))); - }); -}); - // `streamJsonOutputLayer` nests the success payload under `data`, but its `fail` // implementation spreads `MachineErrorContext`'s payload at the top level alongside // `type`/`error`/`timestamp` — an asymmetry within the same layer. diff --git a/apps/cli/src/commands/seed/buckets/buckets.command.ts b/apps/cli/src/commands/seed/buckets/buckets.command.ts index 6b12a693c4..d3657c551b 100644 --- a/apps/cli/src/commands/seed/buckets/buckets.command.ts +++ b/apps/cli/src/commands/seed/buckets/buckets.command.ts @@ -25,16 +25,7 @@ export const bucketsCommand = Command.make("buckets").pipe( Command.withHandler(() => Effect.gen(function* () { const flags: BucketsFlags = yield* readSeedTargetFlags(); - return yield* seedBuckets(flags).pipe( - withCommandTelemetry({ - flags: { - remote: flags.remote, - linked: flags.linked, - local: flags.local, - "project-ref": flags.projectRef, - }, - }), - ); + return yield* seedBuckets(flags).pipe(withCommandTelemetry()); }).pipe(withJsonErrorHandling), ), Command.provide(Layer.mergeAll(storageGatewayRuntimeLayer(["seed", "buckets"]), stdinLayer)), diff --git a/apps/cli/src/commands/storage/cp/cp.command.ts b/apps/cli/src/commands/storage/cp/cp.command.ts index f8e04745ea..b78ebb93dd 100644 --- a/apps/cli/src/commands/storage/cp/cp.command.ts +++ b/apps/cli/src/commands/storage/cp/cp.command.ts @@ -97,10 +97,6 @@ export const storageCpCommand = Command.make("cp", config).pipe( cacheControl: flags.cacheControl, contentType: flags.contentType, jobs: flags.jobs, - remote: flags.remote, - linked: flags.linked, - local: flags.local, - "project-ref": flags.projectRef, }; return yield* storageCp(flags).pipe(withCommandTelemetry({ flags: telemetryFlags })); }).pipe(withJsonErrorHandling), diff --git a/apps/cli/src/commands/storage/ls/ls.command.ts b/apps/cli/src/commands/storage/ls/ls.command.ts index 9733651ee9..0668d57a4c 100644 --- a/apps/cli/src/commands/storage/ls/ls.command.ts +++ b/apps/cli/src/commands/storage/ls/ls.command.ts @@ -43,10 +43,6 @@ export const storageLsCommand = Command.make("ls", config).pipe( yield* requireExperimental; const telemetryFlags = { recursive: flags.recursive, - remote: flags.remote, - linked: flags.linked, - local: flags.local, - "project-ref": flags.projectRef, }; return yield* storageLs(flags).pipe(withCommandTelemetry({ flags: telemetryFlags })); }).pipe(withJsonErrorHandling), diff --git a/apps/cli/src/commands/storage/mv/mv.command.ts b/apps/cli/src/commands/storage/mv/mv.command.ts index 3fa5305810..d8253023dc 100644 --- a/apps/cli/src/commands/storage/mv/mv.command.ts +++ b/apps/cli/src/commands/storage/mv/mv.command.ts @@ -41,10 +41,6 @@ export const storageMvCommand = Command.make("mv", config).pipe( yield* requireExperimental; const telemetryFlags = { recursive: flags.recursive, - remote: flags.remote, - linked: flags.linked, - local: flags.local, - "project-ref": flags.projectRef, }; return yield* storageMv(flags).pipe(withCommandTelemetry({ flags: telemetryFlags })); }).pipe(withJsonErrorHandling), diff --git a/apps/cli/src/commands/storage/rm/rm.command.ts b/apps/cli/src/commands/storage/rm/rm.command.ts index 9732f239e4..ce48466392 100644 --- a/apps/cli/src/commands/storage/rm/rm.command.ts +++ b/apps/cli/src/commands/storage/rm/rm.command.ts @@ -45,10 +45,6 @@ export const storageRmCommand = Command.make("rm", config).pipe( yield* requireExperimental; const telemetryFlags = { recursive: flags.recursive, - remote: flags.remote, - linked: flags.linked, - local: flags.local, - "project-ref": flags.projectRef, }; return yield* storageRm({ files: flags.files.map(String), diff --git a/apps/cli/src/telemetry/command-telemetry.ts b/apps/cli/src/telemetry/command-telemetry.ts index f64cae6a39..0fe7749a37 100644 --- a/apps/cli/src/telemetry/command-telemetry.ts +++ b/apps/cli/src/telemetry/command-telemetry.ts @@ -1,4 +1,4 @@ -import { Cause, Clock, Effect, Exit, Option, Ref, Stdio } from "effect"; +import { Cause, Clock, Effect, Exit, Option, Ref, Result, Stdio } from "effect"; import { Param } from "effect/cli"; import { CommandRuntime, @@ -43,13 +43,14 @@ import { } from "../command-internal/output-formats.ts"; import { TelemetryOutputFormat } from "./telemetry-output-format.service.ts"; import { IdentityStitch } from "../command-internal/identity-stitch.ts"; +import { BOOLEAN_FLAG_VALUES } from "../shared/cli/agent-output.ts"; import { VALUE_CONSUMING_LONG_FLAGS, VALUE_CONSUMING_SHORT_FLAGS, } from "../shared/cli/value-consuming-flags.ts"; import { unwrapToSingleParam } from "../command-internal/param-introspection.ts"; import { StackBackendContext } from "../command-internal/stack-backend.ts"; -import { isRemoteValue, type RemoteValue } from "../command-internal/target/remote-value.ts"; +import { parseRemoteValue } from "../command-internal/target/remote-value.ts"; import { REMOTE_FLAG_NAME } from "../command-internal/target/remote-vocabulary.ts"; import { TargetPolicy } from "../command-internal/target/target-policy.ts"; import { PROJECT_REF_PATTERN } from "../config/project-ref.service.ts"; @@ -122,11 +123,21 @@ const REDACTED_VALUE = "<redacted>"; const MACHINE_OUTPUT_FLAG_FORMATS = new Set(["env", "json", "toml", "yaml", "csv"]); const OUTPUT_FLAG_FORMATS = new Set([...MACHINE_OUTPUT_FLAG_FORMATS, "pretty"]); -// A project ref is telemetry-safe; a branch name or database URL is user data. -const TARGET_ALIAS_FLAGS: ReadonlySet<string> = new Set(["project-ref", "project-id"]); +const TARGET_FLAG_NAMES: ReadonlySet<string> = new Set([ + REMOTE_FLAG_NAME, + "linked", + "local", + "db-url", + "project-ref", + "project-id", +]); + +const TRUE_FLAG_LITERALS: ReadonlySet<string> = new Set(["true", "yes", "on", "1", "y"]); -function remoteTelemetryValue(remote: RemoteValue): string | boolean { - switch (remote._tag) { +// A project ref is telemetry-safe; a branch name or database URL is user data. +function remoteTelemetryValue(raw: string): string | boolean { + const remote = Result.getOrUndefined(parseRemoteValue(raw)); + switch (remote?._tag) { case "Linked": return true; case "Ref": @@ -175,11 +186,18 @@ function resolveOutputFormatForTelemetry(args: ReadonlyArray<string>, outputForm return outputFormat; } -function extractChangedFlagNames( +interface FlagOccurrence { + readonly name: string; + readonly inline?: string | undefined; + // The token after a bare `--name`: the value of a value-consuming flag or an explicit boolean. + readonly next?: string | undefined; +} + +function scanFlags( args: ReadonlyArray<string>, aliases: Readonly<Record<string, string>> = {}, -): ReadonlyArray<string> { - const used = new Set<string>(); +): ReadonlyArray<FlagOccurrence> { + const occurrences: Array<FlagOccurrence> = []; let skipNext = false; for (let index = 0; index < args.length; index++) { @@ -203,7 +221,11 @@ function extractChangedFlagNames( const flagName = eqIdx === -1 ? raw : raw.slice(0, eqIdx); const isBare = eqIdx === -1; if (flagName.length === 0) continue; - used.add(flagName); + occurrences.push({ + name: flagName, + inline: isBare ? undefined : raw.slice(eqIdx + 1), + next: isBare ? args[index + 1] : undefined, + }); // A bare value-consuming flag's next token is its value (space-separated form) — // skip it so only the flag name itself is recorded, not the value that follows. if (isBare && VALUE_CONSUMING_LONG_FLAGS.has(flagName)) { @@ -219,7 +241,7 @@ function extractChangedFlagNames( const short = arg[1]; if (short === undefined) continue; const canonical = aliases[short]; - if (canonical !== undefined) used.add(canonical); + if (canonical !== undefined) occurrences.push({ name: canonical }); // A bare short flag (`-s`, length 2) takes its value from the next token; attached forms // (`-svalue`, `-s=value`) carry it inline, so no skip is needed there. // Known aliases use their canonical flag because shorthand arity can differ between commands. @@ -233,7 +255,50 @@ function extractChangedFlagNames( } } - return [...used].sort((left, right) => left.localeCompare(right)); + return occurrences; +} + +function extractChangedFlagNames( + args: ReadonlyArray<string>, + aliases: Readonly<Record<string, string>> = {}, +): ReadonlyArray<string> { + const names = new Set(scanFlags(args, aliases).map(({ name }) => name)); + return [...names].sort((left, right) => left.localeCompare(right)); +} + +function targetFlagValue( + name: string, + inline: string | undefined, + next: string | undefined, +): string | boolean { + switch (name) { + case REMOTE_FLAG_NAME: + return inline === undefined ? true : remoteTelemetryValue(inline); + case "linked": + case "local": { + const explicit = inline ?? (BOOLEAN_FLAG_VALUES.has(next ?? "") ? next : undefined); + return explicit === undefined || TRUE_FLAG_LITERALS.has(explicit); + } + case "db-url": + return REDACTED_VALUE; + default: { + const value = inline ?? next; + return value !== undefined && PROJECT_REF_PATTERN.test(value) ? value : REDACTED_VALUE; + } + } +} + +// A repeated flag takes its first occurrence, as the flag parser does. +function extractTargetFlagValues( + args: ReadonlyArray<string>, +): ReadonlyMap<string, string | boolean> { + const values = new Map<string, string | boolean>(); + for (const { name, inline, next } of scanFlags(args)) { + if (TARGET_FLAG_NAMES.has(name) && !values.has(name)) { + values.set(name, targetFlagValue(name, inline, next)); + } + } + return values; } /** @@ -311,8 +376,8 @@ function buildFlagsMap<Flags extends Record<string, unknown>>(options: { readonly safeFlagSet: ReadonlySet<string>; readonly changedFlagNames: ReadonlyArray<string>; readonly choiceFlagNames: ReadonlySet<string>; - // Whether the command acts on a project, so its `--project-ref`/`--project-id` alias is a target. - readonly hasTarget: boolean; + // The target flags' recorded values, read from argv for a command that acts on a project. + readonly targetFlagValues: ReadonlyMap<string, string | boolean>; }): Record<string, unknown> | undefined { const { flags, @@ -320,7 +385,7 @@ function buildFlagsMap<Flags extends Record<string, unknown>>(options: { safeFlagSet, changedFlagNames, choiceFlagNames, - hasTarget, + targetFlagValues, } = options; if (changedFlagNames.length === 0) return undefined; @@ -333,33 +398,25 @@ function buildFlagsMap<Flags extends Record<string, unknown>>(options: { } for (const cliName of changedFlagNames) { + const target = targetFlagValues.get(cliName); + if (target !== undefined) { + result[cliName] = target; + continue; + } + // A command's own flag always wins over a global/persistent flag sharing the same CLI name // (e.g. `db diff`'s local `--output` file-path flag shadows the global `--output` enum) — // only fall back to the live global-flag value when the handler never declared this name. const isFromHandler = handlerFlagsByCliName.has(cliName); const rawValue = isFromHandler ? handlerFlagsByCliName.get(cliName) : globalFlags[cliName]; - const normalized = normalizeFlagValue(rawValue); - const remote = - isFromHandler && cliName === REMOTE_FLAG_NAME && isRemoteValue(normalized) - ? normalized - : undefined; - const value = remote === undefined ? normalized : remoteTelemetryValue(remote); + const value = normalizeFlagValue(rawValue); // `safeFlagSet`/`choiceFlagNames` vouch for a value only when it actually came from this // command's own `flags` record — a value resolved from the global-flag fallback instead // consults `GLOBAL_CHOICE_FLAG_NAMES`, the global flag's own choice-ness, so it can't inherit // a different command's per-flag safe/choice annotation just because the CLI name matches. // Boolean values are always safe, regardless of source. - // A target alias can also hold a branch name, so only a ref-shaped value is logged. - const isRefShapedTargetAlias = - hasTarget && - isFromHandler && - TARGET_ALIAS_FLAGS.has(cliName) && - typeof value === "string" && - PROJECT_REF_PATTERN.test(value); const isSafe = - remote !== undefined || - isRefShapedTargetAlias || typeof value === "boolean" || (isFromHandler ? safeFlagSet.has(cliName) || choiceFlagNames.has(cliName) @@ -424,16 +481,17 @@ function withCommandAnalyticsImplementation<Flags extends Record<string, unknown }); const globalFlags = yield* globalFlagValues; const targetPolicy = yield* Effect.serviceOption(TargetPolicy); + const actsOnTarget = Option.exists( + targetPolicy, + ({ group }) => group === "hosted" || group === "selectable", + ); const flags = buildFlagsMap({ flags: options?.flags, globalFlagValues: globalFlags, safeFlagSet, changedFlagNames, choiceFlagNames, - hasTarget: Option.exists( - targetPolicy, - ({ group }) => group === "hosted" || group === "selectable", - ), + targetFlagValues: actsOnTarget ? extractTargetFlagValues(args) : new Map(), }); const analyticsContext = { command_run_id: commandRuntime.commandRunId, diff --git a/apps/cli/src/telemetry/command-telemetry.unit.test.ts b/apps/cli/src/telemetry/command-telemetry.unit.test.ts index d123760b05..833f907061 100644 --- a/apps/cli/src/telemetry/command-telemetry.unit.test.ts +++ b/apps/cli/src/telemetry/command-telemetry.unit.test.ts @@ -29,7 +29,6 @@ import { DbDumpRunError } from "../commands/db/dump/dump.errors.ts"; import { StdoutClosedError } from "../shared/output/errors.ts"; import { IdentityStitch } from "../command-internal/identity-stitch.ts"; import { RemovedSurfaceError } from "../command-internal/removed-command.ts"; -import type { RemoteValue } from "../command-internal/target/remote-value.ts"; import { TargetPolicy, type TargetGroup } from "../command-internal/target/target-policy.ts"; import { withCommandTelemetry } from "./command-telemetry.ts"; import { recordCommandTelemetry } from "./command-telemetry-attributes.ts"; @@ -606,107 +605,21 @@ describe("withCommandTelemetry", () => { ); }); - describe("--remote", () => { - const REF = "abcdefghijklmnopqrst"; - const DB_URL = "postgresql://postgres:secret@db.example.com:5432/postgres"; - - const recordedFlags = (remote: unknown, arg: string, safeFlags?: ReadonlyArray<string>) => { - const analytics = mockContextualAnalytics(); - return Effect.void.pipe( - withCommandTelemetry({ flags: { remote }, safeFlags }), - Effect.provide( - Layer.mergeAll( - analytics.layer, - mockProcessControl().layer, - mockOutput({ format: "text" }).layer, - Stdio.layerTest({ args: Effect.succeed(["functions", "deploy", arg]) }), - commandRuntimeLayer(["functions", "deploy"]).pipe(Layer.provide(BunCrypto.layer)), - ), - ), - Effect.map(() => analytics.captured[0]), - ); - }; - - it.live.each([ - { - name: "a bare --remote as true", - arg: "--remote", - remote: { _tag: "Linked" }, - expected: true, - }, - { - name: "a project ref verbatim", - arg: `--remote=${REF}`, - remote: { _tag: "Ref", ref: REF }, - expected: REF, - }, - { - name: "a branch name as redacted", - arg: "--remote=customer-acme", - remote: { _tag: "Branch", name: "customer-acme" }, - expected: "<redacted>", - }, - { - name: "a database URL as redacted", - arg: `--remote=${DB_URL}`, - remote: { _tag: "DbUrl", url: DB_URL }, - expected: "<redacted>", - }, - ] as const)("records $name when the command passes no safeFlags", ({ arg, remote, expected }) => - Effect.gen(function* () { - const event = yield* recordedFlags(Option.some(remote), arg); - expect(event?.properties.flags).toEqual({ remote: expected }); - const eventJson = yield* encodeJson(event); - expect(eventJson).not.toContain(DB_URL); - expect(eventJson).not.toContain("customer-acme"); - }), - ); - - it.live("keeps the command's own safeFlags alongside --remote", () => - Effect.gen(function* () { - const analytics = mockContextualAnalytics(); - const event = yield* Effect.void.pipe( - withCommandTelemetry({ - flags: { remote: Option.some<RemoteValue>({ _tag: "Linked" }), projectRef: REF }, - safeFlags: ["project-ref"], - }), - Effect.provide( - Layer.mergeAll( - analytics.layer, - mockProcessControl().layer, - mockOutput({ format: "text" }).layer, - Stdio.layerTest({ - args: Effect.succeed(["functions", "deploy", "--remote", "--project-ref", REF]), - }), - commandRuntimeLayer(["functions", "deploy"]).pipe(Layer.provide(BunCrypto.layer)), - ), - ), - Effect.map(() => analytics.captured[0]), - ); - expect(event?.properties.flags).toEqual({ remote: true, "project-ref": REF }); - }), - ); - - it.live("redacts a --remote flag that does not carry a RemoteValue", () => - Effect.gen(function* () { - const event = yield* recordedFlags("customer-acme", "--remote=customer-acme"); - expect(event?.properties.flags).toEqual({ remote: "<redacted>" }); - }), - ); - }); - - describe("target aliases", () => { + describe("target flags", () => { const REF = "abcdefghijklmnopqrst"; const DB_URL = "postgresql://postgres:secret@db.example.com:5432/postgres"; const recordedFlags = ( group: TargetGroup, - flags: Record<string, unknown>, args: ReadonlyArray<string>, + options?: { + readonly flags: Record<string, unknown>; + readonly safeFlags?: ReadonlyArray<string>; + }, ) => { const analytics = mockContextualAnalytics(); return Effect.void.pipe( - withCommandTelemetry({ flags }), + options === undefined ? withCommandTelemetry() : withCommandTelemetry(options), Effect.provide( Layer.mergeAll( analytics.layer, @@ -721,55 +634,122 @@ describe("withCommandTelemetry", () => { ); }; - it.live.each([ - { - name: "a ref-shaped --project-ref verbatim", - flags: { projectRef: Option.some(REF) }, - args: ["--project-ref", REF], - expected: { "project-ref": REF }, - }, - { - name: "a branch-shaped --project-ref as redacted", - flags: { projectRef: Option.some("customer-acme") }, - args: ["--project-ref", "customer-acme"], - expected: { "project-ref": "<redacted>" }, - }, - { - name: "a ref-shaped --project-id verbatim", - flags: { projectId: Option.some(REF) }, - args: ["--project-id", REF], - expected: { "project-id": REF }, - }, - { - name: "--db-url as redacted", - flags: { dbUrl: Option.some(DB_URL) }, - args: ["--db-url", DB_URL], - expected: { "db-url": "<redacted>" }, - }, - { - name: "--linked and --local as booleans", - flags: { linked: true, local: false }, - args: ["--linked", "--local=false"], - expected: { linked: true, local: false }, - }, - ] as const)("records $name on a hosted command", ({ flags, args, expected }) => - Effect.gen(function* () { - const recorded = yield* recordedFlags("hosted", flags, args); - expect(recorded).toEqual(expected); - const recordedJson = yield* encodeJson(recorded); - expect(recordedJson).not.toContain(DB_URL); - expect(recordedJson).not.toContain("customer-acme"); - }), - ); + describe.each(["hosted", "selectable"] as const)("on a %s command", (group) => { + it.live.each([ + { name: "a bare --remote as true", args: ["--remote"], expected: { remote: true } }, + { name: "--remote=true as true", args: ["--remote=true"], expected: { remote: true } }, + { + name: "a project ref in --remote verbatim", + args: [`--remote=${REF}`], + expected: { remote: REF }, + }, + { + name: "a branch name in --remote as redacted", + args: ["--remote=customer-acme"], + expected: { remote: "<redacted>" }, + }, + { + name: "a database URL in --remote as redacted", + args: [`--remote=${DB_URL}`], + expected: { remote: "<redacted>" }, + }, + { + name: "--linked and --local as booleans", + args: ["--linked", "--local=false"], + expected: { linked: true, local: false }, + }, + { + name: "a space-separated boolean literal after --linked", + args: ["--linked", "false", "--local"], + expected: { linked: false, local: true }, + }, + { + name: "a ref-shaped --project-ref verbatim", + args: ["--project-ref", REF], + expected: { "project-ref": REF }, + }, + { + name: "a ref-shaped --project-ref=<ref> verbatim", + args: [`--project-ref=${REF}`], + expected: { "project-ref": REF }, + }, + { + name: "a branch-shaped --project-ref as redacted", + args: ["--project-ref", "customer-acme"], + expected: { "project-ref": "<redacted>" }, + }, + { + name: "a ref-shaped --project-id verbatim", + args: ["--project-id", REF], + expected: { "project-id": REF }, + }, + { + name: "--db-url as redacted", + args: ["--db-url", DB_URL], + expected: { "db-url": "<redacted>" }, + }, + { + name: "--db-url=<url> as redacted", + args: [`--db-url=${DB_URL}`], + expected: { "db-url": "<redacted>" }, + }, + ] as const)("records $name from a leaf that lists no target flags", ({ args, expected }) => + Effect.gen(function* () { + const recorded = yield* recordedFlags(group, args); + expect(recorded).toEqual(expected); + const recordedJson = yield* encodeJson(recorded); + expect(recordedJson).not.toContain(DB_URL); + expect(recordedJson).not.toContain("customer-acme"); + }), + ); - it.live.each(["account", "local-only"] as const)( - "redacts a ref-shaped --project-ref on a %s command, where it is not a target", - (group) => + it.live("records the target flags next to the command's own flags", () => + Effect.gen(function* () { + const recorded = yield* recordedFlags( + group, + ["--remote", "--password", "s3cret", "--version", "20240101000000"], + { + flags: { password: Option.some("s3cret"), version: "20240101000000" }, + safeFlags: ["version"], + }, + ); + expect(recorded).toEqual({ + remote: true, + password: "<redacted>", + version: "20240101000000", + }); + }), + ); + + it.live("records a repeated target flag from its first occurrence", () => Effect.gen(function* () { - const recorded = yield* recordedFlags(group, { projectRef: Option.some(REF) }, [ + const recorded = yield* recordedFlags(group, [ + "--linked=false", + "--linked", + "--project-ref", + "customer-acme", "--project-ref", REF, ]); + expect(recorded).toEqual({ linked: false, "project-ref": "<redacted>" }); + }), + ); + + it.live("does not read an operand after -- as a target flag", () => + Effect.gen(function* () { + const recorded = yield* recordedFlags(group, ["--", "--linked", `--remote=${REF}`]); + expect(recorded).toBeUndefined(); + }), + ); + }); + + it.live.each(["account", "local-only"] as const)( + "redacts a ref-shaped --project-ref on a %s command, where it is not a target", + (group) => + Effect.gen(function* () { + const recorded = yield* recordedFlags(group, ["--project-ref", REF], { + flags: { projectRef: Option.some(REF) }, + }); expect(recorded).toEqual({ "project-ref": "<redacted>" }); }), ); From 9f11486eb81fff372b0ecf3128f4556f42c68de7 Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Tue, 20 Oct 2026 12:03:41 +0100 Subject: [PATCH 186/291] test(cli): make the db query --read-only live test reach the database The test sent `create table`, which the client-side keyword check refuses before any connection opens, so it could only ever see the keyword error. `select ... into` passes that check and is refused by Postgres inside the read-only transaction. --- apps/cli/src/commands/db/query/query.live.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 bcfdee3e4d..c8543f48f6 100644 --- a/apps/cli/src/commands/db/query/query.live.test.ts +++ b/apps/cli/src/commands/db/query/query.live.test.ts @@ -33,7 +33,7 @@ test("runs SQL against the remote database and returns its rows", ({ { signal }, )); -test("rejects a write against the remote database under --read-only", ({ +test("the database rejects a write that passes the keyword check under --read-only", ({ cliEffect, project, signal, @@ -44,7 +44,7 @@ test("rejects a write against the remote database under --read-only", ({ const result = yield* cliEffect([ "db", "query", - `create table ${table} (id int)`, + `select 1 as id into ${table}`, "--read-only", `--remote=${project.dbUrl}`, ]); From 74b0ec6d930e5086c12d1430aca4271de95fc23e Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Tue, 20 Oct 2026 12:06:13 +0100 Subject: [PATCH 187/291] chore(cli): trim two comments in the explicit db diff changes Shorten the mergedLinkedRef note and drop the JSDoc on the stack fake in the diff integration test. --- apps/cli/src/commands/db/diff/diff.handler.ts | 6 ++---- apps/cli/src/commands/db/diff/diff.integration.test.ts | 1 - 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/apps/cli/src/commands/db/diff/diff.handler.ts b/apps/cli/src/commands/db/diff/diff.handler.ts index 092579c406..bae3081da7 100644 --- a/apps/cli/src/commands/db/diff/diff.handler.ts +++ b/apps/cli/src/commands/db/diff/diff.handler.ts @@ -209,10 +209,8 @@ export const dbDiff = Effect.fn("db.diff")(function* (flags: DbDiffFlags) { message: `${selectorFlag} doesn't apply with --from/--to; use --from=${side} or --to=${side}.`, }); } - // `mergedLinkedRef` tracks the linked ref resolved so far so `loadCfg` and a later - // `migrations` catalog export merge the matching `[remotes.<ref>]` override. Undefined until - // a linked ref resolves, so a `local` or `migrations` ref resolved before any linked ref - // uses base. + // The linked ref resolved so far: later config reads and `migrations` catalog exports merge + // its `[remotes.<ref>]` override; endpoints resolved before any linked ref use the base. let mergedLinkedRef: string | undefined; // The first migrations endpoint resolved wins: the engine provisions a single migrations // shadow/catalog, so only refs resolved before that point should influence it. 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 0cb4386c00..c80a3e802e 100644 --- a/apps/cli/src/commands/db/diff/diff.integration.test.ts +++ b/apps/cli/src/commands/db/diff/diff.integration.test.ts @@ -240,7 +240,6 @@ function fakeShadowDbConnection(opts: { readonly neverConnectableShadow?: boolea const STACK_SQL_PORT = 54329; -/** A registered project stack whose healthy database serves SQL on `STACK_SQL_PORT`. */ const runningStackApi = (projectRoot: string) => { const unused = Effect.die("unused by db diff"); const database: DatabaseInstance = { From 5f0b97315afd8e74c61513a38c1b701cff83fb09 Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Tue, 20 Oct 2026 12:07:11 +0100 Subject: [PATCH 188/291] fix(cli): let the last --yes occurrence decide The flag parser keeps the first of a repeated --yes, and the argv scan denied on any --yes=false, so --yes=false --yes denied. The scan now resolves the last --yes or --yes=<bool> occurrence from the raw argv, up to the first -- and skipping tokens consumed as another flag's value, and that value replaces the parsed flag. --yes=false --yes confirms, --yes --yes=false denies, and no occurrence still falls back to SUPABASE_YES. The scan also reads the parser's other false spellings (no, off, n) and a boolean literal after a bare --yes, which it missed, so --yes=no and --yes false now deny even when SUPABASE_YES is set. --- apps/cli/src/command-internal/global-flags.ts | 53 +++++++---- .../global-flags.unit.test.ts | 89 +++++++++++++++++++ 2 files changed, 123 insertions(+), 19 deletions(-) diff --git a/apps/cli/src/command-internal/global-flags.ts b/apps/cli/src/command-internal/global-flags.ts index 45b1681713..43dd0ac1bc 100644 --- a/apps/cli/src/command-internal/global-flags.ts +++ b/apps/cli/src/command-internal/global-flags.ts @@ -177,6 +177,9 @@ export const globalFlagValues = Effect.gen(function* () { const FALSE_VALUES = new Set(["0", "f", "F", "false", "FALSE", "False"]); +// The false spellings of the CLI's boolean flag parser, which also reads one after a bare flag. +const BOOLEAN_FALSE_VALUES = new Set(["false", "no", "off", "0", "n"]); + /** * Raw argv truncated at the first bare `--` operand terminator. This CLI's own lexer stops parsing * flags there too, so everything after is a positional operand, e.g. a migration name literally @@ -217,27 +220,39 @@ const nonValueConsumedTokens = (args: ReadonlyArray<string>): ReadonlyArray<stri }; /** - * True when the raw argv contains an explicit `--yes=<false>`. A plain boolean flag can't - * distinguish an explicit `--yes=false` from the omitted default, so this scans the raw argv up to - * the first `--` operand terminator (see {@link argsBeforeOperandTerminator}), skipping tokens - * consumed as another flag's value (see {@link nonValueConsumedTokens}). + * Resolves the raw argv's *last* `--yes`/`--yes=<bool>` occurrence to a boolean, or `undefined` if + * it never appears before the first `--` operand terminator. The flag parser keeps the *first* of + * a repeated `--yes` and can't tell an explicit `--yes=false` from the omitted default, so a caller + * must reread raw argv rather than trust the parsed flag. A bare `--yes` reads a boolean literal + * that follows it (`--yes false`), as the parser does. Tokens consumed as another flag's value are + * skipped (see {@link nonValueConsumedTokens}). */ -const yesFlagExplicitlyFalse = (args: ReadonlyArray<string>): boolean => - nonValueConsumedTokens(argsBeforeOperandTerminator(args)).some( - (arg) => arg.startsWith("--yes=") && FALSE_VALUES.has(arg.slice("--yes=".length)), - ); +const yesFlagFromArgs = (args: ReadonlyArray<string>): boolean | undefined => { + const tokens = nonValueConsumedTokens(argsBeforeOperandTerminator(args)); + let result: boolean | undefined; + for (const [index, arg] of tokens.entries()) { + if (arg === "--yes") { + result = !BOOLEAN_FALSE_VALUES.has(tokens[index + 1] ?? ""); + } else if (arg.startsWith("--yes=")) { + const value = arg.slice("--yes=".length); + result = !(FALSE_VALUES.has(value) || BOOLEAN_FALSE_VALUES.has(value)); + } + } + return result; +}; /** `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. */ +/** The origin of a granted `--yes`, or `None`; the last `--yes`/`--yes=<bool>` decides, and an explicit false wins over the env. */ export const resolveYesSource = Effect.gen(function* () { const flag = yield* YesFlag; const cliArgs = yield* CliArgs; - if (yesFlagExplicitlyFalse(cliArgs.args)) { + const explicit = yesFlagFromArgs(cliArgs.args); + if (explicit === false) { return Option.none<YesSource>(); } - if (flag) return Option.some<YesSource>({ via: "flag" }); + if (explicit ?? flag) return Option.some<YesSource>({ via: "flag" }); const raw = yield* envValue("SUPABASE_YES"); return raw !== undefined && parseEnvBool(raw) ? Option.some<YesSource>({ via: "env", raw }) @@ -246,8 +261,8 @@ export const resolveYesSource = Effect.gen(function* () { /** * `--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. + * explicit `--yes` (including `--yes=false`, and the last of a repeated flag) 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); @@ -262,8 +277,9 @@ export const resolveYesWithProjectEnv = (projectEnv: Record<string, string>) => Effect.gen(function* () { const flag = yield* YesFlag; const cliArgs = yield* CliArgs; - if (yesFlagExplicitlyFalse(cliArgs.args)) { - return false; + const explicit = yesFlagFromArgs(cliArgs.args); + if (explicit !== undefined) { + return explicit; } return flag || (yield* supabaseEnvBoolWithProjectFallback("SUPABASE_YES", projectEnv)); }); @@ -319,10 +335,9 @@ export const resolveExperimentalWithProjectEnv = (projectEnv: Record<string, str /** * True when the LAST `--debug`/`--debug=<value>` occurrence in argv resolves to false. Tracks the - * last occurrence (unlike {@link yesFlagExplicitlyFalse}'s "any occurrence" check), since a - * trailing bare `--debug` after an earlier `--debug=false` must win. Scans only the flag-parsing - * region (see {@link argsBeforeOperandTerminator}) and skips tokens consumed as another flag's - * value (see {@link nonValueConsumedTokens}). + * last occurrence, since a trailing bare `--debug` after an earlier `--debug=false` must win. + * Scans only the flag-parsing region (see {@link argsBeforeOperandTerminator}) and skips tokens + * consumed as another flag's value (see {@link nonValueConsumedTokens}). */ const debugFlagExplicitlyFalse = (args: ReadonlyArray<string>): boolean => { let lastExplicitlyFalse = false; diff --git a/apps/cli/src/command-internal/global-flags.unit.test.ts b/apps/cli/src/command-internal/global-flags.unit.test.ts index f6153d82a2..9ce75ab49d 100644 --- a/apps/cli/src/command-internal/global-flags.unit.test.ts +++ b/apps/cli/src/command-internal/global-flags.unit.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "@effect/vitest"; import { Effect, Layer, Option } from "effect"; +import { withConfigEnv, withEmptyConfigEnv } from "../../tests/helpers/command-mocks.ts"; import { CliArgs } from "../shared/cli/cli-args.service.ts"; import { GLOBAL_FLAGS, @@ -15,6 +16,8 @@ import { YesFlag, globalFlagValues, resolveDebugWithProjectEnv, + resolveYesSource, + resolveYesWithProjectEnv, } from "./global-flags.ts"; describe("globalFlagValues", () => { @@ -120,3 +123,89 @@ describe("resolveDebugWithProjectEnv", () => { }, ); }); + +// `parsed` is what the flag parser yields, which keeps the first occurrence of a repeated flag. +describe("resolveYesSource", () => { + const resolve = ( + args: ReadonlyArray<string>, + { parsed, shellYes }: { readonly parsed: boolean; readonly shellYes?: string }, + ) => + withEmptyConfigEnv( + withConfigEnv(shellYes === undefined ? {} : { SUPABASE_YES: shellYes }, resolveYesSource), + ).pipe( + Effect.provide( + Layer.mergeAll(Layer.succeed(YesFlag, parsed), Layer.succeed(CliArgs, { args })), + ), + ); + + it.effect.each([ + { args: ["--yes=false", "--yes"], parsed: false }, + { args: ["--yes=false", "--yes=true"], parsed: false }, + { args: ["--yes", "false", "--yes"], parsed: false }, + { args: ["--yes", "--", "--yes=false"], parsed: true }, + { args: ["--yes", "--password", "--yes=false"], parsed: true }, + ])("confirms $args because the last --yes is true", ({ args, parsed }) => + Effect.gen(function* () { + expect(yield* resolve(args, { parsed })).toEqual(Option.some({ via: "flag" })); + }), + ); + + it.effect.each([ + { args: ["--yes", "--yes=false"], parsed: true }, + { args: ["--yes=true", "--yes=false"], parsed: true }, + { args: ["--yes=false"], parsed: false }, + { args: ["--yes", "false"], parsed: false }, + { args: ["--yes=no"], parsed: false }, + { args: ["--yes", "off"], parsed: false }, + ])( + "denies $args because the last --yes is false, even with SUPABASE_YES set", + ({ args, parsed }) => + Effect.gen(function* () { + expect(yield* resolve(args, { parsed, shellYes: "1" })).toEqual(Option.none()); + }), + ); + + it.effect("falls back to a shell SUPABASE_YES when --yes never appears", () => + Effect.gen(function* () { + expect(yield* resolve([], { parsed: false, shellYes: "1" })).toEqual( + Option.some({ via: "env", raw: "1" }), + ); + expect(yield* resolve([], { parsed: false })).toEqual(Option.none()); + }), + ); +}); + +describe("resolveYesWithProjectEnv", () => { + const resolve = ( + args: ReadonlyArray<string>, + { + parsed, + projectEnv, + }: { readonly parsed: boolean; readonly projectEnv: Record<string, string> }, + ) => + withEmptyConfigEnv(resolveYesWithProjectEnv(projectEnv)).pipe( + Effect.provide( + Layer.mergeAll(Layer.succeed(YesFlag, parsed), Layer.succeed(CliArgs, { args })), + ), + ); + + it.effect("lets the last --yes decide, in either order", () => + Effect.gen(function* () { + expect(yield* resolve(["--yes=false", "--yes"], { parsed: false, projectEnv: {} })).toBe( + true, + ); + expect( + yield* resolve(["--yes", "--yes=false"], { + parsed: true, + projectEnv: { SUPABASE_YES: "1" }, + }), + ).toBe(false); + }), + ); + + it.effect("falls back to the project env when --yes never appears", () => + Effect.gen(function* () { + expect(yield* resolve([], { parsed: false, projectEnv: { SUPABASE_YES: "1" } })).toBe(true); + }), + ); +}); From e999d8505fa01a4c3ca3697851c7dd5382605a44 Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Tue, 20 Oct 2026 12:14:09 +0100 Subject: [PATCH 189/291] docs(cli): centralize target and consent docs in command-target.md Each hosted, selectable and account SIDE_EFFECTS.md now opens with one Target line that links to the shared page and keeps only what is specific to the command. The selector, ambiguity, deprecated-alias, banner, invalid-selector and consent prose moves into command-target.md once. --- apps/cli/docs/command-target.md | 112 +++++++++++++++++- .../src/commands/backups/list/SIDE_EFFECTS.md | 8 +- .../commands/backups/restore/SIDE_EFFECTS.md | 9 +- .../src/commands/bootstrap/SIDE_EFFECTS.md | 13 +- .../commands/branches/create/SIDE_EFFECTS.md | 8 +- .../commands/branches/delete/SIDE_EFFECTS.md | 8 +- .../commands/branches/disable/SIDE_EFFECTS.md | 8 +- .../src/commands/branches/get/SIDE_EFFECTS.md | 6 +- .../commands/branches/list/SIDE_EFFECTS.md | 7 +- .../commands/branches/pause/SIDE_EFFECTS.md | 8 +- .../commands/branches/unpause/SIDE_EFFECTS.md | 8 +- .../commands/branches/update/SIDE_EFFECTS.md | 8 +- .../src/commands/config/diff/SIDE_EFFECTS.md | 5 +- .../src/commands/config/pull/SIDE_EFFECTS.md | 4 +- .../src/commands/config/push/SIDE_EFFECTS.md | 14 +-- .../src/commands/db/advisors/SIDE_EFFECTS.md | 42 ++----- apps/cli/src/commands/db/diff/SIDE_EFFECTS.md | 41 ++----- apps/cli/src/commands/db/dump/SIDE_EFFECTS.md | 32 +---- apps/cli/src/commands/db/lint/SIDE_EFFECTS.md | 40 ++----- apps/cli/src/commands/db/pull/SIDE_EFFECTS.md | 43 ++----- apps/cli/src/commands/db/push/SIDE_EFFECTS.md | 33 +----- .../cli/src/commands/db/query/SIDE_EFFECTS.md | 43 ++----- .../cli/src/commands/db/reset/SIDE_EFFECTS.md | 35 ++---- .../declarative/generate/SIDE_EFFECTS.md | 33 ++---- apps/cli/src/commands/db/test/SIDE_EFFECTS.md | 9 +- apps/cli/src/commands/domains/SIDE_EFFECTS.md | 9 +- .../src/commands/encryption/SIDE_EFFECTS.md | 9 +- .../compute/delete/SIDE_EFFECTS.md | 18 +-- .../experimental/compute/list/SIDE_EFFECTS.md | 8 +- .../experimental/compute/logs/SIDE_EFFECTS.md | 8 +- .../experimental/compute/push/SIDE_EFFECTS.md | 15 +-- .../compute/status/SIDE_EFFECTS.md | 8 +- .../src/commands/feedback/add/SIDE_EFFECTS.md | 2 + .../commands/feedback/delete/SIDE_EFFECTS.md | 10 +- .../commands/functions/delete/SIDE_EFFECTS.md | 7 +- .../commands/functions/deploy/SIDE_EFFECTS.md | 19 +-- .../functions/download/SIDE_EFFECTS.md | 7 +- .../commands/functions/list/SIDE_EFFECTS.md | 7 +- .../src/commands/gen/types/SIDE_EFFECTS.md | 49 ++------ .../src/commands/inspect/db/SIDE_EFFECTS.md | 36 ++---- .../commands/inspect/report/SIDE_EFFECTS.md | 43 ++----- apps/cli/src/commands/link/SIDE_EFFECTS.md | 2 + apps/cli/src/commands/login/SIDE_EFFECTS.md | 2 + apps/cli/src/commands/logout/SIDE_EFFECTS.md | 2 + .../commands/migration/down/SIDE_EFFECTS.md | 39 ++---- .../commands/migration/fetch/SIDE_EFFECTS.md | 35 ++---- .../commands/migration/list/SIDE_EFFECTS.md | 44 ++----- .../commands/migration/repair/SIDE_EFFECTS.md | 55 ++------- .../commands/migration/squash/SIDE_EFFECTS.md | 51 ++------ .../src/commands/migration/up/SIDE_EFFECTS.md | 40 ++----- .../commands/network-bans/get/SIDE_EFFECTS.md | 30 ++--- .../network-bans/remove/SIDE_EFFECTS.md | 16 +-- .../network-restrictions/get/SIDE_EFFECTS.md | 28 ++--- .../update/SIDE_EFFECTS.md | 16 +-- .../commands/notebooks/pull/SIDE_EFFECTS.md | 43 +++---- .../commands/notebooks/push/SIDE_EFFECTS.md | 46 +++---- .../src/commands/orgs/create/SIDE_EFFECTS.md | 8 +- .../src/commands/orgs/list/SIDE_EFFECTS.md | 2 + .../postgres-config/delete/SIDE_EFFECTS.md | 16 +-- .../postgres-config/get/SIDE_EFFECTS.md | 30 ++--- .../postgres-config/update/SIDE_EFFECTS.md | 16 +-- .../cli/src/commands/projects/SIDE_EFFECTS.md | 13 +- .../projects/api-keys/SIDE_EFFECTS.md | 28 ++--- .../commands/projects/create/SIDE_EFFECTS.md | 8 +- .../commands/projects/delete/SIDE_EFFECTS.md | 10 +- .../commands/projects/list/SIDE_EFFECTS.md | 2 + apps/cli/src/commands/pull/SIDE_EFFECTS.md | 15 +-- .../src/commands/secrets/list/SIDE_EFFECTS.md | 34 ++---- .../src/commands/secrets/set/SIDE_EFFECTS.md | 49 +++----- .../commands/secrets/unset/SIDE_EFFECTS.md | 15 +-- .../src/commands/seed/buckets/SIDE_EFFECTS.md | 45 ++----- .../snippets/download/SIDE_EFFECTS.md | 29 ++--- .../commands/snippets/list/SIDE_EFFECTS.md | 28 ++--- .../ssl-enforcement/get/SIDE_EFFECTS.md | 28 ++--- .../ssl-enforcement/update/SIDE_EFFECTS.md | 38 +++--- apps/cli/src/commands/sso/add/SIDE_EFFECTS.md | 16 +-- .../cli/src/commands/sso/info/SIDE_EFFECTS.md | 24 ++-- .../cli/src/commands/sso/list/SIDE_EFFECTS.md | 26 ++-- .../src/commands/sso/remove/SIDE_EFFECTS.md | 35 ++---- .../cli/src/commands/sso/show/SIDE_EFFECTS.md | 31 ++--- .../src/commands/sso/update/SIDE_EFFECTS.md | 17 +-- .../src/commands/storage/cp/SIDE_EFFECTS.md | 57 +++------ .../src/commands/storage/ls/SIDE_EFFECTS.md | 51 ++------ .../src/commands/storage/mv/SIDE_EFFECTS.md | 57 +++------ .../src/commands/storage/rm/SIDE_EFFECTS.md | 41 ++----- apps/cli/src/commands/test/db/SIDE_EFFECTS.md | 48 +++----- .../activate/SIDE_EFFECTS.md | 36 ++---- .../check-availability/SIDE_EFFECTS.md | 31 ++--- .../vanity-subdomains/delete/SIDE_EFFECTS.md | 34 ++---- .../vanity-subdomains/get/SIDE_EFFECTS.md | 29 ++--- apps/cli/src/commands/whoami/SIDE_EFFECTS.md | 2 + 91 files changed, 707 insertions(+), 1523 deletions(-) diff --git a/apps/cli/docs/command-target.md b/apps/cli/docs/command-target.md index 863ce00999..9c59929f64 100644 --- a/apps/cli/docs/command-target.md +++ b/apps/cli/docs/command-target.md @@ -2,11 +2,107 @@ 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. -Database commands run against the local stack unless `--remote` is passed: bare `--remote` is the linked project, and `--remote=<ref|branch|url>` is another project, a branch of the linked project, or a database URL. Hosted-only commands, which have no local side, use the linked project by default and take `--remote=<ref|branch>` for another. Every command resolves a branch name or UUID the same way `supabase link` does, and a branch UUID also works without a linked project. `--linked`, `--project-ref`, `--db-url` and `--local` still work in v3 but are deprecated and removed in v4; use `--remote`. Their warning shows the command path, its operand placeholders and the `--remote` replacement, never your operands or other flags. +This page is the shared reference for how a command's target is chosen, which errors the selection raises, the deprecated selector flags, how a hosted write is confirmed, and what the banner and the JSON `target` carry. [ADR 0033](../../../docs/adr/0033-target-selection-and-hosted-mutation-consent.md) records why. The `SIDE_EFFECTS.md` of every `hosted`, `selectable` and `account` command opens with a `Target:` line that links here and lists only what is specific to that command: its own calls, files, exit codes and prompt, and any selector rule that differs from this page. A `local-only` command has no `Target:` line. + +## Groups + +The `Target:` line names the command's group and its consent default: `Target: <group>, consent default <No|Yes>`, or `Target: <group>, no consent` when the command never asks for hosted-mutation consent. A document that covers several subcommands gives each one's default. + +| Group | Acts on | With no `--remote` | Selector flags | +| ------------ | ------------------------------- | ------------------ | ---------------------------------------------------------------------------- | +| `selectable` | a database or the Storage API | the local stack | `--remote`; deprecated `--local`, `--linked`, `--project-ref` and `--db-url` | +| `hosted` | a hosted project | the linked project | `--remote=<ref\|branch>`; deprecated `--project-ref` | +| `account` | the account or an organisation | no project | none | +| `local-only` | the local stack or the checkout | the local stack | none | + +The consent default is the answer a confirmation starts on. It is `No` for anything that replaces or deletes live state and for arbitrary SQL, migration, seed and storage writes, and `Yes` for create-only operations (`projects create`, `orgs create`, `branches create`, `domains create` and `bootstrap`). `no consent` means the command never asks for hosted-mutation consent because it only reads or writes local files. It can still ask a local question of its own, such as `config pull` before it rewrites `config.toml` or `migration fetch` before it replaces local migrations. + +## Choosing a target + +| You pass | Target | +| ------------------- | ------------------------------------------------------------------------------- | +| nothing | The local stack on a `selectable` command; the linked project on a `hosted` one | +| `--remote` | The linked project | +| `--remote=<ref>` | That project: 20 lowercase letters, never looked up as a branch | +| `--remote=<url>` | That database: a `postgres://` or `postgresql://` URL | +| `--remote=<branch>` | A branch of the linked project, by name or UUID | + +- **The linked project** is `SUPABASE_PROJECT_ID`, else `supabase/.temp/project-ref`. With neither, an interactive terminal (stdin and stdout are TTYs and the output is text) gets the project picker (`GET /v1/projects`), and any other run fails with `ProjectRefNotLinkedError`. A malformed ref from either source fails with `InvalidProjectRefError`. `SUPABASE_PROJECT_ID` alone never makes a `selectable` command remote: it is a ref source once `--remote` is passed, and enough for the `default; pass --remote` hint. The CLI never falls back between local and remote. +- **A branch** resolves with the lookup `supabase link` uses (`GET /v1/projects/{parent}/branches`, where `{parent}` is `SUPABASE_PROJECT_ID`, else the linked project), and the command acts on that branch's own project. With no linked project a UUID is looked up with `GET /v1/branches/{id}`. +- **Hosted-only commands** take `--remote=<ref|branch>` for another project, and a bare `--remote` changes nothing for them. `link` takes its target as an argument and has no `--remote`. `branches *` commands act on a parent project, so their `--remote` takes a ref, never a branch name. +- **A database URL** applies only where the remote side is Postgres. `storage`, `seed buckets` and hosted commands need a project and reject one with an error that names the command and sends no request. A value with `://` that is not a `postgres://` or `postgresql://` URL, and a keyword connection string (`host=… user=…`), is rejected on every command and is never looked up as a branch. +- **`--remote=true|yes|on|1`** means a bare `--remote`. `--no-remote` and `--remote=false|no|off|0` fail with `--remote doesn't take false. Leave it out to <fallback>.`, and an empty `--remote=` with `--remote= has an empty value. Pass --remote for the linked project or --remote=<values>; leave it out to <fallback>.` `<fallback>` is `target the local database`, `target the local Storage API` or, on a hosted command, `use the linked project`; `<values>` is `<ref|branch|url>` where the command takes a database URL, `<ref|branch>` elsewhere and `<ref>` on `branches *`. Neither error prints the command's help. + +## `--remote <word>` + +`--remote=<word>` always parses as a value. A bare `--remote` followed by a word is rewritten to `--remote=<word>` when the command takes no operands (a hidden one does not count), when the word is a project ref or a database URL, or when the command already has more words than operands. + +The one ambiguous shape is a bare `--remote` directly followed by a word the command took as an operand. When a linked project is known, the CLI checks that word against its branches before anything runs. A word that exactly matches a branch, is within a small edit distance of one, or can't be checked because the branch listing failed stops the run with an error that names the cause (an HTTP status, or a missing or invalid access token) and offers both readings without echoing the word: `--remote=<branch>` for the branch, or the command's operands followed by `--remote` for the linked project. Any other word runs as the operand. + +## Selector errors + +These fail with exit 1 before the command does any work: + +| Condition | Error | +| --------------------------------------------------------------------------------------------------------- | --------------------------------------------------------- | +| more than one of `--remote`, `--linked`, `--db-url` and `--local`, or `--project-ref` with `--project-id` | `TargetSelectorConflictError` | +| `--project-ref` or `--project-id` with `--local`, `--db-url` or `--remote=<value>` | `TargetProjectRefConflictError` | +| an empty `--db-url`, `--project-ref` or `--project-id` | `TargetEmptyValueError` | +| `--project-ref` or `--project-id` given a URL or connection string | `TargetInvalidValueError` | +| `--remote=<url>` on a command with no database connection | `TargetNeedsProjectError` | +| a bare `--remote` followed by a word that may name a branch | `TargetRemoteAmbiguousError` | +| `--password` with `--remote=<url>` or the local default | `TargetPasswordConflictError` | +| `--remote=<branch>` naming no branch of the linked project | `LinkBranchNotFoundError` | +| a branch name with no linked project to search | `LinkBranchNotLinkedError` | +| linked state whose ref is malformed, so there is no project to search | `LinkParentRefInvalidError` | +| a branch that has no project ref yet because it is still provisioning | `LinkBranchNotReadyError` | +| the branch lookup fails to reach the API or gets a non-200 status | `LinkBranchListNetworkError`, `LinkBranchListStatusError` | + +A selector conflict lists only the selector flags the command has: `Only one of --remote, --linked, --db-url and --local can be set; got …`, with the suggestion to use `--remote` or `--remote=<values>` on its own. A bare `--remote` together with `--project-ref=<x>` is accepted: the alias's value wins and its warning prints. A `false` switch counts as absent, so `--linked=false` selects nothing. + +`LinkBranchNotFoundError` names the parent project, lists the branches it has, offers the nearest name as "Did you mean" and, for a word of lowercase letters, notes that a project ref is exactly 20 lowercase letters. A `404` from the branch listing adds that the linked ref may itself be a preview branch and says to link its parent project. + +## `--password` + +`db dump`, `db pull`, `db push`, `db schema declarative generate`, `migration list`, `migration repair` and `migration squash` take `--password`, which applies only when the target is a project: a bare `--remote`, a ref or a branch. Anything else fails before the command runs, with `TargetPasswordConflictError`: + +- `--remote=<url>`: `--password can't be used with --remote=<db-url>. Put the password in the connection string: postgres://USER:PASSWORD@HOST:PORT/postgres` +- the local default: `--password only applies to a linked project. The local database uses [db].password from supabase/config.toml.`, followed by `To use the linked project instead, pass --remote.` in a linked checkout. + +A command's own `SIDE_EFFECTS.md` records where else its password can come from and when it is withheld from a target. + +## Deprecated selector flags + +`--linked`, `--project-ref`, `--db-url` and `--local` (and `gen types --project-id`) still work in v3 and are removed in v4, as is the `linked` value of `db diff --from` and `--to`, which `remote` replaces. Each goes through the same resolver as the `--remote` form it maps to and prints a warning on stderr in every output format, before the `Target:` line: + +| Flag | Replacement | +| --------------------- | ------------------------------------------------------------------------------------------------------- | +| `--linked` | `--remote` | +| `--project-ref=<ref>` | `--remote=<ref>`, or `--remote=<branch>` when the value is a branch name | +| `--db-url=<url>` | `--remote=<db-url>`, or `--remote=<ref>` with an access token set when the URL names a Supabase project | +| `--local` | nothing: local is the default, so the flag does nothing | + +A warning shows the command path, the command's operand placeholders and the `--remote` replacement, last. It never repeats your operands, flag values or a database URL, because a CI log would keep them: + +```text +Warning: --project-ref is deprecated and will be removed in v4. Use --remote=<ref> instead: supabase secrets set <NAME=VALUE>... --remote=<ref> +``` + +## Hosted-mutation consent + +Every persistent mutation of a hosted resource asks for consent first, including a write through a non-local `--remote=<url>`. A loopback host on the local or shadow database ports, or the local stack's endpoint, counts as local. + +- **Approval.** `--yes`, or a `SUPABASE_YES` in the shell environment. A `SUPABASE_YES` in a project `.env*` file does not count, and `--yes=false` beats the environment. Stdin is never read, so a piped `y` does nothing. The confirmation line names which one approved: `Confirmed by --yes: <action> on <target>`. +- **Prompt.** Only text output on an interactive stdout with a TTY stdin, with no agent driving, is asked. The question starts on the command's consent default. +- **Refusal.** JSON, stream-json, `-o json|yaml|toml|env`, agent 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`. Nothing is changed. +- **Decline.** A declined prompt fails with `HostedMutationDeclinedError` (`context canceled`), exit 1, and nothing is changed. +- **One question.** A command asks once, after the plan is known and before the first persistent effect, and lists everything it will do. +- **Network bans.** Removing a network ban to connect needs `--yes` or a shell `SUPABASE_YES`; without one, the command fails with `LinkedDbNetworkBannedError` and points at `supabase network-bans remove`. This applies to reads too. +- **Reads never prompt.** A command marked `no consent` never asks for hosted-mutation consent, on any target, and `--yes` and `SUPABASE_YES` have no effect on it. `db query --read-only` is a read and skips consent too. ## Banner -`Target: <text>` on stderr in every output format, printed once per distinct text. +`Target: <text>` on stderr in every output format, printed once per distinct text. It is best effort: it adds no API call and no auth requirement, and it never prints credentials. | Target | Text | | ------------------------------------- | ----------------------------------------------------------------------------------- | @@ -20,7 +116,9 @@ Database commands run against the local stack unless `--remote` is passed: bare | The project's default branch | `project <name> (<ref>) · production` | | Branch UUID, no linked project | `project <ref>`: the lookup does not say whether it is the default branch | -The `default; pass --remote` hint appears only when a project is linked, by `SUPABASE_PROJECT_ID` or the link file, and no `--remote` was passed. "Branch" is claimed only on positive evidence: a branch lookup by name or UUID, or a live probe. A cached link never proves one. A branch's parent shows as its bare ref when the cache doesn't know its name. +On a `hosted` command the banner is the notice that the command is talking to a remote project, and it names the linked project the command defaults to. The `default; pass --remote` hint appears only when a project is linked, by `SUPABASE_PROJECT_ID` or the link file, and no `--remote` was passed; a bare `db push`, `db pull` or `migration fetch` in a linked checkout runs locally and prints it. + +Identity comes from `supabase/.temp/linked-project.json`, with no API call. A cache that names the resolved ref itself marks a production project. The cache can be stale or missing, so it never proves a branch: "branch" is claimed only on positive evidence, which is a branch lookup by name or UUID or a live probe. A branch's parent shows as its bare ref when the cache doesn't know its name. ## JSON `target` @@ -34,6 +132,12 @@ The `default; pass --remote` hint appears only when a project is linked, by `SUP `environment` is `production` when the linked-project cache names the resolved ref or the resolved branch is the project's default branch, `branch` on positive evidence of any other branch, and `unverified` otherwise, including a branch UUID looked up with no linked project. 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 first announced target becomes the run's `target`, so a command that announces two (`db diff --from=remote --to=<url>`) prints both and reports the first. It is merged into object results, the `stream-json` result event and the machine error envelope. Arrays, `--output {json,yaml,toml,env}` payloads and generated artifacts such as `gen types` output are 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. + +## Help and docs + +A group's help labels each subcommand `[local or remote]` (`selectable`), `[remote only]` (`hosted`) or `[local only]`; `account` and removed commands carry no label. A command's own help has a `TARGET` section above `USAGE`, and its `--remote` row reads `--remote[=<ref|branch|url>]` where the command takes a database URL, `--remote[=<ref|branch>]` where it doesn't, and `--remote[=<ref>]` on `branches *`. JSON help gives the `remote` flag `value` and `valueOptional` fields in place of the bracketed name. + +Docs and examples put `--remote` last, in the `=` form when it has a value (`db pull add_profiles --remote`, `db push --remote=<ref>`), so it cannot be read as anything else. Shell completion never offers a space-separated value for `--remote` on a command that takes operands. diff --git a/apps/cli/src/commands/backups/list/SIDE_EFFECTS.md b/apps/cli/src/commands/backups/list/SIDE_EFFECTS.md index 3177cb3c2e..84c1630ae8 100644 --- a/apps/cli/src/commands/backups/list/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/backups/list/SIDE_EFFECTS.md @@ -1,5 +1,7 @@ # `supabase backups list` +Target: hosted, no consent — see [Command target](../../../../docs/command-target.md) + ## Files Read | Path | Format | When | @@ -93,11 +95,5 @@ One `result` NDJSON event on success containing the backup response object. ## Notes -- Targets the linked project unless `--remote=<ref>` is given; a bare `--remote` also targets the linked project. `--remote=<value>` is read as a project ref (20 lowercase letters), a branch of the linked project (by name or UUID, resolved through `GET /v1/projects/{parent}/branches`, or `GET /v1/branches/{id}` for a UUID when no project is linked), or `true|yes|on|1` for the linked project. A database URL is rejected (`supabase backups list` takes a project, not a database); `false|no|off|0` and `--no-remote` fail with `--remote doesn't take false. Leave it out to use the linked project.`, and an empty value with `--remote= has an empty value. Pass --remote for the linked project or --remote=<ref|branch>; leave it out to use the linked project.` -- A word after a bare `--remote` is read as its value, `--remote=<word>`, because the command takes no operands. -- The deprecated `--project-ref` is an alias of `--remote=<ref>` and prints a deprecation warning on stderr that names the replacement; it cannot be combined with `--remote=<value>`. An empty `--project-ref` is rejected. -- Requires `--remote=<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 ae63c51ced..b0a3ef4867 100644 --- a/apps/cli/src/commands/backups/restore/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/backups/restore/SIDE_EFFECTS.md @@ -1,5 +1,7 @@ # `supabase backups restore` +Target: hosted, consent default No — see [Command target](../../../../docs/command-target.md) + ## Files Read | Path | Format | When | @@ -81,14 +83,9 @@ One `result` NDJSON event on success with the project ref payload. ## Notes -- Targets the linked project unless `--remote=<ref>` is given; a bare `--remote` also targets the linked project. `--remote=<value>` is read as a project ref (20 lowercase letters), a branch of the linked project (by name or UUID, resolved through `GET /v1/projects/{parent}/branches`, or `GET /v1/branches/{id}` for a UUID when no project is linked), or `true|yes|on|1` for the linked project. A database URL is rejected (`supabase backups restore` takes a project, not a database); `false|no|off|0` and `--no-remote` fail with `--remote doesn't take false. Leave it out to use the linked project.`, and an empty value with `--remote= has an empty value. Pass --remote for the linked project or --remote=<ref|branch>; leave it out to use the linked project.` -- A word after a bare `--remote` is read as its value, `--remote=<word>`, because the command takes no operands. -- The deprecated `--project-ref` is an alias of `--remote=<ref>` and prints a deprecation warning on stderr that names the replacement; it cannot be combined with `--remote=<value>`. An empty `--project-ref` is rejected. - `--timestamp` / `-t` accepts seconds since Unix epoch (int64). Defaults to `0`, which the API interprets as "now". - Known gap: the generated `V1RestorePitrBackupInput` schema enforces `recovery_time_target_unix >= 0`, so a negative value is rejected locally with a schema decode error instead of being forwarded to the API (the underlying field has no lower bound). Resolving this requires an upstream OpenAPI spec change. -- Requires `--remote=<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. +- **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`. diff --git a/apps/cli/src/commands/bootstrap/SIDE_EFFECTS.md b/apps/cli/src/commands/bootstrap/SIDE_EFFECTS.md index 1bc416c946..fc6e595b11 100644 --- a/apps/cli/src/commands/bootstrap/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/bootstrap/SIDE_EFFECTS.md @@ -1,5 +1,7 @@ # `supabase bootstrap [template]` +Target: account, consent default Yes — see [Command target](../../../docs/command-target.md) + `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, @@ -125,14 +127,11 @@ 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: +- **Hosted-mutation consent.** 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. + with nothing written. 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, includeVault: true, diff --git a/apps/cli/src/commands/branches/create/SIDE_EFFECTS.md b/apps/cli/src/commands/branches/create/SIDE_EFFECTS.md index 5bab3d8c03..001d674f95 100644 --- a/apps/cli/src/commands/branches/create/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/branches/create/SIDE_EFFECTS.md @@ -1,5 +1,7 @@ # `supabase branches create` +Target: hosted, consent default Yes — see [Command target](../../../../docs/command-target.md) + ## Files Read | Path | Format | When | @@ -69,7 +71,5 @@ In `--output-format json` / `stream-json`, a `success` event carries the respons ## Notes -- `--remote=<ref>` names the parent project; with no selector or a bare `--remote` the parent comes from the chain above. A branch name or UUID is rejected with `` `supabase branches create` acts on a parent project, so --remote takes its project ref, not a branch name. Use --remote=<ref>. ``, and a database URL with `` `supabase branches create` needs a project, not a database URL. ``; `false|no|off|0` and `--no-remote` fail with `--remote doesn't take false. Leave it out to use the linked project.`, and an empty value with `--remote= has an empty value. Pass --remote for the linked project or --remote=<ref>; leave it out to use the linked project.` The branch itself stays a positional argument. -- The deprecated `--project-ref` is an alias of `--remote=<ref>` and prints a deprecation warning on stderr that names the replacement; it cannot be combined with `--remote=<value>`. An empty `--project-ref` is rejected. -- 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. +- `--remote=<ref>` names the parent project; with no selector or a bare `--remote` the parent comes from the chain above. A branch name or UUID is rejected with `` `supabase branches create` acts on a parent project, so --remote takes its project ref, not a branch name. Use --remote=<ref>. `` The branch itself stays a positional argument. +- **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. diff --git a/apps/cli/src/commands/branches/delete/SIDE_EFFECTS.md b/apps/cli/src/commands/branches/delete/SIDE_EFFECTS.md index c7cd11621d..5881a8cde9 100644 --- a/apps/cli/src/commands/branches/delete/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/branches/delete/SIDE_EFFECTS.md @@ -1,5 +1,7 @@ # `supabase branches delete` +Target: hosted, consent default No — see [Command target](../../../../docs/command-target.md) + ## Files Read Same auth fallback chain as every Management-API command. Project-ref discovery (for the PARENT) is PARENT-scoped (CLI-2167 follow-up): env `SUPABASE_PROJECT_ID` → `<workdir>/supabase/.temp/linked-project.json`'s `ref` → `<workdir>/supabase/.temp/project-ref`, first ref-shaped candidate wins — see `branches list/SIDE_EFFECTS.md` for the full chain and rationale. @@ -57,7 +59,5 @@ Single `success` event carrying `{project_ref}`. ## Notes -- `--remote=<ref>` names the parent project; with no selector or a bare `--remote` the parent comes from the chain above. A branch name or UUID is rejected with `` `supabase branches delete` acts on a parent project, so --remote takes its project ref, not a branch name. Use --remote=<ref>. ``, and a database URL with `` `supabase branches delete` needs a project, not a database URL. ``; `false|no|off|0` and `--no-remote` fail with `--remote doesn't take false. Leave it out to use the linked project.`, and an empty value with `--remote= has an empty value. Pass --remote for the linked project or --remote=<ref>; leave it out to use the linked project.` The branch itself stays a positional argument. -- The deprecated `--project-ref` is an alias of `--remote=<ref>` and prints a deprecation warning on stderr that names the replacement; it cannot be combined with `--remote=<value>`. An empty `--project-ref` is rejected. -- 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. +- `--remote=<ref>` names the parent project; with no selector or a bare `--remote` the parent comes from the chain above. A branch name or UUID is rejected with `` `supabase branches delete` acts on a parent project, so --remote takes its project ref, not a branch name. Use --remote=<ref>. `` The branch itself stays a positional argument. +- **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. diff --git a/apps/cli/src/commands/branches/disable/SIDE_EFFECTS.md b/apps/cli/src/commands/branches/disable/SIDE_EFFECTS.md index 686ce917fd..7c083cd590 100644 --- a/apps/cli/src/commands/branches/disable/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/branches/disable/SIDE_EFFECTS.md @@ -1,5 +1,7 @@ # `supabase branches disable` +Target: hosted, consent default No — see [Command target](../../../../docs/command-target.md) + Hidden subcommand. Operates on the entire linked project rather than a single branch. ## Files Read @@ -55,7 +57,5 @@ Single `success` event carrying `{project_ref}`. ## Notes -- `--remote=<ref>` names the parent project; with no selector or a bare `--remote` the parent comes from the chain above. A branch name or UUID is rejected with `` `supabase branches disable` acts on a parent project, so --remote takes its project ref, not a branch name. Use --remote=<ref>. ``, and a database URL with `` `supabase branches disable` needs a project, not a database URL. ``; `false|no|off|0` and `--no-remote` fail with `--remote doesn't take false. Leave it out to use the linked project.`, and an empty value with `--remote= has an empty value. Pass --remote for the linked project or --remote=<ref>; leave it out to use the linked project.` The branch itself stays a positional argument. -- The deprecated `--project-ref` is an alias of `--remote=<ref>` and prints a deprecation warning on stderr that names the replacement; it cannot be combined with `--remote=<value>`. An empty `--project-ref` is rejected. -- 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. +- `--remote=<ref>` names the parent project; with no selector or a bare `--remote` the parent comes from the chain above. A branch name or UUID is rejected with `` `supabase branches disable` acts on a parent project, so --remote takes its project ref, not a branch name. Use --remote=<ref>. `` The branch itself stays a positional argument. +- **Consent.** Asks once, after the parent project is resolved and before the DELETE, with default No: `Disable preview branching`. diff --git a/apps/cli/src/commands/branches/get/SIDE_EFFECTS.md b/apps/cli/src/commands/branches/get/SIDE_EFFECTS.md index 73b0a0cc82..5d04e16602 100644 --- a/apps/cli/src/commands/branches/get/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/branches/get/SIDE_EFFECTS.md @@ -1,5 +1,7 @@ # `supabase branches get` +Target: hosted, no consent — see [Command target](../../../../docs/command-target.md) + ## Files Read Same auth fallback chain (env / keyring / `~/.supabase/access-token`) as every Management-API command. Project-ref discovery is PARENT-scoped (CLI-2167 follow-up): env `SUPABASE_PROJECT_ID` → `<workdir>/supabase/.temp/linked-project.json`'s `ref` → `<workdir>/supabase/.temp/project-ref`, first ref-shaped candidate wins — see `branches list/SIDE_EFFECTS.md` for the full chain and rationale. @@ -56,6 +58,4 @@ Emits the standard-env projection: `POSTGRES_URL` (pooled, falls back to direct ## Notes -- `--remote=<ref>` names the parent project; with no selector or a bare `--remote` the parent comes from the chain above. A branch name or UUID is rejected with `` `supabase branches get` acts on a parent project, so --remote takes its project ref, not a branch name. Use --remote=<ref>. ``, and a database URL with `` `supabase branches get` needs a project, not a database URL. ``; `false|no|off|0` and `--no-remote` fail with `--remote doesn't take false. Leave it out to use the linked project.`, and an empty value with `--remote= has an empty value. Pass --remote for the linked project or --remote=<ref>; leave it out to use the linked project.` The branch itself stays a positional argument. -- The deprecated `--project-ref` is an alias of `--remote=<ref>` and prints a deprecation warning on stderr that names the replacement; it cannot be combined with `--remote=<value>`. An empty `--project-ref` is rejected. -- 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. +- `--remote=<ref>` names the parent project; with no selector or a bare `--remote` the parent comes from the chain above. A branch name or UUID is rejected with `` `supabase branches get` acts on a parent project, so --remote takes its project ref, not a branch name. Use --remote=<ref>. `` The branch itself stays a positional argument. diff --git a/apps/cli/src/commands/branches/list/SIDE_EFFECTS.md b/apps/cli/src/commands/branches/list/SIDE_EFFECTS.md index 7a1846baa5..d38f94b0ce 100644 --- a/apps/cli/src/commands/branches/list/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/branches/list/SIDE_EFFECTS.md @@ -1,5 +1,7 @@ # `supabase branches list` +Target: hosted, no consent — see [Command target](../../../../docs/command-target.md) + ## Files Read | Path | Format | When | @@ -107,9 +109,6 @@ One `result` NDJSON event with `{branches: [...]}`. ## Notes -- `--remote=<ref>` names the parent project; with no selector or a bare `--remote` the parent comes from the chain above. A branch name or UUID is rejected with `` `supabase branches list` acts on a parent project, so --remote takes its project ref, not a branch name. Use --remote=<ref>. ``, and a database URL with `` `supabase branches list` needs a project, not a database URL. ``; `false|no|off|0` and `--no-remote` fail with `--remote doesn't take false. Leave it out to use the linked project.`, and an empty value with `--remote= has an empty value. Pass --remote for the linked project or --remote=<ref>; leave it out to use the linked project.` The branch itself stays a positional argument. -- The deprecated `--project-ref` is an alias of `--remote=<ref>` and prints a deprecation warning on stderr that names the replacement; it cannot be combined with `--remote=<value>`. An empty `--project-ref` is rejected. +- `--remote=<ref>` names the parent project; with no selector or a bare `--remote` the parent comes from the chain above. A branch name or UUID is rejected with `` `supabase branches list` acts on a parent project, so --remote takes its project ref, not a branch name. Use --remote=<ref>. `` The branch itself stays a positional argument. - 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 27772c414b..2fa4f3f721 100644 --- a/apps/cli/src/commands/branches/pause/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/branches/pause/SIDE_EFFECTS.md @@ -1,5 +1,7 @@ # `supabase branches pause` +Target: hosted, consent default No — see [Command target](../../../../docs/command-target.md) + ## Files Read Same auth fallback chain as every Management-API command. Project-ref discovery (for the PARENT) is PARENT-scoped (CLI-2167 follow-up): env `SUPABASE_PROJECT_ID` → `<workdir>/supabase/.temp/linked-project.json`'s `ref` → `<workdir>/supabase/.temp/project-ref`, first ref-shaped candidate wins — see `branches list/SIDE_EFFECTS.md` for the full chain and rationale. @@ -51,7 +53,5 @@ Silent on success in every mode (the handler writes nothing itself; the target b ## Notes -- `--remote=<ref>` names the parent project; with no selector or a bare `--remote` the parent comes from the chain above. A branch name or UUID is rejected with `` `supabase branches pause` acts on a parent project, so --remote takes its project ref, not a branch name. Use --remote=<ref>. ``, and a database URL with `` `supabase branches pause` needs a project, not a database URL. ``; `false|no|off|0` and `--no-remote` fail with `--remote doesn't take false. Leave it out to use the linked project.`, and an empty value with `--remote= has an empty value. Pass --remote for the linked project or --remote=<ref>; leave it out to use the linked project.` The branch itself stays a positional argument. -- The deprecated `--project-ref` is an alias of `--remote=<ref>` and prints a deprecation warning on stderr that names the replacement; it cannot be combined with `--remote=<value>`. An empty `--project-ref` is rejected. -- 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. +- `--remote=<ref>` names the parent project; with no selector or a bare `--remote` the parent comes from the chain above. A branch name or UUID is rejected with `` `supabase branches pause` acts on a parent project, so --remote takes its project ref, not a branch name. Use --remote=<ref>. `` The branch itself stays a positional argument. +- **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. diff --git a/apps/cli/src/commands/branches/unpause/SIDE_EFFECTS.md b/apps/cli/src/commands/branches/unpause/SIDE_EFFECTS.md index 630ed1a5bb..2398ff13d3 100644 --- a/apps/cli/src/commands/branches/unpause/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/branches/unpause/SIDE_EFFECTS.md @@ -1,5 +1,7 @@ # `supabase branches unpause` +Target: hosted, consent default No — see [Command target](../../../../docs/command-target.md) + ## Files Read Same auth fallback chain as every Management-API command. Project-ref discovery (for the PARENT) is PARENT-scoped (CLI-2167 follow-up): env `SUPABASE_PROJECT_ID` → `<workdir>/supabase/.temp/linked-project.json`'s `ref` → `<workdir>/supabase/.temp/project-ref`, first ref-shaped candidate wins — see `branches list/SIDE_EFFECTS.md` for the full chain and rationale. @@ -51,7 +53,5 @@ Silent on success in every mode (the handler writes nothing itself; the target b ## Notes -- `--remote=<ref>` names the parent project; with no selector or a bare `--remote` the parent comes from the chain above. A branch name or UUID is rejected with `` `supabase branches unpause` acts on a parent project, so --remote takes its project ref, not a branch name. Use --remote=<ref>. ``, and a database URL with `` `supabase branches unpause` needs a project, not a database URL. ``; `false|no|off|0` and `--no-remote` fail with `--remote doesn't take false. Leave it out to use the linked project.`, and an empty value with `--remote= has an empty value. Pass --remote for the linked project or --remote=<ref>; leave it out to use the linked project.` The branch itself stays a positional argument. -- The deprecated `--project-ref` is an alias of `--remote=<ref>` and prints a deprecation warning on stderr that names the replacement; it cannot be combined with `--remote=<value>`. An empty `--project-ref` is rejected. -- 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. +- `--remote=<ref>` names the parent project; with no selector or a bare `--remote` the parent comes from the chain above. A branch name or UUID is rejected with `` `supabase branches unpause` acts on a parent project, so --remote takes its project ref, not a branch name. Use --remote=<ref>. `` The branch itself stays a positional argument. +- **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. diff --git a/apps/cli/src/commands/branches/update/SIDE_EFFECTS.md b/apps/cli/src/commands/branches/update/SIDE_EFFECTS.md index fd8797fceb..3cff71b826 100644 --- a/apps/cli/src/commands/branches/update/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/branches/update/SIDE_EFFECTS.md @@ -1,5 +1,7 @@ # `supabase branches update` +Target: hosted, consent default No — see [Command target](../../../../docs/command-target.md) + ## Files Read Same auth fallback chain as every Management-API command. Project-ref discovery (for the PARENT) is PARENT-scoped (CLI-2167 follow-up): env `SUPABASE_PROJECT_ID` → `<workdir>/supabase/.temp/linked-project.json`'s `ref` → `<workdir>/supabase/.temp/project-ref`, first ref-shaped candidate wins — see `branches list/SIDE_EFFECTS.md` for the full chain and rationale. @@ -58,8 +60,6 @@ For `--output {json,yaml,toml,env}`, the header goes to stderr followed by the e ## Notes -- `--remote=<ref>` names the parent project; with no selector or a bare `--remote` the parent comes from the chain above. A branch name or UUID is rejected with `` `supabase branches update` acts on a parent project, so --remote takes its project ref, not a branch name. Use --remote=<ref>. ``, and a database URL with `` `supabase branches update` needs a project, not a database URL. ``; `false|no|off|0` and `--no-remote` fail with `--remote doesn't take false. Leave it out to use the linked project.`, and an empty value with `--remote= has an empty value. Pass --remote for the linked project or --remote=<ref>; leave it out to use the linked project.` The branch itself stays a positional argument. -- The deprecated `--project-ref` is an alias of `--remote=<ref>` and prints a deprecation warning on stderr that names the replacement; it cannot be combined with `--remote=<value>`. An empty `--project-ref` is rejected. +- `--remote=<ref>` names the parent project; with no selector or a bare `--remote` the parent comes from the chain above. A branch name or UUID is rejected with `` `supabase branches update` acts on a parent project, so --remote takes its project ref, not a branch name. Use --remote=<ref>. `` The branch itself stays a positional argument. - The upgrade-suggest call uses the branch's own resolved project ref (`resolveBranchProjectRef`) — not the parent `--remote=<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. +- **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`). diff --git a/apps/cli/src/commands/config/diff/SIDE_EFFECTS.md b/apps/cli/src/commands/config/diff/SIDE_EFFECTS.md index 2ec89cdc87..609291d5e4 100644 --- a/apps/cli/src/commands/config/diff/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/config/diff/SIDE_EFFECTS.md @@ -1,5 +1,7 @@ # `supabase config diff` +Target: hosted, no consent — see [Command target](../../../../docs/command-target.md) + Read-only comparison between the local `supabase/config.toml` and the effective configuration the Management API reports for a target project or branch. Classifies every remotely-managed property as `update` / `remote_only` / @@ -146,9 +148,6 @@ and emit nothing. ## Notes -- `--remote=<value>` selects the target: a project ref (20 lowercase letters), or the name or UUID of a branch of the linked project (resolved through `GET /v1/projects/{parent}/branches`, or `GET /v1/branches/{id}` for a UUID when no project is linked). A bare `--remote` and no selector both target the linked project. A database URL is rejected (`supabase config diff` takes a project, not a database); `false|no|off|0` and `--no-remote` fail with `--remote doesn't take false. Leave it out to use the linked project.`, and an empty value with `--remote= has an empty value. Pass --remote for the linked project or --remote=<ref|branch>; leave it out to use the linked project.` -- The deprecated `--project-ref` is an alias of `--remote=<ref>` (it still accepts a branch name or UUID) and prints a deprecation warning on stderr that names the replacement; it cannot be combined with `--remote=<value>`. An empty `--project-ref` is rejected. -- 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 cfb54115d8..9fc1c6534b 100644 --- a/apps/cli/src/commands/config/pull/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/config/pull/SIDE_EFFECTS.md @@ -1,5 +1,7 @@ # `supabase config pull` +Target: hosted, no consent — see [Command target](../../../../docs/command-target.md) + Writes the effective configuration of a remote project or branch back into the local `supabase/config.toml`/`config.json` — the write side of `supabase config diff` (same target resolution, fetch, and classification, `../diff/`). Prompts for confirmation before writing on an @@ -182,8 +184,6 @@ the -o/--output flag is not supported by config pull; use --output-format json|s ## Notes -- `--remote=<value>` selects the target: a project ref (20 lowercase letters), or the name or UUID of a branch of the linked project (resolved through `GET /v1/projects/{parent}/branches`, or `GET /v1/branches/{id}` for a UUID when no project is linked). A bare `--remote` and no selector both target the linked project. A database URL is rejected (`supabase config pull` takes a project, not a database); `false|no|off|0` and `--no-remote` fail with `--remote doesn't take false. Leave it out to use the linked project.`, and an empty value with `--remote= has an empty value. Pass --remote for the linked project or --remote=<ref|branch>; leave it out to use the linked project.` -- The deprecated `--project-ref` is an alias of `--remote=<ref>` (it still accepts a branch name or UUID) and prints a deprecation warning on stderr that names the replacement; it cannot be combined with `--remote=<value>`. An empty `--project-ref` is rejected. - **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. diff --git a/apps/cli/src/commands/config/push/SIDE_EFFECTS.md b/apps/cli/src/commands/config/push/SIDE_EFFECTS.md index 4f23003e0b..49750fd210 100644 --- a/apps/cli/src/commands/config/push/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/config/push/SIDE_EFFECTS.md @@ -1,5 +1,7 @@ # `supabase config push` +Target: hosted, consent default No — see [Command target](../../../../docs/command-target.md) + Pushes the local `supabase/config.toml`'s declared, pushable properties to the linked project's (or, via `--remote=<branch-name-or-uuid>`, one of its branches') Management API. Reports which project/branch it resolved first, reads @@ -215,13 +217,8 @@ When at least one resource (or the webhooks enable) will be written, a price lin `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. +default answer of **no**. A push with nothing to write never asks. Declining fails the +command with nothing written. Afterwards, up to six `Note:` lines report anything the push couldn't do or had to work around: @@ -349,9 +346,6 @@ may itself contain a `.`. ## Notes -- `--remote=<value>` selects the target: a project ref (20 lowercase letters), or the name or UUID of a branch of the linked project (resolved through `GET /v1/projects/{parent}/branches`, or `GET /v1/branches/{id}` for a UUID when no project is linked). A bare `--remote` and no selector both target the linked project. A database URL is rejected (`supabase config push` takes a project, not a database); `false|no|off|0` and `--no-remote` fail with `--remote doesn't take false. Leave it out to use the linked project.`, and an empty value with `--remote= has an empty value. Pass --remote for the linked project or --remote=<ref|branch>; leave it out to use the linked project.` -- The deprecated `--project-ref` is an alias of `--remote=<ref>` (it still accepts a branch name or UUID) and prints a deprecation warning on stderr that names the replacement; it cannot be combined with `--remote=<value>`. An empty `--project-ref` is rejected. -- **`--remote` 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 or UUID is resolved against the branches of the currently linked project (fails if none is linked, or if the linked ref is itself invalid); a UUID with no usable linked project resolves directly through `GET /v1/branches/{id}`. - **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 `--remote` 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 `--remote` 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 `--remote=<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. diff --git a/apps/cli/src/commands/db/advisors/SIDE_EFFECTS.md b/apps/cli/src/commands/db/advisors/SIDE_EFFECTS.md index 9902e44ba2..abb305518f 100644 --- a/apps/cli/src/commands/db/advisors/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/db/advisors/SIDE_EFFECTS.md @@ -1,16 +1,11 @@ # `supabase db advisors` +Target: selectable, no consent — see [Command target](../../../../docs/command-target.md) + Checks a database for security and performance issues. Two backends: the local stack and `--remote=<url>` query the database directly; a project fetches from the Management API. -With no selector the command checks the local stack, and in a linked checkout the `Target:` -line adds `default; pass --remote for <project>`. `--remote` selects the linked project, -`--remote=<ref>` another project, `--remote=<branch>` a branch of the linked project (by name -or UUID) and `--remote=<url>` a Postgres connection string -(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 | @@ -62,25 +57,19 @@ 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` | more than one of `--remote` / `--linked` / `--db-url` / `--local` (`TargetSelectorConflictError`) | -| `1` | a remote project or branch with no access token (suggests `supabase login`) | -| `1` | `--remote=<branch>` naming no branch of the parent project | -| `1` | connection / `BEGIN` / setup / query failure (local, `--remote=<url>`) | -| `1` | advisors API non-200 (remote project or branch) | -| `1` | a lint's level is at or above `--fail-on` | -| `1` | `--project-ref` with `--local`, `--db-url` or `--remote=<value>` (`TargetProjectRefConflictError`) | +| Code | Condition | +| ---- | --------------------------------------------------------------------------- | +| `0` | success — no issues at or above `--fail-on` (empty result is still `0`) | +| `1` | a remote project or branch with no access token (suggests `supabase login`) | +| `1` | connection / `BEGIN` / setup / query failure (local, `--remote=<url>`) | +| `1` | advisors API non-200 (remote project or branch) | +| `1` | a lint's level is at or above `--fail-on` | ## Output ### `--output-format text` -Diagnostics on **stderr**: the `Target: …` line (remote project `Target: <name> (<ref>)`, branch -`Target: project <parent> · branch <name> (<ref>)`, `Target: local stack (<host>:<port>)` with -` · default; pass --remote for <project>` appended in a linked checkout when no selector was given, -or `Target: custom Postgres (<host>:<port>)`), alias deprecation warnings, and +Diagnostics on **stderr**: alias deprecation warnings, the `Target:` line, 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). @@ -102,16 +91,5 @@ the process exits non-zero (no error envelope is written over the payload). - `--type` (`all` default): `security` / `performance` select endpoints (remote) or filter categories (local, `--remote=<url>`). - `--level` (`warn` default) sets the minimum issue level to display. - `--fail-on` (`none` default) sets the level that forces a non-zero exit. -- **Target selection.** `--remote` is read as the linked project (bare), a project ref (20 - lowercase letters), a Postgres URL, or else a branch of the linked project by name or UUID; - `--remote=false` fails with `--remote doesn't take false. Leave it out to target the local database.`, and `--remote=` with `--remote= has an empty value. Pass --remote for the linked project or --remote=<ref|branch|url>; leave it out to target the local database.` A - word after a bare `--remote` is read as its value, `--remote=<word>`, because the command takes no - operands. -- **Deprecated aliases.** `--linked`, `--project-ref=<ref>`, `--db-url=<url>` and `--local` still - work and print a deprecation warning on stderr naming the `--remote` replacement (`--local` - warns that local is the default). They are mutually exclusive with each other and with - `--remote`; a `false` switch counts as absent. `--project-ref` with `--local`, `--db-url` or - `--remote=<value>` is a hard error, and an empty `--db-url` or `--project-ref` fails with - `TargetEmptyValueError` instead of reading as absent. - 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 3e264fea09..6aff0c26b1 100644 --- a/apps/cli/src/commands/db/diff/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/db/diff/SIDE_EFFECTS.md @@ -1,5 +1,7 @@ # `supabase db diff` +Target: selectable, no consent — see [Command target](../../../../docs/command-target.md) + Diffs the local project's expected schema (a throwaway shadow database) against a target database (local / a remote project or branch / `--remote=<url>`), using one of three native engines: bundled in-process pg-delta, migra (edge-runtime), or @@ -8,13 +10,6 @@ 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 selector the command diffs the local stack, and in a linked checkout the `Target:` line -adds `default; pass --remote for <project>`. `--remote` selects the linked project, -`--remote=<ref>` another project, `--remote=<branch>` a branch of the linked project (by name or -UUID) and `--remote=<url>` a custom database (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. - 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 @@ -146,21 +141,18 @@ migra/pg-delta-engine-specific). ## Exit Codes -| Code | Condition | -| ---- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `0` | success; empty diff ("No schema changes found") | -| `1` | `--from` without `--to`; `--remote` or an alias combined with `--from`/`--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 `--remote`/`--linked`/`--db-url`/`--local` (`TargetSelectorConflictError`); `--project-ref` with `--local`, `--db-url` or `--remote=<value>` (`TargetProjectRefConflictError`); `--remote=<branch>`, or a `--from`/`--to` branch, naming no branch of the parent project | -| `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`; `--remote` or an alias combined with `--from`/`--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` | a `--from`/`--to` branch naming no branch of the parent project | +| `1` | `--use-pg-schema` passed at all (any value, including `=false`) — removed, see Notes | ## Output ### `--output-format text` -Progress to stderr (alias deprecation warnings, then the `Target: …` line — remote project -`Target: <name> (<ref>)`, branch `Target: project <parent> · branch <name> (<ref>)`, -`Target: local stack (<host>:<port>)` (with ` · default; pass --remote for <project>` appended in a -linked checkout when no selector was given) or `Target: custom Postgres (<host>:<port>)`, printed +Progress to stderr (alias deprecation warnings, then the `Target:` line, 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 @@ -227,8 +219,7 @@ transaction metadata. invalid value fails the command. A remote project or branch 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 `--remote=<url>` and for the local - default. + temporary login role instead. - **pg-delta selection**: `--use-pg-delta` sets `[experimental.pgdelta].enabled` at the flag tier, so `--use-pg-delta=false` selects migra over env and config. The default is pg-delta when the stack backend is active or `[experimental.pgdelta].enabled` resolves true. An explicit @@ -237,18 +228,8 @@ transaction metadata. (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.** `--remote` is read as the linked project (bare), a project ref (20 - lowercase letters), a Postgres URL, or else a branch of the linked project by name or UUID; - `--remote=false` fails with `--remote doesn't take false. Leave it out to target the local database.`, and `--remote=` with `--remote= has an empty value. Pass --remote for the linked project or --remote=<ref|branch|url>; leave it out to target the local database.` A - word after a bare `--remote` is read as its value, `--remote=<word>`, because the command takes no - operands. - `--remote=<ref>` supplies only the ref; unlike `SUPABASE_PROJECT_ID`, it does not affect the +- `--remote=<ref>` supplies only the ref; unlike `SUPABASE_PROJECT_ID`, it does not affect the shadow container's project id/labels. -- **Deprecated aliases.** `--linked`, `--project-ref=<ref>`, `--db-url=<url>` and `--local` still - work and print a deprecation warning on stderr naming the `--remote` replacement (`--local` - warns that local is the default). They are mutually exclusive with each other and with - `--remote`; a `false` switch counts as absent. `--project-ref` with `--local`, `--db-url` or - `--remote=<value>` is a hard error. - **Explicit `--from`/`--to`.** Each endpoint is one of `local`, `migrations`, `remote` (the linked project), a project ref, a Postgres URL, or a branch name or UUID of the linked project (classified like a `--remote` value, so a branch named like a keyword is reached by its ref or diff --git a/apps/cli/src/commands/db/dump/SIDE_EFFECTS.md b/apps/cli/src/commands/db/dump/SIDE_EFFECTS.md index 0901877e1c..9f4c59f605 100644 --- a/apps/cli/src/commands/db/dump/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/db/dump/SIDE_EFFECTS.md @@ -1,17 +1,12 @@ # `supabase db dump` +Target: selectable, no consent — see [Command target](../../../../docs/command-target.md) + Implemented in `dump.handler.ts`. Streams a `pg_dump`/`pg_dumpall` 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 selector the command dumps the local stack, and in a linked checkout the `Target:` line -adds `default; pass --remote for <project>`. `--remote` selects the linked project, -`--remote=<ref>` another project, `--remote=<branch>` a branch of the linked project (by name or -UUID) and `--remote=<url>` a custom database (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 | @@ -60,19 +55,12 @@ It only reads, so it never asks for confirmation, on any target. | ---- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `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` | more than one of `--remote`/`--linked`/`--db-url`/`--local` (`TargetSelectorConflictError`) | -| `1` | `--project-ref` with `--local`, `--db-url` or `--remote=<value>` (`TargetProjectRefConflictError`) | -| `1` | `--password` with `--remote=<url>` or the local default (`TargetPasswordConflictError`) | -| `1` | `--remote=<branch>` naming no branch of the parent project | ## 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**: alias deprecation warnings, the `Target: …` line (remote project `Target: <name> (<ref>)`, -branch `Target: project <parent> · branch <name> (<ref>)`, `Target: local stack (<host>:<port>)` -with ` · default; pass --remote for <project>` appended in a linked checkout when no selector was -given, or `Target: custom Postgres (<host>:<port>)`), +to **stderr**: alias deprecation warnings, the `Target:` line, `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 @@ -102,21 +90,9 @@ shell inherits the suppressing variables and is missed. - **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 a remote project or branch) > 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. `--password` is rejected with - `--remote=<url>` and for the local default, which carry their own credentials. + 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. - `--data-only` XOR `--role-only`; `--keep-comments` XOR `--data-only`; `--schema` XOR `--role-only`. `--use-copy` / `--exclude` require `--data-only`. -- **Target selection.** `--remote` is read as the linked project (bare), a project ref (20 - lowercase letters), a Postgres URL, or else a branch of the linked project by name or UUID; - `--remote=false` fails with `--remote doesn't take false. Leave it out to target the local database.`, and `--remote=` with `--remote= has an empty value. Pass --remote for the linked project or --remote=<ref|branch|url>; leave it out to target the local database.` A - word after a bare `--remote` is read as its value, `--remote=<word>`, because the command takes no - operands. -- **Deprecated aliases.** `--linked`, `--project-ref=<ref>`, `--db-url=<url>` and `--local` still - work and print a deprecation warning on stderr naming the `--remote` replacement (`--local` - warns that local is the default). They are mutually exclusive with each other and with - `--remote`; a `false` switch counts as absent. `--project-ref` with `--local`, `--db-url` or - `--remote=<value>` is a hard error, and an empty `--db-url` or `--project-ref` fails with - `TargetEmptyValueError` instead of reading as absent. - **Container-level pooler fallback.** When a remote 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 cba0c46786..b304391a2d 100644 --- a/apps/cli/src/commands/db/lint/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/db/lint/SIDE_EFFECTS.md @@ -1,13 +1,8 @@ # `supabase db lint` -Checks a database for PL/pgSQL typing errors via the `plpgsql_check` extension. +Target: selectable, no consent — see [Command target](../../../../docs/command-target.md) -With no selector the command lints the local stack, and in a linked checkout the `Target:` line -adds `default; pass --remote for <project>`. `--remote` selects the linked project, -`--remote=<ref>` another project, `--remote=<branch>` a branch of the linked project (by name or -UUID) and `--remote=<url>` a custom database (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. +Checks a database for PL/pgSQL typing errors via the `plpgsql_check` extension. ## Files Read @@ -69,24 +64,18 @@ container stacks can enable it. Compose uses the Compose Postgres image. ## Exit Codes -| Code | Condition | -| ---- | -------------------------------------------------------------------------------------------------- | -| `0` | success — no issues at or above `--fail-on` (an empty result is still `0`) | -| `1` | more than one of `--remote` / `--linked` / `--db-url` / `--local` (`TargetSelectorConflictError`) | -| `1` | connection / `BEGIN` / list-schemas / enable-extension / query failure | -| `1` | `--remote=<branch>` naming no branch of the parent project | -| `1` | malformed `plpgsql_check` JSON | -| `1` | an issue's level is at or above `--fail-on` | -| `1` | `--project-ref` with `--local`, `--db-url` or `--remote=<value>` (`TargetProjectRefConflictError`) | +| Code | Condition | +| ---- | -------------------------------------------------------------------------- | +| `0` | success — no issues at or above `--fail-on` (an empty result is still `0`) | +| `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` | ## Output ### `--output-format text` -Diagnostics on **stderr**: alias deprecation warnings, the `Target: …` line (remote project -`Target: <name> (<ref>)`, branch `Target: project <parent> · branch <name> (<ref>)`, -`Target: local stack (<host>:<port>)` with ` · default; pass --remote for <project>` appended in a -linked checkout when no selector was given, or `Target: custom Postgres (<host>:<port>)`), +Diagnostics on **stderr**: alias deprecation warnings, the `Target:` line, `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 @@ -109,15 +98,4 @@ 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. -- **Target selection.** `--remote` is read as the linked project (bare), a project ref (20 - lowercase letters), a Postgres URL, or else a branch of the linked project by name or UUID; - `--remote=false` fails with `--remote doesn't take false. Leave it out to target the local database.`, and `--remote=` with `--remote= has an empty value. Pass --remote for the linked project or --remote=<ref|branch|url>; leave it out to target the local database.` A - word after a bare `--remote` is read as its value, `--remote=<word>`, because the command takes no - operands. -- **Deprecated aliases.** `--linked`, `--project-ref=<ref>`, `--db-url=<url>` and `--local` still - work and print a deprecation warning on stderr naming the `--remote` replacement (`--local` - warns that local is the default). They are mutually exclusive with each other and with - `--remote`; a `false` switch counts as absent. `--project-ref` with `--local`, `--db-url` or - `--remote=<value>` is a hard error, and an empty `--db-url` or `--project-ref` fails with - `TargetEmptyValueError` instead of reading as absent. - Telemetry: only the standard `cli_command_executed` event (no custom events). diff --git a/apps/cli/src/commands/db/pull/SIDE_EFFECTS.md b/apps/cli/src/commands/db/pull/SIDE_EFFECTS.md index cc749dc2cf..3f60bc7a79 100644 --- a/apps/cli/src/commands/db/pull/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/db/pull/SIDE_EFFECTS.md @@ -1,5 +1,7 @@ # `supabase db pull` +Target: selectable, consent default No — see [Command target](../../../../docs/command-target.md) + Pulls the remote schema into either a new timestamped migration (diffing a throwaway shadow against the remote, bundled pg-delta or migra) or declarative files (`--declarative`). The declarative mode runs the native @@ -10,11 +12,7 @@ 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 local database, and in a linked checkout the `Target:` line adds -`default; pass --remote for <project>`. `--remote` selects the linked project, `--remote=<ref>` -another project, `--remote=<branch>` a branch of the linked project (by name or UUID) and -`--remote=<url>` another database. One `Target: …` line on stderr names the resolved database. In -migration mode a hosted target (a remote project or branch, or a non-local `--remote=<url>`) needs +In migration mode a hosted target (a remote project or branch, or a non-local `--remote=<url>`) needs one confirmation (default **No**) that covers writing the migration file(s) and recording them in the remote migration history; see "Consent" below. @@ -149,13 +147,12 @@ at all, so nothing is cached for it. ## Exit Codes -| Code | Condition | -| ---- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `0` | success (migration written and recorded in the history; declarative export) | -| `1` | conflicting target flags (`--remote`/`--linked`/`--db-url`/`--local`); `--declarative` with `--diff-engine`; migration-history conflict; **no schema changes ("No schema changes found")**; `--remote=<branch>` naming no branch of the parent project; 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`, `--db-url` or `--remote=<value>` | -| `1` | hosted confirmation declined (`context canceled`), or needed in a run that cannot prompt (non-interactive, JSON, stream-json, agent) without `--yes` | +| Code | Condition | +| ---- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `0` | success (migration written and recorded in the history; declarative export) | +| `1` | `--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` | 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 @@ -193,11 +190,6 @@ resolved `target` object is merged into the payload. - 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 @@ -206,24 +198,13 @@ resolved `target` object is merged into the payload. invalid value fails the command. A remote project or branch 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 `--remote=<url>` and for the local default. + temporary login role instead. - `--declarative` is mutually exclusive with `--diff-engine`. - `--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. -- **Target selection.** `--remote` is read as the linked project (bare), a project ref (20 - lowercase letters), a Postgres URL, or else a branch of the linked project by name or UUID; - `--remote=false` fails with `--remote doesn't take false. Leave it out to target the local database.`, and `--remote=` with `--remote= has an empty value. Pass --remote for the linked project or --remote=<ref|branch|url>; leave it out to target the local database.` A - bare `--remote` followed by a word that exactly matches a branch of the linked project, is within - a small edit distance of one, or can't be checked because the branch listing failed stops the run - with an error that names the cause (an HTTP status, or a missing or invalid access token) and offers both readings without echoing the word: `--remote=<branch>` for the branch, the command's operands then `--remote` for the linked project. Any other word runs as the operand. -- **Deprecated aliases.** `--linked`, `--project-ref=<ref>`, `--db-url=<url>` and `--local` still - work and print a deprecation warning on stderr naming the `--remote` replacement (`--local` - warns that local is the default). They are mutually exclusive with each other and with - `--remote`; a `false` switch counts as absent. `--project-ref` with `--local`, `--db-url` or - `--remote=<value>` is a hard error, and an empty `--db-url` or `--project-ref` fails with - `TargetEmptyValueError` instead of reading as absent. Unlike `SUPABASE_PROJECT_ID`, `--remote=<ref>` and - `--project-ref` do not affect the shadow container's project id/labels. +- Unlike `SUPABASE_PROJECT_ID`, `--remote=<ref>` and `--project-ref` do not affect the shadow + container's project id/labels. - 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/push/SIDE_EFFECTS.md b/apps/cli/src/commands/db/push/SIDE_EFFECTS.md index 9f2a6d5b7f..fbd3f76d5a 100644 --- a/apps/cli/src/commands/db/push/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/db/push/SIDE_EFFECTS.md @@ -1,11 +1,11 @@ # `supabase db push` +Target: selectable, consent default No — see [Command target](../../../../docs/command-target.md) + Applies pending local migrations (and optionally seed data and custom roles) to -the local database by default, or to the database `--remote` selects (the linked project, -`--remote=<ref>`, a branch via `--remote=<branch>`, or a database via `--remote=<url>`), +the local database by default, or to the database `--remote` selects, updating configured Vault secrets before migrations unless `--skip-vault` -is set. A hosted target asks one combined confirmation before the first write. With no -selector in a linked checkout, the `Target:` line adds `default; pass --remote for <project>`. +is set. A hosted target asks one combined confirmation before the first write. ## Files Read @@ -61,13 +61,10 @@ selector in a linked checkout, the `Target:` line adds `default; pass --remote f | Code | Condition | | ---- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `0` | success (including "up to date") | -| `1` | more than one of `--remote`, `--linked`, `--db-url` and `--local`, or `--project-ref` with `--local`/`--db-url`/`--remote=<value>` (target selector conflict) | -| `1` | `--remote=<branch>` naming no branch of the parent project | | `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 `--remote=<url>` or the local default (`--password can't be used with --remote=<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 | @@ -105,19 +102,6 @@ project ref and its environment. ## Notes -- **Targets**: with no selector the target is the local database. The target is announced as a - `Target:` banner on stderr before anything is connected. -- **Target selection.** `--remote` is read as the linked project (bare), a project ref (20 - lowercase letters), a Postgres URL, or else a branch of the linked project by name or UUID; - `--remote=false` fails with `--remote doesn't take false. Leave it out to target the local database.`, and `--remote=` with `--remote= has an empty value. Pass --remote for the linked project or --remote=<ref|branch|url>; leave it out to target the local database.` A - word after a bare `--remote` is read as its value, `--remote=<word>`, because the command takes no - operands. -- **Deprecated aliases.** `--linked`, `--project-ref=<ref>`, `--db-url=<url>` and `--local` still - work and print a deprecation warning on stderr naming the `--remote` replacement (`--local` - warns that local is the default). They are mutually exclusive with each other and with - `--remote`; a `false` switch counts as absent. `--project-ref` with `--local`, `--db-url` or - `--remote=<value>` is a hard error, and an empty `--db-url` or `--project-ref` fails with - `TargetEmptyValueError` instead of reading as absent. - **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 @@ -126,18 +110,11 @@ project ref and its environment. is withheld when the target project differs from the one in `.temp/project-ref`. The command prints `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>.` to stderr and mints a temporary login role instead. Unlinked workdirs use the env value. -- **`--password`** is rejected with `--remote=<url>` and for the local default, because those - targets carry their own credentials. - **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. The local default 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`. + `--dry-run` ask nothing, and neither does a run that finds nothing to apply. - **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. When the target matches a `[remotes.<name>]` block that does not diff --git a/apps/cli/src/commands/db/query/SIDE_EFFECTS.md b/apps/cli/src/commands/db/query/SIDE_EFFECTS.md index 3c892c94c3..6e16d22327 100644 --- a/apps/cli/src/commands/db/query/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/db/query/SIDE_EFFECTS.md @@ -1,15 +1,11 @@ # `supabase db query` +Target: selectable, consent default No — see [Command target](../../../../docs/command-target.md) + Implemented in `query.handler.ts`. Executes SQL against the local database (direct connection), a `--remote=<url>` database (direct connection) or a remote project or branch (Management API), then renders the result as a table or JSON. -With no selector the command runs against the local stack, and in a linked checkout the `Target:` -line adds `default; pass --remote for <project>`. `--remote` selects the linked project, -`--remote=<ref>` another project, `--remote=<branch>` a branch of the linked project (by name or -UUID) and `--remote=<url>` a custom database (see -[ADR 0033](../../../../../../docs/adr/0033-target-selection-and-hosted-mutation-consent.md)). - Any statement sent to a hosted target (remote project, branch or remote `--remote=<url>`) asks for one confirmation first, because the command cannot tell a read from a write. `--read-only` skips that confirmation and accepts only read statements in a read-only transaction (see Notes). The local @@ -52,23 +48,19 @@ stack never prompts. ## Exit Codes -| Code | Condition | -| ---- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `0` | success | -| `1` | more than one of `--remote`/`--linked`/`--db-url`/`--local` (`TargetSelectorConflictError`); no SQL provided; empty stdin; unreadable `--file`; remote project or branch without login; `--remote=<branch>` naming no branch of the parent project; query exec failure; non-201 remote status | -| `1` | `--project-ref` with `--local`, `--db-url` or `--remote=<value>` (`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 | +| Code | Condition | +| ---- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `0` | success | +| `1` | no SQL provided; empty stdin; unreadable `--file`; remote project or branch without login; query exec failure; non-201 remote status | +| `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 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**: alias deprecation warnings, the `Target: …` line (remote -project `Target: <name> (<ref>)`, branch `Target: project <parent> · branch <name> (<ref>)`, -`Target: local stack (<host>:<port>)` with ` · default; pass --remote for <project>` appended in a -linked checkout when no selector was given, `Target: custom Postgres (<host>:<port>)`) +payload). Diagnostics go to **stderr**: alias deprecation warnings, the `Target:` line 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. @@ -103,22 +95,9 @@ 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). -- **Target selection.** `--remote` is read as the linked project (bare), a project ref (20 - lowercase letters), a Postgres URL, or else a branch of the linked project by name or UUID; - `--remote=false` fails with `--remote doesn't take false. Leave it out to target the local database.`, and `--remote=` with `--remote= has an empty value. Pass --remote for the linked project or --remote=<ref|branch|url>; leave it out to target the local database.` A - bare `--remote` followed by a word that exactly matches a branch of the linked project, is within - a small edit distance of one, or can't be checked because the branch listing failed stops the run - with an error that names the cause (an HTTP status, or a missing or invalid access token) and offers both readings without echoing the word: `--remote=<branch>` for the branch, the command's operands then `--remote` for the linked project. Any other word runs as the operand. -- **Deprecated aliases.** `--linked`, `--project-ref=<ref>`, `--db-url=<url>` and `--local` still - work and print a deprecation warning on stderr naming the `--remote` replacement (`--local` - warns that local is the default). They are mutually exclusive with each other and with - `--remote`; a `false` switch counts as absent. `--project-ref` with `--local`, `--db-url` or - `--remote=<value>` is a hard error. - **Hosted consent.** A remote project, branch or remote `--remote=<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 + characters) as the detail and default No. 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. On every target, before the statement is sent or any question is asked, the SQL is split into statements diff --git a/apps/cli/src/commands/db/reset/SIDE_EFFECTS.md b/apps/cli/src/commands/db/reset/SIDE_EFFECTS.md index 05d522777e..2791750749 100644 --- a/apps/cli/src/commands/db/reset/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/db/reset/SIDE_EFFECTS.md @@ -1,5 +1,7 @@ # `supabase db reset` +Target: selectable, consent default No — see [Command target](../../../../docs/command-target.md) + Reinitialises a database from local migrations (plus seed). Both targets are fully native. The **remote** path (`--remote` for a project or branch, or a remote `--remote=<url>`) drops all user schemas, upserts vault secrets, then either re-applies migrations @@ -18,11 +20,7 @@ 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 selector the command resets the local stack, and in a linked checkout the `Target:` line -adds `default; pass --remote for <project>`. `--remote` selects the linked project, -`--remote=<ref>` another project, `--remote=<branch>` a branch of the linked project (by name or -UUID) and `--remote=<url>` a custom database (see -[ADR 0033](../../../../../../docs/adr/0033-target-selection-and-hosted-mutation-consent.md)). A hosted target (a remote project or branch, or a remote `--remote=<url>`) asks for one +A hosted target (a remote project or branch, or a remote `--remote=<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/ @@ -199,7 +197,6 @@ echoed, because those may genuinely have reached the server. | Code | Condition | | ---- | ------------------------------------------------------------------------------------------------------------------------------------------ | | `0` | success | -| `1` | more than one of `--remote`, `--linked`, `--db-url`, `--local` (`TargetSelectorConflictError`) | | `1` | `--version` + `--last` together (`[last version]`) | | `1` | `--version` not an integer (`invalid version number`) | | `1` | `--version` has no matching migration file | @@ -211,19 +208,14 @@ echoed, because those may genuinely have reached the server. | `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` with `--local`, `--db-url` or `--remote=<value>` (`TargetProjectRefConflictError`) | -| `1` | `--remote=<branch>` naming no branch of the parent project | Neither target delegates to a child process — every failure is a native, typed error surfaced as `1`. ## Output -Alias deprecation warnings print first. Every target prints a `Target: …` line to **stderr** -before the first database change: a remote project (`Target: <name> (<ref>)`), a branch -(`Target: project <parent> · branch <name> (<ref>)`), `Target: local stack (<host>:<port>)` with -` · default; pass --remote for <project>` appended in a linked checkout when no selector was given, -or `Target: custom Postgres (<host>:<port>)`. There is no `Connecting to … database…` line. +Alias deprecation warnings print first. Every target prints a `Target:` line to **stderr** +before the first database change. 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 @@ -272,26 +264,15 @@ 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.** `--remote` is read as the linked project (bare), a project ref (20 - lowercase letters), a Postgres URL, or else a branch of the linked project by name or UUID; - `--remote=false` fails with `--remote doesn't take false. Leave it out to target the local database.`, and `--remote=` with `--remote= has an empty value. Pass --remote for the linked project or --remote=<ref|branch|url>; leave it out to target the local database.` A - word after a bare `--remote` is read as its value, `--remote=<word>`, because the command takes no - operands. -- **Deprecated aliases.** `--linked`, `--project-ref=<ref>`, `--db-url=<url>` and `--local` still - work and print a deprecation warning on stderr naming the `--remote` replacement (`--local` - warns that local is the default). They are mutually exclusive with each other and with - `--remote`; a `false` switch counts as absent. `--project-ref` with `--local`, `--db-url` or - `--remote=<value>` is a hard error, and an empty `--db-url` or `--project-ref` fails with - `TargetEmptyValueError` instead of reading as absent. Unlike `SUPABASE_PROJECT_ID`, `--remote=<ref>` and - `--project-ref` do not affect the local container id. +- Unlike `SUPABASE_PROJECT_ID`, `--remote=<ref>` and `--project-ref` do not affect the local + container id. - **Target/local split** follows whether the resolved config points at the local stack, not the flag name: a `--remote=<url>` pointing at the local stack is treated as a local reset and never prompts. - **Hosted consent.** A remote project, branch or remote `--remote=<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. + plan. - **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, 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 14a53ced1a..5e180ec600 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 @@ -1,15 +1,13 @@ # `supabase db schema declarative generate` +Target: selectable, no consent — see [Command target](../../../../../../docs/command-target.md) + Generates declarative schema files from a database using pg-delta's managed 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>/`. -The command is `selectable` and never asks for hosted-mutation consent: the target is `--remote` -(the linked project), `--remote=<ref>`, `--remote=<branch>` (a branch of the linked project, by name -or UUID) or `--remote=<url>`, else the local database; in a linked checkout the default `Target:` -line adds `default; pass --remote for <project>`. 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 @@ -60,14 +58,11 @@ formatting without disabling safe compaction. ## 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 target flags (`--remote`, `--linked`, `--db-url`, `--local`), or `--project-ref` combined with `--local`/`--db-url`/`--remote=<value>` | -| `1` | `--password` combined with `--remote=<url>` or the local default | -| `1` | `--remote=<branch>` naming no branch of the linked project | -| `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` | local-database bring-up / pg-delta engine / export failure | 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 @@ -91,20 +86,6 @@ 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. -- The target comes from `resolveTarget`: `--remote` or one of its forms wins, else local. With no - selector the command exports the local database; it neither prompts for a target nor fails in - non-interactive mode. -- **Target selection.** `--remote` is read as the linked project (bare), a project ref (20 - lowercase letters), a Postgres URL, or else a branch of the linked project by name or UUID; - `--remote=false` fails with `--remote doesn't take false. Leave it out to target the local database.`, and `--remote=` with `--remote= has an empty value. Pass --remote for the linked project or --remote=<ref|branch|url>; leave it out to target the local database.` A - word after a bare `--remote` is read as its value, `--remote=<word>`, because the command takes no - operands. -- **Deprecated aliases.** `--linked`, `--project-ref=<ref>`, `--db-url=<url>` and `--local` still - work and print a deprecation warning on stderr naming the `--remote` replacement (`--local` - warns that local is the default). They are mutually exclusive with each other and with - `--remote`; a `false` switch counts as absent. `--project-ref` with `--local`, `--db-url` or - `--remote=<value>` is a hard error, and an empty `--db-url` or `--project-ref` fails with - `TargetEmptyValueError` instead of reading as absent. - `--reset` recreates the local database from the migrations before exporting; it has no effect on a remote project, branch or `--remote=<url>` target. - Existing declarative files are overwritten only after the export succeeds: without diff --git a/apps/cli/src/commands/db/test/SIDE_EFFECTS.md b/apps/cli/src/commands/db/test/SIDE_EFFECTS.md index fd22a7b2ed..df5e22c139 100644 --- a/apps/cli/src/commands/db/test/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/db/test/SIDE_EFFECTS.md @@ -1,5 +1,7 @@ # `supabase db test [path...]` +Target: selectable, consent default No — see [Command target](../../../../docs/command-target.md) + `db test` is a **hidden alias** for `supabase test db`. It shares the same flag config and handler: `test.command.ts` reuses `test db`'s flag config and assembled handler verbatim @@ -74,11 +76,6 @@ Identical to `test db`. See - Hidden command — registered with `.pipe(Command.unlisted)` in `../db.command.ts`. -- Target selection and the hosted-target confirmation are identical to `test db`: with no selector, - bare `supabase db test` runs against the local stack (a linked checkout adds - `default; pass --remote for <project>` to the `Target:` line), `--remote` and its forms select a - remote project, branch or database, and a remote project, branch or remote `--remote=<url>` run - asks for confirmation (or needs `--yes`) before pgTAP is enabled. The deprecated `--linked`, - `--project-ref`, `--db-url` and `--local` aliases warn on stderr. +- Target selection and the hosted-target confirmation are identical to `test db`. - 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/domains/SIDE_EFFECTS.md b/apps/cli/src/commands/domains/SIDE_EFFECTS.md index 06481bc7e9..78de585c3c 100644 --- a/apps/cli/src/commands/domains/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/domains/SIDE_EFFECTS.md @@ -1,5 +1,7 @@ # `supabase domains [create|get|reverify|activate|delete]` +Target: hosted, consent default Yes for `create`, No for `activate` and `delete`, no consent for `get` and `reverify` — see [Command target](../../../docs/command-target.md) + Custom hostname management. Every subcommand resolves a project ref and calls a single Management API custom-hostname endpoint. `create` additionally performs a Cloudflare DNS-over-HTTPS CNAME pre-check. @@ -98,13 +100,8 @@ suppressed on stderr. `delete` ignores `-o`. ## Notes -- Targets the linked project unless `--remote=<ref>` is given; a bare `--remote` also targets the linked project. `--remote=<value>` is read as a project ref (20 lowercase letters), a branch of the linked project (by name or UUID, resolved through `GET /v1/projects/{parent}/branches`, or `GET /v1/branches/{id}` for a UUID when no project is linked), or `true|yes|on|1` for the linked project. A database URL is rejected (these commands take a project, not a database); `false|no|off|0` and `--no-remote` fail with `--remote doesn't take false. Leave it out to use the linked project.`, and an empty value with `--remote= has an empty value. Pass --remote for the linked project or --remote=<ref|branch>; leave it out to use the linked project.` -- A word after a bare `--remote` is read as its value, `--remote=<word>`, because the command takes no operands. -- The deprecated `--project-ref` is an alias of `--remote=<ref>` and prints a deprecation warning on stderr that names the replacement; it cannot be combined with `--remote=<value>`. An empty `--project-ref` is rejected. - `--custom-hostname` is required for `create`. - `create` validates the CNAME via Cloudflare DNS-over-HTTPS (`https://1.1.1.1`, 10s timeout) before initializing; on failure it short-circuits before any POST. -- All subcommands resolve the ref via `--remote=<ref>` → `SUPABASE_PROJECT_ID` env → `supabase/.temp/project-ref` → interactive project picker (only when stdin is a TTY and output is interactive). -- The project-ref fallback env var is `SUPABASE_PROJECT_ID`. - **Notes:** - `--include-raw-output` is removed on every subcommand, `delete` included. Passing it (any value, including `=false`) fails with `--include-raw-output was removed.` and the suggestion `Use -o json instead.`, before credentials are resolved or any API work runs, so it needs no login. `RemovedSurfaceError` records `cli_command_executed` with `error_fingerprint` ending in `:removed_flag`, and the post-run `telemetry.json` read and write under `SUPABASE_HOME` still happens. The flag stays parsed (hidden) only so misuse reaches this error instead of an unknown-flag parse error; its rejection lives in `domains.removed-flag.ts`. - The degenerate `validation_records != 1` status message is an approximate struct dump (the exact text is not stable). @@ -112,9 +109,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 (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`. -- **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/encryption/SIDE_EFFECTS.md b/apps/cli/src/commands/encryption/SIDE_EFFECTS.md index bef0e1d8e1..afd04a94c5 100644 --- a/apps/cli/src/commands/encryption/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/encryption/SIDE_EFFECTS.md @@ -1,5 +1,7 @@ # `supabase encryption [get-root-key|update-root-key]` +Target: hosted, consent default No for `update-root-key`, no consent for `get-root-key` — see [Command target](../../../docs/command-target.md) + Manage a project's pgsodium root encryption key. Each subcommand resolves a project ref and calls one Management API endpoint. `update-root-key` additionally reads the new key from stdin. @@ -83,16 +85,11 @@ One `result` event carrying `{root_key}` (both subcommands). ## Notes -- Targets the linked project unless `--remote=<ref>` is given; a bare `--remote` also targets the linked project. `--remote=<value>` is read as a project ref (20 lowercase letters), a branch of the linked project (by name or UUID, resolved through `GET /v1/projects/{parent}/branches`, or `GET /v1/branches/{id}` for a UUID when no project is linked), or `true|yes|on|1` for the linked project. A database URL is rejected (these commands take a project, not a database); `false|no|off|0` and `--no-remote` fail with `--remote doesn't take false. Leave it out to use the linked project.`, and an empty value with `--remote= has an empty value. Pass --remote for the linked project or --remote=<ref|branch>; leave it out to use the linked project.` -- A word after a bare `--remote` is read as its value, `--remote=<word>`, because the command takes no operands. -- The deprecated `--project-ref` is an alias of `--remote=<ref>` and prints a deprecation warning on stderr that names the replacement; it cannot be combined with `--remote=<value>`. An empty `--project-ref` is rejected. -- Requires `--remote=<ref>`, `SUPABASE_PROJECT_ID`, or a linked project. - `update-root-key` reads the key from stdin: a real TTY is read with a masked prompt; piped stdin is decoded as UTF-8 and whitespace-trimmed. An empty or whitespace-only key sends an empty `root_key` (the TTY masked prompt also trims). - 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: …` 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. +- `update-root-key` asks `Replace the project's root encryption key on <target>?` (default No) before the first write. - 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/experimental/compute/delete/SIDE_EFFECTS.md b/apps/cli/src/commands/experimental/compute/delete/SIDE_EFFECTS.md index 6dbadb8706..2985a0ef26 100644 --- a/apps/cli/src/commands/experimental/compute/delete/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/experimental/compute/delete/SIDE_EFFECTS.md @@ -1,5 +1,7 @@ # `supabase compute delete <name>` +Target: hosted, consent default No — see [Command target](../../../../../docs/command-target.md) + ## Feature gate This command is registered only when `experimental.compute` is enabled. Set @@ -39,13 +41,9 @@ 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 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. +deleted. Under `--yes` the typed name is skipped and `Confirmed by --yes: …` is printed +on stderr. The action line and the running-instance tally (when one is known) are +written to stderr before the typed-name prompt. ## API Routes @@ -107,9 +105,3 @@ A structured emission is the end of the run: the handler returns at branch below it — the kept-nothing notice and the redeploy trailer — is reached. `-o pretty`, `table` and `csv` are the exception, since they encode nothing and fall through to that same text branch. - -## Notes - -- Targets the linked project unless `--remote=<ref>` is given; a bare `--remote` also targets the linked project. `--remote=<value>` is read as a project ref (20 lowercase letters), a branch of the linked project (by name or UUID, resolved through `GET /v1/projects/{parent}/branches`, or `GET /v1/branches/{id}` for a UUID when no project is linked), or `true|yes|on|1` for the linked project. A database URL is rejected (`supabase experimental compute delete` takes a project, not a database); `false|no|off|0` and `--no-remote` fail with `--remote doesn't take false. Leave it out to use the linked project.`, and an empty value with `--remote= has an empty value. Pass --remote for the linked project or --remote=<ref|branch>; leave it out to use the linked project.` -- A bare `--remote` followed by a word that exactly matches a branch of the linked project, is within a small edit distance of one, or can't be checked because the branch listing failed stops the run before any other work with an error that names the cause (an HTTP status, or a missing or invalid access token) and offers both readings without echoing the word: `--remote=<branch>` for the branch, the command's operands then `--remote` for the linked project. Any other word runs as the operand. -- The deprecated `--project-ref` is an alias of `--remote=<ref>` and prints a deprecation warning on stderr that names the replacement; it cannot be combined with `--remote=<value>`. An empty `--project-ref` is rejected. diff --git a/apps/cli/src/commands/experimental/compute/list/SIDE_EFFECTS.md b/apps/cli/src/commands/experimental/compute/list/SIDE_EFFECTS.md index dfe6c842dd..80111d0a65 100644 --- a/apps/cli/src/commands/experimental/compute/list/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/experimental/compute/list/SIDE_EFFECTS.md @@ -1,5 +1,7 @@ # `supabase compute list` +Target: hosted, no consent — see [Command target](../../../../../docs/command-target.md) + ## Feature gate This command is registered only when `experimental.compute` is enabled. Set @@ -84,9 +86,3 @@ wrapper emits for every command. The text table omits each compute's URL — it is the same host and prefix on every row, and carrying it made the table 137 columns wide. Every machine format still carries `url` per compute, and `compute status` renders it. - -## Notes - -- Targets the linked project unless `--remote=<ref>` is given; a bare `--remote` also targets the linked project. `--remote=<value>` is read as a project ref (20 lowercase letters), a branch of the linked project (by name or UUID, resolved through `GET /v1/projects/{parent}/branches`, or `GET /v1/branches/{id}` for a UUID when no project is linked), or `true|yes|on|1` for the linked project. A database URL is rejected (`supabase experimental compute list` takes a project, not a database); `false|no|off|0` and `--no-remote` fail with `--remote doesn't take false. Leave it out to use the linked project.`, and an empty value with `--remote= has an empty value. Pass --remote for the linked project or --remote=<ref|branch>; leave it out to use the linked project.` -- A word after a bare `--remote` is read as its value, `--remote=<word>`, because the command takes no operands. -- The deprecated `--project-ref` is an alias of `--remote=<ref>` and prints a deprecation warning on stderr that names the replacement; it cannot be combined with `--remote=<value>`. An empty `--project-ref` is rejected. diff --git a/apps/cli/src/commands/experimental/compute/logs/SIDE_EFFECTS.md b/apps/cli/src/commands/experimental/compute/logs/SIDE_EFFECTS.md index eb76f09862..060fdb61a1 100644 --- a/apps/cli/src/commands/experimental/compute/logs/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/experimental/compute/logs/SIDE_EFFECTS.md @@ -1,5 +1,7 @@ # `supabase compute logs <name>` +Target: hosted, no consent — see [Command target](../../../../../docs/command-target.md) + ## Feature gate This command is registered only when `experimental.compute` is enabled. Set @@ -167,9 +169,3 @@ A `worker_guest_logs` message is bytes the tenant's own code printed. Control an escape sequences are stripped before it reaches a terminal, so a compute cannot reposition the cursor or forge CLI output; interior newlines and indentation are preserved so a stack trace survives intact. - -## Notes - -- Targets the linked project unless `--remote=<ref>` is given; a bare `--remote` also targets the linked project. `--remote=<value>` is read as a project ref (20 lowercase letters), a branch of the linked project (by name or UUID, resolved through `GET /v1/projects/{parent}/branches`, or `GET /v1/branches/{id}` for a UUID when no project is linked), or `true|yes|on|1` for the linked project. A database URL is rejected (`supabase experimental compute logs` takes a project, not a database); `false|no|off|0` and `--no-remote` fail with `--remote doesn't take false. Leave it out to use the linked project.`, and an empty value with `--remote= has an empty value. Pass --remote for the linked project or --remote=<ref|branch>; leave it out to use the linked project.` -- A bare `--remote` followed by a word that exactly matches a branch of the linked project, is within a small edit distance of one, or can't be checked because the branch listing failed stops the run before any other work with an error that names the cause (an HTTP status, or a missing or invalid access token) and offers both readings without echoing the word: `--remote=<branch>` for the branch, the command's operands then `--remote` for the linked project. Any other word runs as the operand. -- The deprecated `--project-ref` is an alias of `--remote=<ref>` and prints a deprecation warning on stderr that names the replacement; it cannot be combined with `--remote=<value>`. An empty `--project-ref` is rejected. 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 a2a44a850c..876f58c764 100644 --- a/apps/cli/src/commands/experimental/compute/push/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/experimental/compute/push/SIDE_EFFECTS.md @@ -1,5 +1,7 @@ # `supabase compute push [name...] (alias: deploy)` +Target: hosted, consent default No — see [Command target](../../../../../docs/command-target.md) + ## Feature gate This command is registered only when `experimental.compute` is enabled. Set @@ -35,12 +37,7 @@ and the command handler does not run. See the [Compute command guide](../../../. ## 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. +**No**). Under `--yes` it prints `Confirmed by --yes: …` on stderr instead of asking. ## API Routes @@ -191,9 +188,3 @@ adding a hoisted `node_modules` link to `exclude` is a real answer to The presigned upload above is the one request whose URL is itself a credential. `--debug` logs every request URL, so `httpClientLayer` redacts query strings that carry a signature. - -## Notes - -- Targets the linked project unless `--remote=<ref>` is given; a bare `--remote` also targets the linked project. `--remote=<value>` is read as a project ref (20 lowercase letters), a branch of the linked project (by name or UUID, resolved through `GET /v1/projects/{parent}/branches`, or `GET /v1/branches/{id}` for a UUID when no project is linked), or `true|yes|on|1` for the linked project. A database URL is rejected (`supabase experimental compute push` takes a project, not a database); `false|no|off|0` and `--no-remote` fail with `--remote doesn't take false. Leave it out to use the linked project.`, and an empty value with `--remote= has an empty value. Pass --remote for the linked project or --remote=<ref|branch>; leave it out to use the linked project.` -- A bare `--remote` followed by a word that exactly matches a branch of the linked project, is within a small edit distance of one, or can't be checked because the branch listing failed stops the run before any other work with an error that names the cause (an HTTP status, or a missing or invalid access token) and offers both readings without echoing the word: `--remote=<branch>` for the branch, the command's operands then `--remote` for the linked project. Any other word runs as the operand. -- The deprecated `--project-ref` is an alias of `--remote=<ref>` and prints a deprecation warning on stderr that names the replacement; it cannot be combined with `--remote=<value>`. An empty `--project-ref` is rejected. diff --git a/apps/cli/src/commands/experimental/compute/status/SIDE_EFFECTS.md b/apps/cli/src/commands/experimental/compute/status/SIDE_EFFECTS.md index b0788d8306..10a6d9496d 100644 --- a/apps/cli/src/commands/experimental/compute/status/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/experimental/compute/status/SIDE_EFFECTS.md @@ -1,5 +1,7 @@ # `supabase compute status <name>` +Target: hosted, no consent — see [Command target](../../../../../docs/command-target.md) + ## Feature gate This command is registered only when `experimental.compute` is enabled. Set @@ -88,9 +90,3 @@ A structured emission is the end of the run: the handler returns at branch below it — the instance tally and the build-retry trailer — is reached. `-o pretty`, `table` and `csv` are the exception, since they encode nothing and fall through to that same text branch. - -## Notes - -- Targets the linked project unless `--remote=<ref>` is given; a bare `--remote` also targets the linked project. `--remote=<value>` is read as a project ref (20 lowercase letters), a branch of the linked project (by name or UUID, resolved through `GET /v1/projects/{parent}/branches`, or `GET /v1/branches/{id}` for a UUID when no project is linked), or `true|yes|on|1` for the linked project. A database URL is rejected (`supabase experimental compute status` takes a project, not a database); `false|no|off|0` and `--no-remote` fail with `--remote doesn't take false. Leave it out to use the linked project.`, and an empty value with `--remote= has an empty value. Pass --remote for the linked project or --remote=<ref|branch>; leave it out to use the linked project.` -- A bare `--remote` followed by a word that exactly matches a branch of the linked project, is within a small edit distance of one, or can't be checked because the branch listing failed stops the run before any other work with an error that names the cause (an HTTP status, or a missing or invalid access token) and offers both readings without echoing the word: `--remote=<branch>` for the branch, the command's operands then `--remote` for the linked project. Any other word runs as the operand. -- The deprecated `--project-ref` is an alias of `--remote=<ref>` and prints a deprecation warning on stderr that names the replacement; it cannot be combined with `--remote=<value>`. An empty `--project-ref` is rejected. diff --git a/apps/cli/src/commands/feedback/add/SIDE_EFFECTS.md b/apps/cli/src/commands/feedback/add/SIDE_EFFECTS.md index 844fbba57f..3e78dbe3a9 100644 --- a/apps/cli/src/commands/feedback/add/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/feedback/add/SIDE_EFFECTS.md @@ -1,5 +1,7 @@ # `supabase feedback add [message...] [--project-ref <ref>]` +Target: account, no consent — see [Command target](../../../../docs/command-target.md) + ## Files Read | Path | Format | When | diff --git a/apps/cli/src/commands/feedback/delete/SIDE_EFFECTS.md b/apps/cli/src/commands/feedback/delete/SIDE_EFFECTS.md index 9003a4d648..746fb6fc60 100644 --- a/apps/cli/src/commands/feedback/delete/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/feedback/delete/SIDE_EFFECTS.md @@ -1,5 +1,7 @@ # `supabase feedback delete <token> [--project-ref <ref>]` +Target: account, consent default No — see [Command target](../../../../docs/command-target.md) + ## Files Read | Path | Format | When | @@ -87,11 +89,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` / 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 }`. +Under `--yes` it prints `Confirmed by --yes: Permanently delete this feedback` on stderr +instead of asking. `-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 }`. ### `--output-format json` diff --git a/apps/cli/src/commands/functions/delete/SIDE_EFFECTS.md b/apps/cli/src/commands/functions/delete/SIDE_EFFECTS.md index 0a9b8dcc88..0219eb4543 100644 --- a/apps/cli/src/commands/functions/delete/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/functions/delete/SIDE_EFFECTS.md @@ -1,5 +1,7 @@ # `supabase functions delete <Function name>` +Target: hosted, consent default No — see [Command target](../../../../docs/command-target.md) + ## Files Read | Path | Format | When | @@ -72,13 +74,8 @@ Prints the same structured success result, including `target`, and needs `--yes` ## Notes -- Targets the linked project unless `--remote=<ref>` is given; a bare `--remote` also targets the linked project. `--remote=<value>` is read as a project ref (20 lowercase letters), a branch of the linked project (by name or UUID, resolved through `GET /v1/projects/{parent}/branches`, or `GET /v1/branches/{id}` for a UUID when no project is linked), or `true|yes|on|1` for the linked project. A database URL is rejected (`supabase functions delete` takes a project, not a database); `false|no|off|0` and `--no-remote` fail with `--remote doesn't take false. Leave it out to use the linked project.`, and an empty value with `--remote= has an empty value. Pass --remote for the linked project or --remote=<ref|branch>; leave it out to use the linked project.` -- A bare `--remote` followed by a word that exactly matches a branch of the linked project, is within a small edit distance of one, or can't be checked because the branch listing failed stops the run before any other work with an error that names the cause (an HTTP status, or a missing or invalid access token) and offers both readings without echoing the word: `--remote=<branch>` for the branch, the command's operands then `--remote` for the linked project. Any other word runs as the operand. -- The deprecated `--project-ref` is an alias of `--remote=<ref>` and prints a deprecation warning on stderr that names the replacement; it cannot be combined with `--remote=<value>`. An empty `--project-ref` is rejected. - Requires exactly one argument: the function slug/name. - Does NOT remove the function from the local filesystem. -- Requires a linked project (`--remote=<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. - 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 201f8f08ea..b045a7f727 100644 --- a/apps/cli/src/commands/functions/deploy/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/functions/deploy/SIDE_EFFECTS.md @@ -1,5 +1,7 @@ # `supabase functions deploy [Function name]` +Target: hosted, consent default No — see [Command target](../../../../docs/command-target.md) + ## Files Read | Path | Format | When | @@ -113,29 +115,14 @@ The `--output`/`-o` flag does not change deploy output. directory, forward slashes). Imports outside the workdir but inside the nearest git root still upload, with `../`-relative names. The nearest git root is the containment boundary for uploaded imports. -- Requires a linked project unless `--remote=<ref|branch>` is given. A bare `--remote` targets the - linked project. -- `--remote=<value>` is read as a project ref (20 lowercase letters), a branch of the linked - project (by name or UUID), or `true|yes|on|1` for the linked project. A database URL is rejected - (`functions deploy` takes a project, not a database); `false|no|off|0` and `--no-remote` fail with `--remote doesn't take false. Leave it out to use the linked project.`, and an empty value with `--remote= has an empty value. Pass --remote for the linked project or --remote=<ref|branch>; leave it out to use the linked project.` -- A bare `--remote` followed by a word that exactly matches a branch of the linked project, is - within a small edit distance of one, or can't be checked because the branch listing failed stops - the run before any other work with an error that names the cause (an HTTP status, or a missing or invalid access token) and offers both readings without echoing the word: `--remote=<branch>` for the branch, the command's operands then `--remote` for the linked project. Any other word runs as the operand. -- The deprecated `--project-ref` is an alias of `--remote=<ref>` and prints a deprecation warning - on stderr that names the replacement; it cannot be combined with `--remote=<value>`. - 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. -- Prints the `Target:` banner (project name, ref and, for a branch, the parent project) before - anything else. +- Prints the target banner 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 diff --git a/apps/cli/src/commands/functions/download/SIDE_EFFECTS.md b/apps/cli/src/commands/functions/download/SIDE_EFFECTS.md index 810ff6cc81..3a0e2fc12f 100644 --- a/apps/cli/src/commands/functions/download/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/functions/download/SIDE_EFFECTS.md @@ -1,5 +1,7 @@ # `supabase functions download [Function name]` +Target: hosted, no consent — see [Command target](../../../../docs/command-target.md) + ## Files Read | Path | Format | When | @@ -104,15 +106,10 @@ Same envelope as `json` above (including on the Docker-unbundle path). ## Notes -- Targets the linked project unless `--remote=<ref>` is given; a bare `--remote` also targets the linked project. `--remote=<value>` is read as a project ref (20 lowercase letters), a branch of the linked project (by name or UUID, resolved through `GET /v1/projects/{parent}/branches`, or `GET /v1/branches/{id}` for a UUID when no project is linked), or `true|yes|on|1` for the linked project. A database URL is rejected (`supabase functions download` takes a project, not a database); `false|no|off|0` and `--no-remote` fail with `--remote doesn't take false. Leave it out to use the linked project.`, and an empty value with `--remote= has an empty value. Pass --remote for the linked project or --remote=<ref|branch>; leave it out to use the linked project.` -- A bare `--remote` followed by a word that exactly matches a branch of the linked project, is within a small edit distance of one, or can't be checked because the branch listing failed stops the run before any other work with an error that names the cause (an HTTP status, or a missing or invalid access token) and offers both readings without echoing the word: `--remote=<branch>` for the branch, the command's operands then `--remote` for the linked project. Any other word runs as the operand. -- The deprecated `--project-ref` is an alias of `--remote=<ref>` and prints a deprecation warning on stderr that names the replacement; it cannot be combined with `--remote=<value>`. An empty `--project-ref` is rejected. - **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, including an unparsable `SUPABASE_*` override, fails the command. - If no function name is provided, downloads all functions. -- Requires a linked project (`--remote=<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/list/SIDE_EFFECTS.md b/apps/cli/src/commands/functions/list/SIDE_EFFECTS.md index d7b2a06648..694b79836b 100644 --- a/apps/cli/src/commands/functions/list/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/functions/list/SIDE_EFFECTS.md @@ -1,5 +1,7 @@ # `supabase functions list` +Target: hosted, no consent — see [Command target](../../../../docs/command-target.md) + ## Files Read | Path | Format | When | @@ -69,12 +71,7 @@ Prints the same structured success result, including `target`. ## Notes -- Targets the linked project unless `--remote=<ref>` is given; a bare `--remote` also targets the linked project. `--remote=<value>` is read as a project ref (20 lowercase letters), a branch of the linked project (by name or UUID, resolved through `GET /v1/projects/{parent}/branches`, or `GET /v1/branches/{id}` for a UUID when no project is linked), or `true|yes|on|1` for the linked project. A database URL is rejected (`supabase functions list` takes a project, not a database); `false|no|off|0` and `--no-remote` fail with `--remote doesn't take false. Leave it out to use the linked project.`, and an empty value with `--remote= has an empty value. Pass --remote for the linked project or --remote=<ref|branch>; leave it out to use the linked project.` -- A word after a bare `--remote` is read as its value, `--remote=<word>`, because the command takes no operands. -- The deprecated `--project-ref` is an alias of `--remote=<ref>` and prints a deprecation warning on stderr that names the replacement; it cannot be combined with `--remote=<value>`. An empty `--project-ref` is rejected. -- Requires a linked project (`--remote=<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, without `target`. - `yaml` emits the raw array, without `target`. diff --git a/apps/cli/src/commands/gen/types/SIDE_EFFECTS.md b/apps/cli/src/commands/gen/types/SIDE_EFFECTS.md index 630bda1cf6..e561e29cf2 100644 --- a/apps/cli/src/commands/gen/types/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/gen/types/SIDE_EFFECTS.md @@ -1,5 +1,7 @@ # `supabase gen types` +Target: selectable, no consent — see [Command target](../../../../docs/command-target.md) + Generates PostgREST client types against a direct PostgreSQL connection. Introspection runs in-process via `@supabase/postgrest-typegen`; the language comes from the `@supabase/typegen` registry, which lists every `--lang` value and either calls that language's generator in-process @@ -7,15 +9,11 @@ registry, which lists every `--lang` value and either calls that language's gene was run from, with the introspected document on stdin (`--lang dart` runs `dart run supabase_typegen --output -`, `--lang swift` runs supabase-swift's `supabase-typegen`). A bump of that dependency can add a `--lang` value or a -language flag; the CLI names no language itself. The command is `selectable` and never asks for -hosted-mutation consent. With no selector it targets the local database; `--remote` targets a remote -project: bare for the linked project, `--remote=<ref>`, `--remote=<branch>` for a branch of the -linked project, or `--remote=<db-url>` for a database URL. TypeScript output for a remote project +language flag; the CLI names no language itself. TypeScript output for a remote project still comes from the Management API; every other language and target (including local and a database 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_*`. `--linked`, `--project-ref`, `--project-id`, `--db-url` and -`--local` are deprecated aliases. +instead of inspecting `supabase_db_*`. ## Files Read @@ -116,9 +114,6 @@ way `--remote=<db-url>` does. | Code | Condition | | ---- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `0` | success — types printed to stdout | -| `1` | conflicting target flags (see Notes): two selectors, `--project-ref` or `--project-id` combined with `--local`, `--db-url` or `--remote=<value>`, or `--project-id` combined with `--project-ref` (`TargetSelectorConflictError`, `TargetProjectRefConflictError`) | -| `1` | an empty `--db-url`, `--project-ref` or `--project-id` (`TargetEmptyValueError`), `--remote` switched off or given an empty value | -| `1` | `--remote=<branch>` matches no branch of the linked project (`LinkBranchNotFoundError`, lists the branches that exist) | | `1` | a remote project selector (`--remote`, `--remote=<ref>`, `--remote=<branch>`, `--linked`, `--project-ref` or `--project-id`) combined with `--postgrest-v9-compat`, `--query-timeout` or a language flag (`GenTypesFlagUsageError`); `--remote=<db-url>` is outside the group | | `1` | `--postgrest-v9-compat` used without `--remote=<db-url>` or `--db-url` (`GenTypesFlagUsageError`) | | `1` | a positional language other than `typescript` passed without `--lang` (`GenTypesFlagUsageError`) | @@ -138,14 +133,10 @@ way `--remote=<db-url>` does. ### `--output-format text` 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. With the local default in a -linked directory the banner ends with `· default; pass --remote for <project>`. 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. -Each deprecated selector additionally prints its warning on stderr, for example -`--project-id is deprecated and will be removed in v4. Use --remote=<ref> instead: supabase gen types --remote=<ref>`. +Diagnostics go to stderr: alias deprecation warnings, one `Target:` banner 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. ### `--output-format json` @@ -159,26 +150,10 @@ Not applicable. - TypeScript generated in-process is unformatted. stderr suggests `npx oxfmt <generated-file.ts>`. TypeScript for a remote project still comes from the Management API. -- **Target.** With no selector the command runs against the local database; when the directory is - linked the local `Target:` banner ends with `· default; pass --remote for <project>`. `--remote` - targets the linked project (`SUPABASE_PROJECT_ID`, else `.temp/project-ref`). `--remote=<value>` - is read as a project ref (20 lowercase letters), a database URL (`postgres://` or - `postgresql://`), `true|yes|on|1` (the linked project), or otherwise the name of a branch of the - linked project, which resolves to that branch's own project. `false|no|off|0` and `--no-remote` fail with `--remote doesn't take false. Leave it out to target the local database.`, and an empty value with `--remote= has an empty value. Pass --remote for the linked project or --remote=<ref|branch|url>; leave it out to target the local database.` `--remote <word>` is always read as - `--remote=<word>`: the legacy positional language is hidden and takes no part in that reading, so a - language goes before `--remote` (`gen types typescript --remote`). The - resolved target is announced on stderr (`Target: …`) in every output format. -- **Deprecated selectors.** `--linked`, `--project-ref`, `--project-id`, `--db-url` and `--local` - are hidden aliases that still select their target and print a warning on stderr naming the - replacement, for example - `--linked is deprecated and will be removed in v4. Use --remote instead: supabase gen types --remote`, - `--project-id is deprecated and will be removed in v4. Use --remote=<ref> instead: supabase gen types --remote=<ref>` - and `--local isn't needed (local is the default) and will be removed in v4: supabase gen types`; a - database URL is never echoed. Two selectors together fail - (`Only one of --remote, --linked, --db-url and --local can be set; got …`), `--project-ref` and - `--project-id` cannot be combined with `--local`, `--db-url` or `--remote=<value>`, an empty - `--db-url`, `--project-ref` or `--project-id` is rejected, and `--linked=false` selects nothing. - `--project-id` is an alias of `--project-ref`; passing both fails with +- **`--remote <word>`.** A word after `--remote` is always read as `--remote=<word>`: the legacy positional + language is hidden and takes no part in that reading, so a language goes before `--remote` + (`gen types typescript --remote`). +- **`--project-id`.** A hidden alias of the deprecated `--project-ref`; passing both fails with `--project-id is a deprecated alias of --project-ref; pass only one of them.` - **Remote project flag groups.** `--remote` (bare, or with a ref or branch), `--linked`, `--project-ref` and `--project-id` count as one member of each group diff --git a/apps/cli/src/commands/inspect/db/SIDE_EFFECTS.md b/apps/cli/src/commands/inspect/db/SIDE_EFFECTS.md index ab9e333128..016a0e70a2 100644 --- a/apps/cli/src/commands/inspect/db/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/inspect/db/SIDE_EFFECTS.md @@ -1,11 +1,11 @@ # `supabase inspect db <subcommand>` +Target: selectable, no consent — see [Command target](../../../../docs/command-target.md) + 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 the -target from `--remote` (or a deprecated alias; local by default), runs one read-only +their 12 deprecated aliases. Every subcommand has the same surface — it resolves its target, 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. +the columns rendered (see the per-subcommand `<name>.query.ts`). ## Files Read @@ -88,12 +88,10 @@ flags, run no query and open no connection, and exit 1 with ## Exit Codes -| Code | Condition | -| ---- | ---------------------------------------------------------------------------------------------------------------------- | -| `0` | success | -| `1` | conflicting target selectors, empty `--db-url`/`--project-ref`, resolution, connection, or query failure | -| `1` | `--project-ref` combined with `--local`, `--db-url` or `--remote=<value>` | -| `1` | `--remote=<branch>` matches no branch of the linked project (`LinkBranchNotFoundError`, lists the branches that exist) | +| Code | Condition | +| ---- | ---------------------------------------- | +| `0` | success | +| `1` | resolution, connection, or query failure | ## Telemetry Events Fired @@ -130,24 +128,6 @@ Emit one extra stderr line before the table: ## Notes -- **Target.** With no selector the command runs against the local database; when the directory is - linked the local `Target:` banner ends with `· default; pass --remote for <project>`. `--remote` - targets the linked project (`SUPABASE_PROJECT_ID`, else `.temp/project-ref`). `--remote=<value>` - is read as a project ref (20 lowercase letters), a database URL (`postgres://` or - `postgresql://`), `true|yes|on|1` (the linked project), or otherwise the name of a branch of the - linked project, which resolves to that branch's own project. `false|no|off|0` and `--no-remote` fail with `--remote doesn't take false. Leave it out to target the local database.`, and an empty value with `--remote= has an empty value. Pass --remote for the linked project or --remote=<ref|branch|url>; leave it out to target the local database.` A word after a bare - `--remote` is read as its value, `--remote=<word>`, because the command takes no operands. The - resolved target is announced on stderr (`Target: …`) in every output format and, in JSON results, - as a `target` object. -- **Deprecated selectors.** `--linked`, `--project-ref`, `--db-url` and `--local` are hidden aliases - that still select their target and print a warning on stderr naming the replacement, for example - `--linked is deprecated and will be removed in v4. Use --remote instead: supabase inspect db locks --remote` - and - `--local isn't needed (local is the default) and will be removed in v4: supabase inspect db locks`; - a database URL is never echoed. Two selectors together fail - (`Only one of --remote, --linked, --db-url and --local can be set; got …`), `--project-ref` cannot - be combined with `--local`, `--db-url` or `--remote=<value>`, an empty `--db-url` or - `--project-ref` is rejected, and `--linked=false` selects nothing. - The Management API stack is built lazily, only for a remote project or a branch lookup, so a local or database-URL target never requires an access token. - All queries are read-only `SELECT`s; the command performs no writes to the database. diff --git a/apps/cli/src/commands/inspect/report/SIDE_EFFECTS.md b/apps/cli/src/commands/inspect/report/SIDE_EFFECTS.md index 08a2aad0bc..3e53650a79 100644 --- a/apps/cli/src/commands/inspect/report/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/inspect/report/SIDE_EFFECTS.md @@ -1,9 +1,10 @@ # `supabase inspect report` +Target: selectable, no consent — see [Command target](../../../../docs/command-target.md) + 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. The target is `--remote` (or a deprecated alias), else the local -database. The command is `selectable` and never asks for hosted-mutation consent. +validating those CSVs. ## Files Read @@ -85,16 +86,13 @@ connect retries run out while bans are still listed. ## 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` | -| `1` | conflicting target selectors, or an empty `--db-url` / `--project-ref` | -| `1` | `--project-ref` combined with `--local`, `--db-url` or `--remote=<value>` | -| `1` | `--remote=<branch>` matches no branch of the linked project (`LinkBranchNotFoundError`, lists the branches that exist) | +| 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` | A **per-rule** csvq evaluation error does **not** fail the command — it becomes the rule's STATUS cell. @@ -130,24 +128,3 @@ instead a structured result is emitted. The stderr `Target:` banner is still pri ```json { "outputDir": "<abs path>", "files": [{ "name": "locks", "path": "..." }, ...], "rules": [{ "name": "...", "status": "...", "matches": "..." }, ...], "target": { ... } } ``` - -## Notes - -- **Target.** With no selector the command runs against the local database; when the directory is - linked the local `Target:` banner ends with `· default; pass --remote for <project>`. `--remote` - targets the linked project (`SUPABASE_PROJECT_ID`, else `.temp/project-ref`). `--remote=<value>` - is read as a project ref (20 lowercase letters), a database URL (`postgres://` or - `postgresql://`), `true|yes|on|1` (the linked project), or otherwise the name of a branch of the - linked project, which resolves to that branch's own project. `false|no|off|0` and `--no-remote` fail with `--remote doesn't take false. Leave it out to target the local database.`, and an empty value with `--remote= has an empty value. Pass --remote for the linked project or --remote=<ref|branch|url>; leave it out to target the local database.` A word after a bare - `--remote` is read as its value, `--remote=<word>`, because the command takes no operands. The - resolved target is announced on stderr (`Target: …`) in every output format and, in JSON results, - as a `target` object. -- **Deprecated selectors.** `--linked`, `--project-ref`, `--db-url` and `--local` are hidden aliases - that still select their target and print a warning on stderr naming the replacement, for example - `--linked is deprecated and will be removed in v4. Use --remote instead: supabase inspect report --remote` - and - `--local isn't needed (local is the default) and will be removed in v4: supabase inspect report`; - a database URL is never echoed. Two selectors together fail - (`Only one of --remote, --linked, --db-url and --local can be set; got …`), `--project-ref` cannot - be combined with `--local`, `--db-url` or `--remote=<value>`, an empty `--db-url` or - `--project-ref` is rejected, and `--linked=false` selects nothing. diff --git a/apps/cli/src/commands/link/SIDE_EFFECTS.md b/apps/cli/src/commands/link/SIDE_EFFECTS.md index 8a1c3d38c7..a97ca27729 100644 --- a/apps/cli/src/commands/link/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/link/SIDE_EFFECTS.md @@ -1,5 +1,7 @@ # `supabase link` +Target: hosted, no consent — see [Command target](../../../docs/command-target.md) + Writes flat state files under `<workdir>/supabase/.temp/`. `link` accepts an optional `[ref-or-branch]` positional diff --git a/apps/cli/src/commands/login/SIDE_EFFECTS.md b/apps/cli/src/commands/login/SIDE_EFFECTS.md index 97c7aa268e..4cdbd876ca 100644 --- a/apps/cli/src/commands/login/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/login/SIDE_EFFECTS.md @@ -1,5 +1,7 @@ # `supabase login` +Target: account, no consent — see [Command target](../../../docs/command-target.md) + Writes the access token, then stitches/clears the telemetry identity and captures `cli_login_completed`. diff --git a/apps/cli/src/commands/logout/SIDE_EFFECTS.md b/apps/cli/src/commands/logout/SIDE_EFFECTS.md index 6220fa67db..bbeba6da0c 100644 --- a/apps/cli/src/commands/logout/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/logout/SIDE_EFFECTS.md @@ -1,5 +1,7 @@ # `supabase logout` +Target: account, no consent — see [Command target](../../../docs/command-target.md) + Deletes the access token and sweeps all stored project credentials. Makes no API calls. diff --git a/apps/cli/src/commands/migration/down/SIDE_EFFECTS.md b/apps/cli/src/commands/migration/down/SIDE_EFFECTS.md index 423cd9add5..6d04dc3dbd 100644 --- a/apps/cli/src/commands/migration/down/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/migration/down/SIDE_EFFECTS.md @@ -1,5 +1,7 @@ # `supabase migration down` +Target: selectable, consent default No — see [Command target](../../../../docs/command-target.md) + ## Files Read | Path | Format | When | @@ -42,15 +44,13 @@ Local targets and database URLs make none. ## Exit Codes -| Code | Condition | -| ---- | ---------------------------------------------------------------------------------------------------------------------- | -| `0` | success | -| `1` | database connection failure | -| `1` | migration SQL execution error | -| `1` | conflicting target selectors (see Notes) | -| `1` | `--remote=<branch>` matches no branch of the linked project (`LinkBranchNotFoundError`, lists the branches that exist) | -| `1` | hosted target without `--yes` in a run that cannot prompt (`HostedMutationConsentRequiredError`) | -| `1` | hosted confirmation declined (`context canceled`) | +| Code | Condition | +| ---- | ------------------------------------------------------------------------------------------------ | +| `0` | success | +| `1` | database connection failure | +| `1` | migration SQL execution error | +| `1` | hosted target without `--yes` in a run that cannot prompt (`HostedMutationConsentRequiredError`) | +| `1` | hosted confirmation declined (`context canceled`) | ## Output @@ -74,31 +74,12 @@ Same structured result delivered as an NDJSON `result` event. - A hosted target (a remote project or a non-local database 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 + (default **NO**). Declining exits 1 (`context canceled`). - A local target never prompts. ## Notes -- **Target.** With no selector the command runs against the local database; when the directory is - linked the local `Target:` banner ends with `· default; pass --remote for <project>`. `--remote` - targets the linked project (`SUPABASE_PROJECT_ID`, else `.temp/project-ref`). `--remote=<value>` - is read as a project ref (20 lowercase letters), a database URL (`postgres://` or - `postgresql://`), `true|yes|on|1` (the linked project), or otherwise the name of a branch of the - linked project, which resolves to that branch's own project. `false|no|off|0` and `--no-remote` fail with `--remote doesn't take false. Leave it out to target the local database.`, and an empty value with `--remote= has an empty value. Pass --remote for the linked project or --remote=<ref|branch|url>; leave it out to target the local database.` A word after a bare - `--remote` is read as its value, `--remote=<word>`, because the command takes no operands. The - resolved target is announced on stderr (`Target: …`) in every output format and, in JSON results, - as a `target` object. -- **Deprecated selectors.** `--linked`, `--project-ref`, `--db-url` and `--local` are hidden aliases - that still select their target and print a warning on stderr naming the replacement, for example - `--linked is deprecated and will be removed in v4. Use --remote instead: supabase migration down --remote` - and - `--local isn't needed (local is the default) and will be removed in v4: supabase migration down`; - a database URL is never echoed. Two selectors together fail - (`Only one of --remote, --linked, --db-url and --local can be set; got …`), `--project-ref` cannot - be combined with `--local`, `--db-url` or `--remote=<value>`, an empty `--db-url` or - `--project-ref` is rejected, and `--linked=false` selects nothing. - `--last` (default 1) resets up to the last n migration versions; must be `> 0` and `<` the number of applied migrations. The error's `Try supabase db reset …` suggestion carries the flags that select a hosted target (`--remote`, `--remote=<ref>`). diff --git a/apps/cli/src/commands/migration/fetch/SIDE_EFFECTS.md b/apps/cli/src/commands/migration/fetch/SIDE_EFFECTS.md index a24365ca8a..9170ae42d7 100644 --- a/apps/cli/src/commands/migration/fetch/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/migration/fetch/SIDE_EFFECTS.md @@ -1,5 +1,7 @@ # `supabase migration fetch` +Target: selectable, no consent — see [Command target](../../../../docs/command-target.md) + ## Files Read | Path | Format | When | @@ -42,13 +44,11 @@ Local targets and database URLs make none. ## Exit Codes -| Code | Condition | -| ---- | ---------------------------------------------------------------------------------------------------------------------- | -| `0` | success | -| `1` | database connection failure | -| `1` | failed to write migration files | -| `1` | conflicting target selectors (see Notes) | -| `1` | `--remote=<branch>` matches no branch of the linked project (`LinkBranchNotFoundError`, lists the branches that exist) | +| Code | Condition | +| ---- | ------------------------------- | +| `0` | success | +| `1` | database connection failure | +| `1` | failed to write migration files | ## Output @@ -76,30 +76,9 @@ Same structured `files` result delivered as an NDJSON `result` event. `SUPABASE_YES` (shell env or project `.env`) auto-confirms; a non-interactive / 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 -- **Target.** With no selector the command runs against the local database; when the directory is - linked the local `Target:` banner ends with `· default; pass --remote for <project>`. `--remote` - targets the linked project (`SUPABASE_PROJECT_ID`, else `.temp/project-ref`). `--remote=<value>` - is read as a project ref (20 lowercase letters), a database URL (`postgres://` or - `postgresql://`), `true|yes|on|1` (the linked project), or otherwise the name of a branch of the - linked project, which resolves to that branch's own project. `false|no|off|0` and `--no-remote` fail with `--remote doesn't take false. Leave it out to target the local database.`, and an empty value with `--remote= has an empty value. Pass --remote for the linked project or --remote=<ref|branch|url>; leave it out to target the local database.` A word after a bare - `--remote` is read as its value, `--remote=<word>`, because the command takes no operands. The - resolved target is announced on stderr (`Target: …`) in every output format and, in JSON results, - as a `target` object. -- **Deprecated selectors.** `--linked`, `--project-ref`, `--db-url` and `--local` are hidden aliases - that still select their target and print a warning on stderr naming the replacement, for example - `--linked is deprecated and will be removed in v4. Use --remote instead: supabase migration fetch --remote` - and - `--local isn't needed (local is the default) and will be removed in v4: supabase migration fetch`; - a database URL is never echoed. Two selectors together fail - (`Only one of --remote, --linked, --db-url and --local can be set; got …`), `--project-ref` cannot - be combined with `--local`, `--db-url` or `--remote=<value>`, an empty `--db-url` or - `--project-ref` is rejected, and `--linked=false` selects nothing. - 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/list/SIDE_EFFECTS.md b/apps/cli/src/commands/migration/list/SIDE_EFFECTS.md index 252b3d3296..e7566545da 100644 --- a/apps/cli/src/commands/migration/list/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/migration/list/SIDE_EFFECTS.md @@ -1,5 +1,7 @@ # `supabase migration list` +Target: selectable, no consent — see [Command target](../../../../docs/command-target.md) + ## Files Read | Path | Format | When | @@ -41,14 +43,11 @@ Local targets and database URLs make none. ## Exit Codes -| Code | Condition | -| ---- | ---------------------------------------------------------------------------------------------------------------------- | -| `0` | success | -| `1` | database connection failure | -| `1` | `--password` with a local target or `--remote=<db-url>` | -| `1` | failed to open migrations directory | -| `1` | conflicting target selectors (see Notes) | -| `1` | `--remote=<branch>` matches no branch of the linked project (`LinkBranchNotFoundError`, lists the branches that exist) | +| Code | Condition | +| ---- | ----------------------------------- | +| `0` | success | +| `1` | database connection failure | +| `1` | failed to open migrations directory | ## Output @@ -69,33 +68,8 @@ Same structured `migrations` result delivered as an NDJSON `result` event. ## Notes -- **Target.** With no selector the command runs against the local database; when the directory is - linked the local `Target:` banner ends with `· default; pass --remote for <project>`. `--remote` - targets the linked project (`SUPABASE_PROJECT_ID`, else `.temp/project-ref`). `--remote=<value>` - is read as a project ref (20 lowercase letters), a database URL (`postgres://` or - `postgresql://`), `true|yes|on|1` (the linked project), or otherwise the name of a branch of the - linked project, which resolves to that branch's own project. `false|no|off|0` and `--no-remote` fail with `--remote doesn't take false. Leave it out to target the local database.`, and an empty value with `--remote= has an empty value. Pass --remote for the linked project or --remote=<ref|branch|url>; leave it out to target the local database.` A word after a bare - `--remote` is read as its value, `--remote=<word>`, because the command takes no operands. The - resolved target is announced on stderr (`Target: …`) in every output format and, in JSON results, - as a `target` object. -- **Deprecated selectors.** `--linked`, `--project-ref`, `--db-url` and `--local` are hidden aliases - that still select their target and print a warning on stderr naming the replacement, for example - `--linked is deprecated and will be removed in v4. Use --remote instead: supabase migration list --remote` - and - `--local isn't needed (local is the default) and will be removed in v4: supabase migration list`; - a database URL is never echoed. Two selectors together fail - (`Only one of --remote, --linked, --db-url and --local can be set; got …`), `--project-ref` cannot - be combined with `--local`, `--db-url` or `--remote=<value>`, an empty `--db-url` or - `--project-ref` is rejected, and `--linked=false` selects nothing. -- Listing is a read: it never asks for confirmation, on any target. -- **`--password`** applies only to a remote project. With `--remote=<db-url>` (or `--db-url`) it - fails with - `--password can't be used with --remote=<db-url>. Put the password in the connection string: postgres://USER:PASSWORD@HOST:PORT/postgres`; - with a local target it fails with - `--password only applies to a linked project. The local database uses [db].password from supabase/config.toml.` - followed by `To use the linked project instead, pass --remote.` when the directory is linked (exit - 1). For a remote 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` +- **`--password`.** For a remote 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). diff --git a/apps/cli/src/commands/migration/repair/SIDE_EFFECTS.md b/apps/cli/src/commands/migration/repair/SIDE_EFFECTS.md index db7dd37b71..aef9121f1f 100644 --- a/apps/cli/src/commands/migration/repair/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/migration/repair/SIDE_EFFECTS.md @@ -1,5 +1,7 @@ # `supabase migration repair` +Target: selectable, consent default No — see [Command target](../../../../docs/command-target.md) + ## Files Read | Path | Format | When | @@ -42,17 +44,14 @@ Local targets and database URLs make none. ## Exit Codes -| Code | Condition | -| ---- | ---------------------------------------------------------------------------------------------------------------------- | -| `0` | success | -| `1` | database connection failure | -| `1` | `--password` with a local target or `--remote=<db-url>` | -| `1` | invalid or missing `--status` flag | -| `1` | conflicting target selectors (see Notes) | -| `1` | `--remote=<branch>` matches no branch of the linked project (`LinkBranchNotFoundError`, lists the branches that exist) | -| `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`) | +| Code | Condition | +| ---- | ------------------------------------------------------------------------------------------------ | +| `0` | success | +| `1` | database connection failure | +| `1` | invalid or missing `--status` flag | +| `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 @@ -86,34 +85,12 @@ Same structured result delivered as an NDJSON `result` event. - A hosted target (a remote project or a non-local database 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 + clearing. The default is **NO**. Declining exits 1 (`context canceled`) and changes nothing. - A local target never prompts, including repair-all. ## Notes -- **Target.** With no selector the command runs against the local database; when the directory is - linked the local `Target:` banner ends with `· default; pass --remote for <project>`. `--remote` - targets the linked project (`SUPABASE_PROJECT_ID`, else `.temp/project-ref`). `--remote=<value>` - is read as a project ref (20 lowercase letters), a database URL (`postgres://` or - `postgresql://`), `true|yes|on|1` (the linked project), or otherwise the name of a branch of the - linked project, which resolves to that branch's own project. `false|no|off|0` and `--no-remote` fail with `--remote doesn't take false. Leave it out to target the local database.`, and an empty value with `--remote= has an empty value. Pass --remote for the linked project or --remote=<ref|branch|url>; leave it out to target the local database.` A bare `--remote` - followed by a word that exactly matches a branch of the linked project, is within a small edit - distance of one, or can't be checked because the branch listing failed stops the run with an error - that names the cause (an HTTP status, or a missing or invalid access token) and offers both readings without echoing the word: `--remote=<branch>` for the branch, the command's operands then `--remote` for the linked project. - Any other word runs as the operand. The - resolved target is announced on stderr (`Target: …`) in every output format and, in JSON results, - as a `target` object. -- **Deprecated selectors.** `--linked`, `--project-ref`, `--db-url` and `--local` are hidden aliases - that still select their target and print a warning on stderr naming the replacement, for example - `--linked is deprecated and will be removed in v4. Use --remote instead: supabase migration repair <version>... --remote` - and - `--local isn't needed (local is the default) and will be removed in v4: supabase migration repair <version>...`; - a database URL is never echoed. Two selectors together fail - (`Only one of --remote, --linked, --db-url and --local can be set; got …`), `--project-ref` cannot - be combined with `--local`, `--db-url` or `--remote=<value>`, an empty `--db-url` or - `--project-ref` is rejected, and `--linked=false` selects nothing. - `--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 @@ -121,14 +98,8 @@ Same structured result delivered as an NDJSON `result` event. - In `applied` mode, reads the matching `supabase/migrations/<version>_*.sql` file for the name + statements before connecting; a missing file exits non-zero without touching the database. -- **`--password`** applies only to a remote project. With `--remote=<db-url>` (or `--db-url`) it - fails with - `--password can't be used with --remote=<db-url>. Put the password in the connection string: postgres://USER:PASSWORD@HOST:PORT/postgres`; - with a local target it fails with - `--password only applies to a linked project. The local database uses [db].password from supabase/config.toml.` - followed by `To use the linked project instead, pass --remote.` when the directory is linked (exit - 1). For a remote 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` +- **`--password`.** For a remote 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). diff --git a/apps/cli/src/commands/migration/squash/SIDE_EFFECTS.md b/apps/cli/src/commands/migration/squash/SIDE_EFFECTS.md index 7e6d12e10d..165fcc20d0 100644 --- a/apps/cli/src/commands/migration/squash/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/migration/squash/SIDE_EFFECTS.md @@ -1,5 +1,7 @@ # `supabase migration squash` +Target: selectable, consent default No — see [Command target](../../../../docs/command-target.md) + 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 @@ -96,14 +98,12 @@ live shadow available. ## Exit Codes -| 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 local target or `--remote=<db-url>` | -| `1` | `--remote=<branch>` matches no branch of the linked project (`LinkBranchNotFoundError`, lists the branches that exist) | -| `1` | hosted target without `--yes` in a run that cannot prompt (`HostedMutationConsentRequiredError`); hosted confirmation declined (`context canceled`) — nothing is changed | -| `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 | +| `1` | hosted target without `--yes` in a run that cannot prompt (`HostedMutationConsentRequiredError`); hosted confirmation declined (`context canceled`) — nothing is changed | +| `130` | SIGINT | ## Output @@ -151,37 +151,12 @@ code or the rest of the payload. ## Notes -- **Target.** With no selector the command runs against the local database; when the directory is - linked the local `Target:` banner ends with `· default; pass --remote for <project>`. `--remote` - targets the linked project (`SUPABASE_PROJECT_ID`, else `.temp/project-ref`). `--remote=<value>` - is read as a project ref (20 lowercase letters), a database URL (`postgres://` or - `postgresql://`), `true|yes|on|1` (the linked project), or otherwise the name of a branch of the - linked project, which resolves to that branch's own project. `false|no|off|0` and `--no-remote` fail with `--remote doesn't take false. Leave it out to target the local database.`, and an empty value with `--remote= has an empty value. Pass --remote for the linked project or --remote=<ref|branch|url>; leave it out to target the local database.` A word after a bare - `--remote` is read as its value, `--remote=<word>`, because the command takes no operands. 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. -- **Deprecated selectors.** `--linked`, `--project-ref`, `--db-url` and `--local` are hidden aliases - that still select their target and print a warning on stderr naming the replacement, for example - `--linked is deprecated and will be removed in v4. Use --remote instead: supabase migration squash --remote` - and - `--local isn't needed (local is the default) and will be removed in v4: supabase migration squash`; - a database URL is never echoed. Two selectors together fail - (`Only one of --remote, --linked, --db-url and --local can be set; got …`), `--project-ref` cannot - be combined with `--local`, `--db-url` or `--remote=<value>`, an empty `--db-url` or - `--project-ref` is rejected, and `--linked=false` selects nothing. +- The shadow database is provisioned locally whatever the target; the target only decides whether the + remote history table is baselined. - **Consent.** A hosted target (a remote project or a non-local database 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`** applies only to a remote project. With `--remote=<db-url>` (or `--db-url`) it - fails with - `--password can't be used with --remote=<db-url>. Put the password in the connection string: postgres://USER:PASSWORD@HOST:PORT/postgres`; - with a local target it fails with - `--password only applies to a linked project. The local database uses [db].password from supabase/config.toml.` - followed by `To use the linked project instead, pass --remote.` when the directory is linked (exit - 1). For a remote 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` + (default **NO**) because the local checkout rewrite precedes the remote history reset. A local target never prompts. +- **`--password`.** For a remote 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). diff --git a/apps/cli/src/commands/migration/up/SIDE_EFFECTS.md b/apps/cli/src/commands/migration/up/SIDE_EFFECTS.md index 88593d213a..56ff50a61e 100644 --- a/apps/cli/src/commands/migration/up/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/migration/up/SIDE_EFFECTS.md @@ -1,5 +1,7 @@ # `supabase migration up` +Target: selectable, consent default No — see [Command target](../../../../docs/command-target.md) + ## Files Read | Path | Format | When | @@ -42,15 +44,13 @@ Local targets and database URLs make none. ## Exit Codes -| Code | Condition | -| ---- | ---------------------------------------------------------------------------------------------------------------------- | -| `0` | success | -| `1` | database connection failure | -| `1` | migration SQL execution error | -| `1` | conflicting target selectors (see Notes) | -| `1` | `--remote=<branch>` matches no branch of the linked project (`LinkBranchNotFoundError`, lists the branches that exist) | -| `1` | hosted target without `--yes` in a run that cannot prompt (`HostedMutationConsentRequiredError`) | -| `1` | hosted confirmation declined (`context canceled`) | +| Code | Condition | +| ---- | ------------------------------------------------------------------------------------------------ | +| `0` | success | +| `1` | database connection failure | +| `1` | migration SQL execution error | +| `1` | hosted target without `--yes` in a run that cannot prompt (`HostedMutationConsentRequiredError`) | +| `1` | hosted confirmation declined (`context canceled`) | ## Output @@ -74,31 +74,11 @@ Same structured `applied` result delivered as an NDJSON `result` event. - A hosted target (a remote project or a non-local database 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`). + vault secrets to update (default **NO**). Nothing is asked when there is nothing to apply. Declining exits 1 (`context canceled`). - A local target never prompts. ## Notes -- **Target.** With no selector the command runs against the local database; when the directory is - linked the local `Target:` banner ends with `· default; pass --remote for <project>`. `--remote` - targets the linked project (`SUPABASE_PROJECT_ID`, else `.temp/project-ref`). `--remote=<value>` - is read as a project ref (20 lowercase letters), a database URL (`postgres://` or - `postgresql://`), `true|yes|on|1` (the linked project), or otherwise the name of a branch of the - linked project, which resolves to that branch's own project. `false|no|off|0` and `--no-remote` fail with `--remote doesn't take false. Leave it out to target the local database.`, and an empty value with `--remote= has an empty value. Pass --remote for the linked project or --remote=<ref|branch|url>; leave it out to target the local database.` A word after a bare - `--remote` is read as its value, `--remote=<word>`, because the command takes no operands. The - resolved target is announced on stderr (`Target: …`) in every output format and, in JSON results, - as a `target` object. -- **Deprecated selectors.** `--linked`, `--project-ref`, `--db-url` and `--local` are hidden aliases - that still select their target and print a warning on stderr naming the replacement, for example - `--linked is deprecated and will be removed in v4. Use --remote instead: supabase migration up --remote` - and - `--local isn't needed (local is the default) and will be removed in v4: supabase migration up`; a - database URL is never echoed. Two selectors together fail - (`Only one of --remote, --linked, --db-url and --local can be set; got …`), `--project-ref` cannot - be combined with `--local`, `--db-url` or `--remote=<value>`, an empty `--db-url` or - `--project-ref` is rejected, and `--linked=false` selects nothing. - `--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/network-bans/get/SIDE_EFFECTS.md b/apps/cli/src/commands/network-bans/get/SIDE_EFFECTS.md index 115ccb2ec0..431b4c6dd6 100644 --- a/apps/cli/src/commands/network-bans/get/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/network-bans/get/SIDE_EFFECTS.md @@ -1,5 +1,7 @@ # `supabase network-bans get` +Target: hosted, no consent — see [Command target](../../../../docs/command-target.md) + ## Files Read | Path | Format | When | @@ -38,15 +40,14 @@ The Management API exposes this read operation as `POST .../network-bans/retriev ## Exit Codes -| Code | Condition | -| ---- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `0` | success — network bans printed to stdout | -| `1` | `--experimental` not passed and `SUPABASE_EXPERIMENTAL` unset (`ExperimentalRequiredError`) — checked before ref resolution/API/telemetry | -| `1` | project ref unresolved (`ProjectRefNotLinkedError`; `InvalidProjectRefError` for a malformed `SUPABASE_PROJECT_ID` or link-file ref) | -| `1` | invalid `--remote` selector — `--remote=<url>` (`TargetNeedsProjectError`), an empty value, `--remote=<value>` combined with `--project-ref`, or a branch the linked project does not have (`LinkBranchNotFoundError`) | -| `1` | API non-2xx (`NetworkBansGetUnexpectedStatusError`) | -| `1` | transport failure (`NetworkBansGetNetworkError`) | -| `1` | `--output env` requested (`NetworkBansEnvNotSupportedError`) | +| Code | Condition | +| ---- | ----------------------------------------------------------------------------------------------------------------------------------------- | +| `0` | success — network bans printed to stdout | +| `1` | `--experimental` not passed and `SUPABASE_EXPERIMENTAL` unset (`ExperimentalRequiredError`) — checked before ref resolution/API/telemetry | +| `1` | project ref unresolved (`ProjectRefNotLinkedError`; `InvalidProjectRefError` for a malformed `SUPABASE_PROJECT_ID` or link-file ref) | +| `1` | API non-2xx (`NetworkBansGetUnexpectedStatusError`) | +| `1` | transport failure (`NetworkBansGetNetworkError`) | +| `1` | `--output env` requested (`NetworkBansEnvNotSupportedError`) | ## Telemetry Events Fired @@ -90,14 +91,3 @@ 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: …` banner naming the resolved project is printed on stderr in every output format; see [Command target](../../../../docs/command-target.md). -- Requires `--remote=<ref|branch>`, `SUPABASE_PROJECT_ID`, a linked project (`supabase/.temp/project-ref`) - or, on an interactive terminal, the project picker. A bare `--remote` targets the linked project. -- `--remote=<value>` is read as a project ref (20 lowercase letters), a branch of the linked - project (by name or UUID), or `true|yes|on|1` for the linked project. A database URL is rejected - (`supabase network-bans get` takes a project, not a database); `false|no|off|0` and `--no-remote` fail with `--remote doesn't take false. Leave it out to use the linked project.`, and an empty value with `--remote= has an empty value. Pass --remote for the linked project or --remote=<ref|branch>; leave it out to use the linked project.` -- A word after a bare `--remote` is read as its value, `--remote=<word>`, because the command takes - no operands. -- The deprecated `--project-ref` is an alias of `--remote=<ref>` (a non-ref value is read as a - branch name) and prints a deprecation warning on stderr that names the replacement; it cannot - be combined with `--remote=<value>`. 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 22407dff5d..a32584e9a3 100644 --- a/apps/cli/src/commands/network-bans/remove/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/network-bans/remove/SIDE_EFFECTS.md @@ -1,5 +1,7 @@ # `supabase network-bans remove` +Target: hosted, consent default No — see [Command target](../../../../docs/command-target.md) + ## Files Read | Path | Format | When | @@ -46,7 +48,6 @@ | `1` | `--experimental` not passed and `SUPABASE_EXPERIMENTAL` unset (`ExperimentalRequiredError`) — checked before ref resolution/API/telemetry | | `1` | invalid IP supplied via `--db-unban-ip` (`NetworkBansInvalidIpError`) | | `1` | project ref unresolved (`ProjectRefNotLinkedError`; `InvalidProjectRefError` for a malformed `SUPABASE_PROJECT_ID` or link-file ref) | -| `1` | invalid `--remote` selector — `--remote=<url>` (`TargetNeedsProjectError`), an empty value, `--remote=<value>` combined with `--project-ref`, or a branch the linked project does not have (`LinkBranchNotFoundError`) | | `1` | API non-2xx (`NetworkBansRemoveUnexpectedStatusError`) | | `1` | transport failure (`NetworkBansRemoveNetworkError`) | | `1` | declined at the confirmation prompt (`HostedMutationDeclinedError`, `context canceled`) — nothing is changed | @@ -86,22 +87,11 @@ One `result` event on success when the `--output` flag is unset. - The `--output` flag wins over `--output-format` when both are provided. - Requires `--db-unban-ip` flag to specify IP(s) to unban (repeatable). When omitted, the caller's own IP is unbanned (`requester_ip: true`). -- Requires `--remote=<ref|branch>`, `SUPABASE_PROJECT_ID`, a linked project (`supabase/.temp/project-ref`) - or, on an interactive terminal, the project picker. A bare `--remote` targets the linked project. - `.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 that reaches the handler, including failures, but only once the `--experimental` gate is open. A malformed `--db-unban-ip` CSV value (e.g. an unterminated quote) fails during flag parsing — before the gate, the handler, both file writes, and the `cli_command_executed` event — even when `--experimental` is passed. - `network-bans` is an experimental command: `remove` requires `--experimental` (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: …` 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. +- 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). - 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. -- `--remote=<value>` is read as a project ref (20 lowercase letters), a branch of the linked - project (by name or UUID), or `true|yes|on|1` for the linked project. A database URL is rejected - (`supabase network-bans remove` takes a project, not a database); `false|no|off|0` and `--no-remote` fail with `--remote doesn't take false. Leave it out to use the linked project.`, and an empty value with `--remote= has an empty value. Pass --remote for the linked project or --remote=<ref|branch>; leave it out to use the linked project.` -- A word after a bare `--remote` is read as its value, `--remote=<word>`, because the command takes - no operands. -- The deprecated `--project-ref` is an alias of `--remote=<ref>` (a non-ref value is read as a - branch name) and prints a deprecation warning on stderr that names the replacement; it cannot - be combined with `--remote=<value>`. 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 22599c9ecd..6a2ce97d20 100644 --- a/apps/cli/src/commands/network-restrictions/get/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/network-restrictions/get/SIDE_EFFECTS.md @@ -1,5 +1,7 @@ # `supabase network-restrictions get` +Target: hosted, no consent — see [Command target](../../../../docs/command-target.md) + ## Files Read | Path | Format | When | @@ -36,14 +38,13 @@ ## Exit Codes -| Code | Condition | -| ---- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `0` | success — network-restrictions status printed to stdout | -| `1` | `--experimental` not passed and `SUPABASE_EXPERIMENTAL` unset (`ExperimentalRequiredError`) — checked before ref resolution/API/telemetry | -| `1` | project ref unresolved (`ProjectRefNotLinkedError`; `InvalidProjectRefError` for a malformed `SUPABASE_PROJECT_ID` or link-file ref) | -| `1` | invalid `--remote` selector — `--remote=<url>` (`TargetNeedsProjectError`), an empty value, `--remote=<value>` combined with `--project-ref`, or a branch the linked project does not have (`LinkBranchNotFoundError`) | -| `1` | API non-200 (`NetworkRestrictionsGetUnexpectedStatusError`) | -| `1` | transport failure (`NetworkRestrictionsGetNetworkError`) | +| Code | Condition | +| ---- | ----------------------------------------------------------------------------------------------------------------------------------------- | +| `0` | success — network-restrictions status printed to stdout | +| `1` | `--experimental` not passed and `SUPABASE_EXPERIMENTAL` unset (`ExperimentalRequiredError`) — checked before ref resolution/API/telemetry | +| `1` | project ref unresolved (`ProjectRefNotLinkedError`; `InvalidProjectRefError` for a malformed `SUPABASE_PROJECT_ID` or link-file ref) | +| `1` | API non-200 (`NetworkRestrictionsGetUnexpectedStatusError`) | +| `1` | transport failure (`NetworkRestrictionsGetNetworkError`) | ## Telemetry Events Fired @@ -101,14 +102,3 @@ 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: …` banner naming the resolved project is printed on stderr in every output format; see [Command target](../../../../docs/command-target.md). -- Requires `--remote=<ref|branch>`, `SUPABASE_PROJECT_ID`, a linked project (`supabase/.temp/project-ref`) - or, on an interactive terminal, the project picker. A bare `--remote` targets the linked project. -- `--remote=<value>` is read as a project ref (20 lowercase letters), a branch of the linked - project (by name or UUID), or `true|yes|on|1` for the linked project. A database URL is rejected - (`supabase network-restrictions get` takes a project, not a database); `false|no|off|0` and `--no-remote` fail with `--remote doesn't take false. Leave it out to use the linked project.`, and an empty value with `--remote= has an empty value. Pass --remote for the linked project or --remote=<ref|branch>; leave it out to use the linked project.` -- A word after a bare `--remote` is read as its value, `--remote=<word>`, because the command takes - no operands. -- The deprecated `--project-ref` is an alias of `--remote=<ref>` (a non-ref value is read as a - branch name) and prints a deprecation warning on stderr that names the replacement; it cannot - be combined with `--remote=<value>`. 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 4e28f9342e..756fb1476a 100644 --- a/apps/cli/src/commands/network-restrictions/update/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/network-restrictions/update/SIDE_EFFECTS.md @@ -1,5 +1,7 @@ # `supabase network-restrictions update` +Target: hosted, consent default No — see [Command target](../../../../docs/command-target.md) + ## Files Read | Path | Format | When | @@ -51,7 +53,6 @@ when no `--db-allow-cidr` was supplied). | `1` | CIDR parse failure — `NetworkRestrictionsInvalidCidrError` (`failed to parse IP: <input>`) | | `1` | private-IP rejection — `NetworkRestrictionsPrivateIpError` (`private IP provided: <input>`) | | `1` | project ref unresolved (`ProjectRefNotLinkedError`; `InvalidProjectRefError` for a malformed `SUPABASE_PROJECT_ID` or link-file ref) | -| `1` | invalid `--remote` selector — `--remote=<url>` (`TargetNeedsProjectError`), an empty value, `--remote=<value>` combined with `--project-ref`, or a branch the linked project does not have (`LinkBranchNotFoundError`) | | `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 | @@ -127,15 +128,4 @@ 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: …` 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. -- Requires `--remote=<ref|branch>`, `SUPABASE_PROJECT_ID`, a linked project (`supabase/.temp/project-ref`) - or, on an interactive terminal, the project picker. A bare `--remote` targets the linked project. -- `--remote=<value>` is read as a project ref (20 lowercase letters), a branch of the linked - project (by name or UUID), or `true|yes|on|1` for the linked project. A database URL is rejected - (`supabase network-restrictions update` takes a project, not a database); `false|no|off|0` and `--no-remote` fail with `--remote doesn't take false. Leave it out to use the linked project.`, and an empty value with `--remote= has an empty value. Pass --remote for the linked project or --remote=<ref|branch>; leave it out to use the linked project.` -- A word after a bare `--remote` is read as its value, `--remote=<word>`, because the command takes - no operands. -- The deprecated `--project-ref` is an alias of `--remote=<ref>` (a non-ref value is read as a - branch name) and prints a deprecation warning on stderr that names the replacement; it cannot - be combined with `--remote=<value>`. +- 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. diff --git a/apps/cli/src/commands/notebooks/pull/SIDE_EFFECTS.md b/apps/cli/src/commands/notebooks/pull/SIDE_EFFECTS.md index 7f947dcc66..51935a7ef0 100644 --- a/apps/cli/src/commands/notebooks/pull/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/notebooks/pull/SIDE_EFFECTS.md @@ -1,5 +1,7 @@ # `supabase notebooks pull [Notebook id]` +Target: hosted, no consent — see [Command target](../../../../docs/command-target.md) + Writes the linked project's notebooks into `supabase/notebooks/<name>.json`, one file per notebook. A notebook file holds the notebook's API attributes minus the ones a file cannot own: `name` is carried by the file name, and the server owns @@ -73,19 +75,18 @@ size. ## Exit Codes -| Code | Condition | -| ---- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `0` | success | -| `1` | no project ref — not linked and no `--remote` | -| `1` | the supplied notebook id is not a UUID | -| `1` | the project holds two notebooks of one name during a broad pull | -| `1` | the notebooks path exists but cannot be read as a directory | -| `1` | an explicitly requested notebook name cannot be stored safely as a local file | -| `1` | a Management API call failed (transport, unexpected status, or undecodable body) | -| `1` | a local file the user chose to create in the project is not a readable notebook | -| `1` | a local notebook file cannot be written | -| `1` | `-o env`, which cannot represent the payload | -| `1` | invalid `--remote` selector — `--remote=<url>` (`TargetNeedsProjectError`), an empty value, `--remote=<value>` combined with `--project-ref`, or a branch the linked project does not have (`LinkBranchNotFoundError`) | +| Code | Condition | +| ---- | -------------------------------------------------------------------------------- | +| `0` | success | +| `1` | no project ref — not linked and no `--remote` | +| `1` | the supplied notebook id is not a UUID | +| `1` | the project holds two notebooks of one name during a broad pull | +| `1` | the notebooks path exists but cannot be read as a directory | +| `1` | an explicitly requested notebook name cannot be stored safely as a local file | +| `1` | a Management API call failed (transport, unexpected status, or undecodable body) | +| `1` | a local file the user chose to create in the project is not a readable notebook | +| `1` | a local notebook file cannot be written | +| `1` | `-o env`, which cannot represent the payload | ## Environment Variables @@ -128,19 +129,3 @@ 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: …` banner naming the resolved project is printed on stderr in every output format; see [Command target](../../../../docs/command-target.md). - -## Notes - -- Requires `--remote=<ref|branch>`, `SUPABASE_PROJECT_ID`, a linked project (`supabase/.temp/project-ref`) - or, on an interactive terminal, the project picker. A bare `--remote` targets the linked project. -- `--remote=<value>` is read as a project ref (20 lowercase letters), a branch of the linked - project (by name or UUID), or `true|yes|on|1` for the linked project. A database URL is rejected - (`supabase notebooks pull` takes a project, not a database); `false|no|off|0` and `--no-remote` fail with `--remote doesn't take false. Leave it out to use the linked project.`, and an empty value with `--remote= has an empty value. Pass --remote for the linked project or --remote=<ref|branch>; leave it out to use the linked project.` -- A bare `--remote` followed by a word that exactly matches a branch of the linked project, is - within a small edit distance of one, or can't be checked because the branch listing failed stops - the run before any other work with an error that names the cause (an HTTP status, or a missing or invalid access token) and offers both readings without echoing the word: `--remote=<branch>` for the branch, the command's operands then `--remote` for the linked project. Any other word runs as the operand. -- The deprecated `--project-ref` is an alias of `--remote=<ref>` (a non-ref value is read as a - branch name) and prints a deprecation warning on stderr that names the replacement; it cannot - be combined with `--remote=<value>`. diff --git a/apps/cli/src/commands/notebooks/push/SIDE_EFFECTS.md b/apps/cli/src/commands/notebooks/push/SIDE_EFFECTS.md index 37b7bb826c..2404366bca 100644 --- a/apps/cli/src/commands/notebooks/push/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/notebooks/push/SIDE_EFFECTS.md @@ -1,5 +1,7 @@ # `supabase notebooks push [Notebook name]` +Target: hosted, consent default No — see [Command target](../../../../docs/command-target.md) + Writes `supabase/notebooks/<name>.json` into the linked project, one call per file. A file is matched to a project notebook **by name** — the API assigns a uuid, but a checkout is shared through git and a uuid in a file name is @@ -57,8 +59,7 @@ entry are canonicalized and refused when they resolve outside the project root. ## Confirmation -- 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. +- 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. - 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 @@ -84,20 +85,19 @@ never mentions `favorite` keeps whatever the dashboard set. ## Exit Codes -| Code | Condition | -| ---- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `0` | success | -| `1` | no project ref — not linked and no `--remote` | -| `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 | -| `1` | invalid `--remote` selector — `--remote=<url>` (`TargetNeedsProjectError`), an empty value, `--remote=<value>` combined with `--project-ref`, or a branch the linked project does not have (`LinkBranchNotFoundError`) | +| Code | Condition | +| ---- | -------------------------------------------------------------------------------------------------------------------------- | +| `0` | success | +| `1` | no project ref — not linked and no `--remote` | +| `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 @@ -141,17 +141,3 @@ 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. - -## Notes - -- Requires `--remote=<ref|branch>`, `SUPABASE_PROJECT_ID`, a linked project (`supabase/.temp/project-ref`) - or, on an interactive terminal, the project picker. A bare `--remote` targets the linked project. -- `--remote=<value>` is read as a project ref (20 lowercase letters), a branch of the linked - project (by name or UUID), or `true|yes|on|1` for the linked project. A database URL is rejected - (`supabase notebooks push` takes a project, not a database); `false|no|off|0` and `--no-remote` fail with `--remote doesn't take false. Leave it out to use the linked project.`, and an empty value with `--remote= has an empty value. Pass --remote for the linked project or --remote=<ref|branch>; leave it out to use the linked project.` -- A bare `--remote` followed by a word that exactly matches a branch of the linked project, is - within a small edit distance of one, or can't be checked because the branch listing failed stops - the run before any other work with an error that names the cause (an HTTP status, or a missing or invalid access token) and offers both readings without echoing the word: `--remote=<branch>` for the branch, the command's operands then `--remote` for the linked project. Any other word runs as the operand. -- The deprecated `--project-ref` is an alias of `--remote=<ref>` (a non-ref value is read as a - branch name) and prints a deprecation warning on stderr that names the replacement; it cannot - be combined with `--remote=<value>`. diff --git a/apps/cli/src/commands/orgs/create/SIDE_EFFECTS.md b/apps/cli/src/commands/orgs/create/SIDE_EFFECTS.md index 68d88d65cb..29e660329f 100644 --- a/apps/cli/src/commands/orgs/create/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/orgs/create/SIDE_EFFECTS.md @@ -1,5 +1,7 @@ # `supabase orgs create` +Target: account, consent default Yes — see [Command target](../../../../docs/command-target.md) + ## Files Read | Path | Format | When | @@ -40,11 +42,7 @@ linked-project cache is never written. ## 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. +(default **Yes**). Without consent the run makes no API write. ## Exit Codes diff --git a/apps/cli/src/commands/orgs/list/SIDE_EFFECTS.md b/apps/cli/src/commands/orgs/list/SIDE_EFFECTS.md index effd012474..719db76f7c 100644 --- a/apps/cli/src/commands/orgs/list/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/orgs/list/SIDE_EFFECTS.md @@ -1,5 +1,7 @@ # `supabase orgs list` +Target: account, no consent — see [Command target](../../../../docs/command-target.md) + ## Files Read | Path | Format | When | 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 34bee53947..edde2f8197 100644 --- a/apps/cli/src/commands/postgres-config/delete/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/postgres-config/delete/SIDE_EFFECTS.md @@ -1,5 +1,7 @@ # `supabase postgres-config delete` +Target: hosted, consent default No — see [Command target](../../../../docs/command-target.md) + ## Files Read | Path | Format | When | @@ -48,7 +50,6 @@ The remaining stored overrides are forwarded unvalidated, so a stored key this C | `1` | malformed CSV in a `--config` value — fails during flag parsing, before the `--experimental` gate, the handler, and telemetry, with the exact diagnostic text on stderr (e.g. `invalid argument "\"max_connections" for "--config" flag: parse error on line 1, column 17: extraneous or missing " in quoted-field`; a blank-only value fails with `EOF`) — CLI-2005 | | `1` | `--experimental` not passed and `SUPABASE_EXPERIMENTAL` unset (`ExperimentalRequiredError`) - checked before ref resolution/API/telemetry | | `1` | project ref unresolved (`ProjectRefNotLinkedError`; `InvalidProjectRefError` for a malformed `SUPABASE_PROJECT_ID` or link-file ref) | -| `1` | invalid `--remote` selector — `--remote=<url>` (`TargetNeedsProjectError`), an empty value, `--remote=<value>` combined with `--project-ref`, or a branch the linked project does not have (`LinkBranchNotFoundError`) | | `1` | initial GET non-2xx (`PostgresConfigGetUnexpectedStatusError`) | | `1` | initial GET transport failure (`PostgresConfigGetNetworkError`) | | `1` | PUT non-2xx (`PostgresConfigDeleteUnexpectedStatusError`) | @@ -107,8 +108,6 @@ One `result` event on success. - The `--output` flag wins over `--output-format` when both are provided. - Flags: `--config` (repeatable, config keys to delete), `--no-restart`. -- Requires `--remote=<ref|branch>`, `SUPABASE_PROJECT_ID`, a linked project (`supabase/.temp/project-ref`) - or, on an interactive terminal, the project picker. A bare `--remote` targets the linked project. - Each config key is trimmed of whitespace before deletion. - `--no-restart` injects `restart_database = false` into the final `PUT` body. - `.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. @@ -117,14 +116,5 @@ 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: …` 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. +- 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. - The current overrides are fetched (a read) before the confirmation so the plan is known; the `PUT` happens only after it. -- `--remote=<value>` is read as a project ref (20 lowercase letters), a branch of the linked - project (by name or UUID), or `true|yes|on|1` for the linked project. A database URL is rejected - (`supabase postgres-config delete` takes a project, not a database); `false|no|off|0` and `--no-remote` fail with `--remote doesn't take false. Leave it out to use the linked project.`, and an empty value with `--remote= has an empty value. Pass --remote for the linked project or --remote=<ref|branch>; leave it out to use the linked project.` -- A word after a bare `--remote` is read as its value, `--remote=<word>`, because the command takes - no operands. -- The deprecated `--project-ref` is an alias of `--remote=<ref>` (a non-ref value is read as a - branch name) and prints a deprecation warning on stderr that names the replacement; it cannot - be combined with `--remote=<value>`. 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 0d9b26d3be..679cc13239 100644 --- a/apps/cli/src/commands/postgres-config/get/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/postgres-config/get/SIDE_EFFECTS.md @@ -1,5 +1,7 @@ # `supabase postgres-config get` +Target: hosted, no consent — see [Command target](../../../../docs/command-target.md) + ## Files Read | Path | Format | When | @@ -36,15 +38,14 @@ ## Exit Codes -| Code | Condition | -| ---- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `0` | success - Postgres config printed | -| `1` | `--experimental` not passed and `SUPABASE_EXPERIMENTAL` unset (`ExperimentalRequiredError`) - checked before ref resolution/API/telemetry | -| `1` | project ref unresolved (`ProjectRefNotLinkedError`; `InvalidProjectRefError` for a malformed `SUPABASE_PROJECT_ID` or link-file ref) | -| `1` | invalid `--remote` selector — `--remote=<url>` (`TargetNeedsProjectError`), an empty value, `--remote=<value>` combined with `--project-ref`, or a branch the linked project does not have (`LinkBranchNotFoundError`) | -| `1` | API non-2xx (`PostgresConfigGetUnexpectedStatusError`) | -| `1` | transport failure (`PostgresConfigGetNetworkError`); the GET retries on 5xx and transport errors first | -| `1` | response body is not a JSON object (`PostgresConfigGetUnmarshalError`) | +| Code | Condition | +| ---- | ----------------------------------------------------------------------------------------------------------------------------------------- | +| `0` | success - Postgres config printed | +| `1` | `--experimental` not passed and `SUPABASE_EXPERIMENTAL` unset (`ExperimentalRequiredError`) - checked before ref resolution/API/telemetry | +| `1` | project ref unresolved (`ProjectRefNotLinkedError`; `InvalidProjectRefError` for a malformed `SUPABASE_PROJECT_ID` or link-file ref) | +| `1` | API non-2xx (`PostgresConfigGetUnexpectedStatusError`) | +| `1` | transport failure (`PostgresConfigGetNetworkError`); the GET retries on 5xx and transport errors first | +| `1` | response body is not a JSON object (`PostgresConfigGetUnmarshalError`) | ## Telemetry Events Fired @@ -95,20 +96,9 @@ One `result` event on success. ## Notes - The `--output` flag wins over `--output-format` when both are provided. -- Requires `--remote=<ref|branch>`, `SUPABASE_PROJECT_ID`, a linked project (`supabase/.temp/project-ref`) - or, on an interactive terminal, the project picker. A bare `--remote` targets the linked project. - `.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, including failures, but only once the `--experimental` gate is open. - `postgres-config` is an experimental command: `get` requires `--experimental` (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: …` banner naming the resolved project is printed on stderr in every output format; see [Command target](../../../../docs/command-target.md). -- `--remote=<value>` is read as a project ref (20 lowercase letters), a branch of the linked - project (by name or UUID), or `true|yes|on|1` for the linked project. A database URL is rejected - (`supabase postgres-config get` takes a project, not a database); `false|no|off|0` and `--no-remote` fail with `--remote doesn't take false. Leave it out to use the linked project.`, and an empty value with `--remote= has an empty value. Pass --remote for the linked project or --remote=<ref|branch>; leave it out to use the linked project.` -- A word after a bare `--remote` is read as its value, `--remote=<word>`, because the command takes - no operands. -- The deprecated `--project-ref` is an alias of `--remote=<ref>` (a non-ref value is read as a - branch name) and prints a deprecation warning on stderr that names the replacement; it cannot - be combined with `--remote=<value>`. 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 1e88eb38a8..d6cf5e2d45 100644 --- a/apps/cli/src/commands/postgres-config/update/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/postgres-config/update/SIDE_EFFECTS.md @@ -1,5 +1,7 @@ # `supabase postgres-config update` +Target: hosted, consent default No — see [Command target](../../../../docs/command-target.md) + ## Files Read | Path | Format | When | @@ -49,7 +51,6 @@ The keys passed with `--config` (and `restart_database`) are validated locally a | `1` | `--experimental` not passed and `SUPABASE_EXPERIMENTAL` unset (`ExperimentalRequiredError`) - checked before ref resolution/API/telemetry | | `1` | malformed `--config` (`PostgresConfigInvalidConfigValueError`) | | `1` | project ref unresolved (`ProjectRefNotLinkedError`; `InvalidProjectRefError` for a malformed `SUPABASE_PROJECT_ID` or link-file ref) | -| `1` | invalid `--remote` selector — `--remote=<url>` (`TargetNeedsProjectError`), an empty value, `--remote=<value>` combined with `--project-ref`, or a branch the linked project does not have (`LinkBranchNotFoundError`) | | `1` | initial GET non-2xx (`PostgresConfigGetUnexpectedStatusError`) | | `1` | initial GET transport failure (`PostgresConfigGetNetworkError`) | | `1` | PUT non-2xx (`PostgresConfigUpdateUnexpectedStatusError`) | @@ -109,8 +110,6 @@ One `result` event on success. - The `--output` flag wins over `--output-format` when both are provided. - Flags: `--config` (repeatable; each value must contain exactly one `=`, otherwise `PostgresConfigInvalidConfigValueError`), `--replace-existing-overrides`, `--no-restart`. -- Requires `--remote=<ref|branch>`, `SUPABASE_PROJECT_ID`, a linked project (`supabase/.temp/project-ref`) - or, on an interactive terminal, the project picker. A bare `--remote` targets the linked project. - Integer-like values are coerced to integers, boolean-like values are coerced to booleans, and everything else stays stringly typed before the final JSON body is serialized. - Keys ending in `_timeout` are always stringified before the `PUT`. - `--no-restart` injects `restart_database = false` into the final request body. @@ -120,14 +119,5 @@ 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: …` 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. +- 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. - The current overrides are fetched (a read) before the confirmation so the plan is known; the `PUT` happens only after it. -- `--remote=<value>` is read as a project ref (20 lowercase letters), a branch of the linked - project (by name or UUID), or `true|yes|on|1` for the linked project. A database URL is rejected - (`supabase postgres-config update` takes a project, not a database); `false|no|off|0` and `--no-remote` fail with `--remote doesn't take false. Leave it out to use the linked project.`, and an empty value with `--remote= has an empty value. Pass --remote for the linked project or --remote=<ref|branch>; leave it out to use the linked project.` -- A word after a bare `--remote` is read as its value, `--remote=<word>`, because the command takes - no operands. -- The deprecated `--project-ref` is an alias of `--remote=<ref>` (a non-ref value is read as a - branch name) and prints a deprecation warning on stderr that names the replacement; it cannot - be combined with `--remote=<value>`. diff --git a/apps/cli/src/commands/projects/SIDE_EFFECTS.md b/apps/cli/src/commands/projects/SIDE_EFFECTS.md index b7c103f577..6df1d781d3 100644 --- a/apps/cli/src/commands/projects/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/projects/SIDE_EFFECTS.md @@ -1,5 +1,7 @@ # `supabase projects` (group) +Target: account, consent default Yes for `create`, No for `delete`, no consent for `list`; `api-keys` is hosted, no consent — see [Command target](../../../docs/command-target.md) + Group-level side-effect summary for the natively-ported `projects` commands: `list`, `create`, `delete`, `api-keys`. Per-subcommand detail lives in each subcommand's own `SIDE_EFFECTS.md`. @@ -94,13 +96,4 @@ subcommand's own `SIDE_EFFECTS.md`. - `create` interactivity is gated on `--interactive` (default true) **and** a TTY stdin **and** an interactive (text-mode) `Output`. - `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 `--remote=<ref|branch>` (or the deprecated `--project-ref` alias) via the shared - resolver (flag → env → `.temp/project-ref` → prompt on a TTY → error when unlinked). A bare - `--remote` targets the linked project, `--remote=<branch>` looks the branch up on the linked - project (`GET /v1/projects/{parent}/branches`, or `GET /v1/branches/{id}` for a UUID when no - project is linked), and `--remote=<url>` is rejected. + (default **No**, irreversible); without consent neither makes an API write. diff --git a/apps/cli/src/commands/projects/api-keys/SIDE_EFFECTS.md b/apps/cli/src/commands/projects/api-keys/SIDE_EFFECTS.md index 6534d16ff1..ae7e0d6787 100644 --- a/apps/cli/src/commands/projects/api-keys/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/projects/api-keys/SIDE_EFFECTS.md @@ -1,5 +1,7 @@ # `supabase projects api-keys` +Target: hosted, no consent — see [Command target](../../../../docs/command-target.md) + ## Files Read | Path | Format | When | @@ -42,14 +44,13 @@ Management API to return the full secret keys (`sb_secret_...`) in `api_key` ins ## Exit Codes -| Code | Condition | -| ---- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `0` | success — API keys printed to stdout | -| `1` | authentication error — no valid token found | -| `1` | API error — non-2xx response from `/v1/projects/{ref}/api-keys` | -| `1` | network / connection failure | -| `1` | project ref not provided and no linked project found | -| `1` | invalid `--remote` selector — `--remote=<url>` (`TargetNeedsProjectError`), an empty value, `--remote=<value>` combined with `--project-ref`, or a branch the linked project does not have (`LinkBranchNotFoundError`) | +| Code | Condition | +| ---- | --------------------------------------------------------------- | +| `0` | success — API keys printed to stdout | +| `1` | authentication error — no valid token found | +| `1` | API error — non-2xx response from `/v1/projects/{ref}/api-keys` | +| `1` | network / connection failure | +| `1` | project ref not provided and no linked project found | ## Telemetry Events Fired @@ -107,14 +108,3 @@ On failure, an `error` event is emitted instead: in the toml/env env map; the json/yaml encodings preserve the raw `null`. Passing `--reveal` makes the API return the secret values, so they print in full across all formats (issue #4775). -- Requires `--remote=<ref|branch>`, `SUPABASE_PROJECT_ID`, a linked project (`supabase/.temp/project-ref`) - or, on an interactive terminal, the project picker; it fails with a not-linked error otherwise. A - bare `--remote` targets the linked project. -- `--remote=<value>` is read as a project ref (20 lowercase letters), a branch of the linked - project (by name or UUID), or `true|yes|on|1` for the linked project. A database URL is rejected - (`supabase projects api-keys` takes a project, not a database); `false|no|off|0` and `--no-remote` fail with `--remote doesn't take false. Leave it out to use the linked project.`, and an empty value with `--remote= has an empty value. Pass --remote for the linked project or --remote=<ref|branch>; leave it out to use the linked project.` -- A word after a bare `--remote` is read as its value, `--remote=<word>`, because the command takes - no operands. -- The deprecated `--project-ref` is an alias of `--remote=<ref>` (a non-ref value is read as a - branch name) and prints a deprecation warning on stderr that names the replacement; it cannot - be combined with `--remote=<value>`. diff --git a/apps/cli/src/commands/projects/create/SIDE_EFFECTS.md b/apps/cli/src/commands/projects/create/SIDE_EFFECTS.md index 3e052e8d29..6179aa72a5 100644 --- a/apps/cli/src/commands/projects/create/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/projects/create/SIDE_EFFECTS.md @@ -1,5 +1,7 @@ # `supabase projects create` +Target: account, consent default Yes — see [Command target](../../../../docs/command-target.md) + ## Files Read | Path | Format | When | @@ -93,11 +95,7 @@ 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. + (default **Yes**). Without consent the run makes no `POST`. - 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/delete/SIDE_EFFECTS.md b/apps/cli/src/commands/projects/delete/SIDE_EFFECTS.md index 1d61410421..cab63787f1 100644 --- a/apps/cli/src/commands/projects/delete/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/projects/delete/SIDE_EFFECTS.md @@ -1,5 +1,7 @@ # `supabase projects delete` +Target: account, consent default No — see [Command target](../../../../docs/command-target.md) + ## Files Read | Path | Format | When | @@ -63,9 +65,8 @@ ### `--output-format text` 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. +**No**), then on success prints `Deleted project: <name>` to stdout. Under `--yes` it prints +`Confirmed by --yes: Delete project <ref>` on stderr instead of asking. ### `--output-format json` @@ -96,5 +97,4 @@ 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. -- The confirmation runs after the project ref is resolved and before the DELETE call; - stdin is never read for the answer. +- The confirmation runs after the project ref is resolved and before the DELETE call. diff --git a/apps/cli/src/commands/projects/list/SIDE_EFFECTS.md b/apps/cli/src/commands/projects/list/SIDE_EFFECTS.md index 1fa0b988d9..f3ce66ea0b 100644 --- a/apps/cli/src/commands/projects/list/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/projects/list/SIDE_EFFECTS.md @@ -1,5 +1,7 @@ # `supabase projects list` +Target: account, no consent — see [Command target](../../../../docs/command-target.md) + ## Files Read | Path | Format | When | diff --git a/apps/cli/src/commands/pull/SIDE_EFFECTS.md b/apps/cli/src/commands/pull/SIDE_EFFECTS.md index ac030a9ed7..f1a01d0652 100644 --- a/apps/cli/src/commands/pull/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/pull/SIDE_EFFECTS.md @@ -1,5 +1,7 @@ # `supabase pull` +Target: hosted, consent default No — see [Command target](../../../docs/command-target.md) + Orchestrates four existing pull-style commands — `config pull`, an optional `migration fetch`, `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 @@ -18,10 +20,6 @@ detail, see its own `SIDE_EFFECTS.md`: it actually pulls a migration-mode schema change, and that step requires Docker (shadow database + 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); see -[Command target](../../../docs/command-target.md). - ## Files Read | Path | Format | When | @@ -116,14 +114,9 @@ any step writes: `Pull project state and update the remote migration history tab - 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. +- `--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 @@ -293,8 +286,6 @@ the -o/--output flag is not supported by pull; use --output-format json|stream-j ## Notes -- `--remote=<value>` selects the target: a project ref (20 lowercase letters), or the name or UUID of a branch of the linked project (resolved through `GET /v1/projects/{parent}/branches`, or `GET /v1/branches/{id}` for a UUID when no project is linked). A bare `--remote` and no selector both target the linked project. A database URL is rejected (`supabase pull` takes a project, not a database); `false|no|off|0` and `--no-remote` fail with `--remote doesn't take false. Leave it out to use the linked project.`, and an empty value with `--remote= has an empty value. Pass --remote for the linked project or --remote=<ref|branch>; leave it out to use the linked project.` -- The deprecated `--project-ref` is an alias of `--remote=<ref>` (it still accepts a branch name or UUID) and prints a deprecation warning on stderr that names the replacement; it cannot be combined with `--remote=<value>`. An empty `--project-ref` is rejected. - **Steps run sequentially, not in parallel**, refining ADR 0004's "runs all sub-syncs in parallel" aspiration for `pull` specifically — see [ADR 0024](../../../../../docs/adr/0024-top-level-pull-orchestration.md) for the two real data diff --git a/apps/cli/src/commands/secrets/list/SIDE_EFFECTS.md b/apps/cli/src/commands/secrets/list/SIDE_EFFECTS.md index fc74dbbe16..e8d03487c8 100644 --- a/apps/cli/src/commands/secrets/list/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/secrets/list/SIDE_EFFECTS.md @@ -1,5 +1,7 @@ # `supabase secrets list` +Target: hosted, no consent — see [Command target](../../../../docs/command-target.md) + ## Files Read | Path | Format | When | @@ -37,17 +39,16 @@ ## Exit Codes -| Code | Condition | -| ---- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `0` | success — secrets printed to stdout | -| `1` | `AccessTokenRequiredError` — no token in env/keyring/file | -| `1` | `InvalidAccessTokenError` — token violates `^sbp_(oauth_\|v0_)?[a-f0-9]{40}$` | -| `1` | `ProjectRefNotLinkedError` — `--remote=<ref>` unset, env/file empty, and stdin not a TTY | -| `1` | invalid `--remote` selector — `--remote=<url>` (`TargetNeedsProjectError`), an empty value, `--remote=<value>` combined with `--project-ref`, or a branch the linked project does not have (`LinkBranchNotFoundError`) | -| `1` | `InvalidProjectRefError` — a `SUPABASE_PROJECT_ID` or link-file ref violates `^[a-z]{20}$` | -| `1` | `SecretsListUnexpectedStatusError` — non-2xx response from the secrets endpoint | -| `1` | `SecretsListNetworkError` — transport-level network failure | -| `1` | `SecretsEnvNotSupportedError` — `--output env` flag is rejected | +| Code | Condition | +| ---- | ------------------------------------------------------------------------------------------ | +| `0` | success — secrets printed to stdout | +| `1` | `AccessTokenRequiredError` — no token in env/keyring/file | +| `1` | `InvalidAccessTokenError` — token violates `^sbp_(oauth_\|v0_)?[a-f0-9]{40}$` | +| `1` | `ProjectRefNotLinkedError` — `--remote=<ref>` unset, env/file empty, and stdin not a TTY | +| `1` | `InvalidProjectRefError` — a `SUPABASE_PROJECT_ID` or link-file ref violates `^[a-z]{20}$` | +| `1` | `SecretsListUnexpectedStatusError` — non-2xx response from the secrets endpoint | +| `1` | `SecretsListNetworkError` — transport-level network failure | +| `1` | `SecretsEnvNotSupportedError` — `--output env` flag is rejected | ## Telemetry Events Fired @@ -89,18 +90,7 @@ 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. -- Requires `--remote=<ref|branch>`, `SUPABASE_PROJECT_ID`, a linked project (`supabase/.temp/project-ref`) - or, on an interactive terminal, the project picker. A bare `--remote` targets the linked project. -- `--remote=<value>` is read as a project ref (20 lowercase letters), a branch of the linked - project (by name or UUID), or `true|yes|on|1` for the linked project. A database URL is rejected - (`supabase secrets list` takes a project, not a database); `false|no|off|0` and `--no-remote` fail with `--remote doesn't take false. Leave it out to use the linked project.`, and an empty value with `--remote= has an empty value. Pass --remote for the linked project or --remote=<ref|branch>; leave it out to use the linked project.` -- A word after a bare `--remote` is read as its value, `--remote=<word>`, because the command takes - no operands. -- The deprecated `--project-ref` is an alias of `--remote=<ref>` (a non-ref value is read as a - branch name) and prints a deprecation warning on stderr that names the replacement; it cannot - be combined with `--remote=<value>`. diff --git a/apps/cli/src/commands/secrets/set/SIDE_EFFECTS.md b/apps/cli/src/commands/secrets/set/SIDE_EFFECTS.md index 6901ea9a9e..93be242827 100644 --- a/apps/cli/src/commands/secrets/set/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/secrets/set/SIDE_EFFECTS.md @@ -1,5 +1,7 @@ # `supabase secrets set` +Target: hosted, consent default No — see [Command target](../../../../docs/command-target.md) + ## Files Read | Path | Format | When | @@ -44,24 +46,23 @@ ## 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` — `--remote=<ref>` unset, env/file empty, and stdin not a TTY | -| `1` | invalid `--remote` selector — `--remote=<url>` (`TargetNeedsProjectError`), an empty value, `--remote=<value>` combined with `--project-ref`, or a branch the linked project does not have (`LinkBranchNotFoundError`) | -| `1` | `InvalidProjectRefError` — a `SUPABASE_PROJECT_ID` or link-file 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 | +| 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` — `--remote=<ref>` unset, env/file empty, and stdin not a TTY | +| `1` | `InvalidProjectRefError` — a `SUPABASE_PROJECT_ID` or link-file 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 @@ -88,7 +89,6 @@ One `result` NDJSON event on success containing `{project_ref, count}` and `targ ## 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. - Secrets are POSTed in batches of 100 (the API's per-request cap); every batch is validated before the first POST. When no secrets remain after filtering, the command fails with `SecretsNoArgumentsError` and makes no POST (the cache fill still runs). @@ -96,14 +96,3 @@ One `result` NDJSON event on success containing `{project_ref, count}` and `targ - A removed config key or a non-empty `SUPABASE_EXPERIMENTAL_ORIOLEDB_VERSION` **does** abort the command (exit 1, before any request) instead of being tolerated: silently dropping the config's `[edge_runtime.secrets]` would send a partial secret set. - A malformed `config.toml` does **not** abort the command — the error is logged to the debug logger and the command proceeds. `--env-file` and positional `NAME=VALUE` secrets always still apply. What happens to config-declared secrets depends on the failure class: a raw TOML/JSON syntax error drops everything (no `EdgeRuntime.Secrets`), but a schema-type error on an _unrelated_ field (e.g. `analytics.port` being a string) still leaves a valid `[edge_runtime.secrets]` section usable — the handler recovers it by re-decoding just that subtree. Pass `--debug` to see the logged parse error. - Sends `User-Agent: SupabaseCLI/<version>` and Bearer auth. No `X-Supabase-Command` headers. -- Requires `--remote=<ref|branch>`, `SUPABASE_PROJECT_ID`, a linked project (`supabase/.temp/project-ref`) - or, on an interactive terminal, the project picker. A bare `--remote` targets the linked project. -- `--remote=<value>` is read as a project ref (20 lowercase letters), a branch of the linked - project (by name or UUID), or `true|yes|on|1` for the linked project. A database URL is rejected - (`supabase secrets set` takes a project, not a database); `false|no|off|0` and `--no-remote` fail with `--remote doesn't take false. Leave it out to use the linked project.`, and an empty value with `--remote= has an empty value. Pass --remote for the linked project or --remote=<ref|branch>; leave it out to use the linked project.` -- A bare `--remote` followed by a word that exactly matches a branch of the linked project, is - within a small edit distance of one, or can't be checked because the branch listing failed stops - the run before any other work with an error that names the cause (an HTTP status, or a missing or invalid access token) and offers both readings without echoing the word: `--remote=<branch>` for the branch, the command's operands then `--remote` for the linked project. Any other word runs as the operand. -- The deprecated `--project-ref` is an alias of `--remote=<ref>` (a non-ref value is read as a - branch name) and prints a deprecation warning on stderr that names the replacement; it cannot - be combined with `--remote=<value>`. diff --git a/apps/cli/src/commands/secrets/unset/SIDE_EFFECTS.md b/apps/cli/src/commands/secrets/unset/SIDE_EFFECTS.md index 04e411a4a0..0746dd4cf6 100644 --- a/apps/cli/src/commands/secrets/unset/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/secrets/unset/SIDE_EFFECTS.md @@ -1,5 +1,7 @@ # `supabase secrets unset` +Target: hosted, consent default No — see [Command target](../../../../docs/command-target.md) + ## Files Read | Path | Format | When | @@ -51,7 +53,6 @@ | `1` | `AccessTokenRequiredError` — no token in env/keyring/file | | `1` | `InvalidAccessTokenError` — token violates `^sbp_(oauth_\|v0_)?[a-f0-9]{40}$` | | `1` | `ProjectRefNotLinkedError` — `--remote=<ref>` unset, env/file empty, and stdin not a TTY | -| `1` | invalid `--remote` selector — `--remote=<url>` (`TargetNeedsProjectError`), an empty value, `--remote=<value>` combined with `--project-ref`, or a branch the linked project does not have (`LinkBranchNotFoundError`) | | `1` | `InvalidProjectRefError` — a `SUPABASE_PROJECT_ID` or link-file 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) | @@ -101,18 +102,6 @@ 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. - 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. -- Requires `--remote=<ref|branch>`, `SUPABASE_PROJECT_ID`, a linked project (`supabase/.temp/project-ref`) - or, on an interactive terminal, the project picker. A bare `--remote` targets the linked project. -- `--remote=<value>` is read as a project ref (20 lowercase letters), a branch of the linked - project (by name or UUID), or `true|yes|on|1` for the linked project. A database URL is rejected - (`supabase secrets unset` takes a project, not a database); `false|no|off|0` and `--no-remote` fail with `--remote doesn't take false. Leave it out to use the linked project.`, and an empty value with `--remote= has an empty value. Pass --remote for the linked project or --remote=<ref|branch>; leave it out to use the linked project.` -- A bare `--remote` followed by a word that exactly matches a branch of the linked project, is - within a small edit distance of one, or can't be checked because the branch listing failed stops - the run before any other work with an error that names the cause (an HTTP status, or a missing or invalid access token) and offers both readings without echoing the word: `--remote=<branch>` for the branch, the command's operands then `--remote` for the linked project. Any other word runs as the operand. -- The deprecated `--project-ref` is an alias of `--remote=<ref>` (a non-ref value is read as a - branch name) and prints a deprecation warning on stderr that names the replacement; it cannot - be combined with `--remote=<value>`. diff --git a/apps/cli/src/commands/seed/buckets/SIDE_EFFECTS.md b/apps/cli/src/commands/seed/buckets/SIDE_EFFECTS.md index 82fea0d6d1..be893ff40e 100644 --- a/apps/cli/src/commands/seed/buckets/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/seed/buckets/SIDE_EFFECTS.md @@ -1,11 +1,9 @@ # `supabase seed buckets` +Target: selectable, consent default No — see [Command target](../../../../docs/command-target.md) + Seeds Supabase Storage buckets from `[storage.buckets]` and -`[storage.vector]` in `supabase/config.toml`. With no selector the command targets the local -stack; `--remote` targets the linked project, `--remote=<ref>` another project and -`--remote=<branch>` a branch of the linked project (see -[ADR 0033](../../../../../../docs/adr/0033-target-selection-and-hosted-mutation-consent.md)). `--local`, `--linked` and -`--project-ref` are deprecated aliases. A run against a remote project asks for consent before its +`[storage.vector]` in `supabase/config.toml`. A run against a remote project asks for consent before its first write. The local path resolves the Storage gateway through the `experimental.stack` @@ -124,9 +122,6 @@ 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` | two of `--remote`, `--linked` and `--local`, or `--project-ref` with `--local` or `--remote=<value>` (`TargetSelectorConflictError`, `TargetProjectRefConflictError`) — after the workdir checks and before any Storage call | -| `1` | `--remote=<db-url>` (`TargetNeedsProjectError`): seeding talks to the Storage API, which needs a project, not a database URL; no Storage request is sent | -| `1` | `--remote=<branch>` matches no branch of the linked project (`LinkBranchNotFoundError`, lists the branches that exist) | | `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`) | @@ -198,37 +193,15 @@ stdout and a terminal `result`/`error` event is emitted. ## Notes -- **Target.** With no selector the command runs against the local stack; when the directory is - linked the local `Target:` banner ends with `· default; pass --remote for <project>`. `--remote` - targets the linked project (`SUPABASE_PROJECT_ID`, else `.temp/project-ref`). `--remote=<value>` - is read as a project ref (20 lowercase letters), `true|yes|on|1` (the linked project), or - otherwise the name of a branch of the linked project, which resolves to that branch's own project. - A database URL (`--remote=<db-url>`) fails with a `needs a project, not a database URL` error and - sends no request. `false|no|off|0` and `--no-remote` fail with `--remote doesn't take false. Leave it out to target the local Storage API.`, and an empty value with `--remote= has an empty value. Pass --remote for the linked project or --remote=<ref|branch>; leave it out to target the local Storage API.` A word after a bare `--remote` is read as - its value, `--remote=<word>`, because the command takes no operands. The resolved target is - announced on stderr (`Target: …`) - in every output format and, in JSON results, as a `target` object. `--remote`, `--linked`, - `--local` and the deprecated `--project-ref` are scoped globals on the `seed` group, so they are - also accepted before the subcommand token. -- **Deprecated selectors.** `--linked`, `--project-ref` and `--local` are hidden aliases that still - select their target and print a warning on stderr naming the replacement, for example - `--linked is deprecated and will be removed in v4. Use --remote instead: supabase seed buckets --remote` - and - `--local isn't needed (local is the default) and will be removed in v4: supabase seed buckets`. - Two selectors together fail - (`Only one of --remote, --linked and --local can be set; got …`), `--project-ref` cannot - be combined with `--local` or `--remote=<value>`, an empty `--project-ref` is rejected, and - `--linked=false` selects nothing. -- **Target banner.** A remote project 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` and the deprecated `--linked`, `--local` and `--project-ref` are scoped globals on the `seed` + group, so they are also accepted before the subcommand token. +- **Target banner.** A remote project prints its `Target:` line to stderr once its ref resolves; the local + target prints it 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. + write nothing does not ask. The local stack never asks for consent and keeps its per-item prompts. - **Remote project — 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 diff --git a/apps/cli/src/commands/snippets/download/SIDE_EFFECTS.md b/apps/cli/src/commands/snippets/download/SIDE_EFFECTS.md index d88ac8ec5a..b07276053b 100644 --- a/apps/cli/src/commands/snippets/download/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/snippets/download/SIDE_EFFECTS.md @@ -1,5 +1,7 @@ # `supabase snippets download` +Target: hosted, no consent — see [Command target](../../../../docs/command-target.md) + ## Files Read | Path | Format | When | @@ -41,14 +43,13 @@ The snippet request carries no project ref and no query. The ref is resolved onl ## Exit Codes -| Code | Condition | -| ---- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `0` | success — SQL written to stdout | -| `1` | `SnippetsInvalidIdError` — `<snippet-id>` is not a valid UUID | -| `1` | `ProjectRefNotLinkedError`; `InvalidProjectRefError` for a malformed `SUPABASE_PROJECT_ID` or link-file ref | -| `1` | invalid `--remote` selector — `--remote=<url>` (`TargetNeedsProjectError`), an empty value, `--remote=<value>` combined with `--project-ref`, or a branch the linked project does not have (`LinkBranchNotFoundError`) | -| `1` | `SnippetsDownloadUnexpectedStatusError` — non-2xx response | -| `1` | `SnippetsDownloadNetworkError` — transport-level failure | +| Code | Condition | +| ---- | ----------------------------------------------------------------------------------------------------------- | +| `0` | success — SQL written to stdout | +| `1` | `SnippetsInvalidIdError` — `<snippet-id>` is not a valid UUID | +| `1` | `ProjectRefNotLinkedError`; `InvalidProjectRefError` for a malformed `SUPABASE_PROJECT_ID` or link-file ref | +| `1` | `SnippetsDownloadUnexpectedStatusError` — non-2xx response | +| `1` | `SnippetsDownloadNetworkError` — transport-level failure | ## Telemetry Events Fired @@ -89,15 +90,3 @@ 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: …` banner naming the resolved project is printed on stderr in every output format; see [Command target](../../../../docs/command-target.md). -- Requires `--remote=<ref|branch>`, `SUPABASE_PROJECT_ID`, a linked project (`supabase/.temp/project-ref`) - or, on an interactive terminal, the project picker. A bare `--remote` targets the linked project. -- `--remote=<value>` is read as a project ref (20 lowercase letters), a branch of the linked - project (by name or UUID), or `true|yes|on|1` for the linked project. A database URL is rejected - (`supabase snippets download` takes a project, not a database); `false|no|off|0` and `--no-remote` fail with `--remote doesn't take false. Leave it out to use the linked project.`, and an empty value with `--remote= has an empty value. Pass --remote for the linked project or --remote=<ref|branch>; leave it out to use the linked project.` -- A bare `--remote` followed by a word that exactly matches a branch of the linked project, is - within a small edit distance of one, or can't be checked because the branch listing failed stops - the run before any other work with an error that names the cause (an HTTP status, or a missing or invalid access token) and offers both readings without echoing the word: `--remote=<branch>` for the branch, the command's operands then `--remote` for the linked project. Any other word runs as the operand. -- The deprecated `--project-ref` is an alias of `--remote=<ref>` (a non-ref value is read as a - branch name) and prints a deprecation warning on stderr that names the replacement; it cannot - be combined with `--remote=<value>`. diff --git a/apps/cli/src/commands/snippets/list/SIDE_EFFECTS.md b/apps/cli/src/commands/snippets/list/SIDE_EFFECTS.md index fa3faa0be9..9e59e91a17 100644 --- a/apps/cli/src/commands/snippets/list/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/snippets/list/SIDE_EFFECTS.md @@ -1,5 +1,7 @@ # `supabase snippets list` +Target: hosted, no consent — see [Command target](../../../../docs/command-target.md) + ## Files Read | Path | Format | When | @@ -35,14 +37,13 @@ ## Exit Codes -| Code | Condition | -| ---- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `0` | success | -| `1` | `SnippetsEnvNotSupportedError` — `--output env` was requested | -| `1` | `ProjectRefNotLinkedError`; `InvalidProjectRefError` for a malformed `SUPABASE_PROJECT_ID` or link-file ref | -| `1` | invalid `--remote` selector — `--remote=<url>` (`TargetNeedsProjectError`), an empty value, `--remote=<value>` combined with `--project-ref`, or a branch the linked project does not have (`LinkBranchNotFoundError`) | -| `1` | `SnippetsListUnexpectedStatusError` — non-2xx response | -| `1` | `SnippetsListNetworkError` — transport-level failure | +| Code | Condition | +| ---- | ----------------------------------------------------------------------------------------------------------- | +| `0` | success | +| `1` | `SnippetsEnvNotSupportedError` — `--output env` was requested | +| `1` | `ProjectRefNotLinkedError`; `InvalidProjectRefError` for a malformed `SUPABASE_PROJECT_ID` or link-file ref | +| `1` | `SnippetsListUnexpectedStatusError` — non-2xx response | +| `1` | `SnippetsListNetworkError` — transport-level failure | ## Telemetry Events Fired @@ -101,14 +102,3 @@ 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: …` banner naming the resolved project is printed on stderr in every output format; see [Command target](../../../../docs/command-target.md). -- Requires `--remote=<ref|branch>`, `SUPABASE_PROJECT_ID`, a linked project (`supabase/.temp/project-ref`) - or, on an interactive terminal, the project picker. A bare `--remote` targets the linked project. -- `--remote=<value>` is read as a project ref (20 lowercase letters), a branch of the linked - project (by name or UUID), or `true|yes|on|1` for the linked project. A database URL is rejected - (`supabase snippets list` takes a project, not a database); `false|no|off|0` and `--no-remote` fail with `--remote doesn't take false. Leave it out to use the linked project.`, and an empty value with `--remote= has an empty value. Pass --remote for the linked project or --remote=<ref|branch>; leave it out to use the linked project.` -- A word after a bare `--remote` is read as its value, `--remote=<word>`, because the command takes - no operands. -- The deprecated `--project-ref` is an alias of `--remote=<ref>` (a non-ref value is read as a - branch name) and prints a deprecation warning on stderr that names the replacement; it cannot - be combined with `--remote=<value>`. 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 305a21216c..62801e509e 100644 --- a/apps/cli/src/commands/ssl-enforcement/get/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/ssl-enforcement/get/SIDE_EFFECTS.md @@ -1,5 +1,7 @@ # `supabase ssl-enforcement get` +Target: hosted, no consent — see [Command target](../../../../docs/command-target.md) + ## Files Read | Path | Format | When | @@ -36,14 +38,13 @@ ## Exit Codes -| Code | Condition | -| ---- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `0` | success — SSL enforcement status printed to stdout | -| `1` | `--experimental` not passed and `SUPABASE_EXPERIMENTAL` unset (`ExperimentalRequiredError`) — checked before ref resolution/API/telemetry | -| `1` | project ref unresolved (`ProjectRefNotLinkedError`; `InvalidProjectRefError` for a malformed `SUPABASE_PROJECT_ID` or link-file ref) | -| `1` | invalid `--remote` selector — `--remote=<url>` (`TargetNeedsProjectError`), an empty value, `--remote=<value>` combined with `--project-ref`, or a branch the linked project does not have (`LinkBranchNotFoundError`) | -| `1` | API non-200 (`SslEnforcementGetUnexpectedStatusError`) | -| `1` | transport failure (`SslEnforcementGetNetworkError`) | +| Code | Condition | +| ---- | ----------------------------------------------------------------------------------------------------------------------------------------- | +| `0` | success — SSL enforcement status printed to stdout | +| `1` | `--experimental` not passed and `SUPABASE_EXPERIMENTAL` unset (`ExperimentalRequiredError`) — checked before ref resolution/API/telemetry | +| `1` | project ref unresolved (`ProjectRefNotLinkedError`; `InvalidProjectRefError` for a malformed `SUPABASE_PROJECT_ID` or link-file ref) | +| `1` | API non-200 (`SslEnforcementGetUnexpectedStatusError`) | +| `1` | transport failure (`SslEnforcementGetNetworkError`) | ## Telemetry Events Fired @@ -108,14 +109,3 @@ 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: …` banner naming the resolved project is printed on stderr in every output format; see [Command target](../../../../docs/command-target.md). -- Requires `--remote=<ref|branch>`, `SUPABASE_PROJECT_ID`, a linked project (`supabase/.temp/project-ref`) - or, on an interactive terminal, the project picker. A bare `--remote` targets the linked project. -- `--remote=<value>` is read as a project ref (20 lowercase letters), a branch of the linked - project (by name or UUID), or `true|yes|on|1` for the linked project. A database URL is rejected - (`supabase ssl-enforcement get` takes a project, not a database); `false|no|off|0` and `--no-remote` fail with `--remote doesn't take false. Leave it out to use the linked project.`, and an empty value with `--remote= has an empty value. Pass --remote for the linked project or --remote=<ref|branch>; leave it out to use the linked project.` -- A word after a bare `--remote` is read as its value, `--remote=<word>`, because the command takes - no operands. -- The deprecated `--project-ref` is an alias of `--remote=<ref>` (a non-ref value is read as a - branch name) and prints a deprecation warning on stderr that names the replacement; it cannot - be combined with `--remote=<value>`. 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 792fb36cfa..afce26cb3c 100644 --- a/apps/cli/src/commands/ssl-enforcement/update/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/ssl-enforcement/update/SIDE_EFFECTS.md @@ -1,5 +1,7 @@ # `supabase ssl-enforcement update` +Target: hosted, consent default No — see [Command target](../../../../docs/command-target.md) + ## Files Read | Path | Format | When | @@ -37,18 +39,17 @@ ## Exit Codes -| Code | Condition | -| ---- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `0` | success — SSL enforcement status (post-update) printed to stdout | -| `1` | `--experimental` not passed and `SUPABASE_EXPERIMENTAL` unset (`ExperimentalRequiredError`) — checked in `.command.ts`, before the handler (and its flag validation/telemetry) ever runs | -| `1` | neither `--enable-db-ssl-enforcement` nor `--disable-db-ssl-enforcement` set (`SslEnforcementNoEnableDisableFlagError`) | -| `1` | both `--enable-db-ssl-enforcement` and `--disable-db-ssl-enforcement` set (`SslEnforcementMutuallyExclusiveFlagsError`) | -| `1` | project ref unresolved (`ProjectRefNotLinkedError`; `InvalidProjectRefError` for a malformed `SUPABASE_PROJECT_ID` or link-file ref) | -| `1` | invalid `--remote` selector — `--remote=<url>` (`TargetNeedsProjectError`), an empty value, `--remote=<value>` combined with `--project-ref`, or a branch the linked project does not have (`LinkBranchNotFoundError`) | -| `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 | +| Code | Condition | +| ---- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `0` | success — SSL enforcement status (post-update) printed to stdout | +| `1` | `--experimental` not passed and `SUPABASE_EXPERIMENTAL` unset (`ExperimentalRequiredError`) — checked in `.command.ts`, before the handler (and its flag validation/telemetry) ever runs | +| `1` | neither `--enable-db-ssl-enforcement` nor `--disable-db-ssl-enforcement` set (`SslEnforcementNoEnableDisableFlagError`) | +| `1` | both `--enable-db-ssl-enforcement` and `--disable-db-ssl-enforcement` set (`SslEnforcementMutuallyExclusiveFlagsError`) | +| `1` | project ref unresolved (`ProjectRefNotLinkedError`; `InvalidProjectRefError` for a malformed `SUPABASE_PROJECT_ID` or link-file ref) | +| `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 @@ -110,15 +111,4 @@ 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: …` 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. -- Requires `--remote=<ref|branch>`, `SUPABASE_PROJECT_ID`, a linked project (`supabase/.temp/project-ref`) - or, on an interactive terminal, the project picker. A bare `--remote` targets the linked project. -- `--remote=<value>` is read as a project ref (20 lowercase letters), a branch of the linked - project (by name or UUID), or `true|yes|on|1` for the linked project. A database URL is rejected - (`supabase ssl-enforcement update` takes a project, not a database); `false|no|off|0` and `--no-remote` fail with `--remote doesn't take false. Leave it out to use the linked project.`, and an empty value with `--remote= has an empty value. Pass --remote for the linked project or --remote=<ref|branch>; leave it out to use the linked project.` -- A word after a bare `--remote` is read as its value, `--remote=<word>`, because the command takes - no operands. -- The deprecated `--project-ref` is an alias of `--remote=<ref>` (a non-ref value is read as a - branch name) and prints a deprecation warning on stderr that names the replacement; it cannot - be combined with `--remote=<value>`. +- 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. diff --git a/apps/cli/src/commands/sso/add/SIDE_EFFECTS.md b/apps/cli/src/commands/sso/add/SIDE_EFFECTS.md index d3ad644175..52ecdc39e4 100644 --- a/apps/cli/src/commands/sso/add/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/sso/add/SIDE_EFFECTS.md @@ -1,5 +1,7 @@ # `supabase sso add` +Target: hosted, consent default No — see [Command target](../../../../docs/command-target.md) + ## Files Read | Path | Format | When | @@ -66,7 +68,6 @@ is sent. A 2xx response whose body does not match the provider schema fails as | `1` | `SsoAddNetworkError` — transport failure or undecodable 2xx body | | `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 | -| `1` | invalid `--remote` selector — `--remote=<url>` (`TargetNeedsProjectError`), an empty value, `--remote=<value>` combined with `--project-ref`, or a branch the linked project does not have (`LinkBranchNotFoundError`) | ## Telemetry Events Fired @@ -106,15 +107,4 @@ 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: …` 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. -- Requires `--remote=<ref|branch>`, `SUPABASE_PROJECT_ID`, a linked project (`supabase/.temp/project-ref`) - or, on an interactive terminal, the project picker. A bare `--remote` targets the linked project. -- `--remote=<value>` is read as a project ref (20 lowercase letters), a branch of the linked - project (by name or UUID), or `true|yes|on|1` for the linked project. A database URL is rejected - (`supabase sso add` takes a project, not a database); `false|no|off|0` and `--no-remote` fail with `--remote doesn't take false. Leave it out to use the linked project.`, and an empty value with `--remote= has an empty value. Pass --remote for the linked project or --remote=<ref|branch>; leave it out to use the linked project.` -- A word after a bare `--remote` is read as its value, `--remote=<word>`, because the command takes - no operands. -- The deprecated `--project-ref` is an alias of `--remote=<ref>` (a non-ref value is read as a - branch name) and prints a deprecation warning on stderr that names the replacement; it cannot - be combined with `--remote=<value>`. +- 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). diff --git a/apps/cli/src/commands/sso/info/SIDE_EFFECTS.md b/apps/cli/src/commands/sso/info/SIDE_EFFECTS.md index 404ef49c15..88e114c3f2 100644 --- a/apps/cli/src/commands/sso/info/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/sso/info/SIDE_EFFECTS.md @@ -1,5 +1,7 @@ # `supabase sso info` +Target: hosted, no consent — see [Command target](../../../../docs/command-target.md) + ## Files Read | Path | Format | When | @@ -41,12 +43,11 @@ own. The ref resolution and the cache fill still call the API: ## Exit Codes -| Code | Condition | -| ---- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `0` | success | -| `1` | `InvalidProjectRefError` — `SUPABASE_PROJECT_ID` or the link file holds a ref that doesn't match `^[a-z]{20}$` | -| `1` | `ProjectRefNotLinkedError` — no ref source available | -| `1` | invalid `--remote` selector — `--remote=<url>` (`TargetNeedsProjectError`), an empty value, `--remote=<value>` combined with `--project-ref`, or a branch the linked project does not have (`LinkBranchNotFoundError`) | +| Code | Condition | +| ---- | -------------------------------------------------------------------------------------------------------------- | +| `0` | success | +| `1` | `InvalidProjectRefError` — `SUPABASE_PROJECT_ID` or the link file holds a ref that doesn't match `^[a-z]{20}$` | +| `1` | `ProjectRefNotLinkedError` — no ref source available | ## Telemetry Events Fired @@ -80,14 +81,3 @@ 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: …` banner naming the resolved project is printed on stderr in every output format; see [Command target](../../../../docs/command-target.md). -- Requires `--remote=<ref|branch>`, `SUPABASE_PROJECT_ID`, a linked project (`supabase/.temp/project-ref`) - or, on an interactive terminal, the project picker. A bare `--remote` targets the linked project. -- `--remote=<value>` is read as a project ref (20 lowercase letters), a branch of the linked - project (by name or UUID), or `true|yes|on|1` for the linked project. A database URL is rejected - (`supabase sso info` takes a project, not a database); `false|no|off|0` and `--no-remote` fail with `--remote doesn't take false. Leave it out to use the linked project.`, and an empty value with `--remote= has an empty value. Pass --remote for the linked project or --remote=<ref|branch>; leave it out to use the linked project.` -- A word after a bare `--remote` is read as its value, `--remote=<word>`, because the command takes - no operands. -- The deprecated `--project-ref` is an alias of `--remote=<ref>` (a non-ref value is read as a - branch name) and prints a deprecation warning on stderr that names the replacement; it cannot - be combined with `--remote=<value>`. diff --git a/apps/cli/src/commands/sso/list/SIDE_EFFECTS.md b/apps/cli/src/commands/sso/list/SIDE_EFFECTS.md index 18edece0be..78e42cd4a8 100644 --- a/apps/cli/src/commands/sso/list/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/sso/list/SIDE_EFFECTS.md @@ -1,5 +1,7 @@ # `supabase sso list` +Target: hosted, no consent — see [Command target](../../../../docs/command-target.md) + ## Files Read | Path | Format | When | @@ -38,13 +40,12 @@ ## Exit Codes -| Code | Condition | -| ---- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `0` | success | -| `1` | `SsoListSamlDisabledError` — 404 from list endpoint | -| `1` | `SsoListUnexpectedStatusError` — other non-2xx | -| `1` | `SsoListNetworkError` — transport-level failure | -| `1` | invalid `--remote` selector — `--remote=<url>` (`TargetNeedsProjectError`), an empty value, `--remote=<value>` combined with `--project-ref`, or a branch the linked project does not have (`LinkBranchNotFoundError`) | +| Code | Condition | +| ---- | --------------------------------------------------- | +| `0` | success | +| `1` | `SsoListSamlDisabledError` — 404 from list endpoint | +| `1` | `SsoListUnexpectedStatusError` — other non-2xx | +| `1` | `SsoListNetworkError` — transport-level failure | ## Telemetry Events Fired @@ -75,14 +76,3 @@ 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: …` banner naming the resolved project is printed on stderr in every output format; see [Command target](../../../../docs/command-target.md). -- Requires `--remote=<ref|branch>`, `SUPABASE_PROJECT_ID`, a linked project (`supabase/.temp/project-ref`) - or, on an interactive terminal, the project picker. A bare `--remote` targets the linked project. -- `--remote=<value>` is read as a project ref (20 lowercase letters), a branch of the linked - project (by name or UUID), or `true|yes|on|1` for the linked project. A database URL is rejected - (`supabase sso list` takes a project, not a database); `false|no|off|0` and `--no-remote` fail with `--remote doesn't take false. Leave it out to use the linked project.`, and an empty value with `--remote= has an empty value. Pass --remote for the linked project or --remote=<ref|branch>; leave it out to use the linked project.` -- A word after a bare `--remote` is read as its value, `--remote=<word>`, because the command takes - no operands. -- The deprecated `--project-ref` is an alias of `--remote=<ref>` (a non-ref value is read as a - branch name) and prints a deprecation warning on stderr that names the replacement; it cannot - be combined with `--remote=<value>`. diff --git a/apps/cli/src/commands/sso/remove/SIDE_EFFECTS.md b/apps/cli/src/commands/sso/remove/SIDE_EFFECTS.md index 698fc9e3e4..943231f860 100644 --- a/apps/cli/src/commands/sso/remove/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/sso/remove/SIDE_EFFECTS.md @@ -1,5 +1,7 @@ # `supabase sso remove` +Target: hosted, consent default No — see [Command target](../../../../docs/command-target.md) + ## Files Read | Path | Format | When | @@ -39,16 +41,15 @@ ## 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 | -| `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 | -| `1` | invalid `--remote` selector — `--remote=<url>` (`TargetNeedsProjectError`), an empty value, `--remote=<value>` combined with `--project-ref`, or a branch the linked project does not have (`LinkBranchNotFoundError`) | +| 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 @@ -79,16 +80,4 @@ 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: …` 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. -- Requires `--remote=<ref|branch>`, `SUPABASE_PROJECT_ID`, a linked project (`supabase/.temp/project-ref`) - or, on an interactive terminal, the project picker. A bare `--remote` targets the linked project. -- `--remote=<value>` is read as a project ref (20 lowercase letters), a branch of the linked - project (by name or UUID), or `true|yes|on|1` for the linked project. A database URL is rejected - (`supabase sso remove` takes a project, not a database); `false|no|off|0` and `--no-remote` fail with `--remote doesn't take false. Leave it out to use the linked project.`, and an empty value with `--remote= has an empty value. Pass --remote for the linked project or --remote=<ref|branch>; leave it out to use the linked project.` -- A bare `--remote` followed by a word that exactly matches a branch of the linked project, is - within a small edit distance of one, or can't be checked because the branch listing failed stops - the run before any other work with an error that names the cause (an HTTP status, or a missing or invalid access token) and offers both readings without echoing the word: `--remote=<branch>` for the branch, the command's operands then `--remote` for the linked project. Any other word runs as the operand. -- The deprecated `--project-ref` is an alias of `--remote=<ref>` (a non-ref value is read as a - branch name) and prints a deprecation warning on stderr that names the replacement; it cannot - be combined with `--remote=<value>`. +- Asks `Remove SSO provider <id> on <target>?` (default No) before the first write. diff --git a/apps/cli/src/commands/sso/show/SIDE_EFFECTS.md b/apps/cli/src/commands/sso/show/SIDE_EFFECTS.md index c3f6a375ef..2a0373e647 100644 --- a/apps/cli/src/commands/sso/show/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/sso/show/SIDE_EFFECTS.md @@ -1,5 +1,7 @@ # `supabase sso show` +Target: hosted, no consent — see [Command target](../../../../docs/command-target.md) + ## Files Read | Path | Format | When | @@ -39,15 +41,14 @@ side-calls. ## Exit Codes -| Code | Condition | -| ---- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `0` | success | -| `1` | `SsoInvalidUuidError` — provider ID is not a canonical UUID | -| `1` | `SsoShowNotFoundError` — 404 from get endpoint | -| `1` | `SsoShowUnexpectedStatusError` — other non-2xx | -| `1` | `SsoShowEnvNotSupportedError` — `--output env` with show | -| `1` | `SsoShowNetworkError` — transport-level failure | -| `1` | invalid `--remote` selector — `--remote=<url>` (`TargetNeedsProjectError`), an empty value, `--remote=<value>` combined with `--project-ref`, or a branch the linked project does not have (`LinkBranchNotFoundError`) | +| Code | Condition | +| ---- | ----------------------------------------------------------- | +| `0` | success | +| `1` | `SsoInvalidUuidError` — provider ID is not a canonical UUID | +| `1` | `SsoShowNotFoundError` — 404 from get endpoint | +| `1` | `SsoShowUnexpectedStatusError` — other non-2xx | +| `1` | `SsoShowEnvNotSupportedError` — `--output env` with show | +| `1` | `SsoShowNetworkError` — transport-level failure | ## Telemetry Events Fired @@ -82,15 +83,3 @@ 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: …` banner naming the resolved project is printed on stderr in every output format; see [Command target](../../../../docs/command-target.md). -- Requires `--remote=<ref|branch>`, `SUPABASE_PROJECT_ID`, a linked project (`supabase/.temp/project-ref`) - or, on an interactive terminal, the project picker. A bare `--remote` targets the linked project. -- `--remote=<value>` is read as a project ref (20 lowercase letters), a branch of the linked - project (by name or UUID), or `true|yes|on|1` for the linked project. A database URL is rejected - (`supabase sso show` takes a project, not a database); `false|no|off|0` and `--no-remote` fail with `--remote doesn't take false. Leave it out to use the linked project.`, and an empty value with `--remote= has an empty value. Pass --remote for the linked project or --remote=<ref|branch>; leave it out to use the linked project.` -- A bare `--remote` followed by a word that exactly matches a branch of the linked project, is - within a small edit distance of one, or can't be checked because the branch listing failed stops - the run before any other work with an error that names the cause (an HTTP status, or a missing or invalid access token) and offers both readings without echoing the word: `--remote=<branch>` for the branch, the command's operands then `--remote` for the linked project. Any other word runs as the operand. -- The deprecated `--project-ref` is an alias of `--remote=<ref>` (a non-ref value is read as a - branch name) and prints a deprecation warning on stderr that names the replacement; it cannot - be combined with `--remote=<value>`. diff --git a/apps/cli/src/commands/sso/update/SIDE_EFFECTS.md b/apps/cli/src/commands/sso/update/SIDE_EFFECTS.md index 05fbda51bd..55c6876519 100644 --- a/apps/cli/src/commands/sso/update/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/sso/update/SIDE_EFFECTS.md @@ -1,5 +1,7 @@ # `supabase sso update` +Target: hosted, consent default No — see [Command target](../../../../docs/command-target.md) + ## Files Read | Path | Format | When | @@ -70,7 +72,6 @@ classification), for the GET before any PUT. | `1` | `SsoUpdateNetworkError` — transport failure or undecodable 2xx body | | `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 | -| `1` | invalid `--remote` selector — `--remote=<url>` (`TargetNeedsProjectError`), an empty value, `--remote=<value>` combined with `--project-ref`, or a branch the linked project does not have (`LinkBranchNotFoundError`) | ## Telemetry Events Fired @@ -112,17 +113,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.` (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: …` 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. +- 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. - The provider is fetched (a read) before the confirmation so the resulting domain set is known; the `PUT` happens only after it. -- Requires `--remote=<ref|branch>`, `SUPABASE_PROJECT_ID`, a linked project (`supabase/.temp/project-ref`) - or, on an interactive terminal, the project picker. A bare `--remote` targets the linked project. -- `--remote=<value>` is read as a project ref (20 lowercase letters), a branch of the linked - project (by name or UUID), or `true|yes|on|1` for the linked project. A database URL is rejected - (`supabase sso update` takes a project, not a database); `false|no|off|0` and `--no-remote` fail with `--remote doesn't take false. Leave it out to use the linked project.`, and an empty value with `--remote= has an empty value. Pass --remote for the linked project or --remote=<ref|branch>; leave it out to use the linked project.` -- A bare `--remote` followed by a word that exactly matches a branch of the linked project, is - within a small edit distance of one, or can't be checked because the branch listing failed stops - the run before any other work with an error that names the cause (an HTTP status, or a missing or invalid access token) and offers both readings without echoing the word: `--remote=<branch>` for the branch, the command's operands then `--remote` for the linked project. Any other word runs as the operand. -- The deprecated `--project-ref` is an alias of `--remote=<ref>` (a non-ref value is read as a - branch name) and prints a deprecation warning on stderr that names the replacement; it cannot - be combined with `--remote=<value>`. diff --git a/apps/cli/src/commands/storage/cp/SIDE_EFFECTS.md b/apps/cli/src/commands/storage/cp/SIDE_EFFECTS.md index 0ee678d27f..cab5876e68 100644 --- a/apps/cli/src/commands/storage/cp/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/storage/cp/SIDE_EFFECTS.md @@ -1,12 +1,11 @@ # `supabase storage cp <src> <dst>` +Target: selectable, consent default No — see [Command target](../../../../docs/command-target.md) + 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. -With no selector the command targets the local stack. `--remote` targets the linked project, -`--remote=<ref>` another project and `--remote=<branch>` a branch of the linked project (see -[ADR 0033](../../../../../../docs/adr/0033-target-selection-and-hosted-mutation-consent.md)); -`--local`, `--linked` and `--project-ref` are deprecated aliases. The local Storage gateway +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. @@ -65,18 +64,15 @@ 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` | two of `--remote`, `--linked` and `--local`, or `--project-ref` with `--local` or `--remote=<value>` (`TargetSelectorConflictError`, `TargetProjectRefConflictError`), after the experimental gate and the workdir check and before any Storage request | -| `1` | `--remote=<db-url>` (`TargetNeedsProjectError`): the Storage API needs a project, not a database URL; no Storage request is sent | -| `1` | `--remote=<branch>` matches no branch of the linked project (`LinkBranchNotFoundError`, lists the branches that exist) | -| `1` | upload to a remote 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`) | +| 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` | upload to a remote 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 @@ -107,34 +103,11 @@ backend — same roles as `storage ls`. ## Notes -- **Target.** With no selector the command runs against the local stack; when the directory is - linked the local `Target:` banner ends with `· default; pass --remote for <project>`. `--remote` - targets the linked project (`SUPABASE_PROJECT_ID`, else `.temp/project-ref`). `--remote=<value>` - is read as a project ref (20 lowercase letters), `true|yes|on|1` (the linked project), or - otherwise the name of a branch of the linked project, which resolves to that branch's own project. - A database URL (`--remote=<db-url>`) fails with a `needs a project, not a database URL` error and - sends no request. `false|no|off|0` and `--no-remote` fail with `--remote doesn't take false. Leave it out to target the local Storage API.`, and an empty value with `--remote= has an empty value. Pass --remote for the linked project or --remote=<ref|branch>; leave it out to target the local Storage API.` A bare `--remote` followed by a word that - exactly matches a branch of the linked project, is within a small edit distance of one, or can't - be checked because the branch listing failed stops the run with an error that names the cause (an HTTP status, or a missing or invalid access token) and offers both readings without echoing the word: `--remote=<branch>` for the branch, the command's operands then `--remote` for the linked project. Any other word runs - as the operand. The resolved target is announced on stderr (`Target: …`) - in every output format and, in JSON results, as a `target` object. -- **Deprecated selectors.** `--linked`, `--project-ref` and `--local` are hidden aliases that still - select their target and print a warning on stderr naming the replacement, for example - `--linked is deprecated and will be removed in v4. Use --remote instead: supabase storage cp <src> <dst> --remote` - and `--local isn't needed (local is the default) and will be removed in v4: supabase storage cp <src> <dst>`. - Two selectors together fail - (`Only one of --remote, --linked and --local can be set; got …`), `--project-ref` cannot - be combined with `--local` or `--remote=<value>`, an empty `--project-ref` is rejected, and - `--linked=false` selects nothing. -- **Target banner.** A remote project 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. +- **Target banner.** A remote project prints its `Target:` line to stderr once its ref resolves; the local + target prints it once the Storage endpoint is known. Both come before the first Storage request. - **Hosted consent.** Uploading to a remote 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 + under the destination are overwritten. 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. diff --git a/apps/cli/src/commands/storage/ls/SIDE_EFFECTS.md b/apps/cli/src/commands/storage/ls/SIDE_EFFECTS.md index d8b91e44c1..addb64c44d 100644 --- a/apps/cli/src/commands/storage/ls/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/storage/ls/SIDE_EFFECTS.md @@ -1,11 +1,10 @@ # `supabase storage ls [path]` +Target: selectable, no consent — see [Command target](../../../../docs/command-target.md) + Lists objects/buckets by path prefix against the Storage gateway (local stack or a remote project). -With no selector the command targets the local stack. `--remote` targets the linked project, -`--remote=<ref>` another project and `--remote=<branch>` a branch of the linked project (see -[ADR 0033](../../../../../../docs/adr/0033-target-selection-and-hosted-mutation-consent.md)); -`--local`, `--linked` and `--project-ref` are deprecated aliases. The local Storage gateway +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. @@ -61,17 +60,14 @@ Auth: `apikey` header always; `Authorization: Bearer <key>` unless the key is `s ## 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 URL / url-parse error / API non-2xx / network / auth / config parse | -| `1` | two of `--remote`, `--linked` and `--local`, or `--project-ref` with `--local` or `--remote=<value>` (`TargetSelectorConflictError`, `TargetProjectRefConflictError`), after the experimental gate and the workdir check and before any Storage request | -| `1` | `--remote=<db-url>` (`TargetNeedsProjectError`): the Storage API needs a project, not a database URL; no Storage request is sent | -| `1` | `--remote=<branch>` matches no branch of the linked project (`LinkBranchNotFoundError`, lists the branches that exist) | -| `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 URL / url-parse error / API non-2xx / network / auth / config parse | +| `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 @@ -105,34 +101,13 @@ 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). -- **Target.** With no selector the command runs against the local stack; when the directory is - linked the local `Target:` banner ends with `· default; pass --remote for <project>`. `--remote` - targets the linked project (`SUPABASE_PROJECT_ID`, else `.temp/project-ref`). `--remote=<value>` - is read as a project ref (20 lowercase letters), `true|yes|on|1` (the linked project), or - otherwise the name of a branch of the linked project, which resolves to that branch's own project. - A database URL (`--remote=<db-url>`) fails with a `needs a project, not a database URL` error and - sends no request. `false|no|off|0` and `--no-remote` fail with `--remote doesn't take false. Leave it out to target the local Storage API.`, and an empty value with `--remote= has an empty value. Pass --remote for the linked project or --remote=<ref|branch>; leave it out to target the local Storage API.` A bare `--remote` followed by a word that - exactly matches a branch of the linked project, is within a small edit distance of one, or can't - be checked because the branch listing failed stops the run with an error that names the cause (an HTTP status, or a missing or invalid access token) and offers both readings without echoing the word: `--remote=<branch>` for the branch, the command's operands then `--remote` for the linked project. Any other word runs - as the operand. The resolved target is announced on stderr (`Target: …`) - in every output format and, in JSON results, as a `target` object. -- **Deprecated selectors.** `--linked`, `--project-ref` and `--local` are hidden aliases that still - select their target and print a warning on stderr naming the replacement, for example - `--linked is deprecated and will be removed in v4. Use --remote instead: supabase storage ls <path> --remote` - and `--local isn't needed (local is the default) and will be removed in v4: supabase storage ls <path>`. - Two selectors together fail - (`Only one of --remote, --linked and --local can be set; got …`), `--project-ref` cannot - be combined with `--local` or `--remote=<value>`, an empty `--project-ref` is rejected, and - `--linked=false` selects nothing. -- **Target banner.** A remote project 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. +- **Target banner.** A remote project prints its `Target:` line to stderr once its ref resolves; the local + target prints it once the Storage endpoint is known. Both come before the first Storage request. - `--remote` and the deprecated `--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 them; the only behavioural cost is that they must follow the subcommand token (`storage ls --remote`, not `storage --remote ls`) — the same shape the `db` family uses. -- Listing is a read: it never asks for consent. - **Stack backend (local target).** 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/mv/SIDE_EFFECTS.md b/apps/cli/src/commands/storage/mv/SIDE_EFFECTS.md index 1864980069..ef646a3c53 100644 --- a/apps/cli/src/commands/storage/mv/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/storage/mv/SIDE_EFFECTS.md @@ -1,13 +1,12 @@ # `supabase storage mv <src> <dst>` +Target: selectable, consent default No — see [Command target](../../../../docs/command-target.md) + Moves objects within a bucket. Both paths must be `ss://` and resolve to the same bucket. A direct move that returns `not_found` falls back to a recursive per-object move when `--recursive` is set. -With no selector the command targets the local stack. `--remote` targets the linked project, -`--remote=<ref>` another project and `--remote=<branch>` a branch of the linked project (see -[ADR 0033](../../../../../../docs/adr/0033-target-selection-and-hosted-mutation-consent.md)); -`--local`, `--linked` and `--project-ref` are deprecated aliases. The local Storage gateway +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. @@ -60,18 +59,15 @@ 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` | two of `--remote`, `--linked` and `--local`, or `--project-ref` with `--local` or `--remote=<value>` (`TargetSelectorConflictError`, `TargetProjectRefConflictError`), after the experimental gate and the workdir check and before any Storage request | -| `1` | `--remote=<db-url>` (`TargetNeedsProjectError`): the Storage API needs a project, not a database URL; no Storage request is sent | -| `1` | `--remote=<branch>` matches no branch of the linked project (`LinkBranchNotFoundError`, lists the branches that exist) | -| `1` | move on a remote 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`) | +| 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` | move on a remote 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 @@ -102,34 +98,11 @@ backend — same roles as `storage ls`. ## Notes -- **Target.** With no selector the command runs against the local stack; when the directory is - linked the local `Target:` banner ends with `· default; pass --remote for <project>`. `--remote` - targets the linked project (`SUPABASE_PROJECT_ID`, else `.temp/project-ref`). `--remote=<value>` - is read as a project ref (20 lowercase letters), `true|yes|on|1` (the linked project), or - otherwise the name of a branch of the linked project, which resolves to that branch's own project. - A database URL (`--remote=<db-url>`) fails with a `needs a project, not a database URL` error and - sends no request. `false|no|off|0` and `--no-remote` fail with `--remote doesn't take false. Leave it out to target the local Storage API.`, and an empty value with `--remote= has an empty value. Pass --remote for the linked project or --remote=<ref|branch>; leave it out to target the local Storage API.` A bare `--remote` followed by a word that - exactly matches a branch of the linked project, is within a small edit distance of one, or can't - be checked because the branch listing failed stops the run with an error that names the cause (an HTTP status, or a missing or invalid access token) and offers both readings without echoing the word: `--remote=<branch>` for the branch, the command's operands then `--remote` for the linked project. Any other word runs - as the operand. The resolved target is announced on stderr (`Target: …`) - in every output format and, in JSON results, as a `target` object. -- **Deprecated selectors.** `--linked`, `--project-ref` and `--local` are hidden aliases that still - select their target and print a warning on stderr naming the replacement, for example - `--linked is deprecated and will be removed in v4. Use --remote instead: supabase storage mv <src> <dst> --remote` - and `--local isn't needed (local is the default) and will be removed in v4: supabase storage mv <src> <dst>`. - Two selectors together fail - (`Only one of --remote, --linked and --local can be set; got …`), `--project-ref` cannot - be combined with `--local` or `--remote=<value>`, an empty `--project-ref` is rejected, and - `--linked=false` selects nothing. -- **Target banner.** A remote project 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. +- **Target banner.** A remote project prints its `Target:` line to stderr once its ref resolves; the local + target prints it once the Storage endpoint is known. Both come before the first Storage request. - **Hosted consent.** Moving on a remote 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 + object under the source path is moved. 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 diff --git a/apps/cli/src/commands/storage/rm/SIDE_EFFECTS.md b/apps/cli/src/commands/storage/rm/SIDE_EFFECTS.md index f93f8bee15..af2de3f945 100644 --- a/apps/cli/src/commands/storage/rm/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/storage/rm/SIDE_EFFECTS.md @@ -1,14 +1,13 @@ # `supabase storage rm <file> ...` +Target: selectable, consent default No — see [Command target](../../../../docs/command-target.md) + 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. The local stack confirms each bucket; a remote project asks once for the whole run. -With no selector the command targets the local stack. `--remote` targets the linked project, -`--remote=<ref>` another project and `--remote=<branch>` a branch of the linked project (see -[ADR 0033](../../../../../../docs/adr/0033-target-selection-and-hosted-mutation-consent.md)); -`--local`, `--linked` and `--project-ref` are deprecated aliases. The local Storage gateway +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. @@ -71,9 +70,6 @@ wins); for a remote project only `--yes` or a shell `SUPABASE_YES` counts. | `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` | two of `--remote`, `--linked` and `--local`, or `--project-ref` with `--local` or `--remote=<value>` (`TargetSelectorConflictError`, `TargetProjectRefConflictError`), after the experimental gate and the workdir check and before any Storage request | -| `1` | `--remote=<db-url>` (`TargetNeedsProjectError`): the Storage API needs a project, not a database URL; no Storage request is sent | -| `1` | `--remote=<branch>` matches no branch of the linked project (`LinkBranchNotFoundError`, lists the branches that exist) | | `1` | remote 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`) | @@ -120,37 +116,14 @@ Requires `--yes`/`SUPABASE_YES` on the same terms as `json`. ## Notes -- **Target.** With no selector the command runs against the local stack; when the directory is - linked the local `Target:` banner ends with `· default; pass --remote for <project>`. `--remote` - targets the linked project (`SUPABASE_PROJECT_ID`, else `.temp/project-ref`). `--remote=<value>` - is read as a project ref (20 lowercase letters), `true|yes|on|1` (the linked project), or - otherwise the name of a branch of the linked project, which resolves to that branch's own project. - A database URL (`--remote=<db-url>`) fails with a `needs a project, not a database URL` error and - sends no request. `false|no|off|0` and `--no-remote` fail with `--remote doesn't take false. Leave it out to target the local Storage API.`, and an empty value with `--remote= has an empty value. Pass --remote for the linked project or --remote=<ref|branch>; leave it out to target the local Storage API.` A bare `--remote` followed by a word that - exactly matches a branch of the linked project, is within a small edit distance of one, or can't - be checked because the branch listing failed stops the run with an error that names the cause (an HTTP status, or a missing or invalid access token) and offers both readings without echoing the word: `--remote=<branch>` for the branch, the command's operands then `--remote` for the linked project. Any other word runs - as the operand. The resolved target is announced on stderr (`Target: …`) - in every output format and, in JSON results, as a `target` object. -- **Deprecated selectors.** `--linked`, `--project-ref` and `--local` are hidden aliases that still - select their target and print a warning on stderr naming the replacement, for example - `--linked is deprecated and will be removed in v4. Use --remote instead: supabase storage rm <file>... --remote` - and `--local isn't needed (local is the default) and will be removed in v4: supabase storage rm <file>...`. - Two selectors together fail - (`Only one of --remote, --linked and --local can be set; got …`), `--project-ref` cannot - be combined with `--local` or `--remote=<value>`, an empty `--project-ref` is rejected, and - `--linked=false` selects nothing. -- **Target banner.** A remote project 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. +- **Target banner.** A remote project prints its `Target:` line to stderr once its ref resolves; the local + target prints it 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. - **Hosted consent.** A remote 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. + paths, after the bucket list) and before the first delete, with default No. 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 diff --git a/apps/cli/src/commands/test/db/SIDE_EFFECTS.md b/apps/cli/src/commands/test/db/SIDE_EFFECTS.md index 2ea08e6c2f..f3bd9989c0 100644 --- a/apps/cli/src/commands/test/db/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/test/db/SIDE_EFFECTS.md @@ -1,10 +1,8 @@ # `supabase test db [path...]` -Runs pgTAP tests against a database. With no selector the command runs against the local stack, -and in a linked checkout the `Target:` line adds `default; pass --remote for <project>`. `--remote` -selects the linked project, `--remote=<ref>` another project, `--remote=<branch>` a branch of the -linked project (by name or UUID) and `--remote=<url>` a custom database (see -[ADR 0033](../../../../../../docs/adr/0033-target-selection-and-hosted-mutation-consent.md)). The run enables pgTAP and drops it again, so a hosted target (a remote project, branch or +Target: selectable, consent default No — see [Command target](../../../../docs/command-target.md) + +Runs pgTAP tests against a database. The run enables pgTAP and drops it again, so a hosted target (a remote project, branch or remote `--remote=<url>`) asks for one confirmation before anything runs; the local stack never prompts. ## Files Read @@ -71,16 +69,14 @@ Managed local stack prove runs the catalog `pg_prove` tool through the owning st ## Exit Codes -| Code | Condition | -| ---- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `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` | more than one of `--remote` / `--linked` / `--db-url` / `--local` (`TargetSelectorConflictError`) | -| `1` | database connection failure / pgTAP enable failure / docker failure / remote-project auth or IPv6 errors; `--remote=<branch>` naming no branch of the parent project | -| `1` | `--project-ref` with `--local`, `--db-url` or `--remote=<value>` (`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 | +| Code | Condition | +| ---- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `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` | database connection failure / pgTAP enable failure / docker failure / remote-project auth or IPv6 errors | +| `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 @@ -104,10 +100,7 @@ subprocess's stdout is forwarded chunk-by-chunk, byte-exact and unframed) — ### `--output-format text` -TAP streams to stdout. The alias deprecation warnings, the `Target: …` line (remote project `Target: <name> (<ref>)`, -branch `Target: project <parent> · branch <name> (<ref>)`, `Target: local stack (<host>:<port>)` -with ` · default; pass --remote for <project>` appended in a linked checkout when no selector was -given, or `Target: custom Postgres (<host>:<port>)`) and the hosted +TAP streams to stdout. The alias deprecation warnings, the `Target:` line and the hosted confirmation question are written to **stderr**, never to stdout — no spinner is used, so stdout carries only the raw TAP bytes. @@ -122,22 +115,9 @@ 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. -- **Target selection** (shared by `db test` via `testDbConfig`). `--remote` is read as the linked project (bare), a project ref (20 - lowercase letters), a Postgres URL, or else a branch of the linked project by name or UUID; - `--remote=false` fails with `--remote doesn't take false. Leave it out to target the local database.`, and `--remote=` with `--remote= has an empty value. Pass --remote for the linked project or --remote=<ref|branch|url>; leave it out to target the local database.` A - bare `--remote` followed by a word that exactly matches a branch of the linked project, is within - a small edit distance of one, or can't be checked because the branch listing failed stops the run - with an error that names the cause (an HTTP status, or a missing or invalid access token) and offers both readings without echoing the word: `--remote=<branch>` for the branch, the command's operands then `--remote` for the linked project. Any other word runs as the operand. -- **Deprecated aliases.** `--linked`, `--project-ref=<ref>`, `--db-url=<url>` and `--local` still - work and print a deprecation warning on stderr naming the `--remote` replacement (`--local` - warns that local is the default). They are mutually exclusive with each other and with - `--remote`; a `false` switch counts as absent. `--project-ref` with `--local`, `--db-url` or - `--remote=<value>` is a hard error. - **Hosted consent.** A remote project, branch or remote `--remote=<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 `--remote=<url>` + enable, the `pg_prove` run against the test paths, and the pgTAP drop, with default No. A `--remote=<url>` that points at the local stack never prompts. - Postgres TLS behavior: local connections disable TLS (`ConnectLocalPostgres` sets `cc.TLSConfig = nil`); remote (`--remote`) @@ -145,7 +125,7 @@ command (exit 1). `disable` → plaintext, `verify-ca` / `verify-full` → TLS **with** certificate verification, and everything else (`prefer` / `require` / unset) → TLS **without** verification (non-TLS fallbacks stripped). -- `--remote=<url>` (and the deprecated `--db-url`) accepts both the WHATWG `postgres(ql)://…` URL form and the libpq +- `--remote=<url>` accepts a WHATWG `postgres(ql)://…` URL; the deprecated `--db-url` also accepts the libpq keyword/value DSN form (`host=… dbname=… user=…`, incl. unix-socket paths), following libpq parsing. The `sslmode` and libpq `options` (Supavisor `?options=reference=<ref>`) parameters are preserved on both forms. A malformed URL or percent escape surfaces as a redacted `failed to parse connection string` error, never 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 4c2ce8d83c..d96a538e52 100644 --- a/apps/cli/src/commands/vanity-subdomains/activate/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/vanity-subdomains/activate/SIDE_EFFECTS.md @@ -1,5 +1,7 @@ # `supabase vanity-subdomains activate` +Target: hosted, consent default No — see [Command target](../../../../docs/command-target.md) + ## Files Read | Path | Format | When | @@ -39,17 +41,16 @@ ## Exit Codes -| Code | Condition | -| ---- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `0` | success | -| `1` | `--experimental` not passed and `SUPABASE_EXPERIMENTAL` unset (`ExperimentalRequiredError`) — checked before ref resolution/API/telemetry | -| `1` | project ref unresolved (`ProjectRefNotLinkedError`; `InvalidProjectRefError` for a malformed `SUPABASE_PROJECT_ID` or link-file ref) | -| `1` | invalid `--remote` selector — `--remote=<url>` (`TargetNeedsProjectError`), an empty value, `--remote=<value>` combined with `--project-ref`, or a branch the linked project does not have (`LinkBranchNotFoundError`) | -| `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 | +| Code | Condition | +| ---- | ----------------------------------------------------------------------------------------------------------------------------------------- | +| `0` | success | +| `1` | `--experimental` not passed and `SUPABASE_EXPERIMENTAL` unset (`ExperimentalRequiredError`) — checked before ref resolution/API/telemetry | +| `1` | project ref unresolved (`ProjectRefNotLinkedError`; `InvalidProjectRefError` for a malformed `SUPABASE_PROJECT_ID` or link-file ref) | +| `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 @@ -95,15 +96,4 @@ One `result` event with the full response object. 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. -- Requires `--remote=<ref|branch>`, `SUPABASE_PROJECT_ID`, a linked project (`supabase/.temp/project-ref`) - or, on an interactive terminal, the project picker. A bare `--remote` targets the linked project. -- `--remote=<value>` is read as a project ref (20 lowercase letters), a branch of the linked - project (by name or UUID), or `true|yes|on|1` for the linked project. A database URL is rejected - (`supabase vanity-subdomains activate` takes a project, not a database); `false|no|off|0` and `--no-remote` fail with `--remote doesn't take false. Leave it out to use the linked project.`, and an empty value with `--remote= has an empty value. Pass --remote for the linked project or --remote=<ref|branch>; leave it out to use the linked project.` -- A word after a bare `--remote` is read as its value, `--remote=<word>`, because the command takes - no operands. -- The deprecated `--project-ref` is an alias of `--remote=<ref>` (a non-ref value is read as a - branch name) and prints a deprecation warning on stderr that names the replacement; it cannot - be combined with `--remote=<value>`. +- **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. 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 07781daefa..dc35e60073 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 @@ -1,5 +1,7 @@ # `supabase vanity-subdomains check-availability` +Target: hosted, no consent — see [Command target](../../../../docs/command-target.md) + ## Files Read | Path | Format | When | @@ -38,15 +40,14 @@ ## Exit Codes -| Code | Condition | -| ---- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `0` | success | -| `1` | `--experimental` not passed and `SUPABASE_EXPERIMENTAL` unset (`ExperimentalRequiredError`) — checked before ref resolution/API/telemetry | -| `1` | project ref unresolved (`ProjectRefNotLinkedError`; `InvalidProjectRefError` for a malformed `SUPABASE_PROJECT_ID` or link-file ref) | -| `1` | invalid `--remote` selector — `--remote=<url>` (`TargetNeedsProjectError`), an empty value, `--remote=<value>` combined with `--project-ref`, or a branch the linked project does not have (`LinkBranchNotFoundError`) | -| `1` | `--desired-subdomain` omitted (`DesiredSubdomainRequiredError`) — checked after gate/login/ref resolution; telemetry still fires | -| `1` | API non-2xx (`VanitySubdomainsCheckUnexpectedStatusError`) | -| `1` | transport failure (`VanitySubdomainsCheckNetworkError`) | +| Code | Condition | +| ---- | ----------------------------------------------------------------------------------------------------------------------------------------- | +| `0` | success | +| `1` | `--experimental` not passed and `SUPABASE_EXPERIMENTAL` unset (`ExperimentalRequiredError`) — checked before ref resolution/API/telemetry | +| `1` | project ref unresolved (`ProjectRefNotLinkedError`; `InvalidProjectRefError` for a malformed `SUPABASE_PROJECT_ID` or link-file ref) | +| `1` | `--desired-subdomain` omitted (`DesiredSubdomainRequiredError`) — checked after gate/login/ref resolution; telemetry still fires | +| `1` | API non-2xx (`VanitySubdomainsCheckUnexpectedStatusError`) | +| `1` | transport failure (`VanitySubdomainsCheckNetworkError`) | ## Telemetry Events Fired @@ -92,15 +93,3 @@ One `result` event with the full response object. flag, and telemetry still fires and the cache fill still runs 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. -- Requires `--remote=<ref|branch>`, `SUPABASE_PROJECT_ID`, a linked project (`supabase/.temp/project-ref`) - or, on an interactive terminal, the project picker. A bare `--remote` targets the linked project. -- `--remote=<value>` is read as a project ref (20 lowercase letters), a branch of the linked - project (by name or UUID), or `true|yes|on|1` for the linked project. A database URL is rejected - (`supabase vanity-subdomains check-availability` takes a project, not a database); `false|no|off|0` and `--no-remote` fail with `--remote doesn't take false. Leave it out to use the linked project.`, and an empty value with `--remote= has an empty value. Pass --remote for the linked project or --remote=<ref|branch>; leave it out to use the linked project.` -- A word after a bare `--remote` is read as its value, `--remote=<word>`, because the command takes - no operands. -- The deprecated `--project-ref` is an alias of `--remote=<ref>` (a non-ref value is read as a - branch name) and prints a deprecation warning on stderr that names the replacement; it cannot - be combined with `--remote=<value>`. 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 92510c4235..0af66922fd 100644 --- a/apps/cli/src/commands/vanity-subdomains/delete/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/vanity-subdomains/delete/SIDE_EFFECTS.md @@ -1,5 +1,7 @@ # `supabase vanity-subdomains delete` +Target: hosted, consent default No — see [Command target](../../../../docs/command-target.md) + ## Files Read | Path | Format | When | @@ -37,16 +39,15 @@ ## Exit Codes -| Code | Condition | -| ---- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `0` | success | -| `1` | `--experimental` not passed and `SUPABASE_EXPERIMENTAL` unset (`ExperimentalRequiredError`) — checked before ref resolution/API/telemetry | -| `1` | project ref unresolved (`ProjectRefNotLinkedError`; `InvalidProjectRefError` for a malformed `SUPABASE_PROJECT_ID` or link-file ref) | -| `1` | invalid `--remote` selector — `--remote=<url>` (`TargetNeedsProjectError`), an empty value, `--remote=<value>` combined with `--project-ref`, or a branch the linked project does not have (`LinkBranchNotFoundError`) | -| `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 | +| Code | Condition | +| ---- | ----------------------------------------------------------------------------------------------------------------------------------------- | +| `0` | success | +| `1` | `--experimental` not passed and `SUPABASE_EXPERIMENTAL` unset (`ExperimentalRequiredError`) — checked before ref resolution/API/telemetry | +| `1` | project ref unresolved (`ProjectRefNotLinkedError`; `InvalidProjectRefError` for a malformed `SUPABASE_PROJECT_ID` or link-file ref) | +| `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 @@ -84,15 +85,4 @@ One `result` event when the `--output` flag is unset. `GET /v1/projects/{ref}` succeeds and `.temp/project-ref` does not name another ref. 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. -- Requires `--remote=<ref|branch>`, `SUPABASE_PROJECT_ID`, a linked project (`supabase/.temp/project-ref`) - or, on an interactive terminal, the project picker. A bare `--remote` targets the linked project. -- `--remote=<value>` is read as a project ref (20 lowercase letters), a branch of the linked - project (by name or UUID), or `true|yes|on|1` for the linked project. A database URL is rejected - (`supabase vanity-subdomains delete` takes a project, not a database); `false|no|off|0` and `--no-remote` fail with `--remote doesn't take false. Leave it out to use the linked project.`, and an empty value with `--remote= has an empty value. Pass --remote for the linked project or --remote=<ref|branch>; leave it out to use the linked project.` -- A word after a bare `--remote` is read as its value, `--remote=<word>`, because the command takes - no operands. -- The deprecated `--project-ref` is an alias of `--remote=<ref>` (a non-ref value is read as a - branch name) and prints a deprecation warning on stderr that names the replacement; it cannot - be combined with `--remote=<value>`. +- **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. 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 f4211ec45b..2d1c761edc 100644 --- a/apps/cli/src/commands/vanity-subdomains/get/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/vanity-subdomains/get/SIDE_EFFECTS.md @@ -1,5 +1,7 @@ # `supabase vanity-subdomains get` +Target: hosted, no consent — see [Command target](../../../../docs/command-target.md) + ## Files Read | Path | Format | When | @@ -36,14 +38,13 @@ ## Exit Codes -| Code | Condition | -| ---- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `0` | success | -| `1` | `--experimental` not passed and `SUPABASE_EXPERIMENTAL` unset (`ExperimentalRequiredError`) — checked before ref resolution/API/telemetry | -| `1` | project ref unresolved (`ProjectRefNotLinkedError`; `InvalidProjectRefError` for a malformed `SUPABASE_PROJECT_ID` or link-file ref) | -| `1` | invalid `--remote` selector — `--remote=<url>` (`TargetNeedsProjectError`), an empty value, `--remote=<value>` combined with `--project-ref`, or a branch the linked project does not have (`LinkBranchNotFoundError`) | -| `1` | API non-2xx (`VanitySubdomainsGetUnexpectedStatusError`) | -| `1` | transport failure (`VanitySubdomainsGetNetworkError`) | +| Code | Condition | +| ---- | ----------------------------------------------------------------------------------------------------------------------------------------- | +| `0` | success | +| `1` | `--experimental` not passed and `SUPABASE_EXPERIMENTAL` unset (`ExperimentalRequiredError`) — checked before ref resolution/API/telemetry | +| `1` | project ref unresolved (`ProjectRefNotLinkedError`; `InvalidProjectRefError` for a malformed `SUPABASE_PROJECT_ID` or link-file ref) | +| `1` | API non-2xx (`VanitySubdomainsGetUnexpectedStatusError`) | +| `1` | transport failure (`VanitySubdomainsGetNetworkError`) | ## Telemetry Events Fired @@ -84,15 +85,3 @@ One `result` event with the full response object. 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. - -- 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. -- Requires `--remote=<ref|branch>`, `SUPABASE_PROJECT_ID`, a linked project (`supabase/.temp/project-ref`) - or, on an interactive terminal, the project picker. A bare `--remote` targets the linked project. -- `--remote=<value>` is read as a project ref (20 lowercase letters), a branch of the linked - project (by name or UUID), or `true|yes|on|1` for the linked project. A database URL is rejected - (`supabase vanity-subdomains get` takes a project, not a database); `false|no|off|0` and `--no-remote` fail with `--remote doesn't take false. Leave it out to use the linked project.`, and an empty value with `--remote= has an empty value. Pass --remote for the linked project or --remote=<ref|branch>; leave it out to use the linked project.` -- A word after a bare `--remote` is read as its value, `--remote=<word>`, because the command takes - no operands. -- The deprecated `--project-ref` is an alias of `--remote=<ref>` (a non-ref value is read as a - branch name) and prints a deprecation warning on stderr that names the replacement; it cannot - be combined with `--remote=<value>`. diff --git a/apps/cli/src/commands/whoami/SIDE_EFFECTS.md b/apps/cli/src/commands/whoami/SIDE_EFFECTS.md index a29203f485..ba623dc334 100644 --- a/apps/cli/src/commands/whoami/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/whoami/SIDE_EFFECTS.md @@ -1,5 +1,7 @@ # `supabase whoami` +Target: account, no consent — see [Command target](../../../docs/command-target.md) + ## Files Read | Path | Format | When | From 1dc6a8a12b90dc644b4626321192120dfdc66607 Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Tue, 20 Oct 2026 12:16:08 +0100 Subject: [PATCH 190/291] refactor(cli): reject a database URL after --remote at parse time without a database The URL-acceptance type brands on the --remote flag are gone. The --remote parser now rejects a database URL itself for a command whose targetFlags declare dbUrl: false and for every hosted command, with "--remote takes a project ref or a branch name." (a project ref on parent-scoped commands). A --project-ref value that is a URL is rejected the same way on a command with no database connection, so it can no longer select a database there. TargetNeedsProjectError stays for a branch name given to a parent-scoped command. --- .../src/tests/target-selection.e2e.test.ts | 2 +- .../target/remote-resolution.ts | 11 +-- ...resolve-project-target.integration.test.ts | 9 +- .../target/resolve-target.integration.test.ts | 8 +- .../command-internal/target/resolve-target.ts | 22 ++--- .../command-internal/target/target-flags.ts | 88 +++++++------------ .../target/target-flags.unit.test.ts | 49 ++++++----- .../target/target-selector.ts | 47 +++++----- .../target/target-selector.unit.test.ts | 34 ++++--- .../command-internal/target/target.errors.ts | 5 +- .../config/diff/diff.integration.test.ts | 25 ------ .../config/pull/pull.integration.test.ts | 26 ------ .../config/push/push.integration.test.ts | 25 ------ .../deploy/deploy.integration.test.ts | 27 ------ .../notebooks/notebooks.integration.test.ts | 18 ---- .../commands/pull/pull.integration.test.ts | 25 ------ .../commands/seed/buckets/buckets.command.ts | 4 +- .../seed/buckets/buckets.integration.test.ts | 20 ----- .../storage/cp/cp.integration.test.ts | 21 ----- .../storage/ls/ls.integration.test.ts | 19 ---- .../storage/mv/mv.integration.test.ts | 20 ----- .../cli/src/commands/storage/rm/rm.handler.ts | 4 +- .../storage/rm/rm.integration.test.ts | 20 ----- .../cli/src/commands/storage/storage.frame.ts | 6 +- .../tests/helpers/hosted-target-selection.ts | 21 +---- 25 files changed, 127 insertions(+), 429 deletions(-) diff --git a/apps/cli-e2e/src/tests/target-selection.e2e.test.ts b/apps/cli-e2e/src/tests/target-selection.e2e.test.ts index d6cdb51719..f5ddffc029 100644 --- a/apps/cli-e2e/src/tests/target-selection.e2e.test.ts +++ b/apps/cli-e2e/src/tests/target-selection.e2e.test.ts @@ -140,7 +140,7 @@ describe("remote target selection", () => { testBehaviour("rejects --remote=<url> on a hosted command", async ({ run, apiUrl }) => { const result = await run(["secrets", "list", `--remote=${DEAD_DB_URL}`]); expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("needs a project, not a database URL"); + expect(result.stderr).toContain("--remote takes a project ref or a branch name."); const requests = await getRequestLog(apiUrl); expect(requests.filter((r) => r.pathname.endsWith("/secrets"))).toEqual([]); }); diff --git a/apps/cli/src/command-internal/target/remote-resolution.ts b/apps/cli/src/command-internal/target/remote-resolution.ts index 6eaf4a0de0..b1e0b206af 100644 --- a/apps/cli/src/command-internal/target/remote-resolution.ts +++ b/apps/cli/src/command-internal/target/remote-resolution.ts @@ -32,12 +32,9 @@ import type { RemoteValue } from "./remote-value.ts"; import { stripUserinfo } from "./remote-vocabulary.ts"; import type { LinkedTarget } from "./resolve-target.ts"; import { deprecationMessages, remoteFlagText, shellQuote } from "./target-deprecation.ts"; +import type { TargetFlagValues } from "./target-flags.ts"; import { describeLinkedTarget, renderBranchTarget } from "./target-identity.ts"; -import { - parseTargetSelector, - type TargetDeprecation, - type TargetSelectorInput, -} from "./target-selector.ts"; +import { parseTargetSelector, type TargetDeprecation } from "./target-selector.ts"; import { TargetRemoteAmbiguousError } from "./target.errors.ts"; /** A `--remote` value that names a hosted project, as opposed to a database URL. */ @@ -65,7 +62,7 @@ export const warnTargetDeprecations = Effect.fnUntraced(function* ( * errors that need it. */ export const selectTarget = Effect.fnUntraced(function* ( - flags: TargetSelectorInput, + flags: TargetFlagValues, options: { readonly valueHint?: string } = {}, ) { const runtime = yield* Effect.serviceOption(CommandRuntime); @@ -75,7 +72,7 @@ export const selectTarget = Effect.fnUntraced(function* ( const cliArgs = yield* Effect.serviceOption(CliArgs); const remoteConflict = Option.getOrUndefined(cliArgs)?.remoteConflict === true; const { selection, deprecations } = yield* Result.match( - parseTargetSelector(flags, { commandName, remoteConflict, ...options }), + parseTargetSelector(flags, { remoteConflict, ...options }), { onFailure: Effect.fail, onSuccess: Effect.succeed }, ); yield* warnTargetDeprecations(deprecations); diff --git a/apps/cli/src/command-internal/target/resolve-project-target.integration.test.ts b/apps/cli/src/command-internal/target/resolve-project-target.integration.test.ts index e0a3e0e938..9d544abad4 100644 --- a/apps/cli/src/command-internal/target/resolve-project-target.integration.test.ts +++ b/apps/cli/src/command-internal/target/resolve-project-target.integration.test.ts @@ -284,14 +284,15 @@ describe("resolveProjectTarget", () => { }), ); - it.effect("rejects a database URL, naming the command", () => + it.effect("rejects a database URL given to --project-ref, since a project has no database", () => Effect.gen(function* () { const result = yield* run({ - remote: { _tag: "DbUrl", url: "postgresql://postgres:pw@db.example.com:5432/postgres" }, + projectRef: "postgresql://postgres:pw@db.example.com:5432/postgres", }); expect(failureOf(result.exit)).toMatchObject({ - _tag: "TargetNeedsProjectError", - message: "`supabase functions deploy` needs a project, not a database URL.", + _tag: "TargetInvalidValueError", + flag: "--project-ref", + message: "--project-ref takes a project ref or a branch name, not a URL.", }); expect(result.refFlags).toEqual([]); }), 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 231abd61a9..46e1fdb024 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 @@ -33,9 +33,9 @@ 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 { TargetFlagValuesWithDbUrl } from "./target-flags.ts"; +import type { TargetFlagValues } from "./target-flags.ts"; -const none: TargetFlagValuesWithDbUrl = { +const none: TargetFlagValues = { remote: Option.none(), local: false, linked: false, @@ -43,7 +43,7 @@ const none: TargetFlagValuesWithDbUrl = { projectRef: Option.none(), }; -const remote = (value: RemoteValue): Partial<TargetFlagValuesWithDbUrl> => ({ +const remote = (value: RemoteValue): Partial<TargetFlagValues> => ({ remote: Option.some(value), }); @@ -100,7 +100,7 @@ const projectRefResolver = (options: { readonly linked: boolean }) => { const run = ( group: TargetGroup, - input: TargetFlagValuesWithDbUrl, + input: TargetFlagValues, options: { readonly linked?: boolean; readonly projectId?: Option.Option<string>; diff --git a/apps/cli/src/command-internal/target/resolve-target.ts b/apps/cli/src/command-internal/target/resolve-target.ts index 49905657cf..fe91e49efc 100644 --- a/apps/cli/src/command-internal/target/resolve-target.ts +++ b/apps/cli/src/command-internal/target/resolve-target.ts @@ -6,10 +6,9 @@ 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 { resolveHostedRemote, selectTarget } from "./remote-resolution.ts"; -import type { TargetFlagValuesWithDbUrl, TargetFlagValuesWithoutDbUrl } from "./target-flags.ts"; +import type { TargetFlagValues } from "./target-flags.ts"; import { linkedAlternativeHint } from "./target-identity.ts"; import { TargetPolicy } from "./target-policy.ts"; -import type { TargetSelectorInput } from "./target-selector.ts"; export type ResolvedTarget = | { readonly kind: "local" } @@ -47,7 +46,11 @@ const hintLinkedAlternative = Effect.fnUntraced(function* () { yield* cell.value.hintLocal(linkedAlternativeHint({ ref: linkedRef.value, cache })); }); -const resolveSelected = Effect.fn("Target.resolve")(function* (flags: TargetSelectorInput) { +/** + * Resolves the target a `selectable` command acts on: `--remote` (or a deprecated alias) picks a + * linked project, a branch or a database URL, and no selector means the local stack. + */ +export const resolveTarget = Effect.fn("Target.resolve")(function* (flags: TargetFlagValues) { const policy = yield* TargetPolicy; if (policy.group !== "selectable") { return yield* Effect.die( @@ -78,16 +81,3 @@ const resolveSelected = Effect.fn("Target.resolve")(function* (flags: TargetSele return yield* finish(yield* resolveHostedRemote(selection), "flag"); } }); - -/** - * Resolves the target a `selectable` command acts on: `--remote` (or a deprecated alias) picks a - * linked project, a branch or a database URL, and no selector means the local stack. A command - * whose flags came from `targetFlags({ dbUrl: false })` cannot pass a database URL here. - */ -export function resolveTarget(flags: TargetFlagValuesWithDbUrl): ReturnType<typeof resolveSelected>; -export function resolveTarget( - flags: TargetFlagValuesWithoutDbUrl, -): ReturnType<typeof resolveSelected>; -export function resolveTarget(flags: TargetSelectorInput) { - return resolveSelected(flags); -} diff --git a/apps/cli/src/command-internal/target/target-flags.ts b/apps/cli/src/command-internal/target/target-flags.ts index f7ebacb386..d3d54adaff 100644 --- a/apps/cli/src/command-internal/target/target-flags.ts +++ b/apps/cli/src/command-internal/target/target-flags.ts @@ -12,10 +12,10 @@ interface TargetFlagOptions { readonly subject: TargetSubject; } -interface SelectableRemoteFlagVariant<DbUrl extends boolean> { +interface SelectableRemoteFlagVariant { readonly kind: "selectable"; readonly subject: TargetSubject; - readonly dbUrl: DbUrl; + readonly dbUrl: boolean; } interface HostedRemoteFlagVariant { @@ -24,15 +24,10 @@ interface HostedRemoteFlagVariant { readonly parent: boolean; } -type RemoteFlagVariant = SelectableRemoteFlagVariant<boolean> | HostedRemoteFlagVariant; +type RemoteFlagVariant = SelectableRemoteFlagVariant | HostedRemoteFlagVariant; -declare const urlAcceptance: unique symbol; - -/** A `--remote` value on a command that takes a database URL; the brand exists only in the type. */ -type RemoteValueWithUrl = RemoteValue & { readonly [urlAcceptance]?: "accepted" }; - -/** A `--remote` value on a command with no database connection; the brand exists only in the type. */ -type RemoteValueWithoutUrl = RemoteValue & { readonly [urlAcceptance]?: "rejected" }; +const takesDbUrl = (variant: RemoteFlagVariant): boolean => + variant.kind === "selectable" && variant.dbUrl; /** What the variant's `--remote` takes as a value; the help rows, the flag description and the errors all read it. */ const valueHintOf = (variant: RemoteFlagVariant): string => { @@ -42,7 +37,7 @@ const valueHintOf = (variant: RemoteFlagVariant): string => { const acceptedValuesOf = (variant: RemoteFlagVariant): string => { if (variant.kind === "hosted" && variant.parent) return "a project ref"; - const url = variant.kind === "selectable" && variant.dbUrl ? "a postgres:// URL, " : ""; + const url = takesDbUrl(variant) ? "a postgres:// URL, " : ""; return `${url}a project ref or a branch name`; }; @@ -72,7 +67,7 @@ const rejectionMessage = (variant: RemoteFlagVariant, rejection: RemoteValueReje }`; case "KeywordDsn": return `--remote takes ${acceptedValuesOf(variant)}${ - variant.kind === "selectable" && variant.dbUrl + takesDbUrl(variant) ? "; convert a connection string to a postgresql:// URL and pass --remote=<url>." : "." }`; @@ -83,7 +78,8 @@ const REMOTE_PRIMITIVE = Symbol.for("supabase/cli/RemotePrimitive"); /** * Keeps the `Boolean` primitive tag so the parser accepts a bare `--remote` and never takes the - * next token as its value; only `--remote=<value>` (or a boolean literal) reaches `parse`. + * next token as its value; only `--remote=<value>` (or a boolean literal) reaches `parse`. A + * database URL is rejected here for a command with no database connection. */ const remotePrimitive = (variant: RemoteFlagVariant): Primitive.Primitive<RemoteValue> => Object.assign(Object.create(Primitive.Boolean), { @@ -91,7 +87,10 @@ const remotePrimitive = (variant: RemoteFlagVariant): Primitive.Primitive<Remote parse: (raw: string) => Result.match(parseRemoteValue(raw), { onFailure: (rejection) => Effect.fail(rejectionMessage(variant, rejection)), - onSuccess: Effect.succeed, + onSuccess: (value) => + value._tag === "DbUrl" && !takesDbUrl(variant) + ? Effect.fail(`--remote takes ${acceptedValuesOf(variant)}.`) + : Effect.succeed(value), }), }); @@ -100,21 +99,13 @@ export const isRemoteFlag = (single: Param.Single<Param.ParamKind, unknown>): bo REMOTE_PRIMITIVE in single.primitiveType; /** `--remote`: the linked project when bare, else the project, branch or database named by its value. */ -function remoteFlag( - variant: SelectableRemoteFlagVariant<true>, -): Flag.Flag<Option.Option<RemoteValueWithUrl>>; -function remoteFlag( - variant: SelectableRemoteFlagVariant<false>, -): Flag.Flag<Option.Option<RemoteValueWithoutUrl>>; -function remoteFlag(variant: HostedRemoteFlagVariant): Flag.Flag<Option.Option<RemoteValue>>; -function remoteFlag(variant: RemoteFlagVariant): Flag.Flag<Option.Option<RemoteValue>> { - return Param.makeSingle({ +const remoteFlag = (variant: RemoteFlagVariant): Flag.Flag<Option.Option<RemoteValue>> => + Param.makeSingle({ kind: Param.flagKind, name: REMOTE_FLAG_NAME, primitiveType: remotePrimitive(variant), typeName: valueHintOf(variant), }).pipe(Flag.withDescription(remoteDescription(variant)), Flag.optional); -} const localFlag = () => Flag.Boolean("local").pipe( @@ -146,20 +137,14 @@ const projectRefFlag = () => Flag.withHidden, ); -export type TargetFlagsWithDbUrl = { - readonly remote: Flag.Flag<Option.Option<RemoteValueWithUrl>>; +type TargetFlagDeclarations = { + readonly remote: Flag.Flag<Option.Option<RemoteValue>>; readonly local: Flag.Flag<boolean>; readonly linked: Flag.Flag<boolean>; - readonly dbUrl: Flag.Flag<Option.Option<string>>; readonly projectRef: Flag.Flag<Option.Option<string>>; }; -export type TargetFlagsWithoutDbUrl = { - readonly remote: Flag.Flag<Option.Option<RemoteValueWithoutUrl>>; - readonly local: Flag.Flag<boolean>; - readonly linked: Flag.Flag<boolean>; - readonly projectRef: Flag.Flag<Option.Option<string>>; -}; +type DbUrlFlagDeclaration = { readonly dbUrl: Flag.Flag<Option.Option<string>> }; /** The decoded flags every hosted command's selector is read from. */ export interface ProjectTargetFlagValues { @@ -173,41 +158,30 @@ export interface TargetFlagValues extends ProjectTargetFlagValues { readonly linked: boolean; /** `gen types` only: the deprecated alias of `--project-ref`. */ readonly projectId?: Option.Option<string>; + /** The deprecated `--db-url`; absent on a command with no database connection. */ + readonly dbUrl?: Option.Option<string>; } /** The decoded flags of a command whose `targetFlags` declared `dbUrl: true`. */ export interface TargetFlagValuesWithDbUrl extends TargetFlagValues { - readonly remote: Option.Option<RemoteValueWithUrl>; readonly dbUrl: Option.Option<string>; } -/** The decoded flags of a command whose `targetFlags` declared `dbUrl: false`. */ -export interface TargetFlagValuesWithoutDbUrl extends TargetFlagValues { - readonly remote: Option.Option<RemoteValueWithoutUrl>; - readonly dbUrl?: undefined; -} - /** The only declaration of `--remote` and its deprecated `--local`, `--linked`, `--db-url` and `--project-ref` aliases for selectable commands. */ export function targetFlags( options: TargetFlagOptions & { readonly dbUrl: true }, -): TargetFlagsWithDbUrl; +): TargetFlagDeclarations & DbUrlFlagDeclaration; export function targetFlags( options: TargetFlagOptions & { readonly dbUrl: false }, -): TargetFlagsWithoutDbUrl; -export function targetFlags( - options: TargetFlagOptions & { readonly dbUrl: boolean }, -): TargetFlagsWithDbUrl | TargetFlagsWithoutDbUrl { - const base = { local: localFlag(), linked: linkedFlag(), projectRef: projectRefFlag() }; - return options.dbUrl - ? { - ...base, - remote: remoteFlag({ kind: "selectable", subject: options.subject, dbUrl: true }), - dbUrl: dbUrlFlag(), - } - : { - ...base, - remote: remoteFlag({ kind: "selectable", subject: options.subject, dbUrl: false }), - }; +): TargetFlagDeclarations; +export function targetFlags(options: TargetFlagOptions & { readonly dbUrl: boolean }) { + const base = { + remote: remoteFlag({ kind: "selectable", subject: options.subject, dbUrl: options.dbUrl }), + local: localFlag(), + linked: linkedFlag(), + projectRef: projectRefFlag(), + }; + return options.dbUrl ? { ...base, dbUrl: dbUrlFlag() } : base; } /** The flags of a hosted-only command: `--remote=<ref|branch>` and the deprecated `--project-ref` alias; `parent` narrows the value to a ref. */ @@ -253,7 +227,7 @@ export const readSeedTargetFlags = Effect.fnUntraced(function* () { linked: yield* seedTargetGlobalFlags.linked, local: yield* seedTargetGlobalFlags.local, projectRef: yield* seedTargetGlobalFlags.projectRef, - } satisfies TargetFlagValuesWithoutDbUrl; + } satisfies TargetFlagValues; }); /** Hidden alias of `--remote=<ref>`, accepted only by `gen types`. */ 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 573769064d..d2c73c6eb7 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 @@ -14,7 +14,6 @@ import { seedTargetGlobalFlags, targetFlags, } from "./target-flags.ts"; -import { resolveTarget } from "./resolve-target.ts"; const silentFormatter: CliOutput.Formatter = { formatCliError: () => "", @@ -218,28 +217,32 @@ describe("targetFlags", () => { ]); }); - it("keeps a database-URL leaf from dropping --db-url and a no-URL leaf from passing one", () => { - const storage = targetFlags({ subject: "Storage API", dbUrl: false }); - const unselected = { - remote: Option.none(), - local: false, - linked: false, - projectRef: Option.none(), - }; - const withUrl: CliCommand.Command.Config.Infer<typeof withDbUrl> = { - ...unselected, - dbUrl: Option.none(), - }; - const withoutUrl: CliCommand.Command.Config.Infer<typeof storage> = unselected; - const { dbUrl: _dropped, ...droppedDbUrl } = withUrl; - - expect(Effect.isEffect(resolveTarget(withUrl))).toBe(true); - expect(Effect.isEffect(resolveTarget(withoutUrl))).toBe(true); - // @ts-expect-error a leaf that declared dbUrl: true cannot hand the resolver flags without it - resolveTarget(droppedDbUrl); - // @ts-expect-error a leaf that declared dbUrl: false cannot pass a database URL - resolveTarget({ ...withoutUrl, dbUrl: Option.some(URL) }); - }); + it.effect.each([ + { + name: "a command with no database connection", + config: targetFlags({ subject: "Storage API", dbUrl: false }), + message: "--remote takes a project ref or a branch name.", + }, + { + name: "a hosted command", + config: projectTargetFlags(), + message: "--remote takes a project ref or a branch name.", + }, + { + name: "a parent-scoped hosted command", + config: projectTargetFlags({ parent: true }), + message: "--remote takes a project ref.", + }, + { + name: "the seed group", + config: { remote: seedTargetGlobalFlags.remote.flag }, + message: "--remote takes a project ref or a branch name.", + }, + ])("rejects a database URL on $name without echoing it", ({ config, message }) => + Effect.gen(function* () { + expect(yield* rejectedMessage(config, [`--remote=${URL}`])).toBe(message); + }), + ); it("shows --remote and hides every deprecated alias", () => { expect(describeFlag(withDbUrl.remote).hidden).toBeFalsy(); diff --git a/apps/cli/src/command-internal/target/target-selector.ts b/apps/cli/src/command-internal/target/target-selector.ts index 112995b14e..3fa6189bfd 100644 --- a/apps/cli/src/command-internal/target/target-selector.ts +++ b/apps/cli/src/command-internal/target/target-selector.ts @@ -1,26 +1,25 @@ import { Option, Result } from "effect"; -import { parseNamedRemote, type RemoteValue } from "./remote-value.ts"; +import { + parseNamedRemote, + type NamedRemoteRejection, + type NamedRemoteValue, + type RemoteValue, +} from "./remote-value.ts"; import type { TargetFlagValues } from "./target-flags.ts"; import { TargetEmptyValueError, TargetInvalidValueError, - TargetNeedsProjectError, TargetProjectRefConflictError, TargetSelectorConflictError, type TargetSelectorError, } from "./target.errors.ts"; -/** The decoded selector flags; `dbUrl` is absent for a command with no database connection, which rejects a database URL. */ -export type TargetSelectorInput = TargetFlagValues & { - readonly dbUrl?: Option.Option<string> | undefined; -}; - /** What the flags select: the default, the local stack, or a remote project, branch or database. */ type TargetSelection = { readonly _tag: "Default" } | { readonly _tag: "Local" } | RemoteValue; /** Whether any selector flag, `--local` and the deprecated aliases included, was given. */ -export const hasExplicitSelector = (flags: TargetSelectorInput): boolean => +export const hasExplicitSelector = (flags: TargetFlagValues): boolean => flags.local || flags.linked || Option.isSome(flags.remote) || @@ -45,8 +44,6 @@ export interface ParsedTargetSelector { } export interface TargetSelectorOptions { - /** Names the command in the error for a database URL it cannot use. */ - readonly commandName?: string; /** What `--remote` takes on this command, for the suggestions; a command with no `dbUrl` input defaults to `<ref|branch>`. */ readonly valueHint?: string; /** `--remote` is given more than once with values that differ. */ @@ -79,13 +76,25 @@ const PROJECT_REF_CONFLICT_SUGGESTION: Readonly< const trimmed = (value: string): string => value.trim(); +/** Why an alias value cannot be used: a foreign URL or keyword connection string, or a database URL on a command with no database. */ +const aliasRejection = ( + parsed: Result.Result<NamedRemoteValue, NamedRemoteRejection> | undefined, + hasDatabase: boolean, +): NamedRemoteRejection | undefined => { + if (parsed === undefined) return undefined; + if (Result.isFailure(parsed)) return parsed.failure; + return parsed.success._tag === "DbUrl" && !hasDatabase + ? { _tag: "ForeignUrl", ref: undefined } + : undefined; +}; + /** * A `false` switch counts as absent, so `--linked=false` and `--no-linked` select nothing. * `--linked`, `--project-ref`, `--project-id` and `--db-url` resolve exactly like the `--remote` * form they map to and are reported as deprecations; `--local` selects the default. */ export const parseTargetSelector = ( - input: TargetSelectorInput, + input: TargetFlagValues, options: TargetSelectorOptions = {}, ): Result.Result<ParsedTargetSelector, TargetSelectorError> => { const remote = Option.getOrUndefined(input.remote); @@ -163,14 +172,15 @@ export const parseTargetSelector = ( } const aliasRemote = aliasValue === undefined ? undefined : parseNamedRemote(aliasValue); - if (aliasRemote !== undefined && Result.isFailure(aliasRemote)) { + const rejectedAlias = aliasRejection(aliasRemote, input.dbUrl !== undefined); + if (rejectedAlias !== undefined) { return Result.fail( new TargetInvalidValueError({ flag: aliasFlag, - message: `${aliasFlag} takes a project ref or a branch name, not a ${aliasRemote.failure._tag === "ForeignUrl" ? "URL" : "connection string"}.`, + message: `${aliasFlag} takes a project ref or a branch name, not a ${rejectedAlias._tag === "ForeignUrl" ? "URL" : "connection string"}.`, suggestion: - aliasRemote.failure._tag === "ForeignUrl" && aliasRemote.failure.ref !== undefined - ? `Use --remote=${aliasRemote.failure.ref}.` + rejectedAlias._tag === "ForeignUrl" && rejectedAlias.ref !== undefined + ? `Use --remote=${rejectedAlias.ref}.` : "Pass a project ref or a branch name, for example --remote=abcdefghijklmnopqrst", }), ); @@ -211,12 +221,5 @@ export const parseTargetSelector = ( return { selection: { _tag: "Default" }, deprecations: [] }; })(); - if (input.dbUrl === undefined && outcome.selection._tag === "DbUrl") { - return Result.fail( - new TargetNeedsProjectError({ - message: `${options.commandName ?? "This command"} needs a project, not a database URL.`, - }), - ); - } return Result.succeed(outcome); }; 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 846783b723..694ab75aea 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 @@ -2,17 +2,17 @@ import { Option, Result } from "effect"; import { describe, expect, it } from "vitest"; import type { RemoteValue } from "./remote-value.ts"; +import type { TargetFlagValues } from "./target-flags.ts"; import { hasExplicitSelector, parseTargetSelector, - type TargetSelectorInput, type TargetSelectorOptions, } from "./target-selector.ts"; const REF = "abcdefghijklmnopqrst"; const URL = "postgresql://u:p@db.example.com:5432/postgres"; -const none: TargetSelectorInput = { +const none: TargetFlagValues = { remote: Option.none(), local: false, linked: false, @@ -22,17 +22,17 @@ const none: TargetSelectorInput = { const remote = (value: RemoteValue) => ({ remote: Option.some(value) }); -const parse = (overrides: Partial<TargetSelectorInput>, options?: TargetSelectorOptions) => +const parse = (overrides: Partial<TargetFlagValues>, options?: TargetSelectorOptions) => parseTargetSelector({ ...none, ...overrides }, options); -const success = (overrides: Partial<TargetSelectorInput>) => { +const success = (overrides: Partial<TargetFlagValues>) => { const result = parse(overrides); if (Result.isFailure(result)) throw new Error(`expected the selector to be accepted: ${result.failure.message}`); return result.success; }; -const failure = (overrides: Partial<TargetSelectorInput>, options?: TargetSelectorOptions) => { +const failure = (overrides: Partial<TargetFlagValues>, options?: TargetSelectorOptions) => { const result = parse(overrides, options); if (Result.isSuccess(result)) throw new Error("expected the selector to be rejected"); return result.failure; @@ -378,7 +378,7 @@ describe("parseTargetSelector", () => { }); describe("a command with no database connection", () => { - const hosted = (overrides: Partial<TargetSelectorInput>, options?: TargetSelectorOptions) => + const hosted = (overrides: Partial<TargetFlagValues>, options?: TargetSelectorOptions) => parseTargetSelector( { remote: Option.none(), @@ -402,22 +402,18 @@ describe("parseTargetSelector", () => { }); }); - it("rejects a database URL and names the command", () => { - const result = hosted(remote({ _tag: "DbUrl", url: URL }), { - commandName: "`supabase functions deploy`", - }); + it("rejects a database URL given to --project-ref without echoing it", () => { + const result = hosted({ projectRef: Option.some(URL) }); expect(result).toMatchObject({ failure: { - _tag: "TargetNeedsProjectError", - message: "`supabase functions deploy` needs a project, not a database URL.", + _tag: "TargetInvalidValueError", + flag: "--project-ref", + message: "--project-ref takes a project ref or a branch name, not a URL.", + suggestion: + "Pass a project ref or a branch name, for example --remote=abcdefghijklmnopqrst", }, }); - }); - - it("falls back to a generic name for the command", () => { - expect(hosted(remote({ _tag: "DbUrl", url: URL }))).toMatchObject({ - failure: { message: "This command needs a project, not a database URL." }, - }); + expect(JSON.stringify(result)).not.toContain("db.example.com"); }); }); }); @@ -427,7 +423,7 @@ describe("hasExplicitSelector", () => { expect(hasExplicitSelector(none)).toBe(false); }); - it.each<readonly [string, Partial<TargetSelectorInput>]>([ + it.each<readonly [string, Partial<TargetFlagValues>]>([ ["--remote", { remote: Option.some({ _tag: "Linked" }) }], ["--linked", { linked: true }], ["--local", { local: true }], diff --git a/apps/cli/src/command-internal/target/target.errors.ts b/apps/cli/src/command-internal/target/target.errors.ts index 12a5823402..a5c3577f4f 100644 --- a/apps/cli/src/command-internal/target/target.errors.ts +++ b/apps/cli/src/command-internal/target/target.errors.ts @@ -53,7 +53,7 @@ export class TargetInvalidValueError extends Data.TaggedError("TargetInvalidValu } } -/** A command that acts on a project was given a database URL. */ +/** A command that acts on a parent project was given a branch name. */ export class TargetNeedsProjectError extends Data.TaggedError("TargetNeedsProjectError")<{ readonly message: string; }> { @@ -97,5 +97,4 @@ export type TargetSelectorError = | TargetSelectorConflictError | TargetProjectRefConflictError | TargetEmptyValueError - | TargetInvalidValueError - | TargetNeedsProjectError; + | TargetInvalidValueError; 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 e978641a92..3f8dcadfd5 100644 --- a/apps/cli/src/commands/config/diff/diff.integration.test.ts +++ b/apps/cli/src/commands/config/diff/diff.integration.test.ts @@ -709,31 +709,6 @@ describe("config diff integration", () => { }).pipe(Effect.provide(layer)); }); - it.effect("--remote=<url> is rejected before any request", () => { - const { layer, api } = setup({ toml: 'project_id = "test"\n' }); - return Effect.gen(function* () { - const error = yield* configDiff({ - ...noFlags, - remote: Option.some<RemoteValue>({ - _tag: "DbUrl", - url: "postgresql://postgres:pw@db.example.com:5432/postgres", - }), - }).pipe(Effect.flip); - expect(error).toMatchObject({ - _tag: "TargetNeedsProjectError", - message: "`supabase config diff` needs a project, not a database URL.", - }); - expect(api.requests).toEqual([]); - }).pipe( - Effect.provide( - Layer.mergeAll( - layer, - commandRuntimeLayer(["config", "diff"]).pipe(Layer.provide(BunServices.layer)), - ), - ), - ); - }); - it.effect("an unknown branch fails listing the available branches", () => { const { layer, telemetry, linkedProjectCache } = setup({ toml: 'project_id = "test"\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 e163d1d35a..288a109a9f 100644 --- a/apps/cli/src/commands/config/pull/pull.integration.test.ts +++ b/apps/cli/src/commands/config/pull/pull.integration.test.ts @@ -47,7 +47,6 @@ import { realLinkedProjectCache } from "../../../../tests/helpers/permission-rea import { configPullRun } from "../../../command-internal/config-pull-run.ts"; import { GLOBAL_OUTPUT_FORMATS, YesFlag } from "../../../command-internal/global-flags.ts"; import type { RemoteValue } from "../../../command-internal/target/remote-value.ts"; -import { commandRuntimeLayer } from "../../../shared/runtime/command-runtime.layer.ts"; import { Output } from "../../../shared/output/output.service.ts"; import { configPull, @@ -1787,31 +1786,6 @@ describe("config pull integration", () => { }).pipe(Effect.provide(layer)); }); - it.effect("--remote=<url> is rejected before any request", () => { - const { layer, api } = setup({ toml: 'project_id = "test"\n', yes: true }); - return Effect.gen(function* () { - const error = yield* configPull({ - ...noFlags, - remote: Option.some<RemoteValue>({ - _tag: "DbUrl", - url: "postgresql://postgres:pw@db.example.com:5432/postgres", - }), - }).pipe(Effect.flip); - expect(error).toMatchObject({ - _tag: "TargetNeedsProjectError", - message: "`supabase config pull` needs a project, not a database URL.", - }); - expect(api.requests).toEqual([]); - }).pipe( - Effect.provide( - Layer.mergeAll( - layer, - commandRuntimeLayer(["config", "pull"]).pipe(Layer.provide(BunServices.layer)), - ), - ), - ); - }); - it.effect("a branch-named target resolves via the linked parent project", () => { const { layer, api, out } = setup({ toml: 'project_id = "test"\n', 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 0768810b56..bcf487fd94 100644 --- a/apps/cli/src/commands/config/push/push.integration.test.ts +++ b/apps/cli/src/commands/config/push/push.integration.test.ts @@ -3570,31 +3570,6 @@ describe("config push --remote branch name/UUID resolution (CLI-2289)", () => { }).pipe(Effect.provide(layer)); }); - it.effect("--remote=<url> is rejected before any request", () => { - const { layer, api } = setup({ toml: BRANCH_PUSH_TOML, yes: true }); - return Effect.gen(function* () { - const error = yield* configPush({ - remote: Option.some<RemoteValue>({ - _tag: "DbUrl", - url: "postgresql://postgres:pw@db.example.com:5432/postgres", - }), - projectRef: Option.none(), - }).pipe(Effect.flip); - expect(error).toMatchObject({ - _tag: "TargetNeedsProjectError", - message: "`supabase config push` needs a project, not a database URL.", - }); - expect(api.requests).toEqual([]); - }).pipe( - Effect.provide( - Layer.mergeAll( - layer, - commandRuntimeLayer(["config", "push"]).pipe(Layer.provide(BunServices.layer)), - ), - ), - ); - }); - it.effect("--remote=<unknown-branch-name> fails listing the available branches", () => { const { layer, api, telemetry, linkedProjectCache } = setup({ toml: BRANCH_PUSH_TOML, 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 655f3daa63..01b205f5a9 100644 --- a/apps/cli/src/commands/functions/deploy/deploy.integration.test.ts +++ b/apps/cli/src/commands/functions/deploy/deploy.integration.test.ts @@ -545,33 +545,6 @@ describe("functions deploy", () => { }).pipe(Effect.provide(layer), Effect.ensuring(removeTempRoot)); }); - it.effect("rejects a database URL before any request", () => { - const { api, layer } = selectionHarness([ - "functions", - "deploy", - "hello-world", - "--remote=postgresql://postgres:pw@db.example.com:5432/postgres", - ]); - return Effect.gen(function* () { - yield* writeCliConfig(tempRoot.current); - yield* writeLocalFunction(tempRoot.current, "hello-world"); - - const error = yield* functionsDeploy({ - ...baseFlags, - remote: Option.some({ - _tag: "DbUrl", - url: "postgresql://postgres:pw@db.example.com:5432/postgres", - }), - }).pipe(Effect.flip); - - expect(error).toMatchObject({ - _tag: "TargetNeedsProjectError", - message: "`supabase functions deploy` needs a project, not a database URL.", - }); - expect(api.requests).toEqual([]); - }).pipe(Effect.provide(layer), Effect.ensuring(removeTempRoot)); - }); - it.effect("deploys to the project of the branch --remote=<branch> names", () => { const { api, layer, deployedTo } = selectionHarness([ "functions", diff --git a/apps/cli/src/commands/notebooks/notebooks.integration.test.ts b/apps/cli/src/commands/notebooks/notebooks.integration.test.ts index 3d7282dad8..ebb756a5a4 100644 --- a/apps/cli/src/commands/notebooks/notebooks.integration.test.ts +++ b/apps/cli/src/commands/notebooks/notebooks.integration.test.ts @@ -636,7 +636,6 @@ describe.each(commands)("notebooks %s command wiring", (command) => { describe.each(commands)("notebooks %s project selection", (command) => { const BRANCH_REF = "branchprojectrefabcd"; - const DB_URL = "postgresql://postgres:pw@db.example.com:5432/postgres"; const none = { remote: Option.none(), projectRef: Option.none() } satisfies Selector; const branchesRoute = `GET /v1/projects/${NOTEBOOKS_PROJECT_REF}/branches`; const branchRow = { @@ -677,23 +676,6 @@ describe.each(commands)("notebooks %s project selection", (command) => { }).pipe(Effect.provide(layer)); }); - it.effect("rejects --remote=<url> before any request", () => { - const { layer, http } = setup(command, { - args: ["notebooks", command, `--remote=${DB_URL}`], - }); - return Effect.gen(function* () { - const error = yield* runWith(command, { - ...none, - remote: Option.some({ _tag: "DbUrl", url: DB_URL }), - }).pipe(Effect.flip); - expect(error).toMatchObject({ - _tag: "TargetNeedsProjectError", - message: `\`supabase notebooks ${command}\` needs a project, not a database URL.`, - }); - expect(http.requests).toEqual([]); - }).pipe(Effect.provide(layer)); - }); - it.effect("keeps --project-ref working and points at --remote=<ref>", () => { const argv = ["notebooks", command, "--project-ref", NOTEBOOKS_PROJECT_REF]; const { layer, out, http } = setup(command, { args: argv }); diff --git a/apps/cli/src/commands/pull/pull.integration.test.ts b/apps/cli/src/commands/pull/pull.integration.test.ts index d4d690e748..546e2ebaf3 100644 --- a/apps/cli/src/commands/pull/pull.integration.test.ts +++ b/apps/cli/src/commands/pull/pull.integration.test.ts @@ -997,31 +997,6 @@ describe("pull integration", () => { }).pipe(Effect.provide(layer)); }); - it.effect("--remote=<url> is rejected before any request", () => { - const { layer, api } = setup({ yes: true }); - return Effect.gen(function* () { - yield* writeConfig(); - const error = yield* runPull( - pullFlags({ - remote: Option.some<RemoteValue>({ - _tag: "DbUrl", - url: "postgresql://postgres:pw@db.example.com:5432/postgres", - }), - dryRun: true, - }), - ).pipe(Effect.flip); - expect(error).toMatchObject({ - _tag: "TargetNeedsProjectError", - message: "`supabase pull` needs a project, not a database URL.", - }); - expect(api.requests).toEqual([]); - }).pipe( - Effect.provide( - Layer.mergeAll(layer, commandRuntimeLayer(["pull"]).pipe(Layer.provide(BunServices.layer))), - ), - ); - }); - it.effect( "a branch-name --remote resolves exactly once: one branch lookup, one config GET, no picker prompt", () => { diff --git a/apps/cli/src/commands/seed/buckets/buckets.command.ts b/apps/cli/src/commands/seed/buckets/buckets.command.ts index 6b12a693c4..9e8303faf6 100644 --- a/apps/cli/src/commands/seed/buckets/buckets.command.ts +++ b/apps/cli/src/commands/seed/buckets/buckets.command.ts @@ -6,7 +6,7 @@ import { cliConfigValuesLayer } from "../../../config/cli-config-values.layer.ts import { withPermissions } from "../../../command-internal/command-permissions/command-permissions.annotation.ts"; import { readSeedTargetFlags, - type TargetFlagValuesWithoutDbUrl, + type TargetFlagValues, } from "../../../command-internal/target/target-flags.ts"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; import { withCommandTelemetry } from "../../../telemetry/command-telemetry.ts"; @@ -16,7 +16,7 @@ import { storageGatewayRuntimeLayer } from "../../../command-internal/storage-ru import { seedBuckets } from "./buckets.handler.ts"; import { withTargetPolicy } from "../../../command-internal/target/target-policy.ts"; -export type BucketsFlags = TargetFlagValuesWithoutDbUrl; +export type BucketsFlags = TargetFlagValues; export const bucketsCommand = Command.make("buckets").pipe( Command.withDescription("Seed buckets declared in [storage.buckets]."), 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 f76196f270..76c4717fe8 100644 --- a/apps/cli/src/commands/seed/buckets/buckets.integration.test.ts +++ b/apps/cli/src/commands/seed/buckets/buckets.integration.test.ts @@ -310,7 +310,6 @@ const setupSeedBuckets = Effect.fnUntraced(function* ( }); const FLAG_REF = "flagflagflagflagflag"; -const DB_URL = "postgresql://postgres:postgres@127.0.0.1:54322/postgres"; const VECTOR_LIST = "/storage/v1/vector/ListVectorBuckets"; const VECTOR_CREATE = "/storage/v1/vector/CreateVectorBucket"; @@ -2343,25 +2342,6 @@ describe("seed buckets", () => { }).pipe(seedScenario), ); - it.effect("rejects --remote=<url> because seeding buckets needs a project, not a database", () => - Effect.gen(function* () { - const { layer, requests, linkedCache } = yield* setupSeedBuckets(tmp.current, { - toml: "[storage.buckets.test]\npublic = true\n", - }); - const error = yield* seedBuckets({ - ...DEFAULT_FLAGS, - remote: Option.some({ _tag: "DbUrl", url: DB_URL } as const), - local: false, - }).pipe(Effect.provide(layer), Effect.flip); - expect(error).toMatchObject({ - _tag: "TargetNeedsProjectError", - message: expect.stringContaining("needs a project, not a database URL"), - }); - expect(requests).toEqual([]); - expect(linkedCache.cached).toBe(false); - }).pipe(seedScenario), - ); - describe("deprecated selectors", () => { const cases = [ { 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 c89da113d2..4ba0efa115 100644 --- a/apps/cli/src/commands/storage/cp/cp.integration.test.ts +++ b/apps/cli/src/commands/storage/cp/cp.integration.test.ts @@ -20,7 +20,6 @@ import type { StorageCpFlags } from "./cp.command.ts"; import { warningsOf } from "../../../../tests/helpers/remote-selection.ts"; const FLAG_REF = "flagflagflagflagflag"; -const DB_URL = "postgresql://postgres:postgres@127.0.0.1:54322/postgres"; const BUCKET = "/storage/v1/bucket"; const OBJECT = (p: string) => `/storage/v1/object/${p}`; const LIST = (bucket: string) => `/storage/v1/object/list/${bucket}`; @@ -634,26 +633,6 @@ describe("storage cp", () => { }).pipe(Effect.provide(layer)); }); - it.effect("rejects --remote=<url> because storage needs a project, not a database", () => { - const { layer, requests, linkedCache } = setupStorage(tmp.current, { - toml: 'project_id = "test"\n', - yes: true, - }); - return Effect.gen(function* () { - const path = yield* Path.Path; - const error = yield* storageCp({ - ...cpFlags({ src: "ss:///private/readme.md", dst: path.join(tmp.current, "out.md") }), - remote: Option.some({ _tag: "DbUrl", url: DB_URL } as const), - }).pipe(Effect.flip); - expect(error).toMatchObject({ - _tag: "TargetNeedsProjectError", - message: expect.stringContaining("needs a project, not a database URL"), - }); - expect(requests).toHaveLength(0); - expect(linkedCache.cached).toBe(false); - }).pipe(Effect.provide(layer)); - }); - describe("deprecated selectors", () => { const cases = [ { 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 183099bf63..169435f998 100644 --- a/apps/cli/src/commands/storage/ls/ls.integration.test.ts +++ b/apps/cli/src/commands/storage/ls/ls.integration.test.ts @@ -21,7 +21,6 @@ import { generateLocalJwt } from "../../../command-internal/local-jwt.ts"; import { warningsOf } from "../../../../tests/helpers/remote-selection.ts"; const FLAG_REF = "flagflagflagflagflag"; -const DB_URL = "postgresql://postgres:postgres@127.0.0.1:54322/postgres"; const BUCKET = "/storage/v1/bucket"; const LIST = (bucket: string) => `/storage/v1/object/list/${bucket}`; const REVEALED_API_KEYS = `GET https://api.supabase.com/v1/projects/${VALID_REF}/api-keys?reveal=true`; @@ -336,24 +335,6 @@ project_id = "env(STAGING_REF)" }); }); - it.effect("rejects --remote=<url> because storage needs a project, not a database", () => { - const { layer, requests, linkedCache } = setupStorage(tmp.current, { - toml: 'project_id = "test"\n', - }); - return Effect.gen(function* () { - const error = yield* storageLs({ - ...lsFlags(), - remote: Option.some({ _tag: "DbUrl", url: DB_URL } as const), - }).pipe(Effect.provide(layer), Effect.flip); - expect(error).toMatchObject({ - _tag: "TargetNeedsProjectError", - message: expect.stringContaining("needs a project, not a database URL"), - }); - expect(requests).toHaveLength(0); - expect(linkedCache.cached).toBe(false); - }); - }); - describe("deprecated selectors", () => { const cases = [ { 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 864225675c..543ec4583b 100644 --- a/apps/cli/src/commands/storage/mv/mv.integration.test.ts +++ b/apps/cli/src/commands/storage/mv/mv.integration.test.ts @@ -19,7 +19,6 @@ import type { StorageMvFlags } from "./mv.command.ts"; import { warningsOf } from "../../../../tests/helpers/remote-selection.ts"; const FLAG_REF = "flagflagflagflagflag"; -const DB_URL = "postgresql://postgres:postgres@127.0.0.1:54322/postgres"; const MOVE = "/storage/v1/object/move"; const LIST = (bucket: string) => `/storage/v1/object/list/${bucket}`; const REVEALED_API_KEYS = `GET https://api.supabase.com/v1/projects/${VALID_REF}/api-keys?reveal=true`; @@ -357,25 +356,6 @@ describe("storage mv", () => { }).pipe(Effect.provide(layer)); }); - it.effect("rejects --remote=<url> because storage needs a project, not a database", () => { - const { layer, requests, linkedCache } = setupStorage(tmp.current, { - toml: 'project_id = "test"\n', - yes: true, - }); - return Effect.gen(function* () { - const error = yield* storageMv({ - ...mvFlags({ src: "ss:///private/a", dst: "ss:///private/b" }), - remote: Option.some({ _tag: "DbUrl", url: DB_URL } as const), - }).pipe(Effect.flip); - expect(error).toMatchObject({ - _tag: "TargetNeedsProjectError", - message: expect.stringContaining("needs a project, not a database URL"), - }); - expect(requests).toHaveLength(0); - expect(linkedCache.cached).toBe(false); - }).pipe(Effect.provide(layer)); - }); - describe("deprecated selectors", () => { const cases = [ { diff --git a/apps/cli/src/commands/storage/rm/rm.handler.ts b/apps/cli/src/commands/storage/rm/rm.handler.ts index c88866d7a6..08723634da 100644 --- a/apps/cli/src/commands/storage/rm/rm.handler.ts +++ b/apps/cli/src/commands/storage/rm/rm.handler.ts @@ -15,7 +15,7 @@ import { type StorageGateway, } from "../../../command-internal/storage-gateway.ts"; import { splitBucketPrefix, storageIsDir } from "../../../command-internal/storage-url.ts"; -import type { TargetFlagValuesWithoutDbUrl } from "../../../command-internal/target/target-flags.ts"; +import type { TargetFlagValues } from "../../../command-internal/target/target-flags.ts"; import { assertStorageWorkdir, connectStorageGateway, @@ -31,7 +31,7 @@ import { } from "../storage.errors.ts"; import { listStoragePaths } from "../storage.iterate.ts"; -export interface StorageRmFlags extends TargetFlagValuesWithoutDbUrl { +export interface StorageRmFlags extends TargetFlagValues { readonly files: ReadonlyArray<string>; readonly recursive: boolean; } 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 1232ca8a38..83cab3c6df 100644 --- a/apps/cli/src/commands/storage/rm/rm.integration.test.ts +++ b/apps/cli/src/commands/storage/rm/rm.integration.test.ts @@ -43,7 +43,6 @@ const RM_ARGS = { projectRef: Option.none(), }; const FLAG_REF = "flagflagflagflagflag"; -const DB_URL = "postgresql://postgres:postgres@127.0.0.1:54322/postgres"; const BUCKET = "/storage/v1/bucket"; const DELETE_OBJECT = (bucket: string) => `/storage/v1/object/${bucket}`; const DELETE_BUCKET = (bucket: string) => `/storage/v1/bucket/${bucket}`; @@ -826,25 +825,6 @@ describe("storage rm", () => { }).pipe(Effect.provide(layer)); }); - it.effect("rejects --remote=<url> because storage needs a project, not a database", () => { - const { layer, requests, linkedCache } = setupStorage(tmp.current, { - toml: 'project_id = "test"\n', - yes: true, - }); - return Effect.gen(function* () { - const error = yield* storageRm({ - ...RM_ARGS, - remote: Option.some({ _tag: "DbUrl", url: DB_URL } as const), - }).pipe(Effect.flip); - expect(error).toMatchObject({ - _tag: "TargetNeedsProjectError", - message: expect.stringContaining("needs a project, not a database URL"), - }); - expect(requests).toHaveLength(0); - expect(linkedCache.cached).toBe(false); - }).pipe(Effect.provide(layer)); - }); - describe("deprecated selectors", () => { const cases = [ { diff --git a/apps/cli/src/commands/storage/storage.frame.ts b/apps/cli/src/commands/storage/storage.frame.ts index 7e7c767405..1ecff4c97c 100644 --- a/apps/cli/src/commands/storage/storage.frame.ts +++ b/apps/cli/src/commands/storage/storage.frame.ts @@ -15,7 +15,7 @@ import { makeStorageGateway, type StorageGateway, } from "../../command-internal/storage-gateway.ts"; -import type { TargetFlagValuesWithoutDbUrl } from "../../command-internal/target/target-flags.ts"; +import type { TargetFlagValues } from "../../command-internal/target/target-flags.ts"; import { resolveTarget } from "../../command-internal/target/resolve-target.ts"; import { UrlParseError, @@ -36,9 +36,7 @@ import { } from "./storage.errors.ts"; /** The resolved target and its project ref, empty for the local stack. */ -export const resolveStorageTarget = Effect.fnUntraced(function* ( - flags: TargetFlagValuesWithoutDbUrl, -) { +export const resolveStorageTarget = Effect.fnUntraced(function* (flags: TargetFlagValues) { const target = yield* resolveTarget(flags); return { target, projectRef: target.kind === "linked" ? target.ref : "" }; }); diff --git a/apps/cli/tests/helpers/hosted-target-selection.ts b/apps/cli/tests/helpers/hosted-target-selection.ts index 07298ef89e..57e855b74c 100644 --- a/apps/cli/tests/helpers/hosted-target-selection.ts +++ b/apps/cli/tests/helpers/hosted-target-selection.ts @@ -22,7 +22,6 @@ import type { ActiveFlags, PermissionVariant } from "./permission-registry.ts"; import { BRANCH_PROJECT_REF, branchRow, warningsOf } from "./remote-selection.ts"; const OTHER_REF = "qrstuvwxyzabcdefghij"; -const DB_URL = "postgresql://postgres:pw@db.example.com:5432/postgres"; const API_URL = "https://api.supabase.com"; const PROJECT_REF_IN_URL = /\/v[12]\/projects\/([a-z]{20})(?:[/?]|$)/; @@ -82,8 +81,8 @@ const projectRefsOf = (requests: ReadonlyArray<RecordedCall>): ReadonlyArray<str /** * Registers the `--remote` selection matrix for the leaf `path`: `--remote=<ref>` with no * warning, a bare `--remote` on the linked project, the `--project-ref` alias with its - * deprecation warning, a database URL rejected before any request, and a branch name looked up - * under the linked project (or rejected, for a parent-scoped command). + * deprecation warning, and a branch name looked up under the linked project (or rejected, for a + * parent-scoped command). */ export function describeTargetSelectionMatrix<R>( path: string, @@ -159,22 +158,6 @@ export function describeTargetSelectionMatrix<R>( }).pipe(Effect.provide(run.layer)); }); - it.effect("rejects a database URL before any request", () => { - const run = harness([`--remote=${DB_URL}`], VALID_REF); - return Effect.gen(function* () { - yield* prepare; - const error = yield* matrix - .run({ remote: Option.some({ _tag: "DbUrl", url: DB_URL }), projectRef: Option.none() }) - .pipe(Effect.flip); - - expect(error).toMatchObject({ - _tag: "TargetNeedsProjectError", - message: `${commandName} needs a project, not a database URL.`, - }); - expect(run.requests()).toEqual([]); - }).pipe(Effect.provide(run.layer)); - }); - if (matrix.mode === "parent") { it.effect("rejects a branch name and points at --remote=<ref>", () => { const run = harness(["--remote=feature-x"], VALID_REF); From 158b391c2fe17a5cd8c381ac3113cedf505988bf Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Tue, 20 Oct 2026 12:16:25 +0100 Subject: [PATCH 191/291] fix(cli): suggest --remote when gen types finds no local stack The JSON error envelope for a missing database container or a failed container inspect now carries "To use the linked project instead, pass --remote." when the checkout is linked, decided with isCheckoutLinked like db push and migration list. Unlinked checkouts keep their current error. --- .../src/commands/gen/types/types.errors.ts | 2 + .../src/commands/gen/types/types.handler.ts | 17 ++++++- .../gen/types/types.integration.test.ts | 47 +++++++++++++++++++ 3 files changed, 64 insertions(+), 2 deletions(-) diff --git a/apps/cli/src/commands/gen/types/types.errors.ts b/apps/cli/src/commands/gen/types/types.errors.ts index a9d1d45fd1..b3f0adc771 100644 --- a/apps/cli/src/commands/gen/types/types.errors.ts +++ b/apps/cli/src/commands/gen/types/types.errors.ts @@ -114,6 +114,7 @@ export class GenTypesLocalDbNotRunningError extends Data.TaggedError( "GenTypesLocalDbNotRunningError", )<{ readonly message: string; + readonly suggestion?: string; }> { get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { return actionability.startStack; @@ -123,6 +124,7 @@ export class GenTypesLocalDbNotRunningError extends Data.TaggedError( export class GenTypesLocalDbInspectError extends Data.TaggedError("GenTypesLocalDbInspectError")<{ readonly message: string; readonly daemonDown: boolean; + readonly suggestion?: string; }> { get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { if (this.daemonDown) { diff --git a/apps/cli/src/commands/gen/types/types.handler.ts b/apps/cli/src/commands/gen/types/types.handler.ts index fa8bad0e4c..7ba3987d58 100644 --- a/apps/cli/src/commands/gen/types/types.handler.ts +++ b/apps/cli/src/commands/gen/types/types.handler.ts @@ -14,7 +14,10 @@ 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 { spawnContainerCli } from "../../../command-internal/container-cli.ts"; -import { isIPv6ConnectivityErrorCause } from "../../../command-internal/connect-errors.ts"; +import { + isIPv6ConnectivityErrorCause, + SUGGEST_LINKED_PROJECT, +} from "../../../command-internal/connect-errors.ts"; import { isDockerDaemonUnreachable } from "../../../command-internal/docker-suggest.ts"; import { mapHttpError } from "../../../command-internal/http-errors.ts"; import { @@ -33,7 +36,7 @@ import { renderLocalTarget } from "../../../command-internal/target/target-ident import { parseTargetSelector } from "../../../command-internal/target/target-selector.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"; +import { isCheckoutLinked, tempPaths } from "../../../shared/config/temp-paths.ts"; import { describeConfigParseError, missingProjectConfigMessageEffect, @@ -533,9 +536,18 @@ export const genTypes = Effect.fn("gen.types")(function* (flags: GenTypesFlags) ]); if (exitCode !== 0) { const message = stderr.trim(); + const suggestion = (yield* isCheckoutLinked( + fs, + path, + cliSettings.workdir, + cliSettings.projectId, + )) + ? SUGGEST_LINKED_PROJECT + : undefined; if (message.toLowerCase().includes("no such container")) { return yield* new GenTypesLocalDbNotRunningError({ message: "supabase start is not running.", + suggestion, }); } return yield* new GenTypesLocalDbInspectError({ @@ -544,6 +556,7 @@ export const genTypes = Effect.fn("gen.types")(function* (flags: GenTypesFlags) ? `failed to inspect service: ${message}` : "failed to inspect service", daemonDown: isDockerDaemonUnreachable(message), + suggestion, }); } }), 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 312a89c84e..a407e9face 100644 --- a/apps/cli/src/commands/gen/types/types.integration.test.ts +++ b/apps/cli/src/commands/gen/types/types.integration.test.ts @@ -35,6 +35,7 @@ import { rootCommandForFeatures } from "../../../cli/root.ts"; import { cliArgsLayer, prepareCliArgs } from "../../../shared/cli/run.ts"; import { CommandPlatformApiFactory } from "../../../auth/command-platform-api-factory.service.ts"; import { CommandPlatformApi } from "../../../auth/command-platform-api.service.ts"; +import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; import { stripAnsi } from "../../../../tests/helpers/ansi.ts"; import { mockContextualAnalytics, mockOutput, mockTty } from "../../../../tests/helpers/mocks.ts"; import { assertPermissionDrift } from "../../../../tests/helpers/permission-drift.ts"; @@ -3184,6 +3185,52 @@ describe("gen types", () => { }).pipe(Effect.provide(BunServices.layer)), ); + it.effect.each([ + { + name: "a missing database container in a linked checkout", + linked: true, + stderr: "Error: No such container: supabase_db_demo", + expected: { + code: "GenTypesLocalDbNotRunningError", + message: "supabase start is not running.", + suggestion: "To use the linked project instead, pass --remote.", + }, + }, + { + name: "an unreachable Docker daemon in a linked checkout", + linked: true, + stderr: "Cannot connect to the Docker daemon", + expected: { + code: "GenTypesLocalDbInspectError", + message: "failed to inspect service: Cannot connect to the Docker daemon", + suggestion: "To use the linked project instead, pass --remote.", + }, + }, + { + name: "a missing database container in an unlinked checkout", + linked: false, + stderr: "Error: No such container: supabase_db_demo", + expected: { + code: "GenTypesLocalDbNotRunningError", + message: "supabase start is not running.", + }, + }, + ])("reports $name in the JSON error envelope", ({ linked, stderr, expected }) => + Effect.gen(function* () { + const workdir = yield* makeWorkdir("supabase-gen-types-local-envelope-"); + if (linked) yield* writeTempFile(workdir, "project-ref", VALID_REF); + const { layer, out } = yield* setup({ + workdir, + format: "json", + childExitCode: 1, + childStderr: [stderr], + }); + yield* genTypes(defaultFlags()).pipe(withJsonErrorHandling, Effect.provide(layer)); + + expect(out.failures).toStrictEqual([expected]); + }).pipe(Effect.provide(BunServices.layer)), + ); + it.effect("generates locally with defaults when supabase/config.toml is missing", () => Effect.gen(function* () { const workdir = yield* makeWorkdir("supabase-gen-types-local-no-config-"); From 949729912474de6e9282aaab07adf51078a80548 Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Tue, 20 Oct 2026 12:19:15 +0100 Subject: [PATCH 192/291] docs(cli): trim ADR 0033 to the decisions Drop the banner/JSON target, help and docs, and guardrails sections, which describe formats and test assertions that apps/cli/docs/command-target.md and the command tests already carry. Fold the decisions they held into the groups, `--remote <value>`, announcing-the-target and consent text. --- ...t-selection-and-hosted-mutation-consent.md | 186 ++++++------------ 1 file changed, 55 insertions(+), 131 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 6fc63a1ae6..e2a1e252c0 100644 --- a/docs/adr/0033-target-selection-and-hosted-mutation-consent.md +++ b/docs/adr/0033-target-selection-and-hosted-mutation-consent.md @@ -31,7 +31,9 @@ Output rarely said which target a command had resolved. ### 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. +truth for the default target, the consent default and the help labels, so none of them can drift from +behaviour. `resolveTarget` and `requireHostedMutationConsent` require it, so a command that resolves +a target or asks for consent without one fails to compile, and a handler never passes a 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 @@ -70,23 +72,27 @@ line and nowhere else. Every command resolves a `--remote=<branch|uuid>` value with one resolver, the one `supabase link` uses. A value that matches no branch fails with link's error, which lists the branches. A branch UUID also resolves in a checkout with no linked project, where it is reported as the bare project -ref, `unverified` in JSON, because the lookup does not say whether it is the default branch. A keyword -connection string (`host=… user=…`) is rejected, not looked up as a branch. A URL applies only where the remote side is -Postgres: `storage`, `seed buckets` and `hosted` commands need a project and reject one. `hosted` -commands accept `--remote=<ref|branch>`, and bare `--remote` changes nothing for them. The linked -project comes from `SUPABASE_PROJECT_ID`, then the link file. `SUPABASE_PROJECT_ID` alone never -makes a `selectable` command remote; it is only a ref source once `--remote` is passed, and it -alone is enough for the `default; pass --remote` hint. An unresolvable remote target fails. The CLI never falls back between local and remote. +ref, `unverified` in JSON, because the lookup does not say whether it is the default branch. A +keyword connection string (`host=… user=…`) is rejected, not looked up as a branch. A URL applies +only where the remote side is Postgres: `storage`, `seed buckets` and `hosted` commands need a +project and reject one. `hosted` commands accept `--remote=<ref|branch>`, and bare `--remote` +changes nothing for them. `SUPABASE_PROJECT_ID` alone never makes a `selectable` command remote; it +is only a ref source once `--remote` is passed, and it alone is enough for the +`default; pass --remote` hint. An unresolvable remote target fails. The CLI never falls back between +local and remote. `--remote=<value>` always parses. `--remote <value>` is rewritten to `--remote=<value>` before parsing when the command takes no positional (a hidden one, such as `gen types`'s legacy language, -does not count), when the token is a ref or URL, or when the command -already has more bare tokens than positionals. The one ambiguous shape, a bare `--remote` directly -followed by a token the command took as a positional, is checked against the linked project's -branches before anything runs. A token that exactly matches a branch, is within a small edit -distance of one, or can't be checked because the branch listing failed stops the run with an error -that names the cause (an HTTP status, or a missing or invalid access token) and offers both readings without echoing the word: `--remote=<branch>` for the branch, the command's operands then `--remote` for the linked project. Any -other token runs as the command's operand. +does not count), when the token is a ref or URL, or when the command already has more bare tokens +than positionals. The one ambiguous shape, a bare `--remote` directly followed by a token the +command took as a positional, is checked against the linked project's branches before anything runs. +A token that exactly matches a branch, is within a small edit distance of one, or can't be checked +because the branch listing failed stops the run with an error that names the cause (an HTTP status, +or a missing or invalid access token) and offers both readings without echoing the word: +`--remote=<branch>` for the branch, the command's operands then `--remote` for the linked project. +Any other token runs as the command's operand. Docs, help and examples put `--remote` last, in the +`=` form when it has a value, so it cannot be read as anything else, and shell completion never +offers a space-separated value for `--remote` on a command that has a positional. ### Deprecated aliases @@ -94,15 +100,9 @@ other token runs as the command's operand. aliases. Each goes through the same resolver as the `--remote` form it maps to, prints a warning that names that form, and says v4 removes it. `--local` is still accepted and does nothing, because local is already the default; it warns the same way. v4 removes all of them, and `db diff`'s -`linked` value for `--from` and `--to`, which `remote` replaces. - -A warning shows the command path, the command's operand placeholders and the `--remote` -replacement, last. It never repeats the user's operands or other flags, because they can be secrets -that a CI log would then keep: - -```text -Warning: --project-ref is deprecated and will be removed in v4. Use --remote=<ref> instead: supabase secrets set <NAME=VALUE>... --remote=<ref> -``` +`linked` value for `--from` and `--to`, which `remote` replaces. A warning shows the command path, +the command's operand placeholders and the `--remote` replacement, last. It never repeats the user's +operands or other flags, because they can be secrets that a CI log would then keep. ### Selector rules @@ -117,11 +117,11 @@ onto `--remote`: - `db diff` with `--remote` or an alias together with `--from` or `--to` is an error. In `--from` and `--to`, `remote` always means the linked project. - `--no-remote` and `--remote=false` fail and tell the user to leave `--remote` out. - `--remote yes`, `--remote 1`, `--remote true` and `--remote on` mean bare `--remote`. -- An empty `--remote=` value is rejected, and the message names the values that command accepts. - Neither error prints the command's help. + `--remote yes`, `--remote 1`, `--remote true` and `--remote on` mean bare `--remote`. An empty + `--remote=` value is rejected, and the message names the values that command accepts. Neither + error prints the command's help. - A conflict error lists only the selector flags the command has and suggests the `--remote` form. -- `--remote` on `link`, which takes the project as its argument, and on a `local-only` command + `--remote` on `link`, which takes the project as its argument, and on a `local-only` command fails with a message saying so instead of an unknown-flag error. - `--password` is rejected unless the target is a project, meaning bare `--remote`, a ref or a branch. @@ -135,65 +135,18 @@ differs: `link` skips the ref file, `branches` derives the parent project, `proj for the project, and `projects list` and `services` read the ref for display only. Branch names and UUIDs go through the one branch resolver on every command, the `config` family included. -### 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 (<host>:<port>) -Target: local stack (<host>:<port>) · default; pass --remote for <name> (<ref>) -Target: custom Postgres (<host>:<port>) -Target: project <name> (<ref>) · production -Target: project <parent name> (<parent ref>) · branch <name> (<ref>) -Target: project <ref> -Target: project <ref> (not the linked project) -``` - -On `hosted` commands the banner is the notice that the command is talking to a remote project, and -it names the linked project they default to. A bare `db push`, `db pull` or `migration fetch` in a -linked checkout runs locally and prints the `default; pass --remote` hint. - -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 branch -line names the parent project when the cache knows its name, and shows the parent ref otherwise. - -A project's default branch is production, not a preview branch. `--remote=<default branch>`, by name -or UUID, resolves to the production project: the banner, the consent prompt and JSON -`environment: "production"` all say so, so a script that checks `environment` to stay away from +### Announcing the target + +A command that resolves a target announces it once on stderr, in every output format, and reports +the same identity as JSON `target`; [the command target doc](../../apps/cli/docs/command-target.md) +has the formats. The announcement is best effort: it adds no API call and no auth requirement, and +it never prints credentials. Identity comes from `supabase/.temp/linked-project.json`, which 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, `unverified` in +JSON. A project's default branch is production, not a preview branch: `--remote=<default branch>`, +by name or UUID, resolves to the production project, so the banner, the consent prompt and JSON +`environment: "production"` all say so, and a script that checks `environment` to stay away from production sees it. A UUID looked up with no linked project cannot tell, so it stays `unverified`. -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`: - -```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 remote --to <url>`) prints both and reports the -first in JSON. - -### Help and docs - -Help states each command's target from the same policy declaration, so labels cannot drift from -behaviour. A group's help labels each subcommand `[local or remote]` (`selectable`), `[local only]` -or `[remote only]` (`hosted`); account commands carry no label. A command's own help has a `TARGET` -section above `USAGE`, and its `remote` flag row reads `--remote[=<ref|branch|url>]` on a command -that takes a database URL and `--remote[=<ref|branch>]` on one that does not (`hosted` commands, -`storage`, `seed buckets`); `branches *` commands act on a parent project and take -`--remote[=<ref>]`. JSON help carries the same information as `target`, and gives the `remote` flag -`value` and `valueOptional` fields in place of the bracketed name. Docs, examples, help and the deprecation warnings put `--remote` last, in the `=` form -when it has a value (`db pull add_profiles --remote`, `db push --remote=<ref>`), so it cannot be -read as anything else, and shell completion never offers a space-separated value for `--remote` on -a command that has a positional. ### Hosted-mutation consent @@ -229,26 +182,25 @@ hosted. - **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. + login role may be created before consent. Removing a network ban may not: it needs `--yes` and the + `HostedMutationConsent` token only the consent module constructs, and without `--yes` 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 database URL target. It skips consent. -- **Exemptions.** A leaf that never asks declares `consent: "none"`. A `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. +- **Exemptions.** A leaf that never asks declares `consent: "none"`. A `selectable` leaf must also + give a `consentExemptReason`, 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.** A `consent: "none"` leaf may send a non-GET request only from a + reviewed list: 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. `notebooks pull` alone may also create a + notebook: 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` @@ -297,34 +249,6 @@ to No makes the Enter key safe. - 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 `selectable` leaf lacks `--remote`; - - 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 `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: - - `--remote`, `--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 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 1. **Per-command defaults for the target and for consent**: keep each handler's choice and document From 06bcd2efe905412ad49c02913d1d526dcf6835aa Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Tue, 20 Oct 2026 12:19:49 +0100 Subject: [PATCH 193/291] fix(cli): report an unlinked branch UUID the API client rejects as an unknown branch A UUID-shaped --remote value that the generated client's input schema refuses (a variant nibble outside 8-b, say) surfaced as a raw schema error: 'Expected a string with at most 20 code points'. resolveBranchById now maps that rejection to the same not-found error an unknown branch UUID gets from the API, without sending the request. --- .../command-internal/target/branch-resolution.ts | 10 +++++++--- .../resolve-project-target.integration.test.ts | 13 +++++++++++++ 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/apps/cli/src/command-internal/target/branch-resolution.ts b/apps/cli/src/command-internal/target/branch-resolution.ts index ff75d25f3a..aad23f318e 100644 --- a/apps/cli/src/command-internal/target/branch-resolution.ts +++ b/apps/cli/src/command-internal/target/branch-resolution.ts @@ -222,15 +222,14 @@ export const resolveLinkBranchRef = Effect.fn("Target.resolveBranch")(function* const resolveBranchById = Effect.fnUntraced(function* (id: string) { const api = yield* acquireApi(); + const notFoundMessage = `Branch "${sanitizeInlineName(id)}" not found. Run \`supabase branches list\` to see available branches.`; const mapBranchGetError = mapHttpError({ networkError: LinkBranchListNetworkError, statusError: LinkBranchListStatusError, networkMessage: (cause) => `failed to look up branch: ${cause}`, statusMessage: (status, body) => { if (status === 401) return AUTHENTICATION_FAILED_STATUS_MESSAGE; - return status === 404 - ? `Branch "${sanitizeInlineName(id)}" not found. Run \`supabase branches list\` to see available branches.` - : `unexpected get branch status ${status}: ${body}`; + return status === 404 ? notFoundMessage : `unexpected get branch status ${status}: ${body}`; }, }); @@ -238,6 +237,11 @@ const resolveBranchById = Effect.fnUntraced(function* (id: string) { const detail = yield* api.v1.getABranchConfig({ branch_id_or_ref: id }).pipe( Effect.tapError(() => task?.fail() ?? Effect.void), Effect.catch(mapBranchGetError), + Effect.catchTag("SupabaseApiInputError", () => + Effect.fail( + new LinkBranchListStatusError({ status: 404, body: "", message: notFoundMessage }), + ), + ), ); yield* task?.clear ?? Effect.void; diff --git a/apps/cli/src/command-internal/target/resolve-project-target.integration.test.ts b/apps/cli/src/command-internal/target/resolve-project-target.integration.test.ts index 9d544abad4..e81712955d 100644 --- a/apps/cli/src/command-internal/target/resolve-project-target.integration.test.ts +++ b/apps/cli/src/command-internal/target/resolve-project-target.integration.test.ts @@ -369,6 +369,19 @@ describe("resolveProjectTarget", () => { }), ); + it.effect("reports a UUID-shaped value the API would reject as an unknown branch", () => + Effect.gen(function* () { + const name = "11111111-2222-3333-4444-555555555555"; + const result = yield* run({ remote: { _tag: "Branch", name } }); + expect(failureOf(result.exit)).toMatchObject({ + _tag: "LinkBranchListStatusError", + status: 404, + message: `Branch "${name}" not found. Run \`supabase branches list\` to see available branches.`, + }); + expect(result.requestedUrls).toEqual([]); + }), + ); + it.effect("reports a branch UUID whose project is still provisioning", () => Effect.gen(function* () { const result = yield* run( From cfab10c02326b7af7adbfc84d5b26e8abe61c9d5 Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Tue, 20 Oct 2026 12:20:08 +0100 Subject: [PATCH 194/291] docs(cli): fix the linked project ref and cache paths in side effects The linked project ref lives in `<workdir>/supabase/.temp/project-ref` and its cache in `<workdir>/supabase/.temp/linked-project.json`, not under a hashed directory in `~/.supabase`. --- apps/cli/src/commands/db/diff/SIDE_EFFECTS.md | 2 +- apps/cli/src/commands/db/pull/SIDE_EFFECTS.md | 2 +- apps/cli/src/commands/db/push/SIDE_EFFECTS.md | 10 +++++----- apps/cli/src/commands/db/reset/SIDE_EFFECTS.md | 10 +++++----- apps/cli/src/commands/orgs/list/SIDE_EFFECTS.md | 2 +- apps/cli/src/commands/secrets/list/SIDE_EFFECTS.md | 8 ++++---- 6 files changed, 17 insertions(+), 17 deletions(-) diff --git a/apps/cli/src/commands/db/diff/SIDE_EFFECTS.md b/apps/cli/src/commands/db/diff/SIDE_EFFECTS.md index 6aff0c26b1..851e2b9804 100644 --- a/apps/cli/src/commands/db/diff/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/db/diff/SIDE_EFFECTS.md @@ -54,7 +54,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 | a remote project or branch (post-run cache) | +| `<workdir>/supabase/.temp/linked-project.json` | JSON | a remote project or branch (post-run cache) | | `~/.supabase/telemetry.json` | JSON | every invocation (post-run) | ## Docker diff --git a/apps/cli/src/commands/db/pull/SIDE_EFFECTS.md b/apps/cli/src/commands/db/pull/SIDE_EFFECTS.md index 3f60bc7a79..19ba5696c7 100644 --- a/apps/cli/src/commands/db/pull/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/db/pull/SIDE_EFFECTS.md @@ -63,7 +63,7 @@ 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, migration-style pull only (never a declarative export, which provisions no shadow); 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 | a remote project or branch (post-run cache) | +| `<workdir>/supabase/.temp/linked-project.json` | JSON | a remote project or branch (post-run cache) | | `~/.supabase/telemetry.json` | JSON | every invocation (post-run) | ## Docker diff --git a/apps/cli/src/commands/db/push/SIDE_EFFECTS.md b/apps/cli/src/commands/db/push/SIDE_EFFECTS.md index fbd3f76d5a..bfca19357d 100644 --- a/apps/cli/src/commands/db/push/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/db/push/SIDE_EFFECTS.md @@ -13,7 +13,7 @@ is set. A hosted target asks one combined confirmation before the first write. | ------------------------------------------------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | | `<workdir>/supabase/config.json` or `config.toml` | JSON / TOML | always; `config.json` is preferred when both exist (embedded defaults used when neither exists) | | `<workdir>/.env*`, `<workdir>/supabase/.env*` | dotenv | project env files, resolved with `SUPABASE_ENV` (default `development`); a variable the shell sets is never taken from a file | -| `~/.supabase/<hash>/project-ref` | plain text | bare `--remote`, to resolve the ref — skipped when `SUPABASE_PROJECT_ID` is set; with no selector, only to name the project in the `Target:` hint | +| `<workdir>/supabase/.temp/project-ref` | plain text | bare `--remote`, to resolve the ref — skipped when `SUPABASE_PROJECT_ID` is set; with no selector, only to name the project in the `Target:` hint | | `~/.supabase/access-token` | plain text | when `SUPABASE_ACCESS_TOKEN` unset and a remote temp-role is minted | | `<workdir>/supabase/migrations/` | directory | when `[db.migrations].enabled` (default true), to list local files | | `<workdir>/supabase/migrations/*.sql` | SQL | for each pending migration, when applied (and not `--dry-run`) | @@ -22,10 +22,10 @@ is set. A hosted target asks one combined confirmation before the first write. ## Files Written -| Path | Format | When | -| ------------------------------------------------ | ------ | ---------------------------------------------- | -| `~/.supabase/<workdir-hash>/linked-project.json` | JSON | on a remote project or branch (post-run cache) | -| `~/.supabase/telemetry.json` | JSON | always (post-run telemetry flush) | +| Path | Format | When | +| ---------------------------------------------- | ------ | ---------------------------------------------- | +| `<workdir>/supabase/.temp/linked-project.json` | JSON | on a remote project or branch (post-run cache) | +| `~/.supabase/telemetry.json` | JSON | always (post-run telemetry flush) | ## Database Mutations diff --git a/apps/cli/src/commands/db/reset/SIDE_EFFECTS.md b/apps/cli/src/commands/db/reset/SIDE_EFFECTS.md index 2791750749..391a4ebe9e 100644 --- a/apps/cli/src/commands/db/reset/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/db/reset/SIDE_EFFECTS.md @@ -72,7 +72,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 | bare `--remote`, to resolve the ref — skipped when `SUPABASE_PROJECT_ID` is set; with no selector, only to name the project in the `Target:` hint | +| `<workdir>/supabase/.temp/project-ref` | plain text | bare `--remote`, to resolve the ref — skipped when `SUPABASE_PROJECT_ID` is set; with no selector, only to name the project in the `Target:` hint | | `~/.supabase/access-token` | plain text | a remote project or branch, 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) | @@ -82,10 +82,10 @@ or migrations fail, companion services remain stopped. Fix the reported error an ## Files Written -| Path | Format | When | -| ------------------------------------------------ | ------ | ------------------------------------------- | -| `~/.supabase/<workdir-hash>/linked-project.json` | JSON | a remote project or branch (post-run cache) | -| `~/.supabase/telemetry.json` | JSON | always (post-run telemetry flush) | +| Path | Format | When | +| ---------------------------------------------- | ------ | ------------------------------------------- | +| `<workdir>/supabase/.temp/linked-project.json` | JSON | a remote project or branch (post-run cache) | +| `~/.supabase/telemetry.json` | JSON | always (post-run telemetry flush) | On the local path, the native recreate additionally recreates the `supabase_db_<project>` container/volume (PG15) or the `postgres`/`_supabase` diff --git a/apps/cli/src/commands/orgs/list/SIDE_EFFECTS.md b/apps/cli/src/commands/orgs/list/SIDE_EFFECTS.md index 719db76f7c..bb294ecaa1 100644 --- a/apps/cli/src/commands/orgs/list/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/orgs/list/SIDE_EFFECTS.md @@ -17,7 +17,7 @@ Target: account, no consent — see [Command target](../../../../docs/command-ta | `~/.supabase/telemetry.json` | JSON | always (in `Effect.ensuring`) at end of command | `orgs list` is a user-level command — it does not resolve a `--project-ref`, so the -linked-project cache (`~/.supabase/<workdir-hash>/linked-project.json`) is never written. +linked-project cache (`<workdir>/supabase/.temp/linked-project.json`) is never written. ## API Routes diff --git a/apps/cli/src/commands/secrets/list/SIDE_EFFECTS.md b/apps/cli/src/commands/secrets/list/SIDE_EFFECTS.md index e8d03487c8..29255304b0 100644 --- a/apps/cli/src/commands/secrets/list/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/secrets/list/SIDE_EFFECTS.md @@ -14,10 +14,10 @@ Target: hosted, no consent — see [Command target](../../../../docs/command-tar ## Files Written -| Path | Format | When | -| ------------------------------------------------ | ------ | ------------------------------------------------------------ | -| `~/.supabase/<workdir-hash>/linked-project.json` | JSON | always (in `Effect.ensuring`) after the project ref resolves | -| `~/.supabase/telemetry.json` | JSON | always (in `Effect.ensuring`) at end of command | +| Path | Format | When | +| ---------------------------------------------- | ------ | ------------------------------------------------------------ | +| `<workdir>/supabase/.temp/linked-project.json` | JSON | always (in `Effect.ensuring`) after the project ref resolves | +| `~/.supabase/telemetry.json` | JSON | always (in `Effect.ensuring`) at end of command | ## API Routes From 02eeaa703012499049e87fd7e6fc4023c2b5b1d5 Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Tue, 20 Oct 2026 12:21:33 +0100 Subject: [PATCH 195/291] fix(cli): let db query --read-only accept BEGIN ATOMIC inside literals The check no longer scans the raw SQL. The statement splitter now reports when it reads a BEGIN ATOMIC body, so the words inside a string, escape string, dollar quote, quoted identifier or comment no longer trip --read-only, while an alias lookalike that hides the statements after it from the splitter is still refused. --- apps/cli/src/command-internal/sql-split.ts | 22 +++++++++++++---- .../command-internal/sql-split.unit.test.ts | 24 +++++++++++++++++++ .../src/commands/db/query/query.read-only.ts | 14 +++++------ .../db/query/query.read-only.unit.test.ts | 14 ++++++++++- 4 files changed, 61 insertions(+), 13 deletions(-) diff --git a/apps/cli/src/command-internal/sql-split.ts b/apps/cli/src/command-internal/sql-split.ts index fa2cc45463..d05dd47d45 100644 --- a/apps/cli/src/command-internal/sql-split.ts +++ b/apps/cli/src/command-internal/sql-split.ts @@ -12,6 +12,8 @@ interface State { next(char: string, data: string): State | null; /** `true` while inside a block comment, quoted string, quoted identifier or dollar quote. */ readonly open?: boolean; + /** `true` while inside a `BEGIN ATOMIC` body, where `;` no longer ends a statement. */ + readonly atomic?: boolean; } const BEGIN_ATOMIC = "ATOMIC"; @@ -257,6 +259,9 @@ class ParenState implements State { get open(): boolean { return this.prev.open === true; } + get atomic(): boolean { + return this.prev.atomic === true; + } next(char: string, data: string): State | null { const curr = this.prev.next(char, data); if (curr === null) { @@ -270,6 +275,7 @@ class ParenState implements State { } class AtomicState implements State { + readonly atomic = true; private pendingEnd = false; private statementStart: number; private statementHasContent = false; @@ -325,11 +331,14 @@ interface RawSplit { readonly tokens: RawToken[]; /** The input ends inside a block comment, quoted string, quoted identifier or dollar quote. */ readonly unterminated: boolean; + /** The input holds a `BEGIN ATOMIC` body, which the splitter reads as part of one statement. */ + readonly beginAtomic: boolean; } /** The FSM traversal shared by every `splitSql*` entry point below. */ function splitRaw(sql: string): RawSplit { let state: State = new ReadyState(); + let beginAtomic = false; const tokens: RawToken[] = []; // Slice each token from `sql` instead of growing it with `+=`: states read the token's tail every // char, which would rebuild the whole string each time and go quadratic on large tokens. `data` @@ -346,11 +355,12 @@ function splitRaw(sql: string): RawSplit { state = new ReadyState(); } else { state = next; + beginAtomic ||= state.atomic === true; } } // Trailing non-terminated statement at EOF. if (end > start) tokens.push({ text: sql.slice(start), terminated: false }); - return { tokens, unterminated: state.open === true }; + return { tokens, unterminated: state.open === true, beginAtomic }; } /** @@ -378,14 +388,18 @@ export function splitAndTrim(sql: string): string[] { return splitSql(sql, trimStatement); } -/** `splitAndTrim`, plus whether `sql` ends inside a block comment, quote or dollar quote. */ +/** + * `splitAndTrim`, plus whether `sql` ends inside a block comment, quote or dollar quote, and + * whether the splitter read a `BEGIN ATOMIC` body, which hides its inner statements from `statements`. + */ export function splitAndTrimChecked(sql: string): { readonly statements: string[]; readonly unterminated: boolean; + readonly beginAtomic: boolean; } { - const { tokens, unterminated } = splitRaw(sql); + const { tokens, unterminated, beginAtomic } = splitRaw(sql); const statements = tokens.map(({ text }) => trimStatement(text)).filter((s) => s.length > 0); - return { statements, unterminated }; + return { statements, unterminated, beginAtomic }; } /** One statement, paired with both its raw and trimmed forms. */ 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 a3437b2b2a..913748fec6 100644 --- a/apps/cli/src/command-internal/sql-split.unit.test.ts +++ b/apps/cli/src/command-internal/sql-split.unit.test.ts @@ -334,6 +334,30 @@ describe("splitAndTrimChecked", () => { ])("does not flag %s", (_name, sql) => { expect(splitAndTrimChecked(sql).unterminated).toBe(false); }); + + it.each([ + ["a function body", "CREATE FUNCTION f() BEGIN ATOMIC SELECT 1; END"], + ["a lowercase pair split by a newline", "SELECT 1 FROM begin\n atomic; COMMIT; END"], + ["a pair inside parentheses", "SELECT (SELECT 1 FROM begin atomic; COMMIT; END)"], + ["a pair next to a string holding the same words", "SELECT 'begin atomic' FROM begin atomic"], + ])("reports %s as a BEGIN ATOMIC body", (_name, sql) => { + expect(splitAndTrimChecked(sql).beginAtomic).toBe(true); + }); + + it.each([ + ["a string", "SELECT 'begin atomic'"], + ["a string with a newline between the words", "SELECT 'begin\natomic'"], + ["an escape string", String.raw`SELECT E'it\'s begin atomic'`], + ["a Unicode escape string", "SELECT U&'begin atomic'"], + ["a dollar quote", "SELECT $$begin atomic$$"], + ["a quoted identifier", 'SELECT 1 AS "begin atomic"'], + ["a line comment", "SELECT 1 -- begin atomic\n; SELECT 2"], + ["a block comment", "SELECT 1 /* begin atomic */"], + ["an identifier that merely ends in begin", "SELECT 1 xbegin atomic; SELECT 2"], + ["a plain statement", "SELECT 1"], + ])("does not report %s as a BEGIN ATOMIC body", (_name, sql) => { + expect(splitAndTrimChecked(sql).beginAtomic).toBe(false); + }); }); describe("splitSql", () => { 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 4e487066e9..c3b2e76fa3 100644 --- a/apps/cli/src/commands/db/query/query.read-only.ts +++ b/apps/cli/src/commands/db/query/query.read-only.ts @@ -70,10 +70,6 @@ const UNTERMINATED_INPUT = "an unterminated comment, quote or dollar quote"; // 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; -// 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; - const statementViolation = (statement: string): string | undefined => { const text = trimLeadingSqlComments(statement); if (text === "") return undefined; @@ -89,15 +85,17 @@ const statementViolation = (statement: string): string | undefined => { /** * 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. + * that does not start with one of {@link READ_ONLY_STATEMENT_KEYWORDS}, a `BEGIN ATOMIC` body, or + * input the splitter cannot read to the end. */ export const findReadOnlyViolation = (sql: string): string | undefined => { - const { statements, unterminated } = splitAndTrimChecked(sql); + const { statements, unterminated, beginAtomic } = splitAndTrimChecked(sql); for (const statement of statements) { const violation = statementViolation(statement); if (violation !== undefined) return violation; } - if (BEGIN_ATOMIC.test(sql)) return "BEGIN ATOMIC"; + // 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. + if (beginAtomic) 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 351beb4b86..ee833a8a75 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 @@ -136,7 +136,9 @@ describe("findReadOnlyViolation", () => { 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'", + "SELECT 1 FROM BEGIN \t\r\n ATOMIC; COMMIT; DELETE FROM users; END", + "SELECT 'begin atomic' FROM begin atomic; COMMIT; DELETE FROM users; END", + "SELECT 1 -- begin\natomic; COMMIT; DELETE FROM users; END", ])("rejects input containing BEGIN ATOMIC: %s", (sql) => { expect(findReadOnlyViolation(sql)).toBe("BEGIN ATOMIC"); }); @@ -173,6 +175,16 @@ describe("findReadOnlyViolation", () => { ["a keyword inside a line comment", "SELECT 1 -- COMMIT\n; SELECT 2"], ["a keyword inside a block comment", "SELECT 1 /* ROLLBACK; */ ; SELECT 2"], ["a keyword as a quoted identifier", 'SELECT "commit", "begin" FROM t'], + ["BEGIN ATOMIC inside a string", "select 'begin atomic' as s"], + ["BEGIN ATOMIC with a newline inside a string", "SELECT 'begin\n atomic'"], + ["BEGIN ATOMIC inside an escape string", String.raw`SELECT E'it\'s begin atomic'`], + ["BEGIN ATOMIC inside a Unicode escape string", "SELECT U&'begin atomic'"], + ["BEGIN ATOMIC inside a dollar quote", "SELECT $$begin atomic$$"], + ["BEGIN ATOMIC inside a tagged dollar quote", "SELECT $q$begin atomic$q$"], + ["BEGIN ATOMIC as a quoted identifier", 'select 1 as "begin atomic"'], + ["BEGIN ATOMIC inside a line comment", "SELECT 1 -- begin atomic"], + ["BEGIN ATOMIC inside a block comment", "select 1 /* begin atomic */"], + ["BEGIN ATOMIC inside a nested block comment", "SELECT 1 /* a /* begin atomic */ b */"], ["a keyword as a column", "SELECT begin, commit, rollback FROM t"], ["set_config", "SELECT set_config('default_transaction_read_only', 'off', false)"], ["current_setting", "SELECT current_setting('transaction_read_only')"], From 0acfc192d543a010edc55a0209ad84dd3ef77ce2 Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Tue, 20 Oct 2026 12:21:46 +0100 Subject: [PATCH 196/291] fix(cli): hint at the lowercased ref when a 20-letter --remote value has uppercase letters A value such as ABCDEFGHIJKLMNOPQRST is looked up as a branch name, like link does, but got no hint that project refs are lowercase. parentRefTypoHint now suggests the lowercased ref for a 20-letter value with an uppercase letter, in both the branch-not-found and the not-linked messages. --- .../command-internal/parent-project-ref.ts | 13 ++++-- .../target/resolve-target.integration.test.ts | 42 +++++++++++++++++++ 2 files changed, 51 insertions(+), 4 deletions(-) diff --git a/apps/cli/src/command-internal/parent-project-ref.ts b/apps/cli/src/command-internal/parent-project-ref.ts index d576845933..7f1b83167a 100644 --- a/apps/cli/src/command-internal/parent-project-ref.ts +++ b/apps/cli/src/command-internal/parent-project-ref.ts @@ -169,12 +169,17 @@ export const resolveParentScopedProjectRef = Effect.fn("ParentProjectRef.resolve /** * A value made entirely of lowercase letters (but not 20 of them, or it would already have - * been treated as a ref) is a plausible ref typo. Appended to both {@link parentNotLinkedMessage} - * and `link`'s own branch-not-found message. + * been treated as a ref), or 20 letters with an uppercase one, is a plausible ref typo. Appended + * to both {@link parentNotLinkedMessage} and `link`'s own branch-not-found message. */ export function parentRefTypoHint(value: string): string { - if (!/^[a-z]+$/.test(value)) return ""; - return `\n If you meant a project ref: refs are exactly 20 lowercase letters ("${value}" has ${value.length}).`; + if (/^[a-z]+$/.test(value)) { + return `\n If you meant a project ref: refs are exactly 20 lowercase letters ("${value}" has ${value.length}).`; + } + if (/^[A-Za-z]{20}$/.test(value)) { + return `\n If you meant a project ref: refs are exactly 20 lowercase letters (try "${value.toLowerCase()}").`; + } + return ""; } /** 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 46e1fdb024..56186db67c 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 @@ -382,6 +382,33 @@ describe("resolveTarget", () => { }), ); + it.effect("suggests the lowercased ref when a 20-letter value has uppercase letters", () => + Effect.gen(function* () { + const result = yield* run( + "selectable", + { ...none, ...remote({ _tag: "Branch", name: "ABCDEFGHIJKLMNOPQRST" }) }, + { savedRef: FILE_REF, branches: [branchRow("main")] }, + ); + expect(failureOf(result.exit)).toMatchObject({ + _tag: "LinkBranchNotFoundError", + message: `Branch "ABCDEFGHIJKLMNOPQRST" not found for project ${FILE_REF}. Available branches: main.\n If you meant a project ref: refs are exactly 20 lowercase letters (try "abcdefghijklmnopqrst").`, + }); + }), + ); + + it.effect("gives no ref hint to a mixed-case value that is not 20 letters", () => + Effect.gen(function* () { + const result = yield* run( + "selectable", + { ...none, ...remote({ _tag: "Branch", name: "ABCDEFGHIJKLMNOPQRS" }) }, + { savedRef: FILE_REF, branches: [branchRow("main")] }, + ); + expect(String((failureOf(result.exit) as Error).message)).not.toContain( + "If you meant a project ref", + ); + }), + ); + it.effect("tells an expired token to log in instead of showing the response body", () => Effect.gen(function* () { const result = yield* run( @@ -408,6 +435,21 @@ describe("resolveTarget", () => { }), ); + it.effect("suggests the lowercased ref when no project is linked", () => + Effect.gen(function* () { + const result = yield* run("selectable", { + ...none, + ...remote({ _tag: "Branch", name: "ABCDEFGHIJKLMNOPQRST" }), + }); + expect(failureOf(result.exit)).toMatchObject({ + _tag: "LinkBranchNotLinkedError", + message: expect.stringContaining( + 'If you meant a project ref: refs are exactly 20 lowercase letters (try "abcdefghijklmnopqrst").', + ), + }); + }), + ); + it.effect("fails with not-linked instead of falling back to local when it cannot resolve", () => Effect.gen(function* () { const result = yield* run( From 3c339bcf4a830828b57efc1cf9998b96b0894885 Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Tue, 20 Oct 2026 12:27:31 +0100 Subject: [PATCH 197/291] docs(cli): document the round-two target and consent behaviour Record the changes in command-target.md and, where it states the rule, ADR 0033: - deprecation warnings name only the replacement and carry no example - a repeated --remote with different values fails before anything runs - a project-ref-shaped word after a bare --remote fails when the command can take it as an operand, and the near-miss rule is narrower - a failed branch listing names its cause and the fix - --remote=<url> on a command with no database fails at flag-parse time - an unlinked --remote=<uuid> the API can't look up is branch not found - an uppercase 20-letter --remote value gets the lowercase-ref hint - the last --yes wins gen types documents the --remote suggestion in a linked checkout, and db query documents that --read-only no longer refuses BEGIN ATOMIC inside a string, quoted identifier or comment. --- apps/cli/docs/command-target.md | 45 ++++++++------- .../cli/src/commands/db/query/SIDE_EFFECTS.md | 9 +-- .../src/commands/gen/types/SIDE_EFFECTS.md | 12 +++- ...t-selection-and-hosted-mutation-consent.md | 56 +++++++++++-------- 4 files changed, 70 insertions(+), 52 deletions(-) diff --git a/apps/cli/docs/command-target.md b/apps/cli/docs/command-target.md index 9c59929f64..648614636c 100644 --- a/apps/cli/docs/command-target.md +++ b/apps/cli/docs/command-target.md @@ -28,16 +28,21 @@ The consent default is the answer a confirmation starts on. It is `No` for anyth | `--remote=<branch>` | A branch of the linked project, by name or UUID | - **The linked project** is `SUPABASE_PROJECT_ID`, else `supabase/.temp/project-ref`. With neither, an interactive terminal (stdin and stdout are TTYs and the output is text) gets the project picker (`GET /v1/projects`), and any other run fails with `ProjectRefNotLinkedError`. A malformed ref from either source fails with `InvalidProjectRefError`. `SUPABASE_PROJECT_ID` alone never makes a `selectable` command remote: it is a ref source once `--remote` is passed, and enough for the `default; pass --remote` hint. The CLI never falls back between local and remote. -- **A branch** resolves with the lookup `supabase link` uses (`GET /v1/projects/{parent}/branches`, where `{parent}` is `SUPABASE_PROJECT_ID`, else the linked project), and the command acts on that branch's own project. With no linked project a UUID is looked up with `GET /v1/branches/{id}`. +- **A branch** resolves with the lookup `supabase link` uses (`GET /v1/projects/{parent}/branches`, where `{parent}` is `SUPABASE_PROJECT_ID`, else the linked project), and the command acts on that branch's own project. With no linked project a UUID is looked up with `GET /v1/branches/{id}`, and one the API can't look up fails as ``Branch "<uuid>" not found. Run `supabase branches list` to see available branches.``, never as a schema error. - **Hosted-only commands** take `--remote=<ref|branch>` for another project, and a bare `--remote` changes nothing for them. `link` takes its target as an argument and has no `--remote`. `branches *` commands act on a parent project, so their `--remote` takes a ref, never a branch name. -- **A database URL** applies only where the remote side is Postgres. `storage`, `seed buckets` and hosted commands need a project and reject one with an error that names the command and sends no request. A value with `://` that is not a `postgres://` or `postgresql://` URL, and a keyword connection string (`host=… user=…`), is rejected on every command and is never looked up as a branch. +- **A database URL** applies only where the remote side is Postgres. `storage`, `seed buckets` and hosted commands need a project, so they reject `--remote=<url>` when the flags are parsed, as an invalid `--remote` value (`--remote takes a project ref or a branch name.`, or `--remote takes a project ref.` on `branches *`). The run exits 1 before anything else happens, and the URL is never echoed. A value with `://` that is not a `postgres://` or `postgresql://` URL, and a keyword connection string (`host=… user=…`), is rejected on every command and is never looked up as a branch. - **`--remote=true|yes|on|1`** means a bare `--remote`. `--no-remote` and `--remote=false|no|off|0` fail with `--remote doesn't take false. Leave it out to <fallback>.`, and an empty `--remote=` with `--remote= has an empty value. Pass --remote for the linked project or --remote=<values>; leave it out to <fallback>.` `<fallback>` is `target the local database`, `target the local Storage API` or, on a hosted command, `use the linked project`; `<values>` is `<ref|branch|url>` where the command takes a database URL, `<ref|branch>` elsewhere and `<ref>` on `branches *`. Neither error prints the command's help. +- **A local stack that is down in a linked checkout.** A database command that defaults to the local stack and can't reach it adds `To use the linked project instead, pass --remote.` to its error's suggestion when the checkout is linked (`SUPABASE_PROJECT_ID` is set, or `supabase/.temp/project-ref` names a project). `gen types` adds it to its errors for a local database container that is missing or can't be inspected. ## `--remote <word>` -`--remote=<word>` always parses as a value. A bare `--remote` followed by a word is rewritten to `--remote=<word>` when the command takes no operands (a hidden one does not count), when the word is a project ref or a database URL, or when the command already has more words than operands. +`--remote=<word>` always parses as a value. A bare `--remote` followed by a word is rewritten to `--remote=<word>` when the command takes no operands (a hidden one does not count), when the word is a database URL, or when the command already has more words than operands. -The one ambiguous shape is a bare `--remote` directly followed by a word the command took as an operand. When a linked project is known, the CLI checks that word against its branches before anything runs. A word that exactly matches a branch, is within a small edit distance of one, or can't be checked because the branch listing failed stops the run with an error that names the cause (an HTTP status, or a missing or invalid access token) and offers both readings without echoing the word: `--remote=<branch>` for the branch, or the command's operands followed by `--remote` for the linked project. Any other word runs as the operand. +The one ambiguous shape is a bare `--remote` directly followed by a word the command took as an operand. It is settled before anything runs, and the error offers both readings: + +- **A project-ref-shaped word** (20 lowercase letters) fails when the command still has room for operands, with no network call: pass `--remote=<ref>` for that project, or put `--remote` last to keep the word as an operand. It is read as `--remote=<ref>` only when the command takes no operands or its operands are already full. +- **Any other word** is checked against the branches of the linked project, when one is known. The run fails when the word names a branch, equals a branch name ignoring case, or is one edit from the name of a non-default branch of five or more characters; swapping two adjacent letters is one edit. The error offers `--remote=<branch>` for the branch, or the command's operands followed by `--remote` for the linked project. Otherwise the word runs as the operand. `--remote=<unknown>` is separate: its "Did you mean" suggestion allows up to two edits. +- **A listing that fails** still fails the run before anything runs, and the error gives the cause: `HTTP <status>`, the network error, or a missing or invalid access token. It never echoes the word, and its linked-project example reads `supabase <path> <operands> --remote`. The suggestion follows the cause: a `401` says to log in again, or to fix `SUPABASE_ACCESS_TOKEN` when the token came from the environment; a `403` says to use a token that can read the project's branches; a `404` says the linked ref may be a preview branch, so link its parent project. ## Selector errors @@ -46,11 +51,13 @@ These fail with exit 1 before the command does any work: | Condition | Error | | --------------------------------------------------------------------------------------------------------- | --------------------------------------------------------- | | more than one of `--remote`, `--linked`, `--db-url` and `--local`, or `--project-ref` with `--project-id` | `TargetSelectorConflictError` | +| `--remote` given more than once with different values | `TargetSelectorConflictError` | | `--project-ref` or `--project-id` with `--local`, `--db-url` or `--remote=<value>` | `TargetProjectRefConflictError` | | an empty `--db-url`, `--project-ref` or `--project-id` | `TargetEmptyValueError` | | `--project-ref` or `--project-id` given a URL or connection string | `TargetInvalidValueError` | -| `--remote=<url>` on a command with no database connection | `TargetNeedsProjectError` | -| a bare `--remote` followed by a word that may name a branch | `TargetRemoteAmbiguousError` | +| `--remote=<url>` on a command with no database connection | a flag-parse error: an invalid `--remote` value | +| a branch name given to a `branches *` command | `TargetNeedsProjectError` | +| a bare `--remote` followed by a project-ref-shaped word, or by a word that may name a branch | `TargetRemoteAmbiguousError` | | `--password` with `--remote=<url>` or the local default | `TargetPasswordConflictError` | | `--remote=<branch>` naming no branch of the linked project | `LinkBranchNotFoundError` | | a branch name with no linked project to search | `LinkBranchNotLinkedError` | @@ -58,9 +65,9 @@ These fail with exit 1 before the command does any work: | a branch that has no project ref yet because it is still provisioning | `LinkBranchNotReadyError` | | the branch lookup fails to reach the API or gets a non-200 status | `LinkBranchListNetworkError`, `LinkBranchListStatusError` | -A selector conflict lists only the selector flags the command has: `Only one of --remote, --linked, --db-url and --local can be set; got …`, with the suggestion to use `--remote` or `--remote=<values>` on its own. A bare `--remote` together with `--project-ref=<x>` is accepted: the alias's value wins and its warning prints. A `false` switch counts as absent, so `--linked=false` selects nothing. +A selector conflict lists only the selector flags the command has: `Only one of --remote, --linked, --db-url and --local can be set; got …`, with the suggestion to use `--remote` or `--remote=<values>` on its own. A bare `--remote` together with `--project-ref=<x>` is accepted: the alias's value wins and its warning prints. A `false` switch counts as absent, so `--linked=false` selects nothing. A repeated `--remote` is one selection when every occurrence resolves to the same target, and a bare `--remote` is the linked project, so `--remote --remote` and `--remote=<ref> --remote=<ref>` run. When the occurrences differ the run fails before anything runs, with `--remote is given more than once with different values; pass it once.` and the suggestion to pass `--remote` once. -`LinkBranchNotFoundError` names the parent project, lists the branches it has, offers the nearest name as "Did you mean" and, for a word of lowercase letters, notes that a project ref is exactly 20 lowercase letters. A `404` from the branch listing adds that the linked ref may itself be a preview branch and says to link its parent project. +`LinkBranchNotFoundError` names the parent project, lists the branches it has, offers the nearest name as "Did you mean" and, for a word of lowercase letters, or a 20-letter word with an uppercase letter (which is still looked up as a branch and gets the lowercase spelling as a suggestion), notes that a project ref is exactly 20 lowercase letters. A `404` from the branch listing adds that the linked ref may itself be a preview branch and says to link its parent project. ## `--password` @@ -73,26 +80,22 @@ A command's own `SIDE_EFFECTS.md` records where else its password can come from ## Deprecated selector flags -`--linked`, `--project-ref`, `--db-url` and `--local` (and `gen types --project-id`) still work in v3 and are removed in v4, as is the `linked` value of `db diff --from` and `--to`, which `remote` replaces. Each goes through the same resolver as the `--remote` form it maps to and prints a warning on stderr in every output format, before the `Target:` line: - -| Flag | Replacement | -| --------------------- | ------------------------------------------------------------------------------------------------------- | -| `--linked` | `--remote` | -| `--project-ref=<ref>` | `--remote=<ref>`, or `--remote=<branch>` when the value is a branch name | -| `--db-url=<url>` | `--remote=<db-url>`, or `--remote=<ref>` with an access token set when the URL names a Supabase project | -| `--local` | nothing: local is the default, so the flag does nothing | +`--linked`, `--project-ref`, `--db-url` and `--local` (and `gen types --project-id`) still work in v3 and are removed in v4, as is the `linked` value of `db diff --from` and `--to`, which `remote` replaces. Each goes through the same resolver as the `--remote` form it maps to and prints a warning on stderr in every output format, before the `Target:` line. The warning names only the replacement: -A warning shows the command path, the command's operand placeholders and the `--remote` replacement, last. It never repeats your operands, flag values or a database URL, because a CI log would keep them: +| Flag | The warning says | +| --------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `--linked` | `--linked is deprecated and will be removed in v4. Use --remote instead` | +| `--project-ref=<ref>` | `--project-ref is deprecated and will be removed in v4. Use --remote=<ref> instead`, or `Use --remote=<branch> instead` when the value is a branch name | +| `--db-url=<url>` | `--db-url is deprecated and will be removed in v4. Use --remote=<db-url> instead`, then `, or --remote=<ref> with an access token set` when the URL names a Supabase project | +| `--local` | `--local isn't needed (local is the default) and will be removed in v4` | -```text -Warning: --project-ref is deprecated and will be removed in v4. Use --remote=<ref> instead: supabase secrets set <NAME=VALUE>... --remote=<ref> -``` +A warning never repeats an operand, a flag value or a database URL, because a CI log would keep them, and it carries no example command. Two values have their own wording: a branch whose name is a boolean literal (`true`, `false`, `yes`, `no`, `on`, `off`, `1`, `0`) is reached with `--remote=<its ref or UUID>`, and a keyword `--db-url` connection string is converted to a `postgresql://` URL for `--remote=<url>`. ## Hosted-mutation consent Every persistent mutation of a hosted resource asks for consent first, including a write through a non-local `--remote=<url>`. A loopback host on the local or shadow database ports, or the local stack's endpoint, counts as local. -- **Approval.** `--yes`, or a `SUPABASE_YES` in the shell environment. A `SUPABASE_YES` in a project `.env*` file does not count, and `--yes=false` beats the environment. Stdin is never read, so a piped `y` does nothing. The confirmation line names which one approved: `Confirmed by --yes: <action> on <target>`. +- **Approval.** `--yes`, or a `SUPABASE_YES` in the shell environment. A `SUPABASE_YES` in a project `.env*` file does not count. The last `--yes` on the command line wins and beats the environment, so `--yes=false --yes` confirms and `--yes --yes=false` declines. Stdin is never read, so a piped `y` does nothing. The confirmation line names which one approved: `Confirmed by --yes: <action> on <target>`. - **Prompt.** Only text output on an interactive stdout with a TTY stdin, with no agent driving, is asked. The question starts on the command's consent default. - **Refusal.** JSON, stream-json, `-o json|yaml|toml|env`, agent 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`. Nothing is changed. - **Decline.** A declined prompt fails with `HostedMutationDeclinedError` (`context canceled`), exit 1, and nothing is changed. diff --git a/apps/cli/src/commands/db/query/SIDE_EFFECTS.md b/apps/cli/src/commands/db/query/SIDE_EFFECTS.md index 6e16d22327..453afc4bc4 100644 --- a/apps/cli/src/commands/db/query/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/db/query/SIDE_EFFECTS.md @@ -103,11 +103,12 @@ from the environment. Agent mode defaults the format to JSON (table for humans). 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`, `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 + …), input that ends inside a comment, quote or dollar quote, and input that contains a + `BEGIN ATOMIC` function body (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 remote project or branch sends `"read_only": true` in the Management API request, and the + inside strings, dollar quotes and comments, `BEGIN ATOMIC` inside a string literal, quoted + identifier, dollar quote or comment (`select 'begin atomic'`), and reads such as + `SELECT set_config(...)`, pass the checks. A remote project or branch sends `"read_only": true` in the Management API request, and the API enforces it. The local stack and a `--remote=<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`). diff --git a/apps/cli/src/commands/gen/types/SIDE_EFFECTS.md b/apps/cli/src/commands/gen/types/SIDE_EFFECTS.md index e561e29cf2..60fc68a5c3 100644 --- a/apps/cli/src/commands/gen/types/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/gen/types/SIDE_EFFECTS.md @@ -19,7 +19,7 @@ instead of inspecting `supabase_db_*`. | Path | Format | When | | ----------------------------------------------------------------------- | ------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `<workdir>/supabase/.temp/project-ref` | plain text | a remote project: resolves the ref (bare `--remote` with no `SUPABASE_PROJECT_ID`) and tells whether it is the linked project; with no selector, only to word the linked-checkout hint | +| `<workdir>/supabase/.temp/project-ref` | plain text | a remote project: resolves the ref (bare `--remote` with no `SUPABASE_PROJECT_ID`) and tells whether it is the linked project; with no selector, only to word the linked-checkout hint and to decide whether a stopped local stack suggests `--remote` | | `<workdir>/supabase/.temp/linked-project.json` | JSON | to name the project in the `Target:` banner and the linked-checkout hint, and as the parent project for `--remote=<branch>` | | `~/.supabase/access-token` | plain text | when `SUPABASE_ACCESS_TOKEN` unset and the target is a remote project (or a branch is looked up) | | `<workdir>/supabase/config.toml` or `config.json` | TOML/JSON | when selecting schemas on a remote project (a matched `[remotes.<ref>]` block over the base) or with `--remote=<db-url>` (`--schema` omitted; any `--schema` skips the schema load entirely). The local target 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 | @@ -119,8 +119,8 @@ way `--remote=<db-url>` does. | `1` | a positional language other than `typescript` passed without `--lang` (`GenTypesFlagUsageError`) | | `1` | invalid `--query-timeout` duration or invalid database URL | | `1` | `--network-id` set (`GenTypesNetworkIdUnsupportedError`) — generation runs in-process and cannot join a Docker network | -| `1` | `supabase start` not running (local target on the legacy Docker Compose stack) (`GenTypesLocalDbNotRunningError`) | -| `1` | the local target on the legacy Docker Compose stack and `container inspect` exits non-zero for any other reason (`GenTypesLocalDbInspectError`) | +| `1` | `supabase start` not running (local target on the legacy Docker Compose stack) (`GenTypesLocalDbNotRunningError`); in a linked checkout the suggestion is `To use the linked project instead, pass --remote.` | +| `1` | the local target on the legacy Docker Compose stack and `container inspect` exits non-zero for any other reason (`GenTypesLocalDbInspectError`); a linked checkout gets the same suggestion | | `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`) | @@ -153,6 +153,12 @@ Not applicable. - **`--remote <word>`.** A word after `--remote` is always read as `--remote=<word>`: the legacy positional language is hidden and takes no part in that reading, so a language goes before `--remote` (`gen types typescript --remote`). +- **Stopped local stack in a linked checkout.** A checkout is linked when `SUPABASE_PROJECT_ID` is + set or `supabase/.temp/project-ref` names a project. On the legacy Docker Compose stack, a missing + database container (`GenTypesLocalDbNotRunningError`) or a failed `container inspect` + (`GenTypesLocalDbInspectError`) then carries the suggestion + `To use the linked project instead, pass --remote.`, which the JSON error envelope reports as + `suggestion`. An unlinked checkout keeps the error without one. - **`--project-id`.** A hidden alias of the deprecated `--project-ref`; passing both fails with `--project-id is a deprecated alias of --project-ref; pass only one of them.` - **Remote project flag groups.** `--remote` (bare, or with a ref or branch), `--linked`, 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 e2a1e252c0..c814861d9a 100644 --- a/docs/adr/0033-target-selection-and-hosted-mutation-consent.md +++ b/docs/adr/0033-target-selection-and-hosted-mutation-consent.md @@ -70,29 +70,34 @@ line and nowhere else. | `--remote=<branch>` | A branch of the linked project, by name or UUID | Every command resolves a `--remote=<branch|uuid>` value with one resolver, the one `supabase link` -uses. A value that matches no branch fails with link's error, which lists the branches. A branch -UUID also resolves in a checkout with no linked project, where it is reported as the bare project -ref, `unverified` in JSON, because the lookup does not say whether it is the default branch. A -keyword connection string (`host=… user=…`) is rejected, not looked up as a branch. A URL applies -only where the remote side is Postgres: `storage`, `seed buckets` and `hosted` commands need a -project and reject one. `hosted` commands accept `--remote=<ref|branch>`, and bare `--remote` -changes nothing for them. `SUPABASE_PROJECT_ID` alone never makes a `selectable` command remote; it -is only a ref source once `--remote` is passed, and it alone is enough for the -`default; pass --remote` hint. An unresolvable remote target fails. The CLI never falls back between -local and remote. +uses. A value that matches no branch fails with link's error, which lists the branches; a 20-letter +value with an uppercase letter is still looked up as a branch, and the error hints that refs are +lowercase. A branch UUID also resolves in a checkout with no linked project, where it is reported as +the bare project ref, `unverified` in JSON, because the lookup does not say whether it is the +default branch, and one the API can't look up fails as branch not found. A keyword connection string +(`host=… user=…`) is rejected, not looked up as a branch. A URL applies only where the remote side +is Postgres: `storage`, `seed buckets` and `hosted` commands need a project, so the `--remote` +parser rejects a URL there as an invalid value, before any later check or request, without echoing +it. `hosted` commands accept `--remote=<ref|branch>`, and bare `--remote` changes nothing for them. +`SUPABASE_PROJECT_ID` alone never makes a `selectable` command remote; it is only a ref source once +`--remote` is passed, and it alone is enough for the `default; pass --remote` hint. An unresolvable +remote target fails. The CLI never falls back between local and remote. `--remote=<value>` always parses. `--remote <value>` is rewritten to `--remote=<value>` before parsing when the command takes no positional (a hidden one, such as `gen types`'s legacy language, -does not count), when the token is a ref or URL, or when the command already has more bare tokens +does not count), when the token is a database URL, or when the command already has more bare tokens than positionals. The one ambiguous shape, a bare `--remote` directly followed by a token the -command took as a positional, is checked against the linked project's branches before anything runs. -A token that exactly matches a branch, is within a small edit distance of one, or can't be checked -because the branch listing failed stops the run with an error that names the cause (an HTTP status, -or a missing or invalid access token) and offers both readings without echoing the word: -`--remote=<branch>` for the branch, the command's operands then `--remote` for the linked project. -Any other token runs as the command's operand. Docs, help and examples put `--remote` last, in the -`=` form when it has a value, so it cannot be read as anything else, and shell completion never -offers a space-separated value for `--remote` on a command that has a positional. +command took as a positional, is settled before anything runs. A project-ref-shaped token fails +with no request, because a 20-letter operand such as a migration or function name must not silently +retarget the command at another project: the error offers `--remote=<ref>` for that project and +`--remote` last to keep the operand. Any other token is checked against the linked project's +branches. It fails when it names a branch, equals one ignoring case, or is one edit (an adjacent swap +counts as one) from a non-default branch name of five or more characters, so an ordinary word is not +mistaken for a typo; otherwise it runs as the operand. When the branches can't be listed the run +still fails closed, and the error names the cause (an HTTP status or the network error, with the fix +for a 401, 403 or 404). Docs, help and examples put `--remote` last, in the `=` form when it has a +value, so it cannot be read as anything else, and shell completion never offers a space-separated +value for `--remote` on a command that has a positional. ### Deprecated aliases @@ -100,9 +105,9 @@ offers a space-separated value for `--remote` on a command that has a positional aliases. Each goes through the same resolver as the `--remote` form it maps to, prints a warning that names that form, and says v4 removes it. `--local` is still accepted and does nothing, because local is already the default; it warns the same way. v4 removes all of them, and `db diff`'s -`linked` value for `--from` and `--to`, which `remote` replaces. A warning shows the command path, -the command's operand placeholders and the `--remote` replacement, last. It never repeats the user's -operands or other flags, because they can be secrets that a CI log would then keep. +`linked` value for `--from` and `--to`, which `remote` replaces. A warning names only the +replacement and carries no example command. It never repeats an operand, a flag value or a URL, +because they can be secrets that a CI log would then keep. ### Selector rules @@ -112,6 +117,8 @@ onto `--remote`: - `--remote` and the aliases name one target and are never resolved by precedence. `--remote=<value>` together with an alias is an error, and so are two of `--remote`, `--linked`, `--db-url` and `--local`, or `--project-ref` with `--local` or `--db-url`. +- A repeated `--remote` is one selection when every occurrence resolves to the same target, with a + bare `--remote` counting as the linked project, and an error before anything runs when they differ. - A bare `--remote` together with `--project-ref=<x>` is accepted, as `--linked --project-ref=<x>` is: the alias's value wins and its warning prints. - `db diff` with `--remote` or an alias together with `--from` or `--to` is an error. In `--from` @@ -164,8 +171,9 @@ hosted. 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. + same value is treated as the file's; a shell export of that value then needs `--yes` as well. The + last `--yes` on the command line wins and beats the environment: `--yes=false --yes` confirms and + `--yes --yes=false` declines. - **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`, agents (`--agent`, or an AI tool detected from From 149fc94729ffd4dd75316e9f89f17394ada95c64 Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Tue, 20 Oct 2026 12:36:02 +0100 Subject: [PATCH 198/291] fix(cli): trust a cached parent project only while it lists the linked project as a branch A stale linked-project.json could send --remote=<branch>, link <branch> and the bare --remote <word> ambiguity check to the wrong parent project. A cached parent that differs from the project-ref file now stands only while its branch list contains that file ref as a project_ref; otherwise the file project's own branches are searched. When that listing fails, for example for a deleted linked branch, the cached parent is kept. --- .../command-internal/parent-project-ref.ts | 37 +++++---- .../target/branch-resolution.ts | 36 ++++++-- .../target/remote-resolution.ts | 16 ++-- .../target/resolve-target.integration.test.ts | 82 ++++++++++++++++++- 4 files changed, 139 insertions(+), 32 deletions(-) diff --git a/apps/cli/src/command-internal/parent-project-ref.ts b/apps/cli/src/command-internal/parent-project-ref.ts index 7f1b83167a..5d54de3e4c 100644 --- a/apps/cli/src/command-internal/parent-project-ref.ts +++ b/apps/cli/src/command-internal/parent-project-ref.ts @@ -7,7 +7,12 @@ 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 } + | { + readonly kind: "resolved"; + readonly ref: string; + /** The project the `project-ref` file links when a cached parent differs from it; the cache stands only while that parent lists it as a branch. */ + readonly linkedRef?: string; + } | { readonly kind: "invalid" } | { readonly kind: "absent" }; @@ -92,12 +97,10 @@ function classifyParentCandidates( } /** - * Resolves the currently-linked PARENT project ref, not the branch's own ref that - * `ProjectRefResolver.resolveOptional`/`resolve` would return right after `link <branch>`. - * The first present candidate wins, in order: `SUPABASE_PROJECT_ID` env, the - * `linked-project.json` cache, then the temp `project-ref` file. A malformed candidate - * hard-classifies as `"invalid"` rather than falling through to a lower-priority one; no - * candidate at all means the workdir was never linked (`"absent"`). + * Resolves the linked PARENT project ref, not the branch's own ref that `resolve`/`resolveOptional` + * return right after `link <branch>`. The first present candidate wins: `SUPABASE_PROJECT_ID`, the + * `linked-project.json` cache, then the temp `project-ref` file. A malformed candidate is + * `"invalid"` and never falls through; no candidate at all is `"absent"`. */ export const resolveLinkedParentRef = Effect.fnUntraced(function* () { const cliSettings = yield* CommandSettings; @@ -117,10 +120,8 @@ export const resolveLinkedParentRef = Effect.fnUntraced(function* () { ) : Option.none<string>(); - // An env candidate that merely restates the file candidate's own (valid) value carries no - // parent information; drop it so the cache gets a chance to win instead. Restricted to - // pattern-valid values so a garbage env equal to a garbage file still hard-classifies as - // invalid below instead of sneaking past to the cache. + // An env candidate restating the file's own valid value carries no parent information; drop it + // so the cache can win. Pattern-valid only, so a garbage env equal to a garbage file stays invalid. const envRef = Option.isSome(cliSettings.projectId) && Option.isSome(fileRef) && @@ -129,7 +130,14 @@ export const resolveLinkedParentRef = Effect.fnUntraced(function* () { ? Option.none<string>() : cliSettings.projectId; - return classifyParentCandidates([envRef, cachedRef, fileRef]); + const parent = classifyParentCandidates([envRef, cachedRef, fileRef]); + if (parent.kind !== "resolved" || Option.isSome(envRef) || Option.isNone(cachedRef)) + return parent; + const linkedRef = Option.filter( + fileRef, + (ref) => ref !== parent.ref && PROJECT_REF_PATTERN.test(ref), + ); + return Option.isSome(linkedRef) ? { ...parent, linkedRef: linkedRef.value } : parent; }); /** @@ -168,9 +176,8 @@ export const resolveParentScopedProjectRef = Effect.fn("ParentProjectRef.resolve }); /** - * A value made entirely of lowercase letters (but not 20 of them, or it would already have - * been treated as a ref), or 20 letters with an uppercase one, is a plausible ref typo. Appended - * to both {@link parentNotLinkedMessage} and `link`'s own branch-not-found message. + * A plausible ref typo: all lowercase letters but not 20 of them, or 20 letters with an uppercase + * one. Appended to {@link parentNotLinkedMessage} and `link`'s own branch-not-found message. */ export function parentRefTypoHint(value: string): string { if (/^[a-z]+$/.test(value)) { diff --git a/apps/cli/src/command-internal/target/branch-resolution.ts b/apps/cli/src/command-internal/target/branch-resolution.ts index aad23f318e..97dbbf8fda 100644 --- a/apps/cli/src/command-internal/target/branch-resolution.ts +++ b/apps/cli/src/command-internal/target/branch-resolution.ts @@ -151,8 +151,8 @@ export const listBranchesFailureCause = (error: LinkBranchListNetworkError): str export const previewBranchHint = (ref: string): string => `If ${ref} is itself a preview branch, link its parent project first: supabase link --project-ref <parent-ref>`; -/** `GET /v1/projects/{ref}/branches`, shared by every branch-name lookup. */ -export const listParentBranches = Effect.fnUntraced(function* (parentRef: string, label: string) { +/** `GET /v1/projects/{ref}/branches`, shared by every branch-name lookup; no `label` means no spinner. */ +const listParentBranches = Effect.fnUntraced(function* (parentRef: string, label?: string) { const api = yield* acquireApi(); const mapBranchListError = mapHttpError({ @@ -167,7 +167,7 @@ export const listParentBranches = Effect.fnUntraced(function* (parentRef: string }, }); - const task = yield* startBranchLookupTask(label); + const task = label === undefined ? undefined : yield* startBranchLookupTask(label); const branches: Branches = yield* api.v1.listAllBranches({ ref: parentRef }).pipe( Effect.tapError(() => task?.fail() ?? Effect.void), Effect.catch(mapBranchListError), @@ -176,8 +176,30 @@ export const listParentBranches = Effect.fnUntraced(function* (parentRef: string return branches; }); -const resolveInParent = Effect.fnUntraced(function* (value: string, parentRef: string) { - const branches = yield* listParentBranches(parentRef, "Resolving branch..."); +type ResolvedParent = Extract<ParentRefResolution, { readonly kind: "resolved" }>; + +/** + * The branches of the parent a lookup runs under. A cached parent stands only while it lists the + * linked project as a branch; otherwise the linked project's own branches are used, unless they + * cannot be listed, as for a deleted branch's project, which keeps the cached parent. + */ +export const listLinkedParentBranches = Effect.fnUntraced(function* ( + parent: ResolvedParent, + label: string, +) { + const cached = yield* listParentBranches(parent.ref, label); + const { linkedRef } = parent; + if (linkedRef === undefined || cached.some((branch) => branch.project_ref === linkedRef)) { + return { parentRef: parent.ref, branches: cached }; + } + const own = yield* listParentBranches(linkedRef).pipe(Effect.option); + return Option.isSome(own) + ? { parentRef: linkedRef, branches: own.value } + : { parentRef: parent.ref, branches: cached }; +}); + +const resolveInParent = Effect.fnUntraced(function* (value: string, parent: ResolvedParent) { + const { parentRef, branches } = yield* listLinkedParentBranches(parent, "Resolving branch..."); yield* Effect.annotateCurrentSpan({ "project.parent_ref": parentRef, "branch.count": branches.length, @@ -217,7 +239,7 @@ const resolveInParent = Effect.fnUntraced(function* (value: string, parentRef: s export const resolveLinkBranchRef = Effect.fn("Target.resolveBranch")(function* (value: string) { const parent = yield* resolveLinkedParentRef(); if (parent.kind !== "resolved") return yield* unresolvedParent(parent, value); - return yield* resolveInParent(value, parent.ref); + return yield* resolveInParent(value, parent); }); const resolveBranchById = Effect.fnUntraced(function* (id: string) { @@ -269,7 +291,7 @@ export const resolveRemoteBranchRef = Effect.fn("Target.resolveRemoteBranch")(fu value: string, ) { const parent = yield* resolveLinkedParentRef(); - if (parent.kind === "resolved") return yield* resolveInParent(value, parent.ref); + if (parent.kind === "resolved") return yield* resolveInParent(value, parent); if (BRANCH_UUID_PATTERN.test(value)) return yield* resolveBranchById(value); return yield* unresolvedParent(parent, value); }); diff --git a/apps/cli/src/command-internal/target/remote-resolution.ts b/apps/cli/src/command-internal/target/remote-resolution.ts index b1e0b206af..999e4096d4 100644 --- a/apps/cli/src/command-internal/target/remote-resolution.ts +++ b/apps/cli/src/command-internal/target/remote-resolution.ts @@ -19,7 +19,7 @@ import { findBranch, isDefaultBranch, listBranchesFailureCause, - listParentBranches, + listLinkedParentBranches, previewBranchHint, resolveRemoteBranchRef, } from "./branch-resolution.ts"; @@ -168,7 +168,9 @@ const checkRemoteOperand = Effect.fn("Target.checkRemoteOperand")(function* (raw const usage = yield* commandUsage(); const options = `For the branch: ${usage} ${remoteFlagText({ _tag: "Branch", name: operand })}. For the linked project: ${usage} ${shellQuote(operand)} --remote`; - const listed = yield* listParentBranches(parent.ref, "Checking branches...").pipe(Effect.result); + const listed = yield* listLinkedParentBranches(parent, "Checking branches...").pipe( + Effect.result, + ); if (Result.isFailure(listed)) { const { accessToken } = yield* CommandSettings; const failure = listingFailure( @@ -184,17 +186,15 @@ const checkRemoteOperand = Effect.fn("Target.checkRemoteOperand")(function* (raw cause: listed.failure, }); } - const branches = listed.success; + const { parentRef, branches } = listed.success; if (findBranch(branches, operand) !== undefined) { - const name = yield* linkedProjectName(parent.ref); - const project = name === undefined ? `project ${parent.ref}` : formatNamedRef(name, parent.ref); + const name = yield* linkedProjectName(parentRef); + const project = name === undefined ? `project ${parentRef}` : formatNamedRef(name, parentRef); return yield* new TargetRemoteAmbiguousError({ message: `"${operand}" is a branch of ${project}. ${options}`, }); } - const closest = nearMissByName(branches, operand, (branch) => - isDefaultBranch(branch, parent.ref), - ); + const closest = nearMissByName(branches, operand, (branch) => isDefaultBranch(branch, parentRef)); if (closest !== undefined) { const closestName = sanitizeInlineName(closest.name); return yield* new TargetRemoteAmbiguousError({ 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 56186db67c..0b02d766f2 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 @@ -50,6 +50,8 @@ const remote = (value: RemoteValue): Partial<TargetFlagValues> => ({ const FILE_REF = "filefilefilefilefile"; const FLAG_REF = "flagflagflagflagflag"; const BRANCH_REF = "branchprojectrefabcd"; +const STALE_REF = "stalestalestalestale"; +const GONE_REF = "gonegonegonegonegone"; const SECRET_URL = "postgresql://postgres:hunter2@db.abcdefghijklmnopqrst.supabase.co:5432/postgres"; @@ -73,6 +75,8 @@ const failingListings = { missing: { status: 404, message: "Not found" }, } as const; +type Listing = ReadonlyArray<ReturnType<typeof branchRow>> | keyof typeof failingListings; + const root = rootCommandForFeatures(); const projectRefResolver = (options: { readonly linked: boolean }) => { @@ -112,7 +116,8 @@ const run = ( readonly tokenFailure?: AccessTokenRequiredError | InvalidAccessTokenError; readonly storedToken?: boolean; readonly unreachable?: string; - readonly branches?: ReadonlyArray<ReturnType<typeof branchRow>> | keyof typeof failingListings; + readonly branches?: Listing; + readonly listings?: Readonly<Record<string, Listing>>; } = {}, ) => Effect.gen(function* () { @@ -139,7 +144,8 @@ const run = ( if (options.unreachable !== undefined) { return Effect.fail(transportFailure(request, options.unreachable)); } - const { branches = [] } = options; + const parentRef = new URL(request.url).pathname.split("/")[3] ?? ""; + const branches = options.listings?.[parentRef] ?? options.branches ?? []; if (typeof branches === "string") { const { status, message } = failingListings[branches]; return Effect.succeed(jsonResponse(request, status, { message })); @@ -312,6 +318,59 @@ describe("resolveTarget", () => { }), ); + it.effect("looks a branch up under the cached parent that lists the linked project", () => + Effect.gen(function* () { + const result = yield* run( + "selectable", + { ...none, ...remote({ _tag: "Branch", name: "feature-x" }) }, + { + savedRef: BRANCH_REF, + cache: { ref: FILE_REF }, + listings: { [FILE_REF]: [branchRow("feature-x"), branchRow("other", FLAG_REF)] }, + }, + ); + expect(succeeded(result.exit)).toMatchObject({ ref: BRANCH_REF, parentRef: FILE_REF }); + expect(result.apiRequests).toEqual([`GET /v1/projects/${FILE_REF}/branches`]); + }), + ); + + it.effect("ignores a cached parent that does not list the linked project", () => + Effect.gen(function* () { + const result = yield* run( + "selectable", + { ...none, ...remote({ _tag: "Branch", name: "feature-x" }) }, + { + savedRef: FILE_REF, + cache: { ref: STALE_REF }, + listings: { + [STALE_REF]: [branchRow("feature-x", FLAG_REF)], + [FILE_REF]: [branchRow("feature-x")], + }, + }, + ); + expect(succeeded(result.exit)).toMatchObject({ + ref: BRANCH_REF, + branch: "feature-x", + parentRef: FILE_REF, + }); + }), + ); + + it.effect("keeps the cached parent when the linked project cannot list branches", () => + Effect.gen(function* () { + const result = yield* run( + "selectable", + { ...none, ...remote({ _tag: "Branch", name: "feature-x" }) }, + { + savedRef: GONE_REF, + cache: { ref: FILE_REF }, + listings: { [FILE_REF]: [branchRow("feature-x")], [GONE_REF]: "missing" }, + }, + ); + expect(succeeded(result.exit)).toMatchObject({ ref: BRANCH_REF, parentRef: FILE_REF }); + }), + ); + it.effect("announces the default branch as the production project", () => Effect.gen(function* () { const result = yield* run( @@ -485,6 +544,25 @@ describe("resolveTarget", () => { }), ); + it.effect("checks the branches of the linked project when the cached parent is stale", () => + Effect.gen(function* () { + const result = yield* run("selectable", bare, { + args, + command, + savedRef: FILE_REF, + cache: { ref: STALE_REF }, + listings: { + [STALE_REF]: [branchRow("other", FLAG_REF)], + [FILE_REF]: [branchRow("feature-x")], + }, + }); + expect(failureOf(result.exit)).toMatchObject({ + _tag: "TargetRemoteAmbiguousError", + message: `"feature-x" is a branch of project ${FILE_REF}. ${options}`, + }); + }), + ); + it.effect("resolves the linked project when the word names no branch", () => Effect.gen(function* () { const result = yield* run("selectable", bare, { From fa9ca37a9f00379c476fe21e054cd2211c1619c2 Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Tue, 20 Oct 2026 12:39:01 +0100 Subject: [PATCH 199/291] docs(cli): document the shipped consent, ban-recovery and read-only behaviour The last --yes or --yes=<bool> decides, SUPABASE_YES is read only when --yes never appears, and a bare --yes followed by false, no, off, 0 or n reads as false. The network-ban recovery suggestion names the failing project with --remote=<ref>, so the reference pages that quote it say so. db query --read-only refuses only a BEGIN ATOMIC body the statement splitter reads, not the same words inside a string, quoted identifier, dollar quote or comment. gen types prints its linked-checkout --remote suggestion in place of the --debug hint, and the JSON error envelope carries it as the suggestion. --- apps/cli/docs/command-target.md | 4 ++-- apps/cli/docs/supabase/db/query.md | 2 +- apps/cli/docs/supabase/pull.md | 2 +- apps/cli/src/commands/db/query/SIDE_EFFECTS.md | 5 +++-- apps/cli/src/commands/gen/types/SIDE_EFFECTS.md | 11 +++++++---- .../src/commands/network-bans/remove/SIDE_EFFECTS.md | 2 +- ...33-target-selection-and-hosted-mutation-consent.md | 10 ++++++---- 7 files changed, 21 insertions(+), 15 deletions(-) diff --git a/apps/cli/docs/command-target.md b/apps/cli/docs/command-target.md index 648614636c..283f36e7e8 100644 --- a/apps/cli/docs/command-target.md +++ b/apps/cli/docs/command-target.md @@ -95,12 +95,12 @@ A warning never repeats an operand, a flag value or a database URL, because a CI Every persistent mutation of a hosted resource asks for consent first, including a write through a non-local `--remote=<url>`. A loopback host on the local or shadow database ports, or the local stack's endpoint, counts as local. -- **Approval.** `--yes`, or a `SUPABASE_YES` in the shell environment. A `SUPABASE_YES` in a project `.env*` file does not count. The last `--yes` on the command line wins and beats the environment, so `--yes=false --yes` confirms and `--yes --yes=false` declines. Stdin is never read, so a piped `y` does nothing. The confirmation line names which one approved: `Confirmed by --yes: <action> on <target>`. +- **Approval.** `--yes`, or a `SUPABASE_YES` in the shell environment. A `SUPABASE_YES` in a project `.env*` file does not count. The last `--yes` on the command line wins, and `SUPABASE_YES` is read only when `--yes` never appears, so `--yes=false --yes` confirms and `--yes --yes=false` declines. A bare `--yes` followed by `false`, `no`, `off`, `0` or `n` reads as false, as the flag parser does. Stdin is never read, so a piped `y` does nothing. The confirmation line names which one approved: `Confirmed by --yes: <action> on <target>`. - **Prompt.** Only text output on an interactive stdout with a TTY stdin, with no agent driving, is asked. The question starts on the command's consent default. - **Refusal.** JSON, stream-json, `-o json|yaml|toml|env`, agent 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`. Nothing is changed. - **Decline.** A declined prompt fails with `HostedMutationDeclinedError` (`context canceled`), exit 1, and nothing is changed. - **One question.** A command asks once, after the plan is known and before the first persistent effect, and lists everything it will do. -- **Network bans.** Removing a network ban to connect needs `--yes` or a shell `SUPABASE_YES`; without one, the command fails with `LinkedDbNetworkBannedError` and points at `supabase network-bans remove`. This applies to reads too. +- **Network bans.** Removing a network ban to connect needs `--yes` or a shell `SUPABASE_YES`; without one, the command fails with `LinkedDbNetworkBannedError`. Its suggestion is `Re-run with --yes to remove them, or run supabase network-bans remove --remote=<ref> --db-unban-ip <addrs> --experimental --yes.`, and `<ref>` is always the project whose bans blocked the connection, which after `db pull --remote=<other-ref>` is not the linked project. This applies to reads too. - **Reads never prompt.** A command marked `no consent` never asks for hosted-mutation consent, on any target, and `--yes` and `SUPABASE_YES` have no effect on it. `db query --read-only` is a read and skips consent too. ## Banner diff --git a/apps/cli/docs/supabase/db/query.md b/apps/cli/docs/supabase/db/query.md index 0005e69b66..1aff246cca 100644 --- a/apps/cli/docs/supabase/db/query.md +++ b/apps/cli/docs/supabase/db/query.md @@ -8,7 +8,7 @@ Runs against the local database by default. Pass `--remote` for the linked proje The command cannot tell a read from a write, so a hosted database (the linked project or a remote database 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`, `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. +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 that contains a `BEGIN ATOMIC` function body is rejected. The words `begin atomic` inside a string, quoted identifier, dollar quote or comment, as in `select 'begin atomic'`, are not. Drop `--read-only` to run one of these. The local database or a database URL 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/docs/supabase/pull.md b/apps/cli/docs/supabase/pull.md index c49a4814d3..0d62f5ef5d 100644 --- a/apps/cli/docs/supabase/pull.md +++ b/apps/cli/docs/supabase/pull.md @@ -8,7 +8,7 @@ On a fresh checkout, the migration history step runs automatically even without `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. -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`. +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 suggests re-running with `--yes`, or running `supabase network-bans remove --remote=<ref>` for the project whose ban blocked the connection. `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. diff --git a/apps/cli/src/commands/db/query/SIDE_EFFECTS.md b/apps/cli/src/commands/db/query/SIDE_EFFECTS.md index 453afc4bc4..1f0a90440c 100644 --- a/apps/cli/src/commands/db/query/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/db/query/SIDE_EFFECTS.md @@ -107,8 +107,9 @@ from the environment. Agent mode defaults the format to JSON (table for humans). `BEGIN ATOMIC` function body (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, `BEGIN ATOMIC` inside a string literal, quoted - identifier, dollar quote or comment (`select 'begin atomic'`), and reads such as - `SELECT set_config(...)`, pass the checks. A remote project or branch sends `"read_only": true` in the Management API request, and the + identifier, dollar quote or comment (`select 'begin atomic' as s`, `select 1 /* begin atomic */`, + `select 1 as "begin atomic"`), and reads such as `SELECT set_config(...)`, pass the checks. A + remote project or branch sends `"read_only": true` in the Management API request, and the API enforces it. The local stack and a `--remote=<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`). diff --git a/apps/cli/src/commands/gen/types/SIDE_EFFECTS.md b/apps/cli/src/commands/gen/types/SIDE_EFFECTS.md index 60fc68a5c3..fe73213ac4 100644 --- a/apps/cli/src/commands/gen/types/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/gen/types/SIDE_EFFECTS.md @@ -155,10 +155,13 @@ Not applicable. (`gen types typescript --remote`). - **Stopped local stack in a linked checkout.** A checkout is linked when `SUPABASE_PROJECT_ID` is set or `supabase/.temp/project-ref` names a project. On the legacy Docker Compose stack, a missing - database container (`GenTypesLocalDbNotRunningError`) or a failed `container inspect` - (`GenTypesLocalDbInspectError`) then carries the suggestion - `To use the linked project instead, pass --remote.`, which the JSON error envelope reports as - `suggestion`. An unlinked checkout keeps the error without one. + database container (`GenTypesLocalDbNotRunningError`, `supabase start is not running.`) or a + failed `container inspect` (`GenTypesLocalDbInspectError`) then carries the suggestion + `To use the linked project instead, pass --remote.`. Text mode prints it in place of + `Try rerunning the command with --debug to troubleshoot the error.`, and the JSON error envelope + reports it as `suggestion`: + `{"code":"GenTypesLocalDbNotRunningError","message":"supabase start is not running.","suggestion":"To use the linked project instead, pass --remote."}`. + An unlinked checkout keeps the error without one. - **`--project-id`.** A hidden alias of the deprecated `--project-ref`; passing both fails with `--project-id is a deprecated alias of --project-ref; pass only one of them.` - **Remote project flag groups.** `--remote` (bare, or with a ref or branch), `--linked`, 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 a32584e9a3..3d40748a9f 100644 --- a/apps/cli/src/commands/network-bans/remove/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/network-bans/remove/SIDE_EFFECTS.md @@ -94,4 +94,4 @@ One `result` event on success when the `--output` flag is unset. 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. - 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). -- 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. +- The `LinkedDbNetworkBannedError` raised by commands that connect to a linked database suggests this command as `supabase network-bans remove --remote=<ref> --db-unban-ip <addrs> --experimental --yes`, with `<ref>` the project whose bans blocked the connection; it needs `--experimental` and the confirmation above like any other run. 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 c814861d9a..3c04a78bdb 100644 --- a/docs/adr/0033-target-selection-and-hosted-mutation-consent.md +++ b/docs/adr/0033-target-selection-and-hosted-mutation-consent.md @@ -172,8 +172,9 @@ hosted. 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. The - last `--yes` on the command line wins and beats the environment: `--yes=false --yes` confirms and - `--yes --yes=false` declines. + last `--yes` on the command line wins, and `SUPABASE_YES` is read only when `--yes` never appears: + `--yes=false --yes` confirms and `--yes --yes=false` declines. A bare `--yes` followed by `false`, + `no`, `off`, `0` or `n` reads as false, as the flag parser does. - **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`, agents (`--agent`, or an AI tool detected from @@ -192,8 +193,9 @@ hosted. 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 the `HostedMutationConsent` token only the consent module constructs, and without `--yes` the command - fails with `LinkedDbNetworkBannedError` pointing at `supabase network-bans remove`. This applies - to reads too. + fails with `LinkedDbNetworkBannedError`. Its suggestion is to re-run with `--yes` or to run + `supabase network-bans remove --remote=<ref>` with the ref of the project that failed, which need + not be the linked one. 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 database URL target. It skips consent. From 862f989938f704c9349087cadda30db161c45882 Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Tue, 20 Oct 2026 12:39:40 +0100 Subject: [PATCH 200/291] docs(cli): document the default-branch, explicit diff and reset question behaviour config push reports a project's default branch as production (is_branch false, no parent project, not probed with GET /v1/projects/{ref}) and leaves a branch UUID looked up with no linked project unverified, with is_branch omitted. db diff with explicit --from/--to reads its config when the first endpoint that needs it resolves, so a hosted endpoint resolved first merges its [remotes.<ref>] block before validation and before its temporary login role is minted. It announces every database endpoint in resolution order: a URL as custom Postgres, local as the local stack, a hosted endpoint as its project line, and migrations announces nothing. The JSON target is the first one announced. The remote db reset question lists the apply step the run performs: the declarative schema files, the local migrations (up to --version when given) only when [db.migrations].enabled is true, or no apply step. --- apps/cli/docs/command-target.md | 2 +- .../src/commands/config/push/SIDE_EFFECTS.md | 31 ++++++++++------- apps/cli/src/commands/db/diff/SIDE_EFFECTS.md | 34 ++++++++++++++----- .../cli/src/commands/db/reset/SIDE_EFFECTS.md | 10 +++--- ...t-selection-and-hosted-mutation-consent.md | 14 ++++---- 5 files changed, 60 insertions(+), 31 deletions(-) diff --git a/apps/cli/docs/command-target.md b/apps/cli/docs/command-target.md index 283f36e7e8..07482d5fcc 100644 --- a/apps/cli/docs/command-target.md +++ b/apps/cli/docs/command-target.md @@ -135,7 +135,7 @@ Identity comes from `supabase/.temp/linked-project.json`, with no API call. A ca `environment` is `production` when the linked-project cache names the resolved ref or the resolved branch is the project's default branch, `branch` on positive evidence of any other branch, and `unverified` otherwise, including a branch UUID looked up with no linked project. A failed identity check is `unverified`, never `branch`. -The first announced target becomes the run's `target`, so a command that announces two (`db diff --from=remote --to=<url>`) prints both and reports the first. It is merged into object results, the `stream-json` result event and the machine error envelope. Arrays, `--output {json,yaml,toml,env}` payloads and generated artifacts such as `gen types` output are 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 first announced target becomes the run's `target`, so a command that announces two (`db diff --from=remote --to=<url>`) prints both and reports the first. `db diff --from`/`--to` announces every database endpoint in resolution order, the local stack and a database URL included, so `--from local` reports `{"kind":"local","host":"127.0.0.1","port":54322}`; `migrations` announces nothing, and the same target on both sides prints one line. It is merged into object results, the `stream-json` result event and the machine error envelope. Arrays, `--output {json,yaml,toml,env}` payloads and generated artifacts such as `gen types` output are unchanged. A command that owns extra `target` fields (for example `branch`, `local_scope`, or `config push`'s `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/commands/config/push/SIDE_EFFECTS.md b/apps/cli/src/commands/config/push/SIDE_EFFECTS.md index 49750fd210..7248efbd57 100644 --- a/apps/cli/src/commands/config/push/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/config/push/SIDE_EFFECTS.md @@ -62,7 +62,8 @@ question is declined (a decline writes nothing at all). is every resource's entire source of remote state. Rows -2/-1 are a DIFFERENT concern (resolving and describing WHAT `ref` is, before any resource is even considered) and run at most once per invocation, never -per-resource. `UpdateSigningKeys` is **not** +per-resource. A `--remote` naming a project's default branch resolves to that project's own ref +and skips row -1: it is production, not probed. `UpdateSigningKeys` is **not** called by `config push`. No spinner on the read; 401/403/404 get purpose-written messages, every other status the generic `unexpected status N: body` shape. The read's response is echoed via a @@ -159,9 +160,10 @@ 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 <parent ref> · branch <name> -(<ref>)` for a branch named with `--remote`, 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, +(`Target: project <name> (<ref>) · production` for a project or its default branch, +`Target: project <parent ref> · branch <name> (<ref>)` for any other branch named with `--remote`, +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 target the banner announced. @@ -246,19 +248,24 @@ something to write needs `--yes` or a shell `SUPABASE_YES`; without them the com {...}}` 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`](../../../../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. +summary. Its `environment` and `parent_project_ref` come from the resolved target: `production` +for a project or for its default branch (named, or a UUID looked up under the linked project or +`SUPABASE_PROJECT_ID`), which has no `parent_project_ref`, and `branch` for any other branch. When +the target can't be told, the announced identity's `unverified` stands: a branch UUID looked up +with no linked project and no `SUPABASE_PROJECT_ID` can't be told from a default branch, and a +live probe that could not answer is neither a project nor a branch. `env_sourced` is additive and present only when a `SUPABASE_*` variable supplied a pushed value: `[{path: [...], origin: {source: "shell" | "project_env", env_variable, file?}}]`. `json` mode — one flat object (`message` is a one-sentence summary, with a caveat sentence appended for anything withheld — see below); `is_branch`/`branch`/ -`parent_project_ref` are additive (CLI-2168/CLI-2289) — `branch`/`parent_project_ref` are -present only when resolved, and `is_branch` itself is OMITTED entirely (not `false`) for -the "could not determine" target — an absent key is the honest "unknown" signal, since -asserting `false` would be as misleading as asserting `true`: +`parent_project_ref` are additive (CLI-2168/CLI-2289) — `is_branch` is `false` for a project and +for a project's default branch (which has no `parent_project_ref`), `true` for any other branch, +whose `branch`/`parent_project_ref` are present only when resolved, and OMITTED entirely (not +`false`) for the "could not determine" target (a live probe that could not answer, or a branch +UUID looked up with no linked project and no `SUPABASE_PROJECT_ID`) — an absent key is the honest +"unknown" signal, since asserting `false` would be as misleading as asserting `true`: ```jsonc { @@ -346,7 +353,7 @@ may itself contain a `.`. ## Notes -- **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 `--remote` 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 `--remote` are treated alike, and the question names the branch and its parent project. +- **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 `--remote` already named a branch by name/UUID, this is known for free (never re-derived from a live probe): the project's default branch is the production project itself (`is_branch: false`, no parent), any other branch is certainly a branch, and a branch UUID looked up with no linked project and no `SUPABASE_PROJECT_ID` can't be told from a default branch, so it stays undeterminable (`unverified`, `is_branch` omitted). 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 `--remote` 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 `--remote=<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/db/diff/SIDE_EFFECTS.md b/apps/cli/src/commands/db/diff/SIDE_EFFECTS.md index 851e2b9804..4c34566254 100644 --- a/apps/cli/src/commands/db/diff/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/db/diff/SIDE_EFFECTS.md @@ -159,6 +159,12 @@ once per distinct target — then `Creating shadow database...`, `Diffing schema that it no longer changes the migrations baseline. The SQL diff prints to stdout when neither `--file` nor explicit `--output` is set. +Explicit `--from`/`--to` announces every database endpoint in resolution order, `--from` first: a +URL endpoint as `Target: custom Postgres (<host>:<port>)`, never with its credentials; `local` as +`Target: local stack (<host>:<port>)`, with an IPv6 host in brackets; and a hosted endpoint as its +project or branch line. `migrations` announces nothing, and the same target on both sides prints +one line. + Explicit `--from`/`--to` mode returns before normal `--file` handling, so `-f` is ignored. It joins the pg-delta plan files with blank lines and writes that same flattened review representation to stdout or `--output`. File header comments @@ -186,7 +192,11 @@ enabled in config or the directory is `supabase/schemas`; never on the stack bac In explicit `--from`/`--to` mode, the `diff` field is the same flattened review representation as text stdout; the machine envelope does not restore the per-unit -transaction metadata. +transaction metadata. The envelope's `target` is the first endpoint announced: +`{"kind":"local","host":"127.0.0.1","port":54322}` for `local`, +`{"kind":"db-url","host":"db.example.com","port":6543}` for a URL, or the linked identity for a +hosted endpoint. `--from migrations` announces nothing, so `--to`'s endpoint supplies it when it +announces one. ### `--use-pgadmin` (CLI-1968) @@ -215,11 +225,11 @@ transaction metadata. ## Notes / Delegation - **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 remote project or branch 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. + (`config.json` over `config.toml`) > default. The whole config is decoded where it is loaded, so + an invalid value fails the command; explicit `--from`/`--to` loads it lazily (see below). A remote + project or branch 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. - **pg-delta selection**: `--use-pg-delta` sets `[experimental.pgdelta].enabled` at the flag tier, so `--use-pg-delta=false` selects migra over env and config. The default is pg-delta when the stack backend is active or `[experimental.pgdelta].enabled` resolves true. An explicit @@ -243,13 +253,21 @@ be removed in v4. Use --from remote instead.` A `--remote` selector (or an alias `--project-ref`, `--db-url`, `--local`) given alongside fails with `--remote doesn't apply with --from/--to; use --from=remote or --to=remote.` (the flag named is the one passed; `--db-url` suggests `<db-url>` and `--local` suggests `local`), before any target resolves, and the default - `Target:` hint is not printed. The first hosted endpoint merges its `[remotes.<ref>]` override - into the config the later endpoints see. The linked project resolves once, so `--from remote + `Target:` hint is not printed. The linked project resolves once, so `--from remote --to remote` announces it once, and a branch or ref used on both sides shares one connection. A branch endpoint looks the name up in the branch list of `SUPABASE_PROJECT_ID`, else the linked project. `--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` config and URLs.** The config is read when the first endpoint that + needs it resolves, not before. A hosted endpoint resolved first merges its `[remotes.<ref>]` + block into that read, before validation and before its temporary login role is minted, so + `--from=remote --to=migrations` honours a remote override (a `major_version` that only + `[remotes.<ref>]` sets, for example) and a config error surfaces before that side effect. A + `local` or `migrations` endpoint resolved first reads the base config, and the first hosted + endpoint after it merges its block into the config the later endpoints and the diff itself see. + A malformed URL endpoint fails as soon as it is parsed (`PgDeltaEngineError`, the URL is never + echoed), before a later hosted endpoint mints its temporary login role. - 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/reset/SIDE_EFFECTS.md b/apps/cli/src/commands/db/reset/SIDE_EFFECTS.md index 391a4ebe9e..dbf1d0b280 100644 --- a/apps/cli/src/commands/db/reset/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/db/reset/SIDE_EFFECTS.md @@ -269,10 +269,12 @@ to those defaults (the usual outcome for an interactive terminal). - **Target/local split** follows whether the resolved config points at the local stack, not the flag name: a `--remote=<url>` pointing at the local stack is treated as a local reset and never prompts. -- **Hosted consent.** A remote project, branch or remote `--remote=<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. +- **Hosted consent.** A remote project, branch or remote `--remote=<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, the apply step and the seed plan. The apply step reads + `apply the declarative schema files` when schema files are in use, and otherwise + `apply local migrations` (`apply local migrations up to version <version>` with `--version`) only + when `[db.migrations].enabled` is true; with migrations disabled the question lists no apply step. - **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, 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 3c04a78bdb..78d8d51685 100644 --- a/docs/adr/0033-target-selection-and-hosted-mutation-consent.md +++ b/docs/adr/0033-target-selection-and-hosted-mutation-consent.md @@ -146,12 +146,14 @@ UUIDs go through the one branch resolver on every command, the `config` family i A command that resolves a target announces it once on stderr, in every output format, and reports the same identity as JSON `target`; [the command target doc](../../apps/cli/docs/command-target.md) -has the formats. The announcement is best effort: it adds no API call and no auth requirement, and -it never prints credentials. Identity comes from `supabase/.temp/linked-project.json`, which 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, `unverified` in -JSON. A project's default branch is production, not a preview branch: `--remote=<default branch>`, -by name or UUID, resolves to the production project, so the banner, the consent prompt and JSON +has the formats. A command with two database endpoints (`db diff --from`/`--to`) announces each in +resolution order, the local stack and a database URL included, and reports the first. The +announcement is best effort: it adds no API call and no auth requirement, and it never prints +credentials. Identity comes from `supabase/.temp/linked-project.json`, which 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, `unverified` in JSON. A +project's default branch is production, not a preview branch: `--remote=<default branch>`, by name +or UUID, resolves to the production project, so the banner, the consent prompt and JSON `environment: "production"` all say so, and a script that checks `environment` to stay away from production sees it. A UUID looked up with no linked project cannot tell, so it stays `unverified`. From 3109186544d38753da4949adc0753703b3d06597 Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Tue, 20 Oct 2026 12:44:18 +0100 Subject: [PATCH 201/291] fix(cli): report the default branch as the production project in config push `config push --remote=<default branch>` described the production project as a branch in the machine payload (is_branch, branch, parent_project_ref, target.environment) while the banner called it production. The resolved target now carries whether the branch is the production branch, so push treats it as the project itself without the live probe. A branch UUID looked up with no linked project says nothing about the project, so its target stays unverified and is_branch is omitted. --- .../target/remote-resolution.ts | 1 + ...resolve-project-target.integration.test.ts | 14 ++++ .../target/resolve-target.integration.test.ts | 1 + .../command-internal/target/resolve-target.ts | 2 + .../config/push/push.branch-target.ts | 45 ++++++------ .../src/commands/config/push/push.handler.ts | 20 ++++-- .../config/push/push.integration.test.ts | 68 +++++++++++++++++++ 7 files changed, 121 insertions(+), 30 deletions(-) diff --git a/apps/cli/src/command-internal/target/remote-resolution.ts b/apps/cli/src/command-internal/target/remote-resolution.ts index 999e4096d4..66b4e14291 100644 --- a/apps/cli/src/command-internal/target/remote-resolution.ts +++ b/apps/cli/src/command-internal/target/remote-resolution.ts @@ -274,6 +274,7 @@ export const resolveRemoteTarget = Effect.fn("Target.resolveRemote")(function* ( }), branch: remote.name, ...(found.parentRef === undefined ? {} : { parentRef: found.parentRef }), + ...(found.isDefault === undefined ? {} : { isDefault: found.isDefault }), } satisfies LinkedTarget; } const projectRef = yield* ProjectRefResolver; diff --git a/apps/cli/src/command-internal/target/resolve-project-target.integration.test.ts b/apps/cli/src/command-internal/target/resolve-project-target.integration.test.ts index e81712955d..a27c5cb1aa 100644 --- a/apps/cli/src/command-internal/target/resolve-project-target.integration.test.ts +++ b/apps/cli/src/command-internal/target/resolve-project-target.integration.test.ts @@ -437,10 +437,24 @@ describe("resolveProjectTarget with detail", () => { matchesLinkedRef: false, branch: "feature-x", parentRef: FILE_REF, + isDefault: false, }); }), ); + it.effect("marks the production branch as the default", () => + Effect.gen(function* () { + const result = yield* runDetailed( + { remote: { _tag: "Branch", name: "main" } }, + { + savedRef: FILE_REF, + branches: [{ ...branchRow("main"), project_ref: FILE_REF, is_default: true }], + }, + ); + expect(successOf(result.exit)).toMatchObject({ ref: FILE_REF, isDefault: true }); + }), + ); + it.effect("matches the linked ref when the branch's own ref is the saved one", () => Effect.gen(function* () { const result = yield* runDetailed( 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 0b02d766f2..43b3c78177 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 @@ -294,6 +294,7 @@ describe("resolveTarget", () => { matchesLinkedRef: false, branch: "feature-x", parentRef: FILE_REF, + isDefault: false, }); expect(result.apiRequests).toEqual([`GET /v1/projects/${FILE_REF}/branches`]); expect(result.refFlags).toEqual([]); diff --git a/apps/cli/src/command-internal/target/resolve-target.ts b/apps/cli/src/command-internal/target/resolve-target.ts index fe91e49efc..33cbbc935e 100644 --- a/apps/cli/src/command-internal/target/resolve-target.ts +++ b/apps/cli/src/command-internal/target/resolve-target.ts @@ -21,6 +21,8 @@ export type ResolvedTarget = readonly branch?: string | undefined; /** The linked project the branch was found under, when it was looked up through one. */ readonly parentRef?: string | undefined; + /** Whether the branch is the production branch, when its lookup said so. */ + readonly isDefault?: boolean | undefined; } | { readonly kind: "db-url"; readonly url: Redacted.Redacted<string> }; 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 1eeef10f97..fffd422190 100644 --- a/apps/cli/src/commands/config/push/push.branch-target.ts +++ b/apps/cli/src/commands/config/push/push.branch-target.ts @@ -15,12 +15,11 @@ import { Output } from "../../../shared/output/output.service.ts"; /** * What `ref` actually is, resolved for `config push`'s payload: * - * - `"project"` — `ref` is the linked project itself. - * - `"branch"` — `ref` is confirmed to be a preview branch (an explicit `--remote - * <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. + * - `"project"` — `ref` is the linked or production project itself. + * - `"branch"` — `ref` is confirmed to be a preview branch (a `--remote` branch, or a live 404); + * `parentRef`/`branch` are each present only when they could be determined. + * - `"unknown"` — nothing could settle it (a probe timeout, transport failure or unexpected + * status, or a UUID looked up with no linked project). Asserted as neither a branch nor a project. */ export type ConfigPushTarget = | { readonly kind: "project"; readonly ref: string } @@ -33,24 +32,22 @@ export type ConfigPushTarget = | { readonly kind: "unknown"; readonly ref: string }; /** - * A branch name/UUID the caller already resolved `ref` from — this makes the caller's knowledge - * that `ref` is a branch definitive, so {@link resolveConfigPushTarget} never runs the live - * `getProject` probe for it: + * What the caller already knows about the `--remote` branch name/UUID `ref` was resolved from, so + * {@link resolveConfigPushTarget} never runs the live `getProject` probe for it: * - * - `"name"` — both fields were resolved eagerly, nothing more to recover. - * - `"uuid"` — certain to be a branch, but a UUID carries no display name and never forces its - * parent, so it runs the same best-effort cache recovery a live 404 does. A discriminated - * union rather than an all-optional shape, since a partial state (a parent without a name, or - * vice versa) never occurs in practice. + * - `"name"` — a branch found by name under its parent; both fields are known. + * - `"uuid"` — certainly a branch, but a UUID carries no display name and never forces its parent, + * so it runs the same best-effort cache recovery a live 404 does. + * - `"production"` — the project's default branch, which is the project itself. + * - `"unverified"` — a UUID looked up with no linked project, which says nothing about `ref`. */ export type ConfigPushKnownBranch = | { readonly kind: "name"; readonly branchName: string; readonly parentRef: string } - | { readonly kind: "uuid" }; + | { readonly kind: "uuid" } + | { readonly kind: "production" } + | { readonly kind: "unverified" }; -/** `V1GetProjectOutput.name`/a branch's `name` are unconstrained strings — an - * empty (or empty-after-sanitization-adjacent) live value must render as "no - * name available", matching `parseCachedLinkedProject`'s own - * empty-filtering convention for cached names. */ +/** An API name is an unconstrained string; an empty one means "no name available". */ function normalizeApiName(name: string | undefined): string | undefined { return name !== undefined && name.length > 0 ? name : undefined; } @@ -103,10 +100,11 @@ export function resolveConfigPushTarget( const { branchName, parentRef } = opts.knownBranch; return { kind: "branch", ref, parentRef, branch: branchName }; } + if (opts.knownBranch?.kind === "production") return { kind: "project", ref }; + if (opts.knownBranch?.kind === "unverified") return { kind: "unknown", ref }; - // A uuid known-branch means ref is certainly a branch, so skip the live probe. Otherwise - // the probe's only certain outcomes are 200 (return immediately) and 404 (fall through to - // shared recovery). + // A known branch skips the live probe; otherwise its only certain outcomes are 200 (return) + // and 404 (fall through to the shared recovery). if (opts.knownBranch === undefined) { const api = yield* CommandPlatformApi; const output = yield* Output; @@ -132,8 +130,7 @@ export function resolveConfigPushTarget( const probed = ProbeOutcome.$match(probe, { 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. + // A 404 falls into the shared recovery below, same as a uuid known branch. Branch: () => undefined, }); if (probed !== undefined) { diff --git a/apps/cli/src/commands/config/push/push.handler.ts b/apps/cli/src/commands/config/push/push.handler.ts index c1b73782c4..e8498e4538 100644 --- a/apps/cli/src/commands/config/push/push.handler.ts +++ b/apps/cli/src/commands/config/push/push.handler.ts @@ -225,7 +225,9 @@ export const configPush = Effect.fn("config.push")(function* (flags: ConfigPushF // the resolved config load before its one schema decode, so a base document that's invalid without its // overlay must never be decoded on its own — this can cost a network round trip before a // malformed `config.toml` is caught. - const { ref, branch, parentRef } = yield* resolveProjectTarget(flags, { detail: true }); + const { ref, branch, parentRef, isDefault } = yield* resolveProjectTarget(flags, { + detail: true, + }); yield* Ref.set(resolvedRef, Option.some(ref)); yield* Effect.annotateCurrentSpan({ "project.ref": ref, @@ -265,14 +267,20 @@ 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 for the echo and the payload. A UUID carries no display name, - // so only a branch looked up by name under its parent is known well enough to skip the probe. + // 5. Determine the push target for the echo and the payload. The production branch is the + // project itself and a UUID looked up with no link says nothing about what it is, so neither + // is probed. Any other UUID carries no display name, so only a name lookup is fully known. + const isUuid = branch !== undefined && BRANCH_UUID_PATTERN.test(branch); const knownBranch: ConfigPushKnownBranch | undefined = branch === undefined ? undefined - : !BRANCH_UUID_PATTERN.test(branch) && parentRef !== undefined - ? { kind: "name", branchName: branch, parentRef } - : { kind: "uuid" }; + : isDefault === true + ? { kind: "production" } + : isUuid && isDefault === undefined + ? { kind: "unverified" } + : !isUuid && parentRef !== undefined + ? { kind: "name", branchName: branch, parentRef } + : { kind: "uuid" }; const target = yield* resolveConfigPushTarget(ref, { knownBranch }).pipe( Effect.withSpan("config.push.probeTarget"), ); 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 bcf487fd94..12c5cfe086 100644 --- a/apps/cli/src/commands/config/push/push.integration.test.ts +++ b/apps/cli/src/commands/config/push/push.integration.test.ts @@ -231,6 +231,14 @@ const BRANCH_BY_NAME = { with_data: false, }; +/** The production branch's list row: its project is the project itself, so its ref is the parent's. */ +const DEFAULT_BRANCH = { + ...BRANCH_BY_NAME, + name: "main", + project_ref: REF, + is_default: true, +}; + /** `V1GetABranchConfig` body for a UUID `--remote` lookup. */ const BRANCH_CONFIG = { ref: UUID_TARGET_REF, @@ -3534,6 +3542,66 @@ describe("config push --remote branch name/UUID resolution (CLI-2289)", () => { }).pipe(Effect.provide(layer)); }); + it.effect.each([ + { label: "name", value: DEFAULT_BRANCH.name }, + { label: "UUID", value: DEFAULT_BRANCH.id }, + ])( + "--remote=<default-branch $label> reports the production project, not a branch", + ({ value }) => { + const { layer, out, api } = setup({ + toml: BRANCH_PUSH_TOML, + yes: true, + format: "json", + branchList: { status: 200, body: [DEFAULT_BRANCH] }, + v2: { status: 200, body: v2Response({ ref: REF }) }, + }); + return Effect.gen(function* () { + yield* configPush(pushRemoteBranch(value)); + const data = yield* decodeJsonRecord(out.messages.find((m) => m.type === "success")?.data); + expect(data["is_branch"]).toBe(false); + expect("branch" in data).toBe(false); + expect("parent_project_ref" in data).toBe(false); + expect(data["target"]).toEqual({ + kind: "linked", + project_ref: REF, + environment: "production", + }); + expect(api.requests.some((r) => new URL(r.url).pathname === `/v1/projects/${REF}`)).toBe( + false, + ); + }).pipe(Effect.provide(layer)); + }, + ); + + it.effect( + "--remote=<uuid> with no linked project reports an unverified target, not a branch", + () => { + const { layer, out, api } = setup({ + toml: BRANCH_PUSH_TOML, + yes: true, + format: "json", + 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* () { + yield* configPush(pushRemoteBranch(BRANCH_UUID)); + const data = yield* decodeJsonRecord(out.messages.find((m) => m.type === "success")?.data); + expect(data["project_ref"]).toBe(UUID_TARGET_REF); + expect("is_branch" in data).toBe(false); + expect(data["target"]).toEqual({ + kind: "linked", + project_ref: UUID_TARGET_REF, + environment: "unverified", + }); + expect( + api.requests.some((r) => new URL(r.url).pathname === `/v1/projects/${UUID_TARGET_REF}`), + ).toBe(false); + }).pipe(Effect.provide(layer)); + }, + ); + it.effect("--project-ref still resolves a branch name and warns to use --remote", () => { const { layer, out } = setup({ toml: BRANCH_PUSH_TOML, From aa4d0e4a955bbe179394ade026a0ca78a87a8ea7 Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Tue, 20 Oct 2026 12:45:13 +0100 Subject: [PATCH 202/291] test(cli): cover a malformed SUPABASE_PROJECT_ID next to a valid link file in the default banner A malformed SUPABASE_PROJECT_ID is the linked-project candidate even when the checkout has a valid project-ref file, so the default-local banner adds no `pass --remote for ...` hint rather than naming the file's project. --- .../target/resolve-target.integration.test.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) 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 43b3c78177..d3e3e0b0ca 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 @@ -1182,6 +1182,18 @@ describe("resolveTarget", () => { }), ); + it.effect( + "adds no hint when SUPABASE_PROJECT_ID is not a project ref, even if a link exists", + () => + Effect.gen(function* () { + const result = yield* run("selectable", none, { + projectId: Option.some("not-a-ref"), + savedRef: FILE_REF, + }); + expect(result.stderr).toBe("Target: local stack (127.0.0.1:54322)\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" } }); From c25626d3a48450a3486ceddab2dd154f5e85c6d0 Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Tue, 20 Oct 2026 12:59:01 +0100 Subject: [PATCH 203/291] test(cli): drop an import the round-2 merge left unused --- apps/cli/src/commands/config/push/push.integration.test.ts | 1 - 1 file changed, 1 deletion(-) 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 edf3331965..7077c6c2e2 100644 --- a/apps/cli/src/commands/config/push/push.integration.test.ts +++ b/apps/cli/src/commands/config/push/push.integration.test.ts @@ -46,7 +46,6 @@ import { import type { RemoteValue } from "../../../command-internal/target/remote-value.ts"; import { TargetPolicy } from "../../../command-internal/target/target-policy.ts"; import { cliConfigProviderLayer } from "../../../shared/config/cli-config-provider.layer.ts"; -import { commandRuntimeLayer } from "../../../shared/runtime/command-runtime.layer.ts"; import { secretDigestHex } from "./push.secret.ts"; import { configDiff } from "../diff/diff.handler.ts"; import { configPush } from "./push.handler.ts"; From 414ec177e1e9197832cba49949aa799a8c78e61e Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Tue, 20 Oct 2026 13:12:02 +0100 Subject: [PATCH 204/291] fix(cli): keep config.target_is_branch false for the default branch The config push, pull and diff spans reported any --remote branch as a branch, while the banner and the push payload treat the default branch as the production project. The attribute is now false for no branch or the default branch, true for any other branch, and omitted for a branch UUID whose default-ness is unknown. --- .../target/target-identity.ts | 11 +++++++++ .../target/target-identity.unit.test.ts | 23 +++++++++++++++++++ .../src/commands/config/diff/diff.handler.ts | 5 ++-- .../src/commands/config/pull/pull.handler.ts | 5 ++-- .../src/commands/config/push/push.handler.ts | 3 ++- 5 files changed, 42 insertions(+), 5 deletions(-) diff --git a/apps/cli/src/command-internal/target/target-identity.ts b/apps/cli/src/command-internal/target/target-identity.ts index 12374c59ac..113fc44127 100644 --- a/apps/cli/src/command-internal/target/target-identity.ts +++ b/apps/cli/src/command-internal/target/target-identity.ts @@ -76,6 +76,17 @@ export const linkedAlternativeHint = (input: { return `default; pass --remote for ${named(name, input.ref)}`; }; +/** The `config.target_is_branch` span attribute: the default branch is the production project, and it is omitted while a branch UUID's default-ness is unknown. */ +export const targetIsBranchAttribute = (target: { + readonly branch?: string | undefined; + readonly isDefault?: boolean | undefined; +}): { readonly "config.target_is_branch"?: boolean } => { + if (target.branch === undefined || target.isDefault === true) { + return { "config.target_is_branch": false }; + } + return target.isDefault === undefined ? {} : { "config.target_is_branch": true }; +}; + /** * A branch resolved to its own project ref; `branch` is absent for a UUID lookup and `parentRef` * for anything but a name lookup. The default branch is the linked project itself, so it renders 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 6bd3620852..ce351562f5 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 @@ -7,6 +7,7 @@ import { renderBranchTarget, renderDbUrlTarget, renderLocalTarget, + targetIsBranchAttribute, } from "./target-identity.ts"; const REF = "abcdefghijklmnopqrst"; @@ -209,3 +210,25 @@ describe("renderBranchTarget", () => { expect(target.json).toEqual({ kind: "linked", project_ref: REF, environment: "production" }); }); }); + +describe("targetIsBranchAttribute", () => { + it("is false for a target with no branch", () => { + expect(targetIsBranchAttribute({})).toEqual({ "config.target_is_branch": false }); + }); + + it("is false for the default branch, which is the production project", () => { + expect(targetIsBranchAttribute({ branch: "main", isDefault: true })).toEqual({ + "config.target_is_branch": false, + }); + }); + + it("is true for any other branch", () => { + expect(targetIsBranchAttribute({ branch: "feature-x", isDefault: false })).toEqual({ + "config.target_is_branch": true, + }); + }); + + it("is omitted for a branch UUID whose default-ness is unknown", () => { + expect(targetIsBranchAttribute({ branch: "11111111-2222-4333-8444-555555555555" })).toEqual({}); + }); +}); diff --git a/apps/cli/src/commands/config/diff/diff.handler.ts b/apps/cli/src/commands/config/diff/diff.handler.ts index 2a9be28d9d..3319da0ec8 100644 --- a/apps/cli/src/commands/config/diff/diff.handler.ts +++ b/apps/cli/src/commands/config/diff/diff.handler.ts @@ -17,6 +17,7 @@ import { Output } from "../../../shared/output/output.service.ts"; import { ProcessControl } from "../../../shared/runtime/process-control.service.ts"; import { sanitizeErrorBody } from "../../../command-internal/http-errors.ts"; import { resolveProjectTarget } from "../../../command-internal/target/resolve-project-target.ts"; +import { targetIsBranchAttribute } from "../../../command-internal/target/target-identity.ts"; import { configIsRecord } from "../config.paths.ts"; import { loadResolvedConfig, @@ -83,11 +84,11 @@ export const configDiff = Effect.fn("config.diff")(function* (flags: ConfigDiffF makeLoadError, ); - const { ref, branch } = yield* resolveProjectTarget(flags, { detail: true }); + const { ref, branch, isDefault } = yield* resolveProjectTarget(flags, { detail: true }); yield* Ref.set(resolvedRef, Option.some(ref)); yield* Effect.annotateCurrentSpan({ "project.ref": ref, - "config.target_is_branch": branch !== undefined, + ...targetIsBranchAttribute({ branch, isDefault }), }); // The view `config push` sends: the `[remotes.*]` block matched to `ref` and the env overlay. diff --git a/apps/cli/src/commands/config/pull/pull.handler.ts b/apps/cli/src/commands/config/pull/pull.handler.ts index b72054aa32..abb27add44 100644 --- a/apps/cli/src/commands/config/pull/pull.handler.ts +++ b/apps/cli/src/commands/config/pull/pull.handler.ts @@ -5,6 +5,7 @@ import { pathHasUncommittedChanges } from "../../../command-internal/git-status. import { sanitizeInlineName } from "../../../command-internal/http-errors.ts"; import { resolveProjectTarget } from "../../../command-internal/target/resolve-project-target.ts"; import type { LinkedTarget } from "../../../command-internal/target/resolve-target.ts"; +import { targetIsBranchAttribute } from "../../../command-internal/target/target-identity.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"; @@ -226,9 +227,9 @@ export const configPull = Effect.fn("config.pull")(function* (flags: ConfigPullF // and a malformed document doesn't burn a branch-resolution round trip. const source = yield* openConfigPullSource(); - const { ref, branch } = yield* resolveProjectTarget(flags, { detail: true }); + const { ref, branch, isDefault } = yield* resolveProjectTarget(flags, { detail: true }); yield* Ref.set(resolvedRef, Option.some(ref)); - yield* Effect.annotateCurrentSpan("config.target_is_branch", branch !== undefined); + yield* Effect.annotateCurrentSpan(targetIsBranchAttribute({ branch, isDefault })); yield* runConfigPull({ target: { ref, branch }, diff --git a/apps/cli/src/commands/config/push/push.handler.ts b/apps/cli/src/commands/config/push/push.handler.ts index e8498e4538..860e4fbf9d 100644 --- a/apps/cli/src/commands/config/push/push.handler.ts +++ b/apps/cli/src/commands/config/push/push.handler.ts @@ -21,6 +21,7 @@ import { unexpectedStatusMessage, } from "../../../command-internal/http-errors.ts"; import { resolveProjectTarget } from "../../../command-internal/target/resolve-project-target.ts"; +import { targetIsBranchAttribute } from "../../../command-internal/target/target-identity.ts"; import { missingProjectConfigMessageEffect, relativeConfigPath, @@ -231,7 +232,7 @@ export const configPush = Effect.fn("config.push")(function* (flags: ConfigPushF yield* Ref.set(resolvedRef, Option.some(ref)); yield* Effect.annotateCurrentSpan({ "project.ref": ref, - "config.target_is_branch": branch !== undefined, + ...targetIsBranchAttribute({ branch, isDefault }), }); // 2. Load config.toml with the resolved ref (a TOML parse error aborts before any network From b188c800f3416b87cbb3a1c4cde24cb03675e4ad Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Tue, 20 Oct 2026 13:22:34 +0100 Subject: [PATCH 205/291] test(cli): add a shared target-selection harness and apply it to migration up --- .../migration/up/up.integration.test.ts | 161 +-------- apps/cli/tests/helpers/db-target-selection.ts | 319 ++++++++++++++++++ 2 files changed, 337 insertions(+), 143 deletions(-) create mode 100644 apps/cli/tests/helpers/db-target-selection.ts 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 e54c6a7701..f6b303f70d 100644 --- a/apps/cli/src/commands/migration/up/up.integration.test.ts +++ b/apps/cli/src/commands/migration/up/up.integration.test.ts @@ -23,6 +23,7 @@ import { sequentialExecBatch, } from "../../../../tests/helpers/command-mocks.ts"; import { cliConfigValuesTestLayer } from "../../../../tests/helpers/config-values-layer.ts"; +import { describeDbTargetSelection } from "../../../../tests/helpers/db-target-selection.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"; @@ -51,23 +52,13 @@ import { type DbSession, DbConnection } from "../../../command-internal/db-conne import { MigrationApplyError } from "../../../command-internal/migration-apply.ts"; import { MigrationVaultError } from "../../../command-internal/vault.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"; -import { - BRANCH_PROJECT_REF, - branchRow, - warningsOf, -} from "../../../../tests/helpers/remote-selection.ts"; +import { branchRow } from "../../../../tests/helpers/remote-selection.ts"; const LIST_SQL = "SELECT version FROM supabase_migrations.schema_migrations ORDER BY version"; const READ_VAULT = "SELECT id, name FROM vault.secrets WHERE name = ANY($1)"; -const FLAG_REF = "flagflagflagflagflag"; -const DB_URL = "postgresql://postgres:postgres@127.0.0.1:54322/postgres"; interface SetupOpts { /** Runs the real `DbConfigResolver` and linked-project cache against `workdir`. */ @@ -205,7 +196,6 @@ function setup(workdir: string, opts: SetupOpts = {}) { cache, resolverCalls, branchRequests: branchApi.requests, - warnings: () => warningsOf(out), }; } @@ -340,137 +330,6 @@ describe("migration up", () => { }).pipe(Effect.provide(layer)); }); - it.effect("rejects --remote combined with --local", () => { - const { layer } = setup(tmp.current); - return Effect.gen(function* () { - const error = yield* migrationUp( - flags({ remote: Option.some({ _tag: "Linked" }), local: true }), - ).pipe(Effect.flip); - expect(error).toBeInstanceOf(TargetSelectorConflictError); - }).pipe(Effect.provide(layer)); - }); - - it.effect("applies on the project given via --remote=<ref>", () => { - // VALID_REF is the resolver's fallback if the flag were absent; the flag must win. - const ctx = setup(tmp.current, { yes: true, remote: [] }); - return Effect.gen(function* () { - yield* migrationUp(flags({ remote: Option.some({ _tag: "Ref", ref: FLAG_REF }) })); - expect(ctx.resolverCalls[0]?.target).toMatchObject({ kind: "linked", ref: FLAG_REF }); - expect(ctx.cache.cachedRef).toBe(FLAG_REF); - expect(ctx.cache.cachedRef).not.toBe(VALID_REF); - expect(ctx.warnings()).toEqual([]); - }).pipe(Effect.provide(ctx.layer)); - }); - - it.effect("applies on the project of the branch --remote=<branch> names", () => { - const ctx = setup(tmp.current, { yes: true, remote: [] }); - return Effect.gen(function* () { - yield* migrationUp(flags({ remote: Option.some({ _tag: "Branch", name: "feature-x" }) })); - expect(ctx.branchRequests.map(({ method, url }) => `${method} ${url}`)).toEqual([ - `GET https://api.supabase.com/v1/projects/${VALID_REF}/branches`, - ]); - expect(ctx.resolverCalls[0]?.target).toMatchObject({ - kind: "linked", - ref: BRANCH_PROJECT_REF, - }); - expect(ctx.cache.cachedRef).toBe(BRANCH_PROJECT_REF); - expect(ctx.warnings()).toEqual([]); - }).pipe(Effect.provide(ctx.layer)); - }); - - it.effect("fails with the branches that exist when --remote=<branch> matches none", () => { - const ctx = setup(tmp.current, { yes: true, remote: [] }); - return Effect.gen(function* () { - const error = yield* migrationUp( - flags({ remote: Option.some({ _tag: "Branch", name: "feature-y" }) }), - ).pipe(Effect.flip); - expect(error).toMatchObject({ - _tag: "LinkBranchNotFoundError", - message: expect.stringContaining("Available branches: feature-x"), - }); - expect(ctx.resolverCalls).toEqual([]); - expect(ctx.execs).toEqual([]); - }).pipe(Effect.provide(ctx.layer)); - }); - - it.effect( - "applies on the database --remote=<url> names and never touches the linked cache", - () => { - const ctx = setup(tmp.current, { yes: true, remote: [] }); - return Effect.gen(function* () { - yield* seed(tmp.current, "20240101000000_a.sql"); - yield* migrationUp(flags({ remote: Option.some({ _tag: "DbUrl", url: DB_URL }) })); - expect(ctx.resolverCalls[0]?.target).toMatchObject({ kind: "db-url" }); - expect(insertedVersions(ctx.queries)).toEqual(["20240101000000"]); - expect(ctx.cache.cached).toBe(false); - expect(ctx.warnings()).toEqual([]); - }).pipe(Effect.provide(ctx.layer)); - }, - ); - - describe("deprecated selectors", () => { - const cases = [ - { - alias: "--linked", - args: ["migration", "up", "--linked"], - flags: { linked: true }, - target: { kind: "linked", ref: VALID_REF }, - warning: "--linked is deprecated and will be removed in v4. Use --remote instead", - }, - { - alias: "--project-ref", - args: ["migration", "up", "--project-ref", FLAG_REF], - flags: { projectRef: Option.some(FLAG_REF) }, - target: { kind: "linked", ref: FLAG_REF }, - warning: `--project-ref is deprecated and will be removed in v4. Use --remote=<ref> instead`, - }, - { - alias: "--db-url", - args: ["migration", "up", "--db-url", DB_URL], - flags: { dbUrl: Option.some(DB_URL) }, - target: { kind: "db-url" }, - warning: "--db-url is deprecated and will be removed in v4. Use --remote=<db-url> instead", - }, - { - alias: "--local", - args: ["migration", "up", "--local"], - flags: { local: true }, - target: { kind: "local" }, - warning: "--local isn't needed (local is the default) and will be removed in v4", - }, - ] as const satisfies ReadonlyArray<{ - readonly alias: string; - readonly args: ReadonlyArray<string>; - readonly flags: Partial<MigrationUpFlags>; - readonly target: Record<string, string>; - readonly warning: string; - }>; - - for (const testCase of cases) { - it.effect(`${testCase.alias} still selects its target and warns`, () => { - const ctx = setup(tmp.current, { yes: true, remote: [], args: testCase.args }); - return Effect.gen(function* () { - yield* migrationUp(flags(testCase.flags)); - expect(ctx.resolverCalls[0]?.target).toMatchObject(testCase.target); - expect(ctx.warnings()).toEqual([testCase.warning]); - }).pipe(Effect.provide(ctx.layer)); - }); - } - - it.effect("rejects --project-ref combined with --local before touching the database", () => { - const { layer, execs, queries, cache } = setup(tmp.current, { remote: [] }); - return Effect.gen(function* () { - 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", () => { @@ -583,6 +442,22 @@ describe("migration up", () => { }); }); +describeDbTargetSelection("migration up", { + dbUrl: true, + workdir: () => tmp.current, + attempt: ({ workdir, args, target }) => { + const ctx = setup(workdir, { yes: true, remote: [], args }); + return { + layer: ctx.layer, + out: ctx.out, + run: migrationUp(flags(target)), + target: () => ctx.resolverCalls[0]?.target, + cachedRef: () => ctx.cache.cachedRef, + branchLookups: () => ctx.branchRequests.map(({ method, url }) => `${method} ${url}`), + }; + }, +}); + describeHostedConsent("migration up", { setup: (workdir) => seed(workdir, "20240101000000_a.sql"), invoke: () => { diff --git a/apps/cli/tests/helpers/db-target-selection.ts b/apps/cli/tests/helpers/db-target-selection.ts new file mode 100644 index 0000000000..cf7e43b53d --- /dev/null +++ b/apps/cli/tests/helpers/db-target-selection.ts @@ -0,0 +1,319 @@ +import { mkdirSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; + +import { BunServices } from "@effect/platform-bun"; +import { describe, expect, it } from "@effect/vitest"; +import { Effect, Exit, Layer, Option } from "effect"; +import { CliOutput, Command } from "effect/cli"; + +import type { RemoteValue } from "../../src/command-internal/target/remote-value.ts"; +import type { TargetFlagValuesWithDbUrl } from "../../src/command-internal/target/target-flags.ts"; +import { renderLocalTarget } from "../../src/command-internal/target/target-identity.ts"; +import { CliArgs } from "../../src/shared/cli/cli-args.service.ts"; +import { commandTargetLayer } from "../../src/shared/output/command-target.layer.ts"; +import type { CommandTarget } from "../../src/shared/output/command-target.service.ts"; +import { announceTarget } from "../../src/shared/output/command-target.service.ts"; +import { normalizeCause } from "../../src/shared/output/normalize-error.ts"; +import { textCliOutputFormatter } from "../../src/shared/output/text-formatter.ts"; +import { stripAnsi } from "./ansi.ts"; +import { VALID_REF } from "./command-mocks.ts"; +import { + mockAnalytics, + mockOutput, + mockProcessControl, + mockRuntimeInfo, + mockTelemetryRuntime, + mockTty, +} from "./mocks.ts"; +import { BRANCH_PROJECT_REF, warningsOf } from "./remote-selection.ts"; + +const FLAG_REF = "flagflagflagflagflag"; +const DB_URL = "postgresql://postgres:postgres@db.example.com:5432/postgres"; +const BRANCH_LOOKUP = `GET https://api.supabase.com/v1/projects/${VALID_REF}/branches`; + +/** Every selector flag absent, so a command takes its default target. */ +export const NO_TARGET: TargetFlagValuesWithDbUrl = { + remote: Option.none(), + local: false, + linked: false, + dbUrl: Option.none(), + projectRef: Option.none(), +}; + +const selecting = (remote: RemoteValue): TargetFlagValuesWithDbUrl => ({ + ...NO_TARGET, + remote: Option.some(remote), +}); + +/** `--remote=<ref>`. */ +export const remoteTarget = (ref: string): TargetFlagValuesWithDbUrl => + selecting({ _tag: "Ref", ref }); + +type ObservedTarget = + | { readonly kind: "local" } + | { readonly kind: "linked"; readonly ref: string } + | { readonly kind: "db-url" }; + +interface DbTargetAttempt<R> { + readonly layer: Layer.Layer<R, unknown>; + readonly out: ReturnType<typeof mockOutput>; + readonly run: Effect.Effect<unknown, unknown, R>; + /** The target the leaf opened its connection or requests for; `undefined` until it reached one. */ + readonly target: () => ObservedTarget | undefined; + readonly cachedRef?: () => string | undefined; + /** The `METHOD url` of each branch-list request, for a leaf that records them. */ + readonly branchLookups?: () => ReadonlyArray<string>; +} + +type Row = + | "default" + | "linked-hint" + | "bare-remote" + | "remote-ref" + | "remote-branch" + | "unknown-branch" + | "remote-db-url" + | "deprecated-alias" + | "remote-with-local"; + +interface DbTargetSelectionScenario<R> { + /** Whether the command takes a database URL; storage and seed take a project ref or a branch name. */ + readonly dbUrl: boolean; + /** The directory the leaf's layers read, which the linked-checkout row links to {@link VALID_REF}. */ + readonly workdir: () => string; + /** + * One run of the leaf: its layers, its handler called with `target`, and what it observed. The + * linked project the fakes answer for is {@link VALID_REF}, whose only branch is `feature-x`. + */ + readonly attempt: (input: { + readonly workdir: string; + readonly args: ReadonlyArray<string>; + readonly target: TargetFlagValuesWithDbUrl; + }) => DbTargetAttempt<R>; + /** Rows the leaf cannot express, each with why. */ + readonly optOut?: Partial<Record<Row, string>>; +} + +const linkCheckout = (workdir: string) => { + const temp = join(workdir, "supabase", ".temp"); + mkdirSync(temp, { recursive: true }); + writeFileSync(join(temp, "project-ref"), VALID_REF); +}; + +const expectCachedRef = <R>(ctx: DbTargetAttempt<R>, ref: string | undefined) => { + if (ctx.cachedRef !== undefined) expect(ctx.cachedRef()).toBe(ref); +}; + +/** + * Registers one wiring row per `--remote` selector kind for a selectable leaf: the local default, + * its linked-checkout hint, a bare `--remote`, a ref, a branch name that exists and one that does + * not, a database URL, the deprecated `--linked`, and `--remote` with `--local`. The selector rules + * themselves are proven by the `command-internal/target` tests. + */ +export function describeDbTargetSelection<R>( + path: string, + scenario: DbTargetSelectionScenario<R>, +): void { + const words = path.split(" "); + const subject = scenario.dbUrl ? "database" : "Storage API"; + const optedOut = (row: Row) => scenario.optOut?.[row] !== undefined; + + const rowTest = ( + row: Row, + name: string, + spec: { + readonly args: ReadonlyArray<string>; + readonly target: TargetFlagValuesWithDbUrl; + readonly linked?: true; + readonly check: (ctx: DbTargetAttempt<R>) => Effect.Effect<void, unknown, R | CommandTarget>; + }, + ) => { + if (optedOut(row)) return; + it.effect(name, () => { + const workdir = scenario.workdir(); + if (spec.linked === true) linkCheckout(workdir); + const ctx = scenario.attempt({ + workdir, + args: [...words, ...spec.args], + target: spec.target, + }); + return spec + .check(ctx) + .pipe( + Effect.provide( + Layer.mergeAll(ctx.layer, commandTargetLayer.pipe(Layer.provide(ctx.out.layer))), + ), + ); + }); + }; + + describe(`target selection: ${path}`, () => { + rowTest("default", `targets the local ${subject}, with no warning, when no selector is given`, { + args: [], + target: NO_TARGET, + check: (ctx) => + Effect.gen(function* () { + yield* ctx.run; + expect(ctx.target()).toEqual({ kind: "local" }); + expect(warningsOf(ctx.out)).toEqual([]); + expectCachedRef(ctx, undefined); + }), + }); + + rowTest( + "linked-hint", + "points the default-local banner at --remote when the checkout is linked", + { + args: [], + target: NO_TARGET, + linked: true, + check: (ctx) => + Effect.gen(function* () { + yield* ctx.run; + yield* announceTarget(renderLocalTarget("127.0.0.1", 54322)); + expect(stripAnsi(ctx.out.stderrText)).toContain( + `default; pass --remote for ${VALID_REF}`, + ); + }), + }, + ); + + rowTest("bare-remote", "targets the linked project for a bare --remote, with no warning", { + args: ["--remote"], + target: selecting({ _tag: "Linked" }), + check: (ctx) => + Effect.gen(function* () { + yield* ctx.run; + expect(ctx.target()).toMatchObject({ kind: "linked", ref: VALID_REF }); + expect(warningsOf(ctx.out)).toEqual([]); + expectCachedRef(ctx, VALID_REF); + }), + }); + + rowTest("remote-ref", "targets the project --remote=<ref> names, with no warning", { + args: [`--remote=${FLAG_REF}`], + target: remoteTarget(FLAG_REF), + check: (ctx) => + Effect.gen(function* () { + yield* ctx.run; + expect(ctx.target()).toMatchObject({ kind: "linked", ref: FLAG_REF }); + expect(warningsOf(ctx.out)).toEqual([]); + expectCachedRef(ctx, FLAG_REF); + }), + }); + + rowTest("remote-branch", "targets the project of the branch --remote=<branch> names", { + args: ["--remote=feature-x"], + target: selecting({ _tag: "Branch", name: "feature-x" }), + check: (ctx) => + Effect.gen(function* () { + yield* ctx.run; + expect(ctx.target()).toMatchObject({ kind: "linked", ref: BRANCH_PROJECT_REF }); + expect(warningsOf(ctx.out)).toEqual([]); + expectCachedRef(ctx, BRANCH_PROJECT_REF); + if (ctx.branchLookups !== undefined) expect(ctx.branchLookups()).toEqual([BRANCH_LOOKUP]); + }), + }); + + rowTest( + "unknown-branch", + "fails with the branches that exist, before connecting, when --remote=<branch> names none", + { + args: ["--remote=feature-y"], + target: selecting({ _tag: "Branch", name: "feature-y" }), + check: (ctx) => + Effect.gen(function* () { + const error = yield* Effect.flip(ctx.run); + expect(error).toMatchObject({ + _tag: "LinkBranchNotFoundError", + message: expect.stringContaining("Available branches: feature-x"), + }); + expect(ctx.target()).toBeUndefined(); + expectCachedRef(ctx, undefined); + }), + }, + ); + + if (scenario.dbUrl) { + rowTest( + "remote-db-url", + "targets the database --remote=<postgres url> names, never touching the linked project", + { + args: [`--remote=${DB_URL}`], + target: selecting({ _tag: "DbUrl", url: DB_URL }), + check: (ctx) => + Effect.gen(function* () { + yield* ctx.run; + expect(ctx.target()).toMatchObject({ kind: "db-url" }); + expect(warningsOf(ctx.out)).toEqual([]); + expectCachedRef(ctx, undefined); + }), + }, + ); + } else if (!optedOut("remote-db-url")) { + const args = [...words, `--remote=${DB_URL}`]; + it.effect( + "rejects --remote=<postgres url> while parsing, since there is no database to connect to", + () => + Effect.gen(function* () { + const { rootCommandForFeatures } = yield* Effect.promise( + () => import("../../src/cli/root.ts"), + ); + const exit = yield* Effect.exit( + Command.runWith(rootCommandForFeatures(), { version: "0.0.0-test" })(args), + ); + expect( + Exit.match(exit, { + onFailure: (cause) => normalizeCause(cause).message, + onSuccess: () => "parsed", + }), + ).toBe("--remote takes a project ref or a branch name."); + }).pipe( + Effect.provide( + Layer.mergeAll( + BunServices.layer, + CliOutput.layer(textCliOutputFormatter()), + Layer.succeed(CliArgs, { args }), + mockOutput({ format: "text" }).layer, + mockRuntimeInfo(), + mockAnalytics().layer, + mockTelemetryRuntime(), + mockTty(), + mockProcessControl().layer, + ), + ), + ), + ); + } + + rowTest( + "deprecated-alias", + "keeps --linked targeting the linked project and warns that it is deprecated", + { + args: ["--linked"], + target: { ...NO_TARGET, linked: true }, + check: (ctx) => + Effect.gen(function* () { + yield* ctx.run; + expect(ctx.target()).toMatchObject({ kind: "linked", ref: VALID_REF }); + expect(warningsOf(ctx.out)).toEqual([ + expect.stringContaining("--linked is deprecated"), + ]); + expectCachedRef(ctx, VALID_REF); + }), + }, + ); + + rowTest("remote-with-local", "rejects --remote combined with --local before anything runs", { + args: ["--remote", "--local"], + target: { ...selecting({ _tag: "Linked" }), local: true }, + check: (ctx) => + Effect.gen(function* () { + const error = yield* Effect.flip(ctx.run); + expect(error).toMatchObject({ _tag: "TargetSelectorConflictError" }); + expect(ctx.target()).toBeUndefined(); + expectCachedRef(ctx, undefined); + }), + }); + }); +} From f6dc9270af4d1516cf4b9c4663a71d237716046d Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Tue, 20 Oct 2026 13:27:08 +0100 Subject: [PATCH 206/291] test(cli): cover target selection for the migration leaves with the shared harness --- .../migration/down/down.integration.test.ts | 189 ++-------------- .../migration/fetch/fetch.integration.test.ts | 166 ++------------ .../migration/list/list.integration.test.ts | 163 ++------------ .../repair/repair.integration.test.ts | 206 ++---------------- .../squash/squash.integration.test.ts | 204 ++--------------- 5 files changed, 104 insertions(+), 824 deletions(-) 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 67a35103f4..c0452d2ff2 100644 --- a/apps/cli/src/commands/migration/down/down.integration.test.ts +++ b/apps/cli/src/commands/migration/down/down.integration.test.ts @@ -24,6 +24,7 @@ import { sequentialExecBatch, } from "../../../../tests/helpers/command-mocks.ts"; import { cliConfigValuesTestLayer } from "../../../../tests/helpers/config-values-layer.ts"; +import { describeDbTargetSelection } from "../../../../tests/helpers/db-target-selection.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"; @@ -53,22 +54,12 @@ import { type DbSession, DbConnection } from "../../../command-internal/db-conne import { MigrationDropError } from "../../../command-internal/drop-objects.ts"; import { MigrationSeedError } from "../../../command-internal/seed.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"; -import { - BRANCH_PROJECT_REF, - branchRow, - warningsOf, -} from "../../../../tests/helpers/remote-selection.ts"; +import { branchRow } from "../../../../tests/helpers/remote-selection.ts"; const LIST_SQL = "SELECT version FROM supabase_migrations.schema_migrations ORDER BY version"; -const FLAG_REF = "flagflagflagflagflag"; -const DB_URL = "postgresql://postgres:postgres@127.0.0.1:54322/postgres"; interface SetupOpts { /** Runs the real `DbConfigResolver` and linked-project cache against `workdir`. */ @@ -225,7 +216,6 @@ function setup(workdir: string, opts: SetupOpts = {}) { cache, resolverCalls, branchRequests: branchApi.requests, - warnings: () => warningsOf(out), }; } @@ -340,163 +330,6 @@ describe("migration down", () => { }).pipe(Effect.provide(layer)); }); - it.effect("reverts on the project given via --remote=<ref>", () => { - // VALID_REF is the fake resolver's fallback, representing whatever the - // workdir would resolve to without the flag override. - const ctx = setup(tmp.current, { - yes: true, - remote: ["20240101000000", "20240102000000"], - }); - return Effect.gen(function* () { - yield* seed(tmp.current, "20240101000000_a.sql"); - yield* migrationDown(flags({ last: 1, remote: Option.some({ _tag: "Ref", ref: FLAG_REF }) })); - expect(ctx.cache.cached).toBe(true); - expect(ctx.cache.cachedRef).toBe(FLAG_REF); - expect(ctx.cache.cachedRef).not.toBe(VALID_REF); - expect(ctx.warnings()).toEqual([]); - }).pipe(Effect.provide(ctx.layer)); - }); - - it.effect("reverts on the project of the branch --remote=<branch> names", () => { - const ctx = setup(tmp.current, { - yes: true, - remote: ["20240101000000", "20240102000000"], - }); - return Effect.gen(function* () { - yield* seed(tmp.current, "20240101000000_a.sql"); - yield* migrationDown( - flags({ last: 1, remote: Option.some({ _tag: "Branch", name: "feature-x" }) }), - ); - expect(ctx.branchRequests.map(({ method, url }) => `${method} ${url}`)).toEqual([ - `GET https://api.supabase.com/v1/projects/${VALID_REF}/branches`, - ]); - expect(ctx.resolverCalls[0]?.target).toMatchObject({ - kind: "linked", - ref: BRANCH_PROJECT_REF, - }); - expect(ctx.cache.cachedRef).toBe(BRANCH_PROJECT_REF); - expect(ctx.warnings()).toEqual([]); - }).pipe(Effect.provide(ctx.layer)); - }); - - it.effect("fails with the branches that exist when --remote=<branch> matches none", () => { - const ctx = setup(tmp.current, { - yes: true, - remote: ["20240101000000", "20240102000000"], - }); - return Effect.gen(function* () { - const error = yield* migrationDown( - flags({ last: 1, remote: Option.some({ _tag: "Branch", name: "feature-y" }) }), - ).pipe(Effect.flip); - expect(error).toMatchObject({ - _tag: "LinkBranchNotFoundError", - message: expect.stringContaining("Available branches: feature-x"), - }); - expect(ctx.resolverCalls).toEqual([]); - expect(ctx.execs).toEqual([]); - }).pipe(Effect.provide(ctx.layer)); - }); - - it.effect("reverts the database --remote=<url> names and never touches the linked cache", () => { - const ctx = setup(tmp.current, { - yes: true, - remote: ["20240101000000", "20240102000000"], - }); - return Effect.gen(function* () { - yield* seed(tmp.current, "20240101000000_a.sql"); - yield* migrationDown(flags({ last: 1, remote: Option.some({ _tag: "DbUrl", url: DB_URL }) })); - expect(ctx.resolverCalls[0]?.target).toMatchObject({ kind: "db-url" }); - expect(droppedSchemas(ctx.execs)).toBe(1); - expect(ctx.cache.cached).toBe(false); - expect(ctx.warnings()).toEqual([]); - }).pipe(Effect.provide(ctx.layer)); - }); - - it.effect("rejects --remote combined with --local before touching the database", () => { - const ctx = setup(tmp.current, { - remote: ["20240101000000", "20240102000000"], - }); - return Effect.gen(function* () { - const error = yield* migrationDown( - flags({ last: 1, remote: Option.some({ _tag: "Linked" }), local: true }), - ).pipe(Effect.flip); - expect(error).toBeInstanceOf(TargetSelectorConflictError); - expect(ctx.execs).toEqual([]); - expect(ctx.queries).toEqual([]); - expect(ctx.cache.cached).toBe(false); - }).pipe(Effect.provide(ctx.layer)); - }); - - describe("deprecated selectors", () => { - const cases = [ - { - alias: "--linked", - args: ["migration", "down", "--linked"], - flags: { linked: true }, - target: { kind: "linked", ref: VALID_REF }, - warning: "--linked is deprecated and will be removed in v4. Use --remote instead", - }, - { - alias: "--project-ref", - args: ["migration", "down", "--project-ref", FLAG_REF], - flags: { projectRef: Option.some(FLAG_REF) }, - target: { kind: "linked", ref: FLAG_REF }, - warning: `--project-ref is deprecated and will be removed in v4. Use --remote=<ref> instead`, - }, - { - alias: "--db-url", - args: ["migration", "down", "--db-url", DB_URL], - flags: { dbUrl: Option.some(DB_URL) }, - target: { kind: "db-url" }, - warning: "--db-url is deprecated and will be removed in v4. Use --remote=<db-url> instead", - }, - { - alias: "--local", - args: ["migration", "down", "--local"], - flags: { local: true }, - target: { kind: "local" }, - warning: "--local isn't needed (local is the default) and will be removed in v4", - }, - ] as const satisfies ReadonlyArray<{ - readonly alias: string; - readonly args: ReadonlyArray<string>; - readonly flags: Partial<MigrationDownFlags>; - readonly target: Record<string, string>; - readonly warning: string; - }>; - - for (const testCase of cases) { - it.effect(`${testCase.alias} still selects its target and warns`, () => { - const ctx = setup(tmp.current, { - yes: true, - remote: ["20240101000000", "20240102000000"], - args: testCase.args, - }); - return Effect.gen(function* () { - yield* seed(tmp.current, "20240101000000_a.sql"); - yield* migrationDown(flags({ last: 1, ...testCase.flags })); - expect(ctx.resolverCalls[0]?.target).toMatchObject(testCase.target); - expect(ctx.warnings()).toEqual([testCase.warning]); - }).pipe(Effect.provide(ctx.layer)); - }); - } - - it.effect("rejects --project-ref combined with --local before touching the database", () => { - const { layer, execs, queries, cache } = setup(tmp.current, { - remote: ["20240101000000", "20240102000000"], - }); - return Effect.gen(function* () { - 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 on the local database", () => { const { layer, out } = setup(tmp.current, { format: "json", @@ -675,6 +508,24 @@ describe("migration down", () => { }); }); +describeDbTargetSelection("migration down", { + dbUrl: true, + workdir: () => tmp.current, + attempt: ({ workdir, args, target }) => { + const ctx = setup(workdir, { yes: true, remote: ["20240101000000", "20240102000000"], args }); + return { + layer: ctx.layer, + out: ctx.out, + run: seed(workdir, "20240101000000_a.sql").pipe( + Effect.andThen(migrationDown(flags({ last: 1, ...target }))), + ), + target: () => ctx.resolverCalls[0]?.target, + cachedRef: () => ctx.cache.cachedRef, + branchLookups: () => ctx.branchRequests.map(({ method, url }) => `${method} ${url}`), + }; + }, +}); + describeHostedConsent("migration down", { setup: (workdir) => seed(workdir, "20240101000000_a.sql"), invoke: () => { 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 35fd2a9248..b12bbf6693 100644 --- a/apps/cli/src/commands/migration/fetch/fetch.integration.test.ts +++ b/apps/cli/src/commands/migration/fetch/fetch.integration.test.ts @@ -34,6 +34,7 @@ import { type StubbedRequest, } from "../../../../tests/helpers/permission-real-layers.ts"; import { configValuesLayer } from "../../../../tests/helpers/config-values-layer.ts"; +import { describeDbTargetSelection } from "../../../../tests/helpers/db-target-selection.ts"; import { CliArgs } from "../../../shared/cli/cli-args.service.ts"; import { commandRuntimeLayer } from "../../../shared/runtime/command-runtime.layer.ts"; import { DnsResolverFlag, OutputFlag, YesFlag } from "../../../command-internal/global-flags.ts"; @@ -49,24 +50,15 @@ import { DbConnectError } from "../../../command-internal/db-connection.errors.t import { DbConnection } from "../../../command-internal/db-connection.service.ts"; import { TargetPolicy } from "../../../command-internal/target/target-policy.ts"; import { CommandTarget } from "../../../shared/output/command-target.service.ts"; -import { - TargetProjectRefConflictError, - TargetSelectorConflictError, -} from "../../../command-internal/target/target.errors.ts"; +import { 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"; -import { - BRANCH_PROJECT_REF, - branchRow, - warningsOf, -} from "../../../../tests/helpers/remote-selection.ts"; +import { branchRow } from "../../../../tests/helpers/remote-selection.ts"; const SELECT_SQL = "SELECT version, coalesce(name, '') as name, statements FROM supabase_migrations.schema_migrations"; -const FLAG_REF = "flagflagflagflagflag"; -const DB_URL = "postgresql://u:p@db.example.com/postgres"; interface MigrationRow { readonly version: string; @@ -194,7 +186,6 @@ function setup(workdir: string, opts: SetupOpts = {}) { ), BunServices.layer, ); - const warnings = () => warningsOf(out); return { layer, http, @@ -202,7 +193,6 @@ function setup(workdir: string, opts: SetupOpts = {}) { telemetry, cache, resolveCalls, - warnings, branchRequests: branchApi.requests, }; } @@ -545,29 +535,6 @@ describe("migration fetch", () => { }).pipe(Effect.provide(layer)); }); - it.effect("fetches from the local database by default without caching a project", () => { - const { layer, resolveCalls, cache, warnings } = setup(tmp.current, { - rows: [{ version: "20240101000000", name: "init", statements: ["create table a"] }], - }); - return Effect.gen(function* () { - yield* migrationFetch(flags()); - expect(resolveCalls[0]?.target).toEqual({ kind: "local" }); - expect(cache.cached).toBe(false); - expect(warnings()).toEqual([]); - expect(yield* listMigrations(tmp.current)).toEqual(["20240101000000_init.sql"]); - }).pipe(Effect.provide(layer)); - }); - - it.effect("fetches from the database --remote=<url> names", () => { - const { layer, resolveCalls, cache, warnings } = setup(tmp.current, { rows: [] }); - return Effect.gen(function* () { - yield* migrationFetch(flags({ remote: Option.some({ _tag: "DbUrl", url: DB_URL }) })); - expect(resolveCalls[0]?.target.kind).toBe("db-url"); - expect(cache.cached).toBe(false); - expect(warnings()).toEqual([]); - }).pipe(Effect.provide(layer)); - }); - it.effect("rejects --remote combined with --local before reading the project .env", () => { // A flag conflict must surface even when supabase/.env is malformed. const { layer } = setup(tmp.current); @@ -580,117 +547,6 @@ describe("migration fetch", () => { }).pipe(Effect.provide(layer)); }); - it.effect( - "fetches from the project --remote=<ref> names, overriding the default linked ref", - () => { - const { layer, cache, warnings } = setup(tmp.current, { rows: [] }); - return Effect.gen(function* () { - yield* migrationFetch(flags({ remote: Option.some({ _tag: "Ref", ref: FLAG_REF }) })); - expect(cache.cached).toBe(true); - expect(cache.cachedRef).toBe(FLAG_REF); - expect(cache.cachedRef).not.toBe(VALID_REF); - expect(warnings()).toEqual([]); - }).pipe(Effect.provide(layer)); - }, - ); - - it.effect("fetches from the project of the branch --remote=<branch> names", () => { - const { layer, resolveCalls, cache, branchRequests, warnings } = setup(tmp.current, { - rows: [], - }); - return Effect.gen(function* () { - yield* migrationFetch(flags({ remote: Option.some({ _tag: "Branch", name: "feature-x" }) })); - expect(branchRequests.map(({ method, url }) => `${method} ${url}`)).toEqual([ - `GET https://api.supabase.com/v1/projects/${VALID_REF}/branches`, - ]); - expect(resolveCalls[0]?.target).toMatchObject({ kind: "linked", ref: BRANCH_PROJECT_REF }); - expect(cache.cachedRef).toBe(BRANCH_PROJECT_REF); - expect(warnings()).toEqual([]); - }).pipe(Effect.provide(layer)); - }); - - it.effect("fails with the branches that exist when --remote=<branch> matches none", () => { - const { layer, resolveCalls, out } = setup(tmp.current, { rows: [] }); - return Effect.gen(function* () { - const error = yield* migrationFetch( - flags({ remote: Option.some({ _tag: "Branch", name: "feature-y" }) }), - ).pipe(Effect.flip); - expect(error).toMatchObject({ - _tag: "LinkBranchNotFoundError", - message: expect.stringContaining("Available branches: feature-x"), - }); - expect(resolveCalls).toEqual([]); - expect(yield* migrationsDirExists(tmp.current)).toBe(false); - expect(out.promptConfirmCalls.length).toBe(0); - }).pipe(Effect.provide(layer)); - }); - - describe("deprecated selectors", () => { - const cases = [ - { - alias: "--linked", - args: ["migration", "fetch", "--linked"], - flags: { linked: true }, - target: { kind: "linked", ref: VALID_REF }, - warning: "--linked is deprecated and will be removed in v4. Use --remote instead", - }, - { - alias: "--project-ref", - args: ["migration", "fetch", "--project-ref", FLAG_REF], - flags: { projectRef: Option.some(FLAG_REF) }, - target: { kind: "linked", ref: FLAG_REF }, - warning: `--project-ref is deprecated and will be removed in v4. Use --remote=<ref> instead`, - }, - { - alias: "--db-url", - args: ["migration", "fetch", "--db-url", DB_URL], - flags: { dbUrl: Option.some(DB_URL) }, - target: { kind: "db-url" }, - warning: "--db-url is deprecated and will be removed in v4. Use --remote=<db-url> instead", - }, - { - alias: "--local", - args: ["migration", "fetch", "--local"], - flags: { local: true }, - target: { kind: "local" }, - warning: "--local isn't needed (local is the default) and will be removed in v4", - }, - ] as const satisfies ReadonlyArray<{ - readonly alias: string; - readonly args: ReadonlyArray<string>; - readonly flags: Partial<MigrationFetchFlags>; - readonly target: Record<string, string>; - readonly warning: string; - }>; - - for (const testCase of cases) { - it.effect(`${testCase.alias} still selects its target and warns`, () => { - const { layer, resolveCalls, warnings } = setup(tmp.current, { - rows: [], - args: testCase.args, - }); - return Effect.gen(function* () { - yield* migrationFetch(flags(testCase.flags)); - expect(resolveCalls[0]?.target).toMatchObject(testCase.target); - expect(warnings()).toEqual([testCase.warning]); - }).pipe(Effect.provide(layer)); - }); - } - - it.effect("rejects --project-ref combined with --local", () => { - const { layer, out, cache } = setup(tmp.current); - return Effect.gen(function* () { - const error = yield* migrationFetch( - flags({ local: true, projectRef: Option.some(FLAG_REF) }), - ).pipe(Effect.flip); - expect(error).toBeInstanceOf(TargetProjectRefConflictError); - expect(yield* migrationsDirExists(tmp.current)).toBe(false); - expect(out.promptConfirmCalls.length).toBe(0); - expect(cache.cached).toBe(false); - }).pipe(Effect.provide(layer)); - }); - }); - it.effect("mints a login role and fills the cache through the real resolver and cache", () => { const ctx = setup(tmp.current, { realLayers: true, @@ -750,3 +606,19 @@ describe("migration fetch", () => { }, ); }); + +describeDbTargetSelection("migration fetch", { + dbUrl: true, + workdir: () => tmp.current, + attempt: ({ workdir, args, target }) => { + const ctx = setup(workdir, { rows: [], args }); + return { + layer: ctx.layer, + out: ctx.out, + run: migrationFetch(flags(target)), + target: () => ctx.resolveCalls[0]?.target, + cachedRef: () => ctx.cache.cachedRef, + branchLookups: () => ctx.branchRequests.map(({ method, url }) => `${method} ${url}`), + }; + }, +}); 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 b84741fe5f..914f359df1 100644 --- a/apps/cli/src/commands/migration/list/list.integration.test.ts +++ b/apps/cli/src/commands/migration/list/list.integration.test.ts @@ -34,6 +34,7 @@ import { type StubbedRequest, } from "../../../../tests/helpers/permission-real-layers.ts"; import { cliConfigValuesTestLayer } from "../../../../tests/helpers/config-values-layer.ts"; +import { describeDbTargetSelection } from "../../../../tests/helpers/db-target-selection.ts"; import { CliArgs } from "../../../shared/cli/cli-args.service.ts"; import { commandRuntimeLayer } from "../../../shared/runtime/command-runtime.layer.ts"; import { DnsResolverFlag, YesFlag } from "../../../command-internal/global-flags.ts"; @@ -48,22 +49,12 @@ import type { 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"; +import { TargetPasswordConflictError } from "../../../command-internal/target/target.errors.ts"; import { migrationList } from "./list.handler.ts"; import type { MigrationListFlags } from "./list.command.ts"; -import { - BRANCH_PROJECT_REF, - branchRow, - warningsOf, -} from "../../../../tests/helpers/remote-selection.ts"; +import { branchRow } from "../../../../tests/helpers/remote-selection.ts"; const LIST_SQL = "SELECT version FROM supabase_migrations.schema_migrations ORDER BY version"; -const FLAG_REF = "flagflagflagflagflag"; -const DB_URL = "postgresql://postgres:postgres@127.0.0.1:54322/postgres"; interface SetupOpts { readonly format?: OutputFormat; @@ -174,7 +165,6 @@ function setup(workdir: string, opts: SetupOpts = {}) { http, resolverCalls, branchRequests: branchApi.requests, - warnings: () => warningsOf(out), }; } @@ -261,135 +251,6 @@ describe("migration list", () => { }).pipe(Effect.provide(layer)); }); - it.effect("lists the project given via --remote=<ref> and fills the cache for that ref", () => { - // VALID_REF is the fake resolver's fallback; the flag must win over it and drive - // the cached ref. - const ctx = setup(tmp.current, { remote: ["20240101000000"] }); - return Effect.gen(function* () { - yield* seedMigrations(tmp.current, ["20240101000000_a.sql"]); - yield* migrationList(flags({ remote: Option.some({ _tag: "Ref", ref: FLAG_REF }) })); - expect(ctx.cache.cachedRef).toBe(FLAG_REF); - expect(ctx.cache.cachedRef).not.toBe(VALID_REF); - expect(ctx.warnings()).toEqual([]); - }).pipe(Effect.provide(ctx.layer)); - }); - - it.effect("lists the database --remote=<url> names and never touches the linked cache", () => { - const ctx = setup(tmp.current, { remote: ["20240101000000"] }); - return Effect.gen(function* () { - yield* seedMigrations(tmp.current, ["20240101000000_a.sql"]); - yield* migrationList(flags({ remote: Option.some({ _tag: "DbUrl", url: DB_URL }) })); - expect(ctx.resolverCalls[0]?.target).toMatchObject({ kind: "db-url" }); - expect(ctx.cache.cachedRef).toBeUndefined(); - expect(ctx.warnings()).toEqual([]); - }).pipe(Effect.provide(ctx.layer)); - }); - - it.effect("lists the project of the branch --remote=<branch> names", () => { - const ctx = setup(tmp.current, { remote: ["20240101000000"] }); - return Effect.gen(function* () { - yield* seedMigrations(tmp.current, ["20240101000000_a.sql"]); - yield* migrationList(flags({ remote: Option.some({ _tag: "Branch", name: "feature-x" }) })); - expect(ctx.branchRequests.map(({ method, url }) => `${method} ${url}`)).toEqual([ - `GET https://api.supabase.com/v1/projects/${VALID_REF}/branches`, - ]); - expect(ctx.resolverCalls[0]?.target).toMatchObject({ - kind: "linked", - ref: BRANCH_PROJECT_REF, - }); - expect(ctx.cache.cachedRef).toBe(BRANCH_PROJECT_REF); - expect(ctx.warnings()).toEqual([]); - }).pipe(Effect.provide(ctx.layer)); - }); - - it.effect("fails with the branches that exist when --remote=<branch> matches none", () => { - const ctx = setup(tmp.current, { remote: [] }); - return Effect.gen(function* () { - const error = yield* migrationList( - flags({ remote: Option.some({ _tag: "Branch", name: "feature-y" }) }), - ).pipe(Effect.flip); - expect(error).toMatchObject({ - _tag: "LinkBranchNotFoundError", - message: expect.stringContaining("Available branches: feature-x"), - }); - expect(ctx.resolverCalls).toEqual([]); - }).pipe(Effect.provide(ctx.layer)); - }); - - it.effect("rejects --remote combined with --local before connecting", () => { - const ctx = setup(tmp.current, { remote: [] }); - return Effect.gen(function* () { - const error = yield* migrationList( - flags({ remote: Option.some({ _tag: "Linked" }), local: true }), - ).pipe(Effect.flip); - expect(error).toBeInstanceOf(TargetSelectorConflictError); - expect(ctx.resolverCalls).toEqual([]); - expect(ctx.cache.cachedRef).toBeUndefined(); - }).pipe(Effect.provide(ctx.layer)); - }); - - describe("deprecated selectors", () => { - const cases = [ - { - alias: "--linked", - args: ["migration", "list", "--linked"], - flags: { linked: true }, - target: { kind: "linked", ref: VALID_REF }, - warning: "--linked is deprecated and will be removed in v4. Use --remote instead", - }, - { - alias: "--project-ref", - args: ["migration", "list", "--project-ref", FLAG_REF], - flags: { projectRef: Option.some(FLAG_REF) }, - target: { kind: "linked", ref: FLAG_REF }, - warning: `--project-ref is deprecated and will be removed in v4. Use --remote=<ref> instead`, - }, - { - alias: "--db-url", - args: ["migration", "list", "--db-url", DB_URL], - flags: { dbUrl: Option.some(DB_URL) }, - target: { kind: "db-url" }, - warning: "--db-url is deprecated and will be removed in v4. Use --remote=<db-url> instead", - }, - { - alias: "--local", - args: ["migration", "list", "--local"], - flags: { local: true }, - target: { kind: "local" }, - warning: "--local isn't needed (local is the default) and will be removed in v4", - }, - ] as const satisfies ReadonlyArray<{ - readonly alias: string; - readonly args: ReadonlyArray<string>; - readonly flags: Partial<MigrationListFlags>; - readonly target: Record<string, string>; - readonly warning: string; - }>; - - for (const testCase of cases) { - it.effect(`${testCase.alias} still selects its target and warns`, () => { - const ctx = setup(tmp.current, { remote: [], args: testCase.args }); - return Effect.gen(function* () { - yield* migrationList(flags(testCase.flags)); - expect(ctx.resolverCalls[0]?.target).toMatchObject(testCase.target); - expect(ctx.warnings()).toEqual([testCase.warning]); - }).pipe(Effect.provide(ctx.layer)); - }); - } - - it.effect("rejects --project-ref combined with --local before connecting", () => { - const ctx = setup(tmp.current, { remote: [] }); - return Effect.gen(function* () { - const error = yield* migrationList( - flags({ local: true, projectRef: Option.some(FLAG_REF) }), - ).pipe(Effect.flip); - expect(error).toBeInstanceOf(TargetProjectRefConflictError); - expect(ctx.resolverCalls).toEqual([]); - expect(ctx.cache.cachedRef).toBeUndefined(); - }).pipe(Effect.provide(ctx.layer)); - }); - }); - it.effect("emits structured migrations in json", () => { const { layer, out } = setup(tmp.current, { format: "json", remote: ["20240102000000"] }); return Effect.gen(function* () { @@ -526,3 +387,21 @@ describe("migration list", () => { ); }); }); + +describeDbTargetSelection("migration list", { + dbUrl: true, + workdir: () => tmp.current, + attempt: ({ workdir, args, target }) => { + const ctx = setup(workdir, { remote: ["20240101000000"], args }); + return { + layer: ctx.layer, + out: ctx.out, + run: seedMigrations(workdir, ["20240101000000_a.sql"]).pipe( + Effect.andThen(migrationList(flags(target))), + ), + target: () => ctx.resolverCalls[0]?.target, + cachedRef: () => ctx.cache.cachedRef, + branchLookups: () => ctx.branchRequests.map(({ method, url }) => `${method} ${url}`), + }; + }, +}); 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 b1fd7c3706..7d8ea3a4a9 100644 --- a/apps/cli/src/commands/migration/repair/repair.integration.test.ts +++ b/apps/cli/src/commands/migration/repair/repair.integration.test.ts @@ -23,6 +23,7 @@ import { sequentialExecBatch, } from "../../../../tests/helpers/command-mocks.ts"; import { cliConfigValuesTestLayer } from "../../../../tests/helpers/config-values-layer.ts"; +import { describeDbTargetSelection } from "../../../../tests/helpers/db-target-selection.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"; @@ -51,22 +52,13 @@ 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 { - TargetPasswordConflictError, - TargetProjectRefConflictError, - TargetSelectorConflictError, -} from "../../../command-internal/target/target.errors.ts"; +import { TargetPasswordConflictError } 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"; -import { - BRANCH_PROJECT_REF, - branchRow, - warningsOf, -} from "../../../../tests/helpers/remote-selection.ts"; +import { branchRow } from "../../../../tests/helpers/remote-selection.ts"; const FLAG_REF = "flagflagflagflagflag"; -const DB_URL = "postgresql://postgres:postgres@127.0.0.1:54322/postgres"; interface SetupOpts { /** Runs the real `DbConfigResolver` and linked-project cache against `workdir`. */ @@ -213,7 +205,6 @@ function setup(workdir: string, opts: SetupOpts = {}) { cache, resolverCalls, branchRequests: branchApi.requests, - warnings: () => warningsOf(out), }; } @@ -439,177 +430,6 @@ describe("migration repair", () => { }).pipe(Effect.provide(layer)); }); - it.effect("rejects --remote combined with --local before touching the database", () => { - const { layer, execs, queries, cache } = setup(tmp.current); - return Effect.gen(function* () { - const error = yield* migrationRepair( - input({ - versions: ["20240101000000"], - status: "applied", - remote: Option.some({ _tag: "Linked" }), - local: true, - }), - ).pipe(Effect.flip); - expect(error).toBeInstanceOf(TargetSelectorConflictError); - expect(execs).toEqual([]); - expect(queries).toEqual([]); - expect(cache.cached).toBe(false); - }).pipe(Effect.provide(layer)); - }); - - it.effect("repairs the project given via --remote=<ref>", () => { - // VALID_REF is the fake resolver's fallback; the flag must win over it and drive - // the cached ref. - const ctx = setup(tmp.current, { yes: true }); - return Effect.gen(function* () { - yield* seedMigration(tmp.current, "20240101000000_init.sql", "create table a;\n"); - yield* migrationRepair( - input({ - versions: ["20240101000000"], - status: "applied", - remote: Option.some({ _tag: "Ref", ref: FLAG_REF }), - }), - ); - expect(ctx.cache.cached).toBe(true); - expect(ctx.cache.cachedRef).toBe(FLAG_REF); - expect(ctx.cache.cachedRef).not.toBe(VALID_REF); - expect(ctx.warnings()).toEqual([]); - }).pipe(Effect.provide(ctx.layer)); - }); - - it.effect("repairs the project of the branch --remote=<branch> names", () => { - const ctx = setup(tmp.current, { yes: true }); - return Effect.gen(function* () { - yield* seedMigration(tmp.current, "20240101000000_init.sql", "create table a;\n"); - yield* migrationRepair( - input({ - versions: ["20240101000000"], - status: "applied", - remote: Option.some({ _tag: "Branch", name: "feature-x" }), - }), - ); - expect(ctx.branchRequests.map(({ method, url }) => `${method} ${url}`)).toEqual([ - `GET https://api.supabase.com/v1/projects/${VALID_REF}/branches`, - ]); - expect(ctx.resolverCalls[0]?.target).toMatchObject({ - kind: "linked", - ref: BRANCH_PROJECT_REF, - }); - expect(ctx.cache.cachedRef).toBe(BRANCH_PROJECT_REF); - expect(ctx.warnings()).toEqual([]); - }).pipe(Effect.provide(ctx.layer)); - }); - - it.effect("fails with the branches that exist when --remote=<branch> matches none", () => { - const ctx = setup(tmp.current, { yes: 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", - remote: Option.some({ _tag: "Branch", name: "feature-y" }), - }), - ).pipe(Effect.flip); - expect(error).toMatchObject({ - _tag: "LinkBranchNotFoundError", - message: expect.stringContaining("Available branches: feature-x"), - }); - expect(ctx.resolverCalls).toEqual([]); - expect(ctx.execs).toEqual([]); - }).pipe(Effect.provide(ctx.layer)); - }); - - it.effect("repairs the database --remote=<url> names and never touches the linked cache", () => { - const ctx = setup(tmp.current, { yes: true }); - return Effect.gen(function* () { - yield* seedMigration(tmp.current, "20240101000000_init.sql", "create table a;\n"); - yield* migrationRepair( - input({ - versions: ["20240101000000"], - status: "applied", - remote: Option.some({ _tag: "DbUrl", url: DB_URL }), - }), - ); - expect(ctx.resolverCalls[0]?.target).toMatchObject({ kind: "db-url" }); - expect(ctx.execs).toContain("COMMIT"); - expect(ctx.cache.cached).toBe(false); - expect(ctx.warnings()).toEqual([]); - }).pipe(Effect.provide(ctx.layer)); - }); - - describe("deprecated selectors", () => { - const base = ["migration", "repair", "--status", "applied", "20240101000000"]; - const cases = [ - { - alias: "--linked", - args: [...base, "--linked"], - flags: { linked: true }, - target: { kind: "linked", ref: VALID_REF }, - warning: `--linked is deprecated and will be removed in v4. Use --remote instead`, - }, - { - alias: "--project-ref", - args: [...base, "--project-ref", FLAG_REF], - flags: { projectRef: Option.some(FLAG_REF) }, - target: { kind: "linked", ref: FLAG_REF }, - warning: `--project-ref is deprecated and will be removed in v4. Use --remote=<ref> instead`, - }, - { - alias: "--db-url", - args: [...base, "--db-url", DB_URL], - flags: { dbUrl: Option.some(DB_URL) }, - target: { kind: "db-url" }, - warning: `--db-url is deprecated and will be removed in v4. Use --remote=<db-url> instead`, - }, - { - alias: "--local", - args: [...base, "--local"], - flags: { local: true }, - target: { kind: "local" }, - warning: `--local isn't needed (local is the default) and will be removed in v4`, - }, - ] as const satisfies ReadonlyArray<{ - readonly alias: string; - readonly args: ReadonlyArray<string>; - readonly flags: Partial<MigrationRepairInput>; - readonly target: Record<string, string>; - readonly warning: string; - }>; - - for (const testCase of cases) { - it.effect(`${testCase.alias} still selects its target and warns`, () => { - const ctx = setup(tmp.current, { yes: true, args: testCase.args }); - return Effect.gen(function* () { - yield* seedMigration(tmp.current, "20240101000000_init.sql", "create table a;\n"); - yield* migrationRepair( - input({ versions: ["20240101000000"], status: "applied", ...testCase.flags }), - ); - expect(ctx.resolverCalls[0]?.target).toMatchObject(testCase.target); - expect(ctx.warnings()).toEqual([testCase.warning]); - }).pipe(Effect.provide(ctx.layer)); - }); - } - - it.effect("rejects --project-ref combined with --local before touching the database", () => { - const { layer, execs, queries, cache } = setup(tmp.current); - return Effect.gen(function* () { - const error = yield* migrationRepair( - input({ - versions: ["20240101000000"], - status: "applied", - 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 mode", () => { const { layer, out } = setup(tmp.current, { format: "json" }); return Effect.gen(function* () { @@ -732,6 +552,26 @@ describe("migration repair", () => { }); }); +describeDbTargetSelection("migration repair", { + dbUrl: true, + workdir: () => tmp.current, + attempt: ({ workdir, args, target }) => { + const ctx = setup(workdir, { yes: true, args }); + return { + layer: ctx.layer, + out: ctx.out, + run: seedMigration(workdir, "20240101000000_init.sql", "create table a;\n").pipe( + Effect.andThen( + migrationRepair(input({ versions: ["20240101000000"], status: "applied", ...target })), + ), + ), + target: () => ctx.resolverCalls[0]?.target, + cachedRef: () => ctx.cache.cachedRef, + branchLookups: () => ctx.branchRequests.map(({ method, url }) => `${method} ${url}`), + }; + }, +}); + describeHostedConsent("migration repair", { setup: (workdir) => seedMigration(workdir, "20240101000000_init.sql", "create table a;\n"), invoke: () => { 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 7e36cae6d6..47101989da 100644 --- a/apps/cli/src/commands/migration/squash/squash.integration.test.ts +++ b/apps/cli/src/commands/migration/squash/squash.integration.test.ts @@ -1,4 +1,5 @@ import { cliConfigValuesTestLayer } from "../../../../tests/helpers/config-values-layer.ts"; +import { describeDbTargetSelection } from "../../../../tests/helpers/db-target-selection.ts"; import { unusedStackServices } from "../../../../tests/helpers/unused-stack.ts"; import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; @@ -83,11 +84,7 @@ import { ShadowDbError } from "../../../command-internal/db-bootstrap/shadow-dat 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 { - TargetPasswordConflictError, - TargetProjectRefConflictError, - TargetSelectorConflictError, -} from "../../../command-internal/target/target.errors.ts"; +import { TargetPasswordConflictError } from "../../../command-internal/target/target.errors.ts"; import { MigrationFileNotFoundError, MigrationInvalidVersionError } from "../migration.errors.ts"; import type { MigrationSquashFlags } from "./squash.command.ts"; import { @@ -97,11 +94,7 @@ import { MigrationSquashWriteError, } from "./squash.errors.ts"; import { migrationSquash } from "./squash.handler.ts"; -import { - BRANCH_PROJECT_REF, - branchRow, - warningsOf, -} from "../../../../tests/helpers/remote-selection.ts"; +import { branchRow } from "../../../../tests/helpers/remote-selection.ts"; // Distinguishes squash's three pg_dump containers from the shadow's setup jobs by env: // only a pg_dump invocation carries PGDATABASE. Among dump calls, the first two sharing @@ -240,9 +233,6 @@ const alwaysReadyHttpClientLayer = Layer.succeed( ), ); -const FLAG_REF = "flagflagflagflagflag"; -const DB_URL = "postgresql://postgres:postgres@127.0.0.1:54322/postgres"; - interface SetupOpts { /** Runs the real `DbConfigResolver` and linked-project cache against `workdir`. */ readonly realLayers?: boolean; @@ -465,7 +455,6 @@ function setup(workdir: string, opts: SetupOpts = {}) { telemetry, cache, branchRequests: branchApi.requests, - warnings: () => warningsOf(out), ...fake, }; } @@ -533,44 +522,6 @@ const tmp = useTempWorkdir(); describe("migration squash", () => { describe("flag surface & ordering", () => { - it.effect("rejects --remote combined with --local", () => { - const s = setup(tmp.current); - return Effect.gen(function* () { - const error = yield* migrationSquash( - flags({ remote: Option.some({ _tag: "Linked" }), local: true }), - ).pipe(Effect.flip); - expect(error).toBeInstanceOf(TargetSelectorConflictError); - expect(s.resolverCalls).toEqual([]); - }).pipe(Effect.provide(s.layer)); - }); - - 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({ 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([]); - expect(s.setupJobCalls).toEqual([]); - expect(s.cache.cached).toBe(false); - }).pipe(Effect.provide(s.layer)); - }); - - 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) }), - ).pipe(Effect.flip); - expect(error).toBeInstanceOf(TargetProjectRefConflictError); - expect(s.resolverCalls).toEqual([]); - expect(s.cache.cached).toBe(false); - }).pipe(Effect.provide(s.layer)); - }); - it.effect("rejects --password combined with --remote=<url> through the real resolver", () => { const s = setup(tmp.current, { realLayers: true }); return Effect.gen(function* () { @@ -632,15 +583,6 @@ describe("migration squash", () => { expect(error).toBeInstanceOf(ProjectRefNotLinkedError); }).pipe(Effect.provide(s.layer)); }); - - it.effect("defaults to the local database when no target flag is given", () => { - const s = setup(tmp.current); - return Effect.gen(function* () { - yield* seedMigration(tmp.current, "0_init.sql"); - yield* migrationSquash(flags()); - expect(s.resolverCalls[0]?.target).toEqual({ kind: "local" }); - }).pipe(Effect.provide(s.layer)); - }); }); describe("squashToVersion", () => { @@ -1362,128 +1304,6 @@ describe("migration squash", () => { }).pipe(Effect.provide(s.layer)); }); - it.effect("--remote=<ref> overrides the workdir's own ref for resolution and caching", () => { - // VALID_REF is what the workdir would resolve to without the flag. - const s = setup(tmp.current); - return Effect.gen(function* () { - const error = yield* migrationSquash( - flags({ - remote: Option.some({ _tag: "Ref", ref: 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); - expect(s.warnings()).toEqual([]); - }).pipe(Effect.provide(s.layer)); - }); - - it.effect( - "--remote=<branch> resolves the branch's project ref for resolution and caching", - () => { - const s = setup(tmp.current); - return Effect.gen(function* () { - const error = yield* migrationSquash( - flags({ - remote: Option.some({ _tag: "Branch", name: "feature-x" }), - version: Option.some("bad"), - }), - ).pipe(Effect.flip); - expect(error).toBeInstanceOf(MigrationInvalidVersionError); - expect(s.branchRequests.map(({ method, url }) => `${method} ${url}`)).toEqual([ - `GET https://api.supabase.com/v1/projects/${VALID_REF}/branches`, - ]); - expect(s.resolverCalls[0]?.target).toMatchObject({ - kind: "linked", - ref: BRANCH_PROJECT_REF, - }); - expect(s.cache.cachedRef).toBe(BRANCH_PROJECT_REF); - expect(s.warnings()).toEqual([]); - }).pipe(Effect.provide(s.layer)); - }, - ); - - it.effect("--remote=<branch> fails with the branches that exist when none matches", () => { - const s = setup(tmp.current); - return Effect.gen(function* () { - const error = yield* migrationSquash( - flags({ remote: Option.some({ _tag: "Branch", name: "feature-y" }) }), - ).pipe(Effect.flip); - expect(error).toMatchObject({ - _tag: "LinkBranchNotFoundError", - message: expect.stringContaining("Available branches: feature-x"), - }); - expect(s.resolverCalls).toEqual([]); - expect(s.shadowSpawned).toEqual([]); - }).pipe(Effect.provide(s.layer)); - }); - - it.effect("--remote=<url> squashes against that database and skips the linked cache", () => { - const s = setupRemote(); - return Effect.gen(function* () { - yield* seedMigration(tmp.current, "0_init.sql"); - yield* migrationSquash(flags({ remote: Option.some({ _tag: "DbUrl", url: DB_URL }) })); - expect(s.resolverCalls[0]?.target).toMatchObject({ kind: "db-url" }); - expect(s.cache.cached).toBe(false); - expect(s.warnings()).toEqual([]); - }).pipe(Effect.provide(s.layer)); - }); - - describe("deprecated selectors", () => { - const cases = [ - { - alias: "--linked", - args: ["migration", "squash", "--linked"], - flags: { linked: true }, - target: { kind: "linked", ref: VALID_REF }, - warning: "--linked is deprecated and will be removed in v4. Use --remote instead", - }, - { - alias: "--project-ref", - args: ["migration", "squash", "--project-ref", FLAG_REF], - flags: { projectRef: Option.some(FLAG_REF) }, - target: { kind: "linked", ref: FLAG_REF }, - warning: `--project-ref is deprecated and will be removed in v4. Use --remote=<ref> instead`, - }, - { - alias: "--db-url", - args: ["migration", "squash", "--db-url", DB_URL], - flags: { dbUrl: Option.some(DB_URL) }, - target: { kind: "db-url" }, - warning: - "--db-url is deprecated and will be removed in v4. Use --remote=<db-url> instead", - }, - { - alias: "--local", - args: ["migration", "squash", "--local"], - flags: { local: true }, - target: { kind: "local" }, - warning: "--local isn't needed (local is the default) and will be removed in v4", - }, - ] as const satisfies ReadonlyArray<{ - readonly alias: string; - readonly args: ReadonlyArray<string>; - readonly flags: Partial<MigrationSquashFlags>; - readonly target: Record<string, string>; - readonly warning: string; - }>; - - for (const testCase of cases) { - it.effect(`${testCase.alias} still selects its target and warns`, () => { - const s = setupRemote({ args: testCase.args }); - return Effect.gen(function* () { - yield* seedMigration(tmp.current, "0_init.sql"); - yield* migrationSquash(flags(testCase.flags)); - expect(s.resolverCalls[0]?.target).toMatchObject(testCase.target); - expect(s.warnings()).toEqual([testCase.warning]); - }).pipe(Effect.provide(s.layer)); - }); - } - }); - it.effect( "--remote reads [remotes.<ref>] and prints the config-override line before the consent", () => { @@ -1671,6 +1491,24 @@ describe("migration squash", () => { }); }); +describeDbTargetSelection("migration squash", { + dbUrl: true, + workdir: () => tmp.current, + attempt: ({ workdir, args, target }) => { + const s = setup(workdir, { yes: true, args }); + return { + layer: s.layer, + out: s.out, + run: seedMigration(workdir, "0_init.sql").pipe( + Effect.andThen(migrationSquash(flags(target))), + ), + target: () => s.resolverCalls[0]?.target, + cachedRef: () => s.cache.cachedRef, + branchLookups: () => s.branchRequests.map(({ method, url }) => `${method} ${url}`), + }; + }, +}); + describeHostedConsent("migration squash", { setup: (workdir) => Effect.gen(function* () { From 0af31c0348ed449739a8dde6ef21c66ddee71635 Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Tue, 20 Oct 2026 13:31:39 +0100 Subject: [PATCH 207/291] test(cli): run config push, pull, diff and functions deploy through the selection matrix The four leaves hand-wrote the --remote=<ref>, --project-ref and branch-lookup rows that describeHostedTargetSelection already generates. Give each leaf one matrix call (with the project files the helper now seeds) and drop the hand copies; the central --remote=<ref> rows now assert that no lookup is made. --- ...resolve-project-target.integration.test.ts | 1 + .../target/resolve-target.integration.test.ts | 1 + .../config/diff/diff.integration.test.ts | 64 +++-------- .../config/pull/pull.integration.test.ts | 87 +++----------- .../config/push/push.integration.test.ts | 92 +++------------ .../deploy/deploy.integration.test.ts | 108 ++++-------------- .../tests/helpers/hosted-target-selection.ts | 24 ++-- 7 files changed, 82 insertions(+), 295 deletions(-) diff --git a/apps/cli/src/command-internal/target/resolve-project-target.integration.test.ts b/apps/cli/src/command-internal/target/resolve-project-target.integration.test.ts index a27c5cb1aa..3e156611bf 100644 --- a/apps/cli/src/command-internal/target/resolve-project-target.integration.test.ts +++ b/apps/cli/src/command-internal/target/resolve-project-target.integration.test.ts @@ -217,6 +217,7 @@ describe("resolveProjectTarget", () => { expect(refOf(result.exit)).toBe(FLAG_REF); expect(result.refFlags).toEqual([Option.some(FLAG_REF)]); expect(result.warnings).toEqual([]); + expect(result.branchRequests).toBe(0); }), ); 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 d3e3e0b0ca..f7bd872567 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 @@ -262,6 +262,7 @@ describe("resolveTarget", () => { matchesLinkedRef: false, }); expect(result.refFlags).toEqual([Option.some(FLAG_REF)]); + expect(result.apiRequests).toEqual([]); }), ); 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 90804659c1..3c4205dcf1 100644 --- a/apps/cli/src/commands/config/diff/diff.integration.test.ts +++ b/apps/cli/src/commands/config/diff/diff.integration.test.ts @@ -4,8 +4,8 @@ import { describe, expect, it } from "@effect/vitest"; import { Cause, Effect, Exit, FileSystem, Layer, Option, Path, Schema, Stdio } from "effect"; import { cliConfigValuesTestLayer } from "../../../../tests/helpers/config-values-layer.ts"; +import { describeHostedTargetSelection } from "../../../../tests/helpers/hosted-target-selection.ts"; import { - mockContextualAnalytics, mockOutput, mockProcessControl, mockRuntimeInfo, @@ -34,7 +34,6 @@ import { commandRuntimeLayer } from "../../../shared/runtime/command-runtime.lay import { configDiffHandler } from "./diff.command.ts"; import { CONFIG_DIFF_PAYLOAD_VERSION } from "./diff.format.ts"; import { configDiff } from "./diff.handler.ts"; -import { warningsOf } from "../../../../tests/helpers/remote-selection.ts"; const tempRoot = useTempWorkdir("supabase-config-diff-int-"); @@ -129,9 +128,6 @@ interface SetupOpts { readonly workdir?: string; /** cliSettings.explicitWorkdir override — true iff --workdir/SUPABASE_WORKDIR was set verbatim. */ readonly explicitWorkdir?: boolean; - readonly analytics?: ReturnType<typeof mockContextualAnalytics>; - /** The command path the deprecation warnings name in their example. */ - readonly commandPath?: ReadonlyArray<string>; } function setup(opts: SetupOpts = {}) { @@ -184,8 +180,6 @@ function setup(opts: SetupOpts = {}) { linkedProjectCache: linkedProjectCache.layer, processControl, outputFlag: opts.outputFlag === undefined ? Option.none() : Option.some(opts.outputFlag), - ...(opts.analytics === undefined ? {} : { analytics: opts.analytics }), - ...(opts.commandPath === undefined ? {} : { commandPath: opts.commandPath }), }), projectFiles, cliConfigValuesTestLayer, @@ -577,33 +571,12 @@ describe("config diff integration", () => { }).pipe(Effect.provide(layer)); }); - it.effect("a branch-named --remote resolves via the parent project", () => { - const { layer, out, api } = setup({ - toml: 'project_id = "test"\n', - v2: { status: 200, body: v2Response({ ref: BRANCH_REF }) }, - }); - return Effect.gen(function* () { - yield* configDiff(remoteBranch("staging")); - expect(out.stderrText).toContain( - `Comparing against 'staging' (branch ${BRANCH_REF}) using base config`, - ); - const urls = api.requests.map((request) => request.url); - expect(urls.some((url) => url.endsWith(`/v1/projects/${VALID_REF}/branches`))).toBe(true); - expect(urls.some((url) => url.includes(`/v2/projects/${BRANCH_REF}/config`))).toBe(true); - assertPermissionDrift({ - command: "config diff", - activeFlags: { remote: "staging" }, - requests: api.requests, - }); - }).pipe(Effect.provide(layer)); - }); - it.effect( "branch-name resolution uses the linked PARENT, not a branch ref in project-ref", () => { // project-ref holds the branch's own ref; linked-project.json recovers the parent, which the // parent-scoped branches endpoint requires. - const { layer, api } = setup({ + const { layer, out, api } = setup({ toml: 'project_id = "test"\n', linked: false, v2: { status: 200, body: v2Response({ ref: BRANCH_REF }) }, @@ -619,6 +592,9 @@ describe("config diff integration", () => { yield* jsonText({ ref: VALID_REF }), ); yield* configDiff(remoteBranch("staging")); + expect(out.stderrText).toContain( + `Comparing against 'staging' (branch ${BRANCH_REF}) using base config`, + ); const urls = api.requests.map((request) => request.url); expect(urls.some((url) => url.endsWith(`/v1/projects/${VALID_REF}/branches`))).toBe(true); expect(urls.some((url) => url.includes(`/v1/projects/${BRANCH_REF}/`))).toBe(false); @@ -688,26 +664,6 @@ describe("config diff integration", () => { }).pipe(Effect.provide(layer)); }); - it.effect("--project-ref still resolves a branch name and warns to use --remote", () => { - const { layer, out, api } = setup({ - toml: 'project_id = "test"\n', - v2: { status: 200, body: v2Response({ ref: BRANCH_REF }) }, - commandPath: ["config", "diff"], - }); - return Effect.gen(function* () { - yield* configDiff({ ...noFlags, projectRef: Option.some("staging") }); - expect(warningsOf(out)).toEqual([ - "--project-ref is deprecated and will be removed in v4. Use --remote=<branch> instead", - ]); - expect(out.stderrText).toContain( - `Comparing against 'staging' (branch ${BRANCH_REF}) using base config`, - ); - expect( - api.requests.some((request) => request.url.endsWith(`/v1/projects/${VALID_REF}/branches`)), - ).toBe(true); - }).pipe(Effect.provide(layer)); - }); - it.effect("an unknown branch fails listing the available branches", () => { const { layer, telemetry, linkedProjectCache } = setup({ toml: 'project_id = "test"\n', @@ -1486,3 +1442,13 @@ describe("config diff -o/--output wrapper wiring", () => { ); }); }); + +describeHostedTargetSelection("config diff", { + files: { "supabase/config.toml": 'project_id = "test"\n' }, + respond: (request, ref) => + Effect.succeed( + jsonResponse(request, 200, request.url.includes("/v2/projects/") ? v2Response({ ref }) : {}), + ), + run: (flags) => + configDiff({ ...noFlags, ...flags }).pipe(Effect.provide(cliConfigValuesTestLayer)), +}); 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 288a109a9f..028fde7ff3 100644 --- a/apps/cli/src/commands/config/pull/pull.integration.test.ts +++ b/apps/cli/src/commands/config/pull/pull.integration.test.ts @@ -26,6 +26,7 @@ import { mockTty, } from "../../../../tests/helpers/mocks.ts"; import { configValuesLayer } from "../../../../tests/helpers/config-values-layer.ts"; +import { describeHostedTargetSelection } from "../../../../tests/helpers/hosted-target-selection.ts"; import { buildTestRuntime, DEFAULT_API_URL, @@ -55,7 +56,6 @@ import { type ConfigPullSource, } from "./pull.handler.ts"; import type { ConfigPullFlags } from "./pull.command.ts"; -import { warningsOf } from "../../../../tests/helpers/remote-selection.ts"; // Setup mirrors ../diff/diff.integration.test.ts: same temp workdir helper, mocked // services, and v2Response() fixture. @@ -379,8 +379,6 @@ interface SetupOpts { /** Serves the project list and project lookup, answers the project picker with `VALID_REF`, and * writes the linked-project cache through the real cache layer. */ readonly pickerAndRealCache?: boolean; - /** The command path the deprecation warnings name in their example. */ - readonly commandPath?: ReadonlyArray<string>; readonly yes?: boolean; readonly stdinIsTty?: boolean; readonly confirm?: ReadonlyArray<boolean>; @@ -483,7 +481,6 @@ function setup(opts: SetupOpts = {}) { processControl, tty: mockTty({ stdinIsTty: opts.stdinIsTty ?? false, stdoutIsTty: false }), outputFlag: opts.outputFlag === undefined ? Option.none() : Option.some(opts.outputFlag), - ...(opts.commandPath === undefined ? {} : { commandPath: opts.commandPath }), }), mockStdin(opts.stdinIsTty ?? false), Layer.succeed(YesFlag, opts.yes ?? false), @@ -619,7 +616,7 @@ describe("config pull integration", () => { it.effect("a branch-named target creates a new [remotes.<name>] block at EOF", () => { const before = 'project_id = "test"\n'; - const { layer } = setup({ + const { layer, out } = setup({ toml: before, yes: true, v2: { @@ -635,6 +632,7 @@ describe("config pull integration", () => { }); return Effect.gen(function* () { yield* configPull({ ...noFlags, remote: branchRemote("staging") }); + expect(out.stderrText).toContain(`Pulling config from 'staging' (branch ${BRANCH_REF})`); const after = yield* readConfig; expect(after.startsWith(before)).toBe(true); const remotesIndex = after.indexOf("[remotes.staging]"); @@ -1756,56 +1754,6 @@ describe("config pull integration", () => { }); }); - it.effect("--remote=<ref> pulls from that project without a branch lookup", () => { - const { layer, api, out } = setup({ - toml: 'project_id = "test"\n', - yes: true, - projectId: Option.none(), - v2: { status: 200, body: v2Response({ ref: BRANCH_REF }) }, - }); - return Effect.gen(function* () { - yield* configPull({ ...noFlags, remote: refRemote(BRANCH_REF) }); - expect(out.stderrText).toContain(`Pulling config from project ${BRANCH_REF}`); - expect(api.requests.some((request) => request.url.includes("/branches"))).toBe(false); - }).pipe(Effect.provide(layer)); - }); - - it.effect("--project-ref still resolves a branch name and warns to use --remote", () => { - const { layer, out } = setup({ - toml: 'project_id = "test"\n', - yes: true, - v2: { status: 200, body: v2Response({ ref: BRANCH_REF }) }, - commandPath: ["config", "pull"], - }); - return Effect.gen(function* () { - yield* configPull({ ...noFlags, projectRef: Option.some("staging") }); - expect(warningsOf(out)).toEqual([ - "--project-ref is deprecated and will be removed in v4. Use --remote=<branch> instead", - ]); - expect(out.stderrText).toContain(`Pulling config from 'staging' (branch ${BRANCH_REF})`); - }).pipe(Effect.provide(layer)); - }); - - it.effect("a branch-named target resolves via the linked parent project", () => { - const { layer, api, out } = setup({ - toml: 'project_id = "test"\n', - yes: true, - v2: { status: 200, body: v2Response({ ref: BRANCH_REF }) }, - }); - return Effect.gen(function* () { - yield* configPull({ ...noFlags, remote: branchRemote("staging") }); - expect(out.stderrText).toContain(`Pulling config from 'staging' (branch ${BRANCH_REF})`); - const urls = api.requests.map((request) => request.url); - expect(urls.some((url) => url.endsWith(`/v1/projects/${VALID_REF}/branches`))).toBe(true); - expect(urls.some((url) => url.includes(`/v2/projects/${BRANCH_REF}/config`))).toBe(true); - assertPermissionDrift({ - command: "config pull", - activeFlags: { remote: "staging" }, - requests: api.requests, - }); - }).pipe(Effect.provide(layer)); - }); - it.effect("a branch UUID target resolves directly, even in an unlinked directory", () => { const { layer, api, out } = setup({ toml: 'project_id = "test"\n', @@ -2312,23 +2260,6 @@ describe("config pull integration", () => { }).pipe(Effect.provide(layer)); }); - it.effect("an unknown branch in json mode fails without a spinner", () => { - const { layer } = setup({ - toml: 'project_id = "test"\n', - format: "json", - branchList: { status: 200, body: [BRANCH_BY_NAME] }, - }); - return Effect.gen(function* () { - const exit = yield* configPull({ ...noFlags, remote: branchRemote("ghost") }).pipe( - Effect.exit, - ); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - expect(Cause.pretty(exit.cause)).toContain("LinkBranchNotFoundError"); - } - }).pipe(Effect.provide(layer)); - }); - it.effect("a single declared-but-unpushable property surfaces in the unmanaged note", () => { // enabled matches the remote and produces no change; its sibling allow_dynamic_registration // is what demonstrates declared-but-unpushable, since DISABLED_SENTINEL_PRUNES drops it from @@ -2832,3 +2763,15 @@ describe("config pull integration", () => { }, ); }); + +describeHostedTargetSelection("config pull", { + files: { "supabase/config.toml": 'project_id = "test"\n' }, + respond: (request, ref) => + Effect.succeed( + jsonResponse(request, 200, request.url.includes("/v2/projects/") ? v2Response({ ref }) : {}), + ), + run: (flags) => + configPull({ ...noFlags, ...flags }).pipe( + Effect.provide(Layer.mergeAll(configValuesLayer(), mockGitStatusSpawner().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 7077c6c2e2..f37a07aa89 100644 --- a/apps/cli/src/commands/config/push/push.integration.test.ts +++ b/apps/cli/src/commands/config/push/push.integration.test.ts @@ -16,7 +16,7 @@ import * as HttpClientRequest from "effect/http/HttpClientRequest"; import * as HttpClientResponse from "effect/http/HttpClientResponse"; import { cliConfigValuesTestLayer } from "../../../../tests/helpers/config-values-layer.ts"; -import { mockAnalytics, mockOutput } from "../../../../tests/helpers/mocks.ts"; +import { mockOutput } from "../../../../tests/helpers/mocks.ts"; import { buildTestRuntime, DEFAULT_API_URL, @@ -28,13 +28,14 @@ import { mockLinkedProjectCacheTracked, mockCommandPlatformApi, mockCommandPlatformApiService, - type MockCommandPlatformApiOpts, + type ApiHandler, 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 { describeHostedTargetSelection } from "../../../../tests/helpers/hosted-target-selection.ts"; import { mockRuntimeInfo, mockStdin, mockTty } from "../../../../tests/helpers/mocks.ts"; import { assertPermissionDrift } from "../../../../tests/helpers/permission-drift.ts"; import { realLinkedProjectCache } from "../../../../tests/helpers/permission-real-layers.ts"; @@ -49,7 +50,6 @@ import { cliConfigProviderLayer } from "../../../shared/config/cli-config-provid import { secretDigestHex } from "./push.secret.ts"; import { configDiff } from "../diff/diff.handler.ts"; import { configPush } from "./push.handler.ts"; -import { warningsOf } from "../../../../tests/helpers/remote-selection.ts"; const tempRoot = useTempWorkdir("supabase-config-push-int-"); @@ -274,8 +274,6 @@ interface SetupOptions { readonly workdir?: string; /** cliSettings.explicitWorkdir override — true iff --workdir/SUPABASE_WORKDIR was set verbatim. */ readonly explicitWorkdir?: boolean; - /** Analytics mock for tests asserting on captured telemetry events. */ - readonly analytics?: ReturnType<typeof mockAnalytics>; // Live target-detection probe and branch-name/UUID resolution. Defaults keep every // plain-project scenario working without opting in: the project probe succeeds with an // unnamed project, and branch-lookup endpoints degrade to "not found"/empty rather than @@ -294,8 +292,6 @@ interface SetupOptions { /** Answers the project picker with `REF` and writes the linked-project cache through the real * cache layer. */ readonly pickerAndRealCache?: boolean; - /** The command path the deprecation warnings name in their example. */ - readonly commandPath?: ReadonlyArray<string>; } type PushApiRoutes = Pick< @@ -311,7 +307,9 @@ type PushApiRoutes = Pick< | "branchById" >; -function pushApiHandler(opts: PushApiRoutes): MockCommandPlatformApiOpts["handler"] { +function pushApiHandler( + opts: PushApiRoutes, +): (request: Parameters<ApiHandler>[0]) => ReturnType<ApiHandler> { return (request) => { const url = request.url; if (url.includes("/billing/addons")) { @@ -451,8 +449,6 @@ function setup(opts: SetupOptions) { (opts.stdinIsTty ?? true) && (opts.interactive ?? true), }), - ...(opts.analytics === undefined ? {} : { analytics: opts.analytics }), - ...(opts.commandPath === undefined ? {} : { commandPath: opts.commandPath }), }), mockStdin(opts.stdinIsTty ?? true), Layer.succeed(YesFlag, opts.yes ?? false), @@ -3460,19 +3456,6 @@ describe("config push --remote branch name/UUID resolution (CLI-2289)", () => { }, ); - it.effect("--remote=<branch-name> announces the parent project and resolved branch", () => { - const { layer, out } = setup({ - toml: BRANCH_PUSH_TOML, - yes: true, - branchList: { status: 200, body: [BRANCH_BY_NAME] }, - v2: { status: 200, body: v2Response({ ref: BRANCH_REF }) }, - }); - return Effect.gen(function* () { - yield* configPush(pushRemoteBranch("staging")); - expect(out.stderrText).toContain(`Target: project ${REF} · branch staging (${BRANCH_REF})\n`); - }).pipe(Effect.provide(layer)); - }); - it.effect("--remote=<branch-name> resolution works without a spinner in json output mode", () => { const { layer, out } = setup({ toml: BRANCH_PUSH_TOML, @@ -3595,42 +3578,6 @@ describe("config push --remote branch name/UUID resolution (CLI-2289)", () => { }, ); - it.effect("--project-ref still resolves a branch name and warns to use --remote", () => { - const { layer, out } = setup({ - toml: BRANCH_PUSH_TOML, - yes: true, - branchList: { status: 200, body: [BRANCH_BY_NAME] }, - v2: { status: 200, body: v2Response({ ref: BRANCH_REF }) }, - commandPath: ["config", "push"], - }); - return Effect.gen(function* () { - yield* configPush({ remote: Option.none(), projectRef: Option.some("staging") }); - expect(warningsOf(out)).toEqual([ - "--project-ref is deprecated and will be removed in v4. Use --remote=<branch> instead", - ]); - expect(out.stderrText).toContain(`Target: project ${REF} · branch staging (${BRANCH_REF})\n`); - }).pipe(Effect.provide(layer)); - }); - - it.effect("--remote=<ref> pushes to that project without a branch lookup", () => { - const { layer, api } = setup({ - toml: BRANCH_PUSH_TOML, - yes: true, - projectId: Option.none(), - v2: { status: 200, body: v2Response({ ref: BRANCH_REF }) }, - }); - return Effect.gen(function* () { - yield* configPush({ - remote: Option.some<RemoteValue>({ _tag: "Ref", ref: BRANCH_REF }), - projectRef: Option.none(), - }); - expect(api.requests.some((r) => r.url.includes("/branches"))).toBe(false); - expect(api.requests.some((r) => r.url.includes(`/v2/projects/${BRANCH_REF}/config`))).toBe( - true, - ); - }).pipe(Effect.provide(layer)); - }); - it.effect("--remote=<unknown-branch-name> fails listing the available branches", () => { const { layer, api, telemetry, linkedProjectCache } = setup({ toml: BRANCH_PUSH_TOML, @@ -3655,26 +3602,6 @@ describe("config push --remote branch name/UUID resolution (CLI-2289)", () => { }).pipe(Effect.provide(layer)); }); - it.effect( - "a branch-name lookup failure in json mode still resolves cleanly without a spinner", - () => { - const { layer, api } = setup({ - toml: BRANCH_PUSH_TOML, - yes: true, - format: "json", - branchList: { status: 200, body: [BRANCH_BY_NAME] }, - }); - return Effect.gen(function* () { - const exit = yield* configPush(pushRemoteBranch("ghost")).pipe(Effect.exit); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - expect(Cause.pretty(exit.cause)).toContain("LinkBranchNotFoundError"); - } - expect(api.requests.some((r) => r.url.includes("/billing/addons"))).toBe(false); - }).pipe(Effect.provide(layer)); - }, - ); - it.effect("--remote=<branch-name> in an unlinked directory fails naming the value", () => { const { layer, api, telemetry, linkedProjectCache } = setup({ toml: BRANCH_PUSH_TOML, @@ -3772,6 +3699,13 @@ describe("config push --remote branch name/UUID resolution (CLI-2289)", () => { }); }); +describeHostedTargetSelection("config push", { + files: { "supabase/config.toml": BRANCH_PUSH_TOML }, + respond: (request, ref) => + pushApiHandler({ v2: { status: 200, body: v2Response({ ref }) } })(request), + run: (flags) => configPush(flags).pipe(Effect.provide(cliConfigValuesTestLayer)), +}); + describeHostedConsent("config push", { api: { handler: pushApiHandler({}) }, setup: (workdir) => 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 01b205f5a9..5978a63986 100644 --- a/apps/cli/src/commands/functions/deploy/deploy.integration.test.ts +++ b/apps/cli/src/commands/functions/deploy/deploy.integration.test.ts @@ -39,7 +39,6 @@ import { withConfigEnv, withEnvVar, } from "../../../../tests/helpers/command-mocks.ts"; -import { stripAnsi } from "../../../../tests/helpers/ansi.ts"; import { cliConfigValuesTestLayer } from "../../../../tests/helpers/config-values-layer.ts"; import { mockOutput, mockRuntimeInfo, mockTty } from "../../../../tests/helpers/mocks.ts"; import { assertPermissionDrift } from "../../../../tests/helpers/permission-drift.ts"; @@ -51,6 +50,7 @@ import { Output } from "../../../shared/output/output.service.ts"; import { commandRuntimeLayer } from "../../../shared/runtime/command-runtime.layer.ts"; import { mockChildProcessSpawner } from "../../../../tests/helpers/child-process-spawner.ts"; import { describeHostedConsent } from "../../../../tests/helpers/hosted-consent.ts"; +import { describeHostedTargetSelection } from "../../../../tests/helpers/hosted-target-selection.ts"; import { deployFunctions, shouldChmodBundleOutputDirectory, @@ -68,7 +68,7 @@ import { withJsonErrorHandling } from "../../../shared/output/json-error-handlin import { CommandPlatformApi } from "../../../auth/command-platform-api.service.ts"; import { functionsDeploy } from "./deploy.handler.ts"; import type { FunctionsDeployFlags } from "./deploy.command.ts"; -import { BRANCH_PROJECT_REF, branchRow } from "../../../../tests/helpers/remote-selection.ts"; +import { branchRow } from "../../../../tests/helpers/remote-selection.ts"; const decodeJson = Schema.decodeEffect(Schema.fromJsonString(Schema.Unknown)); @@ -451,8 +451,6 @@ describe("functions deploy", () => { }); describe("project selection", () => { - const OTHER_REF = "qrstuvwxyzabcdefghij"; - const selectionHarness = (args: ReadonlyArray<string>) => { const out = mockOutput({ format: "text" }); const api = mockCommandPlatformApi({ @@ -485,12 +483,7 @@ describe("functions deploy", () => { (request) => request.method === "POST" && request.url.endsWith("/functions/deploy"), ) .map((request) => request.url); - const warnings = () => - stripAnsi(out.stderrText) - .split("\n") - .filter((line) => line.startsWith("Warning: ")) - .map((line) => line.slice("Warning: ".length)); - return { api, layer, deployedTo, warnings }; + return { layer, deployedTo }; }; const linkParent = Effect.gen(function* () { @@ -499,83 +492,6 @@ describe("functions deploy", () => { yield* fs.writeFileString(`${tempRoot.current}/supabase/.temp/project-ref`, VALID_REF); }).pipe(Effect.provide(BunServices.layer)); - it.effect("deploys to the project --remote=<ref> names, with no deprecation warning", () => { - const { layer, deployedTo, warnings } = selectionHarness([ - "functions", - "deploy", - "hello-world", - `--remote=${OTHER_REF}`, - ]); - return Effect.gen(function* () { - yield* writeCliConfig(tempRoot.current); - yield* writeLocalFunction(tempRoot.current, "hello-world"); - - yield* functionsDeploy({ - ...baseFlags, - remote: Option.some({ _tag: "Ref", ref: OTHER_REF }), - }); - - expect(deployedTo()).toEqual([ - `https://api.supabase.com/v1/projects/${OTHER_REF}/functions/deploy`, - ]); - expect(warnings()).toEqual([]); - }).pipe(Effect.provide(layer), Effect.ensuring(removeTempRoot)); - }); - - it.effect("keeps --project-ref working and points at --remote=<ref>", () => { - const { layer, deployedTo, warnings } = selectionHarness([ - "functions", - "deploy", - "hello-world", - "--project-ref", - OTHER_REF, - ]); - return Effect.gen(function* () { - yield* writeCliConfig(tempRoot.current); - yield* writeLocalFunction(tempRoot.current, "hello-world"); - - yield* functionsDeploy({ ...baseFlags, projectRef: Option.some(OTHER_REF) }); - - expect(deployedTo()).toEqual([ - `https://api.supabase.com/v1/projects/${OTHER_REF}/functions/deploy`, - ]); - expect(warnings()).toEqual([ - `--project-ref is deprecated and will be removed in v4. Use --remote=<ref> instead`, - ]); - }).pipe(Effect.provide(layer), Effect.ensuring(removeTempRoot)); - }); - - it.effect("deploys to the project of the branch --remote=<branch> names", () => { - const { api, layer, deployedTo } = selectionHarness([ - "functions", - "deploy", - "hello-world", - "--remote=feature-x", - ]); - return Effect.gen(function* () { - yield* writeCliConfig(tempRoot.current); - yield* writeLocalFunction(tempRoot.current, "hello-world"); - yield* linkParent; - - yield* functionsDeploy({ - ...baseFlags, - remote: Option.some({ _tag: "Branch", name: "feature-x" }), - }); - - expect(requestLines(api.requests)[0]).toBe( - `GET https://api.supabase.com/v1/projects/${VALID_REF}/branches`, - ); - expect(deployedTo()).toEqual([ - `https://api.supabase.com/v1/projects/${BRANCH_PROJECT_REF}/functions/deploy`, - ]); - assertPermissionDrift({ - command: "functions deploy", - activeFlags: { ...apiPathFlags, remote: true }, - requests: api.requests, - }); - }).pipe(Effect.provide(layer), Effect.ensuring(removeTempRoot)); - }); - it.effect("fails with the branches that exist when no branch has that name", () => { const { layer, deployedTo } = selectionHarness([ "functions", @@ -3047,6 +2963,24 @@ describe("functions deploy", () => { }); }); +describeHostedTargetSelection("functions deploy", { + operands: ["hello-world"], + activeFlags: apiPathFlags, + files: { + "supabase/config.toml": 'project_id = "test-project"\n', + "supabase/functions/hello-world/index.ts": "Deno.serve(() => new Response())\n", + "supabase/functions/hello-world/deno.json": '{"imports":{}}\n', + }, + respond: (request) => + Effect.succeed( + request.method === "GET" + ? jsonResponse(request, 200, []) + : jsonResponse(request, 201, deployedFunction("hello-world")), + ), + run: (flags) => + functionsDeploy({ ...baseFlags, ...flags }).pipe(Effect.provide(cliConfigValuesTestLayer)), +}); + describeHostedConsent("functions deploy", { api: { handler: (request) => diff --git a/apps/cli/tests/helpers/hosted-target-selection.ts b/apps/cli/tests/helpers/hosted-target-selection.ts index 57e855b74c..ae759d3ed6 100644 --- a/apps/cli/tests/helpers/hosted-target-selection.ts +++ b/apps/cli/tests/helpers/hosted-target-selection.ts @@ -231,6 +231,8 @@ type HostedTargetSelectionServices = Layer.Success<ReturnType<typeof hostedLayer export interface HostedTargetSelectionScenario { readonly mode?: "project" | "parent"; readonly operands?: ReadonlyArray<string>; + /** Files the leaf reads from the project directory, relative to it. */ + readonly files?: Readonly<Record<string, string>>; /** * The command's own success response, given the project ref the run should act on; in * `"project"` mode the harness answers the branch lookup itself. @@ -245,16 +247,29 @@ export interface HostedTargetSelectionScenario { readonly activeFlags?: ActiveFlags; } +const writeFiles = (workdir: string, files: Readonly<Record<string, string>>) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + for (const [relativePath, contents] of Object.entries(files)) { + const absolutePath = path.join(workdir, relativePath); + yield* fs.makeDirectory(path.dirname(absolutePath), { recursive: true }); + yield* fs.writeFileString(absolutePath, contents); + } + }).pipe(Effect.provide(BunServices.layer)); + /** `describeTargetSelectionMatrix` for a hosted leaf whose runtime is `buildTestRuntime` over a mocked Management API. */ export function describeHostedTargetSelection( path: string, scenario: HostedTargetSelectionScenario, ): void { const mode = scenario.mode ?? "project"; + const { files } = scenario; describeTargetSelectionMatrix<HostedTargetSelectionServices>(path, { mode, operands: scenario.operands ?? [], ...(scenario.activeFlags === undefined ? {} : { activeFlags: scenario.activeFlags }), + ...(files === undefined ? {} : { prepare: (workdir) => writeFiles(workdir, files) }), run: scenario.run, harness: ({ selectorArgs, expectedRef, workdir }) => { const out = mockOutput({ format: "text" }); @@ -282,13 +297,6 @@ export function describeHostedTargetSelection( cachedRef: () => cache.cachedRef, }; }, - linkParent: (workdir) => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const temp = path.join(workdir, "supabase", ".temp"); - yield* fs.makeDirectory(temp, { recursive: true }); - yield* fs.writeFileString(path.join(temp, "project-ref"), VALID_REF); - }).pipe(Effect.provide(BunServices.layer)), + linkParent: (workdir) => writeFiles(workdir, { "supabase/.temp/project-ref": VALID_REF }), }); } From 53600e94c5ab73d53c26e2a82ea6db4eae4eae37 Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Tue, 20 Oct 2026 13:32:12 +0100 Subject: [PATCH 208/291] fix(cli): verify the cached parent before a branches command acts on it A stale linked-project.json could point every `branches *` command at the wrong parent project after a re-link. When the cached parent differs from the project-ref file, it now stands only while its branch list contains the file's ref; otherwise the file's project is used if its own branches can be listed. A listing that fails keeps the cached parent, and a run with no differing cache makes no extra request. resolveParentScopedProjectRef moves next to the branch lookup it now shares, so parent-project-ref.ts no longer imports the target module. --- .oxlintrc.effect.json | 1 + .../command-internal/parent-project-ref.ts | 79 +------ .../target/branch-resolution.ts | 4 +- ...ent-scoped-project-ref.integration.test.ts | 201 ++++++++++++++++++ .../target/parent-scoped-project-ref.ts | 48 +++++ .../target/resolve-project-target.ts | 2 +- .../src/commands/branches/branches.target.ts | 12 +- .../delete/delete.integration.test.ts | 7 + .../commands/branches/list/SIDE_EFFECTS.md | 7 +- .../branches/list/list.integration.test.ts | 16 +- 10 files changed, 295 insertions(+), 82 deletions(-) create mode 100644 apps/cli/src/command-internal/target/parent-scoped-project-ref.integration.test.ts create mode 100644 apps/cli/src/command-internal/target/parent-scoped-project-ref.ts diff --git a/.oxlintrc.effect.json b/.oxlintrc.effect.json index a3d69838c8..8ef77ee1e5 100644 --- a/.oxlintrc.effect.json +++ b/.oxlintrc.effect.json @@ -120,6 +120,7 @@ "!apps/cli/src/command-internal/string-slice-flag.*", "!apps/cli/src/command-internal/struct-output.*", "!apps/cli/src/command-internal/supabase-env.*", + "!apps/cli/src/command-internal/target/parent-scoped-project-ref.*", "!apps/cli/src/command-internal/tenant-*", "!apps/cli/src/command-internal/test-db.*", "!apps/cli/src/command-internal/timestamp.format.*", diff --git a/apps/cli/src/command-internal/parent-project-ref.ts b/apps/cli/src/command-internal/parent-project-ref.ts index 5d54de3e4c..7431c3d375 100644 --- a/apps/cli/src/command-internal/parent-project-ref.ts +++ b/apps/cli/src/command-internal/parent-project-ref.ts @@ -1,10 +1,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 { 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 = | { @@ -16,11 +14,7 @@ export type ParentRefResolution = | { readonly kind: "invalid" } | { readonly kind: "absent" }; -/** - * The subset of `<workdir>/supabase/.temp/linked-project.json` callers care about: the - * parent project's `ref`, `name`, `organization_slug`, and `organization_id`. The latter - * three are optional, present only when the cache holds a non-empty string value. - */ +/** The fields of `supabase/.temp/linked-project.json` callers read; all but `ref` are present only when non-empty. */ export interface CachedLinkedProject { readonly ref: string; readonly name?: string; @@ -37,11 +31,7 @@ function isRecord(value: unknown): value is Record<string, unknown> { return typeof value === "object" && value !== null; } -/** - * Best-effort parse of `<workdir>/supabase/.temp/linked-project.json`'s `ref` (and, when - * present, `name`/`organization_slug`/`organization_id`) fields. A missing/unreadable file, - * malformed JSON, or non-string/empty `ref` all degrade to `None` rather than failing. - */ +/** Parses `linked-project.json`; malformed JSON or a missing `ref` is `None`, never a failure. */ export function parseCachedLinkedProject(content: string): Option.Option<CachedLinkedProject> { try { const parsed: unknown = JSON.parse(content); @@ -83,9 +73,8 @@ function parseCachedParentRef(content: string): Option.Option<string> { function classifyParentCandidates( candidates: ReadonlyArray<Option.Option<string>>, ): ParentRefResolution { - // The first present candidate decides: a malformed higher-priority candidate (e.g. a - // typo'd SUPABASE_PROJECT_ID) hard-classifies as invalid rather than falling through to a - // lower-priority project, which could otherwise run a mutation against the wrong project. + // The first present candidate decides: a malformed higher-priority one stays invalid rather than + // falling through to a lower-priority project that a mutation could then hit by mistake. for (const candidate of candidates) { if (Option.isSome(candidate)) { return PROJECT_REF_PATTERN.test(candidate.value) @@ -97,10 +86,8 @@ function classifyParentCandidates( } /** - * Resolves the linked PARENT project ref, not the branch's own ref that `resolve`/`resolveOptional` - * return right after `link <branch>`. The first present candidate wins: `SUPABASE_PROJECT_ID`, the - * `linked-project.json` cache, then the temp `project-ref` file. A malformed candidate is - * `"invalid"` and never falls through; no candidate at all is `"absent"`. + * The linked PARENT project ref, not the branch's own ref that `resolve` returns right after + * `link <branch>`: `SUPABASE_PROJECT_ID`, then the `linked-project.json` cache, then `project-ref`. */ export const resolveLinkedParentRef = Effect.fnUntraced(function* () { const cliSettings = yield* CommandSettings; @@ -140,45 +127,7 @@ export const resolveLinkedParentRef = Effect.fnUntraced(function* () { return Option.isSome(linkedRef) ? { ...parent, linkedRef: linkedRef.value } : parent; }); -/** - * Project-ref resolver for the PARENT-SCOPED `branches` command family: after `link <branch>`, - * the temp `project-ref` file holds the branch's own ref, but branch-management endpoints - * return 403 for branch refs, so this resolves the parent instead. An explicit `flagValue` - * always wins; otherwise it prefers `resolveLinkedParentRef`'s cached parent, falling back to - * `resolver.resolve` unchanged when no parent is resolvable. - */ -export const resolveParentScopedProjectRef = Effect.fn("ParentProjectRef.resolve")(function* ( - flagValue: Option.Option<string>, -) { - const resolver = yield* ProjectRefResolver; - - if (Option.isSome(flagValue) && flagValue.value.length > 0) { - yield* Effect.annotateCurrentSpan("parent_project_ref.source", "flag"); - return yield* resolver.resolve(flagValue); - } - - const parent = yield* resolveLinkedParentRef(); - 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, - ); - yield* announceTarget(describeLinkedTarget({ ref: parent.ref, cache })); - return parent.ref; - } - - yield* Effect.annotateCurrentSpan("parent_project_ref.source", "resolver"); - return yield* resolver.resolve(Option.none()); -}); - -/** - * A plausible ref typo: all lowercase letters but not 20 of them, or 20 letters with an uppercase - * one. Appended to {@link parentNotLinkedMessage} and `link`'s own branch-not-found message. - */ +/** A hint for a value that looks like a mistyped project ref; empty otherwise. */ export function parentRefTypoHint(value: string): string { if (/^[a-z]+$/.test(value)) { return `\n If you meant a project ref: refs are exactly 20 lowercase letters ("${value}" has ${value.length}).`; @@ -189,11 +138,7 @@ export function parentRefTypoHint(value: string): string { return ""; } -/** - * Shared "no project is linked to search for branches" message, produced when - * {@link resolveLinkedParentRef} reports `"absent"` for a non-ref-shaped - * `--project-ref`/positional value. Used by both `link` and `config diff`. - */ +/** The error for a branch name looked up with no linked project, shared by `link` and `config diff`. */ export function parentNotLinkedMessage(value: string): string { return ( `Cannot resolve "${value}": it is not a project ref (refs are exactly 20 lowercase letters, ` + @@ -204,11 +149,7 @@ export function parentNotLinkedMessage(value: string): string { ); } -/** - * Shared "the linked project ref is invalid" message, produced when - * {@link resolveLinkedParentRef} reports `"invalid"` for a non-ref-shaped - * `--project-ref`/positional value. Used by both `link` and `config diff`. - */ +/** The error for a branch name looked up when the linked project ref is invalid, shared by `link` and `config diff`. */ export function parentRefInvalidMessage(value: string): string { return `Cannot resolve branch "${value}": the linked project ref is invalid (checked SUPABASE_PROJECT_ID, supabase/.temp/linked-project.json, supabase/.temp/project-ref). Relink the parent project first: supabase link --project-ref <parent-ref>`; } diff --git a/apps/cli/src/command-internal/target/branch-resolution.ts b/apps/cli/src/command-internal/target/branch-resolution.ts index 97dbbf8fda..824ccdaaf4 100644 --- a/apps/cli/src/command-internal/target/branch-resolution.ts +++ b/apps/cli/src/command-internal/target/branch-resolution.ts @@ -176,7 +176,7 @@ const listParentBranches = Effect.fnUntraced(function* (parentRef: string, label return branches; }); -type ResolvedParent = Extract<ParentRefResolution, { readonly kind: "resolved" }>; +export type ResolvedParent = Extract<ParentRefResolution, { readonly kind: "resolved" }>; /** * The branches of the parent a lookup runs under. A cached parent stands only while it lists the @@ -185,7 +185,7 @@ type ResolvedParent = Extract<ParentRefResolution, { readonly kind: "resolved" } */ export const listLinkedParentBranches = Effect.fnUntraced(function* ( parent: ResolvedParent, - label: string, + label?: string, ) { const cached = yield* listParentBranches(parent.ref, label); const { linkedRef } = parent; diff --git a/apps/cli/src/command-internal/target/parent-scoped-project-ref.integration.test.ts b/apps/cli/src/command-internal/target/parent-scoped-project-ref.integration.test.ts new file mode 100644 index 0000000000..848656c949 --- /dev/null +++ b/apps/cli/src/command-internal/target/parent-scoped-project-ref.integration.test.ts @@ -0,0 +1,201 @@ +import { describe, expect, it } from "@effect/vitest"; +import { Effect, FileSystem, Option, Path } from "effect"; + +import { + buildTestRuntime, + jsonResponse, + mockCommandPlatformApi, + mockCommandSettings, + unverifiedTargetBanner, + useTempWorkdir, +} from "../../../tests/helpers/command-mocks.ts"; +import { mockOutput } from "../../../tests/helpers/mocks.ts"; +import { assertBlockDrift } from "../../../tests/helpers/permission-drift.ts"; +import { branchesParentTarget } from "../../commands/branches/branches.target.ts"; +import type { RemoteValue } from "./remote-value.ts"; +import { resolveProjectTarget } from "./resolve-project-target.ts"; + +const CACHED_PARENT = "cachedparentprojecta"; +const LINKED_REF = "linkedprojectrefabcd"; +const OTHER_BRANCH_REF = "otherbranchrefabcdef"; +const ENV_REF = "envprojectrefaaaaaaa"; +const FLAG_REF = "flagprojectrefabcdef"; +const API = "https://api.supabase.com"; + +const tempRoot = useTempWorkdir("supabase-parent-scoped-ref-int-"); + +interface Listing { + readonly status: number; + readonly body: unknown; +} + +const branchRow = (projectRef: string) => ({ + id: "11111111-2222-4333-8444-555555555555", + name: `branch-${projectRef}`, + project_ref: projectRef, + parent_project_ref: CACHED_PARENT, + is_default: false, + persistent: false, + status: "MIGRATIONS_PASSED", + created_at: "2026-05-27T01:02:03Z", + updated_at: "2026-05-27T01:02:04Z", + with_data: true, +}); + +const branches = (...refs: ReadonlyArray<string>): Listing => ({ + status: 200, + body: refs.map(branchRow), +}); + +const failure = (status: number): Listing => ({ status, body: { message: "failed" } }); + +const listingRequest = (projectRef: string) => `GET ${API}/v1/projects/${projectRef}/branches`; + +interface LinkState { + readonly file?: string; + readonly cache?: string; + readonly env?: string; + readonly remote?: RemoteValue; +} + +interface DifferingCacheCase { + readonly name: string; + readonly listings: Readonly<Record<string, Listing>>; + readonly expected: { readonly ref: string; readonly requests: string[]; readonly banner: string }; +} + +interface NoCheckCase { + readonly name: string; + readonly state: LinkState; + readonly ref: string; +} + +function resolveParent(state: LinkState, listings: Readonly<Record<string, Listing>>) { + const out = mockOutput(); + const api = mockCommandPlatformApi({ + handler: (request) => { + const projectRef = /\/v1\/projects\/([a-z]{20})\/branches$/.exec(request.url)?.[1]; + const listing = projectRef === undefined ? undefined : listings[projectRef]; + return Effect.succeed( + listing === undefined + ? jsonResponse(request, 404, { message: "unexpected request" }) + : jsonResponse(request, listing.status, listing.body), + ); + }, + }); + const layer = buildTestRuntime({ + out, + api, + cliSettings: mockCommandSettings({ + workdir: tempRoot.current, + projectId: Option.fromNullishOr(state.env), + }), + }); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = path.join(tempRoot.current, "supabase", ".temp"); + yield* fs.makeDirectory(tempDir, { recursive: true }); + if (state.file !== undefined) { + yield* fs.writeFileString(path.join(tempDir, "project-ref"), state.file); + } + if (state.cache !== undefined) { + yield* fs.writeFileString( + path.join(tempDir, "linked-project.json"), + `{"ref":"${state.cache}","name":"Parent Project","organization_id":"org_1","organization_slug":"acme"}`, + ); + } + const ref = yield* resolveProjectTarget( + { remote: Option.fromNullishOr(state.remote), projectRef: Option.none() }, + { parent: true }, + ); + assertBlockDrift(branchesParentTarget, { requests: api.requests }); + return { + ref, + requests: api.requests.map(({ method, url }) => `${method} ${url}`), + banner: out.stderrText, + }; + }).pipe(Effect.provide(layer)); +} + +describe("a parent-scoped command whose cached parent differs from the linked project", () => { + const cachedParentBanner = `Target: project Parent Project (${CACHED_PARENT}) · production\n`; + + const differingCacheCases: ReadonlyArray<DifferingCacheCase> = [ + { + name: "keeps the cached parent while it lists the linked project as a branch", + listings: { [CACHED_PARENT]: branches(OTHER_BRANCH_REF, LINKED_REF) }, + expected: { + ref: CACHED_PARENT, + requests: [listingRequest(CACHED_PARENT)], + banner: cachedParentBanner, + }, + }, + { + name: "switches to the linked project, announced without the cache's name, once the cached parent no longer lists it", + listings: { [CACHED_PARENT]: branches(OTHER_BRANCH_REF), [LINKED_REF]: branches() }, + expected: { + ref: LINKED_REF, + requests: [listingRequest(CACHED_PARENT), listingRequest(LINKED_REF)], + banner: unverifiedTargetBanner(LINKED_REF), + }, + }, + { + name: "keeps the cached parent when the linked project's own branches cannot be listed", + listings: { [CACHED_PARENT]: branches(OTHER_BRANCH_REF), [LINKED_REF]: failure(403) }, + expected: { + ref: CACHED_PARENT, + requests: [listingRequest(CACHED_PARENT), listingRequest(LINKED_REF)], + banner: cachedParentBanner, + }, + }, + { + name: "keeps the cached parent when its own branches cannot be listed", + listings: { [CACHED_PARENT]: failure(500), [LINKED_REF]: branches() }, + expected: { + ref: CACHED_PARENT, + requests: [listingRequest(CACHED_PARENT)], + banner: cachedParentBanner, + }, + }, + ]; + + it.effect.each(differingCacheCases)("$name", ({ listings, expected }) => + Effect.gen(function* () { + const resolved = yield* resolveParent({ file: LINKED_REF, cache: CACHED_PARENT }, listings); + expect(resolved).toEqual(expected); + }), + ); + + const noCheckCases: ReadonlyArray<NoCheckCase> = [ + { + name: "there is no cache", + state: { file: LINKED_REF }, + ref: LINKED_REF, + }, + { + name: "the cache names the linked project itself", + state: { file: CACHED_PARENT, cache: CACHED_PARENT }, + ref: CACHED_PARENT, + }, + { + name: "SUPABASE_PROJECT_ID names the project", + state: { file: LINKED_REF, cache: CACHED_PARENT, env: ENV_REF }, + ref: ENV_REF, + }, + { + name: "--remote=<ref> names the project", + state: { file: LINKED_REF, cache: CACHED_PARENT, remote: { _tag: "Ref", ref: FLAG_REF } }, + ref: FLAG_REF, + }, + ]; + + it.effect.each(noCheckCases)( + "lists no branches to check the cache when $name", + ({ state, ref }) => + Effect.gen(function* () { + const resolved = yield* resolveParent(state, {}); + expect(resolved).toMatchObject({ ref, requests: [] }); + }), + ); +}); diff --git a/apps/cli/src/command-internal/target/parent-scoped-project-ref.ts b/apps/cli/src/command-internal/target/parent-scoped-project-ref.ts new file mode 100644 index 0000000000..98f8b6007c --- /dev/null +++ b/apps/cli/src/command-internal/target/parent-scoped-project-ref.ts @@ -0,0 +1,48 @@ +import { Effect, FileSystem, Option, Path } from "effect"; + +import { CommandSettings } from "../../config/command-settings.service.ts"; +import { ProjectRefResolver } from "../../config/project-ref.service.ts"; +import { announceTarget } from "../../shared/output/command-target.service.ts"; +import { readCachedLinkedProject, resolveLinkedParentRef } from "../parent-project-ref.ts"; +import { listLinkedParentBranches, type ResolvedParent } from "./branch-resolution.ts"; +import { describeLinkedTarget } from "./target-identity.ts"; + +/** The cache outlives a re-link, so it stands only while it lists the linked project; a check that fails keeps it. */ +const checkedParentRef = Effect.fnUntraced(function* (parent: ResolvedParent) { + if (parent.linkedRef === undefined) return parent.ref; + const { parentRef } = yield* listLinkedParentBranches(parent).pipe( + Effect.orElseSucceed(() => ({ parentRef: parent.ref })), + ); + yield* Effect.annotateCurrentSpan("parent_project_ref.cache_stale", parentRef !== parent.ref); + return parentRef; +}); + +/** The project a `branches` command acts on: an explicit ref, else the linked parent, since a branch's own ref gets a 403 from branch-management endpoints. */ +export const resolveParentScopedProjectRef = Effect.fn("ParentProjectRef.resolve")(function* ( + flagValue: Option.Option<string>, +) { + const resolver = yield* ProjectRefResolver; + + if (Option.isSome(flagValue) && flagValue.value.length > 0) { + yield* Effect.annotateCurrentSpan("parent_project_ref.source", "flag"); + return yield* resolver.resolve(flagValue); + } + + const parent = yield* resolveLinkedParentRef(); + yield* Effect.annotateCurrentSpan("parent_project_ref.linked_parent", parent.kind); + if (parent.kind === "resolved") { + yield* Effect.annotateCurrentSpan("parent_project_ref.source", "linked_parent"); + const ref = yield* checkedParentRef(parent); + const cliSettings = yield* CommandSettings; + const cache = yield* readCachedLinkedProject( + yield* FileSystem.FileSystem, + yield* Path.Path, + cliSettings.workdir, + ); + yield* announceTarget(describeLinkedTarget({ ref, cache })); + return ref; + } + + yield* Effect.annotateCurrentSpan("parent_project_ref.source", "resolver"); + return yield* resolver.resolve(Option.none()); +}); diff --git a/apps/cli/src/command-internal/target/resolve-project-target.ts b/apps/cli/src/command-internal/target/resolve-project-target.ts index 63fe8709dc..b22c3eb291 100644 --- a/apps/cli/src/command-internal/target/resolve-project-target.ts +++ b/apps/cli/src/command-internal/target/resolve-project-target.ts @@ -1,6 +1,6 @@ import { Effect, Option } from "effect"; -import { resolveParentScopedProjectRef } from "../parent-project-ref.ts"; +import { resolveParentScopedProjectRef } from "./parent-scoped-project-ref.ts"; import { resolveHostedRemote, selectTarget } from "./remote-resolution.ts"; import type { ProjectTargetFlagValues } from "./target-flags.ts"; import { TargetNeedsProjectError } from "./target.errors.ts"; diff --git a/apps/cli/src/commands/branches/branches.target.ts b/apps/cli/src/commands/branches/branches.target.ts index 7f7f3de945..45a4cb57a2 100644 --- a/apps/cli/src/commands/branches/branches.target.ts +++ b/apps/cli/src/commands/branches/branches.target.ts @@ -1,4 +1,12 @@ import { block } from "../../command-internal/command-permissions/model.ts"; -/** `--remote` only picks the parent project; it never changes which operations a branches command calls. */ -export const branchesParentTarget = block("branches-parent-target", [], ["remote"]); +/** `--remote=<ref>` names the parent outright; otherwise a cached parent is checked against its own branches. */ +export const branchesParentTarget = block("branches-parent-target", [ + { + operationId: "v1-list-all-branches", + kind: "best-effort", + when: [{ selector: ["none", "bare"] }], + context: + "a cached parent differs from the linked project, so its branches are listed to check it", + }, +]); 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 fa39e8661a..2905df7bc6 100644 --- a/apps/cli/src/commands/branches/delete/delete.integration.test.ts +++ b/apps/cli/src/commands/branches/delete/delete.integration.test.ts @@ -232,6 +232,12 @@ describe("branches delete integration", () => { if (request.method === "DELETE" && request.url.includes("/v1/branches/")) { return jsonResponse(request, 200, { message: "ok" }); } + if ( + request.method === "GET" && + request.url === `${API}/v1/projects/${PARENT_REF}/branches` + ) { + return jsonResponse(request, 200, [branchListing(BRANCH_OWN_REF, "linked-branch")]); + } return jsonResponse(request, 200, null); }), }); @@ -262,6 +268,7 @@ describe("branches delete integration", () => { const del = api.requests.find((r) => r.method === "DELETE"); expect(del?.url).toContain(`/v1/branches/${RESOLVED_BRANCH_UUID}`); expect(sequence(api.requests)).toEqual([ + `GET ${API}/v1/projects/${PARENT_REF}/branches`, `GET ${API}/v1/projects/${PARENT_REF}/branches/my-feature`, `DELETE ${API}/v1/branches/${RESOLVED_BRANCH_UUID}`, ]); diff --git a/apps/cli/src/commands/branches/list/SIDE_EFFECTS.md b/apps/cli/src/commands/branches/list/SIDE_EFFECTS.md index d38f94b0ce..95dfc23888 100644 --- a/apps/cli/src/commands/branches/list/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/branches/list/SIDE_EFFECTS.md @@ -15,12 +15,15 @@ Target: hosted, no consent — see [Command target](../../../../docs/command-tar > `branches` is PARENT-scoped: after `supabase link <branch>`, the on-disk `project-ref` file > holds the BRANCH's own ref, and the Management API returns 403 for a branch ref on every > branches-management endpoint. Every `branches` subcommand therefore resolves the project ref -> via `resolveParentScopedProjectRef` (`command-internal/parent-project-ref.ts`) instead +> via `resolveParentScopedProjectRef` (`command-internal/target/parent-scoped-project-ref.ts`) instead > of calling `ProjectRefResolver.resolve` directly: an explicit `--remote=<ref>` still wins > outright; otherwise the PARENT is resolved as env `SUPABASE_PROJECT_ID` → `linked-project.json`'s > `ref` → the `project-ref` file, first ref-shaped candidate wins, falling through to > `resolver.resolve(None)`'s ordinary env/prompt/not-linked behavior when no candidate is -> ref-shaped. No-op when linked to a real (non-branch) project, since the cache and the +> ref-shaped. A cached parent that differs from the `project-ref` file stands only while its +> branch list contains the file's ref (one extra `GET /v1/projects/{ref}/branches`); otherwise +> the file's project is used when its own branches can be listed, and a failed check keeps the +> cached parent. No-op when linked to a real (non-branch) project, since the cache and the > `project-ref` file then hold the same ref (CLI-2167). > Pretty-table rendering ALSO makes its own independent, soft read of `<workdir>/supabase/.temp/project-ref` 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 ff1dc09a83..5fe6eb06ad 100644 --- a/apps/cli/src/commands/branches/list/list.integration.test.ts +++ b/apps/cli/src/commands/branches/list/list.integration.test.ts @@ -67,6 +67,8 @@ const EXPLICIT_REF = "explicitprojectrefzz"; const ENV_REF = "envprojectrefaaaaaaa"; const CACHE_REF = "cacheprojectrefbbbbb"; const FILE_ONLY_REF = "fileonlyprojectrefcc"; +// A cached parent stands only while its branch list contains the linked branch. +const LINKED_BRANCH: Branches[number] = { ...SAMPLE_BRANCH, project_ref: BRANCH_OWN_REF }; function writeTempContent(workdir: string, name: string, content: string) { return Effect.gen(function* () { @@ -386,14 +388,16 @@ describe("branches list integration", () => { () => { const { layer, out, api, workdir } = setup({ projectId: Option.none(), - response: [SAMPLE_BRANCH], + response: [LINKED_BRANCH], }); return Effect.gen(function* () { yield* writeProjectRefFile(workdir, BRANCH_OWN_REF); yield* writeLinkedProjectCacheFile(workdir, PARENT_REF); yield* branchesList({ remote: Option.none(), projectRef: Option.none() }); - expect(api.requests).toHaveLength(1); - expect(api.requests[0]?.url).toContain(`/v1/projects/${PARENT_REF}/branches`); + expect(api.requests.map(({ url }) => url)).toEqual([ + `https://api.supabase.com/v1/projects/${PARENT_REF}/branches`, + `https://api.supabase.com/v1/projects/${PARENT_REF}/branches`, + ]); expect(out.stdoutText).toContain("STATUS"); expect(out.stdoutText).toContain("feat-1"); assertPermissionDrift({ command: "branches list", requests: api.requests, exact: true }); @@ -404,7 +408,7 @@ 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], + response: [LINKED_BRANCH], }); return Effect.gen(function* () { yield* writeProjectRefFile(workdir, BRANCH_OWN_REF); @@ -495,13 +499,13 @@ describe("branches list integration", () => { () => { const { layer, api, workdir } = setup({ projectId: Option.some(BRANCH_OWN_REF), - response: [SAMPLE_BRANCH], + response: [LINKED_BRANCH], }); return Effect.gen(function* () { yield* writeProjectRefFile(workdir, BRANCH_OWN_REF); yield* writeLinkedProjectCacheFile(workdir, PARENT_REF); yield* branchesList({ remote: Option.none(), projectRef: Option.none() }); - expect(api.requests[0]?.url).toContain(`/v1/projects/${PARENT_REF}/branches`); + expect(api.requests.at(-1)?.url).toContain(`/v1/projects/${PARENT_REF}/branches`); }).pipe(Effect.provide(layer)); }, ); From 7bbaeea2dbbb9fcafbf384c3f00047ec29a56b88 Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Tue, 20 Oct 2026 13:33:27 +0100 Subject: [PATCH 209/291] test(cli): cover target selection for the inspect leaves with the shared harness --- .../db/inspect-query.integration.test.ts | 140 +++----------- .../inspect/report/report.integration.test.ts | 176 +++--------------- 2 files changed, 43 insertions(+), 273 deletions(-) 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 55afeab9a7..667884c42d 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 @@ -24,6 +24,7 @@ import { useTempWorkdir, } from "../../../../tests/helpers/command-mocks.ts"; import { configValuesLayer } from "../../../../tests/helpers/config-values-layer.ts"; +import { describeDbTargetSelection } from "../../../../tests/helpers/db-target-selection.ts"; import { assertPermissionDrift } from "../../../../tests/helpers/permission-drift.ts"; import { asRecordedRequests, @@ -43,11 +44,7 @@ import type { } 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 { TargetEmptyValueError } 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"; @@ -55,11 +52,7 @@ 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 type { InspectConnectionFlags } from "./inspect-query.ts"; -import { - BRANCH_PROJECT_REF, - branchRow, - warningsOf, -} from "../../../../tests/helpers/remote-selection.ts"; +import { branchRow, warningsOf } from "../../../../tests/helpers/remote-selection.ts"; const FLAG_REF = "flagflagflagflagflag"; @@ -391,116 +384,25 @@ describe("inspect db query runner", () => { }).pipe(Effect.provide(layer)); }); - it.effect("--remote=<branch> targets the project of the branch it names", () => { - const { layer, resolver, branchRequests, warnings } = setup({ - rows: [DB_STATS_ROW], - projectId: VALID_REF, - }); - return Effect.gen(function* () { - yield* inspectDbDbStats( - flags({ remote: Option.some({ _tag: "Branch", name: "feature-x" } as const) }), - ); - expect(branchRequests.map(({ method, url }) => `${method} ${url}`)).toEqual([ - `GET https://api.supabase.com/v1/projects/${VALID_REF}/branches`, - ]); - expect(resolver.connectionInput?.target).toMatchObject({ - kind: "linked", - ref: BRANCH_PROJECT_REF, - }); - expect(warnings()).toEqual([]); - }).pipe(Effect.provide(layer)); - }); - - it.effect("--remote=<branch> fails with the branches that exist when none matches", () => { - const { layer, resolver } = setup({ rows: [DB_STATS_ROW], projectId: VALID_REF }); - return Effect.gen(function* () { - const error = yield* inspectDbDbStats( - flags({ remote: Option.some({ _tag: "Branch", name: "feature-y" } as const) }), - ).pipe(Effect.flip); - expect(error).toMatchObject({ - _tag: "LinkBranchNotFoundError", - message: expect.stringContaining("Available branches: feature-x"), - }); - expect(resolver.connectionInput).toBeUndefined(); - }).pipe(Effect.provide(layer)); - }); - - it.effect("rejects --remote combined with --local", () => { - const { layer, resolver } = setup(); - return Effect.gen(function* () { - const error = yield* inspectDbDbStats( - flags({ remote: Option.some({ _tag: "Linked" } as const), local: true }), - ).pipe(Effect.flip); - expect(error).toBeInstanceOf(TargetSelectorConflictError); - expect(resolver.connectionInput).toBeUndefined(); - }).pipe(Effect.provide(layer)); - }); - - describe("deprecated selectors", () => { - const DEPRECATED_DB_URL = "postgres://x"; - const cases = [ - { - alias: "--linked", - cliArgs: ["inspect", "db", "db-stats", "--linked"], - flags: { linked: true }, - target: { kind: "linked", ref: VALID_REF }, - warning: "--linked is deprecated and will be removed in v4. Use --remote instead", - }, - { - alias: "--project-ref", - cliArgs: ["inspect", "db", "db-stats", "--project-ref", FLAG_REF], - flags: { projectRef: Option.some(FLAG_REF) }, - target: { kind: "linked", ref: FLAG_REF, source: "flag" }, - warning: `--project-ref is deprecated and will be removed in v4. Use --remote=<ref> instead`, - }, - { - alias: "--db-url", - cliArgs: ["inspect", "db", "db-stats", "--db-url", DEPRECATED_DB_URL], - flags: { dbUrl: Option.some(DEPRECATED_DB_URL) }, - target: { kind: "db-url" }, - warning: "--db-url is deprecated and will be removed in v4. Use --remote=<db-url> instead", - }, - { - alias: "--local", - cliArgs: ["inspect", "db", "db-stats", "--local"], - flags: { local: true }, - target: { kind: "local" }, - warning: "--local isn't needed (local is the default) and will be removed in v4", - }, - ] as const satisfies ReadonlyArray<{ - readonly alias: string; - readonly cliArgs: ReadonlyArray<string>; - readonly flags: Partial<InspectConnectionFlags>; - readonly target: Record<string, string>; - readonly warning: string; - }>; - - for (const testCase of cases) { - it.effect(`${testCase.alias} still selects its target and warns`, () => { - const { layer, resolver, warnings } = setup({ - rows: [DB_STATS_ROW], - projectId: VALID_REF, - cliArgs: testCase.cliArgs, - commandPath: ["inspect", "db", "db-stats"], - }); - return Effect.gen(function* () { - yield* inspectDbDbStats(flags(testCase.flags)); - expect(resolver.connectionInput?.target).toMatchObject(testCase.target); - expect(warnings()).toEqual([testCase.warning]); - }).pipe(Effect.provide(layer)); + describeDbTargetSelection("inspect db db-stats", { + dbUrl: true, + workdir: () => workdir.current, + attempt: ({ workdir: dir, args, target }) => { + const ctx = setup({ + rows: [DB_STATS_ROW], + workdir: dir, + projectId: VALID_REF, + cliArgs: args, + commandPath: ["inspect", "db", "db-stats"], }); - } - - it.effect("rejects --project-ref combined with --local", () => { - const { layer, resolver } = setup(); - return Effect.gen(function* () { - const error = yield* inspectDbDbStats( - flags({ local: true, projectRef: Option.some(FLAG_REF) }), - ).pipe(Effect.flip); - expect(error).toBeInstanceOf(TargetProjectRefConflictError); - expect(resolver.connectionInput).toBeUndefined(); - }).pipe(Effect.provide(layer)); - }); + return { + layer: ctx.layer, + out: ctx.out, + run: inspectDbDbStats(flags(target)), + target: () => ctx.resolver.connectionInput?.target, + branchLookups: () => ctx.branchRequests.map(({ method, url }) => `${method} ${url}`), + }; + }, }); it.effect("passes network-ban removal only under --yes", () => { 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 bbaefebb2a..d3abb63e80 100644 --- a/apps/cli/src/commands/inspect/report/report.integration.test.ts +++ b/apps/cli/src/commands/inspect/report/report.integration.test.ts @@ -53,21 +53,12 @@ import type { } 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"; -import { - BRANCH_PROJECT_REF, - branchRow, - warningsOf, -} from "../../../../tests/helpers/remote-selection.ts"; - -const FLAG_REF = "flagflagflagflagflag"; +import { describeDbTargetSelection } from "../../../../tests/helpers/db-target-selection.ts"; +import { branchRow, warningsOf } from "../../../../tests/helpers/remote-selection.ts"; const LOCAL_CONN: PgConnInput = { host: "127.0.0.1", @@ -291,6 +282,8 @@ const dateFolderContents = Effect.fnUntraced(function* (base: string) { }); describe("inspect report", () => { + const targetWorkdir = useTempWorkdir("supabase-inspect-report-target-"); + it.effect("writes one CSV per inspect query", () => { const { layer, connection } = setupReport({ csvs: DEFAULT_RULE_CSVS }); const prevUmask = process.umask(0); @@ -333,26 +326,6 @@ describe("inspect report", () => { }).pipe(Effect.provide(layer)); }); - 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.connectionInput?.target).toEqual({ kind: "local" }); - }).pipe(Effect.provide(layer)); - }); - - it.effect("rejects --remote combined with --local", () => { - const { layer, resolver } = setupReport(); - return Effect.gen(function* () { - const error = yield* inspectReport( - flags({ remote: Option.some({ _tag: "Linked" } as const), local: true }), - ).pipe(Effect.flip); - expect(error).toBeInstanceOf(TargetSelectorConflictError); - expect(resolver.connectionInput).toBeUndefined(); - }).pipe(Effect.provide(layer)); - }); - it.effect("a bare --remote routes to the linked project and announces it", () => { const { layer, resolver, out, warnings } = setupReport({ csvs: DEFAULT_RULE_CSVS, @@ -369,130 +342,25 @@ describe("inspect report", () => { }).pipe(Effect.provide(layer)); }); - it.effect("--remote=<ref> targets that project", () => { - const { layer, resolver, warnings } = setupReport({ csvs: DEFAULT_RULE_CSVS }); - return Effect.gen(function* () { - const base = yield* tempDir("supabase-report-out-"); - yield* inspectReport( - flags({ outputDir: base, remote: Option.some({ _tag: "Ref", ref: FLAG_REF } as const) }), - ); - expect(resolver.connectionInput?.target).toMatchObject({ - kind: "linked", - ref: FLAG_REF, - source: "flag", - }); - expect(warnings()).toEqual([]); - }).pipe(Effect.provide(layer)); - }); - - it.effect("--remote=<branch> targets the project of the branch it names", () => { - const { layer, resolver, branchRequests, warnings } = setupReport({ - csvs: DEFAULT_RULE_CSVS, - projectId: VALID_REF, - }); - return Effect.gen(function* () { - const base = yield* tempDir("supabase-report-out-"); - yield* inspectReport( - flags({ - outputDir: base, - remote: Option.some({ _tag: "Branch", name: "feature-x" } as const), - }), - ); - expect(branchRequests.map(({ method, url }) => `${method} ${url}`)).toEqual([ - `GET https://api.supabase.com/v1/projects/${VALID_REF}/branches`, - ]); - expect(resolver.connectionInput?.target).toMatchObject({ - kind: "linked", - ref: BRANCH_PROJECT_REF, - }); - expect(warnings()).toEqual([]); - }).pipe(Effect.provide(layer)); - }); - - it.effect("--remote=<branch> fails with the branches that exist when none matches", () => { - const { layer, resolver } = setupReport({ csvs: DEFAULT_RULE_CSVS, projectId: VALID_REF }); - return Effect.gen(function* () { - const base = yield* tempDir("supabase-report-out-"); - const error = yield* inspectReport( - flags({ - outputDir: base, - remote: Option.some({ _tag: "Branch", name: "feature-y" } as const), - }), - ).pipe(Effect.flip); - expect(error).toMatchObject({ - _tag: "LinkBranchNotFoundError", - message: expect.stringContaining("Available branches: feature-x"), + describeDbTargetSelection("inspect report", { + dbUrl: true, + workdir: () => targetWorkdir.current, + attempt: ({ workdir, args, target }) => { + const ctx = setupReport({ + csvs: DEFAULT_RULE_CSVS, + workdir, + projectId: VALID_REF, + cliArgs: args, + commandPath: ["inspect", "report"], }); - expect(resolver.connectionInput).toBeUndefined(); - }).pipe(Effect.provide(layer)); - }); - - describe("deprecated selectors", () => { - const DEPRECATED_DB_URL = "postgres://x"; - const cases = [ - { - alias: "--linked", - cliArgs: ["inspect", "report", "--linked"], - flags: { linked: true }, - target: { kind: "linked", ref: VALID_REF }, - warning: "--linked is deprecated and will be removed in v4. Use --remote instead", - }, - { - alias: "--project-ref", - cliArgs: ["inspect", "report", "--project-ref", FLAG_REF], - flags: { projectRef: Option.some(FLAG_REF) }, - target: { kind: "linked", ref: FLAG_REF, source: "flag" }, - warning: `--project-ref is deprecated and will be removed in v4. Use --remote=<ref> instead`, - }, - { - alias: "--db-url", - cliArgs: ["inspect", "report", "--db-url", DEPRECATED_DB_URL], - flags: { dbUrl: Option.some(DEPRECATED_DB_URL) }, - target: { kind: "db-url" }, - warning: "--db-url is deprecated and will be removed in v4. Use --remote=<db-url> instead", - }, - { - alias: "--local", - cliArgs: ["inspect", "report", "--local"], - flags: { local: true }, - target: { kind: "local" }, - warning: "--local isn't needed (local is the default) and will be removed in v4", - }, - ] as const satisfies ReadonlyArray<{ - readonly alias: string; - readonly cliArgs: ReadonlyArray<string>; - readonly flags: Partial<InspectReportFlags>; - readonly target: Record<string, string>; - readonly warning: string; - }>; - - for (const testCase of cases) { - it.effect(`${testCase.alias} still selects its target and warns`, () => { - const { layer, resolver, warnings } = setupReport({ - csvs: DEFAULT_RULE_CSVS, - projectId: VALID_REF, - cliArgs: testCase.cliArgs, - commandPath: ["inspect", "report"], - }); - return Effect.gen(function* () { - const base = yield* tempDir("supabase-report-out-"); - yield* inspectReport(flags({ outputDir: base, ...testCase.flags })); - expect(resolver.connectionInput?.target).toMatchObject(testCase.target); - expect(warnings()).toEqual([testCase.warning]); - }).pipe(Effect.provide(layer)); - }); - } - - it.effect("rejects --project-ref combined with --local", () => { - const { layer, resolver } = setupReport({ csvs: DEFAULT_RULE_CSVS }); - return Effect.gen(function* () { - const error = yield* inspectReport( - flags({ local: true, projectRef: Option.some(FLAG_REF) }), - ).pipe(Effect.flip); - expect(error).toBeInstanceOf(TargetProjectRefConflictError); - expect(resolver.connectionInput).toBeUndefined(); - }).pipe(Effect.provide(layer)); - }); + return { + layer: ctx.layer, + out: ctx.out, + run: inspectReport(flags({ ...target, outputDir: workdir })), + target: () => ctx.resolver.connectionInput?.target, + branchLookups: () => ctx.branchRequests.map(({ method, url }) => `${method} ${url}`), + }; + }, }); it.effect("prints running + saved progress to stderr and the rules table to stdout", () => { From 3b06dd0a161bc05a37e192bd5add10949e00074f Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Tue, 20 Oct 2026 13:39:28 +0100 Subject: [PATCH 210/291] test(cli): cover target selection for the storage leaves with the shared harness --- .../storage/cp/cp.integration.test.ts | 169 ++++-------------- .../storage/ls/ls.integration.test.ts | 162 +++-------------- .../storage/mv/mv.integration.test.ts | 156 +++------------- .../storage/rm/rm.integration.test.ts | 160 +++-------------- apps/cli/tests/helpers/db-target-selection.ts | 32 +++- 5 files changed, 136 insertions(+), 543 deletions(-) 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 4ba0efa115..9e4bf8cafc 100644 --- a/apps/cli/src/commands/storage/cp/cp.integration.test.ts +++ b/apps/cli/src/commands/storage/cp/cp.integration.test.ts @@ -1,10 +1,14 @@ import { describe, expect, it } from "@effect/vitest"; import { Cause, Effect, Exit, FileSystem, Option, Path, Predicate } from "effect"; +import { + branchLookupsOf, + describeDbTargetSelection, + storageTargetOf, +} from "../../../../tests/helpers/db-target-selection.ts"; import { describeHostedConsent } from "../../../../tests/helpers/hosted-consent.ts"; import { setupStorage, - STORAGE_TEST_BRANCH_REF, STORAGE_TEST_JWT_SECRET, storageConsentApi, storageConsentServices, @@ -17,9 +21,7 @@ import { generateLocalJwt } from "../../../command-internal/local-jwt.ts"; import { StorageUnsupportedOperationError } from "../storage.errors.ts"; import { storageCp } from "./cp.handler.ts"; import type { StorageCpFlags } from "./cp.command.ts"; -import { warningsOf } from "../../../../tests/helpers/remote-selection.ts"; -const FLAG_REF = "flagflagflagflagflag"; const BUCKET = "/storage/v1/bucket"; const OBJECT = (p: string) => `/storage/v1/object/${p}`; const LIST = (bucket: string) => `/storage/v1/object/list/${bucket}`; @@ -568,143 +570,32 @@ describe("storage cp", () => { }).pipe(Effect.provide(layer)); }); - it.effect("uploads to the project given via --remote=<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 { layer, requests, linkedCache } = setupStorage(tmp.current, { - yes: true, - routes: [{ method: "POST", match: OBJECT("private/readme.md"), body: {} }], - }); - return Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - yield* fs.writeFileString(path.join(tmp.current, "readme.md"), "hello world"); - const exit = yield* storageCp({ - ...cpFlags({ src: path.join(tmp.current, "readme.md"), dst: "ss:///private/readme.md" }), - remote: Option.some({ _tag: "Ref", ref: FLAG_REF } as const), - }).pipe(Effect.exit); - expect(Exit.isSuccess(exit)).toBe(true); - expect(requests.some((r) => r.url.startsWith(`https://${FLAG_REF}.supabase.co`))).toBe(true); - expect(requests.some((r) => r.url.includes(VALID_REF))).toBe(false); - expect(linkedCache.cached).toBe(true); - expect(linkedCache.cachedRef).toBe(FLAG_REF); - }).pipe(Effect.provide(layer)); - }); - - it.effect("downloads from the project of the branch --remote=<branch> names", () => { - const { layer, requests, linkedCache, managementCalls, out } = setupStorage(tmp.current, { - branches: [storageTestBranch("feature-x")], - routes: [{ method: "GET", match: OBJECT("private/readme.md"), rawBody: "downloaded-bytes" }], - }); - return Effect.gen(function* () { - const path = yield* Path.Path; - const exit = yield* storageCp({ - ...cpFlags({ src: "ss:///private/readme.md", dst: path.join(tmp.current, "out.md") }), - remote: Option.some({ _tag: "Branch", name: "feature-x" } as const), - }).pipe(Effect.exit); - expect(Exit.isSuccess(exit)).toBe(true); - expect(managementCalls.filter(({ method }) => method === "listAllBranches")).toEqual([ - { method: "listAllBranches", input: { ref: VALID_REF } }, - ]); - expect( - requests.some((r) => r.url.startsWith(`https://${STORAGE_TEST_BRANCH_REF}.supabase.co`)), - ).toBe(true); - expect(linkedCache.cachedRef).toBe(STORAGE_TEST_BRANCH_REF); - expect(warningsOf(out)).toEqual([]); - }).pipe(Effect.provide(layer)); - }); - - it.effect("fails with the branches that exist when --remote=<branch> matches none", () => { - const { layer, requests } = setupStorage(tmp.current, { - branches: [storageTestBranch("feature-x")], - yes: true, - }); - return Effect.gen(function* () { - const path = yield* Path.Path; - const error = yield* storageCp({ - ...cpFlags({ src: "ss:///private/readme.md", dst: path.join(tmp.current, "out.md") }), - remote: Option.some({ _tag: "Branch", name: "feature-y" } as const), - }).pipe(Effect.flip); - expect(error).toMatchObject({ - _tag: "LinkBranchNotFoundError", - message: expect.stringContaining("Available branches: feature-x"), - }); - expect(requests).toHaveLength(0); - }).pipe(Effect.provide(layer)); - }); - - describe("deprecated selectors", () => { - const cases = [ - { - alias: "--linked", - cliArgs: ["storage", "cp", "--linked"], - flags: { linked: true }, - host: `https://${VALID_REF}.supabase.co`, - warning: "--linked is deprecated and will be removed in v4. Use --remote instead", - }, - { - alias: "--project-ref", - cliArgs: ["storage", "cp", "--project-ref", FLAG_REF], - flags: { projectRef: Option.some(FLAG_REF) }, - host: `https://${FLAG_REF}.supabase.co`, - warning: `--project-ref is deprecated and will be removed in v4. Use --remote=<ref> instead`, - }, - { - alias: "--local", - cliArgs: ["storage", "cp", "--local"], - flags: { local: true }, - host: "http://127.0.0.1:65432", - warning: "--local isn't needed (local is the default) and will be removed in v4", - }, - ] as const; - - for (const testCase of cases) { - it.effect(`${testCase.alias} still selects its target and warns`, () => { - const { layer, requests, out } = setupStorage(tmp.current, { - toml: 'project_id = "test"\n[api]\nport = 65432\n', - cliArgs: testCase.cliArgs, - commandPath: ["storage", "cp"], - routes: [ - { method: "GET", match: OBJECT("private/readme.md"), rawBody: "downloaded-bytes" }, - ], - }); - return Effect.gen(function* () { - const path = yield* Path.Path; - const exit = yield* storageCp({ - ...cpFlags({ src: "ss:///private/readme.md", dst: path.join(tmp.current, "out.md") }), - ...testCase.flags, - }).pipe(Effect.exit); - expect(Exit.isSuccess(exit)).toBe(true); - expect(requests[0]?.url.startsWith(testCase.host)).toBe(true); - expect(warningsOf(out)).toEqual([testCase.warning]); - }).pipe(Effect.provide(layer)); - }); - } - - it.effect("rejects --project-ref combined with --local", () => { - const { layer, requests, linkedCache } = setupStorage(tmp.current, { - toml: 'project_id = "test"\n', - local: true, + describeDbTargetSelection("storage cp", { + dbUrl: false, + operands: ["ss:///private/readme.md", "out.md"], + workdir: () => tmp.current, + attempt: ({ workdir, args, target }) => { + const ctx = setupStorage(workdir, { + toml: 'project_id = "test"\n[api]\nport = 65432\n', + branches: [storageTestBranch("feature-x")], + cliArgs: args, + commandPath: ["storage", "cp"], + 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; - yield* fs.writeFileString(path.join(tmp.current, "readme.md"), "hello world"); - const exit = yield* storageCp({ - ...cpFlags({ src: path.join(tmp.current, "readme.md"), dst: "ss:///private/readme.md" }), - local: true, - projectRef: Option.some(FLAG_REF), - }).pipe(Effect.exit); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - expect(Cause.pretty(exit.cause)).toContain( - "--project-ref selects a remote project and cannot be combined with --local.", - ); - } - expect(requests).toHaveLength(0); - expect(linkedCache.cached).toBe(false); - }).pipe(Effect.provide(layer)); - }); + return { + layer: ctx.layer, + out: ctx.out, + run: storageCp({ + ...cpFlags({ src: "ss:///private/readme.md", dst: `${workdir}/out.md` }), + ...target, + }), + target: () => storageTargetOf(ctx.requests), + cachedRef: () => ctx.linkedCache.cachedRef, + branchLookups: () => branchLookupsOf(ctx.managementCalls), + }; + }, }); it.effect("propagates a non-200 from the gateway on upload", () => { 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 169435f998..fd6e290b9a 100644 --- a/apps/cli/src/commands/storage/ls/ls.integration.test.ts +++ b/apps/cli/src/commands/storage/ls/ls.integration.test.ts @@ -3,9 +3,13 @@ import { describe, expect, it } from "@effect/vitest"; import { Cause, Effect, Exit, FileSystem, Option, Path, Predicate } from "effect"; import { VALID_REF, useTempWorkdir, withEnvVar } from "../../../../tests/helpers/command-mocks.ts"; +import { + branchLookupsOf, + describeDbTargetSelection, + storageTargetOf, +} from "../../../../tests/helpers/db-target-selection.ts"; import { setupStorage, - STORAGE_TEST_BRANCH_REF, STORAGE_TEST_JWT_SECRET, storageTestBranch, } from "../../../../tests/helpers/storage.ts"; @@ -18,9 +22,7 @@ import { StorageGatewayStatusError } from "../../../command-internal/storage-gat import { storageLs } from "./ls.handler.ts"; import type { StorageLsFlags } from "./ls.command.ts"; import { generateLocalJwt } from "../../../command-internal/local-jwt.ts"; -import { warningsOf } from "../../../../tests/helpers/remote-selection.ts"; -const FLAG_REF = "flagflagflagflagflag"; const BUCKET = "/storage/v1/bucket"; const LIST = (bucket: string) => `/storage/v1/object/list/${bucket}`; const REVEALED_API_KEYS = `GET https://api.supabase.com/v1/projects/${VALID_REF}/api-keys?reveal=true`; @@ -277,130 +279,26 @@ project_id = "env(STAGING_REF)" ); }); - it.effect("lists the project given via --remote=<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 { layer, requests, linkedCache } = setupStorage(tmp.current, { - routes: [{ method: "GET", match: BUCKET, body: [{ name: "remote", id: "remote" }] }], - }); - return Effect.gen(function* () { - const exit = yield* storageLs({ - ...lsFlags(), - remote: Option.some({ _tag: "Ref", ref: FLAG_REF } as const), - }).pipe(Effect.provide(layer), Effect.exit); - expect(Exit.isSuccess(exit)).toBe(true); - expect(requests.some((r) => r.url.startsWith(`https://${FLAG_REF}.supabase.co`))).toBe(true); - expect(requests.some((r) => r.url.includes(VALID_REF))).toBe(false); - expect(linkedCache.cached).toBe(true); - expect(linkedCache.cachedRef).toBe(FLAG_REF); - }); - }); - - it.effect("lists the project of the branch --remote=<branch> names", () => { - const { layer, requests, linkedCache, managementCalls, out } = setupStorage(tmp.current, { - branches: [storageTestBranch("feature-x")], - routes: [{ method: "GET", match: BUCKET, body: [{ name: "remote", id: "remote" }] }], - }); - return Effect.gen(function* () { - const exit = yield* storageLs({ - ...lsFlags(), - remote: Option.some({ _tag: "Branch", name: "feature-x" } as const), - }).pipe(Effect.provide(layer), Effect.exit); - expect(Exit.isSuccess(exit)).toBe(true); - expect(managementCalls.filter(({ method }) => method === "listAllBranches")).toEqual([ - { method: "listAllBranches", input: { ref: VALID_REF } }, - ]); - expect( - requests.some((r) => r.url.startsWith(`https://${STORAGE_TEST_BRANCH_REF}.supabase.co`)), - ).toBe(true); - expect(linkedCache.cachedRef).toBe(STORAGE_TEST_BRANCH_REF); - expect(warningsOf(out)).toEqual([]); - }); - }); - - it.effect("fails with the branches that exist when --remote=<branch> matches none", () => { - const { layer, requests } = setupStorage(tmp.current, { - branches: [storageTestBranch("feature-x")], - }); - return Effect.gen(function* () { - const error = yield* storageLs({ - ...lsFlags(), - remote: Option.some({ _tag: "Branch", name: "feature-y" } as const), - }).pipe(Effect.provide(layer), Effect.flip); - expect(error).toMatchObject({ - _tag: "LinkBranchNotFoundError", - message: expect.stringContaining("Available branches: feature-x"), - }); - expect(requests).toHaveLength(0); - }); - }); - - describe("deprecated selectors", () => { - const cases = [ - { - alias: "--linked", - cliArgs: ["storage", "ls", "--linked"], - flags: { linked: true }, - host: `https://${VALID_REF}.supabase.co`, - warning: "--linked is deprecated and will be removed in v4. Use --remote instead", - }, - { - alias: "--project-ref", - cliArgs: ["storage", "ls", "--project-ref", FLAG_REF], - flags: { projectRef: Option.some(FLAG_REF) }, - host: `https://${FLAG_REF}.supabase.co`, - warning: `--project-ref is deprecated and will be removed in v4. Use --remote=<ref> instead`, - }, - { - alias: "--local", - cliArgs: ["storage", "ls", "--local"], - flags: { local: true }, - host: "http://127.0.0.1:65432", - warning: "--local isn't needed (local is the default) and will be removed in v4", - }, - ] as const; - - for (const testCase of cases) { - it.effect(`${testCase.alias} still selects its target and warns`, () => { - const { layer, requests, out } = setupStorage(tmp.current, { - toml: 'project_id = "test"\n[api]\nport = 65432\n', - cliArgs: testCase.cliArgs, - commandPath: ["storage", "ls"], - routes: [{ method: "GET", match: BUCKET, body: [{ name: "remote", id: "remote" }] }], - }); - return Effect.gen(function* () { - const exit = yield* storageLs({ ...lsFlags(), ...testCase.flags }).pipe( - Effect.provide(layer), - Effect.exit, - ); - expect(Exit.isSuccess(exit)).toBe(true); - expect(requests[0]?.url.startsWith(testCase.host)).toBe(true); - expect(warningsOf(out)).toEqual([testCase.warning]); - }); - }); - } - - it.effect("rejects --project-ref combined with --local", () => { - const { layer, requests, linkedCache } = setupStorage(tmp.current, { - toml: 'project_id = "test"\n', - local: true, - }); - return Effect.gen(function* () { - const exit = yield* storageLs({ - ...lsFlags(), - local: true, - projectRef: Option.some(FLAG_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 selects a remote project and cannot be combined with --local.", - ); - } - expect(requests).toHaveLength(0); - expect(linkedCache.cached).toBe(false); + describeDbTargetSelection("storage ls", { + dbUrl: false, + workdir: () => tmp.current, + attempt: ({ workdir, args, target }) => { + const ctx = setupStorage(workdir, { + toml: 'project_id = "test"\n[api]\nport = 65432\n', + branches: [storageTestBranch("feature-x")], + cliArgs: args, + commandPath: ["storage", "ls"], + routes: [{ method: "GET", match: BUCKET, body: [{ name: "remote", id: "remote" }] }], }); - }); + return { + layer: ctx.layer, + out: ctx.out, + run: storageLs({ ...lsFlags(), ...target }), + target: () => storageTargetOf(ctx.requests), + cachedRef: () => ctx.linkedCache.cachedRef, + branchLookups: () => branchLookupsOf(ctx.managementCalls), + }; + }, }); it.effect( @@ -1080,18 +978,6 @@ describe("stack backend", () => { expect(out.stderrText).toContain("Target: local stack (127.0.0.1:65432)"); }); }); - - 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()).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); - }); - }); }); function hasOffset(body: unknown): boolean { 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 543ec4583b..a3904f24ad 100644 --- a/apps/cli/src/commands/storage/mv/mv.integration.test.ts +++ b/apps/cli/src/commands/storage/mv/mv.integration.test.ts @@ -3,10 +3,14 @@ import { Cause, Effect, Exit, FileSystem, Option, Predicate } from "effect"; import { StackStorageCapabilityError } from "../../../command-internal/stack-storage.ts"; import { generateLocalJwt } from "../../../command-internal/local-jwt.ts"; +import { + branchLookupsOf, + describeDbTargetSelection, + storageTargetOf, +} from "../../../../tests/helpers/db-target-selection.ts"; import { describeHostedConsent } from "../../../../tests/helpers/hosted-consent.ts"; import { setupStorage, - STORAGE_TEST_BRANCH_REF, STORAGE_TEST_JWT_SECRET, storageConsentApi, storageConsentServices, @@ -16,9 +20,7 @@ import { VALID_REF, useTempWorkdir, withEnvVar } from "../../../../tests/helpers import { assertPermissionDrift } from "../../../../tests/helpers/permission-drift.ts"; import { storageMv } from "./mv.handler.ts"; import type { StorageMvFlags } from "./mv.command.ts"; -import { warningsOf } from "../../../../tests/helpers/remote-selection.ts"; -const FLAG_REF = "flagflagflagflagflag"; const MOVE = "/storage/v1/object/move"; const LIST = (bucket: string) => `/storage/v1/object/list/${bucket}`; const REVEALED_API_KEYS = `GET https://api.supabase.com/v1/projects/${VALID_REF}/api-keys?reveal=true`; @@ -296,133 +298,31 @@ describe("storage mv", () => { }); }); - it.effect("moves within the project given via --remote=<ref>, overriding VALID_REF", () => { - // The fake's default projectRef is VALID_REF; the flag must win over it. - const { layer, requests, linkedCache } = setupStorage(tmp.current, { - yes: true, - routes: [{ method: "POST", match: MOVE, body: { message: "Successfully moved" } }], - }); - return Effect.gen(function* () { - const exit = yield* storageMv({ - ...mvFlags({ src: "ss:///private/a", dst: "ss:///private/b" }), - remote: Option.some({ _tag: "Ref", ref: FLAG_REF } as const), - }).pipe(Effect.provide(layer), Effect.exit); - expect(Exit.isSuccess(exit)).toBe(true); - expect(requests.some((r) => r.url.startsWith(`https://${FLAG_REF}.supabase.co`))).toBe(true); - expect(requests.some((r) => r.url.includes(VALID_REF))).toBe(false); - expect(linkedCache.cached).toBe(true); - expect(linkedCache.cachedRef).toBe(FLAG_REF); - }); - }); - - it.effect("moves within the project of the branch --remote=<branch> names", () => { - const { layer, requests, linkedCache, managementCalls, out } = setupStorage(tmp.current, { - branches: [storageTestBranch("feature-x")], - yes: true, - routes: [{ method: "POST", match: MOVE, body: { message: "Successfully moved" } }], - }); - return Effect.gen(function* () { - const exit = yield* storageMv({ - ...mvFlags({ src: "ss:///private/a", dst: "ss:///private/b" }), - remote: Option.some({ _tag: "Branch", name: "feature-x" } as const), - }).pipe(Effect.exit); - expect(Exit.isSuccess(exit)).toBe(true); - expect(managementCalls.filter(({ method }) => method === "listAllBranches")).toEqual([ - { method: "listAllBranches", input: { ref: VALID_REF } }, - ]); - expect( - requests.some((r) => r.url.startsWith(`https://${STORAGE_TEST_BRANCH_REF}.supabase.co`)), - ).toBe(true); - expect(linkedCache.cachedRef).toBe(STORAGE_TEST_BRANCH_REF); - expect(warningsOf(out)).toEqual([]); - }).pipe(Effect.provide(layer)); - }); - - it.effect("fails with the branches that exist when --remote=<branch> matches none", () => { - const { layer, requests } = setupStorage(tmp.current, { - branches: [storageTestBranch("feature-x")], - yes: true, - }); - return Effect.gen(function* () { - const error = yield* storageMv({ - ...mvFlags({ src: "ss:///private/a", dst: "ss:///private/b" }), - remote: Option.some({ _tag: "Branch", name: "feature-y" } as const), - }).pipe(Effect.flip); - expect(error).toMatchObject({ - _tag: "LinkBranchNotFoundError", - message: expect.stringContaining("Available branches: feature-x"), - }); - expect(requests).toHaveLength(0); - }).pipe(Effect.provide(layer)); - }); - - describe("deprecated selectors", () => { - const cases = [ - { - alias: "--linked", - cliArgs: ["storage", "mv", "--linked"], - flags: { linked: true }, - host: `https://${VALID_REF}.supabase.co`, - warning: "--linked is deprecated and will be removed in v4. Use --remote instead", - }, - { - alias: "--project-ref", - cliArgs: ["storage", "mv", "--project-ref", FLAG_REF], - flags: { projectRef: Option.some(FLAG_REF) }, - host: `https://${FLAG_REF}.supabase.co`, - warning: `--project-ref is deprecated and will be removed in v4. Use --remote=<ref> instead`, - }, - { - alias: "--local", - cliArgs: ["storage", "mv", "--local"], - flags: { local: true }, - host: "http://127.0.0.1:65432", - warning: "--local isn't needed (local is the default) and will be removed in v4", - }, - ] as const; - - for (const testCase of cases) { - it.effect(`${testCase.alias} still selects its target and warns`, () => { - const { layer, requests, out } = setupStorage(tmp.current, { - toml: 'project_id = "test"\n[api]\nport = 65432\n', - cliArgs: testCase.cliArgs, - commandPath: ["storage", "mv"], - yes: true, - routes: [{ method: "POST", match: MOVE, body: { message: "Successfully moved" } }], - }); - return Effect.gen(function* () { - const exit = yield* storageMv({ - ...mvFlags({ src: "ss:///private/a", dst: "ss:///private/b" }), - ...testCase.flags, - }).pipe(Effect.exit); - expect(Exit.isSuccess(exit)).toBe(true); - expect(requests[0]?.url.startsWith(testCase.host)).toBe(true); - expect(warningsOf(out)).toEqual([testCase.warning]); - }).pipe(Effect.provide(layer)); - }); - } - - it.effect("rejects --project-ref combined with --local", () => { - const { layer, requests, linkedCache } = setupStorage(tmp.current, { - toml: 'project_id = "test"\n', - local: true, + describeDbTargetSelection("storage mv", { + dbUrl: false, + operands: ["ss:///private/a", "ss:///private/b"], + workdir: () => tmp.current, + attempt: ({ workdir, args, target }) => { + const ctx = setupStorage(workdir, { + toml: 'project_id = "test"\n[api]\nport = 65432\n', + branches: [storageTestBranch("feature-x")], + yes: true, + cliArgs: args, + commandPath: ["storage", "mv"], + routes: [{ method: "POST", match: MOVE, body: { message: "Successfully moved" } }], }); - return Effect.gen(function* () { - const exit = yield* storageMv({ + return { + layer: ctx.layer, + out: ctx.out, + run: storageMv({ ...mvFlags({ src: "ss:///private/a", dst: "ss:///private/b" }), - local: true, - projectRef: Option.some(FLAG_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 selects a remote project and cannot be combined with --local.", - ); - } - expect(requests).toHaveLength(0); - expect(linkedCache.cached).toBe(false); - }); - }); + ...target, + }), + target: () => storageTargetOf(ctx.requests), + cachedRef: () => ctx.linkedCache.cachedRef, + branchLookups: () => branchLookupsOf(ctx.managementCalls), + }; + }, }); it.effect("propagates a 503 from the move endpoint even when recursive", () => { 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 83cab3c6df..bf4f6c7ca4 100644 --- a/apps/cli/src/commands/storage/rm/rm.integration.test.ts +++ b/apps/cli/src/commands/storage/rm/rm.integration.test.ts @@ -7,10 +7,14 @@ 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 { + branchLookupsOf, + describeDbTargetSelection, + storageTargetOf, +} from "../../../../tests/helpers/db-target-selection.ts"; import { describeHostedConsent } from "../../../../tests/helpers/hosted-consent.ts"; import { setupStorage, - STORAGE_TEST_BRANCH_REF, STORAGE_TEST_JWT_SECRET, storageConsentApi, storageConsentServices, @@ -24,7 +28,6 @@ import { } from "../../../../tests/helpers/command-mocks.ts"; import { assertPermissionDrift } from "../../../../tests/helpers/permission-drift.ts"; import { storageRm } from "./rm.handler.ts"; -import { warningsOf } from "../../../../tests/helpers/remote-selection.ts"; const writeAncestorConfig = Effect.fnUntraced(function* (root: string, toml: string) { const fs = yield* FileSystem.FileSystem; @@ -42,7 +45,6 @@ const RM_ARGS = { local: false, projectRef: Option.none(), }; -const FLAG_REF = "flagflagflagflagflag"; const BUCKET = "/storage/v1/bucket"; const DELETE_OBJECT = (bucket: string) => `/storage/v1/object/${bucket}`; const DELETE_BUCKET = (bucket: string) => `/storage/v1/bucket/${bucket}`; @@ -761,140 +763,28 @@ describe("storage rm", () => { }); }); - it.effect("deletes from the project given via --remote=<ref>, overriding VALID_REF", () => { - // The fake's default projectRef is VALID_REF; the flag must win over it. - const { layer, requests, linkedCache } = setupStorage(tmp.current, { - yes: true, - routes: [{ method: "DELETE", match: DELETE_OBJECT("private"), body: [{ name: "a.pdf" }] }], - }); - return Effect.gen(function* () { - const exit = yield* storageRm({ - files: ["ss:///private/a.pdf"], - recursive: false, - remote: Option.some({ _tag: "Ref", ref: FLAG_REF } as const), - linked: false, - local: false, - projectRef: Option.none(), - }).pipe(Effect.provide(layer), Effect.exit); - expect(Exit.isSuccess(exit)).toBe(true); - expect(requests.some((r) => r.url.startsWith(`https://${FLAG_REF}.supabase.co`))).toBe(true); - expect(requests.some((r) => r.url.includes(VALID_REF))).toBe(false); - expect(linkedCache.cached).toBe(true); - expect(linkedCache.cachedRef).toBe(FLAG_REF); - }); - }); - - it.effect("deletes from the project of the branch --remote=<branch> names", () => { - const { layer, requests, linkedCache, managementCalls, out } = setupStorage(tmp.current, { - branches: [storageTestBranch("feature-x")], - yes: true, - routes: [{ method: "DELETE", match: DELETE_OBJECT("private"), body: [{ name: "a.pdf" }] }], - }); - return Effect.gen(function* () { - const exit = yield* storageRm({ - ...RM_ARGS, - remote: Option.some({ _tag: "Branch", name: "feature-x" } as const), - }).pipe(Effect.exit); - expect(Exit.isSuccess(exit)).toBe(true); - expect(managementCalls.filter(({ method }) => method === "listAllBranches")).toEqual([ - { method: "listAllBranches", input: { ref: VALID_REF } }, - ]); - expect( - requests.some((r) => r.url.startsWith(`https://${STORAGE_TEST_BRANCH_REF}.supabase.co`)), - ).toBe(true); - expect(linkedCache.cachedRef).toBe(STORAGE_TEST_BRANCH_REF); - expect(warningsOf(out)).toEqual([]); - }).pipe(Effect.provide(layer)); - }); - - it.effect("fails with the branches that exist when --remote=<branch> matches none", () => { - const { layer, requests } = setupStorage(tmp.current, { - branches: [storageTestBranch("feature-x")], - yes: true, - }); - return Effect.gen(function* () { - const error = yield* storageRm({ - ...RM_ARGS, - remote: Option.some({ _tag: "Branch", name: "feature-y" } as const), - }).pipe(Effect.flip); - expect(error).toMatchObject({ - _tag: "LinkBranchNotFoundError", - message: expect.stringContaining("Available branches: feature-x"), - }); - expect(requests).toHaveLength(0); - }).pipe(Effect.provide(layer)); - }); - - describe("deprecated selectors", () => { - const cases = [ - { - alias: "--linked", - cliArgs: ["storage", "rm", "--linked"], - flags: { linked: true }, - host: `https://${VALID_REF}.supabase.co`, - warning: "--linked is deprecated and will be removed in v4. Use --remote instead", - }, - { - alias: "--project-ref", - cliArgs: ["storage", "rm", "--project-ref", FLAG_REF], - flags: { projectRef: Option.some(FLAG_REF) }, - host: `https://${FLAG_REF}.supabase.co`, - warning: `--project-ref is deprecated and will be removed in v4. Use --remote=<ref> instead`, - }, - { - alias: "--local", - cliArgs: ["storage", "rm", "--local"], - flags: { local: true }, - host: "http://127.0.0.1:65432", - warning: "--local isn't needed (local is the default) and will be removed in v4", - }, - ] as const; - - for (const testCase of cases) { - it.effect(`${testCase.alias} still selects its target and warns`, () => { - const { layer, requests, out } = setupStorage(tmp.current, { - toml: 'project_id = "test"\n[api]\nport = 65432\n', - cliArgs: testCase.cliArgs, - commandPath: ["storage", "rm"], - yes: true, - routes: [ - { method: "DELETE", match: DELETE_OBJECT("private"), body: [{ name: "a.pdf" }] }, - ], - }); - return Effect.gen(function* () { - const exit = yield* storageRm({ ...RM_ARGS, ...testCase.flags }).pipe(Effect.exit); - expect(Exit.isSuccess(exit)).toBe(true); - expect(requests[0]?.url.startsWith(testCase.host)).toBe(true); - expect(warningsOf(out)).toEqual([testCase.warning]); - }).pipe(Effect.provide(layer)); - }); - } - - it.effect("rejects --project-ref combined with --local", () => { - const { layer, requests, linkedCache } = setupStorage(tmp.current, { - toml: 'project_id = "test"\n', - local: true, + describeDbTargetSelection("storage rm", { + dbUrl: false, + operands: ["ss:///private/a.pdf"], + workdir: () => tmp.current, + attempt: ({ workdir, args, target }) => { + const ctx = setupStorage(workdir, { + toml: 'project_id = "test"\n[api]\nport = 65432\n', + branches: [storageTestBranch("feature-x")], yes: true, + cliArgs: args, + commandPath: ["storage", "rm"], + routes: [{ method: "DELETE", match: DELETE_OBJECT("private"), body: [{ name: "a.pdf" }] }], }); - return Effect.gen(function* () { - const exit = yield* storageRm({ - files: ["ss:///private/a.pdf"], - recursive: false, - remote: Option.none(), - linked: false, - local: true, - projectRef: Option.some(FLAG_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 selects a remote project and cannot be combined with --local.", - ); - } - expect(requests).toHaveLength(0); - expect(linkedCache.cached).toBe(false); - }); - }); + return { + layer: ctx.layer, + out: ctx.out, + run: storageRm({ ...RM_ARGS, ...target }), + target: () => storageTargetOf(ctx.requests), + cachedRef: () => ctx.linkedCache.cachedRef, + branchLookups: () => branchLookupsOf(ctx.managementCalls), + }; + }, }); it.effect( diff --git a/apps/cli/tests/helpers/db-target-selection.ts b/apps/cli/tests/helpers/db-target-selection.ts index cf7e43b53d..3a8495be91 100644 --- a/apps/cli/tests/helpers/db-target-selection.ts +++ b/apps/cli/tests/helpers/db-target-selection.ts @@ -3,7 +3,7 @@ import { join } from "node:path"; import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; -import { Effect, Exit, Layer, Option } from "effect"; +import { Effect, Exit, Layer, Option, Predicate } from "effect"; import { CliOutput, Command } from "effect/cli"; import type { RemoteValue } from "../../src/command-internal/target/remote-value.ts"; @@ -29,7 +29,9 @@ import { BRANCH_PROJECT_REF, warningsOf } from "./remote-selection.ts"; const FLAG_REF = "flagflagflagflagflag"; const DB_URL = "postgresql://postgres:postgres@db.example.com:5432/postgres"; -const BRANCH_LOOKUP = `GET https://api.supabase.com/v1/projects/${VALID_REF}/branches`; +const branchListRequest = (ref: string) => + `GET https://api.supabase.com/v1/projects/${ref}/branches`; +const BRANCH_LOOKUP = branchListRequest(VALID_REF); /** Every selector flag absent, so a command takes its default target. */ export const NO_TARGET: TargetFlagValuesWithDbUrl = { @@ -54,6 +56,28 @@ type ObservedTarget = | { readonly kind: "linked"; readonly ref: string } | { readonly kind: "db-url" }; +/** The branch lookups, as `branchLookups` reports them, among the Management API method calls a fake recorded. */ +export const branchLookupsOf = ( + calls: ReadonlyArray<{ readonly method: string; readonly input: unknown }>, +): ReadonlyArray<string> => + calls.flatMap(({ method, input }) => + method === "listAllBranches" && + Predicate.hasProperty(input, "ref") && + Predicate.isString(input.ref) + ? [branchListRequest(input.ref)] + : [], + ); + +/** The target a Storage leaf sent its first request to; `undefined` before it sent one. */ +export const storageTargetOf = ( + requests: ReadonlyArray<{ readonly url: string }>, +): ObservedTarget | undefined => { + const first = requests[0]; + if (first === undefined) return undefined; + const ref = /^https:\/\/([a-z]{20})\.supabase\.co/.exec(first.url)?.[1]; + return ref === undefined ? { kind: "local" } : { kind: "linked", ref }; +}; + interface DbTargetAttempt<R> { readonly layer: Layer.Layer<R, unknown>; readonly out: ReturnType<typeof mockOutput>; @@ -81,6 +105,8 @@ interface DbTargetSelectionScenario<R> { readonly dbUrl: boolean; /** The directory the leaf's layers read, which the linked-checkout row links to {@link VALID_REF}. */ readonly workdir: () => string; + /** Positional arguments the parser needs before it reaches `--remote`, for a command that takes no database URL. */ + readonly operands?: ReadonlyArray<string>; /** * One run of the leaf: its layers, its handler called with `target`, and what it observed. The * linked project the fakes answer for is {@link VALID_REF}, whose only branch is `feature-x`. @@ -251,7 +277,7 @@ export function describeDbTargetSelection<R>( }, ); } else if (!optedOut("remote-db-url")) { - const args = [...words, `--remote=${DB_URL}`]; + const args = [...words, ...(scenario.operands ?? []), `--remote=${DB_URL}`]; it.effect( "rejects --remote=<postgres url> while parsing, since there is no database to connect to", () => From e0ad79d2529e4dac779341b950fdfea19df48f93 Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Tue, 20 Oct 2026 13:39:44 +0100 Subject: [PATCH 211/291] test(cli): trim describeHostedConsent to four cases and drop repeated leaf rows The helper keeps the JSON refusal, the declined prompt with its default, --yes and a shell SUPABASE_YES. Piped stdin, an agent in a terminal and a project .env are decided by requireHostedMutationConsent and stay pinned in hosted-consent.unit. secrets unset and secrets set lose the rows that replayed those cases without command-specific prompt, plan or echo text. --- .../secrets/set/set.integration.test.ts | 15 ---- .../secrets/unset/unset.integration.test.ts | 72 +------------------ apps/cli/tests/helpers/hosted-consent.ts | 69 ++---------------- 3 files changed, 9 insertions(+), 147 deletions(-) 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 c269ec1713..a31cd9d854 100644 --- a/apps/cli/src/commands/secrets/set/set.integration.test.ts +++ b/apps/cli/src/commands/secrets/set/set.integration.test.ts @@ -13,7 +13,6 @@ import { } 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 { describeHostedConsent } from "../../../../tests/helpers/hosted-consent.ts"; @@ -162,20 +161,6 @@ describe("secrets set integration", () => { 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({ - remote: Option.none(), - 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", () => { 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 995d53f752..757550e211 100644 --- a/apps/cli/src/commands/secrets/unset/unset.integration.test.ts +++ b/apps/cli/src/commands/secrets/unset/unset.integration.test.ts @@ -3,10 +3,7 @@ import { describe, expect, it } from "@effect/vitest"; import { Cause, Effect, Exit, FileSystem, Layer, Option, Schema } from "effect"; import { YesFlag } from "../../../command-internal/global-flags.ts"; -import { - HostedMutationConsentRequiredError, - HostedMutationDeclinedError, -} from "../../../command-internal/hosted-consent/hosted-consent.errors.ts"; +import { HostedMutationConsentRequiredError } 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"; @@ -20,7 +17,6 @@ import { mockCommandSettings, mockCommandPlatformApi, useTempWorkdir, - withConfigEnv, unverifiedTargetBanner, } from "../../../../tests/helpers/command-mocks.ts"; import { assertPermissionDrift } from "../../../../tests/helpers/permission-drift.ts"; @@ -39,12 +35,9 @@ const decodeJson = Schema.decodeEffect(Schema.fromJsonString(Schema.Unknown)); interface SetupOpts { format?: "text" | "json" | "stream-json"; outputFlag?: "pretty" | "json" | "yaml" | "toml" | "env"; - interactive?: boolean; unlinked?: boolean; yes?: boolean; stdinIsTty?: boolean; - /** Piped stdin lines a non-TTY confirm read would consume. */ - stdinInput?: string; confirm?: boolean; list?: SecretsList; listStatus?: number; @@ -66,7 +59,6 @@ const ON_TARGET = `project ${VALID_REF}`; function setup(opts: SetupOpts = {}) { const out = mockOutput({ format: opts.format ?? "text", - interactive: opts.interactive, confirmLogout: opts.confirm, }); // GET = list, DELETE = unset. Each branch supports its own status/network @@ -95,7 +87,7 @@ function setup(opts: SetupOpts = {}) { api, cliSettings, 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), @@ -167,21 +159,6 @@ describe("secrets unset integration", () => { }).pipe(Effect.provide(layer)); }); - it.effect("treats SUPABASE_YES=1 in the environment like --yes", () => { - const { layer, out, api } = setup(); - // Inner provide so the layer builds inside the pinned-env sandbox. - return withConfigEnv( - { SUPABASE_YES: "1" }, - Effect.gen(function* () { - yield* unset({ names: ["FOO"] }); - expect(out.stderrText).toBe( - `${unverifiedTargetBanner()}Confirmed by SUPABASE_YES: Unset 1 secret on ${ON_TARGET}\n`, - ); - expect(methods(api)).toEqual(["DELETE"]); - }).pipe(Effect.provide(layer)), - ); - }); - it.effect("prompts on a TTY with a default of No and unsets when the user accepts", () => { const { layer, out, api } = setup({ stdinIsTty: true, confirm: true }); return Effect.gen(function* () { @@ -195,18 +172,6 @@ describe("secrets unset integration", () => { expect(out.stdoutText).toBe("Finished supabase secrets unset.\n"); }).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("lists projects for the picker and fills the real linked-project cache", () => { @@ -330,39 +295,6 @@ describe("secrets unset integration", () => { }, ); - 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(HostedMutationConsentRequiredError); - expect(api.requests).toEqual([]); - expect(out.stderrText).toBe(unverifiedTargetBanner()); - }).pipe(Effect.provide(layer)); - }); - - it.effect.each(["json", "stream-json"] as const)( - "refuses --output-format %s without --yes even on a TTY", - (format) => { - const { layer, out, api } = setup({ format, stdinIsTty: true }); - return Effect.gen(function* () { - const error = yield* unset({ names: ["FOO"] }).pipe(Effect.flip); - expect(error).toBeInstanceOf(HostedMutationConsentRequiredError); - expect(api.requests).toEqual([]); - expect(out.promptConfirmCalls).toEqual([]); - }).pipe(Effect.provide(layer)); - }, - ); - - it.effect("refuses text output that is not interactive even on a TTY stdin", () => { - 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(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* () { diff --git a/apps/cli/tests/helpers/hosted-consent.ts b/apps/cli/tests/helpers/hosted-consent.ts index 33321a7095..98f954a54f 100644 --- a/apps/cli/tests/helpers/hosted-consent.ts +++ b/apps/cli/tests/helpers/hosted-consent.ts @@ -1,6 +1,6 @@ import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; -import { Cause, Effect, Exit, FileSystem, Layer, Option, Path } from "effect"; +import { Cause, Effect, Exit, FileSystem, Layer, Option } from "effect"; import type { Command } from "effect/cli"; import { @@ -24,16 +24,11 @@ import { mockOutput, mockStdin, mockTty } from "./mocks.ts"; type RuntimeLayer = ReturnType<typeof buildTestRuntime>; -const consentLayer = ( - runtime: RuntimeLayer, - yes: boolean, - policy: TargetPolicyShape, - agent: "auto" | "yes", -) => +const consentLayer = (runtime: RuntimeLayer, yes: boolean, policy: TargetPolicyShape) => Layer.mergeAll( runtime, Layer.succeed(YesFlag, yes), - Layer.succeed(AgentFlag, agent), + Layer.succeed(AgentFlag, "auto"), Layer.succeed(TargetPolicy, policy), ); @@ -68,9 +63,6 @@ interface CaseOptions { readonly interactive?: boolean; readonly yes?: boolean; readonly shellYes?: boolean; - readonly agent?: boolean; - readonly projectEnvYes?: boolean; - readonly stdinInput?: string; readonly confirmResponses?: ReadonlyArray<boolean>; readonly textResponses?: ReadonlyArray<string>; } @@ -92,14 +84,6 @@ const policyFor = (path: string, scenario: HostedConsentScenario): TargetPolicyS 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, unknown>): unknown => { if (Exit.isSuccess(exit)) return undefined; const failure = Exit.findErrorOption(exit); @@ -114,9 +98,8 @@ 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, no prompt for an agent, `--yes`, shell `SUPABASE_YES`, and - * a project `.env` that must not count as consent. + * Runs the hosted-consent matrix against the leaf at `path`: JSON output without `--yes` is + * refused, a TTY decline stops it, and `--yes` or a shell `SUPABASE_YES` lets it run. */ export function describeHostedConsent(path: string, scenario: HostedConsentScenario): void { const policy = policyFor(path, scenario); @@ -141,21 +124,15 @@ export function describeHostedConsent(path: string, scenario: HostedConsentScena api, cliSettings: mockCommandSettings({ workdir }), tty: mockTty({ stdinIsTty, stdoutIsTty: interactive }), - stdin: mockStdin(stdinIsTty, options.stdinInput), + stdin: mockStdin(stdinIsTty), outputFlag: options.json ? Option.some("json") : Option.none(), cliArgs: options.yes === true ? ["--yes"] : [], }); - const layer = consentLayer( - runtime, - options.yes ?? false, - policy, - options.agent ? "yes" : "auto", - ); + 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 = <A, E, R>(body: Effect.Effect<A, E, R>) => @@ -178,15 +155,6 @@ export function describeHostedConsent(path: string, scenario: HostedConsentScena }), ); - 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({ @@ -203,21 +171,6 @@ 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" @@ -244,13 +197,5 @@ export function describeHostedConsent(path: string, scenario: HostedConsentScena expect(out.stderrText).toContain("Confirmed by SUPABASE_YES: "); }), ); - - 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 af46d02fea0a8305e1e8bde5839923be100480e8 Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Tue, 20 Oct 2026 13:40:15 +0100 Subject: [PATCH 212/291] fix(cli): point a refused legacy local gen types connection at --remote On the legacy backend the hand-built local connection carried no suggestion context, so a database container that inspects fine but refuses the connection got no hint. A linked checkout now gets the local-stack hint plus the --remote pointer, as on the stack backend. --- .../src/commands/gen/types/SIDE_EFFECTS.md | 2 ++ .../src/commands/gen/types/types.handler.ts | 28 +++++++++------ .../gen/types/types.integration.test.ts | 34 +++++++++++++++++++ 3 files changed, 54 insertions(+), 10 deletions(-) diff --git a/apps/cli/src/commands/gen/types/SIDE_EFFECTS.md b/apps/cli/src/commands/gen/types/SIDE_EFFECTS.md index fe73213ac4..5cadf3ef2f 100644 --- a/apps/cli/src/commands/gen/types/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/gen/types/SIDE_EFFECTS.md @@ -161,6 +161,8 @@ Not applicable. `Try rerunning the command with --debug to troubleshoot the error.`, and the JSON error envelope reports it as `suggestion`: `{"code":"GenTypesLocalDbNotRunningError","message":"supabase start is not running.","suggestion":"To use the linked project instead, pass --remote."}`. + A container that exists but refuses the connection (`DbConnectError`) carries + `Make sure Docker is running, then run: supabase start` and, on a second line, the same pointer. An unlinked checkout keeps the error without one. - **`--project-id`.** A hidden alias of the deprecated `--project-ref`; passing both fails with `--project-id is a deprecated alias of --project-ref; pass only one of them.` diff --git a/apps/cli/src/commands/gen/types/types.handler.ts b/apps/cli/src/commands/gen/types/types.handler.ts index 7ba3987d58..6ff0b09607 100644 --- a/apps/cli/src/commands/gen/types/types.handler.ts +++ b/apps/cli/src/commands/gen/types/types.handler.ts @@ -516,7 +516,7 @@ export const genTypes = Effect.fn("gen.types")(function* (flags: GenTypesFlags) }); }); - const assertLocalDbRunning = (projectId: string) => + const assertLocalDbRunning = (projectId: string, linkedCheckout: boolean) => Effect.scoped( Effect.gen(function* () { // Only the exit code and stderr matter; discard stdout so the inspect JSON can't @@ -536,14 +536,7 @@ export const genTypes = Effect.fn("gen.types")(function* (flags: GenTypesFlags) ]); if (exitCode !== 0) { const message = stderr.trim(); - const suggestion = (yield* isCheckoutLinked( - fs, - path, - cliSettings.workdir, - cliSettings.projectId, - )) - ? SUGGEST_LINKED_PROJECT - : undefined; + const suggestion = linkedCheckout ? SUGGEST_LINKED_PROJECT : undefined; if (message.toLowerCase().includes("no such container")) { return yield* new GenTypesLocalDbNotRunningError({ message: "supabase start is not running.", @@ -665,7 +658,13 @@ export const genTypes = Effect.fn("gen.types")(function* (flags: GenTypesFlags) yield* configValues.load({ workdir: cliSettings.workdir, projectRef: Option.none() }), ); yield* announceTarget(renderLocalTarget(host, config.port)); - yield* assertLocalDbRunning(projectId); + const linkedCheckout = yield* isCheckoutLinked( + fs, + path, + cliSettings.workdir, + cliSettings.projectId, + ); + yield* assertLocalDbRunning(projectId, linkedCheckout); yield* runGenerate({ conn: { host, @@ -673,6 +672,15 @@ export const genTypes = Effect.fn("gen.types")(function* (flags: GenTypesFlags) user: "postgres", password: config.password, database: "postgres", + ...(linkedCheckout + ? { + suggestionContext: { + dashboardUrl: cliSettings.dashboardUrl, + profileName: cliSettings.profile, + linkedCheckout, + }, + } + : {}), }, isLocal: true, includedSchemas, 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 a8c7341be8..f7152c61fc 100644 --- a/apps/cli/src/commands/gen/types/types.integration.test.ts +++ b/apps/cli/src/commands/gen/types/types.integration.test.ts @@ -3229,6 +3229,40 @@ describe("gen types", () => { }).pipe(Effect.provide(BunServices.layer)), ); + it.effect.each([ + { + name: "a linked checkout", + linked: true, + suggestion: + "Make sure Docker is running, then run: supabase start\nTo use the linked project instead, pass --remote.", + }, + { name: "an unlinked checkout", linked: false, suggestion: undefined }, + ])( + "reports a refused local connection in $name in the JSON error envelope", + ({ linked, suggestion }) => + Effect.gen(function* () { + const workdir = yield* makeWorkdir("supabase-gen-types-local-refused-"); + if (linked) yield* writeTempFile(workdir, "project-ref", VALID_REF); + const refused = Object.assign(new Error("PgClient: Failed to connect"), { + cause: Object.assign(new Error("connect ECONNREFUSED 127.0.0.1:54322"), { + code: "ECONNREFUSED", + }), + }); + const { layer, out } = yield* setup({ + workdir, + format: "json", + generator: mockGenTypesGenerator({ + generate: (input) => Effect.fail(toConnectError(input.conn, input.isLocal, refused)), + }), + }); + yield* genTypes(defaultFlags()).pipe(withJsonErrorHandling, Effect.provide(layer)); + + expect(out.failures).toHaveLength(1); + expect(out.failures[0]).toMatchObject({ code: "DbConnectError" }); + expect(out.failures[0]?.suggestion).toBe(suggestion); + }).pipe(Effect.provide(BunServices.layer)), + ); + it.effect("generates locally with defaults when supabase/config.toml is missing", () => Effect.gen(function* () { const workdir = yield* makeWorkdir("supabase-gen-types-local-no-config-"); From 532f6fc1a7628801e799fbeb143b4a717e818af5 Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Tue, 20 Oct 2026 13:42:02 +0100 Subject: [PATCH 213/291] test(cli): pin the hosted-consent prompt default of every consenting leaf An inline snapshot of leaf path to consent default, over the same leaf enumeration as the coverage scan, records the create-only leaves that default to Yes and everything else defaulting to No. describeHostedConsent derives its expected default from the policy under test, so this is the independent record of the ruling. --- .../hosted-consent-coverage.unit.test.ts | 73 +++++++++++++++++-- 1 file changed, 65 insertions(+), 8 deletions(-) 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 c00ec4d0b9..f317bf6077 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 @@ -15,18 +15,19 @@ const VARIANTS: ReadonlyArray<PermissionVariant> = [ { stackBackend: "stack", computeEnabled: true }, ]; -const consentLeafPaths: ReadonlySet<string> = new Set( +const consentDefaultByLeaf: ReadonlyMap<string, "no" | "yes"> = new Map( 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), + [...commandTreeFor(variant)].flatMap(([path, node]) => { + const policy = readTargetPolicy(node.command); + return node.isLeaf && Option.isSome(policy) && policy.value.consent !== "none" + ? [[path, policy.value.consent] as const] + : []; + }), ), ); +const consentLeafPaths: ReadonlySet<string> = new Set(consentDefaultByLeaf.keys()); + const LITERAL_CALL = /\bdescribeHostedConsent\(\s*"([^"]+)"/g; const ANY_CALL = /\bdescribeHostedConsent\(/g; @@ -90,4 +91,60 @@ describe("hosted consent coverage", () => { it("starts every consent action literal with a capital letter", () => { expect(lowercaseActions).toEqual([]); }); + + it("defaults the prompt to Yes for create-only leaves and No for everything else", () => { + expect(Object.fromEntries(consentDefaultByLeaf)).toMatchInlineSnapshot(` + { + "backups restore": "no", + "bootstrap": "yes", + "branches create": "yes", + "branches delete": "no", + "branches disable": "no", + "branches pause": "no", + "branches unpause": "no", + "branches update": "no", + "compute delete": "no", + "compute push": "no", + "config push": "no", + "db pull": "no", + "db push": "no", + "db query": "no", + "db reset": "no", + "db test": "no", + "domains activate": "no", + "domains create": "yes", + "domains delete": "no", + "encryption update-root-key": "no", + "feedback delete": "no", + "functions delete": "no", + "functions deploy": "no", + "migration down": "no", + "migration repair": "no", + "migration squash": "no", + "migration up": "no", + "network-bans remove": "no", + "network-restrictions update": "no", + "notebooks push": "no", + "orgs create": "yes", + "postgres-config delete": "no", + "postgres-config update": "no", + "projects create": "yes", + "projects delete": "no", + "pull": "no", + "secrets set": "no", + "secrets unset": "no", + "seed buckets": "no", + "ssl-enforcement update": "no", + "sso add": "no", + "sso remove": "no", + "sso update": "no", + "storage cp": "no", + "storage mv": "no", + "storage rm": "no", + "test db": "no", + "vanity-subdomains activate": "no", + "vanity-subdomains delete": "no", + } + `); + }); }); From 85052003fd512ee20b88c3b9482cee97a01a7141 Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Tue, 20 Oct 2026 13:42:12 +0100 Subject: [PATCH 214/291] test(cli): cover target selection for seed buckets with the shared harness --- .../seed/buckets/buckets.integration.test.ts | 165 +++--------------- apps/cli/tests/helpers/db-target-selection.ts | 27 ++- 2 files changed, 41 insertions(+), 151 deletions(-) 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 76c4717fe8..6d11e3a3ab 100644 --- a/apps/cli/src/commands/seed/buckets/buckets.integration.test.ts +++ b/apps/cli/src/commands/seed/buckets/buckets.integration.test.ts @@ -51,10 +51,14 @@ import { StackStorageCapabilityError, StackStorageUnavailableError, } from "../../../command-internal/stack-storage.ts"; +import { + branchLookupsOf, + describeDbTargetSelection, + storageTargetOf, +} from "../../../../tests/helpers/db-target-selection.ts"; import { describeHostedConsent } from "../../../../tests/helpers/hosted-consent.ts"; import { buildStorageStackApi, - STORAGE_TEST_BRANCH_REF, STORAGE_TEST_JWT_SECRET, storageConsentApi, storageConsentServices, @@ -68,7 +72,6 @@ import { realLinkedProjectCache, } from "../../../../tests/helpers/permission-real-layers.ts"; import { cliConfigValuesTestLayer } from "../../../../tests/helpers/config-values-layer.ts"; -import { warningsOf } from "../../../../tests/helpers/remote-selection.ts"; interface MockRoute { readonly method: string; @@ -309,8 +312,6 @@ const setupSeedBuckets = Effect.fnUntraced(function* ( }; }); -const FLAG_REF = "flagflagflagflagflag"; - const VECTOR_LIST = "/storage/v1/vector/ListVectorBuckets"; const VECTOR_CREATE = "/storage/v1/vector/CreateVectorBucket"; const VECTOR_DELETE = "/storage/v1/vector/DeleteVectorBucket"; @@ -2267,152 +2268,32 @@ describe("seed buckets", () => { }).pipe(seedScenario), ); - it.effect("--remote=<ref> seeds the project given by the flag, overriding VALID_REF", () => - Effect.gen(function* () { - const { layer, out, requests, linkedCache } = yield* setupSeedBuckets(tmp.current, { - toml: "[storage.buckets.test]\npublic = true\n", - args: ["seed", "buckets", `--remote=${FLAG_REF}`], - routes: [ - { method: "GET", match: "/storage/v1/bucket", body: [] }, - { method: "POST", match: "/storage/v1/bucket", body: { name: "test" } }, - ], - }); - const exit = yield* seedBuckets({ - ...DEFAULT_FLAGS, - remote: Option.some({ _tag: "Ref", ref: FLAG_REF } as const), - local: false, - }).pipe(Effect.provide(layer), Effect.exit); - expect(Exit.isSuccess(exit)).toBe(true); - expect(out.stderrText).toContain("Creating Storage bucket: test"); - expect(requests.some((r) => r.url.startsWith(`https://${FLAG_REF}.supabase.co`))).toBe(true); - expect(requests.some((r) => r.url.includes(VALID_REF))).toBe(false); - expect(linkedCache.cached).toBe(true); - expect(linkedCache.cachedRef).toBe(FLAG_REF); - }).pipe(seedScenario), - ); - - it.effect("--remote=<branch> seeds the project of the branch it names", () => - Effect.gen(function* () { - const { layer, requests, linkedCache, managementCalls, out } = yield* setupSeedBuckets( - tmp.current, - { + describeDbTargetSelection("seed buckets", { + dbUrl: false, + workdir: () => tmp.current, + around: seedScenario, + attempt: ({ workdir, args, target }) => + Effect.gen(function* () { + const ctx = yield* setupSeedBuckets(workdir, { toml: "[storage.buckets.test]\npublic = true\n", - args: ["seed", "buckets", "--remote=feature-x"], + args, branches: [storageTestBranch("feature-x")], routes: [ { method: "GET", match: "/storage/v1/bucket", body: [] }, { method: "POST", match: "/storage/v1/bucket", body: { name: "test" } }, ], - }, - ); - const exit = yield* seedBuckets({ - ...DEFAULT_FLAGS, - remote: Option.some({ _tag: "Branch", name: "feature-x" } as const), - local: false, - }).pipe(Effect.provide(layer), Effect.exit); - expect(Exit.isSuccess(exit)).toBe(true); - expect(managementCalls.filter(({ method }) => method === "listAllBranches")).toEqual([ - { method: "listAllBranches", input: { ref: VALID_REF } }, - ]); - expect( - requests.some((r) => r.url.startsWith(`https://${STORAGE_TEST_BRANCH_REF}.supabase.co`)), - ).toBe(true); - expect(linkedCache.cachedRef).toBe(STORAGE_TEST_BRANCH_REF); - expect(warningsOf(out)).toEqual([]); - }).pipe(seedScenario), - ); - - it.effect("fails with the branches that exist when --remote=<branch> matches none", () => - Effect.gen(function* () { - const { layer, requests, linkedCache } = yield* setupSeedBuckets(tmp.current, { - toml: "[storage.buckets.test]\npublic = true\n", - branches: [storageTestBranch("feature-x")], - }); - const error = yield* seedBuckets({ - ...DEFAULT_FLAGS, - remote: Option.some({ _tag: "Branch", name: "feature-y" } as const), - local: false, - }).pipe(Effect.provide(layer), Effect.flip); - expect(error).toMatchObject({ - _tag: "LinkBranchNotFoundError", - message: expect.stringContaining("Available branches: feature-x"), - }); - expect(requests).toEqual([]); - expect(linkedCache.cached).toBe(false); - }).pipe(seedScenario), - ); - - describe("deprecated selectors", () => { - const cases = [ - { - alias: "--linked", - cliArgs: ["seed", "buckets", "--linked"], - flags: { linked: true, local: false }, - host: `https://${VALID_REF}.supabase.co`, - warning: "--linked is deprecated and will be removed in v4. Use --remote instead", - }, - { - alias: "--project-ref", - cliArgs: ["seed", "buckets", "--project-ref", FLAG_REF], - flags: { projectRef: Option.some(FLAG_REF), local: false }, - host: `https://${FLAG_REF}.supabase.co`, - warning: `--project-ref is deprecated and will be removed in v4. Use --remote=<ref> instead`, - }, - { - alias: "--local", - cliArgs: ["seed", "buckets", "--local"], - flags: { local: true }, - host: "http://", - warning: "--local isn't needed (local is the default) and will be removed in v4", - }, - ] as const; - - for (const testCase of cases) { - it.effect(`${testCase.alias} still selects its target and warns`, () => - Effect.gen(function* () { - const { layer, out, requests } = yield* setupSeedBuckets(tmp.current, { - toml: "[storage.buckets.test]\npublic = true\n", - args: testCase.cliArgs, - routes: [ - { method: "GET", match: "/storage/v1/bucket", body: [] }, - { method: "POST", match: "/storage/v1/bucket", body: { name: "test" } }, - ], - }); - const exit = yield* seedBuckets({ ...DEFAULT_FLAGS, ...testCase.flags }).pipe( - Effect.provide(layer), - Effect.exit, - ); - expect(Exit.isSuccess(exit)).toBe(true); - expect(requests.length).toBeGreaterThan(0); - expect(requests.every((r) => r.url.startsWith(testCase.host))).toBe(true); - expect(warningsOf(out)).toEqual([testCase.warning]); - }).pipe(seedScenario), - ); - } + }); + return { + layer: ctx.layer, + out: ctx.out, + run: seedBuckets({ ...DEFAULT_FLAGS, ...target }), + target: () => storageTargetOf(ctx.requests), + cachedRef: () => ctx.linkedCache.cachedRef, + branchLookups: () => branchLookupsOf(ctx.managementCalls), + }; + }), }); - it.effect("rejects --project-ref combined with --local", () => - Effect.gen(function* () { - const { layer, requests, linkedCache } = yield* setupSeedBuckets(tmp.current, { - toml: "[storage.buckets.test]\npublic = true\n", - }); - const exit = yield* seedBuckets({ - remote: Option.none(), - linked: false, - local: true, - projectRef: Option.some(FLAG_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 selects a remote project and cannot be combined with --local.", - ); - } - expect(requests).toEqual([]); - expect(linkedCache.cached).toBe(false); - }).pipe(seedScenario), - ); - it.effect("--local=false stays on the local path", () => Effect.gen(function* () { const { layer, requests } = yield* setupSeedBuckets(tmp.current, { diff --git a/apps/cli/tests/helpers/db-target-selection.ts b/apps/cli/tests/helpers/db-target-selection.ts index 3a8495be91..0de56cf886 100644 --- a/apps/cli/tests/helpers/db-target-selection.ts +++ b/apps/cli/tests/helpers/db-target-selection.ts @@ -115,7 +115,9 @@ interface DbTargetSelectionScenario<R> { readonly workdir: string; readonly args: ReadonlyArray<string>; readonly target: TargetFlagValuesWithDbUrl; - }) => DbTargetAttempt<R>; + }) => DbTargetAttempt<R> | Effect.Effect<DbTargetAttempt<R>, unknown, BunServices.BunServices>; + /** Wraps each row, for a leaf whose tests pin ambient environment variables around their body. */ + readonly around?: (row: Effect.Effect<void, unknown>) => Effect.Effect<void, unknown>; /** Rows the leaf cannot express, each with why. */ readonly optOut?: Partial<Record<Row, string>>; } @@ -158,18 +160,25 @@ export function describeDbTargetSelection<R>( it.effect(name, () => { const workdir = scenario.workdir(); if (spec.linked === true) linkCheckout(workdir); - const ctx = scenario.attempt({ + const attempted = scenario.attempt({ workdir, args: [...words, ...spec.args], target: spec.target, }); - return spec - .check(ctx) - .pipe( - Effect.provide( - Layer.mergeAll(ctx.layer, commandTargetLayer.pipe(Layer.provide(ctx.out.layer))), - ), - ); + const row = Effect.flatMap( + Effect.isEffect(attempted) + ? Effect.provide(attempted, BunServices.layer) + : Effect.succeed(attempted), + (ctx) => + spec + .check(ctx) + .pipe( + Effect.provide( + Layer.mergeAll(ctx.layer, commandTargetLayer.pipe(Layer.provide(ctx.out.layer))), + ), + ), + ); + return scenario.around === undefined ? row : scenario.around(row); }); }; From 6712abd0e069e657161bb14a5edff8a8adeae3e6 Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Tue, 20 Oct 2026 13:43:58 +0100 Subject: [PATCH 215/291] test(cli): pin the operand capacity of every leaf that accepts --remote Whether a bare --remote joins the next word depends on how many operands the leaf takes, and that changes silently when a command's arguments change. An inline snapshot of leaf path to operand capacity, computed through remoteArgvLeafFor as prepareCliArgs does, surfaces the change. --- .../src/shared/cli/remote-argv.unit.test.ts | 136 +++++++++++++++++- 1 file changed, 135 insertions(+), 1 deletion(-) diff --git a/apps/cli/src/shared/cli/remote-argv.unit.test.ts b/apps/cli/src/shared/cli/remote-argv.unit.test.ts index 0003f663a6..d78b2f4a7e 100644 --- a/apps/cli/src/shared/cli/remote-argv.unit.test.ts +++ b/apps/cli/src/shared/cli/remote-argv.unit.test.ts @@ -1,5 +1,7 @@ import { describe, expect, it } from "vitest"; +import { commandTreeFor } from "../../../tests/helpers/command-flags.ts"; +import type { PermissionVariant } from "../../../tests/helpers/permission-registry.ts"; import { rootCommandForFeatures } from "../../cli/root.ts"; import { bareRemoteOperand, @@ -7,7 +9,7 @@ import { joinRemoteOperands, type RemoteArgvLeaf, } from "./remote-argv.ts"; -import { prepareCliArgs } from "./run.ts"; +import { prepareCliArgs, remoteArgvLeafFor } from "./run.ts"; const REF = "abcdefghijklmnopqrst"; const URL = "postgresql://postgres:secret@db.example.com:5432/postgres"; @@ -462,3 +464,135 @@ describe("prepareCliArgs remoteConflict on the real command tree", () => { expect(conflictOf(["db", "pull", "--remote", "feature-x", "--remote"])).toBe(false); }); }); + +describe("remote operand capacity of the real command tree", () => { + const variants: ReadonlyArray<PermissionVariant> = [ + undefined, + { stackBackend: "stack", computeEnabled: true }, + ]; + + it("pins the operand capacity of every leaf that accepts --remote", () => { + const capacityByLeaf = new Map( + variants.flatMap((variant) => { + const root = rootCommandForFeatures(variant); + return [...commandTreeFor(variant)] + .filter(([, node]) => node.isLeaf && node.flags.has("remote")) + .map(([path]) => { + const leaf = remoteArgvLeafFor(root, [...path.split(" "), "--remote"]); + return [path, leaf?.operandCapacity] as const; + }); + }), + ); + expect(Object.fromEntries(capacityByLeaf)).toMatchInlineSnapshot(` + { + "backups list": 0, + "backups restore": 0, + "branches create": 1, + "branches delete": 1, + "branches disable": 0, + "branches get": 1, + "branches list": 0, + "branches pause": 1, + "branches unpause": 1, + "branches update": 1, + "compute delete": 1, + "compute list": 0, + "compute logs": 1, + "compute push": Infinity, + "compute status": 1, + "config diff": 0, + "config pull": 0, + "config push": 0, + "db advisors": 0, + "db diff": 0, + "db dump": 0, + "db lint": 0, + "db pull": 1, + "db push": 0, + "db query": 1, + "db reset": 0, + "db schema declarative generate": 0, + "db test": Infinity, + "domains activate": 0, + "domains create": 0, + "domains delete": 0, + "domains get": 0, + "domains reverify": 0, + "encryption get-root-key": 0, + "encryption update-root-key": 0, + "functions delete": 1, + "functions deploy": Infinity, + "functions download": 1, + "functions list": 0, + "gen keys": 0, + "gen types": 0, + "inspect db bloat": 0, + "inspect db blocking": 0, + "inspect db cache-hit": 0, + "inspect db calls": 0, + "inspect db db-stats": 0, + "inspect db index-sizes": 0, + "inspect db index-stats": 0, + "inspect db index-usage": 0, + "inspect db locks": 0, + "inspect db long-running-queries": 0, + "inspect db outliers": 0, + "inspect db replication-slots": 0, + "inspect db role-configs": 0, + "inspect db role-connections": 0, + "inspect db role-stats": 0, + "inspect db seq-scans": 0, + "inspect db table-index-sizes": 0, + "inspect db table-record-counts": 0, + "inspect db table-sizes": 0, + "inspect db table-stats": 0, + "inspect db total-index-size": 0, + "inspect db total-table-sizes": 0, + "inspect db traffic-profile": 0, + "inspect db unused-indexes": 0, + "inspect db vacuum-stats": 0, + "inspect report": 0, + "migration down": 0, + "migration fetch": 0, + "migration list": 0, + "migration repair": Infinity, + "migration squash": 0, + "migration up": 0, + "network-bans get": 0, + "network-bans remove": 0, + "network-restrictions get": 0, + "network-restrictions update": 0, + "notebooks pull": 1, + "notebooks push": 1, + "postgres-config delete": 0, + "postgres-config get": 0, + "postgres-config update": 0, + "projects api-keys": 0, + "pull": 0, + "secrets list": 0, + "secrets set": Infinity, + "secrets unset": Infinity, + "seed buckets": 0, + "snippets download": 1, + "snippets list": 0, + "ssl-enforcement get": 0, + "ssl-enforcement update": 0, + "sso add": 0, + "sso info": 0, + "sso list": 0, + "sso remove": 1, + "sso show": 1, + "sso update": 1, + "storage cp": 2, + "storage ls": 1, + "storage mv": 2, + "storage rm": Infinity, + "test db": Infinity, + "vanity-subdomains activate": 0, + "vanity-subdomains check-availability": 0, + "vanity-subdomains delete": 0, + "vanity-subdomains get": 0, + } + `); + }); +}); From e69e54ce1ceb6a1e53a4086b9c0c826263e2ac1a Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Tue, 20 Oct 2026 13:46:39 +0100 Subject: [PATCH 216/291] test(cli): keep the branch permission-drift check in the shared target-selection harness --- .../db/inspect-query.integration.test.ts | 2 +- .../inspect/report/report.integration.test.ts | 2 +- .../migration/down/down.integration.test.ts | 2 +- .../migration/fetch/fetch.integration.test.ts | 2 +- .../migration/list/list.integration.test.ts | 2 +- .../repair/repair.integration.test.ts | 2 +- .../squash/squash.integration.test.ts | 2 +- .../migration/up/up.integration.test.ts | 2 +- apps/cli/tests/helpers/db-target-selection.ts | 30 ++++++++++++++----- 9 files changed, 30 insertions(+), 16 deletions(-) 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 667884c42d..0ab29e63fa 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 @@ -400,7 +400,7 @@ describe("inspect db query runner", () => { out: ctx.out, run: inspectDbDbStats(flags(target)), target: () => ctx.resolver.connectionInput?.target, - branchLookups: () => ctx.branchRequests.map(({ method, url }) => `${method} ${url}`), + branchLookups: () => ctx.branchRequests, }; }, }); 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 d3abb63e80..bab83312d3 100644 --- a/apps/cli/src/commands/inspect/report/report.integration.test.ts +++ b/apps/cli/src/commands/inspect/report/report.integration.test.ts @@ -358,7 +358,7 @@ describe("inspect report", () => { out: ctx.out, run: inspectReport(flags({ ...target, outputDir: workdir })), target: () => ctx.resolver.connectionInput?.target, - branchLookups: () => ctx.branchRequests.map(({ method, url }) => `${method} ${url}`), + branchLookups: () => ctx.branchRequests, }; }, }); 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 c0452d2ff2..b51b1176b5 100644 --- a/apps/cli/src/commands/migration/down/down.integration.test.ts +++ b/apps/cli/src/commands/migration/down/down.integration.test.ts @@ -521,7 +521,7 @@ describeDbTargetSelection("migration down", { ), target: () => ctx.resolverCalls[0]?.target, cachedRef: () => ctx.cache.cachedRef, - branchLookups: () => ctx.branchRequests.map(({ method, url }) => `${method} ${url}`), + branchLookups: () => ctx.branchRequests, }; }, }); 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 b12bbf6693..f5a13d42a2 100644 --- a/apps/cli/src/commands/migration/fetch/fetch.integration.test.ts +++ b/apps/cli/src/commands/migration/fetch/fetch.integration.test.ts @@ -618,7 +618,7 @@ describeDbTargetSelection("migration fetch", { run: migrationFetch(flags(target)), target: () => ctx.resolveCalls[0]?.target, cachedRef: () => ctx.cache.cachedRef, - branchLookups: () => ctx.branchRequests.map(({ method, url }) => `${method} ${url}`), + branchLookups: () => ctx.branchRequests, }; }, }); 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 914f359df1..b9d184c020 100644 --- a/apps/cli/src/commands/migration/list/list.integration.test.ts +++ b/apps/cli/src/commands/migration/list/list.integration.test.ts @@ -401,7 +401,7 @@ describeDbTargetSelection("migration list", { ), target: () => ctx.resolverCalls[0]?.target, cachedRef: () => ctx.cache.cachedRef, - branchLookups: () => ctx.branchRequests.map(({ method, url }) => `${method} ${url}`), + branchLookups: () => ctx.branchRequests, }; }, }); 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 7d8ea3a4a9..1cdad05ce1 100644 --- a/apps/cli/src/commands/migration/repair/repair.integration.test.ts +++ b/apps/cli/src/commands/migration/repair/repair.integration.test.ts @@ -567,7 +567,7 @@ describeDbTargetSelection("migration repair", { ), target: () => ctx.resolverCalls[0]?.target, cachedRef: () => ctx.cache.cachedRef, - branchLookups: () => ctx.branchRequests.map(({ method, url }) => `${method} ${url}`), + branchLookups: () => ctx.branchRequests, }; }, }); 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 47101989da..ab89f6f9ea 100644 --- a/apps/cli/src/commands/migration/squash/squash.integration.test.ts +++ b/apps/cli/src/commands/migration/squash/squash.integration.test.ts @@ -1504,7 +1504,7 @@ describeDbTargetSelection("migration squash", { ), target: () => s.resolverCalls[0]?.target, cachedRef: () => s.cache.cachedRef, - branchLookups: () => s.branchRequests.map(({ method, url }) => `${method} ${url}`), + branchLookups: () => s.branchRequests, }; }, }); 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 f6b303f70d..c3db756a6d 100644 --- a/apps/cli/src/commands/migration/up/up.integration.test.ts +++ b/apps/cli/src/commands/migration/up/up.integration.test.ts @@ -453,7 +453,7 @@ describeDbTargetSelection("migration up", { run: migrationUp(flags(target)), target: () => ctx.resolverCalls[0]?.target, cachedRef: () => ctx.cache.cachedRef, - branchLookups: () => ctx.branchRequests.map(({ method, url }) => `${method} ${url}`), + branchLookups: () => ctx.branchRequests, }; }, }); diff --git a/apps/cli/tests/helpers/db-target-selection.ts b/apps/cli/tests/helpers/db-target-selection.ts index 0de56cf886..ae484b7321 100644 --- a/apps/cli/tests/helpers/db-target-selection.ts +++ b/apps/cli/tests/helpers/db-target-selection.ts @@ -17,6 +17,7 @@ import { normalizeCause } from "../../src/shared/output/normalize-error.ts"; import { textCliOutputFormatter } from "../../src/shared/output/text-formatter.ts"; import { stripAnsi } from "./ansi.ts"; import { VALID_REF } from "./command-mocks.ts"; +import { assertPermissionDrift } from "./permission-drift.ts"; import { mockAnalytics, mockOutput, @@ -29,9 +30,8 @@ import { BRANCH_PROJECT_REF, warningsOf } from "./remote-selection.ts"; const FLAG_REF = "flagflagflagflagflag"; const DB_URL = "postgresql://postgres:postgres@db.example.com:5432/postgres"; -const branchListRequest = (ref: string) => - `GET https://api.supabase.com/v1/projects/${ref}/branches`; -const BRANCH_LOOKUP = branchListRequest(VALID_REF); +const branchListUrl = (ref: string) => `https://api.supabase.com/v1/projects/${ref}/branches`; +const BRANCH_LOOKUP = `GET ${branchListUrl(VALID_REF)}`; /** Every selector flag absent, so a command takes its default target. */ export const NO_TARGET: TargetFlagValuesWithDbUrl = { @@ -56,15 +56,21 @@ type ObservedTarget = | { readonly kind: "linked"; readonly ref: string } | { readonly kind: "db-url" }; +interface RecordedLookup { + readonly method: string; + readonly url: string; + readonly urlParams?: string; +} + /** The branch lookups, as `branchLookups` reports them, among the Management API method calls a fake recorded. */ export const branchLookupsOf = ( calls: ReadonlyArray<{ readonly method: string; readonly input: unknown }>, -): ReadonlyArray<string> => +): ReadonlyArray<RecordedLookup> => calls.flatMap(({ method, input }) => method === "listAllBranches" && Predicate.hasProperty(input, "ref") && Predicate.isString(input.ref) - ? [branchListRequest(input.ref)] + ? [{ method: "GET", url: branchListUrl(input.ref) }] : [], ); @@ -85,8 +91,8 @@ interface DbTargetAttempt<R> { /** The target the leaf opened its connection or requests for; `undefined` until it reached one. */ readonly target: () => ObservedTarget | undefined; readonly cachedRef?: () => string | undefined; - /** The `METHOD url` of each branch-list request, for a leaf that records them. */ - readonly branchLookups?: () => ReadonlyArray<string>; + /** The branch-list requests the leaf sent, for a leaf that records them. */ + readonly branchLookups?: () => ReadonlyArray<RecordedLookup>; } type Row = @@ -246,7 +252,15 @@ export function describeDbTargetSelection<R>( expect(ctx.target()).toMatchObject({ kind: "linked", ref: BRANCH_PROJECT_REF }); expect(warningsOf(ctx.out)).toEqual([]); expectCachedRef(ctx, BRANCH_PROJECT_REF); - if (ctx.branchLookups !== undefined) expect(ctx.branchLookups()).toEqual([BRANCH_LOOKUP]); + if (ctx.branchLookups !== undefined) { + const lookups = ctx.branchLookups(); + expect(lookups.map(({ method, url }) => `${method} ${url}`)).toEqual([BRANCH_LOOKUP]); + assertPermissionDrift({ + command: path, + activeFlags: { remote: "feature-x" }, + requests: lookups, + }); + } }), }); From 3578c791dcb93fca2e637738ea44b73209f9214d Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Tue, 20 Oct 2026 13:47:10 +0100 Subject: [PATCH 217/291] refactor(cli): bring round-2 files under the comment ratio Condense multi-line JSDoc to one sentence and drop prose the names already carry, so comment lines stay at or below a quarter of code lines in the files this PR added or edited. --- apps/cli-e2e/src/tests/link-project.ts | 6 +- .../command-permissions/model.ts | 62 ++-------- apps/cli/src/command-internal/global-flags.ts | 111 ++++-------------- .../target/branch-resolution.errors.ts | 16 +-- .../target/remote-vocabulary.ts | 1 - .../config/push/push.branch-target.ts | 30 +---- apps/cli/src/commands/db/test/test.command.ts | 12 +- .../commands/inspect/db/inspect-db-command.ts | 5 +- apps/cli/src/commands/pull/pull.steps.ts | 25 +--- apps/cli/src/shared/cli/cli-args.service.ts | 1 - 10 files changed, 50 insertions(+), 219 deletions(-) diff --git a/apps/cli-e2e/src/tests/link-project.ts b/apps/cli-e2e/src/tests/link-project.ts index 79af00b6c6..e0d0df353a 100644 --- a/apps/cli-e2e/src/tests/link-project.ts +++ b/apps/cli-e2e/src/tests/link-project.ts @@ -1,11 +1,7 @@ import { mkdirSync, writeFileSync } from "node:fs"; import { join } from "node:path"; -/** Writes .supabase/.temp/project-ref and a stub pooler-url so --remote - * commands resolve a connection without a live postgres TCP probe (nothing - * listens on db.{ref}.localhost:5432 in the test harness); combined with - * SUPABASE_DB_PASSWORD (set in harness.ts), the command reaches its handler - * and makes the Management API call under test. */ +/** Writes `project-ref` and a stub `pooler-url` so `--remote` commands reach their handler without a live Postgres probe. */ export function linkProject(dir: string, ref: string): void { const tempDir = join(dir, "supabase", ".temp"); mkdirSync(tempDir, { recursive: true }); diff --git a/apps/cli/src/command-internal/command-permissions/model.ts b/apps/cli/src/command-internal/command-permissions/model.ts index 165d98103f..7940d81ef2 100644 --- a/apps/cli/src/command-internal/command-permissions/model.ts +++ b/apps/cli/src/command-internal/command-permissions/model.ts @@ -1,47 +1,31 @@ /** Serializable description of the Management API operations a command may call, keyed by the spec's `operationId`. */ -/** - * Whether a command's useful result depends on the call. `best-effort` means a 403 on the call - * still leaves the command's useful result; anything else is `required`. - */ +/** `best-effort` keeps the command's useful result when the call gets a 403; anything else is `required`. */ type PermissionKind = "required" | "best-effort"; interface FlagConditionBase { readonly flag: string; - /** - * The building block that wrote this condition (set by {@link block}). A block's condition may - * name a flag the including command lacks; a condition without `source`, including one added - * by {@link withCondition}, must name a flag of the command it's declared on. - */ + /** The block that wrote this condition ({@link block}); without one, the flag must belong to the command it is declared on. */ readonly source?: string; readonly dbTarget?: never; readonly selector?: never; } -/** - * The flag must be present (or absent, when `present: false`) for an entry to apply. A flag given - * an empty value counts as absent. - */ +/** The flag must be present (or absent, with `present: false`); an empty value counts as absent. */ interface PresenceCondition extends FlagConditionBase { readonly present?: boolean; readonly oneOf?: never; readonly is?: never; } -/** - * The switch's effective value, default included, must equal `is` for an entry to apply. A switch - * the including command lacks counts as `false`. - */ +/** The switch's effective value, default included, must equal `is`; a switch the command lacks counts as `false`. */ interface SwitchCondition extends FlagConditionBase { readonly is: boolean; readonly present?: never; readonly oneOf?: never; } -/** - * The flag's value must be one of `oneOf` for an entry to apply. For a flag with a default, the - * effective value counts, so a run that omits `--lang` still matches `oneOf: ["typescript"]`. - */ +/** The flag's effective value, default included, must be one of `oneOf`. */ interface ValueCondition extends FlagConditionBase { readonly oneOf: ReadonlyArray<string>; readonly present?: never; @@ -50,18 +34,10 @@ interface ValueCondition extends FlagConditionBase { type FlagCondition = PresenceCondition | SwitchCondition | ValueCondition; -/** - * The target a command talks to: the local stack, a database URL, or a hosted project. `seed - * buckets` uses it for its Storage target, which `--remote`/`--local` select the same way. - */ +/** The target a command talks to: the local stack, a database URL, or a hosted project; `seed buckets` uses it for its Storage target. */ export type DbTarget = "local" | "db-url" | "linked"; -/** - * The run's database target must be one of `dbTarget` for an entry to apply. The target is - * `--db-url` (or `--remote=<url>`) if given, else `--local`, else `--linked`, `--project-ref` or - * any other `--remote`, else the local stack. As with {@link FlagCondition}, `source` marks a - * building block's condition. - */ +/** The run's database target must be one of `dbTarget`: `--db-url` or `--remote=<url>`, else `--local`, else a linked selector, else the local stack. */ interface DbTargetCondition { readonly dbTarget: ReadonlyArray<DbTarget>; readonly source?: string; @@ -117,13 +93,7 @@ export type CommandPermissions = } | { readonly status: "unmapped"; readonly reason: "go-delegated" }; -/** - * Builds a named building block for Management API calls a shared module makes on behalf of its - * callers, tagging every entry and each of its conditions with `source` (see - * {@link FlagConditionBase.source}). Declare it next to the module that makes the calls; a command - * includes the blocks its handler reaches with {@link compose}, adding its own condition (e.g. - * `--linked`) with {@link withCondition} where the module is only used on that path. - */ +/** Builds a named block for the calls a shared module makes for its callers, tagging each entry and condition with `source`; commands include it with {@link compose}. */ export function block( source: string, operations: ReadonlyArray<Omit<OperationEntry, "source">>, @@ -149,11 +119,7 @@ export function compose(...fragments: ReadonlyArray<PermissionFragment>): Permis }; } -/** - * Adds `condition` to every operation entry in `fragment`, ahead of each entry's own `when`. It - * keeps no block `source`, so a flag condition must name a flag of the command that declares it, - * and a target condition a target that command can select. - */ +/** Adds `condition` ahead of every entry's own `when`; it keeps no block `source`, so it must name a flag or target of the declaring command. */ export function withCondition( fragment: PermissionFragment, condition: EntryCondition, @@ -167,10 +133,7 @@ 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. - */ +/** Adds `prerequisite` to every entry's `context`, for a condition that isn't a flag, such as a prompt choice. */ export function withContext( fragment: PermissionFragment, prerequisite: string, @@ -185,10 +148,7 @@ export function withContext( } /** - * 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 - * block treats as required). - * + * Gives every entry the `kind` the including command handles it with when that differs from the block's. * @public */ export function withKind(fragment: PermissionFragment, kind: PermissionKind): PermissionFragment { diff --git a/apps/cli/src/command-internal/global-flags.ts b/apps/cli/src/command-internal/global-flags.ts index 43dd0ac1bc..82dcdedc59 100644 --- a/apps/cli/src/command-internal/global-flags.ts +++ b/apps/cli/src/command-internal/global-flags.ts @@ -13,18 +13,9 @@ import { 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 -// each handler honors only the subset it cares about (e.g. `db query` reads `table`/`csv`; other -// commands ignore them and fall through to text). -// -// These flag description strings are user-visible in shell completion (`__complete` candidate -// descriptions), so keep them accurate when changing a flag. -/** - * Every value the global `-o/--output` flag accepts. Per-command `outputFormats` overrides (e.g. - * `config diff`'s handler-level rejection) widen against this list, so a value added here flows to - * those commands and their tests automatically. - */ +// `-o/--output` is tree-wide and can't vary per command, so it takes the union of every command's values and each handler honors its subset. +// Flag descriptions are user-visible in shell completion (`__complete`), so keep them accurate. +/** Every value the global `-o/--output` flag accepts; per-command `outputFormats` overrides widen against this list. */ export const GLOBAL_OUTPUT_FORMATS = [ "env", "pretty", @@ -43,12 +34,7 @@ export const OutputFlag = GlobalFlag.Setting("output")({ ), }); -/** - * The TS-only `--output-format` global, accepted on any subcommand. - * - * A value-taking global flag must also be registered in `PERSISTENT_VALUE_FLAG_NAMES` - * (`shared/cli/flag-groups.ts`), or the pre-parse argv scanners won't consume its value. - */ +/** The TS-only `--output-format` global, accepted on any subcommand. */ export const OutputFormatFlag = GlobalFlag.Setting("output-format")({ flag: Flag.Literals("output-format", ["text", "json", "stream-json"]).pipe( Flag.withDescription("Output format: text (default), json, or stream-json (NDJSON)"), @@ -127,12 +113,8 @@ export const AgentFlag = GlobalFlag.Setting("agent")({ }); /** - * Every global/persistent flag declared above. - * - * A value-taking flag added here must also be added to `PERSISTENT_VALUE_FLAG_NAMES` - * (`shared/cli/flag-groups.ts`), which the handler-side argv scans and pre-parse token - * scanners both derive their token set from. A flag missed there fails silently: an unregistered - * value flag won't consume its following token, so that token gets misread as positional. + * Every global flag declared above. A value-taking one must also join `PERSISTENT_VALUE_FLAG_NAMES` + * (`shared/cli/flag-groups.ts`), or its value token is silently misread as positional. */ export const GLOBAL_FLAGS = [ OutputFlag, @@ -148,14 +130,8 @@ export const GLOBAL_FLAGS = [ ] as const; /** - * Resolves the current value of every global/persistent flag above, keyed by its own CLI flag - * name. Used by `telemetry/command-telemetry.ts` as a fallback for a global flag not present in a - * handler's own `flags` record. - * - * Reads via `Effect.serviceOption` so a caller without the global-flag context (e.g. a focused - * unit test) gets an empty record instead of a missing-service defect. Reads each flag - * individually, since each `Setting<Id, A>`'s distinct value type can't be looped over without an - * `as` cast; `global-flags.unit.test.ts` keeps the resolved id set in sync with `GLOBAL_FLAGS`. + * Every global flag's current value keyed by flag name, as telemetry's fallback for a flag missing from a handler's `flags`. + * `serviceOption` gives a caller without the global-flag context an empty record, and each flag is read individually to avoid an `as` cast. */ export const globalFlagValues = Effect.gen(function* () { const values: Record<string, unknown> = {}; @@ -180,25 +156,13 @@ const FALSE_VALUES = new Set(["0", "f", "F", "false", "FALSE", "False"]); // The false spellings of the CLI's boolean flag parser, which also reads one after a bare flag. const BOOLEAN_FALSE_VALUES = new Set(["false", "no", "off", "0", "n"]); -/** - * Raw argv truncated at the first bare `--` operand terminator. This CLI's own lexer stops parsing - * flags there too, so everything after is a positional operand, e.g. a migration name literally - * called `--experimental=false` passed as `db pull -- --experimental=false`. The argv-scanning - * `*ExplicitlyFalse` heuristics below must only look at this region, or a positional operand that - * merely looks like a flag gets mistaken for an explicit one. - */ +/** Raw argv before the first bare `--`; everything after is an operand (`db pull -- --experimental=false`), so the scanners below must not read it. */ const argsBeforeOperandTerminator = (args: ReadonlyArray<string>): ReadonlyArray<string> => { const terminatorIndex = args.indexOf("--"); return terminatorIndex === -1 ? args : args.slice(0, terminatorIndex); }; -/** - * Drops tokens consumed as a value-consuming flag's value in space-separated form - * (`--flag value` / `-f value`), so the `--yes`/`--experimental`/`--debug` argv scanners below - * don't mistake a consumed value token for an explicit occurrence of the global flag — e.g. - * `db pull --password --experimental=false` treats `--experimental=false` as `--password`'s - * value, not a changed `--experimental`. - */ +/** Drops tokens consumed as a flag's space-separated value, so `db pull --password --experimental=false` isn't read as a changed `--experimental`. */ const nonValueConsumedTokens = (args: ReadonlyArray<string>): ReadonlyArray<string> => { const kept: Array<string> = []; let skipNext = false; @@ -220,12 +184,8 @@ const nonValueConsumedTokens = (args: ReadonlyArray<string>): ReadonlyArray<stri }; /** - * Resolves the raw argv's *last* `--yes`/`--yes=<bool>` occurrence to a boolean, or `undefined` if - * it never appears before the first `--` operand terminator. The flag parser keeps the *first* of - * a repeated `--yes` and can't tell an explicit `--yes=false` from the omitted default, so a caller - * must reread raw argv rather than trust the parsed flag. A bare `--yes` reads a boolean literal - * that follows it (`--yes false`), as the parser does. Tokens consumed as another flag's value are - * skipped (see {@link nonValueConsumedTokens}). + * The last `--yes`/`--yes=<bool>` in argv as a boolean, or `undefined` if there is none. The parser keeps the first of a repeat and can't tell + * `--yes=false` from the default, so callers reread argv; a bare `--yes` reads a boolean literal that follows it, as the parser does. */ const yesFlagFromArgs = (args: ReadonlyArray<string>): boolean | undefined => { const tokens = nonValueConsumedTokens(argsBeforeOperandTerminator(args)); @@ -259,19 +219,12 @@ export const resolveYesSource = Effect.gen(function* () { : 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`, and the last of a repeated flag) wins over the env. - * Prefer this over reading {@link YesFlag} directly anywhere a command auto-confirms a prompt. - */ +/** `--yes` with a `SUPABASE_YES` fallback; an explicit flag (including `--yes=false`) wins over the env. Prefer it to reading {@link YesFlag} directly. */ 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 - * *presence* (any value) suppresses the file value entirely (see - * {@link supabaseEnvBoolWithProjectFallback}); an explicit `--yes` wins over both. `projectEnv` is - * the loaded map from `loadProjectEnvValues`. + * {@link resolveYes} that also consults the project `.env` (the `loadProjectEnvValues` map) for commands that load it before prompting; + * shell env presence suppresses the file value (see {@link supabaseEnvBoolWithProjectFallback}), and an explicit `--yes` wins over both. */ export const resolveYesWithProjectEnv = (projectEnv: Record<string, string>) => Effect.gen(function* () { @@ -284,13 +237,7 @@ export const resolveYesWithProjectEnv = (projectEnv: Record<string, string>) => return flag || (yield* supabaseEnvBoolWithProjectFallback("SUPABASE_YES", projectEnv)); }); -/** - * Resolves the raw argv's *last* explicit `--experimental` occurrence to a boolean, or `undefined` - * if it never appears before the first `--` operand terminator. Repeated occurrences resolve to - * the last one, the opposite of how this CLI's own flag parser resolves a repeat (first wins), so - * a caller must reread raw argv rather than trust the parsed flag. Tokens consumed as another - * flag's value are skipped (see {@link nonValueConsumedTokens}). - */ +/** The last `--experimental`/`--experimental=<bool>` in argv as a boolean, or `undefined`; the parser keeps the first of a repeat, so callers reread argv. */ const experimentalFlagFromArgs = (args: ReadonlyArray<string>): boolean | undefined => { let result: boolean | undefined; for (const arg of nonValueConsumedTokens(argsBeforeOperandTerminator(args))) { @@ -303,11 +250,7 @@ const experimentalFlagFromArgs = (args: ReadonlyArray<string>): boolean | undefi return result; }; -/** - * `--experimental` resolved with an env fallback: `SUPABASE_EXPERIMENTAL` enables experimental - * commands when the flag isn't passed. An explicit `--experimental` (including - * `--experimental=false`, and the last of a repeated flag) wins over the env. - */ +/** `--experimental` with a `SUPABASE_EXPERIMENTAL` fallback; an explicit flag (including `--experimental=false`) wins over the env. */ export const resolveExperimental = Effect.gen(function* () { const flag = yield* ExperimentalFlag; const cliArgs = yield* CliArgs; @@ -318,10 +261,7 @@ export const resolveExperimental = Effect.gen(function* () { return flag || (yield* supabaseEnvBool("SUPABASE_EXPERIMENTAL")); }); -/** - * `--experimental` with project `.env` fallback. Shell-env presence wins over the file; - * an explicit flag wins over both. See {@link supabaseEnvBoolWithProjectFallback}. - */ +/** {@link resolveExperimental} with the project `.env` fallback; shell env presence wins over the file and an explicit flag over both. */ export const resolveExperimentalWithProjectEnv = (projectEnv: Record<string, string>) => Effect.gen(function* () { const flag = yield* ExperimentalFlag; @@ -333,12 +273,7 @@ export const resolveExperimentalWithProjectEnv = (projectEnv: Record<string, str return flag || (yield* supabaseEnvBoolWithProjectFallback("SUPABASE_EXPERIMENTAL", projectEnv)); }); -/** - * True when the LAST `--debug`/`--debug=<value>` occurrence in argv resolves to false. Tracks the - * last occurrence, since a trailing bare `--debug` after an earlier `--debug=false` must win. - * Scans only the flag-parsing region (see {@link argsBeforeOperandTerminator}) and skips tokens - * consumed as another flag's value (see {@link nonValueConsumedTokens}). - */ +/** True when the last `--debug`/`--debug=<value>` in argv resolves to false, since a trailing bare `--debug` must beat an earlier `--debug=false`. */ const debugFlagExplicitlyFalse = (args: ReadonlyArray<string>): boolean => { let lastExplicitlyFalse = false; for (const arg of nonValueConsumedTokens(argsBeforeOperandTerminator(args))) { @@ -351,13 +286,7 @@ const debugFlagExplicitlyFalse = (args: ReadonlyArray<string>): boolean => { return lastExplicitlyFalse; }; -/** - * `--debug` resolved with an env fallback, and the project `.env` consulted too, for debug-gated - * behavior downstream of a command that already loaded the nested project env. `loadProjectEnvValues` - * is pure, so callers pass the loaded map through explicitly — same shape as - * {@link resolveYesWithProjectEnv}. Shell env *presence* suppresses the file value; an explicit - * `--debug` wins over both. - */ +/** `--debug` with env and project `.env` fallbacks, for debug-gated code downstream of a command that already loaded the project env; same shape as {@link resolveYesWithProjectEnv}. */ export const resolveDebugWithProjectEnv = (projectEnv: Record<string, string>) => Effect.gen(function* () { const flag = yield* DebugFlag; diff --git a/apps/cli/src/command-internal/target/branch-resolution.errors.ts b/apps/cli/src/command-internal/target/branch-resolution.errors.ts index 14bb46ea4c..15becaa69f 100644 --- a/apps/cli/src/command-internal/target/branch-resolution.errors.ts +++ b/apps/cli/src/command-internal/target/branch-resolution.errors.ts @@ -6,11 +6,7 @@ import { statusCodeActionability, } from "../../shared/telemetry/error-actionability.ts"; -/** - * A non-ref-shaped value was given (treated as a branch name) but no linked parent project - * could be resolved to search for that branch — none of `SUPABASE_PROJECT_ID`, - * `supabase/.temp/linked-project.json`, or `supabase/.temp/project-ref` yielded a candidate. - */ +/** A branch name was given but no linked parent project could be resolved to search for it. */ export class LinkBranchNotLinkedError extends Data.TaggedError("LinkBranchNotLinkedError")<{ readonly message: string; }> { @@ -19,10 +15,7 @@ export class LinkBranchNotLinkedError extends Data.TaggedError("LinkBranchNotLin } } -/** - * A parent-project candidate exists (`SUPABASE_PROJECT_ID`, `supabase/.temp/linked-project.json`, - * or `supabase/.temp/project-ref`) but none of them is ref-shaped — corrupt or stale linked state. - */ +/** A linked-parent candidate exists (`SUPABASE_PROJECT_ID`, `linked-project.json` or `project-ref`) but none is ref-shaped: corrupt or stale linked state. */ export class LinkParentRefInvalidError extends Data.TaggedError("LinkParentRefInvalidError")<{ readonly message: string; }> { @@ -40,10 +33,7 @@ export class LinkBranchNotFoundError extends Data.TaggedError("LinkBranchNotFoun } } -/** - * The resolved branch has no `project_ref` yet (e.g. `status: CREATING_PROJECT`). Guards - * against silently falling through to an unrelated ref elsewhere in the resolver chain. - */ +/** The resolved branch has no `project_ref` yet (for example `CREATING_PROJECT`), so the lookup must not fall through to another ref. */ export class LinkBranchNotReadyError extends Data.TaggedError("LinkBranchNotReadyError")<{ readonly branch: string; readonly status: string; diff --git a/apps/cli/src/command-internal/target/remote-vocabulary.ts b/apps/cli/src/command-internal/target/remote-vocabulary.ts index 0f9634a4d9..bf2c3d9bae 100644 --- a/apps/cli/src/command-internal/target/remote-vocabulary.ts +++ b/apps/cli/src/command-internal/target/remote-vocabulary.ts @@ -5,7 +5,6 @@ export const DB_URL_PATTERN = /^postgres(ql)?:\/\//i; /** The spellings of a bare `--remote`, in any case. */ export const LINKED_LITERAL = /^(true|yes|on|1)$/i; -/** The spellings of an off switch, in any case. */ export const OFF_LITERAL = /^(false|no|off|0)$/i; export const isBooleanLiteral = (word: string): boolean => 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 fffd422190..288b658b6d 100644 --- a/apps/cli/src/commands/config/push/push.branch-target.ts +++ b/apps/cli/src/commands/config/push/push.branch-target.ts @@ -13,13 +13,8 @@ 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 payload: - * - * - `"project"` — `ref` is the linked or production project itself. - * - `"branch"` — `ref` is confirmed to be a preview branch (a `--remote` branch, or a live 404); - * `parentRef`/`branch` are each present only when they could be determined. - * - `"unknown"` — nothing could settle it (a probe timeout, transport failure or unexpected - * status, or a UUID looked up with no linked project). Asserted as neither a branch nor a project. + * What `ref` is for `config push`'s payload: the linked or production `"project"`, a confirmed preview `"branch"` (`parentRef`/`branch` present only when known), + * or `"unknown"` when nothing could settle it, which the payload asserts as neither. */ export type ConfigPushTarget = | { readonly kind: "project"; readonly ref: string } @@ -32,14 +27,8 @@ export type ConfigPushTarget = | { readonly kind: "unknown"; readonly ref: string }; /** - * What the caller already knows about the `--remote` branch name/UUID `ref` was resolved from, so - * {@link resolveConfigPushTarget} never runs the live `getProject` probe for it: - * - * - `"name"` — a branch found by name under its parent; both fields are known. - * - `"uuid"` — certainly a branch, but a UUID carries no display name and never forces its parent, - * so it runs the same best-effort cache recovery a live 404 does. - * - `"production"` — the project's default branch, which is the project itself. - * - `"unverified"` — a UUID looked up with no linked project, which says nothing about `ref`. + * What the caller knows about the `--remote` branch `ref` came from, so {@link resolveConfigPushTarget} skips the live probe: a `"name"` (parent known), + * a `"uuid"` (certainly a branch; the parent is recovered best-effort), the `"production"` default branch (the project itself) or an `"unverified"` UUID (says nothing about `ref`). */ export type ConfigPushKnownBranch = | { readonly kind: "name"; readonly branchName: string; readonly parentRef: string } @@ -68,15 +57,8 @@ type ProbeOutcome = Data.TaggedEnum<{ const ProbeOutcome = Data.taggedEnum<ProbeOutcome>(); /** - * 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 - * (never blocking the push), or falls into best-effort recovery on a 404: - * `.temp/linked-project.json` and `.temp/project-ref` supply the parent ref and branch name when - * they can be trusted, and a bare `{ kind: "branch", ref }` otherwise. + * Resolves what `ref` is so `config push` can tell a branch from the linked project. Never fails, since this diagnostic must not abort a push: `GET /v1/projects/{ref}` + * (bounded at {@link BRANCH_LOOKUP_TIMEOUT}) is a project on 200, `"unknown"` on any other failure, and on a 404 recovers the parent and name from `.temp` when they can be trusted. */ export function resolveConfigPushTarget( ref: string, diff --git a/apps/cli/src/commands/db/test/test.command.ts b/apps/cli/src/commands/db/test/test.command.ts index cc12aeb193..cdadbe0a50 100644 --- a/apps/cli/src/commands/db/test/test.command.ts +++ b/apps/cli/src/commands/db/test/test.command.ts @@ -15,21 +15,13 @@ import { withCliConfigFlags } from "../../../config/cli-config-flags.ts"; import { branchResolution } from "../../../command-internal/target/branch-resolution.ts"; import { withTargetPolicy } from "../../../command-internal/target/target-policy.ts"; -/** - * `db test` is a hidden alias for `test db`, registered hidden by the parent - * (`../db.command.ts`'s `dbTestCommand.pipe(Command.unlisted)`). Both commands share one - * implementation, hoisted to `command-internal/` since `commands/<family>/` files may not - * import another family's internals (`code-structure.unit.test.ts`). - */ +/** Hidden alias of `test db`; the shared implementation lives in `command-internal/` because command families may not import each other's internals. */ export const dbTestCommand = Command.make("test", testDbConfig).pipe( Command.withDescription(TEST_DB_DESCRIPTION), Command.withShortDescription(TEST_DB_SHORT), withPermissions(compose(dbConfig, branchResolution)), Command.withHandler(runTestDbCommand), - // `["db", "test"]`, not `["test", "db"]`: the `cli_command_executed` - // telemetry records the actual invoked command path, which differs by - // entry point even though the handler is identical — see - // `testDbRuntimeLayer`'s doc comment. + // `cli_command_executed` records the invoked path, so this entry point passes `["db", "test"]`. Command.provide(testDbRuntimeLayer(["db", "test"])), Command.provide(cliConfigValuesLayer), withCliConfigFlags(testDbConfig), 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 e8d2b22c50..8e4a6eb193 100644 --- a/apps/cli/src/commands/inspect/db/inspect-db-command.ts +++ b/apps/cli/src/commands/inspect/db/inspect-db-command.ts @@ -9,10 +9,7 @@ import { withJsonErrorHandling } from "../../../shared/output/json-error-handlin import { withCommandTelemetry } from "../../../telemetry/command-telemetry.ts"; 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. - */ +/** The persistent flag set every `inspect db` subcommand inherits. */ export const INSPECT_DB_FLAGS = targetFlags({ subject: "database", dbUrl: true }); /** The Management API calls every `inspect db` subcommand makes, all through the database-config resolver. */ diff --git a/apps/cli/src/commands/pull/pull.steps.ts b/apps/cli/src/commands/pull/pull.steps.ts index e1f8048552..c09ad9305c 100644 --- a/apps/cli/src/commands/pull/pull.steps.ts +++ b/apps/cli/src/commands/pull/pull.steps.ts @@ -24,18 +24,9 @@ import type { } from "./pull.aggregate.ts"; import type { PullStepContext } from "./pull.types.ts"; -/** - * `supabase pull` step runners — one per `PULL_STEP_ORDER` entry, each a thin adapter over its - * sub-step's own run-core. No confirmation, failure capture, or aggregation lives here: these - * only run once the aggregated confirmation is accepted, so every one performs its side effect - * (there's no internal dry-run/declined branch). - */ +// One adapter per `PULL_STEP_ORDER` entry; each runs only after the aggregated confirmation, so it always performs its side effect. -/** - * `config` step: applies the already-planned config write (skipped when the plan had no work) - * and reports the fixed `{dryRun: false, confirmed: true}` shape `pullConfigStepResult` expects - * for an executed run. - */ +/** `config` step: applies the planned config write (skipped when the plan had no work) and reports the executed-run shape `pullConfigStepResult` expects. */ export const pullConfigStep = Effect.fnUntraced(function* (input: { readonly runPlan: ConfigPullRunPlan; readonly source: ConfigPullSource; @@ -52,10 +43,8 @@ export const pullConfigStep = Effect.fnUntraced(function* (input: { }); /** - * `migration_history` step: fetches the remote migration history table into - * `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. + * `migration_history` step: fetches the remote migration history into `supabase/migrations` from + * `context.target` (ADR 0024), with its overwrite prompt suppressed by the orchestrator's confirmation. */ export const pullMigrationHistoryStep = Effect.fnUntraced(function* (context: PullStepContext) { const cliSettings = yield* CommandSettings; @@ -71,10 +60,8 @@ export const pullMigrationHistoryStep = Effect.fnUntraced(function* (context: Pu }); /** - * `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). + * `db` step: pulls the linked project's schema in migration mode, reusing the orchestrator's consent. + * `DbPullInSyncError` is reported as `in_sync`, a finding rather than a failure (ADR 0024). */ export const pullDbStep = Effect.fnUntraced(function* (context: PullStepContext) { const cliSettings = yield* CommandSettings; diff --git a/apps/cli/src/shared/cli/cli-args.service.ts b/apps/cli/src/shared/cli/cli-args.service.ts index 8f618232d0..00e78d3057 100644 --- a/apps/cli/src/shared/cli/cli-args.service.ts +++ b/apps/cli/src/shared/cli/cli-args.service.ts @@ -3,7 +3,6 @@ import { Context } from "effect"; export interface CliArgsShape { /** The argv the command parses, with a bare `--remote <word>` already joined where that is the only reading. */ readonly args: ReadonlyArray<string>; - /** The word a bare `--remote` is followed by when it was taken as an operand and could name a branch. */ readonly bareRemoteOperand?: string | undefined; readonly remoteConflict?: boolean; } From d699ce39c301de2babe74d619aa992d96cb7eaba Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Tue, 20 Oct 2026 13:47:12 +0100 Subject: [PATCH 218/291] refactor(cli): stop growing comments in files already over the ratio Return each file to at most its merge-base comment-line count by dropping JSDoc the names and types already carry, and by condensing a few neighbouring comments. --- apps/cli/src/cli/complete.ts | 13 ++----------- .../cli/src/command-internal/argv-flag-reconcile.ts | 1 - apps/cli/src/command-internal/connect-errors.ts | 2 -- apps/cli/src/command-internal/db-config.service.ts | 13 ++++--------- apps/cli/src/command-internal/migration-history.ts | 1 - apps/cli/src/command-internal/workdir-project.ts | 1 - apps/cli/src/commands/db/diff/diff.explicit.ts | 8 +++----- apps/cli/src/commands/pull/pull.types.ts | 1 - apps/cli/src/config/project-ref.service.ts | 2 -- apps/cli/src/shared/cli/invalid-value-message.ts | 6 ++---- apps/cli/src/shared/config/temp-paths.ts | 2 -- apps/cli/tests/helpers/command-mocks.ts | 2 -- 12 files changed, 11 insertions(+), 41 deletions(-) diff --git a/apps/cli/src/cli/complete.ts b/apps/cli/src/cli/complete.ts index 732d84f8eb..07d7a158b7 100644 --- a/apps/cli/src/cli/complete.ts +++ b/apps/cli/src/cli/complete.ts @@ -84,7 +84,6 @@ export interface ClassifyCompletionInput { readonly trimmedArgs: ReadonlyArray<string>; readonly toComplete: string; readonly inScopeFlags: ReadonlyArray<FlagDescriptor>; - /** How the argv rewrite reads this command's operands; absent when the command takes no `--remote`. */ readonly remoteLeaf?: RemoteArgvLeaf | undefined; } @@ -134,10 +133,8 @@ function flattenSubcommands(command: Command.Command.Any): ReadonlyArray<Command } /** - * `choiceKeys` (a `Choice` primitive's valid value set) is attached via - * `Object.assign` at runtime and isn't part of the public `Primitive<A>` - * type, so this narrows through the same runtime-guard idiom as - * `CommandInternal`. + * `choiceKeys` (a `Choice` primitive's valid value set) is attached via `Object.assign` at runtime and + * isn't part of the public `Primitive<A>` type, so it narrows through the same guard as `CommandInternal`. */ interface ChoicePrimitive { readonly choiceKeys: ReadonlyArray<string>; @@ -354,10 +351,8 @@ const COMPLETION_NO_FILE_COMP_PATHS: ReadonlySet<string> = new Set([ "completion powershell", ]); -/** Offered instead of a bare `--remote`, so the next word is typed as its value rather than read as an operand. */ const REMOTE_FLAG_CANDIDATE = `--${REMOTE_FLAG_NAME}=`; -/** `--remote=` takes its value right after the `=`, so no space follows it; any other candidate in the list keeps the space. */ function flagNameDirective(candidates: ReadonlyArray<CompletionCandidate>): number { return candidates.length > 0 && candidates.every((candidate) => candidate.name.endsWith("=")) ? CompletionDirective.NoFileComp | CompletionDirective.NoSpace @@ -666,10 +661,6 @@ function outputFlagChoiceKeys(matchedPath: ReadonlyArray<string>): ReadonlyArray : RESOURCE_OUTPUT_FORMATS; } -/** - * Whether the word being typed after a bare `--remote` would be joined into `--remote=<word>` by - * the argv rewrite, so it completes as the flag's value rather than as an operand. - */ function remoteTakesWord( remoteLeaf: RemoteArgvLeaf | undefined, trimmedArgs: ReadonlyArray<string>, diff --git a/apps/cli/src/command-internal/argv-flag-reconcile.ts b/apps/cli/src/command-internal/argv-flag-reconcile.ts index f04e4897e9..bfe9fee626 100644 --- a/apps/cli/src/command-internal/argv-flag-reconcile.ts +++ b/apps/cli/src/command-internal/argv-flag-reconcile.ts @@ -51,7 +51,6 @@ export function argvStringValue( return values === undefined ? Option.none() : Option.some(values[values.length - 1] ?? ""); } -/** The scan's last `--remote`, classified like the flag's own parser, so it reconciles with `--project-ref` read from the same scan. */ export function argvRemoteValue( occurrences: ReadonlyMap<string, ReadonlyArray<string>>, ): Option.Option<RemoteValue> { diff --git a/apps/cli/src/command-internal/connect-errors.ts b/apps/cli/src/command-internal/connect-errors.ts index 73646823c9..2de4ebb706 100644 --- a/apps/cli/src/command-internal/connect-errors.ts +++ b/apps/cli/src/command-internal/connect-errors.ts @@ -62,10 +62,8 @@ 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 --remote."; -/** The local-stack hint, plus the `--remote` pointer when the checkout is linked. */ export function localStackSuggestion(linkedCheckout: boolean | undefined): string { return linkedCheckout === true ? `${SUGGEST_LOCAL_STACK}\n${SUGGEST_LINKED_PROJECT}` diff --git a/apps/cli/src/command-internal/db-config.service.ts b/apps/cli/src/command-internal/db-config.service.ts index 6300c0e81d..cceee5d95f 100644 --- a/apps/cli/src/command-internal/db-config.service.ts +++ b/apps/cli/src/command-internal/db-config.service.ts @@ -68,16 +68,11 @@ export type DbConfigError = // profile resolution can fail inside the resolver effect the same way. | ProfileLoadError; -// The `--linked` path builds a lazy Management API runtime (so `--local`/`--db-url` never -// resolve an access token) and provides every one of its own requirements from the resolver's -// captured context, so `resolve`'s R stays `never`. Access-token resolution is deferred to -// first API use, so its auth-required error surfaces through the resolver effect (folded into -// `DbConfigError`) rather than a layer-build error channel. +// The `--linked` path builds a lazy Management API runtime (so `--local`/`--db-url` never resolve an +// access token) from the resolver's captured context, so `resolve`'s R stays `never` and the +// auth-required error surfaces through the resolver effect, not a layer-build error channel. interface DbConfigResolverShape { - /** - * Connects to an already-resolved target and announces it: the local stack, a `--db-url` - * database, or the linked project's temp-role connection. - */ + /** Connects to a resolved target (the local stack, a `--db-url` database or the linked project's temp-role connection) and announces it. */ readonly resolveConnection: ( input: DbConnectionInput, ) => Effect.Effect<ResolvedDbConnection, DbConfigError>; diff --git a/apps/cli/src/command-internal/migration-history.ts b/apps/cli/src/command-internal/migration-history.ts index c815a079c1..1089e149f0 100644 --- a/apps/cli/src/command-internal/migration-history.ts +++ b/apps/cli/src/command-internal/migration-history.ts @@ -228,7 +228,6 @@ export function reconcileMigrations( export const withTargetFlags = (command: string, targetFlags: string): string => targetFlags === "" ? command : `${command} ${targetFlags}`; -/** `targetFlags` are the flags that select the resolved target (see `flagsForTarget`), appended last. */ export function suggestMigrationRepair( extraRemote: ReadonlyArray<string>, extraLocal: ReadonlyArray<string>, diff --git a/apps/cli/src/command-internal/workdir-project.ts b/apps/cli/src/command-internal/workdir-project.ts index c32b16b2ba..ff8c8e2790 100644 --- a/apps/cli/src/command-internal/workdir-project.ts +++ b/apps/cli/src/command-internal/workdir-project.ts @@ -13,7 +13,6 @@ 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 }, diff --git a/apps/cli/src/commands/db/diff/diff.explicit.ts b/apps/cli/src/commands/db/diff/diff.explicit.ts index 13b93aff75..428e63d490 100644 --- a/apps/cli/src/commands/db/diff/diff.explicit.ts +++ b/apps/cli/src/commands/db/diff/diff.explicit.ts @@ -15,11 +15,9 @@ export type ExplicitRef = | NamedRemoteValue; /** - * `local`, `migrations` and `remote` (the linked project) are keywords; `linked` is the - * deprecated spelling of `remote`. Anything else is classified like a `--remote` value, so a - * branch that shares a keyword's name is reached by its ref or UUID. A value `--remote` rejects (a - * non-postgres URL, a keyword connection string) is returned as that rejection, never as a branch - * name, so it is not echoed. + * `local`, `migrations` and `remote` (the linked project) are keywords and `linked` is the deprecated spelling of `remote`; + * anything else is classified like a `--remote` value, so a branch sharing a keyword's name is reached by its ref or UUID. + * A value `--remote` rejects is returned as that rejection, never as a branch name, so it is not echoed. */ export function classifyExplicitRef(ref: string): ExplicitRef { switch (ref) { diff --git a/apps/cli/src/commands/pull/pull.types.ts b/apps/cli/src/commands/pull/pull.types.ts index 4509aa6dae..d397359ab5 100644 --- a/apps/cli/src/commands/pull/pull.types.ts +++ b/apps/cli/src/commands/pull/pull.types.ts @@ -34,7 +34,6 @@ 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>; } diff --git a/apps/cli/src/config/project-ref.service.ts b/apps/cli/src/config/project-ref.service.ts index bcc781362e..8319ed64e3 100644 --- a/apps/cli/src/config/project-ref.service.ts +++ b/apps/cli/src/config/project-ref.service.ts @@ -9,13 +9,11 @@ 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; } diff --git a/apps/cli/src/shared/cli/invalid-value-message.ts b/apps/cli/src/shared/cli/invalid-value-message.ts index bc43099c3c..89626b099b 100644 --- a/apps/cli/src/shared/cli/invalid-value-message.ts +++ b/apps/cli/src/shared/cli/invalid-value-message.ts @@ -9,10 +9,8 @@ import { REMOTE_FLAG_NAME } from "../../command-internal/target/remote-vocabular // TODO(CLI-1898): remove once https://github.com/Effect-TS/effect/issues/6312 is fixed upstream. const EXPECTED_PREFIX = "Expected "; -// Some flags already fail with their own byte-exact diagnostic (e.g. malformed CSV in a -// string-slice flag, or any `--remote` value), which the CLI's stderr contract must reproduce -// verbatim. Wrapping it in `CliError.InvalidValue`'s own template would double-frame it, so -// render it as-is instead. +// Some flags already fail with their own byte-exact diagnostic (malformed CSV in a string-slice flag, any `--remote` value), +// which the stderr contract reproduces verbatim; wrapping it in `CliError.InvalidValue`'s template would double-frame it. const INVALID_ARGUMENT_PREFIX = "invalid argument "; export interface InvalidValueMessageFields { diff --git a/apps/cli/src/shared/config/temp-paths.ts b/apps/cli/src/shared/config/temp-paths.ts index acc7e78383..ecfcd63ad7 100644 --- a/apps/cli/src/shared/config/temp-paths.ts +++ b/apps/cli/src/shared/config/temp-paths.ts @@ -63,8 +63,6 @@ export const readProjectRefFile = ( ): Effect.Effect<Option.Option<string>, ProjectRefReadError> => Effect.gen(function* () { const refPath = tempPaths(path, workdir).projectRef; - // A `NotFound` PlatformError means unlinked (fall through); any other - // read error fails. const content = yield* fs.readFileString(refPath).pipe( Effect.catchTag("PlatformError", (error) => error.reason._tag === "NotFound" diff --git a/apps/cli/tests/helpers/command-mocks.ts b/apps/cli/tests/helpers/command-mocks.ts index f9b62d1961..f7cb5ab1c0 100644 --- a/apps/cli/tests/helpers/command-mocks.ts +++ b/apps/cli/tests/helpers/command-mocks.ts @@ -84,7 +84,6 @@ import { // 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}\n`; @@ -1455,7 +1454,6 @@ export interface BuildTestRuntimeOpts { readonly outputFlag?: Option.Option<OutputFlagValue>; /** Raw argv seen by the handler (e.g. to exercise an explicit `--yes=false`). */ readonly cliArgs?: ReadonlyArray<string>; - /** The command path the handler runs as, e.g. `["storage", "ls"]`; leave it out to run without a `CommandRuntime`. */ readonly commandPath?: ReadonlyArray<string>; } From 5e6fffb6b5e6156f53daab7d20ccd22eb63e7b97 Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Tue, 20 Oct 2026 13:51:11 +0100 Subject: [PATCH 219/291] test(cli): cover target selection for db advisors with the shared harness --- .../db/advisors/advisors.integration.test.ts | 176 ++---------------- 1 file changed, 19 insertions(+), 157 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 3e5b17a70c..1a0056d980 100644 --- a/apps/cli/src/commands/db/advisors/advisors.integration.test.ts +++ b/apps/cli/src/commands/db/advisors/advisors.integration.test.ts @@ -11,11 +11,8 @@ vi.mock("node:net", (importOriginal) => ); import { cliConfigValuesTestLayer } from "../../../../tests/helpers/config-values-layer.ts"; -import { - BRANCH_PROJECT_REF, - branchListApi, - warningsOf, -} from "../../../../tests/helpers/remote-selection.ts"; +import { describeDbTargetSelection } from "../../../../tests/helpers/db-target-selection.ts"; +import { branchRow } from "../../../../tests/helpers/remote-selection.ts"; import { mockOutput, mockProcessControl } from "../../../../tests/helpers/mocks.ts"; import { VALID_REF, @@ -53,10 +50,6 @@ import type { 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, @@ -282,6 +275,9 @@ function setup(opts: SetupOpts = {}) { if (request.method === "GET" && url.endsWith(`/v1/projects/${VALID_REF}`)) { return Effect.succeed(jsonResponse(request, 200, projectResponse(VALID_REF))); } + if (request.method === "GET" && url.endsWith(`/v1/projects/${VALID_REF}/branches`)) { + return Effect.succeed(jsonResponse(request, 200, [branchRow("feature-x")])); + } return Effect.succeed(jsonResponse(request, 404, {})); }, }); @@ -432,17 +428,6 @@ describe("db advisors — local", () => { }).pipe(Effect.provide(layer)); }); - it.effect("rejects --db-url together with --local", () => { - const { layer, connection } = setup(); - return Effect.gen(function* () { - 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)); - }); - it.effect("emits a success envelope in json mode and writes nothing raw to stdout", () => { const { layer, out } = setup({ format: "json", rows: [lintRow()] }); return Effect.gen(function* () { @@ -475,24 +460,6 @@ describe("db advisors — local", () => { expect(telemetry.flushed).toBe(true); }).pipe(Effect.provide(layer)); }); - - 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({ linked: false })); - expect(resolver.resolveInputs.map((input) => input.target.kind)).toEqual(["local"]); - expect(cache.cached).toBe(false); - }).pipe(Effect.provide(layer)); - }); - - it.effect("targets the linked project for a bare --remote", () => { - const { layer, resolver, cache } = setup({ securityLints: [], performanceLints: [] }); - return Effect.gen(function* () { - yield* dbAdvisors(flags({ remote: Option.some({ _tag: "Linked" } as const) })); - expect(resolver.resolveInputs.map((input) => input.target.kind)).toEqual(["linked"]); - expect(cache.cached).toBe(true); - }).pipe(Effect.provide(layer)); - }); }); const linkedFlags = (over: Partial<DbAdvisorsFlags> = {}) => @@ -664,20 +631,6 @@ describe("db advisors — linked", () => { }, ); - it.effect("rejects --project-ref together with --local", () => { - const FLAG_REF = "flagflagflagflagflag"; - const { layer, connection, api, cache } = setup({ rows: [] }); - return Effect.gen(function* () { - 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); - }).pipe(Effect.provide(layer)); - }); - it.effect("resolves the linked DB config before fetching advisors", () => { // Resolved even though the linked lint-gathering path discards the connection. const { layer, resolver, api } = setup({ @@ -858,109 +811,18 @@ describe("db advisors — linked", () => { }); }); -describe("db advisors — target selection", () => { - const FLAG_PROJECT_REF = "flagflagflagflagflag"; - - const resolvedTarget = ( - selector: Partial<DbAdvisorsFlags>, - options: { readonly branchApi?: ReturnType<typeof branchListApi> } = {}, - ) => { - const { layer, out, resolver } = setup({ - rows: [], - securityLints: [], - performanceLints: [], - }); - const provided = - options.branchApi === undefined ? layer : Layer.merge(layer, options.branchApi.layer); - return Effect.gen(function* () { - yield* dbAdvisors(flags(selector)).pipe(Effect.provide(provided)); - return { target: resolver.resolveInputs[0]?.target, warnings: warningsOf(out) }; - }); - }; - - it.effect("targets the local database, with no warning, when nothing selects one", () => - Effect.gen(function* () { - const { target, warnings } = yield* resolvedTarget({}); - expect(target).toEqual({ kind: "local" }); - expect(warnings).toEqual([]); - }), - ); - - it.effect("targets the project --remote=<ref> names, with no warning", () => - Effect.gen(function* () { - const { target, warnings } = yield* resolvedTarget({ - remote: Option.some({ _tag: "Ref", ref: FLAG_PROJECT_REF } as const), - }); - expect(target).toMatchObject({ kind: "linked", ref: FLAG_PROJECT_REF, source: "flag" }); - expect(warnings).toEqual([]); - }), - ); - - it.effect("targets the database --remote=<db-url> names, with no warning", () => - Effect.gen(function* () { - const { target, warnings } = yield* resolvedTarget({ - remote: Option.some({ - _tag: "DbUrl", - url: "postgresql://postgres:pw@db.example.com/db", - } as const), - }); - expect(target?.kind).toBe("db-url"); - expect(warnings).toEqual([]); - }), - ); - - it.effect("targets the project of the branch --remote=<branch> names", () => { - const branchApi = branchListApi(); - return Effect.gen(function* () { - const { target, warnings } = yield* resolvedTarget( - { remote: Option.some({ _tag: "Branch", name: "feature-x" }) }, - { branchApi }, - ); - expect(target).toMatchObject({ kind: "linked", ref: BRANCH_PROJECT_REF, source: "flag" }); - expect(warnings).toEqual([]); - assertPermissionDrift({ - command: "db advisors", - activeFlags: { remote: "feature-x" }, - requests: branchApi.requests, - }); - }); - }); - - it.effect.each([ - { - alias: "--linked", - flags: { linked: true }, - kind: "linked", - warning: "--linked is deprecated and will be removed in v4. Use --remote instead", - }, - { - alias: "--project-ref", - flags: { projectRef: Option.some(FLAG_PROJECT_REF) }, - kind: "linked", - warning: "--project-ref is deprecated and will be removed in v4. Use --remote=<ref> instead", - }, - { - alias: "--db-url", - flags: { dbUrl: Option.some("postgresql://postgres:pw@db.example.com/db") }, - kind: "db-url", - warning: "--db-url is deprecated and will be removed in v4. Use --remote=<db-url> instead", - }, - { - alias: "--local", - flags: { local: true }, - kind: "local", - warning: "--local isn't needed (local is the default) and will be removed in v4", - }, - ] satisfies ReadonlyArray<{ - alias: string; - flags: Partial<DbAdvisorsFlags>; - kind: string; - warning: string; - }>)("keeps $alias working and warns that it is deprecated", ({ flags, kind, warning }) => - Effect.gen(function* () { - const { target, warnings } = yield* resolvedTarget(flags); - expect(target?.kind).toBe(kind); - expect(warnings).toEqual([expect.stringContaining(warning)]); - }), - ); +describeDbTargetSelection("db advisors", { + dbUrl: true, + workdir: () => tempRoot.current, + attempt: ({ target }) => { + const ctx = setup({ rows: [], securityLints: [], performanceLints: [] }); + return { + layer: Layer.merge(ctx.layer, ctx.api.layer), + out: ctx.out, + run: dbAdvisors(flags(target)), + target: () => ctx.resolver.resolveInputs[0]?.target, + cachedRef: () => ctx.cache.cachedRef, + branchLookups: () => ctx.api.requests.filter(({ url }) => url.endsWith("/branches")), + }; + }, }); From 8739ca6fcaefee88ec371c48eac02a3a516c6035 Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Tue, 20 Oct 2026 13:55:45 +0100 Subject: [PATCH 220/291] test(cli): drop repeated deprecated-alias rows from the central target tests The deprecation text, the alias-to-selection mapping and the output routing are each pinned once in target-deprecation, target-selector and remote-resolution. resolve-target keeps the --db-url row, which is the only test that proves the settings project host reaches the deprecation text, and the --local banner row, renamed for what it asserts. target-deprecation now pins the --project-id text next to --project-ref. --- .../target/resolve-target.integration.test.ts | 111 +++--------------- .../target/target-deprecation.unit.test.ts | 17 ++- 2 files changed, 22 insertions(+), 106 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 f7bd872567..86a47b3d3e 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 @@ -196,7 +196,6 @@ const run = ( .split("\n") .filter((line) => line.startsWith("Warning: ")) .map((line) => line.slice("Warning: ".length)), - outputWarnCalls: out.messages.filter((m) => m.type === "warn"), refFlags: resolver.flags, apiRequests: api.requests.map( (request) => `${request.method} ${new URL(request.url).pathname}`, @@ -997,110 +996,26 @@ describe("resolveTarget", () => { }); describe("deprecated selectors", () => { - it.effect("warns that --linked is replaced by --remote, with the command as run", () => - Effect.gen(function* () { - const result = yield* run( - "selectable", - { ...none, linked: true }, - { args: ["db", "push", "--dry-run", "--linked"], command: ["db", "push"] }, - ); - expect(succeeded(result.exit)).toMatchObject({ kind: "linked", ref: FILE_REF }); - expect(result.warnings).toEqual([ - "--linked is deprecated and will be removed in v4. Use --remote instead", - ]); - }), - ); - - it.effect.each(["text", "json", "stream-json"] satisfies Array<OutputFormat>)( - "writes the warning to stderr and nothing to stdout in %s mode", - (format) => + it.effect( + "warns that --db-url is deprecated and offers its project ref, without echoing secrets", + () => Effect.gen(function* () { const result = yield* run( "selectable", - { ...none, linked: true }, - { args: ["db", "push", "--linked"], command: ["db", "push"], format }, + { ...none, dbUrl: Option.some(SECRET_URL) }, + { + args: ["db", "push", "--db-url", SECRET_URL, "--password", "hunter2"], + command: ["db", "push"], + }, ); - expect(succeeded(result.exit)).toMatchObject({ kind: "linked" }); + expect(succeeded(result.exit)).toMatchObject({ kind: "db-url" }); expect(result.warnings).toEqual([ - "--linked is deprecated and will be removed in v4. Use --remote instead", + "--db-url is deprecated and will be removed in v4. Use --remote=<db-url> instead, or --remote=abcdefghijklmnopqrst with an access token set", ]); - expect(result.outputWarnCalls).toEqual([]); - expect(result.stdout).toBe(""); + expect(result.warnings.join("\n")).not.toContain("hunter2"); }), ); - it.effect("folds --linked and --project-ref into one warning that ends in --remote=<ref>", () => - Effect.gen(function* () { - const result = yield* run( - "selectable", - { ...none, linked: true, projectRef: Option.some(FLAG_REF) }, - { - args: ["db", "push", "--linked", "--project-ref", FLAG_REF, "--dry-run"], - command: ["db", "push"], - }, - ); - expect(succeeded(result.exit)).toMatchObject({ kind: "linked", ref: FLAG_REF }); - expect(result.warnings).toEqual([ - `--linked and --project-ref are deprecated and will be removed in v4. Use --remote=<ref> instead`, - ]); - }), - ); - - it.effect("never echoes the --db-url connection string or --password", () => - Effect.gen(function* () { - const result = yield* run( - "selectable", - { ...none, dbUrl: Option.some(SECRET_URL) }, - { - args: ["db", "push", "--db-url", SECRET_URL, "--password", "hunter2"], - command: ["db", "push"], - }, - ); - expect(succeeded(result.exit)).toMatchObject({ kind: "db-url" }); - expect(result.warnings).toEqual([ - "--db-url is deprecated and will be removed in v4. Use --remote=<db-url> instead, or --remote=abcdefghijklmnopqrst with an access token set", - ]); - expect(result.warnings.join("\n")).not.toContain("hunter2"); - }), - ); - - it.effect("says --local is not needed", () => - Effect.gen(function* () { - const result = yield* run( - "selectable", - { ...none, local: true }, - { args: ["db", "push", "--local"], command: ["db", "push"] }, - ); - expect(succeeded(result.exit)).toEqual({ kind: "local" }); - expect(result.warnings).toEqual([ - "--local isn't needed (local is the default) and will be removed in v4", - ]); - expect(result.refFlags).toEqual([]); - }), - ); - - it.effect("warns without a command line when the real argv is unknown", () => - Effect.gen(function* () { - const result = yield* run("selectable", { ...none, linked: true }); - expect(result.warnings).toEqual([ - "--linked is deprecated and will be removed in v4. Use --remote instead", - ]); - }), - ); - - it.effect("treats --project-ref as --remote=<ref> and keeps the 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("does not warn for --remote itself", () => Effect.gen(function* () { const result = yield* run("selectable", { ...none, ...remote({ _tag: "Linked" }) }); @@ -1202,9 +1117,11 @@ describe("resolveTarget", () => { }), ); - it.effect("adds no hint when --local chose local", () => + it.effect("warns that --local is not needed and adds no hint, even in a linked checkout", () => Effect.gen(function* () { const result = yield* run("selectable", { ...none, local: true }, { savedRef: FILE_REF }); + expect(succeeded(result.exit)).toEqual({ kind: "local" }); + expect(result.refFlags).toEqual([]); expect(stripAnsi(result.stderr)).toBe( "Warning: --local isn't needed (local is the default) and will be removed in v4\nTarget: local stack (127.0.0.1:54322)\n", ); diff --git a/apps/cli/src/command-internal/target/target-deprecation.unit.test.ts b/apps/cli/src/command-internal/target/target-deprecation.unit.test.ts index 6c291ceb92..e73f2663a7 100644 --- a/apps/cli/src/command-internal/target/target-deprecation.unit.test.ts +++ b/apps/cli/src/command-internal/target/target-deprecation.unit.test.ts @@ -39,15 +39,14 @@ describe("deprecationMessages", () => { const messages = (deprecations: ReadonlyArray<TargetDeprecation>) => deprecationMessages(deprecations, HOST); - it("points a deprecated --project-ref at --remote=<ref>", () => { - expect( - messages([ - { kind: "remote-alias", flags: ["--project-ref"], remote: { _tag: "Ref", ref: REF } }, - ]), - ).toEqual([ - "--project-ref is deprecated and will be removed in v4. Use --remote=<ref> instead", - ]); - }); + it.each(["--project-ref", "--project-id"] as const)( + "points a deprecated %s at --remote=<ref>", + (flag) => { + expect( + messages([{ kind: "remote-alias", flags: [flag], remote: { _tag: "Ref", ref: REF } }]), + ).toEqual([`${flag} is deprecated and will be removed in v4. Use --remote=<ref> instead`]); + }, + ); it("points a deprecated --linked at a bare --remote", () => { expect( From 2be04aaa9df283724c863995d8532bba6bbb5776 Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Tue, 20 Oct 2026 13:55:52 +0100 Subject: [PATCH 221/291] test(cli-e2e): keep one deprecated target alias case and drop its four repeats --project-id on gen types is the only alias no lower layer sends through the real command, so that case stays. The other four are covered by the flag parser, selector and deprecation unit tests and the resolve-target integration row; their scenario fixtures go with them. --- .../interactions.json | 1 - .../interactions.json | 69 ------------------- .../interactions.json | 1 - .../interactions.json | 67 ------------------ .../src/tests/target-selection.e2e.test.ts | 47 ------------- 5 files changed, 185 deletions(-) delete mode 100644 apps/cli-e2e/fixtures/scenarios/deprecated-target-aliases-warns-that-db-url-is-deprecated-and-still-targets-that-database/interactions.json delete mode 100644 apps/cli-e2e/fixtures/scenarios/deprecated-target-aliases-warns-that-linked-is-deprecated-and-still-targets-the-linked-project/interactions.json delete mode 100644 apps/cli-e2e/fixtures/scenarios/deprecated-target-aliases-warns-that-local-is-not-needed-and-still-targets-local/interactions.json delete mode 100644 apps/cli-e2e/fixtures/scenarios/deprecated-target-aliases-warns-that-project-ref-is-deprecated-on-a-hosted-command-and-still-targets-that-project/interactions.json diff --git a/apps/cli-e2e/fixtures/scenarios/deprecated-target-aliases-warns-that-db-url-is-deprecated-and-still-targets-that-database/interactions.json b/apps/cli-e2e/fixtures/scenarios/deprecated-target-aliases-warns-that-db-url-is-deprecated-and-still-targets-that-database/interactions.json deleted file mode 100644 index fe51488c70..0000000000 --- a/apps/cli-e2e/fixtures/scenarios/deprecated-target-aliases-warns-that-db-url-is-deprecated-and-still-targets-that-database/interactions.json +++ /dev/null @@ -1 +0,0 @@ -[] diff --git a/apps/cli-e2e/fixtures/scenarios/deprecated-target-aliases-warns-that-linked-is-deprecated-and-still-targets-the-linked-project/interactions.json b/apps/cli-e2e/fixtures/scenarios/deprecated-target-aliases-warns-that-linked-is-deprecated-and-still-targets-the-linked-project/interactions.json deleted file mode 100644 index 0790d147ea..0000000000 --- a/apps/cli-e2e/fixtures/scenarios/deprecated-target-aliases-warns-that-linked-is-deprecated-and-still-targets-the-linked-project/interactions.json +++ /dev/null @@ -1,69 +0,0 @@ -[ - { - "request": { - "method": "GET", - "path": "/v1/projects/replayfixtureprojref/advisors/security", - "query": {}, - "headers": { - "accept-encoding": "gzip", - "authorization": "Bearer __ACCESS_TOKEN__", - "host": "localhost:__PORT__", - "user-agent": "SupabaseCLI/" - }, - "body": null - }, - "response": { - "status": 200, - "headers": { - "content-type": "application/json; charset=utf-8", - "x-gotrue-id": "__UUID__", - "x-ratelimit-limit": "120", - "x-ratelimit-remaining": "119", - "x-ratelimit-reset": "60" - }, - "body": { - "lints": [] - } - } - }, - { - "request": { - "method": "GET", - "path": "/v1/projects/replayfixtureprojref", - "query": {}, - "headers": { - "accept-encoding": "gzip", - "authorization": "Bearer __ACCESS_TOKEN__", - "host": "localhost:__PORT__", - "user-agent": "SupabaseCLI/" - }, - "body": null - }, - "response": { - "status": 200, - "headers": { - "content-type": "application/json; charset=utf-8", - "x-gotrue-id": "__UUID__", - "x-ratelimit-limit": "120", - "x-ratelimit-remaining": "116", - "x-ratelimit-reset": "54" - }, - "body": { - "id": "replayfixtureprojref", - "ref": "replayfixtureprojref", - "organization_id": "replayfixtureprojref", - "organization_slug": "replayfixtureprojref", - "name": "cli-e2e-test", - "region": "us-east-1", - "status": "ACTIVE_HEALTHY", - "database": { - "host": "db.replayfixtureprojref.supabase.red", - "version": "17.6.1.111", - "postgres_engine": "17", - "release_channel": "ga" - }, - "created_at": "2000-01-01T00:00:00Z" - } - } - } -] diff --git a/apps/cli-e2e/fixtures/scenarios/deprecated-target-aliases-warns-that-local-is-not-needed-and-still-targets-local/interactions.json b/apps/cli-e2e/fixtures/scenarios/deprecated-target-aliases-warns-that-local-is-not-needed-and-still-targets-local/interactions.json deleted file mode 100644 index fe51488c70..0000000000 --- a/apps/cli-e2e/fixtures/scenarios/deprecated-target-aliases-warns-that-local-is-not-needed-and-still-targets-local/interactions.json +++ /dev/null @@ -1 +0,0 @@ -[] diff --git a/apps/cli-e2e/fixtures/scenarios/deprecated-target-aliases-warns-that-project-ref-is-deprecated-on-a-hosted-command-and-still-targets-that-project/interactions.json b/apps/cli-e2e/fixtures/scenarios/deprecated-target-aliases-warns-that-project-ref-is-deprecated-on-a-hosted-command-and-still-targets-that-project/interactions.json deleted file mode 100644 index 43e0e51f84..0000000000 --- a/apps/cli-e2e/fixtures/scenarios/deprecated-target-aliases-warns-that-project-ref-is-deprecated-on-a-hosted-command-and-still-targets-that-project/interactions.json +++ /dev/null @@ -1,67 +0,0 @@ -[ - { - "request": { - "method": "GET", - "path": "/v1/projects/replayfixtureprojref/secrets", - "query": {}, - "headers": { - "accept-encoding": "gzip", - "authorization": "Bearer __ACCESS_TOKEN__", - "host": "localhost:__PORT__", - "user-agent": "SupabaseCLI/" - }, - "body": null - }, - "response": { - "status": 200, - "headers": { - "content-type": "application/json; charset=utf-8", - "x-gotrue-id": "__UUID__", - "x-ratelimit-limit": "120", - "x-ratelimit-remaining": "119", - "x-ratelimit-reset": "60" - }, - "body": [] - } - }, - { - "request": { - "method": "GET", - "path": "/v1/projects/replayfixtureprojref", - "query": {}, - "headers": { - "accept-encoding": "gzip", - "authorization": "Bearer __ACCESS_TOKEN__", - "host": "localhost:__PORT__", - "user-agent": "SupabaseCLI/" - }, - "body": null - }, - "response": { - "status": 200, - "headers": { - "content-type": "application/json; charset=utf-8", - "x-gotrue-id": "__UUID__", - "x-ratelimit-limit": "120", - "x-ratelimit-remaining": "119", - "x-ratelimit-reset": "60" - }, - "body": { - "id": "replayfixtureprojref", - "ref": "replayfixtureprojref", - "organization_id": "replayfixtureprojref", - "organization_slug": "replayfixtureprojref", - "name": "cli-e2e-test", - "region": "us-east-1", - "status": "ACTIVE_HEALTHY", - "database": { - "host": "db.replayfixtureprojref.supabase.red", - "version": "17.6.1.111", - "postgres_engine": "17", - "release_channel": "ga" - }, - "created_at": "2000-01-01T00:00:00Z" - } - } - } -] diff --git a/apps/cli-e2e/src/tests/target-selection.e2e.test.ts b/apps/cli-e2e/src/tests/target-selection.e2e.test.ts index f5ddffc029..b07d065676 100644 --- a/apps/cli-e2e/src/tests/target-selection.e2e.test.ts +++ b/apps/cli-e2e/src/tests/target-selection.e2e.test.ts @@ -16,53 +16,6 @@ async function getRequestLog(apiUrl: string): Promise<RequestEntry[]> { } describe("deprecated target aliases", () => { - testBehaviour( - "warns that --linked is deprecated and still targets the linked project", - async ({ run, projectRef, workspace }) => { - linkProject(workspace.path, projectRef); - const result = await run(["db", "advisors", "--type", "security", "--linked"]); - expect(result.exitCode).toBe(0); - expect(`${result.stdout}${result.stderr}`).toContain( - "--linked is deprecated and will be removed in v4. Use --remote instead", - ); - expect(result.stderr).toContain("No issues found"); - }, - ); - - testBehaviour("warns that --local is not needed and still targets local", async ({ run }) => { - const result = await run(["db", "push", "--local"]); - expect(result.exitCode).not.toBe(0); - expect(`${result.stdout}${result.stderr}`).toContain( - "--local isn't needed (local is the default) and will be removed in v4", - ); - expect(result.stderr).toContain("Target: local stack"); - expect(result.stderr).toContain("failed to connect to postgres"); - }); - - testBehaviour( - "warns that --db-url is deprecated and still targets that database", - async ({ run }) => { - const result = await run(["inspect", "db", "db-stats", "--db-url", DEAD_DB_URL]); - expect(result.exitCode).not.toBe(0); - expect(`${result.stdout}${result.stderr}`).toContain( - "--db-url is deprecated and will be removed in v4. Use --remote=<db-url> instead", - ); - expect(`${result.stdout}${result.stderr}`).not.toContain(DEAD_DB_URL); - expect(result.stderr).toContain("Target: custom Postgres (localhost:1)"); - }, - ); - - testBehaviour( - "warns that --project-ref is deprecated on a hosted command and still targets that project", - async ({ run, projectRef }) => { - const result = await run(["secrets", "list", "--project-ref", projectRef]); - expect(result.exitCode).toBe(0); - expect(`${result.stdout}${result.stderr}`).toContain( - `--project-ref is deprecated and will be removed in v4. Use --remote=<ref> instead`, - ); - }, - ); - testBehaviour( "warns that --project-id is deprecated on gen types and still targets that project", async ({ run, projectRef }) => { From 7a6b01c677d80ae972d81728bd018e4069386370 Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Tue, 20 Oct 2026 13:55:57 +0100 Subject: [PATCH 222/291] fix(cli): put --remote last in the network-ban suggestion The LinkedDbNetworkBannedError suggestion put --remote=<ref> before the other flags. It now ends the command, in the form the docs use, so a copied command cannot read the next word as part of the flag. The sentence-final period is dropped so it cannot attach to the ref when copied. --- apps/cli/docs/command-target.md | 2 +- apps/cli/src/command-internal/db-config.integration.test.ts | 2 +- apps/cli/src/command-internal/db-config.layer.ts | 2 +- apps/cli/src/commands/network-bans/remove/SIDE_EFFECTS.md | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/cli/docs/command-target.md b/apps/cli/docs/command-target.md index 07482d5fcc..dede3ae080 100644 --- a/apps/cli/docs/command-target.md +++ b/apps/cli/docs/command-target.md @@ -100,7 +100,7 @@ Every persistent mutation of a hosted resource asks for consent first, including - **Refusal.** JSON, stream-json, `-o json|yaml|toml|env`, agent 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`. Nothing is changed. - **Decline.** A declined prompt fails with `HostedMutationDeclinedError` (`context canceled`), exit 1, and nothing is changed. - **One question.** A command asks once, after the plan is known and before the first persistent effect, and lists everything it will do. -- **Network bans.** Removing a network ban to connect needs `--yes` or a shell `SUPABASE_YES`; without one, the command fails with `LinkedDbNetworkBannedError`. Its suggestion is `Re-run with --yes to remove them, or run supabase network-bans remove --remote=<ref> --db-unban-ip <addrs> --experimental --yes.`, and `<ref>` is always the project whose bans blocked the connection, which after `db pull --remote=<other-ref>` is not the linked project. This applies to reads too. +- **Network bans.** Removing a network ban to connect needs `--yes` or a shell `SUPABASE_YES`; without one, the command fails with `LinkedDbNetworkBannedError`. Its suggestion is `Re-run with --yes to remove them, or run supabase network-bans remove --db-unban-ip <addrs> --experimental --yes --remote=<ref>`, and `<ref>` is always the project whose bans blocked the connection, which after `db pull --remote=<other-ref>` is not the linked project. This applies to reads too. - **Reads never prompt.** A command marked `no consent` never asks for hosted-mutation consent, on any target, and `--yes` and `SUPABASE_YES` have no effect on it. `db query --read-only` is a read and skips consent too. ## Banner 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 84335ff581..98bf6bb08a 100644 --- a/apps/cli/src/command-internal/db-config.integration.test.ts +++ b/apps/cli/src/command-internal/db-config.integration.test.ts @@ -2096,7 +2096,7 @@ describe("dbConfigResolver.resolveConnection", () => { Option.some( expect.objectContaining({ 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 --remote=${ref} --db-unban-ip 203.0.113.7,198.51.100.2 --experimental --yes.`, + 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 --remote=${ref}`, }), ), ); diff --git a/apps/cli/src/command-internal/db-config.layer.ts b/apps/cli/src/command-internal/db-config.layer.ts index 36d8995a61..ca824bf76f 100644 --- a/apps/cli/src/command-internal/db-config.layer.ts +++ b/apps/cli/src/command-internal/db-config.layer.ts @@ -269,7 +269,7 @@ const removeBans = Effect.fnUntraced(function* (ref: string, addrs: ReadonlyArra const networkBannedError = (ref: string, 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 --remote=${ref} --db-unban-ip ${addrs.join(",")} --experimental --yes.`, + suggestion: `Re-run with --yes to remove them, or run supabase network-bans remove --db-unban-ip ${addrs.join(",")} --experimental --yes --remote=${ref}`, }); // Verify-connect with backoff while the pooler refreshes the temp password. From attempt 3 on, look 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 3d40748a9f..2b38c98916 100644 --- a/apps/cli/src/commands/network-bans/remove/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/network-bans/remove/SIDE_EFFECTS.md @@ -94,4 +94,4 @@ One `result` event on success when the `--output` flag is unset. 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. - 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). -- The `LinkedDbNetworkBannedError` raised by commands that connect to a linked database suggests this command as `supabase network-bans remove --remote=<ref> --db-unban-ip <addrs> --experimental --yes`, with `<ref>` the project whose bans blocked the connection; it needs `--experimental` and the confirmation above like any other run. +- The `LinkedDbNetworkBannedError` raised by commands that connect to a linked database suggests this command as `supabase network-bans remove --db-unban-ip <addrs> --experimental --yes --remote=<ref>`, with `<ref>` the project whose bans blocked the connection; it needs `--experimental` and the confirmation above like any other run. From dd323276b0e79198b443a1733d4b83431fe39ecb Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Tue, 20 Oct 2026 13:56:13 +0100 Subject: [PATCH 223/291] test(cli): cover target selection for db lint and db dump with the shared harness --- .../db/advisors/advisors.integration.test.ts | 2 +- .../commands/db/dump/dump.integration.test.ts | 243 ++---------------- .../commands/db/lint/lint.integration.test.ts | 215 ++-------------- apps/cli/tests/helpers/db-target-selection.ts | 16 +- 4 files changed, 47 insertions(+), 429 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 1a0056d980..18110557b7 100644 --- a/apps/cli/src/commands/db/advisors/advisors.integration.test.ts +++ b/apps/cli/src/commands/db/advisors/advisors.integration.test.ts @@ -817,7 +817,7 @@ describeDbTargetSelection("db advisors", { attempt: ({ target }) => { const ctx = setup({ rows: [], securityLints: [], performanceLints: [] }); return { - layer: Layer.merge(ctx.layer, ctx.api.layer), + layer: ctx.layer, out: ctx.out, run: dbAdvisors(flags(target)), target: () => ctx.resolver.resolveInputs[0]?.target, 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 01dc84b7d2..adcb5356c4 100644 --- a/apps/cli/src/commands/db/dump/dump.integration.test.ts +++ b/apps/cli/src/commands/db/dump/dump.integration.test.ts @@ -27,11 +27,8 @@ vi.mock("node:net", (importOriginal) => const reachableDirectHost = withReachableDirectHost(fixtureNet); -import { - BRANCH_PROJECT_REF, - branchListApi, - warningsOf, -} from "../../../../tests/helpers/remote-selection.ts"; +import { describeDbTargetSelection } from "../../../../tests/helpers/db-target-selection.ts"; +import { branchListApi } from "../../../../tests/helpers/remote-selection.ts"; import { mockOutput, mockTty, processEnvLayer } from "../../../../tests/helpers/mocks.ts"; import { unusedGateway } from "../../../../tests/helpers/unused-stack.ts"; import { withReachableDirectHost } from "../../../../tests/helpers/refuse-invalid-db-hosts.ts"; @@ -61,10 +58,6 @@ import { cliConfigValuesTestLayer } from "../../../../tests/helpers/config-value 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, @@ -641,15 +634,6 @@ describe("db dump integration", () => { }).pipe(Effect.provide(layer)); }); - it.effect("rejects combining --linked and --local", () => { - const { layer, resolver } = setup(); - return Effect.gen(function* () { - 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("hands --password to the resolver, which rejects it for a non-linked target", () => { const { layer, resolver } = setup(); return Effect.gen(function* () { @@ -681,14 +665,6 @@ describe("db dump integration", () => { }).pipe(Effect.provide(layer)); }); - 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: 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* () { @@ -847,22 +823,6 @@ describe("db dump integration", () => { }, ); - 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("targets the linked project for a bare --remote", () => { - const { layer, resolver } = setup({ conn: REMOTE_CONN, isLocal: false }); - return Effect.gen(function* () { - yield* dbDump(flags({ dryRun: true, remote: Option.some({ _tag: "Linked" }) })); - expect(resolver.calls[0]?.target.kind).toBe("linked"); - }).pipe(Effect.provide(layer)); - }); - it.effect("keeps an explicit local db-url on the bundled client path", () => { const { layer, bundled } = setup({ conn: { @@ -994,191 +954,22 @@ describe("db dump integration", () => { }).pipe(Effect.provide(layer)); }); - it.effect("caches the linked project from the resolved ref on a successful dump", () => { - const { layer, cache } = setup({ - conn: REMOTE_CONN, - isLocal: false, - ref: "abcdefghijklmnopqrst", - stdout: "CREATE SCHEMA public;\n", - }); - return Effect.gen(function* () { - yield* dbDump(flags({ remote: REMOTE_LINKED })); - expect(cache.cached).toBe(true); - }).pipe(Effect.provide(layer)); - }); - - it.effect("dumps the project given via --remote=<ref> without a linked workdir", () => { - // No fixed `opts.ref`; only the flag can resolve a ref here. - const FLAG_REF = "flagflagflagflagflag"; - const { layer, cache, resolver } = setup({ - conn: REMOTE_CONN, - isLocal: false, - stdout: "CREATE SCHEMA public;\n", - }); - return Effect.gen(function* () { - yield* dbDump(flags({ remote: Option.some({ _tag: "Ref", ref: FLAG_REF } as const) })); - 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)); - }); - - it.effect("--remote=<ref> overrides an already-linked workdir's project ref", () => { - const FLAG_REF = "flagflagflagflagflag"; - // A distinct fixed ref proves the flag, not the workdir's own ref, wins. - const { layer, cache } = setup({ - conn: REMOTE_CONN, - isLocal: false, - ref: "abcdefghijklmnopqrst", - stdout: "CREATE SCHEMA public;\n", - }); - return Effect.gen(function* () { - yield* dbDump(flags({ remote: Option.some({ _tag: "Ref", ref: FLAG_REF } as const) })); - expect(cache.cached).toBe(true); - expect(cache.cachedRef).toBe(FLAG_REF); - expect(cache.cachedRef).not.toBe("abcdefghijklmnopqrst"); - }).pipe(Effect.provide(layer)); - }); - - it.effect("fails before resolving or caching when --remote=<branch> names no branch", () => { - const { layer, cache, resolver } = setup({ projectId: Option.some(VALID_REF) }); - return Effect.gen(function* () { - const exit = yield* dbDump( - flags({ remote: Option.some({ _tag: "Branch", name: "BADREF" }) }), - ).pipe(Effect.exit, Effect.provide(Layer.merge(layer, branchListApi().layer))); - expect(Exit.isFailure(exit)).toBe(true); - expect(failMessage(exit)).toContain('Branch "BADREF" not found'); - expect(resolver.calls).toEqual([]); - expect(cache.cached).toBe(false); - }); - }); - - it.effect("rejects --project-ref combined with an explicit --local target", () => { - 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, - ); - expect(error).toBeInstanceOf(TargetProjectRefConflictError); - expect(resolver.calls).toEqual([]); - expect(cache.cached).toBe(false); - }).pipe(Effect.provide(layer)); - }); - - describe("target selection", () => { - const FLAG_PROJECT_REF = "flagflagflagflagflag"; - - const resolvedTarget = ( - selector: Partial<DbDumpFlags>, - options: { readonly branchApi?: ReturnType<typeof branchListApi> } = {}, - ) => { - const { layer, out, resolver } = setup({ - isLocal: true, - projectId: Option.some(VALID_REF), - }); - const provided = - options.branchApi === undefined ? layer : Layer.merge(layer, options.branchApi.layer); - return Effect.gen(function* () { - yield* dbDump(flags({ dryRun: true, ...selector })).pipe(Effect.provide(provided)); - return { target: resolver.calls[0]?.target, warnings: warningsOf(out) }; - }); - }; - - it.effect("targets the local database, with no warning, when nothing selects one", () => - Effect.gen(function* () { - const { target, warnings } = yield* resolvedTarget({}); - expect(target).toEqual({ kind: "local" }); - expect(warnings).toEqual([]); - }), - ); - - it.effect("targets the linked project for a bare --remote, with no warning", () => - Effect.gen(function* () { - const { target, warnings } = yield* resolvedTarget({ remote: REMOTE_LINKED }); - expect(target).toMatchObject({ kind: "linked" }); - expect(warnings).toEqual([]); - }), - ); - - it.effect("targets the project --remote=<ref> names, with no warning", () => - Effect.gen(function* () { - const { target, warnings } = yield* resolvedTarget({ - remote: Option.some({ _tag: "Ref", ref: FLAG_PROJECT_REF } as const), - }); - expect(target).toMatchObject({ kind: "linked", ref: FLAG_PROJECT_REF, source: "flag" }); - expect(warnings).toEqual([]); - }), - ); - - it.effect("targets the database --remote=<db-url> names, with no warning", () => - Effect.gen(function* () { - const { target, warnings } = yield* resolvedTarget({ - remote: Option.some({ - _tag: "DbUrl", - url: "postgresql://postgres:pw@db.example.com/db", - } as const), - }); - expect(target?.kind).toBe("db-url"); - expect(warnings).toEqual([]); - }), - ); - - it.effect("targets the project of the branch --remote=<branch> names", () => { + describeDbTargetSelection("db dump", { + dbUrl: true, + workdir: () => tmp.current, + activeFlags: { "dry-run": true }, + attempt: ({ workdir, target }) => { + const ctx = setup({ isLocal: true, workdir }); const branchApi = branchListApi(); - return Effect.gen(function* () { - const { target, warnings } = yield* resolvedTarget( - { remote: Option.some({ _tag: "Branch", name: "feature-x" }) }, - { branchApi }, - ); - expect(target).toMatchObject({ kind: "linked", ref: BRANCH_PROJECT_REF, source: "flag" }); - expect(warnings).toEqual([]); - assertPermissionDrift({ - command: "db dump", - activeFlags: { "dry-run": true, remote: "feature-x" }, - requests: branchApi.requests, - }); - }); - }); - - it.effect.each([ - { - alias: "--linked", - flags: { linked: true }, - kind: "linked", - warning: "--linked is deprecated and will be removed in v4. Use --remote instead", - }, - { - alias: "--project-ref", - flags: { projectRef: Option.some(FLAG_PROJECT_REF) }, - kind: "linked", - warning: - "--project-ref is deprecated and will be removed in v4. Use --remote=<ref> instead", - }, - { - alias: "--db-url", - flags: { dbUrl: Option.some("postgresql://postgres:pw@db.example.com/db") }, - kind: "db-url", - warning: "--db-url is deprecated and will be removed in v4. Use --remote=<db-url> instead", - }, - { - alias: "--local", - flags: { local: true }, - kind: "local", - warning: "--local isn't needed (local is the default) and will be removed in v4", - }, - ] satisfies ReadonlyArray<{ - alias: string; - flags: Partial<DbDumpFlags>; - kind: string; - warning: string; - }>)("keeps $alias working and warns that it is deprecated", ({ flags, kind, warning }) => - Effect.gen(function* () { - const { target, warnings } = yield* resolvedTarget(flags); - expect(target?.kind).toBe(kind); - expect(warnings).toEqual([expect.stringContaining(warning)]); - }), - ); + return { + layer: Layer.merge(ctx.layer, branchApi.layer), + out: ctx.out, + run: dbDump(flags({ dryRun: true, ...target })), + target: () => ctx.resolver.calls[0]?.target, + cachedRef: () => ctx.cache.cachedRef, + branchLookups: () => branchApi.requests, + }; + }, }); it.effect("writes the dump to --file and reports the absolute path on stderr", () => { 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 46c2fa8d0a..7346c23132 100644 --- a/apps/cli/src/commands/db/lint/lint.integration.test.ts +++ b/apps/cli/src/commands/db/lint/lint.integration.test.ts @@ -11,11 +11,8 @@ vi.mock("node:net", (importOriginal) => ); import { cliConfigValuesTestLayer } from "../../../../tests/helpers/config-values-layer.ts"; -import { - BRANCH_PROJECT_REF, - branchListApi, - warningsOf, -} from "../../../../tests/helpers/remote-selection.ts"; +import { describeDbTargetSelection } from "../../../../tests/helpers/db-target-selection.ts"; +import { branchListApi } from "../../../../tests/helpers/remote-selection.ts"; import { mockOutput, mockProcessControl } from "../../../../tests/helpers/mocks.ts"; import { VALID_REF, @@ -46,10 +43,6 @@ import type { 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, @@ -263,8 +256,6 @@ function setup(opts: SetupOpts = {}) { return { layer, out, resolver, connection, telemetry, processControl, projectRef, cache, http }; } -const REMOTE_LINKED: DbLintFlags["remote"] = Option.some({ _tag: "Linked" }); - const flags = (over: Partial<DbLintFlags> = {}): DbLintFlags => ({ dbUrl: over.dbUrl ?? Option.none<string>(), remote: over.remote ?? Option.none(), @@ -435,17 +426,6 @@ describe("db lint", () => { }).pipe(Effect.provide(layer)); }); - it.effect("rejects --db-url together with --linked", () => { - const { layer, connection } = setup(); - return Effect.gen(function* () { - 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)); - }); - it.effect("emits a standard success envelope in json mode", () => { const { layer, out } = setup({ format: "json", @@ -501,19 +481,6 @@ describe("db lint", () => { }).pipe(Effect.provide(layer)); }); - it.effect("connects to the --remote=<db-url> target", () => { - const { layer, resolver } = setup({ isLocal: false, checkRows: { public: [] } }); - return Effect.gen(function* () { - yield* dbLint( - flags({ - schema: ["public"], - remote: Option.some({ _tag: "DbUrl", url: "postgres://x" } as const), - }), - ); - expect(resolver.resolveInput?.target.kind).toBe("db-url"); - }).pipe(Effect.provide(layer)); - }); - it.effect("lints multiple pre-parsed schemas from a comma-separated --schema value", () => { // CSV parsing happens at `Flag.mapTryCatch` parse time; the handler receives the // already-split list directly. @@ -526,30 +493,6 @@ describe("db lint", () => { }).pipe(Effect.provide(layer)); }); - it.effect("writes the linked-project cache for a bare --remote", () => { - const { layer, projectRef, cache } = setup({ isLocal: false, checkRows: { public: [] } }); - return Effect.gen(function* () { - yield* dbLint(flags({ schema: ["public"], remote: REMOTE_LINKED })); - expect(projectRef.calls).toContain("resolveRef"); - expect(cache.cached).toBe(true); - }).pipe(Effect.provide(layer)); - }); - - it.effect("lints the project given via --remote=<ref>, overriding the workdir's own ref", () => { - // `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: [] } }); - return Effect.gen(function* () { - yield* dbLint( - flags({ schema: ["public"], remote: Option.some({ _tag: "Ref", ref: FLAG_REF } as const) }), - ); - expect(cache.cached).toBe(true); - expect(cache.cachedRef).toBe(FLAG_REF); - expect(cache.cachedRef).not.toBe(VALID_REF); - }).pipe(Effect.provide(layer)); - }); - it.effect("mints a login role and fills the cache through the real resolver and cache", () => { const { layer, http } = setup({ isLocal: false, @@ -670,27 +613,6 @@ describe("db lint", () => { ); }); - it.effect("does not write the linked-project cache for a local run", () => { - const { layer, cache } = setup({ checkRows: { public: [] } }); - return Effect.gen(function* () { - yield* dbLint(flags({ schema: ["public"] })); - expect(cache.cached).toBe(false); - }).pipe(Effect.provide(layer)); - }); - - it.effect("rejects --project-ref together with --local", () => { - const FLAG_REF = "flagflagflagflagflag"; - const { layer, connection, cache } = setup({ checkRows: { public: [] } }); - return Effect.gen(function* () { - 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)); - }); - it.effect("flushes telemetry on success and on failure", () => { const success = setup({ checkRows: { public: [] } }); const failure = setup({ enableFails: true }); @@ -701,125 +623,22 @@ describe("db lint", () => { expect(failure.telemetry.flushed).toBe(true); }); }); - - 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"], linked: false })); - expect(resolver.resolveInput?.target.kind).toBe("local"); - expect(cache.cached).toBe(false); - }).pipe(Effect.provide(layer)); - }); - - it.effect("targets the linked project for a bare --remote", () => { - const { layer, resolver, cache } = setup({ isLocal: false, checkRows: { public: [] } }); - return Effect.gen(function* () { - yield* dbLint(flags({ schema: ["public"], remote: Option.some({ _tag: "Linked" }) })); - expect(resolver.resolveInput?.target.kind).toBe("linked"); - expect(cache.cached).toBe(true); - }).pipe(Effect.provide(layer)); - }); }); -describe("db lint target selection", () => { - const FLAG_PROJECT_REF = "flagflagflagflagflag"; - - const resolvedTarget = ( - selector: Partial<DbLintFlags>, - options: { readonly branchApi?: ReturnType<typeof branchListApi> } = {}, - ) => { - const { layer, out, resolver } = setup({ checkRows: { public: [] } }); - const provided = - options.branchApi === undefined ? layer : Layer.merge(layer, options.branchApi.layer); - return Effect.gen(function* () { - yield* dbLint(flags({ schema: ["public"], ...selector })).pipe(Effect.provide(provided)); - return { target: resolver.resolveInput?.target, warnings: warningsOf(out) }; - }); - }; - - it.effect("targets the local database, with no warning, when nothing selects one", () => - Effect.gen(function* () { - const { target, warnings } = yield* resolvedTarget({}); - expect(target).toEqual({ kind: "local" }); - expect(warnings).toEqual([]); - }), - ); - - it.effect("targets the project --remote=<ref> names, with no warning", () => - Effect.gen(function* () { - const { target, warnings } = yield* resolvedTarget({ - remote: Option.some({ _tag: "Ref", ref: FLAG_PROJECT_REF } as const), - }); - expect(target).toMatchObject({ kind: "linked", ref: FLAG_PROJECT_REF, source: "flag" }); - expect(warnings).toEqual([]); - }), - ); - - it.effect("targets the database --remote=<db-url> names, with no warning", () => - Effect.gen(function* () { - const { target, warnings } = yield* resolvedTarget({ - remote: Option.some({ - _tag: "DbUrl", - url: "postgresql://postgres:pw@db.example.com/db", - } as const), - }); - expect(target?.kind).toBe("db-url"); - expect(warnings).toEqual([]); - }), - ); - - it.effect("targets the project of the branch --remote=<branch> names", () => { +describeDbTargetSelection("db lint", { + dbUrl: true, + workdir: () => tempRoot.current, + activeFlags: { schema: true }, + attempt: ({ target }) => { + const ctx = setup({ checkRows: { public: [] } }); const branchApi = branchListApi(); - return Effect.gen(function* () { - const { target, warnings } = yield* resolvedTarget( - { remote: Option.some({ _tag: "Branch", name: "feature-x" }) }, - { branchApi }, - ); - expect(target).toMatchObject({ kind: "linked", ref: BRANCH_PROJECT_REF, source: "flag" }); - expect(warnings).toEqual([]); - assertPermissionDrift({ - command: "db lint", - activeFlags: { schema: true, remote: "feature-x" }, - requests: branchApi.requests, - }); - }); - }); - - it.effect.each([ - { - alias: "--linked", - flags: { linked: true }, - kind: "linked", - warning: "--linked is deprecated and will be removed in v4. Use --remote instead", - }, - { - alias: "--project-ref", - flags: { projectRef: Option.some(FLAG_PROJECT_REF) }, - kind: "linked", - warning: "--project-ref is deprecated and will be removed in v4. Use --remote=<ref> instead", - }, - { - alias: "--db-url", - flags: { dbUrl: Option.some("postgresql://postgres:pw@db.example.com/db") }, - kind: "db-url", - warning: "--db-url is deprecated and will be removed in v4. Use --remote=<db-url> instead", - }, - { - alias: "--local", - flags: { local: true }, - kind: "local", - warning: "--local isn't needed (local is the default) and will be removed in v4", - }, - ] satisfies ReadonlyArray<{ - alias: string; - flags: Partial<DbLintFlags>; - kind: string; - warning: string; - }>)("keeps $alias working and warns that it is deprecated", ({ flags, kind, warning }) => - Effect.gen(function* () { - const { target, warnings } = yield* resolvedTarget(flags); - expect(target?.kind).toBe(kind); - expect(warnings).toEqual([expect.stringContaining(warning)]); - }), - ); + return { + layer: Layer.merge(ctx.layer, branchApi.layer), + out: ctx.out, + run: dbLint(flags({ schema: ["public"], ...target })), + target: () => ctx.resolver.resolveInput?.target, + cachedRef: () => ctx.cache.cachedRef, + branchLookups: () => branchApi.requests, + }; + }, }); diff --git a/apps/cli/tests/helpers/db-target-selection.ts b/apps/cli/tests/helpers/db-target-selection.ts index ae484b7321..0cd22b21b6 100644 --- a/apps/cli/tests/helpers/db-target-selection.ts +++ b/apps/cli/tests/helpers/db-target-selection.ts @@ -3,7 +3,7 @@ import { join } from "node:path"; import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; -import { Effect, Exit, Layer, Option, Predicate } from "effect"; +import { Effect, Exit, Layer, Option, Predicate, type Scope } from "effect"; import { CliOutput, Command } from "effect/cli"; import type { RemoteValue } from "../../src/command-internal/target/remote-value.ts"; @@ -18,6 +18,7 @@ import { textCliOutputFormatter } from "../../src/shared/output/text-formatter.t import { stripAnsi } from "./ansi.ts"; import { VALID_REF } from "./command-mocks.ts"; import { assertPermissionDrift } from "./permission-drift.ts"; +import type { ActiveFlags } from "./permission-registry.ts"; import { mockAnalytics, mockOutput, @@ -87,7 +88,7 @@ export const storageTargetOf = ( interface DbTargetAttempt<R> { readonly layer: Layer.Layer<R, unknown>; readonly out: ReturnType<typeof mockOutput>; - readonly run: Effect.Effect<unknown, unknown, R>; + readonly run: Effect.Effect<unknown, unknown, R | Scope.Scope>; /** The target the leaf opened its connection or requests for; `undefined` until it reached one. */ readonly target: () => ObservedTarget | undefined; readonly cachedRef?: () => string | undefined; @@ -124,6 +125,8 @@ interface DbTargetSelectionScenario<R> { }) => DbTargetAttempt<R> | Effect.Effect<DbTargetAttempt<R>, unknown, BunServices.BunServices>; /** Wraps each row, for a leaf whose tests pin ambient environment variables around their body. */ readonly around?: (row: Effect.Effect<void, unknown>) => Effect.Effect<void, unknown>; + /** The flags besides `--remote` that the run sets and the leaf's permission map branches on. */ + readonly activeFlags?: ActiveFlags; /** Rows the leaf cannot express, each with why. */ readonly optOut?: Partial<Record<Row, string>>; } @@ -159,7 +162,9 @@ export function describeDbTargetSelection<R>( readonly args: ReadonlyArray<string>; readonly target: TargetFlagValuesWithDbUrl; readonly linked?: true; - readonly check: (ctx: DbTargetAttempt<R>) => Effect.Effect<void, unknown, R | CommandTarget>; + readonly check: ( + ctx: DbTargetAttempt<R>, + ) => Effect.Effect<void, unknown, R | CommandTarget | Scope.Scope>; }, ) => { if (optedOut(row)) return; @@ -182,6 +187,7 @@ export function describeDbTargetSelection<R>( Effect.provide( Layer.mergeAll(ctx.layer, commandTargetLayer.pipe(Layer.provide(ctx.out.layer))), ), + Effect.scoped, ), ); return scenario.around === undefined ? row : scenario.around(row); @@ -246,6 +252,7 @@ export function describeDbTargetSelection<R>( rowTest("remote-branch", "targets the project of the branch --remote=<branch> names", { args: ["--remote=feature-x"], target: selecting({ _tag: "Branch", name: "feature-x" }), + linked: true, check: (ctx) => Effect.gen(function* () { yield* ctx.run; @@ -257,7 +264,7 @@ export function describeDbTargetSelection<R>( expect(lookups.map(({ method, url }) => `${method} ${url}`)).toEqual([BRANCH_LOOKUP]); assertPermissionDrift({ command: path, - activeFlags: { remote: "feature-x" }, + activeFlags: { ...scenario.activeFlags, remote: "feature-x" }, requests: lookups, }); } @@ -270,6 +277,7 @@ export function describeDbTargetSelection<R>( { args: ["--remote=feature-y"], target: selecting({ _tag: "Branch", name: "feature-y" }), + linked: true, check: (ctx) => Effect.gen(function* () { const error = yield* Effect.flip(ctx.run); From 5ab7efad36a4c4e1c290d270363bf15c81a4cf6b Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Tue, 20 Oct 2026 14:01:29 +0100 Subject: [PATCH 224/291] test(cli): cover target selection for db query with the shared harness --- .../db/query/query.integration.test.ts | 255 ++---------------- 1 file changed, 28 insertions(+), 227 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 7ac529ba4d..7f7d124739 100644 --- a/apps/cli/src/commands/db/query/query.integration.test.ts +++ b/apps/cli/src/commands/db/query/query.integration.test.ts @@ -32,11 +32,8 @@ import { useTempWorkdir, } from "../../../../tests/helpers/command-mocks.ts"; import { cliConfigValuesTestLayer } from "../../../../tests/helpers/config-values-layer.ts"; -import { - BRANCH_PROJECT_REF, - branchListApi, - warningsOf, -} from "../../../../tests/helpers/remote-selection.ts"; +import { describeDbTargetSelection } from "../../../../tests/helpers/db-target-selection.ts"; +import { branchListApi } from "../../../../tests/helpers/remote-selection.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"; @@ -66,10 +63,6 @@ import type { } 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, @@ -751,30 +744,6 @@ describe("db query integration", () => { }).pipe(Effect.provide(layer)); }); - it.effect("rejects conflicting targets (--remote --local) before running any SQL", () => { - const { layer, cache, dbConnection } = setup(); - return Effect.gen(function* () { - const exit = yield* dbQuery( - flags({ sql: Option.some("select 1"), remote: REMOTE_LINKED, local: true }), - ).pipe(Effect.exit); - expect(Exit.isFailure(exit)).toBe(true); - 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("treats --local=false --linked=false as absent and queries the local target", () => { - const { layer, out, cache } = setup({ result: SELECT_RESULT }); - return Effect.gen(function* () { - yield* dbQuery(flags({ sql: Option.some("select 1") })); - expect(out.stdoutText).toContain("alice"); - expect(cache.cached).toBe(false); - }).pipe(Effect.provide(layer)); - }); - it.effect("fails an unlinked bare --remote query without prompting for a project", () => { const { layer } = setup({ unlinked: true }); return Effect.gen(function* () { @@ -930,86 +899,6 @@ describe("db query integration", () => { }).pipe(Effect.provide(layer)); }); - it.effect("--remote=<ref> overrides an already-linked workdir's project ref", () => { - const FLAG_REF = "flagflagflagflagflag"; - const { layer, cache, platformApi } = setup({ - linkedStatus: 201, - linkedBody: '[{"name":"alice","id":1}]', - }); - return Effect.gen(function* () { - yield* dbQuery( - flags({ - sql: Option.some("select 1"), - remote: Option.some({ _tag: "Ref", ref: FLAG_REF } as const), - }), - ); - expect( - platformApi.requests.some((req) => - req.url.includes(`/v1/projects/${FLAG_REF}/database/query`), - ), - ).toBe(true); - expect(cache.cached).toBe(true); - expect(cache.cachedRef).toBe(FLAG_REF); - expect(cache.cachedRef).not.toBe(REF); - }).pipe(Effect.provide(layer)); - }); - - it.effect("selects the linked project from --remote=<ref> alone", () => { - const FLAG_REF = "flagflagflagflagflag"; - const { layer, cache, platformApi } = setup({ - linkedStatus: 201, - linkedBody: '[{"name":"alice","id":1}]', - }); - return Effect.gen(function* () { - yield* dbQuery( - flags({ - sql: Option.some("select 1"), - remote: Option.some({ _tag: "Ref", ref: FLAG_REF } as const), - }), - ); - expect( - platformApi.requests.some((req) => - req.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, platformApi } = setup({ result: SELECT_RESULT }); - return Effect.gen(function* () { - const exit = yield* dbQuery( - flags({ - sql: Option.some("select 1"), - local: true, - projectRef: Option.some("flagflagflagflagflag"), - }), - ).pipe(Effect.exit); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - expect(Cause.squash(exit.cause)).toBeInstanceOf(TargetProjectRefConflictError); - } - expect(out.stdoutText).toBe(""); - expect(platformApi.requests).toEqual([]); - expect(cache.cached).toBe(false); - }).pipe(Effect.provide(layer)); - }); - - it.effect("targets the linked project for a bare --remote", () => { - const { layer, out, cache } = setup({ - linkedStatus: 201, - linkedBody: '[{"name":"alice","id":1}]', - }); - return Effect.gen(function* () { - yield* dbQuery( - flags({ sql: Option.some("select 1"), remote: Option.some({ _tag: "Linked" }) }), - ); - expect(out.stdoutText).toContain("│ name │ id │"); - expect(cache.cached).toBe(true); - }).pipe(Effect.provide(layer)); - }); - it.effect("never resolves a database connection for a linked query", () => { const { layer, out, cache } = setup({ resolveFails: true, @@ -1341,124 +1230,36 @@ describe("db query integration", () => { }); }); }); -}); -describe("db query target selection", () => { - const FLAG_PROJECT_REF = "flagflagflagflagflag"; - - const observed = ( - selector: Partial<DbQueryFlags>, - branchApi?: ReturnType<typeof branchListApi>, - ) => { - const { layer, out, platformApi, resolvedTargets } = setup({ - result: SELECT_RESULT, - linkedStatus: 201, - linkedBody: '[{"name":"alice","id":1}]', - }); - const provided = branchApi === undefined ? layer : Layer.merge(layer, branchApi.layer); - return Effect.gen(function* () { - yield* dbQuery(flags({ sql: Option.some("select 1"), ...selector })).pipe( - Effect.provide(provided), - ); - const hostedRef = platformApi.requests - .filter((request) => request.method === "POST") - .map((request) => request.url.match(/\/v1\/projects\/([^/]+)\/database\/query$/u)?.[1]) - .find((ref) => ref !== undefined); + describeDbTargetSelection("db query", { + dbUrl: true, + workdir: () => tempRoot.current, + attempt: ({ workdir, target }) => { + const ctx = setup({ + result: SELECT_RESULT, + linkedStatus: 201, + linkedBody: '[{"name":"alice","id":1}]', + workdir, + }); + const branchApi = branchListApi(); return { - hostedRef, - target: resolvedTargets[0], - warnings: warningsOf(out), + layer: Layer.merge(ctx.layer, branchApi.layer), + out: ctx.out, + run: dbQuery(flags({ sql: Option.some("select 1"), ...target })), + target: () => { + const hostedRef = ctx.platformApi.requests + .filter(({ method }) => method === "POST") + .map(({ url }) => /\/v1\/projects\/([^/]+)\/database\/query$/u.exec(url)?.[1]) + .find((ref) => ref !== undefined); + return hostedRef === undefined + ? ctx.resolvedTargets[0] + : { kind: "linked" as const, ref: hostedRef }; + }, + cachedRef: () => ctx.cache.cachedRef, + branchLookups: () => branchApi.requests, }; - }); - }; - - it.effect("queries the local database, with no warning, when nothing selects one", () => - Effect.gen(function* () { - const { hostedRef, target, warnings } = yield* observed({}); - expect(hostedRef).toBeUndefined(); - expect(target).toEqual({ kind: "local" }); - expect(warnings).toEqual([]); - }), - ); - - it.effect("queries the project --remote=<ref> names over HTTP, with no warning", () => - Effect.gen(function* () { - const { hostedRef, warnings } = yield* observed({ - remote: Option.some({ _tag: "Ref", ref: FLAG_PROJECT_REF } as const), - }); - expect(hostedRef).toBe(FLAG_PROJECT_REF); - expect(warnings).toEqual([]); - }), - ); - - it.effect("queries the database --remote=<db-url> names, with no warning", () => - Effect.gen(function* () { - const { hostedRef, target, warnings } = yield* observed({ - remote: Option.some({ - _tag: "DbUrl", - url: "postgresql://postgres:pw@db.example.com/db", - } as const), - }); - expect(hostedRef).toBeUndefined(); - expect(target?.kind).toBe("db-url"); - expect(warnings).toEqual([]); - }), - ); - - it.effect("queries the project of the branch --remote=<branch> names over HTTP", () => { - const branchApi = branchListApi(); - return Effect.gen(function* () { - const { hostedRef, warnings } = yield* observed( - { remote: Option.some({ _tag: "Branch", name: "feature-x" }) }, - branchApi, - ); - expect(hostedRef).toBe(BRANCH_PROJECT_REF); - expect(warnings).toEqual([]); - assertPermissionDrift({ - command: "db query", - activeFlags: { remote: "feature-x" }, - requests: branchApi.requests, - }); - }); - }); - - it.effect.each([ - { - alias: "--linked", - flags: { linked: true }, - expected: { hostedRef: REF }, - warning: "--linked is deprecated and will be removed in v4. Use --remote instead", - }, - { - alias: "--project-ref", - flags: { projectRef: Option.some(FLAG_PROJECT_REF) }, - expected: { hostedRef: FLAG_PROJECT_REF }, - warning: "--project-ref is deprecated and will be removed in v4. Use --remote=<ref> instead", - }, - { - alias: "--db-url", - flags: { dbUrl: Option.some("postgresql://postgres:pw@db.example.com/db") }, - expected: { target: expect.objectContaining({ kind: "db-url" }) }, - warning: "--db-url is deprecated and will be removed in v4. Use --remote=<db-url> instead", - }, - { - alias: "--local", - flags: { local: true }, - expected: { target: { kind: "local" } }, - warning: "--local isn't needed (local is the default) and will be removed in v4", }, - ] satisfies ReadonlyArray<{ - alias: string; - flags: Partial<DbQueryFlags>; - expected: object; - warning: string; - }>)("keeps $alias working and warns that it is deprecated", ({ flags, expected, warning }) => - Effect.gen(function* () { - const result = yield* observed(flags); - expect(result).toMatchObject(expected); - expect(result.warnings).toEqual([expect.stringContaining(warning)]); - }), - ); + }); }); describeHostedConsent("db query", { From 3d6f9b38c004828c1ee60a7c71cd8cd9b6e7bcb3 Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Tue, 20 Oct 2026 14:01:50 +0100 Subject: [PATCH 225/291] fix(cli): read --remote y and --remote n as yes and no The flag parser takes y and n as boolean words, but the remote vocabulary did not, so --remote y looked up a branch named y and --remote n looked up one named n. They now mean the linked project and the off switch, like yes and no, in the flag value, the argv rewrite, completion and the deprecated-flag warning for a branch with that name. --- apps/cli/docs/command-target.md | 4 ++-- apps/cli/src/cli/complete.remote.unit.test.ts | 5 ++++ .../target/remote-value.unit.test.ts | 8 +++++++ .../target/remote-vocabulary.ts | 6 ++--- .../target/target-deprecation.unit.test.ts | 2 +- .../target/target-flags.unit.test.ts | 20 ++++++++++++++++ .../src/shared/cli/remote-argv.unit.test.ts | 23 +++++++++++++++++++ ...t-selection-and-hosted-mutation-consent.md | 2 +- 8 files changed, 63 insertions(+), 7 deletions(-) diff --git a/apps/cli/docs/command-target.md b/apps/cli/docs/command-target.md index dede3ae080..b9724cbe6c 100644 --- a/apps/cli/docs/command-target.md +++ b/apps/cli/docs/command-target.md @@ -31,7 +31,7 @@ The consent default is the answer a confirmation starts on. It is `No` for anyth - **A branch** resolves with the lookup `supabase link` uses (`GET /v1/projects/{parent}/branches`, where `{parent}` is `SUPABASE_PROJECT_ID`, else the linked project), and the command acts on that branch's own project. With no linked project a UUID is looked up with `GET /v1/branches/{id}`, and one the API can't look up fails as ``Branch "<uuid>" not found. Run `supabase branches list` to see available branches.``, never as a schema error. - **Hosted-only commands** take `--remote=<ref|branch>` for another project, and a bare `--remote` changes nothing for them. `link` takes its target as an argument and has no `--remote`. `branches *` commands act on a parent project, so their `--remote` takes a ref, never a branch name. - **A database URL** applies only where the remote side is Postgres. `storage`, `seed buckets` and hosted commands need a project, so they reject `--remote=<url>` when the flags are parsed, as an invalid `--remote` value (`--remote takes a project ref or a branch name.`, or `--remote takes a project ref.` on `branches *`). The run exits 1 before anything else happens, and the URL is never echoed. A value with `://` that is not a `postgres://` or `postgresql://` URL, and a keyword connection string (`host=… user=…`), is rejected on every command and is never looked up as a branch. -- **`--remote=true|yes|on|1`** means a bare `--remote`. `--no-remote` and `--remote=false|no|off|0` fail with `--remote doesn't take false. Leave it out to <fallback>.`, and an empty `--remote=` with `--remote= has an empty value. Pass --remote for the linked project or --remote=<values>; leave it out to <fallback>.` `<fallback>` is `target the local database`, `target the local Storage API` or, on a hosted command, `use the linked project`; `<values>` is `<ref|branch|url>` where the command takes a database URL, `<ref|branch>` elsewhere and `<ref>` on `branches *`. Neither error prints the command's help. +- **`--remote=true|yes|on|1|y`** means a bare `--remote`. `--no-remote` and `--remote=false|no|off|0|n` fail with `--remote doesn't take false. Leave it out to <fallback>.`, and an empty `--remote=` with `--remote= has an empty value. Pass --remote for the linked project or --remote=<values>; leave it out to <fallback>.` `<fallback>` is `target the local database`, `target the local Storage API` or, on a hosted command, `use the linked project`; `<values>` is `<ref|branch|url>` where the command takes a database URL, `<ref|branch>` elsewhere and `<ref>` on `branches *`. Neither error prints the command's help. - **A local stack that is down in a linked checkout.** A database command that defaults to the local stack and can't reach it adds `To use the linked project instead, pass --remote.` to its error's suggestion when the checkout is linked (`SUPABASE_PROJECT_ID` is set, or `supabase/.temp/project-ref` names a project). `gen types` adds it to its errors for a local database container that is missing or can't be inspected. ## `--remote <word>` @@ -89,7 +89,7 @@ A command's own `SIDE_EFFECTS.md` records where else its password can come from | `--db-url=<url>` | `--db-url is deprecated and will be removed in v4. Use --remote=<db-url> instead`, then `, or --remote=<ref> with an access token set` when the URL names a Supabase project | | `--local` | `--local isn't needed (local is the default) and will be removed in v4` | -A warning never repeats an operand, a flag value or a database URL, because a CI log would keep them, and it carries no example command. Two values have their own wording: a branch whose name is a boolean literal (`true`, `false`, `yes`, `no`, `on`, `off`, `1`, `0`) is reached with `--remote=<its ref or UUID>`, and a keyword `--db-url` connection string is converted to a `postgresql://` URL for `--remote=<url>`. +A warning never repeats an operand, a flag value or a database URL, because a CI log would keep them, and it carries no example command. Two values have their own wording: a branch whose name is a boolean literal (`true`, `false`, `yes`, `no`, `on`, `off`, `y`, `n`, `1`, `0`) is reached with `--remote=<its ref or UUID>`, and a keyword `--db-url` connection string is converted to a `postgresql://` URL for `--remote=<url>`. ## Hosted-mutation consent diff --git a/apps/cli/src/cli/complete.remote.unit.test.ts b/apps/cli/src/cli/complete.remote.unit.test.ts index d24c287eab..288f2b35b0 100644 --- a/apps/cli/src/cli/complete.remote.unit.test.ts +++ b/apps/cli/src/cli/complete.remote.unit.test.ts @@ -74,6 +74,11 @@ describe("the token after a bare --remote", () => { expect(complete("db", "deploy", "--remote", "feat")).toEqual(noValueCandidates); }); + it("is the value when it is the linked boolean literal y, even with a free positional slot", () => { + expect(complete("db", "pull", "--remote", "y")).toEqual(noValueCandidates); + expect(complete("db", "query", "--remote", "y")).toEqual(noValueCandidates); + }); + it("is the positional while the command has a free positional slot", () => { expect(complete("db", "pull", "--remote", "")).toEqual(positionalCandidates); expect(complete("db", "query", "--remote", "sel")).toEqual(positionalCandidates); diff --git a/apps/cli/src/command-internal/target/remote-value.unit.test.ts b/apps/cli/src/command-internal/target/remote-value.unit.test.ts index eec23f90c0..ea5d0a26c2 100644 --- a/apps/cli/src/command-internal/target/remote-value.unit.test.ts +++ b/apps/cli/src/command-internal/target/remote-value.unit.test.ts @@ -34,6 +34,14 @@ describe("parseRemoteValue", () => { ])("rejects $raw as $expected", ({ raw, expected }) => { expect(parseRemoteValue(raw)).toEqual(Result.fail(expected)); }); + + it.each(["y", "Y"])("reads %s as the linked project, not a branch", (raw) => { + expect(parseRemoteValue(raw)).toEqual(Result.succeed({ _tag: "Linked" })); + }); + + it.each(["n", "N"])("rejects %s as an off switch, not a branch", (raw) => { + expect(parseRemoteValue(raw)).toEqual(Result.fail({ _tag: "Off" })); + }); }); describe("parseNamedRemote", () => { diff --git a/apps/cli/src/command-internal/target/remote-vocabulary.ts b/apps/cli/src/command-internal/target/remote-vocabulary.ts index bf2c3d9bae..14e6697db9 100644 --- a/apps/cli/src/command-internal/target/remote-vocabulary.ts +++ b/apps/cli/src/command-internal/target/remote-vocabulary.ts @@ -2,10 +2,10 @@ export const REMOTE_FLAG_NAME = "remote"; export const DB_URL_PATTERN = /^postgres(ql)?:\/\//i; -/** The spellings of a bare `--remote`, in any case. */ -export const LINKED_LITERAL = /^(true|yes|on|1)$/i; +/** The spellings of a bare `--remote`, in any case: the words the flag parser reads as a boolean. */ +export const LINKED_LITERAL = /^(true|yes|on|1|y)$/i; -export const OFF_LITERAL = /^(false|no|off|0)$/i; +export const OFF_LITERAL = /^(false|no|off|0|n)$/i; export const isBooleanLiteral = (word: string): boolean => LINKED_LITERAL.test(word) || OFF_LITERAL.test(word); diff --git a/apps/cli/src/command-internal/target/target-deprecation.unit.test.ts b/apps/cli/src/command-internal/target/target-deprecation.unit.test.ts index 6c291ceb92..598a4a0efb 100644 --- a/apps/cli/src/command-internal/target/target-deprecation.unit.test.ts +++ b/apps/cli/src/command-internal/target/target-deprecation.unit.test.ts @@ -108,7 +108,7 @@ describe("deprecationMessages", () => { expect(message).not.toContain("hunter2"); }); - it.each(["true", "No", "OFF", "1"])( + it.each(["true", "No", "OFF", "1", "y", "N"])( "says a branch named %s is reached by its ref or UUID", (name) => { expect( 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 d2c73c6eb7..3395292b4f 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 @@ -97,6 +97,16 @@ describe("targetFlags", () => { }), ); + it.effect.each([{ args: ["--remote", "y"] }, { args: ["--remote=y"] }, { args: ["--remote=Y"] }])( + "reads $args as the linked project, not a branch named y", + ({ args }) => + Effect.gen(function* () { + expect(yield* parseWith(withDbUrl, args)).toMatchObject({ + remote: Option.some({ _tag: "Linked" }), + }); + }), + ); + it.effect("keeps the deprecated aliases parseable", () => Effect.gen(function* () { expect(yield* parseWith(withDbUrl, ["--local"])).toMatchObject({ local: true }); @@ -124,6 +134,16 @@ describe("targetFlags", () => { }), ); + it.effect.each([{ args: ["--remote", "n"] }, { args: ["--remote=n"] }])( + "rejects $args as the off switch, not a branch named n", + ({ args }) => + Effect.gen(function* () { + expect(yield* rejectedMessage(withDbUrl, args)).toBe( + "--remote doesn't take false. Leave it out to target the local database.", + ); + }), + ); + it.effect("rejects an empty value and offers every form the command takes", () => Effect.gen(function* () { expect(yield* rejectedMessage(withDbUrl, ["--remote="])).toBe( diff --git a/apps/cli/src/shared/cli/remote-argv.unit.test.ts b/apps/cli/src/shared/cli/remote-argv.unit.test.ts index 0003f663a6..a707da60c7 100644 --- a/apps/cli/src/shared/cli/remote-argv.unit.test.ts +++ b/apps/cli/src/shared/cli/remote-argv.unit.test.ts @@ -81,6 +81,12 @@ describe("joinRemoteOperands", () => { args: ["db", "push", "--remote", "false"], expected: ["db", "push", "--remote", "false"], }, + { + name: "joins y as a linked boolean literal even where the command takes an operand", + leaf: oneOperand, + args: ["storage", "ls", "--remote", "y"], + expected: ["storage", "ls", "--remote=y"], + }, { name: "keeps a branch-shaped word as the operand when the command takes one", leaf: oneOperand, @@ -277,6 +283,18 @@ describe("hasConflictingRemotes", () => { args: ["functions", "deploy", "--remote=YES", "--remote", "--remote=on"], expected: false, }, + { + name: "reads y as the bare --remote", + leaf: variadic, + args: ["functions", "deploy", "--remote=y", "--remote"], + expected: false, + }, + { + name: "ignores n, an off switch its parser reports", + leaf: noOperands, + args: ["db", "push", "--remote=n", "--remote=feature-x"], + expected: false, + }, { name: "reads a space-form word joined to --remote against a bare --remote", leaf: noOperands, @@ -326,6 +344,11 @@ describe("prepareCliArgs argv", () => { args: ["db", "push", "--remote", "feature-x"], expected: ["db", "push", "--remote=feature-x"], }, + { + name: "joins y after --remote for db push as the linked project", + args: ["db", "push", "--remote", "y"], + expected: ["db", "push", "--remote=y"], + }, { name: "joins a branch after global flags and their values", args: ["--profile", "staging", "db", "push", "--remote", "feature-x", "--dry-run"], 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 78d8d51685..c185bcc6ff 100644 --- a/docs/adr/0033-target-selection-and-hosted-mutation-consent.md +++ b/docs/adr/0033-target-selection-and-hosted-mutation-consent.md @@ -124,7 +124,7 @@ onto `--remote`: - `db diff` with `--remote` or an alias together with `--from` or `--to` is an error. In `--from` and `--to`, `remote` always means the linked project. - `--no-remote` and `--remote=false` fail and tell the user to leave `--remote` out. - `--remote yes`, `--remote 1`, `--remote true` and `--remote on` mean bare `--remote`. An empty + `--remote yes`, `--remote y`, `--remote 1`, `--remote true` and `--remote on` mean bare `--remote`. An empty `--remote=` value is rejected, and the message names the values that command accepts. Neither error prints the command's help. - A conflict error lists only the selector flags the command has and suggests the `--remote` form. From 444aed6ec27899b2e1f08a33d9483e07039c8264 Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Tue, 20 Oct 2026 14:06:05 +0100 Subject: [PATCH 226/291] test(cli): drop duplicate db query rows --- .../db/query/query.integration.test.ts | 138 ++++++------------ 1 file changed, 45 insertions(+), 93 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 7f7d124739..e2c8347958 100644 --- a/apps/cli/src/commands/db/query/query.integration.test.ts +++ b/apps/cli/src/commands/db/query/query.integration.test.ts @@ -549,27 +549,25 @@ describe("db query integration", () => { }).pipe(Effect.provide(layer)); }); - 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") })); - const parsed = yield* decodeJson(out.stdoutText); - expect(parsed).toHaveProperty("boundary", BOUNDARY); - expect(parsed).toHaveProperty("rows", [ - { id: 1, name: "alice" }, - { id: 2, name: "bob" }, - ]); - expect(out.stdoutText).toContain(`\\u003c${BOUNDARY}\\u003e`); - }).pipe(Effect.provide(layer)); - }); - - 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") })); - expect(yield* decodeJson(out.stdoutText)).toHaveProperty("boundary", BOUNDARY); - }).pipe(Effect.provide(layer)); - }); + const agentSources = [ + ["--agent yes", { agent: "yes" }], + ["an agent detected from AiTool", { agent: "auto", aiTool: "cursor" }], + ] as const; + for (const [name, opts] of agentSources) { + it.effect(`renders JSON with the untrusted-data envelope by default for ${name}`, () => { + const { layer, out } = setup({ result: SELECT_RESULT, ...opts }); + return Effect.gen(function* () { + yield* dbQuery(flags({ sql: Option.some("select 1") })); + const parsed = yield* decodeJson(out.stdoutText); + expect(parsed).toHaveProperty("boundary", BOUNDARY); + expect(parsed).toHaveProperty("rows", [ + { id: 1, name: "alice" }, + { id: 2, name: "bob" }, + ]); + expect(out.stdoutText).toContain(`\\u003c${BOUNDARY}\\u003e`); + }).pipe(Effect.provide(layer)); + }); + } it.effect("renders plain JSON (no envelope) for a human with -o json", () => { const { layer, out } = setup({ result: SELECT_RESULT, agent: "no", outputFlag: "json" }); @@ -677,14 +675,6 @@ 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") })); - expect(out.stdoutText).toBe("id,name\n1,alice\n2,bob\n"); - }).pipe(Effect.provide(layer)); - }); - 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* () { @@ -767,30 +757,6 @@ describe("db query integration", () => { }).pipe(Effect.provide(layer)); }); - it.effect("queries the linked project over HTTP and writes the linked-project cache", () => { - const { layer, out, cache, platformApi } = setup({ - linkedStatus: 201, - linkedBody: '[{"name":"alice","id":1}]', - }); - return Effect.gen(function* () { - yield* dbQuery(flags({ sql: Option.some("select 1"), remote: REMOTE_LINKED })); - expect(out.stdoutText).toContain("│ name │ id │"); - expect(platformApi.requests).toHaveLength(1); - expect(platformApi.requests[0]).toMatchObject({ - method: "POST", - url: `https://api.supabase.com/v1/projects/${REF}/database/query`, - body: { query: "select 1" }, - }); - expect(cache.cached).toBe(true); - assertPermissionDrift({ - command: "db query", - activeFlags: { remote: true }, - requests: platformApi.requests, - exact: true, - }); - }).pipe(Effect.provide(layer)); - }); - it.effect("fills the linked-project cache through the real cache layer", () => { const { layer, platformApi } = setup({ linkedStatus: 201, @@ -912,16 +878,6 @@ describe("db query integration", () => { }).pipe(Effect.provide(layer)); }); - 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({ remote: REMOTE_LINKED })).pipe(Effect.exit); - expect(Exit.isFailure(exit)).toBe(true); - expect(failMessage(exit)).toContain("no SQL query provided"); - expect(cache.cached).toBe(true); - }).pipe(Effect.provide(layer)); - }); - it.effect( "errors when the linked API returns a non-201 but still caches the linked project", () => { @@ -949,29 +905,19 @@ describe("db query integration", () => { }).pipe(Effect.provide(layer)); }); - 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"), remote: REMOTE_LINKED })); - expect(out.stdoutText).toBe(""); - }).pipe(Effect.provide(layer)); - }); - - 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 ..."), remote: REMOTE_LINKED })); - expect(out.stdoutText).toBe('{"command":"INSERT"}\n'); - }).pipe(Effect.provide(layer)); - }); - - 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 ..."), remote: REMOTE_LINKED })); - expect(out.stdoutText).toBe("CREATE TABLE\n"); - }).pipe(Effect.provide(layer)); - }); + const rawBodies = [ + ["valid JSON that is not an array", '{"command":"INSERT"}'], + ["not valid JSON", "CREATE TABLE"], + ] as const; + for (const [name, body] of rawBodies) { + it.effect(`prints the raw body when the linked response is ${name}`, () => { + const { layer, out } = setup({ linkedStatus: 201, linkedBody: body }); + return Effect.gen(function* () { + yield* dbQuery(flags({ sql: Option.some("select 1"), remote: REMOTE_LINKED })); + expect(out.stdoutText).toBe(`${body}\n`); + }).pipe(Effect.provide(layer)); + }); + } it.effect("renders linked agent JSON with the envelope (no advisory on the linked path)", () => { const { layer, out } = setup({ @@ -988,13 +934,19 @@ describe("db query integration", () => { }).pipe(Effect.provide(layer)); }); - 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"), remote: REMOTE_LINKED })); - expect(out.stdoutText).toBe(""); - }).pipe(Effect.provide(layer)); - }); + const columnlessBodies = [ + ["an empty array", "[]"], + ["a null first row", "[null]"], + ] as const; + for (const [name, body] of columnlessBodies) { + it.effect(`prints nothing for a linked result with ${name}`, () => { + const { layer, out } = setup({ linkedStatus: 201, linkedBody: body }); + return Effect.gen(function* () { + yield* dbQuery(flags({ sql: Option.some("select 1"), remote: REMOTE_LINKED })); + expect(out.stdoutText).toBe(""); + }).pipe(Effect.provide(layer)); + }); + } it.effect("renders NULL for a null row object in a linked result", () => { const { layer, out } = setup({ linkedStatus: 201, linkedBody: '[{"a":1},null]' }); From 86fdad75cc5bee88408fbe8b0cc27187217d8f0d Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Tue, 20 Oct 2026 14:06:48 +0100 Subject: [PATCH 227/291] fix(cli): fail a ref-shaped word after a bare --remote on branches commands A branches command read supabase branches get --remote <ref> as a branch operand, while --remote=<ref> there names the parent project. A project-ref-shaped word after a bare --remote now fails with both readings, as it does on other commands. A branch-name word still runs as the operand, since --remote=<branch> is already an error on these commands. --- apps/cli/docs/command-target.md | 1 + .../target/remote-resolution.ts | 21 +++- .../target/resolve-project-target.ts | 5 +- ...ranches.remote-operand.integration.test.ts | 119 ++++++++++++++++++ ...t-selection-and-hosted-mutation-consent.md | 9 +- 5 files changed, 145 insertions(+), 10 deletions(-) create mode 100644 apps/cli/src/commands/branches/branches.remote-operand.integration.test.ts diff --git a/apps/cli/docs/command-target.md b/apps/cli/docs/command-target.md index b9724cbe6c..757ebde448 100644 --- a/apps/cli/docs/command-target.md +++ b/apps/cli/docs/command-target.md @@ -43,6 +43,7 @@ The one ambiguous shape is a bare `--remote` directly followed by a word the com - **A project-ref-shaped word** (20 lowercase letters) fails when the command still has room for operands, with no network call: pass `--remote=<ref>` for that project, or put `--remote` last to keep the word as an operand. It is read as `--remote=<ref>` only when the command takes no operands or its operands are already full. - **Any other word** is checked against the branches of the linked project, when one is known. The run fails when the word names a branch, equals a branch name ignoring case, or is one edit from the name of a non-default branch of five or more characters; swapping two adjacent letters is one edit. The error offers `--remote=<branch>` for the branch, or the command's operands followed by `--remote` for the linked project. Otherwise the word runs as the operand. `--remote=<unknown>` is separate: its "Did you mean" suggestion allows up to two edits. - **A listing that fails** still fails the run before anything runs, and the error gives the cause: `HTTP <status>`, the network error, or a missing or invalid access token. It never echoes the word, and its linked-project example reads `supabase <path> <operands> --remote`. The suggestion follows the cause: a `401` says to log in again, or to fix `SUPABASE_ACCESS_TOKEN` when the token came from the environment; a `403` says to use a token that can read the project's branches; a `404` says the linked ref may be a preview branch, so link its parent project. +- **`branches *` commands** apply only the project-ref-shaped check: their `--remote` never names a branch, so any other word is the operand and nothing more is checked. ## Selector errors diff --git a/apps/cli/src/command-internal/target/remote-resolution.ts b/apps/cli/src/command-internal/target/remote-resolution.ts index 66b4e14291..9c1c4e1dec 100644 --- a/apps/cli/src/command-internal/target/remote-resolution.ts +++ b/apps/cli/src/command-internal/target/remote-resolution.ts @@ -210,16 +210,27 @@ const rejectRefShapedOperand = Effect.fnUntraced(function* (operand: string) { }); }); +const bareRemoteWord = Effect.fnUntraced(function* () { + const cliArgs = yield* Effect.serviceOption(CliArgs); + return Option.getOrUndefined(cliArgs)?.bareRemoteOperand; +}); + +/** Fails a bare `--remote` whose next word is a project ref, before the command prompts, connects or writes. */ +export const rejectRefShapedRemote = Effect.fnUntraced(function* () { + const operand = yield* bareRemoteWord(); + if (operand !== undefined && BRANCH_PROJECT_REF_PATTERN.test(operand)) { + return yield* rejectRefShapedOperand(operand); + } +}); + /** * Fails a bare `--remote` whose next word is a project ref, or is, or may be, a branch of the * linked project, before the command prompts, connects or writes. */ const rejectAmbiguousRemote = Effect.fnUntraced(function* () { - const cliArgs = yield* Effect.serviceOption(CliArgs); - const operand = Option.getOrUndefined(cliArgs)?.bareRemoteOperand; - if (operand === undefined) return; - if (BRANCH_PROJECT_REF_PATTERN.test(operand)) return yield* rejectRefShapedOperand(operand); - yield* checkRemoteOperand(operand); + yield* rejectRefShapedRemote(); + const operand = yield* bareRemoteWord(); + if (operand !== undefined) yield* checkRemoteOperand(operand); }); /** The linked target for a ref the ref chain has already resolved and announced. */ diff --git a/apps/cli/src/command-internal/target/resolve-project-target.ts b/apps/cli/src/command-internal/target/resolve-project-target.ts index b22c3eb291..f8f893cd1d 100644 --- a/apps/cli/src/command-internal/target/resolve-project-target.ts +++ b/apps/cli/src/command-internal/target/resolve-project-target.ts @@ -1,7 +1,7 @@ import { Effect, Option } from "effect"; import { resolveParentScopedProjectRef } from "./parent-scoped-project-ref.ts"; -import { resolveHostedRemote, selectTarget } from "./remote-resolution.ts"; +import { rejectRefShapedRemote, resolveHostedRemote, selectTarget } from "./remote-resolution.ts"; import type { ProjectTargetFlagValues } from "./target-flags.ts"; import { TargetNeedsProjectError } from "./target.errors.ts"; @@ -63,6 +63,9 @@ const resolveParent = Effect.fn("Target.resolveProject")(function* ( return yield* resolveParentScopedProjectRef(Option.some(selection.ref)); case "Linked": case "Default": + // A branch-name word has one reading here, since `--remote=<branch>` is an error; + // only a ref-shaped word reads both ways. + yield* rejectRefShapedRemote(); return yield* resolveParentScopedProjectRef(Option.none()); case "Local": case "DbUrl": diff --git a/apps/cli/src/commands/branches/branches.remote-operand.integration.test.ts b/apps/cli/src/commands/branches/branches.remote-operand.integration.test.ts new file mode 100644 index 0000000000..c18a78202d --- /dev/null +++ b/apps/cli/src/commands/branches/branches.remote-operand.integration.test.ts @@ -0,0 +1,119 @@ +import { describe, expect, it } from "@effect/vitest"; +import { Effect, Layer, Option } from "effect"; + +import { + VALID_REF, + buildTestRuntime, + jsonResponse, + mockCommandPlatformApi, + mockCommandSettings, + useTempWorkdir, +} from "../../../tests/helpers/command-mocks.ts"; +import { mockOutput } from "../../../tests/helpers/mocks.ts"; +import { rootCommandForFeatures } from "../../cli/root.ts"; +import { CliArgs } from "../../shared/cli/cli-args.service.ts"; +import { prepareCliArgs } from "../../shared/cli/run.ts"; +import { branchesGet } from "./get/get.handler.ts"; + +const API = "https://api.supabase.com"; +const OTHER_REF = "qrstuvwxyzabcdefghij"; +const BRANCH_UUID = "11111111-1111-4111-8111-111111111111"; + +const DETAIL = { + ref: OTHER_REF, + postgres_version: "15", + postgres_engine: "15", + release_channel: "ga", + status: "ACTIVE_HEALTHY", + db_host: `db.${OTHER_REF}.supabase.co`, + db_port: 5432, + db_user: "postgres", + db_pass: "secret", + jwt_secret: "jwt-secret", +}; + +const listedBranch = { + id: BRANCH_UUID, + name: "feat-x", + project_ref: BRANCH_UUID, + parent_project_ref: VALID_REF, + is_default: false, + persistent: false, + status: "MIGRATIONS_PASSED", + created_at: "2024-01-01T00:00:00Z", + updated_at: "2024-01-01T00:00:00Z", + with_data: false, +}; + +const root = rootCommandForFeatures(); +const tempRoot = useTempWorkdir("supabase-branches-remote-operand-int-"); + +function setup() { + const api = mockCommandPlatformApi({ + handler: (request) => { + const { pathname } = new URL(request.url); + if (pathname === `/v1/projects/${VALID_REF}/branches`) { + return Effect.succeed(jsonResponse(request, 200, [listedBranch])); + } + if (pathname === `/v1/projects/${VALID_REF}/branches/feat-x`) { + return Effect.succeed(jsonResponse(request, 200, listedBranch)); + } + if (pathname.startsWith("/v1/branches/")) { + return Effect.succeed(jsonResponse(request, 200, DETAIL)); + } + return Effect.succeed(jsonResponse(request, 404, { message: "unrouted" })); + }, + }); + const layer = buildTestRuntime({ + out: mockOutput(), + api, + cliSettings: mockCommandSettings({ workdir: tempRoot.current }), + commandPath: ["branches", "get"], + }); + return { api, layer }; +} + +const getBranch = (argv: ReadonlyArray<string>, name: string) => + branchesGet({ + name: Option.some(name), + remote: Option.some({ _tag: "Linked" }), + projectRef: Option.none(), + }).pipe(Effect.provide(Layer.succeed(CliArgs, prepareCliArgs(root, argv)))); + +const requested = (api: { readonly requests: ReadonlyArray<{ method: string; url: string }> }) => + api.requests.map(({ method, url }) => `${method} ${url}`); + +describe("a bare --remote followed by a word on a parent-scoped command", () => { + it.effect("fails a project-ref-shaped word with both readings, before any request", () => { + const { api, layer } = setup(); + return Effect.gen(function* () { + const error = yield* getBranch(["branches", "get", "--remote", OTHER_REF], OTHER_REF).pipe( + Effect.flip, + ); + expect(error).toMatchObject({ + _tag: "TargetRemoteAmbiguousError", + message: `"${OTHER_REF}" after --remote looks like a project ref. For that project: supabase branches get --remote=${OTHER_REF}. To keep it as an operand, put --remote last: supabase branches get <operands> --remote`, + }); + expect(api.requests).toEqual([]); + }).pipe(Effect.provide(layer)); + }); + + it.effect("keeps a project-ref-shaped operand when --remote comes last", () => { + const { api, layer } = setup(); + return Effect.gen(function* () { + yield* getBranch(["branches", "get", OTHER_REF, "--remote"], OTHER_REF); + expect(requested(api)).toEqual([`GET ${API}/v1/branches/${OTHER_REF}`]); + }).pipe(Effect.provide(layer)); + }); + + it.effect("reads a branch-name word as the operand without listing branches to check it", () => { + const { api, layer } = setup(); + return Effect.gen(function* () { + yield* getBranch(["branches", "get", "--remote", "feat-x"], "feat-x"); + expect(requested(api)).toEqual([ + `GET ${API}/v1/projects/${VALID_REF}/branches/feat-x`, + `GET ${API}/v1/branches/${BRANCH_UUID}`, + ]); + }).pipe(Effect.provide(layer)); + }); +}); 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 c185bcc6ff..e3c8adc515 100644 --- a/docs/adr/0033-target-selection-and-hosted-mutation-consent.md +++ b/docs/adr/0033-target-selection-and-hosted-mutation-consent.md @@ -90,10 +90,11 @@ than positionals. The one ambiguous shape, a bare `--remote` directly followed b command took as a positional, is settled before anything runs. A project-ref-shaped token fails with no request, because a 20-letter operand such as a migration or function name must not silently retarget the command at another project: the error offers `--remote=<ref>` for that project and -`--remote` last to keep the operand. Any other token is checked against the linked project's -branches. It fails when it names a branch, equals one ignoring case, or is one edit (an adjacent swap -counts as one) from a non-default branch name of five or more characters, so an ordinary word is not -mistaken for a typo; otherwise it runs as the operand. When the branches can't be listed the run +`--remote` last to keep the operand. A `branches *` command, whose `--remote` never names a branch, +applies only this check. Any other token is checked against the linked project's branches. It fails +when it names a branch, equals one ignoring case, or is one edit (an adjacent swap counts as one) +from a non-default branch name of five or more characters, so an ordinary word is not mistaken for +a typo; otherwise it runs as the operand. When the branches can't be listed the run still fails closed, and the error names the cause (an HTTP status or the network error, with the fix for a 401, 403 or 404). Docs, help and examples put `--remote` last, in the `=` form when it has a value, so it cannot be read as anything else, and shell completion never offers a space-separated From 5d2d3fa8a9421698c18f9c3dd393b80df9d9d0fb Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Tue, 20 Oct 2026 14:11:57 +0100 Subject: [PATCH 228/291] test(cli): drop gen types selector-conflict rows the selector unit tests own --- .../gen/types/types.integration.test.ts | 136 ------------------ 1 file changed, 136 deletions(-) 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 a8c7341be8..569863f3d2 100644 --- a/apps/cli/src/commands/gen/types/types.integration.test.ts +++ b/apps/cli/src/commands/gen/types/types.integration.test.ts @@ -1063,74 +1063,6 @@ describe("gen types", () => { }).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("generates for the project of the branch --remote=<branch> names", () => Effect.gen(function* () { const { layer, api, linkedProjectCache } = yield* setup({ @@ -1341,52 +1273,6 @@ describe("gen types", () => { ); describe("Flag mutex groups and argv-scan precedence", () => { - it.effect("rejects combining --remote and --local", () => - Effect.gen(function* () { - const { layer, telemetry, api, generator } = yield* setup({ - args: ["gen", "types", "--remote", "--local"], - }); - const error = yield* genTypes( - defaultFlags({ remote: Option.some({ _tag: "Linked" } as const), local: true }), - ).pipe(Effect.provide(layer), Effect.flip); - - expect(error).toMatchObject({ - _tag: "TargetSelectorConflictError", - message: - "Only one of --remote, --linked, --db-url and --local can be set; got --remote and --local.", - }); - expect(api.requests).toEqual([]); - expect(generator.calls).toHaveLength(0); - expect(telemetry.flushed).toBe(true); - }).pipe(Effect.provide(BunServices.layer)), - ); - - it.effect("rejects combining --local and --linked", () => - Effect.gen(function* () { - const { layer, telemetry } = yield* setup({ - args: ["gen", "types", "--local", "--linked"], - }); - const exit = yield* genTypes(defaultFlags({ local: true, linked: true })).pipe( - Effect.provide(layer), - Effect.exit, - ); - - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - expect(String(exit.cause)).toContain( - "Only one of --remote, --linked, --db-url and --local can be set; got --linked and --local.", - ); - expect( - Option.exists( - Cause.findErrorOption(exit.cause), - Predicate.isTagged("TargetSelectorConflictError"), - ), - ).toBe(true); - } - expect(telemetry.flushed).toBe(true); - }).pipe(Effect.provide(BunServices.layer)), - ); - it.effect("does not misdetect a mutex flag consumed as -s's value", () => Effect.gen(function* () { const { layer, api } = yield* setup({ @@ -1553,28 +1439,6 @@ describe("gen types", () => { }).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({ From 2c9333cacc0d176a36f3aadca391eaf91dbfe479 Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Tue, 20 Oct 2026 14:12:21 +0100 Subject: [PATCH 229/291] test(cli): cover target selection for gen types with the shared harness --- .../gen/types/types.integration.test.ts | 192 ++++-------------- 1 file changed, 42 insertions(+), 150 deletions(-) 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 569863f3d2..73eeb40c49 100644 --- a/apps/cli/src/commands/gen/types/types.integration.test.ts +++ b/apps/cli/src/commands/gen/types/types.integration.test.ts @@ -62,8 +62,13 @@ import { mockCommandPlatformApiService, mockTelemetryStateTracked, unverifiedTargetBanner, + useTempWorkdir, withEnvVar, } from "../../../../tests/helpers/command-mocks.ts"; +import { + branchLookupsOf, + describeDbTargetSelection, +} from "../../../../tests/helpers/db-target-selection.ts"; import { cliConfigValuesTestLayer } from "../../../../tests/helpers/config-values-layer.ts"; import type { PgConnInput } from "../../../command-internal/db-connection.service.ts"; import type { DbConnectError } from "../../../command-internal/db-connection.errors.ts"; @@ -836,16 +841,6 @@ describe("gen types", () => { }).pipe(Effect.provide(BunServices.layer)), ); - 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-"); @@ -994,23 +989,7 @@ describe("gen types", () => { }); describe("target selection", () => { - it.effect("targets the linked project for a bare --remote", () => - Effect.gen(function* () { - const { layer, api, out } = yield* setup({ - projectId: Option.some(VALID_REF), - projectTypes: "ok", - }); - yield* genTypes(defaultFlags({ remote: Option.some({ _tag: "Linked" } as const) })).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)), - ); + const tempRoot = useTempWorkdir("supabase-gen-types-target-"); it.effect("treats --project-id as a deprecated alias of --remote=<ref>", () => Effect.gen(function* () { @@ -1063,28 +1042,6 @@ describe("gen types", () => { }).pipe(Effect.provide(BunServices.layer)), ); - it.effect("generates for the project of the branch --remote=<branch> names", () => - Effect.gen(function* () { - const { layer, api, linkedProjectCache } = yield* setup({ - projectId: Option.some(VALID_REF), - branches: [branchRow("feature-x")], - projectTypes: "ok", - }); - yield* genTypes( - defaultFlags({ remote: Option.some({ _tag: "Branch", name: "feature-x" } as const) }), - ).pipe(Effect.provide(layer)); - - expect(api.requests).toEqual([ - { method: "listAllBranches", input: { ref: VALID_REF } }, - { - method: "generateTypescriptTypes", - input: { ref: BRANCH_PROJECT_REF, included_schemas: "public" }, - }, - ]); - expect(linkedProjectCache.cachedRef).toBe(BRANCH_PROJECT_REF); - }).pipe(Effect.provide(BunServices.layer)), - ); - it.effect("reads a bare --remote followed by a project ref as --remote=<ref>", () => Effect.gen(function* () { const { layer, api } = yield* setup({ @@ -1126,107 +1083,42 @@ describe("gen types", () => { }).pipe(Effect.provide(BunServices.layer)), ); - it.effect("fails with the branches that exist when --remote=<branch> matches none", () => - Effect.gen(function* () { - const { layer, api, generator } = yield* setup({ - projectId: Option.some(VALID_REF), - branches: [branchRow("feature-x")], - }); - const error = yield* genTypes( - defaultFlags({ remote: Option.some({ _tag: "Branch", name: "feature-y" } as const) }), - ).pipe(Effect.provide(layer), Effect.flip); - - expect(error).toMatchObject({ - _tag: "LinkBranchNotFoundError", - message: expect.stringContaining("Available branches: feature-x"), - }); - expect(api.requests.map(({ method }) => method)).toEqual(["listAllBranches"]); - expect(generator.calls).toHaveLength(0); - }).pipe(Effect.provide(BunServices.layer)), - ); - - it.effect("reads the database of --remote=<url> without a Management API call", () => - Effect.gen(function* () { - const dbUrl = "postgresql://postgres:postgres@127.0.0.1:5432/postgres"; - const { layer, api, dbConfig, out } = yield* setup({ - args: ["gen", "types", `--remote=${dbUrl}`], - }); - yield* genTypes( - defaultFlags({ remote: Option.some({ _tag: "DbUrl", url: dbUrl } as const) }), - ).pipe(Effect.provide(layer)); - - expect(dbConfig.resolves.map(({ target }) => target.kind)).toEqual(["db-url"]); - expect(api.requests).toEqual([]); - expect(warningsOf(out)).toEqual([]); - }).pipe(Effect.provide(BunServices.layer)), - ); - - describe("deprecated selectors", () => { - const dbUrl = "postgresql://postgres:postgres@127.0.0.1:5432/postgres"; - const FLAG_REF = "flagflagflagflagflag"; - type Context = Effect.Success<ReturnType<typeof setup>>; - const cases: ReadonlyArray<{ - readonly alias: string; - readonly args: ReadonlyArray<string>; - readonly flags: Partial<GenTypesFlags>; - readonly warning: string; - readonly reached: (context: Context) => void; - }> = [ - { - alias: "--linked", - args: ["gen", "types", "--linked"], - flags: { linked: true }, - warning: "--linked is deprecated and will be removed in v4. Use --remote instead", - reached: ({ api }) => - expect(api.requests).toContainEqual({ - method: "generateTypescriptTypes", - input: { ref: VALID_REF, included_schemas: "public" }, - }), - }, - { - alias: "--project-ref", - args: ["gen", "types", "--project-ref", FLAG_REF], - flags: { projectRef: Option.some(FLAG_REF) }, - warning: `--project-ref is deprecated and will be removed in v4. Use --remote=<ref> instead`, - reached: ({ api }) => - expect(api.requests).toContainEqual({ - method: "generateTypescriptTypes", - input: { ref: FLAG_REF, included_schemas: "public" }, - }), - }, - { - alias: "--db-url", - args: ["gen", "types", "--db-url", dbUrl], - flags: { dbUrl: Option.some(dbUrl) }, - warning: - "--db-url is deprecated and will be removed in v4. Use --remote=<db-url> instead", - reached: ({ dbConfig }) => - expect(dbConfig.resolves.map(({ target }) => target.kind)).toEqual(["db-url"]), - }, - { - alias: "--local", - args: ["gen", "types", "--local"], - flags: { local: true }, - warning: "--local isn't needed (local is the default) and will be removed in v4", - reached: ({ generator }) => expect(generator.calls[0]?.isLocal).toBe(true), - }, - ]; - - for (const testCase of cases) { - it.effect(`${testCase.alias} still selects its target and warns`, () => - Effect.gen(function* () { - const context = yield* setup({ - args: testCase.args, - projectId: Option.some(VALID_REF), - projectTypes: "ok", - }); - yield* genTypes(defaultFlags(testCase.flags)).pipe(Effect.provide(context.layer)); - - testCase.reached(context); - expect(warningsOf(context.out)).toEqual([testCase.warning]); - }).pipe(Effect.provide(BunServices.layer)), - ); - } + describeDbTargetSelection("gen types", { + dbUrl: true, + workdir: () => tempRoot.current, + activeFlags: { "postgrest-v9-compat": false }, + attempt: ({ workdir, args, target }) => + Effect.scoped( + setup({ + workdir, + args, + projectId: Option.some(VALID_REF), + branches: [branchRow("feature-x")], + projectTypes: "ok", + }), + ).pipe( + Effect.map((ctx) => ({ + layer: ctx.layer, + out: ctx.out, + run: genTypes(defaultFlags({ ...target })), + target: () => { + const generated = ctx.api.requests.find( + ({ method }) => method === "generateTypescriptTypes", + )?.input; + if (Predicate.hasProperty(generated, "ref") && Predicate.isString(generated.ref)) { + return { kind: "linked" as const, ref: generated.ref }; + } + if (ctx.dbConfig.resolves[0]?.target.kind === "db-url") { + return { kind: "db-url" as const }; + } + return ctx.generator.calls[0]?.isLocal === true + ? { kind: "local" as const } + : undefined; + }, + cachedRef: () => ctx.linkedProjectCache.cachedRef, + branchLookups: () => branchLookupsOf(ctx.api.requests), + })), + ), }); }); From 0a06289472fe118baa31ee03bde676c38c5ecf6c Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Tue, 20 Oct 2026 14:17:08 +0100 Subject: [PATCH 230/291] test(cli): cover target selection for db pull with the shared harness --- .../commands/db/pull/pull.integration.test.ts | 204 ++---------------- 1 file changed, 17 insertions(+), 187 deletions(-) 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 c9cce13190..0b245bbd42 100644 --- a/apps/cli/src/commands/db/pull/pull.integration.test.ts +++ b/apps/cli/src/commands/db/pull/pull.integration.test.ts @@ -28,11 +28,8 @@ vi.mock("node:net", (importOriginal) => const reachableDirectHost = withReachableDirectHost(fixtureNet); import { stripAnsi } from "../../../../tests/helpers/ansi.ts"; -import { - BRANCH_PROJECT_REF, - branchListApi, - warningsOf, -} from "../../../../tests/helpers/remote-selection.ts"; +import { describeDbTargetSelection } from "../../../../tests/helpers/db-target-selection.ts"; +import { branchListApi } from "../../../../tests/helpers/remote-selection.ts"; import { describeHostedConsent } from "../../../../tests/helpers/hosted-consent.ts"; import { withReachableDirectHost } from "../../../../tests/helpers/refuse-invalid-db-hosts.ts"; import { @@ -83,7 +80,6 @@ import { 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, @@ -670,65 +666,6 @@ describe("db pull", () => { }).pipe(Effect.provide(s.layer)); }); - it.effect("pulls from the project given via --remote=<ref> without a linked workdir", () => { - // `linkedFails: true` simulates an unlinked workdir; only the flag can resolve a ref. - const FLAG_REF = "flagflagflagflagflag"; - const s = setup(tmp.current, { - migrations: ["20240101000000"], - remoteVersions: ["20240101000000"], - edgeStdout: pgDeltaDiffEnvelope([{ name: "schema_changes", sql: "create table remote ();" }]), - yes: true, - projectId: Option.none(), - linkedFails: true, - }); - return Effect.gen(function* () { - yield* dbPull( - flags({ - diffEngine: Option.some("pg-delta"), - remote: Option.some({ _tag: "Ref", ref: FLAG_REF } as const), - }), - ); - expect(s.cache.cached).toBe(true); - expect(s.cache.cachedRef).toBe(FLAG_REF); - }).pipe(Effect.provide(s.layer)); - }); - - it.effect("--remote=<ref> overrides an already-linked workdir's project ref", () => { - const FLAG_REF = "flagflagflagflagflag"; - const s = setup(tmp.current, { - migrations: ["20240101000000"], - remoteVersions: ["20240101000000"], - edgeStdout: pgDeltaDiffEnvelope([{ name: "schema_changes", sql: "create table remote ();" }]), - yes: true, - // A distinct fixed ref proves the flag, not the workdir's own ref, wins. - resolvedRef: "abcdefghijklmnopqrst", - }); - return Effect.gen(function* () { - yield* dbPull( - flags({ - diffEngine: Option.some("pg-delta"), - remote: Option.some({ _tag: "Ref", ref: FLAG_REF } as const), - }), - ); - expect(s.cache.cached).toBe(true); - expect(s.cache.cachedRef).toBe(FLAG_REF); - expect(s.cache.cachedRef).not.toBe("abcdefghijklmnopqrst"); - }).pipe(Effect.provide(s.layer)); - }); - - it.effect("rejects the deprecated --project-ref combined with --local", () => { - const FLAG_REF = "flagflagflagflagflag"; - const s = setup(tmp.current, {}); - return Effect.gen(function* () { - 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)); - }); - it.effect("honors --remote=<ref> on the declarative export", () => { const FLAG_REF = "flagflagflagflagflag"; const s = setup(tmp.current, { edgeStdout: EXPORT_JSON }); @@ -744,129 +681,22 @@ describe("db pull", () => { }).pipe(Effect.provide(s.layer)); }); - describe("target selection", () => { - const FLAG_PROJECT_REF = "flagflagflagflagflag"; - - const resolvedTarget = ( - selector: Partial<DbPullFlags>, - options: { readonly branchApi?: ReturnType<typeof branchListApi> } = {}, - ) => { - const s = setup(tmp.current, { edgeStdout: EXPORT_JSON, projectId: Option.some(VALID_REF) }); - const provided = - options.branchApi === undefined ? s.layer : Layer.merge(s.layer, options.branchApi.layer); - return Effect.gen(function* () { - yield* dbPull(flags({ declarative: Option.some(true), ...selector })).pipe( - Effect.provide(provided), - ); - return { target: s.resolveCalls[0]?.target, warnings: warningsOf(s.out) }; - }); - }; - - it.effect("targets the local database, with no warning, when nothing selects one", () => - Effect.gen(function* () { - const { target, warnings } = yield* resolvedTarget({}); - expect(target).toEqual({ kind: "local" }); - expect(warnings).toEqual([]); - }), - ); - - it.effect("targets the linked project for a bare --remote, with no warning", () => - Effect.gen(function* () { - const { target, warnings } = yield* resolvedTarget({ remote: REMOTE_LINKED }); - expect(target).toMatchObject({ kind: "linked", ref: VALID_REF, source: "file" }); - expect(warnings).toEqual([]); - }), - ); - - it.effect("targets the project --remote=<ref> names, with no warning", () => - Effect.gen(function* () { - const { target, warnings } = yield* resolvedTarget({ - remote: Option.some({ _tag: "Ref", ref: FLAG_PROJECT_REF } as const), - }); - expect(target).toMatchObject({ kind: "linked", ref: FLAG_PROJECT_REF, source: "flag" }); - expect(warnings).toEqual([]); - }), - ); - - it.effect("targets the database --remote=<db-url> names, with no warning", () => - Effect.gen(function* () { - const { target, warnings } = yield* resolvedTarget({ - remote: Option.some({ - _tag: "DbUrl", - url: "postgresql://postgres:pw@db.example.com/db", - } as const), - }); - expect(target?.kind).toBe("db-url"); - expect(warnings).toEqual([]); - }), - ); - - it.effect("targets the project of the branch --remote=<branch> names", () => { + describeDbTargetSelection("db pull", { + dbUrl: true, + workdir: () => tmp.current, + activeFlags: { declarative: true }, + attempt: ({ workdir, target }) => { + const s = setup(workdir, { edgeStdout: EXPORT_JSON, projectId: Option.some(VALID_REF) }); const branchApi = branchListApi(); - return Effect.gen(function* () { - const { target, warnings } = yield* resolvedTarget( - { remote: Option.some({ _tag: "Branch", name: "feature-x" }) }, - { branchApi }, - ); - expect(target).toMatchObject({ kind: "linked", ref: BRANCH_PROJECT_REF, source: "flag" }); - expect(warnings).toEqual([]); - assertPermissionDrift({ - command: "db pull", - activeFlags: { declarative: true, remote: "feature-x" }, - requests: branchApi.requests, - }); - }); - }); - - it.effect("rejects --remote combined with --local", () => { - const s = setup(tmp.current, {}); - return Effect.gen(function* () { - const error = yield* dbPull(flags({ remote: REMOTE_LINKED, local: true })).pipe( - Effect.flip, - ); - expect(error).toMatchObject({ _tag: "TargetSelectorConflictError" }); - expect(s.resolveCalls).toEqual([]); - }).pipe(Effect.provide(s.layer)); - }); - - it.effect.each([ - { - alias: "--linked", - flags: { linked: true }, - kind: "linked", - warning: "--linked is deprecated and will be removed in v4. Use --remote instead", - }, - { - alias: "--project-ref", - flags: { projectRef: Option.some(FLAG_PROJECT_REF) }, - kind: "linked", - warning: - "--project-ref is deprecated and will be removed in v4. Use --remote=<ref> instead", - }, - { - alias: "--db-url", - flags: { dbUrl: Option.some("postgresql://postgres:pw@db.example.com/db") }, - kind: "db-url", - warning: "--db-url is deprecated and will be removed in v4. Use --remote=<db-url> instead", - }, - { - alias: "--local", - flags: { local: true }, - kind: "local", - warning: "--local isn't needed (local is the default) and will be removed in v4", - }, - ] satisfies ReadonlyArray<{ - alias: string; - flags: Partial<DbPullFlags>; - kind: string; - warning: string; - }>)("keeps $alias working and warns that it is deprecated", ({ flags, kind, warning }) => - Effect.gen(function* () { - const { target, warnings } = yield* resolvedTarget(flags); - expect(target?.kind).toBe(kind); - expect(warnings).toEqual([expect.stringContaining(warning)]); - }), - ); + return { + layer: Layer.merge(s.layer, branchApi.layer), + out: s.out, + run: dbPull(flags({ declarative: Option.some(true), ...target })), + target: () => s.resolveCalls[0]?.target, + cachedRef: () => s.cache.cachedRef, + branchLookups: () => branchApi.requests, + }; + }, }); it.effect( From fd4b3a8685a50200674245d86a67e035ffb7e4e7 Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Tue, 20 Oct 2026 14:19:54 +0100 Subject: [PATCH 231/291] test(cli): cover target selection for db push with the shared harness --- .../commands/db/push/push.integration.test.ts | 197 ++---------------- 1 file changed, 16 insertions(+), 181 deletions(-) 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 c0fa43a917..7b70d497e8 100644 --- a/apps/cli/src/commands/db/push/push.integration.test.ts +++ b/apps/cli/src/commands/db/push/push.integration.test.ts @@ -12,13 +12,10 @@ vi.mock("node:net", (importOriginal) => ]).then(([helper, net]) => helper.refuseInvalidDbHosts(net)), ); +import { describeDbTargetSelection } from "../../../../tests/helpers/db-target-selection.ts"; import { describeHostedConsent } from "../../../../tests/helpers/hosted-consent.ts"; import { mockOutput, mockStdin, mockTty } from "../../../../tests/helpers/mocks.ts"; -import { - BRANCH_PROJECT_REF, - branchListApi, - warningsOf, -} from "../../../../tests/helpers/remote-selection.ts"; +import { branchListApi } from "../../../../tests/helpers/remote-selection.ts"; import { assertPermissionDrift } from "../../../../tests/helpers/permission-drift.ts"; import { asRecordedRequests, @@ -376,19 +373,6 @@ describe("db push", () => { }); }); - 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, local: true, 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 to the local database without asking", () => { const { layer, out, conn } = setup(tmp.current, { toml: 'project_id = "test"\n', @@ -1100,106 +1084,21 @@ describe("db push", () => { }); }); - describe("target selection", () => { - const resolvedTarget = ( - flags: Partial<DbPushFlags>, - options: { readonly branchApi?: ReturnType<typeof branchListApi> } = {}, - ) => { - const { layer, out, resolver } = setup(tmp.current, { toml: 'project_id = "test"\n' }); - const provided = - options.branchApi === undefined ? layer : Layer.merge(layer, options.branchApi.layer); - return Effect.gen(function* () { - yield* dbPush({ ...DEFAULT_FLAGS, ...flags }).pipe(Effect.provide(provided)); - return { target: resolver.calls[0]?.target, warnings: warningsOf(out) }; - }); - }; - - it.effect("targets the local database, with no warning, when nothing selects one", () => - Effect.gen(function* () { - const { target, warnings } = yield* resolvedTarget({}); - expect(target).toEqual({ kind: "local" }); - expect(warnings).toEqual([]); - }), - ); - - it.effect("targets the project --remote=<ref> names, with no warning", () => - Effect.gen(function* () { - const { target, warnings } = yield* resolvedTarget({ - remote: Option.some({ _tag: "Ref", ref: FLAG_PROJECT_REF } as const), - }); - expect(target).toMatchObject({ kind: "linked", ref: FLAG_PROJECT_REF, source: "flag" }); - expect(warnings).toEqual([]); - }), - ); - - it.effect("targets the database --remote=<db-url> names, with no warning", () => - Effect.gen(function* () { - const { target, warnings } = yield* resolvedTarget({ - remote: Option.some({ - _tag: "DbUrl", - url: "postgresql://postgres:pw@db.example.com/db", - } as const), - }); - expect(target?.kind).toBe("db-url"); - expect(warnings).toEqual([]); - }), - ); - - it.effect("targets the project of the branch --remote=<branch> names", () => { + describeDbTargetSelection("db push", { + dbUrl: true, + workdir: () => tmp.current, + attempt: ({ workdir, target }) => { + const ctx = setup(workdir, { toml: 'project_id = "test"\n' }); const branchApi = branchListApi(); - return Effect.gen(function* () { - const { target, warnings } = yield* resolvedTarget( - { remote: Option.some({ _tag: "Branch", name: "feature-x" }) }, - { branchApi }, - ); - expect(target).toMatchObject({ kind: "linked", ref: BRANCH_PROJECT_REF, source: "flag" }); - expect(warnings).toEqual([]); - assertPermissionDrift({ - command: "db push", - activeFlags: { remote: "feature-x" }, - requests: branchApi.requests, - }); - }); - }); - - it.effect.each([ - { - alias: "--linked", - flags: { linked: true }, - kind: "linked", - warning: "--linked is deprecated and will be removed in v4. Use --remote instead", - }, - { - alias: "--project-ref", - flags: { projectRef: Option.some(FLAG_PROJECT_REF) }, - kind: "linked", - warning: - "--project-ref is deprecated and will be removed in v4. Use --remote=<ref> instead", - }, - { - alias: "--db-url", - flags: { dbUrl: Option.some("postgresql://postgres:pw@db.example.com/db") }, - kind: "db-url", - warning: "--db-url is deprecated and will be removed in v4. Use --remote=<db-url> instead", - }, - { - alias: "--local", - flags: { local: true }, - kind: "local", - warning: "--local isn't needed (local is the default) and will be removed in v4", - }, - ] satisfies ReadonlyArray<{ - alias: string; - flags: Partial<DbPushFlags>; - kind: string; - warning: string; - }>)("keeps $alias working and warns that it is deprecated", ({ flags, kind, warning }) => - Effect.gen(function* () { - const { target, warnings } = yield* resolvedTarget(flags); - expect(target?.kind).toBe(kind); - expect(warnings).toEqual([expect.stringContaining(warning)]); - }), - ); + return { + layer: Layer.merge(ctx.layer, branchApi.layer), + out: ctx.out, + run: dbPush({ ...DEFAULT_FLAGS, ...target }), + target: () => ctx.resolver.calls[0]?.target, + cachedRef: () => ctx.linkedCache.cachedRef, + branchLookups: () => branchApi.requests, + }; + }, }); it.effect("mints a login role and fills the cache through the real resolver and cache", () => { @@ -1494,33 +1393,6 @@ describe("db push", () => { }); }); - it.effect("pushes to the project --remote=<ref> names without a linked workdir", () => { - // `linkedFails: true` simulates an unlinked workdir; only the flag can resolve a ref. - const { layer, out, linkedCache, resolver } = setup(tmp.current, { - toml: 'project_id = "test"\n', - files: migrationFile("20240101000000"), - linkedFails: true, - format: "json", - yes: true, - }); - return Effect.gen(function* () { - yield* dbPush({ - ...DEFAULT_FLAGS, - remote: Option.some({ _tag: "Ref", ref: FLAG_PROJECT_REF } as const), - }).pipe(Effect.provide(layer)); - expect(linkedCache.cached).toBe(true); - expect(linkedCache.cachedRef).toBe(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"]); - }); - }); - it.effect("--remote=<ref> drives which [remotes.<ref>] block merges into config", () => { // `[remotes.staging]`'s `project_id` matches the flag ref, not the resolver's own // `VALID_REF` fallback, so the override only announces if the flag resolved it. @@ -1535,43 +1407,6 @@ describe("db push", () => { expect(out.stderrText).toContain("Loading config override: [remotes.staging]"); }); }); - - it.effect("--remote=<ref> overrides an already-linked workdir's project ref", () => { - const { layer, linkedCache } = setup(tmp.current, { - toml: 'project_id = "test"\n', - files: migrationFile("20240101000000"), - // A distinct fixed ref proves the flag, not the workdir's own ref, wins. - projectRef: VALID_REF, - confirm: [true], - }); - return Effect.gen(function* () { - yield* dbPush({ - ...DEFAULT_FLAGS, - remote: Option.some({ _tag: "Ref", ref: FLAG_PROJECT_REF } as const), - }).pipe(Effect.provide(layer)); - expect(linkedCache.cached).toBe(true); - expect(linkedCache.cachedRef).toBe(FLAG_PROJECT_REF); - expect(linkedCache.cachedRef).not.toBe(VALID_REF); - }); - }); - - it.effect("rejects --project-ref combined with an explicit --local target", () => { - const { layer, conn, resolver, linkedCache } = setup(tmp.current, { - toml: 'project_id = "test"\n', - files: migrationFile("20240101000000"), - }); - return Effect.gen(function* () { - const exit = yield* dbPush({ - ...DEFAULT_FLAGS, - local: true, - projectRef: Option.some(FLAG_PROJECT_REF), - }).pipe(Effect.provide(layer), Effect.exit); - expect(failError(exit)._tag).toBe("TargetProjectRefConflictError"); - expect(conn.execs).toEqual([]); - expect(resolver.calls).toEqual([]); - expect(linkedCache.cached).toBe(false); - }); - }); }); describeHostedConsent("db push", { From 6c003a9d298504477da49617bebab779b651228d Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Tue, 20 Oct 2026 14:20:12 +0100 Subject: [PATCH 232/291] fix(cli): fall back to the API pooler config for every linked target A bare --remote, or a checkout linked through SUPABASE_PROJECT_ID or the link file, now asks the Management API for the primary pooler config when the direct host is unreachable and no saved .temp/pooler-url applies, as --remote=<ref> already did. The saved URL is still tried first and a URL saved for another tenant is still rejected. The declared permission for the pooler config call no longer depends on the selector shape. --- .../db-config.integration.test.ts | 42 ++++++++++++++++++- .../src/command-internal/db-config.layer.ts | 27 ++++-------- .../src/commands/inspect/db/SIDE_EFFECTS.md | 2 +- .../commands/inspect/report/SIDE_EFFECTS.md | 2 +- .../commands/migration/down/SIDE_EFFECTS.md | 2 +- .../commands/migration/fetch/SIDE_EFFECTS.md | 2 +- .../commands/migration/list/SIDE_EFFECTS.md | 2 +- .../commands/migration/repair/SIDE_EFFECTS.md | 2 +- .../commands/migration/squash/SIDE_EFFECTS.md | 2 +- .../src/commands/migration/up/SIDE_EFFECTS.md | 2 +- 10 files changed, 58 insertions(+), 27 deletions(-) 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 98bf6bb08a..00f733eb66 100644 --- a/apps/cli/src/command-internal/db-config.integration.test.ts +++ b/apps/cli/src/command-internal/db-config.integration.test.ts @@ -1491,11 +1491,17 @@ describe("dbConfigResolver (an explicit --project-ref fetches the pooler config) ); it.effect( - "the plain --linked path (no --project-ref) keeps the IPv6 error when no pooler URL is saved", + "the plain --linked path (no --project-ref) keeps the IPv6 error when the API has no pooler config either", () => { const ref = "plainlinkedrefabcdef"; + 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([]) + : undefined, + ); const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const dir = yield* withWorkdir( @@ -1505,11 +1511,15 @@ describe("dbConfigResolver (an explicit --project-ref fetches the pooler config) yield* fs.writeFileString(path.join(dir, "supabase", ".temp", "project-ref"), ref); return yield* resolveConnection(dir, inputFor(linkedTarget(ref)), { projectHost: "invalid", + configEnv: { SUPABASE_ACCESS_TOKEN: VALID_TOKEN }, }); }).pipe( Effect.exit, Effect.tap((exit) => Effect.sync(() => { + expect(requests).toEqual([ + { method: "GET", path: `/v1/projects/${ref}/config/database/pooler` }, + ]); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { const causeText = Cause.pretty(exit.cause); @@ -1975,6 +1985,36 @@ describe("dbConfigResolver.resolveConnection", () => { }).pipe(Effect.scoped, Effect.provide(BunServices.layer)); }); + it.effect( + "a linked target with no saved pooler URL fetches the pooler config from the API", + () => { + const ref = "bareremoterefabcdefg"; + 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 r = yield* resolveConnection( + dir, + inputFor(linkedTarget(ref), { 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}`, + password: "pw", + }); + expect(requests).toEqual([ + { method: "GET", path: `/v1/projects/${ref}/config/database/pooler` }, + ]); + assertDbConfigDrift(requests, { remote: true }); + }).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"; diff --git a/apps/cli/src/command-internal/db-config.layer.ts b/apps/cli/src/command-internal/db-config.layer.ts index ca824bf76f..dc9138170a 100644 --- a/apps/cli/src/command-internal/db-config.layer.ts +++ b/apps/cli/src/command-internal/db-config.layer.ts @@ -62,7 +62,7 @@ const poolerConfigEntry = { operationId: "v1-get-pooler-config", kind: "required", context: - "a project ref or branch names the target, the direct database host is unreachable (IPv4-only network) and there is no usable saved supabase/.temp/pooler-url", + "the direct database host is unreachable (IPv4-only network) and there is no usable saved supabase/.temp/pooler-url", } as const satisfies Omit<OperationEntry, "source">; const networkBanEntries = [ @@ -84,7 +84,7 @@ const networkBanEntries = [ const linkedEntries: ReadonlyArray<Omit<OperationEntry, "source">> = [ loginRoleEntry, - { ...poolerConfigEntry, when: [{ selector: ["ref", "branch", "uuid"] }] }, + poolerConfigEntry, ...networkBanEntries, ...projectRefPicker.operations, ]; @@ -521,11 +521,9 @@ export const resolveLinkedConn = Effect.fn("DbConfig.resolveLinkedConn")(functio readonly adHocProjectRef?: boolean; readonly resolveVaultSecrets?: boolean; /** - * Requests the Management API pooler-config fetch on an IPv4-only network - * 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. + * Requests the Management API pooler-config fetch on an IPv4-only network independent of + * `adHocProjectRef`'s saved-URL semantics, so a workdir with no usable saved pooler URL 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. */ @@ -556,14 +554,9 @@ 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-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 - // stale saved URL for a different ref), but independently requests the same API fetch via - // `fetchPoolerFromApi` so an unlinked or mismatched-tenant workdir doesn't dead-end in the - // IPv6 error. + // Direct host unreachable (IPv6-only network) → try the pooler. An ad-hoc ref skips the saved + // pooler URL, which belongs to the linked project; with `fetchPoolerFromApi`, either case falls + // back to the Management API's pooler config. const poolerConn = yield* resolvePoolerConn( ref, workdir, @@ -774,7 +767,6 @@ export const dbConfigResolverLayer = Layer.effect( readonly password: Option.Option<string>; readonly resolveVaultSecrets: boolean; readonly adHocProjectRef: boolean; - readonly fetchPoolerFromApi: boolean; readonly hasPasswordFlag: boolean; readonly removeNetworkBans: boolean; }, @@ -793,7 +785,7 @@ export const dbConfigResolverLayer = Layer.effect( { adHocProjectRef: options.adHocProjectRef, resolveVaultSecrets: options.resolveVaultSecrets, - fetchPoolerFromApi: options.fetchPoolerFromApi, + fetchPoolerFromApi: true, hasPasswordFlag: options.hasPasswordFlag, removeNetworkBans: options.removeNetworkBans, }, @@ -829,7 +821,6 @@ export const dbConfigResolverLayer = Layer.effect( password: input.password, resolveVaultSecrets, adHocProjectRef: !target.matchesLinkedRef, - fetchPoolerFromApi: target.source === "flag", hasPasswordFlag: input.hasPasswordFlag ?? false, removeNetworkBans: canRemoveNetworkBans(input), }), diff --git a/apps/cli/src/commands/inspect/db/SIDE_EFFECTS.md b/apps/cli/src/commands/inspect/db/SIDE_EFFECTS.md index 016a0e70a2..dbd26af7ee 100644 --- a/apps/cli/src/commands/inspect/db/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/inspect/db/SIDE_EFFECTS.md @@ -38,7 +38,7 @@ Local targets and database URLs make none. | `GET` | `/v1/projects/{parent}/branches` | Bearer token | none | `[{id, name, project_ref, is_default}]` — only for `--remote=<branch>` (or the deprecated `--project-ref=<branch>`), and for a bare `--remote` followed by a word that could name a branch; `{parent}` is `SUPABASE_PROJECT_ID`, else the linked project | | `GET` | `/v1/branches/{id}` | Bearer token | none | `{ref, status, …}` — only for `--remote=<branch UUID>` when no project is linked | | `POST` | `/v1/projects/{ref}/cli/login-role` | Bearer token | `{ read_only: false }` | temporary `role` and `password` — remote project with no database password | -| `GET` | `/v1/projects/{ref}/config/database/pooler` | Bearer token | none | primary pooler `connection_string` — remote project on an IPv4-only network (direct host unreachable), only when the ref came from `--remote=<ref>` or `--remote=<branch>`, or differs from the linked project; a differing ref ignores the saved `.temp/pooler-url` | +| `GET` | `/v1/projects/{ref}/config/database/pooler` | Bearer token | none | primary pooler `connection_string` — remote project on an IPv4-only network (direct host unreachable), only when there is no usable saved `.temp/pooler-url`; a ref that differs from the linked project ignores the saved URL | | `POST` | `/v1/projects/{ref}/network-bans/retrieve` | Bearer token | none | `banned_ipv4_addresses` — remote project, from the 3rd failed temporary-role connect | | `DELETE` | `/v1/projects/{ref}/network-bans` | Bearer token | `{ ipv4_addresses, requester_ip: false }` | none — remote project, only when bans are listed and `--yes` or a shell `SUPABASE_YES` is set (clears 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 | diff --git a/apps/cli/src/commands/inspect/report/SIDE_EFFECTS.md b/apps/cli/src/commands/inspect/report/SIDE_EFFECTS.md index 3e53650a79..a85cbb2266 100644 --- a/apps/cli/src/commands/inspect/report/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/inspect/report/SIDE_EFFECTS.md @@ -63,7 +63,7 @@ Local targets and database URLs make none. | `GET` | `/v1/projects/{parent}/branches` | Bearer token | none | `[{id, name, project_ref, is_default}]` — only for `--remote=<branch>` (or the deprecated `--project-ref=<branch>`), and for a bare `--remote` followed by a word that could name a branch; `{parent}` is `SUPABASE_PROJECT_ID`, else the linked project | | `GET` | `/v1/branches/{id}` | Bearer token | none | `{ref, status, …}` — only for `--remote=<branch UUID>` when no project is linked | | `POST` | `/v1/projects/{ref}/cli/login-role` | Bearer token | `{ read_only: false }` | temporary `role` and `password` — remote project with no database password | -| `GET` | `/v1/projects/{ref}/config/database/pooler` | Bearer token | none | primary pooler `connection_string` — remote project on an IPv4-only network (direct host unreachable), only when the ref came from `--remote=<ref>` or `--remote=<branch>`, or differs from the linked project; a differing ref ignores the saved `.temp/pooler-url` | +| `GET` | `/v1/projects/{ref}/config/database/pooler` | Bearer token | none | primary pooler `connection_string` — remote project on an IPv4-only network (direct host unreachable), only when there is no usable saved `.temp/pooler-url`; a ref that differs from the linked project ignores the saved URL | | `POST` | `/v1/projects/{ref}/network-bans/retrieve` | Bearer token | none | `banned_ipv4_addresses` — remote project, from the 3rd failed temporary-role connect | | `DELETE` | `/v1/projects/{ref}/network-bans` | Bearer token | `{ ipv4_addresses, requester_ip: false }` | none — remote project, only when bans are listed and `--yes` or a shell `SUPABASE_YES` is set (clears 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 | diff --git a/apps/cli/src/commands/migration/down/SIDE_EFFECTS.md b/apps/cli/src/commands/migration/down/SIDE_EFFECTS.md index 6d04dc3dbd..5618bb657b 100644 --- a/apps/cli/src/commands/migration/down/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/migration/down/SIDE_EFFECTS.md @@ -28,7 +28,7 @@ Local targets and database URLs make none. | `GET` | `/v1/projects/{parent}/branches` | Bearer token | none | `[{id, name, project_ref, is_default}]` — only for `--remote=<branch>` (or the deprecated `--project-ref=<branch>`), and for a bare `--remote` followed by a word that could name a branch; `{parent}` is `SUPABASE_PROJECT_ID`, else the linked project | | `GET` | `/v1/branches/{id}` | Bearer token | none | `{ref, status, …}` — only for `--remote=<branch UUID>` when no project is linked | | `POST` | `/v1/projects/{ref}/cli/login-role` | Bearer token | `{ read_only: false }` | temporary `role` and `password` — remote project with no database password | -| `GET` | `/v1/projects/{ref}/config/database/pooler` | Bearer token | none | primary pooler `connection_string` — remote project on an IPv4-only network (direct host unreachable), only when the ref came from `--remote=<ref>` or `--remote=<branch>`, or differs from the linked project; a differing ref ignores the saved `.temp/pooler-url` | +| `GET` | `/v1/projects/{ref}/config/database/pooler` | Bearer token | none | primary pooler `connection_string` — remote project on an IPv4-only network (direct host unreachable), only when there is no usable saved `.temp/pooler-url`; a ref that differs from the linked project ignores the saved URL | | `POST` | `/v1/projects/{ref}/network-bans/retrieve` | Bearer token | none | `banned_ipv4_addresses` — remote project, from the 3rd failed temporary-role connect | | `DELETE` | `/v1/projects/{ref}/network-bans` | Bearer token | `{ ipv4_addresses, requester_ip: false }` | none — remote project, only when bans are listed and `--yes` or a shell `SUPABASE_YES` is set (clears every listed address); otherwise the connect keeps retrying and fails with `LinkedDbNetworkBannedError` once the retries run out | | `GET` | `/v1/projects/{ref}` | Bearer token | none | `{ref, name, organization_id, organization_slug}` — linked-project cache miss only (best-effort, post-run): written only when `.temp/linked-project.json` doesn't exist, `.temp/project-ref` doesn't name another ref, an access token exists and the lookup returns 200 | diff --git a/apps/cli/src/commands/migration/fetch/SIDE_EFFECTS.md b/apps/cli/src/commands/migration/fetch/SIDE_EFFECTS.md index 9170ae42d7..ddff26b189 100644 --- a/apps/cli/src/commands/migration/fetch/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/migration/fetch/SIDE_EFFECTS.md @@ -29,7 +29,7 @@ Local targets and database URLs make none. | `GET` | `/v1/projects/{parent}/branches` | Bearer token | none | `[{id, name, project_ref, is_default}]` — only for `--remote=<branch>` (or the deprecated `--project-ref=<branch>`), and for a bare `--remote` followed by a word that could name a branch; `{parent}` is `SUPABASE_PROJECT_ID`, else the linked project | | `GET` | `/v1/branches/{id}` | Bearer token | none | `{ref, status, …}` — only for `--remote=<branch UUID>` when no project is linked | | `POST` | `/v1/projects/{ref}/cli/login-role` | Bearer token | `{ read_only: false }` | temporary `role` and `password` — remote project with no database password | -| `GET` | `/v1/projects/{ref}/config/database/pooler` | Bearer token | none | primary pooler `connection_string` — remote project on an IPv4-only network (direct host unreachable), only when the ref came from `--remote=<ref>` or `--remote=<branch>`, or differs from the linked project; a differing ref ignores the saved `.temp/pooler-url` | +| `GET` | `/v1/projects/{ref}/config/database/pooler` | Bearer token | none | primary pooler `connection_string` — remote project on an IPv4-only network (direct host unreachable), only when there is no usable saved `.temp/pooler-url`; a ref that differs from the linked project ignores the saved URL | | `POST` | `/v1/projects/{ref}/network-bans/retrieve` | Bearer token | none | `banned_ipv4_addresses` — remote project, from the 3rd failed temporary-role connect | | `DELETE` | `/v1/projects/{ref}/network-bans` | Bearer token | `{ ipv4_addresses, requester_ip: false }` | none — remote project, only when bans are listed and `--yes` or a shell `SUPABASE_YES` is set (clears every listed address); otherwise the connect keeps retrying and fails with `LinkedDbNetworkBannedError` once the retries run out | | `GET` | `/v1/projects/{ref}` | Bearer token | none | `{ref, name, organization_id, organization_slug}` — linked-project cache miss only (best-effort, post-run): written only when `.temp/linked-project.json` doesn't exist, `.temp/project-ref` doesn't name another ref, an access token exists and the lookup returns 200 | diff --git a/apps/cli/src/commands/migration/list/SIDE_EFFECTS.md b/apps/cli/src/commands/migration/list/SIDE_EFFECTS.md index e7566545da..60c467e09a 100644 --- a/apps/cli/src/commands/migration/list/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/migration/list/SIDE_EFFECTS.md @@ -28,7 +28,7 @@ Local targets and database URLs make none. | `GET` | `/v1/projects/{parent}/branches` | Bearer token | none | `[{id, name, project_ref, is_default}]` — only for `--remote=<branch>` (or the deprecated `--project-ref=<branch>`), and for a bare `--remote` followed by a word that could name a branch; `{parent}` is `SUPABASE_PROJECT_ID`, else the linked project | | `GET` | `/v1/branches/{id}` | Bearer token | none | `{ref, status, …}` — only for `--remote=<branch UUID>` when no project is linked | | `POST` | `/v1/projects/{ref}/cli/login-role` | Bearer token | `{ read_only: false }` | temporary `role` and `password` — remote project with no database password | -| `GET` | `/v1/projects/{ref}/config/database/pooler` | Bearer token | none | primary pooler `connection_string` — remote project on an IPv4-only network (direct host unreachable), only when the ref came from `--remote=<ref>` or `--remote=<branch>`, or differs from the linked project; a differing ref ignores the saved `.temp/pooler-url` | +| `GET` | `/v1/projects/{ref}/config/database/pooler` | Bearer token | none | primary pooler `connection_string` — remote project on an IPv4-only network (direct host unreachable), only when there is no usable saved `.temp/pooler-url`; a ref that differs from the linked project ignores the saved URL | | `POST` | `/v1/projects/{ref}/network-bans/retrieve` | Bearer token | none | `banned_ipv4_addresses` — remote project, from the 3rd failed temporary-role connect | | `DELETE` | `/v1/projects/{ref}/network-bans` | Bearer token | `{ ipv4_addresses, requester_ip: false }` | none — remote project, only when bans are listed and `--yes` or a shell `SUPABASE_YES` is set (clears every listed address); otherwise the connect keeps retrying and fails with `LinkedDbNetworkBannedError` once the retries run out | | `GET` | `/v1/projects/{ref}` | Bearer token | none | `{ref, name, organization_id, organization_slug}` — linked-project cache miss only (best-effort, post-run): written only when `.temp/linked-project.json` doesn't exist, `.temp/project-ref` doesn't name another ref, an access token exists and the lookup returns 200 | diff --git a/apps/cli/src/commands/migration/repair/SIDE_EFFECTS.md b/apps/cli/src/commands/migration/repair/SIDE_EFFECTS.md index aef9121f1f..7c26cd932f 100644 --- a/apps/cli/src/commands/migration/repair/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/migration/repair/SIDE_EFFECTS.md @@ -28,7 +28,7 @@ Local targets and database URLs make none. | `GET` | `/v1/projects/{parent}/branches` | Bearer token | none | `[{id, name, project_ref, is_default}]` — only for `--remote=<branch>` (or the deprecated `--project-ref=<branch>`), and for a bare `--remote` followed by a word that could name a branch; `{parent}` is `SUPABASE_PROJECT_ID`, else the linked project | | `GET` | `/v1/branches/{id}` | Bearer token | none | `{ref, status, …}` — only for `--remote=<branch UUID>` when no project is linked | | `POST` | `/v1/projects/{ref}/cli/login-role` | Bearer token | `{ read_only: false }` | temporary `role` and `password` — remote project with no database password | -| `GET` | `/v1/projects/{ref}/config/database/pooler` | Bearer token | none | primary pooler `connection_string` — remote project on an IPv4-only network (direct host unreachable), only when the ref came from `--remote=<ref>` or `--remote=<branch>`, or differs from the linked project; a differing ref ignores the saved `.temp/pooler-url` | +| `GET` | `/v1/projects/{ref}/config/database/pooler` | Bearer token | none | primary pooler `connection_string` — remote project on an IPv4-only network (direct host unreachable), only when there is no usable saved `.temp/pooler-url`; a ref that differs from the linked project ignores the saved URL | | `POST` | `/v1/projects/{ref}/network-bans/retrieve` | Bearer token | none | `banned_ipv4_addresses` — remote project, from the 3rd failed temporary-role connect | | `DELETE` | `/v1/projects/{ref}/network-bans` | Bearer token | `{ ipv4_addresses, requester_ip: false }` | none — remote project, only when bans are listed and `--yes` or a shell `SUPABASE_YES` is set (clears every listed address); otherwise the connect keeps retrying and fails with `LinkedDbNetworkBannedError` once the retries run out | | `GET` | `/v1/projects/{ref}` | Bearer token | none | `{ref, name, organization_id, organization_slug}` — linked-project cache miss only (best-effort, post-run): written only when `.temp/linked-project.json` doesn't exist, `.temp/project-ref` doesn't name another ref, an access token exists and the lookup returns 200 | diff --git a/apps/cli/src/commands/migration/squash/SIDE_EFFECTS.md b/apps/cli/src/commands/migration/squash/SIDE_EFFECTS.md index 165fcc20d0..6750491dc2 100644 --- a/apps/cli/src/commands/migration/squash/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/migration/squash/SIDE_EFFECTS.md @@ -85,7 +85,7 @@ live shadow available. | GET | `/v1/projects/{parent}/branches` | Bearer | only for `--remote=<branch>` (or the deprecated `--project-ref=<branch>`), and for a bare `--remote` followed by a word that could name a branch; `{parent}` is `SUPABASE_PROJECT_ID`, else the linked project | | GET | `/v1/branches/{id}` | Bearer | only for `--remote=<branch UUID>` when no project is linked | | POST | `/v1/projects/{ref}/cli/login-role` | Bearer | remote project: temp login role when no password | -| GET | `/v1/projects/{ref}/config/database/pooler` | Bearer | remote project: IPv4 pooler fallback (IPv6-only network), only when the ref came from `--remote=<ref>` or `--remote=<branch>`, or differs from the linked project | +| GET | `/v1/projects/{ref}/config/database/pooler` | Bearer | remote project: IPv4 pooler fallback (IPv6-only network), only when there is no usable saved `.temp/pooler-url`; a ref that differs from the linked project ignores the saved URL | | POST/DELETE | `/v1/projects/{ref}/network-bans/retrieve`, `/v1/projects/{ref}/network-bans` | Bearer | remote project: unban during pooler login retry, only under `--yes` | | GET | `/v1/projects/{ref}` | Bearer | remote project: linked-project cache (post-run, unconditional) | diff --git a/apps/cli/src/commands/migration/up/SIDE_EFFECTS.md b/apps/cli/src/commands/migration/up/SIDE_EFFECTS.md index 56ff50a61e..0a4f65dc18 100644 --- a/apps/cli/src/commands/migration/up/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/migration/up/SIDE_EFFECTS.md @@ -28,7 +28,7 @@ Local targets and database URLs make none. | `GET` | `/v1/projects/{parent}/branches` | Bearer token | none | `[{id, name, project_ref, is_default}]` — only for `--remote=<branch>` (or the deprecated `--project-ref=<branch>`), and for a bare `--remote` followed by a word that could name a branch; `{parent}` is `SUPABASE_PROJECT_ID`, else the linked project | | `GET` | `/v1/branches/{id}` | Bearer token | none | `{ref, status, …}` — only for `--remote=<branch UUID>` when no project is linked | | `POST` | `/v1/projects/{ref}/cli/login-role` | Bearer token | `{ read_only: false }` | temporary `role` and `password` — remote project with no database password | -| `GET` | `/v1/projects/{ref}/config/database/pooler` | Bearer token | none | primary pooler `connection_string` — remote project on an IPv4-only network (direct host unreachable), only when the ref came from `--remote=<ref>` or `--remote=<branch>`, or differs from the linked project; a differing ref ignores the saved `.temp/pooler-url` | +| `GET` | `/v1/projects/{ref}/config/database/pooler` | Bearer token | none | primary pooler `connection_string` — remote project on an IPv4-only network (direct host unreachable), only when there is no usable saved `.temp/pooler-url`; a ref that differs from the linked project ignores the saved URL | | `POST` | `/v1/projects/{ref}/network-bans/retrieve` | Bearer token | none | `banned_ipv4_addresses` — remote project, from the 3rd failed temporary-role connect | | `DELETE` | `/v1/projects/{ref}/network-bans` | Bearer token | `{ ipv4_addresses, requester_ip: false }` | none — remote project, only when bans are listed and `--yes` or a shell `SUPABASE_YES` is set (clears every listed address); otherwise the connect keeps retrying and fails with `LinkedDbNetworkBannedError` once the retries run out | | `GET` | `/v1/projects/{ref}` | Bearer token | none | `{ref, name, organization_id, organization_slug}` — linked-project cache miss only (best-effort, post-run): written only when `.temp/linked-project.json` doesn't exist, `.temp/project-ref` doesn't name another ref, an access token exists and the lookup returns 200 | From 9d4f48ae60d449f600318cfb7cd45da6f269dc68 Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Tue, 20 Oct 2026 14:21:17 +0100 Subject: [PATCH 233/291] fix(cli): word the db diff linked warning like the other deprecations --from linked and --to linked now warn "Use --from=remote instead" and "Use --to=remote instead", with no trailing period, matching the "Use --remote=<ref> instead" form of the sibling warnings. --- apps/cli/src/commands/db/diff/SIDE_EFFECTS.md | 2 +- apps/cli/src/commands/db/diff/diff.handler.ts | 2 +- apps/cli/src/commands/db/diff/diff.integration.test.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/cli/src/commands/db/diff/SIDE_EFFECTS.md b/apps/cli/src/commands/db/diff/SIDE_EFFECTS.md index 4c34566254..4b525d5a67 100644 --- a/apps/cli/src/commands/db/diff/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/db/diff/SIDE_EFFECTS.md @@ -249,7 +249,7 @@ a postgresql:// URL; convert a connection string to a postgresql:// URL and pass a non-postgres URL with the same first sentence (naming `--from=<ref>` for a project's own `https://<ref>.supabase.co` URL); `--to` likewise. In `--from`/`--to`, `remote` is always the linked project. `--from linked` / `--to linked` still work and warn `--from linked is deprecated and will -be removed in v4. Use --from remote instead.` A `--remote` selector (or an alias: `--linked`, +be removed in v4. Use --from=remote instead` (and `--to=remote` for `--to`). A `--remote` selector (or an alias: `--linked`, `--project-ref`, `--db-url`, `--local`) given alongside fails with `--remote doesn't apply with --from/--to; use --from=remote or --to=remote.` (the flag named is the one passed; `--db-url` suggests `<db-url>` and `--local` suggests `local`), before any target resolves, and the default diff --git a/apps/cli/src/commands/db/diff/diff.handler.ts b/apps/cli/src/commands/db/diff/diff.handler.ts index bae3081da7..8bd76017fd 100644 --- a/apps/cli/src/commands/db/diff/diff.handler.ts +++ b/apps/cli/src/commands/db/diff/diff.handler.ts @@ -326,7 +326,7 @@ export const dbDiff = Effect.fn("db.diff")(function* (flags: DbDiffFlags) { case "Remote": if (explicit.viaDeprecatedLinked) { yield* warnTarget( - `${flag} linked is deprecated and will be removed in v4. Use ${flag} remote instead.`, + `${flag} linked is deprecated and will be removed in v4. Use ${flag}=remote instead`, ); } return yield* hostedEndpoint({ _tag: "Linked" }); 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 c80a3e802e..ad4617afe2 100644 --- a/apps/cli/src/commands/db/diff/diff.integration.test.ts +++ b/apps/cli/src/commands/db/diff/diff.integration.test.ts @@ -1521,7 +1521,7 @@ describe("db diff", () => { { value: "linked", expected: { kind: "linked", source: "file" }, - warning: `--${side} linked is deprecated and will be removed in v4. Use --${side} remote instead.`, + warning: `--${side} linked is deprecated and will be removed in v4. Use --${side}=remote instead`, }, ].map((row) => ({ side, ...row })), ); From 273ef9fe955e5c7d01c1b198623e14d1c1fe9928 Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Tue, 20 Oct 2026 14:21:58 +0100 Subject: [PATCH 234/291] test(cli): cover target selection for db reset with the shared harness --- .../db/reset/reset.integration.test.ts | 222 ++---------------- 1 file changed, 23 insertions(+), 199 deletions(-) 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 fb63db89b8..b5398aa9f7 100644 --- a/apps/cli/src/commands/db/reset/reset.integration.test.ts +++ b/apps/cli/src/commands/db/reset/reset.integration.test.ts @@ -50,11 +50,8 @@ import { sequentialExecBatch, transportFailure, } from "../../../../tests/helpers/command-mocks.ts"; -import { - BRANCH_PROJECT_REF, - branchListApi, - warningsOf, -} from "../../../../tests/helpers/remote-selection.ts"; +import { describeDbTargetSelection } from "../../../../tests/helpers/db-target-selection.ts"; +import { branchListApi } from "../../../../tests/helpers/remote-selection.ts"; import { assertPermissionDrift } from "../../../../tests/helpers/permission-drift.ts"; import { asRecordedRequests, @@ -113,10 +110,6 @@ import type { } 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"; @@ -2569,21 +2562,6 @@ describe("db reset", () => { ).pipe((body) => withConfigEnv({ MIGRATIONS_ENABLED: "true" }, body)); }); - it.effect("rejects mutually exclusive target flags", () => { - const { layer, resolver } = setup(tmp.current, { toml: 'project_id = "test"\n' }); - return Effect.gen(function* () { - 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); - }); - }); - it.effect("rejects --version together with --last", () => { const { layer } = setup(tmp.current, { toml: 'project_id = "test"\n' }); return Effect.gen(function* () { @@ -2869,62 +2847,6 @@ describe("db reset", () => { }); }); - it.effect("resets the project given via --remote=<ref> without a linked workdir", () => { - const FLAG_REF = "flagflagflagflagflag"; - const { layer, conn, linkedCache } = setup(tmp.current, { - toml: 'project_id = "test"\n', - confirm: [true], - linkedFails: true, - }); - return Effect.gen(function* () { - yield* dbReset({ - ...DEFAULT_FLAGS, - remote: Option.some({ _tag: "Ref", ref: FLAG_REF } as const), - }).pipe(Effect.provide(layer)); - expect(conn.execs.some((s) => s.includes("drop schema if exists"))).toBe(true); - expect(linkedCache.cached).toBe(true); - expect(linkedCache.cachedRef).toBe(FLAG_REF); - }); - }); - - it.effect("--remote=<ref> overrides an already-linked workdir's project ref", () => { - const FLAG_REF = "flagflagflagflagflag"; - const { layer, linkedCache } = setup(tmp.current, { - toml: 'project_id = "test"\n', - ref: VALID_REF, - confirm: [true], - }); - return Effect.gen(function* () { - yield* dbReset({ - ...DEFAULT_FLAGS, - remote: Option.some({ _tag: "Ref", ref: FLAG_REF } as const), - }).pipe(Effect.provide(layer)); - expect(linkedCache.cached).toBe(true); - expect(linkedCache.cachedRef).toBe(FLAG_REF); - expect(linkedCache.cachedRef).not.toBe(VALID_REF); - }); - }); - - it.effect("rejects --project-ref combined with --local", () => { - const { layer, conn, resolver, linkedCache } = setup(tmp.current, { - toml: 'project_id = "test"\n', - }); - return Effect.gen(function* () { - const exit = yield* dbReset({ - ...DEFAULT_FLAGS, - local: true, - projectRef: Option.some("flagflagflagflagflag"), - }).pipe(Effect.provide(layer), Effect.exit); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - expect(Cause.squash(exit.cause)).toBeInstanceOf(TargetProjectRefConflictError); - } - expect(conn.execs).toEqual([]); - expect(resolver.calls).toBe(0); - expect(linkedCache.cached).toBe(false); - }); - }); - it.effect("resets to a specific version, applying only migrations up to it", () => { const { layer, out, conn } = setup(tmp.current, { toml: 'project_id = "test"\n', @@ -3844,128 +3766,30 @@ describe("db reset", () => { }, ); }); -}); - -describe("db reset target selection", () => { - const tmp = useTempWorkdir("supabase-db-reset-selection-"); - const FLAG_PROJECT_REF = "flagflagflagflagflag"; - - const resolvedTarget = ( - selector: Partial<DbResetFlags>, - options: { readonly branchApi?: ReturnType<typeof branchListApi> } = {}, - ) => { - const { layer, out, resolver } = setup(tmp.current, { - toml: 'project_id = "test"\n', - files: { "supabase/seed.sql": "select 1;\n" }, - args: ["db", "reset"], - confirm: [true], - isLocal: selector.local === true || Object.keys(selector).length === 0, - }); - const provided = - options.branchApi === undefined ? layer : Layer.merge(layer, options.branchApi.layer); - return withEnvVar( - "GITHUB_HEAD_REF", - undefined, - Effect.gen(function* () { - yield* dbReset({ ...DEFAULT_FLAGS, ...selector }).pipe(Effect.provide(provided)); - return { target: resolver.inputs[0]?.target, warnings: warningsOf(out) }; - }), - ); - }; - it.effect("targets the local database, with no warning, when nothing selects one", () => - Effect.gen(function* () { - const { target, warnings } = yield* resolvedTarget({}); - expect(target).toEqual({ kind: "local" }); - expect(warnings).toEqual([]); - }), - ); - - it.effect("targets the linked project for a bare --remote, with no warning", () => - Effect.gen(function* () { - const { target, warnings } = yield* resolvedTarget({ remote: REMOTE_LINKED }); - expect(target).toMatchObject({ kind: "linked", ref: VALID_REF, source: "file" }); - expect(warnings).toEqual([]); - }), - ); - - it.effect("targets the project --remote=<ref> names, with no warning", () => - Effect.gen(function* () { - const { target, warnings } = yield* resolvedTarget({ - remote: Option.some({ _tag: "Ref", ref: FLAG_PROJECT_REF } as const), - }); - expect(target).toMatchObject({ kind: "linked", ref: FLAG_PROJECT_REF, source: "flag" }); - expect(warnings).toEqual([]); - }), - ); - - it.effect("targets the database --remote=<db-url> names, with no warning", () => - Effect.gen(function* () { - const { target, warnings } = yield* resolvedTarget({ - remote: Option.some({ - _tag: "DbUrl", - url: "postgresql://postgres:pw@db.example.com/db", - } as const), - }); - expect(target?.kind).toBe("db-url"); - expect(warnings).toEqual([]); - }), - ); - - it.effect("targets the project of the branch --remote=<branch> names", () => { - const branchApi = branchListApi(); - return Effect.gen(function* () { - const { target, warnings } = yield* resolvedTarget( - { remote: Option.some({ _tag: "Branch", name: "feature-x" }) }, - { branchApi }, - ); - expect(target).toMatchObject({ kind: "linked", ref: BRANCH_PROJECT_REF, source: "flag" }); - expect(warnings).toEqual([]); - assertPermissionDrift({ - command: "db reset", - activeFlags: { remote: "feature-x" }, - requests: branchApi.requests, + describeDbTargetSelection("db reset", { + dbUrl: true, + workdir: () => tmp.current, + around: (row) => withEnvVar("GITHUB_HEAD_REF", undefined, row), + attempt: ({ workdir, args, target }) => { + const ctx = setup(workdir, { + toml: 'project_id = "test"\n', + files: { "supabase/seed.sql": "select 1;\n" }, + args, + confirm: [true], + isLocal: Option.isNone(target.remote) && !target.linked, }); - }); - }); - - it.effect.each([ - { - alias: "--linked", - flags: { linked: true }, - kind: "linked", - warning: "--linked is deprecated and will be removed in v4. Use --remote instead", - }, - { - alias: "--project-ref", - flags: { projectRef: Option.some(FLAG_PROJECT_REF) }, - kind: "linked", - warning: "--project-ref is deprecated and will be removed in v4. Use --remote=<ref> instead", - }, - { - alias: "--db-url", - flags: { dbUrl: Option.some("postgresql://postgres:pw@db.example.com/db") }, - kind: "db-url", - warning: "--db-url is deprecated and will be removed in v4. Use --remote=<db-url> instead", - }, - { - alias: "--local", - flags: { local: true }, - kind: "local", - warning: "--local isn't needed (local is the default) and will be removed in v4", + const branchApi = branchListApi(); + return { + layer: Layer.merge(ctx.layer, branchApi.layer), + out: ctx.out, + run: dbReset({ ...DEFAULT_FLAGS, ...target }), + target: () => ctx.resolver.inputs[0]?.target, + cachedRef: () => ctx.linkedCache.cachedRef, + branchLookups: () => branchApi.requests, + }; }, - ] satisfies ReadonlyArray<{ - alias: string; - flags: Partial<DbResetFlags>; - kind: string; - warning: string; - }>)("keeps $alias working and warns that it is deprecated", ({ flags, kind, warning }) => - Effect.gen(function* () { - const { target, warnings } = yield* resolvedTarget(flags); - expect(target?.kind).toBe(kind); - expect(warnings).toEqual([expect.stringContaining(warning)]); - }), - ); + }); }); describeHostedConsent("db reset", { From 06362d7f6a1aed1ca6f662ef1afb40cb9c4b80dc Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Tue, 20 Oct 2026 14:23:32 +0100 Subject: [PATCH 235/291] test(cli): cover target selection for db diff with the shared harness --- .../commands/db/diff/diff.integration.test.ts | 218 ++---------------- 1 file changed, 15 insertions(+), 203 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 c80a3e802e..26ddf8d2af 100644 --- a/apps/cli/src/commands/db/diff/diff.integration.test.ts +++ b/apps/cli/src/commands/db/diff/diff.integration.test.ts @@ -31,6 +31,7 @@ vi.mock("node:net", (importOriginal) => ); import { stripAnsi } from "../../../../tests/helpers/ansi.ts"; +import { describeDbTargetSelection } from "../../../../tests/helpers/db-target-selection.ts"; import { BRANCH_PROJECT_REF, branchListApi, @@ -92,10 +93,6 @@ import type { } from "../../../command-internal/db-config.types.ts"; import { describeLinkedTarget } from "../../../command-internal/target/target-identity.ts"; import { TargetPolicy } from "../../../command-internal/target/target-policy.ts"; -import { - TargetProjectRefConflictError, - TargetSelectorConflictError, -} from "../../../command-internal/target/target.errors.ts"; import { DbConnection, type DbSession, @@ -1366,131 +1363,21 @@ describe("db diff", () => { }).pipe(Effect.provide(s.layer)); }); - it.effect("diffs the linked project and writes the linked-project cache", () => { - const s = setup(tmp.current, { - isLocal: false, - linkedRef: "abcdefghijklmnopqrst", - diffSql: "alter table x;\n", - }); - return Effect.gen(function* () { - yield* dbDiff(flags({ remote: REMOTE_LINKED })); - expect(s.cache.cached).toBe(true); - }).pipe(Effect.provide(s.layer)); - }); - - describe("target selection", () => { - const FLAG_PROJECT_REF = "flagflagflagflagflag"; - - const resolvedTarget = ( - selector: Partial<DbDiffFlags>, - options: { readonly branchApi?: ReturnType<typeof branchListApi> } = {}, - ) => { - const s = setup(tmp.current, { - projectId: Option.some(VALID_REF), - diffSql: "alter table x;\n", - }); - const provided = - options.branchApi === undefined ? s.layer : Layer.merge(s.layer, options.branchApi.layer); - return Effect.gen(function* () { - yield* dbDiff(flags(selector)).pipe(Effect.provide(provided)); - return { target: s.resolverCalls[0]?.target, warnings: warningsOf(s.out) }; - }); - }; - - it.effect("targets the local database, with no warning, when nothing selects one", () => - Effect.gen(function* () { - const { target, warnings } = yield* resolvedTarget({}); - expect(target).toEqual({ kind: "local" }); - expect(warnings).toEqual([]); - }), - ); - - it.effect("targets the linked project for a bare --remote, with no warning", () => - Effect.gen(function* () { - const { target, warnings } = yield* resolvedTarget({ remote: REMOTE_LINKED }); - expect(target).toMatchObject({ kind: "linked", source: "file" }); - expect(warnings).toEqual([]); - }), - ); - - it.effect("targets the project --remote=<ref> names, with no warning", () => - Effect.gen(function* () { - const { target, warnings } = yield* resolvedTarget({ - remote: Option.some({ _tag: "Ref", ref: FLAG_PROJECT_REF } as const), - }); - expect(target).toMatchObject({ kind: "linked", ref: FLAG_PROJECT_REF, source: "flag" }); - expect(warnings).toEqual([]); - }), - ); - - it.effect("targets the database --remote=<db-url> names, with no warning", () => - Effect.gen(function* () { - const { target, warnings } = yield* resolvedTarget({ - remote: Option.some({ - _tag: "DbUrl", - url: "postgresql://postgres:pw@db.example.com/db", - } as const), - }); - expect(target?.kind).toBe("db-url"); - expect(warnings).toEqual([]); - }), - ); - - it.effect("targets the project of the branch --remote=<branch> names", () => { + describeDbTargetSelection("db diff", { + dbUrl: true, + workdir: () => tmp.current, + attempt: ({ workdir, target }) => { + const s = setup(workdir, { projectId: Option.some(VALID_REF), diffSql: "alter table x;\n" }); const branchApi = branchListApi(); - return Effect.gen(function* () { - const { target, warnings } = yield* resolvedTarget( - { remote: Option.some({ _tag: "Branch", name: "feature-x" }) }, - { branchApi }, - ); - expect(target).toMatchObject({ kind: "linked", ref: BRANCH_PROJECT_REF, source: "flag" }); - expect(warnings).toEqual([]); - assertPermissionDrift({ - command: "db diff", - activeFlags: { remote: "feature-x" }, - requests: branchApi.requests, - }); - }); - }); - - it.effect.each([ - { - alias: "--linked", - flags: { linked: true }, - kind: "linked", - warning: "--linked is deprecated and will be removed in v4. Use --remote instead", - }, - { - alias: "--project-ref", - flags: { projectRef: Option.some(FLAG_PROJECT_REF) }, - kind: "linked", - warning: - "--project-ref is deprecated and will be removed in v4. Use --remote=<ref> instead", - }, - { - alias: "--db-url", - flags: { dbUrl: Option.some("postgresql://postgres:pw@db.example.com/db") }, - kind: "db-url", - warning: "--db-url is deprecated and will be removed in v4. Use --remote=<db-url> instead", - }, - { - alias: "--local", - flags: { local: true }, - kind: "local", - warning: "--local isn't needed (local is the default) and will be removed in v4", - }, - ] satisfies ReadonlyArray<{ - alias: string; - flags: Partial<DbDiffFlags>; - kind: string; - warning: string; - }>)("keeps $alias working and warns that it is deprecated", ({ flags, kind, warning }) => - Effect.gen(function* () { - const { target, warnings } = yield* resolvedTarget(flags); - expect(target?.kind).toBe(kind); - expect(warnings).toEqual([expect.stringContaining(warning)]); - }), - ); + return { + layer: Layer.merge(s.layer, branchApi.layer), + out: s.out, + run: dbDiff(flags({ ...target })), + target: () => s.resolverCalls[0]?.target, + cachedRef: () => s.cache.cachedRef, + branchLookups: () => branchApi.requests, + }; + }, }); describe("explicit --from and --to values", () => { @@ -1783,53 +1670,6 @@ describe("db diff", () => { }); }); - it.effect("diffs the project given via --remote=<ref> without a linked workdir", () => { - // `linkedFails: true` simulates an unlinked workdir; only the flag can resolve a ref. - const FLAG_REF = "flagflagflagflagflag"; - const s = setup(tmp.current, { - isLocal: false, - diffSql: "alter table x;\n", - linkedFails: true, - }); - return Effect.gen(function* () { - yield* dbDiff(flags({ remote: Option.some({ _tag: "Ref", ref: FLAG_REF } as const) })); - expect(s.cache.cached).toBe(true); - expect(s.cache.cachedRef).toBe(FLAG_REF); - }).pipe(Effect.provide(s.layer)); - }); - - it.effect("--remote=<ref> overrides an already-linked workdir's project ref", () => { - const FLAG_REF = "flagflagflagflagflag"; - // A distinct `linkedRef` proves the flag, not the workdir's own linked ref, wins. - const s = setup(tmp.current, { - isLocal: false, - linkedRef: "abcdefghijklmnopqrst", - diffSql: "alter table x;\n", - }); - return Effect.gen(function* () { - yield* dbDiff(flags({ remote: Option.some({ _tag: "Ref", ref: FLAG_REF } as const) })); - expect(s.cache.cached).toBe(true); - expect(s.cache.cachedRef).toBe(FLAG_REF); - expect(s.cache.cachedRef).not.toBe("abcdefghijklmnopqrst"); - }).pipe(Effect.provide(s.layer)); - }); - - it.effect("rejects the deprecated --project-ref combined with --local", () => { - const FLAG_REF = "flagflagflagflagflag"; - const s = setup(tmp.current); - return Effect.gen(function* () { - const exit = yield* Effect.exit( - dbDiff(flags({ local: true, projectRef: Option.some(FLAG_REF) })), - ); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - expect(Cause.squash(exit.cause)).toBeInstanceOf(TargetProjectRefConflictError); - } - expect(s.resolverCalls).toEqual([]); - expect(s.cache.cached).toBe(false); - }).pipe(Effect.provide(s.layer)); - }); - it.effect( "explicit --from <ref> --to migrations applies the ref's remote override and caches the ref", () => { @@ -2655,17 +2495,6 @@ describe("db diff", () => { }).pipe(Effect.provide(Layer.mergeAll(s.layer, stackBackendLayer("stack")))); }); - it.effect("fails on target mutex (--linked with --local)", () => { - const s = setup(tmp.current); - return Effect.gen(function* () { - 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)); - }); - it.effect("warns on drop statements in the diff", () => { const s = setup(tmp.current, { diffSql: "drop table gone;\n" }); return Effect.gen(function* () { @@ -3270,23 +3099,6 @@ describe("db diff", () => { }, ); - it.effect( - "fails on target mutex when --use-pgadmin is combined with --linked and --local", - () => { - const s = setup(tmp.current); - return Effect.gen(function* () { - const exit = yield* dbDiff( - flags({ - usePgAdmin: Option.some(true), - linked: true, - local: true, - }), - ).pipe(Effect.exit); - expect(Exit.isFailure(exit)).toBe(true); - }).pipe(Effect.provide(s.layer)); - }, - ); - it.effect( "explicit --from/--to wins over --use-pgadmin (pgadmin is ignored, pg-delta runs)", () => { From 2591bff494c84804a31ae59b885322f11121cd32 Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Tue, 20 Oct 2026 14:29:33 +0100 Subject: [PATCH 236/291] test(cli): cover target selection for db schema declarative generate with the shared harness --- .../generate/generate.integration.test.ts | 221 +++--------------- 1 file changed, 35 insertions(+), 186 deletions(-) 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 71ea24871e..10f9f5d774 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 @@ -15,12 +15,9 @@ vi.mock("node:net", (importOriginal) => const reachableDirectHost = withReachableDirectHost(fixtureNet); import { stripAnsi } from "../../../../../../tests/helpers/ansi.ts"; +import { describeDbTargetSelection } from "../../../../../../tests/helpers/db-target-selection.ts"; import { withReachableDirectHost } from "../../../../../../tests/helpers/refuse-invalid-db-hosts.ts"; -import { - BRANCH_PROJECT_REF, - branchListApi, - warningsOf, -} from "../../../../../../tests/helpers/remote-selection.ts"; +import { branchListApi } from "../../../../../../tests/helpers/remote-selection.ts"; import { assertPermissionDrift } from "../../../../../../tests/helpers/permission-drift.ts"; import { asRecordedRequests, @@ -498,20 +495,6 @@ describe("db schema declarative generate integration", () => { }).pipe(Effect.provide(layer)); }); - 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: true, linked: true })), - ); - expect(error).toMatchObject({ - _tag: "TargetSelectorConflictError", - message: - "Only one of --remote, --linked, --db-url and --local can be set; got --linked and --local.", - }); - }).pipe(Effect.provide(layer)); - }); - it.effect( "--local --linked without --experimental fails with the gate error, not the selector conflict", () => { @@ -526,24 +509,14 @@ describe("db schema declarative generate integration", () => { }, ); - it.effect( - "--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: true, linked: true }))), - ); - expect(error).toMatchObject({ - _tag: "TargetSelectorConflictError", - message: - "Only one of --remote, --linked, --db-url and --local can be set; got --linked and --local.", - }); - }).pipe(Effect.provide(layer)); - }, - ); + it.effect("SUPABASE_EXPERIMENTAL in the shell opens a gate that config.toml closes", () => { + const s = setup(tmp.current, { experimental: false }); + return Effect.gen(function* () { + yield* disablePgDelta(tmp.current); + yield* withConfigEnv({ SUPABASE_EXPERIMENTAL: "1" }, dbSchemaDeclarativeGenerate(flags())); + expect(s.engineExportCalls).toHaveLength(1); + }).pipe(Effect.provide(s.layer)); + }); it.effect( "an explicit --experimental=false closes the gate even when SUPABASE_EXPERIMENTAL is set", @@ -565,27 +538,21 @@ describe("db schema declarative generate integration", () => { ); it.effect( - "--local --linked with SUPABASE_EXPERIMENTAL set only in the project .env fails with the selector conflict", + "SUPABASE_EXPERIMENTAL set only in the project .env opens a gate that config.toml closes", () => { - const { layer } = setup(tmp.current, { experimental: false }); + const s = setup(tmp.current, { experimental: false }); return withEmptyConfigEnv( Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; - yield* fs.makeDirectory(path.join(tmp.current, "supabase"), { recursive: true }); + yield* disablePgDelta(tmp.current); yield* fs.writeFileString( path.join(tmp.current, "supabase", ".env"), "SUPABASE_EXPERIMENTAL=true\n", ); - const error = yield* Effect.flip( - dbSchemaDeclarativeGenerate(flags({ local: true, linked: true })), - ); - expect(error).toMatchObject({ - _tag: "TargetSelectorConflictError", - message: - "Only one of --remote, --linked, --db-url and --local can be set; got --linked and --local.", - }); - }).pipe(Effect.provide(layer)), + yield* dbSchemaDeclarativeGenerate(flags()); + expect(s.engineExportCalls).toHaveLength(1); + }).pipe(Effect.provide(s.layer)), ); }, ); @@ -661,30 +628,25 @@ describe("db schema declarative generate integration", () => { }).pipe(Effect.provide(s.layer)); }); - it.effect("selects the linked project for a bare --remote", () => { - const s = setup(tmp.current, { experimental: true, projectId: Option.some(VALID_REF) }); - return Effect.gen(function* () { - yield* dbSchemaDeclarativeGenerate(flags({ remote: Option.some({ _tag: "Linked" }) })); - 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("--remote=<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({ remote: Option.some({ _tag: "Ref", ref: VALID_REF } as const) }), - ); - 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)); + describeDbTargetSelection("db schema declarative generate", { + dbUrl: true, + workdir: () => tmp.current, + attempt: ({ workdir, args, target }) => { + const s = setup(workdir, { + experimental: true, + projectId: Option.some(VALID_REF), + args, + }); + const branchApi = branchListApi(); + return { + layer: Layer.merge(s.layer, branchApi.layer), + out: s.out, + run: dbSchemaDeclarativeGenerate(flags({ ...target })), + target: () => s.resolverCalls[0]?.target, + cachedRef: () => s.cache.cachedRef, + branchLookups: () => branchApi.requests, + }; + }, }); it.effect("--reset recreates the local database before exporting", () => { @@ -1004,15 +966,6 @@ describe("db schema declarative generate integration", () => { }).pipe(Effect.provide(s.layer)); }); - 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()); - expect(s.cache.cached).toBe(false); - expect(s.telemetry.flushCount).toBe(1); - }).pipe(Effect.provide(s.layer)); - }); - it.effect("--remote gates pg-delta on base config, not a remote enabled override", () => { const ref = "abcdefghijklmnopqrst"; const s = setup(tmp.current, { experimental: false, projectId: Option.some(ref) }); @@ -1282,107 +1235,3 @@ describe("db schema declarative generate permissions through the real resolver a ); }); }); - -describe("db schema declarative generate target selection", () => { - const tmp = useTempWorkdir(); - const FLAG_PROJECT_REF = "flagflagflagflagflag"; - - const resolvedTarget = ( - selector: Partial<DbSchemaDeclarativeGenerateFlags>, - options: { readonly branchApi?: ReturnType<typeof branchListApi> } = {}, - ) => { - const s = setup(tmp.current, { experimental: true, projectId: Option.some(VALID_REF) }); - const provided = - options.branchApi === undefined ? s.layer : Layer.merge(s.layer, options.branchApi.layer); - return Effect.gen(function* () { - yield* dbSchemaDeclarativeGenerate(flags(selector)).pipe(Effect.provide(provided)); - return { target: s.resolverCalls[0]?.target, warnings: warningsOf(s.out) }; - }); - }; - - it.effect("targets the local database, with no warning, when nothing selects one", () => - Effect.gen(function* () { - const { target, warnings } = yield* resolvedTarget({}); - expect(target).toEqual({ kind: "local" }); - expect(warnings).toEqual([]); - }), - ); - - it.effect("targets the project --remote=<ref> names, with no warning", () => - Effect.gen(function* () { - const { target, warnings } = yield* resolvedTarget({ - remote: Option.some({ _tag: "Ref", ref: FLAG_PROJECT_REF } as const), - }); - expect(target).toMatchObject({ kind: "linked", ref: FLAG_PROJECT_REF, source: "flag" }); - expect(warnings).toEqual([]); - }), - ); - - it.effect("targets the database --remote=<db-url> names, with no warning", () => - Effect.gen(function* () { - const { target, warnings } = yield* resolvedTarget({ - remote: Option.some({ - _tag: "DbUrl", - url: "postgresql://postgres:pw@db.example.com/db", - } as const), - }); - expect(target?.kind).toBe("db-url"); - expect(warnings).toEqual([]); - }), - ); - - it.effect("targets the project of the branch --remote=<branch> names", () => { - const branchApi = branchListApi(); - return Effect.gen(function* () { - const { target, warnings } = yield* resolvedTarget( - { remote: Option.some({ _tag: "Branch", name: "feature-x" }) }, - { branchApi }, - ); - expect(target).toMatchObject({ kind: "linked", ref: BRANCH_PROJECT_REF, source: "flag" }); - expect(warnings).toEqual([]); - assertPermissionDrift({ - command: "db schema declarative generate", - activeFlags: { remote: "feature-x" }, - requests: branchApi.requests, - }); - }); - }); - - it.effect.each([ - { - alias: "--linked", - flags: { linked: true }, - kind: "linked", - warning: "--linked is deprecated and will be removed in v4. Use --remote instead", - }, - { - alias: "--project-ref", - flags: { projectRef: Option.some(FLAG_PROJECT_REF) }, - kind: "linked", - warning: "--project-ref is deprecated and will be removed in v4. Use --remote=<ref> instead", - }, - { - alias: "--db-url", - flags: { dbUrl: Option.some("postgresql://postgres:pw@db.example.com/db") }, - kind: "db-url", - warning: "--db-url is deprecated and will be removed in v4. Use --remote=<db-url> instead", - }, - { - alias: "--local", - flags: { local: true }, - kind: "local", - warning: "--local isn't needed (local is the default) and will be removed in v4", - }, - ] satisfies ReadonlyArray<{ - alias: string; - flags: Partial<DbSchemaDeclarativeGenerateFlags>; - kind: string; - warning: string; - }>)("keeps $alias working and warns that it is deprecated", ({ flags, kind, warning }) => - Effect.gen(function* () { - const { target, warnings } = yield* resolvedTarget(flags); - expect(target?.kind).toBe(kind); - expect(warnings).toEqual([expect.stringContaining(warning)]); - }), - ); -}); From a50b5268d1b496ba894fdbd2c891fe9df7dc7293 Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Tue, 20 Oct 2026 14:34:08 +0100 Subject: [PATCH 237/291] test(cli): replace hand-spelled target flag literals with shared fixtures --- .../commands/db/push/push.integration.test.ts | 11 +- .../db/reset/reset.integration.test.ts | 11 +- .../commands/db/test/test.integration.test.ts | 7 +- .../gen/types/types.integration.test.ts | 7 +- .../db/inspect-deprecated.integration.test.ts | 13 +- .../db/inspect-specs.integration.test.ts | 11 +- .../seed/buckets/buckets.integration.test.ts | 136 +++----------- .../storage/cp/cp.integration.test.ts | 7 +- .../storage/ls/ls.integration.test.ts | 7 +- .../storage/mv/mv.integration.test.ts | 7 +- .../storage/rm/rm.integration.test.ts | 175 ++++-------------- apps/cli/tests/helpers/db-target-selection.ts | 7 +- 12 files changed, 93 insertions(+), 306 deletions(-) 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 7b70d497e8..b856f80c5b 100644 --- a/apps/cli/src/commands/db/push/push.integration.test.ts +++ b/apps/cli/src/commands/db/push/push.integration.test.ts @@ -12,7 +12,10 @@ vi.mock("node:net", (importOriginal) => ]).then(([helper, net]) => helper.refuseInvalidDbHosts(net)), ); -import { describeDbTargetSelection } from "../../../../tests/helpers/db-target-selection.ts"; +import { + NO_TARGET, + describeDbTargetSelection, +} from "../../../../tests/helpers/db-target-selection.ts"; import { describeHostedConsent } from "../../../../tests/helpers/hosted-consent.ts"; import { mockOutput, mockStdin, mockTty } from "../../../../tests/helpers/mocks.ts"; import { branchListApi } from "../../../../tests/helpers/remote-selection.ts"; @@ -82,11 +85,7 @@ const DEFAULT_FLAGS: DbPushFlags = { includeSeed: Option.none(), skipVault: false, dryRun: false, - dbUrl: Option.none(), - remote: Option.none(), - linked: false, - local: false, - projectRef: Option.none(), + ...NO_TARGET, password: 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 b5398aa9f7..51f757b9ed 100644 --- a/apps/cli/src/commands/db/reset/reset.integration.test.ts +++ b/apps/cli/src/commands/db/reset/reset.integration.test.ts @@ -50,7 +50,10 @@ import { sequentialExecBatch, transportFailure, } from "../../../../tests/helpers/command-mocks.ts"; -import { describeDbTargetSelection } from "../../../../tests/helpers/db-target-selection.ts"; +import { + NO_TARGET, + describeDbTargetSelection, +} from "../../../../tests/helpers/db-target-selection.ts"; import { branchListApi } from "../../../../tests/helpers/remote-selection.ts"; import { assertPermissionDrift } from "../../../../tests/helpers/permission-drift.ts"; import { @@ -139,11 +142,7 @@ const CONN: PgConnInput = { const REMOTE_LINKED: DbResetFlags["remote"] = Option.some({ _tag: "Linked" }); const DEFAULT_FLAGS: DbResetFlags = { - dbUrl: Option.none(), - remote: Option.none(), - linked: false, - local: false, - projectRef: Option.none(), + ...NO_TARGET, noSeed: Option.none(), sqlPaths: Option.none(), version: Option.none(), 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 588625b524..dd4f5a8981 100644 --- a/apps/cli/src/commands/db/test/test.integration.test.ts +++ b/apps/cli/src/commands/db/test/test.integration.test.ts @@ -32,6 +32,7 @@ import { sequentialExecBatch, useTempWorkdir, } from "../../../../tests/helpers/command-mocks.ts"; +import { NO_TARGET } from "../../../../tests/helpers/db-target-selection.ts"; import { assertPermissionDrift } from "../../../../tests/helpers/permission-drift.ts"; import { asRecordedRequests, @@ -263,11 +264,7 @@ function setup(opts: SetupOpts = {}) { const flags = (over: Partial<TestDbFlags> = {}): TestDbFlags => ({ paths: [], - remote: Option.none(), - dbUrl: Option.none(), - linked: false, - local: false, - projectRef: Option.none(), + ...NO_TARGET, ...over, }); 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 73eeb40c49..4df7abdc77 100644 --- a/apps/cli/src/commands/gen/types/types.integration.test.ts +++ b/apps/cli/src/commands/gen/types/types.integration.test.ts @@ -66,6 +66,7 @@ import { withEnvVar, } from "../../../../tests/helpers/command-mocks.ts"; import { + NO_TARGET, branchLookupsOf, describeDbTargetSelection, } from "../../../../tests/helpers/db-target-selection.ts"; @@ -160,11 +161,7 @@ class ApiCallFailure extends Data.TaggedError("ApiCallFailure")<{ function defaultFlags(overrides: Partial<GenTypesFlags> = {}): GenTypesFlags { return { - remote: Option.none(), - local: false, - linked: false, - dbUrl: Option.none(), - projectRef: Option.none(), + ...NO_TARGET, projectId: Option.none(), lang: "typescript" as const, schema: [], 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 752ac9d847..940702bc09 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 @@ -9,12 +9,13 @@ import { mockTelemetryStateTracked, } from "../../../../tests/helpers/command-mocks.ts"; import { configValuesLayer } from "../../../../tests/helpers/config-values-layer.ts"; +import { NO_TARGET } from "../../../../tests/helpers/db-target-selection.ts"; import { DnsResolverFlag, 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 { 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 type { InspectQuerySpec } from "./inspect-query.ts"; import { dbStatsSpec } from "./db-stats/db-stats.query.ts"; import { indexStatsSpec } from "./index-stats/index-stats.query.ts"; import { roleStatsSpec } from "./role-stats/role-stats.query.ts"; @@ -81,14 +82,6 @@ function setup() { }; } -const flags: InspectConnectionFlags = { - dbUrl: Option.none<string>(), - remote: Option.none(), - linked: false, - local: false, - projectRef: Option.none<string>(), -}; - // All deprecated-alias handlers share the same factory-produced type. type AliasHandler = typeof inspectDbCacheHit; @@ -168,7 +161,7 @@ describe("inspect db deprecated aliases", () => { it.effect(`${testCase.alias} warns and runs the ${testCase.routedSpec.name} query`, () => { const ctx = setup(); return Effect.gen(function* () { - yield* testCase.handler(flags); + yield* testCase.handler(NO_TARGET); expect(ctx.out.stderrText).toContain( `Command "${testCase.alias}" is deprecated, use "${testCase.target}" instead.\n`, ); 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 6f01ebd85f..e1e7f1d0d1 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 @@ -8,6 +8,7 @@ import { mockCommandSettings, } from "../../../../tests/helpers/command-mocks.ts"; import { configValuesLayer } from "../../../../tests/helpers/config-values-layer.ts"; +import { NO_TARGET } from "../../../../tests/helpers/db-target-selection.ts"; import { YesFlag } from "../../../command-internal/global-flags.ts"; import { DbConfigResolver } from "../../../command-internal/db-config.service.ts"; import type { @@ -86,14 +87,6 @@ function setup(rows: ReadonlyArray<Record<string, unknown>>) { }; } -const localFlags = { - remote: Option.none(), - dbUrl: Option.none<string>(), - linked: false, - local: false, - projectRef: Option.none<string>(), -}; - type ParamKind = "none" | "schemas1" | "schemas2"; interface Case { @@ -276,7 +269,7 @@ describe("inspect db specs (per-subcommand correctness)", () => { it.effect(`runs the ${testCase.spec.name} query and renders its cells`, () => { const ctx = setup([testCase.row]); return Effect.gen(function* () { - yield* runInspectQuery(testCase.spec, localFlags, "native"); + yield* runInspectQuery(testCase.spec, NO_TARGET, "native"); expect(ctx.querySql).toBe(testCase.spec.sql); 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 6d11e3a3ab..97edcbf3c0 100644 --- a/apps/cli/src/commands/seed/buckets/buckets.integration.test.ts +++ b/apps/cli/src/commands/seed/buckets/buckets.integration.test.ts @@ -52,8 +52,11 @@ import { StackStorageUnavailableError, } from "../../../command-internal/stack-storage.ts"; import { + BARE_REMOTE, + NO_TARGET, branchLookupsOf, describeDbTargetSelection, + remoteTarget, storageTargetOf, } from "../../../../tests/helpers/db-target-selection.ts"; import { describeHostedConsent } from "../../../../tests/helpers/hosted-consent.ts"; @@ -85,12 +88,7 @@ interface MockRoute { readonly transportDescription?: string; } -const DEFAULT_FLAGS: BucketsFlags = { - remote: Option.none(), - linked: false, - local: false, - projectRef: Option.none(), -}; +const DEFAULT_FLAGS: BucketsFlags = NO_TARGET; function failureErrors<A, E>(exit: Exit.Exit<A, E>): ReadonlyArray<E> { if (!Exit.isFailure(exit)) return []; @@ -844,12 +842,7 @@ describe("seed buckets", () => { // A remote run never short-circuits on empty config: even with no config // file, the remote client is built, the service-role key fetched, and // buckets listed — failures surface instead of a silent success. - const flags: BucketsFlags = { - remote: Option.some({ _tag: "Linked" } as const), - linked: false, - local: false, - projectRef: Option.none(), - }; + const flags: BucketsFlags = BARE_REMOTE; const { layer, requests } = yield* setupSeedBuckets(tmp.current, { projectRef: VALID_REF, apiKeys: [ @@ -1181,12 +1174,7 @@ describe("seed buckets", () => { args: ["seed", "buckets", "--remote"], routes: [{ method: "GET", match: "/storage/v1/bucket", transport: true }], }); - const exit = yield* seedBuckets({ - remote: Option.some({ _tag: "Linked" } as const), - linked: false, - local: false, - projectRef: Option.none(), - }).pipe(Effect.provide(layer), Effect.exit); + const exit = yield* seedBuckets(BARE_REMOTE).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { expect(Cause.pretty(exit.cause)).not.toContain("Another process may be listening"); @@ -2143,12 +2131,7 @@ describe("seed buckets", () => { it.effect("--remote seeds the remote storage project", () => Effect.gen(function* () { - const flags: BucketsFlags = { - remote: Option.some({ _tag: "Linked" } as const), - linked: false, - local: false, - projectRef: Option.none(), - }; + const flags: BucketsFlags = BARE_REMOTE; const { layer, out, requests } = yield* setupSeedBuckets(tmp.current, { toml: "[storage.buckets.test]\npublic = true\n", projectRef: VALID_REF, @@ -2187,12 +2170,7 @@ describe("seed buckets", () => { ], }); const fs = yield* FileSystem.FileSystem; - yield* seedBuckets({ - remote: Option.some({ _tag: "Linked" } as const), - linked: false, - local: false, - projectRef: Option.none(), - }).pipe(Effect.provide(layer)); + yield* seedBuckets(BARE_REMOTE).pipe(Effect.provide(layer)); expect( managementRequests.map(({ method, urlWithParams }) => `${method} ${urlWithParams}`), ).toEqual([ @@ -2226,12 +2204,7 @@ describe("seed buckets", () => { { method: "POST", match: "/storage/v1/bucket", body: { name: "test" } }, ], }); - yield* seedBuckets({ - remote: Option.some({ _tag: "Linked" } as const), - linked: false, - local: false, - projectRef: Option.none(), - }).pipe(Effect.provide(layer)); + yield* seedBuckets(BARE_REMOTE).pipe(Effect.provide(layer)); expect(managementRequests.map(({ method, url }) => `${method} ${url}`)).toEqual([ `GET https://api.supabase.com/v1/projects/${VALID_REF}`, ]); @@ -2304,12 +2277,7 @@ describe("seed buckets", () => { { method: "POST", match: "/storage/v1/bucket", body: { name: "test" } }, ], }); - const exit = yield* seedBuckets({ - remote: Option.none(), - linked: false, - local: false, - projectRef: Option.none(), - }).pipe(Effect.provide(layer), Effect.exit); + const exit = yield* seedBuckets(NO_TARGET).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isSuccess(exit)).toBe(true); // Asserts "some request happened, not the remote host" — keeps this // independent of the loopback host env. @@ -2328,12 +2296,7 @@ describe("seed buckets", () => { args: ["seed", "buckets", "--remote"], routes: [{ method: "GET", match: "/storage/v1/bucket", body: [] }], }); - const exit = yield* seedBuckets({ - remote: Option.some({ _tag: "Linked" } as const), - linked: false, - local: false, - projectRef: Option.none(), - }).pipe(Effect.provide(layer), Effect.exit); + const exit = yield* seedBuckets(BARE_REMOTE).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { expect(Cause.pretty(exit.cause)).toContain("Anon key not found."); @@ -2354,12 +2317,7 @@ describe("seed buckets", () => { args: ["seed", "buckets", "--remote"], routes: [{ method: "GET", match: "/storage/v1/bucket", body: [] }], }); - const exit = yield* seedBuckets({ - remote: Option.some({ _tag: "Linked" } as const), - linked: false, - local: false, - projectRef: Option.none(), - }).pipe(Effect.provide(layer), Effect.exit); + const exit = yield* seedBuckets(BARE_REMOTE).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { const causeText = Cause.pretty(exit.cause); @@ -2393,12 +2351,7 @@ describe("seed buckets", () => { { method: "POST", match: "/storage/v1/bucket", body: { name: "test" } }, ], }); - yield* seedBuckets({ - remote: Option.some({ _tag: "Linked" } as const), - linked: false, - local: false, - projectRef: Option.none(), - }).pipe(Effect.provide(linked.layer), Effect.exit); + yield* seedBuckets(BARE_REMOTE).pipe(Effect.provide(linked.layer), Effect.exit); expect(linked.linkedCache.cached).toBe(true); expect(linked.linkedCache.cachedRef).toBe(VALID_REF); @@ -2413,12 +2366,7 @@ describe("seed buckets", () => { "SUPABASE_AUTH_SERVICE_ROLE_KEY", "env-service-role-key", Effect.gen(function* () { - const flags: BucketsFlags = { - remote: Option.some({ _tag: "Linked" } as const), - linked: false, - local: false, - projectRef: Option.none(), - }; + const flags: BucketsFlags = BARE_REMOTE; const { layer, requests } = yield* setupSeedBuckets(tmp.current, { toml: "[storage.buckets.test]\npublic = true\n", projectRef: VALID_REF, @@ -2442,12 +2390,7 @@ describe("seed buckets", () => { "SUPABASE_AUTH_SERVICE_ROLE_KEY", "", Effect.gen(function* () { - const flags: BucketsFlags = { - remote: Option.some({ _tag: "Linked" } as const), - linked: false, - local: false, - projectRef: Option.none(), - }; + const flags: BucketsFlags = BARE_REMOTE; const { layer, requests } = yield* setupSeedBuckets(tmp.current, { toml: "[storage.buckets.test]\npublic = true\n", projectRef: VALID_REF, @@ -2468,12 +2411,7 @@ describe("seed buckets", () => { it.effect("upserts analytics buckets when analytics.enabled and --remote", () => Effect.gen(function* () { - const flags: BucketsFlags = { - remote: Option.some({ _tag: "Linked" } as const), - linked: false, - local: false, - projectRef: Option.none(), - }; + const flags: BucketsFlags = BARE_REMOTE; const { layer, out, requests } = yield* setupSeedBuckets(tmp.current, { toml: [ "[storage.analytics]", @@ -2524,12 +2462,7 @@ describe("seed buckets", () => { it.effect("prunes a stale analytics bucket after the single hosted confirmation", () => Effect.gen(function* () { - const flags: BucketsFlags = { - remote: Option.some({ _tag: "Linked" } as const), - linked: false, - local: false, - projectRef: Option.none(), - }; + const flags: BucketsFlags = BARE_REMOTE; const { layer, out, requests } = yield* setupSeedBuckets(tmp.current, { toml: [ "[storage.analytics]", @@ -2583,12 +2516,7 @@ describe("seed buckets", () => { { method: "PUT", match: "/storage/v1/bucket/test", body: {} }, ], }); - const exit = yield* seedBuckets({ - remote: Option.some({ _tag: "Linked" } as const), - linked: false, - local: false, - projectRef: Option.none(), - }).pipe(Effect.provide(layer), Effect.exit); + const exit = yield* seedBuckets(BARE_REMOTE).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([ @@ -2610,12 +2538,7 @@ describe("seed buckets", () => { confirm: [false], routes: [{ method: "GET", match: "/storage/v1/bucket", body: [] }], }); - const exit = yield* seedBuckets({ - remote: Option.some({ _tag: "Linked" } as const), - linked: false, - local: false, - projectRef: Option.none(), - }).pipe(Effect.provide(layer), Effect.exit); + const exit = yield* seedBuckets(BARE_REMOTE).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), @@ -2639,12 +2562,7 @@ describe("seed buckets", () => { it.effect("--remote fails when the project is not linked", () => Effect.gen(function* () { - const flags: BucketsFlags = { - remote: Option.some({ _tag: "Linked" } as const), - linked: false, - local: false, - projectRef: Option.none(), - }; + const flags: BucketsFlags = BARE_REMOTE; const { layer } = yield* setupSeedBuckets(tmp.current, { toml: "[storage.buckets.test]\npublic = true\n", linkedFails: true, @@ -2740,12 +2658,7 @@ describe("seed buckets", () => { // bucket; `mergeRemoteConfig` merges subtrees recursively rather than // replacing [storage.buckets] wholesale, so both buckets are seeded. const remoteRef = VALID_REF; - const flags: BucketsFlags = { - remote: Option.some({ _tag: "Linked" } as const), - linked: false, - local: false, - projectRef: Option.none(), - }; + const flags: BucketsFlags = BARE_REMOTE; const { layer, out, requests } = yield* setupSeedBuckets(tmp.current, { toml: [ 'project_id = "test"', @@ -3225,12 +3138,7 @@ describeHostedConsent("seed buckets", { { method: "POST", match: "/storage/v1/bucket", body: { name: "test" } }, ]); return { - run: seedBuckets({ - remote: Option.some({ _tag: "Ref", ref: VALID_REF } as const), - linked: false, - local: false, - projectRef: Option.none(), - }).pipe(Effect.provide(storage.layer)), + run: seedBuckets(remoteTarget(VALID_REF)).pipe(Effect.provide(storage.layer)), mutations: storage.writes, }; }, 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 9e4bf8cafc..11c07677d7 100644 --- a/apps/cli/src/commands/storage/cp/cp.integration.test.ts +++ b/apps/cli/src/commands/storage/cp/cp.integration.test.ts @@ -2,6 +2,8 @@ import { describe, expect, it } from "@effect/vitest"; import { Cause, Effect, Exit, FileSystem, Option, Path, Predicate } from "effect"; import { + BARE_REMOTE, + NO_TARGET, branchLookupsOf, describeDbTargetSelection, storageTargetOf, @@ -45,10 +47,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), - remote: opts.remote === true ? Option.some({ _tag: "Linked" } as const) : Option.none(), - linked: false, - local: false, - projectRef: Option.none(), + ...(opts.remote === true ? BARE_REMOTE : NO_TARGET), }; } 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 fd6e290b9a..5e6f013ee9 100644 --- a/apps/cli/src/commands/storage/ls/ls.integration.test.ts +++ b/apps/cli/src/commands/storage/ls/ls.integration.test.ts @@ -4,6 +4,8 @@ import { Cause, Effect, Exit, FileSystem, Option, Path, Predicate } from "effect import { VALID_REF, useTempWorkdir, withEnvVar } from "../../../../tests/helpers/command-mocks.ts"; import { + BARE_REMOTE, + NO_TARGET, branchLookupsOf, describeDbTargetSelection, storageTargetOf, @@ -50,10 +52,7 @@ function lsFlags( return { path: opts.path === undefined ? Option.none() : Option.some(opts.path), recursive: opts.recursive ?? false, - remote: opts.remote === true ? Option.some({ _tag: "Linked" } as const) : Option.none(), - linked: false, - local: false, - projectRef: Option.none(), + ...(opts.remote === true ? BARE_REMOTE : NO_TARGET), }; } 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 a3904f24ad..103c4ae8d1 100644 --- a/apps/cli/src/commands/storage/mv/mv.integration.test.ts +++ b/apps/cli/src/commands/storage/mv/mv.integration.test.ts @@ -4,6 +4,8 @@ import { Cause, Effect, Exit, FileSystem, Option, Predicate } from "effect"; import { StackStorageCapabilityError } from "../../../command-internal/stack-storage.ts"; import { generateLocalJwt } from "../../../command-internal/local-jwt.ts"; import { + BARE_REMOTE, + NO_TARGET, branchLookupsOf, describeDbTargetSelection, storageTargetOf, @@ -37,10 +39,7 @@ function mvFlags(opts: { src: opts.src, dst: opts.dst, recursive: opts.recursive ?? false, - remote: opts.remote === true ? Option.some({ _tag: "Linked" } as const) : Option.none(), - linked: false, - local: false, - projectRef: Option.none(), + ...(opts.remote === true ? BARE_REMOTE : NO_TARGET), }; } 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 bf4f6c7ca4..38849d0c75 100644 --- a/apps/cli/src/commands/storage/rm/rm.integration.test.ts +++ b/apps/cli/src/commands/storage/rm/rm.integration.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "@effect/vitest"; import { BunServices } from "@effect/platform-bun"; -import { Cause, Effect, Exit, FileSystem, Option, Path, Predicate } from "effect"; +import { Cause, Effect, Exit, FileSystem, Path, Predicate } from "effect"; import { DbConfigLoadError } from "../../../command-internal/db-config.errors.ts"; import { StackStorageCapabilityError } from "../../../command-internal/stack-storage.ts"; @@ -8,8 +8,11 @@ import { generateLocalJwt } from "../../../command-internal/local-jwt.ts"; import { ProjectRefNotLinkedError } from "../../../config/project-ref.errors.ts"; import { StorageRmConfirmationRequiredError } from "../storage.errors.ts"; import { + BARE_REMOTE, + NO_TARGET, branchLookupsOf, describeDbTargetSelection, + remoteTarget, storageTargetOf, } from "../../../../tests/helpers/db-target-selection.ts"; import { describeHostedConsent } from "../../../../tests/helpers/hosted-consent.ts"; @@ -40,10 +43,7 @@ const writeAncestorConfig = Effect.fnUntraced(function* (root: string, toml: str const RM_ARGS = { files: ["ss:///private/a.pdf"], recursive: false, - remote: Option.none(), - linked: false, - local: false, - projectRef: Option.none(), + ...NO_TARGET, }; const BUCKET = "/storage/v1/bucket"; const DELETE_OBJECT = (bucket: string) => `/storage/v1/object/${bucket}`; @@ -89,10 +89,7 @@ describe("storage rm", () => { const exit = yield* storageRm({ files: ["ss:///private/abstract.pdf", "ss:///private/docs/readme.md"], recursive: false, - remote: Option.none(), - linked: false, - local: false, - projectRef: Option.none(), + ...NO_TARGET, }).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isSuccess(exit)).toBe(true); const del = requests.find( @@ -113,10 +110,7 @@ describe("storage rm", () => { const exit = yield* storageRm({ files: ["ss:///private/a.pdf"], recursive: false, - remote: Option.none(), - linked: false, - local: false, - projectRef: Option.none(), + ...NO_TARGET, }).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isSuccess(exit)).toBe(true); expect(out.stderrText).toContain("Confirm deleting files in bucket"); @@ -138,10 +132,7 @@ describe("storage rm", () => { const exit = yield* storageRm({ files: ["ss:///private/a.pdf"], recursive: false, - remote: Option.none(), - linked: false, - local: false, - projectRef: Option.none(), + ...NO_TARGET, }).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isSuccess(exit)).toBe(true); expect(out.stderrText).toContain("[y/N] y"); @@ -162,10 +153,7 @@ describe("storage rm", () => { const exit = yield* storageRm({ files: ["ss:///private/a.pdf"], recursive: false, - remote: Option.none(), - linked: false, - local: false, - projectRef: Option.none(), + ...NO_TARGET, }).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isSuccess(exit)).toBe(true); expect(out.stderrText).toContain("[y/N] y"); @@ -184,10 +172,7 @@ describe("storage rm", () => { const exit = yield* storageRm({ files: ["ss:///private/a.pdf"], recursive: false, - remote: Option.some({ _tag: "Linked" } as const), - linked: false, - local: false, - projectRef: Option.none(), + ...BARE_REMOTE, }).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { @@ -214,10 +199,7 @@ describe("storage rm", () => { const exit = yield* storageRm({ files: ["ss:///private/a.pdf"], recursive: false, - remote: Option.none(), - linked: false, - local: false, - projectRef: Option.none(), + ...NO_TARGET, }).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isSuccess(exit)).toBe(true); expect(requests.some((r) => r.method === "DELETE")).toBe(false); @@ -236,10 +218,7 @@ describe("storage rm", () => { const exit = yield* storageRm({ files: ["ss:///private/a.pdf"], recursive: false, - remote: Option.none(), - linked: false, - local: false, - projectRef: Option.none(), + ...NO_TARGET, }).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isSuccess(exit)).toBe(true); expect(requests.some((r) => r.method === "DELETE")).toBe(true); @@ -259,10 +238,7 @@ describe("storage rm", () => { const exit = yield* storageRm({ files: ["ss:///private/a.pdf"], recursive: false, - remote: Option.none(), - linked: false, - local: false, - projectRef: Option.none(), + ...NO_TARGET, }).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isSuccess(exit)).toBe(true); expect(requests.some((r) => r.method === "DELETE")).toBe(false); @@ -289,10 +265,7 @@ describe("storage rm", () => { const exit = yield* storageRm({ files: ["ss:///private/a.pdf"], recursive: false, - remote: Option.none(), - linked: false, - local: false, - projectRef: Option.none(), + ...NO_TARGET, }).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { @@ -324,10 +297,7 @@ describe("storage rm", () => { const exit = yield* storageRm({ files: [], recursive: true, - remote: Option.none(), - linked: false, - local: false, - projectRef: Option.none(), + ...NO_TARGET, }).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { @@ -354,10 +324,7 @@ describe("storage rm", () => { const exit = yield* storageRm({ files: [], recursive: false, - remote: Option.none(), - linked: false, - local: false, - projectRef: Option.none(), + ...NO_TARGET, }).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { @@ -395,10 +362,7 @@ describe("storage rm", () => { const exit = yield* storageRm({ files, recursive: false, - remote: Option.none(), - linked: false, - local: false, - projectRef: Option.none(), + ...NO_TARGET, }).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isSuccess(exit)).toBe(true); const deletes = requests.filter( @@ -419,10 +383,7 @@ describe("storage rm", () => { const exit = yield* storageRm({ files: ["ss:///"], recursive: false, - remote: Option.none(), - linked: false, - local: false, - projectRef: Option.none(), + ...NO_TARGET, }).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { @@ -441,10 +402,7 @@ describe("storage rm", () => { const exit = yield* storageRm({ files: ["ss:///private/"], recursive: false, - remote: Option.none(), - linked: false, - local: false, - projectRef: Option.none(), + ...NO_TARGET, }).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { @@ -465,10 +423,7 @@ describe("storage rm", () => { const exit = yield* storageRm({ files: [], recursive: false, - remote: Option.none(), - linked: false, - local: false, - projectRef: Option.none(), + ...NO_TARGET, }).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { @@ -495,10 +450,7 @@ describe("storage rm", () => { const exit = yield* storageRm({ files: [], recursive: true, - remote: Option.none(), - linked: false, - local: false, - projectRef: Option.none(), + ...NO_TARGET, }).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isSuccess(exit)).toBe(true); expect(out.stderrText).toContain("Deleting bucket: b1"); @@ -531,10 +483,7 @@ describe("storage rm", () => { const exit = yield* storageRm({ files: ["ss:///test"], recursive: true, - remote: Option.none(), - linked: false, - local: false, - projectRef: Option.none(), + ...NO_TARGET, }).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isSuccess(exit)).toBe(true); expect(out.stderrText).toContain("Bucket not found: test"); @@ -590,10 +539,7 @@ describe("storage rm", () => { const exit = yield* storageRm({ files: ["ss:///private/dir"], recursive: true, - remote: Option.none(), - linked: false, - local: false, - projectRef: Option.none(), + ...NO_TARGET, }).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isSuccess(exit)).toBe(true); expect( @@ -623,10 +569,7 @@ describe("storage rm", () => { const exit = yield* storageRm({ files: ["ss:///test"], recursive: true, - remote: Option.none(), - linked: false, - local: false, - projectRef: Option.none(), + ...NO_TARGET, }).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isSuccess(exit)).toBe(true); expect(out.stderrText).toContain("Deleting bucket: test"); @@ -648,10 +591,7 @@ describe("storage rm", () => { const exit = yield* storageRm({ files: ["ss:///private/dir"], recursive: true, - remote: Option.none(), - linked: false, - local: false, - projectRef: Option.none(), + ...NO_TARGET, }).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { @@ -672,10 +612,7 @@ describe("storage rm", () => { const exit = yield* storageRm({ files: ["ss:///private/a.pdf"], recursive: false, - remote: Option.none(), - linked: false, - local: false, - projectRef: Option.none(), + ...NO_TARGET, }).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isSuccess(exit)).toBe(true); expect(requests.some((r) => r.method === "DELETE")).toBe(true); @@ -704,10 +641,7 @@ describe("storage rm", () => { const exit = yield* storageRm({ files: ["ss:///private/a.pdf"], recursive: false, - remote: Option.none(), - linked: false, - local: false, - projectRef: Option.none(), + ...NO_TARGET, }).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { @@ -727,10 +661,7 @@ describe("storage rm", () => { const exit = yield* storageRm({ files: [], recursive: true, - remote: Option.none(), - linked: false, - local: false, - projectRef: Option.none(), + ...NO_TARGET, }).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { @@ -750,10 +681,7 @@ describe("storage rm", () => { const exit = yield* storageRm({ files: ["ss:///private/a.pdf"], recursive: false, - remote: Option.some({ _tag: "Linked" } as const), - linked: false, - local: false, - projectRef: Option.none(), + ...BARE_REMOTE, }).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isSuccess(exit)).toBe(true); expect(requests.some((r) => r.url.startsWith(`https://${VALID_REF}.supabase.co`))).toBe(true); @@ -809,10 +737,7 @@ describe("storage rm", () => { const exit = yield* storageRm({ files: ["ss:///private/a.pdf"], recursive: false, - remote: Option.none(), - linked: false, - local: false, - projectRef: Option.none(), + ...NO_TARGET, }).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { @@ -835,10 +760,7 @@ describe("storage rm", () => { const exit = yield* storageRm({ files: ["ss:///private/a.pdf"], recursive: false, - remote: Option.none(), - linked: false, - local: false, - projectRef: Option.none(), + ...NO_TARGET, }).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isSuccess(exit)).toBe(true); expect(requests.some((r) => r.method === "DELETE")).toBe(true); @@ -860,10 +782,7 @@ describe("storage rm", () => { const exit = yield* storageRm({ files: ["ss:///private/a.pdf"], recursive: false, - remote: Option.none(), - linked: false, - local: false, - projectRef: Option.none(), + ...NO_TARGET, }).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { @@ -887,10 +806,7 @@ describe("storage rm", () => { const exit = yield* storageRm({ files: ["ss:///private/a.pdf"], recursive: false, - remote: Option.none(), - linked: false, - local: false, - projectRef: Option.none(), + ...NO_TARGET, }).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isSuccess(exit)).toBe(true); const success = out.messages.find((m) => m.type === "success"); @@ -915,10 +831,7 @@ describe("storage rm", () => { yield* storageRm({ files: ["ss:///private/a.pdf"], recursive: false, - remote: Option.some({ _tag: "Linked" } as const), - linked: false, - local: false, - projectRef: Option.none(), + ...BARE_REMOTE, }); expect( managementRequests.map(({ method, urlWithParams }) => `${method} ${urlWithParams}`), @@ -954,10 +867,7 @@ describe("stack backend", () => { const exit = yield* storageRm({ files: ["ss:///private/a.pdf"], recursive: false, - remote: Option.none(), - linked: false, - local: false, - projectRef: Option.none(), + ...NO_TARGET, }).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isSuccess(exit)).toBe(true); const del = requests.find( @@ -984,10 +894,7 @@ describe("stack backend", () => { const exit = yield* storageRm({ files: ["ss:///private/a.pdf"], recursive: false, - remote: Option.none(), - linked: false, - local: false, - projectRef: Option.none(), + ...NO_TARGET, }).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isSuccess(exit)).toBe(true); expect(requests.some((r) => r.method === "DELETE")).toBe(false); @@ -1008,10 +915,7 @@ describe("stack backend", () => { const exit = yield* storageRm({ files: ["ss:///private/a.pdf"], recursive: false, - remote: Option.none(), - linked: false, - local: false, - projectRef: Option.none(), + ...NO_TARGET, }).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { @@ -1042,10 +946,7 @@ describeHostedConsent("storage rm", { run: storageRm({ files: ["ss:///private/a.pdf"], recursive: false, - remote: Option.some({ _tag: "Ref", ref: VALID_REF } as const), - linked: false, - local: false, - projectRef: Option.none(), + ...remoteTarget(VALID_REF), }).pipe(Effect.provide(storage.layer)), mutations: storage.writes, }; diff --git a/apps/cli/tests/helpers/db-target-selection.ts b/apps/cli/tests/helpers/db-target-selection.ts index 0cd22b21b6..b6c373a388 100644 --- a/apps/cli/tests/helpers/db-target-selection.ts +++ b/apps/cli/tests/helpers/db-target-selection.ts @@ -48,6 +48,9 @@ const selecting = (remote: RemoteValue): TargetFlagValuesWithDbUrl => ({ remote: Option.some(remote), }); +/** A bare `--remote`: the linked project. */ +export const BARE_REMOTE: TargetFlagValuesWithDbUrl = selecting({ _tag: "Linked" }); + /** `--remote=<ref>`. */ export const remoteTarget = (ref: string): TargetFlagValuesWithDbUrl => selecting({ _tag: "Ref", ref }); @@ -227,7 +230,7 @@ export function describeDbTargetSelection<R>( rowTest("bare-remote", "targets the linked project for a bare --remote, with no warning", { args: ["--remote"], - target: selecting({ _tag: "Linked" }), + target: BARE_REMOTE, check: (ctx) => Effect.gen(function* () { yield* ctx.run; @@ -363,7 +366,7 @@ export function describeDbTargetSelection<R>( rowTest("remote-with-local", "rejects --remote combined with --local before anything runs", { args: ["--remote", "--local"], - target: { ...selecting({ _tag: "Linked" }), local: true }, + target: { ...BARE_REMOTE, local: true }, check: (ctx) => Effect.gen(function* () { const error = yield* Effect.flip(ctx.run); From 1409f91519e797d40f54b7483339f3d37d5fa800 Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Tue, 20 Oct 2026 14:37:51 +0100 Subject: [PATCH 238/291] test(cli): require db push consent before roles, vault, migrations and seeds --- .../commands/db/push/push.integration.test.ts | 85 +++++-------------- 1 file changed, 20 insertions(+), 65 deletions(-) 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 b856f80c5b..e7a4e1f427 100644 --- a/apps/cli/src/commands/db/push/push.integration.test.ts +++ b/apps/cli/src/commands/db/push/push.integration.test.ts @@ -321,12 +321,6 @@ const failSuggestion = ( ): string | undefined => Exit.isFailure(exit) ? exit.cause.reasons.find(Cause.isFailReason)?.error.suggestion : undefined; -const failError = (exit: Exit.Exit<unknown, unknown>): { _tag: string; message: string } => - (Exit.isFailure(exit) ? exit.cause.reasons.find(Cause.isFailReason)?.error : undefined) as { - _tag: string; - message: string; - }; - const MIGRATION_DIR = "supabase/migrations"; const migrationFile = (version: string, body = "create table t ();") => ({ [`${MIGRATION_DIR}/${version}_test.sql`]: body, @@ -709,40 +703,6 @@ describe("db push", () => { }); }); - 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], - }); - return Effect.gen(function* () { - 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("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", - }); - return Effect.gen(function* () { - 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"); - 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, @@ -827,26 +787,6 @@ describe("db push", () => { }, ); - it.effect("asks before pushing to a --remote=<db-url> that is not local", () => { - const { layer, out, conn } = setup(tmp.current, { - toml: 'project_id = "test"\n', - files: migrationFile("20240101000000"), - isLocal: false, - confirm: [true], - }); - return Effect.gen(function* () { - yield* dbPush({ - ...DEFAULT_FLAGS, - remote: Option.some({ - _tag: "DbUrl", - url: "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 }); @@ -1408,34 +1348,49 @@ describe("db push", () => { }); }); +const CONSENT_READS = new Set([LIST_MIGRATIONS, SELECT_SEEDS, READ_VAULT]); + 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.writeFileString( + `${workdir}/supabase/config.toml`, + 'project_id = "test"\n\n[db.vault]\nexisting = "v1"\nfresh = "v2"\n', + ); yield* fs.makeDirectory(`${workdir}/${MIGRATION_DIR}`, { recursive: true }); yield* fs.writeFileString( `${workdir}/${MIGRATION_DIR}/20240101000000_test.sql`, "create table t ();", ); + yield* fs.writeFileString(`${workdir}/supabase/roles.sql`, "create role app;"); + yield* fs.writeFileString(`${workdir}/supabase/seed.sql`, "insert into t values (1);"); }), invoke: ({ out }) => { - const conn = mockConnection({}); + const conn = mockConnection({ vaultRows: [{ id: "id-1", name: "existing" }] }); return { - run: dbPush({ ...DEFAULT_FLAGS, remote: REMOTE_LINKED }).pipe( + run: dbPush({ + ...DEFAULT_FLAGS, + remote: REMOTE_LINKED, + includeRoles: true, + includeSeed: Option.some(true), + }).pipe( Effect.provide( Layer.mergeAll( conn.layer, mockResolver().layer, - configValuesLayer({ output: out.layer }), + configValuesLayer({ + output: out.layer, + flags: [flagInput("db.seed.enabled", "include-seed", true)], + }), Layer.succeed(DnsResolverFlag, "native"), ), ), ), mutations: () => - conn.queries.filter((q) => q.sql.includes("INSERT INTO supabase_migrations")).length, + conn.execs.length + conn.queries.filter((query) => !CONSENT_READS.has(query.sql)).length, }; }, }); From fd4d11058453d58dcc91d4b889871ea8a899fd18 Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Tue, 20 Oct 2026 14:42:19 +0100 Subject: [PATCH 239/291] fix(cli): accept the deprecated selector flags with db diff --from/--to Explicit mode rejected --local, --linked, --db-url and --project-ref. It now accepts them with a deprecation warning and keeps rejecting --remote. - --local, --linked and --db-url warn and change nothing. - --project-ref=<value> redirects the remote (or linked) side to <value>, read like a --from value; the warning names --from=<ref> or --to=<ref>. Without a remote side it is an error that points at --from=<ref> or --to=<ref>. - The Target: announcements and the JSON target show the redirected project. - Conflicts between the deprecated flags themselves still fail. --- apps/cli/docs/command-target.md | 2 + apps/cli/docs/supabase/db/diff.md | 2 +- .../target/target-deprecation.ts | 12 +- apps/cli/src/commands/db/diff/SIDE_EFFECTS.md | 50 ++- apps/cli/src/commands/db/diff/diff.errors.ts | 4 +- .../cli/src/commands/db/diff/diff.explicit.ts | 37 +++ .../db/diff/diff.explicit.unit.test.ts | 87 ++++- apps/cli/src/commands/db/diff/diff.handler.ts | 52 +-- .../commands/db/diff/diff.integration.test.ts | 314 ++++++++++++++++-- ...t-selection-and-hosted-mutation-consent.md | 7 +- 10 files changed, 496 insertions(+), 71 deletions(-) diff --git a/apps/cli/docs/command-target.md b/apps/cli/docs/command-target.md index 757ebde448..e1f502897e 100644 --- a/apps/cli/docs/command-target.md +++ b/apps/cli/docs/command-target.md @@ -92,6 +92,8 @@ A command's own `SIDE_EFFECTS.md` records where else its password can come from A warning never repeats an operand, a flag value or a database URL, because a CI log would keep them, and it carries no example command. Two values have their own wording: a branch whose name is a boolean literal (`true`, `false`, `yes`, `no`, `on`, `off`, `y`, `n`, `1`, `0`) is reached with `--remote=<its ref or UUID>`, and a keyword `--db-url` connection string is converted to a `postgresql://` URL for `--remote=<url>`. +`db diff --from`/`--to` accepts the same flags and warns the same way, but names the `--from`/`--to` value that replaces each: `Use --from=remote or --to=remote instead` for `--linked`, `Use --from=<ref> or --to=<ref> instead` for `--project-ref` (`<branch>` for a branch name) and `Use --from=<url> or --to=<url> instead` for `--db-url`. `--project-ref` also points a `remote` side, or the deprecated `linked`, at the project it names, and fails when neither side is `remote`; `--linked`, `--db-url` and `--local` change nothing. `--remote` stays an error there. + ## Hosted-mutation consent Every persistent mutation of a hosted resource asks for consent first, including a write through a non-local `--remote=<url>`. A loopback host on the local or shadow database ports, or the local stack's endpoint, counts as local. diff --git a/apps/cli/docs/supabase/db/diff.md b/apps/cli/docs/supabase/db/diff.md index 30a411e3a8..38c6c3d1bf 100644 --- a/apps/cli/docs/supabase/db/diff.md +++ b/apps/cli/docs/supabase/db/diff.md @@ -8,7 +8,7 @@ Diffs the local database by default, which requires the local development stack 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. -In `--from`/`--to`, `remote` always means the linked project; passing `--remote` or one of its aliases together with `--from` or `--to` is an error. Explicit `--from`/`--to` mode always uses pg-delta. In this mode, `-f` is ignored and stdout (or `--output`) is a flattened representation for review, not a portable apply script. Do not apply it directly with plain `psql -f`: transactional units can contain `SET LOCAL` preambles that only take effect inside a transaction, while plans that mix transactional and non-transactional units cannot safely be wrapped in one transaction. To create an applicable migration, use normal target mode with `supabase db diff -f <name>`, then apply it through `supabase db reset` locally or `supabase db push --remote` against the linked project. These paths preserve the plan's per-unit transaction semantics. +In `--from`/`--to`, `remote` means the linked project; passing `--remote` together with `--from` or `--to` is an error. The deprecated aliases still work there and warn: `--project-ref` points a `remote` side at the project it names, and the rest change nothing. Explicit `--from`/`--to` mode always uses pg-delta. In this mode, `-f` is ignored and stdout (or `--output`) is a flattened representation for review, not a portable apply script. Do not apply it directly with plain `psql -f`: transactional units can contain `SET LOCAL` preambles that only take effect inside a transaction, while plans that mix transactional and non-transactional units cannot safely be wrapped in one transaction. To create an applicable migration, use normal target mode with `supabase db diff -f <name>`, then apply it through `supabase db reset` locally or `supabase db push --remote` against the linked project. These paths preserve the plan's per-unit transaction semantics. By default, all schemas in the target database are diffed. Use the `--schema public,extensions` flag to restrict diffing to a subset of schemas. diff --git a/apps/cli/src/command-internal/target/target-deprecation.ts b/apps/cli/src/command-internal/target/target-deprecation.ts index be2624e0f3..3e74557fd7 100644 --- a/apps/cli/src/command-internal/target/target-deprecation.ts +++ b/apps/cli/src/command-internal/target/target-deprecation.ts @@ -56,13 +56,19 @@ export const refFromDbUrl = (url: string, projectHost: string): string | undefin } }; +/** The first sentence of every alias warning: which flags are deprecated, and that v4 removes them. */ +export const deprecationLead = (flags: ReadonlyArray<string>): string => + `${joinFlags(flags)} ${flags.length > 1 ? "are" : "is"} deprecated and will be removed in v4.`; + +export const LOCAL_NOT_NEEDED_MESSAGE = + "--local isn't needed (local is the default) and will be removed in v4"; + const aliasMessage = ( deprecation: Extract<TargetDeprecation, { readonly kind: "remote-alias" }>, projectHost: string, ): string => { const { flags, remote } = deprecation; - const verb = flags.length > 1 ? "are" : "is"; - const lead = `${joinFlags(flags)} ${verb} deprecated and will be removed in v4.`; + const lead = deprecationLead(flags); if (remote._tag === "Branch" && isBooleanLiteral(remote.name)) { return `${lead} Reach a branch named "${remote.name}" with ${BRANCH_BY_REF} instead`; @@ -82,6 +88,6 @@ export const deprecationMessages = ( ): ReadonlyArray<string> => deprecations.map((deprecation) => deprecation.kind === "local" - ? "--local isn't needed (local is the default) and will be removed in v4" + ? LOCAL_NOT_NEEDED_MESSAGE : aliasMessage(deprecation, projectHost), ); diff --git a/apps/cli/src/commands/db/diff/SIDE_EFFECTS.md b/apps/cli/src/commands/db/diff/SIDE_EFFECTS.md index 4b525d5a67..e0e38cd2bb 100644 --- a/apps/cli/src/commands/db/diff/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/db/diff/SIDE_EFFECTS.md @@ -141,12 +141,12 @@ migra/pg-delta-engine-specific). ## Exit Codes -| Code | Condition | -| ---- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `0` | success; empty diff ("No schema changes found") | -| `1` | `--from` without `--to`; `--remote` or an alias combined with `--from`/`--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` | a `--from`/`--to` branch naming no branch of the parent project | -| `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`; `--remote` combined with `--from`/`--to`; `--project-ref` with no `remote` side or naming a URL; conflicting selector flags; 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` | a `--from`/`--to` branch naming no branch of the parent project | +| `1` | `--use-pg-schema` passed at all (any value, including `=false`) — removed, see Notes | ## Output @@ -247,18 +247,36 @@ announces one. (`host=… password=…`) with `--from takes local, migrations, remote, a project ref, a branch name or a postgresql:// URL; convert a connection string to a postgresql:// URL and pass --from=<url>.`, and a non-postgres URL with the same first sentence (naming `--from=<ref>` for a project's own - `https://<ref>.supabase.co` URL); `--to` likewise. In `--from`/`--to`, `remote` is always the linked project. `--from linked` / `--to linked` - still work and warn `--from linked is deprecated and will -be removed in v4. Use --from=remote instead` (and `--to=remote` for `--to`). A `--remote` selector (or an alias: `--linked`, - `--project-ref`, `--db-url`, `--local`) given alongside fails with `--remote doesn't apply with ---from/--to; use --from=remote or --to=remote.` (the flag named is the one passed; `--db-url` - suggests `<db-url>` and `--local` suggests `local`), before any target resolves, and the default - `Target:` hint is not printed. The linked project resolves once, so `--from remote ---to remote` announces it once, and a branch or ref used on both sides shares one connection. A branch - endpoint looks the name up in the branch list of `SUPABASE_PROJECT_ID`, else the linked - project. `--use-pgadmin` honors the same selection (CLI-1968 — same target resolve); + `https://<ref>.supabase.co` URL); `--to` likewise. In `--from`/`--to`, `remote` is the linked + project, or the one `--project-ref` names. `--from linked` / `--to linked` still work and warn + `--from linked is deprecated and will be removed in v4. Use --from=remote instead` (and + `--to=remote` for `--to`). A `--remote` selector given alongside fails with + `--remote doesn't apply with --from/--to; use --from=remote or --to=remote.` before any target + resolves, and the default `Target:` hint is not printed. The linked project resolves once, so + `--from remote --to remote` announces it once, and a branch or ref used on both sides shares + one connection. A branch endpoint looks the name up in the branch list of + `SUPABASE_PROJECT_ID`, else the linked project. `--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` with a deprecated selector flag.** `--local`, `--linked`, `--db-url` + and `--project-ref` are accepted. Each warns on stderr before any target resolves, names the + value that replaces it, and never repeats a URL: + - `--local`: `--local isn't needed (local is the default) and will be removed in v4`. + - `--linked`: `--linked is deprecated and will be removed in v4. Use --from=remote or --to=remote instead`. + - `--db-url`: `--db-url is deprecated and will be removed in v4. Use --from=<url> or --to=<url> instead`. + - `--project-ref=<ref|branch>`: `--project-ref is deprecated and will be removed in v4. Use --from=<ref> or --to=<ref> instead` + (`<branch>` for a branch name). With `--linked` the two share one warning: + `--linked and --project-ref are deprecated and will be removed in v4. Use --from=<ref> or --to=<ref> instead`. + + `--local`, `--linked` and `--db-url` change nothing. `--project-ref` resolves every `remote` (or + deprecated `linked`) side to the project it names, read like a `--from` value, so the `Target:` + lines and the JSON `target` show that project. With no `remote` side it fails with + `--project-ref only applies when --from or --to is remote; to diff another project, use --from=<ref> or --to=<ref>.` + and a URL value fails with `--project-ref takes a project ref or a branch name, not a URL.` + Selector flags that conflict (`--linked` with `--local`, `--project-ref` with `--db-url`) fail as + they do without `--from`/`--to`. + - **Explicit `--from`/`--to` config and URLs.** The config is read when the first endpoint that needs it resolves, not before. A hosted endpoint resolved first merges its `[remotes.<ref>]` block into that read, before validation and before its temporary login role is minted, so diff --git a/apps/cli/src/commands/db/diff/diff.errors.ts b/apps/cli/src/commands/db/diff/diff.errors.ts index bfdea3c114..ba1a18a8ed 100644 --- a/apps/cli/src/commands/db/diff/diff.errors.ts +++ b/apps/cli/src/commands/db/diff/diff.errors.ts @@ -21,8 +21,8 @@ export class DbDiffEngineConflictError extends Data.TaggedError("DbDiffEngineCon /** * Explicit diff mode got only one of `--from` / `--to`, a non-postgres URL or keyword connection - * string for one of them, or a target selector (`--remote` and its aliases), which it doesn't take; message text is - * an established output contract. + * string for one of them, `--remote`, which it doesn't take, or a `--project-ref` that names a URL + * or has no `remote` side to apply to; message text is an established output contract. */ export class DbDiffExplicitFlagsError extends Data.TaggedError("DbDiffExplicitFlagsError")<{ readonly message: string; diff --git a/apps/cli/src/commands/db/diff/diff.explicit.ts b/apps/cli/src/commands/db/diff/diff.explicit.ts index 428e63d490..154474d5cb 100644 --- a/apps/cli/src/commands/db/diff/diff.explicit.ts +++ b/apps/cli/src/commands/db/diff/diff.explicit.ts @@ -4,7 +4,14 @@ import { parseNamedRemote, type NamedRemoteRejection, type NamedRemoteValue, + type RemoteValue, } from "../../../command-internal/target/remote-value.ts"; +import { DB_URL_PATTERN } from "../../../command-internal/target/remote-vocabulary.ts"; +import { + deprecationLead, + LOCAL_NOT_NEEDED_MESSAGE, +} from "../../../command-internal/target/target-deprecation.ts"; +import type { TargetDeprecation } from "../../../command-internal/target/target-selector.ts"; /** What an explicit `--from`/`--to` value names. */ export type ExplicitRef = @@ -35,3 +42,33 @@ export function classifyExplicitRef(ref: string): ExplicitRef { } } } + +const replacementOf = (remote: RemoteValue): string => { + switch (remote._tag) { + case "Linked": + return "--from=remote or --to=remote"; + case "Ref": + return "--from=<ref> or --to=<ref>"; + case "Branch": + return "--from=<branch> or --to=<branch>"; + case "DbUrl": + return "--from=<url> or --to=<url>"; + } +}; + +/** The warning for each deprecated selector flag an explicit run used, naming the `--from`/`--to` value that replaces it; a database URL is never echoed. */ +export const explicitDeprecationMessages = ( + deprecations: ReadonlyArray<TargetDeprecation>, +): ReadonlyArray<string> => + deprecations.map((deprecation) => { + if (deprecation.kind === "local") return LOCAL_NOT_NEEDED_MESSAGE; + const { flags, remote } = deprecation; + const lead = deprecationLead(flags); + if (remote._tag === "Branch" && classifyExplicitRef(remote.name)._tag !== "Branch") { + return `${lead} Reach a branch named "${remote.name}" with --from=<its ref or UUID> or --to=<its ref or UUID> instead`; + } + if (remote._tag === "DbUrl" && !DB_URL_PATTERN.test(remote.url)) { + return `${lead} Convert the connection string to a postgresql:// URL and pass it as --from=<url> or --to=<url> instead`; + } + return `${lead} Use ${replacementOf(remote)} instead`; + }); diff --git a/apps/cli/src/commands/db/diff/diff.explicit.unit.test.ts b/apps/cli/src/commands/db/diff/diff.explicit.unit.test.ts index 9e0fd2cd6c..c2b743ed0d 100644 --- a/apps/cli/src/commands/db/diff/diff.explicit.unit.test.ts +++ b/apps/cli/src/commands/db/diff/diff.explicit.unit.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; -import { classifyExplicitRef } from "./diff.explicit.ts"; +import type { TargetDeprecation } from "../../../command-internal/target/target-selector.ts"; +import { classifyExplicitRef, explicitDeprecationMessages } from "./diff.explicit.ts"; const REF = "abcdefghijklmnopqrst"; const UUID = "11111111-2222-4333-8444-555555555555"; @@ -50,3 +51,87 @@ describe("classifyExplicitRef", () => { expect(classifyExplicitRef(UUID)).toEqual({ _tag: "Branch", name: UUID }); }); }); + +describe("explicitDeprecationMessages", () => { + const messages = (deprecations: ReadonlyArray<TargetDeprecation>) => + explicitDeprecationMessages(deprecations); + + it.each([ + { + flags: ["--linked"], + remote: { _tag: "Linked" }, + expected: + "--linked is deprecated and will be removed in v4. Use --from=remote or --to=remote instead", + }, + { + flags: ["--project-ref"], + remote: { _tag: "Ref", ref: REF }, + expected: + "--project-ref is deprecated and will be removed in v4. Use --from=<ref> or --to=<ref> instead", + }, + { + flags: ["--project-ref"], + remote: { _tag: "Branch", name: "staging" }, + expected: + "--project-ref is deprecated and will be removed in v4. Use --from=<branch> or --to=<branch> instead", + }, + { + flags: ["--project-ref"], + remote: { _tag: "Branch", name: "true" }, + expected: + "--project-ref is deprecated and will be removed in v4. Use --from=<branch> or --to=<branch> instead", + }, + { + flags: ["--linked", "--project-ref"], + remote: { _tag: "Ref", ref: REF }, + expected: + "--linked and --project-ref are deprecated and will be removed in v4. Use --from=<ref> or --to=<ref> instead", + }, + ] as const)("points $flags at $expected", ({ flags, remote, expected }) => { + expect(messages([{ kind: "remote-alias", flags, remote }])).toEqual([expected]); + }); + + it.each(["local", "migrations", "remote", "linked"])( + "says a branch named %s is reached by its ref or UUID, since the keyword wins", + (name) => { + expect( + messages([ + { kind: "remote-alias", flags: ["--project-ref"], remote: { _tag: "Branch", name } }, + ]), + ).toEqual([ + `--project-ref is deprecated and will be removed in v4. Reach a branch named "${name}" with --from=<its ref or UUID> or --to=<its ref or UUID> instead`, + ]); + }, + ); + + it("points --db-url at --from=<url> without echoing the URL", () => { + const url = "postgresql://postgres:hunter2@db.example.com:5432/postgres"; + const [message] = messages([ + { kind: "remote-alias", flags: ["--db-url"], remote: { _tag: "DbUrl", url } }, + ]); + expect(message).toBe( + "--db-url is deprecated and will be removed in v4. Use --from=<url> or --to=<url> instead", + ); + expect(message).not.toContain("hunter2"); + }); + + it("asks for a postgresql:// URL when --db-url held a keyword connection string", () => { + const [message] = messages([ + { + kind: "remote-alias", + flags: ["--db-url"], + remote: { _tag: "DbUrl", url: "host=db.example.com password=hunter2" }, + }, + ]); + expect(message).toBe( + "--db-url is deprecated and will be removed in v4. Convert the connection string to a postgresql:// URL and pass it as --from=<url> or --to=<url> instead", + ); + expect(message).not.toContain("hunter2"); + }); + + it("says --local is not needed", () => { + expect(messages([{ kind: "local" }])).toEqual([ + "--local isn't needed (local is the default) and will be removed in v4", + ]); + }); +}); diff --git a/apps/cli/src/commands/db/diff/diff.handler.ts b/apps/cli/src/commands/db/diff/diff.handler.ts index 8bd76017fd..09489bb050 100644 --- a/apps/cli/src/commands/db/diff/diff.handler.ts +++ b/apps/cli/src/commands/db/diff/diff.handler.ts @@ -35,6 +35,7 @@ import { renderDbUrlTarget, renderLocalTarget, } from "../../../command-internal/target/target-identity.ts"; +import { parseTargetSelector } from "../../../command-internal/target/target-selector.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"; @@ -88,7 +89,7 @@ import { writePgDeltaMigrations } from "../shared/pgdelta-migrations.write.ts"; import { type PgDeltaContext, isPgDeltaDebugEnabled } from "../../../command-internal/pgdelta.ts"; import { prepareShadowSource } from "../shared/shadow-source.ts"; import type { DbDiffFlags } from "./diff.command.ts"; -import { classifyExplicitRef } from "./diff.explicit.ts"; +import { classifyExplicitRef, explicitDeprecationMessages } from "./diff.explicit.ts"; import { DbDiffDbNotRunningError, DbDiffEngineConflictError, @@ -123,25 +124,6 @@ const declarativeFilesIgnoredNote = (displayPath: string, nextSteps: string) => const EXPLICIT_REF_VALUES_MESSAGE = (flag: "--from" | "--to") => `${flag} takes local, migrations, remote, a project ref, a branch name or a postgresql:// URL`; -const EXPLICIT_SIDE_FOR_SELECTOR = { - "--remote": "remote", - "--linked": "remote", - "--project-ref": "remote", - "--db-url": "<db-url>", - "--local": "local", -} as const; - -const givenSelectorFlag = ( - flags: DbDiffFlags, -): keyof typeof EXPLICIT_SIDE_FOR_SELECTOR | undefined => { - if (Option.isSome(flags.remote)) return "--remote"; - if (flags.linked) return "--linked"; - if (Option.isSome(flags.projectRef)) return "--project-ref"; - if (Option.isSome(flags.dbUrl)) return "--db-url"; - if (flags.local) return "--local"; - return undefined; -}; - export const dbDiff = Effect.fn("db.diff")(function* (flags: DbDiffFlags) { if (Option.isSome(flags.usePgSchema)) { return yield* removedFlag("--use-pg-schema", "Use the default pg-delta engine or --use-migra."); @@ -202,13 +184,33 @@ export const dbDiff = Effect.fn("db.diff")(function* (flags: DbDiffFlags) { message: "must set both --from and --to when using explicit diff mode", }); } - const selectorFlag = givenSelectorFlag(flags); - if (selectorFlag !== undefined) { - const side = EXPLICIT_SIDE_FOR_SELECTOR[selectorFlag]; + if (Option.isSome(flags.remote)) { return yield* new DbDiffExplicitFlagsError({ - message: `${selectorFlag} doesn't apply with --from/--to; use --from=${side} or --to=${side}.`, + message: "--remote doesn't apply with --from/--to; use --from=remote or --to=remote.", }); } + const { selection, deprecations } = yield* Effect.fromResult(parseTargetSelector(flags)); + const projectRefTarget = + selection._tag === "Ref" || selection._tag === "Branch" ? selection : undefined; + if (Option.isSome(flags.projectRef)) { + if (projectRefTarget === undefined) { + return yield* new DbDiffExplicitFlagsError({ + message: "--project-ref takes a project ref or a branch name, not a URL.", + }); + } + if ( + classifyExplicitRef(from)._tag !== "Remote" && + classifyExplicitRef(to)._tag !== "Remote" + ) { + return yield* new DbDiffExplicitFlagsError({ + message: + "--project-ref only applies when --from or --to is remote; to diff another project, use --from=<ref> or --to=<ref>.", + }); + } + } + for (const message of explicitDeprecationMessages(deprecations)) { + yield* warnTarget(message); + } // The linked ref resolved so far: later config reads and `migrations` catalog exports merge // its `[remotes.<ref>]` override; endpoints resolved before any linked ref use the base. let mergedLinkedRef: string | undefined; @@ -329,7 +331,7 @@ export const dbDiff = Effect.fn("db.diff")(function* (flags: DbDiffFlags) { `${flag} linked is deprecated and will be removed in v4. Use ${flag}=remote instead`, ); } - return yield* hostedEndpoint({ _tag: "Linked" }); + return yield* hostedEndpoint(projectRefTarget ?? { _tag: "Linked" }); case "Ref": case "Branch": return yield* hostedEndpoint(explicit); 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 ad4617afe2..0453f6f38c 100644 --- a/apps/cli/src/commands/db/diff/diff.integration.test.ts +++ b/apps/cli/src/commands/db/diff/diff.integration.test.ts @@ -2567,39 +2567,230 @@ describe("db diff", () => { }); describe.each([ - { flag: "--remote", side: "remote", selector: { remote: REMOTE_LINKED } }, + { selector: { remote: REMOTE_LINKED } }, { - flag: "--remote", - side: "remote", selector: { remote: Option.some({ _tag: "Ref", ref: "flagflagflagflagflag" } as const) }, }, { - flag: "--remote", - side: "remote", selector: { remote: Option.some({ _tag: "DbUrl", url: "postgresql://x" } as const) }, }, - { flag: "--linked", side: "remote", selector: { linked: true } }, - { - flag: "--project-ref", - side: "remote", - selector: { projectRef: Option.some("flagflagflagflagflag") }, + ] satisfies ReadonlyArray<{ selector: Partial<DbDiffFlags> }>)( + "explicit --from/--to with --remote", + ({ selector }) => { + it.effect("is rejected before any target resolves", () => { + const s = setup(tmp.current); + return Effect.gen(function* () { + const error = yield* dbDiff( + flags({ from: Option.some("local"), to: Option.some("migrations"), ...selector }), + ).pipe(Effect.flip); + expect(error).toMatchObject({ + _tag: "DbDiffExplicitFlagsError", + message: "--remote doesn't apply with --from/--to; use --from=remote or --to=remote.", + }); + expect(s.resolverCalls).toEqual([]); + expect(s.explicitDiffCalls).toEqual([]); + expect(s.cache.cached).toBe(false); + expect(warningsOf(s.out)).toEqual([]); + }).pipe(Effect.provide(s.layer)); + }); }, - { flag: "--db-url", side: "<db-url>", selector: { dbUrl: Option.some("postgresql://x") } }, - { flag: "--local", side: "local", selector: { local: true } }, - ] satisfies ReadonlyArray<{ - flag: string; - side: string; - selector: Partial<DbDiffFlags>; - }>)("explicit --from/--to with $flag", ({ flag, side, selector }) => { - it.effect("is rejected before any target resolves", () => { - const s = setup(tmp.current); + ); + + describe("explicit --from/--to with the deprecated selector flags", () => { + const FLAG_PROJECT_REF = "flagflagflagflagflag"; + const CREDENTIALED_URL = "postgresql://u:secret@db.example.com:6543/postgres"; + const PROJECT_REF_WARNING = + "--project-ref is deprecated and will be removed in v4. Use --from=<ref> or --to=<ref> instead"; + + const hostedSetup = () => + setup(tmp.current, { + isLocal: false, + projectId: Option.some(VALID_REF), + diffSql: "create table r ();\n", + }); + + it.effect.each([ + { + flag: "--local", + selector: { local: true }, + warning: "--local isn't needed (local is the default) and will be removed in v4", + }, + { + flag: "--linked", + selector: { linked: true }, + warning: + "--linked is deprecated and will be removed in v4. Use --from=remote or --to=remote instead", + }, + { + flag: "--db-url", + selector: { dbUrl: Option.some(CREDENTIALED_URL) }, + warning: + "--db-url is deprecated and will be removed in v4. Use --from=<url> or --to=<url> instead", + }, + ] satisfies ReadonlyArray<{ flag: string; selector: Partial<DbDiffFlags>; warning: string }>)( + "$flag warns and leaves --from local --to migrations as they are", + ({ selector, warning }) => { + const s = setup(tmp.current, { diffSql: "create table c ();\n" }); + return Effect.gen(function* () { + yield* dbDiff( + flags({ from: Option.some("local"), to: Option.some("migrations"), ...selector }), + ); + expect(warningsOf(s.out)).toEqual([warning]); + expect(s.explicitDiffCalls[0]?.source).toMatchObject({ + kind: "database", + connectOptions: { isLocal: true }, + }); + expect(s.explicitDiffCalls[0]?.desired).toEqual({ kind: "migrations" }); + expect(s.resolverCalls).toEqual([]); + expect(s.cache.cached).toBe(false); + expect(`${s.out.stdoutText}${s.out.stderrText}`).not.toContain("secret"); + }).pipe(Effect.provide(s.layer)); + }, + ); + + it.effect("--linked with --from remote resolves the linked project once", () => { + const s = hostedSetup(); + return Effect.gen(function* () { + yield* dbDiff( + flags({ from: Option.some("remote"), to: Option.some("local"), linked: true }), + ); + expect(s.resolverCalls.map((call) => call.target)).toEqual([ + expect.objectContaining({ kind: "linked", ref: VALID_REF, source: "file" }), + ]); + expect(warningsOf(s.out)).toEqual([ + "--linked is deprecated and will be removed in v4. Use --from=remote or --to=remote instead", + ]); + }).pipe(Effect.provide(s.layer)); + }); + + it.effect.each( + (["from", "to"] as const).flatMap((side) => + (["remote", "linked"] as const).map((keyword) => ({ side, keyword })), + ), + )("--project-ref redirects --$side $keyword to the project it names", ({ side, keyword }) => { + const s = hostedSetup(); + const other = side === "from" ? "to" : "from"; + return Effect.gen(function* () { + yield* dbDiff( + flags({ + [side]: Option.some(keyword), + [other]: Option.some("migrations"), + projectRef: Option.some(FLAG_PROJECT_REF), + }), + ); + expect(s.resolverCalls.map((call) => call.target)).toEqual([ + expect.objectContaining({ kind: "linked", ref: FLAG_PROJECT_REF, source: "flag" }), + ]); + expect(targetLines(s.out)).toEqual([`Target: project ${FLAG_PROJECT_REF}`]); + expect(yield* CommandTarget.use((cell) => cell.json)).toEqual( + Option.some({ kind: "linked", project_ref: FLAG_PROJECT_REF, environment: "unverified" }), + ); + expect(warningsOf(s.out)).toEqual([ + PROJECT_REF_WARNING, + ...(keyword === "linked" + ? [ + `--${side} linked is deprecated and will be removed in v4. Use --${side}=remote instead`, + ] + : []), + ]); + }).pipe(Effect.provide(s.layer)); + }); + + it.effect("--project-ref redirects both remote sides to one shared connection", () => { + const s = hostedSetup(); + return Effect.gen(function* () { + yield* dbDiff( + flags({ + from: Option.some("remote"), + to: Option.some("remote"), + projectRef: Option.some(FLAG_PROJECT_REF), + }), + ); + expect(s.resolverCalls).toHaveLength(1); + expect(targetLines(s.out)).toEqual([`Target: project ${FLAG_PROJECT_REF}`]); + expect(warningsOf(s.out)).toEqual([PROJECT_REF_WARNING]); + }).pipe(Effect.provide(s.layer)); + }); + + it.effect("--project-ref=<branch> redirects the remote side to the branch's project", () => { + const branchApi = branchListApi(); + const s = hostedSetup(); + return Effect.gen(function* () { + yield* dbDiff( + flags({ + from: Option.some("remote"), + to: Option.some("migrations"), + projectRef: Option.some("feature-x"), + }), + ).pipe(Effect.provide(Layer.merge(s.layer, branchApi.layer))); + expect(s.resolverCalls.map((call) => call.target)).toEqual([ + expect.objectContaining({ kind: "linked", ref: BRANCH_PROJECT_REF, source: "flag" }), + ]); + expect(warningsOf(s.out)).toEqual([ + "--project-ref is deprecated and will be removed in v4. Use --from=<branch> or --to=<branch> instead", + ]); + assertPermissionDrift({ + command: "db diff", + activeFlags: { "project-ref": "feature-x", from: "remote", to: "migrations" }, + requests: branchApi.requests, + }); + }); + }); + + it.effect("--linked with --project-ref redirects the remote side under one warning", () => { + const s = hostedSetup(); + return Effect.gen(function* () { + yield* dbDiff( + flags({ + from: Option.some("remote"), + to: Option.some("migrations"), + linked: true, + projectRef: Option.some(FLAG_PROJECT_REF), + }), + ); + expect(s.resolverCalls.map((call) => call.target)).toEqual([ + expect.objectContaining({ kind: "linked", ref: FLAG_PROJECT_REF, source: "flag" }), + ]); + expect(warningsOf(s.out)).toEqual([ + "--linked and --project-ref are deprecated and will be removed in v4. Use --from=<ref> or --to=<ref> instead", + ]); + }).pipe(Effect.provide(s.layer)); + }); + + it.effect.each([ + { + label: "--project-ref", + selector: { projectRef: Option.some(FLAG_PROJECT_REF) }, + from: "local", + to: "migrations", + }, + { + label: "--project-ref with a ref on the other side", + selector: { projectRef: Option.some(FLAG_PROJECT_REF) }, + from: "abcdefghijklmnopqrst", + to: "migrations", + }, + { + label: "--linked --project-ref", + selector: { linked: true, projectRef: Option.some(FLAG_PROJECT_REF) }, + from: "local", + to: "migrations", + }, + ] satisfies ReadonlyArray<{ + label: string; + selector: Partial<DbDiffFlags>; + from: string; + to: string; + }>)("fails $label when neither side is remote", ({ selector, from, to }) => { + const s = hostedSetup(); return Effect.gen(function* () { const error = yield* dbDiff( - flags({ from: Option.some("local"), to: Option.some("migrations"), ...selector }), + flags({ from: Option.some(from), to: Option.some(to), ...selector }), ).pipe(Effect.flip); expect(error).toMatchObject({ _tag: "DbDiffExplicitFlagsError", - message: `${flag} doesn't apply with --from/--to; use --from=${side} or --to=${side}.`, + message: + "--project-ref only applies when --from or --to is remote; to diff another project, use --from=<ref> or --to=<ref>.", }); expect(s.resolverCalls).toEqual([]); expect(s.explicitDiffCalls).toEqual([]); @@ -2607,6 +2798,60 @@ describe("db diff", () => { expect(warningsOf(s.out)).toEqual([]); }).pipe(Effect.provide(s.layer)); }); + + it.effect.each([ + { value: `https://${FLAG_PROJECT_REF}.supabase.co`, tag: "TargetInvalidValueError" }, + { value: CREDENTIALED_URL, tag: "DbDiffExplicitFlagsError" }, + ])("rejects --project-ref=<url> without echoing it ($tag)", ({ value, tag }) => { + const s = hostedSetup(); + return Effect.gen(function* () { + const error = yield* dbDiff( + flags({ + from: Option.some("remote"), + to: Option.some("migrations"), + projectRef: Option.some(value), + }), + ).pipe(Effect.flip); + expect(error).toMatchObject({ + _tag: tag, + message: "--project-ref takes a project ref or a branch name, not a URL.", + }); + expect(error.message).not.toContain("secret"); + expect(s.resolverCalls).toEqual([]); + expect(s.explicitDiffCalls).toEqual([]); + expect(warningsOf(s.out)).toEqual([]); + }).pipe(Effect.provide(s.layer)); + }); + + it.effect.each([ + { + label: "--linked with --local", + selector: { linked: true, local: true }, + tag: "TargetSelectorConflictError", + }, + { + label: "--project-ref with --db-url", + selector: { + projectRef: Option.some(FLAG_PROJECT_REF), + dbUrl: Option.some(CREDENTIALED_URL), + }, + tag: "TargetProjectRefConflictError", + }, + ] satisfies ReadonlyArray<{ label: string; selector: Partial<DbDiffFlags>; tag: string }>)( + "still rejects $label", + ({ selector, tag }) => { + const s = hostedSetup(); + return Effect.gen(function* () { + const error = yield* dbDiff( + flags({ from: Option.some("remote"), to: Option.some("migrations"), ...selector }), + ).pipe(Effect.flip); + expect(error).toMatchObject({ _tag: tag }); + expect(s.resolverCalls).toEqual([]); + expect(s.explicitDiffCalls).toEqual([]); + expect(warningsOf(s.out)).toEqual([]); + }).pipe(Effect.provide(s.layer)); + }, + ); }); it.effect("fails when --from is set without --to", () => { @@ -3480,6 +3725,33 @@ describe("db diff permissions through the real resolver and cache", () => { }); } + it.effect("resolves the project --project-ref names for a remote side", () => { + const s = setup(tmp.current, { realLayers: true, diffSql: "create table e ();\n" }); + const requests: Array<StubbedRequest> = []; + return Effect.gen(function* () { + yield* stubManagementApi(requests, serveLinkedDatabase(VALID_REF)); + yield* dbDiff( + flags({ + from: Option.some("remote"), + to: Option.some("migrations"), + projectRef: Option.some(VALID_REF), + }), + ).pipe(Effect.provide(s.layer)); + expect(asLines(requests)).toEqual([poolerRequest, loginRoleRequest]); + expect(cacheCalls(s.projectHttp.calls)).toEqual([cacheRequest]); + assertPermissionDrift({ + command: "db diff", + activeFlags: { "project-ref": VALID_REF, from: "remote", to: "migrations" }, + requests: [...asRecordedRequests(requests), ...s.projectHttp.calls], + exact: true, + }); + }).pipe( + Effect.scoped, + Effect.provide(BunServices.layer), + provideEnv({ SUPABASE_ACCESS_TOKEN: VALID_TOKEN }), + ); + }); + it.effect("makes no Management API request for --from local --to migrations", () => { const s = setup(tmp.current, { realLayers: true, diffSql: "create table e ();\n" }); const requests: Array<StubbedRequest> = []; 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 e3c8adc515..6db810c487 100644 --- a/docs/adr/0033-target-selection-and-hosted-mutation-consent.md +++ b/docs/adr/0033-target-selection-and-hosted-mutation-consent.md @@ -122,8 +122,11 @@ onto `--remote`: bare `--remote` counting as the linked project, and an error before anything runs when they differ. - A bare `--remote` together with `--project-ref=<x>` is accepted, as `--linked --project-ref=<x>` is: the alias's value wins and its warning prints. -- `db diff` with `--remote` or an alias together with `--from` or `--to` is an error. In `--from` - and `--to`, `remote` always means the linked project. +- `db diff` with `--remote` together with `--from` or `--to` is an error. The aliases are accepted + there with a warning that names the `--from`/`--to` value: `--project-ref` points a `remote` side + at the project it names, and is an error when neither side is `remote`; `--linked`, `--db-url` + and `--local` change nothing. In `--from` and `--to`, `remote` means the linked project, or the + one `--project-ref` names. - `--no-remote` and `--remote=false` fail and tell the user to leave `--remote` out. `--remote yes`, `--remote y`, `--remote 1`, `--remote true` and `--remote on` mean bare `--remote`. An empty `--remote=` value is rejected, and the message names the values that command accepts. Neither From 9f9d40881147d26fca76f8df95798a820603d54b Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Tue, 20 Oct 2026 14:43:44 +0100 Subject: [PATCH 240/291] test(cli): prove db pull writes no migration before consent in the generated cases The db pull hosted-consent probe counts migration files written next to history upserts, so the generated cases cover the decline, piped-stdin and JSON refusals the hand-written rows repeated. --- .../commands/db/pull/pull.integration.test.ts | 81 +++---------------- 1 file changed, 12 insertions(+), 69 deletions(-) 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 0b245bbd42..1b517b08b9 100644 --- a/apps/cli/src/commands/db/pull/pull.integration.test.ts +++ b/apps/cli/src/commands/db/pull/pull.integration.test.ts @@ -75,10 +75,7 @@ import { OutputFlag, YesFlag, } from "../../../command-internal/global-flags.ts"; -import { - HostedMutationConsentRequiredError, - HostedMutationDeclinedError, -} from "../../../command-internal/hosted-consent/hosted-consent.errors.ts"; +import { HostedMutationDeclinedError } from "../../../command-internal/hosted-consent/hosted-consent.errors.ts"; import { TargetPolicy } from "../../../command-internal/target/target-policy.ts"; import type { DbConnectionInput, @@ -158,8 +155,6 @@ interface SetupOpts { readonly remoteVersions?: ReadonlyArray<string>; readonly edgeStdout?: string; // diff SQL or declarative export JSON readonly stdinIsTty?: boolean; - // Piped (non-TTY) stdin answers, one consumed per confirmation prompt. - readonly pipedAnswers?: ReadonlyArray<string>; readonly yes?: boolean; readonly experimental?: boolean; readonly promptConfirmResponses?: ReadonlyArray<boolean>; @@ -538,10 +533,7 @@ function setup(workdir: string, opts: SetupOpts = {}) { ...(opts.realLayers === true ? { projectHost: "invalid" } : {}), }), mockTty({ stdinIsTty: opts.stdinIsTty ?? false, stdoutIsTty: false }), - mockStdin( - opts.stdinIsTty ?? false, - opts.pipedAnswers ? `${opts.pipedAnswers.join("\n")}\n` : undefined, - ), + mockStdin(opts.stdinIsTty ?? false), Layer.succeed(YesFlag, opts.yes ?? false), Layer.succeed(OutputFlag, Option.none()), Layer.succeed(TargetPolicy, { group: "selectable", consent: "no" }), @@ -1466,28 +1458,6 @@ describe("db pull", () => { }).pipe(Effect.provide(s.layer)); }); - it.effect( - "declining the history update aborts before any migration file is written (tty)", - () => { - const s = setup(tmp.current, { - migrations: ["20240101000000"], - remoteVersions: ["20240101000000"], - edgeStdout: "create table remote ();\n", - stdinIsTty: true, - promptConfirmResponses: [false], - }); - return Effect.gen(function* () { - const error = yield* dbPull( - flags({ remote: REMOTE_LINKED, diffEngine: Option.some("migra") }), - ).pipe(Effect.flip); - 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("an initial migra pull asks before the dump writes anything", () => { const s = setup(tmp.current, { remoteVersions: [], @@ -1546,24 +1516,6 @@ describe("db pull", () => { }).pipe(Effect.provide(s.layer)); }); - 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, - pipedAnswers: ["y"], - }); - return Effect.gen(function* () { - const error = yield* dbPull( - flags({ remote: REMOTE_LINKED, 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("a local target records the migration without asking", () => { const s = setup(tmp.current, { migrations: ["20240101000000"], @@ -1932,24 +1884,6 @@ describe("db pull", () => { }, ); - 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, - }); - return Effect.gen(function* () { - const error = yield* dbPull( - flags({ remote: REMOTE_LINKED, 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)); - }); - it.effect("a linked [remotes.<ref>] block enabling pg-delta selects the pg-delta engine", () => { // The linked path merges the matching [remotes.<ref>] block before // experimental.pgdelta.enabled is read. Base config disables pg-delta; the @@ -2371,11 +2305,20 @@ describeHostedConsent("db pull", { remoteVersions: ["20240101000000"], edgeStdout: "create table remote ();\n", }); + let migrationsAfterRun: ReadonlyArray<string> = []; return { run: dbPull(flags({ remote: REMOTE_LINKED, diffEngine: Option.some("migra") })).pipe( + Effect.ensuring( + migrationFiles(workdir).pipe( + Effect.tap((files) => Effect.sync(() => void (migrationsAfterRun = files))), + Effect.orDie, + ), + ), Effect.provide(Layer.mergeAll(s.engineLayer, configValuesLayer({ output: out.layer }))), ), - mutations: () => s.historyUpserts.length, + mutations: () => + s.historyUpserts.length + + migrationsAfterRun.filter((file) => file !== "20240101000000_local.sql").length, }; }, }); From 17752142469092c24106dba8b46d0e2113d17007 Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Tue, 20 Oct 2026 14:48:04 +0100 Subject: [PATCH 241/291] test(cli): prove db reset asks despite a project .env and drop its consent replays The remote-reset .env test asserted a drop-schema exec the mock prompt allowed whatever supabase/.env held; it now requires exactly one prompt. The generated consent probe counts every exec, which covers the declined and JSON-without-yes rows the leaf repeated. The target-selection harness types around as generic so inline wrappers keep the error channel open. --- .../db/reset/reset.integration.test.ts | 77 +++++-------------- apps/cli/tests/helpers/db-target-selection.ts | 2 +- 2 files changed, 19 insertions(+), 60 deletions(-) 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 51f757b9ed..4a19a5d453 100644 --- a/apps/cli/src/commands/db/reset/reset.integration.test.ts +++ b/apps/cli/src/commands/db/reset/reset.integration.test.ts @@ -111,7 +111,6 @@ 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 { DbConfigConnectTempRoleError } from "../../../command-internal/db-config.errors.ts"; import { generateLocalJwt } from "../../../command-internal/local-jwt.ts"; @@ -2644,22 +2643,6 @@ describe("db reset", () => { }); }); - it.effect("returns context canceled when the reset prompt is declined", () => { - const { layer, conn } = setup(tmp.current, { - toml: 'project_id = "test"\n', - confirm: [false], - }); - return Effect.gen(function* () { - const exit = yield* dbReset({ ...DEFAULT_FLAGS, remote: REMOTE_LINKED }).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).toHaveLength(0); - }); - }); - it.effect("drops schemas and applies migrations + seed on a confirmed remote reset", () => { const { layer, out, conn, linkedCache } = setup(tmp.current, { toml: 'project_id = "test"\n', @@ -2818,17 +2801,23 @@ describe("db reset", () => { }); }); - it.effect("auto-confirms a remote reset via SUPABASE_YES set only in the project .env", () => { - const { layer, conn } = setup(tmp.current, { - toml: 'project_id = "test"\n', - files: { "supabase/.env": "SUPABASE_YES=true\n" }, - // No `confirm` responses: the prompt must auto-confirm. - }); - return Effect.gen(function* () { - yield* dbReset({ ...DEFAULT_FLAGS, remote: REMOTE_LINKED }).pipe(Effect.provide(layer)); - expect(conn.execs.some((s) => s.includes("drop schema if exists"))).toBe(true); - }); - }); + it.effect( + "asks once before a remote reset when SUPABASE_YES is set only in the project .env", + () => { + const { layer, out, conn } = setup(tmp.current, { + toml: 'project_id = "test"\n', + files: { "supabase/.env": "SUPABASE_YES=true\n" }, + confirm: [true], + }); + return withEmptyConfigEnv( + Effect.gen(function* () { + yield* dbReset({ ...DEFAULT_FLAGS, remote: REMOTE_LINKED }).pipe(Effect.provide(layer)); + expect(out.promptConfirmCalls).toHaveLength(1); + expect(conn.execs.some((s) => s.includes("drop schema if exists"))).toBe(true); + }), + ); + }, + ); it.effect("still caches the linked ref when DB-config resolution fails", () => { const { layer, linkedCache } = setup(tmp.current, { @@ -3449,23 +3438,6 @@ describe("db reset", () => { }); }); - it.effect("emits a json result for a confirmed remote reset", () => { - const { layer, out } = setup(tmp.current, { - toml: 'project_id = "test"\n', - files: migrationFile("20240101000000"), - format: "json", - }); - return Effect.gen(function* () { - // json mode's default-false prompt has no confirm response, so it declines and cancels. - const exit = yield* dbReset({ ...DEFAULT_FLAGS, remote: REMOTE_LINKED }).pipe( - Effect.provide(layer), - Effect.exit, - ); - expect(Exit.isFailure(exit)).toBe(true); - expect(out).toBeDefined(); - }); - }); - describe("seeding into a target that matched a [remotes.*] block", () => { const REMOTE_TOML = `project_id = "base"\n\n[remotes.preview]\nproject_id = "${VALID_REF}"\n`; const remoteSeed = ( @@ -3509,19 +3481,6 @@ describe("db reset", () => { }); }); - 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(Cause.squash(exit.cause)).toBeInstanceOf(HostedMutationDeclinedError); - } - expect(out.stderrText).not.toContain("Resetting remote database"); - expect(conn.execs).toEqual([]); - }); - }); - it.effect("proceeds without asking under --yes", () => { const { layer, out } = remoteSeed({ yes: true }); return Effect.gen(function* () { @@ -3831,7 +3790,7 @@ describeHostedConsent("db reset", { ), ), ), - mutations: () => conn.execs.filter((sql) => sql.includes("drop schema if exists")).length, + mutations: () => conn.execs.length, }; }, }); diff --git a/apps/cli/tests/helpers/db-target-selection.ts b/apps/cli/tests/helpers/db-target-selection.ts index b6c373a388..2450949223 100644 --- a/apps/cli/tests/helpers/db-target-selection.ts +++ b/apps/cli/tests/helpers/db-target-selection.ts @@ -127,7 +127,7 @@ interface DbTargetSelectionScenario<R> { readonly target: TargetFlagValuesWithDbUrl; }) => DbTargetAttempt<R> | Effect.Effect<DbTargetAttempt<R>, unknown, BunServices.BunServices>; /** Wraps each row, for a leaf whose tests pin ambient environment variables around their body. */ - readonly around?: (row: Effect.Effect<void, unknown>) => Effect.Effect<void, unknown>; + readonly around?: <E>(row: Effect.Effect<void, E>) => Effect.Effect<void, E>; /** The flags besides `--remote` that the run sets and the leaf's permission map branches on. */ readonly activeFlags?: ActiveFlags; /** Rows the leaf cannot express, each with why. */ From 54b962dcaaad38e9126935d961abb564004dd7ed Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Tue, 20 Oct 2026 14:50:45 +0100 Subject: [PATCH 242/291] test(cli): drop the seed buckets hosted decline row the generated case covers The generated consent decline case already asserts the declined error, the prompt default and zero storage writes for a hosted target. --- .../seed/buckets/buckets.integration.test.ts | 16 ---------------- 1 file changed, 16 deletions(-) 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 97edcbf3c0..b9f3e8bea6 100644 --- a/apps/cli/src/commands/seed/buckets/buckets.integration.test.ts +++ b/apps/cli/src/commands/seed/buckets/buckets.integration.test.ts @@ -32,7 +32,6 @@ import { } from "../../../../tests/helpers/command-mocks.ts"; import { CliArgs } from "../../../shared/cli/cli-args.service.ts"; import { commandRuntimeLayer } from "../../../shared/runtime/command-runtime.layer.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"; @@ -2529,21 +2528,6 @@ describe("seed buckets", () => { }).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", "--remote"], - confirm: [false], - routes: [{ method: "GET", match: "/storage/v1/bucket", body: [] }], - }); - const exit = yield* seedBuckets(BARE_REMOTE).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, { From c46ffe55db0947e59f0bd56daebad10a790929f0 Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Tue, 20 Oct 2026 14:52:44 +0100 Subject: [PATCH 243/291] test(cli): match leaf deprecation warnings loosely Leaf tests assert that the alias warns, not the full sentence; target-deprecation.unit pins the wording. --- .../src/commands/gen/types/types.integration.test.ts | 10 +++------- apps/cli/src/commands/seed/buckets/buckets.e2e.test.ts | 4 +--- 2 files changed, 4 insertions(+), 10 deletions(-) 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 4df7abdc77..020824a258 100644 --- a/apps/cli/src/commands/gen/types/types.integration.test.ts +++ b/apps/cli/src/commands/gen/types/types.integration.test.ts @@ -998,9 +998,7 @@ describe("gen types", () => { Effect.provide(layer), ); - expect(stripAnsi(out.stderrText)).toContain( - `Warning: --project-id is deprecated and will be removed in v4. Use --remote=<ref> instead\n`, - ); + expect(stripAnsi(out.stderrText)).toContain("--project-id is deprecated"); expect(out.stderrText).toContain(unverifiedTargetBanner()); expect(api.requests).toContainEqual({ method: "generateTypescriptTypes", @@ -1033,7 +1031,7 @@ describe("gen types", () => { expect(alias.out.stdoutText).toBe("export type Database = {}\n"); expect(alias.out.stdoutText).toBe(canonical.out.stdoutText); expect(warningsOf(alias.out)).toEqual([ - `--project-id is deprecated and will be removed in v4. Use --remote=<ref> instead`, + expect.stringContaining("--project-id is deprecated"), ]); expect(warningsOf(canonical.out)).toEqual([]); }).pipe(Effect.provide(BunServices.layer)), @@ -3573,9 +3571,7 @@ describe("gen types permissions through the real layers", () => { expect(methodPaths(ctx.api.requests)).toEqual([ `GET /v1/projects/${VALID_REF}/types/typescript`, ]); - expect(warningsOf(ctx.out)).toEqual([ - `--project-id is deprecated and will be removed in v4. Use --remote=<ref> instead`, - ]); + expect(warningsOf(ctx.out)).toEqual([expect.stringContaining("--project-id is deprecated")]); assertPermissionDrift({ command: "gen types", activeFlags: { 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 67aba20b18..f502858001 100644 --- a/apps/cli/src/commands/seed/buckets/buckets.e2e.test.ts +++ b/apps/cli/src/commands/seed/buckets/buckets.e2e.test.ts @@ -69,9 +69,7 @@ describe("supabase seed buckets", () => { { cwd: projectDir }, ); expect(`${stdout}${stderr}`).not.toContain("Unrecognized flag"); - expect(`${stdout}${stderr}`).toContain( - "--local isn't needed (local is the default) and will be removed in v4", - ); + expect(`${stdout}${stderr}`).toContain("--local isn't needed"); expect(exitCode).toBe(0); }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), E2E_TIMEOUT_MS, From a77dcbb4d7929e1be9e91250c883aadb31a2122e Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Tue, 20 Oct 2026 14:58:49 +0100 Subject: [PATCH 244/291] test(cli): keep the storage test branch ref private to its helper Nothing imports it since the storage leaves moved to the shared target-selection harness; knip flags the unused export. --- apps/cli/tests/helpers/storage.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/cli/tests/helpers/storage.ts b/apps/cli/tests/helpers/storage.ts index 8eb37abe70..856eb5d901 100644 --- a/apps/cli/tests/helpers/storage.ts +++ b/apps/cli/tests/helpers/storage.ts @@ -385,7 +385,7 @@ const STORAGE_TEST_API_KEYS = [ }, ]; -export const STORAGE_TEST_BRANCH_REF = "branchprojectrefabcd"; +const STORAGE_TEST_BRANCH_REF = "branchprojectrefabcd"; /** A linked-project branch row for the `branches` option. */ export const storageTestBranch = (name: string): Record<string, unknown> => ({ From 910b8b9caac6271f7fee7640ed5b3de00deafea6 Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Tue, 20 Oct 2026 15:11:29 +0100 Subject: [PATCH 245/291] test(cli): keep the db query probe for the linked cache surviving a failed SQL resolution The cache ref is recorded before the SQL is resolved, and this is the only row that fails at that step; the non-201 row fails later, at the API response. --- apps/cli/src/commands/db/query/query.integration.test.ts | 9 +++++++++ 1 file changed, 9 insertions(+) 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 e2c8347958..ce87167e09 100644 --- a/apps/cli/src/commands/db/query/query.integration.test.ts +++ b/apps/cli/src/commands/db/query/query.integration.test.ts @@ -878,6 +878,15 @@ describe("db query integration", () => { }).pipe(Effect.provide(layer)); }); + 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({ remote: REMOTE_LINKED })).pipe(Effect.exit); + expect(failMessage(exit)).toContain("no SQL query provided"); + expect(cache.cachedRef).toBe(REF); + }).pipe(Effect.provide(layer)); + }); + it.effect( "errors when the linked API returns a non-201 but still caches the linked project", () => { From 7598712653e1fb49998fdeec87155d549f9f5926 Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Tue, 20 Oct 2026 15:19:59 +0100 Subject: [PATCH 246/291] fix(cli): suggest --from/--to, not --remote, for explicit db diff selector errors --- apps/cli/src/commands/db/diff/SIDE_EFFECTS.md | 5 +- .../cli/src/commands/db/diff/diff.explicit.ts | 44 +++++++ .../db/diff/diff.explicit.unit.test.ts | 109 +++++++++++++++++- apps/cli/src/commands/db/diff/diff.handler.ts | 10 +- .../commands/db/diff/diff.integration.test.ts | 69 ++++++++--- 5 files changed, 217 insertions(+), 20 deletions(-) diff --git a/apps/cli/src/commands/db/diff/SIDE_EFFECTS.md b/apps/cli/src/commands/db/diff/SIDE_EFFECTS.md index e0e38cd2bb..798771557c 100644 --- a/apps/cli/src/commands/db/diff/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/db/diff/SIDE_EFFECTS.md @@ -274,8 +274,9 @@ a postgresql:// URL; convert a connection string to a postgresql:// URL and pass lines and the JSON `target` show that project. With no `remote` side it fails with `--project-ref only applies when --from or --to is remote; to diff another project, use --from=<ref> or --to=<ref>.` and a URL value fails with `--project-ref takes a project ref or a branch name, not a URL.` - Selector flags that conflict (`--linked` with `--local`, `--project-ref` with `--db-url`) fail as - they do without `--from`/`--to`. + Selector flags that conflict (`--linked` with `--local`, `--project-ref` with `--db-url`), an + empty `--project-ref` or `--db-url`, and a URL in `--project-ref` keep their usual messages; each + suggestion names `--from`/`--to` instead of `--remote`, which this mode rejects. - **Explicit `--from`/`--to` config and URLs.** The config is read when the first endpoint that needs it resolves, not before. A hosted endpoint resolved first merges its `[remotes.<ref>]` diff --git a/apps/cli/src/commands/db/diff/diff.explicit.ts b/apps/cli/src/commands/db/diff/diff.explicit.ts index 154474d5cb..4e8907ed62 100644 --- a/apps/cli/src/commands/db/diff/diff.explicit.ts +++ b/apps/cli/src/commands/db/diff/diff.explicit.ts @@ -12,6 +12,13 @@ import { LOCAL_NOT_NEEDED_MESSAGE, } from "../../../command-internal/target/target-deprecation.ts"; import type { TargetDeprecation } from "../../../command-internal/target/target-selector.ts"; +import { + TargetEmptyValueError, + TargetInvalidValueError, + TargetProjectRefConflictError, + TargetSelectorConflictError, + type TargetSelectorError, +} from "../../../command-internal/target/target.errors.ts"; /** What an explicit `--from`/`--to` value names. */ export type ExplicitRef = @@ -72,3 +79,40 @@ export const explicitDeprecationMessages = ( } return `${lead} Use ${replacementOf(remote)} instead`; }); + +/** A selector error with its suggestion pointing at `--from`/`--to`, since explicit mode rejects `--remote`. */ +export const explicitSelectorError = (error: TargetSelectorError): TargetSelectorError => { + switch (error._tag) { + case "TargetEmptyValueError": + return new TargetEmptyValueError({ + flag: error.flag, + message: error.message, + suggestion: + error.flag === "--db-url" + ? "Pass a postgresql:// URL as --from=<url> or --to=<url>." + : "Pass a project ref as --from=<ref> or --to=<ref>.", + }); + case "TargetInvalidValueError": + return new TargetInvalidValueError({ + flag: error.flag, + message: error.message, + suggestion: "Pass a project ref as --from=<ref> or --to=<ref>.", + }); + case "TargetProjectRefConflictError": + return new TargetProjectRefConflictError({ + conflictsWith: error.conflictsWith, + message: error.message, + suggestion: + error.conflictsWith === "--db-url" + ? "Use --from=<ref> or --to=<ref> instead of --project-ref, and --from=<url> or --to=<url> instead of --db-url." + : "Use --from=<ref> or --to=<ref> instead of --project-ref, and drop --local.", + }); + case "TargetSelectorConflictError": + return new TargetSelectorConflictError({ + flags: error.flags, + message: error.message, + suggestion: + "Use --from=remote or --to=remote instead of --linked, --from=<url> or --to=<url> instead of --db-url, and drop --local.", + }); + } +}; diff --git a/apps/cli/src/commands/db/diff/diff.explicit.unit.test.ts b/apps/cli/src/commands/db/diff/diff.explicit.unit.test.ts index c2b743ed0d..9dbe5ea204 100644 --- a/apps/cli/src/commands/db/diff/diff.explicit.unit.test.ts +++ b/apps/cli/src/commands/db/diff/diff.explicit.unit.test.ts @@ -1,7 +1,16 @@ +import { Option, Result } from "effect"; import { describe, expect, it } from "vitest"; -import type { TargetDeprecation } from "../../../command-internal/target/target-selector.ts"; -import { classifyExplicitRef, explicitDeprecationMessages } from "./diff.explicit.ts"; +import type { TargetFlagValues } from "../../../command-internal/target/target-flags.ts"; +import { + parseTargetSelector, + type TargetDeprecation, +} from "../../../command-internal/target/target-selector.ts"; +import { + classifyExplicitRef, + explicitDeprecationMessages, + explicitSelectorError, +} from "./diff.explicit.ts"; const REF = "abcdefghijklmnopqrst"; const UUID = "11111111-2222-4333-8444-555555555555"; @@ -135,3 +144,99 @@ describe("explicitDeprecationMessages", () => { ]); }); }); + +describe("explicitSelectorError", () => { + const none: TargetFlagValues = { + remote: Option.none(), + local: false, + linked: false, + dbUrl: Option.none(), + projectRef: Option.none(), + }; + + const selectorFailure = (overrides: Partial<TargetFlagValues>) => { + const result = parseTargetSelector({ ...none, ...overrides }); + if (Result.isSuccess(result)) throw new Error("expected the selector to be rejected"); + return result.failure; + }; + + it.each([ + { + label: "an empty --project-ref", + overrides: { projectRef: Option.some("") }, + expected: { + _tag: "TargetEmptyValueError", + flag: "--project-ref", + message: "--project-ref cannot be empty.", + suggestion: "Pass a project ref as --from=<ref> or --to=<ref>.", + }, + }, + { + label: "an empty --db-url", + overrides: { dbUrl: Option.some("") }, + expected: { + _tag: "TargetEmptyValueError", + flag: "--db-url", + message: "--db-url cannot be empty.", + suggestion: "Pass a postgresql:// URL as --from=<url> or --to=<url>.", + }, + }, + { + label: "a --project-ref URL", + overrides: { projectRef: Option.some(`https://${REF}.supabase.co`) }, + expected: { + _tag: "TargetInvalidValueError", + flag: "--project-ref", + message: "--project-ref takes a project ref or a branch name, not a URL.", + suggestion: "Pass a project ref as --from=<ref> or --to=<ref>.", + }, + }, + { + label: "--project-ref with --local", + overrides: { projectRef: Option.some(REF), local: true }, + expected: { + _tag: "TargetProjectRefConflictError", + conflictsWith: "--local", + message: "--project-ref selects a remote project and cannot be combined with --local.", + suggestion: "Use --from=<ref> or --to=<ref> instead of --project-ref, and drop --local.", + }, + }, + { + label: "--project-ref with --db-url", + overrides: { projectRef: Option.some(REF), dbUrl: Option.some("postgresql://u@h/db") }, + expected: { + _tag: "TargetProjectRefConflictError", + conflictsWith: "--db-url", + message: "--project-ref selects a remote project and cannot be combined with --db-url.", + suggestion: + "Use --from=<ref> or --to=<ref> instead of --project-ref, and --from=<url> or --to=<url> instead of --db-url.", + }, + }, + { + label: "--linked with --local", + overrides: { linked: true, local: true }, + expected: { + _tag: "TargetSelectorConflictError", + flags: ["--linked", "--local"], + message: + "Only one of --remote, --linked, --db-url and --local can be set; got --linked and --local.", + suggestion: + "Use --from=remote or --to=remote instead of --linked, --from=<url> or --to=<url> instead of --db-url, and drop --local.", + }, + }, + ])("points $label at --from/--to and keeps the rest of the error", ({ overrides, expected }) => { + const rewritten = explicitSelectorError(selectorFailure(overrides)); + expect(rewritten).toMatchObject(expected); + expect(rewritten.suggestion).not.toContain("--remote"); + }); + + it("never echoes the connection string a conflict was raised for", () => { + const rewritten = explicitSelectorError( + selectorFailure({ + projectRef: Option.some(REF), + dbUrl: Option.some("postgresql://u:hunter2@db.example.com:5432/postgres"), + }), + ); + expect(`${rewritten.message} ${rewritten.suggestion}`).not.toContain("hunter2"); + }); +}); diff --git a/apps/cli/src/commands/db/diff/diff.handler.ts b/apps/cli/src/commands/db/diff/diff.handler.ts index 09489bb050..1053088380 100644 --- a/apps/cli/src/commands/db/diff/diff.handler.ts +++ b/apps/cli/src/commands/db/diff/diff.handler.ts @@ -89,7 +89,11 @@ import { writePgDeltaMigrations } from "../shared/pgdelta-migrations.write.ts"; import { type PgDeltaContext, isPgDeltaDebugEnabled } from "../../../command-internal/pgdelta.ts"; import { prepareShadowSource } from "../shared/shadow-source.ts"; import type { DbDiffFlags } from "./diff.command.ts"; -import { classifyExplicitRef, explicitDeprecationMessages } from "./diff.explicit.ts"; +import { + classifyExplicitRef, + explicitDeprecationMessages, + explicitSelectorError, +} from "./diff.explicit.ts"; import { DbDiffDbNotRunningError, DbDiffEngineConflictError, @@ -189,7 +193,9 @@ export const dbDiff = Effect.fn("db.diff")(function* (flags: DbDiffFlags) { message: "--remote doesn't apply with --from/--to; use --from=remote or --to=remote.", }); } - const { selection, deprecations } = yield* Effect.fromResult(parseTargetSelector(flags)); + const { selection, deprecations } = yield* Effect.fromResult(parseTargetSelector(flags)).pipe( + Effect.mapError(explicitSelectorError), + ); const projectRefTarget = selection._tag === "Ref" || selection._tag === "Branch" ? selection : undefined; if (Option.isSome(flags.projectRef)) { 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 0453f6f38c..a620acc17d 100644 --- a/apps/cli/src/commands/db/diff/diff.integration.test.ts +++ b/apps/cli/src/commands/db/diff/diff.integration.test.ts @@ -2828,6 +2828,8 @@ describe("db diff", () => { label: "--linked with --local", selector: { linked: true, local: true }, tag: "TargetSelectorConflictError", + suggestion: + "Use --from=remote or --to=remote instead of --linked, --from=<url> or --to=<url> instead of --db-url, and drop --local.", }, { label: "--project-ref with --db-url", @@ -2836,22 +2838,61 @@ describe("db diff", () => { dbUrl: Option.some(CREDENTIALED_URL), }, tag: "TargetProjectRefConflictError", + suggestion: + "Use --from=<ref> or --to=<ref> instead of --project-ref, and --from=<url> or --to=<url> instead of --db-url.", }, - ] satisfies ReadonlyArray<{ label: string; selector: Partial<DbDiffFlags>; tag: string }>)( - "still rejects $label", - ({ selector, tag }) => { - const s = hostedSetup(); - return Effect.gen(function* () { - const error = yield* dbDiff( - flags({ from: Option.some("remote"), to: Option.some("migrations"), ...selector }), - ).pipe(Effect.flip); - expect(error).toMatchObject({ _tag: tag }); - expect(s.resolverCalls).toEqual([]); - expect(s.explicitDiffCalls).toEqual([]); - expect(warningsOf(s.out)).toEqual([]); - }).pipe(Effect.provide(s.layer)); + ] satisfies ReadonlyArray<{ + label: string; + selector: Partial<DbDiffFlags>; + tag: string; + suggestion: string; + }>)("still rejects $label, suggesting --from/--to", ({ selector, tag, suggestion }) => { + const s = hostedSetup(); + return Effect.gen(function* () { + const error = yield* dbDiff( + flags({ from: Option.some("remote"), to: Option.some("migrations"), ...selector }), + ).pipe(Effect.flip); + expect(error).toMatchObject({ _tag: tag, suggestion }); + expect(s.resolverCalls).toEqual([]); + expect(s.explicitDiffCalls).toEqual([]); + expect(warningsOf(s.out)).toEqual([]); + }).pipe(Effect.provide(s.layer)); + }); + + it.effect.each([ + { + label: "a --project-ref URL", + selector: { projectRef: Option.some(`https://${FLAG_PROJECT_REF}.supabase.co`) }, + expected: { + _tag: "TargetInvalidValueError", + message: "--project-ref takes a project ref or a branch name, not a URL.", + suggestion: "Pass a project ref as --from=<ref> or --to=<ref>.", + }, }, - ); + { + label: "an empty --project-ref", + selector: { projectRef: Option.some("") }, + expected: { + _tag: "TargetEmptyValueError", + message: "--project-ref cannot be empty.", + suggestion: "Pass a project ref as --from=<ref> or --to=<ref>.", + }, + }, + ] satisfies ReadonlyArray<{ + label: string; + selector: Partial<DbDiffFlags>; + expected: { _tag: string; message: string; suggestion: string }; + }>)("suggests --from/--to, not --remote, for $label", ({ selector, expected }) => { + const s = hostedSetup(); + return Effect.gen(function* () { + const error = yield* dbDiff( + flags({ from: Option.some("remote"), to: Option.some("migrations"), ...selector }), + ).pipe(Effect.flip); + expect(error).toMatchObject(expected); + expect(s.resolverCalls).toEqual([]); + expect(s.explicitDiffCalls).toEqual([]); + }).pipe(Effect.provide(s.layer)); + }); }); it.effect("fails when --from is set without --to", () => { From 58531e51a2cca30738230667e3d71b448fadf6b7 Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Tue, 20 Oct 2026 15:26:41 +0100 Subject: [PATCH 247/291] fix(cli): keep the IPv6 error when the pooler fallback has no usable access token --- .../db-config.integration.test.ts | 39 +++++++++++++++++ .../src/command-internal/db-config.layer.ts | 42 +++++++++---------- .../src/command-internal/db-config.service.ts | 5 ++- .../src/commands/inspect/db/SIDE_EFFECTS.md | 2 +- .../commands/inspect/report/SIDE_EFFECTS.md | 2 +- .../commands/migration/down/SIDE_EFFECTS.md | 2 +- .../commands/migration/fetch/SIDE_EFFECTS.md | 2 +- .../commands/migration/list/SIDE_EFFECTS.md | 2 +- .../commands/migration/repair/SIDE_EFFECTS.md | 2 +- .../commands/migration/squash/SIDE_EFFECTS.md | 20 ++++----- .../src/commands/migration/up/SIDE_EFFECTS.md | 2 +- 11 files changed, 78 insertions(+), 42 deletions(-) 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 00f733eb66..f6f3058736 100644 --- a/apps/cli/src/command-internal/db-config.integration.test.ts +++ b/apps/cli/src/command-internal/db-config.integration.test.ts @@ -2015,6 +2015,45 @@ describe("dbConfigResolver.resolveConnection", () => { }, ); + const unusableTokens: ReadonlyArray<readonly [string, Record<string, string>]> = [ + ["no access token", { SUPABASE_NO_KEYRING: "1" }], + [ + "a malformed access token", + { SUPABASE_NO_KEYRING: "1", SUPABASE_ACCESS_TOKEN: "not-a-token" }, + ], + ]; + for (const [label, configEnv] of unusableTokens) { + it.effect( + `a linked target with a password and ${label} keeps the IPv6 error and makes no API request`, + () => { + const ref = "tokenlessrefabcdefgh"; + const requests: Array<{ readonly method: string; readonly path: string }> = []; + return Effect.gen(function* () { + yield* stubManagementApi(requests, () => undefined); + const dir = yield* withWorkdir(); + return yield* resolveConnection( + dir, + inputFor(linkedTarget(ref), { password: Option.some("pw") }), + { projectHost: "invalid", configEnv }, + ); + }).pipe( + Effect.flip, + Effect.tap((error) => + Effect.sync(() => { + expect(error).toMatchObject({ + _tag: "DbConfigIpv6Error", + suggestion: `Run supabase link --project-ref ${ref} to setup IPv4 connection.`, + }); + expect(requests).toEqual([]); + }), + ), + 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"; diff --git a/apps/cli/src/command-internal/db-config.layer.ts b/apps/cli/src/command-internal/db-config.layer.ts index dc9138170a..9607131849 100644 --- a/apps/cli/src/command-internal/db-config.layer.ts +++ b/apps/cli/src/command-internal/db-config.layer.ts @@ -234,9 +234,8 @@ const tcpReachable = (host: string, port: number): Effect.Effect<boolean> => // POST /v1/projects/{ref}/cli/login-role → mint a temporary postgres role. The Management API // client is built lazily via `CommandPlatformApiFactory.make` (not the eager -// `CommandPlatformApi` stack), so the access token is resolved only here — when a temp role is -// actually minted. `--linked --password` returns before reaching this, so it stays auth-free; -// `--local`/`--db-url` never build this layer at all. +// `CommandPlatformApi` stack), so the access token is resolved only when a call needs it: this +// mint or the pooler-config fallback. `--local`/`--db-url` never build this layer at all. const initLoginRole = Effect.fn("DbConfig.initLoginRole")(function* ( ref: string, conn: PgConnInput, @@ -422,6 +421,16 @@ export const resolveLinkedPassword = Effect.fn("DbConfig.resolveLinkedPassword") return resolved.value.value; }); +/** The API's primary pooler URL; any failure, a missing access token included, means none. */ +const fetchApiPoolerUrl = Effect.fn("DbConfig.fetchApiPoolerUrl")( + function* (ref: string) { + const api = yield* (yield* CommandPlatformApiFactory).make; + const configs = yield* api.v1.getPoolerConfig({ ref }); + return configs.find((config) => config.database_type === "PRIMARY")?.connection_string; + }, + Effect.orElseSucceed(() => undefined), +); + /** * Resolve the IPv4 transaction pooler connection for `ref`. Returns `None` when no pooler URL is * configured or it fails validation, so the caller can keep the original error. With a password, @@ -435,9 +444,6 @@ const resolvePoolerConn = Effect.fn("DbConfig.resolvePoolerConn")(function* ( poolerHost: string, dnsResolver: "native" | "https", password: string, - // The container-fallback path falls back to the Management API's primary pooler config when - // 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-ref` the saved `.temp/pooler-url` belongs to the // (possibly different) linked workdir, so ignore it and resolve the pooler for @@ -460,27 +466,17 @@ const resolvePoolerConn = Effect.fn("DbConfig.resolvePoolerConn")(function* ( : Option.getOrUndefined(tomlValues.poolerConnectionString); if (connectionString === undefined) { if (!fetchFromApi) return Option.none<PgConnInput>(); - // No saved pooler URL → fetch the primary pooler config from the Management API. Any API - // failure means "no fallback", so swallow it to `None`. - const api = yield* (yield* CommandPlatformApiFactory).make; - const configsOpt = yield* api.v1.getPoolerConfig({ ref }).pipe(Effect.option); - if (Option.isNone(configsOpt)) return Option.none<PgConnInput>(); - const primary = configsOpt.value.find((config) => config.database_type === "PRIMARY"); - if (primary === undefined) return Option.none<PgConnInput>(); - connectionString = primary.connection_string; + connectionString = yield* fetchApiPoolerUrl(ref); + if (connectionString === undefined) return Option.none<PgConnInput>(); } let pooler = Option.none<PgConnInput>(); if (connectionString !== undefined) { pooler = yield* poolerConfigFrom(ref, connectionString, poolerHost); } if (Option.isNone(pooler) && fetchFromApi) { - const api = yield* (yield* CommandPlatformApiFactory).make; - const configsOpt = yield* api.v1.getPoolerConfig({ ref }).pipe(Effect.option); - if (Option.isSome(configsOpt)) { - const primary = configsOpt.value.find((config) => config.database_type === "PRIMARY"); - if (primary !== undefined) { - pooler = yield* poolerConfigFrom(ref, primary.connection_string, poolerHost); - } + const apiUrl = yield* fetchApiPoolerUrl(ref); + if (apiUrl !== undefined) { + pooler = yield* poolerConfigFrom(ref, apiUrl, poolerHost); } } if (Option.isNone(pooler)) return Option.none<PgConnInput>(); @@ -556,7 +552,7 @@ export const resolveLinkedConn = Effect.fn("DbConfig.resolveLinkedConn")(functio // Direct host unreachable (IPv6-only network) → try the pooler. An ad-hoc ref skips the saved // pooler URL, which belongs to the linked project; with `fetchPoolerFromApi`, either case falls - // back to the Management API's pooler config. + // back to the Management API's pooler config when an access token is available. const poolerConn = yield* resolvePoolerConn( ref, workdir, @@ -793,7 +789,7 @@ export const dbConfigResolverLayer = Layer.effect( }); // 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. + // resolves the access token only when a call needs it, and `--password` needs none. const withLinkedRuntime = <A, E, R>(effect: Effect.Effect<A, E, R>) => effect.pipe( Effect.provide( diff --git a/apps/cli/src/command-internal/db-config.service.ts b/apps/cli/src/command-internal/db-config.service.ts index cceee5d95f..49c9d9665c 100644 --- a/apps/cli/src/command-internal/db-config.service.ts +++ b/apps/cli/src/command-internal/db-config.service.ts @@ -60,9 +60,10 @@ export type DbConfigError = | DbConfigPoolerLoginError | DbConnectError // The `--linked` path resolves the access token lazily via - // `CommandPlatformApiFactory.make` (only when minting a temp login role), so the + // `CommandPlatformApiFactory.make` (when minting a temp login role), so the // auth-required / invalid-token / api-config errors surface from the resolver effect — not a - // layer-build channel. `--linked --password` skips `make` entirely and never raises these. + // layer-build channel. `--linked --password` never raises these: a failed `make` only skips + // the pooler-config fallback. | CommandPlatformApiFactoryError // The lazy linked runtime rebuilds `commandSettingsLayer`, whose strict // profile resolution can fail inside the resolver effect the same way. diff --git a/apps/cli/src/commands/inspect/db/SIDE_EFFECTS.md b/apps/cli/src/commands/inspect/db/SIDE_EFFECTS.md index dbd26af7ee..ab89a0d71d 100644 --- a/apps/cli/src/commands/inspect/db/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/inspect/db/SIDE_EFFECTS.md @@ -38,7 +38,7 @@ Local targets and database URLs make none. | `GET` | `/v1/projects/{parent}/branches` | Bearer token | none | `[{id, name, project_ref, is_default}]` — only for `--remote=<branch>` (or the deprecated `--project-ref=<branch>`), and for a bare `--remote` followed by a word that could name a branch; `{parent}` is `SUPABASE_PROJECT_ID`, else the linked project | | `GET` | `/v1/branches/{id}` | Bearer token | none | `{ref, status, …}` — only for `--remote=<branch UUID>` when no project is linked | | `POST` | `/v1/projects/{ref}/cli/login-role` | Bearer token | `{ read_only: false }` | temporary `role` and `password` — remote project with no database password | -| `GET` | `/v1/projects/{ref}/config/database/pooler` | Bearer token | none | primary pooler `connection_string` — remote project on an IPv4-only network (direct host unreachable), only when there is no usable saved `.temp/pooler-url`; a ref that differs from the linked project ignores the saved URL | +| `GET` | `/v1/projects/{ref}/config/database/pooler` | Bearer token | none | primary pooler `connection_string` — remote project on an IPv4-only network (direct host unreachable), only when there is no usable saved `.temp/pooler-url` and an access token is available; a ref that differs from the linked project ignores the saved URL | | `POST` | `/v1/projects/{ref}/network-bans/retrieve` | Bearer token | none | `banned_ipv4_addresses` — remote project, from the 3rd failed temporary-role connect | | `DELETE` | `/v1/projects/{ref}/network-bans` | Bearer token | `{ ipv4_addresses, requester_ip: false }` | none — remote project, only when bans are listed and `--yes` or a shell `SUPABASE_YES` is set (clears 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 | diff --git a/apps/cli/src/commands/inspect/report/SIDE_EFFECTS.md b/apps/cli/src/commands/inspect/report/SIDE_EFFECTS.md index a85cbb2266..c6538d27e3 100644 --- a/apps/cli/src/commands/inspect/report/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/inspect/report/SIDE_EFFECTS.md @@ -63,7 +63,7 @@ Local targets and database URLs make none. | `GET` | `/v1/projects/{parent}/branches` | Bearer token | none | `[{id, name, project_ref, is_default}]` — only for `--remote=<branch>` (or the deprecated `--project-ref=<branch>`), and for a bare `--remote` followed by a word that could name a branch; `{parent}` is `SUPABASE_PROJECT_ID`, else the linked project | | `GET` | `/v1/branches/{id}` | Bearer token | none | `{ref, status, …}` — only for `--remote=<branch UUID>` when no project is linked | | `POST` | `/v1/projects/{ref}/cli/login-role` | Bearer token | `{ read_only: false }` | temporary `role` and `password` — remote project with no database password | -| `GET` | `/v1/projects/{ref}/config/database/pooler` | Bearer token | none | primary pooler `connection_string` — remote project on an IPv4-only network (direct host unreachable), only when there is no usable saved `.temp/pooler-url`; a ref that differs from the linked project ignores the saved URL | +| `GET` | `/v1/projects/{ref}/config/database/pooler` | Bearer token | none | primary pooler `connection_string` — remote project on an IPv4-only network (direct host unreachable), only when there is no usable saved `.temp/pooler-url` and an access token is available; a ref that differs from the linked project ignores the saved URL | | `POST` | `/v1/projects/{ref}/network-bans/retrieve` | Bearer token | none | `banned_ipv4_addresses` — remote project, from the 3rd failed temporary-role connect | | `DELETE` | `/v1/projects/{ref}/network-bans` | Bearer token | `{ ipv4_addresses, requester_ip: false }` | none — remote project, only when bans are listed and `--yes` or a shell `SUPABASE_YES` is set (clears 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 | diff --git a/apps/cli/src/commands/migration/down/SIDE_EFFECTS.md b/apps/cli/src/commands/migration/down/SIDE_EFFECTS.md index 5618bb657b..ba7f8d1d39 100644 --- a/apps/cli/src/commands/migration/down/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/migration/down/SIDE_EFFECTS.md @@ -28,7 +28,7 @@ Local targets and database URLs make none. | `GET` | `/v1/projects/{parent}/branches` | Bearer token | none | `[{id, name, project_ref, is_default}]` — only for `--remote=<branch>` (or the deprecated `--project-ref=<branch>`), and for a bare `--remote` followed by a word that could name a branch; `{parent}` is `SUPABASE_PROJECT_ID`, else the linked project | | `GET` | `/v1/branches/{id}` | Bearer token | none | `{ref, status, …}` — only for `--remote=<branch UUID>` when no project is linked | | `POST` | `/v1/projects/{ref}/cli/login-role` | Bearer token | `{ read_only: false }` | temporary `role` and `password` — remote project with no database password | -| `GET` | `/v1/projects/{ref}/config/database/pooler` | Bearer token | none | primary pooler `connection_string` — remote project on an IPv4-only network (direct host unreachable), only when there is no usable saved `.temp/pooler-url`; a ref that differs from the linked project ignores the saved URL | +| `GET` | `/v1/projects/{ref}/config/database/pooler` | Bearer token | none | primary pooler `connection_string` — remote project on an IPv4-only network (direct host unreachable), only when there is no usable saved `.temp/pooler-url` and an access token is available; a ref that differs from the linked project ignores the saved URL | | `POST` | `/v1/projects/{ref}/network-bans/retrieve` | Bearer token | none | `banned_ipv4_addresses` — remote project, from the 3rd failed temporary-role connect | | `DELETE` | `/v1/projects/{ref}/network-bans` | Bearer token | `{ ipv4_addresses, requester_ip: false }` | none — remote project, only when bans are listed and `--yes` or a shell `SUPABASE_YES` is set (clears every listed address); otherwise the connect keeps retrying and fails with `LinkedDbNetworkBannedError` once the retries run out | | `GET` | `/v1/projects/{ref}` | Bearer token | none | `{ref, name, organization_id, organization_slug}` — linked-project cache miss only (best-effort, post-run): written only when `.temp/linked-project.json` doesn't exist, `.temp/project-ref` doesn't name another ref, an access token exists and the lookup returns 200 | diff --git a/apps/cli/src/commands/migration/fetch/SIDE_EFFECTS.md b/apps/cli/src/commands/migration/fetch/SIDE_EFFECTS.md index ddff26b189..1f562c0311 100644 --- a/apps/cli/src/commands/migration/fetch/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/migration/fetch/SIDE_EFFECTS.md @@ -29,7 +29,7 @@ Local targets and database URLs make none. | `GET` | `/v1/projects/{parent}/branches` | Bearer token | none | `[{id, name, project_ref, is_default}]` — only for `--remote=<branch>` (or the deprecated `--project-ref=<branch>`), and for a bare `--remote` followed by a word that could name a branch; `{parent}` is `SUPABASE_PROJECT_ID`, else the linked project | | `GET` | `/v1/branches/{id}` | Bearer token | none | `{ref, status, …}` — only for `--remote=<branch UUID>` when no project is linked | | `POST` | `/v1/projects/{ref}/cli/login-role` | Bearer token | `{ read_only: false }` | temporary `role` and `password` — remote project with no database password | -| `GET` | `/v1/projects/{ref}/config/database/pooler` | Bearer token | none | primary pooler `connection_string` — remote project on an IPv4-only network (direct host unreachable), only when there is no usable saved `.temp/pooler-url`; a ref that differs from the linked project ignores the saved URL | +| `GET` | `/v1/projects/{ref}/config/database/pooler` | Bearer token | none | primary pooler `connection_string` — remote project on an IPv4-only network (direct host unreachable), only when there is no usable saved `.temp/pooler-url` and an access token is available; a ref that differs from the linked project ignores the saved URL | | `POST` | `/v1/projects/{ref}/network-bans/retrieve` | Bearer token | none | `banned_ipv4_addresses` — remote project, from the 3rd failed temporary-role connect | | `DELETE` | `/v1/projects/{ref}/network-bans` | Bearer token | `{ ipv4_addresses, requester_ip: false }` | none — remote project, only when bans are listed and `--yes` or a shell `SUPABASE_YES` is set (clears every listed address); otherwise the connect keeps retrying and fails with `LinkedDbNetworkBannedError` once the retries run out | | `GET` | `/v1/projects/{ref}` | Bearer token | none | `{ref, name, organization_id, organization_slug}` — linked-project cache miss only (best-effort, post-run): written only when `.temp/linked-project.json` doesn't exist, `.temp/project-ref` doesn't name another ref, an access token exists and the lookup returns 200 | diff --git a/apps/cli/src/commands/migration/list/SIDE_EFFECTS.md b/apps/cli/src/commands/migration/list/SIDE_EFFECTS.md index 60c467e09a..8fddd38aaf 100644 --- a/apps/cli/src/commands/migration/list/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/migration/list/SIDE_EFFECTS.md @@ -28,7 +28,7 @@ Local targets and database URLs make none. | `GET` | `/v1/projects/{parent}/branches` | Bearer token | none | `[{id, name, project_ref, is_default}]` — only for `--remote=<branch>` (or the deprecated `--project-ref=<branch>`), and for a bare `--remote` followed by a word that could name a branch; `{parent}` is `SUPABASE_PROJECT_ID`, else the linked project | | `GET` | `/v1/branches/{id}` | Bearer token | none | `{ref, status, …}` — only for `--remote=<branch UUID>` when no project is linked | | `POST` | `/v1/projects/{ref}/cli/login-role` | Bearer token | `{ read_only: false }` | temporary `role` and `password` — remote project with no database password | -| `GET` | `/v1/projects/{ref}/config/database/pooler` | Bearer token | none | primary pooler `connection_string` — remote project on an IPv4-only network (direct host unreachable), only when there is no usable saved `.temp/pooler-url`; a ref that differs from the linked project ignores the saved URL | +| `GET` | `/v1/projects/{ref}/config/database/pooler` | Bearer token | none | primary pooler `connection_string` — remote project on an IPv4-only network (direct host unreachable), only when there is no usable saved `.temp/pooler-url` and an access token is available; a ref that differs from the linked project ignores the saved URL | | `POST` | `/v1/projects/{ref}/network-bans/retrieve` | Bearer token | none | `banned_ipv4_addresses` — remote project, from the 3rd failed temporary-role connect | | `DELETE` | `/v1/projects/{ref}/network-bans` | Bearer token | `{ ipv4_addresses, requester_ip: false }` | none — remote project, only when bans are listed and `--yes` or a shell `SUPABASE_YES` is set (clears every listed address); otherwise the connect keeps retrying and fails with `LinkedDbNetworkBannedError` once the retries run out | | `GET` | `/v1/projects/{ref}` | Bearer token | none | `{ref, name, organization_id, organization_slug}` — linked-project cache miss only (best-effort, post-run): written only when `.temp/linked-project.json` doesn't exist, `.temp/project-ref` doesn't name another ref, an access token exists and the lookup returns 200 | diff --git a/apps/cli/src/commands/migration/repair/SIDE_EFFECTS.md b/apps/cli/src/commands/migration/repair/SIDE_EFFECTS.md index 7c26cd932f..a36dbef465 100644 --- a/apps/cli/src/commands/migration/repair/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/migration/repair/SIDE_EFFECTS.md @@ -28,7 +28,7 @@ Local targets and database URLs make none. | `GET` | `/v1/projects/{parent}/branches` | Bearer token | none | `[{id, name, project_ref, is_default}]` — only for `--remote=<branch>` (or the deprecated `--project-ref=<branch>`), and for a bare `--remote` followed by a word that could name a branch; `{parent}` is `SUPABASE_PROJECT_ID`, else the linked project | | `GET` | `/v1/branches/{id}` | Bearer token | none | `{ref, status, …}` — only for `--remote=<branch UUID>` when no project is linked | | `POST` | `/v1/projects/{ref}/cli/login-role` | Bearer token | `{ read_only: false }` | temporary `role` and `password` — remote project with no database password | -| `GET` | `/v1/projects/{ref}/config/database/pooler` | Bearer token | none | primary pooler `connection_string` — remote project on an IPv4-only network (direct host unreachable), only when there is no usable saved `.temp/pooler-url`; a ref that differs from the linked project ignores the saved URL | +| `GET` | `/v1/projects/{ref}/config/database/pooler` | Bearer token | none | primary pooler `connection_string` — remote project on an IPv4-only network (direct host unreachable), only when there is no usable saved `.temp/pooler-url` and an access token is available; a ref that differs from the linked project ignores the saved URL | | `POST` | `/v1/projects/{ref}/network-bans/retrieve` | Bearer token | none | `banned_ipv4_addresses` — remote project, from the 3rd failed temporary-role connect | | `DELETE` | `/v1/projects/{ref}/network-bans` | Bearer token | `{ ipv4_addresses, requester_ip: false }` | none — remote project, only when bans are listed and `--yes` or a shell `SUPABASE_YES` is set (clears every listed address); otherwise the connect keeps retrying and fails with `LinkedDbNetworkBannedError` once the retries run out | | `GET` | `/v1/projects/{ref}` | Bearer token | none | `{ref, name, organization_id, organization_slug}` — linked-project cache miss only (best-effort, post-run): written only when `.temp/linked-project.json` doesn't exist, `.temp/project-ref` doesn't name another ref, an access token exists and the lookup returns 200 | diff --git a/apps/cli/src/commands/migration/squash/SIDE_EFFECTS.md b/apps/cli/src/commands/migration/squash/SIDE_EFFECTS.md index 6750491dc2..1bd4f4a4c2 100644 --- a/apps/cli/src/commands/migration/squash/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/migration/squash/SIDE_EFFECTS.md @@ -78,16 +78,16 @@ live shadow available. ## API Routes -| Method | Path | Auth | Purpose | -| ----------- | ----------------------------------------------------------------------------- | ------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| — | — | — | local target or database URL: none | -| GET | `/v1/projects` | Bearer | project picker: bare `--remote` with no ref from `SUPABASE_PROJECT_ID` or `.temp/project-ref`, interactive terminal only | -| GET | `/v1/projects/{parent}/branches` | Bearer | only for `--remote=<branch>` (or the deprecated `--project-ref=<branch>`), and for a bare `--remote` followed by a word that could name a branch; `{parent}` is `SUPABASE_PROJECT_ID`, else the linked project | -| GET | `/v1/branches/{id}` | Bearer | only for `--remote=<branch UUID>` when no project is linked | -| POST | `/v1/projects/{ref}/cli/login-role` | Bearer | remote project: temp login role when no password | -| GET | `/v1/projects/{ref}/config/database/pooler` | Bearer | remote project: IPv4 pooler fallback (IPv6-only network), only when there is no usable saved `.temp/pooler-url`; a ref that differs from the linked project ignores the saved URL | -| POST/DELETE | `/v1/projects/{ref}/network-bans/retrieve`, `/v1/projects/{ref}/network-bans` | Bearer | remote project: unban during pooler login retry, only under `--yes` | -| GET | `/v1/projects/{ref}` | Bearer | remote project: linked-project cache (post-run, unconditional) | +| Method | Path | Auth | Purpose | +| ----------- | ----------------------------------------------------------------------------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| — | — | — | local target or database URL: none | +| GET | `/v1/projects` | Bearer | project picker: bare `--remote` with no ref from `SUPABASE_PROJECT_ID` or `.temp/project-ref`, interactive terminal only | +| GET | `/v1/projects/{parent}/branches` | Bearer | only for `--remote=<branch>` (or the deprecated `--project-ref=<branch>`), and for a bare `--remote` followed by a word that could name a branch; `{parent}` is `SUPABASE_PROJECT_ID`, else the linked project | +| GET | `/v1/branches/{id}` | Bearer | only for `--remote=<branch UUID>` when no project is linked | +| POST | `/v1/projects/{ref}/cli/login-role` | Bearer | remote project: temp login role when no password | +| GET | `/v1/projects/{ref}/config/database/pooler` | Bearer | remote project: IPv4 pooler fallback (IPv6-only network), only when there is no usable saved `.temp/pooler-url` and an access token is available; a ref that differs from the linked project ignores the saved URL | +| POST/DELETE | `/v1/projects/{ref}/network-bans/retrieve`, `/v1/projects/{ref}/network-bans` | Bearer | remote project: unban during pooler login retry, only under `--yes` | +| GET | `/v1/projects/{ref}` | Bearer | remote project: linked-project cache (post-run, unconditional) | ## Environment Variables diff --git a/apps/cli/src/commands/migration/up/SIDE_EFFECTS.md b/apps/cli/src/commands/migration/up/SIDE_EFFECTS.md index 0a4f65dc18..b9160fa63b 100644 --- a/apps/cli/src/commands/migration/up/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/migration/up/SIDE_EFFECTS.md @@ -28,7 +28,7 @@ Local targets and database URLs make none. | `GET` | `/v1/projects/{parent}/branches` | Bearer token | none | `[{id, name, project_ref, is_default}]` — only for `--remote=<branch>` (or the deprecated `--project-ref=<branch>`), and for a bare `--remote` followed by a word that could name a branch; `{parent}` is `SUPABASE_PROJECT_ID`, else the linked project | | `GET` | `/v1/branches/{id}` | Bearer token | none | `{ref, status, …}` — only for `--remote=<branch UUID>` when no project is linked | | `POST` | `/v1/projects/{ref}/cli/login-role` | Bearer token | `{ read_only: false }` | temporary `role` and `password` — remote project with no database password | -| `GET` | `/v1/projects/{ref}/config/database/pooler` | Bearer token | none | primary pooler `connection_string` — remote project on an IPv4-only network (direct host unreachable), only when there is no usable saved `.temp/pooler-url`; a ref that differs from the linked project ignores the saved URL | +| `GET` | `/v1/projects/{ref}/config/database/pooler` | Bearer token | none | primary pooler `connection_string` — remote project on an IPv4-only network (direct host unreachable), only when there is no usable saved `.temp/pooler-url` and an access token is available; a ref that differs from the linked project ignores the saved URL | | `POST` | `/v1/projects/{ref}/network-bans/retrieve` | Bearer token | none | `banned_ipv4_addresses` — remote project, from the 3rd failed temporary-role connect | | `DELETE` | `/v1/projects/{ref}/network-bans` | Bearer token | `{ ipv4_addresses, requester_ip: false }` | none — remote project, only when bans are listed and `--yes` or a shell `SUPABASE_YES` is set (clears every listed address); otherwise the connect keeps retrying and fails with `LinkedDbNetworkBannedError` once the retries run out | | `GET` | `/v1/projects/{ref}` | Bearer token | none | `{ref, name, organization_id, organization_slug}` — linked-project cache miss only (best-effort, post-run): written only when `.temp/linked-project.json` doesn't exist, `.temp/project-ref` doesn't name another ref, an access token exists and the lookup returns 200 | From f07c23dff81e830660dca066853efd4ef11756a1 Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Tue, 20 Oct 2026 15:29:21 +0100 Subject: [PATCH 248/291] fix(cli): suggest the local stack on a refused gen types connection in an unlinked checkout --- apps/cli/src/commands/gen/types/SIDE_EFFECTS.md | 5 +++-- apps/cli/src/commands/gen/types/types.handler.ts | 14 +++++--------- .../commands/gen/types/types.integration.test.ts | 16 +++++++++++++++- 3 files changed, 23 insertions(+), 12 deletions(-) diff --git a/apps/cli/src/commands/gen/types/SIDE_EFFECTS.md b/apps/cli/src/commands/gen/types/SIDE_EFFECTS.md index 5cadf3ef2f..d9a9f3efc7 100644 --- a/apps/cli/src/commands/gen/types/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/gen/types/SIDE_EFFECTS.md @@ -162,8 +162,9 @@ Not applicable. reports it as `suggestion`: `{"code":"GenTypesLocalDbNotRunningError","message":"supabase start is not running.","suggestion":"To use the linked project instead, pass --remote."}`. A container that exists but refuses the connection (`DbConnectError`) carries - `Make sure Docker is running, then run: supabase start` and, on a second line, the same pointer. - An unlinked checkout keeps the error without one. + `Make sure Docker is running, then run: supabase start` in any checkout and, in a linked one, the + same pointer on a second line. An unlinked checkout keeps the other two errors without a + suggestion. - **`--project-id`.** A hidden alias of the deprecated `--project-ref`; passing both fails with `--project-id is a deprecated alias of --project-ref; pass only one of them.` - **Remote project flag groups.** `--remote` (bare, or with a ref or branch), `--linked`, diff --git a/apps/cli/src/commands/gen/types/types.handler.ts b/apps/cli/src/commands/gen/types/types.handler.ts index 6ff0b09607..4404927c16 100644 --- a/apps/cli/src/commands/gen/types/types.handler.ts +++ b/apps/cli/src/commands/gen/types/types.handler.ts @@ -672,15 +672,11 @@ export const genTypes = Effect.fn("gen.types")(function* (flags: GenTypesFlags) user: "postgres", password: config.password, database: "postgres", - ...(linkedCheckout - ? { - suggestionContext: { - dashboardUrl: cliSettings.dashboardUrl, - profileName: cliSettings.profile, - linkedCheckout, - }, - } - : {}), + suggestionContext: { + dashboardUrl: cliSettings.dashboardUrl, + profileName: cliSettings.profile, + linkedCheckout, + }, }, isLocal: true, includedSchemas, 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 f7152c61fc..d9ee7872e2 100644 --- a/apps/cli/src/commands/gen/types/types.integration.test.ts +++ b/apps/cli/src/commands/gen/types/types.integration.test.ts @@ -2843,6 +2843,11 @@ describe("gen types", () => { user: "postgres", password: "postgres", database: "postgres", + suggestionContext: { + dashboardUrl: "https://supabase.com/dashboard", + profileName: "supabase", + linkedCheckout: false, + }, runtimeParams: { statement_timeout: "15000" }, connectTimeoutSeconds: 15, }); @@ -3236,7 +3241,11 @@ describe("gen types", () => { suggestion: "Make sure Docker is running, then run: supabase start\nTo use the linked project instead, pass --remote.", }, - { name: "an unlinked checkout", linked: false, suggestion: undefined }, + { + name: "an unlinked checkout", + linked: false, + suggestion: "Make sure Docker is running, then run: supabase start", + }, ])( "reports a refused local connection in $name in the JSON error envelope", ({ linked, suggestion }) => @@ -3281,6 +3290,11 @@ describe("gen types", () => { user: "postgres", password: "postgres", database: "postgres", + suggestionContext: { + dashboardUrl: "https://supabase.com/dashboard", + profileName: "supabase", + linkedCheckout: false, + }, runtimeParams: { statement_timeout: "15000" }, connectTimeoutSeconds: 15, }); From 4bbc68dee9667f9523f2b406a8aef9861107dcc5 Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Tue, 20 Oct 2026 15:30:07 +0100 Subject: [PATCH 249/291] test(cli): run test db through the shared target-selection harness test db and its db test alias share one handler, so one describeDbTargetSelection call covers both. The hand-written selector rows it replaces are gone: the default, bare --remote, --remote=<ref>, --remote=<db-url>, branch and --linked rows, the --project-ref, --db-url and --local alias rows, and the two selector-conflict rows the selector unit tests own. The consent, decline and real-resolver rows stay. --- .../test-db.integration.test.ts | 187 ++---------------- 1 file changed, 16 insertions(+), 171 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 0406e133e3..51cfa06138 100644 --- a/apps/cli/src/command-internal/test-db.integration.test.ts +++ b/apps/cli/src/command-internal/test-db.integration.test.ts @@ -28,11 +28,7 @@ import { mockTelemetryStateTracked, useTempWorkdir, } from "../../tests/helpers/command-mocks.ts"; -import { - BRANCH_PROJECT_REF, - branchListApi, - warningsOf, -} from "../../tests/helpers/remote-selection.ts"; +import { branchListApi } from "../../tests/helpers/remote-selection.ts"; import { assertPermissionDrift } from "../../tests/helpers/permission-drift.ts"; import { asRecordedRequests, @@ -43,6 +39,7 @@ import { type StubbedRequest, } from "../../tests/helpers/permission-real-layers.ts"; import { cliConfigValuesTestLayer } from "../../tests/helpers/config-values-layer.ts"; +import { describeDbTargetSelection } from "../../tests/helpers/db-target-selection.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"; @@ -52,10 +49,6 @@ 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"; @@ -718,61 +711,6 @@ describe("test db integration", () => { }).pipe(Effect.provide(layer)); }); - 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({ linked: true, local: true }))); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - expect(Cause.squash(exit.cause)).toBeInstanceOf(TargetSelectorConflictError); - } - expect(resolver.inputs).toEqual([]); - expect(connection.connectCalls).toEqual([]); - }).pipe(Effect.provide(layer)); - }); - - it.live("treats --linked=false as absent and runs against the default local target", () => { - const { layer, resolver } = setup(); - return Effect.gen(function* () { - yield* testDb(flags({ linked: false, local: false })); - expect(resolver.inputs[0]?.target.kind).toBe("local"); - }).pipe(Effect.provide(layer)); - }); - - it.live("targets the linked project for a bare --remote", () => { - const { layer, resolver } = setup({ conn: REMOTE_CONN, isLocal: false }); - return Effect.gen(function* () { - yield* testDb(flags({ remote: Option.some({ _tag: "Linked" }) })); - expect(resolver.inputs[0]?.target.kind).toBe("linked"); - }).pipe(Effect.provide(layer)); - }); - - it.live("selects the project given via --remote=<ref> alone", () => { - const FLAG_REF = "flagflagflagflagflag"; - const { layer, resolver } = setup({ conn: REMOTE_CONN, isLocal: false }); - return Effect.gen(function* () { - yield* testDb(flags({ remote: Option.some({ _tag: "Ref", ref: FLAG_REF } as const) })); - expect(resolver.inputs[0]?.target).toMatchObject({ kind: "linked", ref: FLAG_REF }); - }).pipe(Effect.provide(layer)); - }); - - 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({ local: true, projectRef: Option.some(FLAG_REF) })), - ); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - expect(Cause.squash(exit.cause)).toBeInstanceOf(TargetProjectRefConflictError); - } - expect(resolver.inputs).toEqual([]); - expect(connection.execCalls).toEqual([]); - expect(docker.lastOpts).toBeUndefined(); - }).pipe(Effect.provide(layer)); - }); - it.live("honors --network-id, overriding the generated local network name", () => { const { layer, docker } = setup({ networkId: "my-custom-net" }); return Effect.gen(function* () { @@ -914,115 +852,22 @@ describe("test db integration", () => { provideEnv({ SUPABASE_ACCESS_TOKEN: VALID_TOKEN }), ); }); -}); - -describe("test db target selection", () => { - const FLAG_PROJECT_REF = "flagflagflagflagflag"; - - const resolvedTarget = ( - selector: Partial<Parameters<typeof testDb>[0]>, - options: { readonly branchApi?: ReturnType<typeof branchListApi> } = {}, - ) => { - const { layer, out, resolver } = setup({ conn: REMOTE_CONN }); - const provided = - options.branchApi === undefined - ? layer - : Layer.mergeAll( - layer, - mockCommandSettings({ workdir: "/work/project", projectId: Option.some(VALID_REF) }), - options.branchApi.layer, - ); - return Effect.gen(function* () { - yield* testDb(flags(selector)).pipe(Effect.provide(provided)); - return { target: resolver.inputs[0]?.target, warnings: warningsOf(out) }; - }); - }; - it.live("targets the local database, with no warning, when nothing selects one", () => - Effect.gen(function* () { - const { target, warnings } = yield* resolvedTarget({}); - expect(target).toEqual({ kind: "local" }); - expect(warnings).toEqual([]); - }), - ); - - it.live("targets the project --remote=<ref> names, with no warning", () => - Effect.gen(function* () { - const { target, warnings } = yield* resolvedTarget({ - remote: Option.some({ _tag: "Ref", ref: FLAG_PROJECT_REF } as const), - }); - expect(target).toMatchObject({ kind: "linked", ref: FLAG_PROJECT_REF, source: "flag" }); - expect(warnings).toEqual([]); - }), - ); - - it.live("targets the database --remote=<db-url> names, with no warning", () => - Effect.gen(function* () { - const { target, warnings } = yield* resolvedTarget({ - remote: Option.some({ - _tag: "DbUrl", - url: "postgresql://postgres:pw@db.example.com/db", - } as const), - }); - expect(target?.kind).toBe("db-url"); - expect(warnings).toEqual([]); - }), - ); - - it.live("targets the project of the branch --remote=<branch> names", () => { - const branchApi = branchListApi(); - return Effect.gen(function* () { - const { target, warnings } = yield* resolvedTarget( - { remote: Option.some({ _tag: "Branch", name: "feature-x" }) }, - { branchApi }, - ); - expect(target).toMatchObject({ kind: "linked", ref: BRANCH_PROJECT_REF, source: "flag" }); - expect(warnings).toEqual([]); - assertPermissionDrift({ - command: "test db", - activeFlags: { remote: "feature-x" }, - requests: branchApi.requests, - }); - }); - }); - - it.live.each([ - { - alias: "--linked", - flags: { linked: true }, - kind: "linked", - warning: "--linked is deprecated and will be removed in v4. Use --remote instead", - }, - { - alias: "--project-ref", - flags: { projectRef: Option.some(FLAG_PROJECT_REF) }, - kind: "linked", - warning: "--project-ref is deprecated and will be removed in v4. Use --remote=<ref> instead", - }, - { - alias: "--db-url", - flags: { dbUrl: Option.some("postgresql://postgres:pw@db.example.com/db") }, - kind: "db-url", - warning: "--db-url is deprecated and will be removed in v4. Use --remote=<db-url> instead", + describeDbTargetSelection("test db", { + dbUrl: true, + workdir: () => tempWorkdir.current, + attempt: ({ workdir, target }) => { + const ctx = setup({ conn: REMOTE_CONN, workdir }); + const branchApi = branchListApi(); + return { + layer: Layer.merge(ctx.layer, branchApi.layer), + out: ctx.out, + run: testDb(flags(target)), + target: () => ctx.resolver.inputs[0]?.target, + branchLookups: () => branchApi.requests, + }; }, - { - alias: "--local", - flags: { local: true }, - kind: "local", - warning: "--local isn't needed (local is the default) and will be removed in v4", - }, - ] satisfies ReadonlyArray<{ - alias: string; - flags: Partial<Parameters<typeof testDb>[0]>; - kind: string; - warning: string; - }>)("keeps $alias working and warns that it is deprecated", ({ flags, kind, warning }) => - Effect.gen(function* () { - const { target, warnings } = yield* resolvedTarget(flags); - expect(target?.kind).toBe(kind); - expect(warnings).toEqual([expect.stringContaining(warning)]); - }), - ); + }); }); describeHostedConsent("test db", { From 98621edb1a9ed42520a8ff40c2f81459fbf8d1b3 Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Tue, 20 Oct 2026 15:30:07 +0100 Subject: [PATCH 250/291] test(cli): assert the resolved ref when --project-ref redirects both db diff remote sides --- apps/cli/src/commands/db/diff/diff.integration.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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 a620acc17d..89b0bdeda0 100644 --- a/apps/cli/src/commands/db/diff/diff.integration.test.ts +++ b/apps/cli/src/commands/db/diff/diff.integration.test.ts @@ -2706,7 +2706,9 @@ describe("db diff", () => { projectRef: Option.some(FLAG_PROJECT_REF), }), ); - expect(s.resolverCalls).toHaveLength(1); + expect(s.resolverCalls.map((call) => call.target)).toEqual([ + expect.objectContaining({ kind: "linked", ref: FLAG_PROJECT_REF, source: "flag" }), + ]); expect(targetLines(s.out)).toEqual([`Target: project ${FLAG_PROJECT_REF}`]); expect(warningsOf(s.out)).toEqual([PROJECT_REF_WARNING]); }).pipe(Effect.provide(s.layer)); From dd738f065520b683675c7f3859733f7c289c8d09 Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Tue, 20 Oct 2026 15:31:31 +0100 Subject: [PATCH 251/291] test(cli): match the --project-ref deprecation warning loosely in the hosted matrix The hosted target-selection matrix asserts that the alias warns, not the full sentence; target-deprecation.unit pins the wording. --- apps/cli/tests/helpers/hosted-target-selection.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/apps/cli/tests/helpers/hosted-target-selection.ts b/apps/cli/tests/helpers/hosted-target-selection.ts index ae759d3ed6..2e2fe2d882 100644 --- a/apps/cli/tests/helpers/hosted-target-selection.ts +++ b/apps/cli/tests/helpers/hosted-target-selection.ts @@ -145,16 +145,14 @@ export function describeTargetSelectionMatrix<R>( }).pipe(Effect.provide(run.layer)); }); - it.effect("keeps --project-ref working and points at --remote=<ref>", () => { + it.effect("keeps --project-ref working and warns that it is deprecated", () => { const run = harness(["--project-ref", OTHER_REF], OTHER_REF); return Effect.gen(function* () { yield* prepare; yield* matrix.run({ remote: Option.none(), projectRef: Option.some(OTHER_REF) }); expectActedOn(run, OTHER_REF); - expect(run.warnings()).toEqual([ - `--project-ref is deprecated and will be removed in v4. Use --remote=<ref> instead`, - ]); + expect(run.warnings()).toEqual([expect.stringContaining("--project-ref is deprecated")]); }).pipe(Effect.provide(run.layer)); }); From 3871c22528de25425a25ddc308564c62248732b2 Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Tue, 20 Oct 2026 15:37:31 +0100 Subject: [PATCH 252/291] test(cli): shorten the comment lines this branch added past 100 columns Tighten the 26 comment lines over 100 columns that the branch added to test files and helpers. The two multi-line blocks (push `setupLinkedBranchPush`, seed buckets e2e) collapse to one line each; no file ends with more comment lines than it had. --- .../command-internal/target/target-policy.unit.test.ts | 2 +- .../src/commands/config/diff/diff.integration.test.ts | 2 +- .../src/commands/config/pull/pull.integration.test.ts | 2 +- .../src/commands/config/push/push.integration.test.ts | 9 ++------- .../migration/squash/squash.integration.test.ts | 2 +- apps/cli/src/commands/seed/buckets/buckets.e2e.test.ts | 8 +------- apps/cli/src/shared/cli/code-structure.unit.test.ts | 2 +- .../shared/telemetry/error-tag-stability.unit.test.ts | 2 +- apps/cli/tests/helpers/compute-target-selection.ts | 4 ++-- apps/cli/tests/helpers/db-target-selection.ts | 10 +++++----- apps/cli/tests/helpers/hosted-consent.ts | 4 ++-- apps/cli/tests/helpers/hosted-target-selection.ts | 6 +++--- apps/cli/tests/helpers/notebooks.ts | 2 +- apps/cli/tests/helpers/permission-registry.ts | 2 +- apps/cli/tests/helpers/remote-flag-fixture.ts | 2 +- apps/cli/tests/helpers/remote-selection.ts | 2 +- apps/cli/tests/helpers/storage.ts | 2 +- 17 files changed, 26 insertions(+), 37 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 a9286f36c7..29c01cbda0 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 @@ -33,7 +33,7 @@ const CONSENT_FREE_OPERATIONS: ReadonlyMap<string, string> = new Map([ ["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`. */ +/** Non-GET operations a single leaf may call without consent, keyed by its command path. */ const LEAF_CONSENT_FREE_OPERATIONS: ReadonlyMap<string, ReadonlyMap<string, string>> = new Map([ [ "notebooks pull", 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 3c4205dcf1..25087a0ad4 100644 --- a/apps/cli/src/commands/config/diff/diff.integration.test.ts +++ b/apps/cli/src/commands/config/diff/diff.integration.test.ts @@ -85,7 +85,7 @@ const PROJECT: (typeof V1ListAllProjectsOutput.Type)[number] = { }, }; -/** A branch row of the linked project's branch list, which every branch `--remote` is looked up in. */ +/** A branch row of the linked project's branch list. */ const BRANCH_BY_NAME = { id: BRANCH_UUID, name: "staging", 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 028fde7ff3..39158b942d 100644 --- a/apps/cli/src/commands/config/pull/pull.integration.test.ts +++ b/apps/cli/src/commands/config/pull/pull.integration.test.ts @@ -257,7 +257,7 @@ function v2Response( }; } -/** A branch row of the linked project's branch list, which every branch `--remote` is looked up in. */ +/** A branch row of the linked project's branch list. */ const BRANCH_BY_NAME = { id: "11111111-1111-4111-8111-111111111111", name: "staging", 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 f37a07aa89..385ecd6f92 100644 --- a/apps/cli/src/commands/config/push/push.integration.test.ts +++ b/apps/cli/src/commands/config/push/push.integration.test.ts @@ -224,7 +224,7 @@ const BRANCH_BY_NAME = { with_data: false, }; -/** The production branch's list row: its project is the project itself, so its ref is the parent's. */ +/** The production branch's list row; its project_ref is the parent's own. */ const DEFAULT_BRANCH = { ...BRANCH_BY_NAME, name: "main", @@ -3084,12 +3084,7 @@ secrets = "v1,whsec_abc" // the branch/project target-detection scenarios below to prove a push actually proceeded. 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 probed target is `{ kind: "branch", - * ref: BRANCH_REF, parentRef: PARENT_REF, branch: "feat-x" }` while the banner stays `project <ref>`. - */ +/** Mirrors `supabase link <branch>`: `project-ref` holds the branch, the cache its parent. */ function setupLinkedBranchPush( opts: { readonly format?: "text" | "json" | "stream-json"; 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 ab89f6f9ea..067c75c3b0 100644 --- a/apps/cli/src/commands/migration/squash/squash.integration.test.ts +++ b/apps/cli/src/commands/migration/squash/squash.integration.test.ts @@ -240,7 +240,7 @@ interface SetupOpts { readonly interactive?: boolean; readonly yes?: boolean; readonly confirm?: boolean; - /** Overrides whether the resolved connection counts as local (a `--db-url` can point either way). */ + /** Whether the resolved connection counts as local; a `--db-url` can point either way. */ readonly isLocal?: boolean; readonly failResolve?: boolean; readonly failSql?: string; 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 f502858001..3c755ff0e8 100644 --- a/apps/cli/src/commands/seed/buckets/buckets.e2e.test.ts +++ b/apps/cli/src/commands/seed/buckets/buckets.e2e.test.ts @@ -18,13 +18,7 @@ const makeProjectDir = Effect.fnUntraced(function* (prefix: string) { return projectDir; }); -/** - * 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); - * - `--remote` combined with a deprecated selector alias is rejected by the shared target selector. - * Bucket/object seeding is covered by the integration and unit suites. - */ +/** Network-free golden paths only; the integration and unit suites cover seeding itself. */ describe("supabase seed buckets", () => { it.live( "is a no-op with exit 0 when no buckets are configured", 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 fe41bc426d..9020880fad 100644 --- a/apps/cli/src/shared/cli/code-structure.unit.test.ts +++ b/apps/cli/src/shared/cli/code-structure.unit.test.ts @@ -215,7 +215,7 @@ function findConfigLoaderBypasses(source: string): Array<string> { 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. */ +/** Raw declarations of the target selector flags, which `target-flags.ts` owns. */ export function findRawTargetFlags(source: string): Array<string> { return Array.from(stripComments(source).matchAll(RAW_TARGET_FLAG), (m) => m[0]); } diff --git a/apps/cli/src/shared/telemetry/error-tag-stability.unit.test.ts b/apps/cli/src/shared/telemetry/error-tag-stability.unit.test.ts index 479e673b21..71e91f2b51 100644 --- a/apps/cli/src/shared/telemetry/error-tag-stability.unit.test.ts +++ b/apps/cli/src/shared/telemetry/error-tag-stability.unit.test.ts @@ -68,7 +68,7 @@ function isProductionSourceFile(filePath: string): boolean { return !TEST_FILE_SUFFIXES.some((suffix) => filePath.endsWith(suffix)); } -/** Regexes every `Data.TaggedError("...")` string-literal declaration out of the given production files. */ +/** The `Data.TaggedError("...")` declarations in the given production files. */ const collectDeclarations = (filePaths: ReadonlyArray<string>) => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; diff --git a/apps/cli/tests/helpers/compute-target-selection.ts b/apps/cli/tests/helpers/compute-target-selection.ts index 7325f08a21..96e75b125d 100644 --- a/apps/cli/tests/helpers/compute-target-selection.ts +++ b/apps/cli/tests/helpers/compute-target-selection.ts @@ -11,7 +11,7 @@ import { branchRow, warningsOf } from "./remote-selection.ts"; type ComputeSetup = ReturnType<typeof setupCompute>; -/** Everything a compute leaf's handler can require while `describeComputeTargetSelection` runs it. */ +/** Everything a compute leaf's handler can require under `describeComputeTargetSelection`. */ type ComputeTargetSelectionServices = Layer.Success<ComputeSetup["layer"]>; export interface ComputeTargetSelectionScenario { @@ -37,7 +37,7 @@ const writeFiles = (workdir: string, files: Readonly<Record<string, string>>) => } }).pipe(Effect.provide(BunServices.layer)); -/** `describeTargetSelectionMatrix` for a `compute` leaf, whose runtime is `setupCompute` over stubbed routes. */ +/** `describeTargetSelectionMatrix` for a `compute` leaf, run through `setupCompute`. */ export function describeComputeTargetSelection( path: string, scenario: ComputeTargetSelectionScenario, diff --git a/apps/cli/tests/helpers/db-target-selection.ts b/apps/cli/tests/helpers/db-target-selection.ts index 2450949223..99bc423752 100644 --- a/apps/cli/tests/helpers/db-target-selection.ts +++ b/apps/cli/tests/helpers/db-target-selection.ts @@ -66,7 +66,7 @@ interface RecordedLookup { readonly urlParams?: string; } -/** The branch lookups, as `branchLookups` reports them, among the Management API method calls a fake recorded. */ +/** The `branchLookups` entries among the Management API method calls a fake recorded. */ export const branchLookupsOf = ( calls: ReadonlyArray<{ readonly method: string; readonly input: unknown }>, ): ReadonlyArray<RecordedLookup> => @@ -92,7 +92,7 @@ interface DbTargetAttempt<R> { readonly layer: Layer.Layer<R, unknown>; readonly out: ReturnType<typeof mockOutput>; readonly run: Effect.Effect<unknown, unknown, R | Scope.Scope>; - /** The target the leaf opened its connection or requests for; `undefined` until it reached one. */ + /** The target the leaf connected or sent requests to; `undefined` until it reached one. */ readonly target: () => ObservedTarget | undefined; readonly cachedRef?: () => string | undefined; /** The branch-list requests the leaf sent, for a leaf that records them. */ @@ -111,11 +111,11 @@ type Row = | "remote-with-local"; interface DbTargetSelectionScenario<R> { - /** Whether the command takes a database URL; storage and seed take a project ref or a branch name. */ + /** Whether the command takes a database URL; storage and seed take a ref or a branch name. */ readonly dbUrl: boolean; - /** The directory the leaf's layers read, which the linked-checkout row links to {@link VALID_REF}. */ + /** The directory the leaf's layers read; the linked rows link it to {@link VALID_REF}. */ readonly workdir: () => string; - /** Positional arguments the parser needs before it reaches `--remote`, for a command that takes no database URL. */ + /** Operands the parser needs before `--remote`, for a command that takes no database URL. */ readonly operands?: ReadonlyArray<string>; /** * One run of the leaf: its layers, its handler called with `target`, and what it observed. The diff --git a/apps/cli/tests/helpers/hosted-consent.ts b/apps/cli/tests/helpers/hosted-consent.ts index 98f954a54f..5d932c1521 100644 --- a/apps/cli/tests/helpers/hosted-consent.ts +++ b/apps/cli/tests/helpers/hosted-consent.ts @@ -43,7 +43,7 @@ interface HostedConsentEnv { interface HostedConsentInvocation { readonly run: Effect.Effect<unknown, unknown, HostedConsentServices>; - /** Persistent writes made so far; defaults to the non-GET requests the mocked platform API recorded. */ + /** Persistent writes so far; defaults to the non-GET requests the mocked API recorded. */ readonly mutations?: () => number; } @@ -54,7 +54,7 @@ export interface HostedConsentScenario { readonly typed?: boolean; readonly api?: MockCommandPlatformApiOpts; readonly setup?: (workdir: string) => Effect.Effect<void, unknown, FileSystem.FileSystem>; - /** Builds the leaf's handler call; runs once per case, so per-case state can live in its closure. */ + /** Builds the handler call; runs once per case, so per-case state can live in its closure. */ readonly invoke: (env: HostedConsentEnv) => HostedConsentInvocation; } diff --git a/apps/cli/tests/helpers/hosted-target-selection.ts b/apps/cli/tests/helpers/hosted-target-selection.ts index 2e2fe2d882..dd6c43f5b0 100644 --- a/apps/cli/tests/helpers/hosted-target-selection.ts +++ b/apps/cli/tests/helpers/hosted-target-selection.ts @@ -40,7 +40,7 @@ interface TargetSelectionHarness<R> { readonly layer: Layer.Layer<R, unknown>; readonly requests: () => ReadonlyArray<RecordedCall>; readonly warnings: () => ReadonlyArray<string>; - /** For a leaf whose requests never name its project (`sso info`, `snippets list`): the ref it cached as its target. */ + /** The ref the leaf cached as its target, for one whose requests never name its project. */ readonly cachedRef?: () => string | undefined; } @@ -92,7 +92,7 @@ export function describeTargetSelectionMatrix<R>( const commandName = `\`supabase ${path}\``; const extraFlags = matrix.activeFlags ?? {}; const driftVariant = matrix.variant === undefined ? {} : { variant: matrix.variant }; - // A parent-scoped command goes on to touch the branch's own project, so only the first project it reaches is the parent. + // A parent-scoped command goes on to touch the branch's project, so only its first ref counts. const expectActedOn = (run: TargetSelectionHarness<R>, ref: string) => { const refs = projectRefsOf(run.requests()); if (run.cachedRef !== undefined) expect(run.cachedRef()).toBe(ref); @@ -256,7 +256,7 @@ const writeFiles = (workdir: string, files: Readonly<Record<string, string>>) => } }).pipe(Effect.provide(BunServices.layer)); -/** `describeTargetSelectionMatrix` for a hosted leaf whose runtime is `buildTestRuntime` over a mocked Management API. */ +/** `describeTargetSelectionMatrix` for a hosted leaf run through `buildTestRuntime`. */ export function describeHostedTargetSelection( path: string, scenario: HostedTargetSelectionScenario, diff --git a/apps/cli/tests/helpers/notebooks.ts b/apps/cli/tests/helpers/notebooks.ts index a8d8aaa950..60bc890331 100644 --- a/apps/cli/tests/helpers/notebooks.ts +++ b/apps/cli/tests/helpers/notebooks.ts @@ -274,7 +274,7 @@ export interface NotebooksSetupOptions { 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. */ + /** `--yes`; on by default so hosted-mutation consent does not block push tests. */ readonly yes?: boolean; /** * Runs the real `projectRefLayer` (an unlinked workdir reaches its picker) and the real diff --git a/apps/cli/tests/helpers/permission-registry.ts b/apps/cli/tests/helpers/permission-registry.ts index 60f77c5277..ec4abc4400 100644 --- a/apps/cli/tests/helpers/permission-registry.ts +++ b/apps/cli/tests/helpers/permission-registry.ts @@ -83,7 +83,7 @@ function selectorShapeOf(activeFlags: ActiveFlags): SelectorShape { } } -/** The target `activeFlags` selects (`db-url`, `local`, `linked`, `remote`, or `project-ref`, which imply linked unless `remote` is a database URL), else the local stack. */ +/** The database target `activeFlags` selects, or the local stack when it selects none. */ function dbTargetOf(activeFlags: ActiveFlags): DbTarget { for (const flag of ["db-url", "local", "linked"] as const satisfies ReadonlyArray<DbTarget>) { if (Object.hasOwn(activeFlags, flag)) return flag; diff --git a/apps/cli/tests/helpers/remote-flag-fixture.ts b/apps/cli/tests/helpers/remote-flag-fixture.ts index 9efa6518e1..dde3404a83 100644 --- a/apps/cli/tests/helpers/remote-flag-fixture.ts +++ b/apps/cli/tests/helpers/remote-flag-fixture.ts @@ -37,7 +37,7 @@ export interface FixtureLeafOptions { readonly description?: string; } -/** A leaf declaring `group`'s target policy, with the `--remote` flag that group takes unless `withRemote` is false. */ +/** A leaf with `group`'s target policy and its `--remote` flag, unless `withRemote` is false. */ export const fixtureLeaf = ( name: string, group: FixtureGroup, diff --git a/apps/cli/tests/helpers/remote-selection.ts b/apps/cli/tests/helpers/remote-selection.ts index 4d5234c209..5173b58841 100644 --- a/apps/cli/tests/helpers/remote-selection.ts +++ b/apps/cli/tests/helpers/remote-selection.ts @@ -20,7 +20,7 @@ export const branchRow = (name: string) => ({ with_data: true, }); -/** A platform API that only answers the linked project's branch list, with one branch named `name`. */ +/** A platform API that only answers the linked project's branch list, with one branch `name`. */ export const branchListApi = (name = "feature-x") => mockCommandPlatformApi({ handler: (request) => diff --git a/apps/cli/tests/helpers/storage.ts b/apps/cli/tests/helpers/storage.ts index 856eb5d901..be46ccebe6 100644 --- a/apps/cli/tests/helpers/storage.ts +++ b/apps/cli/tests/helpers/storage.ts @@ -406,7 +406,7 @@ 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. */ +/** The Storage 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( From 345bb82e13fd4c96ab47a06f9c12c566417f9bc2 Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Tue, 20 Oct 2026 15:45:13 +0100 Subject: [PATCH 253/291] test(cli): fail the hosted branch row when a leaf sends its request to the parent Every request after the branch lookup that names a project must name the branch's project, so a `--remote=<branch>` that deploys, pulls or diffs against the linked parent no longer passes just because the leaf caches the branch's ref. The project in a `project_ref` query counts as named. A leaf whose requests name no project (`sso info`, `snippets download`) now declares `requestsNameProject: false`; every other leaf must name its project in the `--remote=<ref>` and bare `--remote` rows instead of passing on its cached ref alone. --- .../download/download.integration.test.ts | 1 + .../sso/info/info.integration.test.ts | 1 + .../tests/helpers/hosted-target-selection.ts | 30 ++++++++++++------- 3 files changed, 21 insertions(+), 11 deletions(-) diff --git a/apps/cli/src/commands/snippets/download/download.integration.test.ts b/apps/cli/src/commands/snippets/download/download.integration.test.ts index d9aaaecb93..5fea756d9c 100644 --- a/apps/cli/src/commands/snippets/download/download.integration.test.ts +++ b/apps/cli/src/commands/snippets/download/download.integration.test.ts @@ -439,6 +439,7 @@ describe("snippets download integration", () => { describeHostedTargetSelection("snippets download", { operands: [VALID_ID], + requestsNameProject: false, respond: (request) => Effect.succeed(jsonResponse(request, 200, SNIPPET_RESPONSE)), run: (flags) => snippetsDownload({ ...flags, snippetId: VALID_ID }), }); diff --git a/apps/cli/src/commands/sso/info/info.integration.test.ts b/apps/cli/src/commands/sso/info/info.integration.test.ts index 2d4dbec844..fce0d40a6d 100644 --- a/apps/cli/src/commands/sso/info/info.integration.test.ts +++ b/apps/cli/src/commands/sso/info/info.integration.test.ts @@ -206,6 +206,7 @@ describe("sso info integration", () => { }); describeHostedTargetSelection("sso info", { + requestsNameProject: false, respond: (request) => Effect.succeed(jsonResponse(request, 200, {})), run: (flags) => ssoInfo(flags), }); diff --git a/apps/cli/tests/helpers/hosted-target-selection.ts b/apps/cli/tests/helpers/hosted-target-selection.ts index dd6c43f5b0..a5cde75e32 100644 --- a/apps/cli/tests/helpers/hosted-target-selection.ts +++ b/apps/cli/tests/helpers/hosted-target-selection.ts @@ -40,7 +40,7 @@ interface TargetSelectionHarness<R> { readonly layer: Layer.Layer<R, unknown>; readonly requests: () => ReadonlyArray<RecordedCall>; readonly warnings: () => ReadonlyArray<string>; - /** The ref the leaf cached as its target, for one whose requests never name its project. */ + /** The ref the leaf cached as its target. */ readonly cachedRef?: () => string | undefined; } @@ -56,6 +56,8 @@ export interface TargetSelectionMatrix<R> { /** Flags the command's permission declaration conditions on, beyond the selector under test. */ readonly activeFlags?: ActiveFlags; readonly variant?: PermissionVariant; + /** `false` for a leaf whose requests name no project: only its cached ref shows the target. */ + readonly requestsNameProject?: false; /** Builds the runtime for one run; `selectorArgs` is the selector's share of the command line. */ readonly harness: (input: { readonly selectorArgs: ReadonlyArray<string>; @@ -69,11 +71,14 @@ export interface TargetSelectionMatrix<R> { readonly run: (flags: TargetSelectionFlags) => Effect.Effect<unknown, unknown, R>; } +// `snippets list` names its project in a `project_ref` query parameter, not in the path. const projectRefsOf = (requests: ReadonlyArray<RecordedCall>): ReadonlyArray<string> => [ ...new Set( requests.flatMap((request) => { - const ref = PROJECT_REF_IN_URL.exec(new URL(request.url, API_URL).pathname)?.[1]; - return ref === undefined ? [] : [ref]; + const ref = + PROJECT_REF_IN_URL.exec(new URL(request.url, API_URL).pathname)?.[1] ?? + new URLSearchParams(request.urlParams ?? "").get("project_ref"); + return ref === null || ref === undefined ? [] : [ref]; }), ), ]; @@ -96,8 +101,8 @@ export function describeTargetSelectionMatrix<R>( const expectActedOn = (run: TargetSelectionHarness<R>, ref: string) => { const refs = projectRefsOf(run.requests()); if (run.cachedRef !== undefined) expect(run.cachedRef()).toBe(ref); - if (run.cachedRef !== undefined && refs.length === 0) return; - if (matrix.mode === "parent") expect(refs[0]).toBe(ref); + if (matrix.requestsNameProject === false) expect(refs).toEqual([]); + else if (matrix.mode === "parent") expect(refs[0]).toBe(ref); else expect(refs).toEqual([ref]); }; @@ -189,16 +194,15 @@ export function describeTargetSelectionMatrix<R>( projectRef: Option.none(), }); - expect(run.requests()[0]).toMatchObject({ + const [lookup, ...afterLookup] = run.requests(); + expect(lookup).toMatchObject({ method: "GET", url: `${API_URL}/v1/projects/${VALID_REF}/branches`, }); - const refs = projectRefsOf(run.requests()); if (run.cachedRef !== undefined) expect(run.cachedRef()).toBe(BRANCH_PROJECT_REF); - expect(refs[0]).toBe(VALID_REF); - if (run.cachedRef === undefined || refs.length > 1) { - expect(refs).toEqual([VALID_REF, BRANCH_PROJECT_REF]); - } + expect(projectRefsOf(afterLookup)).toEqual( + matrix.requestsNameProject === false ? [] : [BRANCH_PROJECT_REF], + ); assertPermissionDrift({ command: path, ...driftVariant, @@ -229,6 +233,7 @@ type HostedTargetSelectionServices = Layer.Success<ReturnType<typeof hostedLayer export interface HostedTargetSelectionScenario { readonly mode?: "project" | "parent"; readonly operands?: ReadonlyArray<string>; + readonly requestsNameProject?: false; /** Files the leaf reads from the project directory, relative to it. */ readonly files?: Readonly<Record<string, string>>; /** @@ -267,6 +272,9 @@ export function describeHostedTargetSelection( mode, operands: scenario.operands ?? [], ...(scenario.activeFlags === undefined ? {} : { activeFlags: scenario.activeFlags }), + ...(scenario.requestsNameProject === undefined + ? {} + : { requestsNameProject: scenario.requestsNameProject }), ...(files === undefined ? {} : { prepare: (workdir) => writeFiles(workdir, files) }), run: scenario.run, harness: ({ selectorArgs, expectedRef, workdir }) => { From 2fc69e49f51cc4a3728d31e59743050b0b3b9456 Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Tue, 20 Oct 2026 15:46:32 +0100 Subject: [PATCH 254/291] test(cli): check a failed inspect db run never prints the connection URL The unreachable-database case checks the whole stdout and stderr for the `--remote=<url>` operand, the subprocess-level check the removed `--db-url` e2e case used to carry. --- apps/cli/src/commands/inspect/db/inspect-db.e2e.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 0071715d9f..d92345e241 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 @@ -21,13 +21,14 @@ describe("supabase inspect db", () => { () => withTempHome((home) => Effect.gen(function* () { - const { exitCode, stderr } = yield* runSupabaseEffect( + const { exitCode, stdout, stderr } = yield* runSupabaseEffect( ["inspect", "db", "locks", TEXT_MODE, TEXT_MODE_VALUE, `--remote=${DEAD_DB_URL}`], { home: home.dir, env: { HOME: home.dir } }, ); expect(exitCode).toBe(1); expect(stderr).toContain("Target: custom Postgres (127.0.0.1:1)"); expect(stderr).toMatch(/failed to connect to postgres|connection refused|ECONNREFUSED/i); + expect(`${stdout}${stderr}`).not.toContain(DEAD_DB_URL); }), ), E2E_TIMEOUT_MS, From 945a72bc29e34777792b62963260c75a51343804 Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Tue, 20 Oct 2026 15:47:00 +0100 Subject: [PATCH 255/291] docs(cli): wrap added comment lines at 100 columns and trim comment prose --- apps/cli-e2e/src/tests/link-project.ts | 2 +- apps/cli/src/cli/complete.ts | 4 +- .../command-permissions/model.ts | 23 +++++---- .../src/command-internal/connect-errors.ts | 2 +- .../src/command-internal/db-config.errors.ts | 2 +- .../src/command-internal/db-config.layer.ts | 20 +++++--- .../src/command-internal/db-config.service.ts | 4 +- .../src/command-internal/db-config.types.ts | 4 +- apps/cli/src/command-internal/db-push-core.ts | 2 +- apps/cli/src/command-internal/global-flags.ts | 51 ++++++++++++------- .../hosted-consent/hosted-consent.ts | 7 ++- .../hosted-consent/hosted-consent.yes.ts | 5 +- .../command-internal/parent-project-ref.ts | 12 ++--- .../command-internal/project-create-core.ts | 2 +- .../command-internal/seed-remote-consent.ts | 2 +- apps/cli/src/command-internal/sql-split.ts | 2 +- .../target/branch-resolution.errors.ts | 8 +-- .../target/branch-resolution.ts | 15 +++--- .../command-internal/target/closest-branch.ts | 4 +- .../target/parent-scoped-project-ref.ts | 7 ++- .../target/remote-resolution.ts | 6 +-- .../command-internal/target/remote-value.ts | 10 ++-- .../target/remote-vocabulary.ts | 6 +-- .../target/resolve-project-target.ts | 4 +- .../target/target-deprecation.ts | 10 ++-- .../command-internal/target/target-flags.ts | 23 ++++++--- .../target/target-identity.ts | 6 +-- .../target/target-selector.ts | 4 +- .../command-internal/target/target.errors.ts | 6 +-- .../src/commands/branches/branches.target.ts | 2 +- .../config/push/push.branch-target.ts | 17 ++++--- apps/cli/src/commands/db/diff/diff.command.ts | 4 +- .../cli/src/commands/db/diff/diff.explicit.ts | 9 ++-- apps/cli/src/commands/db/diff/diff.handler.ts | 2 +- .../src/commands/db/query/query.read-only.ts | 4 +- .../declarative/declarative.smart-target.ts | 2 +- apps/cli/src/commands/db/test/test.command.ts | 2 +- .../src/commands/gen/types/types.command.ts | 2 +- .../src/commands/gen/types/types.handler.ts | 6 +-- apps/cli/src/commands/pull/pull.steps.ts | 8 +-- apps/cli/src/config/project-ref.service.ts | 2 +- apps/cli/src/shared/cli/cli-args.service.ts | 2 +- .../src/shared/cli/invalid-value-message.ts | 4 +- apps/cli/src/shared/cli/run.ts | 4 +- .../shared/cli/subcommand-flag-suggestions.ts | 13 +++-- apps/cli/src/shared/config/temp-paths.ts | 2 +- .../shared/output/command-target.service.ts | 4 +- apps/cli/src/shared/output/output.layer.ts | 2 +- apps/cli/src/shared/output/target-help.ts | 2 +- .../shared/telemetry/error-actionability.ts | 2 +- 50 files changed, 199 insertions(+), 149 deletions(-) diff --git a/apps/cli-e2e/src/tests/link-project.ts b/apps/cli-e2e/src/tests/link-project.ts index e0d0df353a..513658c3c7 100644 --- a/apps/cli-e2e/src/tests/link-project.ts +++ b/apps/cli-e2e/src/tests/link-project.ts @@ -1,7 +1,7 @@ import { mkdirSync, writeFileSync } from "node:fs"; import { join } from "node:path"; -/** Writes `project-ref` and a stub `pooler-url` so `--remote` commands reach their handler without a live Postgres probe. */ +/** Writes `project-ref` and a stub `pooler-url` so `--remote` skips the live Postgres probe. */ export function linkProject(dir: string, ref: string): void { const tempDir = join(dir, "supabase", ".temp"); mkdirSync(tempDir, { recursive: true }); diff --git a/apps/cli/src/cli/complete.ts b/apps/cli/src/cli/complete.ts index 07d7a158b7..a175e2908e 100644 --- a/apps/cli/src/cli/complete.ts +++ b/apps/cli/src/cli/complete.ts @@ -133,8 +133,8 @@ function flattenSubcommands(command: Command.Command.Any): ReadonlyArray<Command } /** - * `choiceKeys` (a `Choice` primitive's valid value set) is attached via `Object.assign` at runtime and - * isn't part of the public `Primitive<A>` type, so it narrows through the same guard as `CommandInternal`. + * `choiceKeys` (a `Choice` primitive's valid values) is attached via `Object.assign`, so it is + * absent from the public `Primitive<A>` type and narrows via the same guard as `CommandInternal`. */ interface ChoicePrimitive { readonly choiceKeys: ReadonlyArray<string>; diff --git a/apps/cli/src/command-internal/command-permissions/model.ts b/apps/cli/src/command-internal/command-permissions/model.ts index 7940d81ef2..c7254892eb 100644 --- a/apps/cli/src/command-internal/command-permissions/model.ts +++ b/apps/cli/src/command-internal/command-permissions/model.ts @@ -1,11 +1,11 @@ /** Serializable description of the Management API operations a command may call, keyed by the spec's `operationId`. */ -/** `best-effort` keeps the command's useful result when the call gets a 403; anything else is `required`. */ +/** `best-effort` keeps the command's result on a 403; anything else is `required`. */ type PermissionKind = "required" | "best-effort"; interface FlagConditionBase { readonly flag: string; - /** The block that wrote this condition ({@link block}); without one, the flag must belong to the command it is declared on. */ + /** The block that wrote this condition; absent, the flag must belong to the declaring command. */ readonly source?: string; readonly dbTarget?: never; readonly selector?: never; @@ -18,7 +18,7 @@ interface PresenceCondition extends FlagConditionBase { readonly is?: never; } -/** The switch's effective value, default included, must equal `is`; a switch the command lacks counts as `false`. */ +/** The switch's effective value, default included, must equal `is`; a missing switch is `false`. */ interface SwitchCondition extends FlagConditionBase { readonly is: boolean; readonly present?: never; @@ -34,10 +34,13 @@ interface ValueCondition extends FlagConditionBase { type FlagCondition = PresenceCondition | SwitchCondition | ValueCondition; -/** The target a command talks to: the local stack, a database URL, or a hosted project; `seed buckets` uses it for its Storage target. */ +/** The target a command talks to: the local stack, a database URL, or a hosted project. */ export type DbTarget = "local" | "db-url" | "linked"; -/** The run's database target must be one of `dbTarget`: `--db-url` or `--remote=<url>`, else `--local`, else a linked selector, else the local stack. */ +/** + * The run's database target must be one of `dbTarget`: `--db-url` or `--remote=<url>`, else + * `--local`, else a linked selector, else the local stack. + */ interface DbTargetCondition { readonly dbTarget: ReadonlyArray<DbTarget>; readonly source?: string; @@ -48,7 +51,7 @@ interface DbTargetCondition { readonly oneOf?: never; } -/** What the target selector carries once the deprecated aliases are folded in; `bare` is `--remote` or `--linked` with no value, `uuid` a branch's UUID. */ +/** The selector's shape after folding in the deprecated aliases; `bare` carries no value. */ export type SelectorShape = "none" | "bare" | "ref" | "branch" | "uuid" | "url"; /** Holds when the selector's shape is one of `selector`, whichever flag spelled it. */ @@ -93,7 +96,7 @@ export type CommandPermissions = } | { readonly status: "unmapped"; readonly reason: "go-delegated" }; -/** Builds a named block for the calls a shared module makes for its callers, tagging each entry and condition with `source`; commands include it with {@link compose}. */ +/** A shared module's calls, tagged with `source`; commands include it via {@link compose}. */ export function block( source: string, operations: ReadonlyArray<Omit<OperationEntry, "source">>, @@ -119,7 +122,7 @@ export function compose(...fragments: ReadonlyArray<PermissionFragment>): Permis }; } -/** Adds `condition` ahead of every entry's own `when`; it keeps no block `source`, so it must name a flag or target of the declaring command. */ +/** Prepends `condition` to each `when`; it must name a flag or target the command declares. */ export function withCondition( fragment: PermissionFragment, condition: EntryCondition, @@ -133,7 +136,7 @@ export function withCondition( }; } -/** Adds `prerequisite` to every entry's `context`, for a condition that isn't a flag, such as a prompt choice. */ +/** Adds `prerequisite` to each entry's `context`, for a non-flag condition like a prompt choice. */ export function withContext( fragment: PermissionFragment, prerequisite: string, @@ -148,7 +151,7 @@ export function withContext( } /** - * Gives every entry the `kind` the including command handles it with when that differs from the block's. + * Overrides every entry's `kind` with the one the including command handles it with. * @public */ export function withKind(fragment: PermissionFragment, kind: PermissionKind): PermissionFragment { diff --git a/apps/cli/src/command-internal/connect-errors.ts b/apps/cli/src/command-internal/connect-errors.ts index 2de4ebb706..7fd6054e1f 100644 --- a/apps/cli/src/command-internal/connect-errors.ts +++ b/apps/cli/src/command-internal/connect-errors.ts @@ -85,7 +85,7 @@ 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 `--remote`. */ + /** Whether the checkout is linked, so a local failure can point at `--remote`. */ readonly linkedCheckout?: boolean; } diff --git a/apps/cli/src/command-internal/db-config.errors.ts b/apps/cli/src/command-internal/db-config.errors.ts index 86ba1be874..252f70615e 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 could not connect while network bans were listed and removing them was not approved with `--yes`. */ +/** The temp login role could not connect with network bans listed and no `--yes` to remove them. */ export class LinkedDbNetworkBannedError extends Data.TaggedError("LinkedDbNetworkBannedError")<{ readonly message: string; readonly suggestion?: string; diff --git a/apps/cli/src/command-internal/db-config.layer.ts b/apps/cli/src/command-internal/db-config.layer.ts index 9607131849..f3e8a27a90 100644 --- a/apps/cli/src/command-internal/db-config.layer.ts +++ b/apps/cli/src/command-internal/db-config.layer.ts @@ -95,7 +95,10 @@ const linkedEntries: ReadonlyArray<Omit<OperationEntry, "source">> = [ */ export const dbConfigLinked = block("db-config", linkedEntries); -/** The calls {@link dbConfigLinked} makes for a project a non-selector flag names, such as `db diff --from=<ref>`. */ +/** + * The calls {@link dbConfigLinked} makes for a project a non-selector flag names, such as + * `db diff --from=<ref>`. + */ export const dbConfigNamedProject = block("db-config", [ loginRoleEntry, poolerConfigEntry, @@ -163,7 +166,10 @@ export const dbConfigPoolerFallback = block( })), ); -/** Only a `--yes`-sourced approval may clear bans; a prompt answer never does, since removal clears every listed address. */ +/** + * 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"); @@ -512,14 +518,14 @@ 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. */ + /** Whether the temp-role retry may clear listed bans; only a `--yes` approval sets it. */ readonly removeNetworkBans: boolean; readonly adHocProjectRef?: boolean; readonly resolveVaultSecrets?: boolean; /** * Requests the Management API pooler-config fetch on an IPv4-only network independent of * `adHocProjectRef`'s saved-URL semantics, so a workdir with no usable saved pooler URL still - * reaches the primary pooler config instead of dead-ending in the "run supabase link" IPv6 error. + * reaches the primary pooler config instead of 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. */ @@ -593,8 +599,8 @@ 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 `resolveConnection`/`resolvePoolerFallback`'s R (the interface promises `never`), so - // every call site below re-closes the gap by additionally providing this layer of + // 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( Layer.succeed(FileSystem.FileSystem, fs), @@ -666,7 +672,7 @@ export const dbConfigResolverLayer = Layer.effect( ); // 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. + // setups, defaulting to 127.0.0.1. Only env feeds the hostname; the config file stays unread. const resolveLocalHost = Effect.gen(function* () { const hostConfig = yield* configValues .load({ diff --git a/apps/cli/src/command-internal/db-config.service.ts b/apps/cli/src/command-internal/db-config.service.ts index 49c9d9665c..a1bac7967f 100644 --- a/apps/cli/src/command-internal/db-config.service.ts +++ b/apps/cli/src/command-internal/db-config.service.ts @@ -69,11 +69,11 @@ export type DbConfigError = // profile resolution can fail inside the resolver effect the same way. | ProfileLoadError; -// The `--linked` path builds a lazy Management API runtime (so `--local`/`--db-url` never resolve an +// The `--linked` path builds a lazy Management API runtime (so `--local`/`--db-url` never need an // access token) from the resolver's captured context, so `resolve`'s R stays `never` and the // auth-required error surfaces through the resolver effect, not a layer-build error channel. interface DbConfigResolverShape { - /** Connects to a resolved target (the local stack, a `--db-url` database or the linked project's temp-role connection) and announces it. */ + /** Connects to a resolved target (local, `--db-url` or the linked temp role) and announces it. */ readonly resolveConnection: ( input: DbConnectionInput, ) => Effect.Effect<ResolvedDbConnection, DbConfigError>; diff --git a/apps/cli/src/command-internal/db-config.types.ts b/apps/cli/src/command-internal/db-config.types.ts index b4a3d0c2ad..aa044f5479 100644 --- a/apps/cli/src/command-internal/db-config.types.ts +++ b/apps/cli/src/command-internal/db-config.types.ts @@ -18,7 +18,7 @@ export interface ResolvedDbConfig { readonly ref?: Option.Option<string>; } -/** What {@link DbConfigResolver.resolveConnection} needs: the resolved target plus how to authenticate. */ +/** What {@link DbConfigResolver.resolveConnection} needs: the target and how to authenticate. */ export interface DbConnectionInput { readonly target: ResolvedTarget; readonly dnsResolver: "native" | "https"; @@ -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; - /** A `--yes`-sourced `Some` lets the temp-role retry clear network bans; otherwise a ban surfaces as `LinkedDbNetworkBannedError`. */ + /** A `--yes`-sourced `Some` lets the retry clear bans; else `LinkedDbNetworkBannedError`. */ readonly networkBanRemoval: Option.Option<HostedMutationConsent>; } diff --git a/apps/cli/src/command-internal/db-push-core.ts b/apps/cli/src/command-internal/db-push-core.ts index a279533d37..9b626db193 100644 --- a/apps/cli/src/command-internal/db-push-core.ts +++ b/apps/cli/src/command-internal/db-push-core.ts @@ -77,7 +77,7 @@ export interface DbPushCoreInput { readonly dnsResolver: "native" | "https"; /** Already loaded + validated `config.toml`, e.g. via `checkDbToml`. */ readonly toml: DbTomlValues; - /** Consent the caller already obtained for this push; `None` asks once after the plan is known (never for a local database). */ + /** Consent the caller already obtained; `None` asks once after the plan (never for local). */ readonly preapproved: Option.Option<HostedMutationConsent>; /** * Standalone `db push` emits a `--output-format` json/stream-json success result for its own diff --git a/apps/cli/src/command-internal/global-flags.ts b/apps/cli/src/command-internal/global-flags.ts index 82dcdedc59..40d69551a2 100644 --- a/apps/cli/src/command-internal/global-flags.ts +++ b/apps/cli/src/command-internal/global-flags.ts @@ -13,9 +13,10 @@ import { supabaseEnvBoolWithProjectFallback, } from "./supabase-env.ts"; -// `-o/--output` is tree-wide and can't vary per command, so it takes the union of every command's values and each handler honors its subset. +// `-o/--output` is tree-wide and can't vary per command, so it takes the union of every command's +// values and each handler honors its subset. // Flag descriptions are user-visible in shell completion (`__complete`), so keep them accurate. -/** Every value the global `-o/--output` flag accepts; per-command `outputFormats` overrides widen against this list. */ +/** Every value `-o/--output` accepts; per-command `outputFormats` overrides widen against it. */ export const GLOBAL_OUTPUT_FORMATS = [ "env", "pretty", @@ -130,8 +131,8 @@ export const GLOBAL_FLAGS = [ ] as const; /** - * Every global flag's current value keyed by flag name, as telemetry's fallback for a flag missing from a handler's `flags`. - * `serviceOption` gives a caller without the global-flag context an empty record, and each flag is read individually to avoid an `as` cast. + * Every global flag's current value by name, as telemetry's fallback for a flag missing from a + * handler's `flags`; empty when `serviceOption` finds no global-flag context. */ export const globalFlagValues = Effect.gen(function* () { const values: Record<string, unknown> = {}; @@ -156,13 +157,14 @@ const FALSE_VALUES = new Set(["0", "f", "F", "false", "FALSE", "False"]); // The false spellings of the CLI's boolean flag parser, which also reads one after a bare flag. const BOOLEAN_FALSE_VALUES = new Set(["false", "no", "off", "0", "n"]); -/** Raw argv before the first bare `--`; everything after is an operand (`db pull -- --experimental=false`), so the scanners below must not read it. */ +/** Argv before the first bare `--`; the rest is an operand (`db pull -- --experimental=false`). */ const argsBeforeOperandTerminator = (args: ReadonlyArray<string>): ReadonlyArray<string> => { const terminatorIndex = args.indexOf("--"); return terminatorIndex === -1 ? args : args.slice(0, terminatorIndex); }; -/** Drops tokens consumed as a flag's space-separated value, so `db pull --password --experimental=false` isn't read as a changed `--experimental`. */ +// Drops a flag's space-separated value, so `db pull --password --experimental=false` doesn't read +// as a changed `--experimental`. const nonValueConsumedTokens = (args: ReadonlyArray<string>): ReadonlyArray<string> => { const kept: Array<string> = []; let skipNext = false; @@ -184,8 +186,9 @@ const nonValueConsumedTokens = (args: ReadonlyArray<string>): ReadonlyArray<stri }; /** - * The last `--yes`/`--yes=<bool>` in argv as a boolean, or `undefined` if there is none. The parser keeps the first of a repeat and can't tell - * `--yes=false` from the default, so callers reread argv; a bare `--yes` reads a boolean literal that follows it, as the parser does. + * The last `--yes`/`--yes=<bool>` in argv, or `undefined` if there is none. The parser keeps the + * first of a repeat and can't tell `--yes=false` from the default, so callers reread argv; a bare + * `--yes` reads a boolean literal that follows it, as the parser does. */ const yesFlagFromArgs = (args: ReadonlyArray<string>): boolean | undefined => { const tokens = nonValueConsumedTokens(argsBeforeOperandTerminator(args)); @@ -201,10 +204,13 @@ const yesFlagFromArgs = (args: ReadonlyArray<string>): boolean | undefined => { return result; }; -/** `raw` is the `SUPABASE_YES` value the process sees, which may come from a dotenv file Bun autoloaded. */ +/** + * `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`; the last `--yes`/`--yes=<bool>` decides, and an explicit false wins over the env. */ +/** 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; @@ -219,12 +225,16 @@ export const resolveYesSource = Effect.gen(function* () { : Option.none<YesSource>(); }); -/** `--yes` with a `SUPABASE_YES` fallback; an explicit flag (including `--yes=false`) wins over the env. Prefer it to reading {@link YesFlag} directly. */ +/** + * `--yes` with a `SUPABASE_YES` fallback; an explicit flag (including `--yes=false`) wins over the + * env. Prefer it to reading {@link YesFlag} directly. + */ export const resolveYes = Effect.map(resolveYesSource, Option.isSome); /** - * {@link resolveYes} that also consults the project `.env` (the `loadProjectEnvValues` map) for commands that load it before prompting; - * shell env presence suppresses the file value (see {@link supabaseEnvBoolWithProjectFallback}), and an explicit `--yes` wins over both. + * {@link resolveYes} that also consults the project `.env` (the `loadProjectEnvValues` map) for + * commands that load it before prompting; shell env presence suppresses the file value (see + * {@link supabaseEnvBoolWithProjectFallback}), and an explicit `--yes` wins over both. */ export const resolveYesWithProjectEnv = (projectEnv: Record<string, string>) => Effect.gen(function* () { @@ -237,7 +247,8 @@ export const resolveYesWithProjectEnv = (projectEnv: Record<string, string>) => return flag || (yield* supabaseEnvBoolWithProjectFallback("SUPABASE_YES", projectEnv)); }); -/** The last `--experimental`/`--experimental=<bool>` in argv as a boolean, or `undefined`; the parser keeps the first of a repeat, so callers reread argv. */ +// The last `--experimental`/`--experimental=<bool>` in argv, or `undefined`; the parser keeps the +// first of a repeat, so callers reread argv. const experimentalFlagFromArgs = (args: ReadonlyArray<string>): boolean | undefined => { let result: boolean | undefined; for (const arg of nonValueConsumedTokens(argsBeforeOperandTerminator(args))) { @@ -250,7 +261,7 @@ const experimentalFlagFromArgs = (args: ReadonlyArray<string>): boolean | undefi return result; }; -/** `--experimental` with a `SUPABASE_EXPERIMENTAL` fallback; an explicit flag (including `--experimental=false`) wins over the env. */ +/** `--experimental` with a `SUPABASE_EXPERIMENTAL` fallback; an explicit flag, even false, wins. */ export const resolveExperimental = Effect.gen(function* () { const flag = yield* ExperimentalFlag; const cliArgs = yield* CliArgs; @@ -261,7 +272,7 @@ export const resolveExperimental = Effect.gen(function* () { return flag || (yield* supabaseEnvBool("SUPABASE_EXPERIMENTAL")); }); -/** {@link resolveExperimental} with the project `.env` fallback; shell env presence wins over the file and an explicit flag over both. */ +/** {@link resolveExperimental} plus project `.env`: shell env beats the file, a flag beats both. */ export const resolveExperimentalWithProjectEnv = (projectEnv: Record<string, string>) => Effect.gen(function* () { const flag = yield* ExperimentalFlag; @@ -273,7 +284,8 @@ export const resolveExperimentalWithProjectEnv = (projectEnv: Record<string, str return flag || (yield* supabaseEnvBoolWithProjectFallback("SUPABASE_EXPERIMENTAL", projectEnv)); }); -/** True when the last `--debug`/`--debug=<value>` in argv resolves to false, since a trailing bare `--debug` must beat an earlier `--debug=false`. */ +// True when the last `--debug`/`--debug=<value>` in argv resolves to false: a trailing bare +// `--debug` must beat an earlier `--debug=false`. const debugFlagExplicitlyFalse = (args: ReadonlyArray<string>): boolean => { let lastExplicitlyFalse = false; for (const arg of nonValueConsumedTokens(argsBeforeOperandTerminator(args))) { @@ -286,7 +298,10 @@ const debugFlagExplicitlyFalse = (args: ReadonlyArray<string>): boolean => { return lastExplicitlyFalse; }; -/** `--debug` with env and project `.env` fallbacks, for debug-gated code downstream of a command that already loaded the project env; same shape as {@link resolveYesWithProjectEnv}. */ +/** + * `--debug` with env and project `.env` fallbacks, for debug-gated code downstream of a command + * that already loaded the project env. + */ export const resolveDebugWithProjectEnv = (projectEnv: Record<string, string>) => Effect.gen(function* () { const flag = yield* DebugFlag; 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 f29e2e5a83..c017622f3b 100644 --- a/apps/cli/src/command-internal/hosted-consent/hosted-consent.ts +++ b/apps/cli/src/command-internal/hosted-consent/hosted-consent.ts @@ -123,7 +123,7 @@ export const requireHostedMutationConsent = Effect.fn("HostedConsent.require")(f return grant(request.action, "prompt"); }); -/** `None` for a local target (a local `ResolvedTarget` or a connection with `isLocal`); otherwise {@link requireHostedMutationConsent}. */ +/** `None` for a local target or connection; otherwise {@link requireHostedMutationConsent}. */ export const requireTargetMutationConsent = Effect.fn("HostedConsent.requireForTarget")(function* ( target: ResolvedTarget | ResolvedDbConfig, request: HostedMutationRequest, @@ -133,7 +133,10 @@ export const requireTargetMutationConsent = Effect.fn("HostedConsent.requireForT 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. */ +/** + * `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(resolveHostedYes, (approvedBy) => Option.isSome(approvedBy) ? Option.some(grant("Remove network bans", "yes")) 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 index 5999b2972d..a091987dce 100644 --- a/apps/cli/src/command-internal/hosted-consent/hosted-consent.yes.ts +++ b/apps/cli/src/command-internal/hosted-consent/hosted-consent.yes.ts @@ -24,7 +24,8 @@ interface Platform { readonly cwd: string; } -// Without a filesystem the origin of `SUPABASE_YES` cannot be checked, so callers treat it as unverified. +// 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); @@ -77,7 +78,7 @@ export const resolveHostedYes = Effect.gen(function* () { 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. */ +/** Dotenv files, relative to the cwd, whose `SUPABASE_YES` does not confirm a hosted change. */ export const ignoredYesFiles = Effect.gen(function* () { const host = yield* platform; if (Option.isNone(host)) return []; diff --git a/apps/cli/src/command-internal/parent-project-ref.ts b/apps/cli/src/command-internal/parent-project-ref.ts index 7431c3d375..b2b8fef4f2 100644 --- a/apps/cli/src/command-internal/parent-project-ref.ts +++ b/apps/cli/src/command-internal/parent-project-ref.ts @@ -8,13 +8,13 @@ export type ParentRefResolution = | { readonly kind: "resolved"; readonly ref: string; - /** The project the `project-ref` file links when a cached parent differs from it; the cache stands only while that parent lists it as a branch. */ + /** The `project-ref` ref when it differs from the cached parent; the parent must list it. */ readonly linkedRef?: string; } | { readonly kind: "invalid" } | { readonly kind: "absent" }; -/** The fields of `supabase/.temp/linked-project.json` callers read; all but `ref` are present only when non-empty. */ +/** The `linked-project.json` fields callers read; all but `ref` are present only when non-empty. */ export interface CachedLinkedProject { readonly ref: string; readonly name?: string; @@ -107,8 +107,8 @@ export const resolveLinkedParentRef = Effect.fnUntraced(function* () { ) : Option.none<string>(); - // An env candidate restating the file's own valid value carries no parent information; drop it - // so the cache can win. Pattern-valid only, so a garbage env equal to a garbage file stays invalid. + // An env ref equal to the file's valid ref adds no parent information; drop it so the cache wins. + // Pattern-valid only, so a garbage env equal to a garbage file stays invalid. const envRef = Option.isSome(cliSettings.projectId) && Option.isSome(fileRef) && @@ -138,7 +138,7 @@ export function parentRefTypoHint(value: string): string { return ""; } -/** The error for a branch name looked up with no linked project, shared by `link` and `config diff`. */ +/** The branch-name lookup error when no project is linked, shared by `link` and `config diff`. */ export function parentNotLinkedMessage(value: string): string { return ( `Cannot resolve "${value}": it is not a project ref (refs are exactly 20 lowercase letters, ` + @@ -149,7 +149,7 @@ export function parentNotLinkedMessage(value: string): string { ); } -/** The error for a branch name looked up when the linked project ref is invalid, shared by `link` and `config diff`. */ +/** The branch-name lookup error for an invalid linked ref, shared by `link` and `config diff`. */ export function parentRefInvalidMessage(value: string): string { return `Cannot resolve branch "${value}": the linked project ref is invalid (checked SUPABASE_PROJECT_ID, supabase/.temp/linked-project.json, supabase/.temp/project-ref). Relink the parent project first: supabase link --project-ref <parent-ref>`; } diff --git a/apps/cli/src/command-internal/project-create-core.ts b/apps/cli/src/command-internal/project-create-core.ts index 29108d2809..d21784d799 100644 --- a/apps/cli/src/command-internal/project-create-core.ts +++ b/apps/cli/src/command-internal/project-create-core.ts @@ -60,7 +60,7 @@ 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. */ + /** Proof the caller already confirmed the create; without it the core asks before the POST. */ readonly consent?: HostedMutationConsent; } diff --git a/apps/cli/src/command-internal/seed-remote-consent.ts b/apps/cli/src/command-internal/seed-remote-consent.ts index 3c71a2ea0b..70b6a60ecc 100644 --- a/apps/cli/src/command-internal/seed-remote-consent.ts +++ b/apps/cli/src/command-internal/seed-remote-consent.ts @@ -37,7 +37,7 @@ const remoteDeclaresSeedEnabled = (resolvedConfig: ResolvedCliConfig, remote: st return CliConfigKeys.db.seed.enabled.codec.fromConfig(declared) === true; }; -/** Reads `[db.seed]` through the resolved config; the caller's single consent carries {@link DbSeedPlan.detail}. */ +/** Reads `[db.seed]` from the resolved config; one consent carries {@link DbSeedPlan.detail}. */ export const planDbSeed = Effect.fn("DbSeedPlan.plan")(function* ( resolvedConfig: ResolvedCliConfig, target: { readonly workdir: string; readonly ref: string }, diff --git a/apps/cli/src/command-internal/sql-split.ts b/apps/cli/src/command-internal/sql-split.ts index d05dd47d45..361f8f74ab 100644 --- a/apps/cli/src/command-internal/sql-split.ts +++ b/apps/cli/src/command-internal/sql-split.ts @@ -390,7 +390,7 @@ export function splitAndTrim(sql: string): string[] { /** * `splitAndTrim`, plus whether `sql` ends inside a block comment, quote or dollar quote, and - * whether the splitter read a `BEGIN ATOMIC` body, which hides its inner statements from `statements`. + * whether the splitter read a `BEGIN ATOMIC` body, whose inner statements `statements` omits. */ export function splitAndTrimChecked(sql: string): { readonly statements: string[]; diff --git a/apps/cli/src/command-internal/target/branch-resolution.errors.ts b/apps/cli/src/command-internal/target/branch-resolution.errors.ts index 15becaa69f..181213c023 100644 --- a/apps/cli/src/command-internal/target/branch-resolution.errors.ts +++ b/apps/cli/src/command-internal/target/branch-resolution.errors.ts @@ -15,7 +15,7 @@ export class LinkBranchNotLinkedError extends Data.TaggedError("LinkBranchNotLin } } -/** A linked-parent candidate exists (`SUPABASE_PROJECT_ID`, `linked-project.json` or `project-ref`) but none is ref-shaped: corrupt or stale linked state. */ +/** A linked-parent candidate (env, cache or `project-ref` file) exists but none is ref-shaped. */ export class LinkParentRefInvalidError extends Data.TaggedError("LinkParentRefInvalidError")<{ readonly message: string; }> { @@ -33,7 +33,7 @@ export class LinkBranchNotFoundError extends Data.TaggedError("LinkBranchNotFoun } } -/** The resolved branch has no `project_ref` yet (for example `CREATING_PROJECT`), so the lookup must not fall through to another ref. */ +/** The branch has no `project_ref` yet (e.g. `CREATING_PROJECT`), so the lookup stops there. */ export class LinkBranchNotReadyError extends Data.TaggedError("LinkBranchNotReadyError")<{ readonly branch: string; readonly status: string; @@ -44,7 +44,7 @@ export class LinkBranchNotReadyError extends Data.TaggedError("LinkBranchNotRead } } -/** Transport (or response-decode) failure while listing branches, or fetching one by UUID, for a branch lookup. */ +/** Transport or response-decode failure while listing branches or fetching one by UUID. */ export class LinkBranchListNetworkError extends Data.TaggedError("LinkBranchListNetworkError")<{ readonly message: string; readonly decode?: boolean; @@ -56,7 +56,7 @@ export class LinkBranchListNetworkError extends Data.TaggedError("LinkBranchList } } -/** `GET /v1/projects/{ref}/branches` or `GET /v1/branches/{id}` returned a non-200 status during a branch lookup. */ +/** `GET /v1/projects/{ref}/branches` or `GET /v1/branches/{id}` returned a non-200 status. */ export class LinkBranchListStatusError extends Data.TaggedError("LinkBranchListStatusError")<{ readonly status: number; readonly body: string; diff --git a/apps/cli/src/command-internal/target/branch-resolution.ts b/apps/cli/src/command-internal/target/branch-resolution.ts index 824ccdaaf4..41ba617e61 100644 --- a/apps/cli/src/command-internal/target/branch-resolution.ts +++ b/apps/cli/src/command-internal/target/branch-resolution.ts @@ -99,7 +99,7 @@ function branchNotFoundMessage(value: string, parentRef: string, branches: Branc export const isDefaultBranch = (branch: ParentBranch, parentRef: string): boolean => branch.is_default || branch.project_ref === parentRef; -/** UUID matching is case-insensitive; name matching is exact, with the not-found hint covering near misses. */ +/** UUIDs match case-insensitively, names exactly; the not-found hint covers near misses. */ export const findBranch = (branches: Branches, value: string): ParentBranch | undefined => branches.find( (branch) => branch.name === value || branch.id.toLowerCase() === value.toLowerCase(), @@ -118,7 +118,7 @@ const unresolvedParent = Effect.fnUntraced(function* ( }); }); -/** The eager client when the command built one, else the lazy factory's, so a bare run never resolves a token. */ +/** The command's eager client, else the lazy factory's, so a bare run never resolves a token. */ const acquireApi = Effect.fnUntraced(function* () { const direct = yield* Effect.serviceOption(CommandPlatformApi); if (Option.isSome(direct)) return direct.value; @@ -131,7 +131,10 @@ const acquireApi = Effect.fnUntraced(function* () { return yield* factory.value.make; }); -/** A branch-lookup spinner, shown only on a terminal: a non-terminal text run writes task steps to stdout, which may carry the command's payload. */ +/** + * A branch-lookup spinner, shown only on a terminal: a non-terminal text run writes task steps to + * stdout, which may carry the command's payload. + */ const startBranchLookupTask = Effect.fnUntraced(function* (label: string) { const output = yield* Output; return output.format === "text" && output.interactive ? yield* output.task(label) : undefined; @@ -139,7 +142,7 @@ const startBranchLookupTask = Effect.fnUntraced(function* (label: string) { const LIST_BRANCHES_FAILED = "failed to list branches: "; -/** The transport failure behind a branch-list error, without the `failed to list branches: ` lead the error message carries. */ +/** A branch-list error's transport failure, without its `failed to list branches: ` lead. */ export const listBranchesFailureCause = (error: LinkBranchListNetworkError): string => { const [firstLine = ""] = error.message.split("\n"); return firstLine.startsWith(LIST_BRANCHES_FAILED) @@ -147,11 +150,11 @@ export const listBranchesFailureCause = (error: LinkBranchListNetworkError): str : firstLine; }; -/** What to try when the project whose branches were listed is itself a preview branch, which has none. */ +/** What to try when the listed project is itself a preview branch, which has no branches. */ export const previewBranchHint = (ref: string): string => `If ${ref} is itself a preview branch, link its parent project first: supabase link --project-ref <parent-ref>`; -/** `GET /v1/projects/{ref}/branches`, shared by every branch-name lookup; no `label` means no spinner. */ +/** `GET /v1/projects/{ref}/branches` for every branch-name lookup; no `label` means no spinner. */ const listParentBranches = Effect.fnUntraced(function* (parentRef: string, label?: string) { const api = yield* acquireApi(); diff --git a/apps/cli/src/command-internal/target/closest-branch.ts b/apps/cli/src/command-internal/target/closest-branch.ts index c6d31ad784..8e999c4858 100644 --- a/apps/cli/src/command-internal/target/closest-branch.ts +++ b/apps/cli/src/command-internal/target/closest-branch.ts @@ -1,4 +1,4 @@ -/** The optimal string alignment distance: an insertion, deletion, substitution or adjacent swap is one edit. */ +/** Optimal string alignment distance: an insertion, deletion, substitution or swap is one edit. */ const editDistance = (a: string, b: string): number => { let beforePrevious: ReadonlyArray<number> = []; let previous = Array.from({ length: b.length + 1 }, (_, index) => index); @@ -28,7 +28,7 @@ const allowedEdits = (a: string, b: string): number => const byName = (a: { readonly name: string }, b: { readonly name: string }): number => a.name < b.name ? -1 : a.name > b.name ? 1 : 0; -/** The item whose name matches `word` ignoring case or within two edits of it; the nearest wins and a tie goes to the first name alphabetically. */ +/** The item named `word` ignoring case or within two edits, nearest first, then by name. */ export const closestByName = <T extends { readonly name: string }>( items: ReadonlyArray<T>, word: string, diff --git a/apps/cli/src/command-internal/target/parent-scoped-project-ref.ts b/apps/cli/src/command-internal/target/parent-scoped-project-ref.ts index 98f8b6007c..27930fef6e 100644 --- a/apps/cli/src/command-internal/target/parent-scoped-project-ref.ts +++ b/apps/cli/src/command-internal/target/parent-scoped-project-ref.ts @@ -7,7 +7,7 @@ import { readCachedLinkedProject, resolveLinkedParentRef } from "../parent-proje import { listLinkedParentBranches, type ResolvedParent } from "./branch-resolution.ts"; import { describeLinkedTarget } from "./target-identity.ts"; -/** The cache outlives a re-link, so it stands only while it lists the linked project; a check that fails keeps it. */ +/** The cache outlives a re-link, so it stands only while it lists the linked project. */ const checkedParentRef = Effect.fnUntraced(function* (parent: ResolvedParent) { if (parent.linkedRef === undefined) return parent.ref; const { parentRef } = yield* listLinkedParentBranches(parent).pipe( @@ -17,7 +17,10 @@ const checkedParentRef = Effect.fnUntraced(function* (parent: ResolvedParent) { return parentRef; }); -/** The project a `branches` command acts on: an explicit ref, else the linked parent, since a branch's own ref gets a 403 from branch-management endpoints. */ +/** + * The project a `branches` command acts on: the flag, else the linked parent, because branch + * management endpoints answer 403 for a branch's own ref. + */ export const resolveParentScopedProjectRef = Effect.fn("ParentProjectRef.resolve")(function* ( flagValue: Option.Option<string>, ) { diff --git a/apps/cli/src/command-internal/target/remote-resolution.ts b/apps/cli/src/command-internal/target/remote-resolution.ts index 9c1c4e1dec..a726fdafb2 100644 --- a/apps/cli/src/command-internal/target/remote-resolution.ts +++ b/apps/cli/src/command-internal/target/remote-resolution.ts @@ -46,7 +46,7 @@ export const warnTarget = Effect.fnUntraced(function* (message: string) { yield* output.raw(`${yellow(`Warning: ${message}`)}\n`, "stderr"); }); -/** Prints one warning per deprecated selector flag the run used, naming its `--remote` replacement. */ +/** Prints one warning per deprecated selector flag, naming its `--remote` replacement. */ export const warnTargetDeprecations = Effect.fnUntraced(function* ( deprecations: ReadonlyArray<TargetDeprecation>, ) { @@ -215,7 +215,7 @@ const bareRemoteWord = Effect.fnUntraced(function* () { return Option.getOrUndefined(cliArgs)?.bareRemoteOperand; }); -/** Fails a bare `--remote` whose next word is a project ref, before the command prompts, connects or writes. */ +/** Fails a bare `--remote` followed by a project ref, before any prompt, connection or write. */ export const rejectRefShapedRemote = Effect.fnUntraced(function* () { const operand = yield* bareRemoteWord(); if (operand !== undefined && BRANCH_PROJECT_REF_PATTERN.test(operand)) { @@ -265,7 +265,7 @@ const resolveRemoteBranch = Effect.fnUntraced(function* (name: string) { return found; }); -/** The linked target for one hosted `--remote` value; announces it, as a branch when the value names one. */ +/** The linked target for one hosted `--remote` value, announced as a branch when it names one. */ export const resolveRemoteTarget = Effect.fn("Target.resolveRemote")(function* ( remote: HostedRemote, ) { diff --git a/apps/cli/src/command-internal/target/remote-value.ts b/apps/cli/src/command-internal/target/remote-value.ts index 011c292b95..dc022ff778 100644 --- a/apps/cli/src/command-internal/target/remote-value.ts +++ b/apps/cli/src/command-internal/target/remote-value.ts @@ -17,7 +17,7 @@ export type RemoteValue = export type NamedRemoteValue = Exclude<RemoteValue, { readonly _tag: "Linked" }>; -/** A value with `://` that is not a postgres URL; `ref` is the project it points at when it is a project's own URL. */ +/** A `://` value that is not a postgres URL; `ref` is set when it is a project's own URL. */ interface ForeignUrlRejection { readonly _tag: "ForeignUrl"; readonly ref: string | undefined; @@ -40,7 +40,7 @@ const PROJECT_URL_REF = /^[a-z][a-z0-9+.-]*:\/\/([a-z]{20})\.supabase\.[a-z]+(?: const refOfProjectUrl = (value: string): string | undefined => PROJECT_URL_REF.exec(value.toLowerCase())?.[1]; -/** 20 lowercase letters is a project ref, a postgres URL is a database, any other URL or a keyword connection string is rejected, anything else names a branch. */ +/** A project ref, a postgres URL or a branch name; any other URL or keyword string is rejected. */ export const parseNamedRemote = ( raw: string, ): Result.Result<NamedRemoteValue, NamedRemoteRejection> => { @@ -59,7 +59,7 @@ export const parseNamedRemote = ( return Result.succeed({ _tag: "Branch", name: value }); }; -/** Classifies a `--remote` value, or says why it is rejected: empty, an off switch (`false|no|off|0`) or a URL that is not a postgres URL. */ +/** Classifies a `--remote` value, or why it is rejected: empty, off, a foreign URL or a DSN. */ export const parseRemoteValue = (raw: string): Result.Result<RemoteValue, RemoteValueRejection> => { const value = raw.trim(); if (LINKED_LITERAL.test(value)) return Result.succeed({ _tag: "Linked" }); @@ -68,14 +68,14 @@ export const parseRemoteValue = (raw: string): Result.Result<RemoteValue, Remote return parseNamedRemote(value); }; -/** {@link parseNamedRemote} for a caller that reads a value the flag's own parser already accepted: a rejected value falls back to a branch name. */ +/** {@link parseNamedRemote} for an accepted flag value; a rejection reads as a branch. */ const classifyNamedRemote = (raw: string): NamedRemoteValue => Result.getOrElse(parseNamedRemote(raw), (): NamedRemoteValue => ({ _tag: "Branch", name: raw.trim(), })); -/** {@link parseRemoteValue} for the same caller; `undefined` for an empty value or an off switch. */ +/** {@link parseRemoteValue} for the same caller; `undefined` for an empty or off value. */ export const classifyRemoteValue = (raw: string): RemoteValue | undefined => { const value = raw.trim(); if (value === "" || OFF_LITERAL.test(value)) return undefined; diff --git a/apps/cli/src/command-internal/target/remote-vocabulary.ts b/apps/cli/src/command-internal/target/remote-vocabulary.ts index 14e6697db9..2466a8daad 100644 --- a/apps/cli/src/command-internal/target/remote-vocabulary.ts +++ b/apps/cli/src/command-internal/target/remote-vocabulary.ts @@ -2,7 +2,7 @@ export const REMOTE_FLAG_NAME = "remote"; export const DB_URL_PATTERN = /^postgres(ql)?:\/\//i; -/** The spellings of a bare `--remote`, in any case: the words the flag parser reads as a boolean. */ +/** The spellings of a bare `--remote`, in any case: the words the flag parser reads as true. */ export const LINKED_LITERAL = /^(true|yes|on|1|y)$/i; export const OFF_LITERAL = /^(false|no|off|0|n)$/i; @@ -10,7 +10,7 @@ export const OFF_LITERAL = /^(false|no|off|0|n)$/i; export const isBooleanLiteral = (word: string): boolean => LINKED_LITERAL.test(word) || OFF_LITERAL.test(word); -/** Whitespace or `=`: a `host=… user=…` connection string or a `NAME=value` pair, never a branch name. */ +/** Whitespace or `=`: a keyword connection string or `NAME=value` pair, never a branch name. */ export const KEYWORD_DSN_PATTERN = /[\s=]/; const NOT_A_BRANCH_NAME: ReadonlyArray<RegExp> = [ @@ -20,7 +20,7 @@ const NOT_A_BRANCH_NAME: ReadonlyArray<RegExp> = [ /^[./~]/, ]; -/** Whether a word reads as a branch name rather than a path, URL, number, sentence or `NAME=value` pair. */ +/** Whether a word reads as a branch name, not a path, URL, number, sentence or `NAME=value`. */ export const couldNameBranch = (word: string): boolean => word !== "" && !NOT_A_BRANCH_NAME.some((pattern) => pattern.test(word)); diff --git a/apps/cli/src/command-internal/target/resolve-project-target.ts b/apps/cli/src/command-internal/target/resolve-project-target.ts index f8f893cd1d..ee103cf65d 100644 --- a/apps/cli/src/command-internal/target/resolve-project-target.ts +++ b/apps/cli/src/command-internal/target/resolve-project-target.ts @@ -6,12 +6,12 @@ import type { ProjectTargetFlagValues } from "./target-flags.ts"; import { TargetNeedsProjectError } from "./target.errors.ts"; interface ParentProjectTargetOptions { - /** A command that acts on a parent project, such as `branches *`: its `--remote` takes a ref, never a branch name. */ + /** For a parent-project command such as `branches *`: `--remote` takes a ref, not a branch. */ readonly parent: true; } interface DetailedProjectTargetOptions { - /** Returns the resolved linked target, with the branch it was found through, instead of just its ref. */ + /** Returns the resolved linked target, with the branch it was found through, not just its ref. */ readonly detail: true; } diff --git a/apps/cli/src/command-internal/target/target-deprecation.ts b/apps/cli/src/command-internal/target/target-deprecation.ts index 3e74557fd7..219bd5ae0b 100644 --- a/apps/cli/src/command-internal/target/target-deprecation.ts +++ b/apps/cli/src/command-internal/target/target-deprecation.ts @@ -10,7 +10,7 @@ export const shellQuote = (token: string): string => const BRANCH_BY_REF = "--remote=<its ref or UUID>"; -/** The `--remote` spelling for `value`; a database URL is never echoed, nor is a branch name's userinfo. */ +/** The `--remote` spelling for `value`; never echoes a database URL or a branch name's userinfo. */ export const remoteFlagText = (value: RemoteValue): string => { switch (value._tag) { case "Linked": @@ -26,7 +26,7 @@ export const remoteFlagText = (value: RemoteValue): string => { } }; -/** The `--remote` form a deprecated flag's value maps to, with a placeholder where the user's own value goes. */ +/** The `--remote` form a deprecated flag's value maps to, with a placeholder for the value. */ const replacementOf = (value: RemoteValue): string => { switch (value._tag) { case "Linked": @@ -40,7 +40,7 @@ const replacementOf = (value: RemoteValue): string => { } }; -/** The project ref a Supabase connection string already names: `db.<ref>.<host>` or a `postgres.<ref>` pooler user. */ +/** The ref a Supabase connection string names: `db.<ref>.<host>` or a `postgres.<ref>` user. */ export const refFromDbUrl = (url: string, projectHost: string): string | undefined => { try { const parsed = new URL(url); @@ -56,7 +56,7 @@ export const refFromDbUrl = (url: string, projectHost: string): string | undefin } }; -/** The first sentence of every alias warning: which flags are deprecated, and that v4 removes them. */ +/** The first sentence of every alias warning: the deprecated flags, and that v4 removes them. */ export const deprecationLead = (flags: ReadonlyArray<string>): string => `${joinFlags(flags)} ${flags.length > 1 ? "are" : "is"} deprecated and will be removed in v4.`; @@ -81,7 +81,7 @@ const aliasMessage = ( return `${lead} Use ${replacementOf(remote)} instead${alternative}`; }; -/** The warning for each deprecated selector the run used, naming its `--remote` replacement; a database URL is never echoed. */ +/** The warning per deprecated selector, naming its `--remote` replacement; no URL is echoed. */ export const deprecationMessages = ( deprecations: ReadonlyArray<TargetDeprecation>, projectHost: string, diff --git a/apps/cli/src/command-internal/target/target-flags.ts b/apps/cli/src/command-internal/target/target-flags.ts index d3d54adaff..00d318ea72 100644 --- a/apps/cli/src/command-internal/target/target-flags.ts +++ b/apps/cli/src/command-internal/target/target-flags.ts @@ -20,7 +20,7 @@ interface SelectableRemoteFlagVariant { interface HostedRemoteFlagVariant { readonly kind: "hosted"; - /** A command that acts on a parent project, such as `branches *`: its value is a ref, never a branch name. */ + /** A command on a parent project, such as `branches *`: its value is a ref, not a branch name. */ readonly parent: boolean; } @@ -29,7 +29,7 @@ type RemoteFlagVariant = SelectableRemoteFlagVariant | HostedRemoteFlagVariant; const takesDbUrl = (variant: RemoteFlagVariant): boolean => variant.kind === "selectable" && variant.dbUrl; -/** What the variant's `--remote` takes as a value; the help rows, the flag description and the errors all read it. */ +/** What the variant's `--remote` takes; the help rows, flag description and errors read it. */ const valueHintOf = (variant: RemoteFlagVariant): string => { if (variant.kind === "hosted") return variant.parent ? "<ref>" : "<ref|branch>"; return variant.dbUrl ? "<ref|branch|url>" : "<ref|branch>"; @@ -94,11 +94,14 @@ const remotePrimitive = (variant: RemoteFlagVariant): Primitive.Primitive<Remote }), }); -/** Whether a flag was declared by {@link targetFlags}, {@link projectTargetFlags} or the `seed` globals as `--remote`. */ +/** + * Whether a flag was declared by {@link targetFlags}, {@link projectTargetFlags} or the `seed` + * globals as `--remote`. + */ export const isRemoteFlag = (single: Param.Single<Param.ParamKind, unknown>): boolean => REMOTE_PRIMITIVE in single.primitiveType; -/** `--remote`: the linked project when bare, else the project, branch or database named by its value. */ +/** `--remote`: the linked project when bare, else the project, branch or database it names. */ const remoteFlag = (variant: RemoteFlagVariant): Flag.Flag<Option.Option<RemoteValue>> => Param.makeSingle({ kind: Param.flagKind, @@ -167,7 +170,10 @@ export interface TargetFlagValuesWithDbUrl extends TargetFlagValues { readonly dbUrl: Option.Option<string>; } -/** The only declaration of `--remote` and its deprecated `--local`, `--linked`, `--db-url` and `--project-ref` aliases for selectable commands. */ +/** + * The only declaration of `--remote` and its deprecated `--local`, `--linked`, `--db-url` and + * `--project-ref` aliases for selectable commands. + */ export function targetFlags( options: TargetFlagOptions & { readonly dbUrl: true }, ): TargetFlagDeclarations & DbUrlFlagDeclaration; @@ -184,7 +190,10 @@ export function targetFlags(options: TargetFlagOptions & { readonly dbUrl: boole return options.dbUrl ? { ...base, dbUrl: dbUrlFlag() } : base; } -/** The flags of a hosted-only command: `--remote=<ref|branch>` and the deprecated `--project-ref` alias; `parent` narrows the value to a ref. */ +/** + * The flags of a hosted-only command: `--remote=<ref|branch>` and the deprecated `--project-ref` + * alias; `parent` narrows the value to a ref. + */ export const projectTargetFlags = (options: { readonly parent?: true } = {}) => ({ remote: remoteFlag({ kind: "hosted", parent: options.parent === true }), projectRef: projectRefFlag(), @@ -201,7 +210,7 @@ const remoteValueOfTarget = (target: ResolvedTarget): RemoteValue | undefined => } }; -/** The flags that point a suggested follow-up command at `target`; a database URL is never echoed. */ +/** The flags that point a suggested follow-up command at `target`; a database URL is not echoed. */ export const flagsForTarget = (target: ResolvedTarget): string => { const value = remoteValueOfTarget(target); return value === undefined ? "" : remoteFlagText(value); diff --git a/apps/cli/src/command-internal/target/target-identity.ts b/apps/cli/src/command-internal/target/target-identity.ts index 113fc44127..979de6d20d 100644 --- a/apps/cli/src/command-internal/target/target-identity.ts +++ b/apps/cli/src/command-internal/target/target-identity.ts @@ -45,7 +45,7 @@ export const describeLinkedTarget = (input: { 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. */ + /** The `project-ref` file's ref, when the caller compares against the linked project. */ readonly linkedRef?: Option.Option<string>; }): RenderedTarget => { const { ref } = input; @@ -66,7 +66,7 @@ export const describeLinkedTarget = (input: { }; }; -/** The suffix a default-local banner carries when the checkout is linked, naming the project `--remote` would use. */ +/** The suffix of a default-local banner when linked, naming the project `--remote` would use. */ export const linkedAlternativeHint = (input: { readonly ref: string; readonly cache: Option.Option<CachedLinkedProject>; @@ -76,7 +76,7 @@ export const linkedAlternativeHint = (input: { return `default; pass --remote for ${named(name, input.ref)}`; }; -/** The `config.target_is_branch` span attribute: the default branch is the production project, and it is omitted while a branch UUID's default-ness is unknown. */ +/** The `config.target_is_branch` attribute: false for the default branch, omitted when unknown. */ export const targetIsBranchAttribute = (target: { readonly branch?: string | undefined; readonly isDefault?: boolean | undefined; diff --git a/apps/cli/src/command-internal/target/target-selector.ts b/apps/cli/src/command-internal/target/target-selector.ts index 3fa6189bfd..2137b34134 100644 --- a/apps/cli/src/command-internal/target/target-selector.ts +++ b/apps/cli/src/command-internal/target/target-selector.ts @@ -44,7 +44,7 @@ export interface ParsedTargetSelector { } export interface TargetSelectorOptions { - /** What `--remote` takes on this command, for the suggestions; a command with no `dbUrl` input defaults to `<ref|branch>`. */ + /** What `--remote` takes here, for suggestions; defaults to `<ref|branch>` without `dbUrl`. */ readonly valueHint?: string; /** `--remote` is given more than once with values that differ. */ readonly remoteConflict?: boolean; @@ -76,7 +76,7 @@ const PROJECT_REF_CONFLICT_SUGGESTION: Readonly< const trimmed = (value: string): string => value.trim(); -/** Why an alias value cannot be used: a foreign URL or keyword connection string, or a database URL on a command with no database. */ +/** Why an alias value is unusable: a foreign URL, a keyword DSN, or a URL with no database. */ const aliasRejection = ( parsed: Result.Result<NamedRemoteValue, NamedRemoteRejection> | undefined, hasDatabase: boolean, diff --git a/apps/cli/src/command-internal/target/target.errors.ts b/apps/cli/src/command-internal/target/target.errors.ts index a5c3577f4f..fce48cc37f 100644 --- a/apps/cli/src/command-internal/target/target.errors.ts +++ b/apps/cli/src/command-internal/target/target.errors.ts @@ -7,7 +7,7 @@ import { statusCodeActionability, } from "../../shared/telemetry/error-actionability.ts"; -/** More than one of `--remote`, `--linked`, `--db-url` and `--local` was set, or `--remote` was given twice with different values. */ +/** Several of `--remote`, `--linked`, `--db-url`, `--local`, or two different `--remote` values. */ export class TargetSelectorConflictError extends Data.TaggedError("TargetSelectorConflictError")<{ readonly flags: ReadonlyArray<string>; readonly message: string; @@ -18,7 +18,7 @@ export class TargetSelectorConflictError extends Data.TaggedError("TargetSelecto } } -/** `--project-ref` names a project, so it cannot be combined with `--remote=<value>`, `--local` or `--db-url`. */ +/** `--project-ref` cannot be combined with `--remote=<value>`, `--local` or `--db-url`. */ export class TargetProjectRefConflictError extends Data.TaggedError( "TargetProjectRefConflictError", )<{ @@ -42,7 +42,7 @@ export class TargetEmptyValueError extends Data.TaggedError("TargetEmptyValueErr } } -/** `--project-ref` or `--project-id` was given a URL, which names neither a project ref nor a branch. */ +/** `--project-ref` or `--project-id` was given a URL, not a project ref or branch name. */ export class TargetInvalidValueError extends Data.TaggedError("TargetInvalidValueError")<{ readonly flag: "--project-ref" | "--project-id"; readonly message: string; diff --git a/apps/cli/src/commands/branches/branches.target.ts b/apps/cli/src/commands/branches/branches.target.ts index 45a4cb57a2..02b2bfd725 100644 --- a/apps/cli/src/commands/branches/branches.target.ts +++ b/apps/cli/src/commands/branches/branches.target.ts @@ -1,6 +1,6 @@ import { block } from "../../command-internal/command-permissions/model.ts"; -/** `--remote=<ref>` names the parent outright; otherwise a cached parent is checked against its own branches. */ +/** `--remote=<ref>` names the parent; otherwise a cached parent is checked against its branches. */ export const branchesParentTarget = block("branches-parent-target", [ { operationId: "v1-list-all-branches", 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 288b658b6d..fb66c0310b 100644 --- a/apps/cli/src/commands/config/push/push.branch-target.ts +++ b/apps/cli/src/commands/config/push/push.branch-target.ts @@ -13,8 +13,9 @@ import { readProjectRefFile, tempPaths } from "../../../shared/config/temp-paths import { Output } from "../../../shared/output/output.service.ts"; /** - * What `ref` is for `config push`'s payload: the linked or production `"project"`, a confirmed preview `"branch"` (`parentRef`/`branch` present only when known), - * or `"unknown"` when nothing could settle it, which the payload asserts as neither. + * What `ref` is for `config push`'s payload: the linked or production `"project"`, a confirmed + * preview `"branch"` (`parentRef`/`branch` present only when known), or `"unknown"` when nothing + * could settle it, which the payload asserts as neither. */ export type ConfigPushTarget = | { readonly kind: "project"; readonly ref: string } @@ -27,8 +28,10 @@ export type ConfigPushTarget = | { readonly kind: "unknown"; readonly ref: string }; /** - * What the caller knows about the `--remote` branch `ref` came from, so {@link resolveConfigPushTarget} skips the live probe: a `"name"` (parent known), - * a `"uuid"` (certainly a branch; the parent is recovered best-effort), the `"production"` default branch (the project itself) or an `"unverified"` UUID (says nothing about `ref`). + * What the caller knows about the `--remote` branch `ref` came from, so + * {@link resolveConfigPushTarget} skips the live probe: a `"name"` (parent known), a `"uuid"` + * (certainly a branch; the parent is recovered best-effort), the `"production"` default branch + * (the project itself) or an `"unverified"` UUID (says nothing about `ref`). */ export type ConfigPushKnownBranch = | { readonly kind: "name"; readonly branchName: string; readonly parentRef: string } @@ -57,8 +60,10 @@ type ProbeOutcome = Data.TaggedEnum<{ const ProbeOutcome = Data.taggedEnum<ProbeOutcome>(); /** - * Resolves what `ref` is so `config push` can tell a branch from the linked project. Never fails, since this diagnostic must not abort a push: `GET /v1/projects/{ref}` - * (bounded at {@link BRANCH_LOOKUP_TIMEOUT}) is a project on 200, `"unknown"` on any other failure, and on a 404 recovers the parent and name from `.temp` when they can be trusted. + * Resolves what `ref` is so `config push` can tell a branch from the linked project. Never fails, + * since this diagnostic must not abort a push: `GET /v1/projects/{ref}` (bounded at + * {@link BRANCH_LOOKUP_TIMEOUT}) is a project on 200, `"unknown"` on any other failure, and on a + * 404 recovers the parent and name from `.temp` when they can be trusted. */ export function resolveConfigPushTarget( ref: string, diff --git a/apps/cli/src/commands/db/diff/diff.command.ts b/apps/cli/src/commands/db/diff/diff.command.ts index a0f2bc641e..e884449c86 100644 --- a/apps/cli/src/commands/db/diff/diff.command.ts +++ b/apps/cli/src/commands/db/diff/diff.command.ts @@ -95,11 +95,11 @@ const config = { export type DbDiffFlags = CliCommand.Command.Config.Infer<typeof config>; -// An explicit `--from`/`--to` of `remote` or the deprecated `linked` resolves the linked project without `--remote`. +// An explicit `--from`/`--to` of `remote` or the deprecated `linked` resolves the linked project. const explicitLinkedSide = (flag: "from" | "to") => withCondition(compose(dbConfigLinked, linkedProjectCache), { flag, oneOf: ["linked", "remote"] }); -// A condition can't tell a ref or branch value from `local`, `migrations` or a URL, so any value declares the block. +// Conditions can't tell a ref or branch from `local`, `migrations` or a URL; any value declares it. const explicitNamedProjectSide = (flag: "from" | "to") => withContext( withCondition(compose(dbConfigNamedProject, linkedProjectCache), { flag, present: true }), diff --git a/apps/cli/src/commands/db/diff/diff.explicit.ts b/apps/cli/src/commands/db/diff/diff.explicit.ts index 4e8907ed62..7c62d96e57 100644 --- a/apps/cli/src/commands/db/diff/diff.explicit.ts +++ b/apps/cli/src/commands/db/diff/diff.explicit.ts @@ -29,9 +29,8 @@ export type ExplicitRef = | NamedRemoteValue; /** - * `local`, `migrations` and `remote` (the linked project) are keywords and `linked` is the deprecated spelling of `remote`; - * anything else is classified like a `--remote` value, so a branch sharing a keyword's name is reached by its ref or UUID. - * A value `--remote` rejects is returned as that rejection, never as a branch name, so it is not echoed. + * `local`, `migrations` and `remote` (`linked` is its deprecated spelling) are keywords; anything + * else is classified like a `--remote` value, and a rejected value comes back as that rejection. */ export function classifyExplicitRef(ref: string): ExplicitRef { switch (ref) { @@ -63,7 +62,7 @@ const replacementOf = (remote: RemoteValue): string => { } }; -/** The warning for each deprecated selector flag an explicit run used, naming the `--from`/`--to` value that replaces it; a database URL is never echoed. */ +/** The warning per deprecated flag, naming its `--from`/`--to` replacement; no URL is echoed. */ export const explicitDeprecationMessages = ( deprecations: ReadonlyArray<TargetDeprecation>, ): ReadonlyArray<string> => @@ -80,7 +79,7 @@ export const explicitDeprecationMessages = ( return `${lead} Use ${replacementOf(remote)} instead`; }); -/** A selector error with its suggestion pointing at `--from`/`--to`, since explicit mode rejects `--remote`. */ +/** A selector error whose suggestion names `--from`/`--to`, as explicit mode rejects `--remote`. */ export const explicitSelectorError = (error: TargetSelectorError): TargetSelectorError => { switch (error._tag) { case "TargetEmptyValueError": diff --git a/apps/cli/src/commands/db/diff/diff.handler.ts b/apps/cli/src/commands/db/diff/diff.handler.ts index 1053088380..04f90f8070 100644 --- a/apps/cli/src/commands/db/diff/diff.handler.ts +++ b/apps/cli/src/commands/db/diff/diff.handler.ts @@ -277,7 +277,7 @@ export const dbDiff = Effect.fn("db.diff")(function* (flags: DbDiffFlags) { return endpoint; }); // Each ref resolves in order; the first hosted project's `[remotes.<ref>]` block applies to - // every config read after it, so a later `local` ref and the trailing context see the override. + // every config read after it, so a later `local` ref and the trailing context see it. const resolveRef = (ref: string, flag: "--from" | "--to") => Effect.gen(function* () { const explicit = classifyExplicitRef(ref); 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 c3b2e76fa3..2faa5a9799 100644 --- a/apps/cli/src/commands/db/query/query.read-only.ts +++ b/apps/cli/src/commands/db/query/query.read-only.ts @@ -94,8 +94,8 @@ export const findReadOnlyViolation = (sql: string): string | undefined => { const violation = statementViolation(statement); if (violation !== undefined) return violation; } - // 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. + // 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. if (beginAtomic) return "BEGIN ATOMIC"; return unterminated ? UNTERMINATED_INPUT : undefined; }; 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 e67b58de75..dc4d6a415c 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 @@ -13,7 +13,7 @@ import type { PgDeltaDatabaseEndpoint } from "../../shared/pgdelta-engine.servic import { DeclarativeApplyError, readErrorSuggestion } from "./declarative.errors.ts"; import { DeclarativeSeam } from "../../shared/pgdelta.seam.service.ts"; -/** Resolves a target's connection, which announces it, without discarding TLS and connection options. */ +/** Resolves a target's connection, announcing it, while keeping TLS and connection options. */ export const resolveTargetEndpoint = Effect.fnUntraced(function* ( target: ResolvedTarget, password: Option.Option<string> = Option.none(), diff --git a/apps/cli/src/commands/db/test/test.command.ts b/apps/cli/src/commands/db/test/test.command.ts index cdadbe0a50..6353b01291 100644 --- a/apps/cli/src/commands/db/test/test.command.ts +++ b/apps/cli/src/commands/db/test/test.command.ts @@ -15,7 +15,7 @@ import { withCliConfigFlags } from "../../../config/cli-config-flags.ts"; import { branchResolution } from "../../../command-internal/target/branch-resolution.ts"; import { withTargetPolicy } from "../../../command-internal/target/target-policy.ts"; -/** Hidden alias of `test db`; the shared implementation lives in `command-internal/` because command families may not import each other's internals. */ +/** Hidden alias of `test db`; the shared implementation lives in `command-internal/`. */ export const dbTestCommand = Command.make("test", testDbConfig).pipe( Command.withDescription(TEST_DB_DESCRIPTION), Command.withShortDescription(TEST_DB_SHORT), diff --git a/apps/cli/src/commands/gen/types/types.command.ts b/apps/cli/src/commands/gen/types/types.command.ts index 7bb6b2df02..4acb165ae9 100644 --- a/apps/cli/src/commands/gen/types/types.command.ts +++ b/apps/cli/src/commands/gen/types/types.command.ts @@ -104,7 +104,7 @@ export type GenTypesFlags = CliCommand.Command.Config.Infer<typeof flagsConfig> const NON_TYPESCRIPT = GEN_TYPES_LANGUAGES.filter((lang) => lang !== "typescript"); const nonTypescript = { flag: "lang", oneOf: NON_TYPESCRIPT } as const; const notBranch = "the ref names a project, not a preview branch"; -// Without a linked target the run uses a local or `--remote=<url>` database, or fails before calling the API. +// A run without a linked target uses a local or `--remote=<url>` database, or fails before the API. const remoteTarget: ReadonlyArray<EntryCondition> = [ { dbTarget: ["linked"] }, { flag: "postgrest-v9-compat", is: false }, diff --git a/apps/cli/src/commands/gen/types/types.handler.ts b/apps/cli/src/commands/gen/types/types.handler.ts index 4404927c16..13f9a9ed01 100644 --- a/apps/cli/src/commands/gen/types/types.handler.ts +++ b/apps/cli/src/commands/gen/types/types.handler.ts @@ -118,7 +118,7 @@ function isPoolerHost(host: string, poolerHost: string): boolean { const GEN_TYPES_COMMAND_PATH = ["gen", "types"] as const; -// A hosted project target excludes these flags; only the first one passed is reported, in this order. +// A hosted project target excludes these flags; only the first passed, in this order, is reported. const HOSTED_EXCLUDED_FLAGS: ReadonlyArray<string> = [ "postgrest-v9-compat", "query-timeout", @@ -304,8 +304,8 @@ export const genTypes = Effect.fn("gen.types")(function* (flags: GenTypesFlags) }) : Effect.void; - // `--schema` skips the config load entirely, so a `--remote=<url> --schema ...` invocation must not - // fail just because the workdir has no project config. + // `--schema` skips the config load, so a `--remote=<url> --schema ...` invocation must not fail + // just because the workdir has no project config. const resolveIncludedSchemas = (projectRef?: string) => schemas.length > 0 ? Effect.succeed(schemas) diff --git a/apps/cli/src/commands/pull/pull.steps.ts b/apps/cli/src/commands/pull/pull.steps.ts index c09ad9305c..6bc40c5a98 100644 --- a/apps/cli/src/commands/pull/pull.steps.ts +++ b/apps/cli/src/commands/pull/pull.steps.ts @@ -24,9 +24,9 @@ import type { } from "./pull.aggregate.ts"; import type { PullStepContext } from "./pull.types.ts"; -// One adapter per `PULL_STEP_ORDER` entry; each runs only after the aggregated confirmation, so it always performs its side effect. +// One adapter per `PULL_STEP_ORDER` entry; each runs only after the aggregated confirmation. -/** `config` step: applies the planned config write (skipped when the plan had no work) and reports the executed-run shape `pullConfigStepResult` expects. */ +/** `config` step: applies the planned config write, or skips it when the plan has no work. */ export const pullConfigStep = Effect.fnUntraced(function* (input: { readonly runPlan: ConfigPullRunPlan; readonly source: ConfigPullSource; @@ -44,7 +44,7 @@ export const pullConfigStep = Effect.fnUntraced(function* (input: { /** * `migration_history` step: fetches the remote migration history into `supabase/migrations` from - * `context.target` (ADR 0024), with its overwrite prompt suppressed by the orchestrator's confirmation. + * `context.target` (ADR 0024); the orchestrator's confirmation replaces its overwrite prompt. */ export const pullMigrationHistoryStep = Effect.fnUntraced(function* (context: PullStepContext) { const cliSettings = yield* CommandSettings; @@ -60,7 +60,7 @@ export const pullMigrationHistoryStep = Effect.fnUntraced(function* (context: Pu }); /** - * `db` step: pulls the linked project's schema in migration mode, reusing the orchestrator's consent. + * `db` step: pulls the linked project's schema in migration mode under the orchestrator's consent. * `DbPullInSyncError` is reported as `in_sync`, a finding rather than a failure (ADR 0024). */ export const pullDbStep = Effect.fnUntraced(function* (context: PullStepContext) { diff --git a/apps/cli/src/config/project-ref.service.ts b/apps/cli/src/config/project-ref.service.ts index 8319ed64e3..07296a04cb 100644 --- a/apps/cli/src/config/project-ref.service.ts +++ b/apps/cli/src/config/project-ref.service.ts @@ -20,7 +20,7 @@ export interface ResolvedProjectRef { interface ProjectRefResolverShape { /** * The single resolution chain: `--project-ref` → `SUPABASE_PROJECT_ID` → `.temp/project-ref` - * → project picker (stdin TTY and interactive output only), otherwise `ProjectRefNotLinkedError`. + * → project picker (interactive stdin and output only), otherwise `ProjectRefNotLinkedError`. * Announces the resolved target. */ readonly resolveRef: ( diff --git a/apps/cli/src/shared/cli/cli-args.service.ts b/apps/cli/src/shared/cli/cli-args.service.ts index 00e78d3057..8533295656 100644 --- a/apps/cli/src/shared/cli/cli-args.service.ts +++ b/apps/cli/src/shared/cli/cli-args.service.ts @@ -1,7 +1,7 @@ import { Context } from "effect"; export interface CliArgsShape { - /** The argv the command parses, with a bare `--remote <word>` already joined where that is the only reading. */ + /** The parsed argv, with a bare `--remote <word>` joined where that is the only reading. */ readonly args: ReadonlyArray<string>; readonly bareRemoteOperand?: string | undefined; readonly remoteConflict?: boolean; diff --git a/apps/cli/src/shared/cli/invalid-value-message.ts b/apps/cli/src/shared/cli/invalid-value-message.ts index 89626b099b..909db642f3 100644 --- a/apps/cli/src/shared/cli/invalid-value-message.ts +++ b/apps/cli/src/shared/cli/invalid-value-message.ts @@ -9,8 +9,8 @@ import { REMOTE_FLAG_NAME } from "../../command-internal/target/remote-vocabular // TODO(CLI-1898): remove once https://github.com/Effect-TS/effect/issues/6312 is fixed upstream. const EXPECTED_PREFIX = "Expected "; -// Some flags already fail with their own byte-exact diagnostic (malformed CSV in a string-slice flag, any `--remote` value), -// which the stderr contract reproduces verbatim; wrapping it in `CliError.InvalidValue`'s template would double-frame it. +// A flag's own byte-exact diagnostic (malformed string-slice CSV, any `--remote` value) is printed +// verbatim; wrapping it in `CliError.InvalidValue`'s template would double-frame it. const INVALID_ARGUMENT_PREFIX = "invalid argument "; export interface InvalidValueMessageFields { diff --git a/apps/cli/src/shared/cli/run.ts b/apps/cli/src/shared/cli/run.ts index 1e8400f2f6..3074fb1dff 100644 --- a/apps/cli/src/shared/cli/run.ts +++ b/apps/cli/src/shared/cli/run.ts @@ -197,7 +197,7 @@ function valueTakingFlagTokenPredicateForArgv( return (token) => isGlobalValueFlagToken(token) || leafPredicate(token); } -/** How the command `args` resolves to reads its operands, or `undefined` without `--remote` or when the command takes no `--remote`. */ +/** How the resolved command reads operands; `undefined` unless `--remote` is given and accepted. */ export function remoteArgvLeafFor( rootCommand: Command.Command.Any, args: ReadonlyArray<string>, @@ -239,7 +239,7 @@ export function prepareCliArgs( }; } -/** `CliArgs` and a `Stdio` whose `args` is the same argv, so every reader of either sees the rewritten command line. */ +/** `CliArgs` and a `Stdio` sharing one argv, so every reader sees the rewritten command line. */ export const cliArgsLayer = (cliArgs: CliArgsShape) => Layer.mergeAll( Layer.succeed(CliArgs, cliArgs), diff --git a/apps/cli/src/shared/cli/subcommand-flag-suggestions.ts b/apps/cli/src/shared/cli/subcommand-flag-suggestions.ts index 7a9e39668a..83c4c6455a 100644 --- a/apps/cli/src/shared/cli/subcommand-flag-suggestions.ts +++ b/apps/cli/src/shared/cli/subcommand-flag-suggestions.ts @@ -32,7 +32,7 @@ interface CommandWithHelpDoc extends Command.Command.Any { readonly buildHelpDoc: (path: ReadonlyArray<string>) => HelpDoc.HelpDoc; } -/** `"optional-value"` is a flag that is a switch bare and takes its value only through `=` (`--remote`). */ +/** `"optional-value"` is a switch when bare and takes a value only through `=` (`--remote`). */ type FlagArity = "switch" | "value" | "optional-value"; interface CommandFlag { @@ -58,7 +58,10 @@ export function helpDocFor( return hasHelpDoc(command) ? command.buildHelpDoc(commandPath) : undefined; } -/** The operands a command takes, leaving out a hidden positional: it still parses but is never offered or counted. */ +/** + * The operands a command takes, leaving out a hidden positional: it still parses but is never + * offered or counted. + */ export function visibleOperandsOf( command: Command.Command.Any, ): ReadonlyArray<{ readonly name: string; readonly variadic: boolean }> { @@ -75,7 +78,7 @@ const flagArityOf = (single: Param.Single<Param.ParamKind, unknown>): FlagArity return isRemoteFlag(single) ? "optional-value" : "switch"; }; -/** Every flag the command parses, hidden aliases included: parsing consumes their values like any other flag's. */ +/** Every flag the command parses, hidden aliases included: parsing consumes their values too. */ function commandFlagsFor(command: Command.Command.Any): ReadonlyArray<CommandFlag> { return commandInternals(command).config.flags.flatMap((param) => { const single = unwrapToSingleParam(param); @@ -287,7 +290,7 @@ function findValueAfterOption(args: ReadonlyArray<string>, option: string): stri return undefined; } -/** What to say when `--remote` is passed to `link` or a local-only command, which take no such flag. */ +/** What to say when `--remote` goes to `link` or a local-only command, which take no such flag. */ function remoteFlagMisuseMessage( rootCommand: Command.Command.Any, commandPath: ReadonlyArray<string>, @@ -303,7 +306,7 @@ function remoteFlagMisuseMessage( : undefined; } -/** Whether `error` is `--remote` given to a command that takes no such flag and has its own message for it. */ +/** Whether `error` is `--remote` given to a command with no such flag and its own message. */ export function isRemoteFlagMisuse( error: CliError.CliError, rootCommand: Command.Command.Any, diff --git a/apps/cli/src/shared/config/temp-paths.ts b/apps/cli/src/shared/config/temp-paths.ts index ecfcd63ad7..20d536524f 100644 --- a/apps/cli/src/shared/config/temp-paths.ts +++ b/apps/cli/src/shared/config/temp-paths.ts @@ -78,7 +78,7 @@ export const readProjectRefFile = ( return trimmed.length === 0 ? Option.none<string>() : Option.some(trimmed); }); -/** Whether `SUPABASE_PROJECT_ID` is set or `<workdir>/supabase/.temp/project-ref` names a linked project; an unreadable file counts as unlinked. */ +/** Whether `SUPABASE_PROJECT_ID` or `project-ref` names a project; unreadable is unlinked. */ export const isCheckoutLinked = ( fs: FileSystem.FileSystem, path: Path.Path, diff --git a/apps/cli/src/shared/output/command-target.service.ts b/apps/cli/src/shared/output/command-target.service.ts index b479cc7f29..f7eb247d58 100644 --- a/apps/cli/src/shared/output/command-target.service.ts +++ b/apps/cli/src/shared/output/command-target.service.ts @@ -20,7 +20,7 @@ const LinkedTargetJson = Schema.Struct({ 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`. */ +/** The target as merged into JSON results and error envelopes; see `docs/command-target.md`. */ export const CommandTargetJson = Schema.Union([ LocalTargetJson, DbUrlTargetJson, @@ -59,7 +59,7 @@ export const announceTarget = (rendered: RenderedTarget) => 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. */ +/** Adds the announced target's identity fields to the payload's `target`; the payload's own win. */ export const withAnnouncedTarget = Effect.fnUntraced(function* ( payload: Readonly<Record<string, unknown>>, ) { diff --git a/apps/cli/src/shared/output/output.layer.ts b/apps/cli/src/shared/output/output.layer.ts index dc3d5912d6..e146145d56 100644 --- a/apps/cli/src/shared/output/output.layer.ts +++ b/apps/cli/src/shared/output/output.layer.ts @@ -56,7 +56,7 @@ const isPlainObject = (value: unknown): value is Record<string, unknown> => { 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. */ +/** Adds the announced target to a plain-object result; arrays, scalars and an own `target` stay. */ const withCommandTarget = Effect.fnUntraced(function* (data: unknown) { if (!isPlainObject(data) || data.target !== undefined) return data; const cell = yield* Effect.serviceOption(CommandTarget); diff --git a/apps/cli/src/shared/output/target-help.ts b/apps/cli/src/shared/output/target-help.ts index 898ea07c4b..938e6d1276 100644 --- a/apps/cli/src/shared/output/target-help.ts +++ b/apps/cli/src/shared/output/target-help.ts @@ -50,7 +50,7 @@ const kindOf = (annotations: Context.Context<never>): TargetKind | undefined => const remoteFlagOf = (doc: HelpDoc.HelpDoc): HelpDoc.FlagDoc | undefined => [...doc.flags, ...(doc.globalFlags ?? [])].find((flag) => flag.name === REMOTE_FLAG_NAME); -/** What a leaf's help says about its target; `undefined` for account, removed and group commands. */ +/** What a leaf's help says of its target; `undefined` for account, removed and group commands. */ export function targetHelpFor(doc: HelpDoc.HelpDoc): TargetHelp | undefined { const kind = kindOf(doc.annotations); if (kind === undefined) return undefined; diff --git a/apps/cli/src/shared/telemetry/error-actionability.ts b/apps/cli/src/shared/telemetry/error-actionability.ts index 1646e79c3c..1f874b42a6 100644 --- a/apps/cli/src/shared/telemetry/error-actionability.ts +++ b/apps/cli/src/shared/telemetry/error-actionability.ts @@ -315,7 +315,7 @@ 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. */ + /** A hosted mutation needs confirmation this run cannot ask for; `--yes` or a TTY supplies it. */ consentRequired: { error_kind: CliErrorKind.UserActionable, error_category: CliErrorCategory.ConsentRequired, From cba13554b9c2374cf5e669cc50539a9ae61602a1 Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Tue, 20 Oct 2026 15:48:42 +0100 Subject: [PATCH 256/291] docs(cli): drop "exactly" from two target-selection comments --- apps/cli/src/command-internal/target/branch-resolution.ts | 2 +- apps/cli/src/command-internal/target/target-selector.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/cli/src/command-internal/target/branch-resolution.ts b/apps/cli/src/command-internal/target/branch-resolution.ts index 41ba617e61..e81c23178f 100644 --- a/apps/cli/src/command-internal/target/branch-resolution.ts +++ b/apps/cli/src/command-internal/target/branch-resolution.ts @@ -99,7 +99,7 @@ function branchNotFoundMessage(value: string, parentRef: string, branches: Branc export const isDefaultBranch = (branch: ParentBranch, parentRef: string): boolean => branch.is_default || branch.project_ref === parentRef; -/** UUIDs match case-insensitively, names exactly; the not-found hint covers near misses. */ +/** UUIDs match case-insensitively, names case-sensitively; the not-found hint covers typos. */ export const findBranch = (branches: Branches, value: string): ParentBranch | undefined => branches.find( (branch) => branch.name === value || branch.id.toLowerCase() === value.toLowerCase(), diff --git a/apps/cli/src/command-internal/target/target-selector.ts b/apps/cli/src/command-internal/target/target-selector.ts index 2137b34134..71bc742253 100644 --- a/apps/cli/src/command-internal/target/target-selector.ts +++ b/apps/cli/src/command-internal/target/target-selector.ts @@ -90,8 +90,8 @@ const aliasRejection = ( /** * A `false` switch counts as absent, so `--linked=false` and `--no-linked` select nothing. - * `--linked`, `--project-ref`, `--project-id` and `--db-url` resolve exactly like the `--remote` - * form they map to and are reported as deprecations; `--local` selects the default. + * `--linked`, `--project-ref`, `--project-id` and `--db-url` resolve like the `--remote` form + * they map to and are reported as deprecations; `--local` selects the default. */ export const parseTargetSelector = ( input: TargetFlagValues, From e87b514c42fdc6e026a8dc58dc5378e7f1c32da9 Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Tue, 20 Oct 2026 15:49:55 +0100 Subject: [PATCH 257/291] test(cli): check a consent-refused JSON run emits no payload The refusal case in `describeHostedConsent` now also requires that no success message and nothing on stdout was emitted, so a leaf that prints its result before asking for consent fails for every consenting leaf, not just the one that carried this check. --- apps/cli/tests/helpers/hosted-consent.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/apps/cli/tests/helpers/hosted-consent.ts b/apps/cli/tests/helpers/hosted-consent.ts index 5d932c1521..d0512f3c2a 100644 --- a/apps/cli/tests/helpers/hosted-consent.ts +++ b/apps/cli/tests/helpers/hosted-consent.ts @@ -147,11 +147,13 @@ export function describeHostedConsent(path: string, scenario: HostedConsentScena }; }); - it.effect("refuses JSON output without --yes and changes nothing", () => + it.effect("refuses JSON output without --yes, changes nothing and prints no payload", () => Effect.gen(function* () { - const { exit, mutations } = yield* runCase({ json: true }); + const { exit, out, mutations } = yield* runCase({ json: true }); expect(failureOf(exit)).toBeInstanceOf(HostedMutationConsentRequiredError); expect(mutations).toBe(0); + expect(out.messages.filter((message) => message.type === "success")).toEqual([]); + expect(out.stdoutText).toBe(""); }), ); From 5aa0ff7977b2c78ea466bc6a9f734d371c9685eb Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Tue, 20 Oct 2026 15:53:46 +0100 Subject: [PATCH 258/291] test(cli): require the --remote=<url> row to make no Management API call `describeDbTargetSelection` takes an optional `apiRequests` hook. A leaf that provides it must send no Management API request for a database URL, which the row title already claimed. gen types and every leaf whose fake already records its requests wire it; db reset and declarative generate keep their API fake out of reach of the harness call, so they stay unwired. --- apps/cli/src/command-internal/test-db.integration.test.ts | 1 + apps/cli/src/commands/db/advisors/advisors.integration.test.ts | 1 + apps/cli/src/commands/db/dump/dump.integration.test.ts | 1 + apps/cli/src/commands/db/lint/lint.integration.test.ts | 1 + apps/cli/src/commands/db/pull/pull.integration.test.ts | 1 + apps/cli/src/commands/db/push/push.integration.test.ts | 1 + apps/cli/src/commands/db/query/query.integration.test.ts | 1 + apps/cli/src/commands/gen/types/types.integration.test.ts | 1 + .../src/commands/inspect/db/inspect-query.integration.test.ts | 1 + .../cli/src/commands/inspect/report/report.integration.test.ts | 1 + apps/cli/src/commands/migration/down/down.integration.test.ts | 1 + .../cli/src/commands/migration/fetch/fetch.integration.test.ts | 1 + apps/cli/src/commands/migration/list/list.integration.test.ts | 1 + .../src/commands/migration/repair/repair.integration.test.ts | 1 + .../src/commands/migration/squash/squash.integration.test.ts | 1 + apps/cli/src/commands/migration/up/up.integration.test.ts | 1 + apps/cli/tests/helpers/db-target-selection.ts | 3 +++ 17 files changed, 19 insertions(+) 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 51cfa06138..fee3b71a46 100644 --- a/apps/cli/src/command-internal/test-db.integration.test.ts +++ b/apps/cli/src/command-internal/test-db.integration.test.ts @@ -865,6 +865,7 @@ describe("test db integration", () => { run: testDb(flags(target)), target: () => ctx.resolver.inputs[0]?.target, branchLookups: () => branchApi.requests, + apiRequests: () => branchApi.requests, }; }, }); 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 18110557b7..cd0b1e50b3 100644 --- a/apps/cli/src/commands/db/advisors/advisors.integration.test.ts +++ b/apps/cli/src/commands/db/advisors/advisors.integration.test.ts @@ -823,6 +823,7 @@ describeDbTargetSelection("db advisors", { target: () => ctx.resolver.resolveInputs[0]?.target, cachedRef: () => ctx.cache.cachedRef, branchLookups: () => ctx.api.requests.filter(({ url }) => url.endsWith("/branches")), + apiRequests: () => ctx.api.requests, }; }, }); 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 adcb5356c4..cfab49bd7e 100644 --- a/apps/cli/src/commands/db/dump/dump.integration.test.ts +++ b/apps/cli/src/commands/db/dump/dump.integration.test.ts @@ -968,6 +968,7 @@ describe("db dump integration", () => { target: () => ctx.resolver.calls[0]?.target, cachedRef: () => ctx.cache.cachedRef, branchLookups: () => branchApi.requests, + apiRequests: () => branchApi.requests, }; }, }); 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 7346c23132..ca7984ae48 100644 --- a/apps/cli/src/commands/db/lint/lint.integration.test.ts +++ b/apps/cli/src/commands/db/lint/lint.integration.test.ts @@ -639,6 +639,7 @@ describeDbTargetSelection("db lint", { target: () => ctx.resolver.resolveInput?.target, cachedRef: () => ctx.cache.cachedRef, branchLookups: () => branchApi.requests, + apiRequests: () => branchApi.requests, }; }, }); 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 1b517b08b9..4eff4c2dd9 100644 --- a/apps/cli/src/commands/db/pull/pull.integration.test.ts +++ b/apps/cli/src/commands/db/pull/pull.integration.test.ts @@ -687,6 +687,7 @@ describe("db pull", () => { target: () => s.resolveCalls[0]?.target, cachedRef: () => s.cache.cachedRef, branchLookups: () => branchApi.requests, + apiRequests: () => branchApi.requests, }; }, }); 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 e7a4e1f427..879b147db6 100644 --- a/apps/cli/src/commands/db/push/push.integration.test.ts +++ b/apps/cli/src/commands/db/push/push.integration.test.ts @@ -1036,6 +1036,7 @@ describe("db push", () => { target: () => ctx.resolver.calls[0]?.target, cachedRef: () => ctx.linkedCache.cachedRef, branchLookups: () => branchApi.requests, + apiRequests: () => branchApi.requests, }; }, }); 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 ce87167e09..3984b6e96e 100644 --- a/apps/cli/src/commands/db/query/query.integration.test.ts +++ b/apps/cli/src/commands/db/query/query.integration.test.ts @@ -1218,6 +1218,7 @@ describe("db query integration", () => { }, cachedRef: () => ctx.cache.cachedRef, branchLookups: () => branchApi.requests, + apiRequests: () => [...ctx.platformApi.requests, ...branchApi.requests], }; }, }); 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 6630b96b35..ad8d5db0ce 100644 --- a/apps/cli/src/commands/gen/types/types.integration.test.ts +++ b/apps/cli/src/commands/gen/types/types.integration.test.ts @@ -1112,6 +1112,7 @@ describe("gen types", () => { }, cachedRef: () => ctx.linkedProjectCache.cachedRef, branchLookups: () => branchLookupsOf(ctx.api.requests), + apiRequests: () => ctx.api.requests, })), ), }); 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 0ab29e63fa..2585980738 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 @@ -401,6 +401,7 @@ describe("inspect db query runner", () => { run: inspectDbDbStats(flags(target)), target: () => ctx.resolver.connectionInput?.target, branchLookups: () => ctx.branchRequests, + apiRequests: () => ctx.branchRequests, }; }, }); 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 bab83312d3..06aaf3feba 100644 --- a/apps/cli/src/commands/inspect/report/report.integration.test.ts +++ b/apps/cli/src/commands/inspect/report/report.integration.test.ts @@ -359,6 +359,7 @@ describe("inspect report", () => { run: inspectReport(flags({ ...target, outputDir: workdir })), target: () => ctx.resolver.connectionInput?.target, branchLookups: () => ctx.branchRequests, + apiRequests: () => ctx.branchRequests, }; }, }); 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 b51b1176b5..850f173f17 100644 --- a/apps/cli/src/commands/migration/down/down.integration.test.ts +++ b/apps/cli/src/commands/migration/down/down.integration.test.ts @@ -522,6 +522,7 @@ describeDbTargetSelection("migration down", { target: () => ctx.resolverCalls[0]?.target, cachedRef: () => ctx.cache.cachedRef, branchLookups: () => ctx.branchRequests, + apiRequests: () => ctx.branchRequests, }; }, }); 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 f5a13d42a2..1b863753c4 100644 --- a/apps/cli/src/commands/migration/fetch/fetch.integration.test.ts +++ b/apps/cli/src/commands/migration/fetch/fetch.integration.test.ts @@ -619,6 +619,7 @@ describeDbTargetSelection("migration fetch", { target: () => ctx.resolveCalls[0]?.target, cachedRef: () => ctx.cache.cachedRef, branchLookups: () => ctx.branchRequests, + apiRequests: () => ctx.branchRequests, }; }, }); 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 b9d184c020..e743e64da0 100644 --- a/apps/cli/src/commands/migration/list/list.integration.test.ts +++ b/apps/cli/src/commands/migration/list/list.integration.test.ts @@ -402,6 +402,7 @@ describeDbTargetSelection("migration list", { target: () => ctx.resolverCalls[0]?.target, cachedRef: () => ctx.cache.cachedRef, branchLookups: () => ctx.branchRequests, + apiRequests: () => ctx.branchRequests, }; }, }); 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 1cdad05ce1..2069be9a95 100644 --- a/apps/cli/src/commands/migration/repair/repair.integration.test.ts +++ b/apps/cli/src/commands/migration/repair/repair.integration.test.ts @@ -568,6 +568,7 @@ describeDbTargetSelection("migration repair", { target: () => ctx.resolverCalls[0]?.target, cachedRef: () => ctx.cache.cachedRef, branchLookups: () => ctx.branchRequests, + apiRequests: () => ctx.branchRequests, }; }, }); 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 067c75c3b0..ba6cecc8fa 100644 --- a/apps/cli/src/commands/migration/squash/squash.integration.test.ts +++ b/apps/cli/src/commands/migration/squash/squash.integration.test.ts @@ -1505,6 +1505,7 @@ describeDbTargetSelection("migration squash", { target: () => s.resolverCalls[0]?.target, cachedRef: () => s.cache.cachedRef, branchLookups: () => s.branchRequests, + apiRequests: () => s.branchRequests, }; }, }); 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 c3db756a6d..27c926b3e9 100644 --- a/apps/cli/src/commands/migration/up/up.integration.test.ts +++ b/apps/cli/src/commands/migration/up/up.integration.test.ts @@ -454,6 +454,7 @@ describeDbTargetSelection("migration up", { target: () => ctx.resolverCalls[0]?.target, cachedRef: () => ctx.cache.cachedRef, branchLookups: () => ctx.branchRequests, + apiRequests: () => ctx.branchRequests, }; }, }); diff --git a/apps/cli/tests/helpers/db-target-selection.ts b/apps/cli/tests/helpers/db-target-selection.ts index 99bc423752..6a981931dc 100644 --- a/apps/cli/tests/helpers/db-target-selection.ts +++ b/apps/cli/tests/helpers/db-target-selection.ts @@ -97,6 +97,8 @@ interface DbTargetAttempt<R> { readonly cachedRef?: () => string | undefined; /** The branch-list requests the leaf sent, for a leaf that records them. */ readonly branchLookups?: () => ReadonlyArray<RecordedLookup>; + /** Every Management API request the leaf sent, for a leaf that records them. */ + readonly apiRequests?: () => ReadonlyArray<unknown>; } type Row = @@ -307,6 +309,7 @@ export function describeDbTargetSelection<R>( expect(ctx.target()).toMatchObject({ kind: "db-url" }); expect(warningsOf(ctx.out)).toEqual([]); expectCachedRef(ctx, undefined); + if (ctx.apiRequests !== undefined) expect(ctx.apiRequests()).toEqual([]); }), }, ); From 66de0aa0dc36f6ce69b76026a9eb203ca8dc8157 Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Tue, 20 Oct 2026 15:54:55 +0100 Subject: [PATCH 259/291] test(cli): say why the linked-hint row announces the banner itself The row prints the banner on the leaf's behalf, so it reads as if the harness asserts its own output. The hint only shows when the leaf's `resolveTarget` called `hintLocal`. --- apps/cli/tests/helpers/db-target-selection.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/cli/tests/helpers/db-target-selection.ts b/apps/cli/tests/helpers/db-target-selection.ts index 6a981931dc..28cdf92cf9 100644 --- a/apps/cli/tests/helpers/db-target-selection.ts +++ b/apps/cli/tests/helpers/db-target-selection.ts @@ -222,6 +222,7 @@ export function describeDbTargetSelection<R>( check: (ctx) => Effect.gen(function* () { yield* ctx.run; + // The banner carries the hint only if the leaf's `resolveTarget` called `hintLocal`. yield* announceTarget(renderLocalTarget("127.0.0.1", 54322)); expect(stripAnsi(ctx.out.stderrText)).toContain( `default; pass --remote for ${VALID_REF}`, From 3ece938a579252ff3748d7d1dd77bea944dce567 Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Tue, 20 Oct 2026 16:27:14 +0100 Subject: [PATCH 260/291] fix(cli): keep --remote out of an explicit db diff IPv6 suggestion DbConfigIpv6Error now carries the remedy its suggestion offers. An explicit --from/--to diff of a project other than the linked one rewrites the pooler-URL remedy to name --from=<url> or --to=<url>, because explicit mode rejects --remote. The linked project keeps its link suggestion. --- .../src/command-internal/db-config.errors.ts | 12 ++-- .../db-config.errors.unit.test.ts | 34 +++++++++++ .../db-config.integration.test.ts | 3 + .../src/command-internal/db-config.layer.ts | 1 + apps/cli/src/commands/db/diff/SIDE_EFFECTS.md | 6 ++ .../cli/src/commands/db/diff/diff.explicit.ts | 12 ++++ apps/cli/src/commands/db/diff/diff.handler.ts | 17 ++++-- .../commands/db/diff/diff.integration.test.ts | 61 ++++++++++++++++++- 8 files changed, 134 insertions(+), 12 deletions(-) create mode 100644 apps/cli/src/command-internal/db-config.errors.unit.test.ts diff --git a/apps/cli/src/command-internal/db-config.errors.ts b/apps/cli/src/command-internal/db-config.errors.ts index 252f70615e..85448e7acf 100644 --- a/apps/cli/src/command-internal/db-config.errors.ts +++ b/apps/cli/src/command-internal/db-config.errors.ts @@ -112,17 +112,19 @@ export class DbConfigUnbanStatusError extends Data.TaggedError("DbConfigUnbanSta } /** - * The linked project's direct database host is unreachable (IPv6-only) and no connection pooler - * is configured. The message text is exact: `"IPv6 is not supported on your current network"`. + * A project's direct database host is unreachable (IPv6-only) and no connection pooler is + * configured. The message text is exact: `"IPv6 is not supported on your current network"`. */ export class DbConfigIpv6Error extends Data.TaggedError("DbConfigIpv6Error")<{ readonly message: string; readonly suggestion?: string; + /** `pooler-url` for a project other than the linked one; `link` (the default) otherwise. */ + readonly remedy?: "link" | "pooler-url"; }> { get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { - // The rendered remediation is "Run supabase link --project-ref <ref> to - // setup IPv4 connection", so the suggestion is link-shaped even though the - // category stays db_connection. + if (this.remedy === "pooler-url") { + return { ...actionability.dbConnection, suggestion_type: CliSuggestionType.ProvideFlags }; + } return { ...actionability.dbConnection, suggestion_type: CliSuggestionType.LinkProject, diff --git a/apps/cli/src/command-internal/db-config.errors.unit.test.ts b/apps/cli/src/command-internal/db-config.errors.unit.test.ts new file mode 100644 index 0000000000..4d66e10d3c --- /dev/null +++ b/apps/cli/src/command-internal/db-config.errors.unit.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "vitest"; + +import { classifyCliErrorActionability } from "../shared/telemetry/error-actionability.ts"; +import { DbConfigIpv6Error } from "./db-config.errors.ts"; + +describe("DbConfigIpv6Error", () => { + it.each([undefined, "link"] as const)( + "sends a %s remedy to supabase link and stays a connection failure", + (remedy) => { + expect( + classifyCliErrorActionability(new DbConfigIpv6Error({ message: "m", remedy })), + ).toMatchObject({ + error_kind: "user_actionable", + error_category: "db_connection", + has_suggestion: true, + suggestion_type: "link_project", + suggested_command: "supabase link", + }); + }, + ); + + it("asks for flags, with no command to run, when the remedy is a pooler URL", () => { + const classified = classifyCliErrorActionability( + new DbConfigIpv6Error({ message: "m", remedy: "pooler-url" }), + ); + expect(classified).toMatchObject({ + error_kind: "user_actionable", + error_category: "db_connection", + has_suggestion: true, + suggestion_type: "provide_flags", + }); + expect(classified).not.toHaveProperty("suggested_command"); + }); +}); 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 f6f3058736..b9adc2b6cc 100644 --- a/apps/cli/src/command-internal/db-config.integration.test.ts +++ b/apps/cli/src/command-internal/db-config.integration.test.ts @@ -1474,6 +1474,7 @@ describe("dbConfigResolver (an explicit --project-ref fetches the pooler config) Effect.sync(() => { expect(error).toMatchObject({ _tag: "DbConfigIpv6Error", + remedy: "pooler-url", suggestion: "Pass the project's connection pooler URL with --remote=<pooler db-url> to connect over IPv4.", }); @@ -1528,6 +1529,7 @@ describe("dbConfigResolver (an explicit --project-ref fetches the pooler config) expect(Option.isSome(error)).toBe(true); if (Option.isSome(error)) { expect(error.value).toMatchObject({ + remedy: "link", suggestion: `Run supabase link --project-ref ${ref} to setup IPv4 connection.`, }); } @@ -2042,6 +2044,7 @@ describe("dbConfigResolver.resolveConnection", () => { Effect.sync(() => { expect(error).toMatchObject({ _tag: "DbConfigIpv6Error", + remedy: "link", suggestion: `Run supabase link --project-ref ${ref} to setup IPv4 connection.`, }); expect(requests).toEqual([]); diff --git a/apps/cli/src/command-internal/db-config.layer.ts b/apps/cli/src/command-internal/db-config.layer.ts index f3e8a27a90..e428e1b2ba 100644 --- a/apps/cli/src/command-internal/db-config.layer.ts +++ b/apps/cli/src/command-internal/db-config.layer.ts @@ -573,6 +573,7 @@ export const resolveLinkedConn = Effect.fn("DbConfig.resolveLinkedConn")(functio if (Option.isNone(poolerConn)) { return yield* new Errors.DbConfigIpv6Error({ message: "IPv6 is not supported on your current network", + remedy: adHocProjectRef ? "pooler-url" : "link", suggestion: adHocProjectRef ? "Pass the project's connection pooler URL with --remote=<pooler db-url> to connect over IPv4." : `Run supabase link --project-ref ${ref} to setup IPv4 connection.`, diff --git a/apps/cli/src/commands/db/diff/SIDE_EFFECTS.md b/apps/cli/src/commands/db/diff/SIDE_EFFECTS.md index 798771557c..7234af2d01 100644 --- a/apps/cli/src/commands/db/diff/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/db/diff/SIDE_EFFECTS.md @@ -278,6 +278,12 @@ a postgresql:// URL; convert a connection string to a postgresql:// URL and pass empty `--project-ref` or `--db-url`, and a URL in `--project-ref` keep their usual messages; each suggestion names `--from`/`--to` instead of `--remote`, which this mode rejects. +- **Explicit `--from`/`--to` with a project whose database is unreachable over IPv6.** A project + or branch endpoint falls back to the pooler URL the API returns. With none, the run fails with + `IPv6 is not supported on your current network`. The suggestion for the linked project is + `Run supabase link --project-ref <ref> to setup IPv4 connection.`; for any other project or + branch it is `Pass the project's connection pooler URL as --from=<url> or --to=<url> to connect over IPv4.` + - **Explicit `--from`/`--to` config and URLs.** The config is read when the first endpoint that needs it resolves, not before. A hosted endpoint resolved first merges its `[remotes.<ref>]` block into that read, before validation and before its temporary login role is minted, so diff --git a/apps/cli/src/commands/db/diff/diff.explicit.ts b/apps/cli/src/commands/db/diff/diff.explicit.ts index 7c62d96e57..997a7f5e63 100644 --- a/apps/cli/src/commands/db/diff/diff.explicit.ts +++ b/apps/cli/src/commands/db/diff/diff.explicit.ts @@ -1,5 +1,6 @@ import { Result } from "effect"; +import { DbConfigIpv6Error } from "../../../command-internal/db-config.errors.ts"; import { parseNamedRemote, type NamedRemoteRejection, @@ -115,3 +116,14 @@ export const explicitSelectorError = (error: TargetSelectorError): TargetSelecto }); } }; + +/** An IPv6 failure for another project suggests `--from`/`--to`, as explicit mode rejects `--remote`. */ +export const explicitIpv6Error = (error: DbConfigIpv6Error): DbConfigIpv6Error => + error.remedy === "pooler-url" + ? new DbConfigIpv6Error({ + message: error.message, + remedy: error.remedy, + suggestion: + "Pass the project's connection pooler URL as --from=<url> or --to=<url> to connect over IPv4.", + }) + : error; diff --git a/apps/cli/src/commands/db/diff/diff.handler.ts b/apps/cli/src/commands/db/diff/diff.handler.ts index 04f90f8070..f2ee70b2fe 100644 --- a/apps/cli/src/commands/db/diff/diff.handler.ts +++ b/apps/cli/src/commands/db/diff/diff.handler.ts @@ -92,6 +92,7 @@ import type { DbDiffFlags } from "./diff.command.ts"; import { classifyExplicitRef, explicitDeprecationMessages, + explicitIpv6Error, explicitSelectorError, } from "./diff.explicit.ts"; import { @@ -261,12 +262,16 @@ export const dbDiff = Effect.fn("db.diff")(function* (flags: DbDiffFlags) { mergedLinkedRef ??= target.ref; // Loaded before the temp-role mint, so a config error surfaces before that side effect. yield* loadCfg(); - const resolved = yield* resolver.resolveConnection({ - target, - dnsResolver, - password: Option.none(), - networkBanRemoval: yield* hostedMutationPreapproval, - }); + const resolved = yield* resolver + .resolveConnection({ + target, + dnsResolver, + password: Option.none(), + networkBanRemoval: yield* hostedMutationPreapproval, + }) + .pipe( + Effect.catchTag("DbConfigIpv6Error", (error) => Effect.fail(explicitIpv6Error(error))), + ); const endpoint = { kind: "database", ref: toPostgresURL(resolved.conn), 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 026386dc2b..864e0bdd43 100644 --- a/apps/cli/src/commands/db/diff/diff.integration.test.ts +++ b/apps/cli/src/commands/db/diff/diff.integration.test.ts @@ -85,7 +85,10 @@ import { ProjectRefResolver, PROJECT_NOT_LINKED_MESSAGE, } from "../../../config/project-ref.service.ts"; -import { DbConfigLoadError } from "../../../command-internal/db-config.errors.ts"; +import { + DbConfigIpv6Error, + DbConfigLoadError, +} from "../../../command-internal/db-config.errors.ts"; import { DbConfigResolver } from "../../../command-internal/db-config.service.ts"; import type { DbConnectionInput, @@ -157,6 +160,8 @@ interface SetupOpts { // Simulates an unlinked workdir: the project-ref resolver fails with // `ProjectRefNotLinkedError` absent an explicit `--remote=<ref>`. readonly linkedFails?: boolean; + // Makes every hosted connection resolve fail with this error. + readonly resolveFailsWith?: DbConfigIpv6Error; // Per-differ-run `--json-diff` stdout, indexed by run order; falls back to `""` // once exhausted, so a single-run test only needs one element. readonly pgadminStdout?: ReadonlyArray<string>; @@ -504,6 +509,7 @@ function setup(workdir: string, opts: SetupOpts = {}) { const resolver = Layer.succeed(DbConfigResolver, { resolveConnection: (input: DbConnectionInput) => { resolverCalls.push(input); + if (opts.resolveFailsWith !== undefined) return Effect.fail(opts.resolveFailsWith); return Effect.succeed({ conn: { host: "127.0.0.1", @@ -1668,6 +1674,59 @@ describe("db diff", () => { }); }); }); + + describe("when the project's database is unreachable over IPv6 and has no pooler URL", () => { + const message = "IPv6 is not supported on your current network"; + + it.effect( + "suggests the pooler URL as --from/--to for a project other than the linked one", + () => { + const s = setup(tmp.current, { + isLocal: false, + resolveFailsWith: new DbConfigIpv6Error({ + message, + remedy: "pooler-url", + suggestion: + "Pass the project's connection pooler URL with --remote=<pooler db-url> to connect over IPv4.", + }), + }); + return Effect.gen(function* () { + const error = yield* dbDiff( + flags({ from: Option.some(FLAG_PROJECT_REF), to: Option.some("migrations") }), + ).pipe(Effect.flip); + expect(error).toMatchObject({ + _tag: "DbConfigIpv6Error", + message, + suggestion: + "Pass the project's connection pooler URL as --from=<url> or --to=<url> to connect over IPv4.", + }); + expect(error).toMatchObject({ suggestion: expect.not.stringContaining("--remote") }); + expect(s.explicitDiffCalls).toEqual([]); + }).pipe(Effect.provide(Layer.merge(s.layer, branchListApi().layer))); + }, + ); + + it.effect("keeps the link suggestion for the linked project", () => { + const suggestion = `Run supabase link --project-ref ${VALID_REF} to setup IPv4 connection.`; + const s = setup(tmp.current, { + isLocal: false, + projectId: Option.some(VALID_REF), + resolveFailsWith: new DbConfigIpv6Error({ message, remedy: "link", suggestion }), + }); + return Effect.gen(function* () { + const error = yield* dbDiff( + flags({ from: Option.some("remote"), to: Option.some("migrations") }), + ).pipe(Effect.flip); + expect(error).toMatchObject({ + _tag: "DbConfigIpv6Error", + message, + remedy: "link", + suggestion, + }); + expect(s.explicitDiffCalls).toEqual([]); + }).pipe(Effect.provide(Layer.merge(s.layer, branchListApi().layer))); + }); + }); }); it.effect( From c0704fcf26941f42819b12de25d04861ef1dda81 Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Tue, 20 Oct 2026 16:28:12 +0100 Subject: [PATCH 261/291] test(cli): fail the consent JSON-refusal row only on a printed payload The row asserted no message of type "success", but the output mock records task.succeed under that type too, while the real JSON layer sends task successes to stderr. A leaf that runs a read-only task step before asking for consent therefore failed a row that exists to catch payloads on stdout. Only Output.success records a data key, so the filter now requires it. A synthetic leaf with a read-only task step pins the case. --- .../helpers/hosted-consent.integration.test.ts | 15 ++++++++++++++- apps/cli/tests/helpers/hosted-consent.ts | 5 ++++- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/apps/cli/tests/helpers/hosted-consent.integration.test.ts b/apps/cli/tests/helpers/hosted-consent.integration.test.ts index 373bd39700..b1f6a8e893 100644 --- a/apps/cli/tests/helpers/hosted-consent.integration.test.ts +++ b/apps/cli/tests/helpers/hosted-consent.integration.test.ts @@ -5,11 +5,19 @@ import { CommandPlatformApi } from "../../src/auth/command-platform-api.service. 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 { Output } from "../../src/shared/output/output.service.ts"; import { describeHostedConsent } from "./hosted-consent.ts"; -const syntheticWrite = (options: { typed?: boolean; onWrite?: () => void } = {}) => +const syntheticWrite = ( + options: { typed?: boolean; onWrite?: () => void; readOnlyTask?: boolean } = {}, +) => Effect.gen(function* () { const ref = yield* ProjectRefResolver.use((resolver) => resolver.resolve(Option.none())); + if (options.readOnlyTask === true) { + const output = yield* Output; + const task = yield* output.task("Reading secrets"); + yield* task.succeed("Read secrets"); + } yield* requireHostedMutationConsent({ action: `create a secret on ${ref}`, ...(options.typed === true ? { confirm: { kind: "typed" as const, expected: ref } } : {}), @@ -35,6 +43,11 @@ describeHostedConsent("synthetic-write with a typed confirmation", { invoke: () => ({ run: syntheticWrite({ typed: true }) }), }); +describeHostedConsent("synthetic-write after a read-only task step", { + command: syntheticLeaf, + invoke: () => ({ run: syntheticWrite({ readOnlyTask: true }) }), +}); + describeHostedConsent("synthetic-write with a custom mutation probe", { command: syntheticLeaf, invoke: () => { diff --git a/apps/cli/tests/helpers/hosted-consent.ts b/apps/cli/tests/helpers/hosted-consent.ts index d0512f3c2a..fab4317728 100644 --- a/apps/cli/tests/helpers/hosted-consent.ts +++ b/apps/cli/tests/helpers/hosted-consent.ts @@ -152,7 +152,10 @@ export function describeHostedConsent(path: string, scenario: HostedConsentScena const { exit, out, mutations } = yield* runCase({ json: true }); expect(failureOf(exit)).toBeInstanceOf(HostedMutationConsentRequiredError); expect(mutations).toBe(0); - expect(out.messages.filter((message) => message.type === "success")).toEqual([]); + // Only Output.success reaches stdout and records a data key; task.succeed goes to stderr. + expect( + out.messages.filter((message) => message.type === "success" && "data" in message), + ).toEqual([]); expect(out.stdoutText).toBe(""); }), ); From d27337875bfde647e072eec3e836271c991c0366 Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Tue, 20 Oct 2026 16:28:32 +0100 Subject: [PATCH 262/291] fix(cli): build the explicit db diff selector-conflict error from the flags passed In explicit --from/--to mode --remote is rejected before the selector runs, so the conflict message no longer offers it, and the suggestion carries one clause per flag the user actually passed instead of a fixed three-flag sentence. --- apps/cli/src/commands/db/diff/SIDE_EFFECTS.md | 8 ++- .../cli/src/commands/db/diff/diff.explicit.ts | 24 +++++++-- .../db/diff/diff.explicit.unit.test.ts | 51 ++++++++++++++++++- .../commands/db/diff/diff.integration.test.ts | 3 +- 4 files changed, 76 insertions(+), 10 deletions(-) diff --git a/apps/cli/src/commands/db/diff/SIDE_EFFECTS.md b/apps/cli/src/commands/db/diff/SIDE_EFFECTS.md index 7234af2d01..229b3f7aa1 100644 --- a/apps/cli/src/commands/db/diff/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/db/diff/SIDE_EFFECTS.md @@ -275,8 +275,12 @@ a postgresql:// URL; convert a connection string to a postgresql:// URL and pass `--project-ref only applies when --from or --to is remote; to diff another project, use --from=<ref> or --to=<ref>.` and a URL value fails with `--project-ref takes a project ref or a branch name, not a URL.` Selector flags that conflict (`--linked` with `--local`, `--project-ref` with `--db-url`), an - empty `--project-ref` or `--db-url`, and a URL in `--project-ref` keep their usual messages; each - suggestion names `--from`/`--to` instead of `--remote`, which this mode rejects. + empty `--project-ref` or `--db-url`, and a URL in `--project-ref` keep their usual messages, + except that a conflict among `--linked`, `--db-url` and `--local` leaves `--remote` out of the + flags it allows (`Only one of --linked, --db-url and --local can be set; got --linked and --local.`). + Each suggestion names `--from`/`--to` instead of `--remote`, which this mode rejects, and a + conflict's suggestion has one clause per flag passed + (`Use --from=remote or --to=remote instead of --linked, and drop --local.`). - **Explicit `--from`/`--to` with a project whose database is unreachable over IPv6.** A project or branch endpoint falls back to the pooler URL the API returns. With none, the run fails with diff --git a/apps/cli/src/commands/db/diff/diff.explicit.ts b/apps/cli/src/commands/db/diff/diff.explicit.ts index 997a7f5e63..1cc598179f 100644 --- a/apps/cli/src/commands/db/diff/diff.explicit.ts +++ b/apps/cli/src/commands/db/diff/diff.explicit.ts @@ -12,7 +12,10 @@ import { deprecationLead, LOCAL_NOT_NEEDED_MESSAGE, } from "../../../command-internal/target/target-deprecation.ts"; -import type { TargetDeprecation } from "../../../command-internal/target/target-selector.ts"; +import { + joinFlags, + type TargetDeprecation, +} from "../../../command-internal/target/target-selector.ts"; import { TargetEmptyValueError, TargetInvalidValueError, @@ -80,6 +83,15 @@ export const explicitDeprecationMessages = ( return `${lead} Use ${replacementOf(remote)} instead`; }); +const CONFLICT_REPLACEMENTS = { + "--linked": "--from=remote or --to=remote instead of --linked", + "--db-url": "--from=<url> or --to=<url> instead of --db-url", + "--local": "drop --local", +} as const; + +const isConflictFlag = (flag: string): flag is keyof typeof CONFLICT_REPLACEMENTS => + Object.hasOwn(CONFLICT_REPLACEMENTS, flag); + /** A selector error whose suggestion names `--from`/`--to`, as explicit mode rejects `--remote`. */ export const explicitSelectorError = (error: TargetSelectorError): TargetSelectorError => { switch (error._tag) { @@ -107,13 +119,15 @@ export const explicitSelectorError = (error: TargetSelectorError): TargetSelecto ? "Use --from=<ref> or --to=<ref> instead of --project-ref, and --from=<url> or --to=<url> instead of --db-url." : "Use --from=<ref> or --to=<ref> instead of --project-ref, and drop --local.", }); - case "TargetSelectorConflictError": + case "TargetSelectorConflictError": { + if (!error.flags.every(isConflictFlag)) return error; + const clauses = error.flags.map((flag) => CONFLICT_REPLACEMENTS[flag]); return new TargetSelectorConflictError({ flags: error.flags, - message: error.message, - suggestion: - "Use --from=remote or --to=remote instead of --linked, --from=<url> or --to=<url> instead of --db-url, and drop --local.", + message: `Only one of --linked, --db-url and --local can be set; got ${joinFlags(error.flags)}.`, + suggestion: `Use ${clauses.slice(0, -1).join(", ")}, and ${clauses[clauses.length - 1]}.`, }); + } } }; diff --git a/apps/cli/src/commands/db/diff/diff.explicit.unit.test.ts b/apps/cli/src/commands/db/diff/diff.explicit.unit.test.ts index 9dbe5ea204..b958d94cc4 100644 --- a/apps/cli/src/commands/db/diff/diff.explicit.unit.test.ts +++ b/apps/cli/src/commands/db/diff/diff.explicit.unit.test.ts @@ -6,6 +6,7 @@ import { parseTargetSelector, type TargetDeprecation, } from "../../../command-internal/target/target-selector.ts"; +import { TargetSelectorConflictError } from "../../../command-internal/target/target.errors.ts"; import { classifyExplicitRef, explicitDeprecationMessages, @@ -218,8 +219,40 @@ describe("explicitSelectorError", () => { expected: { _tag: "TargetSelectorConflictError", flags: ["--linked", "--local"], + message: "Only one of --linked, --db-url and --local can be set; got --linked and --local.", + suggestion: "Use --from=remote or --to=remote instead of --linked, and drop --local.", + }, + }, + { + label: "--linked with --db-url", + overrides: { linked: true, dbUrl: Option.some("postgresql://u@h/db") }, + expected: { + _tag: "TargetSelectorConflictError", + flags: ["--linked", "--db-url"], message: - "Only one of --remote, --linked, --db-url and --local can be set; got --linked and --local.", + "Only one of --linked, --db-url and --local can be set; got --linked and --db-url.", + suggestion: + "Use --from=remote or --to=remote instead of --linked, and --from=<url> or --to=<url> instead of --db-url.", + }, + }, + { + label: "--db-url with --local", + overrides: { dbUrl: Option.some("postgresql://u@h/db"), local: true }, + expected: { + _tag: "TargetSelectorConflictError", + flags: ["--db-url", "--local"], + message: "Only one of --linked, --db-url and --local can be set; got --db-url and --local.", + suggestion: "Use --from=<url> or --to=<url> instead of --db-url, and drop --local.", + }, + }, + { + label: "--linked, --db-url and --local together", + overrides: { linked: true, dbUrl: Option.some("postgresql://u@h/db"), local: true }, + expected: { + _tag: "TargetSelectorConflictError", + flags: ["--linked", "--db-url", "--local"], + message: + "Only one of --linked, --db-url and --local can be set; got --linked, --db-url and --local.", suggestion: "Use --from=remote or --to=remote instead of --linked, --from=<url> or --to=<url> instead of --db-url, and drop --local.", }, @@ -230,6 +263,22 @@ describe("explicitSelectorError", () => { expect(rewritten.suggestion).not.toContain("--remote"); }); + it.each([ + ["--project-id", "--project-ref"], + ["--remote", "--local"], + ["--linked", "--remote"], + ])( + "returns a conflict between %s and %s unchanged, since db diff cannot raise it", + (...flags) => { + const conflict = new TargetSelectorConflictError({ + flags, + message: "m", + suggestion: "s", + }); + expect(explicitSelectorError(conflict)).toBe(conflict); + }, + ); + it("never echoes the connection string a conflict was raised for", () => { const rewritten = explicitSelectorError( selectorFailure({ 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 864e0bdd43..c690978f9d 100644 --- a/apps/cli/src/commands/db/diff/diff.integration.test.ts +++ b/apps/cli/src/commands/db/diff/diff.integration.test.ts @@ -2729,8 +2729,7 @@ describe("db diff", () => { label: "--linked with --local", selector: { linked: true, local: true }, tag: "TargetSelectorConflictError", - suggestion: - "Use --from=remote or --to=remote instead of --linked, --from=<url> or --to=<url> instead of --db-url, and drop --local.", + suggestion: "Use --from=remote or --to=remote instead of --linked, and drop --local.", }, { label: "--project-ref with --db-url", From 16b2a8f8e4315b9959a25c15ef599dec54a4e2df Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Tue, 20 Oct 2026 16:29:54 +0100 Subject: [PATCH 263/291] fix(cli): keep the project ref a URL-shaped --project-ref names in an explicit db diff TargetInvalidValueError now carries the ref the selector recovered from the rejected URL, so the explicit-mode rewrite suggests --from=<ref> or --to=<ref> with that ref instead of the generic hint. --- .../src/command-internal/target/target-selector.ts | 8 +++++--- .../target/target-selector.unit.test.ts | 3 ++- .../src/command-internal/target/target.errors.ts | 2 ++ apps/cli/src/commands/db/diff/SIDE_EFFECTS.md | 6 ++++-- apps/cli/src/commands/db/diff/diff.explicit.ts | 6 +++++- .../src/commands/db/diff/diff.explicit.unit.test.ts | 13 ++++++++++++- .../src/commands/db/diff/diff.integration.test.ts | 2 +- 7 files changed, 31 insertions(+), 9 deletions(-) diff --git a/apps/cli/src/command-internal/target/target-selector.ts b/apps/cli/src/command-internal/target/target-selector.ts index 71bc742253..6c577abca8 100644 --- a/apps/cli/src/command-internal/target/target-selector.ts +++ b/apps/cli/src/command-internal/target/target-selector.ts @@ -174,14 +174,16 @@ export const parseTargetSelector = ( const aliasRemote = aliasValue === undefined ? undefined : parseNamedRemote(aliasValue); const rejectedAlias = aliasRejection(aliasRemote, input.dbUrl !== undefined); if (rejectedAlias !== undefined) { + const ref = rejectedAlias._tag === "ForeignUrl" ? rejectedAlias.ref : undefined; return Result.fail( new TargetInvalidValueError({ flag: aliasFlag, message: `${aliasFlag} takes a project ref or a branch name, not a ${rejectedAlias._tag === "ForeignUrl" ? "URL" : "connection string"}.`, suggestion: - rejectedAlias._tag === "ForeignUrl" && rejectedAlias.ref !== undefined - ? `Use --remote=${rejectedAlias.ref}.` - : "Pass a project ref or a branch name, for example --remote=abcdefghijklmnopqrst", + ref === undefined + ? "Pass a project ref or a branch name, for example --remote=abcdefghijklmnopqrst" + : `Use --remote=${ref}.`, + ref, }), ); } 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 694ab75aea..5f159dcf6f 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 @@ -170,10 +170,11 @@ describe("parseTargetSelector", () => { expect(JSON.stringify(error)).not.toContain("hunter2"); }); - it("suggests the ref a project URL already names", () => { + it("suggests the ref a project URL already names and keeps it on the error", () => { expect(failure({ projectRef: Option.some(`https://${REF}.supabase.co`) })).toMatchObject({ _tag: "TargetInvalidValueError", suggestion: `Use --remote=${REF}.`, + ref: REF, }); }); diff --git a/apps/cli/src/command-internal/target/target.errors.ts b/apps/cli/src/command-internal/target/target.errors.ts index fce48cc37f..e11d882c93 100644 --- a/apps/cli/src/command-internal/target/target.errors.ts +++ b/apps/cli/src/command-internal/target/target.errors.ts @@ -47,6 +47,8 @@ export class TargetInvalidValueError extends Data.TaggedError("TargetInvalidValu readonly flag: "--project-ref" | "--project-id"; readonly message: string; readonly suggestion: string; + /** The project ref the rejected URL names, when it names one. */ + readonly ref?: string; }> { get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { return actionability.provideFlags; diff --git a/apps/cli/src/commands/db/diff/SIDE_EFFECTS.md b/apps/cli/src/commands/db/diff/SIDE_EFFECTS.md index 229b3f7aa1..4d4a7ce782 100644 --- a/apps/cli/src/commands/db/diff/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/db/diff/SIDE_EFFECTS.md @@ -278,9 +278,11 @@ a postgresql:// URL; convert a connection string to a postgresql:// URL and pass empty `--project-ref` or `--db-url`, and a URL in `--project-ref` keep their usual messages, except that a conflict among `--linked`, `--db-url` and `--local` leaves `--remote` out of the flags it allows (`Only one of --linked, --db-url and --local can be set; got --linked and --local.`). - Each suggestion names `--from`/`--to` instead of `--remote`, which this mode rejects, and a + Each suggestion names `--from`/`--to` instead of `--remote`, which this mode rejects. A conflict's suggestion has one clause per flag passed - (`Use --from=remote or --to=remote instead of --linked, and drop --local.`). + (`Use --from=remote or --to=remote instead of --linked, and drop --local.`), and a + `--project-ref` URL that names a project (`https://<ref>.supabase.co`) suggests + `Use --from=<ref> or --to=<ref>.` with that ref. - **Explicit `--from`/`--to` with a project whose database is unreachable over IPv6.** A project or branch endpoint falls back to the pooler URL the API returns. With none, the run fails with diff --git a/apps/cli/src/commands/db/diff/diff.explicit.ts b/apps/cli/src/commands/db/diff/diff.explicit.ts index 1cc598179f..a0af6494b8 100644 --- a/apps/cli/src/commands/db/diff/diff.explicit.ts +++ b/apps/cli/src/commands/db/diff/diff.explicit.ts @@ -108,7 +108,11 @@ export const explicitSelectorError = (error: TargetSelectorError): TargetSelecto return new TargetInvalidValueError({ flag: error.flag, message: error.message, - suggestion: "Pass a project ref as --from=<ref> or --to=<ref>.", + suggestion: + error.ref === undefined + ? "Pass a project ref as --from=<ref> or --to=<ref>." + : `Use --from=${error.ref} or --to=${error.ref}.`, + ref: error.ref, }); case "TargetProjectRefConflictError": return new TargetProjectRefConflictError({ diff --git a/apps/cli/src/commands/db/diff/diff.explicit.unit.test.ts b/apps/cli/src/commands/db/diff/diff.explicit.unit.test.ts index b958d94cc4..45cb65cccf 100644 --- a/apps/cli/src/commands/db/diff/diff.explicit.unit.test.ts +++ b/apps/cli/src/commands/db/diff/diff.explicit.unit.test.ts @@ -183,8 +183,19 @@ describe("explicitSelectorError", () => { }, }, { - label: "a --project-ref URL", + label: "a --project-ref URL naming a project", overrides: { projectRef: Option.some(`https://${REF}.supabase.co`) }, + expected: { + _tag: "TargetInvalidValueError", + flag: "--project-ref", + message: "--project-ref takes a project ref or a branch name, not a URL.", + suggestion: `Use --from=${REF} or --to=${REF}.`, + ref: REF, + }, + }, + { + label: "a --project-ref URL naming no project", + overrides: { projectRef: Option.some("https://db.example.com/x") }, expected: { _tag: "TargetInvalidValueError", flag: "--project-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 c690978f9d..ed96c4acaf 100644 --- a/apps/cli/src/commands/db/diff/diff.integration.test.ts +++ b/apps/cli/src/commands/db/diff/diff.integration.test.ts @@ -2766,7 +2766,7 @@ describe("db diff", () => { expected: { _tag: "TargetInvalidValueError", message: "--project-ref takes a project ref or a branch name, not a URL.", - suggestion: "Pass a project ref as --from=<ref> or --to=<ref>.", + suggestion: `Use --from=${FLAG_PROJECT_REF} or --to=${FLAG_PROJECT_REF}.`, }, }, { From 1967aac59dc3c36ebf252c70a16d468f6392b09d Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Tue, 20 Oct 2026 16:30:26 +0100 Subject: [PATCH 264/291] test(cli): record every Management API request in the db diff target-selection rows --- apps/cli/src/commands/db/diff/diff.integration.test.ts | 1 + 1 file changed, 1 insertion(+) 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 ed96c4acaf..b64146cea9 100644 --- a/apps/cli/src/commands/db/diff/diff.integration.test.ts +++ b/apps/cli/src/commands/db/diff/diff.integration.test.ts @@ -1382,6 +1382,7 @@ describe("db diff", () => { target: () => s.resolverCalls[0]?.target, cachedRef: () => s.cache.cachedRef, branchLookups: () => branchApi.requests, + apiRequests: () => branchApi.requests, }; }, }); From 490c9d631be4f7c8606e4ccf20f8207b23216b02 Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Tue, 20 Oct 2026 16:30:50 +0100 Subject: [PATCH 265/291] test(cli): record every Management API call in the reset and declarative generate target rows Both setups provide a typed CommandPlatformApi fake next to the branch-list API, so branchApi.requests alone missed any call the leaf made through the typed factory. The remote-db-url row now reads both records, and a stray call on either path fails it. --- apps/cli/src/commands/db/reset/reset.integration.test.ts | 2 ++ .../db/schema/declarative/generate/generate.integration.test.ts | 2 ++ 2 files changed, 4 insertions(+) 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 4a19a5d453..23becc9a1c 100644 --- a/apps/cli/src/commands/db/reset/reset.integration.test.ts +++ b/apps/cli/src/commands/db/reset/reset.integration.test.ts @@ -1116,6 +1116,7 @@ function setup( catalogApplied: catalog?.applied ?? [], requests, projectHttp, + platformApi, }; } @@ -3745,6 +3746,7 @@ describe("db reset", () => { target: () => ctx.resolver.inputs[0]?.target, cachedRef: () => ctx.linkedCache.cachedRef, branchLookups: () => branchApi.requests, + apiRequests: () => [...branchApi.requests, ...ctx.platformApi.requests], }; }, }); 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 10f9f5d774..5b0d5b464a 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 @@ -432,6 +432,7 @@ function setup(workdir: string, opts: SetupOpts = {}) { engineExportCalls, resolverCalls, projectHttp, + platformApi, localPostgresImageChecks, get ensureStartedCalls() { return ensureStartedCalls; @@ -645,6 +646,7 @@ describe("db schema declarative generate integration", () => { target: () => s.resolverCalls[0]?.target, cachedRef: () => s.cache.cachedRef, branchLookups: () => branchApi.requests, + apiRequests: () => [...branchApi.requests, ...s.platformApi.requests], }; }, }); From da68a834f636f8a8476c2310e1cb6d652da9c41c Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Tue, 20 Oct 2026 16:32:27 +0100 Subject: [PATCH 266/291] test(cli): catch a leaked database password in the inspect db e2e The dead URL's password equalled its user and database name, so the not.toContain(url) check passed for any leak that rewrote the scheme or dropped the path. The fixture now carries a unique password and the output is checked for it directly. --- apps/cli/src/commands/inspect/db/inspect-db.e2e.test.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) 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 d92345e241..e564eefc8d 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 @@ -7,8 +7,10 @@ const E2E_TIMEOUT_MS = 30_000; // A definitely-closed local port: `--remote=<url>` resolves without a config.toml or running // stack, then the native handler dials and fails fast — exercising the real subprocess path -// without depending on a live database in CI. -const DEAD_DB_URL = "postgres://postgres:postgres@127.0.0.1:1/postgres"; +// without depending on a live database in CI. The password is unique so a leak shows up even +// when the scheme or path of the URL is rewritten. +const DEAD_DB_PASSWORD = "e2e-dead-secret"; +const DEAD_DB_URL = `postgres://postgres:${DEAD_DB_PASSWORD}@127.0.0.1:1/postgres`; // `--agent no` forces text-mode output; otherwise the CLI may auto-select a machine format // (JSON on stdout) in a detected coding-agent environment, routing the error away from stderr. @@ -28,6 +30,7 @@ describe("supabase inspect db", () => { expect(exitCode).toBe(1); expect(stderr).toContain("Target: custom Postgres (127.0.0.1:1)"); expect(stderr).toMatch(/failed to connect to postgres|connection refused|ECONNREFUSED/i); + expect(`${stdout}${stderr}`).not.toContain(DEAD_DB_PASSWORD); expect(`${stdout}${stderr}`).not.toContain(DEAD_DB_URL); }), ), From 3014e2053d5302632a3aec5847ed4c561d5d139b Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Tue, 20 Oct 2026 16:34:07 +0100 Subject: [PATCH 267/291] chore(cli): restore the IPv6 link-suggestion note and drop a narrating test comment The IPv6 remedy change had removed the note explaining why the link remedy is link-shaped under a db_connection category; it still applies to the link branch. The resolveFailsWith option name already states what it does. --- apps/cli/src/command-internal/db-config.errors.ts | 3 +++ apps/cli/src/commands/db/diff/diff.integration.test.ts | 1 - 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/cli/src/command-internal/db-config.errors.ts b/apps/cli/src/command-internal/db-config.errors.ts index 85448e7acf..a8cb388a6f 100644 --- a/apps/cli/src/command-internal/db-config.errors.ts +++ b/apps/cli/src/command-internal/db-config.errors.ts @@ -125,6 +125,9 @@ export class DbConfigIpv6Error extends Data.TaggedError("DbConfigIpv6Error")<{ if (this.remedy === "pooler-url") { return { ...actionability.dbConnection, suggestion_type: CliSuggestionType.ProvideFlags }; } + // The rendered remediation is "Run supabase link --project-ref <ref> to + // setup IPv4 connection", so the suggestion is link-shaped even though the + // category stays db_connection. return { ...actionability.dbConnection, suggestion_type: CliSuggestionType.LinkProject, 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 b64146cea9..905cf27f49 100644 --- a/apps/cli/src/commands/db/diff/diff.integration.test.ts +++ b/apps/cli/src/commands/db/diff/diff.integration.test.ts @@ -160,7 +160,6 @@ interface SetupOpts { // Simulates an unlinked workdir: the project-ref resolver fails with // `ProjectRefNotLinkedError` absent an explicit `--remote=<ref>`. readonly linkedFails?: boolean; - // Makes every hosted connection resolve fail with this error. readonly resolveFailsWith?: DbConfigIpv6Error; // Per-differ-run `--json-diff` stdout, indexed by run order; falls back to `""` // once exhausted, so a single-run test only needs one element. From 6ee8720337086818a542384b952cf4f6b0cbe0aa Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Tue, 20 Oct 2026 16:38:47 +0100 Subject: [PATCH 268/291] docs(cli): keep the explicit IPv6 error's JSDoc within 100 columns --- apps/cli/src/commands/db/diff/diff.explicit.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/cli/src/commands/db/diff/diff.explicit.ts b/apps/cli/src/commands/db/diff/diff.explicit.ts index a0af6494b8..cb1817bb19 100644 --- a/apps/cli/src/commands/db/diff/diff.explicit.ts +++ b/apps/cli/src/commands/db/diff/diff.explicit.ts @@ -135,7 +135,7 @@ export const explicitSelectorError = (error: TargetSelectorError): TargetSelecto } }; -/** An IPv6 failure for another project suggests `--from`/`--to`, as explicit mode rejects `--remote`. */ +/** Points another project's IPv6 suggestion at `--from`/`--to`; explicit mode rejects `--remote`. */ export const explicitIpv6Error = (error: DbConfigIpv6Error): DbConfigIpv6Error => error.remedy === "pooler-url" ? new DbConfigIpv6Error({ From 7f94d2c683db1aac2cdfa40fb9014382a511eafd Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Tue, 20 Oct 2026 16:39:03 +0100 Subject: [PATCH 269/291] docs(cli): shorten the explicit IPv6 error's JSDoc --- apps/cli/src/commands/db/diff/diff.explicit.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/cli/src/commands/db/diff/diff.explicit.ts b/apps/cli/src/commands/db/diff/diff.explicit.ts index cb1817bb19..6440d9eaaa 100644 --- a/apps/cli/src/commands/db/diff/diff.explicit.ts +++ b/apps/cli/src/commands/db/diff/diff.explicit.ts @@ -135,7 +135,7 @@ export const explicitSelectorError = (error: TargetSelectorError): TargetSelecto } }; -/** Points another project's IPv6 suggestion at `--from`/`--to`; explicit mode rejects `--remote`. */ +/** Points another project's IPv6 hint at `--from`/`--to`; explicit mode rejects `--remote`. */ export const explicitIpv6Error = (error: DbConfigIpv6Error): DbConfigIpv6Error => error.remedy === "pooler-url" ? new DbConfigIpv6Error({ From 16f3a44147cf40278a6b27857d522a4d01b010b9 Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Wed, 21 Oct 2026 10:59:16 +0100 Subject: [PATCH 270/291] refactor(cli): remove the unused hasExplicitSelector --- .../target/target-selector.ts | 9 ------ .../target/target-selector.unit.test.ts | 28 +------------------ 2 files changed, 1 insertion(+), 36 deletions(-) diff --git a/apps/cli/src/command-internal/target/target-selector.ts b/apps/cli/src/command-internal/target/target-selector.ts index 6c577abca8..7e62c07e6b 100644 --- a/apps/cli/src/command-internal/target/target-selector.ts +++ b/apps/cli/src/command-internal/target/target-selector.ts @@ -18,15 +18,6 @@ import { /** What the flags select: the default, the local stack, or a remote project, branch or database. */ type TargetSelection = { readonly _tag: "Default" } | { readonly _tag: "Local" } | RemoteValue; -/** Whether any selector flag, `--local` and the deprecated aliases included, was given. */ -export const hasExplicitSelector = (flags: TargetFlagValues): boolean => - flags.local || - flags.linked || - Option.isSome(flags.remote) || - Option.isSome(flags.projectRef) || - Option.isSome(flags.projectId ?? Option.none()) || - Option.isSome(flags.dbUrl ?? Option.none()); - type RemoteAliasFlag = "--linked" | "--project-ref" | "--project-id" | "--db-url"; /** A deprecated selector flag the run used, with the `--remote` form it maps to. */ 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 5f159dcf6f..69c465e025 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 @@ -3,11 +3,7 @@ import { describe, expect, it } from "vitest"; import type { RemoteValue } from "./remote-value.ts"; import type { TargetFlagValues } from "./target-flags.ts"; -import { - hasExplicitSelector, - parseTargetSelector, - type TargetSelectorOptions, -} from "./target-selector.ts"; +import { parseTargetSelector, type TargetSelectorOptions } from "./target-selector.ts"; const REF = "abcdefghijklmnopqrst"; const URL = "postgresql://u:p@db.example.com:5432/postgres"; @@ -418,25 +414,3 @@ describe("parseTargetSelector", () => { }); }); }); - -describe("hasExplicitSelector", () => { - it("is false when no selector flag is given", () => { - expect(hasExplicitSelector(none)).toBe(false); - }); - - it.each<readonly [string, Partial<TargetFlagValues>]>([ - ["--remote", { remote: Option.some({ _tag: "Linked" }) }], - ["--linked", { linked: true }], - ["--local", { local: true }], - ["--db-url", { dbUrl: Option.some(URL) }], - ["--project-ref", { projectRef: Option.some(REF) }], - ["--project-id", { projectId: Option.some(REF) }], - ])("is true for %s", (_flag, overrides) => { - expect(hasExplicitSelector({ ...none, ...overrides })).toBe(true); - }); - - it("ignores a database URL flag a command does not declare", () => { - const { dbUrl: _dbUrl, ...withoutDbUrl } = none; - expect(hasExplicitSelector(withoutDbUrl)).toBe(false); - }); -}); From a6b9dd064d115c747eb94357beec167703dc4b59 Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Wed, 21 Oct 2026 10:59:37 +0100 Subject: [PATCH 271/291] test(cli): drop the whole-tree operand-capacity snapshot of --remote leaves --- .../src/shared/cli/remote-argv.unit.test.ts | 136 +----------------- 1 file changed, 1 insertion(+), 135 deletions(-) diff --git a/apps/cli/src/shared/cli/remote-argv.unit.test.ts b/apps/cli/src/shared/cli/remote-argv.unit.test.ts index 7620b6183a..a707da60c7 100644 --- a/apps/cli/src/shared/cli/remote-argv.unit.test.ts +++ b/apps/cli/src/shared/cli/remote-argv.unit.test.ts @@ -1,7 +1,5 @@ import { describe, expect, it } from "vitest"; -import { commandTreeFor } from "../../../tests/helpers/command-flags.ts"; -import type { PermissionVariant } from "../../../tests/helpers/permission-registry.ts"; import { rootCommandForFeatures } from "../../cli/root.ts"; import { bareRemoteOperand, @@ -9,7 +7,7 @@ import { joinRemoteOperands, type RemoteArgvLeaf, } from "./remote-argv.ts"; -import { prepareCliArgs, remoteArgvLeafFor } from "./run.ts"; +import { prepareCliArgs } from "./run.ts"; const REF = "abcdefghijklmnopqrst"; const URL = "postgresql://postgres:secret@db.example.com:5432/postgres"; @@ -487,135 +485,3 @@ describe("prepareCliArgs remoteConflict on the real command tree", () => { expect(conflictOf(["db", "pull", "--remote", "feature-x", "--remote"])).toBe(false); }); }); - -describe("remote operand capacity of the real command tree", () => { - const variants: ReadonlyArray<PermissionVariant> = [ - undefined, - { stackBackend: "stack", computeEnabled: true }, - ]; - - it("pins the operand capacity of every leaf that accepts --remote", () => { - const capacityByLeaf = new Map( - variants.flatMap((variant) => { - const root = rootCommandForFeatures(variant); - return [...commandTreeFor(variant)] - .filter(([, node]) => node.isLeaf && node.flags.has("remote")) - .map(([path]) => { - const leaf = remoteArgvLeafFor(root, [...path.split(" "), "--remote"]); - return [path, leaf?.operandCapacity] as const; - }); - }), - ); - expect(Object.fromEntries(capacityByLeaf)).toMatchInlineSnapshot(` - { - "backups list": 0, - "backups restore": 0, - "branches create": 1, - "branches delete": 1, - "branches disable": 0, - "branches get": 1, - "branches list": 0, - "branches pause": 1, - "branches unpause": 1, - "branches update": 1, - "compute delete": 1, - "compute list": 0, - "compute logs": 1, - "compute push": Infinity, - "compute status": 1, - "config diff": 0, - "config pull": 0, - "config push": 0, - "db advisors": 0, - "db diff": 0, - "db dump": 0, - "db lint": 0, - "db pull": 1, - "db push": 0, - "db query": 1, - "db reset": 0, - "db schema declarative generate": 0, - "db test": Infinity, - "domains activate": 0, - "domains create": 0, - "domains delete": 0, - "domains get": 0, - "domains reverify": 0, - "encryption get-root-key": 0, - "encryption update-root-key": 0, - "functions delete": 1, - "functions deploy": Infinity, - "functions download": 1, - "functions list": 0, - "gen keys": 0, - "gen types": 0, - "inspect db bloat": 0, - "inspect db blocking": 0, - "inspect db cache-hit": 0, - "inspect db calls": 0, - "inspect db db-stats": 0, - "inspect db index-sizes": 0, - "inspect db index-stats": 0, - "inspect db index-usage": 0, - "inspect db locks": 0, - "inspect db long-running-queries": 0, - "inspect db outliers": 0, - "inspect db replication-slots": 0, - "inspect db role-configs": 0, - "inspect db role-connections": 0, - "inspect db role-stats": 0, - "inspect db seq-scans": 0, - "inspect db table-index-sizes": 0, - "inspect db table-record-counts": 0, - "inspect db table-sizes": 0, - "inspect db table-stats": 0, - "inspect db total-index-size": 0, - "inspect db total-table-sizes": 0, - "inspect db traffic-profile": 0, - "inspect db unused-indexes": 0, - "inspect db vacuum-stats": 0, - "inspect report": 0, - "migration down": 0, - "migration fetch": 0, - "migration list": 0, - "migration repair": Infinity, - "migration squash": 0, - "migration up": 0, - "network-bans get": 0, - "network-bans remove": 0, - "network-restrictions get": 0, - "network-restrictions update": 0, - "notebooks pull": 1, - "notebooks push": 1, - "postgres-config delete": 0, - "postgres-config get": 0, - "postgres-config update": 0, - "projects api-keys": 0, - "pull": 0, - "secrets list": 0, - "secrets set": Infinity, - "secrets unset": Infinity, - "seed buckets": 0, - "snippets download": 1, - "snippets list": 0, - "ssl-enforcement get": 0, - "ssl-enforcement update": 0, - "sso add": 0, - "sso info": 0, - "sso list": 0, - "sso remove": 1, - "sso show": 1, - "sso update": 1, - "storage cp": 2, - "storage ls": 1, - "storage mv": 2, - "storage rm": Infinity, - "test db": Infinity, - "vanity-subdomains activate": 0, - "vanity-subdomains check-availability": 0, - "vanity-subdomains delete": 0, - "vanity-subdomains get": 0, - } - `); - }); -}); From cb43102f58024eb867379681c6ca50a503f62448 Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Wed, 21 Oct 2026 11:00:04 +0100 Subject: [PATCH 272/291] test(cli): drop the source-text scan for lowercase consent actions --- .../hosted-consent-coverage.unit.test.ts | 17 ------ .../hosted-consent.integration.test.ts | 60 ------------------- 2 files changed, 77 deletions(-) delete mode 100644 apps/cli/tests/helpers/hosted-consent.integration.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 index f317bf6077..6866152680 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 @@ -37,19 +37,6 @@ 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"); @@ -88,10 +75,6 @@ describe("hosted consent coverage", () => { expect(nonLiteralCallSites).toEqual([]); }); - it("starts every consent action literal with a capital letter", () => { - expect(lowercaseActions).toEqual([]); - }); - it("defaults the prompt to Yes for create-only leaves and No for everything else", () => { expect(Object.fromEntries(consentDefaultByLeaf)).toMatchInlineSnapshot(` { diff --git a/apps/cli/tests/helpers/hosted-consent.integration.test.ts b/apps/cli/tests/helpers/hosted-consent.integration.test.ts deleted file mode 100644 index b1f6a8e893..0000000000 --- a/apps/cli/tests/helpers/hosted-consent.integration.test.ts +++ /dev/null @@ -1,60 +0,0 @@ -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 { Output } from "../../src/shared/output/output.service.ts"; -import { describeHostedConsent } from "./hosted-consent.ts"; - -const syntheticWrite = ( - options: { typed?: boolean; onWrite?: () => void; readOnlyTask?: boolean } = {}, -) => - Effect.gen(function* () { - const ref = yield* ProjectRefResolver.use((resolver) => resolver.resolve(Option.none())); - if (options.readOnlyTask === true) { - const output = yield* Output; - const task = yield* output.task("Reading secrets"); - yield* task.succeed("Read secrets"); - } - 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 after a read-only task step", { - command: syntheticLeaf, - invoke: () => ({ run: syntheticWrite({ readOnlyTask: true }) }), -}); - -describeHostedConsent("synthetic-write with a custom mutation probe", { - command: syntheticLeaf, - invoke: () => { - let writes = 0; - return { - run: syntheticWrite({ onWrite: () => writes++ }), - mutations: () => writes, - }; - }, -}); From 72b8a61ce8e14f7818ecad867861d072a006a665 Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Wed, 21 Oct 2026 11:01:10 +0100 Subject: [PATCH 273/291] fix(cli): report a failed network-ban listing or removal under --yes --- .../db-config.integration.test.ts | 96 ++++++++++++++++++- .../src/command-internal/db-config.layer.ts | 52 ++++++---- .../src/commands/inspect/db/SIDE_EFFECTS.md | 18 ++-- .../commands/inspect/report/SIDE_EFFECTS.md | 21 ++-- ...t-selection-and-hosted-mutation-consent.md | 3 +- 5 files changed, 152 insertions(+), 38 deletions(-) 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 b9adc2b6cc..32509a1b62 100644 --- a/apps/cli/src/command-internal/db-config.integration.test.ts +++ b/apps/cli/src/command-internal/db-config.integration.test.ts @@ -2097,6 +2097,7 @@ describe("dbConfigResolver.resolveConnection", () => { const bannedRequests = ( requests: Array<{ readonly method: string; readonly path: string }>, bans: ReadonlyArray<string>, + failing: "retrieve" | "delete" | undefined = undefined, ) => stubManagementApi(requests, (method, path) => { if (method === "GET" && path === `/v1/projects/${ref}/config/database/pooler`) { @@ -2109,10 +2110,14 @@ describe("dbConfigResolver.resolveConnection", () => { ); } if (method === "POST" && path === `/v1/projects/${ref}/network-bans/retrieve`) { - return Response.json({ banned_ipv4_addresses: bans }); + return failing === "retrieve" + ? Response.json({ message: "listing broke" }, { status: 500 }) + : Response.json({ banned_ipv4_addresses: bans }); } if (method === "DELETE" && path === `/v1/projects/${ref}/network-bans`) { - return Response.json({}); + return failing === "delete" + ? Response.json({ message: "removal broke" }, { status: 500 }) + : Response.json({}); } return undefined; }); @@ -2270,5 +2275,92 @@ describe("dbConfigResolver.resolveConnection", () => { expect(requests.some((request) => request.method === "DELETE")).toBe(false); }).pipe(Effect.scoped, Effect.provide(BunServices.layer)); }); + + it.effect( + "reports the removal failure, not a --yes hint, when removal keeps failing under --yes", + () => { + const requests: Array<{ readonly method: string; readonly path: string }> = []; + const connection = failingConnect(Number.POSITIVE_INFINITY); + return Effect.gen(function* () { + yield* bannedRequests(requests, ["203.0.113.7"], "delete"); + const { fiber, advance } = yield* forkResolve( + inputFor(linkedTarget(ref, { source: "flag", matchesLinkedRef: false }), { + networkBanRemoval: yield* approval, + }), + connection.layer, + ); + yield* advance(8); + const exit = yield* Fiber.await(fiber); + const error = Exit.isFailure(exit) ? Cause.findErrorOption(exit.cause) : Option.none(); + expect(error).toEqual( + Option.some( + expect.objectContaining({ + _tag: "DbConfigUnbanStatusError", + status: 500, + message: expect.stringContaining("unexpected remove bans status 500"), + }), + ), + ); + expect(failureText(exit)).not.toContain("--yes"); + expect(connection.attempts.count).toBe(9); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)); + }, + ); + + it.effect("reports the listing failure when listing keeps failing under --yes", () => { + const requests: Array<{ readonly method: string; readonly path: string }> = []; + const connection = failingConnect(Number.POSITIVE_INFINITY); + return Effect.gen(function* () { + yield* bannedRequests(requests, [], "retrieve"); + const { fiber, advance } = yield* forkResolve( + inputFor(linkedTarget(ref, { source: "flag", matchesLinkedRef: false }), { + networkBanRemoval: yield* approval, + }), + connection.layer, + ); + yield* advance(8); + const exit = yield* Fiber.await(fiber); + expect(failureText(exit)).toContain("DbConfigListBansStatusError"); + expect(requests.some((request) => request.method === "DELETE")).toBe(false); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)); + }); + + it.effect( + "falls back to the connection error once listing recovers with no bans under --yes", + () => { + const requests: Array<{ readonly method: string; readonly path: string }> = []; + const connection = failingConnect(Number.POSITIVE_INFINITY); + let listCalls = 0; + return Effect.gen(function* () { + yield* 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`) { + listCalls += 1; + return listCalls === 1 + ? Response.json({ message: "listing broke" }, { status: 500 }) + : Response.json({ banned_ipv4_addresses: [] }); + } + return undefined; + }); + const { fiber, advance } = yield* forkResolve( + inputFor(linkedTarget(ref, { source: "flag", matchesLinkedRef: false }), { + networkBanRemoval: yield* approval, + }), + connection.layer, + ); + yield* advance(8); + const exit = yield* Fiber.await(fiber); + expect(failureText(exit)).toContain("DbConfigConnectTempRoleError"); + }).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 e428e1b2ba..82da9d71a6 100644 --- a/apps/cli/src/command-internal/db-config.layer.ts +++ b/apps/cli/src/command-internal/db-config.layer.ts @@ -277,9 +277,16 @@ const networkBannedError = (ref: string, addrs: ReadonlyArray<string>) => suggestion: `Re-run with --yes to remove them, or run supabase network-bans remove --db-unban-ip ${addrs.join(",")} --experimental --yes --remote=${ref}`, }); +interface BanState { + readonly listed: ReadonlyArray<string>; + readonly failure: Option.Option<DbConfigError>; +} + +const NO_BANS: BanState = { listed: [], failure: Option.none() }; + // 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. +// for network bans on the project: with approval they are removed and a failed listing or removal +// is what the run reports if the retries run out; without it the last listing is reported instead. const waitForTempRole = Effect.fn("DbConfig.waitForTempRole")(function* ( ref: string, conn: PgConnInput, @@ -290,7 +297,7 @@ const waitForTempRole = Effect.fn("DbConfig.waitForTempRole")(function* ( const debug = yield* DebugLogger; const attempt = ( n: number, - listedBans: ReadonlyArray<string>, + bans: BanState, ): 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`. @@ -300,9 +307,10 @@ const waitForTempRole = Effect.fn("DbConfig.waitForTempRole")(function* ( // 8 retries after the initial attempt allows 9 total attempts. `n` is 1-based, so give // up only after attempt 9 (`n > MAX_RETRIES`), not at attempt 8. if (n > MAX_RETRIES) { + if (Option.isSome(bans.failure)) return Effect.fail(bans.failure.value); return Effect.fail( - listedBans.length > 0 - ? networkBannedError(ref, listedBans) + bans.listed.length > 0 + ? networkBannedError(ref, bans.listed) : new Errors.DbConfigConnectTempRoleError({ message: `failed to connect as temp role: ${cause.message}`, suggestion: SUGGEST_ENV_VAR, @@ -316,28 +324,40 @@ const waitForTempRole = Effect.fn("DbConfig.waitForTempRole")(function* ( 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. - const bans = + // A ban-list/unban failure must not abort the retry loop: it is logged to --debug and, + // with approval, kept so the final error names it. + const nextBans = n < 3 - ? listedBans + ? bans : yield* listBans(ref).pipe( - Effect.flatMap((addrs) => - addrs.length > 0 && removeNetworkBans - ? removeBans(ref, addrs).pipe(Effect.as([])) - : Effect.succeed(addrs), + Effect.flatMap( + ( + addrs, + ): Effect.Effect< + BanState, + DbConfigError, + CommandPlatformApiFactory | Output + > => + addrs.length > 0 && removeNetworkBans + ? removeBans(ref, addrs).pipe(Effect.as(NO_BANS)) + : Effect.succeed({ listed: addrs, failure: Option.none() }), ), Effect.catch((banError) => - debug.debug(banError.message).pipe(Effect.as(listedBans)), + debug.debug(banError.message).pipe( + Effect.as<BanState>({ + listed: bans.listed, + failure: removeNetworkBans ? Option.some(banError) : Option.none(), + }), + ), ), ); yield* debug.debug(`Retry (${n}/${MAX_RETRIES}): ${cause.message}`); yield* Effect.sleep(Duration.millis(delayMs)); - return yield* attempt(n + 1, bans); + return yield* attempt(n + 1, nextBans); }); }), ); - return yield* attempt(1, []); + return yield* attempt(1, NO_BANS); }); /** diff --git a/apps/cli/src/commands/inspect/db/SIDE_EFFECTS.md b/apps/cli/src/commands/inspect/db/SIDE_EFFECTS.md index ab89a0d71d..03b776fd04 100644 --- a/apps/cli/src/commands/inspect/db/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/inspect/db/SIDE_EFFECTS.md @@ -32,15 +32,15 @@ no new config reads. Local targets and database URLs make none. -| Method | Path | Auth | Request body | Response (used fields) | -| -------- | ------------------------------------------- | ------------ | ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `GET` | `/v1/projects` | Bearer token | none | `[{id, name, organization_slug, region}]` — project picker: bare `--remote` with no ref from `SUPABASE_PROJECT_ID` or `.temp/project-ref`, with stdin and stdout TTYs and a non-JSON output format | -| `GET` | `/v1/projects/{parent}/branches` | Bearer token | none | `[{id, name, project_ref, is_default}]` — only for `--remote=<branch>` (or the deprecated `--project-ref=<branch>`), and for a bare `--remote` followed by a word that could name a branch; `{parent}` is `SUPABASE_PROJECT_ID`, else the linked project | -| `GET` | `/v1/branches/{id}` | Bearer token | none | `{ref, status, …}` — only for `--remote=<branch UUID>` when no project is linked | -| `POST` | `/v1/projects/{ref}/cli/login-role` | Bearer token | `{ read_only: false }` | temporary `role` and `password` — remote project with no database password | -| `GET` | `/v1/projects/{ref}/config/database/pooler` | Bearer token | none | primary pooler `connection_string` — remote project on an IPv4-only network (direct host unreachable), only when there is no usable saved `.temp/pooler-url` and an access token is available; a ref that differs from the linked project ignores the saved URL | -| `POST` | `/v1/projects/{ref}/network-bans/retrieve` | Bearer token | none | `banned_ipv4_addresses` — remote project, from the 3rd failed temporary-role connect | -| `DELETE` | `/v1/projects/{ref}/network-bans` | Bearer token | `{ ipv4_addresses, requester_ip: false }` | none — remote project, only when bans are listed and `--yes` or a shell `SUPABASE_YES` is set (clears 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 | +| Method | Path | Auth | Request body | Response (used fields) | +| -------- | ------------------------------------------- | ------------ | ----------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `GET` | `/v1/projects` | Bearer token | none | `[{id, name, organization_slug, region}]` — project picker: bare `--remote` with no ref from `SUPABASE_PROJECT_ID` or `.temp/project-ref`, with stdin and stdout TTYs and a non-JSON output format | +| `GET` | `/v1/projects/{parent}/branches` | Bearer token | none | `[{id, name, project_ref, is_default}]` — only for `--remote=<branch>` (or the deprecated `--project-ref=<branch>`), and for a bare `--remote` followed by a word that could name a branch; `{parent}` is `SUPABASE_PROJECT_ID`, else the linked project | +| `GET` | `/v1/branches/{id}` | Bearer token | none | `{ref, status, …}` — only for `--remote=<branch UUID>` when no project is linked | +| `POST` | `/v1/projects/{ref}/cli/login-role` | Bearer token | `{ read_only: false }` | temporary `role` and `password` — remote project with no database password | +| `GET` | `/v1/projects/{ref}/config/database/pooler` | Bearer token | none | primary pooler `connection_string` — remote project on an IPv4-only network (direct host unreachable), only when there is no usable saved `.temp/pooler-url` and an access token is available; a ref that differs from the linked project ignores the saved URL | +| `POST` | `/v1/projects/{ref}/network-bans/retrieve` | Bearer token | none | `banned_ipv4_addresses` — remote project, from the 3rd failed temporary-role connect | +| `DELETE` | `/v1/projects/{ref}/network-bans` | Bearer token | `{ ipv4_addresses, requester_ip: false }` | none — remote project, only when bans are listed and `--yes` or a shell `SUPABASE_YES` is set (clears 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; under `--yes` a failed listing or removal fails the run with that error instead | ## 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 c6538d27e3..43407b030d 100644 --- a/apps/cli/src/commands/inspect/report/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/inspect/report/SIDE_EFFECTS.md @@ -57,21 +57,22 @@ wasn't refreshed this run. Local targets and database URLs make none. -| Method | Path | Auth | Request body | Response (used fields) | -| -------- | ------------------------------------------- | ------------ | ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `GET` | `/v1/projects` | Bearer token | none | `[{id, name, organization_slug, region}]` — project picker: bare `--remote` with no ref from `SUPABASE_PROJECT_ID` or `.temp/project-ref`, with stdin and stdout TTYs and a non-JSON output format | -| `GET` | `/v1/projects/{parent}/branches` | Bearer token | none | `[{id, name, project_ref, is_default}]` — only for `--remote=<branch>` (or the deprecated `--project-ref=<branch>`), and for a bare `--remote` followed by a word that could name a branch; `{parent}` is `SUPABASE_PROJECT_ID`, else the linked project | -| `GET` | `/v1/branches/{id}` | Bearer token | none | `{ref, status, …}` — only for `--remote=<branch UUID>` when no project is linked | -| `POST` | `/v1/projects/{ref}/cli/login-role` | Bearer token | `{ read_only: false }` | temporary `role` and `password` — remote project with no database password | -| `GET` | `/v1/projects/{ref}/config/database/pooler` | Bearer token | none | primary pooler `connection_string` — remote project on an IPv4-only network (direct host unreachable), only when there is no usable saved `.temp/pooler-url` and an access token is available; a ref that differs from the linked project ignores the saved URL | -| `POST` | `/v1/projects/{ref}/network-bans/retrieve` | Bearer token | none | `banned_ipv4_addresses` — remote project, from the 3rd failed temporary-role connect | -| `DELETE` | `/v1/projects/{ref}/network-bans` | Bearer token | `{ ipv4_addresses, requester_ip: false }` | none — remote project, only when bans are listed and `--yes` or a shell `SUPABASE_YES` is set (clears 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 | +| Method | Path | Auth | Request body | Response (used fields) | +| -------- | ------------------------------------------- | ------------ | ----------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `GET` | `/v1/projects` | Bearer token | none | `[{id, name, organization_slug, region}]` — project picker: bare `--remote` with no ref from `SUPABASE_PROJECT_ID` or `.temp/project-ref`, with stdin and stdout TTYs and a non-JSON output format | +| `GET` | `/v1/projects/{parent}/branches` | Bearer token | none | `[{id, name, project_ref, is_default}]` — only for `--remote=<branch>` (or the deprecated `--project-ref=<branch>`), and for a bare `--remote` followed by a word that could name a branch; `{parent}` is `SUPABASE_PROJECT_ID`, else the linked project | +| `GET` | `/v1/branches/{id}` | Bearer token | none | `{ref, status, …}` — only for `--remote=<branch UUID>` when no project is linked | +| `POST` | `/v1/projects/{ref}/cli/login-role` | Bearer token | `{ read_only: false }` | temporary `role` and `password` — remote project with no database password | +| `GET` | `/v1/projects/{ref}/config/database/pooler` | Bearer token | none | primary pooler `connection_string` — remote project on an IPv4-only network (direct host unreachable), only when there is no usable saved `.temp/pooler-url` and an access token is available; a ref that differs from the linked project ignores the saved URL | +| `POST` | `/v1/projects/{ref}/network-bans/retrieve` | Bearer token | none | `banned_ipv4_addresses` — remote project, from the 3rd failed temporary-role connect | +| `DELETE` | `/v1/projects/{ref}/network-bans` | Bearer token | `{ ipv4_addresses, requester_ip: false }` | none — remote project, only when bans are listed and `--yes` or a shell `SUPABASE_YES` is set (clears 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; under `--yes` a failed listing or removal fails the run with that error instead | Queries run directly against Postgres (server-side `COPY (<query>) TO STDOUT WITH CSV HEADER`). The Management API is used lazily only for a remote project or a branch lookup, 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` once the -connect retries run out while bans are still listed. +connect retries run out while bans are still listed. Under `--yes` a failed listing or removal fails the run +with that error instead. ## Environment Variables 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 6db810c487..2b08cdff9d 100644 --- a/docs/adr/0033-target-selection-and-hosted-mutation-consent.md +++ b/docs/adr/0033-target-selection-and-hosted-mutation-consent.md @@ -201,7 +201,8 @@ hosted. `HostedMutationConsent` token only the consent module constructs, and without `--yes` the command fails with `LinkedDbNetworkBannedError`. Its suggestion is to re-run with `--yes` or to run `supabase network-bans remove --remote=<ref>` with the ref of the project that failed, which need - not be the linked one. This applies to reads too. + not be the linked one. Under `--yes`, a failed ban listing or removal fails the command with + that error instead. 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 database URL target. It skips consent. From 012a45c7c7cc277fd849223cb9b93b5ebf3daa31 Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Wed, 21 Oct 2026 11:02:06 +0100 Subject: [PATCH 274/291] test(cli): share one branchRow fixture across the target integration tests --- ...ent-scoped-project-ref.integration.test.ts | 17 +--- ...resolve-project-target.integration.test.ts | 37 +++----- .../target/resolve-target.integration.test.ts | 90 ++++++++++--------- .../branches.target.integration.test.ts | 27 +++--- .../notebooks/notebooks.integration.test.ts | 28 +++--- apps/cli/tests/helpers/remote-selection.ts | 14 ++- 6 files changed, 96 insertions(+), 117 deletions(-) diff --git a/apps/cli/src/command-internal/target/parent-scoped-project-ref.integration.test.ts b/apps/cli/src/command-internal/target/parent-scoped-project-ref.integration.test.ts index 848656c949..8d6b8a6679 100644 --- a/apps/cli/src/command-internal/target/parent-scoped-project-ref.integration.test.ts +++ b/apps/cli/src/command-internal/target/parent-scoped-project-ref.integration.test.ts @@ -11,6 +11,7 @@ import { } from "../../../tests/helpers/command-mocks.ts"; import { mockOutput } from "../../../tests/helpers/mocks.ts"; import { assertBlockDrift } from "../../../tests/helpers/permission-drift.ts"; +import { branchRow } from "../../../tests/helpers/remote-selection.ts"; import { branchesParentTarget } from "../../commands/branches/branches.target.ts"; import type { RemoteValue } from "./remote-value.ts"; import { resolveProjectTarget } from "./resolve-project-target.ts"; @@ -29,22 +30,12 @@ interface Listing { readonly body: unknown; } -const branchRow = (projectRef: string) => ({ - id: "11111111-2222-4333-8444-555555555555", - name: `branch-${projectRef}`, - project_ref: projectRef, - parent_project_ref: CACHED_PARENT, - is_default: false, - persistent: false, - status: "MIGRATIONS_PASSED", - created_at: "2026-05-27T01:02:03Z", - updated_at: "2026-05-27T01:02:04Z", - with_data: true, -}); +const parentBranch = (projectRef: string) => + branchRow(`branch-${projectRef}`, { project_ref: projectRef, parent_project_ref: CACHED_PARENT }); const branches = (...refs: ReadonlyArray<string>): Listing => ({ status: 200, - body: refs.map(branchRow), + body: refs.map(parentBranch), }); const failure = (status: number): Listing => ({ status, body: { message: "failed" } }); diff --git a/apps/cli/src/command-internal/target/resolve-project-target.integration.test.ts b/apps/cli/src/command-internal/target/resolve-project-target.integration.test.ts index 3e156611bf..389637ca53 100644 --- a/apps/cli/src/command-internal/target/resolve-project-target.integration.test.ts +++ b/apps/cli/src/command-internal/target/resolve-project-target.integration.test.ts @@ -9,6 +9,7 @@ import { } from "../../../tests/helpers/command-mocks.ts"; import { stripAnsi } from "../../../tests/helpers/ansi.ts"; import { mockOutput } from "../../../tests/helpers/mocks.ts"; +import { branchRow } from "../../../tests/helpers/remote-selection.ts"; import { ProjectRefNotLinkedError } from "../../config/project-ref.errors.ts"; import { ProjectRefResolver } from "../../config/project-ref.service.ts"; import { rootCommandForFeatures } from "../../cli/root.ts"; @@ -28,18 +29,8 @@ const FILE_REF = "filefilefilefilefile"; const FLAG_REF = "flagflagflagflagflag"; const BRANCH_REF = "branchprojectrefabcd"; -const branchRow = (name: string) => ({ - id: "11111111-2222-4333-8444-555555555555", - name, - project_ref: BRANCH_REF, - parent_project_ref: FILE_REF, - is_default: false, - persistent: false, - status: "MIGRATIONS_PASSED", - created_at: "2026-05-27T01:02:03Z", - updated_at: "2026-05-27T01:02:04Z", - with_data: true, -}); +const fileBranch = (name: string, overrides: Parameters<typeof branchRow>[1] = {}) => + branchRow(name, { parent_project_ref: FILE_REF, ...overrides }); const BRANCH_UUID = "11111111-2222-4333-8444-555555555555"; @@ -59,7 +50,7 @@ interface RunOptions { readonly linked?: boolean; readonly savedRef?: string; readonly args?: ReadonlyArray<string>; - readonly branches?: ReadonlyArray<ReturnType<typeof branchRow>>; + readonly branches?: ReadonlyArray<ReturnType<typeof fileBranch>>; readonly branchById?: { readonly status: number; readonly body: unknown }; readonly interactive?: boolean; } @@ -197,7 +188,7 @@ describe("resolveProjectTarget", () => { { savedRef: FILE_REF, args: ["functions", "deploy", "--remote=feature-x", `--remote=${FLAG_REF}`], - branches: [branchRow("feature-x")], + branches: [fileBranch("feature-x")], }, ); expect(failureOf(result.exit)).toMatchObject({ @@ -240,7 +231,7 @@ describe("resolveProjectTarget", () => { Effect.gen(function* () { const result = yield* run( { remote: { _tag: "Branch", name: "feature-x" } }, - { savedRef: FILE_REF, branches: [branchRow("feature-x")] }, + { savedRef: FILE_REF, branches: [fileBranch("feature-x")] }, ); expect(refOf(result.exit)).toBe(BRANCH_REF); expect(result.branchRequests).toBe(1); @@ -253,7 +244,7 @@ describe("resolveProjectTarget", () => { Effect.gen(function* () { const result = yield* run( { remote: { _tag: "Branch", name: "feature-x" } }, - { savedRef: FILE_REF, branches: [branchRow("feature-x")], interactive: true }, + { savedRef: FILE_REF, branches: [fileBranch("feature-x")], interactive: true }, ); expect(refOf(result.exit)).toBe(BRANCH_REF); expect(result.progressEvents).toContainEqual({ @@ -267,7 +258,7 @@ describe("resolveProjectTarget", () => { Effect.gen(function* () { const result = yield* run( { remote: { _tag: "Branch", name: "feature-x" } }, - { savedRef: FILE_REF, branches: [branchRow("feature-x")], interactive: false }, + { savedRef: FILE_REF, branches: [fileBranch("feature-x")], interactive: false }, ); expect(refOf(result.exit)).toBe(BRANCH_REF); expect(result.progressEvents).toEqual([]); @@ -279,7 +270,7 @@ describe("resolveProjectTarget", () => { Effect.gen(function* () { const result = yield* run( { projectRef: "feature-x" }, - { savedRef: FILE_REF, branches: [branchRow("feature-x")] }, + { savedRef: FILE_REF, branches: [fileBranch("feature-x")] }, ); expect(refOf(result.exit)).toBe(BRANCH_REF); }), @@ -306,7 +297,7 @@ describe("resolveProjectTarget", () => { { savedRef: FILE_REF, args: ["functions", "deploy", "--remote", "feature-x"], - branches: [branchRow("feature-x")], + branches: [fileBranch("feature-x")], }, ); expect(failureOf(result.exit)).toMatchObject({ @@ -329,7 +320,7 @@ describe("resolveProjectTarget", () => { Effect.gen(function* () { const result = yield* run( { remote: { _tag: "Branch", name: BRANCH_UUID } }, - { savedRef: FILE_REF, branches: [{ ...branchRow("feature-x"), id: BRANCH_UUID }] }, + { savedRef: FILE_REF, branches: [fileBranch("feature-x", { id: BRANCH_UUID })] }, ); expect(refOf(result.exit)).toBe(BRANCH_REF); expect(result.requestedUrls).toEqual([ @@ -429,7 +420,7 @@ describe("resolveProjectTarget with detail", () => { Effect.gen(function* () { const result = yield* runDetailed( { remote: { _tag: "Branch", name: "feature-x" } }, - { savedRef: FILE_REF, branches: [branchRow("feature-x")] }, + { savedRef: FILE_REF, branches: [fileBranch("feature-x")] }, ); expect(successOf(result.exit)).toEqual({ kind: "linked", @@ -449,7 +440,7 @@ describe("resolveProjectTarget with detail", () => { { remote: { _tag: "Branch", name: "main" } }, { savedRef: FILE_REF, - branches: [{ ...branchRow("main"), project_ref: FILE_REF, is_default: true }], + branches: [fileBranch("main", { project_ref: FILE_REF, is_default: true })], }, ); expect(successOf(result.exit)).toMatchObject({ ref: FILE_REF, isDefault: true }); @@ -460,7 +451,7 @@ describe("resolveProjectTarget with detail", () => { Effect.gen(function* () { const result = yield* runDetailed( { remote: { _tag: "Branch", name: "feature-x" } }, - { savedRef: BRANCH_REF, branches: [branchRow("feature-x")] }, + { savedRef: BRANCH_REF, branches: [fileBranch("feature-x")] }, ); expect(successOf(result.exit)).toMatchObject({ ref: BRANCH_REF, matchesLinkedRef: 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 86a47b3d3e..871225917d 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 @@ -11,6 +11,7 @@ import { } from "../../../tests/helpers/command-mocks.ts"; import { stripAnsi } from "../../../tests/helpers/ansi.ts"; import { mockOutput } from "../../../tests/helpers/mocks.ts"; +import { branchRow } from "../../../tests/helpers/remote-selection.ts"; import { AccessTokenRequiredError, InvalidAccessTokenError } from "../../auth/errors.ts"; import { CommandPlatformApiFactory } from "../../auth/command-platform-api-factory.service.ts"; import { rootCommandForFeatures } from "../../cli/root.ts"; @@ -55,18 +56,8 @@ const GONE_REF = "gonegonegonegonegone"; const SECRET_URL = "postgresql://postgres:hunter2@db.abcdefghijklmnopqrst.supabase.co:5432/postgres"; -const branchRow = (name: string, projectRef = BRANCH_REF, isDefault = false) => ({ - id: "11111111-2222-4333-8444-555555555555", - name, - project_ref: projectRef, - parent_project_ref: FILE_REF, - is_default: isDefault, - persistent: false, - status: "MIGRATIONS_PASSED", - created_at: "2026-05-27T01:02:03Z", - updated_at: "2026-05-27T01:02:04Z", - with_data: true, -}); +const fileBranch = (name: string, overrides: Parameters<typeof branchRow>[1] = {}) => + branchRow(name, { parent_project_ref: FILE_REF, ...overrides }); const failingListings = { unavailable: { status: 500, message: "boom" }, @@ -75,7 +66,7 @@ const failingListings = { missing: { status: 404, message: "Not found" }, } as const; -type Listing = ReadonlyArray<ReturnType<typeof branchRow>> | keyof typeof failingListings; +type Listing = ReadonlyArray<ReturnType<typeof fileBranch>> | keyof typeof failingListings; const root = rootCommandForFeatures(); @@ -285,7 +276,10 @@ describe("resolveTarget", () => { const result = yield* run( "selectable", { ...none, ...remote({ _tag: "Branch", name: "feature-x" }) }, - { savedRef: FILE_REF, branches: [branchRow("feature-x"), branchRow("other", FLAG_REF)] }, + { + savedRef: FILE_REF, + branches: [fileBranch("feature-x"), fileBranch("other", { project_ref: FLAG_REF })], + }, ); expect(succeeded(result.exit)).toEqual({ kind: "linked", @@ -310,7 +304,7 @@ describe("resolveTarget", () => { { savedRef: FILE_REF, cache: { ref: FILE_REF, name: "alpha" }, - branches: [branchRow("feature-x")], + branches: [fileBranch("feature-x")], }, ); expect(result.stderr).toContain( @@ -327,7 +321,9 @@ describe("resolveTarget", () => { { savedRef: BRANCH_REF, cache: { ref: FILE_REF }, - listings: { [FILE_REF]: [branchRow("feature-x"), branchRow("other", FLAG_REF)] }, + listings: { + [FILE_REF]: [fileBranch("feature-x"), fileBranch("other", { project_ref: FLAG_REF })], + }, }, ); expect(succeeded(result.exit)).toMatchObject({ ref: BRANCH_REF, parentRef: FILE_REF }); @@ -344,8 +340,8 @@ describe("resolveTarget", () => { savedRef: FILE_REF, cache: { ref: STALE_REF }, listings: { - [STALE_REF]: [branchRow("feature-x", FLAG_REF)], - [FILE_REF]: [branchRow("feature-x")], + [STALE_REF]: [fileBranch("feature-x", { project_ref: FLAG_REF })], + [FILE_REF]: [fileBranch("feature-x")], }, }, ); @@ -365,7 +361,7 @@ describe("resolveTarget", () => { { savedRef: GONE_REF, cache: { ref: FILE_REF }, - listings: { [FILE_REF]: [branchRow("feature-x")], [GONE_REF]: "missing" }, + listings: { [FILE_REF]: [fileBranch("feature-x")], [GONE_REF]: "missing" }, }, ); expect(succeeded(result.exit)).toMatchObject({ ref: BRANCH_REF, parentRef: FILE_REF }); @@ -380,7 +376,7 @@ describe("resolveTarget", () => { { savedRef: FILE_REF, cache: { ref: FILE_REF, name: "alpha" }, - branches: [branchRow("main", FILE_REF, true)], + branches: [fileBranch("main", { project_ref: FILE_REF, is_default: true })], }, ); expect(succeeded(result.exit)).toMatchObject({ kind: "linked", ref: FILE_REF }); @@ -394,7 +390,7 @@ describe("resolveTarget", () => { const result = yield* run( "selectable", { ...none, ...remote({ _tag: "Branch", name: "main" }) }, - { savedRef: FILE_REF, branches: [branchRow("main", FILE_REF)] }, + { savedRef: FILE_REF, branches: [fileBranch("main", { project_ref: FILE_REF })] }, ); expect(result.stderr).toContain(`Target: project ${FILE_REF} · production\n`); }), @@ -405,7 +401,7 @@ describe("resolveTarget", () => { const result = yield* run( "selectable", { ...none, ...remote({ _tag: "Branch", name: "other-name" }) }, - { savedRef: FILE_REF, branches: [branchRow("feature-x")] }, + { savedRef: FILE_REF, branches: [fileBranch("feature-x")] }, ); expect(failureOf(result.exit)).toMatchObject({ _tag: "LinkBranchNotFoundError", @@ -419,7 +415,7 @@ describe("resolveTarget", () => { const result = yield* run( "selectable", { ...none, ...remote({ _tag: "Branch", name: "feature-y" }) }, - { savedRef: FILE_REF, branches: [branchRow("feature-x"), branchRow("main")] }, + { savedRef: FILE_REF, branches: [fileBranch("feature-x"), fileBranch("main")] }, ); expect(failureOf(result.exit)).toMatchObject({ _tag: "LinkBranchNotFoundError", @@ -433,7 +429,10 @@ describe("resolveTarget", () => { const result = yield* run( "selectable", { ...none, ...remote({ _tag: "Branch", name: "mian" }) }, - { savedRef: FILE_REF, branches: [branchRow("main", FILE_REF, true)] }, + { + savedRef: FILE_REF, + branches: [fileBranch("main", { project_ref: FILE_REF, is_default: true })], + }, ); expect(failureOf(result.exit)).toMatchObject({ _tag: "LinkBranchNotFoundError" }); expect(String((failureOf(result.exit) as Error).message)).toContain( @@ -447,7 +446,7 @@ describe("resolveTarget", () => { const result = yield* run( "selectable", { ...none, ...remote({ _tag: "Branch", name: "ABCDEFGHIJKLMNOPQRST" }) }, - { savedRef: FILE_REF, branches: [branchRow("main")] }, + { savedRef: FILE_REF, branches: [fileBranch("main")] }, ); expect(failureOf(result.exit)).toMatchObject({ _tag: "LinkBranchNotFoundError", @@ -461,7 +460,7 @@ describe("resolveTarget", () => { const result = yield* run( "selectable", { ...none, ...remote({ _tag: "Branch", name: "ABCDEFGHIJKLMNOPQRS" }) }, - { savedRef: FILE_REF, branches: [branchRow("main")] }, + { savedRef: FILE_REF, branches: [fileBranch("main")] }, ); expect(String((failureOf(result.exit) as Error).message)).not.toContain( "If you meant a project ref", @@ -535,7 +534,7 @@ describe("resolveTarget", () => { args, command, savedRef: FILE_REF, - branches: [branchRow("feature-x")], + branches: [fileBranch("feature-x")], }); expect(failureOf(result.exit)).toMatchObject({ _tag: "TargetRemoteAmbiguousError", @@ -553,8 +552,8 @@ describe("resolveTarget", () => { savedRef: FILE_REF, cache: { ref: STALE_REF }, listings: { - [STALE_REF]: [branchRow("other", FLAG_REF)], - [FILE_REF]: [branchRow("feature-x")], + [STALE_REF]: [fileBranch("other", { project_ref: FLAG_REF })], + [FILE_REF]: [fileBranch("feature-x")], }, }); expect(failureOf(result.exit)).toMatchObject({ @@ -569,7 +568,7 @@ describe("resolveTarget", () => { const result = yield* run("selectable", bare, { args, savedRef: FILE_REF, - branches: [branchRow("other")], + branches: [fileBranch("other")], }); expect(succeeded(result.exit)).toMatchObject({ kind: "linked", ref: FILE_REF }); }), @@ -586,7 +585,7 @@ describe("resolveTarget", () => { ...linkState, args: ["db", "pull", "--remote", FLAG_REF], command, - branches: [branchRow("feature-x")], + branches: [fileBranch("feature-x")], }); expect(failureOf(result.exit)).toMatchObject({ _tag: "TargetRemoteAmbiguousError", @@ -604,7 +603,7 @@ describe("resolveTarget", () => { command, savedRef: FILE_REF, cache: { ref: FILE_REF, name: "alpha" }, - branches: [branchRow("feature-x")], + branches: [fileBranch("feature-x")], }); expect(failureOf(result.exit)).toMatchObject({ message: `"feature-x" is a branch of alpha (${FILE_REF}). ${options}`, @@ -810,7 +809,7 @@ describe("resolveTarget", () => { args: ["db", "pull", "--remote", "feature-y"], command, savedRef: FILE_REF, - branches: [branchRow("feature-x"), branchRow("main")], + branches: [fileBranch("feature-x"), fileBranch("main")], }); expect(failureOf(result.exit)).toMatchObject({ _tag: "TargetRemoteAmbiguousError", @@ -821,12 +820,12 @@ describe("resolveTarget", () => { ); it.effect.each([ - { name: "two edits from a branch", word: "feature-yz", branches: [branchRow("feature-x")] }, - { name: "one edit from a short branch", word: "den", branches: [branchRow("dev")] }, + { name: "two edits from a branch", word: "feature-yz", branches: [fileBranch("feature-x")] }, + { name: "one edit from a short branch", word: "den", branches: [fileBranch("dev")] }, { name: "one edit from the default branch", word: "mail", - branches: [branchRow("main", FILE_REF, true)], + branches: [fileBranch("main", { project_ref: FILE_REF, is_default: true })], }, ])("keeps a word that is $name as an operand", ({ word, branches }) => Effect.gen(function* () { @@ -846,7 +845,7 @@ describe("resolveTarget", () => { args: ["db", "pull", "--remote", "stagign"], command, savedRef: FILE_REF, - branches: [branchRow("staging")], + branches: [fileBranch("staging")], }); expect(failureOf(result.exit)).toMatchObject({ _tag: "TargetRemoteAmbiguousError", @@ -861,7 +860,7 @@ describe("resolveTarget", () => { args: ["db", "pull", "--remote", "Main"], command, savedRef: FILE_REF, - branches: [branchRow("main", FILE_REF, true)], + branches: [fileBranch("main", { project_ref: FILE_REF, is_default: true })], }); expect(failureOf(result.exit)).toMatchObject({ message: `"Main" isn't a branch, but "main" is. For the branch: supabase db pull --remote=main. For the linked project: supabase db pull Main --remote`, @@ -875,7 +874,7 @@ describe("resolveTarget", () => { args: ["db", "pull", "--remote", "Feature-X"], command, savedRef: FILE_REF, - branches: [branchRow("feature-x")], + branches: [fileBranch("feature-x")], }); expect(failureOf(result.exit)).toMatchObject({ message: `"Feature-X" isn't a branch, but "feature-x" is. For the branch: supabase db pull --remote=feature-x. For the linked project: supabase db pull Feature-X --remote`, @@ -889,7 +888,7 @@ describe("resolveTarget", () => { args: ["db", "pull", "--remote", "hunter2:pw@feature-x"], command, savedRef: FILE_REF, - branches: [branchRow("feature-x")], + branches: [fileBranch("feature-x")], }); const message = String((failureOf(result.exit) as Error).message); expect(message).toContain(`"feature-x" is a branch`); @@ -907,7 +906,7 @@ describe("resolveTarget", () => { const result = yield* run("selectable", bare, { args: tokens, savedRef: FILE_REF, - branches: [branchRow("feature-x")], + branches: [fileBranch("feature-x")], }); expect(succeeded(result.exit)).toMatchObject({ kind: "linked" }); expect(result.apiRequests).toEqual([]); @@ -935,7 +934,7 @@ describe("resolveTarget", () => { args: ["db", "pull", "--remote", "feature-x"], command, savedRef: FILE_REF, - branches: [branchRow("feature-x")], + branches: [fileBranch("feature-x")], }); expect(failureOf(result.exit)).toMatchObject({ _tag: "TargetRemoteAmbiguousError", @@ -946,7 +945,10 @@ describe("resolveTarget", () => { it.effect("does not look up branches when no project is linked", () => Effect.gen(function* () { - const result = yield* run("selectable", bare, { args, branches: [branchRow("feature-x")] }); + const result = yield* run("selectable", bare, { + args, + branches: [fileBranch("feature-x")], + }); expect(succeeded(result.exit)).toMatchObject({ kind: "linked" }); expect(result.apiRequests).toEqual([]); }), @@ -966,7 +968,7 @@ describe("resolveTarget", () => { args: ["db", "push", "--remote=feature-x", "--remote"], command, savedRef: FILE_REF, - branches: [branchRow("feature-x")], + branches: [fileBranch("feature-x")], }, ); const error = failureOf(result.exit); diff --git a/apps/cli/src/commands/branches/branches.target.integration.test.ts b/apps/cli/src/commands/branches/branches.target.integration.test.ts index 4296674e40..800f855d6e 100644 --- a/apps/cli/src/commands/branches/branches.target.integration.test.ts +++ b/apps/cli/src/commands/branches/branches.target.integration.test.ts @@ -2,6 +2,7 @@ import { Effect, Option } from "effect"; import { jsonResponse } from "../../../tests/helpers/command-mocks.ts"; import { describeHostedTargetSelection } from "../../../tests/helpers/hosted-target-selection.ts"; +import { branchRow } from "../../../tests/helpers/remote-selection.ts"; import { branchesCreate } from "./create/create.handler.ts"; import { branchesDelete } from "./delete/delete.handler.ts"; import { branchesDisable } from "./disable/disable.handler.ts"; @@ -14,18 +15,12 @@ import { branchesUpdate } from "./update/update.handler.ts"; const BRANCH_UUID = "11111111-1111-4111-8111-111111111111"; const BRANCH_PROJECT_REF = "cccccccccccccccccccc"; -const branchRow = (projectRef: string) => ({ - id: BRANCH_UUID, - name: "feat-x", - project_ref: projectRef, - parent_project_ref: "bbbbbbbbbbbbbbbbbbbb", - is_default: false, - persistent: false, - status: "MIGRATIONS_PASSED", - created_at: "2026-05-27T01:02:03Z", - updated_at: "2026-05-27T01:02:04Z", - with_data: false, -}); +const featBranch = (projectRef: string) => + branchRow("feat-x", { + id: BRANCH_UUID, + project_ref: projectRef, + parent_project_ref: "bbbbbbbbbbbbbbbbbbbb", + }); const BRANCH_CONFIG = { ref: BRANCH_UUID, @@ -43,13 +38,13 @@ const respondWithBranch = (lookupRef: string) => (request: Parameters<typeof jso const reply = (status: number, body: unknown) => Effect.succeed(jsonResponse(request, status, body)); if (request.method === "GET" && /^\/v1\/projects\/[a-z]{20}\/branches\/feat-x$/.test(pathname)) { - return reply(200, branchRow(lookupRef)); + return reply(200, featBranch(lookupRef)); } if (request.method === "GET" && /^\/v1\/projects\/[a-z]{20}\/branches$/.test(pathname)) { - return reply(200, [branchRow(lookupRef)]); + return reply(200, [featBranch(lookupRef)]); } if (request.method === "POST" && /^\/v1\/projects\/[a-z]{20}\/branches$/.test(pathname)) { - return reply(201, branchRow(lookupRef)); + return reply(201, featBranch(lookupRef)); } if (request.method === "GET" && pathname.startsWith("/v1/branches/")) { return reply(200, BRANCH_CONFIG); @@ -58,7 +53,7 @@ const respondWithBranch = (lookupRef: string) => (request: Parameters<typeof jso return reply(200, { message: "ok" }); } if (request.method === "PATCH" && pathname.startsWith("/v1/branches/")) { - return reply(200, branchRow(BRANCH_PROJECT_REF)); + return reply(200, featBranch(BRANCH_PROJECT_REF)); } return reply(200, null); }; diff --git a/apps/cli/src/commands/notebooks/notebooks.integration.test.ts b/apps/cli/src/commands/notebooks/notebooks.integration.test.ts index ebb756a5a4..6b8a4d1d9f 100644 --- a/apps/cli/src/commands/notebooks/notebooks.integration.test.ts +++ b/apps/cli/src/commands/notebooks/notebooks.integration.test.ts @@ -24,7 +24,11 @@ import { } from "./notebooks.errors.ts"; import { notebooksPullHandler } from "./pull/pull.command.ts"; import { notebooksPushHandler } from "./push/push.command.ts"; -import { warningsOf } from "../../../tests/helpers/remote-selection.ts"; +import { + BRANCH_PROJECT_REF, + branchRow, + warningsOf, +} from "../../../tests/helpers/remote-selection.ts"; const temp = useTempWorkdir("supabase-notebooks-regression-"); const ID = "44444444-4444-4444-8444-444444444444"; @@ -635,28 +639,15 @@ describe.each(commands)("notebooks %s command wiring", (command) => { }); describe.each(commands)("notebooks %s project selection", (command) => { - const BRANCH_REF = "branchprojectrefabcd"; const none = { remote: Option.none(), projectRef: Option.none() } satisfies Selector; const branchesRoute = `GET /v1/projects/${NOTEBOOKS_PROJECT_REF}/branches`; - const branchRow = { - id: "11111111-2222-4333-8444-555555555555", - name: "feature-x", - project_ref: BRANCH_REF, - parent_project_ref: NOTEBOOKS_PROJECT_REF, - is_default: false, - persistent: false, - status: "MIGRATIONS_PASSED", - created_at: "2026-05-27T01:02:03Z", - updated_at: "2026-05-27T01:02:04Z", - with_data: true, - }; it.effect("acts on the project of the branch --remote=<branch> names", () => { const { layer, http } = setup(command, { args: ["notebooks", command, "--remote=feature-x"], routes: { - [branchesRoute]: { status: 200, body: [branchRow] }, - [`GET /v2/projects/${BRANCH_REF}/notebooks`]: { + [branchesRoute]: { status: 200, body: [branchRow("feature-x")] }, + [`GET /v2/projects/${BRANCH_PROJECT_REF}/notebooks`]: { status: 200, body: notebookListPage({ notebooks: [] }), }, @@ -667,7 +658,10 @@ describe.each(commands)("notebooks %s project selection", (command) => { ...none, remote: Option.some({ _tag: "Branch", name: "feature-x" }), }); - expect(http.routeKeys).toEqual([branchesRoute, `GET /v2/projects/${BRANCH_REF}/notebooks`]); + expect(http.routeKeys).toEqual([ + branchesRoute, + `GET /v2/projects/${BRANCH_PROJECT_REF}/notebooks`, + ]); assertPermissionDrift({ command: `notebooks ${command}`, activeFlags: { remote: "feature-x" }, diff --git a/apps/cli/tests/helpers/remote-selection.ts b/apps/cli/tests/helpers/remote-selection.ts index 5173b58841..8fb422f281 100644 --- a/apps/cli/tests/helpers/remote-selection.ts +++ b/apps/cli/tests/helpers/remote-selection.ts @@ -6,10 +6,8 @@ import type { mockOutput } from "./mocks.ts"; export const BRANCH_PROJECT_REF = "branchprojectrefabcd"; -/** A branch of the project {@link VALID_REF} whose own project is {@link BRANCH_PROJECT_REF}. */ -export const branchRow = (name: string) => ({ +const BRANCH_ROW_DEFAULTS = { id: "11111111-2222-4333-8444-555555555555", - name, project_ref: BRANCH_PROJECT_REF, parent_project_ref: VALID_REF, is_default: false, @@ -18,7 +16,15 @@ export const branchRow = (name: string) => ({ created_at: "2026-05-27T01:02:03Z", updated_at: "2026-05-27T01:02:04Z", with_data: true, -}); +}; + +type BranchRow = typeof BRANCH_ROW_DEFAULTS & { readonly name: string }; + +/** A branch of the project {@link VALID_REF} whose own project is {@link BRANCH_PROJECT_REF}. */ +export const branchRow = ( + name: string, + overrides: Partial<typeof BRANCH_ROW_DEFAULTS> = {}, +): BranchRow => ({ ...BRANCH_ROW_DEFAULTS, name, ...overrides }); /** A platform API that only answers the linked project's branch list, with one branch `name`. */ export const branchListApi = (name = "feature-x") => From 02134f0ddf0ba7a88870d99879efee8e3806cf8e Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Wed, 21 Oct 2026 11:02:18 +0100 Subject: [PATCH 275/291] fix(cli): treat loopback aliases as the local database host --- .../db-config.integration.test.ts | 39 +++++++++++++++++++ .../src/command-internal/db-config.layer.ts | 15 ++++--- 2 files changed, 46 insertions(+), 8 deletions(-) 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 32509a1b62..a12a48eb65 100644 --- a/apps/cli/src/command-internal/db-config.integration.test.ts +++ b/apps/cli/src/command-internal/db-config.integration.test.ts @@ -403,6 +403,45 @@ describe("dbConfigResolver (local + db-url)", () => { ), ); + it.effect.each([ + "postgres://postgres:postgres@localhost:54322/postgres", + "postgres://postgres:postgres@[::1]:54322/postgres", + "postgres://postgres:postgres@127.0.0.2:54322/postgres", + ])("db-url mode: the loopback alias %s is local when the services host is 127.0.0.1", (url) => + Effect.gen(function* () { + const dir = yield* withWorkdir(); + return yield* resolveConnection(dir, inputFor(dbUrlTarget(url))); + }).pipe( + Effect.tap((r) => + Effect.sync(() => { + expect(r.isLocal).toBe(true); + }), + ), + Effect.scoped, + Effect.provide(BunServices.layer), + ), + ); + + it.effect( + "db-url mode: a loopback alias on a port the local database does not use stays remote", + () => + Effect.gen(function* () { + const dir = yield* withWorkdir(); + return yield* resolveConnection( + dir, + inputFor(dbUrlTarget("postgres://postgres:postgres@localhost:5432/postgres")), + ); + }).pipe( + Effect.tap((r) => + Effect.sync(() => { + expect(r.isLocal).toBe(false); + }), + ), + Effect.scoped, + Effect.provide(BunServices.layer), + ), + ); + it.effect("db-url mode: a multi-host url stays remote even when its primary is local", () => 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 82da9d71a6..eacf574e1d 100644 --- a/apps/cli/src/command-internal/db-config.layer.ts +++ b/apps/cli/src/command-internal/db-config.layer.ts @@ -200,7 +200,10 @@ const unbanErrorMapper = mapHttpError({ statusMessage: (status, body) => `unexpected remove bans status ${status}: ${body}`, }); -/** Compares against the resolved local services hostname, not a hard-coded loopback. */ +const isLoopbackHost = (host: string): boolean => + host === "localhost" || host === "::1" || (net.isIPv4(host) && host.startsWith("127.")); + +/** Loopback spellings count as the resolved local services hostname; the port must match too. */ function isLocalDatabase( host: string, localHost: string, @@ -208,17 +211,13 @@ function isLocalDatabase( dbPort: number, shadowPort: number, ): boolean { - return host === localHost && (port === dbPort || port === shadowPort); + const sameHost = host === localHost || (isLoopbackHost(host) && isLoopbackHost(localHost)); + return sameHost && (port === dbPort || port === shadowPort); } /** Stack databases listen on loopback, so only loopback or services-hostname URLs can match one. */ function mayBeStackDatabaseHost(host: string, localHost: string): boolean { - return ( - host === localHost || - host === "localhost" || - host === "::1" || - (net.isIPv4(host) && host.startsWith("127.")) - ); + return host === localHost || isLoopbackHost(host); } /** Best-effort TCP reachability probe with a 5s timeout. */ From 0aca206d8389181ddb4efcc12aa4c097acd10da5 Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Wed, 21 Oct 2026 11:04:20 +0100 Subject: [PATCH 276/291] fix(cli): reject db query --file together with positional SQL --- .../cli/src/commands/db/query/SIDE_EFFECTS.md | 7 +++++- .../cli/src/commands/db/query/query.errors.ts | 12 +++++++++ .../src/commands/db/query/query.handler.ts | 11 +++++++- .../db/query/query.integration.test.ts | 25 +++++++++++++++++++ .../telemetry/__fixtures__/error-tags.txt | 1 + 5 files changed, 54 insertions(+), 2 deletions(-) diff --git a/apps/cli/src/commands/db/query/SIDE_EFFECTS.md b/apps/cli/src/commands/db/query/SIDE_EFFECTS.md index 1f0a90440c..c125f636f6 100644 --- a/apps/cli/src/commands/db/query/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/db/query/SIDE_EFFECTS.md @@ -15,7 +15,7 @@ stack never prompts. | Path | Format | When | | ------------------------------------ | ---------- | --------------------------------------------------------------------------------------------------------------------------------------------- | -| `<path>` (from `--file`) | SQL | when `--file` / `-f` is set (takes precedence over arg/stdin) | +| `<path>` (from `--file`) | SQL | when `--file` / `-f` is set (rejected together with positional SQL) | | stdin | SQL | when piped (not a TTY) and no `--file`/positional SQL | | `supabase/config.toml` | TOML | local / `--remote=<url>` connection resolution | | `~/.supabase/access-token` | plain text | a remote project or branch when `SUPABASE_ACCESS_TOKEN` unset | @@ -51,6 +51,7 @@ stack never prompts. | Code | Condition | | ---- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `0` | success | +| `1` | `--file` together with positional SQL (`DbQuerySqlSourceConflictError`: `Pass SQL as an argument or with --file, not both.`); no API call and no `Target:` line | | `1` | no SQL provided; empty stdin; unreadable `--file`; remote project or branch without login; query exec failure; non-201 remote status | | `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 | @@ -95,6 +96,10 @@ 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). +- **One SQL source.** `--file` and positional SQL are mutually exclusive and fail before the target + resolves, so `db query -f x.sql --remote feature` (where `feature` is read as the SQL) fails with + the suggestion `To select a branch or project with --remote, write --remote=<value>.` instead of + running against the linked project. - **Hosted consent.** A remote project, branch or remote `--remote=<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. Reading a missing SQL source (no SQL, empty stdin, unreadable diff --git a/apps/cli/src/commands/db/query/query.errors.ts b/apps/cli/src/commands/db/query/query.errors.ts index 5114e620cb..7739e5b813 100644 --- a/apps/cli/src/commands/db/query/query.errors.ts +++ b/apps/cli/src/commands/db/query/query.errors.ts @@ -19,6 +19,18 @@ export class DbQueryNoSqlError extends Data.TaggedError("DbQueryNoSqlError")<{ } } +/** `--file` was given together with positional SQL, so one of them would be silently ignored. */ +export class DbQuerySqlSourceConflictError extends Data.TaggedError( + "DbQuerySqlSourceConflictError", +)<{ + readonly message: string; + readonly suggestion: string; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.invalidInput; + } +} + /** Stdin was piped but empty; message text (`"no SQL provided via stdin"`) is an established output contract. */ export class DbQueryNoStdinSqlError extends Data.TaggedError("DbQueryNoStdinSqlError")<{ 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 e0951cb791..6a38ebaebf 100644 --- a/apps/cli/src/commands/db/query/query.handler.ts +++ b/apps/cli/src/commands/db/query/query.handler.ts @@ -39,6 +39,7 @@ import { DbQueryNoStdinSqlError, DbQueryReadFileError, DbQueryReadOnlyStatementError, + DbQuerySqlSourceConflictError, DbQueryUnexpectedStatusError, } from "./query.errors.ts"; import { @@ -268,6 +269,14 @@ export const dbQuery = Effect.fn("db.query")(function* (flags: DbQueryFlags) { }); yield* Effect.gen(function* () { + // Checked before the target resolves: `--file` would silently override the positional SQL, + // which is how `-f x.sql --remote <branch>` would otherwise target the linked project. + if (Option.isSome(flags.file) && Option.isSome(flags.sql)) { + return yield* new DbQuerySqlSourceConflictError({ + message: "Pass SQL as an argument or with --file, not both.", + suggestion: "To select a branch or project with --remote, write --remote=<value>.", + }); + } const target = yield* resolveTarget(flags); // Recorded now so the cache finalizer still fires if a later step fails. if (target.kind === "linked") yield* Ref.set(linkedRefForCache, target.ref); @@ -298,7 +307,7 @@ export const dbQuery = Effect.fn("db.query")(function* (flags: DbQueryFlags) { return { kind: "direct", connection } satisfies QueryRoute; }); - // 1. Resolve SQL: --file > positional arg > piped stdin. + // 1. Resolve SQL: --file or the positional arg (never both), else piped stdin. const sql = yield* Effect.gen(function* () { if (Option.isSome(flags.file)) { // A relative `--file` path resolves against the workdir, not the 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 3984b6e96e..9d9cad9842 100644 --- a/apps/cli/src/commands/db/query/query.integration.test.ts +++ b/apps/cli/src/commands/db/query/query.integration.test.ts @@ -523,6 +523,31 @@ describe("db query integration", () => { }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), ); + it.effect( + "rejects --file together with a word after a bare --remote before any request or banner", + () => { + const { layer, out, telemetry, platformApi, resolvedTargets } = setup({ + result: SELECT_RESULT, + }); + return Effect.gen(function* () { + const exit = yield* dbQuery( + flags({ + sql: Option.some("feature"), + file: Option.some("x.sql"), + remote: REMOTE_LINKED, + }), + ).pipe(Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + expect(failMessage(exit)).toBe("Pass SQL as an argument or with --file, not both."); + expect(platformApi.requests).toEqual([]); + expect(resolvedTargets).toEqual([]); + expect(out.stderrText).not.toContain("Target:"); + expect(out.stdoutText).toBe(""); + expect(telemetry.flushed).toBe(true); + }).pipe(Effect.provide(layer)); + }, + ); + it.effect("errors when --file cannot be read", () => { const { layer } = setup(); 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 2e176b92c2..6a48392381 100644 --- a/apps/cli/src/shared/telemetry/__fixtures__/error-tags.txt +++ b/apps/cli/src/shared/telemetry/__fixtures__/error-tags.txt @@ -170,6 +170,7 @@ DbQueryNoSqlError DbQueryNoStdinSqlError DbQueryReadFileError DbQueryReadOnlyStatementError +DbQuerySqlSourceConflictError DbQueryUnexpectedStatusError DbResetApplyError DbResetInvalidVersionError From 57c4bcadc608d4b879aa978b9020cfa4473864cb Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Wed, 21 Oct 2026 11:04:24 +0100 Subject: [PATCH 277/291] refactor(cli): cover target deprecation helpers through their callers and drop overlapping cases --- .../target/remote-resolution.ts | 2 +- .../target/remote-resolution.unit.test.ts | 28 +++++------ .../target/remote-value.unit.test.ts | 9 ++-- .../target/target-deprecation.ts | 2 +- .../target/target-deprecation.unit.test.ts | 46 ++++++++++++------- .../target/target-flags.unit.test.ts | 29 +----------- .../target/target-selector.unit.test.ts | 20 ++------ 7 files changed, 59 insertions(+), 77 deletions(-) diff --git a/apps/cli/src/command-internal/target/remote-resolution.ts b/apps/cli/src/command-internal/target/remote-resolution.ts index a726fdafb2..28aac1fd77 100644 --- a/apps/cli/src/command-internal/target/remote-resolution.ts +++ b/apps/cli/src/command-internal/target/remote-resolution.ts @@ -47,7 +47,7 @@ export const warnTarget = Effect.fnUntraced(function* (message: string) { }); /** Prints one warning per deprecated selector flag, naming its `--remote` replacement. */ -export const warnTargetDeprecations = Effect.fnUntraced(function* ( +const warnTargetDeprecations = Effect.fnUntraced(function* ( deprecations: ReadonlyArray<TargetDeprecation>, ) { if (deprecations.length === 0) return; diff --git a/apps/cli/src/command-internal/target/remote-resolution.unit.test.ts b/apps/cli/src/command-internal/target/remote-resolution.unit.test.ts index 27544c1c25..524a7078b7 100644 --- a/apps/cli/src/command-internal/target/remote-resolution.unit.test.ts +++ b/apps/cli/src/command-internal/target/remote-resolution.unit.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "@effect/vitest"; -import { Effect, Layer, Sink, Stdio, Stream } from "effect"; +import { Effect, Layer, Option, Sink, Stdio, Stream } from "effect"; import { vi } from "vitest"; import { stripAnsi } from "../../../tests/helpers/ansi.ts"; @@ -12,8 +12,8 @@ import { } from "../../shared/output/output.layer.ts"; import { Output } from "../../shared/output/output.service.ts"; import type { Tty } from "../../shared/runtime/tty.service.ts"; -import { warnTargetDeprecations } from "./remote-resolution.ts"; -import type { TargetDeprecation } from "./target-selector.ts"; +import { selectTarget } from "./remote-resolution.ts"; +import type { TargetFlagValues } from "./target-flags.ts"; const clack = vi.hoisted(() => ({ log: { message: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn(), step: vi.fn() }, @@ -36,15 +36,17 @@ const sink = (chunks: Array<string>) => ); const PAYLOAD = "export type Database = {}\n"; -const linkedAlias: TargetDeprecation = { - kind: "remote-alias", - flags: ["--linked"], - remote: { _tag: "Linked" }, -}; +const flags = (overrides: Partial<TargetFlagValues>): TargetFlagValues => ({ + remote: Option.none(), + local: false, + linked: false, + projectRef: Option.none(), + ...overrides, +}); const runWith = ( outputLayer: Layer.Layer<Output, never, Stdio.Stdio | Tty>, - deprecations: ReadonlyArray<TargetDeprecation>, + selector: TargetFlagValues, ) => { const stdout: Array<string> = []; const stderr: Array<string> = []; @@ -58,7 +60,7 @@ const runWith = ( }), ); return Effect.gen(function* () { - yield* warnTargetDeprecations(deprecations); + yield* selectTarget(selector); const output = yield* Output; yield* output.raw(PAYLOAD); }).pipe( @@ -71,15 +73,15 @@ const runWith = ( ); }; -describe("warnTargetDeprecations", () => { +describe("selectTarget deprecation warnings", () => { it.effect.each([ { format: "text", layer: textOutputLayer }, { format: "json", layer: jsonOutputLayer }, { format: "stream-json", layer: streamJsonOutputLayer }, ])("leaves stdout to the payload in $format mode", ({ layer }) => Effect.gen(function* () { - const remoteRun = yield* runWith(layer, []); - const aliasRun = yield* runWith(layer, [linkedAlias]); + const remoteRun = yield* runWith(layer, flags({ remote: Option.some({ _tag: "Linked" }) })); + const aliasRun = yield* runWith(layer, flags({ linked: true })); expect(aliasRun.stdout).toBe(PAYLOAD); expect(aliasRun.stdout).toBe(remoteRun.stdout); diff --git a/apps/cli/src/command-internal/target/remote-value.unit.test.ts b/apps/cli/src/command-internal/target/remote-value.unit.test.ts index ea5d0a26c2..64e50fc21f 100644 --- a/apps/cli/src/command-internal/target/remote-value.unit.test.ts +++ b/apps/cli/src/command-internal/target/remote-value.unit.test.ts @@ -45,9 +45,12 @@ describe("parseRemoteValue", () => { }); describe("parseNamedRemote", () => { - it("treats the words an off switch uses as branch names", () => { - expect(parseNamedRemote("false")).toEqual(Result.succeed({ _tag: "Branch", name: "false" })); - }); + it.each(["true", "false", "False", "YES", "off", "1", "0"])( + "treats the boolean literal %s as a branch name", + (name) => { + expect(parseNamedRemote(name)).toEqual(Result.succeed({ _tag: "Branch", name })); + }, + ); }); describe("couldNameBranch", () => { diff --git a/apps/cli/src/command-internal/target/target-deprecation.ts b/apps/cli/src/command-internal/target/target-deprecation.ts index 219bd5ae0b..33b1e1447a 100644 --- a/apps/cli/src/command-internal/target/target-deprecation.ts +++ b/apps/cli/src/command-internal/target/target-deprecation.ts @@ -41,7 +41,7 @@ const replacementOf = (value: RemoteValue): string => { }; /** The ref a Supabase connection string names: `db.<ref>.<host>` or a `postgres.<ref>` user. */ -export const refFromDbUrl = (url: string, projectHost: string): string | undefined => { +const refFromDbUrl = (url: string, projectHost: string): string | undefined => { try { const parsed = new URL(url); const host = parsed.hostname; diff --git a/apps/cli/src/command-internal/target/target-deprecation.unit.test.ts b/apps/cli/src/command-internal/target/target-deprecation.unit.test.ts index dd93d50894..10f7583426 100644 --- a/apps/cli/src/command-internal/target/target-deprecation.unit.test.ts +++ b/apps/cli/src/command-internal/target/target-deprecation.unit.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { deprecationMessages, refFromDbUrl, remoteFlagText } from "./target-deprecation.ts"; +import { deprecationMessages, remoteFlagText } from "./target-deprecation.ts"; import type { TargetDeprecation } from "./target-selector.ts"; const REF = "abcdefghijklmnopqrst"; @@ -20,21 +20,6 @@ describe("remoteFlagText", () => { }); }); -describe("refFromDbUrl", () => { - it.each([ - { url: `postgresql://postgres:pw@db.${REF}.supabase.co:5432/postgres`, expected: REF }, - { - url: `postgresql://postgres.${REF}:pw@aws-0-eu-west-1.pooler.supabase.com:6543/postgres`, - expected: REF, - }, - { url: "postgresql://postgres:pw@db.example.com:5432/postgres", expected: undefined }, - { url: "postgresql://postgres:pw@127.0.0.1:54322/postgres", expected: undefined }, - { url: "not a url", expected: undefined }, - ])("reads $expected from $url", ({ url, expected }) => { - expect(refFromDbUrl(url, HOST)).toBe(expected); - }); -}); - describe("deprecationMessages", () => { const messages = (deprecations: ReadonlyArray<TargetDeprecation>) => deprecationMessages(deprecations, HOST); @@ -93,6 +78,35 @@ describe("deprecationMessages", () => { expect(message).not.toContain("hunter2"); }); + it.each([ + { + name: "a pooler URL whose user names the project", + url: `postgresql://postgres.${REF}:pw@aws-0-eu-west-1.pooler.supabase.com:6543/postgres`, + alternative: `, or --remote=${REF} with an access token set`, + }, + { + name: "a database host outside the project domain", + url: "postgresql://postgres:pw@db.example.com:5432/postgres", + alternative: "", + }, + { + name: "a loopback address", + url: "postgresql://postgres:pw@127.0.0.1:54322/postgres", + alternative: "", + }, + { + name: "a URL that does not parse", + url: "postgresql://postgres:pw@host:notaport/postgres", + alternative: "", + }, + ])("reads the project ref out of $name only when it names one", ({ url, alternative }) => { + expect( + messages([{ kind: "remote-alias", flags: ["--db-url"], remote: { _tag: "DbUrl", url } }]), + ).toEqual([ + `--db-url is deprecated and will be removed in v4. Use --remote=<db-url> instead${alternative}`, + ]); + }); + it("asks for a postgresql:// URL when --db-url held a keyword connection string", () => { const [message] = messages([ { 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 3395292b4f..b0d6f00406 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 @@ -85,6 +85,7 @@ describe("targetFlags", () => { it.effect.each([ { args: ["--remote"], expected: { _tag: "Linked" } }, { args: ["--remote", "yes"], expected: { _tag: "Linked" } }, + { args: ["--remote", "y"], expected: { _tag: "Linked" } }, { args: ["--remote=on"], expected: { _tag: "Linked" } }, { args: ["--remote=1"], expected: { _tag: "Linked" } }, { args: [`--remote=${REF}`], expected: { _tag: "Ref", ref: REF } }, @@ -97,16 +98,6 @@ describe("targetFlags", () => { }), ); - it.effect.each([{ args: ["--remote", "y"] }, { args: ["--remote=y"] }, { args: ["--remote=Y"] }])( - "reads $args as the linked project, not a branch named y", - ({ args }) => - Effect.gen(function* () { - expect(yield* parseWith(withDbUrl, args)).toMatchObject({ - remote: Option.some({ _tag: "Linked" }), - }); - }), - ); - it.effect("keeps the deprecated aliases parseable", () => Effect.gen(function* () { expect(yield* parseWith(withDbUrl, ["--local"])).toMatchObject({ local: true }); @@ -169,10 +160,6 @@ describe("targetFlags", () => { value: `https://${REF}.supabase.co`, message: `--remote takes a postgres:// URL, a project ref or a branch name; to target that project, use --remote=${REF}.`, }, - { - value: `HTTPS://${REF}.supabase.co/dashboard`, - message: `--remote takes a postgres:// URL, a project ref or a branch name; to target that project, use --remote=${REF}.`, - }, { value: "https://example.com/db", message: "--remote takes a postgres:// URL, a project ref or a branch name.", @@ -211,20 +198,6 @@ describe("targetFlags", () => { }), ); - it.effect.each([ - { value: ` ${URL} `, expected: { _tag: "DbUrl", url: URL } }, - { value: "POSTGRESQL://u:pw@h/db", expected: { _tag: "DbUrl", url: "postgresql://u:pw@h/db" } }, - { value: "Postgres://h/db", expected: { _tag: "DbUrl", url: "postgres://h/db" } }, - { value: ` ${REF} `, expected: { _tag: "Ref", ref: REF } }, - { value: " staging ", expected: { _tag: "Branch", name: "staging" } }, - ])("trims and classifies the value %j", ({ value, expected }) => - Effect.gen(function* () { - expect(yield* parseWith(withDbUrl, [`--remote=${value}`])).toMatchObject({ - remote: Option.some(expected), - }); - }), - ); - it("omits --db-url for commands without a database connection", () => { const storage = targetFlags({ subject: "Storage API", dbUrl: false }); expect(Object.keys(storage).sort()).toEqual(["linked", "local", "projectRef", "remote"]); 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 69c465e025..d0a8a50c7e 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 @@ -181,7 +181,7 @@ describe("parseTargetSelector", () => { }); }); - it.each(["true", "False", "YES", "off", "1", "0"])( + it.each(["true", "false"])( "keeps looking up a branch named %s through --project-ref", (name) => { expect(success({ projectRef: Option.some(name) }).selection).toEqual({ @@ -213,6 +213,7 @@ describe("parseTargetSelector", () => { expect(failure({ ...remote({ _tag: "Linked" }), ...overrides })).toMatchObject({ _tag: "TargetSelectorConflictError", flags, + suggestion: "Use --remote or --remote=<ref|branch|url> on its own.", }); }); @@ -294,19 +295,6 @@ describe("parseTargetSelector", () => { }, ); - it("suggests --remote=<ref> when --project-id meets --project-ref", () => { - expect(failure({ projectRef: Option.some(REF), projectId: Option.some(REF) })).toMatchObject({ - message: "--project-id is a deprecated alias of --project-ref; pass only one of them.", - suggestion: "Use --remote=<ref> instead.", - }); - }); - - it("suggests the --remote form for a selector conflict", () => { - expect(failure({ ...remote({ _tag: "Linked" }), linked: true })).toMatchObject({ - suggestion: "Use --remote or --remote=<ref|branch|url> on its own.", - }); - }); - it("lists only the selectors a command without a database connection has", () => { const result = parseTargetSelector({ remote: Option.some({ _tag: "Linked" }), @@ -330,10 +318,12 @@ describe("parseTargetSelector", () => { ).toMatchObject({ suggestion: "Use --remote or --remote=<ref> on its own." }); }); - it("rejects --project-id with --project-ref", () => { + it("rejects --project-id with --project-ref and suggests --remote=<ref>", () => { expect(failure({ projectRef: Option.some(REF), projectId: Option.some(REF) })).toMatchObject({ _tag: "TargetSelectorConflictError", flags: ["--project-id", "--project-ref"], + message: "--project-id is a deprecated alias of --project-ref; pass only one of them.", + suggestion: "Use --remote=<ref> instead.", }); }); From 14a866deac88c9c8201652da2bc55453f26e9e69 Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Wed, 21 Oct 2026 11:05:12 +0100 Subject: [PATCH 278/291] docs(cli): describe the local default and --remote in db push and runtime layer comments --- apps/cli/src/command-internal/db-config.types.ts | 2 +- apps/cli/src/command-internal/db-push-core.ts | 2 +- apps/cli/src/command-internal/storage-runtime.layer.ts | 4 ++-- apps/cli/src/commands/db/dump/dump.layers.ts | 4 ++-- apps/cli/src/commands/db/push/push.handler.ts | 3 ++- apps/cli/src/commands/db/push/push.layers.ts | 4 ++-- 6 files changed, 10 insertions(+), 9 deletions(-) diff --git a/apps/cli/src/command-internal/db-config.types.ts b/apps/cli/src/command-internal/db-config.types.ts index aa044f5479..a634c6c132 100644 --- a/apps/cli/src/command-internal/db-config.types.ts +++ b/apps/cli/src/command-internal/db-config.types.ts @@ -11,7 +11,7 @@ export interface ResolvedDbConfig { readonly conn: PgConnInput; readonly isLocal: boolean; /** - * The resolved linked project ref (`--linked` path only; `None` for `--local` / `--db-url`). + * The resolved project ref (hosted targets only; `None` for a local or database URL target). * Lets the caller re-read config with the ref applied so a matching `[remotes.<ref>]` block can * override fields like `db.major_version` for the container image. */ diff --git a/apps/cli/src/command-internal/db-push-core.ts b/apps/cli/src/command-internal/db-push-core.ts index 9b626db193..b42c8104fe 100644 --- a/apps/cli/src/command-internal/db-push-core.ts +++ b/apps/cli/src/command-internal/db-push-core.ts @@ -58,7 +58,7 @@ const applyError = (message: string) => new DbPushApplyError({ message }); export interface DbPushCoreInput { /** Absolute project directory (never read from `CommandSettings.workdir`). */ readonly workdir: string; - /** Resolved project ref, or `""` for `--local` / `--db-url`. */ + /** Resolved project ref, or `""` for a local or database URL target. */ readonly projectRef: string; readonly conn: PgConnInput; readonly isLocal: boolean; diff --git a/apps/cli/src/command-internal/storage-runtime.layer.ts b/apps/cli/src/command-internal/storage-runtime.layer.ts index e18b86dbb8..46518fbfdf 100644 --- a/apps/cli/src/command-internal/storage-runtime.layer.ts +++ b/apps/cli/src/command-internal/storage-runtime.layer.ts @@ -22,7 +22,7 @@ import { StackApi, stackApiLayer } from "./stack-api.ts"; /** * Runtime layer for the commands that talk to the Storage gateway directly: * `seed buckets` and `storage ls/cp/mv/rm`. The Management API client is lazy, - * so the local path (no `--linked`) never resolves a token or requires login. + * so the local default (no `--remote`) never resolves a token or requires login. * * `HttpClient` is exposed at the top level because the Storage gateway needs * it directly, not through the typed Management API client. @@ -35,7 +35,7 @@ export function storageGatewayRuntimeLayer(subcommand: ReadonlyArray<string>) { Layer.provide(debugLoggerLayer), ); // Token resolution is deferred until `factory.make` is first called, on the - // `--linked` branch. + // hosted-target branch. const platformApiFactory = commandPlatformApiFactoryLayer.pipe( Layer.provide(credentials), Layer.provide(cliSettings), diff --git a/apps/cli/src/commands/db/dump/dump.layers.ts b/apps/cli/src/commands/db/dump/dump.layers.ts index 12f2e4771f..058c5d6e65 100644 --- a/apps/cli/src/commands/db/dump/dump.layers.ts +++ b/apps/cli/src/commands/db/dump/dump.layers.ts @@ -20,7 +20,7 @@ import { bundledPostgresClientLayer } from "../../../command-internal/bundled-po * Runtime layer for `supabase db dump`. * * Mirrors `test db`'s composition: most of the Management API stack builds lazily - * inside the resolver's `--linked` branch. `ProjectRefResolver` is exposed here (like + * inside the resolver's hosted-target branch. `ProjectRefResolver` is exposed here (like * `db push`) so the handler can validate `--project-ref` before the linked-project-cache * finalizer sees it. */ @@ -32,7 +32,7 @@ const credentials = commandCredentialsLayer.pipe( ); // The lazy `commandPlatformApiFactoryLayer` (not the eager management-API runtime) -// keeps dump's auth-free `--linked --password` path from resolving an access token +// keeps dump's auth-free `--remote --password` path from resolving an access token // at layer-build time, same as `db push`. const platformApiFactory = commandPlatformApiFactoryLayer.pipe( Layer.provide(credentials), diff --git a/apps/cli/src/commands/db/push/push.handler.ts b/apps/cli/src/commands/db/push/push.handler.ts index b7d2d4eb02..a854682a7c 100644 --- a/apps/cli/src/commands/db/push/push.handler.ts +++ b/apps/cli/src/commands/db/push/push.handler.ts @@ -17,7 +17,8 @@ import type { DbPushFlags } from "./push.command.ts"; /** * `supabase db push` — apply pending local migrations (and optionally seed data - * and custom roles) to the linked project, or to the database `--local` / `--db-url` selects. + * and custom roles) to the local database by default, or to the project, branch or database URL + * that `--remote` names (the linked project when bare). * * 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. diff --git a/apps/cli/src/commands/db/push/push.layers.ts b/apps/cli/src/commands/db/push/push.layers.ts index 793cad5d75..011ee6a6c8 100644 --- a/apps/cli/src/commands/db/push/push.layers.ts +++ b/apps/cli/src/commands/db/push/push.layers.ts @@ -14,8 +14,8 @@ import { linkedProjectCacheLayer } from "../../../telemetry/linked-project-cache import { telemetryStateLayer } from "../../../telemetry/telemetry-state.layer.ts"; /** - * Runtime layer for `supabase db push`. Same shape as `db lint`: it spans local - * (`--local` / `--db-url`) and linked DB access, so it composes the Postgres + * Runtime layer for `supabase db push`. Same shape as `db lint`: it spans the local default + * and `--remote` DB access, so it composes the Postgres * connection, the db-config resolver, project-ref resolution, and the * linked-project cache. * From 5d5bcf5d0e43374b0d94056164be433c2a88384b Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Wed, 21 Oct 2026 11:06:18 +0100 Subject: [PATCH 279/291] fix(cli): never treat SUPABASE_YES as consent when a working-directory dotenv file defines it --- apps/cli/src/command-internal/global-flags.ts | 8 ++------ .../global-flags.unit.test.ts | 2 +- .../hosted-consent.unit.test.ts | 20 ++++++++++++------- .../hosted-consent/hosted-consent.yes.ts | 10 ++++------ ...t-selection-and-hosted-mutation-consent.md | 10 +++++----- 5 files changed, 25 insertions(+), 25 deletions(-) diff --git a/apps/cli/src/command-internal/global-flags.ts b/apps/cli/src/command-internal/global-flags.ts index 40d69551a2..6e2bd93659 100644 --- a/apps/cli/src/command-internal/global-flags.ts +++ b/apps/cli/src/command-internal/global-flags.ts @@ -204,11 +204,7 @@ const yesFlagFromArgs = (args: ReadonlyArray<string>): boolean | undefined => { return result; }; -/** - * `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 }; +export type YesSource = { readonly via: "flag" } | { readonly via: "env" }; /** The origin of a granted `--yes`, or `None`; an explicit `--yes=false` wins over the env. */ export const resolveYesSource = Effect.gen(function* () { @@ -221,7 +217,7 @@ export const resolveYesSource = Effect.gen(function* () { if (explicit ?? 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.some<YesSource>({ via: "env" }) : Option.none<YesSource>(); }); diff --git a/apps/cli/src/command-internal/global-flags.unit.test.ts b/apps/cli/src/command-internal/global-flags.unit.test.ts index 9ce75ab49d..390f3fa4cf 100644 --- a/apps/cli/src/command-internal/global-flags.unit.test.ts +++ b/apps/cli/src/command-internal/global-flags.unit.test.ts @@ -168,7 +168,7 @@ describe("resolveYesSource", () => { it.effect("falls back to a shell SUPABASE_YES when --yes never appears", () => Effect.gen(function* () { expect(yield* resolve([], { parsed: false, shellYes: "1" })).toEqual( - Option.some({ via: "env", raw: "1" }), + Option.some({ via: "env" }), ); expect(yield* resolve([], { parsed: false })).toEqual(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 index c64b2bdea1..6230a72a76 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 @@ -280,13 +280,19 @@ describe("requireHostedMutationConsent", () => { ), ); - 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.each([ + { name: "a different value", contents: "SUPABASE_YES=true\n" }, + { name: "an inline comment Bun strips", contents: "SUPABASE_YES=1#x\n" }, + { name: "an empty value", contents: "SUPABASE_YES=\n" }, + ])( + "does not grant a shell value when the working directory's .env sets it with $name", + ({ contents }) => + withDotenvDir({ ".env": contents }, (cwd) => + Effect.gen(function* () { + const exit = yield* consent({ shellYes: "1", cwd }).exit; + expect(failureOf(exit)).toBeInstanceOf(HostedMutationConsentRequiredError); + }), + ), ); it.effect("grants when the working directory's dotenv files do not mention it", () => 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 index a091987dce..7bcd6be76d 100644 --- a/apps/cli/src/command-internal/hosted-consent/hosted-consent.yes.ts +++ b/apps/cli/src/command-internal/hosted-consent/hosted-consent.yes.ts @@ -62,19 +62,17 @@ const autoloadedDefinitions = (host: Platform) => /** * 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. + * working-directory dotenv files into the environment and its parsing can differ from ours, so a + * working-directory dotenv file that defines `SUPABASE_YES` at all, whatever its value, makes the + * environment's value the file's and not 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, - ); + const fromFile = (yield* autoloadedDefinitions(host.value)).length > 0; return fromFile ? Option.none<HostedYesSource>() : Option.some<HostedYesSource>("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 2b08cdff9d..6782bc7604 100644 --- a/docs/adr/0033-target-selection-and-hosted-mutation-consent.md +++ b/docs/adr/0033-target-selection-and-hosted-mutation-consent.md @@ -176,11 +176,11 @@ hosted. 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. The - last `--yes` on the command line wins, and `SUPABASE_YES` is read only when `--yes` never appears: - `--yes=false --yes` confirms and `--yes --yes=false` declines. A bare `--yes` followed by `false`, - `no`, `off`, `0` or `n` reads as false, as the flag parser does. + directory into the environment, so when any of those files defines `SUPABASE_YES` at all, whatever + its value, the environment's value is treated as the file's and a shell export needs `--yes` as + well. The last `--yes` on the command line wins, and `SUPABASE_YES` is read only when `--yes` + never appears: `--yes=false --yes` confirms and `--yes --yes=false` declines. A bare `--yes` + followed by `false`, `no`, `off`, `0` or `n` reads as false, as the flag parser does. - **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`, agents (`--agent`, or an AI tool detected from From d644f7f2335a4c273ab0c8c5d928737b5caa8940 Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Wed, 21 Oct 2026 11:06:31 +0100 Subject: [PATCH 280/291] test(cli-e2e): share one request-log helper and keep only the binary-bound --remote cases --- .../interactions.json | 27 ----- .../interactions.json | 106 ------------------ .../interactions.json | 1 - .../interactions.json | 69 ------------ apps/cli-e2e/src/tests/request-log.ts | 12 ++ apps/cli-e2e/src/tests/stack.e2e.test.ts | 5 +- apps/cli-e2e/src/tests/storage.e2e.test.ts | 13 +-- .../src/tests/target-selection.e2e.test.ts | 73 +----------- 8 files changed, 16 insertions(+), 290 deletions(-) delete mode 100644 apps/cli-e2e/fixtures/scenarios/deprecated-target-aliases-warns-that-project-id-is-deprecated-on-gen-types-and-still-targets-that-project/interactions.json delete mode 100644 apps/cli-e2e/fixtures/scenarios/remote-target-selection-resolves-remote-branch-on-a-hosted-command-through-the-linked-project-s-branches/interactions.json delete mode 100644 apps/cli-e2e/fixtures/scenarios/remote-target-selection-runs-a-bare-db-push-locally-in-a-linked-checkout-and-hints-at-remote/interactions.json delete mode 100644 apps/cli-e2e/fixtures/scenarios/remote-target-selection-targets-the-project-named-by-remote-ref/interactions.json create mode 100644 apps/cli-e2e/src/tests/request-log.ts diff --git a/apps/cli-e2e/fixtures/scenarios/deprecated-target-aliases-warns-that-project-id-is-deprecated-on-gen-types-and-still-targets-that-project/interactions.json b/apps/cli-e2e/fixtures/scenarios/deprecated-target-aliases-warns-that-project-id-is-deprecated-on-gen-types-and-still-targets-that-project/interactions.json deleted file mode 100644 index 513d2118ed..0000000000 --- a/apps/cli-e2e/fixtures/scenarios/deprecated-target-aliases-warns-that-project-id-is-deprecated-on-gen-types-and-still-targets-that-project/interactions.json +++ /dev/null @@ -1,27 +0,0 @@ -[ - { - "request": { - "method": "GET", - "path": "/v1/projects/replayfixtureprojref/types/typescript", - "query": { - "included_schemas": "public" - }, - "headers": { - "accept-encoding": "gzip", - "authorization": "Bearer __ACCESS_TOKEN__", - "host": "localhost:__PORT__", - "user-agent": "SupabaseCLI/<VERSION>" - }, - "body": null - }, - "response": { - "status": 200, - "headers": { - "content-type": "application/json; charset=utf-8" - }, - "body": { - "types": "export type Json =\n | string\n | number\n | boolean\n | null\n | { [key: string]: Json | undefined }\n | Json[]\n\nexport type Database = {\n public: {\n Tables: {\n [_ in never]: never\n }\n Views: {\n [_ in never]: never\n }\n Functions: {\n [_ in never]: never\n }\n Enums: {\n [_ in never]: never\n }\n CompositeTypes: {\n [_ in never]: never\n }\n }\n}\n" - } - } - } -] diff --git a/apps/cli-e2e/fixtures/scenarios/remote-target-selection-resolves-remote-branch-on-a-hosted-command-through-the-linked-project-s-branches/interactions.json b/apps/cli-e2e/fixtures/scenarios/remote-target-selection-resolves-remote-branch-on-a-hosted-command-through-the-linked-project-s-branches/interactions.json deleted file mode 100644 index c5e702b158..0000000000 --- a/apps/cli-e2e/fixtures/scenarios/remote-target-selection-resolves-remote-branch-on-a-hosted-command-through-the-linked-project-s-branches/interactions.json +++ /dev/null @@ -1,106 +0,0 @@ -[ - { - "request": { - "method": "GET", - "path": "/v1/projects/replayfixtureprojref/branches", - "query": {}, - "headers": { - "accept-encoding": "gzip", - "authorization": "Bearer __ACCESS_TOKEN__", - "host": "localhost:__PORT__", - "user-agent": "SupabaseCLI/" - }, - "body": null - }, - "response": { - "status": 200, - "headers": { - "content-type": "application/json; charset=utf-8", - "x-gotrue-id": "__UUID__", - "x-ratelimit-limit": "120", - "x-ratelimit-remaining": "119", - "x-ratelimit-reset": "60" - }, - "body": [ - { - "id": "00000000-0000-0000-0000-000000000000", - "name": "my-branch", - "project_ref": "bbbbbbbbbbbbbbbbbbbb", - "parent_project_ref": "replayfixtureprojref", - "is_default": false, - "git_branch": "main", - "persistent": false, - "status": "MIGRATIONS_PASSED", - "created_at": "2000-01-01T00:00:00Z", - "updated_at": "2000-01-01T00:00:00Z", - "with_data": false - } - ] - } - }, - { - "request": { - "method": "GET", - "path": "/v1/projects/replayfixtureprojref/secrets", - "query": {}, - "headers": { - "accept-encoding": "gzip", - "authorization": "Bearer __ACCESS_TOKEN__", - "host": "localhost:__PORT__", - "user-agent": "SupabaseCLI/" - }, - "body": null - }, - "response": { - "status": 200, - "headers": { - "content-type": "application/json; charset=utf-8", - "x-gotrue-id": "__UUID__", - "x-ratelimit-limit": "120", - "x-ratelimit-remaining": "119", - "x-ratelimit-reset": "60" - }, - "body": [] - } - }, - { - "request": { - "method": "GET", - "path": "/v1/projects/replayfixtureprojref", - "query": {}, - "headers": { - "accept-encoding": "gzip", - "authorization": "Bearer __ACCESS_TOKEN__", - "host": "localhost:__PORT__", - "user-agent": "SupabaseCLI/" - }, - "body": null - }, - "response": { - "status": 200, - "headers": { - "content-type": "application/json; charset=utf-8", - "x-gotrue-id": "__UUID__", - "x-ratelimit-limit": "120", - "x-ratelimit-remaining": "119", - "x-ratelimit-reset": "60" - }, - "body": { - "id": "replayfixtureprojref", - "ref": "replayfixtureprojref", - "organization_id": "replayfixtureprojref", - "organization_slug": "replayfixtureprojref", - "name": "cli-e2e-test", - "region": "us-east-1", - "status": "ACTIVE_HEALTHY", - "database": { - "host": "db.replayfixtureprojref.supabase.red", - "version": "17.6.1.111", - "postgres_engine": "17", - "release_channel": "ga" - }, - "created_at": "2000-01-01T00:00:00Z" - } - } - } -] diff --git a/apps/cli-e2e/fixtures/scenarios/remote-target-selection-runs-a-bare-db-push-locally-in-a-linked-checkout-and-hints-at-remote/interactions.json b/apps/cli-e2e/fixtures/scenarios/remote-target-selection-runs-a-bare-db-push-locally-in-a-linked-checkout-and-hints-at-remote/interactions.json deleted file mode 100644 index fe51488c70..0000000000 --- a/apps/cli-e2e/fixtures/scenarios/remote-target-selection-runs-a-bare-db-push-locally-in-a-linked-checkout-and-hints-at-remote/interactions.json +++ /dev/null @@ -1 +0,0 @@ -[] diff --git a/apps/cli-e2e/fixtures/scenarios/remote-target-selection-targets-the-project-named-by-remote-ref/interactions.json b/apps/cli-e2e/fixtures/scenarios/remote-target-selection-targets-the-project-named-by-remote-ref/interactions.json deleted file mode 100644 index 0790d147ea..0000000000 --- a/apps/cli-e2e/fixtures/scenarios/remote-target-selection-targets-the-project-named-by-remote-ref/interactions.json +++ /dev/null @@ -1,69 +0,0 @@ -[ - { - "request": { - "method": "GET", - "path": "/v1/projects/replayfixtureprojref/advisors/security", - "query": {}, - "headers": { - "accept-encoding": "gzip", - "authorization": "Bearer __ACCESS_TOKEN__", - "host": "localhost:__PORT__", - "user-agent": "SupabaseCLI/" - }, - "body": null - }, - "response": { - "status": 200, - "headers": { - "content-type": "application/json; charset=utf-8", - "x-gotrue-id": "__UUID__", - "x-ratelimit-limit": "120", - "x-ratelimit-remaining": "119", - "x-ratelimit-reset": "60" - }, - "body": { - "lints": [] - } - } - }, - { - "request": { - "method": "GET", - "path": "/v1/projects/replayfixtureprojref", - "query": {}, - "headers": { - "accept-encoding": "gzip", - "authorization": "Bearer __ACCESS_TOKEN__", - "host": "localhost:__PORT__", - "user-agent": "SupabaseCLI/" - }, - "body": null - }, - "response": { - "status": 200, - "headers": { - "content-type": "application/json; charset=utf-8", - "x-gotrue-id": "__UUID__", - "x-ratelimit-limit": "120", - "x-ratelimit-remaining": "116", - "x-ratelimit-reset": "54" - }, - "body": { - "id": "replayfixtureprojref", - "ref": "replayfixtureprojref", - "organization_id": "replayfixtureprojref", - "organization_slug": "replayfixtureprojref", - "name": "cli-e2e-test", - "region": "us-east-1", - "status": "ACTIVE_HEALTHY", - "database": { - "host": "db.replayfixtureprojref.supabase.red", - "version": "17.6.1.111", - "postgres_engine": "17", - "release_channel": "ga" - }, - "created_at": "2000-01-01T00:00:00Z" - } - } - } -] diff --git a/apps/cli-e2e/src/tests/request-log.ts b/apps/cli-e2e/src/tests/request-log.ts new file mode 100644 index 0000000000..4b62c26abb --- /dev/null +++ b/apps/cli-e2e/src/tests/request-log.ts @@ -0,0 +1,12 @@ +export interface RequestEntry { + method: string; + pathname: string; + headers: Record<string, string>; + body: unknown; +} + +/** The requests the replay server received since the last test cleared its log. */ +export async function getRequestLog(apiUrl: string): Promise<RequestEntry[]> { + const res = await fetch(`${apiUrl}/_ctrl/requests`); + return res.json() as Promise<RequestEntry[]>; +} diff --git a/apps/cli-e2e/src/tests/stack.e2e.test.ts b/apps/cli-e2e/src/tests/stack.e2e.test.ts index 8993de60eb..d36ea2ae23 100644 --- a/apps/cli-e2e/src/tests/stack.e2e.test.ts +++ b/apps/cli-e2e/src/tests/stack.e2e.test.ts @@ -2,6 +2,7 @@ import { mkdirSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { describe, expect, inject, test } from "vitest"; import { createHarness, exec, type CLIResult } from "@supabase/cli-test-helpers"; +import { getRequestLog } from "./request-log.ts"; import { testBehaviour } from "./test-context.ts"; import { ACCESS_TOKEN } from "./env.ts"; @@ -122,9 +123,7 @@ describe("seed buckets", () => { ); const result = await run(["seed", "buckets"]); expect(result.exitCode).toBe(0); - const requests = await fetch(`${apiUrl}/_ctrl/requests`).then( - (r) => r.json() as Promise<Array<{ method: string; pathname: string }>>, - ); + const requests = await getRequestLog(apiUrl); expect(requests.some((r) => r.method === "POST" && r.pathname === "/storage/v1/bucket")).toBe( true, ); diff --git a/apps/cli-e2e/src/tests/storage.e2e.test.ts b/apps/cli-e2e/src/tests/storage.e2e.test.ts index e7f9a66769..21f1144fb8 100644 --- a/apps/cli-e2e/src/tests/storage.e2e.test.ts +++ b/apps/cli-e2e/src/tests/storage.e2e.test.ts @@ -1,6 +1,7 @@ import { mkdirSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { describe, expect } from "vitest"; +import { getRequestLog } from "./request-log.ts"; import { testBehaviour } from "./test-context.ts"; const BUCKET = "cli-e2e-bucket"; @@ -15,18 +16,6 @@ function setupStorageWorkspace(dir: string, relayUrl: string): void { writeFileSync(join(dir, "upload.txt"), "test upload content"); } -interface RequestEntry { - method: string; - pathname: string; - headers: Record<string, string>; - body: unknown; -} - -async function getRequestLog(apiUrl: string): Promise<RequestEntry[]> { - const res = await fetch(`${apiUrl}/_ctrl/requests`); - return res.json() as Promise<RequestEntry[]>; -} - async function injectGlobalError(apiUrl: string, status: number, message: string): Promise<void> { await fetch(`${apiUrl}/_ctrl/error-all`, { method: "POST", diff --git a/apps/cli-e2e/src/tests/target-selection.e2e.test.ts b/apps/cli-e2e/src/tests/target-selection.e2e.test.ts index b07d065676..183a5f691d 100644 --- a/apps/cli-e2e/src/tests/target-selection.e2e.test.ts +++ b/apps/cli-e2e/src/tests/target-selection.e2e.test.ts @@ -1,63 +1,11 @@ import { describe, expect } from "vitest"; import { linkProject } from "./link-project.ts"; +import { getRequestLog } from "./request-log.ts"; import { testBehaviour } from "./test-context.ts"; -const BRANCH_PROJECT_REF = "bbbbbbbbbbbbbbbbbbbb"; const DEAD_DB_URL = "postgresql://postgres:postgres@localhost:1/postgres"; -interface RequestEntry { - method: string; - pathname: string; -} - -async function getRequestLog(apiUrl: string): Promise<RequestEntry[]> { - const res = await fetch(`${apiUrl}/_ctrl/requests`); - return res.json() as Promise<RequestEntry[]>; -} - -describe("deprecated target aliases", () => { - testBehaviour( - "warns that --project-id is deprecated on gen types and still targets that project", - async ({ run, projectRef }) => { - const result = await run(["gen", "types", "--project-id", projectRef]); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("export type Database"); - expect(`${result.stdout}${result.stderr}`).toContain( - `--project-id is deprecated and will be removed in v4. Use --remote=<ref> instead`, - ); - }, - ); -}); - describe("remote target selection", () => { - testBehaviour( - "runs a bare db push locally in a linked checkout and hints at --remote", - async ({ run, projectRef, workspace, apiUrl }) => { - linkProject(workspace.path, projectRef); - const result = await run(["db", "push"]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain(`Target: local stack`); - expect(result.stderr).toContain(`default; pass --remote for ${projectRef}`); - expect(result.stderr).not.toContain("Target: project"); - const requests = await getRequestLog(apiUrl); - expect(requests.filter((r) => r.pathname.includes("/database/"))).toEqual([]); - }, - ); - - testBehaviour( - "targets the project named by --remote=<ref>", - async ({ run, projectRef, workspace, apiUrl }) => { - linkProject(workspace.path, projectRef); - const result = await run(["db", "advisors", "--type", "security", `--remote=${projectRef}`]); - expect(result.exitCode).toBe(0); - expect(result.stderr).toContain("No issues found"); - const requests = await getRequestLog(apiUrl); - expect(requests.map((r) => `${r.method} ${r.pathname}`)).toContain( - `GET /v1/projects/${projectRef}/advisors/security`, - ); - }, - ); - testBehaviour( "reads a space-separated --remote <ref> as --remote=<ref>", async ({ run, projectRef, workspace, apiUrl }) => { @@ -71,25 +19,6 @@ describe("remote target selection", () => { }, ); - testBehaviour( - "resolves --remote=<branch> on a hosted command through the linked project's branches", - async ({ run, projectRef, workspace, apiUrl }) => { - linkProject(workspace.path, projectRef); - const result = await run(["secrets", "list", "--remote=my-branch"]); - expect(result.exitCode).toBe(0); - expect(result.stderr).toContain( - `Target: project ${projectRef} · branch my-branch (${BRANCH_PROJECT_REF})`, - ); - const requests = await getRequestLog(apiUrl); - expect(requests.map((r) => `${r.method} ${r.pathname}`)).toEqual( - expect.arrayContaining([ - `GET /v1/projects/${projectRef}/branches`, - `GET /v1/projects/${BRANCH_PROJECT_REF}/secrets`, - ]), - ); - }, - ); - testBehaviour("rejects --remote=<url> on a hosted command", async ({ run, apiUrl }) => { const result = await run(["secrets", "list", `--remote=${DEAD_DB_URL}`]); expect(result.exitCode).not.toBe(0); From f58d71e12f11cfa52b244c74ee9819d7f68af433 Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Wed, 21 Oct 2026 11:07:53 +0100 Subject: [PATCH 281/291] refactor(cli): derive the --yes false words from the shared --remote vocabulary --- apps/cli/src/command-internal/global-flags.ts | 8 +++----- apps/cli/src/command-internal/target/remote-vocabulary.ts | 6 +++++- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/apps/cli/src/command-internal/global-flags.ts b/apps/cli/src/command-internal/global-flags.ts index 6e2bd93659..c0b59cc02d 100644 --- a/apps/cli/src/command-internal/global-flags.ts +++ b/apps/cli/src/command-internal/global-flags.ts @@ -12,6 +12,7 @@ import { supabaseEnvBool, supabaseEnvBoolWithProjectFallback, } from "./supabase-env.ts"; +import { OFF_WORDS } from "./target/remote-vocabulary.ts"; // `-o/--output` is tree-wide and can't vary per command, so it takes the union of every command's // values and each handler honors its subset. @@ -154,9 +155,6 @@ export const globalFlagValues = Effect.gen(function* () { const FALSE_VALUES = new Set(["0", "f", "F", "false", "FALSE", "False"]); -// The false spellings of the CLI's boolean flag parser, which also reads one after a bare flag. -const BOOLEAN_FALSE_VALUES = new Set(["false", "no", "off", "0", "n"]); - /** Argv before the first bare `--`; the rest is an operand (`db pull -- --experimental=false`). */ const argsBeforeOperandTerminator = (args: ReadonlyArray<string>): ReadonlyArray<string> => { const terminatorIndex = args.indexOf("--"); @@ -195,10 +193,10 @@ const yesFlagFromArgs = (args: ReadonlyArray<string>): boolean | undefined => { let result: boolean | undefined; for (const [index, arg] of tokens.entries()) { if (arg === "--yes") { - result = !BOOLEAN_FALSE_VALUES.has(tokens[index + 1] ?? ""); + result = !OFF_WORDS.has(tokens[index + 1] ?? ""); } else if (arg.startsWith("--yes=")) { const value = arg.slice("--yes=".length); - result = !(FALSE_VALUES.has(value) || BOOLEAN_FALSE_VALUES.has(value)); + result = !(FALSE_VALUES.has(value) || OFF_WORDS.has(value)); } } return result; diff --git a/apps/cli/src/command-internal/target/remote-vocabulary.ts b/apps/cli/src/command-internal/target/remote-vocabulary.ts index 2466a8daad..5c41bc7158 100644 --- a/apps/cli/src/command-internal/target/remote-vocabulary.ts +++ b/apps/cli/src/command-internal/target/remote-vocabulary.ts @@ -5,7 +5,11 @@ export const DB_URL_PATTERN = /^postgres(ql)?:\/\//i; /** The spellings of a bare `--remote`, in any case: the words the flag parser reads as true. */ export const LINKED_LITERAL = /^(true|yes|on|1|y)$/i; -export const OFF_LITERAL = /^(false|no|off|0|n)$/i; +/** The lowercase words the flag parser reads as false. */ +export const OFF_WORDS: ReadonlySet<string> = new Set(["false", "no", "off", "0", "n"]); + +/** {@link OFF_WORDS} in any case. */ +export const OFF_LITERAL = new RegExp(`^(${[...OFF_WORDS].join("|")})$`, "i"); export const isBooleanLiteral = (word: string): boolean => LINKED_LITERAL.test(word) || OFF_LITERAL.test(word); From 08fad0e95877feb27fbc892a02b3fbf5f802c872 Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Wed, 21 Oct 2026 11:08:11 +0100 Subject: [PATCH 282/291] docs(cli): reword the parent project ref JSDoc --- apps/cli/src/command-internal/parent-project-ref.ts | 2 +- tools/release/release-notes-prompt.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/cli/src/command-internal/parent-project-ref.ts b/apps/cli/src/command-internal/parent-project-ref.ts index b2b8fef4f2..3b4353a2af 100644 --- a/apps/cli/src/command-internal/parent-project-ref.ts +++ b/apps/cli/src/command-internal/parent-project-ref.ts @@ -86,7 +86,7 @@ function classifyParentCandidates( } /** - * The linked PARENT project ref, not the branch's own ref that `resolve` returns right after + * The linked parent project ref, not the branch's own ref that `resolve` returns right after * `link <branch>`: `SUPABASE_PROJECT_ID`, then the `linked-project.json` cache, then `project-ref`. */ export const resolveLinkedParentRef = Effect.fnUntraced(function* () { diff --git a/tools/release/release-notes-prompt.md b/tools/release/release-notes-prompt.md index 8d548107c4..5bcf2223cb 100644 --- a/tools/release/release-notes-prompt.md +++ b/tools/release/release-notes-prompt.md @@ -187,7 +187,7 @@ Omit empty sections. | Case | ❌ | ✅ | | --------------------------------------- | ------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------- | -| Feature `feat(db): --remote on db diff` | Added `--remote` flag (#4567) | **`db diff` against your linked project, no Docker** — pass `--remote` to diff remote without a local stack; handy in CI (#4567) | +| Feature `feat(db): --linked on db diff` | Added `--linked` flag (#4567) | **`db diff` against your linked project, no Docker** — pass `--linked` to diff remote without a local stack; handy in CI (#4567) | | Bug + issue | Fixed nil pointer in config parser (#5012) | `supabase start` no longer crashes when optional sections like `[db.pooler]` are missing (#5012) | | 3 PRs, one feature | Three `db lint --json` bullets | **`db lint` machine-readable output** — `--json` for CI; empty array when clean (#4801, #4815, #4823) | | Port only | New native `db diff` implementation | Under **TypeScript port progress** only — `db diff`; behavior unchanged (#5314) | From 261421f45d5dd45fa39cbfc18571971047399ffe Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Wed, 21 Oct 2026 11:13:03 +0100 Subject: [PATCH 283/291] fix(cli): suggest what db diff --from/--to accept when a branch value is not found --- .../target/branch-resolution.errors.ts | 3 + .../command-internal/target/closest-branch.ts | 17 +++++ .../target/closest-branch.unit.test.ts | 30 +++++++- .../target/unresolved-branch-suggestion.ts | 30 ++++++++ .../unresolved-branch-suggestion.unit.test.ts | 33 ++++++++ apps/cli/src/commands/db/diff/SIDE_EFFECTS.md | 7 +- .../cli/src/commands/db/diff/diff.explicit.ts | 12 +++ .../db/diff/diff.explicit.unit.test.ts | 24 ++++++ apps/cli/src/commands/db/diff/diff.handler.ts | 7 +- .../commands/db/diff/diff.integration.test.ts | 75 +++++++++++++++++++ 10 files changed, 235 insertions(+), 3 deletions(-) create mode 100644 apps/cli/src/command-internal/target/unresolved-branch-suggestion.ts create mode 100644 apps/cli/src/command-internal/target/unresolved-branch-suggestion.unit.test.ts diff --git a/apps/cli/src/command-internal/target/branch-resolution.errors.ts b/apps/cli/src/command-internal/target/branch-resolution.errors.ts index 181213c023..893b775840 100644 --- a/apps/cli/src/command-internal/target/branch-resolution.errors.ts +++ b/apps/cli/src/command-internal/target/branch-resolution.errors.ts @@ -9,6 +9,7 @@ import { /** A branch name was given but no linked parent project could be resolved to search for it. */ export class LinkBranchNotLinkedError extends Data.TaggedError("LinkBranchNotLinkedError")<{ readonly message: string; + readonly suggestion?: string; }> { get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { return actionability.projectNotLinked; @@ -18,6 +19,7 @@ export class LinkBranchNotLinkedError extends Data.TaggedError("LinkBranchNotLin /** A linked-parent candidate (env, cache or `project-ref` file) exists but none is ref-shaped. */ export class LinkParentRefInvalidError extends Data.TaggedError("LinkParentRefInvalidError")<{ readonly message: string; + readonly suggestion?: string; }> { get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { return actionability.relinkProject; @@ -27,6 +29,7 @@ export class LinkParentRefInvalidError extends Data.TaggedError("LinkParentRefIn /** No branch with the given name/UUID exists on the resolved parent project. */ export class LinkBranchNotFoundError extends Data.TaggedError("LinkBranchNotFoundError")<{ readonly message: string; + readonly suggestion?: string; }> { get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { return actionability.invalidInput; diff --git a/apps/cli/src/command-internal/target/closest-branch.ts b/apps/cli/src/command-internal/target/closest-branch.ts index 8e999c4858..80c595c140 100644 --- a/apps/cli/src/command-internal/target/closest-branch.ts +++ b/apps/cli/src/command-internal/target/closest-branch.ts @@ -72,3 +72,20 @@ export const nearMissByName = <T extends { readonly name: string }>( ) ); }; + +/** The word within `maxEdits` of `word`, ignoring case; the nearest wins, then the earlier one. */ +export const closestWord = ( + words: ReadonlyArray<string>, + word: string, + maxEdits: number, +): string | undefined => { + const target = word.trim().toLowerCase(); + let best: { readonly word: string; readonly distance: number } | undefined; + for (const candidate of words) { + const distance = editDistance(target, candidate.toLowerCase()); + if (distance <= maxEdits && (best === undefined || distance < best.distance)) { + best = { word: candidate, distance }; + } + } + return best?.word; +}; diff --git a/apps/cli/src/command-internal/target/closest-branch.unit.test.ts b/apps/cli/src/command-internal/target/closest-branch.unit.test.ts index aa7cb6cbfd..89c3c7602b 100644 --- a/apps/cli/src/command-internal/target/closest-branch.unit.test.ts +++ b/apps/cli/src/command-internal/target/closest-branch.unit.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { closestByName, nearMissByName } from "./closest-branch.ts"; +import { closestByName, closestWord, nearMissByName } from "./closest-branch.ts"; const branches = ["feature-x", "staging", "Main", "add_profiles", "db"].map((name) => ({ name })); @@ -76,3 +76,31 @@ describe("nearMissByName", () => { expect(nearMissByName([], "staging", () => false)).toBeUndefined(); }); }); + +describe("closestWord", () => { + const words = ["local", "migrations", "remote"]; + + it.each([ + { word: "lcoal", expected: "local" }, + { word: "LOCAL", expected: "local" }, + { word: "loc", expected: "local" }, + { word: "locall", expected: "local" }, + { word: "remove", expected: "remote" }, + { word: "migraitons", expected: "migrations" }, + { word: " remtoe ", expected: "remote" }, + ])("finds $expected for $word within two edits", ({ word, expected }) => { + expect(closestWord(words, word, 2)).toBe(expected); + }); + + it.each(["lo", "main", "feature-x", "migrate", ""])( + "finds nothing for %s, which is more than two edits from every word", + (word) => { + expect(closestWord(words, word, 2)).toBeUndefined(); + }, + ); + + it("takes the nearest word, then the earlier one", () => { + expect(closestWord(["abcd", "abce"], "abcf", 2)).toBe("abcd"); + expect(closestWord(["abxx", "abcx"], "abcd", 2)).toBe("abcx"); + }); +}); diff --git a/apps/cli/src/command-internal/target/unresolved-branch-suggestion.ts b/apps/cli/src/command-internal/target/unresolved-branch-suggestion.ts new file mode 100644 index 0000000000..67a37e2a7a --- /dev/null +++ b/apps/cli/src/command-internal/target/unresolved-branch-suggestion.ts @@ -0,0 +1,30 @@ +import { Effect } from "effect"; + +import { + LinkBranchNotFoundError, + LinkBranchNotLinkedError, + LinkParentRefInvalidError, +} from "./branch-resolution.errors.ts"; + +/** Adds `suggestion` to a branch lookup that found no such branch, or had no usable link to search. */ +export const withUnresolvedBranchSuggestion = + (suggestion: string) => + <A, E, R>( + self: Effect.Effect<A, E, R>, + ): Effect.Effect< + A, + E | LinkBranchNotFoundError | LinkBranchNotLinkedError | LinkParentRefInvalidError, + R + > => + Effect.mapError(self, (error) => { + if (error instanceof LinkBranchNotFoundError) { + return new LinkBranchNotFoundError({ message: error.message, suggestion }); + } + if (error instanceof LinkBranchNotLinkedError) { + return new LinkBranchNotLinkedError({ message: error.message, suggestion }); + } + if (error instanceof LinkParentRefInvalidError) { + return new LinkParentRefInvalidError({ message: error.message, suggestion }); + } + return error; + }); diff --git a/apps/cli/src/command-internal/target/unresolved-branch-suggestion.unit.test.ts b/apps/cli/src/command-internal/target/unresolved-branch-suggestion.unit.test.ts new file mode 100644 index 0000000000..8a5a74bcbc --- /dev/null +++ b/apps/cli/src/command-internal/target/unresolved-branch-suggestion.unit.test.ts @@ -0,0 +1,33 @@ +import { Effect } from "effect"; +import { describe, expect, it } from "vitest"; + +import { + LinkBranchListNetworkError, + LinkBranchNotFoundError, + LinkBranchNotLinkedError, + LinkParentRefInvalidError, +} from "./branch-resolution.errors.ts"; +import { withUnresolvedBranchSuggestion } from "./unresolved-branch-suggestion.ts"; + +const suggestion = "Try --from=local."; + +describe("withUnresolvedBranchSuggestion", () => { + it.each([ + new LinkBranchNotFoundError({ message: "not found" }), + new LinkBranchNotLinkedError({ message: "not linked" }), + new LinkParentRefInvalidError({ message: "bad link" }), + ])("adds the suggestion to $_tag and keeps its message", async (error) => { + const failure = await Effect.runPromise( + Effect.flip(Effect.fail(error).pipe(withUnresolvedBranchSuggestion(suggestion))), + ); + expect(failure).toMatchObject({ _tag: error._tag, message: error.message, suggestion }); + }); + + it("leaves other failures untouched", async () => { + const error = new LinkBranchListNetworkError({ message: "offline" }); + const failure = await Effect.runPromise( + Effect.flip(Effect.fail(error).pipe(withUnresolvedBranchSuggestion(suggestion))), + ); + expect(failure).toBe(error); + }); +}); diff --git a/apps/cli/src/commands/db/diff/SIDE_EFFECTS.md b/apps/cli/src/commands/db/diff/SIDE_EFFECTS.md index 4d4a7ce782..3b57277d7e 100644 --- a/apps/cli/src/commands/db/diff/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/db/diff/SIDE_EFFECTS.md @@ -255,7 +255,12 @@ a postgresql:// URL; convert a connection string to a postgresql:// URL and pass resolves, and the default `Target:` hint is not printed. The linked project resolves once, so `--from remote --to remote` announces it once, and a branch or ref used on both sides shares one connection. A branch endpoint looks the name up in the branch list of - `SUPABASE_PROJECT_ID`, else the linked project. `--use-pgadmin` honors the same selection + `SUPABASE_PROJECT_ID`, else the linked project. When no such branch exists, or no usable link + can be searched, the error's suggestion is + `--from accepts local, migrations, remote, a project ref, a branch of the linked project, or a postgresql:// URL.` + (`--to` for the other side), led by `Did you mean --from=local?` when the value is within two + edits of `local`, `migrations` or `remote`; a branch that does exist, `locale` included, still + resolves. `--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). diff --git a/apps/cli/src/commands/db/diff/diff.explicit.ts b/apps/cli/src/commands/db/diff/diff.explicit.ts index 6440d9eaaa..fecce77600 100644 --- a/apps/cli/src/commands/db/diff/diff.explicit.ts +++ b/apps/cli/src/commands/db/diff/diff.explicit.ts @@ -1,6 +1,7 @@ import { Result } from "effect"; import { DbConfigIpv6Error } from "../../../command-internal/db-config.errors.ts"; +import { closestWord } from "../../../command-internal/target/closest-branch.ts"; import { parseNamedRemote, type NamedRemoteRejection, @@ -53,6 +54,17 @@ export function classifyExplicitRef(ref: string): ExplicitRef { } } +const KEYWORDS = ["local", "migrations", "remote"]; + +const KEYWORD_TYPO_EDITS = 2; + +/** The suggestion for a `--from`/`--to` value read as a branch name that no branch matches. */ +export const unresolvedBranchSuggestion = (flag: "--from" | "--to", value: string): string => { + const accepted = `${flag} accepts local, migrations, remote, a project ref, a branch of the linked project, or a postgresql:// URL.`; + const keyword = closestWord(KEYWORDS, value, KEYWORD_TYPO_EDITS); + return keyword === undefined ? accepted : `Did you mean ${flag}=${keyword}? ${accepted}`; +}; + const replacementOf = (remote: RemoteValue): string => { switch (remote._tag) { case "Linked": diff --git a/apps/cli/src/commands/db/diff/diff.explicit.unit.test.ts b/apps/cli/src/commands/db/diff/diff.explicit.unit.test.ts index 45cb65cccf..f509201b0a 100644 --- a/apps/cli/src/commands/db/diff/diff.explicit.unit.test.ts +++ b/apps/cli/src/commands/db/diff/diff.explicit.unit.test.ts @@ -11,6 +11,7 @@ import { classifyExplicitRef, explicitDeprecationMessages, explicitSelectorError, + unresolvedBranchSuggestion, } from "./diff.explicit.ts"; const REF = "abcdefghijklmnopqrst"; @@ -300,3 +301,26 @@ describe("explicitSelectorError", () => { expect(`${rewritten.message} ${rewritten.suggestion}`).not.toContain("hunter2"); }); }); + +describe("unresolvedBranchSuggestion", () => { + const accepted = (flag: string) => + `${flag} accepts local, migrations, remote, a project ref, a branch of the linked project, or a postgresql:// URL.`; + + it.each([ + { flag: "--from", value: "lcoal", keyword: "local" }, + { flag: "--to", value: "migratoins", keyword: "migrations" }, + { flag: "--from", value: "remove", keyword: "remote" }, + { flag: "--to", value: "Locale", keyword: "local" }, + ] as const)("leads with $flag=$keyword for $value", ({ flag, value, keyword }) => { + expect(unresolvedBranchSuggestion(flag, value)).toBe( + `Did you mean ${flag}=${keyword}? ${accepted(flag)}`, + ); + }); + + it.each(["feature-x", "main", "staging", "lo"])( + "only lists what the flag accepts for %s", + (value) => { + expect(unresolvedBranchSuggestion("--to", value)).toBe(accepted("--to")); + }, + ); +}); diff --git a/apps/cli/src/commands/db/diff/diff.handler.ts b/apps/cli/src/commands/db/diff/diff.handler.ts index f2ee70b2fe..d070a1bf4d 100644 --- a/apps/cli/src/commands/db/diff/diff.handler.ts +++ b/apps/cli/src/commands/db/diff/diff.handler.ts @@ -36,6 +36,7 @@ import { renderLocalTarget, } from "../../../command-internal/target/target-identity.ts"; import { parseTargetSelector } from "../../../command-internal/target/target-selector.ts"; +import { withUnresolvedBranchSuggestion } from "../../../command-internal/target/unresolved-branch-suggestion.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"; @@ -94,6 +95,7 @@ import { explicitDeprecationMessages, explicitIpv6Error, explicitSelectorError, + unresolvedBranchSuggestion, } from "./diff.explicit.ts"; import { DbDiffDbNotRunningError, @@ -344,8 +346,11 @@ export const dbDiff = Effect.fn("db.diff")(function* (flags: DbDiffFlags) { } return yield* hostedEndpoint(projectRefTarget ?? { _tag: "Linked" }); case "Ref": - case "Branch": return yield* hostedEndpoint(explicit); + case "Branch": + return yield* hostedEndpoint(explicit).pipe( + withUnresolvedBranchSuggestion(unresolvedBranchSuggestion(flag, explicit.name)), + ); case "ForeignUrl": return yield* new DbDiffExplicitFlagsError({ message: `${EXPLICIT_REF_VALUES_MESSAGE(flag)}${ 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 905cf27f49..724dc80a55 100644 --- a/apps/cli/src/commands/db/diff/diff.integration.test.ts +++ b/apps/cli/src/commands/db/diff/diff.integration.test.ts @@ -1675,6 +1675,81 @@ describe("db diff", () => { }); }); + describe("a --from/--to value that reads as a branch name no branch matches", () => { + const accepted = (flag: string) => + `${flag} accepts local, migrations, remote, a project ref, a branch of the linked project, or a postgresql:// URL.`; + const failWith = ( + value: string, + side: "from" | "to", + opts: { readonly linked: boolean; readonly branch?: string }, + ) => { + const branchApi = branchListApi(opts.branch); + const s = setup(tmp.current, { + isLocal: false, + projectId: opts.linked ? Option.some(VALID_REF) : Option.none(), + diffSql: "create table e ();\n", + }); + const other = side === "from" ? "to" : "from"; + return dbDiff( + flags({ [side]: Option.some(value), [other]: Option.some("migrations") }), + ).pipe(Effect.provide(Layer.merge(s.layer, branchApi.layer)), Effect.flip); + }; + + it.effect.each([ + { side: "from", value: "lcoal", keyword: "local" }, + { side: "to", value: "lcoal", keyword: "local" }, + { side: "from", value: "migraitons", keyword: "migrations" }, + { side: "to", value: "remtoe", keyword: "remote" }, + { side: "from", value: "Locall", keyword: "local" }, + ] as const)( + "--$side $value suggests --$side=$keyword before listing what it accepts", + ({ side, value, keyword }) => + Effect.gen(function* () { + const error = yield* failWith(value, side, { linked: true }); + expect(error).toMatchObject({ + _tag: "LinkBranchNotFoundError", + suggestion: `Did you mean --${side}=${keyword}? ${accepted(`--${side}`)}`, + }); + }), + ); + + it.effect("lists what --from accepts when the value is not near a keyword", () => + Effect.gen(function* () { + const error = yield* failWith("feature-y", "from", { linked: true }); + expect(error).toMatchObject({ + _tag: "LinkBranchNotFoundError", + suggestion: accepted("--from"), + }); + }), + ); + + it.effect("adds the same suggestion when no project is linked to look the branch up in", () => + Effect.gen(function* () { + const error = yield* failWith("lcoal", "to", { linked: false }); + expect(error).toMatchObject({ + _tag: "LinkBranchNotLinkedError", + suggestion: `Did you mean --to=local? ${accepted("--to")}`, + }); + }), + ); + + it.effect("still resolves a real branch whose name is near a keyword", () => { + const s = setup(tmp.current, { + isLocal: false, + projectId: Option.some(VALID_REF), + diffSql: "create table e ();\n", + }); + return Effect.gen(function* () { + yield* dbDiff(flags({ from: Option.some("locale"), to: Option.some("migrations") })).pipe( + Effect.provide(Layer.merge(s.layer, branchListApi("locale").layer)), + ); + expect(s.resolverCalls.map((call) => call.target)).toEqual([ + expect.objectContaining({ kind: "linked", ref: BRANCH_PROJECT_REF, source: "flag" }), + ]); + }); + }); + }); + describe("when the project's database is unreachable over IPv6 and has no pooler URL", () => { const message = "IPv6 is not supported on your current network"; From 21050d6fef4bd8a01a753cc66f874de5ec518039 Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Wed, 21 Oct 2026 11:14:00 +0100 Subject: [PATCH 284/291] fix(cli): suggest --lang when gen types --remote names a language that is not a branch --- .../src/commands/gen/types/SIDE_EFFECTS.md | 4 +- .../src/commands/gen/types/types.handler.ts | 16 +++- .../gen/types/types.integration.test.ts | 77 +++++++++++++++++++ 3 files changed, 95 insertions(+), 2 deletions(-) diff --git a/apps/cli/src/commands/gen/types/SIDE_EFFECTS.md b/apps/cli/src/commands/gen/types/SIDE_EFFECTS.md index d9a9f3efc7..619a2600bc 100644 --- a/apps/cli/src/commands/gen/types/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/gen/types/SIDE_EFFECTS.md @@ -152,7 +152,9 @@ Not applicable. TypeScript for a remote project still comes from the Management API. - **`--remote <word>`.** A word after `--remote` is always read as `--remote=<word>`: the legacy positional language is hidden and takes no part in that reading, so a language goes before `--remote` - (`gen types typescript --remote`). + (`gen types typescript --remote`). A word that is a language name (`--remote typescript`) is still + a branch name; when the linked project has no such branch, or no project is linked, the lookup + error's suggestion is `To generate <language> types for the linked project, run supabase gen types --lang=<language> --remote.` - **Stopped local stack in a linked checkout.** A checkout is linked when `SUPABASE_PROJECT_ID` is set or `supabase/.temp/project-ref` names a project. On the legacy Docker Compose stack, a missing database container (`GenTypesLocalDbNotRunningError`, `supabase start is not running.`) or a diff --git a/apps/cli/src/commands/gen/types/types.handler.ts b/apps/cli/src/commands/gen/types/types.handler.ts index 13f9a9ed01..7a68dfad63 100644 --- a/apps/cli/src/commands/gen/types/types.handler.ts +++ b/apps/cli/src/commands/gen/types/types.handler.ts @@ -42,6 +42,7 @@ import { missingProjectConfigMessageEffect, } from "../../../command-internal/workdir-project.ts"; import { validateWorkdirIsDirectory } from "../../../command-internal/workdir-validation.ts"; +import { withUnresolvedBranchSuggestion } from "../../../command-internal/target/unresolved-branch-suggestion.ts"; import { LinkedProjectCache } from "../../../telemetry/linked-project-cache.service.ts"; import { TelemetryState } from "../../../telemetry/telemetry-state.service.ts"; import { @@ -52,6 +53,7 @@ import type { GenTypesFlags } from "./types.command.ts"; import { GEN_TYPES_LANGUAGE_FLAG_NAMES, GEN_TYPES_LANGUAGE_VALUE_FLAG_NAMES, + GEN_TYPES_LANGUAGES, languageOptionValues, } from "./types.languages.ts"; import { @@ -619,7 +621,19 @@ export const genTypes = Effect.fn("gen.types")(function* (flags: GenTypesFlags) } } - const target = yield* resolveTarget(flags); + const remoteBranch = Option.getOrUndefined(flags.remote); + const languageNamedBranch = + remoteBranch?._tag === "Branch" && GEN_TYPES_LANGUAGES.includes(remoteBranch.name) + ? remoteBranch.name + : undefined; + const resolution = resolveTarget(flags); + const target = yield* languageNamedBranch === undefined + ? resolution + : resolution.pipe( + withUnresolvedBranchSuggestion( + `To generate ${languageNamedBranch} types for the linked project, run supabase gen types --lang=${languageNamedBranch} --remote.`, + ), + ); yield* Effect.annotateCurrentSpan("typegen.lang", lang); 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 ea239d2022..adc5212606 100644 --- a/apps/cli/src/commands/gen/types/types.integration.test.ts +++ b/apps/cli/src/commands/gen/types/types.integration.test.ts @@ -1078,6 +1078,83 @@ describe("gen types", () => { }).pipe(Effect.provide(BunServices.layer)), ); + describe("--remote <language> read as a branch name", () => { + const languageSuggestion = (language: string) => + `To generate ${language} types for the linked project, run supabase gen types --lang=${language} --remote.`; + + it.effect.each(["typescript", "go"])( + "suggests --lang=%s --remote when the linked project has no such branch", + (language) => + Effect.gen(function* () { + const { layer, api } = yield* setup({ + args: ["gen", "types", "--remote", language], + projectId: Option.some(VALID_REF), + branches: [branchRow("feature-x")], + }); + const error = yield* genTypes( + defaultFlags({ remote: Option.some({ _tag: "Branch", name: language } as const) }), + ).pipe(Effect.provide(layer), Effect.flip); + + expect(error).toMatchObject({ + _tag: "LinkBranchNotFoundError", + suggestion: languageSuggestion(language), + }); + expect(api.requests).toEqual([ + { method: "listAllBranches", input: { ref: VALID_REF } }, + ]); + }).pipe(Effect.provide(BunServices.layer)), + ); + + it.effect("suggests it when no project is linked to look the branch up in", () => + Effect.gen(function* () { + const { layer } = yield* setup({ args: ["gen", "types", "--remote", "python"] }); + const error = yield* genTypes( + defaultFlags({ remote: Option.some({ _tag: "Branch", name: "python" } as const) }), + ).pipe(Effect.provide(layer), Effect.flip); + + expect(error).toMatchObject({ + _tag: "LinkBranchNotLinkedError", + suggestion: languageSuggestion("python"), + }); + }).pipe(Effect.provide(BunServices.layer)), + ); + + it.effect("adds no suggestion for a name that is not a language", () => + Effect.gen(function* () { + const { layer } = yield* setup({ + args: ["gen", "types", "--remote", "feature-y"], + projectId: Option.some(VALID_REF), + branches: [branchRow("feature-x")], + }); + const error = yield* genTypes( + defaultFlags({ remote: Option.some({ _tag: "Branch", name: "feature-y" } as const) }), + ).pipe(Effect.provide(layer), Effect.flip); + + expect(error).toMatchObject({ _tag: "LinkBranchNotFoundError" }); + expect(error).not.toHaveProperty("suggestion"); + }).pipe(Effect.provide(BunServices.layer)), + ); + + it.effect("still reads a real branch named after a language as that branch", () => + Effect.gen(function* () { + const { layer, api } = yield* setup({ + args: ["gen", "types", "--remote", "typescript"], + projectId: Option.some(VALID_REF), + branches: [branchRow("typescript")], + projectTypes: "ok", + }); + yield* genTypes( + defaultFlags({ remote: Option.some({ _tag: "Branch", name: "typescript" } as const) }), + ).pipe(Effect.provide(layer)); + + expect(api.requests).toContainEqual({ + method: "generateTypescriptTypes", + input: { ref: BRANCH_PROJECT_REF, included_schemas: "public" }, + }); + }).pipe(Effect.provide(BunServices.layer)), + ); + }); + describeDbTargetSelection("gen types", { dbUrl: true, workdir: () => tempRoot.current, From 161670c3bff87c103ed3fcc7fa1d3d65a119a911 Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Wed, 21 Oct 2026 11:21:23 +0100 Subject: [PATCH 285/291] test(cli): use an optional parameter for the failing ban endpoint --- apps/cli/src/command-internal/db-config.integration.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 a12a48eb65..6f75572408 100644 --- a/apps/cli/src/command-internal/db-config.integration.test.ts +++ b/apps/cli/src/command-internal/db-config.integration.test.ts @@ -2136,7 +2136,7 @@ describe("dbConfigResolver.resolveConnection", () => { const bannedRequests = ( requests: Array<{ readonly method: string; readonly path: string }>, bans: ReadonlyArray<string>, - failing: "retrieve" | "delete" | undefined = undefined, + failing?: "retrieve" | "delete", ) => stubManagementApi(requests, (method, path) => { if (method === "GET" && path === `/v1/projects/${ref}/config/database/pooler`) { From 8141db87995a6914418682322867af459fd0ac48 Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Wed, 21 Oct 2026 11:29:11 +0100 Subject: [PATCH 286/291] fix(cli): name an autoloaded dotenv SUPABASE_YES in the consent hint whatever its value --- .../hosted-consent/hosted-consent.unit.test.ts | 8 +++++++- .../hosted-consent/hosted-consent.yes.ts | 11 +++++------ 2 files changed, 12 insertions(+), 7 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 6230a72a76..2926bfe3c2 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 @@ -290,7 +290,13 @@ describe("requireHostedMutationConsent", () => { withDotenvDir({ ".env": contents }, (cwd) => Effect.gen(function* () { const exit = yield* consent({ shellYes: "1", cwd }).exit; - expect(failureOf(exit)).toBeInstanceOf(HostedMutationConsentRequiredError); + const error = failureOf(exit); + expect(error).toBeInstanceOf(HostedMutationConsentRequiredError); + expect(error).toMatchObject({ + suggestion: expect.stringContaining( + "SUPABASE_YES in .env does not confirm hosted changes", + ), + }); }), ), ); 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 index 7bcd6be76d..3bff9f513f 100644 --- a/apps/cli/src/command-internal/hosted-consent/hosted-consent.yes.ts +++ b/apps/cli/src/command-internal/hosted-consent/hosted-consent.yes.ts @@ -87,13 +87,12 @@ export const ignoredYesFiles = Effect.gen(function* () { }); const projectEnv = yield* envNameFrom("SUPABASE_ENV"); - const definitions = [ - ...(yield* autoloadedDefinitions(host.value)), + const projectDefinitions = [ ...(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)); + ].filter(({ value }) => Option.isNone(value) || parseEnvBool(value.value)); + const files = [...(yield* autoloadedDefinitions(host.value)), ...projectDefinitions].map( + ({ file }) => path.relative(cwd, file), + ); return [...new Set(files)]; }); From f4a3ea5be8513e0a6e54b292f910fd7c2f8fd0fa Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Wed, 21 Oct 2026 11:29:12 +0100 Subject: [PATCH 287/291] fix(cli): report the connection error when ban listing fails under --yes --- .../db-config.integration.test.ts | 80 ++++++++++--------- .../src/command-internal/db-config.layer.ts | 29 ++++--- ...t-selection-and-hosted-mutation-consent.md | 4 +- 3 files changed, 59 insertions(+), 54 deletions(-) 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 6f75572408..8cf3fb176a 100644 --- a/apps/cli/src/command-internal/db-config.integration.test.ts +++ b/apps/cli/src/command-internal/db-config.integration.test.ts @@ -2346,7 +2346,7 @@ describe("dbConfigResolver.resolveConnection", () => { }, ); - it.effect("reports the listing failure when listing keeps failing under --yes", () => { + it.effect("reports the connection error when listing keeps failing under --yes", () => { const requests: Array<{ readonly method: string; readonly path: string }> = []; const connection = failingConnect(Number.POSITIVE_INFINITY); return Effect.gen(function* () { @@ -2359,47 +2359,49 @@ describe("dbConfigResolver.resolveConnection", () => { ); yield* advance(8); const exit = yield* Fiber.await(fiber); - expect(failureText(exit)).toContain("DbConfigListBansStatusError"); + expect(failureText(exit)).toContain("DbConfigConnectTempRoleError"); + expect(failureText(exit)).not.toContain("--yes"); expect(requests.some((request) => request.method === "DELETE")).toBe(false); }).pipe(Effect.scoped, Effect.provide(BunServices.layer)); }); - it.effect( - "falls back to the connection error once listing recovers with no bans under --yes", - () => { - const requests: Array<{ readonly method: string; readonly path: string }> = []; - const connection = failingConnect(Number.POSITIVE_INFINITY); - let listCalls = 0; - return Effect.gen(function* () { - yield* 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`) { - listCalls += 1; - return listCalls === 1 - ? Response.json({ message: "listing broke" }, { status: 500 }) - : Response.json({ banned_ipv4_addresses: [] }); - } - return undefined; - }); - const { fiber, advance } = yield* forkResolve( - inputFor(linkedTarget(ref, { source: "flag", matchesLinkedRef: false }), { - networkBanRemoval: yield* approval, - }), - connection.layer, - ); - yield* advance(8); - const exit = yield* Fiber.await(fiber); - expect(failureText(exit)).toContain("DbConfigConnectTempRoleError"); - }).pipe(Effect.scoped, Effect.provide(BunServices.layer)); - }, - ); + it.effect("keeps a failed removal when a later listing fails under --yes", () => { + const requests: Array<{ readonly method: string; readonly path: string }> = []; + const connection = failingConnect(Number.POSITIVE_INFINITY); + let listCalls = 0; + return Effect.gen(function* () { + yield* 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`) { + listCalls += 1; + return listCalls === 1 + ? Response.json({ banned_ipv4_addresses: ["203.0.113.7"] }) + : Response.json({ message: "listing broke" }, { status: 500 }); + } + if (method === "DELETE" && path === `/v1/projects/${ref}/network-bans`) { + return Response.json({ message: "removal broke" }, { status: 500 }); + } + return undefined; + }); + const { fiber, advance } = yield* forkResolve( + inputFor(linkedTarget(ref, { source: "flag", matchesLinkedRef: false }), { + networkBanRemoval: yield* approval, + }), + connection.layer, + ); + yield* advance(8); + const exit = yield* Fiber.await(fiber); + expect(failureText(exit)).toContain("DbConfigUnbanStatusError"); + expect(listCalls).toBeGreaterThan(1); + }).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 eacf574e1d..82d8d5b7ff 100644 --- a/apps/cli/src/command-internal/db-config.layer.ts +++ b/apps/cli/src/command-internal/db-config.layer.ts @@ -284,8 +284,8 @@ interface BanState { const NO_BANS: BanState = { listed: [], failure: Option.none() }; // 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 and a failed listing or removal -// is what the run reports if the retries run out; without it the last listing is reported instead. +// for network bans on the project: with approval they are removed and a failed removal is what the +// run reports if the retries run out; without it the last listing is reported instead. const waitForTempRole = Effect.fn("DbConfig.waitForTempRole")(function* ( ref: string, conn: PgConnInput, @@ -323,8 +323,8 @@ const waitForTempRole = Effect.fn("DbConfig.waitForTempRole")(function* ( Duration.toMillis(BACKOFF_MAX), ); return Effect.gen(function* () { - // A ban-list/unban failure must not abort the retry loop: it is logged to --debug and, - // with approval, kept so the final error names it. + // A ban-list/unban failure must not abort the retry loop: it is logged to --debug and a + // failed removal is kept so the final error names it. const nextBans = n < 3 ? bans @@ -338,17 +338,20 @@ const waitForTempRole = Effect.fn("DbConfig.waitForTempRole")(function* ( CommandPlatformApiFactory | Output > => addrs.length > 0 && removeNetworkBans - ? removeBans(ref, addrs).pipe(Effect.as(NO_BANS)) + ? removeBans(ref, addrs).pipe( + Effect.as(NO_BANS), + Effect.catch((removeError) => + debug.debug(removeError.message).pipe( + Effect.as<BanState>({ + listed: addrs, + failure: Option.some(removeError), + }), + ), + ), + ) : Effect.succeed({ listed: addrs, failure: Option.none() }), ), - Effect.catch((banError) => - debug.debug(banError.message).pipe( - Effect.as<BanState>({ - listed: bans.listed, - failure: removeNetworkBans ? Option.some(banError) : Option.none(), - }), - ), - ), + Effect.catch((listError) => debug.debug(listError.message).pipe(Effect.as(bans))), ); yield* debug.debug(`Retry (${n}/${MAX_RETRIES}): ${cause.message}`); yield* Effect.sleep(Duration.millis(delayMs)); 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 6782bc7604..80241d452d 100644 --- a/docs/adr/0033-target-selection-and-hosted-mutation-consent.md +++ b/docs/adr/0033-target-selection-and-hosted-mutation-consent.md @@ -201,8 +201,8 @@ hosted. `HostedMutationConsent` token only the consent module constructs, and without `--yes` the command fails with `LinkedDbNetworkBannedError`. Its suggestion is to re-run with `--yes` or to run `supabase network-bans remove --remote=<ref>` with the ref of the project that failed, which need - not be the linked one. Under `--yes`, a failed ban listing or removal fails the command with - that error instead. This applies to reads too. + not be the linked one. Under `--yes`, a failed ban removal fails the command with that error + instead, and a failed listing leaves the connection error. 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 database URL target. It skips consent. From 07781ad3f0fbc398fc28722e1c5854f3dbe9a687 Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Wed, 21 Oct 2026 11:29:14 +0100 Subject: [PATCH 288/291] fix(cli): suggest --remote=<value> for db query --file only after a bare --remote --- .../cli/src/commands/db/query/SIDE_EFFECTS.md | 16 +++++++------- .../cli/src/commands/db/query/query.errors.ts | 2 +- .../src/commands/db/query/query.handler.ts | 5 ++++- .../db/query/query.integration.test.ts | 21 +++++++++++++++++++ 4 files changed, 34 insertions(+), 10 deletions(-) diff --git a/apps/cli/src/commands/db/query/SIDE_EFFECTS.md b/apps/cli/src/commands/db/query/SIDE_EFFECTS.md index c125f636f6..3708ad03b4 100644 --- a/apps/cli/src/commands/db/query/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/db/query/SIDE_EFFECTS.md @@ -48,14 +48,14 @@ stack never prompts. ## Exit Codes -| Code | Condition | -| ---- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `0` | success | -| `1` | `--file` together with positional SQL (`DbQuerySqlSourceConflictError`: `Pass SQL as an argument or with --file, not both.`); no API call and no `Target:` line | -| `1` | no SQL provided; empty stdin; unreadable `--file`; remote project or branch without login; query exec failure; non-201 remote status | -| `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 | +| Code | Condition | +| ---- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `0` | success | +| `1` | `--file` together with positional SQL (`DbQuerySqlSourceConflictError`: `Pass SQL as an argument or with --file, not both.`, plus the suggestion `To select a branch or project with --remote, write --remote=<value>.` only when `--remote` was passed bare); no API call and no `Target:` line | +| `1` | no SQL provided; empty stdin; unreadable `--file`; remote project or branch without login; query exec failure; non-201 remote status | +| `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 diff --git a/apps/cli/src/commands/db/query/query.errors.ts b/apps/cli/src/commands/db/query/query.errors.ts index 7739e5b813..1f870acf0e 100644 --- a/apps/cli/src/commands/db/query/query.errors.ts +++ b/apps/cli/src/commands/db/query/query.errors.ts @@ -24,7 +24,7 @@ export class DbQuerySqlSourceConflictError extends Data.TaggedError( "DbQuerySqlSourceConflictError", )<{ readonly message: string; - readonly suggestion: string; + readonly suggestion?: string; }> { get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { return actionability.invalidInput; diff --git a/apps/cli/src/commands/db/query/query.handler.ts b/apps/cli/src/commands/db/query/query.handler.ts index 6a38ebaebf..98ebc44eda 100644 --- a/apps/cli/src/commands/db/query/query.handler.ts +++ b/apps/cli/src/commands/db/query/query.handler.ts @@ -272,9 +272,12 @@ export const dbQuery = Effect.fn("db.query")(function* (flags: DbQueryFlags) { // Checked before the target resolves: `--file` would silently override the positional SQL, // which is how `-f x.sql --remote <branch>` would otherwise target the linked project. if (Option.isSome(flags.file) && Option.isSome(flags.sql)) { + const bareRemote = Option.exists(flags.remote, (remote) => remote._tag === "Linked"); return yield* new DbQuerySqlSourceConflictError({ message: "Pass SQL as an argument or with --file, not both.", - suggestion: "To select a branch or project with --remote, write --remote=<value>.", + ...(bareRemote + ? { suggestion: "To select a branch or project with --remote, write --remote=<value>." } + : {}), }); } const target = yield* resolveTarget(flags); 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 9d9cad9842..4bd6d02f65 100644 --- a/apps/cli/src/commands/db/query/query.integration.test.ts +++ b/apps/cli/src/commands/db/query/query.integration.test.ts @@ -96,6 +96,11 @@ 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; +const failSuggestion = ( + exit: Exit.Exit<unknown, { readonly suggestion?: string }>, +): string | undefined => + Exit.isFailure(exit) ? exit.cause.reasons.find(Cause.isFailReason)?.error.suggestion : undefined; + function mockResolver( isLocal: boolean | undefined, resolveFails = false, @@ -539,6 +544,9 @@ describe("db query integration", () => { ).pipe(Effect.exit); expect(Exit.isFailure(exit)).toBe(true); expect(failMessage(exit)).toBe("Pass SQL as an argument or with --file, not both."); + expect(failSuggestion(exit)).toBe( + "To select a branch or project with --remote, write --remote=<value>.", + ); expect(platformApi.requests).toEqual([]); expect(resolvedTargets).toEqual([]); expect(out.stderrText).not.toContain("Target:"); @@ -548,6 +556,19 @@ describe("db query integration", () => { }, ); + it.effect("rejects --file together with positional SQL without a --remote suggestion", () => { + const { layer, platformApi, resolvedTargets } = setup({ result: SELECT_RESULT }); + return Effect.gen(function* () { + const exit = yield* dbQuery( + flags({ sql: Option.some("select 1"), file: Option.some("x.sql") }), + ).pipe(Effect.exit); + expect(failMessage(exit)).toBe("Pass SQL as an argument or with --file, not both."); + expect(failSuggestion(exit)).toBeUndefined(); + expect(platformApi.requests).toEqual([]); + expect(resolvedTargets).toEqual([]); + }).pipe(Effect.provide(layer)); + }); + it.effect("errors when --file cannot be read", () => { const { layer } = setup(); return Effect.gen(function* () { From ef48e859a55b8a30a1dedcad33e0bc67e1cd8a25 Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Wed, 21 Oct 2026 11:29:15 +0100 Subject: [PATCH 289/291] fix(cli): match the gen types --remote language hint case-insensitively --- apps/cli/src/commands/gen/types/types.handler.ts | 4 ++-- .../commands/gen/types/types.integration.test.ts | 14 +++++++++----- 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/apps/cli/src/commands/gen/types/types.handler.ts b/apps/cli/src/commands/gen/types/types.handler.ts index 7a68dfad63..3f3fa21c2a 100644 --- a/apps/cli/src/commands/gen/types/types.handler.ts +++ b/apps/cli/src/commands/gen/types/types.handler.ts @@ -623,8 +623,8 @@ export const genTypes = Effect.fn("gen.types")(function* (flags: GenTypesFlags) const remoteBranch = Option.getOrUndefined(flags.remote); const languageNamedBranch = - remoteBranch?._tag === "Branch" && GEN_TYPES_LANGUAGES.includes(remoteBranch.name) - ? remoteBranch.name + remoteBranch?._tag === "Branch" + ? GEN_TYPES_LANGUAGES.find((language) => language === remoteBranch.name.toLowerCase()) : undefined; const resolution = resolveTarget(flags); const target = yield* languageNamedBranch === undefined 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 adc5212606..6e70bf3bfe 100644 --- a/apps/cli/src/commands/gen/types/types.integration.test.ts +++ b/apps/cli/src/commands/gen/types/types.integration.test.ts @@ -1082,17 +1082,21 @@ describe("gen types", () => { const languageSuggestion = (language: string) => `To generate ${language} types for the linked project, run supabase gen types --lang=${language} --remote.`; - it.effect.each(["typescript", "go"])( - "suggests --lang=%s --remote when the linked project has no such branch", - (language) => + it.effect.each([ + { typed: "typescript", language: "typescript" }, + { typed: "go", language: "go" }, + { typed: "TypeScript", language: "typescript" }, + ])( + "suggests --lang=$language --remote for --remote $typed when the linked project has no such branch", + ({ typed, language }) => Effect.gen(function* () { const { layer, api } = yield* setup({ - args: ["gen", "types", "--remote", language], + args: ["gen", "types", "--remote", typed], projectId: Option.some(VALID_REF), branches: [branchRow("feature-x")], }); const error = yield* genTypes( - defaultFlags({ remote: Option.some({ _tag: "Branch", name: language } as const) }), + defaultFlags({ remote: Option.some({ _tag: "Branch", name: typed } as const) }), ).pipe(Effect.provide(layer), Effect.flip); expect(error).toMatchObject({ From f28065c2a12819ff6a7eb5c0c868d86bc1a8ce75 Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Wed, 21 Oct 2026 11:34:21 +0100 Subject: [PATCH 290/291] test(cli): read the db query error suggestion through an unknown-typed helper --- .../src/commands/db/query/query.integration.test.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 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 4bd6d02f65..50ac275c38 100644 --- a/apps/cli/src/commands/db/query/query.integration.test.ts +++ b/apps/cli/src/commands/db/query/query.integration.test.ts @@ -96,10 +96,14 @@ 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; -const failSuggestion = ( - exit: Exit.Exit<unknown, { readonly suggestion?: string }>, -): string | undefined => - Exit.isFailure(exit) ? exit.cause.reasons.find(Cause.isFailReason)?.error.suggestion : undefined; +const failSuggestion = (exit: Exit.Exit<unknown, unknown>): unknown => { + const error = Exit.isFailure(exit) + ? exit.cause.reasons.find(Cause.isFailReason)?.error + : undefined; + return typeof error === "object" && error !== null && "suggestion" in error + ? error.suggestion + : undefined; +}; function mockResolver( isLocal: boolean | undefined, From 873f68f0e7f21099ea41acd0ad1f975c8ee57079 Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Wed, 21 Oct 2026 11:47:14 +0100 Subject: [PATCH 291/291] fix(cli): treat only localhost, 127.0.0.1 and ::1 as the local database host aliases --- .../db-config.integration.test.ts | 21 ++++++++++++++++++- .../src/command-internal/db-config.layer.ts | 7 +++++-- ...t-selection-and-hosted-mutation-consent.md | 6 +++--- 3 files changed, 28 insertions(+), 6 deletions(-) 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 8cf3fb176a..7d5d69cd76 100644 --- a/apps/cli/src/command-internal/db-config.integration.test.ts +++ b/apps/cli/src/command-internal/db-config.integration.test.ts @@ -406,7 +406,6 @@ describe("dbConfigResolver (local + db-url)", () => { it.effect.each([ "postgres://postgres:postgres@localhost:54322/postgres", "postgres://postgres:postgres@[::1]:54322/postgres", - "postgres://postgres:postgres@127.0.0.2:54322/postgres", ])("db-url mode: the loopback alias %s is local when the services host is 127.0.0.1", (url) => Effect.gen(function* () { const dir = yield* withWorkdir(); @@ -422,6 +421,26 @@ describe("dbConfigResolver (local + db-url)", () => { ), ); + it.effect( + "db-url mode: a loopback address the local stack does not use stays remote on the local port", + () => + Effect.gen(function* () { + const dir = yield* withWorkdir(); + return yield* resolveConnection( + dir, + inputFor(dbUrlTarget("postgres://postgres:postgres@127.0.0.2:54322/postgres")), + ); + }).pipe( + Effect.tap((r) => + Effect.sync(() => { + expect(r.isLocal).toBe(false); + }), + ), + Effect.scoped, + Effect.provide(BunServices.layer), + ), + ); + it.effect( "db-url mode: a loopback alias on a port the local database does not use stays remote", () => diff --git a/apps/cli/src/command-internal/db-config.layer.ts b/apps/cli/src/command-internal/db-config.layer.ts index 82d8d5b7ff..67f33bf283 100644 --- a/apps/cli/src/command-internal/db-config.layer.ts +++ b/apps/cli/src/command-internal/db-config.layer.ts @@ -203,7 +203,9 @@ const unbanErrorMapper = mapHttpError({ const isLoopbackHost = (host: string): boolean => host === "localhost" || host === "::1" || (net.isIPv4(host) && host.startsWith("127.")); -/** Loopback spellings count as the resolved local services hostname; the port must match too. */ +const LOCAL_STACK_HOSTS: ReadonlySet<string> = new Set(["localhost", "127.0.0.1", "::1"]); + +/** The stack's own spellings of its host count as local; the port must match too. */ function isLocalDatabase( host: string, localHost: string, @@ -211,7 +213,8 @@ function isLocalDatabase( dbPort: number, shadowPort: number, ): boolean { - const sameHost = host === localHost || (isLoopbackHost(host) && isLoopbackHost(localHost)); + const sameHost = + host === localHost || (LOCAL_STACK_HOSTS.has(host) && LOCAL_STACK_HOSTS.has(localHost)); return sameHost && (port === dbPort || port === shadowPort); } 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 80241d452d..b19880caa1 100644 --- a/docs/adr/0033-target-selection-and-hosted-mutation-consent.md +++ b/docs/adr/0033-target-selection-and-hosted-mutation-consent.md @@ -164,9 +164,9 @@ production sees it. A UUID looked up with no linked project cannot tell, so it s ### Hosted-mutation consent Every persistent mutation of a hosted resource in the `account`, `hosted` and `selectable` groups -needs consent. This includes writes through a non-local `--remote=<url>`: a loopback host on the -local or shadow database ports, or the local stack's endpoint, counts as local; anything else is -hosted. +needs consent. This includes writes through a non-local `--remote=<url>`: `localhost`, `127.0.0.1` +or `::1` 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