Initial commit: git-forge-plugin DSH plugin with docs
This commit is contained in:
commit
e303c51b23
11 files changed
+2351
No files matched your search
@@ -0,0 +1,162 @@
|
||||
// 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
|
||||
}))
|
||||
}
|
||||
Reference in new issue
Block a user