Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 10 additions & 5 deletions apps/cli/src/command-internal/prompt-yes-no.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
* 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.
*/
Expand All @@ -34,18 +35,22 @@ 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");
Comment thread
7ttp marked this conversation as resolved.
return true;
}
if (output.format !== "text") {
if (output.format !== "text" && !options.readMachineStdin) {
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.
if (output.format !== "text" && (!interactive || tty.stdinIsTty)) {
Comment thread
7ttp marked this conversation as resolved.
return defaultValue;
}
// 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");
Expand Down
88 changes: 87 additions & 1 deletion apps/cli/src/command-internal/prompt-yes-no.unit.test.ts
Original file line number Diff line number Diff line change
@@ -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)", () => {
Expand All @@ -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" : "");
});
}
});
27 changes: 17 additions & 10 deletions apps/cli/src/commands/config/push/SIDE_EFFECTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 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.
Comment thread
7ttp marked this conversation as resolved.
Then `Comparison scope: <present> (not returned:
<missing>)` — printed EVERY run, not just when a block is missing (family consistency
Expand Down Expand Up @@ -218,9 +217,16 @@ 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 are unchanged from before CLI-2168: they render
`<title> [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 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
or had to work around:
Expand All @@ -242,10 +248,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.
Expand Down
2 changes: 1 addition & 1 deletion apps/cli/src/commands/config/push/push.command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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([
Expand Down
72 changes: 72 additions & 0 deletions apps/cli/src/commands/config/push/push.e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -41,4 +42,75 @@ describe("supabase config push", () => {
expect(`${stdout}${stderr}`).toContain("config.toml");
},
);
test.each(["n", "y"] as const)(
"agent auto-detection honors piped %s through the built CLI",
{ timeout: E2E_TIMEOUT_MS },
async (answer) => {
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: `${answer}\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(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);
rmSync(cwd, { recursive: true, force: true });
}
},
);
});
29 changes: 20 additions & 9 deletions apps/cli/src/commands/config/push/push.handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, true, { readMachineStdin: true });
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,
Expand All @@ -309,14 +309,25 @@ 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 =
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?`;
return yield* promptYesNo(output, yes, title, true);
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
Expand Down
Loading
Loading