From 33e614c76b8be6f3cb9f5c2c8bf4c0aca6d6dec0 Mon Sep 17 00:00:00 2001 From: Tom Rochette Date: Thu, 11 Jun 2026 11:43:41 -0400 Subject: [PATCH] Fallback to gh CLI credentials if available (#1515) Adds `gh` CLI as a GitHub credential fallback for users who already have `gh auth login` configured locally. OpenChamber-owned OAuth credentials remain the primary source of truth; the `gh` token is only used when no stored OpenChamber GitHub access token exists and the fallback is not disabled. The fallback is implemented as a credential provider only: GitHub features continue to use the existing Octokit/GitHub API paths for issues, pull requests, checks, merges, and related operations. The PR does not replace those endpoints with `gh issue` or `gh pr` CLI commands. Server changes: - Add `gh-cli-credential.js` to read `gh auth token` with a bounded timeout. - Cache the `gh` token lookup for 30 seconds, including negative results, to avoid repeated subprocess spawning on status/polling paths. - Hide the subprocess window on Windows via `windowsHide: true`. - Clear the gh CLI token cache when the fallback setting changes. - Update `getOctokitOrNull()` to prefer stored OpenChamber OAuth tokens and fall back to the `gh` token only when enabled. - Add `ghCliDisabled` persistence in the existing settings file with atomic writes and `0o600` file permissions. - Add `POST /api/github/auth/gh-cli` to enable or disable the fallback. - Extend `/api/github/auth/status` with `ghCli` metadata: availability, disabled state, active state, and active user when applicable. UI/runtime changes: - Extend `GitHubAuthStatus` and `GitHubAPI` with gh CLI fallback metadata and toggle support. - Add web RuntimeAPI support for toggling the gh CLI fallback through `runtimeFetch`, preserving active runtime/remote target behavior. - Add deterministic VS Code unsupported handling for the gh CLI toggle. - Update GitHub Settings to show gh CLI availability and active status. - When gh CLI is the active auth source, show it in the connected account card and offer Disable instead of Disconnect. - Keep Add Account available so users can still connect an OpenChamber OAuth account, which then takes priority over gh CLI. - Add localized gh CLI settings strings across supported settings locales. Fixes addressed during review: - Removed unreachable UI branches in the inactive gh CLI card. - Avoided duplicate and repeated `gh auth token` subprocess calls. - Hardened settings file permissions for the new persisted flag. - Routed the gh CLI toggle through the RuntimeAPI/runtimeFetch path instead of direct browser `fetch`. - Added targeted tests for hidden subprocess options and negative-result cache behavior. - Fixed a VS Code webview Response body typing issue that blocked type-check. --- .../sections/openchamber/GitHubSettings.tsx | 77 ++++++++++++++++++- packages/ui/src/lib/api/types.ts | 7 ++ .../ui/src/lib/i18n/messages/en.settings.ts | 9 +++ .../ui/src/lib/i18n/messages/es.settings.ts | 9 +++ .../ui/src/lib/i18n/messages/ko.settings.ts | 9 +++ .../ui/src/lib/i18n/messages/pl.settings.ts | 9 +++ .../src/lib/i18n/messages/pt-BR.settings.ts | 9 +++ .../ui/src/lib/i18n/messages/uk.settings.ts | 9 +++ .../src/lib/i18n/messages/zh-CN.settings.ts | 9 +++ .../src/lib/i18n/messages/zh-TW.settings.ts | 9 +++ packages/vscode/webview/api/github.ts | 3 + packages/web/server/lib/github/auth.js | 38 +++++++++ .../server/lib/github/gh-cli-credential.js | 38 +++++++++ .../lib/github/gh-cli-credential.test.js | 43 +++++++++++ packages/web/server/lib/github/index.js | 2 + packages/web/server/lib/github/octokit.js | 8 +- packages/web/server/lib/github/routes.js | 43 ++++++++--- packages/web/src/api/github.ts | 13 ++++ 18 files changed, 329 insertions(+), 15 deletions(-) create mode 100644 packages/web/server/lib/github/gh-cli-credential.js create mode 100644 packages/web/server/lib/github/gh-cli-credential.test.js diff --git a/packages/ui/src/components/sections/openchamber/GitHubSettings.tsx b/packages/ui/src/components/sections/openchamber/GitHubSettings.tsx index dd7a2398..c480a272 100644 --- a/packages/ui/src/components/sections/openchamber/GitHubSettings.tsx +++ b/packages/ui/src/components/sections/openchamber/GitHubSettings.tsx @@ -174,6 +174,30 @@ export const GitHubSettings: React.FC = () => { }; }, [flow, pollIntervalMs, pollOnce, refreshStatus, runtimeGitHub, stopPolling, t]); + const toggleGhCli = React.useCallback(async (disabled: boolean) => { + setIsBusy(true); + try { + if (runtimeGitHub) { + await runtimeGitHub.authSetGhCliDisabled(disabled); + } else { + const response = await runtimeFetch('/api/github/auth/gh-cli', { + method: 'POST', + headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, + body: JSON.stringify({ disabled }), + }); + const body = (await response.json().catch(() => null)) as { error?: string } | null; + if (!response.ok) throw new Error(body?.error || response.statusText); + } + toast.success(disabled ? t('settings.github.page.toast.ghCliDisabled') : t('settings.github.page.toast.ghCliEnabled')); + await refreshStatus(runtimeGitHub, { force: true }); + } catch (error) { + console.error('Failed to update gh CLI setting:', error); + toast.error(t('settings.github.page.toast.ghCliUpdateFailed')); + } finally { + setIsBusy(false); + } + }, [refreshStatus, runtimeGitHub, t]); + const disconnect = React.useCallback(async () => { setIsBusy(true); try { @@ -239,6 +263,7 @@ export const GitHubSettings: React.FC = () => { const connected = Boolean(status?.connected); const user = status?.user; const accounts = status?.accounts ?? []; + const ghCli = status?.ghCli ?? null; return (
@@ -287,12 +312,23 @@ export const GitHubSettings: React.FC = () => { {t('settings.github.page.label.scopes', { value: status.scope })}
)} + {ghCli?.active && ( +
+ {t('settings.github.page.ghCli.activeDescription')} +
+ )} - + {ghCli?.active ? ( + + ) : ( + + )} ) : (
@@ -412,6 +448,41 @@ export const GitHubSettings: React.FC = () => {
)} + + {ghCli?.available && !ghCli?.active && ( +
+

+ {t('settings.github.page.ghCli.title')} +

+
+
+
+
+ +
+
+
+ {ghCli.disabled + ? t('settings.github.page.ghCli.disabledDescription') + : t('settings.github.page.ghCli.fallbackDescription')} +
+
+
+ +
+
+
+ )} ); }; diff --git a/packages/ui/src/lib/api/types.ts b/packages/ui/src/lib/api/types.ts index cc0537c6..3e95dcc6 100644 --- a/packages/ui/src/lib/api/types.ts +++ b/packages/ui/src/lib/api/types.ts @@ -1039,6 +1039,12 @@ export type GitHubAuthStatus = { user?: GitHubUserSummary | null; scope?: string; accounts?: GitHubAuthAccount[]; + ghCli?: { + available: boolean; + disabled: boolean; + active: boolean; + user?: GitHubUserSummary | null; + } | null; }; export type GitHubAuthAccount = { @@ -1068,6 +1074,7 @@ export interface GitHubAPI { authComplete(deviceCode: string): Promise; authDisconnect(): Promise<{ removed: boolean }>; authActivate(accountId: string): Promise; + authSetGhCliDisabled(disabled: boolean): Promise<{ disabled: boolean }>; me?(): Promise; prStatus(directory: string, branch: string, remote?: string, options?: { force?: boolean }): Promise; diff --git a/packages/ui/src/lib/i18n/messages/en.settings.ts b/packages/ui/src/lib/i18n/messages/en.settings.ts index 2faf0add..a2c45ebc 100644 --- a/packages/ui/src/lib/i18n/messages/en.settings.ts +++ b/packages/ui/src/lib/i18n/messages/en.settings.ts @@ -1407,6 +1407,15 @@ export const settingsDict = { 'settings.github.page.toast.disconnectFailed': 'Failed to disconnect GitHub', 'settings.github.page.toast.accountSwitched': 'GitHub account switched', 'settings.github.page.toast.accountSwitchFailed': 'Failed to switch GitHub account', + 'settings.github.page.ghCli.title': 'gh CLI', + 'settings.github.page.ghCli.activeDescription': 'Authenticated via the gh CLI', + 'settings.github.page.ghCli.fallbackDescription': 'Available as fallback if OpenChamber auth is removed', + 'settings.github.page.ghCli.disabledDescription': 'Installed but not used as a fallback', + 'settings.github.page.ghCli.actions.disable': 'Disable', + 'settings.github.page.ghCli.actions.enable': 'Enable', + 'settings.github.page.toast.ghCliEnabled': 'gh CLI fallback enabled', + 'settings.github.page.toast.ghCliDisabled': 'gh CLI fallback disabled', + 'settings.github.page.toast.ghCliUpdateFailed': 'Failed to update gh CLI setting', 'settings.notifications.page.delivery.title': 'Notification Delivery', 'settings.notifications.page.delivery.enableAria': 'Enable notifications', 'settings.notifications.page.delivery.enableLabel': 'Enable Notifications', diff --git a/packages/ui/src/lib/i18n/messages/es.settings.ts b/packages/ui/src/lib/i18n/messages/es.settings.ts index 0f5088fd..127692bd 100644 --- a/packages/ui/src/lib/i18n/messages/es.settings.ts +++ b/packages/ui/src/lib/i18n/messages/es.settings.ts @@ -1374,6 +1374,15 @@ export const settingsDict = { "settings.github.page.toast.disconnectFailed": "No se pudo desconectar de GitHub", "settings.github.page.toast.accountSwitched": "Cuenta de GitHub cambiada", "settings.github.page.toast.accountSwitchFailed": "No se pudo cambiar de cuenta de GitHub", + "settings.github.page.ghCli.title": "gh CLI", + "settings.github.page.ghCli.activeDescription": "Autenticado a través de gh CLI", + "settings.github.page.ghCli.fallbackDescription": "Disponible como respaldo si se elimina la autenticación de OpenChamber", + "settings.github.page.ghCli.disabledDescription": "Instalado pero no usado como respaldo", + "settings.github.page.ghCli.actions.disable": "Desactivar", + "settings.github.page.ghCli.actions.enable": "Activar", + "settings.github.page.toast.ghCliEnabled": "Respaldo de gh CLI activado", + "settings.github.page.toast.ghCliDisabled": "Respaldo de gh CLI desactivado", + "settings.github.page.toast.ghCliUpdateFailed": "No se pudo actualizar la configuración de gh CLI", "settings.notifications.page.delivery.title": "Entrega de notificaciones", "settings.notifications.page.delivery.enableAria": "Habilitar notificaciones", "settings.notifications.page.delivery.enableLabel": "Habilitar notificaciones", diff --git a/packages/ui/src/lib/i18n/messages/ko.settings.ts b/packages/ui/src/lib/i18n/messages/ko.settings.ts index dd4ef3c7..86845342 100644 --- a/packages/ui/src/lib/i18n/messages/ko.settings.ts +++ b/packages/ui/src/lib/i18n/messages/ko.settings.ts @@ -1374,6 +1374,15 @@ export const settingsDict = { 'settings.github.page.toast.disconnectFailed': 'GitHub 연결을 해제하지 못했습니다', 'settings.github.page.toast.accountSwitched': 'GitHub 계정이 전환되었습니다', 'settings.github.page.toast.accountSwitchFailed': 'GitHub 계정을 전환하지 못했습니다', + 'settings.github.page.ghCli.title': 'gh CLI', + 'settings.github.page.ghCli.activeDescription': 'gh CLI를 통해 인증됨', + 'settings.github.page.ghCli.fallbackDescription': 'OpenChamber 인증이 제거된 경우 대체로 사용 가능', + 'settings.github.page.ghCli.disabledDescription': '설치되어 있지만 대체로 사용되지 않음', + 'settings.github.page.ghCli.actions.disable': '비활성화', + 'settings.github.page.ghCli.actions.enable': '활성화', + 'settings.github.page.toast.ghCliEnabled': 'gh CLI 대체 활성화됨', + 'settings.github.page.toast.ghCliDisabled': 'gh CLI 대체 비활성화됨', + 'settings.github.page.toast.ghCliUpdateFailed': 'gh CLI 설정을 업데이트하지 못했습니다', 'settings.notifications.page.delivery.title': '알림', 'settings.notifications.page.delivery.enableAria': '알림 활성화', 'settings.notifications.page.delivery.enableLabel': '알림 활성화', diff --git a/packages/ui/src/lib/i18n/messages/pl.settings.ts b/packages/ui/src/lib/i18n/messages/pl.settings.ts index c6587300..af1e2a9b 100644 --- a/packages/ui/src/lib/i18n/messages/pl.settings.ts +++ b/packages/ui/src/lib/i18n/messages/pl.settings.ts @@ -246,6 +246,15 @@ export const settingsDict = { 'settings.github.page.toast.disconnectFailed': 'Nie udało się odłączyć GitHub', 'settings.github.page.toast.disconnected': 'Odłączono od GitHub', 'settings.github.page.toast.startConnectFailed': 'Nie udało się rozpocząć połączenia z GitHub', + 'settings.github.page.toast.ghCliEnabled': 'Rezerwa gh CLI włączona', + 'settings.github.page.toast.ghCliDisabled': 'Rezerwa gh CLI wyłączona', + 'settings.github.page.toast.ghCliUpdateFailed': 'Nie udało się zaktualizować ustawienia gh CLI', + 'settings.github.page.ghCli.title': 'gh CLI', + 'settings.github.page.ghCli.activeDescription': 'Uwierzytelniono przez gh CLI', + 'settings.github.page.ghCli.fallbackDescription': 'Dostępne jako rezerwa, jeśli uwierzytelnienie OpenChamber zostanie usunięte', + 'settings.github.page.ghCli.disabledDescription': 'Zainstalowane, ale nieużywane jako rezerwa', + 'settings.github.page.ghCli.actions.disable': 'Wyłącz', + 'settings.github.page.ghCli.actions.enable': 'Włącz', 'settings.github.page.tooltip.connectAccount': 'Połącz konto GitHub, aby korzystać z przepływów pracy dla PR i Issue w aplikacji.', 'settings.magicPrompts.page.actions.resetAllOverrides': 'Zresetuj wszystkie nadpisania', 'settings.magicPrompts.page.actions.resetToDefault': 'Zresetuj do domyślnych', diff --git a/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts b/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts index 4caa4cb1..a021ec35 100644 --- a/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts +++ b/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts @@ -1374,6 +1374,15 @@ export const settingsDict = { "settings.github.page.toast.disconnectFailed": "Não foi possível desconectar de GitHub", "settings.github.page.toast.accountSwitched": "Conta de GitHub cambiada", "settings.github.page.toast.accountSwitchFailed": "Não foi possível mudar de conta de GitHub", + "settings.github.page.ghCli.title": "gh CLI", + "settings.github.page.ghCli.activeDescription": "Autenticado via gh CLI", + "settings.github.page.ghCli.fallbackDescription": "Disponível como alternativa se a autenticação do OpenChamber for removida", + "settings.github.page.ghCli.disabledDescription": "Instalado, mas não usado como alternativa", + "settings.github.page.ghCli.actions.disable": "Desativar", + "settings.github.page.ghCli.actions.enable": "Ativar", + "settings.github.page.toast.ghCliEnabled": "Alternativa gh CLI ativada", + "settings.github.page.toast.ghCliDisabled": "Alternativa gh CLI desativada", + "settings.github.page.toast.ghCliUpdateFailed": "Falha ao atualizar configuração do gh CLI", "settings.notifications.page.delivery.title": "Entrega de notificações", "settings.notifications.page.delivery.enableAria": "Ativar notificações", "settings.notifications.page.delivery.enableLabel": "Ativar notificações", diff --git a/packages/ui/src/lib/i18n/messages/uk.settings.ts b/packages/ui/src/lib/i18n/messages/uk.settings.ts index eb413b0b..9252990a 100644 --- a/packages/ui/src/lib/i18n/messages/uk.settings.ts +++ b/packages/ui/src/lib/i18n/messages/uk.settings.ts @@ -1374,6 +1374,15 @@ export const settingsDict = { "settings.github.page.toast.disconnectFailed": "Не вдалося відключити GitHub", "settings.github.page.toast.accountSwitched": "Обліковий запис GitHub змінено", "settings.github.page.toast.accountSwitchFailed": "Не вдалося змінити обліковий запис GitHub", + "settings.github.page.ghCli.title": "gh CLI", + "settings.github.page.ghCli.activeDescription": "Автентифіковано через gh CLI", + "settings.github.page.ghCli.fallbackDescription": "Доступно як резервний варіант, якщо автентифікацію OpenChamber видалено", + "settings.github.page.ghCli.disabledDescription": "Встановлено, але не використовується як резервний варіант", + "settings.github.page.ghCli.actions.disable": "Вимкнути", + "settings.github.page.ghCli.actions.enable": "Увімкнути", + "settings.github.page.toast.ghCliEnabled": "Резервний варіант gh CLI увімкнено", + "settings.github.page.toast.ghCliDisabled": "Резервний варіант gh CLI вимкнено", + "settings.github.page.toast.ghCliUpdateFailed": "Не вдалося оновити налаштування gh CLI", "settings.notifications.page.delivery.title": "Доставка сповіщень", "settings.notifications.page.delivery.enableAria": "Увімкнути сповіщення", "settings.notifications.page.delivery.enableLabel": "Увімкнути сповіщення", diff --git a/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts b/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts index d2e8b0ef..7210d246 100644 --- a/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts +++ b/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts @@ -1374,6 +1374,15 @@ export const settingsDict = { 'settings.github.page.toast.disconnectFailed': '断开 GitHub 失败', 'settings.github.page.toast.accountSwitched': 'GitHub 账号已切换', 'settings.github.page.toast.accountSwitchFailed': '切换 GitHub 账号失败', + 'settings.github.page.ghCli.title': 'gh CLI', + 'settings.github.page.ghCli.activeDescription': '已通过 gh CLI 认证', + 'settings.github.page.ghCli.fallbackDescription': '在 OpenChamber 认证被移除时可作为备用', + 'settings.github.page.ghCli.disabledDescription': '已安装但未作为备用使用', + 'settings.github.page.ghCli.actions.disable': '禁用', + 'settings.github.page.ghCli.actions.enable': '启用', + 'settings.github.page.toast.ghCliEnabled': 'gh CLI 备用已启用', + 'settings.github.page.toast.ghCliDisabled': 'gh CLI 备用已禁用', + 'settings.github.page.toast.ghCliUpdateFailed': '更新 gh CLI 设置失败', 'settings.notifications.page.delivery.title': '通知投递', 'settings.notifications.page.delivery.enableAria': '启用通知', 'settings.notifications.page.delivery.enableLabel': '启用通知', diff --git a/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts b/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts index 2090b254..116bae0d 100644 --- a/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts +++ b/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts @@ -1295,6 +1295,15 @@ 'settings.github.page.toast.disconnectFailed': '中斷 GitHub 失敗', 'settings.github.page.toast.accountSwitched': 'GitHub 帳號已切換', 'settings.github.page.toast.accountSwitchFailed': '切換 GitHub 帳號失敗', + 'settings.github.page.ghCli.title': 'gh CLI', + 'settings.github.page.ghCli.activeDescription': '已透過 gh CLI 認證', + 'settings.github.page.ghCli.fallbackDescription': '在 OpenChamber 認證被移除時可作為備用', + 'settings.github.page.ghCli.disabledDescription': '已安裝但未作為備用使用', + 'settings.github.page.ghCli.actions.disable': '停用', + 'settings.github.page.ghCli.actions.enable': '啟用', + 'settings.github.page.toast.ghCliEnabled': 'gh CLI 備用已啟用', + 'settings.github.page.toast.ghCliDisabled': 'gh CLI 備用已停用', + 'settings.github.page.toast.ghCliUpdateFailed': '更新 gh CLI 設定失敗', 'settings.notifications.page.delivery.title': '通知傳遞', 'settings.notifications.page.delivery.enableAria': '啟用通知', 'settings.notifications.page.delivery.enableLabel': '啟用通知', diff --git a/packages/vscode/webview/api/github.ts b/packages/vscode/webview/api/github.ts index 743cff04..34666bf1 100644 --- a/packages/vscode/webview/api/github.ts +++ b/packages/vscode/webview/api/github.ts @@ -30,6 +30,9 @@ export const createVSCodeGitHubAPI = (): GitHubAPI => ({ authDisconnect: async () => sendBridgeMessage<{ removed: boolean }>('api:github/auth:disconnect'), authActivate: async (accountId: string) => sendBridgeMessage('api:github/auth:activate', { accountId }), + authSetGhCliDisabled: async () => { + throw new Error('gh CLI fallback is not supported in VS Code'); + }, me: async () => sendBridgeMessage('api:github/me'), prStatus: async (directory: string, branch: string) => diff --git a/packages/web/server/lib/github/auth.js b/packages/web/server/lib/github/auth.js index 6ac17865..9e0eeb83 100644 --- a/packages/web/server/lib/github/auth.js +++ b/packages/web/server/lib/github/auth.js @@ -305,3 +305,41 @@ export function getGitHubScopes() { } export const GITHUB_AUTH_FILE = STORAGE_FILE; + +export function isGhCliDisabled() { + try { + if (fs.existsSync(SETTINGS_FILE)) { + const parsed = JSON.parse(fs.readFileSync(SETTINGS_FILE, 'utf8')); + return Boolean(parsed?.ghCliDisabled); + } + } catch { + // ignore + } + return false; +} + +export function setGhCliDisabled(disabled) { + ensureStorageDir(); + let settings = {}; + try { + if (fs.existsSync(SETTINGS_FILE)) { + settings = JSON.parse(fs.readFileSync(SETTINGS_FILE, 'utf8')) || {}; + } + } catch { + // ignore + } + settings.ghCliDisabled = Boolean(disabled); + const tmpFile = `${SETTINGS_FILE}.${process.pid}.${Date.now()}.tmp`; + fs.writeFileSync(tmpFile, JSON.stringify(settings, null, 2), 'utf8'); + try { + fs.chmodSync(tmpFile, 0o600); + } catch { + // best-effort + } + fs.renameSync(tmpFile, SETTINGS_FILE); + try { + fs.chmodSync(SETTINGS_FILE, 0o600); + } catch { + // best-effort + } +} diff --git a/packages/web/server/lib/github/gh-cli-credential.js b/packages/web/server/lib/github/gh-cli-credential.js new file mode 100644 index 00000000..cf77b4d2 --- /dev/null +++ b/packages/web/server/lib/github/gh-cli-credential.js @@ -0,0 +1,38 @@ +import { execFileSync } from 'child_process'; + +const CACHE_TTL_MS = 30_000; +let cachedToken = null; +let cachedAt = 0; +let hasCachedToken = false; + +function fetchGhCliToken() { + try { + const token = execFileSync('gh', ['auth', 'token'], { + encoding: 'utf8', + stdio: ['pipe', 'pipe', 'pipe'], + timeout: 5000, + windowsHide: true, + }).trim(); + return token || null; + } catch { + return null; + } +} + +export function getGhCliToken() { + const now = Date.now(); + if (hasCachedToken && now - cachedAt < CACHE_TTL_MS) { + return cachedToken; + } + const token = fetchGhCliToken(); + cachedToken = token; + cachedAt = now; + hasCachedToken = true; + return token; +} + +export function clearGhCliTokenCache() { + cachedToken = null; + cachedAt = 0; + hasCachedToken = false; +} diff --git a/packages/web/server/lib/github/gh-cli-credential.test.js b/packages/web/server/lib/github/gh-cli-credential.test.js new file mode 100644 index 00000000..2ee18222 --- /dev/null +++ b/packages/web/server/lib/github/gh-cli-credential.test.js @@ -0,0 +1,43 @@ +import { beforeEach, describe, expect, mock, test } from 'bun:test'; + +const execFileSyncMock = mock(() => ''); + +mock.module('child_process', () => ({ + execFileSync: execFileSyncMock, +})); + +const { clearGhCliTokenCache, getGhCliToken } = await import('./gh-cli-credential.js'); + +describe('gh CLI credential lookup', () => { + beforeEach(() => { + execFileSyncMock.mockReset(); + clearGhCliTokenCache(); + }); + + test('hides the subprocess window on Windows', () => { + execFileSyncMock.mockReturnValueOnce('token\n'); + + expect(getGhCliToken()).toBe('token'); + expect(execFileSyncMock).toHaveBeenCalledWith('gh', ['auth', 'token'], { + encoding: 'utf8', + stdio: ['pipe', 'pipe', 'pipe'], + timeout: 5000, + windowsHide: true, + }); + }); + + test('caches unavailable gh CLI result until cache is cleared', () => { + execFileSyncMock.mockImplementation(() => { + throw new Error('gh unavailable'); + }); + + expect(getGhCliToken()).toBeNull(); + expect(getGhCliToken()).toBeNull(); + expect(execFileSyncMock).toHaveBeenCalledTimes(1); + + clearGhCliTokenCache(); + + expect(getGhCliToken()).toBeNull(); + expect(execFileSyncMock).toHaveBeenCalledTimes(2); + }); +}); diff --git a/packages/web/server/lib/github/index.js b/packages/web/server/lib/github/index.js index b86c4749..075748b6 100644 --- a/packages/web/server/lib/github/index.js +++ b/packages/web/server/lib/github/index.js @@ -6,6 +6,8 @@ export { clearGitHubAuth, getGitHubClientId, getGitHubScopes, + isGhCliDisabled, + setGhCliDisabled, GITHUB_AUTH_FILE, } from './auth.js'; diff --git a/packages/web/server/lib/github/octokit.js b/packages/web/server/lib/github/octokit.js index e374cfa5..5ee1e2e7 100644 --- a/packages/web/server/lib/github/octokit.js +++ b/packages/web/server/lib/github/octokit.js @@ -1,10 +1,12 @@ import { Octokit } from '@octokit/rest'; -import { getGitHubAuth } from './auth.js'; +import { getGitHubAuth, isGhCliDisabled } from './auth.js'; +import { getGhCliToken } from './gh-cli-credential.js'; export function getOctokitOrNull() { const auth = getGitHubAuth(); - if (!auth?.accessToken) { + const token = auth?.accessToken || (!isGhCliDisabled() ? getGhCliToken() : null); + if (!token) { return null; } - return new Octokit({ auth: auth.accessToken }); + return new Octokit({ auth: token }); } diff --git a/packages/web/server/lib/github/routes.js b/packages/web/server/lib/github/routes.js index 5cf10c56..a23f8ab4 100644 --- a/packages/web/server/lib/github/routes.js +++ b/packages/web/server/lib/github/routes.js @@ -76,16 +76,25 @@ export function registerGitHubRoutes(app) { app.get('/api/github/auth/status', async (_req, res) => { try { - const { getGitHubAuth, getOctokitOrNull, clearGitHubAuth, getGitHubAuthAccounts } = await getGitHubLibraries(); + const { getGitHubAuth, getOctokitOrNull, clearGitHubAuth, getGitHubAuthAccounts, isGhCliDisabled } = await getGitHubLibraries(); + const { getGhCliToken } = await import('./gh-cli-credential.js'); + const auth = getGitHubAuth(); const accounts = getGitHubAuthAccounts(); - if (!auth?.accessToken) { - return res.json({ connected: false, accounts }); - } + const ghCliDisabled = isGhCliDisabled(); + const ghToken = getGhCliToken(); + const usingOwnToken = Boolean(auth?.accessToken); + + const buildGhCli = (activeUser = null) => ({ + available: ghToken !== null, + disabled: ghCliDisabled, + active: !usingOwnToken && ghToken !== null && !ghCliDisabled, + ...(activeUser ? { user: activeUser } : {}), + }); const octokit = getOctokitOrNull(); if (!octokit) { - return res.json({ connected: false, accounts }); + return res.json({ connected: false, accounts, ghCli: buildGhCli() }); } let user = null; @@ -93,19 +102,21 @@ export function registerGitHubRoutes(app) { user = await getGitHubUserSummary(octokit); } catch (error) { if (isGitHubAuthInvalid(error)) { - clearGitHubAuth(); - return res.json({ connected: false, accounts: getGitHubAuthAccounts() }); + if (usingOwnToken) clearGitHubAuth(); + return res.json({ connected: false, accounts: getGitHubAuthAccounts(), ghCli: buildGhCli() }); } } - const fallback = auth.user; + const fallback = usingOwnToken ? auth.user : null; const mergedUser = user || fallback; + const ghIsActive = !usingOwnToken && ghToken !== null && !ghCliDisabled; return res.json({ connected: true, user: mergedUser, - scope: auth.scope, + scope: usingOwnToken ? auth.scope : undefined, accounts, + ghCli: buildGhCli(ghIsActive ? mergedUser : null), }); } catch (error) { console.error('Failed to get GitHub auth status:', error); @@ -113,6 +124,20 @@ export function registerGitHubRoutes(app) { } }); + app.post('/api/github/auth/gh-cli', async (req, res) => { + try { + const { setGhCliDisabled, isGhCliDisabled } = await getGitHubLibraries(); + const { clearGhCliTokenCache } = await import('./gh-cli-credential.js'); + const disabled = Boolean(req.body?.disabled); + setGhCliDisabled(disabled); + clearGhCliTokenCache(); + return res.json({ disabled: isGhCliDisabled() }); + } catch (error) { + console.error('Failed to update gh CLI setting:', error); + return res.status(500).json({ error: error.message || 'Failed to update gh CLI setting' }); + } + }); + app.post('/api/github/auth/start', async (_req, res) => { try { const { getGitHubClientId, getGitHubScopes, startDeviceFlow } = await getGitHubLibraries(); diff --git a/packages/web/src/api/github.ts b/packages/web/src/api/github.ts index 80b7c7dc..6587a2bf 100644 --- a/packages/web/src/api/github.ts +++ b/packages/web/src/api/github.ts @@ -88,6 +88,19 @@ export const createWebGitHubAPI = ({ urls }: WebGitHubAPIOptions): GitHubAPI => return payload; }, + async authSetGhCliDisabled(disabled: boolean): Promise<{ disabled: boolean }> { + const response = await runtimeFetch('/api/github/auth/gh-cli', { + method: 'POST', + headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, + body: JSON.stringify({ disabled }), + }); + const payload = await jsonOrNull<{ disabled?: boolean; error?: string }>(response); + if (!response.ok || !payload) { + throw new Error(payload?.error || response.statusText || 'Failed to update gh CLI setting'); + } + return { disabled: Boolean(payload.disabled) }; + }, + async me(): Promise { const response = await runtimeFetch('/api/github/me', { method: 'GET', headers: { Accept: 'application/json' } }); const payload = await jsonOrNull(response);