diff --git a/apps/cli/src/legacy/cli/agent-output.e2e.test.ts b/apps/cli/src/legacy/cli/agent-output.e2e.test.ts index 15c124caa2..8cce87cbfe 100644 --- a/apps/cli/src/legacy/cli/agent-output.e2e.test.ts +++ b/apps/cli/src/legacy/cli/agent-output.e2e.test.ts @@ -1,26 +1,5 @@ import { describe, expect, test } from "vitest"; -import { runSupabase } from "../../../tests/helpers/cli.ts"; - -function stripAnsi(output: string): string { - let stripped = ""; - for (let i = 0; i < output.length; i++) { - const charCode = output.charCodeAt(i); - if (charCode !== 0x1b || output[i + 1] !== "[") { - stripped += output[i]; - continue; - } - - i += 2; - while (i < output.length) { - const code = output.charCodeAt(i); - if (code >= 0x40 && code <= 0x7e) { - break; - } - i++; - } - } - return stripped; -} +import { runSupabase, stripAnsi } from "../../../tests/helpers/cli.ts"; function parseJsonLines(output: string): Array { return stripAnsi(output) diff --git a/apps/cli/src/legacy/commands/bootstrap/bootstrap.handler.ts b/apps/cli/src/legacy/commands/bootstrap/bootstrap.handler.ts index 0ed9b20a8a..8541a8a147 100644 --- a/apps/cli/src/legacy/commands/bootstrap/bootstrap.handler.ts +++ b/apps/cli/src/legacy/commands/bootstrap/bootstrap.handler.ts @@ -6,6 +6,7 @@ import { LegacyCliConfig } from "../../config/legacy-cli-config.service.ts"; import { LegacyLinkedProjectCache } from "../../telemetry/legacy-linked-project-cache.service.ts"; import { LegacyTelemetryState } from "../../telemetry/legacy-telemetry-state.service.ts"; import { LegacyWorkdirFlag, legacyResolveYes } from "../../../shared/legacy/global-flags.ts"; +import { CONTEXT_CANCELED_MESSAGE } from "../../../shared/output/errors.ts"; import { Output } from "../../../shared/output/output.service.ts"; import { LegacyGoProxy } from "../../../shared/legacy/go-proxy.service.ts"; import { RuntimeInfo } from "../../../shared/runtime/runtime-info.service.ts"; @@ -135,7 +136,9 @@ export const legacyBootstrap = Effect.fn("legacy.bootstrap")(function* ( { defaultValue: true }, ); if (!overwrite) { - return yield* new LegacyBootstrapOverwriteDeclinedError({ message: "context canceled" }); + return yield* new LegacyBootstrapOverwriteDeclinedError({ + message: CONTEXT_CANCELED_MESSAGE, + }); } } diff --git a/apps/cli/src/legacy/commands/branches/create/create.handler.ts b/apps/cli/src/legacy/commands/branches/create/create.handler.ts index 9cf9352b1f..dc013a2069 100644 --- a/apps/cli/src/legacy/commands/branches/create/create.handler.ts +++ b/apps/cli/src/legacy/commands/branches/create/create.handler.ts @@ -7,6 +7,7 @@ import { LegacyProjectRefResolver } from "../../../config/legacy-project-ref.ser import { LegacyLinkedProjectCache } from "../../../telemetry/legacy-linked-project-cache.service.ts"; import { LegacyTelemetryState } from "../../../telemetry/legacy-telemetry-state.service.ts"; import { LegacyOutputFlag } from "../../../../shared/legacy/global-flags.ts"; +import { CONTEXT_CANCELED_MESSAGE } from "../../../../shared/output/errors.ts"; import { Output } from "../../../../shared/output/output.service.ts"; import { detectGitBranch } from "../../../../shared/git/git-branch.ts"; import { @@ -68,7 +69,7 @@ export const legacyBranchesCreate = Effect.fn("legacy.branches.create")(function .promptConfirm(`Do you want to create a branch named ${gitBranch.value}?`) .pipe(Effect.orElseSucceed(() => true)); if (!confirmed) { - return yield* new LegacyBranchesCreateCancelledError({ message: "context canceled" }); + return yield* new LegacyBranchesCreateCancelledError({ message: CONTEXT_CANCELED_MESSAGE }); } branchName = gitBranch.value; if (gitBranchForBody === undefined) { diff --git a/apps/cli/src/legacy/commands/db/diff/diff.integration.test.ts b/apps/cli/src/legacy/commands/db/diff/diff.integration.test.ts index f4e6fb4ea6..f1a531c7a9 100644 --- a/apps/cli/src/legacy/commands/db/diff/diff.integration.test.ts +++ b/apps/cli/src/legacy/commands/db/diff/diff.integration.test.ts @@ -4,6 +4,7 @@ import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; import { Effect, Exit, Layer, Option } from "effect"; +import { stripAnsi } from "../../../../../tests/helpers/ansi.ts"; import { legacyFailWriteStringOnNthCallFsLayer, mockLegacyCliConfig, @@ -230,10 +231,6 @@ const flags = (over: Partial = {}): LegacyDbDiffFlags => ({ schema: over.schema ?? [], }); -// Strip ANSI so assertions are colour-independent: `legacyAqua`/`legacyYellow` -// emit colour only when the test runner's stderr is a TTY. -// eslint-disable-next-line no-control-regex -const stripAnsi = (text: string) => text.replace(/\x1b\[[0-9;]*m/gu, ""); const stdout = (out: ReturnType) => stripAnsi( out.rawChunks diff --git a/apps/cli/src/legacy/commands/db/pull/pull.debug.unit.test.ts b/apps/cli/src/legacy/commands/db/pull/pull.debug.unit.test.ts index dc83ad49db..5c4c5a36f5 100644 --- a/apps/cli/src/legacy/commands/db/pull/pull.debug.unit.test.ts +++ b/apps/cli/src/legacy/commands/db/pull/pull.debug.unit.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; +import { stripAnsi } from "../../../../../tests/helpers/ansi.ts"; import { legacyFormatByteSize, legacyFormatCatalogSummary, @@ -9,10 +10,6 @@ import { legacySummarizeCatalogJson, } from "./pull.debug.ts"; -// ANSI may wrap the bold debugDir; strip for assertions. -// eslint-disable-next-line no-control-regex -const stripAnsi = (text: string) => text.replace(/\x1b\[[0-9;]*m/gu, ""); - describe("legacyRedactPostgresURL", () => { it("replaces the password but keeps the username", () => { expect(legacyRedactPostgresURL("postgresql://postgres:secret@db.host:5432/postgres")).toBe( diff --git a/apps/cli/src/legacy/commands/db/pull/pull.integration.test.ts b/apps/cli/src/legacy/commands/db/pull/pull.integration.test.ts index 248c0be283..e26329b91c 100644 --- a/apps/cli/src/legacy/commands/db/pull/pull.integration.test.ts +++ b/apps/cli/src/legacy/commands/db/pull/pull.integration.test.ts @@ -4,6 +4,7 @@ import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; import { Effect, Exit, Layer, Option } from "effect"; +import { stripAnsi } from "../../../../../tests/helpers/ansi.ts"; import { legacyFailWriteStringOnNthCallFsLayer, mockLegacyCliConfig, @@ -304,8 +305,6 @@ const flags = (over: Partial = {}): LegacyDbPullFlags => ({ password: over.password ?? Option.none(), }); -// eslint-disable-next-line no-control-regex -const stripAnsi = (text: string) => text.replace(/\x1b\[[0-9;]*m/gu, ""); const streamText = (out: ReturnType, stream: "stdout" | "stderr") => stripAnsi( out.rawChunks diff --git a/apps/cli/src/legacy/commands/db/push/push.handler.ts b/apps/cli/src/legacy/commands/db/push/push.handler.ts index 47ea574459..5887c04583 100644 --- a/apps/cli/src/legacy/commands/db/push/push.handler.ts +++ b/apps/cli/src/legacy/commands/db/push/push.handler.ts @@ -3,6 +3,7 @@ import { Clock, Effect, FileSystem, Option, Path } from "effect"; import { CliArgs } from "../../../../shared/cli/cli-args.service.ts"; import { LegacyDnsResolverFlag } from "../../../../shared/legacy/global-flags.ts"; import { legacyResolveYesWithProjectEnv } from "../../../../shared/legacy/global-flags.ts"; +import { CONTEXT_CANCELED_MESSAGE } from "../../../../shared/output/errors.ts"; import { Output } from "../../../../shared/output/output.service.ts"; import { LegacyCliConfig } from "../../../config/legacy-cli-config.service.ts"; import { LegacyProjectRefResolver } from "../../../config/legacy-project-ref.service.ts"; @@ -268,7 +269,7 @@ export const legacyDbPush = Effect.fn("legacy.db.push")(function* (flags: Legacy ); if (!ok) { return yield* Effect.fail( - new LegacyDbPushCancelledError({ message: "context canceled" }), + new LegacyDbPushCancelledError({ message: CONTEXT_CANCELED_MESSAGE }), ); } yield* legacySeedGlobals( @@ -290,7 +291,7 @@ export const legacyDbPush = Effect.fn("legacy.db.push")(function* (flags: Legacy ); if (!ok) { return yield* Effect.fail( - new LegacyDbPushCancelledError({ message: "context canceled" }), + new LegacyDbPushCancelledError({ message: CONTEXT_CANCELED_MESSAGE }), ); } yield* legacyUpsertVaultSecrets(session, vaultSecrets); @@ -333,7 +334,7 @@ export const legacyDbPush = Effect.fn("legacy.db.push")(function* (flags: Legacy ); if (!ok) { return yield* Effect.fail( - new LegacyDbPushCancelledError({ message: "context canceled" }), + new LegacyDbPushCancelledError({ message: CONTEXT_CANCELED_MESSAGE }), ); } yield* legacySeedData(session, fs, workdir, path, seeds, applyError); diff --git a/apps/cli/src/legacy/commands/db/reset/reset.e2e.test.ts b/apps/cli/src/legacy/commands/db/reset/reset.e2e.test.ts new file mode 100644 index 0000000000..7de2270883 --- /dev/null +++ b/apps/cli/src/legacy/commands/db/reset/reset.e2e.test.ts @@ -0,0 +1,44 @@ +import { mkdirSync, mkdtempSync, 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, stripAnsi } from "../../../../../tests/helpers/cli.ts"; + +const E2E_TIMEOUT_MS = 30_000; + +describe("supabase db reset (legacy)", () => { + let workdir: string; + beforeEach(() => { + workdir = mkdtempSync(join(tmpdir(), "sb-db-reset-e2e-")); + mkdirSync(join(workdir, "supabase"), { recursive: true }); + writeFileSync(join(workdir, "supabase", "config.toml"), "[db]\nport = 54322\n"); + }); + afterEach(() => { + rmSync(workdir, { recursive: true, force: true }); + }); + + // Docker-free: the destructive remote-reset confirmation fires after the config + // load and BEFORE any connection is dialed, so a piped decline exits without a + // database. Declining must byte-match Go: a single `context canceled` line on + // stderr and exit 1, with NO `--debug` troubleshooting hint — `recoverAndExit` + // skips `SuggestDebugFlag` for `context.Canceled` (apps/cli-go/cmd/root.go:287-303). + // CLI-1973. + test( + "declining the remote reset prompt prints only context canceled, no --debug hint", + { timeout: E2E_TIMEOUT_MS }, + async () => { + const { exitCode, stderr } = await runSupabase( + ["db", "reset", "--db-url", "postgresql://postgres:postgres@127.0.0.1:9999/postgres"], + { entrypoint: "legacy", cwd: workdir, stdin: "n\n" }, + ); + expect(exitCode).toBe(1); + // The destructive confirmation (default No → `[y/N]`) actually rendered and + // was answered — the cancellation didn't come from some other failure path. + 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"); + }, + ); +}); diff --git a/apps/cli/src/legacy/commands/db/reset/reset.handler.ts b/apps/cli/src/legacy/commands/db/reset/reset.handler.ts index 4b5fc92782..c2ef78bab3 100644 --- a/apps/cli/src/legacy/commands/db/reset/reset.handler.ts +++ b/apps/cli/src/legacy/commands/db/reset/reset.handler.ts @@ -8,6 +8,7 @@ import { legacyResolveYesWithProjectEnv, } from "../../../../shared/legacy/global-flags.ts"; import { LegacyGoProxy } from "../../../../shared/legacy/go-proxy.service.ts"; +import { CONTEXT_CANCELED_MESSAGE } from "../../../../shared/output/errors.ts"; import { Output } from "../../../../shared/output/output.service.ts"; import { legacyAqua, legacyYellow } from "../../../shared/legacy-colors.ts"; import { LegacyCliConfig } from "../../../config/legacy-cli-config.service.ts"; @@ -415,7 +416,9 @@ export const legacyDbReset = Effect.fn("legacy.db.reset")(function* (flags: Lega false, ); if (!shouldReset) { - return yield* Effect.fail(new LegacyDbResetCancelledError({ message: "context canceled" })); + return yield* Effect.fail( + new LegacyDbResetCancelledError({ message: CONTEXT_CANCELED_MESSAGE }), + ); } yield* output.raw(`Resetting remote database${toLogMessage(resolvedVersion)}\n`, "stderr"); diff --git a/apps/cli/src/legacy/commands/db/schema/declarative/declarative.gate.unit.test.ts b/apps/cli/src/legacy/commands/db/schema/declarative/declarative.gate.unit.test.ts index f605be37fe..030c0890ea 100644 --- a/apps/cli/src/legacy/commands/db/schema/declarative/declarative.gate.unit.test.ts +++ b/apps/cli/src/legacy/commands/db/schema/declarative/declarative.gate.unit.test.ts @@ -1,6 +1,7 @@ import { Cause, Effect, Exit } from "effect"; import { describe, expect, it } from "vitest"; +import { stripAnsi } from "../../../../../../tests/helpers/ansi.ts"; import { LegacyDeclarativeNotEnabledError } from "./declarative.errors.ts"; import { legacyIsPgDeltaEnabled, @@ -8,12 +9,6 @@ import { legacyRequirePgDelta, } from "./declarative.gate.ts"; -// `legacyAqua`/`legacyBold` colour their tokens when stderr is a TTY (matching -// Go's lipgloss). Strip ANSI so the assertions validate text content exactly, -// independent of the runner's colour profile. -const stripAnsi = (text: string) => - text.replace(new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, "g"), ""); - const EXPECTED_SUGGESTION = "Either pass --experimental or add [experimental.pgdelta] with enabled = true to supabase/config.toml"; 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 index 3a2b377ee4..af5c6b3474 100644 --- 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 @@ -43,6 +43,8 @@ describe("supabase gen signing-key (legacy)", () => { }); expect(exitCode).toBe(1); expect(stderr).toContain("context canceled"); + // No SuggestDebugFlag fallback for context.Canceled (cmd/root.go:287-303, CLI-1973). + expect(stderr).not.toContain("Try rerunning the command with --debug"); expect(stderr).not.toContain("Service not found"); const saved = readFileSync(join(projectDir, "supabase", "signing_keys.json"), "utf8"); expect(JSON.parse(saved)).toEqual([]); 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 87a734da73..9dcef82d60 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 @@ -11,6 +11,7 @@ import { LegacyDebugLogger } from "../../../shared/legacy-debug-logger.service.t import { legacyPromptYesNo } from "../../../shared/legacy-prompt-yes-no.ts"; import { LegacyTelemetryState } from "../../../telemetry/legacy-telemetry-state.service.ts"; import { legacyResolveYesWithProjectEnv } from "../../../../shared/legacy/global-flags.ts"; +import { CONTEXT_CANCELED_MESSAGE } from "../../../../shared/output/errors.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"; @@ -316,7 +317,7 @@ export const legacyGenSigningKey = Effect.fn("legacy.gen.signing-key")(function* ); if (!confirmed) { return yield* Effect.fail( - new LegacyGenSigningKeyCancelledError({ message: "context canceled" }), + new LegacyGenSigningKeyCancelledError({ message: CONTEXT_CANCELED_MESSAGE }), ); } return [key]; diff --git a/apps/cli/src/legacy/commands/logout/logout.e2e.test.ts b/apps/cli/src/legacy/commands/logout/logout.e2e.test.ts index 5fb3d31ab2..d039a3d25b 100644 --- a/apps/cli/src/legacy/commands/logout/logout.e2e.test.ts +++ b/apps/cli/src/legacy/commands/logout/logout.e2e.test.ts @@ -3,7 +3,7 @@ import { join } from "node:path"; import { describe, expect, test } from "vitest"; -import { makeTempHome, runSupabase } from "../../../../tests/helpers/cli.ts"; +import { makeTempHome, runSupabase, stripAnsi } from "../../../../tests/helpers/cli.ts"; const E2E_TIMEOUT_MS = 30_000; const VALID_TOKEN = "sbp_" + "a".repeat(40); @@ -37,6 +37,29 @@ describe("supabase logout (legacy)", () => { }, ); + // Declining the confirmation must byte-match Go: a single `context canceled` + // line on stderr and exit 1, with NO `--debug` troubleshooting hint — + // `recoverAndExit` skips `SuggestDebugFlag` for `context.Canceled` + // (apps/cli-go/cmd/root.go:287-303). CLI-1973. + test( + "declining the logout prompt prints only context canceled, no --debug hint", + { timeout: E2E_TIMEOUT_MS }, + async () => { + using home = makeTempHome(); + seedTokenFile(home.dir); + const { exitCode, stderr } = await runSupabase(["logout"], { + entrypoint: "legacy", + home: home.dir, + env: { HOME: home.dir }, + stdin: "n\n", + }); + expect(exitCode).toBe(1); + const lines = stripAnsi(stderr).trimEnd().split("\n"); + expect(lines.at(-1)).toBe("context canceled"); + expect(stderr).not.toContain("Try rerunning the command with --debug"); + }, + ); + // No token at all: same not-logged-in message, exit 0. test( "logout --yes with no token reports not-logged-in and exits 0", diff --git a/apps/cli/src/legacy/commands/logout/logout.errors.ts b/apps/cli/src/legacy/commands/logout/logout.errors.ts index 1f3dd768f6..a7ced0612c 100644 --- a/apps/cli/src/legacy/commands/logout/logout.errors.ts +++ b/apps/cli/src/legacy/commands/logout/logout.errors.ts @@ -2,13 +2,14 @@ import { Data } from "effect"; /** * Raised when the user declines the logout confirmation prompt. Go returns - * `errors.New(context.Canceled)` (`apps/cli-go/internal/logout/logout.go:18`), + * `errors.New(context.Canceled)` (`apps/cli-go/internal/logout/logout.go:19`), * which the root error handler renders as `context canceled` on stderr with - * exit code 1 (`cmd/root.go:288-301` skips the debug suggestion for - * `context.Canceled`). + * exit code 1 and no `--debug` suggestion (`cmd/root.go:287-303` skips + * `SuggestDebugFlag` for `context.Canceled`). The TS renderer mirrors that: + * constructing this error with `CONTEXT_CANCELED_MESSAGE` + * (`shared/output/errors.ts`) is what makes the text `Output.fail` withhold + * the debug hint (CLI-1973). */ export class LegacyLogoutCancelledError extends Data.TaggedError("LegacyLogoutCancelledError")<{ readonly message: string; }> {} - -export const LEGACY_LOGOUT_CANCELLED_MESSAGE = "context canceled"; diff --git a/apps/cli/src/legacy/commands/logout/logout.handler.ts b/apps/cli/src/legacy/commands/logout/logout.handler.ts index 8b4a606249..a89bd0fad1 100644 --- a/apps/cli/src/legacy/commands/logout/logout.handler.ts +++ b/apps/cli/src/legacy/commands/logout/logout.handler.ts @@ -3,9 +3,10 @@ import { Effect } from "effect"; import { LegacyCredentials } from "../../auth/legacy-credentials.service.ts"; import { LegacyTelemetryState } from "../../telemetry/legacy-telemetry-state.service.ts"; import { legacyResolveYes } from "../../../shared/legacy/global-flags.ts"; +import { CONTEXT_CANCELED_MESSAGE } from "../../../shared/output/errors.ts"; import { Output } from "../../../shared/output/output.service.ts"; import { legacyPromptYesNo } from "../../shared/legacy-prompt-yes-no.ts"; -import { LegacyLogoutCancelledError, LEGACY_LOGOUT_CANCELLED_MESSAGE } from "./logout.errors.ts"; +import { LegacyLogoutCancelledError } from "./logout.errors.ts"; const LOGGED_OUT_MSG = "Access token deleted successfully. You are now logged out."; @@ -37,7 +38,7 @@ export const legacyLogout = Effect.fn("legacy.logout")(function* () { }); if (!confirmed) { return yield* Effect.fail( - new LegacyLogoutCancelledError({ message: LEGACY_LOGOUT_CANCELLED_MESSAGE }), + new LegacyLogoutCancelledError({ message: CONTEXT_CANCELED_MESSAGE }), ); } diff --git a/apps/cli/src/legacy/commands/migration/down/down.handler.ts b/apps/cli/src/legacy/commands/migration/down/down.handler.ts index d646db1782..69c7fc46a0 100644 --- a/apps/cli/src/legacy/commands/migration/down/down.handler.ts +++ b/apps/cli/src/legacy/commands/migration/down/down.handler.ts @@ -5,6 +5,7 @@ import { legacyResolveYesWithProjectEnv, } from "../../../../shared/legacy/global-flags.ts"; import { CliArgs } from "../../../../shared/cli/cli-args.service.ts"; +import { CONTEXT_CANCELED_MESSAGE } from "../../../../shared/output/errors.ts"; import { Output } from "../../../../shared/output/output.service.ts"; import { LegacyCliConfig } from "../../../config/legacy-cli-config.service.ts"; import { LegacyProjectRefResolver } from "../../../config/legacy-project-ref.service.ts"; @@ -138,7 +139,7 @@ const runDown = Effect.fnUntraced(function* ( ); if (!confirmed) { return yield* Effect.fail( - new LegacyOperationCanceledError({ message: "context canceled" }), + new LegacyOperationCanceledError({ message: CONTEXT_CANCELED_MESSAGE }), ); } diff --git a/apps/cli/src/legacy/commands/migration/down/down.integration.test.ts b/apps/cli/src/legacy/commands/migration/down/down.integration.test.ts index 57c15c999e..b39174673c 100644 --- a/apps/cli/src/legacy/commands/migration/down/down.integration.test.ts +++ b/apps/cli/src/legacy/commands/migration/down/down.integration.test.ts @@ -5,6 +5,7 @@ import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; import { Cause, Effect, Exit, Layer, Option } from "effect"; +import { stripAnsi } from "../../../../../tests/helpers/ansi.ts"; import { LEGACY_VALID_REF, mockLegacyCliConfig, @@ -151,8 +152,6 @@ const flags = (over: Partial = {}): LegacyMigrationDow local: over.local ?? true, }); -// eslint-disable-next-line no-control-regex -const stripAnsi = (text: string) => text.replace(/\x1b\[[0-9;]*m/gu, ""); const seed = (workdir: string, name: string, body = "create table a;\n") => { const dir = join(workdir, "supabase", "migrations"); mkdirSync(dir, { recursive: true }); diff --git a/apps/cli/src/legacy/commands/migration/fetch/fetch.e2e.test.ts b/apps/cli/src/legacy/commands/migration/fetch/fetch.e2e.test.ts index ec8c28b274..48ac38e76b 100644 --- a/apps/cli/src/legacy/commands/migration/fetch/fetch.e2e.test.ts +++ b/apps/cli/src/legacy/commands/migration/fetch/fetch.e2e.test.ts @@ -3,13 +3,10 @@ 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"; +import { runSupabase, stripAnsi } from "../../../../../tests/helpers/cli.ts"; const E2E_TIMEOUT_MS = 30_000; -// eslint-disable-next-line no-control-regex -const stripAnsi = (text: string) => text.replace(/\x1b\[[0-9;]*m/gu, ""); - describe("supabase migration fetch (legacy)", () => { let workdir: string; beforeEach(() => { @@ -40,9 +37,15 @@ describe("supabase migration fetch (legacy)", () => { stdin: "n\n", }); - // Declined → cancelled (non-zero), and the Go-style prompt label reached stderr. - expect(exitCode).not.toBe(0); + // Declined → cancelled (exit 1), and the Go-style prompt label reached stderr. + expect(exitCode).toBe(1); expect(stripAnsi(stderr)).toContain("[Y/n]"); + // Byte-parity with Go's `recoverAndExit` (apps/cli-go/cmd/root.go:287-303): + // a declined prompt renders a lone `context canceled` line, with NO + // `SuggestDebugFlag` troubleshooting hint appended. CLI-1973. + const lines = stripAnsi(stderr).trimEnd().split("\n"); + expect(lines.at(-1)).toBe("context canceled"); + expect(stderr).not.toContain("Try rerunning the command with --debug"); // The existing file was NOT overwritten — the piped answer was honored. expect(readdirSync(join(workdir, "supabase", "migrations"))).toEqual([ "20240101000000_existing.sql", diff --git a/apps/cli/src/legacy/commands/migration/fetch/fetch.handler.ts b/apps/cli/src/legacy/commands/migration/fetch/fetch.handler.ts index 6fadfea2b4..e5bc6f29c2 100644 --- a/apps/cli/src/legacy/commands/migration/fetch/fetch.handler.ts +++ b/apps/cli/src/legacy/commands/migration/fetch/fetch.handler.ts @@ -5,6 +5,7 @@ import { legacyResolveYesWithProjectEnv, } from "../../../../shared/legacy/global-flags.ts"; import { CliArgs } from "../../../../shared/cli/cli-args.service.ts"; +import { CONTEXT_CANCELED_MESSAGE } from "../../../../shared/output/errors.ts"; import { Output } from "../../../../shared/output/output.service.ts"; import { LegacyCliConfig } from "../../../config/legacy-cli-config.service.ts"; import { LegacyProjectRefResolver } from "../../../config/legacy-project-ref.service.ts"; @@ -114,7 +115,7 @@ const runFetch = Effect.fnUntraced(function* ( const overwrite = yield* legacyMigrationConfirm(title, { defaultValue: true, yes }); if (!overwrite) { return yield* Effect.fail( - new LegacyOperationCanceledError({ message: "context canceled" }), + new LegacyOperationCanceledError({ message: CONTEXT_CANCELED_MESSAGE }), ); } } diff --git a/apps/cli/src/legacy/commands/migration/list/list.integration.test.ts b/apps/cli/src/legacy/commands/migration/list/list.integration.test.ts index 549dfad5e4..fb9a9c3907 100644 --- a/apps/cli/src/legacy/commands/migration/list/list.integration.test.ts +++ b/apps/cli/src/legacy/commands/migration/list/list.integration.test.ts @@ -4,6 +4,7 @@ import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; import { Cause, Effect, Exit, Layer, Option } from "effect"; +import { stripAnsi } from "../../../../../tests/helpers/ansi.ts"; import { LEGACY_VALID_REF, mockLegacyCliConfig, @@ -108,9 +109,6 @@ function setup(workdir: string, opts: SetupOpts = {}) { }; } -// eslint-disable-next-line no-control-regex -const stripAnsi = (text: string) => text.replace(/\x1b\[[0-9;]*m/gu, ""); - const flags = (over: Partial = {}): LegacyMigrationListFlags => ({ dbUrl: over.dbUrl ?? Option.none(), linked: over.linked ?? true, diff --git a/apps/cli/src/legacy/commands/migration/new/new.e2e.test.ts b/apps/cli/src/legacy/commands/migration/new/new.e2e.test.ts index 1c2f012fbd..08270e5b37 100644 --- a/apps/cli/src/legacy/commands/migration/new/new.e2e.test.ts +++ b/apps/cli/src/legacy/commands/migration/new/new.e2e.test.ts @@ -3,17 +3,10 @@ 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"; +import { runSupabase, stripAnsi } from "../../../../../tests/helpers/cli.ts"; const E2E_TIMEOUT_MS = 30_000; -// Strip ANSI so the assertion is colour-independent: the handler prints the path -// via `legacyBold`, which emits bold escapes under CI's `FORCE_COLOR` even on a -// piped stdout. The text content is the parity contract, not the colour. Mirrors -// `new.integration.test.ts`. -// eslint-disable-next-line no-control-regex -const stripAnsi = (text: string) => text.replace(/\x1b\[[0-9;]*m/gu, ""); - describe("supabase migration new (legacy)", () => { let workdir: string; beforeEach(() => { diff --git a/apps/cli/src/legacy/commands/migration/new/new.integration.test.ts b/apps/cli/src/legacy/commands/migration/new/new.integration.test.ts index caa3c051d4..2d8c4b02cb 100644 --- a/apps/cli/src/legacy/commands/migration/new/new.integration.test.ts +++ b/apps/cli/src/legacy/commands/migration/new/new.integration.test.ts @@ -5,6 +5,7 @@ import { describe, expect, it } from "@effect/vitest"; import { Cause, Effect, Exit, Layer, Option, Stream } from "effect"; import { badArgument } from "effect/PlatformError"; +import { stripAnsi } from "../../../../../tests/helpers/ansi.ts"; import { mockLegacyCliConfig, mockLegacyTelemetryStateTracked, @@ -35,10 +36,6 @@ function setup(workdir: string, opts: SetupOpts = {}) { return { layer, out, telemetry }; } -// Strip ANSI so assertions are colour-independent (`legacyBold` emits colour on a TTY). -// eslint-disable-next-line no-control-regex -const stripAnsi = (text: string) => text.replace(/\x1b\[[0-9;]*m/gu, ""); - const tmp = useLegacyTempWorkdir(); const migrationsDir = (workdir: string) => join(workdir, "supabase", "migrations"); diff --git a/apps/cli/src/legacy/commands/migration/repair/repair.handler.ts b/apps/cli/src/legacy/commands/migration/repair/repair.handler.ts index ea6727d68d..3f6d5578a5 100644 --- a/apps/cli/src/legacy/commands/migration/repair/repair.handler.ts +++ b/apps/cli/src/legacy/commands/migration/repair/repair.handler.ts @@ -5,6 +5,7 @@ import { legacyResolveYesWithProjectEnv, } from "../../../../shared/legacy/global-flags.ts"; import { CliArgs } from "../../../../shared/cli/cli-args.service.ts"; +import { CONTEXT_CANCELED_MESSAGE } from "../../../../shared/output/errors.ts"; import { Output } from "../../../../shared/output/output.service.ts"; import { LegacyCliConfig } from "../../../config/legacy-cli-config.service.ts"; import { LegacyProjectRefResolver } from "../../../config/legacy-project-ref.service.ts"; @@ -200,7 +201,7 @@ const runRepair = Effect.fnUntraced(function* ( ); if (!confirmed) { return yield* Effect.fail( - new LegacyOperationCanceledError({ message: "context canceled" }), + new LegacyOperationCanceledError({ message: CONTEXT_CANCELED_MESSAGE }), ); } versions = yield* legacyLoadLocalVersions(fs, path, migrationsDir); diff --git a/apps/cli/src/legacy/commands/migration/repair/repair.integration.test.ts b/apps/cli/src/legacy/commands/migration/repair/repair.integration.test.ts index b6737c053b..a7d335cddd 100644 --- a/apps/cli/src/legacy/commands/migration/repair/repair.integration.test.ts +++ b/apps/cli/src/legacy/commands/migration/repair/repair.integration.test.ts @@ -4,6 +4,7 @@ import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; import { Cause, Effect, Exit, Layer, Option } from "effect"; +import { stripAnsi } from "../../../../../tests/helpers/ansi.ts"; import { LEGACY_VALID_REF, mockLegacyCliConfig, @@ -133,8 +134,6 @@ const input = (over: Partial = {}): LegacyMigrationR password: over.password ?? Option.none(), }); -// eslint-disable-next-line no-control-regex -const stripAnsi = (text: string) => text.replace(/\x1b\[[0-9;]*m/gu, ""); const seedMigration = (workdir: string, name: string, body: string) => { const dir = join(workdir, "supabase", "migrations"); mkdirSync(dir, { recursive: true }); diff --git a/apps/cli/src/legacy/commands/migration/up/up.integration.test.ts b/apps/cli/src/legacy/commands/migration/up/up.integration.test.ts index 733b83cdf1..e25320e3aa 100644 --- a/apps/cli/src/legacy/commands/migration/up/up.integration.test.ts +++ b/apps/cli/src/legacy/commands/migration/up/up.integration.test.ts @@ -4,6 +4,7 @@ import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; import { Cause, Effect, Exit, Layer, Option } from "effect"; +import { stripAnsi } from "../../../../../tests/helpers/ansi.ts"; import { LEGACY_VALID_REF, mockLegacyCliConfig, @@ -126,8 +127,6 @@ const flags = (over: Partial = {}): LegacyMigrationUpFla local: over.local ?? true, }); -// eslint-disable-next-line no-control-regex -const stripAnsi = (text: string) => text.replace(/\x1b\[[0-9;]*m/gu, ""); const seed = (workdir: string, name: string, body = "create table a;\n") => { const dir = join(workdir, "supabase", "migrations"); mkdirSync(dir, { recursive: true }); diff --git a/apps/cli/src/legacy/commands/projects/delete/delete.handler.ts b/apps/cli/src/legacy/commands/projects/delete/delete.handler.ts index a3b7855b72..5dd1d9f1a9 100644 --- a/apps/cli/src/legacy/commands/projects/delete/delete.handler.ts +++ b/apps/cli/src/legacy/commands/projects/delete/delete.handler.ts @@ -13,6 +13,7 @@ import { import { LegacyLinkedProjectCache } from "../../../telemetry/legacy-linked-project-cache.service.ts"; import { LegacyTelemetryState } from "../../../telemetry/legacy-telemetry-state.service.ts"; import { LegacyYesFlag } from "../../../../shared/legacy/global-flags.ts"; +import { CONTEXT_CANCELED_MESSAGE } from "../../../../shared/output/errors.ts"; import { Output } from "../../../../shared/output/output.service.ts"; import { Tty } from "../../../../shared/runtime/tty.service.ts"; import { mapLegacyHttpError } from "../../../shared/legacy-http-errors.ts"; @@ -81,7 +82,7 @@ export const legacyProjectsDelete = Effect.fn("legacy.projects.delete")(function confirmed = yield* output.promptConfirm(title).pipe(Effect.orElseSucceed(() => false)); } if (!confirmed) { - return yield* new LegacyProjectsDeleteCancelledError({ message: "context canceled" }); + return yield* new LegacyProjectsDeleteCancelledError({ message: CONTEXT_CANCELED_MESSAGE }); } const mapDeleteError = mapLegacyHttpError({ diff --git a/apps/cli/src/legacy/commands/secrets/unset/unset.handler.ts b/apps/cli/src/legacy/commands/secrets/unset/unset.handler.ts index 726c8e7b88..f12126477c 100644 --- a/apps/cli/src/legacy/commands/secrets/unset/unset.handler.ts +++ b/apps/cli/src/legacy/commands/secrets/unset/unset.handler.ts @@ -6,6 +6,7 @@ import { LegacyProjectRefResolver } from "../../../config/legacy-project-ref.ser import { LegacyLinkedProjectCache } from "../../../telemetry/legacy-linked-project-cache.service.ts"; import { LegacyTelemetryState } from "../../../telemetry/legacy-telemetry-state.service.ts"; import { legacyResolveYes } from "../../../../shared/legacy/global-flags.ts"; +import { CONTEXT_CANCELED_MESSAGE } from "../../../../shared/output/errors.ts"; import { Output } from "../../../../shared/output/output.service.ts"; import { Tty } from "../../../../shared/runtime/tty.service.ts"; import { mapLegacyHttpError } from "../../../shared/legacy-http-errors.ts"; @@ -87,7 +88,7 @@ export const legacySecretsUnset = Effect.fn("legacy.secrets.unset")(function* ( if (!confirmed) { return yield* Effect.fail( - new LegacySecretsUnsetCancelledError({ message: "context canceled" }), + new LegacySecretsUnsetCancelledError({ message: CONTEXT_CANCELED_MESSAGE }), ); } diff --git a/apps/cli/src/legacy/commands/start/start.e2e.test.ts b/apps/cli/src/legacy/commands/start/start.e2e.test.ts index 6b9932f1e7..9f6bb9eb44 100644 --- a/apps/cli/src/legacy/commands/start/start.e2e.test.ts +++ b/apps/cli/src/legacy/commands/start/start.e2e.test.ts @@ -3,16 +3,10 @@ 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"; +import { runSupabase, stripAnsi } from "../../../../tests/helpers/cli.ts"; const E2E_TIMEOUT_MS = 30_000; -// Strip ANSI so the assertion is colour-independent — `legacyPartitionStartExcludeFlags` -// styles the warning via `legacy-colors.ts`, matching `start.exclude.unit.test.ts`'s -// own convention for the same text. -// eslint-disable-next-line no-control-regex -const stripAnsi = (text: string) => text.replace(/\x1b\[[0-9;]*m/gu, ""); - describe("supabase start (legacy)", () => { let projectDir: string; diff --git a/apps/cli/src/legacy/commands/start/start.exclude.unit.test.ts b/apps/cli/src/legacy/commands/start/start.exclude.unit.test.ts index 6964f40140..93f65230d8 100644 --- a/apps/cli/src/legacy/commands/start/start.exclude.unit.test.ts +++ b/apps/cli/src/legacy/commands/start/start.exclude.unit.test.ts @@ -1,14 +1,8 @@ import { describe, expect, it } from "vitest"; +import { stripAnsi } from "../../../../tests/helpers/ansi.ts"; import { LEGACY_START_EXCLUDABLE_KEYS, legacyPartitionStartExcludeFlags } from "./start.exclude.ts"; -// The warning applies Go-parity ANSI styling via `legacy-colors.ts`, which -// no-ops on a real non-TTY stream but the vitest process presents its stderr -// as color-capable. Strip escapes so these assertions target the plain text -// content — matching `status.pretty.unit.test.ts`'s convention. -// eslint-disable-next-line no-control-regex -const stripAnsi = (text: string) => text.replace(/\x1b\[[0-9;]*m/gu, ""); - // Go's `ExcludableContainers()` order (`apps/cli-go/internal/start/start.go: // 1297-1303` walking `config.Images.Services()`, // `apps/cli-go/pkg/config/constants.go:60-76`), expressed as the exact diff --git a/apps/cli/src/legacy/commands/start/start.format.unit.test.ts b/apps/cli/src/legacy/commands/start/start.format.unit.test.ts index 3f104d7a11..e2fd2310d6 100644 --- a/apps/cli/src/legacy/commands/start/start.format.unit.test.ts +++ b/apps/cli/src/legacy/commands/start/start.format.unit.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; +import { stripAnsi } from "../../../../tests/helpers/ansi.ts"; import { LEGACY_START_STARTING_CONTAINERS_MESSAGE, LEGACY_START_STARTING_DATABASE_FROM_BACKUP_MESSAGE, @@ -10,13 +11,6 @@ import { legacyStartSecurityNotice, } from "./start.format.ts"; -// The formatters apply Go-parity ANSI styling via `legacy-colors.ts`, which -// no-ops on a real non-TTY stream but the vitest process presents its stderr -// as color-capable. Strip escapes so these assertions target the plain text -// content — matching `status.pretty.unit.test.ts`'s convention. -// eslint-disable-next-line no-control-regex -const stripAnsi = (text: string) => text.replace(/\x1b\[[0-9;]*m/gu, ""); - describe("legacyStartAlreadyRunningMessage", () => { it("matches Go's exact stderr line, with a single trailing newline", () => { expect(stripAnsi(legacyStartAlreadyRunningMessage())).toBe( diff --git a/apps/cli/src/legacy/shared/legacy-migration-history.unit.test.ts b/apps/cli/src/legacy/shared/legacy-migration-history.unit.test.ts index 8e61417ba1..f20b6c049f 100644 --- a/apps/cli/src/legacy/shared/legacy-migration-history.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-migration-history.unit.test.ts @@ -1,6 +1,7 @@ import { Effect, Exit } from "effect"; import { describe, expect, it } from "vitest"; +import { stripAnsi } from "../../../tests/helpers/ansi.ts"; import { LegacyDbExecError } from "./legacy-db-connection.errors.ts"; import type { LegacyDbSession } from "./legacy-db-connection.service.ts"; import { @@ -21,10 +22,6 @@ const failingSession = (error: LegacyDbExecError): LegacyDbSession => ({ queryRaw: () => Effect.die("unused"), }); -// Strip ANSI so the bold repair suggestions compare regardless of TTY colour. -// eslint-disable-next-line no-control-regex -const stripAnsi = (text: string) => text.replace(/\x1b\[[0-9;]*m/gu, ""); - describe("legacyReconcileMigrations", () => { it("reports in-sync when remote and local match", () => { expect(legacyReconcileMigrations(["20240101000000"], ["20240101000000"])).toEqual({ diff --git a/apps/cli/src/legacy/shared/legacy-status-pretty.unit.test.ts b/apps/cli/src/legacy/shared/legacy-status-pretty.unit.test.ts index 51337df7e5..9c970819ed 100644 --- a/apps/cli/src/legacy/shared/legacy-status-pretty.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-status-pretty.unit.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; +import { stripAnsi } from "../../../tests/helpers/ansi.ts"; import { legacyRenderStatusPretty, legacyStatusColumnLayout, @@ -8,14 +9,6 @@ import { } from "./legacy-status-pretty.ts"; import type { LegacyStatusOutputNames } from "./legacy-status-values.ts"; -// The renderer applies Go-parity ANSI styling via `legacy-colors.ts`, which -// no-ops on a real non-TTY stream but the vitest process presents its stderr -// as color-capable. Strip escapes so these assertions target the plain -// structural output — the golden contract per the port plan — not whichever -// TTY heuristic the test runner happens to report. -// eslint-disable-next-line no-control-regex -const stripAnsi = (text: string) => text.replace(/\x1b\[[0-9;]*m/gu, ""); - // Default (un-overridden) output names, matching `legacy-status-values.ts`'s // `resolveOutputNames` with an empty override map — the KEYs the pretty // renderer looks values up by. diff --git a/apps/cli/src/shared/functions/deploy.ts b/apps/cli/src/shared/functions/deploy.ts index e707cb76f2..ef8be07080 100644 --- a/apps/cli/src/shared/functions/deploy.ts +++ b/apps/cli/src/shared/functions/deploy.ts @@ -11,6 +11,7 @@ import { import { Duration, Effect, Option, Schema, Stream } from "effect"; import { ChildProcessSpawner } from "effect/unstable/process"; import * as HttpClientError from "effect/unstable/http/HttpClientError"; +import { CONTEXT_CANCELED_MESSAGE } from "../output/errors.ts"; import { Output } from "../output/output.service.ts"; import { spawnContainerCli } from "../../legacy/shared/legacy-container-cli.ts"; import { legacyGetRegistryImageUrl } from "../../legacy/shared/legacy-docker-registry.ts"; @@ -2084,7 +2085,9 @@ const pruneFunctions = Effect.fnUntraced(function* ( ].join("\n"); const confirmed = yes || (yield* output.promptConfirm(`${prompt}\n`, { defaultValue: false })); if (!confirmed) { - return yield* Effect.fail(new FunctionDeployCancelledError({ message: "context canceled" })); + return yield* Effect.fail( + new FunctionDeployCancelledError({ message: CONTEXT_CANCELED_MESSAGE }), + ); } for (const slug of toDelete) { diff --git a/apps/cli/src/shared/output/errors.ts b/apps/cli/src/shared/output/errors.ts index 02e93c8143..39bc34be27 100644 --- a/apps/cli/src/shared/output/errors.ts +++ b/apps/cli/src/shared/output/errors.ts @@ -1,5 +1,31 @@ import { Data } from "effect"; +/** + * Byte-for-byte render of Go's `context.Canceled` sentinel. + * + * Every declined confirmation prompt in the Go CLI surfaces as a bare + * `context.Canceled` (e.g. `errors.New(context.Canceled)` in + * `apps/cli-go/internal/logout/logout.go:19`), and `recoverAndExit` + * (`apps/cli-go/cmd/root.go:287-303`) deliberately skips the + * `SuggestDebugFlag` hint for it — declining a prompt is a user decision, + * not an error worth troubleshooting. Handlers that port those decline + * paths construct their cancellation errors with this exact message, and + * the text `Output.fail` renderer keys on it to suppress the `--debug` + * hint, mirroring Go's `!errors.Is(err, context.Canceled)` guard. + * + * Two invariants of that renderer check: + * - The value must stay trim-invariant: it round-trips through + * `normalizeCliError`'s trimming `readString` before reaching the + * renderer's equality check (`shared/output/normalize-error.ts`). + * - The check is exact-match, narrower than Go's chain-walking + * `errors.Is`: a future producer surfacing a WRAPPED cancellation + * (`"...: context canceled"`) through `Output.fail` would keep the hint + * where Go suppresses it — no such producer exists today (mid-flight + * Ctrl-C takes the interrupt/exit-130 path and never reaches + * `Output.fail`), but widen the check if one ever appears. + */ +export const CONTEXT_CANCELED_MESSAGE = "context canceled"; + export class NonInteractiveError extends Data.TaggedError("NonInteractiveError")<{ readonly detail: string; readonly suggestion: string; diff --git a/apps/cli/src/shared/output/output.layer.ts b/apps/cli/src/shared/output/output.layer.ts index 352e8190b5..fe8ff28710 100644 --- a/apps/cli/src/shared/output/output.layer.ts +++ b/apps/cli/src/shared/output/output.layer.ts @@ -17,7 +17,7 @@ import { styleText } from "node:util"; import { Effect, Layer, Stdio, Stream } from "effect"; import { Tty } from "../runtime/tty.service.ts"; -import { NonInteractiveError } from "./errors.ts"; +import { CONTEXT_CANCELED_MESSAGE, NonInteractiveError } from "./errors.ts"; import { Output } from "./output.service.ts"; import type { OutputFormat, StreamEvent } from "./types.ts"; @@ -346,8 +346,15 @@ export const textOutputLayer = Layer.effect( } if (err.suggestion !== undefined) { process.stderr.write(err.suggestion + "\n"); - } else if (!process.argv.includes("--debug")) { - // Go's `utils.SuggestDebugFlag` (apps/cli-go/internal/utils/misc.go:41). + } else if ( + err.message !== CONTEXT_CANCELED_MESSAGE && + !process.argv.includes("--debug") + ) { + // Go's `utils.SuggestDebugFlag` (apps/cli-go/internal/utils/misc.go:41), + // withheld for the canceled sentinel exactly like `recoverAndExit`'s + // `!errors.Is(err, context.Canceled)` guard (apps/cli-go/cmd/root.go:287-292): + // a declined confirmation prompt is a user decision, so Go prints only + // the red `context canceled` line with no troubleshooting hint (CLI-1973). process.stderr.write( "Try rerunning the command with --debug to troubleshoot the error.\n", ); 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 0a6d7f0497..e3f8346f97 100644 --- a/apps/cli/src/shared/output/output.layer.unit.test.ts +++ b/apps/cli/src/shared/output/output.layer.unit.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "@effect/vitest"; import { afterEach, beforeEach, vi } from "vitest"; import { Cause, Effect, Exit, Layer, Sink, Stdio, Stream } from "effect"; -import { NonInteractiveError } from "./errors.ts"; +import { CONTEXT_CANCELED_MESSAGE, NonInteractiveError } from "./errors.ts"; import { mockTty } from "../../../tests/helpers/mocks.ts"; import { Output } from "./output.service.ts"; import { @@ -250,6 +250,62 @@ describe("Output", () => { ); }); + it.effect("fail withholds the --debug fallback for a declined-prompt cancellation", () => { + const writes: string[] = []; + const originalWrite = process.stderr.write.bind(process.stderr); + const originalArgv = process.argv; + process.stderr.write = ((chunk: string | Uint8Array) => { + writes.push(typeof chunk === "string" ? chunk : new TextDecoder().decode(chunk)); + return true; + }) as typeof process.stderr.write; + // Strip --debug from argv so only the canceled-sentinel check can suppress the hint. + process.argv = originalArgv.filter((arg) => arg !== "--debug"); + return Effect.gen(function* () { + const out = yield* Output; + // Same shape `normalizeCause` produces for any declined confirmation prompt + // (logout, migration fetch/repair/down, db push/reset, functions deploy + // --prune, ...): Go's `recoverAndExit` prints only the red `context canceled` + // line for `context.Canceled` (apps/cli-go/cmd/root.go:287-303) — CLI-1973. + yield* out.fail({ code: "LegacyLogoutCancelledError", message: CONTEXT_CANCELED_MESSAGE }); + expect(writes).toEqual(["\x1B[31mcontext canceled\x1B[39m\n"]); + }).pipe( + Effect.provide(layer), + Effect.ensuring( + Effect.sync(() => { + process.stderr.write = originalWrite; + process.argv = originalArgv; + }), + ), + ); + }); + + it.effect("fail still prints an explicit caller suggestion for a cancellation", () => { + const writes: string[] = []; + const originalWrite = process.stderr.write.bind(process.stderr); + process.stderr.write = ((chunk: string | Uint8Array) => { + writes.push(typeof chunk === "string" ? chunk : new TextDecoder().decode(chunk)); + return true; + }) as typeof process.stderr.write; + return Effect.gen(function* () { + const out = yield* Output; + // Go prints a pre-set `utils.CmdSuggestion` even for `context.Canceled` — + // only the `SuggestDebugFlag` fallback is withheld (cmd/root.go:287-292). + yield* out.fail({ + code: "E_TEST", + message: CONTEXT_CANCELED_MESSAGE, + suggestion: "custom hint", + }); + expect(writes).toEqual(["\x1B[31mcontext canceled\x1B[39m\n", "custom hint\n"]); + }).pipe( + Effect.provide(layer), + Effect.ensuring( + Effect.sync(() => { + process.stderr.write = originalWrite; + }), + ), + ); + }); + it.effect("promptText passes validate callback to clack", () => { mockClack.text.mockImplementation( (opts: { validate?: (v: string | undefined) => string | undefined }) => { diff --git a/apps/cli/tests/helpers/ansi.ts b/apps/cli/tests/helpers/ansi.ts new file mode 100644 index 0000000000..689a67887f --- /dev/null +++ b/apps/cli/tests/helpers/ansi.ts @@ -0,0 +1,21 @@ +/** Strip ANSI CSI escape sequences (colors, styles, cursor codes) from captured CLI output. */ +export function stripAnsi(output: string): string { + let stripped = ""; + for (let i = 0; i < output.length; i++) { + const charCode = output.charCodeAt(i); + if (charCode !== 0x1b || output[i + 1] !== "[") { + stripped += output[i]; + continue; + } + + i += 2; + while (i < output.length) { + const code = output.charCodeAt(i); + if (code >= 0x40 && code <= 0x7e) { + break; + } + i++; + } + } + return stripped; +} diff --git a/apps/cli/tests/helpers/cli.ts b/apps/cli/tests/helpers/cli.ts index c232c776a9..9d0bec1214 100644 --- a/apps/cli/tests/helpers/cli.ts +++ b/apps/cli/tests/helpers/cli.ts @@ -13,6 +13,8 @@ import { registerTempStackProject, } from "./stack-e2e-cleanup.ts"; +export { stripAnsi } from "./ansi.ts"; + const BINARY_EXT = process.platform === "win32" ? ".exe" : ""; const SHIM_PATH = fileURLToPath(new URL("../../dist/supabase.js", import.meta.url)); const LEGACY_BINARY_PATH = fileURLToPath(