diff --git a/packages/cli/src/commands/handlers/auth/login.ts b/packages/cli/src/commands/handlers/auth/login.ts index 746f105346b..2a4859ff190 100644 --- a/packages/cli/src/commands/handlers/auth/login.ts +++ b/packages/cli/src/commands/handlers/auth/login.ts @@ -195,16 +195,14 @@ export const oauthLogin = Effect.fn("cli.auth.login.oauth")(function* ( return } - const waiting = spinner() - waiting.start("Waiting for authorization...") - const status = yield* waitForOAuth(client, integration.id, attempt.attemptID).pipe( - Effect.tapCause(() => Effect.sync(() => waiting.stop("Authentication failed", 1))), - ) + // Clack's spinner captures Ctrl+C and exits the process directly, which would skip the finalizer that + // cancels the attempt. Waits that can last minutes use plain log lines so Ctrl+C interrupts normally. + log.step("Waiting for authorization...") + const status = yield* waitForOAuth(client, integration.id, attempt.attemptID) if (status.status === "complete") { - waiting.stop(`Connected to ${integration.name}`) + log.success(`Connected to ${integration.name}`) return } - waiting.stop("Authentication failed", 1) if (status.status === "failed") yield* Effect.fail(new Error(status.message)) yield* Effect.fail(new Error("Authorization expired")) }) @@ -231,14 +229,21 @@ const commandLogin = Effect.fn("cli.auth.login.command")(function* ( ), ).pipe(Effect.ignore), ) - const status = yield* waitForCommand(client, integration.id, started.data.attemptID, (message) => - progress.message(message.trim() || "Waiting for authentication command..."), - ).pipe(Effect.tapCause(() => Effect.sync(() => progress.stop("Authentication failed", 1)))) + progress.stop("Authentication command started") + // The status message accumulates the command's stderr; print each completed line once. + let printed = 0 + log.step("Waiting for authentication command...") + const status = yield* waitForCommand(client, integration.id, started.data.attemptID, (message) => { + const end = message.lastIndexOf("\n") + 1 + if (end <= printed) return + const output = message.slice(printed, end).trim() + printed = end + if (output) log.message(output) + }) if (status.status === "complete") { - progress.stop(`Connected to ${integration.name}`) + log.success(`Connected to ${integration.name}`) return } - progress.stop("Authentication failed", 1) if (status.status === "failed") yield* Effect.fail(new Error(status.message)) yield* Effect.fail(new Error("Authentication expired")) }) diff --git a/packages/cli/src/ui/prompt.ts b/packages/cli/src/ui/prompt.ts index 04b2ed716a6..3c54c2202eb 100644 --- a/packages/cli/src/ui/prompt.ts +++ b/packages/cli/src/ui/prompt.ts @@ -22,6 +22,7 @@ export const openUrl = Effect.fn("cli.prompt.open-url")(function* (url: string) export function handlePromptErrors(effect: Effect.Effect) { return effect.pipe( + Effect.onInterrupt(() => Effect.sync(() => cancel("Cancelled"))), Effect.catchIf( (error) => error === cancelled, () => diff --git a/packages/cli/test/auth.test.ts b/packages/cli/test/auth.test.ts index 07d77efe54a..8f33f6a9a35 100644 --- a/packages/cli/test/auth.test.ts +++ b/packages/cli/test/auth.test.ts @@ -216,7 +216,8 @@ describe("auth command", () => { expect(requests).toContainEqual({ method: "DELETE", path: `${endpoint}/con_oauth` }) }) - test("settles the OAuth spinner when status polling fails", async () => { + test("reports OAuth status polling failures and cancels the attempt", async () => { + let cancelled = false using server = authServer((request, url) => { if (url.pathname === "/api/integration") { return Response.json( @@ -245,6 +246,7 @@ describe("auth command", () => { return new Response("Unavailable", { status: 500 }) } if (url.pathname === "/api/integration/openai/connect/oauth/con_oauth" && request.method === "DELETE") { + cancelled = true return new Response(null, { status: 204 }) } return new Response("Not found", { status: 404 }) @@ -252,8 +254,10 @@ describe("auth command", () => { const result = await cli(["auth", "login", "openai", "--server", server.url.toString()]) expect(result.exitCode).toBe(1) - expect(result.stdout).toContain("Authentication failed") + expect(result.stdout).toContain("Waiting for authorization...") + expect(result.stdout).toContain("UnexpectedStatus: 500") expect(result.stdout).toContain("Failed") + expect(cancelled).toBe(true) expect(result.stdout).not.toContain("\n at ") })