// Account authorization for the settings panel: gh (GitHub) and tea-cli (Gitea/Forgejo). // All credentials are stored by the CLIs themselves; this module only drives them. import { spawn } from 'node:child_process' import { randomUUID } from 'node:crypto' import { ForgeError, listAccounts, publicAccount, resetAccountCache, run } from './forge.mjs' const TEA_BINARIES = ['tea-cli', 'tea'] const TEA_MISSING = 'neither tea-cli nor tea is installed on the host' let teaBinary // the binary that worked last (tried first). A miss is never cached, so installing one later just works. async function which(cmd) { const r = await run(cmd, ['--version'], { timeoutMs: 10000 }) return r.code === 0 ? (r.stdout || r.stderr).replace(/\x1b\[[0-9;]*m/g, "").trim().split("\n")[0] : null } /** Candidate order for tea commands: the last working binary first, then the rest of tea-cli / tea. */ const teaOrder = () => (teaBinary ? [teaBinary, ...TEA_BINARIES.filter((b) => b !== teaBinary)] : TEA_BINARIES) /** First of tea-cli / tea that runs on this host, or null. */ async function teaBin() { for (const b of teaOrder()) if (await which(b)) return (teaBinary = b) return null } /** * Run a tea command with whichever of tea-cli / tea exists: both names are tried, and a binary that is not * there (ENOENT) is skipped. Resolves like `run`, plus the `bin` that ran it. */ async function runTea(args, opts) { for (const bin of teaOrder()) { const r = await run(bin, args, opts) if (r.error?.code === 'ENOENT') continue teaBinary = bin return { ...r, bin } } throw new ForgeError(TEA_MISSING, 400) } export async function cliStatus() { const gh = await which('gh') const tb = await teaBin() const tea = tb ? await which(tb) : null return { gh: { available: !!gh, version: gh ?? '' }, tea: { available: !!tea, version: tea ?? '', binary: tb ?? '' }, } } const done = () => { resetAccountCache() } const fail = (r, what) => new ForgeError(`${what} failed: ${(r.stderr || r.stdout || '').trim().split('\n').slice(-3).join(' ') || `exit ${r.code}`}`, 400) const cleanHost = (h) => String(h || 'github.com').trim().replace(/^https?:\/\//, '').replace(/\/.*$/, '') || 'github.com' /** Add a Gitea/Forgejo login with a token (or user+password, from which tea creates a token). */ export async function giteaLogin({ name, url, token, user, password, insecure }) { if (!url) throw new ForgeError('server URL is required', 400) if (!token && !(user && password)) throw new ForgeError('an access token (or user and password) is required', 400) let u try { u = new URL(/^https?:\/\//.test(url) ? url : `https://${url}`) } catch { throw new ForgeError('invalid server URL', 400) } const args = ['logins', 'add', '--url', u.origin + u.pathname.replace(/\/+$/, ''), '--name', name || u.hostname] // Secrets go through the environment, not argv (visible in the process list). const env = {} if (token) env.GITEA_SERVER_TOKEN = token else { env.GITEA_SERVER_USER = user; env.GITEA_SERVER_PASSWORD = password } if (insecure) args.push('--insecure') const r = await runTea(args, { env, timeoutMs: 60000 }) if (r.code !== 0) throw fail(r, `${r.bin} logins add`) done() return { ok: true, message: (r.stdout || '').trim() } } /** Log in to GitHub with a personal access token. */ export async function githubTokenLogin({ hostname, token }) { if (!token) throw new ForgeError('token is required', 400) const r = await run('gh', ['auth', 'login', '--hostname', cleanHost(hostname), '--git-protocol', 'https', '--with-token'], { input: String(token).trim() + '\n', timeoutMs: 60000 }) if (r.code !== 0) throw fail(r, 'gh auth login') done() return { ok: true } } // ---- GitHub device (web) flow ------------------------------------------------ const flows = new Map() const FLOW_TTL = 15 * 60 * 1000 function publicFlow(f) { return { id: f.id, host: f.host, state: f.state, code: f.code, verifyUrl: f.verifyUrl, message: f.message } } /** Start `gh auth login --web`; resolves once the one-time code is known. */ export function githubDeviceStart({ hostname }) { for (const f of flows.values()) if (f.state === 'pending') { try { f.child.kill() } catch { /* gone */ } f.state = 'cancelled' } const host = cleanHost(hostname) const id = randomUUID() const child = spawn('gh', ['auth', 'login', '--hostname', host, '--web', '--git-protocol', 'https', '--skip-ssh-key'], { env: { ...process.env, BROWSER: 'true', GH_BROWSER: 'true', GH_PROMPT_DISABLED: '', NO_COLOR: '1' }, stdio: ['pipe', 'pipe', 'pipe'], }) const flow = { id, host, child, state: 'pending', code: '', verifyUrl: '', message: '', output: '', at: Date.now() } flows.set(id, flow) const timer = setTimeout(() => { if (flow.state === 'pending') { flow.state = 'error'; flow.message = 'timed out'; child.kill() } }, FLOW_TTL) return new Promise((resolve, reject) => { let settled = false const settle = (fn) => { if (!settled) { settled = true; fn() } } const onData = (buf) => { flow.output += String(buf) const code = flow.output.match(/one-time code:\s*([A-Z0-9]{4}-[A-Z0-9]{4})/) const url = flow.output.match(/(https?:\/\/\S+\/login\/device)/) if (code) flow.code = code[1] if (url) flow.verifyUrl = url[1] if (/Press Enter/i.test(flow.output)) { try { child.stdin.write('\n') } catch { /* closed */ } } if (flow.code) settle(() => resolve(publicFlow({ ...flow, verifyUrl: flow.verifyUrl || `https://${host}/login/device` }))) } child.stdout.on('data', onData) child.stderr.on('data', onData) try { child.stdin.write('\n') } catch { /* closed */ } child.on('error', (e) => { flow.state = 'error'; flow.message = e.message; settle(() => reject(new ForgeError(`gh: ${e.message}`, 400))) }) child.on('close', (code) => { clearTimeout(timer) if (flow.state === 'pending') { flow.state = code === 0 ? 'success' : 'error' if (code !== 0) flow.message = flow.output.trim().split('\n').slice(-2).join(' ') || `gh exited with ${code}` } if (flow.state === 'success') done() settle(() => (flow.state === 'error' ? reject(new ForgeError(flow.message, 400)) : resolve(publicFlow(flow)))) }) setTimeout(() => settle(() => reject(new ForgeError('gh did not print a device code', 500))), 20000) }) } export function githubDeviceStatus(id) { const f = flows.get(id) if (!f) throw new ForgeError('unknown login flow', 404) if (f.state !== 'pending' && Date.now() - f.at > FLOW_TTL) flows.delete(id) return publicFlow(f) } export function githubDeviceCancel(id) { const f = flows.get(id) if (f && f.state === 'pending') { f.state = 'cancelled'; try { f.child.kill() } catch { /* gone */ } } return { ok: true } } // ---- logout / listing -------------------------------------------------------- export async function logout(accountId) { const accounts = await listAccounts({ refresh: true }) const a = accounts.find((x) => x.id === accountId) if (!a) throw new ForgeError(`unknown account ${accountId}`, 404) let r if (a.kind === 'github') r = await run('gh', ['auth', 'logout', '--hostname', a.host, '--user', a.user], { timeoutMs: 30000 }) else r = await runTea(['logins', 'delete', a.name], { timeoutMs: 30000 }) if (r.code !== 0) throw fail(r, 'logout') done() return { ok: true } } /** Accounts plus a live check of each token (who the API says we are). */ export async function accountsWithStatus({ verify = false } = {}) { const accounts = await listAccounts({ refresh: true }) const { api } = await import('./forge.mjs') return Promise.all(accounts.map(async (a) => { const out = { ...publicAccount(a), active: a.active !== false, status: 'unknown', login: '' } if (!verify) return out try { const me = await api(a, 'GET', '/user', { signal: AbortSignal.timeout(10000) }) out.status = 'ok' out.login = me?.login ?? me?.username ?? '' } catch (e) { out.status = 'error' out.error = String(e?.message ?? e) } return out })) }