From b75a09c88ca1bc85dc0a5da213913dbd6513373c Mon Sep 17 00:00:00 2001 From: AndyMik90 Date: Fri, 19 Dec 2025 16:05:56 +0100 Subject: [PATCH 1/7] auto-claude: subtask-1-1 - Add shell import and device code parsing logic to oauth-handlers.ts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Import `shell` from Electron for browser launching capability - Add DEVICE_CODE_PATTERN regex to parse device code (format: XXXX-XXXX) from gh CLI output - Add DEVICE_URL_PATTERN regex and GITHUB_DEVICE_URL constant for device flow URL - Add parseDeviceCode() helper to extract device code from output - Add parseDeviceUrl() helper to extract or default to GitHub device flow URL - Add DeviceFlowInfo interface for structured device flow output - Add parseDeviceFlowOutput() helper to parse both stdout and stderr for device flow info 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- auto-claude-ui/package-lock.json | 60 +++++++++---------- .../ipc-handlers/github/oauth-handlers.ts | 60 ++++++++++++++++++- 2 files changed, 88 insertions(+), 32 deletions(-) diff --git a/auto-claude-ui/package-lock.json b/auto-claude-ui/package-lock.json index 32602cef..1e185e98 100644 --- a/auto-claude-ui/package-lock.json +++ b/auto-claude-ui/package-lock.json @@ -1,12 +1,12 @@ { "name": "auto-claude-ui", - "version": "2.3.0", + "version": "2.5.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "auto-claude-ui", - "version": "2.3.0", + "version": "2.5.0", "hasInstallScript": true, "license": "AGPL-3.0", "dependencies": { @@ -150,7 +150,6 @@ "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -536,7 +535,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" }, @@ -560,7 +558,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" } @@ -600,7 +597,6 @@ "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz", "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", "license": "MIT", - "peer": true, "dependencies": { "@dnd-kit/accessibility": "^3.1.1", "@dnd-kit/utilities": "^3.2.2", @@ -995,6 +991,7 @@ "dev": true, "license": "BSD-2-Clause", "optional": true, + "peer": true, "dependencies": { "cross-dirname": "^0.1.0", "debug": "^4.3.4", @@ -1016,6 +1013,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", @@ -3930,7 +3928,8 @@ "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@types/babel__core": { "version": "7.20.5", @@ -4127,7 +4126,6 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.7.tgz", "integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==", "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -4138,7 +4136,6 @@ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "devOptional": true, "license": "MIT", - "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -4230,7 +4227,6 @@ "integrity": "sha512-N9lBGA9o9aqb1hVMc9hzySbhKibHmB+N3IpoShyV6HyQYRGIhlrO5rQgttypi+yEeKsKI4idxC8Jw6gXKD4THA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.49.0", "@typescript-eslint/types": "8.49.0", @@ -4630,8 +4626,7 @@ "version": "5.5.0", "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.5.0.tgz", "integrity": "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/7zip-bin": { "version": "5.2.0", @@ -4653,7 +4648,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -4714,7 +4708,6 @@ "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -4887,6 +4880,7 @@ "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", "dev": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "dequal": "^2.0.3" } @@ -5271,7 +5265,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -5951,7 +5944,8 @@ "integrity": "sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q==", "dev": true, "license": "MIT", - "optional": true + "optional": true, + "peer": true }, "node_modules/cross-spawn": { "version": "7.0.6", @@ -6295,7 +6289,6 @@ "integrity": "sha512-59CAAjAhTaIMCN8y9kD573vDkxbs1uhDcrFLHSgutYdPcGOU35Rf95725snvzEOy4BFB7+eLJ8djCNPmGwG67w==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "app-builder-lib": "26.0.12", "builder-util": "26.0.11", @@ -6353,7 +6346,8 @@ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/dotenv": { "version": "16.6.1", @@ -6429,7 +6423,6 @@ "dev": true, "hasInstallScript": true, "license": "MIT", - "peer": true, "dependencies": { "@electron/get": "^2.0.0", "@types/node": "^22.7.7", @@ -6558,6 +6551,7 @@ "dev": true, "hasInstallScript": true, "license": "MIT", + "peer": true, "dependencies": { "@electron/asar": "^3.2.1", "debug": "^4.1.1", @@ -6578,6 +6572,7 @@ "integrity": "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "graceful-fs": "^4.1.2", "jsonfile": "^4.0.0", @@ -6593,6 +6588,7 @@ "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", "dev": true, "license": "MIT", + "peer": true, "optionalDependencies": { "graceful-fs": "^4.1.6" } @@ -6603,6 +6599,7 @@ "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">= 4.0.0" } @@ -6972,7 +6969,6 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -8992,7 +8988,6 @@ "integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "cssstyle": "^4.2.1", "data-urls": "^5.0.0", @@ -9936,6 +9931,7 @@ "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "dev": true, "license": "MIT", + "peer": true, "bin": { "lz-string": "bin/bin.js" } @@ -11775,7 +11771,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -11873,7 +11868,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -11910,6 +11904,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "commander": "^9.4.0" }, @@ -11927,6 +11922,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "engines": { "node": "^12.20.0 || >=14" } @@ -11947,6 +11943,7 @@ "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", @@ -11962,6 +11959,7 @@ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=10" }, @@ -11974,7 +11972,8 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/proc-log": { "version": "2.0.1", @@ -12078,7 +12077,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz", "integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -12088,7 +12086,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz", "integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -13405,8 +13402,7 @@ "version": "4.1.18", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz", "integrity": "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/tapable": { "version": "2.3.0", @@ -13463,6 +13459,7 @@ "integrity": "sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "mkdirp": "^0.5.1", "rimraf": "~2.6.2" @@ -13489,6 +13486,7 @@ "deprecated": "Glob versions prior to v9 are no longer supported", "dev": true, "license": "ISC", + "peer": true, "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", @@ -13510,6 +13508,7 @@ "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", "dev": true, "license": "ISC", + "peer": true, "dependencies": { "brace-expansion": "^1.1.7" }, @@ -13523,6 +13522,7 @@ "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "minimist": "^1.2.6" }, @@ -13537,6 +13537,7 @@ "deprecated": "Rimraf versions prior to v4 are no longer supported", "dev": true, "license": "ISC", + "peer": true, "dependencies": { "glob": "^7.1.3" }, @@ -13853,7 +13854,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -14194,7 +14194,6 @@ "integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -15228,7 +15227,6 @@ "integrity": "sha512-Bd5fw9wlIhtqCCxotZgdTOMwGm1a0u75wARVEY9HMs1X17trvA/lMi4+MGK5EUfYkXVTbX8UDiDKW4OgzHVUZw==", "dev": true, "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } 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..1e772a83 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 */ From 6a4c1b452bb789ebca0878ce23e33069ba53999a Mon Sep 17 00:00:00 2001 From: AndyMik90 Date: Fri, 19 Dec 2025 16:09:28 +0100 Subject: [PATCH 2/7] auto-claude: subtask-1-2 - Implement device code extraction from gh CLI stdout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Modify registerStartGhAuth to: - Extract device code from gh CLI stdout/stderr as data streams in - Use parseDeviceFlowOutput to get device code and auth URL - Open browser via shell.openExternal (bypasses macOS restrictions) - Return device code, auth URL, and browserOpened status in response - Handle browser open failures gracefully (allows manual fallback) Added GitHubAuthStartResult interface with deviceCode, authUrl, and browserOpened fields for rich response data. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../ipc-handlers/github/oauth-handlers.ts | 67 +++++++++++++++++-- 1 file changed, 63 insertions(+), 4 deletions(-) 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 1e772a83..60f7ed5f 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 @@ -172,14 +172,26 @@ 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; +} + /** * 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 { @@ -193,17 +205,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) => { @@ -216,13 +264,24 @@ export function registerStartGhAuth(): void { success: true, data: { success: true, - message: 'Successfully authenticated with GitHub' + message: 'Successfully authenticated with GitHub', + deviceCode: extractedDeviceCode || undefined, + authUrl: extractedAuthUrl, + browserOpened: browserOpenedSuccessfully } }); } else { + // Even if auth failed, return device code info if we extracted it + // This allows user to retry manually resolve({ success: false, - error: errorOutput || `Authentication failed with exit code ${code}` + error: errorOutput || `Authentication failed with exit code ${code}`, + data: extractedDeviceCode ? { + success: false, + deviceCode: extractedDeviceCode, + authUrl: extractedAuthUrl, + browserOpened: browserOpenedSuccessfully + } : undefined }); } }); From 4a4ad6b1df0747eccc4078c2becadce48f06315e Mon Sep 17 00:00:00 2001 From: AndyMik90 Date: Fri, 19 Dec 2025 16:13:29 +0100 Subject: [PATCH 3/7] auto-claude: subtask-1-4 - Add error handling and fallback URL return for browser launch failures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Added fallbackUrl field to GitHubAuthStartResult interface - Updated success case to include fallbackUrl when browser fails to open - Updated failure case to always provide fallbackUrl for manual recovery - Updated gh process error handler to include fallbackUrl - Updated catch block to include fallbackUrl for exception cases This ensures users always have a way to manually navigate to the auth URL when automatic browser opening fails (e.g., due to macOS security restrictions). 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../ipc-handlers/github/oauth-handlers.ts | 48 +++++++++++++++---- 1 file changed, 39 insertions(+), 9 deletions(-) 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 60f7ed5f..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 @@ -181,6 +181,11 @@ interface GitHubAuthStartResult { 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; } /** @@ -260,28 +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 + 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 + // 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}`, - data: extractedDeviceCode ? { + data: { success: false, - deviceCode: extractedDeviceCode, + deviceCode: extractedDeviceCode || undefined, authUrl: extractedAuthUrl, - browserOpened: browserOpenedSuccessfully - } : undefined + browserOpened: browserOpenedSuccessfully, + // Always provide fallback URL on failure for manual recovery + fallbackUrl: fallbackUrlForManualAuth, + message: 'Authentication failed. Please visit the URL manually to complete authentication.' + } }); } }); @@ -290,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.' + } }); } }); From 5f26d3964dafe845c095315a9d1a885985a35ad5 Mon Sep 17 00:00:00 2001 From: AndyMik90 Date: Fri, 19 Dec 2025 16:17:02 +0100 Subject: [PATCH 4/7] auto-claude: subtask-2-1 - Add device code display state and UI component - Add device code, auth URL, and browser opened state variables to GitHubOAuthFlow - Display prominent device code card during authentication with copy button - Show manual auth URL link when browser fails to open - Update IPC types to include deviceCode, authUrl, browserOpened, and fallbackUrl - Add visual feedback for code copy (checkmark icon when copied) - Instructions adapt based on whether browser opened successfully --- .../project-settings/GitHubOAuthFlow.tsx | 146 ++++++++++++++++-- auto-claude-ui/src/shared/types/ipc.ts | 9 +- 2 files changed, 138 insertions(+), 17 deletions(-) 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..df9e677e 100644 --- a/auto-claude-ui/src/renderer/components/project-settings/GitHubOAuthFlow.tsx +++ b/auto-claude-ui/src/renderer/components/project-settings/GitHubOAuthFlow.tsx @@ -6,7 +6,9 @@ import { AlertCircle, Info, ExternalLink, - Terminal + Terminal, + Copy, + Check } from 'lucide-react'; import { Button } from '../ui/button'; import { Card, CardContent } from '../ui/card'; @@ -40,6 +42,12 @@ 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); + // Check gh CLI installation and authentication status on mount // Use a ref to prevent double-execution in React Strict Mode const hasCheckedRef = useRef(false); @@ -138,18 +146,44 @@ export function GitHubOAuthFlow({ onSuccess, onCancel }: GitHubOAuthFlowProps) { setStatus('authenticating'); setError(null); + // Reset device flow state + setDeviceCode(null); + setAuthUrl(null); + setBrowserOpened(false); + setCodeCopied(false); + try { debugLog('Calling startGitHubAuth...'); const result = await window.electronAPI.startGitHubAuth(); debugLog('startGitHubAuth result:', result); + // 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) { @@ -169,6 +203,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 +317,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 */} 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 }>>; From 1a7cf409eb7e5d07decc8dca8a36112682aa0218 Mon Sep 17 00:00:00 2001 From: AndyMik90 Date: Fri, 19 Dec 2025 16:19:50 +0100 Subject: [PATCH 5/7] auto-claude: subtask-2-2 - Implement fallback URL display when browser launch fails MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Added fallback URL card in error state when authUrl is available - Shows "Complete Authentication Manually" instructions when browser fails to open - Added copyable URL display with Copy button that tracks copy state separately - Added "Open URL in Browser" button to attempt manual browser launch - Shows device code reminder in fallback card if available - Changed Retry button in error state to call handleStartAuth instead of handleRetry for fresh auth attempt - Added urlCopied state variable to track URL copy status independently from device code copy status 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../project-settings/GitHubOAuthFlow.tsx | 80 ++++++++++++++++++- 1 file changed, 79 insertions(+), 1 deletion(-) 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 df9e677e..c51a2132 100644 --- a/auto-claude-ui/src/renderer/components/project-settings/GitHubOAuthFlow.tsx +++ b/auto-claude-ui/src/renderer/components/project-settings/GitHubOAuthFlow.tsx @@ -47,6 +47,7 @@ export function GitHubOAuthFlow({ onSuccess, onCancel }: GitHubOAuthFlowProps) { const [authUrl, setAuthUrl] = useState(null); const [browserOpened, setBrowserOpened] = useState(false); const [codeCopied, setCodeCopied] = useState(false); + const [urlCopied, setUrlCopied] = useState(false); // Check gh CLI installation and authentication status on mount // Use a ref to prevent double-execution in React Strict Mode @@ -151,6 +152,7 @@ export function GitHubOAuthFlow({ onSuccess, onCancel }: GitHubOAuthFlowProps) { setAuthUrl(null); setBrowserOpened(false); setCodeCopied(false); + setUrlCopied(false); try { debugLog('Calling startGitHubAuth...'); @@ -430,8 +432,84 @@ export function GitHubOAuthFlow({ onSuccess, onCancel }: GitHubOAuthFlowProps) { + {/* 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 && ( From 81e1536801183b80539f34c74c693e40769623c7 Mon Sep 17 00:00:00 2001 From: AndyMik90 Date: Fri, 19 Dec 2025 16:22:42 +0100 Subject: [PATCH 6/7] auto-claude: subtask-2-3 - Add authentication timeout handling (5 minutes) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add 5-minute authentication timeout using useCallback and useRef for cleanup - Implement clearAuthTimeout helper to manage timeout lifecycle - Start timeout when auth begins, clear on success/failure/unmount - Add isTimeout state to track timeout vs other errors - Display timeout-specific UI with Clock icon and warning colors - Show clear error message with retry option on timeout - Timeout set to 5 minutes (GitHub device codes expire after 15 minutes) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../project-settings/GitHubOAuthFlow.tsx | 63 ++++++++++++++++--- 1 file changed, 55 insertions(+), 8 deletions(-) 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 c51a2132..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, @@ -8,7 +8,8 @@ import { ExternalLink, Terminal, Copy, - Check + Check, + Clock } from 'lucide-react'; import { Button } from '../ui/button'; import { Card, CardContent } from '../ui/card'; @@ -31,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 @@ -48,11 +53,33 @@ export function GitHubOAuthFlow({ onSuccess, onCancel }: GitHubOAuthFlowProps) { 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)'); @@ -61,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'); @@ -153,12 +185,21 @@ export function GitHubOAuthFlow({ onSuccess, onCancel }: GitHubOAuthFlowProps) { 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); @@ -189,6 +230,8 @@ export function GitHubOAuthFlow({ onSuccess, onCancel }: GitHubOAuthFlowProps) { setStatus('error'); } } catch (err) { + // Clear timeout on error + clearAuthTimeout(); debugLog('Error in handleStartAuth:', err); setError(err instanceof Error ? err.message : 'Authentication failed'); setStatus('error'); @@ -418,15 +461,19 @@ export function GitHubOAuthFlow({ onSuccess, onCancel }: GitHubOAuthFlowProps) { {/* Error */} {status === 'error' && error && (
- +
- + {isTimeout ? ( + + ) : ( + + )}
-

- Authentication Failed +

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

-

{error}

+

{error}

From e134c4cba95b04a7f98f05985c0afc74810765be Mon Sep 17 00:00:00 2001 From: AndyMik90 Date: Fri, 19 Dec 2025 16:26:54 +0100 Subject: [PATCH 7/7] auto-claude: subtask-3-1 - Write unit tests for device code parsing and shell MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add comprehensive unit tests for GitHub OAuth handlers: - Device code parsing from gh CLI stdout/stderr output - shell.openExternal success and failure handling - Fallback URL provision when browser launch fails - Error handling for gh CLI process errors and non-zero exit codes - gh CLI check and auth status handlers - Repository format validation for command injection prevention 21 new tests covering all critical OAuth flow paths. Also updates vitest.config.ts to include *.spec.ts files. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../github/__tests__/oauth-handlers.spec.ts | 548 ++++++++++++++++++ auto-claude-ui/vitest.config.ts | 4 +- 2 files changed, 550 insertions(+), 2 deletions(-) create mode 100644 auto-claude-ui/src/main/ipc-handlers/github/__tests__/oauth-handlers.spec.ts 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/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: {