From 42749161ad4d1ff9407af90329e634f5854af45c Mon Sep 17 00:00:00 2001 From: Aman Varshney Date: Mon, 21 Sep 2026 15:54:13 +0530 Subject: [PATCH 1/2] fix(auth): report Ctrl-C at the login paste prompt as a cancellation On a TTY, `auth login` races the browser's loopback callback against a readline prompt for a pasted callback URL. readline holds the terminal in raw mode, so Ctrl-C there never becomes a SIGINT the engine could record. readline closes the interface and rejects the question with its own AbortError while the login's paste signal is still live. The prompt treated every AbortError as "the browser callback won the race", so login resolved with no tokens and the run settled as CLI.INTERNAL_ERROR ("Sign-in finished without producing a credential."), exit 1. A user cancelling sign-in was reported as a CLI bug. The paste signal's own `aborted` state now decides: aborted means the login is over and prompting stops, as before. An AbortError with the signal still live is the user's Ctrl-C and throws CLI.PROMPT_CANCELLED, the code the engine already settles as a user cancellation (exit 3) for Ctrl-C at its own raw-mode prompts. The loopback server and the readline interface are still torn down by the existing finally blocks. The browser-won path and the wrong-paste re-prompt are unchanged. Co-Authored-By: Claude Fable 5.1 --- docs/product/error-conventions.md | 7 +++ docs/reference/error-reference.md | 2 +- packages/cli/src/auth/login.ts | 17 ++++++- packages/cli/tests/auth-login.test.ts | 64 ++++++++++++++++++++++++++- packages/cli/tests/auth.test.ts | 18 +++++++- 5 files changed, 103 insertions(+), 5 deletions(-) diff --git a/docs/product/error-conventions.md b/docs/product/error-conventions.md index 1f8503dd..9926ab1d 100644 --- a/docs/product/error-conventions.md +++ b/docs/product/error-conventions.md @@ -53,6 +53,13 @@ diagnostics because they are untrusted input. Both browser redirects and pasted callback URLs end login with that error; an explicit denial must not reopen the paste prompt. +Ctrl-C at the `auth login` paste prompt is the user cancelling sign-in. It is +reported as `CLI.PROMPT_CANCELLED`, the code Ctrl-C at any other prompt +produces, not as `CLI.INTERNAL_ERROR`. The prompt reads the terminal in raw +mode, so that Ctrl-C never arrives as a signal; the prompt's own abort is what +distinguishes it from the browser callback finishing sign-in first. No session +is created or cleared. + An expected external fault, not a product bug. Examples: diff --git a/docs/reference/error-reference.md b/docs/reference/error-reference.md index 75964ad0..d9928b4a 100644 --- a/docs/reference/error-reference.md +++ b/docs/reference/error-reference.md @@ -174,7 +174,7 @@ A `ctx.packages` operation (an install, or running a package through the manager ### CLI.PROMPT_CANCELLED -The user cancelled a prompt: EOF on stdin at a line-rendered prompt, a clack cancel (Ctrl-C at the prompt UI), an abort during a browserWait poll, or — via the service commands' `userCancelledError` — consent declined interactively. Settles with exit 3, the cancellation code, instead of 2. Meta: none. +The user cancelled a prompt: EOF on stdin at a line-rendered prompt, a clack cancel (Ctrl-C at the prompt UI), an abort during a browserWait poll, Ctrl-C at the `prisma auth login` paste prompt (readline reads the terminal in raw mode, so that Ctrl-C never arrives as a signal), or — via the service commands' `userCancelledError` — consent declined interactively. Settles with exit 3, the cancellation code, instead of 2. Meta: none. ### CLI.PROMPT_INVALID diff --git a/packages/cli/src/auth/login.ts b/packages/cli/src/auth/login.ts index d2f90b90..8f594377 100644 --- a/packages/cli/src/auth/login.ts +++ b/packages/cli/src/auth/login.ts @@ -207,8 +207,21 @@ async function readPastedCallbackUrl( signal: options.signal, }); } catch (error) { - // The browser callback won the race and aborted us. Stop prompting. - if ((error as { name?: string } | null)?.name === "AbortError") return null; + if ((error as { name?: string } | null)?.name === "AbortError") { + // Our own signal says who aborted the question. It fires only once the + // login is over (the browser callback won the race, or the command + // itself was interrupted), so there is nothing left to prompt for. + if (options.signal.aborted) return null; + // Otherwise readline aborted it. readline holds the terminal in raw + // mode, so Ctrl-C never becomes a SIGINT the engine could record: + // readline closes the interface and rejects the question itself. That + // is the user cancelling sign-in, which the engine settles the way it + // settles Ctrl-C at its own prompts (exit 3). + throw new CliStructuredError( + "CLI.PROMPT_CANCELLED", + "Sign-in was cancelled before it completed.", + ); + } throw error; } diff --git a/packages/cli/tests/auth-login.test.ts b/packages/cli/tests/auth-login.test.ts index 667a4a96..c4216a66 100644 --- a/packages/cli/tests/auth-login.test.ts +++ b/packages/cli/tests/auth-login.test.ts @@ -373,6 +373,55 @@ describe("auth login remote paste flow", () => { expect(result.handleCallbackCalls).toBe(2); }); + it("reports Ctrl-C at the paste prompt as a cancelled prompt, not a finished sign-in", async () => { + let redirectUri = ""; + const run = runLogin({ + ttyInput: true, + terminal: true, + openUrl: (uri) => { + redirectUri = uri; + }, + pasteLines: [CTRL_C], + }); + + await expect(run).rejects.toMatchObject({ + code: "CLI.PROMPT_CANCELLED", + message: "Sign-in was cancelled before it completed.", + }); + // The loopback listener is torn down with the cancelled login. + await expect( + fetch(`${redirectUri}?code=code_123&state=state_123`), + ).rejects.toThrow(); + }); + + it("still cancels on Ctrl-C after a wrong paste re-prompted", async () => { + const output: string[] = []; + await expect( + runLogin({ + ttyInput: true, + terminal: true, + openUrl: () => {}, + pasteLines: ["not a url", CTRL_C], + onOutput: (text) => output.push(text), + }), + ).rejects.toMatchObject({ code: "CLI.PROMPT_CANCELLED" }); + + expect(output.join("")).toContain("That didn't look like a URL"); + }); + + it("completes through the browser callback while the paste prompt is still waiting", async () => { + const result = await runLogin({ + ttyInput: true, + terminal: true, + openUrl: async (redirectUri) => { + await fetch(`${redirectUri}?code=code_123&state=state_123`); + }, + }); + + expect(result.handleCallbackCalls).toBe(1); + expect(result.output).toContain("Paste the callback URL here:"); + }); + it("surfaces a browser-launch failure when stdin is not a TTY", async () => { await expect( runLogin({ @@ -401,10 +450,19 @@ describe("auth login remote paste flow", () => { const PASTE_CALLBACK_URL = "http://localhost:9999/auth/callback?code=code_123&state=state_123"; +/** The byte a terminal in raw mode delivers for Ctrl-C. readline only reads + * it as a keypress when it runs in terminal mode (`terminal: true`). */ +const CTRL_C = "\x03"; + async function runLogin(options: { ttyInput: boolean; + /** Makes the output a TTY too, so readline runs in terminal mode and sees + * keypresses the way it does on a real terminal. */ + terminal?: boolean; openUrl: (redirectUri: string) => Promise | unknown; pasteLines?: string[]; + /** Observes output as it is written, for runs that end by rejecting. */ + onOutput?: (text: string) => void; }): Promise<{ handleCallbackCalls: number; output: string }> { let redirectUri = ""; const handleCallback = vi.fn( @@ -458,16 +516,20 @@ async function runLogin(options: { // line at once loses all but the first across re-prompts. const pasteLines = [...(options.pasteLines ?? [])]; const output = new PassThrough(); + if (options.terminal) { + (output as unknown as { isTTY: boolean }).isTTY = true; + } const chunks: string[] = []; output.on("data", (chunk) => { const text = chunk.toString(); chunks.push(text); + options.onOutput?.(text); if ( text.includes("Paste the callback URL here:") && pasteLines.length > 0 ) { const line = pasteLines.shift() as string; - queueMicrotask(() => input.write(`${line}\n`)); + queueMicrotask(() => input.write(line === CTRL_C ? line : `${line}\n`)); } }); diff --git a/packages/cli/tests/auth.test.ts b/packages/cli/tests/auth.test.ts index 2a07e33d..f2564a31 100644 --- a/packages/cli/tests/auth.test.ts +++ b/packages/cli/tests/auth.test.ts @@ -13,7 +13,7 @@ import { type ManagementApiClient, type Session, } from "@prisma/cli-engine"; -import { ok } from "@prisma/cli-engine/protocol"; +import { CliStructuredError, ok } from "@prisma/cli-engine/protocol"; import { createTestCli, mintTestJwt, @@ -227,6 +227,22 @@ describe("auth login", () => { ), ).toHaveLength(1); }); + + it("settles a sign-in cancelled at the paste prompt as a user cancellation, not a CLI bug", async () => { + vi.mocked(performLogin).mockRejectedValue( + new CliStructuredError( + "CLI.PROMPT_CANCELLED", + "Sign-in was cancelled before it completed.", + ), + ); + const cli = makeCli(); + + const result = await cli.run(["auth", "login", "--json"]); + + expect(result.exitCode).toBe(3); + expect(errorOf(result).code).toBe("CLI.PROMPT_CANCELLED"); + expect(cli.credentialManager?.state().sessions).toEqual([]); + }); }); describe("auth logout", () => { From 38ce3f0fdc2fef09043ad324f3371ad89464eb64 Mon Sep 17 00:00:00 2001 From: Aman Varshney Date: Mon, 21 Sep 2026 16:30:09 +0530 Subject: [PATCH 2/2] chore(auth): trim the login Ctrl-C fix to its minimum Shorten the two comments in the paste prompt's abort handling, drop the error-conventions paragraph and the parenthetical in the CLI.PROMPT_CANCELLED reference entry, and keep only the two tests that guard the change: Ctrl-C at the prompt cancels, and the browser callback still wins while the prompt waits. The engine already tests that the code exits 3. Co-Authored-By: Claude Fable 5.1 --- docs/product/error-conventions.md | 7 ----- docs/reference/error-reference.md | 2 +- packages/cli/src/auth/login.ts | 10 ++----- packages/cli/tests/auth-login.test.ts | 39 +++------------------------ packages/cli/tests/auth.test.ts | 18 +------------ 5 files changed, 7 insertions(+), 69 deletions(-) diff --git a/docs/product/error-conventions.md b/docs/product/error-conventions.md index 9926ab1d..1f8503dd 100644 --- a/docs/product/error-conventions.md +++ b/docs/product/error-conventions.md @@ -53,13 +53,6 @@ diagnostics because they are untrusted input. Both browser redirects and pasted callback URLs end login with that error; an explicit denial must not reopen the paste prompt. -Ctrl-C at the `auth login` paste prompt is the user cancelling sign-in. It is -reported as `CLI.PROMPT_CANCELLED`, the code Ctrl-C at any other prompt -produces, not as `CLI.INTERNAL_ERROR`. The prompt reads the terminal in raw -mode, so that Ctrl-C never arrives as a signal; the prompt's own abort is what -distinguishes it from the browser callback finishing sign-in first. No session -is created or cleared. - An expected external fault, not a product bug. Examples: diff --git a/docs/reference/error-reference.md b/docs/reference/error-reference.md index d9928b4a..ed6a7156 100644 --- a/docs/reference/error-reference.md +++ b/docs/reference/error-reference.md @@ -174,7 +174,7 @@ A `ctx.packages` operation (an install, or running a package through the manager ### CLI.PROMPT_CANCELLED -The user cancelled a prompt: EOF on stdin at a line-rendered prompt, a clack cancel (Ctrl-C at the prompt UI), an abort during a browserWait poll, Ctrl-C at the `prisma auth login` paste prompt (readline reads the terminal in raw mode, so that Ctrl-C never arrives as a signal), or — via the service commands' `userCancelledError` — consent declined interactively. Settles with exit 3, the cancellation code, instead of 2. Meta: none. +The user cancelled a prompt: EOF on stdin at a line-rendered prompt, a clack cancel (Ctrl-C at the prompt UI), an abort during a browserWait poll, Ctrl-C at the `prisma auth login` paste prompt, or — via the service commands' `userCancelledError` — consent declined interactively. Settles with exit 3, the cancellation code, instead of 2. Meta: none. ### CLI.PROMPT_INVALID diff --git a/packages/cli/src/auth/login.ts b/packages/cli/src/auth/login.ts index 8f594377..324e38a6 100644 --- a/packages/cli/src/auth/login.ts +++ b/packages/cli/src/auth/login.ts @@ -208,15 +208,9 @@ async function readPastedCallbackUrl( }); } catch (error) { if ((error as { name?: string } | null)?.name === "AbortError") { - // Our own signal says who aborted the question. It fires only once the - // login is over (the browser callback won the race, or the command - // itself was interrupted), so there is nothing left to prompt for. + // Our signal aborted: the login is over. if (options.signal.aborted) return null; - // Otherwise readline aborted it. readline holds the terminal in raw - // mode, so Ctrl-C never becomes a SIGINT the engine could record: - // readline closes the interface and rejects the question itself. That - // is the user cancelling sign-in, which the engine settles the way it - // settles Ctrl-C at its own prompts (exit 3). + // readline's own abort is the user's Ctrl-C (raw mode, no SIGINT). throw new CliStructuredError( "CLI.PROMPT_CANCELLED", "Sign-in was cancelled before it completed.", diff --git a/packages/cli/tests/auth-login.test.ts b/packages/cli/tests/auth-login.test.ts index c4216a66..540b2920 100644 --- a/packages/cli/tests/auth-login.test.ts +++ b/packages/cli/tests/auth-login.test.ts @@ -373,53 +373,26 @@ describe("auth login remote paste flow", () => { expect(result.handleCallbackCalls).toBe(2); }); - it("reports Ctrl-C at the paste prompt as a cancelled prompt, not a finished sign-in", async () => { - let redirectUri = ""; - const run = runLogin({ - ttyInput: true, - terminal: true, - openUrl: (uri) => { - redirectUri = uri; - }, - pasteLines: [CTRL_C], - }); - - await expect(run).rejects.toMatchObject({ - code: "CLI.PROMPT_CANCELLED", - message: "Sign-in was cancelled before it completed.", - }); - // The loopback listener is torn down with the cancelled login. - await expect( - fetch(`${redirectUri}?code=code_123&state=state_123`), - ).rejects.toThrow(); - }); - - it("still cancels on Ctrl-C after a wrong paste re-prompted", async () => { - const output: string[] = []; + it("reports Ctrl-C at the paste prompt as a cancelled prompt", async () => { await expect( runLogin({ ttyInput: true, terminal: true, openUrl: () => {}, - pasteLines: ["not a url", CTRL_C], - onOutput: (text) => output.push(text), + pasteLines: [CTRL_C], }), ).rejects.toMatchObject({ code: "CLI.PROMPT_CANCELLED" }); - - expect(output.join("")).toContain("That didn't look like a URL"); }); it("completes through the browser callback while the paste prompt is still waiting", async () => { const result = await runLogin({ ttyInput: true, - terminal: true, openUrl: async (redirectUri) => { await fetch(`${redirectUri}?code=code_123&state=state_123`); }, }); expect(result.handleCallbackCalls).toBe(1); - expect(result.output).toContain("Paste the callback URL here:"); }); it("surfaces a browser-launch failure when stdin is not a TTY", async () => { @@ -450,19 +423,14 @@ describe("auth login remote paste flow", () => { const PASTE_CALLBACK_URL = "http://localhost:9999/auth/callback?code=code_123&state=state_123"; -/** The byte a terminal in raw mode delivers for Ctrl-C. readline only reads - * it as a keypress when it runs in terminal mode (`terminal: true`). */ const CTRL_C = "\x03"; async function runLogin(options: { ttyInput: boolean; - /** Makes the output a TTY too, so readline runs in terminal mode and sees - * keypresses the way it does on a real terminal. */ + /** readline only sees keypresses such as Ctrl-C when the output is a TTY. */ terminal?: boolean; openUrl: (redirectUri: string) => Promise | unknown; pasteLines?: string[]; - /** Observes output as it is written, for runs that end by rejecting. */ - onOutput?: (text: string) => void; }): Promise<{ handleCallbackCalls: number; output: string }> { let redirectUri = ""; const handleCallback = vi.fn( @@ -523,7 +491,6 @@ async function runLogin(options: { output.on("data", (chunk) => { const text = chunk.toString(); chunks.push(text); - options.onOutput?.(text); if ( text.includes("Paste the callback URL here:") && pasteLines.length > 0 diff --git a/packages/cli/tests/auth.test.ts b/packages/cli/tests/auth.test.ts index f2564a31..2a07e33d 100644 --- a/packages/cli/tests/auth.test.ts +++ b/packages/cli/tests/auth.test.ts @@ -13,7 +13,7 @@ import { type ManagementApiClient, type Session, } from "@prisma/cli-engine"; -import { CliStructuredError, ok } from "@prisma/cli-engine/protocol"; +import { ok } from "@prisma/cli-engine/protocol"; import { createTestCli, mintTestJwt, @@ -227,22 +227,6 @@ describe("auth login", () => { ), ).toHaveLength(1); }); - - it("settles a sign-in cancelled at the paste prompt as a user cancellation, not a CLI bug", async () => { - vi.mocked(performLogin).mockRejectedValue( - new CliStructuredError( - "CLI.PROMPT_CANCELLED", - "Sign-in was cancelled before it completed.", - ), - ); - const cli = makeCli(); - - const result = await cli.run(["auth", "login", "--json"]); - - expect(result.exitCode).toBe(3); - expect(errorOf(result).code).toBe("CLI.PROMPT_CANCELLED"); - expect(cli.credentialManager?.state().sessions).toEqual([]); - }); }); describe("auth logout", () => {