Merge branch 'auto-claude/050-github-connection-will-not-open-browser-on-macos' into v2.6.0
This commit is contained in:
@@ -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 && (
|
||||
|
||||
@@ -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 }> }>>;
|
||||
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user