163 lines
7.3 KiB
JavaScript
163 lines
7.3 KiB
JavaScript
// 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']
|
|
let teaBinary
|
|
|
|
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
|
|
}
|
|
|
|
async function teaBin() {
|
|
if (teaBinary !== undefined) return teaBinary
|
|
for (const b of TEA_BINARIES) if (await which(b)) return (teaBinary = b)
|
|
return (teaBinary = null)
|
|
}
|
|
|
|
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 }) {
|
|
const bin = await teaBin()
|
|
if (!bin) throw new ForgeError('tea-cli is not installed on the host', 400)
|
|
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 run(bin, args, { env, timeoutMs: 60000 })
|
|
if (r.code !== 0) throw fail(r, 'tea-cli 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 {
|
|
const bin = await teaBin()
|
|
if (!bin) throw new ForgeError('tea-cli is not installed on the host', 400)
|
|
r = await run(bin, ['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
|
|
}))
|
|
}
|