Merge branch 'auto-claude/050-github-connection-will-not-open-browser-on-macos' into v2.6.0

This commit is contained in:
AndyMik90
2025-12-20 13:22:07 +01:00
5 changed files with 975 additions and 34 deletions
@@ -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<string, Function> = 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<unknown> {
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<typeof vi.fn>; end: ReturnType<typeof vi.fn> } | null;
} {
const proc = new EventEmitter() as EventEmitter & {
stdout: EventEmitter | null;
stderr: EventEmitter | null;
stdin: { write: ReturnType<typeof vi.fn>; end: ReturnType<typeof vi.fn> } | 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<string, Function>;
invokeHandler: (channel: string, event: unknown, ...args: unknown[]) => Promise<unknown>;
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');
});
});
});
@@ -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<IPCResult<{ success: boolean; message?: string }>> => {
async (): Promise<IPCResult<GitHubAuthStartResult>> => {
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.'
}
});
}
});
@@ -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<string | undefined>();
const [username, setUsername] = useState<string | undefined>();
// Device flow state for displaying code and auth URL
const [deviceCode, setDeviceCode] = useState<string | null>(null);
const [authUrl, setAuthUrl] = useState<string | null>(null);
const [browserOpened, setBrowserOpened] = useState<boolean>(false);
const [codeCopied, setCodeCopied] = useState<boolean>(false);
const [urlCopied, setUrlCopied] = useState<boolean>(false);
const [isTimeout, setIsTimeout] = useState<boolean>(false);
// Ref to track authentication timeout
const authTimeoutRef = useRef<ReturnType<typeof setTimeout> | 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' && (
<Card className="border border-info/30 bg-info/10">
<CardContent className="p-6">
<div className="flex items-center gap-4">
<Loader2 className="h-6 w-6 animate-spin text-info shrink-0" />
<div className="flex-1">
<h3 className="text-lg font-medium text-foreground">
Authenticating...
</h3>
<p className="text-sm text-muted-foreground mt-1">
Please complete the authentication in your browser. This window will update automatically.
</p>
<div className="space-y-4">
<Card className="border border-info/30 bg-info/10">
<CardContent className="p-6">
<div className="flex items-center gap-4">
<Loader2 className="h-6 w-6 animate-spin text-info shrink-0" />
<div className="flex-1">
<h3 className="text-lg font-medium text-foreground">
Authenticating...
</h3>
<p className="text-sm text-muted-foreground mt-1">
{browserOpened
? 'Please complete the authentication in your browser. This window will update automatically.'
: 'Waiting for authentication flow to start...'}
</p>
</div>
</div>
</div>
</CardContent>
</Card>
</CardContent>
</Card>
{/* Device Code Display */}
{deviceCode && (
<Card className="border border-primary/30 bg-primary/5">
<CardContent className="p-6">
<div className="text-center space-y-4">
<div className="space-y-2">
<p className="text-sm font-medium text-foreground">
Your one-time code
</p>
<div className="flex items-center justify-center gap-3">
<code className="text-3xl font-mono font-bold tracking-widest text-primary px-4 py-2 bg-primary/10 rounded-lg">
{deviceCode}
</code>
<Button
variant="outline"
size="sm"
onClick={handleCopyDeviceCode}
className="shrink-0"
>
{codeCopied ? (
<>
<Check className="h-4 w-4 mr-1 text-success" />
Copied
</>
) : (
<>
<Copy className="h-4 w-4 mr-1" />
Copy
</>
)}
</Button>
</div>
</div>
<div className="text-sm text-muted-foreground space-y-2">
<p>
{browserOpened
? 'Enter this code in your browser to complete authentication.'
: 'Copy this code, then open the link below to authenticate.'}
</p>
{!browserOpened && authUrl && (
<Button
variant="link"
onClick={handleOpenAuthUrl}
className="text-info hover:text-info/80 p-0 h-auto gap-1"
>
<ExternalLink className="h-4 w-4" />
Open {authUrl}
</Button>
)}
</div>
</div>
</CardContent>
</Card>
)}
</div>
)}
{/* Success */}
@@ -302,22 +461,102 @@ export function GitHubOAuthFlow({ onSuccess, onCancel }: GitHubOAuthFlowProps) {
{/* Error */}
{status === 'error' && error && (
<div className="space-y-4">
<Card className="border border-destructive/30 bg-destructive/10">
<Card className={`border ${isTimeout ? 'border-warning/30 bg-warning/10' : 'border-destructive/30 bg-destructive/10'}`}>
<CardContent className="p-5">
<div className="flex items-start gap-3">
<AlertCircle className="h-5 w-5 text-destructive shrink-0 mt-0.5" />
{isTimeout ? (
<Clock className="h-5 w-5 text-warning shrink-0 mt-0.5" />
) : (
<AlertCircle className="h-5 w-5 text-destructive shrink-0 mt-0.5" />
)}
<div className="flex-1">
<h3 className="text-lg font-medium text-destructive">
Authentication Failed
<h3 className={`text-lg font-medium ${isTimeout ? 'text-warning' : 'text-destructive'}`}>
{isTimeout ? 'Authentication Timed Out' : 'Authentication Failed'}
</h3>
<p className="text-sm text-destructive/80 mt-1">{error}</p>
<p className={`text-sm mt-1 ${isTimeout ? 'text-warning/80' : 'text-destructive/80'}`}>{error}</p>
</div>
</div>
</CardContent>
</Card>
{/* Fallback URL display when browser failed to open */}
{authUrl && (
<Card className="border border-warning/30 bg-warning/10">
<CardContent className="p-5">
<div className="space-y-4">
<div className="flex items-start gap-3">
<Info className="h-5 w-5 text-warning shrink-0 mt-0.5" />
<div className="flex-1">
<h3 className="text-base font-medium text-foreground">
Complete Authentication Manually
</h3>
<p className="text-sm text-muted-foreground mt-1">
The browser couldn't be opened automatically. Please visit the URL below to complete authentication:
</p>
</div>
</div>
<div className="flex flex-col gap-3">
<div className="flex items-center gap-2 p-3 bg-muted rounded-lg">
<code className="text-sm font-mono text-foreground flex-1 break-all">
{authUrl}
</code>
<Button
variant="outline"
size="sm"
onClick={async () => {
try {
await navigator.clipboard.writeText(authUrl);
setUrlCopied(true);
setTimeout(() => setUrlCopied(false), 2000);
} catch (err) {
debugLog('Failed to copy URL:', err);
}
}}
className="shrink-0"
>
{urlCopied ? (
<>
<Check className="h-4 w-4 mr-1 text-success" />
Copied
</>
) : (
<>
<Copy className="h-4 w-4 mr-1" />
Copy
</>
)}
</Button>
</div>
<Button
variant="secondary"
onClick={handleOpenAuthUrl}
className="gap-2"
>
<ExternalLink className="h-4 w-4" />
Open URL in Browser
</Button>
</div>
{/* Device code reminder if available */}
{deviceCode && (
<div className="pt-2 border-t border-warning/20">
<p className="text-sm text-muted-foreground">
When prompted, enter this code:{' '}
<code className="font-mono font-bold text-primary px-2 py-0.5 bg-primary/10 rounded">
{deviceCode}
</code>
</p>
</div>
)}
</div>
</CardContent>
</Card>
)}
<div className="flex justify-center gap-3">
<Button onClick={handleRetry} variant="outline">
<Button onClick={handleStartAuth} variant="outline">
Retry
</Button>
{onCancel && (
+8 -1
View File
@@ -319,7 +319,14 @@ export interface ElectronAPI {
// GitHub OAuth operations (gh CLI)
checkGitHubCli: () => Promise<IPCResult<{ installed: boolean; version?: string }>>;
checkGitHubAuth: () => Promise<IPCResult<{ authenticated: boolean; username?: string }>>;
startGitHubAuth: () => Promise<IPCResult<{ success: boolean; message?: string }>>;
startGitHubAuth: () => Promise<IPCResult<{
success: boolean;
message?: string;
deviceCode?: string;
authUrl?: string;
browserOpened?: boolean;
fallbackUrl?: string;
}>>;
getGitHubToken: () => Promise<IPCResult<{ token: string }>>;
getGitHubUser: () => Promise<IPCResult<{ username: string; name?: string }>>;
listGitHubUserRepos: () => Promise<IPCResult<{ repos: Array<{ fullName: string; description: string | null; isPrivate: boolean }> }>>;
+2 -2
View File
@@ -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: {