From 0b33eeb8d579eacbeff6a9b17a04e1d2d1e8990e Mon Sep 17 00:00:00 2001 From: Agent59353 Date: Wed, 5 Aug 2026 07:17:58 +0800 Subject: [PATCH] fix(cli): emit login start URL before polling under non-TTY --- src/cli/account-api.ts | 2 ++ src/cli/account-auth.ts | 37 ++++++++++++++++++------- src/cli/runtime-api.ts | 5 ++++ tests/cli-account.test.ts | 58 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 92 insertions(+), 10 deletions(-) diff --git a/src/cli/account-api.ts b/src/cli/account-api.ts index a03b29e1f8..203e5db47e 100644 --- a/src/cli/account-api.ts +++ b/src/cli/account-api.ts @@ -46,6 +46,8 @@ export interface AccountDeps { loadConfigImpl?: () => OcxConfig; stdinImpl?: AccountStdin; stdinTimeoutMs?: number; + /** Test injection for synchronous stdout writes (login start URL). */ + stdoutImpl?: (chunk: string) => void; /** Test/platform injection for the official Codex login in a restricted staging home. */ spawnCodexLoginImpl?: (codexHome: string) => NativeMainLoginChild; /** Legacy test seam. Production always uses the spawned child handle above. */ diff --git a/src/cli/account-auth.ts b/src/cli/account-auth.ts index 79de3e67b0..491f7870f5 100644 --- a/src/cli/account-auth.ts +++ b/src/cli/account-auth.ts @@ -1,3 +1,4 @@ +import { writeSync } from "node:fs"; import { CliUsageError, printData, @@ -33,6 +34,30 @@ interface LoginStart { deviceCode?: string; } +/** Synchronous fallback for `stdoutImpl`; reaches a pipe immediately. */ +function writeSyncStdout(chunk: string): void { + writeSync(1, chunk); +} + +/** + * Announce the login start before the polling loop holds the process open. + * + * `console.log` to a non-TTY stdout can stay buffered for minutes on some + * platforms, so `ocx account login` piped or redirected to a file appeared to + * hang instead of showing the authorization URL (issue #1007). Writing fd 1 + * synchronously delivers the URL to the user before the first poll. + */ +function printLoginStart(start: LoginStart, deps: RuntimeApiDeps): void { + const lines: string[] = []; + if (start.url) lines.push(`Open this URL to sign in:\n${start.url}`); + if (start.instructions) lines.push(start.instructions); + if (start.flowId) lines.push(`Flow: ${start.flowId}`); + if (start.deviceCode) lines.push(`Device code: ${start.deviceCode}`); + if (lines.length === 0) return; + const write = deps.stdoutImpl ?? writeSyncStdout; + write(`${lines.join("\n")}\n`); +} + /** `-` means "read it from stdin", the documented way to pass a code silently. */ const STDIN_SENTINEL = "-"; @@ -81,11 +106,7 @@ async function login(argv: string[], deps: RuntimeApiDeps): Promise { method: "POST", body: JSON.stringify({ ...(id ? { id } : {}), ...(reauth ? { reauth: true } : {}) }), }, deps); - if (!wantsJson) { - if (start.url) console.log(`Open this URL to sign in:\n${start.url}`); - if (start.instructions) console.log(start.instructions); - if (start.flowId) console.log(`Flow: ${start.flowId}`); - } + if (!wantsJson) printLoginStart(start, deps); if (code && start.flowId) { await runtimeRequest("/api/codex-auth/login/code", { method: "POST", @@ -119,11 +140,7 @@ async function login(argv: string[], deps: RuntimeApiDeps): Promise { method: "POST", body: JSON.stringify({ provider, addAccount: !reauth, ...(reauth && id ? { accountId: id, reauth: true } : {}) }), }, deps); - if (!wantsJson) { - if (start.url) console.log(`Open this URL to sign in:\n${start.url}`); - if (start.instructions) console.log(start.instructions); - if (start.deviceCode) console.log(`Device code: ${start.deviceCode}`); - } + if (!wantsJson) printLoginStart(start, deps); if (code) { await runtimeRequest("/api/oauth/login/code", { method: "POST", diff --git a/src/cli/runtime-api.ts b/src/cli/runtime-api.ts index cd65422da3..9e6233c5e6 100644 --- a/src/cli/runtime-api.ts +++ b/src/cli/runtime-api.ts @@ -20,6 +20,11 @@ export interface RuntimeApiDeps { /** Test injection for commands that read a secret from stdin instead of argv. */ stdinImpl?: CliStdin; stdinTimeoutMs?: number; + /** + * Test injection for output that must reach the user before a long-running + * command holds the process open (for example, the login start URL). + */ + stdoutImpl?: (chunk: string) => void; } export class CliUsageError extends Error { diff --git a/tests/cli-account.test.ts b/tests/cli-account.test.ts index 3e17b4b257..5028caf08d 100644 --- a/tests/cli-account.test.ts +++ b/tests/cli-account.test.ts @@ -44,6 +44,7 @@ let codexAccounts: Array> = []; let oauthAccounts: Array> = []; let oauthActiveId: string | null = "acct_1"; let oauthLoginStatus: Record = { loggedIn: false }; +let codexLoginStatus: Record = { status: "pending" }; let keyEntries: Array> = []; let keyActiveId: string | null = "key_1"; let logs: string[] = []; @@ -290,6 +291,10 @@ async function mockManagementApi(req: Request): Promise { return json(oauthLoginStatus); } + if (req.method === "GET" && url.pathname === "/api/codex-auth/login-status") { + return json(codexLoginStatus); + } + return json({ error: `unhandled mock endpoint: ${req.method} ${url.pathname}` }, 404); } @@ -354,6 +359,7 @@ beforeEach(() => { ]; oauthActiveId = "acct_1"; oauthLoginStatus = { loggedIn: false }; + codexLoginStatus = { status: "pending" }; keyEntries = [{ id: "key_1", label: "personal", @@ -1281,6 +1287,58 @@ describe("ocx account CLI (issue #180 matrix)", () => { }); + describe("login announces the start URL before polling (issue #1007)", () => { + test("OAuth login writes the URL synchronously even when stdout is not a TTY", async () => { + oauthLoginStatus = { loggedIn: false }; + const chunks: string[] = []; + let seenBeforeFirstPoll = false; + let polls = 0; + const sleepSpy = spyOn(Bun, "sleep").mockImplementation(async () => { + polls += 1; + if (polls === 1) seenBeforeFirstPoll = chunks.join("").includes("https://auth.example/authorize"); + }); + try { + const result = await run( + ["login", "anthropic"], + { ...defaultDeps(), stdoutImpl: (chunk: string) => chunks.push(chunk) }, + ); + + expect(result.code).toBe(2); + expect(result.stderr).toContain("login timed out"); + expect(seenBeforeFirstPoll).toBe(true); + expect(chunks.join("")).toContain("Open this URL to sign in:\nhttps://auth.example/authorize"); + expect(chunks.join("")).toContain("Sign in, then paste the redirect URL."); + } finally { + sleepSpy.mockRestore(); + } + }); + + test("Codex login writes the URL and flow id before the first poll", async () => { + codexLoginStatus = { status: "done", email: "j***@example.com" }; + const chunks: string[] = []; + let seenBeforeFirstPoll = false; + let polls = 0; + const sleepSpy = spyOn(Bun, "sleep").mockImplementation(async () => { + polls += 1; + if (polls === 1) seenBeforeFirstPoll = chunks.join("").includes("https://auth.example/authorize"); + }); + try { + const result = await run( + ["login", "openai"], + { ...defaultDeps(), stdoutImpl: (chunk: string) => chunks.push(chunk) }, + ); + + expect(result.code).toBe(0); + expect(result.stdout).toContain("Logged in as j***@example.com."); + expect(seenBeforeFirstPoll).toBe(true); + expect(chunks.join("")).toContain("Open this URL to sign in:\nhttps://auth.example/authorize"); + expect(chunks.join("")).toContain("Flow: flow-mock"); + } finally { + sleepSpy.mockRestore(); + } + }); + }); + test("39: a login error wins over a retained OAuth credential", async () => { oauthLoginStatus = { loggedIn: true,