diff --git a/docs/reference/error-reference.md b/docs/reference/error-reference.md index 75964ad0..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, 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 d2f90b90..324e38a6 100644 --- a/packages/cli/src/auth/login.ts +++ b/packages/cli/src/auth/login.ts @@ -207,8 +207,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 667a4a96..540b2920 100644 --- a/packages/cli/tests/auth-login.test.ts +++ b/packages/cli/tests/auth-login.test.ts @@ -373,6 +373,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({ @@ -401,8 +423,12 @@ 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: { 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 }> { @@ -458,6 +484,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(); @@ -467,7 +496,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`)); } });