diff --git a/docs/reference/error-reference.md b/docs/reference/error-reference.md index efbf270c..f96bdd11 100644 --- a/docs/reference/error-reference.md +++ b/docs/reference/error-reference.md @@ -190,7 +190,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, 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 46a8fbb2..6de1438a 100644 --- a/packages/cli/src/auth/login.ts +++ b/packages/cli/src/auth/login.ts @@ -213,8 +213,15 @@ 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 signal aborted: the login is over. + if (options.signal.aborted) return null; + // 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.", + ); + } throw error; } diff --git a/packages/cli/tests/auth-login.test.ts b/packages/cli/tests/auth-login.test.ts index 0bfcd637..0861cbdc 100644 --- a/packages/cli/tests/auth-login.test.ts +++ b/packages/cli/tests/auth-login.test.ts @@ -435,6 +435,28 @@ describe("auth login remote paste flow", () => { expect(result.handleCallbackCalls).toBe(2); }); + it("reports Ctrl-C at the paste prompt as a cancelled prompt", async () => { + await expect( + runLogin({ + ttyInput: true, + terminal: true, + openUrl: () => {}, + pasteLines: [CTRL_C], + }), + ).rejects.toMatchObject({ code: "CLI.PROMPT_CANCELLED" }); + }); + + it("completes through the browser callback while the paste prompt is still waiting", async () => { + const result = await runLogin({ + ttyInput: true, + openUrl: async (redirectUri) => { + await fetch(`${redirectUri}?code=code_123&state=state_123`); + }, + }); + + expect(result.handleCallbackCalls).toBe(1); + }); + it("surfaces a browser-launch failure when stdin is not a TTY", async () => { await expect( runLogin({ @@ -463,10 +485,14 @@ describe("auth login remote paste flow", () => { const PASTE_CALLBACK_URL = "http://localhost:9999/auth/callback?code=code_123&state=state_123"; +const CTRL_C = "\x03"; + async function runLogin(options: { uiContext?: "prisma-plugin"; onVerificationUrl?: (url: string) => void; ttyInput: boolean; + /** readline only sees keypresses such as Ctrl-C when the output is a TTY. */ + terminal?: boolean; openUrl: (redirectUri: string) => Promise | unknown; pasteLines?: string[]; }): Promise<{ handleCallbackCalls: number; output: string }> { @@ -522,6 +548,9 @@ 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(); @@ -531,7 +560,7 @@ async function runLogin(options: { pasteLines.length > 0 ) { const line = pasteLines.shift() as string; - queueMicrotask(() => input.write(`${line}\n`)); + queueMicrotask(() => input.write(line === CTRL_C ? line : `${line}\n`)); } });