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.
This commit is contained in:
@@ -174,6 +174,30 @@ export const GitHubSettings: React.FC = () => {
|
|||||||
};
|
};
|
||||||
}, [flow, pollIntervalMs, pollOnce, refreshStatus, runtimeGitHub, stopPolling, t]);
|
}, [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 () => {
|
const disconnect = React.useCallback(async () => {
|
||||||
setIsBusy(true);
|
setIsBusy(true);
|
||||||
try {
|
try {
|
||||||
@@ -239,6 +263,7 @@ export const GitHubSettings: React.FC = () => {
|
|||||||
const connected = Boolean(status?.connected);
|
const connected = Boolean(status?.connected);
|
||||||
const user = status?.user;
|
const user = status?.user;
|
||||||
const accounts = status?.accounts ?? [];
|
const accounts = status?.accounts ?? [];
|
||||||
|
const ghCli = status?.ghCli ?? null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
@@ -287,12 +312,23 @@ export const GitHubSettings: React.FC = () => {
|
|||||||
{t('settings.github.page.label.scopes', { value: status.scope })}
|
{t('settings.github.page.label.scopes', { value: status.scope })}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{ghCli?.active && (
|
||||||
|
<div className="typography-micro text-muted-foreground/70 mt-0.5">
|
||||||
|
{t('settings.github.page.ghCli.activeDescription')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button size="sm" variant="outline" onClick={disconnect} disabled={isBusy} className={cn("text-[var(--status-error)] hover:text-[var(--status-error)]", isMobile ? "w-full" : undefined)}>
|
{ghCli?.active ? (
|
||||||
{t('settings.github.page.actions.disconnect')}
|
<Button size="sm" variant="outline" onClick={() => toggleGhCli(true)} disabled={isBusy} className={cn(isMobile ? "w-full" : undefined)}>
|
||||||
</Button>
|
{t('settings.github.page.ghCli.actions.disable')}
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button size="sm" variant="outline" onClick={disconnect} disabled={isBusy} className={cn("text-[var(--status-error)] hover:text-[var(--status-error)]", isMobile ? "w-full" : undefined)}>
|
||||||
|
{t('settings.github.page.actions.disconnect')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex items-center justify-between gap-4 px-4 py-4">
|
<div className="flex items-center justify-between gap-4 px-4 py-4">
|
||||||
@@ -412,6 +448,41 @@ export const GitHubSettings: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{ghCli?.available && !ghCli?.active && (
|
||||||
|
<div className="mt-6">
|
||||||
|
<h3 className="typography-ui-header font-semibold text-foreground mb-3 px-1">
|
||||||
|
{t('settings.github.page.ghCli.title')}
|
||||||
|
</h3>
|
||||||
|
<div className="rounded-lg bg-[var(--surface-elevated)]/70 overflow-hidden">
|
||||||
|
<div className={cn("px-4 py-3", isMobile ? "flex flex-col gap-3" : "flex items-center justify-between gap-4")}>
|
||||||
|
<div className={cn("flex min-w-0 items-center gap-4", isMobile ? "w-full" : undefined)}>
|
||||||
|
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full border border-[var(--interactive-border)] bg-[var(--surface-muted)]">
|
||||||
|
<Icon name="terminal" className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className={cn("typography-meta text-muted-foreground", ghCli.disabled ? "opacity-60" : undefined)}>
|
||||||
|
{ghCli.disabled
|
||||||
|
? t('settings.github.page.ghCli.disabledDescription')
|
||||||
|
: t('settings.github.page.ghCli.fallbackDescription')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => toggleGhCli(!ghCli.disabled)}
|
||||||
|
disabled={isBusy}
|
||||||
|
className={cn(isMobile ? "w-full" : undefined)}
|
||||||
|
>
|
||||||
|
{ghCli.disabled
|
||||||
|
? t('settings.github.page.ghCli.actions.enable')
|
||||||
|
: t('settings.github.page.ghCli.actions.disable')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1039,6 +1039,12 @@ export type GitHubAuthStatus = {
|
|||||||
user?: GitHubUserSummary | null;
|
user?: GitHubUserSummary | null;
|
||||||
scope?: string;
|
scope?: string;
|
||||||
accounts?: GitHubAuthAccount[];
|
accounts?: GitHubAuthAccount[];
|
||||||
|
ghCli?: {
|
||||||
|
available: boolean;
|
||||||
|
disabled: boolean;
|
||||||
|
active: boolean;
|
||||||
|
user?: GitHubUserSummary | null;
|
||||||
|
} | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type GitHubAuthAccount = {
|
export type GitHubAuthAccount = {
|
||||||
@@ -1068,6 +1074,7 @@ export interface GitHubAPI {
|
|||||||
authComplete(deviceCode: string): Promise<GitHubDeviceFlowComplete>;
|
authComplete(deviceCode: string): Promise<GitHubDeviceFlowComplete>;
|
||||||
authDisconnect(): Promise<{ removed: boolean }>;
|
authDisconnect(): Promise<{ removed: boolean }>;
|
||||||
authActivate(accountId: string): Promise<GitHubAuthStatus>;
|
authActivate(accountId: string): Promise<GitHubAuthStatus>;
|
||||||
|
authSetGhCliDisabled(disabled: boolean): Promise<{ disabled: boolean }>;
|
||||||
me?(): Promise<GitHubUserSummary>;
|
me?(): Promise<GitHubUserSummary>;
|
||||||
|
|
||||||
prStatus(directory: string, branch: string, remote?: string, options?: { force?: boolean }): Promise<GitHubPullRequestStatus>;
|
prStatus(directory: string, branch: string, remote?: string, options?: { force?: boolean }): Promise<GitHubPullRequestStatus>;
|
||||||
|
|||||||
@@ -1407,6 +1407,15 @@ export const settingsDict = {
|
|||||||
'settings.github.page.toast.disconnectFailed': 'Failed to disconnect GitHub',
|
'settings.github.page.toast.disconnectFailed': 'Failed to disconnect GitHub',
|
||||||
'settings.github.page.toast.accountSwitched': 'GitHub account switched',
|
'settings.github.page.toast.accountSwitched': 'GitHub account switched',
|
||||||
'settings.github.page.toast.accountSwitchFailed': 'Failed to switch GitHub account',
|
'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.title': 'Notification Delivery',
|
||||||
'settings.notifications.page.delivery.enableAria': 'Enable notifications',
|
'settings.notifications.page.delivery.enableAria': 'Enable notifications',
|
||||||
'settings.notifications.page.delivery.enableLabel': 'Enable Notifications',
|
'settings.notifications.page.delivery.enableLabel': 'Enable Notifications',
|
||||||
|
|||||||
@@ -1374,6 +1374,15 @@ export const settingsDict = {
|
|||||||
"settings.github.page.toast.disconnectFailed": "No se pudo desconectar de GitHub",
|
"settings.github.page.toast.disconnectFailed": "No se pudo desconectar de GitHub",
|
||||||
"settings.github.page.toast.accountSwitched": "Cuenta de GitHub cambiada",
|
"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.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.title": "Entrega de notificaciones",
|
||||||
"settings.notifications.page.delivery.enableAria": "Habilitar notificaciones",
|
"settings.notifications.page.delivery.enableAria": "Habilitar notificaciones",
|
||||||
"settings.notifications.page.delivery.enableLabel": "Habilitar notificaciones",
|
"settings.notifications.page.delivery.enableLabel": "Habilitar notificaciones",
|
||||||
|
|||||||
@@ -1374,6 +1374,15 @@ export const settingsDict = {
|
|||||||
'settings.github.page.toast.disconnectFailed': 'GitHub 연결을 해제하지 못했습니다',
|
'settings.github.page.toast.disconnectFailed': 'GitHub 연결을 해제하지 못했습니다',
|
||||||
'settings.github.page.toast.accountSwitched': 'GitHub 계정이 전환되었습니다',
|
'settings.github.page.toast.accountSwitched': 'GitHub 계정이 전환되었습니다',
|
||||||
'settings.github.page.toast.accountSwitchFailed': '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.title': '알림',
|
||||||
'settings.notifications.page.delivery.enableAria': '알림 활성화',
|
'settings.notifications.page.delivery.enableAria': '알림 활성화',
|
||||||
'settings.notifications.page.delivery.enableLabel': '알림 활성화',
|
'settings.notifications.page.delivery.enableLabel': '알림 활성화',
|
||||||
|
|||||||
@@ -246,6 +246,15 @@ export const settingsDict = {
|
|||||||
'settings.github.page.toast.disconnectFailed': 'Nie udało się odłączyć GitHub',
|
'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.disconnected': 'Odłączono od GitHub',
|
||||||
'settings.github.page.toast.startConnectFailed': 'Nie udało się rozpocząć połączenia z 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.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.resetAllOverrides': 'Zresetuj wszystkie nadpisania',
|
||||||
'settings.magicPrompts.page.actions.resetToDefault': 'Zresetuj do domyślnych',
|
'settings.magicPrompts.page.actions.resetToDefault': 'Zresetuj do domyślnych',
|
||||||
|
|||||||
@@ -1374,6 +1374,15 @@ export const settingsDict = {
|
|||||||
"settings.github.page.toast.disconnectFailed": "Não foi possível desconectar de GitHub",
|
"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.accountSwitched": "Conta de GitHub cambiada",
|
||||||
"settings.github.page.toast.accountSwitchFailed": "Não foi possível mudar de conta de GitHub",
|
"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.title": "Entrega de notificações",
|
||||||
"settings.notifications.page.delivery.enableAria": "Ativar notificações",
|
"settings.notifications.page.delivery.enableAria": "Ativar notificações",
|
||||||
"settings.notifications.page.delivery.enableLabel": "Ativar notificações",
|
"settings.notifications.page.delivery.enableLabel": "Ativar notificações",
|
||||||
|
|||||||
@@ -1374,6 +1374,15 @@ export const settingsDict = {
|
|||||||
"settings.github.page.toast.disconnectFailed": "Не вдалося відключити GitHub",
|
"settings.github.page.toast.disconnectFailed": "Не вдалося відключити GitHub",
|
||||||
"settings.github.page.toast.accountSwitched": "Обліковий запис GitHub змінено",
|
"settings.github.page.toast.accountSwitched": "Обліковий запис GitHub змінено",
|
||||||
"settings.github.page.toast.accountSwitchFailed": "Не вдалося змінити обліковий запис 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.title": "Доставка сповіщень",
|
||||||
"settings.notifications.page.delivery.enableAria": "Увімкнути сповіщення",
|
"settings.notifications.page.delivery.enableAria": "Увімкнути сповіщення",
|
||||||
"settings.notifications.page.delivery.enableLabel": "Увімкнути сповіщення",
|
"settings.notifications.page.delivery.enableLabel": "Увімкнути сповіщення",
|
||||||
|
|||||||
@@ -1374,6 +1374,15 @@ export const settingsDict = {
|
|||||||
'settings.github.page.toast.disconnectFailed': '断开 GitHub 失败',
|
'settings.github.page.toast.disconnectFailed': '断开 GitHub 失败',
|
||||||
'settings.github.page.toast.accountSwitched': 'GitHub 账号已切换',
|
'settings.github.page.toast.accountSwitched': 'GitHub 账号已切换',
|
||||||
'settings.github.page.toast.accountSwitchFailed': '切换 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.title': '通知投递',
|
||||||
'settings.notifications.page.delivery.enableAria': '启用通知',
|
'settings.notifications.page.delivery.enableAria': '启用通知',
|
||||||
'settings.notifications.page.delivery.enableLabel': '启用通知',
|
'settings.notifications.page.delivery.enableLabel': '启用通知',
|
||||||
|
|||||||
@@ -1295,6 +1295,15 @@
|
|||||||
'settings.github.page.toast.disconnectFailed': '中斷 GitHub 失敗',
|
'settings.github.page.toast.disconnectFailed': '中斷 GitHub 失敗',
|
||||||
'settings.github.page.toast.accountSwitched': 'GitHub 帳號已切換',
|
'settings.github.page.toast.accountSwitched': 'GitHub 帳號已切換',
|
||||||
'settings.github.page.toast.accountSwitchFailed': '切換 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.title': '通知傳遞',
|
||||||
'settings.notifications.page.delivery.enableAria': '啟用通知',
|
'settings.notifications.page.delivery.enableAria': '啟用通知',
|
||||||
'settings.notifications.page.delivery.enableLabel': '啟用通知',
|
'settings.notifications.page.delivery.enableLabel': '啟用通知',
|
||||||
|
|||||||
@@ -30,6 +30,9 @@ export const createVSCodeGitHubAPI = (): GitHubAPI => ({
|
|||||||
authDisconnect: async () => sendBridgeMessage<{ removed: boolean }>('api:github/auth:disconnect'),
|
authDisconnect: async () => sendBridgeMessage<{ removed: boolean }>('api:github/auth:disconnect'),
|
||||||
authActivate: async (accountId: string) =>
|
authActivate: async (accountId: string) =>
|
||||||
sendBridgeMessage<GitHubAuthStatus>('api:github/auth:activate', { accountId }),
|
sendBridgeMessage<GitHubAuthStatus>('api:github/auth:activate', { accountId }),
|
||||||
|
authSetGhCliDisabled: async () => {
|
||||||
|
throw new Error('gh CLI fallback is not supported in VS Code');
|
||||||
|
},
|
||||||
me: async () => sendBridgeMessage<GitHubUserSummary>('api:github/me'),
|
me: async () => sendBridgeMessage<GitHubUserSummary>('api:github/me'),
|
||||||
|
|
||||||
prStatus: async (directory: string, branch: string) =>
|
prStatus: async (directory: string, branch: string) =>
|
||||||
|
|||||||
@@ -305,3 +305,41 @@ export function getGitHubScopes() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const GITHUB_AUTH_FILE = STORAGE_FILE;
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -6,6 +6,8 @@ export {
|
|||||||
clearGitHubAuth,
|
clearGitHubAuth,
|
||||||
getGitHubClientId,
|
getGitHubClientId,
|
||||||
getGitHubScopes,
|
getGitHubScopes,
|
||||||
|
isGhCliDisabled,
|
||||||
|
setGhCliDisabled,
|
||||||
GITHUB_AUTH_FILE,
|
GITHUB_AUTH_FILE,
|
||||||
} from './auth.js';
|
} from './auth.js';
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import { Octokit } from '@octokit/rest';
|
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() {
|
export function getOctokitOrNull() {
|
||||||
const auth = getGitHubAuth();
|
const auth = getGitHubAuth();
|
||||||
if (!auth?.accessToken) {
|
const token = auth?.accessToken || (!isGhCliDisabled() ? getGhCliToken() : null);
|
||||||
|
if (!token) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return new Octokit({ auth: auth.accessToken });
|
return new Octokit({ auth: token });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -76,16 +76,25 @@ export function registerGitHubRoutes(app) {
|
|||||||
|
|
||||||
app.get('/api/github/auth/status', async (_req, res) => {
|
app.get('/api/github/auth/status', async (_req, res) => {
|
||||||
try {
|
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 auth = getGitHubAuth();
|
||||||
const accounts = getGitHubAuthAccounts();
|
const accounts = getGitHubAuthAccounts();
|
||||||
if (!auth?.accessToken) {
|
const ghCliDisabled = isGhCliDisabled();
|
||||||
return res.json({ connected: false, accounts });
|
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();
|
const octokit = getOctokitOrNull();
|
||||||
if (!octokit) {
|
if (!octokit) {
|
||||||
return res.json({ connected: false, accounts });
|
return res.json({ connected: false, accounts, ghCli: buildGhCli() });
|
||||||
}
|
}
|
||||||
|
|
||||||
let user = null;
|
let user = null;
|
||||||
@@ -93,19 +102,21 @@ export function registerGitHubRoutes(app) {
|
|||||||
user = await getGitHubUserSummary(octokit);
|
user = await getGitHubUserSummary(octokit);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (isGitHubAuthInvalid(error)) {
|
if (isGitHubAuthInvalid(error)) {
|
||||||
clearGitHubAuth();
|
if (usingOwnToken) clearGitHubAuth();
|
||||||
return res.json({ connected: false, accounts: getGitHubAuthAccounts() });
|
return res.json({ connected: false, accounts: getGitHubAuthAccounts(), ghCli: buildGhCli() });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const fallback = auth.user;
|
const fallback = usingOwnToken ? auth.user : null;
|
||||||
const mergedUser = user || fallback;
|
const mergedUser = user || fallback;
|
||||||
|
const ghIsActive = !usingOwnToken && ghToken !== null && !ghCliDisabled;
|
||||||
|
|
||||||
return res.json({
|
return res.json({
|
||||||
connected: true,
|
connected: true,
|
||||||
user: mergedUser,
|
user: mergedUser,
|
||||||
scope: auth.scope,
|
scope: usingOwnToken ? auth.scope : undefined,
|
||||||
accounts,
|
accounts,
|
||||||
|
ghCli: buildGhCli(ghIsActive ? mergedUser : null),
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to get GitHub auth status:', 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) => {
|
app.post('/api/github/auth/start', async (_req, res) => {
|
||||||
try {
|
try {
|
||||||
const { getGitHubClientId, getGitHubScopes, startDeviceFlow } = await getGitHubLibraries();
|
const { getGitHubClientId, getGitHubScopes, startDeviceFlow } = await getGitHubLibraries();
|
||||||
|
|||||||
@@ -88,6 +88,19 @@ export const createWebGitHubAPI = ({ urls }: WebGitHubAPIOptions): GitHubAPI =>
|
|||||||
return payload;
|
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<GitHubUserSummary> {
|
async me(): Promise<GitHubUserSummary> {
|
||||||
const response = await runtimeFetch('/api/github/me', { method: 'GET', headers: { Accept: 'application/json' } });
|
const response = await runtimeFetch('/api/github/me', { method: 'GET', headers: { Accept: 'application/json' } });
|
||||||
const payload = await jsonOrNull<GitHubUserSummary & { error?: string }>(response);
|
const payload = await jsonOrNull<GitHubUserSummary & { error?: string }>(response);
|
||||||
|
|||||||
Reference in New Issue
Block a user