diff --git a/auto-claude-ui/src/main/ipc-handlers/github/__tests__/oauth-handlers.spec.ts b/auto-claude-ui/src/main/ipc-handlers/github/__tests__/oauth-handlers.spec.ts new file mode 100644 index 00000000..6ff4db97 --- /dev/null +++ b/auto-claude-ui/src/main/ipc-handlers/github/__tests__/oauth-handlers.spec.ts @@ -0,0 +1,548 @@ +/** + * Unit tests for GitHub OAuth handlers + * Tests device code parsing, shell.openExternal handling, and error recovery + */ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { EventEmitter } from 'events'; + +// Mock child_process before importing +const mockSpawn = vi.fn(); +const mockExecSync = vi.fn(); +const mockExecFileSync = vi.fn(); + +vi.mock('child_process', () => ({ + spawn: (...args: unknown[]) => mockSpawn(...args), + execSync: (...args: unknown[]) => mockExecSync(...args), + execFileSync: (...args: unknown[]) => mockExecFileSync(...args) +})); + +// Mock shell.openExternal +const mockOpenExternal = vi.fn(); + +vi.mock('electron', () => { + const mockIpcMain = new (class extends EventEmitter { + private handlers: Map = new Map(); + + handle(channel: string, handler: Function): void { + this.handlers.set(channel, handler); + } + + removeHandler(channel: string): void { + this.handlers.delete(channel); + } + + async invokeHandler(channel: string, event: unknown, ...args: unknown[]): Promise { + const handler = this.handlers.get(channel); + if (handler) { + return handler(event, ...args); + } + throw new Error(`No handler for channel: ${channel}`); + } + + getHandler(channel: string): Function | undefined { + return this.handlers.get(channel); + } + })(); + + return { + ipcMain: mockIpcMain, + shell: { + openExternal: (...args: unknown[]) => mockOpenExternal(...args) + } + }; +}); + +// Mock @electron-toolkit/utils +vi.mock('@electron-toolkit/utils', () => ({ + is: { + dev: true, + windows: process.platform === 'win32', + macos: process.platform === 'darwin', + linux: process.platform === 'linux' + } +})); + +// Create mock process for spawn +function createMockProcess(): EventEmitter & { + stdout: EventEmitter | null; + stderr: EventEmitter | null; + stdin: { write: ReturnType; end: ReturnType } | null; +} { + const proc = new EventEmitter() as EventEmitter & { + stdout: EventEmitter | null; + stderr: EventEmitter | null; + stdin: { write: ReturnType; end: ReturnType } | null; + }; + proc.stdout = new EventEmitter(); + proc.stderr = new EventEmitter(); + proc.stdin = { write: vi.fn(), end: vi.fn() }; + return proc; +} + +describe('GitHub OAuth Handlers', () => { + let ipcMain: EventEmitter & { + handlers: Map; + invokeHandler: (channel: string, event: unknown, ...args: unknown[]) => Promise; + getHandler: (channel: string) => Function | undefined; + }; + + beforeEach(async () => { + vi.clearAllMocks(); + vi.resetModules(); + + // Get mocked ipcMain + const electron = await import('electron'); + ipcMain = electron.ipcMain as unknown as typeof ipcMain; + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + describe('Device Code Parsing', () => { + it('should parse device code from standard gh CLI output format', async () => { + const mockProcess = createMockProcess(); + mockSpawn.mockReturnValue(mockProcess); + mockOpenExternal.mockResolvedValue(undefined); + + const { registerStartGhAuth } = await import('../oauth-handlers'); + registerStartGhAuth(); + + // Start the handler + const resultPromise = ipcMain.invokeHandler('github:startAuth', {}); + + // Simulate gh CLI output with device code + mockProcess.stderr?.emit('data', '! First copy your one-time code: ABCD-1234\n'); + mockProcess.stderr?.emit('data', '- Press Enter to open github.com in your browser...\n'); + + // Complete the process + mockProcess.emit('close', 0); + + const result = await resultPromise; + + expect(result).toHaveProperty('success', true); + expect(result).toHaveProperty('data'); + const data = (result as { data: { deviceCode: string } }).data; + expect(data.deviceCode).toBe('ABCD-1234'); + }); + + it('should parse device code from alternate output format (lowercase "code")', async () => { + const mockProcess = createMockProcess(); + mockSpawn.mockReturnValue(mockProcess); + mockOpenExternal.mockResolvedValue(undefined); + + const { registerStartGhAuth } = await import('../oauth-handlers'); + registerStartGhAuth(); + + const resultPromise = ipcMain.invokeHandler('github:startAuth', {}); + + // Alternate format: "code: XXXX-XXXX" without "one-time" + mockProcess.stderr?.emit('data', 'Enter the code: EFGH-5678\n'); + mockProcess.emit('close', 0); + + const result = await resultPromise; + + expect(result).toHaveProperty('success', true); + const data = (result as { data: { deviceCode: string } }).data; + expect(data.deviceCode).toBe('EFGH-5678'); + }); + + it('should parse device code from stdout (not just stderr)', async () => { + const mockProcess = createMockProcess(); + mockSpawn.mockReturnValue(mockProcess); + mockOpenExternal.mockResolvedValue(undefined); + + const { registerStartGhAuth } = await import('../oauth-handlers'); + registerStartGhAuth(); + + const resultPromise = ipcMain.invokeHandler('github:startAuth', {}); + + // Device code in stdout instead of stderr + mockProcess.stdout?.emit('data', '! First copy your one-time code: IJKL-9012\n'); + mockProcess.emit('close', 0); + + const result = await resultPromise; + + expect(result).toHaveProperty('success', true); + const data = (result as { data: { deviceCode: string } }).data; + expect(data.deviceCode).toBe('IJKL-9012'); + }); + + it('should handle output without device code gracefully', async () => { + const mockProcess = createMockProcess(); + mockSpawn.mockReturnValue(mockProcess); + + const { registerStartGhAuth } = await import('../oauth-handlers'); + registerStartGhAuth(); + + const resultPromise = ipcMain.invokeHandler('github:startAuth', {}); + + // Output without device code + mockProcess.stderr?.emit('data', 'Some other message\n'); + mockProcess.emit('close', 0); + + const result = await resultPromise; + + expect(result).toHaveProperty('success', true); + const data = (result as { data: { deviceCode?: string } }).data; + expect(data.deviceCode).toBeUndefined(); + }); + + it('should extract URL from output containing https://github.com/login/device', async () => { + const mockProcess = createMockProcess(); + mockSpawn.mockReturnValue(mockProcess); + mockOpenExternal.mockResolvedValue(undefined); + + const { registerStartGhAuth } = await import('../oauth-handlers'); + registerStartGhAuth(); + + const resultPromise = ipcMain.invokeHandler('github:startAuth', {}); + + mockProcess.stderr?.emit('data', '! First copy your one-time code: MNOP-3456\n'); + mockProcess.stderr?.emit('data', 'Then visit https://github.com/login/device to authenticate\n'); + mockProcess.emit('close', 0); + + const result = await resultPromise; + + expect(result).toHaveProperty('success', true); + const data = (result as { data: { authUrl: string } }).data; + expect(data.authUrl).toBe('https://github.com/login/device'); + }); + }); + + describe('shell.openExternal Handling', () => { + it('should call shell.openExternal with extracted URL when device code found', async () => { + const mockProcess = createMockProcess(); + mockSpawn.mockReturnValue(mockProcess); + mockOpenExternal.mockResolvedValue(undefined); + + const { registerStartGhAuth } = await import('../oauth-handlers'); + registerStartGhAuth(); + + const resultPromise = ipcMain.invokeHandler('github:startAuth', {}); + + mockProcess.stderr?.emit('data', '! First copy your one-time code: QRST-7890\n'); + + // Wait for next tick to allow async browser opening + await new Promise(resolve => setTimeout(resolve, 10)); + + mockProcess.emit('close', 0); + await resultPromise; + + expect(mockOpenExternal).toHaveBeenCalledWith('https://github.com/login/device'); + }); + + it('should set browserOpened to true when shell.openExternal succeeds', async () => { + const mockProcess = createMockProcess(); + mockSpawn.mockReturnValue(mockProcess); + mockOpenExternal.mockResolvedValue(undefined); + + const { registerStartGhAuth } = await import('../oauth-handlers'); + registerStartGhAuth(); + + const resultPromise = ipcMain.invokeHandler('github:startAuth', {}); + + mockProcess.stderr?.emit('data', '! First copy your one-time code: UVWX-1234\n'); + + // Wait for async browser opening + await new Promise(resolve => setTimeout(resolve, 10)); + + mockProcess.emit('close', 0); + const result = await resultPromise; + + expect(result).toHaveProperty('success', true); + const data = (result as { data: { browserOpened: boolean } }).data; + expect(data.browserOpened).toBe(true); + }); + + it('should set browserOpened to false when shell.openExternal fails', async () => { + const mockProcess = createMockProcess(); + mockSpawn.mockReturnValue(mockProcess); + mockOpenExternal.mockRejectedValue(new Error('Failed to open browser')); + + const { registerStartGhAuth } = await import('../oauth-handlers'); + registerStartGhAuth(); + + const resultPromise = ipcMain.invokeHandler('github:startAuth', {}); + + mockProcess.stderr?.emit('data', '! First copy your one-time code: YZAB-5678\n'); + + // Wait for async browser opening to fail + await new Promise(resolve => setTimeout(resolve, 10)); + + mockProcess.emit('close', 0); + const result = await resultPromise; + + expect(result).toHaveProperty('success', true); + const data = (result as { data: { browserOpened: boolean } }).data; + expect(data.browserOpened).toBe(false); + }); + + it('should provide fallbackUrl when browser fails to open', async () => { + const mockProcess = createMockProcess(); + mockSpawn.mockReturnValue(mockProcess); + mockOpenExternal.mockRejectedValue(new Error('Failed to open browser')); + + const { registerStartGhAuth } = await import('../oauth-handlers'); + registerStartGhAuth(); + + const resultPromise = ipcMain.invokeHandler('github:startAuth', {}); + + mockProcess.stderr?.emit('data', '! First copy your one-time code: CDEF-9012\n'); + + // Wait for async browser opening to fail + await new Promise(resolve => setTimeout(resolve, 10)); + + mockProcess.emit('close', 0); + const result = await resultPromise; + + expect(result).toHaveProperty('success', true); + const data = (result as { data: { fallbackUrl?: string } }).data; + expect(data.fallbackUrl).toBe('https://github.com/login/device'); + }); + + it('should not provide fallbackUrl when browser opens successfully', async () => { + const mockProcess = createMockProcess(); + mockSpawn.mockReturnValue(mockProcess); + mockOpenExternal.mockResolvedValue(undefined); + + const { registerStartGhAuth } = await import('../oauth-handlers'); + registerStartGhAuth(); + + const resultPromise = ipcMain.invokeHandler('github:startAuth', {}); + + mockProcess.stderr?.emit('data', '! First copy your one-time code: GHIJ-3456\n'); + + // Wait for async browser opening + await new Promise(resolve => setTimeout(resolve, 10)); + + mockProcess.emit('close', 0); + const result = await resultPromise; + + expect(result).toHaveProperty('success', true); + const data = (result as { data: { fallbackUrl?: string } }).data; + expect(data.fallbackUrl).toBeUndefined(); + }); + }); + + describe('Error Handling', () => { + it('should handle gh CLI process error', async () => { + const mockProcess = createMockProcess(); + mockSpawn.mockReturnValue(mockProcess); + + const { registerStartGhAuth } = await import('../oauth-handlers'); + registerStartGhAuth(); + + const resultPromise = ipcMain.invokeHandler('github:startAuth', {}); + + // Emit error event + mockProcess.emit('error', new Error('spawn gh ENOENT')); + + const result = await resultPromise; + + expect(result).toHaveProperty('success', false); + expect(result).toHaveProperty('error', 'spawn gh ENOENT'); + const data = (result as { data: { fallbackUrl: string } }).data; + expect(data.fallbackUrl).toBe('https://github.com/login/device'); + }); + + it('should handle non-zero exit code', async () => { + const mockProcess = createMockProcess(); + mockSpawn.mockReturnValue(mockProcess); + + const { registerStartGhAuth } = await import('../oauth-handlers'); + registerStartGhAuth(); + + const resultPromise = ipcMain.invokeHandler('github:startAuth', {}); + + mockProcess.stderr?.emit('data', 'error: some authentication error\n'); + mockProcess.emit('close', 1); + + const result = await resultPromise; + + expect(result).toHaveProperty('success', false); + const data = (result as { data: { fallbackUrl: string } }).data; + expect(data.fallbackUrl).toBe('https://github.com/login/device'); + }); + + it('should include device code in error result if it was extracted before failure', async () => { + const mockProcess = createMockProcess(); + mockSpawn.mockReturnValue(mockProcess); + mockOpenExternal.mockResolvedValue(undefined); + + const { registerStartGhAuth } = await import('../oauth-handlers'); + registerStartGhAuth(); + + const resultPromise = ipcMain.invokeHandler('github:startAuth', {}); + + // Device code output followed by failure + mockProcess.stderr?.emit('data', '! First copy your one-time code: KLMN-7890\n'); + + // Wait for async browser opening + await new Promise(resolve => setTimeout(resolve, 10)); + + mockProcess.stderr?.emit('data', 'error: authentication failed\n'); + mockProcess.emit('close', 1); + + const result = await resultPromise; + + expect(result).toHaveProperty('success', false); + const data = (result as { data: { deviceCode: string; fallbackUrl: string } }).data; + expect(data.deviceCode).toBe('KLMN-7890'); + expect(data.fallbackUrl).toBe('https://github.com/login/device'); + }); + + it('should provide user-friendly error message on process spawn failure', async () => { + const mockProcess = createMockProcess(); + mockSpawn.mockReturnValue(mockProcess); + + const { registerStartGhAuth } = await import('../oauth-handlers'); + registerStartGhAuth(); + + const resultPromise = ipcMain.invokeHandler('github:startAuth', {}); + + mockProcess.emit('error', new Error('spawn gh ENOENT')); + + const result = await resultPromise; + + expect(result).toHaveProperty('success', false); + const data = (result as { data: { message: string } }).data; + expect(data.message).toContain('Failed to start GitHub CLI'); + }); + }); + + describe('gh CLI Check Handler', () => { + it('should return installed: true when gh CLI is found', async () => { + mockExecSync.mockImplementation((cmd: string) => { + if (cmd.includes('which gh') || cmd.includes('where gh')) { + return '/usr/local/bin/gh\n'; + } + if (cmd === 'gh --version') { + return 'gh version 2.65.0 (2024-01-15)\n'; + } + return ''; + }); + + const { registerCheckGhCli } = await import('../oauth-handlers'); + registerCheckGhCli(); + + const result = await ipcMain.invokeHandler('github:checkCli', {}); + + expect(result).toHaveProperty('success', true); + const data = (result as { data: { installed: boolean; version: string } }).data; + expect(data.installed).toBe(true); + expect(data.version).toContain('gh version'); + }); + + it('should return installed: false when gh CLI is not found', async () => { + mockExecSync.mockImplementation(() => { + throw new Error('Command not found'); + }); + + const { registerCheckGhCli } = await import('../oauth-handlers'); + registerCheckGhCli(); + + const result = await ipcMain.invokeHandler('github:checkCli', {}); + + expect(result).toHaveProperty('success', true); + const data = (result as { data: { installed: boolean } }).data; + expect(data.installed).toBe(false); + }); + }); + + describe('gh Auth Check Handler', () => { + it('should return authenticated: true with username when logged in', async () => { + mockExecSync.mockImplementation((cmd: string) => { + if (cmd === 'gh auth status') { + return 'Logged in to github.com as testuser\n'; + } + if (cmd === 'gh api user --jq .login') { + return 'testuser\n'; + } + return ''; + }); + + const { registerCheckGhAuth } = await import('../oauth-handlers'); + registerCheckGhAuth(); + + const result = await ipcMain.invokeHandler('github:checkAuth', {}); + + expect(result).toHaveProperty('success', true); + const data = (result as { data: { authenticated: boolean; username: string } }).data; + expect(data.authenticated).toBe(true); + expect(data.username).toBe('testuser'); + }); + + it('should return authenticated: false when not logged in', async () => { + mockExecSync.mockImplementation(() => { + throw new Error('You are not logged into any GitHub hosts'); + }); + + const { registerCheckGhAuth } = await import('../oauth-handlers'); + registerCheckGhAuth(); + + const result = await ipcMain.invokeHandler('github:checkAuth', {}); + + expect(result).toHaveProperty('success', true); + const data = (result as { data: { authenticated: boolean } }).data; + expect(data.authenticated).toBe(false); + }); + }); + + describe('Spawn Arguments', () => { + it('should spawn gh with correct auth login arguments', async () => { + const mockProcess = createMockProcess(); + mockSpawn.mockReturnValue(mockProcess); + + const { registerStartGhAuth } = await import('../oauth-handlers'); + registerStartGhAuth(); + + ipcMain.invokeHandler('github:startAuth', {}); + + expect(mockSpawn).toHaveBeenCalledWith( + 'gh', + ['auth', 'login', '--web', '--scopes', 'repo'], + expect.objectContaining({ + stdio: ['pipe', 'pipe', 'pipe'] + }) + ); + }); + }); + + describe('Repository Validation', () => { + it('should reject invalid repository format', async () => { + const { registerGetGitHubBranches } = await import('../oauth-handlers'); + registerGetGitHubBranches(); + + // Test with injection attempt + const result = await ipcMain.invokeHandler( + 'github:getBranches', + {}, + 'owner/repo; rm -rf /', + 'token' + ); + + expect(result).toHaveProperty('success', false); + expect(result).toHaveProperty('error', 'Invalid repository format. Expected: owner/repo'); + }); + + it('should accept valid repository format', async () => { + mockExecFileSync.mockReturnValue('main\nfeature-branch\n'); + + const { registerGetGitHubBranches } = await import('../oauth-handlers'); + registerGetGitHubBranches(); + + const result = await ipcMain.invokeHandler( + 'github:getBranches', + {}, + 'valid-owner/valid-repo', + 'token' + ); + + expect(result).toHaveProperty('success', true); + const data = (result as { data: string[] }).data; + expect(data).toContain('main'); + expect(data).toContain('feature-branch'); + }); + }); +}); diff --git a/auto-claude-ui/src/main/ipc-handlers/github/oauth-handlers.ts b/auto-claude-ui/src/main/ipc-handlers/github/oauth-handlers.ts index 95f19f44..1ff9d22e 100644 --- a/auto-claude-ui/src/main/ipc-handlers/github/oauth-handlers.ts +++ b/auto-claude-ui/src/main/ipc-handlers/github/oauth-handlers.ts @@ -3,7 +3,7 @@ * Provides a simpler OAuth flow than manual PAT creation */ -import { ipcMain } from 'electron'; +import { ipcMain, shell } from 'electron'; import { execSync, execFileSync, spawn } from 'child_process'; import { IPC_CHANNELS } from '../../../shared/constants'; import type { IPCResult } from '../../../shared/types'; @@ -33,6 +33,64 @@ function isValidGitHubRepo(repo: string): boolean { return GITHUB_REPO_PATTERN.test(repo); } +// Regex patterns for parsing device code from gh CLI output +// Expected format: "! First copy your one-time code: XXXX-XXXX" +const DEVICE_CODE_PATTERN = /(?:one-time code|code):\s*([A-Z0-9]{4}-[A-Z0-9]{4})/i; + +// GitHub device flow URL pattern +const DEVICE_URL_PATTERN = /https:\/\/github\.com\/login\/device/i; + +// Default GitHub device flow URL +const GITHUB_DEVICE_URL = 'https://github.com/login/device'; + +/** + * Parse device code from gh CLI stdout output + * Returns the device code (format: XXXX-XXXX) if found, null otherwise + */ +function parseDeviceCode(output: string): string | null { + const match = output.match(DEVICE_CODE_PATTERN); + if (match && match[1]) { + debugLog('Parsed device code:', match[1]); + return match[1]; + } + return null; +} + +/** + * Parse device URL from gh CLI output + * Returns the URL if found, or the default GitHub device URL + */ +function parseDeviceUrl(output: string): string { + const match = output.match(DEVICE_URL_PATTERN); + if (match) { + debugLog('Found device URL in output:', match[0]); + return match[0]; + } + // Default to standard GitHub device flow URL + return GITHUB_DEVICE_URL; +} + +/** + * Result of parsing device flow output from gh CLI + */ +interface DeviceFlowInfo { + deviceCode: string | null; + authUrl: string; +} + +/** + * Parse both device code and URL from combined gh CLI output + * Searches through both stdout and stderr as gh may output to either + */ +function parseDeviceFlowOutput(stdout: string, stderr: string): DeviceFlowInfo { + const combinedOutput = `${stdout}\n${stderr}`; + + return { + deviceCode: parseDeviceCode(combinedOutput), + authUrl: parseDeviceUrl(combinedOutput) + }; +} + /** * Check if gh CLI is installed */ @@ -114,14 +172,31 @@ export function registerCheckGhAuth(): void { ); } +/** + * Result type for GitHub auth start, including device flow information + */ +interface GitHubAuthStartResult { + success: boolean; + message?: string; + deviceCode?: string; + authUrl?: string; + browserOpened?: boolean; + /** + * Fallback URL provided when browser launch fails. + * The frontend should display this URL so users can manually navigate to complete auth. + */ + fallbackUrl?: string; +} + /** * Start GitHub OAuth flow using gh CLI - * This will open the browser for device flow authentication + * This will extract the device code from gh CLI output and open the browser + * using Electron's shell.openExternal (bypasses macOS child process restrictions) */ export function registerStartGhAuth(): void { ipcMain.handle( IPC_CHANNELS.GITHUB_START_AUTH, - async (): Promise> => { + async (): Promise> => { debugLog('startGitHubAuth handler called'); return new Promise((resolve) => { try { @@ -135,17 +210,53 @@ export function registerStartGhAuth(): void { let output = ''; let errorOutput = ''; + let deviceCodeExtracted = false; + let extractedDeviceCode: string | null = null; + let extractedAuthUrl: string = GITHUB_DEVICE_URL; + let browserOpenedSuccessfully = false; + + // Function to attempt device code extraction and browser opening + const tryExtractAndOpenBrowser = async () => { + if (deviceCodeExtracted) return; // Already extracted + + const deviceFlowInfo = parseDeviceFlowOutput(output, errorOutput); + + if (deviceFlowInfo.deviceCode) { + deviceCodeExtracted = true; + extractedDeviceCode = deviceFlowInfo.deviceCode; + extractedAuthUrl = deviceFlowInfo.authUrl; + + debugLog('Device code extracted:', extractedDeviceCode); + debugLog('Auth URL:', extractedAuthUrl); + + // Open browser using Electron's shell.openExternal + // This bypasses macOS child process restrictions that block gh CLI's browser launch + try { + await shell.openExternal(extractedAuthUrl); + browserOpenedSuccessfully = true; + debugLog('Browser opened successfully via shell.openExternal'); + } catch (browserError) { + debugLog('Failed to open browser:', browserError instanceof Error ? browserError.message : browserError); + browserOpenedSuccessfully = false; + // Don't fail here - we'll return the device code so user can manually navigate + } + } + }; ghProcess.stdout?.on('data', (data) => { const chunk = data.toString(); output += chunk; debugLog('gh stdout:', chunk); + // Try to extract device code as data comes in + tryExtractAndOpenBrowser(); }); ghProcess.stderr?.on('data', (data) => { const chunk = data.toString(); errorOutput += chunk; debugLog('gh stderr:', chunk); + // gh often outputs to stderr, so check there too + tryExtractAndOpenBrowser(); }); ghProcess.on('close', (code) => { @@ -154,17 +265,39 @@ export function registerStartGhAuth(): void { debugLog('Full stderr:', errorOutput); if (code === 0) { + // Success case - include fallbackUrl if browser failed to open + // so the user can manually navigate if needed resolve({ success: true, data: { success: true, - message: 'Successfully authenticated with GitHub' + message: browserOpenedSuccessfully + ? 'Successfully authenticated with GitHub' + : 'Authentication successful. Browser could not be opened automatically.', + deviceCode: extractedDeviceCode || undefined, + authUrl: extractedAuthUrl, + browserOpened: browserOpenedSuccessfully, + // Provide fallback URL when browser failed to open + fallbackUrl: !browserOpenedSuccessfully ? extractedAuthUrl : undefined } }); } else { + // Even if auth failed, return device code info if we extracted it + // This allows user to retry manually with the fallback URL + const fallbackUrlForManualAuth = extractedDeviceCode ? extractedAuthUrl : GITHUB_DEVICE_URL; + resolve({ success: false, - error: errorOutput || `Authentication failed with exit code ${code}` + error: errorOutput || `Authentication failed with exit code ${code}`, + data: { + success: false, + deviceCode: extractedDeviceCode || undefined, + authUrl: extractedAuthUrl, + browserOpened: browserOpenedSuccessfully, + // Always provide fallback URL on failure for manual recovery + fallbackUrl: fallbackUrlForManualAuth, + message: 'Authentication failed. Please visit the URL manually to complete authentication.' + } }); } }); @@ -173,14 +306,28 @@ export function registerStartGhAuth(): void { debugLog('gh process error:', error.message); resolve({ success: false, - error: error.message + error: error.message, + data: { + success: false, + browserOpened: false, + // Provide fallback URL so user can attempt manual auth + fallbackUrl: GITHUB_DEVICE_URL, + message: 'Failed to start GitHub CLI. Please visit the URL manually to authenticate.' + } }); }); } catch (error) { debugLog('Exception in startGitHubAuth:', error instanceof Error ? error.message : error); resolve({ success: false, - error: error instanceof Error ? error.message : 'Unknown error' + error: error instanceof Error ? error.message : 'Unknown error', + data: { + success: false, + browserOpened: false, + // Provide fallback URL for manual authentication recovery + fallbackUrl: GITHUB_DEVICE_URL, + message: 'An unexpected error occurred. Please visit the URL manually to authenticate.' + } }); } }); diff --git a/auto-claude-ui/src/renderer/components/project-settings/GitHubOAuthFlow.tsx b/auto-claude-ui/src/renderer/components/project-settings/GitHubOAuthFlow.tsx index c743cd6c..77e84297 100644 --- a/auto-claude-ui/src/renderer/components/project-settings/GitHubOAuthFlow.tsx +++ b/auto-claude-ui/src/renderer/components/project-settings/GitHubOAuthFlow.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useRef } from 'react'; +import { useState, useEffect, useRef, useCallback } from 'react'; import { Github, Loader2, @@ -6,7 +6,10 @@ import { AlertCircle, Info, ExternalLink, - Terminal + Terminal, + Copy, + Check, + Clock } from 'lucide-react'; import { Button } from '../ui/button'; import { Card, CardContent } from '../ui/card'; @@ -29,6 +32,10 @@ function debugLog(message: string, data?: unknown) { } } +// Authentication timeout in milliseconds (5 minutes) +// GitHub device codes typically expire after 15 minutes, but 5 minutes is a reasonable UX timeout +const AUTH_TIMEOUT_MS = 5 * 60 * 1000; + /** * GitHub OAuth flow component using gh CLI * Guides users through authenticating with GitHub using the gh CLI @@ -40,10 +47,39 @@ export function GitHubOAuthFlow({ onSuccess, onCancel }: GitHubOAuthFlowProps) { const [cliVersion, setCliVersion] = useState(); const [username, setUsername] = useState(); + // Device flow state for displaying code and auth URL + const [deviceCode, setDeviceCode] = useState(null); + const [authUrl, setAuthUrl] = useState(null); + const [browserOpened, setBrowserOpened] = useState(false); + const [codeCopied, setCodeCopied] = useState(false); + const [urlCopied, setUrlCopied] = useState(false); + const [isTimeout, setIsTimeout] = useState(false); + + // Ref to track authentication timeout + const authTimeoutRef = useRef | null>(null); + // Check gh CLI installation and authentication status on mount // Use a ref to prevent double-execution in React Strict Mode const hasCheckedRef = useRef(false); + // Clear the authentication timeout + const clearAuthTimeout = useCallback(() => { + if (authTimeoutRef.current) { + debugLog('Clearing auth timeout'); + clearTimeout(authTimeoutRef.current); + authTimeoutRef.current = null; + } + }, []); + + // Handle authentication timeout + const handleAuthTimeout = useCallback(() => { + debugLog('Authentication timeout triggered after 5 minutes'); + setIsTimeout(true); + setError('Authentication timed out. The authentication window was open for too long. Please try again.'); + setStatus('error'); + authTimeoutRef.current = null; + }, []); + useEffect(() => { if (hasCheckedRef.current) { debugLog('Skipping duplicate check (Strict Mode)'); @@ -52,8 +88,13 @@ export function GitHubOAuthFlow({ onSuccess, onCancel }: GitHubOAuthFlowProps) { hasCheckedRef.current = true; debugLog('Component mounted, checking GitHub status...'); checkGitHubStatus(); + + // Cleanup timeout on unmount + return () => { + clearAuthTimeout(); + }; // eslint-disable-next-line react-hooks/exhaustive-deps -- Only run once on mount, checkGitHubStatus is intentionally excluded - }, []); + }, [clearAuthTimeout]); const checkGitHubStatus = async () => { debugLog('checkGitHubStatus() called'); @@ -138,21 +179,59 @@ export function GitHubOAuthFlow({ onSuccess, onCancel }: GitHubOAuthFlowProps) { setStatus('authenticating'); setError(null); + // Reset device flow state + setDeviceCode(null); + setAuthUrl(null); + setBrowserOpened(false); + setCodeCopied(false); + setUrlCopied(false); + setIsTimeout(false); + + // Clear any existing timeout and start a new one + clearAuthTimeout(); + debugLog(`Starting auth timeout (${AUTH_TIMEOUT_MS / 1000 / 60} minutes)`); + authTimeoutRef.current = setTimeout(handleAuthTimeout, AUTH_TIMEOUT_MS); + try { debugLog('Calling startGitHubAuth...'); const result = await window.electronAPI.startGitHubAuth(); debugLog('startGitHubAuth result:', result); + // Clear timeout since we got a response + clearAuthTimeout(); + + // Capture device flow info if available + if (result.data?.deviceCode) { + debugLog('Device code received:', result.data.deviceCode); + setDeviceCode(result.data.deviceCode); + } + if (result.data?.authUrl) { + debugLog('Auth URL received:', result.data.authUrl); + setAuthUrl(result.data.authUrl); + } + if (result.data?.browserOpened !== undefined) { + debugLog('Browser opened status:', result.data.browserOpened); + setBrowserOpened(result.data.browserOpened); + } + if (result.success && result.data?.success) { debugLog('Auth successful, fetching token...'); // Fetch the token and notify parent await fetchAndNotifyToken(); } else { debugLog('Auth failed:', result.error); - setError(result.error || 'Authentication failed'); + // Include fallback URL info in error message if available + const errorMessage = result.error || 'Authentication failed'; + setError(errorMessage); + // Keep authUrl from response for fallback display + if (result.data?.fallbackUrl) { + setAuthUrl(result.data.fallbackUrl); + } setStatus('error'); } } catch (err) { + // Clear timeout on error + clearAuthTimeout(); debugLog('Error in handleStartAuth:', err); setError(err instanceof Error ? err.message : 'Authentication failed'); setStatus('error'); @@ -169,6 +248,26 @@ export function GitHubOAuthFlow({ onSuccess, onCancel }: GitHubOAuthFlowProps) { checkGitHubStatus(); }; + const handleCopyDeviceCode = async () => { + if (!deviceCode) return; + debugLog('Copying device code to clipboard'); + try { + await navigator.clipboard.writeText(deviceCode); + setCodeCopied(true); + // Reset the copied state after 2 seconds + setTimeout(() => setCodeCopied(false), 2000); + } catch (err) { + debugLog('Failed to copy device code:', err); + } + }; + + const handleOpenAuthUrl = () => { + if (authUrl) { + debugLog('Opening auth URL manually:', authUrl); + window.open(authUrl, '_blank'); + } + }; + debugLog('Rendering with status:', status); return ( @@ -263,21 +362,81 @@ export function GitHubOAuthFlow({ onSuccess, onCancel }: GitHubOAuthFlowProps) { {/* Authenticating */} {status === 'authenticating' && ( - - -
- -
-

- Authenticating... -

-

- Please complete the authentication in your browser. This window will update automatically. -

+
+ + +
+ +
+

+ Authenticating... +

+

+ {browserOpened + ? 'Please complete the authentication in your browser. This window will update automatically.' + : 'Waiting for authentication flow to start...'} +

+
-
- - + + + + {/* Device Code Display */} + {deviceCode && ( + + +
+
+

+ Your one-time code +

+
+ + {deviceCode} + + +
+
+ +
+

+ {browserOpened + ? 'Enter this code in your browser to complete authentication.' + : 'Copy this code, then open the link below to authenticate.'} +

+ {!browserOpened && authUrl && ( + + )} +
+
+
+
+ )} +
)} {/* Success */} @@ -302,22 +461,102 @@ export function GitHubOAuthFlow({ onSuccess, onCancel }: GitHubOAuthFlowProps) { {/* Error */} {status === 'error' && error && (
- +
- + {isTimeout ? ( + + ) : ( + + )}
-

- Authentication Failed +

+ {isTimeout ? 'Authentication Timed Out' : 'Authentication Failed'}

-

{error}

+

{error}

+ {/* Fallback URL display when browser failed to open */} + {authUrl && ( + + +
+
+ +
+

+ Complete Authentication Manually +

+

+ The browser couldn't be opened automatically. Please visit the URL below to complete authentication: +

+
+
+ +
+
+ + {authUrl} + + +
+ + +
+ + {/* Device code reminder if available */} + {deviceCode && ( +
+

+ When prompted, enter this code:{' '} + + {deviceCode} + +

+
+ )} +
+
+
+ )} +
- {onCancel && ( diff --git a/auto-claude-ui/src/shared/types/ipc.ts b/auto-claude-ui/src/shared/types/ipc.ts index 050efe6c..26d0a88b 100644 --- a/auto-claude-ui/src/shared/types/ipc.ts +++ b/auto-claude-ui/src/shared/types/ipc.ts @@ -319,7 +319,14 @@ export interface ElectronAPI { // GitHub OAuth operations (gh CLI) checkGitHubCli: () => Promise>; checkGitHubAuth: () => Promise>; - startGitHubAuth: () => Promise>; + startGitHubAuth: () => Promise>; getGitHubToken: () => Promise>; getGitHubUser: () => Promise>; listGitHubUserRepos: () => Promise }>>; diff --git a/auto-claude-ui/vitest.config.ts b/auto-claude-ui/vitest.config.ts index abc39cc8..6eb2f5ee 100644 --- a/auto-claude-ui/vitest.config.ts +++ b/auto-claude-ui/vitest.config.ts @@ -5,13 +5,13 @@ export default defineConfig({ test: { globals: true, environment: 'node', - include: ['src/**/*.test.ts', 'src/**/*.test.tsx'], + include: ['src/**/*.test.ts', 'src/**/*.test.tsx', 'src/**/*.spec.ts', 'src/**/*.spec.tsx'], exclude: ['node_modules', 'dist', 'out'], coverage: { provider: 'v8', reporter: ['text', 'json', 'html'], include: ['src/**/*.ts', 'src/**/*.tsx'], - exclude: ['src/**/*.test.ts', 'src/**/*.test.tsx', 'src/**/*.d.ts'] + exclude: ['src/**/*.test.ts', 'src/**/*.test.tsx', 'src/**/*.spec.ts', 'src/**/*.spec.tsx', 'src/**/*.d.ts'] }, // Mock Electron modules for unit tests alias: {