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.
346 lines
9.0 KiB
JavaScript
346 lines
9.0 KiB
JavaScript
import fs from 'fs';
|
|
import path from 'path';
|
|
import os from 'os';
|
|
|
|
const OPENCHAMBER_DATA_DIR = process.env.OPENCHAMBER_DATA_DIR
|
|
? path.resolve(process.env.OPENCHAMBER_DATA_DIR)
|
|
: path.join(os.homedir(), '.config', 'openchamber');
|
|
|
|
const STORAGE_DIR = OPENCHAMBER_DATA_DIR;
|
|
const STORAGE_FILE = path.join(STORAGE_DIR, 'github-auth.json');
|
|
const SETTINGS_FILE = path.join(OPENCHAMBER_DATA_DIR, 'settings.json');
|
|
|
|
const DEFAULT_GITHUB_CLIENT_ID = 'Ov23lizomPOC3eFYo56r';
|
|
const DEFAULT_GITHUB_SCOPES = 'repo read:org workflow read:user user:email';
|
|
|
|
function ensureStorageDir() {
|
|
if (!fs.existsSync(STORAGE_DIR)) {
|
|
fs.mkdirSync(STORAGE_DIR, { recursive: true });
|
|
}
|
|
}
|
|
|
|
function readJsonFile() {
|
|
ensureStorageDir();
|
|
if (!fs.existsSync(STORAGE_FILE)) {
|
|
return null;
|
|
}
|
|
try {
|
|
const raw = fs.readFileSync(STORAGE_FILE, 'utf8');
|
|
const trimmed = raw.trim();
|
|
if (!trimmed) {
|
|
return null;
|
|
}
|
|
const parsed = JSON.parse(trimmed);
|
|
if (!parsed || typeof parsed !== 'object') {
|
|
return null;
|
|
}
|
|
return parsed;
|
|
} catch (error) {
|
|
console.error('Failed to read GitHub auth file:', error);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function writeJsonFile(payload) {
|
|
ensureStorageDir();
|
|
|
|
// Atomic write so multiple OpenChamber instances can safely share the same file.
|
|
const tmpFile = `${STORAGE_FILE}.${process.pid}.${Date.now()}.tmp`;
|
|
fs.writeFileSync(tmpFile, JSON.stringify(payload, null, 2), 'utf8');
|
|
try {
|
|
fs.chmodSync(tmpFile, 0o600);
|
|
} catch {
|
|
// best-effort
|
|
}
|
|
|
|
fs.renameSync(tmpFile, STORAGE_FILE);
|
|
try {
|
|
fs.chmodSync(STORAGE_FILE, 0o600);
|
|
} catch {
|
|
// best-effort
|
|
}
|
|
}
|
|
|
|
function resolveAccountId({ user, accessToken, accountId }) {
|
|
if (typeof accountId === 'string' && accountId.trim()) {
|
|
return accountId.trim();
|
|
}
|
|
if (user && typeof user.login === 'string' && user.login.trim()) {
|
|
return user.login.trim();
|
|
}
|
|
if (user && typeof user.id === 'number') {
|
|
return String(user.id);
|
|
}
|
|
if (typeof accessToken === 'string' && accessToken.trim()) {
|
|
return `token:${accessToken.slice(0, 8)}`;
|
|
}
|
|
return '';
|
|
}
|
|
|
|
function normalizeAuthEntry(entry) {
|
|
if (!entry || typeof entry !== 'object') return null;
|
|
const accessToken = typeof entry.accessToken === 'string' ? entry.accessToken : '';
|
|
if (!accessToken) return null;
|
|
const user = entry.user && typeof entry.user === 'object'
|
|
? {
|
|
login: typeof entry.user.login === 'string' ? entry.user.login : null,
|
|
avatarUrl: typeof entry.user.avatarUrl === 'string' ? entry.user.avatarUrl : null,
|
|
id: typeof entry.user.id === 'number' ? entry.user.id : null,
|
|
name: typeof entry.user.name === 'string' ? entry.user.name : null,
|
|
email: typeof entry.user.email === 'string' ? entry.user.email : null,
|
|
}
|
|
: null;
|
|
|
|
const accountId = resolveAccountId({
|
|
user,
|
|
accessToken,
|
|
accountId: typeof entry.accountId === 'string' ? entry.accountId : '',
|
|
});
|
|
|
|
return {
|
|
accessToken,
|
|
scope: typeof entry.scope === 'string' ? entry.scope : '',
|
|
tokenType: typeof entry.tokenType === 'string' ? entry.tokenType : 'bearer',
|
|
createdAt: typeof entry.createdAt === 'number' ? entry.createdAt : null,
|
|
user,
|
|
current: Boolean(entry.current),
|
|
accountId,
|
|
};
|
|
}
|
|
|
|
function normalizeAuthList(raw) {
|
|
const list = (Array.isArray(raw) ? raw : [raw])
|
|
.map((entry) => normalizeAuthEntry(entry))
|
|
.filter(Boolean);
|
|
|
|
if (!list.length) {
|
|
return { list: [], changed: false };
|
|
}
|
|
|
|
let changed = false;
|
|
let currentFound = false;
|
|
list.forEach((entry) => {
|
|
if (entry.current && !currentFound) {
|
|
currentFound = true;
|
|
} else if (entry.current && currentFound) {
|
|
entry.current = false;
|
|
changed = true;
|
|
}
|
|
});
|
|
|
|
if (!currentFound && list[0]) {
|
|
list[0].current = true;
|
|
changed = true;
|
|
}
|
|
|
|
list.forEach((entry) => {
|
|
if (!entry.accountId) {
|
|
entry.accountId = resolveAccountId(entry);
|
|
changed = true;
|
|
}
|
|
});
|
|
|
|
return { list, changed };
|
|
}
|
|
|
|
function readAuthList() {
|
|
const data = readJsonFile();
|
|
if (!data) {
|
|
return [];
|
|
}
|
|
const { list, changed } = normalizeAuthList(data);
|
|
if (changed) {
|
|
writeJsonFile(list);
|
|
}
|
|
return list;
|
|
}
|
|
|
|
function writeAuthList(list) {
|
|
writeJsonFile(list);
|
|
}
|
|
|
|
export function getGitHubAuth() {
|
|
const list = readAuthList();
|
|
if (!list.length) {
|
|
return null;
|
|
}
|
|
const current = list.find((entry) => entry.current) || list[0];
|
|
if (!current?.accessToken) {
|
|
return null;
|
|
}
|
|
return current;
|
|
}
|
|
|
|
export function getGitHubAuthAccounts() {
|
|
const list = readAuthList();
|
|
return list
|
|
.filter((entry) => entry?.user && entry.accountId)
|
|
.map((entry) => ({
|
|
id: entry.accountId,
|
|
user: entry.user,
|
|
scope: entry.scope || '',
|
|
current: Boolean(entry.current),
|
|
}));
|
|
}
|
|
|
|
export function setGitHubAuth({ accessToken, scope, tokenType, user, accountId }) {
|
|
if (!accessToken || typeof accessToken !== 'string') {
|
|
throw new Error('accessToken is required');
|
|
}
|
|
const normalizedUser = user && typeof user === 'object'
|
|
? {
|
|
login: typeof user.login === 'string' ? user.login : undefined,
|
|
avatarUrl: typeof user.avatarUrl === 'string' ? user.avatarUrl : undefined,
|
|
id: typeof user.id === 'number' ? user.id : undefined,
|
|
name: typeof user.name === 'string' ? user.name : undefined,
|
|
email: typeof user.email === 'string' ? user.email : undefined,
|
|
}
|
|
: undefined;
|
|
|
|
const resolvedAccountId = resolveAccountId({
|
|
user: normalizedUser,
|
|
accessToken,
|
|
accountId,
|
|
});
|
|
|
|
const list = readAuthList();
|
|
const existingIndex = list.findIndex((entry) => entry.accountId === resolvedAccountId);
|
|
const nextEntry = {
|
|
accessToken,
|
|
scope: typeof scope === 'string' ? scope : '',
|
|
tokenType: typeof tokenType === 'string' ? tokenType : 'bearer',
|
|
createdAt: Date.now(),
|
|
user: normalizedUser || null,
|
|
current: true,
|
|
accountId: resolvedAccountId,
|
|
};
|
|
|
|
if (existingIndex >= 0) {
|
|
list[existingIndex] = nextEntry;
|
|
} else {
|
|
list.push(nextEntry);
|
|
}
|
|
|
|
list.forEach((entry, index) => {
|
|
entry.current = index === (existingIndex >= 0 ? existingIndex : list.length - 1);
|
|
});
|
|
writeAuthList(list);
|
|
return nextEntry;
|
|
}
|
|
|
|
export function activateGitHubAuth(accountId) {
|
|
if (typeof accountId !== 'string' || !accountId.trim()) {
|
|
return false;
|
|
}
|
|
const list = readAuthList();
|
|
const index = list.findIndex((entry) => entry.accountId === accountId.trim());
|
|
if (index === -1) {
|
|
return false;
|
|
}
|
|
list.forEach((entry, idx) => {
|
|
entry.current = idx === index;
|
|
});
|
|
writeAuthList(list);
|
|
return true;
|
|
}
|
|
|
|
export function clearGitHubAuth() {
|
|
try {
|
|
const list = readAuthList();
|
|
if (!list.length) {
|
|
return true;
|
|
}
|
|
const remaining = list.filter((entry) => !entry.current);
|
|
if (!remaining.length) {
|
|
if (fs.existsSync(STORAGE_FILE)) {
|
|
fs.unlinkSync(STORAGE_FILE);
|
|
}
|
|
return true;
|
|
}
|
|
remaining.forEach((entry, index) => {
|
|
entry.current = index === 0;
|
|
});
|
|
writeAuthList(remaining);
|
|
return true;
|
|
} catch (error) {
|
|
console.error('Failed to clear GitHub auth file:', error);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
export function getGitHubClientId() {
|
|
const raw = process.env.OPENCHAMBER_GITHUB_CLIENT_ID;
|
|
const clientId = typeof raw === 'string' ? raw.trim() : '';
|
|
if (clientId) return clientId;
|
|
|
|
try {
|
|
if (fs.existsSync(SETTINGS_FILE)) {
|
|
const parsed = JSON.parse(fs.readFileSync(SETTINGS_FILE, 'utf8'));
|
|
const stored = typeof parsed?.githubClientId === 'string' ? parsed.githubClientId.trim() : '';
|
|
if (stored) return stored;
|
|
}
|
|
} catch {
|
|
// ignore
|
|
}
|
|
|
|
return DEFAULT_GITHUB_CLIENT_ID;
|
|
}
|
|
|
|
export function getGitHubScopes() {
|
|
const raw = process.env.OPENCHAMBER_GITHUB_SCOPES;
|
|
const fromEnv = typeof raw === 'string' ? raw.trim() : '';
|
|
if (fromEnv) return fromEnv;
|
|
|
|
try {
|
|
if (fs.existsSync(SETTINGS_FILE)) {
|
|
const parsed = JSON.parse(fs.readFileSync(SETTINGS_FILE, 'utf8'));
|
|
const stored = typeof parsed?.githubScopes === 'string' ? parsed.githubScopes.trim() : '';
|
|
if (stored) return stored;
|
|
}
|
|
} catch {
|
|
// ignore
|
|
}
|
|
|
|
return DEFAULT_GITHUB_SCOPES;
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|