diff --git a/src/auth/github.ts b/src/auth/github.ts index 7e6528b..c14d47e 100644 --- a/src/auth/github.ts +++ b/src/auth/github.ts @@ -7,6 +7,20 @@ const DEVICE_CODE_URL = 'https://github.com/login/device/code'; const TOKEN_URL = 'https://github.com/login/oauth/access_token'; const POLLING_SAFETY_MARGIN_MS = 3000; +function abortableSleep(ms: number, signal?: AbortSignal): Promise { + return new Promise((resolve, reject) => { + if (signal?.aborted) { + reject(new Error('Cancelled')); + return; + } + const timer = setTimeout(resolve, ms); + signal?.addEventListener('abort', () => { + clearTimeout(timer); + reject(new Error('Cancelled')); + }, { once: true }); + }); +} + const AUTH_DIR = resolve(homedir(), '.config/flynn'); const AUTH_FILE = resolve(AUTH_DIR, 'auth.json'); @@ -49,11 +63,11 @@ export async function requestDeviceCode(): Promise { * Poll GitHub for an access token after the user has entered the device code. * Blocks until the user authorizes or the code expires. */ -export async function pollForToken(deviceCode: string, interval: number): Promise { +export async function pollForToken(deviceCode: string, interval: number, signal?: AbortSignal): Promise { let currentInterval = interval; while (true) { - await new Promise(r => setTimeout(r, currentInterval * 1000 + POLLING_SAFETY_MARGIN_MS)); + await abortableSleep(currentInterval * 1000 + POLLING_SAFETY_MARGIN_MS, signal); const response = await fetch(TOKEN_URL, { method: 'POST', @@ -153,12 +167,13 @@ export function getGitHubToken(): string | null { */ export async function loginGitHub( onPrompt: (userCode: string, verificationUri: string) => void, + signal?: AbortSignal, ): Promise { const deviceCode = await requestDeviceCode(); onPrompt(deviceCode.user_code, deviceCode.verification_uri); - const token = await pollForToken(deviceCode.device_code, deviceCode.interval); + const token = await pollForToken(deviceCode.device_code, deviceCode.interval, signal); storeToken(token); return token; } diff --git a/src/auth/openai.ts b/src/auth/openai.ts index f403d2c..9e480ef 100644 --- a/src/auth/openai.ts +++ b/src/auth/openai.ts @@ -12,6 +12,20 @@ const TOKEN_URL = `${ISSUER}/oauth/token`; const POLLING_SAFETY_MARGIN_MS = 3000; const REFRESH_SAFETY_MARGIN_MS = 30_000; +function abortableSleep(ms: number, signal?: AbortSignal): Promise { + return new Promise((resolve, reject) => { + if (signal?.aborted) { + reject(new Error('Cancelled')); + return; + } + const timer = setTimeout(resolve, ms); + signal?.addEventListener('abort', () => { + clearTimeout(timer); + reject(new Error('Cancelled')); + }, { once: true }); + }); +} + const AUTH_DIR = resolve(homedir(), '.config/flynn'); const AUTH_FILE = resolve(AUTH_DIR, 'auth.json'); @@ -256,9 +270,9 @@ async function requestDeviceAuth(): Promise { return response.json() as Promise; } -async function pollDeviceToken(deviceAuthId: string, userCode: string, intervalMs: number): Promise { +async function pollDeviceToken(deviceAuthId: string, userCode: string, intervalMs: number, signal?: AbortSignal): Promise { while (true) { - await new Promise(r => setTimeout(r, intervalMs + POLLING_SAFETY_MARGIN_MS)); + await abortableSleep(intervalMs + POLLING_SAFETY_MARGIN_MS, signal); const response = await fetch(DEVICE_TOKEN_URL, { method: 'POST', @@ -365,13 +379,14 @@ export async function ensureValidOpenAIAuth(): Promise { */ export async function loginOpenAI( onPrompt: (userCode: string, verificationUri: string) => void, + signal?: AbortSignal, ): Promise { const device = await requestDeviceAuth(); const intervalMs = Math.max(parseInt(device.interval) || 5, 1) * 1000; onPrompt(device.user_code, DEVICE_URL); - const deviceToken = await pollDeviceToken(device.device_auth_id, device.user_code, intervalMs); + const deviceToken = await pollDeviceToken(device.device_auth_id, device.user_code, intervalMs, signal); const tokens = await exchangeAuthorizationCode(deviceToken.authorization_code, deviceToken.code_verifier); const expiresAt = Date.now() + (tokens.expires_in ?? 3600) * 1000; diff --git a/src/frontends/tui/minimal.ts b/src/frontends/tui/minimal.ts index b131a8b..79d985c 100644 --- a/src/frontends/tui/minimal.ts +++ b/src/frontends/tui/minimal.ts @@ -1142,19 +1142,29 @@ export class MinimalTui { if (target === 'github') { console.log(`${colors.gray}Starting GitHub OAuth device flow...${colors.reset}`); + const controller = new AbortController(); + const onSigint = () => controller.abort(); + this.rl?.once('SIGINT', onSigint); + try { await loginGitHub((userCode, verificationUri) => { console.log(''); console.log(`${colors.gray}Please visit:${colors.reset} ${verificationUri}`); console.log(`${colors.gray}and enter code:${colors.reset} ${userCode}`); console.log(''); - console.log(`${colors.gray}Waiting for authorization...${colors.reset}`); - }); + console.log(`${colors.gray}Waiting for authorization... (Ctrl+C to cancel)${colors.reset}`); + }, controller.signal); console.log(`${colors.gray}GitHub authentication successful! Token stored.${colors.reset}\n`); } catch (error) { - const message = error instanceof Error ? error.message : String(error); - console.log(`${colors.gray}GitHub login failed:${colors.reset} ${message}\n`); + if (controller.signal.aborted) { + console.log(`${colors.gray}GitHub login cancelled.${colors.reset}\n`); + } else { + const message = error instanceof Error ? error.message : String(error); + console.log(`${colors.gray}GitHub login failed:${colors.reset} ${message}\n`); + } + } finally { + this.rl?.removeListener('SIGINT', onSigint); } return; @@ -1222,6 +1232,10 @@ export class MinimalTui { console.log(`${colors.gray}Starting OpenAI OAuth device flow...${colors.reset}`); + const controller = new AbortController(); + const onSigint = () => controller.abort(); + this.rl?.once('SIGINT', onSigint); + let credentialStored = false; try { await loginOpenAI((userCode, verificationUri) => { @@ -1229,14 +1243,20 @@ export class MinimalTui { console.log(`${colors.gray}Please visit:${colors.reset} ${verificationUri}`); console.log(`${colors.gray}and enter code:${colors.reset} ${userCode}`); console.log(''); - console.log(`${colors.gray}Waiting for authorization...${colors.reset}`); - }); + console.log(`${colors.gray}Waiting for authorization... (Ctrl+C to cancel)${colors.reset}`); + }, controller.signal); console.log(`${colors.gray}OpenAI authentication successful! Token stored.${colors.reset}\n`); credentialStored = true; } catch (error) { - const message = error instanceof Error ? error.message : String(error); - console.log(`${colors.gray}OpenAI login failed:${colors.reset} ${message}\n`); + if (controller.signal.aborted) { + console.log(`${colors.gray}OpenAI login cancelled.${colors.reset}\n`); + } else { + const message = error instanceof Error ? error.message : String(error); + console.log(`${colors.gray}OpenAI login failed:${colors.reset} ${message}\n`); + } + } finally { + this.rl?.removeListener('SIGINT', onSigint); } // Offer to set auth_mode if config is available