From 79935b59d21b81a1f17f43f1e136500edcb99f85 Mon Sep 17 00:00:00 2001 From: 7ttp <117663341+7ttp@users.noreply.github.com> Date: Fri, 18 Sep 2026 05:37:27 +0530 Subject: [PATCH 1/3] fix(cli): honour config push consent (CLI-2450) --- .../cli/src/command-internal/prompt-yes-no.ts | 21 +++--- .../src/commands/config/push/SIDE_EFFECTS.md | 21 +++--- .../src/commands/config/push/push.command.ts | 2 +- .../src/commands/config/push/push.e2e.test.ts | 72 +++++++++++++++++++ .../src/commands/config/push/push.handler.ts | 19 ++--- .../config/push/push.integration.test.ts | 64 ++++++++++++++--- 6 files changed, 163 insertions(+), 36 deletions(-) diff --git a/apps/cli/src/command-internal/prompt-yes-no.ts b/apps/cli/src/command-internal/prompt-yes-no.ts index b8a6c975f7..66b8193e83 100644 --- a/apps/cli/src/command-internal/prompt-yes-no.ts +++ b/apps/cli/src/command-internal/prompt-yes-no.ts @@ -23,8 +23,9 @@ export const parseYesNo = (input: string): boolean | undefined => { /** * Confirm-or-default prompt shared by command handlers and shell-agnostic code alike. - * `yes` echoes an affirmative answer and returns `true` immediately; non-text output uses - * the default silently; a real interactive TTY prompts via clack; otherwise (including + * `yes` echoes an affirmative answer and returns `true` immediately; non-text output + * honors piped answers when prompting is permitted, otherwise using the default silently; + * a real interactive text TTY prompts via clack; otherwise (including text callers with * `interactive: false`) it reads one line via the shared `Stdin` reader, falling back to * the default only when the line is empty or unparseable. */ @@ -40,19 +41,23 @@ export const promptYesNo = Effect.fnUntraced(function* ( yield* output.raw(`${label} [${choices}] y\n`, "stderr"); return true; } - if (output.format !== "text") { + const tty = yield* Tty; + if (output.format !== "text" && (!interactive || tty.stdinIsTty)) { return defaultValue; } - const tty = yield* Tty; - // `interactive: false` still prints the label and reads one line instead of silently - // returning the default — it uses the same non-TTY read path below. + // Text `interactive: false` still prints the label and reads one line instead of + // silently returning the default — it uses the same non-TTY read path below. if (!interactive || !tty.stdinIsTty) { // A parsed piped answer wins; an empty or unparseable line falls back to the default. - yield* output.raw(`${label} [${choices}] `, "stderr"); + if (output.format === "text") { + yield* output.raw(`${label} [${choices}] `, "stderr"); + } const stdin = yield* Stdin; const line = yield* stdin.readLine(NON_TTY_TIMEOUT_MILLIS); const input = Option.getOrElse(line, () => ""); - yield* output.raw(`${input}\n`, "stderr"); + if (output.format === "text") { + yield* output.raw(`${input}\n`, "stderr"); + } if (input.length > 0) { const answer = parseYesNo(input); if (answer !== undefined) { diff --git a/apps/cli/src/commands/config/push/SIDE_EFFECTS.md b/apps/cli/src/commands/config/push/SIDE_EFFECTS.md index 4765a85a91..76baf4053b 100644 --- a/apps/cli/src/commands/config/push/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/config/push/SIDE_EFFECTS.md @@ -174,9 +174,8 @@ with --yes) [y/N] ` (bare ref, no quotes, when the name is unknown). An EXPLICIT 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). Unlike this command's other -confirmations, this gate's default is **no**: a non-TTY run with no piped answer, or -`--output-format json`/`stream-json`, declines (and fails) rather than proceeding, +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, declines (and fails) rather than proceeding, unless `--yes`/`SUPABASE_YES` is set. A plain-project target never shows this prompt. Then `Comparison scope: (not returned: )` — printed EVERY run, not just when a block is missing (family consistency @@ -218,9 +217,10 @@ own; `[group-write]` is an undeclared companion the endpoint required alongside 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: `. The -per-service confirmations are unchanged from before CLI-2168: they render -` [Y/n] ` (or `<title> [Y/n] y` when `--yes`) and still exit **0** on decline — -only the branch confirmation gate described above fails. +per-service confirmations render `<title> [Y/n] ` in interactive text output and +`<title> [y/N] ` for piped text input. `--yes` echoes `y` after the applicable +prompt. 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 or had to work around: @@ -242,10 +242,11 @@ through the same control-character sanitizing `config diff` uses. ### `--output-format json` / `stream-json` -Per-service diagnostics stay on stderr; the per-service `keep()` prompts auto-confirm -(default yes) — but the branch confirmation gate above (CLI-2168) auto-**declines** (and -fails) without `--yes`, since its default differs from every other confirmation in this -command. A structured summary is emitted on stdout via `output.success(message, data)`; +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. diff --git a/apps/cli/src/commands/config/push/push.command.ts b/apps/cli/src/commands/config/push/push.command.ts index 74f15df1cc..06c447db41 100644 --- a/apps/cli/src/commands/config/push/push.command.ts +++ b/apps/cli/src/commands/config/push/push.command.ts @@ -41,7 +41,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 — but a non-interactive run (no TTY, --yes, or piped stdin with no answer) defaults to proceeding, so a value your file declares only because `supabase init`'s own template wrote it (e.g. a disabled storage.analytics/auth.oauth_server toggle, or a local development site_url) can silently overwrite a real, intentionally-customized hosted setting. Scripts and agents driving this command non-interactively should run `supabase config diff` first and review it, rather than relying on the prompt.", + "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.", ), Command.withShortDescription("Push local config to linked project"), Command.withExamples([ 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 3c64b303bc..7579187078 100644 --- a/apps/cli/src/commands/config/push/push.e2e.test.ts +++ b/apps/cli/src/commands/config/push/push.e2e.test.ts @@ -3,6 +3,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterAll, beforeAll, describe, expect, test } from "vitest"; +import { v2ProjectConfigResponse } from "../../../../tests/helpers/config-fixtures.ts"; import { runSupabase } from "../../../../tests/helpers/cli.ts"; const E2E_TIMEOUT_MS = 30_000; @@ -41,4 +42,75 @@ describe("supabase config push", () => { expect(`${stdout}${stderr}`).toContain("config.toml"); }, ); + test( + "agent auto-detection honors piped consent through the built CLI", + { timeout: E2E_TIMEOUT_MS }, + async () => { + const cwd = mkdtempSync(join(tmpdir(), "supabase-config-push-consent-e2e-")); + const writes: string[] = []; + const server = Bun.serve({ + hostname: "127.0.0.1", + port: 0, + async fetch(request) { + const url = new URL(request.url); + if ( + request.method === "GET" && + url.pathname === `/v2/projects/${TEST_PROJECT_REF}/config` + ) { + return Response.json(v2ProjectConfigResponse({ ref: TEST_PROJECT_REF })); + } + if (request.method === "GET" && url.pathname === `/v1/projects/${TEST_PROJECT_REF}`) { + return new Response("unavailable", { status: 503 }); + } + if (request.method === "GET" && url.pathname.endsWith("/billing/addons")) { + return Response.json({ available_addons: [] }); + } + if (request.method === "PATCH" && url.pathname.endsWith("/postgrest")) { + writes.push(await request.text()); + return Response.json({ + db_schema: "", + db_extra_search_path: "", + max_rows: 500, + db_pool: null, + db_pool_acquisition_timeout: null, + }); + } + return new Response("not found", { status: 404 }); + }, + }); + try { + mkdirSync(join(cwd, "supabase")); + writeFileSync( + join(cwd, "supabase", "config.toml"), + 'project_id = "test"\n[api]\nmax_rows = 500\n', + ); + const profilePath = join(cwd, "profile.yaml"); + writeFileSync( + profilePath, + `name: config-push-consent-e2e\napi_url: ${server.url.origin}\ndashboard_url: ${server.url.origin}\nproject_host: example.invalid\n`, + ); + const { exitCode, stdout, stderr } = await runSupabase( + ["config", "push", "--project-ref", TEST_PROJECT_REF], + { + cwd, + stdin: "n\n", + env: { + SUPABASE_PROFILE: profilePath, + SUPABASE_ACCESS_TOKEN: TEST_TOKEN, + SUPABASE_WORKDIR: cwd, + SUPABASE_YES: undefined, + CODEX_SANDBOX: "1", + }, + }, + ); + expect(exitCode, `${stdout}\n${stderr}`).toBe(0); + expect(writes).toEqual([]); + expect(stdout).toContain('"status":"skipped"'); + expect(stderr).toContain("api.max_rows [update]"); + } finally { + await server.stop(true); + rmSync(cwd, { recursive: true, force: true }); + } + }, + ); }); diff --git a/apps/cli/src/commands/config/push/push.handler.ts b/apps/cli/src/commands/config/push/push.handler.ts index 585e0f2d58..07adaaad3a 100644 --- a/apps/cli/src/commands/config/push/push.handler.ts +++ b/apps/cli/src/commands/config/push/push.handler.ts @@ -286,17 +286,17 @@ 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); const target = yield* resolveConfigPushTarget(ref, { knownBranch }); yield* output.raw(configPushTargetLines(target), "stderr"); if (target.kind === "branch" && knownBranch === undefined) { - const proceed = yield* promptYesNo( - output, - yes, - configPushBranchPromptLabel(target), - // Defaults `false`, unlike this file's other prompts: an unattended run without `--yes` - // must decline a branch mutation rather than silently proceed. - false, - ); + // 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, @@ -309,6 +309,7 @@ export const configPush = Effect.fn("config.push")(function* (flags: ConfigPushF const cost = yield* getCostMatrix(ref); // `promptYesNo` scans piped stdin on a non-TTY before falling back to the default. + const defaultProceed = tty.stdinIsTty && output.interactive && output.format === "text"; const keep = (name: string) => Effect.gen(function* () { const item = cost.get(name); @@ -316,7 +317,7 @@ export const configPush = Effect.fn("config.push")(function* (flags: ConfigPushF item === undefined ? `Do you want to push ${name} config to remote?` : `Enabling ${item.name} will cost you ${item.price}. Keep it enabled?`; - return yield* promptYesNo(output, yes, title, true); + return yield* confirm(title, defaultProceed); }); // 7. Read the project's effective configuration in one call. No spinner, matching the rest 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 33343dc1af..8e3012fc4f 100644 --- a/apps/cli/src/commands/config/push/push.integration.test.ts +++ b/apps/cli/src/commands/config/push/push.integration.test.ts @@ -224,6 +224,7 @@ function setup(opts: { 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. */ @@ -256,6 +257,7 @@ function setup(opts: { format: opts.format ?? "text", promptConfirmResponses: opts.confirm, promptConfirmFail: opts.promptFail, + interactive: opts.interactive, }); const api = mockCommandPlatformApi({ handler: (request) => { @@ -552,7 +554,7 @@ max_rows = 1000 }).pipe(Effect.provide(layer)); }); - it.live("defaults to yes on empty non-TTY stdin, echoing the prompt", () => { + it.live("skips changes on empty non-TTY stdin, echoing the prompt", () => { const { layer, api, out } = setup({ toml: `project_id = "test"\n[api]\nmax_rows = 2000\n`, stdinIsTty: false, @@ -560,9 +562,9 @@ max_rows = 1000 return Effect.gen(function* () { yield* configPush({ projectRef: Option.none() }); expect(api.requests.some((r) => r.method === "PATCH" && r.url.includes("/postgrest"))).toBe( - true, + false, ); - expect(out.stderrText).toContain("Do you want to push api config to remote? [Y/n] \n"); + expect(out.stderrText).toContain("Do you want to push api config to remote? [y/N] \n"); }).pipe(Effect.provide(layer)); }); @@ -577,7 +579,49 @@ max_rows = 1000 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("Do you want to push api config to remote? [y/N] n"); + }).pipe(Effect.provide(layer)); + }); + + for (const format of ["json", "stream-json"] as const) { + for (const answer of ["n", "y", "", "maybe"] as const) { + it.live(`${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).not.toContain("Do you want to push auth"); + }).pipe(Effect.provide(layer)); + }); + } + } + + it.live("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); }).pipe(Effect.provide(layer)); }); @@ -759,6 +803,7 @@ max_rows = 1000 const { layer, out } = setup({ toml: `project_id = "test"\n[api]\nmax_rows = 2000\n`, format: "json", + yes: true, }); return Effect.gen(function* () { yield* configPush({ projectRef: Option.none() }); @@ -840,6 +885,7 @@ max_rows = 1000 const { layer, api, out } = setup({ toml: `project_id = "test"\n[api]\nschemas = ["public", "graphql_public", "custom_schema"]\n`, format: "json", + yes: true, v2: { status: 200, body: v2Response({ @@ -1856,6 +1902,7 @@ secret = "env(MISSING_CAPTCHA_SECRET)" const { layer, apiMock, out } = setupService({ toml, format: "json", + yes: true, v1: { updateAuthServiceConfig: () => Effect.succeed({}) }, }); return Effect.gen(function* () { @@ -2293,6 +2340,7 @@ max_buckets = 99 const { layer, apiMock, out } = setupService({ toml, format: "json", + yes: true, v2: { status: 200, body: v2Response({ @@ -2503,7 +2551,7 @@ secret = "new-secret" yield* configPush({ projectRef: Option.none() }); 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"); + expect(out.stderrText).toContain("Do you want to push auth config to remote? [y/N] n"); }).pipe(Effect.provide(layer)); }); @@ -3257,8 +3305,8 @@ describe("config push --project-ref branch name/UUID resolution (CLI-2289)", () () => { // `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()`) still default to `true` on empty non-TTY stdin, - // so the mutation still proceeds. + // 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, @@ -3274,7 +3322,7 @@ describe("config push --project-ref branch name/UUID resolution (CLI-2289)", () 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( - true, + false, ); }).pipe(Effect.provide(layer)); }, From d8a001f0abd82a5ef8ef6f6031f12f0c19014cb7 Mon Sep 17 00:00:00 2001 From: 7ttp <117663341+7ttp@users.noreply.github.com> Date: Fri, 18 Sep 2026 06:23:56 +0530 Subject: [PATCH 2/3] fix(cli): scope machine consent and clarify skipped updates --- .../cli/src/command-internal/prompt-yes-no.ts | 14 ++++----- .../src/commands/config/push/SIDE_EFFECTS.md | 7 +++-- .../src/commands/config/push/push.e2e.test.ts | 12 ++++---- .../src/commands/config/push/push.handler.ts | 11 +++++-- .../config/push/push.integration.test.ts | 29 +++++++++++++++---- .../delete/delete.integration.test.ts | 12 ++++++++ 6 files changed, 63 insertions(+), 22 deletions(-) diff --git a/apps/cli/src/command-internal/prompt-yes-no.ts b/apps/cli/src/command-internal/prompt-yes-no.ts index 66b8193e83..8e4b39740c 100644 --- a/apps/cli/src/command-internal/prompt-yes-no.ts +++ b/apps/cli/src/command-internal/prompt-yes-no.ts @@ -24,7 +24,7 @@ export const parseYesNo = (input: string): boolean | undefined => { /** * Confirm-or-default prompt shared by command handlers and shell-agnostic code alike. * `yes` echoes an affirmative answer and returns `true` immediately; non-text output - * honors piped answers when prompting is permitted, otherwise using the default silently; + * uses the default silently unless the caller opts into machine-mode piped answers; * a real interactive text TTY prompts via clack; otherwise (including text callers with * `interactive: false`) it reads one line via the shared `Stdin` reader, falling back to * the default only when the line is empty or unparseable. @@ -35,12 +35,16 @@ export const promptYesNo = Effect.fnUntraced(function* ( label: string, defaultValue: boolean, interactive = true, + options: { readonly readMachineStdin?: boolean } = {}, ) { const choices = defaultValue ? "Y/n" : "y/N"; if (yes) { yield* output.raw(`${label} [${choices}] y\n`, "stderr"); return true; } + if (output.format !== "text" && !options.readMachineStdin) { + return defaultValue; + } const tty = yield* Tty; if (output.format !== "text" && (!interactive || tty.stdinIsTty)) { return defaultValue; @@ -49,15 +53,11 @@ export const promptYesNo = Effect.fnUntraced(function* ( // silently returning the default — it uses the same non-TTY read path below. if (!interactive || !tty.stdinIsTty) { // A parsed piped answer wins; an empty or unparseable line falls back to the default. - if (output.format === "text") { - yield* output.raw(`${label} [${choices}] `, "stderr"); - } + yield* output.raw(`${label} [${choices}] `, "stderr"); const stdin = yield* Stdin; const line = yield* stdin.readLine(NON_TTY_TIMEOUT_MILLIS); const input = Option.getOrElse(line, () => ""); - if (output.format === "text") { - yield* output.raw(`${input}\n`, "stderr"); - } + yield* output.raw(`${input}\n`, "stderr"); if (input.length > 0) { const answer = parseYesNo(input); if (answer !== undefined) { diff --git a/apps/cli/src/commands/config/push/SIDE_EFFECTS.md b/apps/cli/src/commands/config/push/SIDE_EFFECTS.md index 76baf4053b..58966f3ce2 100644 --- a/apps/cli/src/commands/config/push/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/config/push/SIDE_EFFECTS.md @@ -175,7 +175,7 @@ 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, declines (and fails) rather than proceeding, +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. Then `Comparison scope: <present> (not returned: <missing>)` — printed EVERY run, not just when a block is missing (family consistency @@ -219,7 +219,10 @@ project's current value for it. Secret values never appear in output. Every bloc on a blank line. Experimental prints `Enabling webhooks for project: <ref>`. The per-service confirmations render `<title> [Y/n] ` in interactive text output and `<title> [y/N] ` for piped text input. `--yes` echoes `y` after the applicable -prompt. Resource declines still exit **0**; only the branch confirmation gate +prompt. 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`. +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 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 7579187078..6ddce2ad2b 100644 --- a/apps/cli/src/commands/config/push/push.e2e.test.ts +++ b/apps/cli/src/commands/config/push/push.e2e.test.ts @@ -42,10 +42,10 @@ describe("supabase config push", () => { expect(`${stdout}${stderr}`).toContain("config.toml"); }, ); - test( - "agent auto-detection honors piped consent through the built CLI", + test.each(["n", "y"] as const)( + "agent auto-detection honors piped %s through the built CLI", { timeout: E2E_TIMEOUT_MS }, - async () => { + async (answer) => { const cwd = mkdtempSync(join(tmpdir(), "supabase-config-push-consent-e2e-")); const writes: string[] = []; const server = Bun.serve({ @@ -93,7 +93,7 @@ describe("supabase config push", () => { ["config", "push", "--project-ref", TEST_PROJECT_REF], { cwd, - stdin: "n\n", + stdin: `${answer}\n`, env: { SUPABASE_PROFILE: profilePath, SUPABASE_ACCESS_TOKEN: TEST_TOKEN, @@ -104,8 +104,8 @@ describe("supabase config push", () => { }, ); expect(exitCode, `${stdout}\n${stderr}`).toBe(0); - expect(writes).toEqual([]); - expect(stdout).toContain('"status":"skipped"'); + expect(writes).toEqual(answer === "y" ? ['{"max_rows":500}'] : []); + expect(stdout).toContain(`"status":"${answer === "y" ? "updated" : "skipped"}"`); expect(stderr).toContain("api.max_rows [update]"); } finally { await server.stop(true); diff --git a/apps/cli/src/commands/config/push/push.handler.ts b/apps/cli/src/commands/config/push/push.handler.ts index 07adaaad3a..891a3d4492 100644 --- a/apps/cli/src/commands/config/push/push.handler.ts +++ b/apps/cli/src/commands/config/push/push.handler.ts @@ -290,7 +290,7 @@ export const configPush = Effect.fn("config.push")(function* (flags: ConfigPushF const confirm = (label: string, defaultValue: boolean) => !yes && tty.stdinIsTty && !output.interactive ? Effect.succeed(defaultValue) - : promptYesNo(output, yes, label, defaultValue); + : promptYesNo(output, yes, label, defaultValue, true, { readMachineStdin: true }); const target = yield* resolveConfigPushTarget(ref, { knownBranch }); yield* output.raw(configPushTargetLines(target), "stderr"); if (target.kind === "branch" && knownBranch === undefined) { @@ -317,7 +317,14 @@ export const configPush = Effect.fn("config.push")(function* (flags: ConfigPushF item === undefined ? `Do you want to push ${name} config to remote?` : `Enabling ${item.name} will cost you ${item.price}. Keep it enabled?`; - return yield* confirm(title, defaultProceed); + const confirmed = yield* confirm(title, defaultProceed); + if (!confirmed && output.format === "text" && tty.stdinIsTty && !output.interactive) { + yield* output.raw( + `Skipped ${name}: confirmation unavailable with redirected output. 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 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 8e3012fc4f..974d5b5f73 100644 --- a/apps/cli/src/commands/config/push/push.integration.test.ts +++ b/apps/cli/src/commands/config/push/push.integration.test.ts @@ -257,7 +257,8 @@ function setup(opts: { format: opts.format ?? "text", promptConfirmResponses: opts.confirm, promptConfirmFail: opts.promptFail, - interactive: opts.interactive, + interactive: + opts.interactive ?? ((opts.format ?? "text") === "text" && (opts.stdinIsTty ?? true)), }); const api = mockCommandPlatformApi({ handler: (request) => { @@ -358,7 +359,13 @@ function setup(opts: { runtimeInfo: mockRuntimeInfo({ cwd: opts.runtimeCwd ?? tempRoot.current }), telemetry: telemetry.layer, linkedProjectCache: linkedProjectCache.layer, - tty: mockTty({ stdinIsTty: opts.stdinIsTty ?? true, stdoutIsTty: false }), + tty: mockTty({ + stdinIsTty: opts.stdinIsTty ?? true, + stdoutIsTty: + (opts.format ?? "text") === "text" && + (opts.stdinIsTty ?? true) && + (opts.interactive ?? true), + }), ...(opts.analytics === undefined ? {} : { analytics: opts.analytics }), }), mockStdin( @@ -606,7 +613,9 @@ max_rows = 1000 }, ]), }); - expect(out.stderrText).not.toContain("Do you want to push auth"); + expect(out.stderrText).toContain( + `Do you want to push auth config to remote? [y/N] ${answer}\n`, + ); }).pipe(Effect.provide(layer)); }); } @@ -622,6 +631,9 @@ max_rows = 1000 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)); }); @@ -1271,7 +1283,11 @@ function setupService(opts: { readonly pipedAnswers?: ReadonlyArray<string>; }) { writeConfig(opts.toml); - const out = mockOutput({ format: opts.format ?? "text", promptConfirmResponses: opts.confirm }); + const out = mockOutput({ + format: opts.format ?? "text", + promptConfirmResponses: opts.confirm, + interactive: (opts.format ?? "text") === "text" && (opts.stdinIsTty ?? true), + }); const apiMock = mockCommandPlatformApiService({ v1: { // Live target-detection probe — defaults to a schema-valid, unnamed project so every @@ -1299,7 +1315,10 @@ function setupService(opts: { telemetry: telemetry.layer, linkedProjectCache: linkedProjectCache.layer, // Gated-service prompts model an interactive user answering via `confirm`. - tty: mockTty({ stdinIsTty: opts.stdinIsTty ?? true, stdoutIsTty: false }), + tty: mockTty({ + stdinIsTty: opts.stdinIsTty ?? true, + stdoutIsTty: (opts.format ?? "text") === "text" && (opts.stdinIsTty ?? true), + }), }), mockStdin( opts.stdinIsTty ?? true, 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 60534ae8b5..e52bf852d0 100644 --- a/apps/cli/src/commands/projects/delete/delete.integration.test.ts +++ b/apps/cli/src/commands/projects/delete/delete.integration.test.ts @@ -200,6 +200,18 @@ describe("projects delete integration", () => { }).pipe(Effect.provide(layer)); }); + for (const format of ["json", "stream-json"] as const) { + it.live(`${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.live("non-TTY with piped `n` declines like Go", () => { const { layer, out, api } = setup({ stdinIsTty: false, stdinInput: "n\n" }); return Effect.gen(function* () { From 81853d786ba1b9b9447700a98625dc6e07b42432 Mon Sep 17 00:00:00 2001 From: 7ttp <117663341+7ttp@users.noreply.github.com> Date: Fri, 18 Sep 2026 18:17:20 +0530 Subject: [PATCH 3/3] fix(cli): clarify config push consent outcomes --- .../prompt-yes-no.unit.test.ts | 88 ++++++++++++++++++- .../src/commands/config/push/SIDE_EFFECTS.md | 9 +- .../src/commands/config/push/push.handler.ts | 9 +- .../config/push/push.integration.test.ts | 39 ++++++++ 4 files changed, 138 insertions(+), 7 deletions(-) diff --git a/apps/cli/src/command-internal/prompt-yes-no.unit.test.ts b/apps/cli/src/command-internal/prompt-yes-no.unit.test.ts index 124548ac4a..9702f9f938 100644 --- a/apps/cli/src/command-internal/prompt-yes-no.unit.test.ts +++ b/apps/cli/src/command-internal/prompt-yes-no.unit.test.ts @@ -1,6 +1,11 @@ import { describe, expect, it } from "vitest"; +import { Effect, Layer, Option, Stream } from "effect"; -import { parseYesNo } from "./prompt-yes-no.ts"; +import { mockOutput, mockTty } from "../../tests/helpers/mocks.ts"; +import { Output } from "../shared/output/output.service.ts"; +import { Stdin } from "../shared/runtime/stdin.service.ts"; + +import { parseYesNo, promptYesNo } from "./prompt-yes-no.ts"; describe("parseYesNo", () => { it("parses affirmative answers (case-insensitive, trimmed)", () => { @@ -21,3 +26,84 @@ describe("parseYesNo", () => { } }); }); + +describe("promptYesNo machine consent", () => { + for (const format of ["json", "stream-json"] as const) { + it.each([ + { + readMachineStdin: false, + stdinIsTty: false, + interactive: true, + defaultValue: false, + expected: false, + reads: 0, + }, + { + readMachineStdin: true, + stdinIsTty: true, + interactive: true, + defaultValue: false, + expected: false, + reads: 0, + }, + { + readMachineStdin: true, + stdinIsTty: false, + interactive: false, + defaultValue: false, + expected: false, + reads: 0, + }, + { + readMachineStdin: true, + stdinIsTty: false, + interactive: true, + defaultValue: false, + expected: true, + reads: 1, + }, + { + readMachineStdin: true, + stdinIsTty: false, + interactive: false, + defaultValue: true, + expected: true, + reads: 0, + }, + ])(`${format} respects opt-in and input ownership: %j`, async (scenario) => { + const out = mockOutput({ format }); + let reads = 0; + const stdin = Layer.succeed(Stdin, { + isTTY: scenario.stdinIsTty, + readPipedBytes: Effect.die("unexpected whole-stream read"), + pipedBytesStream: Stream.empty, + readPipedText: Effect.die("unexpected whole-stream read"), + readLine: () => + Effect.sync(() => { + reads++; + return Option.some("y"); + }), + }); + const answer = await Effect.runPromise( + Effect.gen(function* () { + const output = yield* Output; + return yield* promptYesNo( + output, + false, + "Confirm?", + scenario.defaultValue, + scenario.interactive, + { readMachineStdin: scenario.readMachineStdin }, + ); + }).pipe( + Effect.provide( + Layer.mergeAll(out.layer, stdin, mockTty({ stdinIsTty: scenario.stdinIsTty })), + ), + ), + ); + expect(answer).toBe(scenario.expected); + expect(reads).toBe(scenario.reads); + expect(out.stderrText).toBe(scenario.reads === 1 ? "Confirm? [y/N] y\n" : ""); + }); + } +}); diff --git a/apps/cli/src/commands/config/push/SIDE_EFFECTS.md b/apps/cli/src/commands/config/push/SIDE_EFFECTS.md index 58966f3ce2..c84c5d2d82 100644 --- a/apps/cli/src/commands/config/push/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/config/push/SIDE_EFFECTS.md @@ -217,11 +217,14 @@ own; `[group-write]` is an undeclared companion the endpoint required alongside 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 render `<title> [Y/n] ` in interactive text output and -`<title> [y/N] ` for piped text input. `--yes` echoes `y` after the applicable -prompt. Piped answers also echo the question and answer to stderr in machine modes, +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. diff --git a/apps/cli/src/commands/config/push/push.handler.ts b/apps/cli/src/commands/config/push/push.handler.ts index 891a3d4492..8a1544c5d4 100644 --- a/apps/cli/src/commands/config/push/push.handler.ts +++ b/apps/cli/src/commands/config/push/push.handler.ts @@ -309,7 +309,8 @@ export const configPush = Effect.fn("config.push")(function* (flags: ConfigPushF const cost = yield* getCostMatrix(ref); // `promptYesNo` scans piped stdin on a non-TTY before falling back to the default. - const defaultProceed = tty.stdinIsTty && output.interactive && output.format === "text"; + const defaultProceed = + yes || (tty.stdinIsTty && output.interactive && output.format === "text"); const keep = (name: string) => Effect.gen(function* () { const item = cost.get(name); @@ -318,9 +319,11 @@ export const configPush = Effect.fn("config.push")(function* (flags: ConfigPushF ? `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) { + if (!confirmed && output.format === "text" && (!tty.stdinIsTty || !output.interactive)) { yield* output.raw( - `Skipped ${name}: confirmation unavailable with redirected output. Pass --yes (or set SUPABASE_YES) to approve.\n`, + 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", ); } 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 974d5b5f73..946852515d 100644 --- a/apps/cli/src/commands/config/push/push.integration.test.ts +++ b/apps/cli/src/commands/config/push/push.integration.test.ts @@ -561,6 +561,39 @@ max_rows = 1000 }).pipe(Effect.provide(layer)); }); + it.live("interactive text decline skips without an unattended recovery hint", () => { + 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(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.live(`${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.live("skips changes on empty non-TTY stdin, echoing the prompt", () => { const { layer, api, out } = setup({ toml: `project_id = "test"\n[api]\nmax_rows = 2000\n`, @@ -572,6 +605,9 @@ max_rows = 1000 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", + ); }).pipe(Effect.provide(layer)); }); @@ -587,6 +623,9 @@ max_rows = 1000 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", + ); }).pipe(Effect.provide(layer)); });