Files
git-forge-plugin/index.js
T

570 lines
29 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// @local/git-forge — Host side.
// HTTP endpoints for the "Clone repository" workspace dialog plus agent tools for
// GitHub (gh) and Gitea/Forgejo (tea-cli) accounts. Tokens never leave the Host.
import os from 'node:os'
import path from 'node:path'
import {
ForgeError, api, apiList, cloneRepo, deriveName, fixRepoConfig, gitIn, listAccounts, listRepos, normRepo, publicAccount, pushRepo, resolveRepo,
} from './forge.mjs'
import {
accountsWithStatus, cliStatus, giteaLogin, githubDeviceCancel, githubDeviceStart, githubDeviceStatus,
githubTokenLogin, logout,
} from './auth.mjs'
export const name = 'git-forge'
export const inject = ['webServer', 'connection', 'tools']
const MAX_BODY = 64 * 1024
const isObj = (v) => v !== null && typeof v === 'object' && !Array.isArray(v)
const CLONE_ROOT = os.homedir()
function sendJson(res, status, payload) {
res.statusCode = status
res.setHeader('content-type', 'application/json; charset=utf-8')
res.setHeader('cache-control', 'no-store')
res.end(JSON.stringify(payload))
}
async function readJsonBody(req) {
const chunks = []
let size = 0
for await (const chunk of req) {
size += chunk.byteLength
if (size > MAX_BODY) throw new ForgeError('request body too large', 413)
chunks.push(chunk)
}
if (size === 0) return {}
const body = JSON.parse(Buffer.concat(chunks, size).toString('utf8'))
if (!isObj(body)) throw new ForgeError('body must be a JSON object', 400)
return body
}
// ---- shared operations (UI + tools) ----------------------------------------
let repoCache = null
async function allRepos({ accountId, refresh = false } = {}) {
if (!refresh && !accountId && repoCache && Date.now() - repoCache.at < 120000) return repoCache.value
const accounts = (await listAccounts({ refresh })).filter((a) => !accountId || a.id === accountId)
const results = await Promise.allSettled(accounts.map((a) => listRepos(a, { max: 500 })))
const repos = []
const errors = []
results.forEach((r, i) => {
if (r.status === 'fulfilled') repos.push(...r.value)
else errors.push({ account: accounts[i].id, message: String(r.reason?.message ?? r.reason) })
})
repos.sort((a, b) => String(b.updatedAt ?? '').localeCompare(String(a.updatedAt ?? '')))
const value = { repos, errors }
if (!accountId) repoCache = { at: Date.now(), value }
return value
}
const enc = encodeURIComponent
const repoPath = (r) => `/repos/${r.owner.split('/').map(enc).join('/')}/${enc(r.name)}`
function slimIssue(i) {
return {
number: i.number,
title: i.title,
state: i.state,
pull: !!i.pull_request,
user: i.user?.login,
labels: (i.labels ?? []).map((l) => l.name),
assignees: (i.assignees ?? []).map((a) => a.login),
comments: i.comments,
createdAt: i.created_at,
updatedAt: i.updated_at,
url: i.html_url,
}
}
function slimPull(p) {
return {
number: p.number,
title: p.title,
state: p.state,
draft: !!p.draft,
merged: !!(p.merged || p.merged_at),
user: p.user?.login,
head: p.head?.label ?? p.head?.ref,
base: p.base?.ref,
mergeable: p.mergeable,
createdAt: p.created_at,
updatedAt: p.updated_at,
url: p.html_url,
}
}
const slimComment = (c) => ({ id: c.id, user: c.user?.login, body: c.body, createdAt: c.created_at, url: c.html_url })
function sessionCwd(exec) {
return exec?.agent?.session?.header?.cwd || process.cwd()
}
function need(v, nameOf) {
if (v === undefined || v === null || v === '') throw new ForgeError(`${nameOf} is required for this action`, 400)
return v
}
// ---- tool implementations ---------------------------------------------------
async function toolAccounts() {
const accounts = await listAccounts({ refresh: true })
return {
accounts: accounts.map(publicAccount),
...(accounts.length ? {} : { hint: 'No authorized accounts. Ask the user to run `gh auth login` (GitHub) or `tea-cli login add` (Gitea/Forgejo) on the host.' }),
}
}
async function toolRepos(args, exec) {
const action = args.action ?? 'list'
if (action === 'list' || action === 'search') {
const accounts = await listAccounts()
const scoped = args.account ? accounts.filter((a) => a.id === args.account || a.name === args.account) : accounts
if (!scoped.length) throw new ForgeError(args.account ? `unknown account ${args.account}` : 'no authorized accounts', 400)
const limit = Math.min(Math.max(args.limit ?? 50, 1), 500)
const results = await Promise.allSettled(scoped.map((a) => listRepos(a, { query: args.query, owner: args.owner, max: limit, signal: exec?.signal })))
const repos = []
const errors = []
results.forEach((r, i) => r.status === 'fulfilled' ? repos.push(...r.value) : errors.push({ account: scoped[i].id, message: String(r.reason?.message) }))
repos.sort((a, b) => String(b.updatedAt ?? '').localeCompare(String(a.updatedAt ?? '')))
return {
repos: repos.slice(0, limit).map((r) => ({ account: r.account, fullName: r.fullName, description: r.description || '', private: !!r.private, fork: !!r.fork, archived: !!r.archived, defaultBranch: r.defaultBranch ?? '', updatedAt: r.updatedAt ?? '', cloneUrl: r.cloneUrl ?? '' })),
...(errors.length ? { errors } : {}),
}
}
if (action === 'create') {
const accounts = await listAccounts()
const account = args.account ? accounts.find((a) => a.id === args.account || a.name === args.account) : accounts[0]
if (!account) throw new ForgeError('no matching account; pass account', 400)
const body = { name: need(args.name, 'name'), description: args.description, private: args.private ?? true, auto_init: args.autoInit ?? false }
if (account.kind === 'gitea' && args.defaultBranch) body.default_branch = args.defaultBranch
const p = args.owner && args.owner.toLowerCase() !== account.user.toLowerCase() ? `/orgs/${enc(args.owner)}/repos` : '/user/repos'
const r = await api(account, 'POST', p, { body, signal: exec?.signal })
return { created: normRepo(account, r) }
}
const target = await resolveRepo({ repo: args.repo, account: args.account, cwd: sessionCwd(exec) })
const { account } = target
const rp = repoPath(target)
if (action === 'get') return { repo: normRepo(account, await api(account, 'GET', rp, { signal: exec?.signal })) }
if (action === 'edit') {
const body = {}
for (const [k, v] of [['description', args.description], ['private', args.private], ['default_branch', args.defaultBranch], ['archived', args.archived], ['name', args.newName], ['website', args.website]]) if (v !== undefined) body[k] = v
if (!Object.keys(body).length) throw new ForgeError('nothing to edit: pass description/private/defaultBranch/archived/newName/website', 400)
return { repo: normRepo(account, await api(account, 'PATCH', rp, { body, signal: exec?.signal })) }
}
if (action === 'fork') {
const body = args.owner ? (account.kind === 'github' ? { organization: args.owner } : { organization: args.owner }) : {}
if (args.newName) body.name = args.newName
return { fork: normRepo(account, await api(account, 'POST', `${rp}/forks`, { body, signal: exec?.signal })) }
}
if (action === 'delete') {
if (args.confirm !== `${target.owner}/${target.name}`) throw new ForgeError(`refusing to delete: set confirm to exactly "${target.owner}/${target.name}" after the user explicitly asked for deletion`, 400)
await api(account, 'DELETE', rp, { signal: exec?.signal })
return { deleted: `${target.owner}/${target.name}` }
}
if (action === 'branches') {
const list = await apiList(account, `${rp}/branches`, { max: args.limit ?? 100, signal: exec?.signal })
return { branches: list.map((b) => ({ name: b.name, commit: (b.commit?.sha ?? b.commit?.id ?? '').slice(0, 12), protected: b.protected })) }
}
if (action === 'releases') {
const list = await apiList(account, `${rp}/releases`, { max: args.limit ?? 20, signal: exec?.signal })
return { releases: list.map((r) => ({ tag: r.tag_name, name: r.name, draft: r.draft, prerelease: r.prerelease, publishedAt: r.published_at, url: r.html_url })) }
}
throw new ForgeError(`unknown action ${action}`, 400)
}
async function toolIssues(args, exec) {
const action = args.action ?? 'list'
const target = await resolveRepo({ repo: args.repo, account: args.account, cwd: sessionCwd(exec) })
const { account } = target
const rp = repoPath(target)
const signal = exec?.signal
switch (action) {
case 'list': {
const query = { state: args.state ?? 'open', labels: args.labels?.join(','), ...(account.kind === 'gitea' ? { type: 'issues', q: args.query } : {}) }
if (args.assignee) query[account.kind === 'gitea' ? 'assigned_by' : 'assignee'] = args.assignee
const raw = await apiList(account, `${rp}/issues`, { query, max: args.limit ?? 30, signal })
let issues = raw.filter((i) => !i.pull_request).map(slimIssue)
if (args.query && account.kind === 'github') issues = issues.filter((i) => i.title.toLowerCase().includes(args.query.toLowerCase()))
return { repo: `${target.owner}/${target.name}`, account: account.id, issues }
}
case 'get': {
const n = need(args.number, 'number')
const i = await api(account, 'GET', `${rp}/issues/${n}`, { signal })
const comments = args.withComments === false ? [] : await apiList(account, `${rp}/issues/${n}/comments`, { max: 100, signal })
return { issue: { ...slimIssue(i), body: i.body }, comments: comments.map(slimComment) }
}
case 'create': {
const body = { title: need(args.title, 'title'), body: args.body ?? '' }
if (args.assignees) body.assignees = args.assignees
if (args.labels && account.kind === 'github') body.labels = args.labels
const i = await api(account, 'POST', `${rp}/issues`, { body, signal })
if (args.labels?.length && account.kind === 'gitea') await setGiteaLabels(account, rp, i.number, args.labels, signal)
return { created: slimIssue(i) }
}
case 'comment': {
const c = await api(account, 'POST', `${rp}/issues/${need(args.number, 'number')}/comments`, { body: { body: need(args.body, 'body') }, signal })
return { comment: slimComment(c) }
}
case 'close':
case 'reopen': {
const n = need(args.number, 'number')
if (args.body) await api(account, 'POST', `${rp}/issues/${n}/comments`, { body: { body: args.body }, signal })
const i = await api(account, 'PATCH', `${rp}/issues/${n}`, { body: { state: action === 'close' ? 'closed' : 'open' }, signal })
return { issue: slimIssue(i) }
}
case 'edit': {
const n = need(args.number, 'number')
const body = {}
if (args.title !== undefined) body.title = args.title
if (args.body !== undefined) body.body = args.body
if (args.assignees) body.assignees = args.assignees
if (args.labels && account.kind === 'github') body.labels = args.labels
const i = Object.keys(body).length ? await api(account, 'PATCH', `${rp}/issues/${n}`, { body, signal }) : await api(account, 'GET', `${rp}/issues/${n}`, { signal })
if (args.labels && account.kind === 'gitea') await setGiteaLabels(account, rp, n, args.labels, signal)
return { issue: slimIssue(i) }
}
default:
throw new ForgeError(`unknown action ${action}`, 400)
}
}
/** Gitea takes label ids, not names. */
async function setGiteaLabels(account, rp, number, names, signal) {
const all = await apiList(account, `${rp}/labels`, { max: 200, signal })
const ids = names.map((n) => all.find((l) => l.name.toLowerCase() === String(n).toLowerCase())?.id).filter(Boolean)
await api(account, 'PUT', `${rp}/issues/${number}/labels`, { body: { labels: ids }, signal })
}
async function toolPulls(args, exec) {
const action = args.action ?? 'list'
const cwd = sessionCwd(exec)
const target = await resolveRepo({ repo: args.repo, account: args.account, cwd })
const { account } = target
const rp = repoPath(target)
const signal = exec?.signal
switch (action) {
case 'list': {
const raw = await apiList(account, `${rp}/pulls`, { query: { state: args.state ?? 'open' }, max: args.limit ?? 30, signal })
return { repo: `${target.owner}/${target.name}`, account: account.id, pulls: raw.map(slimPull) }
}
case 'get': {
const n = need(args.number, 'number')
const p = await api(account, 'GET', `${rp}/pulls/${n}`, { signal })
const comments = args.withComments === false ? [] : await apiList(account, `${rp}/issues/${n}/comments`, { max: 100, signal })
let files
try {
const f = await apiList(account, `${rp}/pulls/${n}/files`, { max: 300, signal })
files = f.map((x) => ({ file: x.filename, status: x.status, additions: x.additions, deletions: x.deletions }))
} catch { files = undefined }
return { pull: { ...slimPull(p), body: p.body, additions: p.additions, deletions: p.deletions, changedFiles: p.changed_files }, files, comments: comments.map(slimComment) }
}
case 'create': {
let head = args.head
if (!head) {
const b = await gitIn(cwd, ['symbolic-ref', '--short', 'HEAD'], { timeoutMs: 10000 })
head = b.code === 0 ? b.stdout.trim() : undefined
}
need(head, 'head')
let base = args.base
if (!base) base = (await api(account, 'GET', rp, { signal })).default_branch
const body = { title: need(args.title, 'title'), body: args.body ?? '', head, base }
if (account.kind === 'github' && args.draft) body.draft = true
const p = await api(account, 'POST', `${rp}/pulls`, { body, signal })
return { created: slimPull(p) }
}
case 'comment': {
const c = await api(account, 'POST', `${rp}/issues/${need(args.number, 'number')}/comments`, { body: { body: need(args.body, 'body') }, signal })
return { comment: slimComment(c) }
}
case 'merge': {
const n = need(args.number, 'number')
const method = args.method ?? 'merge'
if (account.kind === 'github') {
const r = await api(account, 'PUT', `${rp}/pulls/${n}/merge`, { body: { merge_method: method, commit_title: args.title, commit_message: args.body }, signal })
return { merged: !!r?.merged, sha: r?.sha, message: r?.message }
}
await api(account, 'POST', `${rp}/pulls/${n}/merge`, { body: { Do: method, MergeTitleField: args.title, MergeMessageField: args.body, delete_branch_after_merge: !!args.deleteBranch }, signal })
return { merged: true }
}
case 'close':
case 'reopen': {
const p = await api(account, 'PATCH', `${rp}/pulls/${need(args.number, 'number')}`, { body: { state: action === 'close' ? 'closed' : 'open' }, signal })
return { pull: slimPull(p) }
}
case 'diff': {
const n = need(args.number, 'number')
const url = account.kind === 'github' ? `${account.apiBase}${rp}/pulls/${n}` : `${account.apiBase}${rp}/pulls/${n}.diff`
const res = await fetch(url, {
headers: {
authorization: account.kind === 'github' ? `Bearer ${account.token}` : `token ${account.token}`,
accept: account.kind === 'github' ? 'application/vnd.github.diff' : 'text/plain',
'user-agent': 'dsh-git-forge',
},
signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(30000)]) : AbortSignal.timeout(30000),
})
if (!res.ok) throw new ForgeError(`diff → ${res.status}`, res.status)
const text = await res.text()
const max = 60000
return { diff: text.length > max ? `${text.slice(0, max)}\n… [truncated ${text.length - max} chars]` : text }
}
default:
throw new ForgeError(`unknown action ${action}`, 400)
}
}
async function toolApi(args, exec) {
const accounts = await listAccounts()
let account
if (args.account) account = accounts.find((a) => a.id === args.account || a.name === args.account)
else if (args.repo || !args.account) {
if (accounts.length === 1) account = accounts[0]
else if (args.repo) account = (await resolveRepo({ repo: args.repo, cwd: sessionCwd(exec) })).account
}
if (!account) throw new ForgeError(`pass account (one of ${accounts.map((a) => a.id).join(', ') || 'none'})`, 400)
const method = String(args.method ?? 'GET').toUpperCase()
const r = await api(account, method, need(args.path, 'path'), { query: args.query, body: args.body, signal: exec?.signal, raw: true })
let text = JSON.stringify(r.data)
const max = 60000
if (text && text.length > max) return { status: r.status, truncated: true, data: text.slice(0, max) }
return { status: r.status, data: r.data }
}
async function toolClone(args, exec) {
const cwd = sessionCwd(exec)
let url = args.url
if (!url) throw new ForgeError('url is required (any https/ssh git URL, or owner/name with account)', 400)
if (!/[:@]|\/\//.test(url)) {
const t = await resolveRepo({ repo: url, account: args.account, cwd })
url = `${t.account.url}/${t.owner}/${t.name}.git`
}
const parent = args.parent ? path.resolve(cwd, args.parent.replace(/^~(?=\/|$)/, os.homedir())) : cwd
return cloneRepo({ url, parent, name: args.name || deriveName(url), branch: args.branch, depth: args.depth, signal: exec?.signal })
}
async function toolPush(args, exec) {
const cwd = args.path ? path.resolve(sessionCwd(exec), args.path) : sessionCwd(exec)
return pushRepo({ cwd, remote: args.remote, branch: args.branch, force: !!args.force, tags: !!args.tags, signal: exec?.signal })
}
async function toolFixCredentials(args, exec) {
const cwd = sessionCwd(exec)
const requested = String(args.path ?? '').trim()
const repoPath = requested
? path.resolve(cwd, requested.replace(/^~(?=\/|$)/, os.homedir()))
: cwd
return fixRepoConfig(repoPath)
}
// ---- tool definitions ---------------------------------------------------------
const S = (type, description, extra = {}) => ({ type, description, ...extra })
const REPO = S('string', 'Repository as "owner/name" or any git/web URL. Omit to use the "origin" remote of the session working directory.')
const ACCOUNT = S('string', 'Account id from forge_accounts (e.g. "github:octocat", "gitea:Agent"; Gitea and Forgejo accounts both use the gitea: prefix). Needed only when several accounts could match.')
const NUMBER = S('integer', 'Issue or pull request number.')
const LIMIT = S('integer', 'Maximum number of results (default 30).')
function defineTool(def, fn) {
return {
name: def.name,
description: def.description,
parameters: { type: 'object', properties: def.properties, required: def.required ?? [], additionalProperties: false },
output: {
schema: {},
render: (_args, value) => [{ type: 'text', text: JSON.stringify(value, null, 1) }],
},
timeoutMs: def.timeoutMs,
async execute(args, exec) {
try {
// Round-trip drops undefined fields: tool output must be lossless JSON.
return JSON.parse(JSON.stringify((await fn(args ?? {}, exec)) ?? null))
} catch (error) {
return { error: String(error?.message ?? error) }
}
},
}
}
const TOOLS = [
defineTool({
name: 'forge_accounts',
description: 'List the GitHub (gh) and Gitea/Forgejo (tea-cli) accounts authorized on this host. Their tokens are used by the other forge_* tools and by git clone/push.',
properties: {},
}, toolAccounts),
defineTool({
name: 'forge_repos',
description: 'Repositories on authorized forge accounts. Actions: list (repos of all accounts, or of owner), search (query), get, create (name, private defaults true), edit, fork, delete (needs confirm = "owner/name" and an explicit user request), branches, releases.',
properties: {
action: S('string', 'Operation (default list).', { enum: ['list', 'search', 'get', 'create', 'edit', 'fork', 'delete', 'branches', 'releases'] }),
repo: REPO,
account: ACCOUNT,
owner: S('string', 'list/search: user or org to list. create: org to create in (default: the account user). fork: target org.'),
query: S('string', 'search: text to match repository names.'),
name: S('string', 'create: new repository name.'),
newName: S('string', 'edit/fork: new name.'),
description: S('string', 'create/edit: description.'),
private: S('boolean', 'create/edit: visibility.'),
autoInit: S('boolean', 'create: initialize with a README.'),
defaultBranch: S('string', 'create (Gitea) / edit: default branch.'),
archived: S('boolean', 'edit: archive or unarchive.'),
website: S('string', 'edit: website URL.'),
confirm: S('string', 'delete: must equal "owner/name".'),
limit: LIMIT,
},
}, toolRepos),
defineTool({
name: 'forge_issues',
description: 'Issues of a GitHub/Gitea/Forgejo repository. Actions: list, get (with comments), create, comment, edit, close, reopen.',
properties: {
action: S('string', 'Operation (default list).', { enum: ['list', 'get', 'create', 'comment', 'edit', 'close', 'reopen'] }),
repo: REPO,
account: ACCOUNT,
number: NUMBER,
title: S('string', 'create/edit: title.'),
body: S('string', 'create/edit: description; comment/close/reopen: comment text.'),
state: S('string', 'list: filter (default open).', { enum: ['open', 'closed', 'all'] }),
labels: S('array', 'list: filter; create/edit: label names to set.', { items: { type: 'string' } }),
assignees: S('array', 'create/edit: usernames.', { items: { type: 'string' } }),
assignee: S('string', 'list: filter by assignee.'),
query: S('string', 'list: text filter.'),
withComments: S('boolean', 'get: include comments (default true).'),
limit: LIMIT,
},
}, toolIssues),
defineTool({
name: 'forge_pulls',
description: 'Pull requests of a GitHub/Gitea/Forgejo repository. Actions: list, get (with files and comments), diff, create (head defaults to the current branch, base to the default branch; push first with forge_push), comment, merge, close, reopen.',
properties: {
action: S('string', 'Operation (default list).', { enum: ['list', 'get', 'diff', 'create', 'comment', 'merge', 'close', 'reopen'] }),
repo: REPO,
account: ACCOUNT,
number: NUMBER,
title: S('string', 'create: title; merge: commit title.'),
body: S('string', 'create: description; comment: text; merge: commit message.'),
head: S('string', 'create: source branch ("user:branch" for forks). Default: current branch.'),
base: S('string', 'create: target branch. Default: repository default branch.'),
draft: S('boolean', 'create: draft PR (GitHub).'),
method: S('string', 'merge: strategy (default merge).', { enum: ['merge', 'squash', 'rebase'] }),
deleteBranch: S('boolean', 'merge (Gitea): delete head branch after merge.'),
state: S('string', 'list: filter (default open).', { enum: ['open', 'closed', 'all'] }),
withComments: S('boolean', 'get: include comments (default true).'),
limit: LIMIT,
},
}, toolPulls),
defineTool({
name: 'forge_clone',
description: 'Clone a repository with the authorized account credentials (works for private repos; SSH URLs are switched to HTTPS+token when no SSH key is available). Returns the absolute path.',
properties: {
url: S('string', 'Any git URL (https or ssh, GitHub/Gitea/Forgejo) or "owner/name".'),
account: ACCOUNT,
parent: S('string', 'Directory to clone into (default: session working directory).'),
name: S('string', 'Folder name (default: repository name).'),
branch: S('string', 'Branch to check out.'),
depth: S('integer', 'Shallow clone depth.'),
},
required: ['url'],
timeoutMs: 30 * 60000,
}, toolClone),
defineTool({
name: 'forge_push',
description: 'Push local commits of a git repository to its remote using the authorized account token (no SSH keys or password prompts needed). Sets upstream. Use force only when the user asked for it (uses --force-with-lease).',
properties: {
path: S('string', 'Repository directory (default: session working directory).'),
remote: S('string', 'Remote name (default origin).'),
branch: S('string', 'Branch to push (default: current branch).'),
force: S('boolean', 'Force push with lease.'),
tags: S('boolean', 'Also push annotated tags (--follow-tags).'),
},
timeoutMs: 10 * 60000,
}, toolPush),
defineTool({
name: 'forge_api',
description: 'Raw authenticated REST call for anything the other forge_* tools do not cover (labels, milestones, releases, workflows, org members, notifications, …). GitHub: https://api.github.com paths; Gitea/Forgejo: /api/v1 paths (e.g. "/repos/{owner}/{repo}/labels").',
properties: {
account: ACCOUNT,
repo: S('string', 'Optional "owner/name" or URL used only to pick the account.'),
method: S('string', 'HTTP method (default GET).', { enum: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'] }),
path: S('string', 'Path relative to the API base, starting with "/".'),
query: S('object', 'Query parameters.', { additionalProperties: true }),
body: S('object', 'JSON request body.', { additionalProperties: true }),
},
required: ['path'],
}, toolApi),
defineTool({
name: 'forge_fix_credentials',
description: 'Fix the local git credential helper configuration inside a repository. Replaces only that repository’s .git/config credential.helper values with the git-forge helper, so plain git fetch, pull, and push can authenticate through authorized GitHub and Gitea accounts. Does not change global git configuration.',
properties: {
path: S('string', 'Repository directory (default: the session working directory). Relative paths use the session working directory; a nested path resolves to its repository root.'),
},
}, toolFixCredentials),
]
// ---- plugin -----------------------------------------------------------------
export function apply(ctx) {
const route = (pathname, methods, handler) => ctx.effect(() => ctx.webServer.register({
kind: 'exact',
path: pathname,
handler: async (req, res) => {
const rejection = ctx.connection.requestRejection(req)
if (rejection !== undefined) { sendJson(res, rejection, { code: 'rejected' }); return }
if (!methods.includes(req.method)) {
res.statusCode = 405
res.setHeader('allow', methods.join(', '))
res.end()
return
}
try {
const url = new URL(req.url ?? '/', 'http://local')
sendJson(res, 200, await handler(req, url))
} catch (error) {
const status = error instanceof ForgeError && error.status >= 400 && error.status < 500 ? error.status : 500
sendJson(res, status, { code: 'error', message: String(error?.message ?? error) })
}
},
}), `git-forge: ${pathname}`)
route('/git-forge/defaults', ['GET'], async () => ({ cloneRoot: CLONE_ROOT }))
route('/git-forge/accounts', ['GET'], async (_req, url) => ({
accounts: (await listAccounts({ refresh: url.searchParams.get('refresh') === '1' })).map(publicAccount),
}))
route('/git-forge/repos', ['GET'], async (_req, url) => allRepos({
accountId: url.searchParams.get('account') || undefined,
refresh: url.searchParams.get('refresh') === '1',
}))
const invalidate = (r) => { repoCache = null; return r }
route('/git-forge/auth/status', ['GET'], async (_req, url) => ({
cli: await cliStatus(),
accounts: await accountsWithStatus({ verify: url.searchParams.get('verify') === '1' }),
}))
route('/git-forge/auth/gitea', ['POST'], async (req) => invalidate(await giteaLogin(await readJsonBody(req))))
route('/git-forge/auth/github/token', ['POST'], async (req) => invalidate(await githubTokenLogin(await readJsonBody(req))))
route('/git-forge/auth/github/device', ['POST'], async (req) => githubDeviceStart(await readJsonBody(req)))
route('/git-forge/auth/github/device/status', ['GET'], async (_req, url) => {
const s = githubDeviceStatus(url.searchParams.get('id') || '')
if (s.state === 'success') repoCache = null
return s
})
route('/git-forge/auth/github/device/cancel', ['POST'], async (req) => githubDeviceCancel((await readJsonBody(req)).id))
route('/git-forge/auth/logout', ['POST'], async (req) => invalidate(await logout((await readJsonBody(req)).account)))
// Minimal directory listing for the fallback "Open local folder" dialog.
route('/git-forge/ls', ['GET'], async (_req, url) => {
const { promises: fsp } = await import('node:fs')
const raw = url.searchParams.get('path') || CLONE_ROOT
const dir = path.resolve(raw.replace(/^~(?=$|\/)/, os.homedir()))
let entries
try { entries = await fsp.readdir(dir, { withFileTypes: true }) } catch (e) { throw new ForgeError(`cannot list ${dir}: ${e.code || e.message}`, 400) }
const hidden = url.searchParams.get('hidden') === '1'
const dirs = entries.filter((e) => e.isDirectory() && (hidden || !e.name.startsWith('.'))).map((e) => e.name).sort((a, b) => a.localeCompare(b))
return { path: dir, parent: path.dirname(dir) === dir ? '' : path.dirname(dir), home: os.homedir(), dirs }
})
route('/git-forge/clone', ['POST'], async (req) => {
const body = await readJsonBody(req)
return cloneRepo({ url: body.url, parent: body.parent || CLONE_ROOT, name: body.name, branch: body.branch || undefined })
})
ctx.effect(function* () {
for (const tool of TOOLS) yield ctx.tools.register(tool)
}, 'git-forge: agent tools')
}