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
11 changes: 6 additions & 5 deletions apps/cli/src/legacy/commands/gen/signing-key/SIDE_EFFECTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,9 @@

## Environment Variables

| Variable | Purpose | Required? |
| -------- | ------- | --------- |
| - | - | - |
| Variable | Purpose | Required? |
| -------------- | ---------------------------------------------------------------------------------- | --------- |
| `SUPABASE_YES` | Auto-confirms the overwrite prompt, same as `--yes` (Go's `viper.GetBool("YES")`). | No |

## Exit Codes

Expand All @@ -47,16 +47,17 @@

### `--output-format json`

Not applicable; the command uses raw stdout and stderr text like the Go CLI.
Not applicable to output rendering; the command uses raw stdout and stderr text like the Go CLI. It does, however, affect the overwrite-confirmation prompt: since this command has no structured json/stream-json payload, requesting a non-text format from a real interactive terminal (no `--yes`, no piped stdin) fails the overwrite closed (`context canceled`) rather than silently defaulting to yes on a destructive, irreversible action. A non-TTY caller (piped or not) is unaffected — piped `y`/`n` answers are honored regardless of `--output-format`.

### `--output-format stream-json`

Not applicable; the command uses raw stdout and stderr text like the Go CLI.
Same as `--output-format json` above.

## Notes

- `--algorithm` accepts `ES256` (default, recommended) or `RS256`.
- `--append` appends the new key to an existing keys file instead of overwriting.
- The overwrite prompt honors `SUPABASE_YES` and an explicit `--yes=false` override, matching Go's `viper.GetBool("YES")` precedence (flag wins over env; an omitted flag falls back to the env var). On non-TTY stdin, a piped `y`/`n` line is read within a 100ms timeout and honored before falling back to the default (`y`), matching Go's `Console.ReadLine`/`PromptYesNo` — a piped answer other than an exact `y`/`yes`/`n`/`no` (case-insensitive) also falls back to the default.
Comment thread
Coly010 marked this conversation as resolved.
- `auth.signing_keys_path` is resolved relative to the active `supabase/config.toml` or `supabase/config.json`.
- Generated keys are JWKs, not PEM files.
- No network or Management API calls are involved.
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { Command, Flag } from "effect/unstable/cli";
import type * as CliCommand from "effect/unstable/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 { legacyCliConfigLayer } from "../../../config/legacy-cli-config.layer.ts";
import { legacyDebugLoggerLayer } from "../../../shared/legacy-debug-logger.layer.ts";
import { withLegacyCommandInstrumentation } from "../../../telemetry/legacy-command-instrumentation.ts";
Expand All @@ -29,6 +30,10 @@ const legacyGenSigningKeyRuntimeLayer = Layer.mergeAll(
cliConfig,
legacyTelemetryStateLayer,
commandRuntimeLayer(["gen", "signing-key"]),
// The overwrite-confirmation prompt reads piped stdin via `legacyPromptYesNo`
// (`stdin.readLine`), same as `config push`, `seed buckets`, `storage rm`, `db pull`,
// and `logout` — all of which merge `stdinLayer` alongside their runtime layer.
stdinLayer,
);

export const legacyGenSigningKeyCommand = Command.make("signing-key", config).pipe(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, test } from "vitest";

import { runSupabase } from "../../../../../tests/helpers/cli.ts";

const E2E_TIMEOUT_MS = 30_000;

/**
* Golden-path e2e for CLI-1865: exercises the real compiled-binary boundary —
* `signing-key.command.ts`'s actual production runtime layer, not the mocked
* `Stdin` the integration suite provides via `Layer.succeed`. A missing
* `stdinLayer` in that composition only surfaces as a "Service not found" defect
* at this boundary (see the legacy CLAUDE.md Go Parity Checklist item 5). Per-branch
* prompt/format coverage lives in the integration suite.
*/
describe("supabase gen signing-key (legacy)", () => {
let projectDir: string;

beforeEach(() => {
projectDir = mkdtempSync(join(tmpdir(), "supabase-gen-signing-key-e2e-"));
mkdirSync(join(projectDir, "supabase"), { recursive: true });
writeFileSync(
join(projectDir, "supabase", "config.toml"),
'[auth]\nsigning_keys_path = "./signing_keys.json"\n',
);
writeFileSync(join(projectDir, "supabase", "signing_keys.json"), "[]\n");
});

afterEach(() => {
rmSync(projectDir, { recursive: true, force: true });
});

test(
"declines the overwrite on a piped 'n' without crashing or writing the file",
{ timeout: E2E_TIMEOUT_MS },
async () => {
const { exitCode, stderr } = await runSupabase(["gen", "signing-key"], {
entrypoint: "legacy",
cwd: projectDir,
stdin: "n\n",
});
expect(exitCode).toBe(1);
expect(stderr).toContain("context canceled");
expect(stderr).not.toContain("Service not found");
const saved = readFileSync(join(projectDir, "supabase", "signing_keys.json"), "utf8");
expect(JSON.parse(saved)).toEqual([]);
},
);

test("overwrites on a piped 'y'", { timeout: E2E_TIMEOUT_MS }, async () => {
const { exitCode, stderr } = await runSupabase(["gen", "signing-key"], {
entrypoint: "legacy",
cwd: projectDir,
stdin: "y\n",
});
expect(exitCode).toBe(0);
expect(stderr).toContain("JWT signing key appended to:");
const saved = readFileSync(join(projectDir, "supabase", "signing_keys.json"), "utf8");
expect(JSON.parse(saved)).toHaveLength(1);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,9 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
import { LegacyCliConfig } from "../../../config/legacy-cli-config.service.ts";
import { findGitRootPath } from "../../../../shared/git/git-root.ts";
import { LegacyDebugLogger } from "../../../shared/legacy-debug-logger.service.ts";
import { legacyPromptYesNo } from "../../../shared/legacy-prompt-yes-no.ts";
import { LegacyTelemetryState } from "../../../telemetry/legacy-telemetry-state.service.ts";
import { LegacyYesFlag } from "../../../../shared/legacy/global-flags.ts";
import { legacyResolveYes } from "../../../../shared/legacy/global-flags.ts";
import { Output } from "../../../../shared/output/output.service.ts";
import { Tty } from "../../../../shared/runtime/tty.service.ts";
import type { LegacyGenSigningKeyFlags } from "./signing-key.command.ts";
Expand Down Expand Up @@ -246,25 +247,6 @@ const isGitIgnored = Effect.fnUntraced(function* (filePath: string, searchFrom:
.pipe(Effect.map((exitCode) => Option.some(Number(exitCode) === 0)));
});

const confirmOverwrite = Effect.fnUntraced(function* (title: string) {
const output = yield* Output;
const tty = yield* Tty;
const yes = yield* LegacyYesFlag;
if (yes) {
yield* output.raw(`${title} [Y/n] y\n`, "stderr");
return true;
}
if (!tty.stdinIsTty) {
yield* output.raw(`${title} [Y/n] \n`, "stderr");
return true;
}
// In json / stream-json mode `promptConfirm` fails with NonInteractiveError; treat that as a
// declined overwrite so the command cancels cleanly instead of corrupting the machine payload.
return yield* output
.promptConfirm(title, { defaultValue: true })
.pipe(Effect.orElseSucceed(() => false));
});

export const legacyGenSigningKey = Effect.fn("legacy.gen.signing-key")(function* (
flags: LegacyGenSigningKeyFlags,
) {
Expand All @@ -273,6 +255,7 @@ export const legacyGenSigningKey = Effect.fn("legacy.gen.signing-key")(function*
const telemetryState = yield* LegacyTelemetryState;
const output = yield* Output;
const tty = yield* Tty;
const yes = yield* legacyResolveYes;
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const emphasize = (text: string) => styleIfTty(tty.stdoutIsTty, "bold", text);
Expand All @@ -298,9 +281,30 @@ export const legacyGenSigningKey = Effect.fn("legacy.gen.signing-key")(function*
const nextKeys = flags.append
? [...configured.value.existingKeys, key]
: yield* Effect.gen(function* () {
const confirmed = yield* confirmOverwrite(
`Do you want to overwrite the existing ${emphasize(configured.value.displayPath)} file?`,
);
// `legacyPromptYesNo` silently returns the default (true) for any non-text
// `--output-format`, but this command has no structured json/stream-json output
// (SIDE_EFFECTS.md) — that combination only arises from a real interactive TTY
// explicitly requesting machine output. Fail closed rather than silently
// overwriting irrecoverable key material.
const confirmed =
!yes && tty.stdinIsTty && output.format !== "text"
? false
: yield* legacyPromptYesNo(
Comment thread
Coly010 marked this conversation as resolved.
// `legacyPromptYesNo` checks `output.format !== "text"` BEFORE it checks
// TTY, so a non-TTY (piped or empty) invocation under `json`/`stream-json`
// would otherwise hit that check first and return the default without
// ever reading stdin. Go's `console.PromptYesNo`
// (apps/cli-go/internal/utils/console.go:64-82) has no concept of output
// format at all — it always reads piped stdin — so a piped `y`/`n` answer
// must be honored here the same as in text mode. Present a text-shaped
// view of `output` to reach that read; `raw`/`promptConfirm` write the
// prompt to stderr under every `Output` layer, so this never touches the
// machine-readable stdout payload.
output.format === "text" ? output : { ...output, format: "text" },
yes,
`Do you want to overwrite the existing ${emphasize(configured.value.displayPath)} file?`,
true,
Comment thread
Coly010 marked this conversation as resolved.
);
if (!confirmed) {
return yield* Effect.fail(
new LegacyGenSigningKeyCancelledError({ message: "context canceled" }),
Expand Down
Loading
Loading