diff --git a/apps/cli/src/legacy/commands/gen/signing-key/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/gen/signing-key/SIDE_EFFECTS.md index 8cf887643d..2ea04a7fab 100644 --- a/apps/cli/src/legacy/commands/gen/signing-key/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/gen/signing-key/SIDE_EFFECTS.md @@ -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 @@ -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. - `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. diff --git a/apps/cli/src/legacy/commands/gen/signing-key/signing-key.command.ts b/apps/cli/src/legacy/commands/gen/signing-key/signing-key.command.ts index 35a026f682..3cbedfbc01 100644 --- a/apps/cli/src/legacy/commands/gen/signing-key/signing-key.command.ts +++ b/apps/cli/src/legacy/commands/gen/signing-key/signing-key.command.ts @@ -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"; @@ -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( diff --git a/apps/cli/src/legacy/commands/gen/signing-key/signing-key.e2e.test.ts b/apps/cli/src/legacy/commands/gen/signing-key/signing-key.e2e.test.ts new file mode 100644 index 0000000000..3a2b377ee4 --- /dev/null +++ b/apps/cli/src/legacy/commands/gen/signing-key/signing-key.e2e.test.ts @@ -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); + }); +}); diff --git a/apps/cli/src/legacy/commands/gen/signing-key/signing-key.handler.ts b/apps/cli/src/legacy/commands/gen/signing-key/signing-key.handler.ts index 6396c658c6..775a39140e 100644 --- a/apps/cli/src/legacy/commands/gen/signing-key/signing-key.handler.ts +++ b/apps/cli/src/legacy/commands/gen/signing-key/signing-key.handler.ts @@ -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"; @@ -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, ) { @@ -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); @@ -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( + // `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, + ); if (!confirmed) { return yield* Effect.fail( new LegacyGenSigningKeyCancelledError({ message: "context canceled" }), diff --git a/apps/cli/src/legacy/commands/gen/signing-key/signing-key.integration.test.ts b/apps/cli/src/legacy/commands/gen/signing-key/signing-key.integration.test.ts index 93ac85949d..549b3151f5 100644 --- a/apps/cli/src/legacy/commands/gen/signing-key/signing-key.integration.test.ts +++ b/apps/cli/src/legacy/commands/gen/signing-key/signing-key.integration.test.ts @@ -10,6 +10,7 @@ import { mockAnalytics, mockOutput, mockRuntimeInfo, + mockStdin, mockTty, processEnvLayer, } from "../../../../../tests/helpers/mocks.ts"; @@ -20,6 +21,7 @@ import { mockLegacyTelemetryStateTracked, useLegacyTempWorkdir, } from "../../../../../tests/helpers/legacy-mocks.ts"; +import { CliArgs } from "../../../../shared/cli/cli-args.service.ts"; import { LegacyDebugLogger } from "../../../shared/legacy-debug-logger.service.ts"; import { LEGACY_GLOBAL_FLAGS, LegacyYesFlag } from "../../../../shared/legacy/global-flags.ts"; import { textCliOutputFormatter } from "../../../../shared/output/text-formatter.ts"; @@ -32,6 +34,7 @@ import { legacyGenSigningKey } from "./signing-key.handler.ts"; const tempRoot = useLegacyTempWorkdir("supabase-gen-signing-key-int-"); interface SetupOptions { + readonly format?: "text" | "json" | "stream-json"; readonly stdinIsTty?: boolean; readonly yes?: boolean; readonly promptConfirmResponses?: ReadonlyArray; @@ -39,6 +42,10 @@ interface SetupOptions { // Exit code returned by the mocked `git check-ignore` subprocess. `0` means the path is // ignored, any non-zero code means it is not. Only consumed by the gitignore-warning branch. readonly gitCheckIgnoreExitCode?: number; + // Piped (non-TTY) stdin answer for the overwrite prompt (CLI-1865). + readonly pipedAnswer?: string; + // Raw argv for `legacyResolveYes`'s explicit `--yes=false` detection. + readonly cliArgs?: ReadonlyArray; } // `git check-ignore` is invoked via ChildProcessSpawner. Mock it with a controlled exit code so @@ -68,7 +75,7 @@ function mockGitCheckIgnore(exitCode: number) { function setup(options: SetupOptions = {}) { const out = mockOutput({ - format: "text", + format: options.format ?? "text", interactive: options.stdinIsTty ?? false, promptConfirmResponses: options.promptConfirmResponses, }); @@ -82,6 +89,8 @@ function setup(options: SetupOptions = {}) { const layer = Layer.mergeAll( buildLegacyTestRuntime({ out, api, cliConfig, tty, telemetry: telemetry?.layer }), Layer.succeed(LegacyYesFlag, options.yes ?? false), + Layer.succeed(CliArgs, { args: options.cliArgs ?? [] }), + mockStdin(options.stdinIsTty ?? false, options.pipedAnswer), Layer.succeed(LegacyDebugLogger, { debug: () => Effect.void, http: () => Effect.void, @@ -156,6 +165,8 @@ describe("legacy gen signing-key integration", () => { processEnvLayer({ SUPABASE_HOME: tempRoot.current }), mockRuntimeInfo({ cwd: tempRoot.current, homeDir: tempRoot.current }), mockTty({ stdinIsTty: false, stdoutIsTty: false }), + Layer.succeed(CliArgs, { args: [] }), + mockStdin(false), Layer.succeed( TelemetryRuntime, TelemetryRuntime.of({ @@ -203,8 +214,38 @@ describe("legacy gen signing-key integration", () => { }).pipe(Effect.provide(layer)); }); - it.live("overwrites the configured signing keys file and defaults to yes on non-tty", () => { - const { layer, out } = setup({ stdinIsTty: false }); + it.live( + "overwrites the configured signing keys file and defaults to yes on non-tty when stdin has no piped answer", + () => { + const { layer, out } = setup({ stdinIsTty: false }); + return Effect.gen(function* () { + yield* Effect.tryPromise(() => + writeConfig('[auth]\nsigning_keys_path = "./signing_keys.json"\n'), + ); + yield* Effect.tryPromise(() => + writeFile(join(tempRoot.current, "supabase", "signing_keys.json"), "[]\n"), + ); + + yield* legacyGenSigningKey({ algorithm: "RS256", append: false }); + + const saved = yield* Effect.tryPromise(() => + readFile(join(tempRoot.current, "supabase", "signing_keys.json"), "utf8"), + ); + const parsed = JSON.parse(saved) as ReadonlyArray>; + expect(parsed).toHaveLength(1); + expect(parsed[0]?.alg).toBe("RS256"); + expect(out.stderrText).toContain("Do you want to overwrite the existing"); + expect(out.stderrText).toContain("JWT signing key appended to: "); + expect(out.stderrText).toContain(join("supabase", "signing_keys.json")); + }).pipe(Effect.provide(layer)); + }, + ); + + // CLI-1865: Go's overwrite prompt reads piped stdin even in non-TTY mode and honors an + // explicit "n" — before this fix, TS returned `true` unconditionally without reading stdin at + // all, so `echo n | supabase gen signing-key` silently overwrote instead of canceling. + it.live("cancels the overwrite when a piped non-tty answer of 'n' is read", () => { + const { layer, out } = setup({ stdinIsTty: false, pipedAnswer: "n" }); return Effect.gen(function* () { yield* Effect.tryPromise(() => writeConfig('[auth]\nsigning_keys_path = "./signing_keys.json"\n'), @@ -213,17 +254,41 @@ describe("legacy gen signing-key integration", () => { writeFile(join(tempRoot.current, "supabase", "signing_keys.json"), "[]\n"), ); - yield* legacyGenSigningKey({ algorithm: "RS256", append: false }); + const exit = yield* Effect.exit(legacyGenSigningKey({ algorithm: "ES256", append: false })); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const json = JSON.stringify(exit.cause); + expect(json).toContain("LegacyGenSigningKeyCancelledError"); + expect(json).toContain("context canceled"); + } + + const saved = yield* Effect.tryPromise(() => + readFile(join(tempRoot.current, "supabase", "signing_keys.json"), "utf8"), + ); + expect(JSON.parse(saved)).toEqual([]); + // Go's non-TTY prompt echoes the piped answer back to stderr after the label. + expect(out.stderrText).toContain("[Y/n] n\n"); + }).pipe(Effect.provide(layer)); + }); + + it.live("overwrites when a piped non-tty answer of 'y' is read", () => { + const { layer, out } = setup({ stdinIsTty: false, pipedAnswer: "y" }); + return Effect.gen(function* () { + yield* Effect.tryPromise(() => + writeConfig('[auth]\nsigning_keys_path = "./signing_keys.json"\n'), + ); + yield* Effect.tryPromise(() => + writeFile(join(tempRoot.current, "supabase", "signing_keys.json"), "[]\n"), + ); + + yield* legacyGenSigningKey({ algorithm: "ES256", append: false }); const saved = yield* Effect.tryPromise(() => readFile(join(tempRoot.current, "supabase", "signing_keys.json"), "utf8"), ); const parsed = JSON.parse(saved) as ReadonlyArray>; expect(parsed).toHaveLength(1); - expect(parsed[0]?.alg).toBe("RS256"); expect(out.stderrText).toContain("Do you want to overwrite the existing"); - expect(out.stderrText).toContain("JWT signing key appended to: "); - expect(out.stderrText).toContain(join("supabase", "signing_keys.json")); }).pipe(Effect.provide(layer)); }); @@ -440,6 +505,156 @@ describe("legacy gen signing-key integration", () => { }).pipe(Effect.provide(layer)); }); + // This command has no structured json/stream-json output (SIDE_EFFECTS.md), so a real TTY + // requesting machine output is an unsupported combination — fail closed on this destructive, + // irreversible overwrite rather than silently defaulting to yes with no prompt at all. + it.live( + "declines the overwrite without prompting on a tty when --output-format is not text", + () => { + const { layer, out } = setup({ format: "json", stdinIsTty: true }); + return Effect.gen(function* () { + yield* Effect.tryPromise(() => + writeConfig('[auth]\nsigning_keys_path = "./signing_keys.json"\n'), + ); + yield* Effect.tryPromise(() => + writeFile(join(tempRoot.current, "supabase", "signing_keys.json"), "[]\n"), + ); + + const exit = yield* Effect.exit(legacyGenSigningKey({ algorithm: "ES256", append: false })); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const json = JSON.stringify(exit.cause); + expect(json).toContain("LegacyGenSigningKeyCancelledError"); + } + + const saved = yield* Effect.tryPromise(() => + readFile(join(tempRoot.current, "supabase", "signing_keys.json"), "utf8"), + ); + expect(JSON.parse(saved)).toEqual([]); + expect(out.promptConfirmCalls).toHaveLength(0); + }).pipe(Effect.provide(layer)); + }, + ); + + // CLI-1865 follow-up: `legacyPromptYesNo` checks `output.format !== "text"` BEFORE it + // checks TTY, so a non-TTY invocation under `json`/`stream-json` must not fall into that + // early return — this command has no structured json/stream-json payload, so a piped + // answer must be honored the same as text mode. Before this fix, a piped "n" here was + // silently ignored and the file was overwritten with the default (true). + it.live("honors a piped non-tty 'n' even when --output-format is json", () => { + const { layer, out } = setup({ format: "json", stdinIsTty: false, pipedAnswer: "n" }); + return Effect.gen(function* () { + yield* Effect.tryPromise(() => + writeConfig('[auth]\nsigning_keys_path = "./signing_keys.json"\n'), + ); + yield* Effect.tryPromise(() => + writeFile(join(tempRoot.current, "supabase", "signing_keys.json"), "[]\n"), + ); + + const exit = yield* Effect.exit(legacyGenSigningKey({ algorithm: "ES256", append: false })); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("LegacyGenSigningKeyCancelledError"); + } + + const saved = yield* Effect.tryPromise(() => + readFile(join(tempRoot.current, "supabase", "signing_keys.json"), "utf8"), + ); + expect(JSON.parse(saved)).toEqual([]); + expect(out.promptConfirmCalls).toHaveLength(0); + }).pipe(Effect.provide(layer)); + }); + + it.live("honors a piped non-tty 'y' when --output-format is stream-json", () => { + const { layer } = setup({ format: "stream-json", stdinIsTty: false, pipedAnswer: "y" }); + return Effect.gen(function* () { + yield* Effect.tryPromise(() => + writeConfig('[auth]\nsigning_keys_path = "./signing_keys.json"\n'), + ); + yield* Effect.tryPromise(() => + writeFile(join(tempRoot.current, "supabase", "signing_keys.json"), "[]\n"), + ); + + yield* legacyGenSigningKey({ algorithm: "ES256", append: false }); + + const saved = yield* Effect.tryPromise(() => + readFile(join(tempRoot.current, "supabase", "signing_keys.json"), "utf8"), + ); + expect(JSON.parse(saved) as ReadonlyArray).toHaveLength(1); + }).pipe(Effect.provide(layer)); + }); + + it.live("honors SUPABASE_YES and overwrites even when a piped 'n' is present", () => { + // Go reads `viper.GetBool("YES")` (incl. the SUPABASE_YES env var) BEFORE scanning + // stdin (`console.go:71`), so `SUPABASE_YES=1 printf 'n\n' | supabase gen signing-key` + // auto-confirms and overwrites rather than consuming the piped `n`. The handler + // resolves `yes` via `legacyResolveYes`, not the raw --yes flag. + const prev = process.env["SUPABASE_YES"]; + process.env["SUPABASE_YES"] = "1"; + const { layer } = setup({ stdinIsTty: false, pipedAnswer: "n" }); + return Effect.gen(function* () { + yield* Effect.tryPromise(() => + writeConfig('[auth]\nsigning_keys_path = "./signing_keys.json"\n'), + ); + yield* Effect.tryPromise(() => + writeFile(join(tempRoot.current, "supabase", "signing_keys.json"), "[]\n"), + ); + + yield* legacyGenSigningKey({ algorithm: "ES256", append: false }); + + const saved = yield* Effect.tryPromise(() => + readFile(join(tempRoot.current, "supabase", "signing_keys.json"), "utf8"), + ); + const parsed = JSON.parse(saved) as ReadonlyArray>; + expect(parsed).toHaveLength(1); + }).pipe( + Effect.ensuring( + Effect.sync(() => { + if (prev === undefined) delete process.env["SUPABASE_YES"]; + else process.env["SUPABASE_YES"] = prev; + }), + ), + Effect.provide(layer), + ); + }); + + it.live("an explicit --yes=false overrides SUPABASE_YES and honors a piped 'n'", () => { + const prev = process.env["SUPABASE_YES"]; + process.env["SUPABASE_YES"] = "1"; + const { layer } = setup({ + stdinIsTty: false, + pipedAnswer: "n", + cliArgs: ["gen", "signing-key", "--yes=false"], + }); + return Effect.gen(function* () { + yield* Effect.tryPromise(() => + writeConfig('[auth]\nsigning_keys_path = "./signing_keys.json"\n'), + ); + yield* Effect.tryPromise(() => + writeFile(join(tempRoot.current, "supabase", "signing_keys.json"), "[]\n"), + ); + + const exit = yield* Effect.exit(legacyGenSigningKey({ algorithm: "ES256", append: false })); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("LegacyGenSigningKeyCancelledError"); + } + + const saved = yield* Effect.tryPromise(() => + readFile(join(tempRoot.current, "supabase", "signing_keys.json"), "utf8"), + ); + expect(JSON.parse(saved)).toEqual([]); + }).pipe( + Effect.ensuring( + Effect.sync(() => { + if (prev === undefined) delete process.env["SUPABASE_YES"]; + else process.env["SUPABASE_YES"] = prev; + }), + ), + Effect.provide(layer), + ); + }); + it.live("flushes telemetry state after the command finishes", () => { const { layer, telemetry } = setup({ trackTelemetry: true }); return Effect.gen(function* () {