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,423 @@
|
||||
// Shared forge logic: account discovery (gh + tea-cli), REST access, git URL parsing,
|
||||
// clone and push. Used by the Host plugin (index.js) and the git credential helper.
|
||||
import { execFile } from 'node:child_process'
|
||||
import fs from 'node:fs'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
export const HELPER_PATH = path.join(path.dirname(fileURLToPath(import.meta.url)), 'credential-helper.mjs')
|
||||
|
||||
const isObj = (v) => v !== null && typeof v === 'object' && !Array.isArray(v)
|
||||
|
||||
// ---- process helpers --------------------------------------------------------
|
||||
|
||||
/** Run a command; resolves { code, stdout, stderr } (never rejects on exit code). */
|
||||
export function run(cmd, args, { cwd, env, timeoutMs = 30000, signal, input } = {}) {
|
||||
return new Promise((resolve) => {
|
||||
const child = execFile(cmd, args, {
|
||||
cwd,
|
||||
env: { ...process.env, ...env },
|
||||
timeout: timeoutMs,
|
||||
maxBuffer: 32 * 1024 * 1024,
|
||||
signal,
|
||||
windowsHide: true,
|
||||
}, (error, stdout, stderr) => {
|
||||
const code = error ? (typeof error.code === 'number' ? error.code : 1) : 0
|
||||
resolve({ code, stdout: String(stdout ?? ''), stderr: String(stderr ?? '') || (error && code !== 0 && !stderr ? String(error.message) : ''), error })
|
||||
})
|
||||
if (input !== undefined) child.stdin?.end(input)
|
||||
})
|
||||
}
|
||||
|
||||
// ---- account discovery ------------------------------------------------------
|
||||
|
||||
function unquote(v) {
|
||||
v = v.trim()
|
||||
if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) return v.slice(1, -1)
|
||||
return v
|
||||
}
|
||||
|
||||
/** Minimal parser for tea's config.yml `logins:` list. */
|
||||
export function parseTeaConfig(text) {
|
||||
const logins = []
|
||||
let inLogins = false
|
||||
let cur = null
|
||||
for (const raw of text.split(/\r?\n/)) {
|
||||
if (!raw.trim() || raw.trim().startsWith('#')) continue
|
||||
if (/^[^\s-]/.test(raw)) { inLogins = /^logins\s*:/.test(raw); cur = null; continue }
|
||||
if (!inLogins) continue
|
||||
let line = raw
|
||||
const item = /^(\s*)-\s+(.*)$/.exec(line)
|
||||
if (item) { cur = {}; logins.push(cur); line = item[2] } else line = line.trim()
|
||||
const kv = /^([A-Za-z0-9_]+)\s*:\s*(.*)$/.exec(line)
|
||||
if (cur && kv) cur[kv[1]] = unquote(kv[2])
|
||||
}
|
||||
return logins
|
||||
}
|
||||
|
||||
function teaConfigPath() {
|
||||
const base = process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config')
|
||||
return path.join(base, 'tea', 'config.yml')
|
||||
}
|
||||
|
||||
function giteaAccounts() {
|
||||
let text
|
||||
try { text = fs.readFileSync(teaConfigPath(), 'utf8') } catch { return [] }
|
||||
const out = []
|
||||
for (const l of parseTeaConfig(text)) {
|
||||
if (!l.url || !l.token) continue
|
||||
let u
|
||||
try { u = new URL(l.url) } catch { continue }
|
||||
const base = l.url.replace(/\/+$/, '')
|
||||
out.push({
|
||||
id: `gitea:${l.name || u.host}`,
|
||||
kind: 'gitea',
|
||||
name: l.name || u.host,
|
||||
host: u.host,
|
||||
sshHost: l.ssh_host || u.hostname,
|
||||
user: l.user || '',
|
||||
url: base,
|
||||
apiBase: `${base}/api/v1`,
|
||||
token: l.token,
|
||||
insecure: l.insecure === 'true',
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
async function githubAccounts() {
|
||||
const st = await run('gh', ['auth', 'status', '--json', 'hosts'], { timeoutMs: 15000 })
|
||||
if (st.code !== 0 && !st.stdout.trim()) return []
|
||||
let data
|
||||
try { data = JSON.parse(st.stdout) } catch { return [] }
|
||||
const out = []
|
||||
for (const [host, entries] of Object.entries(data?.hosts ?? {})) {
|
||||
for (const e of Array.isArray(entries) ? entries : []) {
|
||||
if (!e?.login || (e.state && e.state !== 'success')) continue
|
||||
const tok = await run('gh', ['auth', 'token', '--hostname', host, '--user', e.login], { timeoutMs: 15000 })
|
||||
const token = tok.stdout.trim()
|
||||
if (tok.code !== 0 || !token) continue
|
||||
out.push({
|
||||
id: `github:${host === 'github.com' ? '' : host + ':'}${e.login}`,
|
||||
kind: 'github',
|
||||
name: e.login,
|
||||
host,
|
||||
sshHost: host,
|
||||
user: e.login,
|
||||
url: `https://${host}`,
|
||||
apiBase: host === 'github.com' ? 'https://api.github.com' : `https://${host}/api/v3`,
|
||||
token,
|
||||
active: e.active !== false,
|
||||
})
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
const flavorCache = new Map()
|
||||
/** Gitea and Forgejo share the /api/v1 API; Forgejo additionally serves /api/forgejo/v1/version. */
|
||||
async function detectFlavor(account) {
|
||||
if (flavorCache.has(account.url)) return flavorCache.get(account.url)
|
||||
let flavor = 'gitea'
|
||||
try {
|
||||
const res = await fetch(`${account.url}/api/forgejo/v1/version`, { headers: { authorization: `token ${account.token}` }, signal: AbortSignal.timeout(5000) })
|
||||
if (res.ok) flavor = 'forgejo'
|
||||
} catch { return 'gitea' }
|
||||
flavorCache.set(account.url, flavor)
|
||||
return flavor
|
||||
}
|
||||
|
||||
let accountCache = null
|
||||
/** Drop cached accounts (after login/logout). */
|
||||
export function resetAccountCache() { accountCache = null }
|
||||
/** All authorized accounts (tokens included — never send them to a client). */
|
||||
export async function listAccounts({ refresh = false } = {}) {
|
||||
if (!refresh && accountCache && Date.now() - accountCache.at < 60000) return accountCache.accounts
|
||||
const [gh, gt] = await Promise.all([githubAccounts().catch(() => []), Promise.resolve(giteaAccounts())])
|
||||
await Promise.all(gt.map(async (a) => { a.flavor = await detectFlavor(a) }))
|
||||
const accounts = [...gh, ...gt]
|
||||
accountCache = { at: Date.now(), accounts }
|
||||
return accounts
|
||||
}
|
||||
|
||||
export const publicAccount = (a) => ({ id: a.id, kind: a.kind, flavor: a.flavor ?? a.kind, name: a.name, host: a.host, user: a.user ?? '', url: a.url })
|
||||
|
||||
// ---- git URLs ---------------------------------------------------------------
|
||||
|
||||
/** Parse https/http/ssh/scp-like git URLs → { protocol, host, hostname, port, owner, name, fullName } or null. */
|
||||
export function parseGitUrl(input) {
|
||||
const s = String(input ?? '').trim()
|
||||
if (!s) return null
|
||||
if (!s.includes('://')) {
|
||||
// scp-like: [user@]host:owner/name(.git)
|
||||
const m = /^(?:([\w.-]+)@)?([\w.-]+):\/?(.+)$/.exec(s)
|
||||
if (m && m[2].length > 1) return finish('ssh', m[2], m[2], '', m[3], m[1] || 'git')
|
||||
return null
|
||||
}
|
||||
let u
|
||||
try { u = new URL(s) } catch { return null }
|
||||
const protocol = u.protocol.replace(/:$/, '')
|
||||
if (!['http', 'https', 'ssh', 'git'].includes(protocol)) return null
|
||||
return finish(protocol, u.host, u.hostname, u.port, decodeURIComponent(u.pathname), u.username || '')
|
||||
|
||||
function finish(protocol, host, hostname, port, pathname, user) {
|
||||
const parts = pathname.replace(/^\/+|\/+$/g, '').replace(/\.git$/, '').split('/').filter(Boolean)
|
||||
if (parts.length < 2) return null
|
||||
const name = parts[parts.length - 1]
|
||||
const owner = parts.slice(0, -1).join('/')
|
||||
return { protocol, host, hostname, port, user, owner, name, fullName: `${owner}/${name}` }
|
||||
}
|
||||
}
|
||||
|
||||
function hostMatches(account, parsed) {
|
||||
const h = parsed.hostname.toLowerCase()
|
||||
const candidates = [account.host, account.sshHost, account.host.split(':')[0]].filter(Boolean).map((x) => x.toLowerCase())
|
||||
return candidates.includes(h) || candidates.includes(parsed.host.toLowerCase())
|
||||
}
|
||||
|
||||
/** Pick the account matching a URL host (prefers a login owning the repo owner). */
|
||||
export function accountForUrl(accounts, parsed) {
|
||||
if (!parsed) return null
|
||||
const matches = accounts.filter((a) => hostMatches(a, parsed))
|
||||
if (!matches.length) return null
|
||||
return matches.find((a) => a.user && a.user.toLowerCase() === parsed.owner.toLowerCase()) ?? matches.find((a) => a.active !== false) ?? matches[0]
|
||||
}
|
||||
|
||||
export function httpsCloneUrl(account, parsed) {
|
||||
if (account) return `${account.url}/${parsed.fullName}.git`
|
||||
return `https://${parsed.hostname}/${parsed.fullName}.git`
|
||||
}
|
||||
|
||||
function hasSshKeys() {
|
||||
try {
|
||||
const dir = path.join(os.homedir(), '.ssh')
|
||||
return fs.readdirSync(dir).some((f) => /^id_/.test(f) && !f.endsWith('.pub'))
|
||||
} catch { return false }
|
||||
}
|
||||
|
||||
export function expandHome(p) {
|
||||
p = String(p ?? '').trim()
|
||||
if (p === '~') return os.homedir()
|
||||
if (p.startsWith('~/')) return path.join(os.homedir(), p.slice(2))
|
||||
return p
|
||||
}
|
||||
|
||||
// ---- REST -------------------------------------------------------------------
|
||||
|
||||
export class ForgeError extends Error {
|
||||
constructor(message, status, data) { super(message); this.status = status; this.data = data }
|
||||
}
|
||||
|
||||
/** Call a forge REST API. `pathname` is relative to the API base (e.g. "/user/repos"). */
|
||||
export async function api(account, method, pathname, { query, body, signal, raw = false } = {}) {
|
||||
let url = /^https?:\/\//.test(pathname) ? pathname : `${account.apiBase}${pathname.startsWith('/') ? '' : '/'}${pathname}`
|
||||
if (query) {
|
||||
const u = new URL(url)
|
||||
for (const [k, v] of Object.entries(query)) if (v !== undefined && v !== null && v !== '') u.searchParams.set(k, String(v))
|
||||
url = u.toString()
|
||||
}
|
||||
const headers = {
|
||||
accept: account.kind === 'github' ? 'application/vnd.github+json' : 'application/json',
|
||||
authorization: account.kind === 'github' ? `Bearer ${account.token}` : `token ${account.token}`,
|
||||
'user-agent': 'dsh-git-forge',
|
||||
}
|
||||
if (body !== undefined) headers['content-type'] = 'application/json'
|
||||
const timeout = AbortSignal.timeout(30000)
|
||||
const res = await fetch(url, {
|
||||
method,
|
||||
headers,
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
signal: signal ? AbortSignal.any([signal, timeout]) : timeout,
|
||||
})
|
||||
const text = await res.text()
|
||||
let data = text
|
||||
try { data = text ? JSON.parse(text) : null } catch { /* keep text */ }
|
||||
if (!res.ok) {
|
||||
const msg = isObj(data) ? (data.message || data.error || JSON.stringify(data)) : String(text).slice(0, 500)
|
||||
throw new ForgeError(`${method} ${pathname} → ${res.status}: ${msg}`, res.status, data)
|
||||
}
|
||||
return raw ? { status: res.status, headers: Object.fromEntries(res.headers), data } : data
|
||||
}
|
||||
|
||||
/** Paginated GET collecting arrays (or `.items`) up to `max` entries. */
|
||||
export async function apiList(account, pathname, { query = {}, max = 100, signal } = {}) {
|
||||
const per = account.kind === 'github' ? Math.min(100, max) : Math.min(50, max)
|
||||
const out = []
|
||||
for (let page = 1; out.length < max && page <= 20; page++) {
|
||||
const q = { ...query, page, ...(account.kind === 'github' ? { per_page: per } : { limit: per }) }
|
||||
const data = await api(account, 'GET', pathname, { query: q, signal })
|
||||
const items = Array.isArray(data) ? data : Array.isArray(data?.items) ? data.items : Array.isArray(data?.data) ? data.data : []
|
||||
out.push(...items)
|
||||
if (items.length < per) break
|
||||
}
|
||||
return out.slice(0, max)
|
||||
}
|
||||
|
||||
export function normRepo(account, r) {
|
||||
return {
|
||||
account: account.id,
|
||||
kind: account.kind,
|
||||
flavor: account.flavor ?? account.kind,
|
||||
host: account.host,
|
||||
fullName: r.full_name,
|
||||
owner: r.owner?.login ?? r.owner?.username ?? '',
|
||||
name: r.name,
|
||||
description: r.description || '',
|
||||
private: !!r.private,
|
||||
fork: !!r.fork,
|
||||
archived: !!r.archived,
|
||||
cloneUrl: r.clone_url,
|
||||
sshUrl: r.ssh_url,
|
||||
htmlUrl: r.html_url,
|
||||
defaultBranch: r.default_branch,
|
||||
updatedAt: r.updated_at || r.pushed_at || null,
|
||||
}
|
||||
}
|
||||
|
||||
export async function listRepos(account, { query, owner, max = 300, signal } = {}) {
|
||||
let raw
|
||||
if (query) {
|
||||
if (account.kind === 'github') {
|
||||
const q = owner ? `${query} user:${owner}` : query
|
||||
raw = await apiList(account, '/search/repositories', { query: { q }, max: Math.min(max, 100), signal })
|
||||
} else {
|
||||
raw = await apiList(account, '/repos/search', { query: { q: query, ...(owner ? { owner } : {}) }, max: Math.min(max, 100), signal })
|
||||
}
|
||||
} else if (owner && owner.toLowerCase() !== (account.user || '').toLowerCase()) {
|
||||
raw = await apiList(account, `/orgs/${encodeURIComponent(owner)}/repos`, { max, signal })
|
||||
.catch(() => apiList(account, `/users/${encodeURIComponent(owner)}/repos`, { max, signal }))
|
||||
} else if (account.kind === 'github') {
|
||||
raw = await apiList(account, '/user/repos', { query: { sort: 'updated', affiliation: 'owner,collaborator,organization_member' }, max, signal })
|
||||
} else {
|
||||
raw = await apiList(account, '/user/repos', { max, signal })
|
||||
}
|
||||
return raw.map((r) => normRepo(account, r))
|
||||
}
|
||||
|
||||
// ---- git operations ---------------------------------------------------------
|
||||
|
||||
/** git -c options that make git ask our helper for credentials of known accounts. */
|
||||
export function credentialArgs() {
|
||||
const helper = `!'${process.execPath.replace(/'/g, "'\\''")}' '${HELPER_PATH.replace(/'/g, "'\\''")}'`
|
||||
return ['-c', 'credential.helper=', '-c', `credential.helper=${helper}`]
|
||||
}
|
||||
|
||||
const GIT_ENV = {
|
||||
GIT_TERMINAL_PROMPT: '0',
|
||||
GIT_SSH_COMMAND: 'ssh -o BatchMode=yes -o StrictHostKeyChecking=accept-new',
|
||||
}
|
||||
|
||||
export function deriveName(url) {
|
||||
const p = parseGitUrl(url)
|
||||
if (p) return p.name
|
||||
const last = String(url).replace(/[\\/]+$/, '').split(/[\\/:]/).pop() || 'repo'
|
||||
return last.replace(/\.git$/, '')
|
||||
}
|
||||
|
||||
/**
|
||||
* Clone `url` into `parent/name`. SSH URLs of hosts without local SSH keys are
|
||||
* rewritten to HTTPS so the account token can be used.
|
||||
*/
|
||||
export async function cloneRepo({ url, parent, name, branch, depth, signal }) {
|
||||
url = String(url ?? '').trim()
|
||||
if (!url) throw new ForgeError('url is required', 400)
|
||||
parent = path.resolve(expandHome(parent || os.homedir()))
|
||||
name = String(name || deriveName(url)).trim()
|
||||
if (!name || name === '.' || name === '..' || /[\\/]/.test(name)) throw new ForgeError(`invalid folder name: ${name}`, 400)
|
||||
const target = path.join(parent, name)
|
||||
if (fs.existsSync(target) && fs.readdirSync(target).length > 0) throw new ForgeError(`destination already exists and is not empty: ${target}`, 409)
|
||||
fs.mkdirSync(parent, { recursive: true })
|
||||
|
||||
const accounts = await listAccounts()
|
||||
const parsed = parseGitUrl(url)
|
||||
const account = accountForUrl(accounts, parsed)
|
||||
let effective = url
|
||||
if (parsed && (parsed.protocol === 'ssh' || parsed.protocol === 'git') && (account || !hasSshKeys())) {
|
||||
effective = httpsCloneUrl(account, parsed)
|
||||
}
|
||||
const args = [...credentialArgs(), 'clone', '--progress']
|
||||
if (branch) args.push('--branch', String(branch))
|
||||
if (depth) args.push('--depth', String(depth))
|
||||
args.push('--', effective, target)
|
||||
const r = await run('git', args, { env: GIT_ENV, timeoutMs: 30 * 60000, signal })
|
||||
if (r.code !== 0) {
|
||||
const msg = r.stderr.split('\n').filter((l) => l.trim() && !/^(Cloning into|remote: (Counting|Compressing|Enumerating|Total)|Receiving|Resolving)/.test(l)).slice(-6).join('\n')
|
||||
throw new ForgeError(`git clone failed: ${msg || r.stderr.slice(-500) || 'unknown error'}`, 422)
|
||||
}
|
||||
// Keep the repo's own pushes/fetches authenticated through the helper.
|
||||
if (account) {
|
||||
await run('git', ['-C', target, 'config', '--local', '--add', 'credential.helper', ''])
|
||||
await run('git', ['-C', target, 'config', '--local', '--add', 'credential.helper', credentialArgs()[3].slice('credential.helper='.length)])
|
||||
}
|
||||
return { path: target, url: effective, account: account ? account.id : null }
|
||||
}
|
||||
|
||||
export async function gitIn(cwd, args, opts = {}) {
|
||||
return run('git', ['-C', cwd, ...args], { env: GIT_ENV, timeoutMs: 10 * 60000, ...opts })
|
||||
}
|
||||
|
||||
/** Push local commits using account credentials. */
|
||||
export async function pushRepo({ cwd, remote = 'origin', branch, setUpstream = true, force = false, tags = false, signal }) {
|
||||
const top = await gitIn(cwd, ['rev-parse', '--show-toplevel'])
|
||||
if (top.code !== 0) throw new ForgeError(`not a git repository: ${cwd}`, 400)
|
||||
const root = top.stdout.trim()
|
||||
if (!branch) {
|
||||
const b = await gitIn(root, ['symbolic-ref', '--short', 'HEAD'])
|
||||
if (b.code !== 0) throw new ForgeError('HEAD is detached; pass branch explicitly', 400)
|
||||
branch = b.stdout.trim()
|
||||
}
|
||||
const remoteUrl = (await gitIn(root, ['remote', 'get-url', remote])).stdout.trim()
|
||||
if (!remoteUrl) throw new ForgeError(`remote "${remote}" not found`, 400)
|
||||
let pushTarget = remote
|
||||
const parsed = parseGitUrl(remoteUrl)
|
||||
if (parsed && parsed.protocol === 'ssh' && !hasSshKeys()) {
|
||||
const account = accountForUrl(await listAccounts(), parsed)
|
||||
pushTarget = httpsCloneUrl(account, parsed)
|
||||
}
|
||||
const ahead = await gitIn(root, ['rev-list', '--count', `${remote}/${branch}..${branch}`])
|
||||
const args = [...credentialArgs(), '-C', root, 'push', '--porcelain']
|
||||
if (setUpstream && pushTarget === remote) args.push('--set-upstream')
|
||||
if (force) args.push('--force-with-lease')
|
||||
if (tags) args.push('--follow-tags')
|
||||
args.push(pushTarget, `${branch}:${branch}`)
|
||||
const r = await run('git', args, { env: GIT_ENV, timeoutMs: 10 * 60000, signal })
|
||||
if (r.code !== 0) throw new ForgeError(`git push failed: ${(r.stderr || r.stdout).trim().slice(-1500)}`, 422)
|
||||
if (pushTarget !== remote) await gitIn(root, ['fetch', remote, branch]).catch(() => {})
|
||||
return {
|
||||
repository: root,
|
||||
remote,
|
||||
remoteUrl,
|
||||
branch,
|
||||
commitsPushed: ahead.code === 0 ? Number(ahead.stdout.trim()) : null,
|
||||
output: (r.stdout + r.stderr).trim().split('\n').filter((l) => !/^remote:\s*$/.test(l)).slice(-15).join('\n'),
|
||||
}
|
||||
}
|
||||
|
||||
/** Resolve "owner/name", a URL, or the cwd's remote into { account, owner, name }. */
|
||||
export async function resolveRepo({ repo, account: accountId, cwd, remote = 'origin' }) {
|
||||
const accounts = await listAccounts()
|
||||
if (!accounts.length) throw new ForgeError('no authorized accounts: run `gh auth login` or `tea-cli login add` on the host', 400)
|
||||
let byId = null
|
||||
if (accountId) {
|
||||
byId = accounts.find((a) => a.id === accountId || a.name === accountId || `${a.user}@${a.host}` === accountId)
|
||||
if (!byId) throw new ForgeError(`unknown account "${accountId}"; available: ${accounts.map((a) => a.id).join(', ')}`, 400)
|
||||
}
|
||||
let parsed = null
|
||||
if (repo && /[:@]|\/\//.test(repo)) parsed = parseGitUrl(repo)
|
||||
else if (repo) {
|
||||
const parts = String(repo).replace(/^\/+|\/+$/g, '').split('/')
|
||||
if (parts.length < 2) throw new ForgeError(`repo must be "owner/name" or a URL, got "${repo}"`, 400)
|
||||
const account = byId ?? (accounts.length === 1 ? accounts[0] : null)
|
||||
if (!account) throw new ForgeError(`several accounts are authorized; pass account (one of ${accounts.map((a) => a.id).join(', ')}) or a full repo URL`, 400)
|
||||
return { account, owner: parts.slice(0, -1).join('/'), name: parts[parts.length - 1].replace(/\.git$/, '') }
|
||||
} else if (cwd) {
|
||||
const r = await gitIn(cwd, ['remote', 'get-url', remote], { timeoutMs: 10000 })
|
||||
if (r.code !== 0) throw new ForgeError(`no repo given and ${cwd} has no "${remote}" remote`, 400)
|
||||
parsed = parseGitUrl(r.stdout.trim())
|
||||
}
|
||||
if (!parsed) throw new ForgeError(`cannot parse repository "${repo ?? ''}"`, 400)
|
||||
const account = byId ?? accountForUrl(accounts, parsed)
|
||||
if (!account) throw new ForgeError(`no authorized account for host ${parsed.host}; available: ${accounts.map((a) => `${a.id} (${a.host})`).join(', ')}`, 400)
|
||||
return { account, owner: parsed.owner, name: parsed.name }
|
||||
}
|
||||
Reference in new issue
Block a user